Compare commits
	
		
			40 Commits
		
	
	
		
			1c23ad0cfd
			...
			v1.5.0
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 25a8e2b65a | |||
| 59faaa181d | |||
| 7bff91679d | |||
| cfa7fc73c9 | |||
| a568a736aa | |||
| db72688aa2 | |||
| 05082d8ff3 | |||
| a4f90c2bc8 | |||
| bec5b3cb9c | |||
| 018c0797f5 | |||
| a7d5317114 | |||
| 89e29eacee | |||
| 4e4e58af83 | |||
| eb81ec4162 | |||
| 21d7f56ccf | |||
| 8653191df2 | |||
| 9da47ce0cf | |||
| 29bfa2d776 | |||
| c94a7ae8ab | |||
| ca57ee728e | |||
| b53628e698 | |||
| 3f7fd36f84 | |||
| 8da1f023a7 | |||
| 33b3a3d2b6 | |||
| 78536c3e19 | |||
| 3a5a333c62 | |||
| 5a2520e3b1 | |||
| 12d71dba1c | |||
| d94f8db27a | |||
| 913a279011 | |||
| af956110be | |||
| 8c5d783d2c | |||
| 6359705714 | |||
| 62bfb91246 | |||
| 083d42a9dd | |||
| 71ea17122c | |||
| 83477d5f18 | |||
| 125bf78c16 | |||
| edaa699c20 | |||
| 5b2da09eb2 | 
							
								
								
									
										5
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										5
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -1,3 +1,8 @@
 | 
				
			|||||||
*.exe
 | 
					*.exe
 | 
				
			||||||
*.exe
 | 
					*.exe
 | 
				
			||||||
cln
 | 
					cln
 | 
				
			||||||
 | 
					cln.log
 | 
				
			||||||
 | 
					.qodo
 | 
				
			||||||
 | 
					*.log
 | 
				
			||||||
 | 
					*.out
 | 
				
			||||||
 | 
					test_temp
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										16
									
								
								.vscode/launch.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								.vscode/launch.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,16 @@
 | 
				
			|||||||
 | 
					{
 | 
				
			||||||
 | 
						// Use IntelliSense to learn about possible attributes.
 | 
				
			||||||
 | 
						// Hover to view descriptions of existing attributes.
 | 
				
			||||||
 | 
						// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
 | 
				
			||||||
 | 
						"version": "0.2.0",
 | 
				
			||||||
 | 
						"configurations": [
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								"name": "Ereshor Workspace",
 | 
				
			||||||
 | 
								"type": "go",
 | 
				
			||||||
 | 
								"request": "launch",
 | 
				
			||||||
 | 
								"mode": "auto",
 | 
				
			||||||
 | 
								"program": "${workspaceFolder}",
 | 
				
			||||||
 | 
								"cwd": "C:\\Users\\Administrator\\Seafile\\Games-Ereshor"
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						]
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										70
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										70
									
								
								README.md
									
									
									
									
									
								
							@@ -4,27 +4,81 @@ A small Go tool for creating symbolic links
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
Created out of infuriating difficulty of creating symbolic links on windows
 | 
					Created out of infuriating difficulty of creating symbolic links on windows
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## Custom syntax
 | 
					## Instruction Formats
 | 
				
			||||||
 | 
					
 | 
				
			||||||
The tool works with "instructions" that describe symbolic links
 | 
					The tool supports two formats for defining symbolic links:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
They are, in any form, \<source>,\<destination>,\<force?>
 | 
					### 1. CSV Format (Legacy)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Simple comma-separated values with the format: `<source>,<destination>[,force][,hard][,delete]`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
For example:
 | 
					For example:
 | 
				
			||||||
`sync this,that`
 | 
					```
 | 
				
			||||||
 | 
					source_path,target_path
 | 
				
			||||||
 | 
					source_path,target_path,true
 | 
				
			||||||
 | 
					source_path,target_path,true,true
 | 
				
			||||||
 | 
					source_path,target_path,true,true,true
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
It supports input of these instructions through:
 | 
					Or with named flags:
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					source_path,target_path,force=true,hard=true,delete=true
 | 
				
			||||||
 | 
					source_path,target_path,f=true,h=true,d=true
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 2. YAML Format (Recommended)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					A more readable format using YAML:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```yaml
 | 
				
			||||||
 | 
					links:
 | 
				
			||||||
 | 
					  - source: ~/Documents/config.ini
 | 
				
			||||||
 | 
					    target: ~/.config/app/config.ini
 | 
				
			||||||
 | 
					    force: true
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  - source: ~/Pictures
 | 
				
			||||||
 | 
					    target: ~/Documents/Pictures
 | 
				
			||||||
 | 
					    hard: true
 | 
				
			||||||
 | 
					    force: true
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  - source: ~/Scripts/script.sh
 | 
				
			||||||
 | 
					    target: ~/bin/script.sh
 | 
				
			||||||
 | 
					    delete: true
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Alternatively, you can use an array directly:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```yaml
 | 
				
			||||||
 | 
					- source: ~/Documents/config.ini
 | 
				
			||||||
 | 
					  target: ~/.config/app/config.ini
 | 
				
			||||||
 | 
					  force: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- source: ~/Pictures
 | 
				
			||||||
 | 
					  target: ~/Documents/Pictures
 | 
				
			||||||
 | 
					  hard: true
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Input Methods
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					The tool supports input of these instructions through:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
- Stdin
 | 
					- Stdin
 | 
				
			||||||
  - `echo "this,that" | sync`
 | 
					  - `echo "this,that" | sync`
 | 
				
			||||||
- Run arguments
 | 
					- Run arguments
 | 
				
			||||||
  - `sync this,that foo,bar "foo 2","C:/bar"`
 | 
					  - `sync this,that foo,bar "foo 2","C:/bar"`
 | 
				
			||||||
- Files
 | 
					- Files
 | 
				
			||||||
	- `sync -f <file>`
 | 
					  - `sync -f <file>` (CSV format)
 | 
				
			||||||
	- Where the file contains instructions, one instruction per line
 | 
					  - `sync -f <file.yaml>` or `sync -f <file.yml>` (YAML format)
 | 
				
			||||||
 | 
					  - Where the file contains instructions, one per line for CSV or structured YAML
 | 
				
			||||||
- Directories
 | 
					- Directories
 | 
				
			||||||
  - `sync -r <directory>`
 | 
					  - `sync -r <directory>`
 | 
				
			||||||
	- This mode will look for "sync" files recursively in directories and run their instructions
 | 
					  - This mode will look for "sync", "sync.yaml", or "sync.yml" files recursively in directories and run their instructions
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Options
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- `force: true` - Overwrite an existing symbolic link at the target location
 | 
				
			||||||
 | 
					- `hard: true` - Create a hard link instead of a symbolic link
 | 
				
			||||||
 | 
					- `delete: true` - Delete a non-symlink file at the target location (implies `force: true`)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## Use case
 | 
					## Use case
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										2
									
								
								build.sh
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								build.sh
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,2 @@
 | 
				
			|||||||
 | 
					GOOS=windows GOARCH=amd64 go build -o cln.exe .
 | 
				
			||||||
 | 
					GOOS=linux GOARCH=amd64 go build -o cln .
 | 
				
			||||||
							
								
								
									
										16
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										16
									
								
								go.mod
									
									
									
									
									
								
							@@ -1,3 +1,17 @@
 | 
				
			|||||||
module cln
 | 
					module cln
 | 
				
			||||||
 | 
					
 | 
				
			||||||
go 1.21.7
 | 
					go 1.23.6
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					require gopkg.in/yaml.v3 v3.0.1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					require (
 | 
				
			||||||
 | 
						git.site.quack-lab.dev/dave/cyutils v1.4.0
 | 
				
			||||||
 | 
						github.com/bmatcuk/doublestar/v4 v4.8.1
 | 
				
			||||||
 | 
						github.com/stretchr/testify v1.11.1
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					require (
 | 
				
			||||||
 | 
						github.com/davecgh/go-spew v1.1.1 // indirect
 | 
				
			||||||
 | 
						github.com/pmezard/go-difflib v1.0.0 // indirect
 | 
				
			||||||
 | 
						golang.org/x/time v0.12.0 // indirect
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										16
									
								
								go.sum
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								go.sum
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,16 @@
 | 
				
			|||||||
 | 
					git.site.quack-lab.dev/dave/cyutils v1.4.0 h1:/Xo3QfLIFNab+axHneWmUK4MyfuObl+qq8whF9vTQpk=
 | 
				
			||||||
 | 
					git.site.quack-lab.dev/dave/cyutils v1.4.0/go.mod h1:fBjALu2Cp2u2bDr+E4zbGVMBeIgFzROg+4TCcTNAiQU=
 | 
				
			||||||
 | 
					github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38=
 | 
				
			||||||
 | 
					github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
 | 
				
			||||||
 | 
					github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 | 
				
			||||||
 | 
					github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 | 
				
			||||||
 | 
					github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 | 
				
			||||||
 | 
					github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 | 
				
			||||||
 | 
					github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
 | 
				
			||||||
 | 
					github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
 | 
				
			||||||
 | 
					golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
 | 
				
			||||||
 | 
					golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
 | 
				
			||||||
 | 
					gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
 | 
				
			||||||
 | 
					gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 | 
				
			||||||
 | 
					gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 | 
				
			||||||
 | 
					gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 | 
				
			||||||
							
								
								
									
										408
									
								
								instruction.go
									
									
									
									
									
								
							
							
						
						
									
										408
									
								
								instruction.go
									
									
									
									
									
								
							@@ -2,20 +2,25 @@ package main
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
import (
 | 
					import (
 | 
				
			||||||
	"fmt"
 | 
						"fmt"
 | 
				
			||||||
	"log"
 | 
					 | 
				
			||||||
	"os"
 | 
						"os"
 | 
				
			||||||
	"path/filepath"
 | 
						"path/filepath"
 | 
				
			||||||
	"regexp"
 | 
					 | 
				
			||||||
	"strconv"
 | 
					 | 
				
			||||||
	"strings"
 | 
						"strings"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						"github.com/bmatcuk/doublestar/v4"
 | 
				
			||||||
 | 
						"gopkg.in/yaml.v3"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type LinkInstruction struct {
 | 
					type LinkInstruction struct {
 | 
				
			||||||
	Source string
 | 
						Source string `yaml:"source"`
 | 
				
			||||||
	Target string
 | 
						Target string `yaml:"target"`
 | 
				
			||||||
	Force  bool
 | 
						Force  bool   `yaml:"force,omitempty"`
 | 
				
			||||||
	Hard   bool
 | 
						Hard   bool   `yaml:"hard,omitempty"`
 | 
				
			||||||
	Delete bool
 | 
						Delete bool   `yaml:"delete,omitempty"`
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type YAMLConfig struct {
 | 
				
			||||||
 | 
						Links []LinkInstruction `yaml:"links"`
 | 
				
			||||||
 | 
						From  []string          `yaml:"from,omitempty"`
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (instruction *LinkInstruction) Tidy() {
 | 
					func (instruction *LinkInstruction) Tidy() {
 | 
				
			||||||
@@ -29,11 +34,60 @@ func (instruction *LinkInstruction) Tidy() {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (instruction *LinkInstruction) String() string {
 | 
					func (instruction *LinkInstruction) String() string {
 | 
				
			||||||
	return fmt.Sprintf("%s%s%s%s%s%s%s%s%s%s%s%s%s", SourceColor, instruction.Source, DefaultColor, deliminer, TargetColor, instruction.Target, DefaultColor, deliminer, strconv.FormatBool(instruction.Force), deliminer, strconv.FormatBool(instruction.Hard), deliminer, strconv.FormatBool(instruction.Delete))
 | 
						var flags []string
 | 
				
			||||||
 | 
						if instruction.Force {
 | 
				
			||||||
 | 
							flags = append(flags, "force=true")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if instruction.Hard {
 | 
				
			||||||
 | 
							flags = append(flags, "hard=true")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if instruction.Delete {
 | 
				
			||||||
 | 
							flags = append(flags, "delete=true")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						flagsStr := ""
 | 
				
			||||||
 | 
						if len(flags) > 0 {
 | 
				
			||||||
 | 
							flagsStr = " [" + strings.Join(flags, ", ") + "]"
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return fmt.Sprintf("%s → %s%s",
 | 
				
			||||||
 | 
							FormatSourcePath(instruction.Source),
 | 
				
			||||||
 | 
							FormatTargetPath(instruction.Target),
 | 
				
			||||||
 | 
							flagsStr)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (instruction *LinkInstruction) Undo() {
 | 
				
			||||||
 | 
						if !FileExists(instruction.Target) {
 | 
				
			||||||
 | 
							LogInfo("%s does not exist, skipping", FormatTargetPath(instruction.Target))
 | 
				
			||||||
 | 
							return
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						isSymlink, err := IsSymlink(instruction.Target)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							LogError("could not determine whether %s is a sym link or not, stopping; err: %v",
 | 
				
			||||||
 | 
								FormatTargetPath(instruction.Target), err)
 | 
				
			||||||
 | 
							return
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if isSymlink {
 | 
				
			||||||
 | 
							LogInfo("Removing symlink at %s", FormatTargetPath(instruction.Target))
 | 
				
			||||||
 | 
							err = os.Remove(instruction.Target)
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								LogError("could not remove symlink at %s; err: %v",
 | 
				
			||||||
 | 
									FormatTargetPath(instruction.Target), err)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							LogSuccess("Removed symlink at %s", FormatTargetPath(instruction.Target))
 | 
				
			||||||
 | 
						} else {
 | 
				
			||||||
 | 
							LogInfo("%s is not a symlink, skipping", FormatTargetPath(instruction.Target))
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func ParseInstruction(line, workdir string) (LinkInstruction, error) {
 | 
					func ParseInstruction(line, workdir string) (LinkInstruction, error) {
 | 
				
			||||||
	line = strings.TrimSpace(line)
 | 
						line = strings.TrimSpace(line)
 | 
				
			||||||
 | 
						if strings.HasPrefix(line, "#") {
 | 
				
			||||||
 | 
							return LinkInstruction{}, fmt.Errorf("comment line")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	parts := strings.Split(line, deliminer)
 | 
						parts := strings.Split(line, deliminer)
 | 
				
			||||||
	instruction := LinkInstruction{}
 | 
						instruction := LinkInstruction{}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -43,19 +97,48 @@ func ParseInstruction(line, workdir string) (LinkInstruction, error) {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	instruction.Source = strings.TrimSpace(parts[0])
 | 
						instruction.Source = strings.TrimSpace(parts[0])
 | 
				
			||||||
	instruction.Target = strings.TrimSpace(parts[1])
 | 
						instruction.Target = strings.TrimSpace(parts[1])
 | 
				
			||||||
	instruction.Force = false
 | 
					
 | 
				
			||||||
	if len(parts) > 2 {
 | 
						for i := 2; i < len(parts); i++ {
 | 
				
			||||||
		res, _ := regexp.MatchString(`^(?i)\s*T|TRUE\s*$`, parts[2])
 | 
							flagPart := strings.TrimSpace(parts[i])
 | 
				
			||||||
		instruction.Force = res
 | 
					
 | 
				
			||||||
 | 
							// Support for legacy format (backward compatibility)
 | 
				
			||||||
 | 
							if !strings.Contains(flagPart, "=") {
 | 
				
			||||||
 | 
								// Legacy format: positional boolean flags
 | 
				
			||||||
 | 
								switch i {
 | 
				
			||||||
 | 
								case 2: // Force flag (3rd position)
 | 
				
			||||||
 | 
									instruction.Force = isTrue(flagPart)
 | 
				
			||||||
 | 
								case 3: // Hard flag (4th position)
 | 
				
			||||||
 | 
									instruction.Hard = isTrue(flagPart)
 | 
				
			||||||
 | 
								case 4: // Delete flag (5th position)
 | 
				
			||||||
 | 
									instruction.Delete = isTrue(flagPart)
 | 
				
			||||||
 | 
									if instruction.Delete {
 | 
				
			||||||
 | 
										instruction.Force = true // Delete implies Force
 | 
				
			||||||
 | 
									}
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
								continue
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// New format: named flags (name=value)
 | 
				
			||||||
 | 
							nameValue := strings.SplitN(flagPart, "=", 2)
 | 
				
			||||||
 | 
							if len(nameValue) != 2 {
 | 
				
			||||||
 | 
								// Skip malformed flags
 | 
				
			||||||
 | 
								continue
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							flagName := strings.ToLower(strings.TrimSpace(nameValue[0]))
 | 
				
			||||||
 | 
							flagValue := strings.TrimSpace(nameValue[1])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							switch flagName {
 | 
				
			||||||
 | 
							case "force", "f":
 | 
				
			||||||
 | 
								instruction.Force = isTrue(flagValue)
 | 
				
			||||||
 | 
							case "hard", "h":
 | 
				
			||||||
 | 
								instruction.Hard = isTrue(flagValue)
 | 
				
			||||||
 | 
							case "delete", "d":
 | 
				
			||||||
 | 
								instruction.Delete = isTrue(flagValue)
 | 
				
			||||||
 | 
								if instruction.Delete {
 | 
				
			||||||
 | 
									instruction.Force = true // Delete implies Force
 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
	if len(parts) > 3 {
 | 
					 | 
				
			||||||
		res, _ := regexp.MatchString(`^(?i)\s*T|TRUE\s*$`, parts[3])
 | 
					 | 
				
			||||||
		instruction.Hard = res
 | 
					 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	if len(parts) > 4 {
 | 
					 | 
				
			||||||
		res, _ := regexp.MatchString(`^(?i)\s*T|TRUE\s*$`, parts[4])
 | 
					 | 
				
			||||||
		instruction.Delete = res
 | 
					 | 
				
			||||||
		instruction.Force = res
 | 
					 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	instruction.Tidy()
 | 
						instruction.Tidy()
 | 
				
			||||||
@@ -68,15 +151,30 @@ func ParseInstruction(line, workdir string) (LinkInstruction, error) {
 | 
				
			|||||||
	return instruction, nil
 | 
						return instruction, nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func isTrue(value string) bool {
 | 
				
			||||||
 | 
						value = strings.ToLower(strings.TrimSpace(value))
 | 
				
			||||||
 | 
						return value == "true" || value == "t" || value == "yes" || value == "y" || value == "1"
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (instruction *LinkInstruction) RunAsync(status chan (error)) {
 | 
					func (instruction *LinkInstruction) RunAsync(status chan (error)) {
 | 
				
			||||||
	defer close(status)
 | 
						defer close(status)
 | 
				
			||||||
 | 
						if undo {
 | 
				
			||||||
 | 
							instruction.Undo()
 | 
				
			||||||
 | 
							return
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if !FileExists(instruction.Source) {
 | 
						if !FileExists(instruction.Source) {
 | 
				
			||||||
		status <- fmt.Errorf("instruction source %s%s%s does not exist", SourceColor, instruction.Source, DefaultColor)
 | 
							status <- fmt.Errorf("instruction source %s does not exist", FormatSourcePath(instruction.Source))
 | 
				
			||||||
		return
 | 
							return
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if !instruction.Force && AreSame(instruction.Source, instruction.Target) {
 | 
						if !instruction.Force && AreSame(instruction.Source, instruction.Target) {
 | 
				
			||||||
		status <- fmt.Errorf("source %s%s%s and target %s%s%s are the same, %snothing to do...%s", SourceColor, instruction.Source, DefaultColor, TargetColor, instruction.Target, DefaultColor, PathColor, DefaultColor)
 | 
							//status <- fmt.Errorf("source %s and target %s are the same, nothing to do...",
 | 
				
			||||||
 | 
							//	FormatSourcePath(instruction.Source),
 | 
				
			||||||
 | 
							//	FormatTargetPath(instruction.Target))
 | 
				
			||||||
 | 
							LogInfo("Source %s and target %s are the same, nothing to do...",
 | 
				
			||||||
 | 
								FormatSourcePath(instruction.Source),
 | 
				
			||||||
 | 
								FormatTargetPath(instruction.Target))
 | 
				
			||||||
		return
 | 
							return
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -84,47 +182,54 @@ func (instruction *LinkInstruction) RunAsync(status chan (error)) {
 | 
				
			|||||||
		if instruction.Force {
 | 
							if instruction.Force {
 | 
				
			||||||
			isSymlink, err := IsSymlink(instruction.Target)
 | 
								isSymlink, err := IsSymlink(instruction.Target)
 | 
				
			||||||
			if err != nil {
 | 
								if err != nil {
 | 
				
			||||||
				status <- fmt.Errorf("could not determine whether %s%s%s is a sym link or not, stopping; err: %s%+v%s", TargetColor, instruction.Target, DefaultColor, ErrorColor, err, DefaultColor)
 | 
									status <- fmt.Errorf("could not determine whether %s is a sym link or not, stopping; err: %v",
 | 
				
			||||||
 | 
										FormatTargetPath(instruction.Target), err)
 | 
				
			||||||
				return
 | 
									return
 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			if instruction.Hard {
 | 
								if instruction.Hard {
 | 
				
			||||||
				info, err := os.Stat(instruction.Target)
 | 
									info, err := os.Stat(instruction.Target)
 | 
				
			||||||
				if err != nil {
 | 
									if err != nil {
 | 
				
			||||||
					status <- fmt.Errorf("could not stat %s%s%s, stopping; err: %s%+v%s", TargetColor, instruction.Target, DefaultColor, ErrorColor, err, DefaultColor)
 | 
										status <- fmt.Errorf("could not stat %s, stopping; err: %v",
 | 
				
			||||||
 | 
											FormatTargetPath(instruction.Target), err)
 | 
				
			||||||
					return
 | 
										return
 | 
				
			||||||
				}
 | 
									}
 | 
				
			||||||
				if info.Mode().IsRegular() && info.Name() == filepath.Base(instruction.Source) {
 | 
									if info.Mode().IsRegular() && info.Name() == filepath.Base(instruction.Source) {
 | 
				
			||||||
					log.Printf("Overwriting existing file %s%s%s", TargetColor, instruction.Target, DefaultColor)
 | 
										LogTarget("Overwriting existing file %s", instruction.Target)
 | 
				
			||||||
					err := os.Remove(instruction.Target)
 | 
										err := os.Remove(instruction.Target)
 | 
				
			||||||
					if err != nil {
 | 
										if err != nil {
 | 
				
			||||||
						status <- fmt.Errorf("could not remove existing file %s%s%s; err: %s%+v%s", TargetColor, instruction.Target, DefaultColor, ErrorColor, err, DefaultColor)
 | 
											status <- fmt.Errorf("could not remove existing file %s; err: %v",
 | 
				
			||||||
 | 
												FormatTargetPath(instruction.Target), err)
 | 
				
			||||||
						return
 | 
											return
 | 
				
			||||||
					}
 | 
										}
 | 
				
			||||||
				}
 | 
									}
 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			if isSymlink {
 | 
								if isSymlink {
 | 
				
			||||||
				log.Printf("Removing symlink at %s%s%s", TargetColor, instruction.Target, DefaultColor)
 | 
									LogTarget("Removing symlink at %s", instruction.Target)
 | 
				
			||||||
				err = os.Remove(instruction.Target)
 | 
									err = os.Remove(instruction.Target)
 | 
				
			||||||
				if err != nil {
 | 
									if err != nil {
 | 
				
			||||||
					status <- fmt.Errorf("failed deleting %s%s%s due to %s%+v%s", TargetColor, instruction.Target, DefaultColor, ErrorColor, err, DefaultColor)
 | 
										status <- fmt.Errorf("failed deleting %s due to %v",
 | 
				
			||||||
 | 
											FormatTargetPath(instruction.Target), err)
 | 
				
			||||||
					return
 | 
										return
 | 
				
			||||||
				}
 | 
									}
 | 
				
			||||||
			} else {
 | 
								} else {
 | 
				
			||||||
				if !instruction.Delete {
 | 
									if !instruction.Delete {
 | 
				
			||||||
					status <- fmt.Errorf("refusing to delte actual (non symlink) file %s%s%s", TargetColor, instruction.Target, DefaultColor)
 | 
										status <- fmt.Errorf("refusing to delte actual (non symlink) file %s",
 | 
				
			||||||
 | 
											FormatTargetPath(instruction.Target))
 | 
				
			||||||
					return
 | 
										return
 | 
				
			||||||
				}
 | 
									}
 | 
				
			||||||
				log.Printf("%sDeleting (!!!)%s %s%s%s", ImportantColor, DefaultColor, TargetColor, instruction.Target, DefaultColor)
 | 
									LogImportant("Deleting (!!!) %s", instruction.Target)
 | 
				
			||||||
				err = os.RemoveAll(instruction.Target)
 | 
									err = os.RemoveAll(instruction.Target)
 | 
				
			||||||
				if err != nil {
 | 
									if err != nil {
 | 
				
			||||||
					status <- fmt.Errorf("failed deleting %s%s%s due to %s%+v%s", TargetColor, instruction.Target, DefaultColor, ErrorColor, err, DefaultColor)
 | 
										status <- fmt.Errorf("failed deleting %s due to %v",
 | 
				
			||||||
 | 
											FormatTargetPath(instruction.Target), err)
 | 
				
			||||||
					return
 | 
										return
 | 
				
			||||||
				}
 | 
									}
 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
		} else {
 | 
							} else {
 | 
				
			||||||
			status <- fmt.Errorf("target %s%s%s exists - handle manually or set the 'forced' flag (3rd field)", TargetColor, instruction.Target, DefaultColor)
 | 
								status <- fmt.Errorf("target %s exists - handle manually or set the 'forced' flag (3rd field)",
 | 
				
			||||||
 | 
									FormatTargetPath(instruction.Target))
 | 
				
			||||||
			return
 | 
								return
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -133,7 +238,8 @@ func (instruction *LinkInstruction) RunAsync(status chan (error)) {
 | 
				
			|||||||
	if _, err := os.Stat(targetDir); os.IsNotExist(err) {
 | 
						if _, err := os.Stat(targetDir); os.IsNotExist(err) {
 | 
				
			||||||
		err = os.MkdirAll(targetDir, 0755)
 | 
							err = os.MkdirAll(targetDir, 0755)
 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
			status <- fmt.Errorf("failed creating directory %s%s%s due to %s%+v%s", TargetColor, targetDir, DefaultColor, ErrorColor, err, DefaultColor)
 | 
								status <- fmt.Errorf("failed creating directory %s due to %v",
 | 
				
			||||||
 | 
									FormatTargetPath(targetDir), err)
 | 
				
			||||||
			return
 | 
								return
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -145,10 +251,242 @@ func (instruction *LinkInstruction) RunAsync(status chan (error)) {
 | 
				
			|||||||
		err = os.Symlink(instruction.Source, instruction.Target)
 | 
							err = os.Symlink(instruction.Source, instruction.Target)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		status <- fmt.Errorf("failed creating symlink between %s%s%s and %s%s%s with error %s%+v%s", SourceColor, instruction.Source, DefaultColor, TargetColor, instruction.Target, DefaultColor, ErrorColor, err, DefaultColor)
 | 
							status <- fmt.Errorf("failed creating symlink between %s and %s with error %v",
 | 
				
			||||||
 | 
								FormatSourcePath(instruction.Source),
 | 
				
			||||||
 | 
								FormatTargetPath(instruction.Target),
 | 
				
			||||||
 | 
								err)
 | 
				
			||||||
		return
 | 
							return
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	log.Printf("Created symlink between %s%s%s and %s%s%s", SourceColor, instruction.Source, DefaultColor, TargetColor, instruction.Target, DefaultColor)
 | 
						LogSuccess("Created symlink between %s and %s",
 | 
				
			||||||
 | 
							FormatSourcePath(instruction.Source),
 | 
				
			||||||
 | 
							FormatTargetPath(instruction.Target))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	status <- nil
 | 
						status <- nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func ParseYAMLFile(filename, workdir string) ([]LinkInstruction, error) {
 | 
				
			||||||
 | 
						data, err := os.ReadFile(filename)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return nil, fmt.Errorf("error reading YAML file: %w", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Parse as a direct list of instructions
 | 
				
			||||||
 | 
						var instructions []LinkInstruction
 | 
				
			||||||
 | 
						err = yaml.Unmarshal(data, &instructions)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return nil, fmt.Errorf("error parsing YAML: %w", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Preprocess instructions: expand globs and from references
 | 
				
			||||||
 | 
						// Create a new visited map for this file
 | 
				
			||||||
 | 
						visited := make(map[string]bool)
 | 
				
			||||||
 | 
						processedInstructions, err := preprocessInstructions(instructions, filename, workdir, visited)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return nil, err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Final processing: normalize paths and set defaults
 | 
				
			||||||
 | 
						for i := range processedInstructions {
 | 
				
			||||||
 | 
							link := &processedInstructions[i]
 | 
				
			||||||
 | 
							link.Tidy()
 | 
				
			||||||
 | 
							link.Source, _ = ConvertHome(link.Source)
 | 
				
			||||||
 | 
							link.Target, _ = ConvertHome(link.Target)
 | 
				
			||||||
 | 
							link.Source = NormalizePath(link.Source, workdir)
 | 
				
			||||||
 | 
							link.Target = NormalizePath(link.Target, workdir)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// If Delete is true, Force must also be true
 | 
				
			||||||
 | 
							if link.Delete {
 | 
				
			||||||
 | 
								link.Force = true
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return processedInstructions, nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// preprocessInstructions handles glob expansion and from references
 | 
				
			||||||
 | 
					func preprocessInstructions(instructions []LinkInstruction, filename, workdir string, visited map[string]bool) ([]LinkInstruction, error) {
 | 
				
			||||||
 | 
						var result []LinkInstruction
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						for _, instr := range instructions {
 | 
				
			||||||
 | 
							if instr.Source == "" {
 | 
				
			||||||
 | 
								continue // Skip invalid instructions
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							if instr.Target == "" {
 | 
				
			||||||
 | 
								// This is a from reference - load the referenced file
 | 
				
			||||||
 | 
								fromInstructions, err := loadFromReference(instr.Source, filename, workdir, visited)
 | 
				
			||||||
 | 
								if err != nil {
 | 
				
			||||||
 | 
									return nil, fmt.Errorf("error loading from reference %s: %w", instr.Source, err)
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
								result = append(result, fromInstructions...)
 | 
				
			||||||
 | 
							} else {
 | 
				
			||||||
 | 
								// This is a regular instruction - expand globs if needed
 | 
				
			||||||
 | 
								expandedInstructions, err := expandGlobs(instr, filename, workdir)
 | 
				
			||||||
 | 
								if err != nil {
 | 
				
			||||||
 | 
									return nil, fmt.Errorf("error expanding globs for %s: %w", instr.Source, err)
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
								result = append(result, expandedInstructions...)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return result, nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// loadFromReference loads instructions from a referenced file
 | 
				
			||||||
 | 
					func loadFromReference(fromFile, currentFile, workdir string, visited map[string]bool) ([]LinkInstruction, error) {
 | 
				
			||||||
 | 
						// Convert relative paths to absolute paths based on the current file's directory
 | 
				
			||||||
 | 
						fromPath := fromFile
 | 
				
			||||||
 | 
						if !filepath.IsAbs(fromPath) {
 | 
				
			||||||
 | 
							currentDir := filepath.Dir(currentFile)
 | 
				
			||||||
 | 
							fromPath = filepath.Join(currentDir, fromPath)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Normalize the path
 | 
				
			||||||
 | 
						fromPath = filepath.Clean(fromPath)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Recursively parse the referenced file with cycle detection
 | 
				
			||||||
 | 
						fromWorkdir := filepath.Dir(fromPath)
 | 
				
			||||||
 | 
						return parseYAMLFileRecursive(fromPath, fromWorkdir, visited)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// expandGlobs expands glob patterns in a single instruction
 | 
				
			||||||
 | 
					func expandGlobs(instr LinkInstruction, filename, workdir string) ([]LinkInstruction, error) {
 | 
				
			||||||
 | 
						LogSource("Expanding pattern source %s in YAML file %s", instr.Source, filename)
 | 
				
			||||||
 | 
						newlinks, err := ExpandPattern(instr.Source, workdir, instr.Target)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return nil, err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Clone the original instruction properties for each expanded link
 | 
				
			||||||
 | 
						for i := range newlinks {
 | 
				
			||||||
 | 
							newlinks[i].Delete = instr.Delete
 | 
				
			||||||
 | 
							newlinks[i].Hard = instr.Hard
 | 
				
			||||||
 | 
							newlinks[i].Force = instr.Force
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						LogInfo("Expanded pattern %s in YAML file %s to %d links",
 | 
				
			||||||
 | 
							FormatSourcePath(instr.Source), FormatSourcePath(filename), len(newlinks))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return newlinks, nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// ParseYAMLFileRecursive parses a YAML file and recursively processes any "From" references
 | 
				
			||||||
 | 
					func ParseYAMLFileRecursive(filename, workdir string) ([]LinkInstruction, error) {
 | 
				
			||||||
 | 
						visited := make(map[string]bool)
 | 
				
			||||||
 | 
						return parseYAMLFileRecursive(filename, workdir, visited)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// parseYAMLFileRecursive is the internal recursive function that tracks visited files to prevent cycles
 | 
				
			||||||
 | 
					func parseYAMLFileRecursive(filename, workdir string, visited map[string]bool) ([]LinkInstruction, error) {
 | 
				
			||||||
 | 
						// Normalize the filename to prevent cycles with different path representations
 | 
				
			||||||
 | 
						normalizedFilename, err := filepath.Abs(filename)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return nil, fmt.Errorf("error normalizing filename: %w", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Check for cycles
 | 
				
			||||||
 | 
						if visited[normalizedFilename] {
 | 
				
			||||||
 | 
							return nil, fmt.Errorf("circular reference detected: %s", filename)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						visited[normalizedFilename] = true
 | 
				
			||||||
 | 
						defer delete(visited, normalizedFilename)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Parse the current file and preprocess it with cycle detection
 | 
				
			||||||
 | 
						data, err := os.ReadFile(filename)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return nil, fmt.Errorf("error reading YAML file: %w", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Parse as a direct list of instructions
 | 
				
			||||||
 | 
						var instructions []LinkInstruction
 | 
				
			||||||
 | 
						err = yaml.Unmarshal(data, &instructions)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return nil, fmt.Errorf("error parsing YAML: %w", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Preprocess instructions: expand globs and from references
 | 
				
			||||||
 | 
						processedInstructions, err := preprocessInstructions(instructions, filename, workdir, visited)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return nil, err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Final processing: normalize paths and set defaults
 | 
				
			||||||
 | 
						for i := range processedInstructions {
 | 
				
			||||||
 | 
							link := &processedInstructions[i]
 | 
				
			||||||
 | 
							link.Tidy()
 | 
				
			||||||
 | 
							link.Source, _ = ConvertHome(link.Source)
 | 
				
			||||||
 | 
							link.Target, _ = ConvertHome(link.Target)
 | 
				
			||||||
 | 
							link.Source = NormalizePath(link.Source, workdir)
 | 
				
			||||||
 | 
							link.Target = NormalizePath(link.Target, workdir)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// If Delete is true, Force must also be true
 | 
				
			||||||
 | 
							if link.Delete {
 | 
				
			||||||
 | 
								link.Force = true
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return processedInstructions, nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func ExpandPattern(source, workdir, target string) (links []LinkInstruction, err error) {
 | 
				
			||||||
 | 
						static, pattern := doublestar.SplitPattern(source)
 | 
				
			||||||
 | 
						if static == "" || static == "." {
 | 
				
			||||||
 | 
							static = workdir
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						LogInfo("Static part: %s", static)
 | 
				
			||||||
 | 
						LogInfo("Pattern part: %s", pattern)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						files, err := doublestar.Glob(os.DirFS(static), pattern)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return nil, fmt.Errorf("error expanding pattern: %w", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						targetIsFile := false
 | 
				
			||||||
 | 
						if info, err := os.Stat(target); err == nil && !info.IsDir() {
 | 
				
			||||||
 | 
							targetIsFile = true
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						for _, file := range files {
 | 
				
			||||||
 | 
							if len(files) == 1 {
 | 
				
			||||||
 | 
								// Special case: if there is only one file
 | 
				
			||||||
 | 
								// This should only ever happen if our source is a path (and not a glob!)
 | 
				
			||||||
 | 
								// And our target is a path
 | 
				
			||||||
 | 
								// ...but it will also happen if the source IS a glob and it happens to match ONE file
 | 
				
			||||||
 | 
								// I think that should happen rarely enough to not be an issue...
 | 
				
			||||||
 | 
								links = append(links, LinkInstruction{
 | 
				
			||||||
 | 
									Source: filepath.Join(static, file),
 | 
				
			||||||
 | 
									Target: target,
 | 
				
			||||||
 | 
								})
 | 
				
			||||||
 | 
								continue
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							if info, err := os.Stat(file); err == nil && info.IsDir() {
 | 
				
			||||||
 | 
								// We don't care about matched directories
 | 
				
			||||||
 | 
								// We want files within them
 | 
				
			||||||
 | 
								LogInfo("Skipping directory %s", file)
 | 
				
			||||||
 | 
								continue
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							var targetPath string
 | 
				
			||||||
 | 
							if targetIsFile && len(files) == 1 {
 | 
				
			||||||
 | 
								// Special case: target is a file, and glob matches exactly one file.
 | 
				
			||||||
 | 
								// Use target directly (don't append filename).
 | 
				
			||||||
 | 
								targetPath = target
 | 
				
			||||||
 | 
							} else {
 | 
				
			||||||
 | 
								// Default: append filename to target dir.
 | 
				
			||||||
 | 
								targetPath = filepath.Join(target, file)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							links = append(links, LinkInstruction{
 | 
				
			||||||
 | 
								Source: filepath.Join(static, file),
 | 
				
			||||||
 | 
								Target: targetPath,
 | 
				
			||||||
 | 
							})
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						LogInfo("Expanded pattern %s to %d links", FormatSourcePath(source), len(links))
 | 
				
			||||||
 | 
						return
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func IsYAMLFile(filename string) bool {
 | 
				
			||||||
 | 
						ext := strings.ToLower(filepath.Ext(filename))
 | 
				
			||||||
 | 
						return ext == ".yaml" || ext == ".yml"
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										4305
									
								
								instruction_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4305
									
								
								instruction_test.go
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										99
									
								
								logger.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								logger.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,99 @@
 | 
				
			|||||||
 | 
					package main
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import (
 | 
				
			||||||
 | 
						"fmt"
 | 
				
			||||||
 | 
						"log"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Message type prefixes
 | 
				
			||||||
 | 
					const (
 | 
				
			||||||
 | 
						InfoPrefix      = "INFO"
 | 
				
			||||||
 | 
						ErrorPrefix     = "ERROR"
 | 
				
			||||||
 | 
						WarningPrefix   = "WARN"
 | 
				
			||||||
 | 
						SourcePrefix    = "SOURCE"
 | 
				
			||||||
 | 
						TargetPrefix    = "TARGET"
 | 
				
			||||||
 | 
						PathPrefix      = "PATH"
 | 
				
			||||||
 | 
						ImportantPrefix = "IMPORTANT"
 | 
				
			||||||
 | 
						SuccessPrefix   = "DONE"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// LogInfo logs an informational message
 | 
				
			||||||
 | 
					func LogInfo(format string, args ...interface{}) {
 | 
				
			||||||
 | 
						message := fmt.Sprintf(format, args...)
 | 
				
			||||||
 | 
						log.Printf("%s[%s]%s %s", BGreen, InfoPrefix, Reset, message)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// LogSuccess logs a success message
 | 
				
			||||||
 | 
					func LogSuccess(format string, args ...interface{}) {
 | 
				
			||||||
 | 
						message := fmt.Sprintf(format, args...)
 | 
				
			||||||
 | 
						log.Printf("%s%s[%s]%s %s", BBlue, On_Blue, SuccessPrefix, Reset, message)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// LogSource logs a message about a source file/path with proper coloring
 | 
				
			||||||
 | 
					func LogSource(format string, args ...interface{}) {
 | 
				
			||||||
 | 
						message := fmt.Sprintf(format, args...)
 | 
				
			||||||
 | 
						log.Printf("%s[%s]%s %s%s%s", BPurple, SourcePrefix, Reset, SourceColor, message, Reset)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// LogTarget logs a message about a target file/path with proper coloring
 | 
				
			||||||
 | 
					func LogTarget(format string, args ...interface{}) {
 | 
				
			||||||
 | 
						message := fmt.Sprintf(format, args...)
 | 
				
			||||||
 | 
						log.Printf("%s[%s]%s %s%s%s", BYellow, TargetPrefix, Reset, TargetColor, message, Reset)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// LogPath logs a message about a path with proper coloring
 | 
				
			||||||
 | 
					func LogPath(format string, args ...interface{}) {
 | 
				
			||||||
 | 
						message := fmt.Sprintf(format, args...)
 | 
				
			||||||
 | 
						log.Printf("%s[%s]%s %s%s%s", BGreen, PathPrefix, Reset, PathColor, message, Reset)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// LogImportant logs a message that needs attention with proper coloring
 | 
				
			||||||
 | 
					func LogImportant(format string, args ...interface{}) {
 | 
				
			||||||
 | 
						message := fmt.Sprintf(format, args...)
 | 
				
			||||||
 | 
						log.Printf("%s[%s]%s %s%s%s", BRed, ImportantPrefix, Reset, ImportantColor, message, Reset)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// LogError logs an error message with proper coloring that won't be cut off
 | 
				
			||||||
 | 
					func LogError(format string, args ...interface{}) {
 | 
				
			||||||
 | 
						// Format the message first with normal text (no red coloring)
 | 
				
			||||||
 | 
						message := fmt.Sprintf(format, args...)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// The Error prefix itself is bold red on a light background for maximum visibility
 | 
				
			||||||
 | 
						prefix := fmt.Sprintf("%s%s[%s]%s ", BRed, On_White, ErrorPrefix, Reset)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// The message is in default color (no red), only the [ERROR] prefix is colored
 | 
				
			||||||
 | 
						log.Printf("%s%s", prefix, message)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// FormatSourcePath returns a source path with proper coloring
 | 
				
			||||||
 | 
					func FormatSourcePath(path string) string {
 | 
				
			||||||
 | 
						return fmt.Sprintf("%s%s%s", SourceColor, path, Reset)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// FormatTargetPath returns a target path with proper coloring
 | 
				
			||||||
 | 
					func FormatTargetPath(path string) string {
 | 
				
			||||||
 | 
						return fmt.Sprintf("%s%s%s", TargetColor, path, Reset)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// FormatPathValue returns a path with proper coloring
 | 
				
			||||||
 | 
					func FormatPathValue(path string) string {
 | 
				
			||||||
 | 
						return fmt.Sprintf("%s%s%s", PathColor, path, Reset)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// FormatErrorValue returns an error value without any additional formatting
 | 
				
			||||||
 | 
					// Since error messages are no longer red, we don't need special formatting
 | 
				
			||||||
 | 
					func FormatErrorValue(err error) string {
 | 
				
			||||||
 | 
						if err == nil {
 | 
				
			||||||
 | 
							return ""
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						// Just return the error string with no color formatting
 | 
				
			||||||
 | 
						return fmt.Sprintf("%v", err)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// FormatErrorMessage formats an error message with no additional color formatting,
 | 
				
			||||||
 | 
					// while preserving the special formatting of embedded source/target/path elements.
 | 
				
			||||||
 | 
					func FormatErrorMessage(format string, args ...interface{}) string {
 | 
				
			||||||
 | 
						// This just formats the message with no additional color formatting
 | 
				
			||||||
 | 
						// The path formatting will still be preserved
 | 
				
			||||||
 | 
						return fmt.Sprintf(format, args...)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										254
									
								
								main.go
									
									
									
									
									
								
							
							
						
						
									
										254
									
								
								main.go
									
									
									
									
									
								
							@@ -7,34 +7,54 @@ import (
 | 
				
			|||||||
	"log"
 | 
						"log"
 | 
				
			||||||
	"os"
 | 
						"os"
 | 
				
			||||||
	"path/filepath"
 | 
						"path/filepath"
 | 
				
			||||||
	"regexp"
 | 
					 | 
				
			||||||
	"sync"
 | 
					 | 
				
			||||||
	"sync/atomic"
 | 
						"sync/atomic"
 | 
				
			||||||
 | 
						utils "git.site.quack-lab.dev/dave/cyutils"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const deliminer = ","
 | 
					const deliminer = ","
 | 
				
			||||||
const SourceColor = Purple
 | 
					const SourceColor = Purple
 | 
				
			||||||
const TargetColor = Yellow
 | 
					const TargetColor = Yellow
 | 
				
			||||||
const ErrorColor = URed
 | 
					const ErrorColor = Red
 | 
				
			||||||
const ImportantColor = BRed
 | 
					const ImportantColor = BRed
 | 
				
			||||||
const DefaultColor = White
 | 
					const DefaultColor = Reset
 | 
				
			||||||
const PathColor = Green
 | 
					const PathColor = Green
 | 
				
			||||||
 | 
					
 | 
				
			||||||
var DirRegex, _ = regexp.Compile(`^(.+?)[/\\]sync$`)
 | 
					 | 
				
			||||||
var FileRegex, _ = regexp.Compile(`^sync$`)
 | 
					 | 
				
			||||||
var programName = os.Args[0]
 | 
					var programName = os.Args[0]
 | 
				
			||||||
 | 
					var undo = false
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func main() {
 | 
					func main() {
 | 
				
			||||||
	recurse := flag.String("r", "", "recurse into directories")
 | 
						recurse := flag.String("r", "", "recurse into directories")
 | 
				
			||||||
	file := flag.String("f", "", "file to read instructions from")
 | 
						file := flag.String("f", "", "file to read instructions from")
 | 
				
			||||||
	debug := flag.Bool("d", false, "debug")
 | 
						debug := flag.Bool("d", false, "debug")
 | 
				
			||||||
 | 
						undoF := flag.Bool("u", false, "undo")
 | 
				
			||||||
	flag.Parse()
 | 
						flag.Parse()
 | 
				
			||||||
 | 
						undo = *undoF
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if *debug {
 | 
						setupLogging(*debug)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						instructions := make(chan *LinkInstruction, 1000)
 | 
				
			||||||
 | 
						status := make(chan error)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						startInputSource(*recurse, *file, instructions, status)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						go handleStatusErrors(status)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						instructionsDone := processInstructions(instructions)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if instructionsDone == 0 {
 | 
				
			||||||
 | 
							LogInfo("No instructions were processed")
 | 
				
			||||||
 | 
							os.Exit(1)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						LogInfo("All done")
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// setupLogging configures logging based on debug flag
 | 
				
			||||||
 | 
					func setupLogging(debug bool) {
 | 
				
			||||||
 | 
						if debug {
 | 
				
			||||||
		log.SetFlags(log.Lmicroseconds | log.Lshortfile)
 | 
							log.SetFlags(log.Lmicroseconds | log.Lshortfile)
 | 
				
			||||||
		logFile, err := os.Create("main.log")
 | 
							logFile, err := os.Create(programName + ".log")
 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
			log.Printf("Error creating log file: %v", err)
 | 
								LogError("Error creating log file: %v", err)
 | 
				
			||||||
			os.Exit(1)
 | 
								os.Exit(1)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		logger := io.MultiWriter(os.Stdout, logFile)
 | 
							logger := io.MultiWriter(os.Stdout, logFile)
 | 
				
			||||||
@@ -42,80 +62,104 @@ func main() {
 | 
				
			|||||||
	} else {
 | 
						} else {
 | 
				
			||||||
		log.SetFlags(log.Lmicroseconds)
 | 
							log.SetFlags(log.Lmicroseconds)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	instructions := make(chan *LinkInstruction, 1000)
 | 
					// startInputSource determines and starts the appropriate input source
 | 
				
			||||||
	status := make(chan error)
 | 
					func startInputSource(recurse, file string, instructions chan *LinkInstruction, status chan error) {
 | 
				
			||||||
 | 
					 | 
				
			||||||
	// Check input sources in priority order
 | 
						// Check input sources in priority order
 | 
				
			||||||
	switch {
 | 
						switch {
 | 
				
			||||||
	case *recurse != "":
 | 
						case recurse != "":
 | 
				
			||||||
		log.Printf("Recurse: %s", *recurse)
 | 
							LogInfo("Recurse: %s", recurse)
 | 
				
			||||||
		go ReadFromFilesRecursively(*recurse, instructions, status)
 | 
							go ReadFromFilesRecursively(recurse, instructions, status)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	case *file != "":
 | 
						case file != "":
 | 
				
			||||||
		log.Printf("File: %s", *file)
 | 
							LogInfo("File: %s", file)
 | 
				
			||||||
		go ReadFromFile(*file, instructions, status, true)
 | 
							go ReadFromFile(file, instructions, status, true)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	case len(flag.Args()) > 0:
 | 
						case len(flag.Args()) > 0:
 | 
				
			||||||
		log.Printf("Reading from command line arguments")
 | 
							LogInfo("Reading from command line arguments")
 | 
				
			||||||
		go ReadFromArgs(instructions, status)
 | 
							go ReadFromArgs(instructions, status)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	case IsPipeInput():
 | 
						// case IsPipeInput():
 | 
				
			||||||
		log.Printf("Reading from stdin pipe")
 | 
						// 	LogInfo("Reading from stdin pipe")
 | 
				
			||||||
		go ReadFromStdin(instructions, status)
 | 
						// 	go ReadFromStdin(instructions, status)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	default:
 | 
						default:
 | 
				
			||||||
		if _, err := os.Stat("sync"); err == nil {
 | 
							startDefaultInputSource(instructions, status)
 | 
				
			||||||
			log.Printf("Using default sync file")
 | 
					 | 
				
			||||||
			go ReadFromFile("sync", instructions, status, true)
 | 
					 | 
				
			||||||
		} else {
 | 
					 | 
				
			||||||
			log.Printf("No input provided")
 | 
					 | 
				
			||||||
			log.Printf("Provide input as: ")
 | 
					 | 
				
			||||||
			log.Printf("Arguments - %s <source>,<target>,<force?>", programName)
 | 
					 | 
				
			||||||
			log.Printf("File - %s -f <file>", programName)
 | 
					 | 
				
			||||||
			log.Printf("Folder (finding sync files in folder recursively) - %s -r <folder>", programName)
 | 
					 | 
				
			||||||
			log.Printf("stdin - (cat <file> | %s)", programName)
 | 
					 | 
				
			||||||
			os.Exit(1)
 | 
					 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	go func() {
 | 
					// startDefaultInputSource tries to find default sync files
 | 
				
			||||||
 | 
					func startDefaultInputSource(instructions chan *LinkInstruction, status chan error) {
 | 
				
			||||||
 | 
						if _, err := os.Stat("sync"); err == nil {
 | 
				
			||||||
 | 
							LogInfo("Using default sync file")
 | 
				
			||||||
 | 
							go ReadFromFile("sync", instructions, status, true)
 | 
				
			||||||
 | 
						} else if _, err := os.Stat("sync.yaml"); err == nil {
 | 
				
			||||||
 | 
							LogInfo("Using default sync.yaml file")
 | 
				
			||||||
 | 
							go ReadFromFile("sync.yaml", instructions, status, true)
 | 
				
			||||||
 | 
						} else if _, err := os.Stat("sync.yml"); err == nil {
 | 
				
			||||||
 | 
							LogInfo("Using default sync.yml file")
 | 
				
			||||||
 | 
							go ReadFromFile("sync.yml", instructions, status, true)
 | 
				
			||||||
 | 
						} else {
 | 
				
			||||||
 | 
							showUsageAndExit()
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// showUsageAndExit displays usage information and exits
 | 
				
			||||||
 | 
					func showUsageAndExit() {
 | 
				
			||||||
 | 
						LogInfo("No input provided")
 | 
				
			||||||
 | 
						LogInfo("Provide input as: ")
 | 
				
			||||||
 | 
						LogInfo("Arguments - %s <source>,<target>,<force?>", programName)
 | 
				
			||||||
 | 
						LogInfo("File - %s -f <file>", programName)
 | 
				
			||||||
 | 
						LogInfo("YAML File - %s -f <file.yaml>", programName)
 | 
				
			||||||
 | 
						LogInfo("Folder (finding sync files in folder recursively) - %s -r <folder>", programName)
 | 
				
			||||||
 | 
						LogInfo("stdin - (cat <file> | %s)", programName)
 | 
				
			||||||
 | 
						os.Exit(1)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// handleStatusErrors processes status channel errors
 | 
				
			||||||
 | 
					func handleStatusErrors(status chan error) {
 | 
				
			||||||
	for {
 | 
						for {
 | 
				
			||||||
		err, ok := <-status
 | 
							err, ok := <-status
 | 
				
			||||||
		if !ok {
 | 
							if !ok {
 | 
				
			||||||
			break
 | 
								break
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
				log.Println(err)
 | 
								LogError("%v", err)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
	}()
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
	var instructionsDone int32
 | 
					// processInstructions processes all instructions from the channel using parallel workers
 | 
				
			||||||
	var wg sync.WaitGroup
 | 
					func processInstructions(instructions chan *LinkInstruction) int32 {
 | 
				
			||||||
 | 
						var instructionsDone int32 = 0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Collect all instructions first
 | 
				
			||||||
 | 
						var allInstructions []*LinkInstruction
 | 
				
			||||||
	for {
 | 
						for {
 | 
				
			||||||
		instruction, ok := <-instructions
 | 
							instruction, ok := <-instructions
 | 
				
			||||||
		if !ok {
 | 
							if !ok {
 | 
				
			||||||
			log.Printf("No more instructions to process")
 | 
								LogInfo("No more instructions to process")
 | 
				
			||||||
			break
 | 
								break
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		log.Printf("Processing: %s", instruction.String())
 | 
							allInstructions = append(allInstructions, instruction)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Process instructions in parallel using cyutils.WithWorkers
 | 
				
			||||||
 | 
						// Let the library handle worker count - use 4 workers as a reasonable default
 | 
				
			||||||
 | 
						utils.WithWorkers(4, allInstructions, func(workerID int, _ int, instruction *LinkInstruction) {
 | 
				
			||||||
 | 
							LogInfo("Processing: %s", instruction.String())
 | 
				
			||||||
		status := make(chan error)
 | 
							status := make(chan error)
 | 
				
			||||||
		go instruction.RunAsync(status)
 | 
							go instruction.RunAsync(status)
 | 
				
			||||||
		wg.Add(1)
 | 
					 | 
				
			||||||
		err := <-status
 | 
							err := <-status
 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
			log.Printf("Failed parsing instruction %s%s%s due to %s%+v%s", SourceColor, instruction.String(), DefaultColor, ErrorColor, err, DefaultColor)
 | 
								LogError("Failed processing instruction: %v", err)
 | 
				
			||||||
		}
 | 
							} else {
 | 
				
			||||||
			atomic.AddInt32(&instructionsDone, 1)
 | 
								atomic.AddInt32(&instructionsDone, 1)
 | 
				
			||||||
		wg.Done()
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	wg.Wait()
 | 
					 | 
				
			||||||
	log.Println("All done")
 | 
					 | 
				
			||||||
	if instructionsDone == 0 {
 | 
					 | 
				
			||||||
		log.Printf("No instructions were processed")
 | 
					 | 
				
			||||||
		os.Exit(1)
 | 
					 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
						})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return instructionsDone
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func IsPipeInput() bool {
 | 
					func IsPipeInput() bool {
 | 
				
			||||||
@@ -132,55 +176,54 @@ func ReadFromFilesRecursively(input string, output chan *LinkInstruction, status
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	workdir, _ := os.Getwd()
 | 
						workdir, _ := os.Getwd()
 | 
				
			||||||
	input = NormalizePath(input, workdir)
 | 
						input = NormalizePath(input, workdir)
 | 
				
			||||||
	log.Printf("Reading input from files recursively starting in %s%s%s", PathColor, input, DefaultColor)
 | 
						LogInfo("Reading input from files recursively starting in %s", FormatPathValue(input))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	files := make(chan string, 128)
 | 
						files := make(chan string, 128)
 | 
				
			||||||
	recurseStatus := make(chan error)
 | 
						fileStatus := make(chan error)
 | 
				
			||||||
	go GetSyncFilesRecursively(input, files, recurseStatus)
 | 
						go GetSyncFilesRecursively(input, files, fileStatus)
 | 
				
			||||||
	go func() {
 | 
					 | 
				
			||||||
		for {
 | 
					 | 
				
			||||||
			err, ok := <-recurseStatus
 | 
					 | 
				
			||||||
			if !ok {
 | 
					 | 
				
			||||||
				break
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
			if err != nil {
 | 
					 | 
				
			||||||
				log.Printf("Failed to get sync files recursively: %s%+v%s", ErrorColor, err, DefaultColor)
 | 
					 | 
				
			||||||
				status <- err
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
	}()
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
	var wg sync.WaitGroup
 | 
						// Collect all files first
 | 
				
			||||||
 | 
						var syncFiles []string
 | 
				
			||||||
	for {
 | 
						for {
 | 
				
			||||||
		file, ok := <-files
 | 
							file, ok := <-files
 | 
				
			||||||
		if !ok {
 | 
							if !ok {
 | 
				
			||||||
			log.Printf("No more files to process")
 | 
					 | 
				
			||||||
			break
 | 
								break
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		wg.Add(1)
 | 
							syncFiles = append(syncFiles, file)
 | 
				
			||||||
		go func() {
 | 
						}
 | 
				
			||||||
			defer wg.Done()
 | 
					
 | 
				
			||||||
			log.Println(file)
 | 
						// Check for errors from file search
 | 
				
			||||||
			file = NormalizePath(file, workdir)
 | 
						for {
 | 
				
			||||||
			log.Printf("Processing file: %s%s%s", PathColor, file, DefaultColor)
 | 
							err, ok := <-fileStatus
 | 
				
			||||||
 | 
							if !ok {
 | 
				
			||||||
			// This "has" to be done because instructions are resolved in relation to cwd
 | 
								break
 | 
				
			||||||
			fileDir := DirRegex.FindStringSubmatch(file)
 | 
					 | 
				
			||||||
			if fileDir == nil {
 | 
					 | 
				
			||||||
				log.Printf("Failed to extract directory from %s%s%s", SourceColor, file, DefaultColor)
 | 
					 | 
				
			||||||
				return
 | 
					 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
			log.Printf("Changing directory to %s%s%s (for %s%s%s)", PathColor, fileDir[1], DefaultColor, PathColor, file, DefaultColor)
 | 
					 | 
				
			||||||
			err := os.Chdir(fileDir[1])
 | 
					 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
				log.Printf("Failed to change directory to %s%s%s: %s%+v%s", SourceColor, fileDir[1], DefaultColor, ErrorColor, err, DefaultColor)
 | 
								LogError("Failed to get sync files recursively: %v", err)
 | 
				
			||||||
				return
 | 
								status <- err
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			ReadFromFile(file, output, status, false)
 | 
						// Process each file
 | 
				
			||||||
		}()
 | 
						for _, file := range syncFiles {
 | 
				
			||||||
 | 
							file = NormalizePath(file, workdir)
 | 
				
			||||||
 | 
							LogInfo("Processing file: %s", FormatPathValue(file))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// Change to the directory containing the sync file
 | 
				
			||||||
 | 
							fileDir := filepath.Dir(file)
 | 
				
			||||||
 | 
							originalDir, _ := os.Getwd()
 | 
				
			||||||
 | 
							err := os.Chdir(fileDir)
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								LogError("Failed to change directory to %s: %v", FormatSourcePath(fileDir), err)
 | 
				
			||||||
 | 
								continue
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// Read and process the file
 | 
				
			||||||
 | 
							ReadFromFile(file, output, status, false)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// Return to original directory
 | 
				
			||||||
 | 
							os.Chdir(originalDir)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	wg.Wait()
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func ReadFromFile(input string, output chan *LinkInstruction, status chan error, doclose bool) {
 | 
					func ReadFromFile(input string, output chan *LinkInstruction, status chan error, doclose bool) {
 | 
				
			||||||
@@ -190,10 +233,32 @@ func ReadFromFile(input string, output chan *LinkInstruction, status chan error,
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	input = NormalizePath(input, filepath.Dir(input))
 | 
						input = NormalizePath(input, filepath.Dir(input))
 | 
				
			||||||
	log.Printf("Reading input from file: %s%s%s", PathColor, input, DefaultColor)
 | 
						LogInfo("Reading input from file: %s", FormatPathValue(input))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Check if this is a YAML file
 | 
				
			||||||
 | 
						if IsYAMLFile(input) {
 | 
				
			||||||
 | 
							LogInfo("Parsing as YAML file")
 | 
				
			||||||
 | 
							instructions, err := ParseYAMLFileRecursive(input, filepath.Dir(input))
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								LogError("Failed to parse YAML file %s: %v",
 | 
				
			||||||
 | 
									FormatSourcePath(input), err)
 | 
				
			||||||
 | 
								status <- err
 | 
				
			||||||
 | 
								return
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							for _, instruction := range instructions {
 | 
				
			||||||
 | 
								instr := instruction // Create a copy to avoid reference issues
 | 
				
			||||||
 | 
								LogInfo("Read YAML instruction: %s", instr.String())
 | 
				
			||||||
 | 
								output <- &instr
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							return
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Handle CSV format (legacy)
 | 
				
			||||||
	file, err := os.Open(input)
 | 
						file, err := os.Open(input)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		log.Fatalf("Failed to open file %s%s%s: %s%+v%s", SourceColor, input, DefaultColor, ErrorColor, err, DefaultColor)
 | 
							log.Fatalf("Failed to open file %s%s%s: %s%+v%s",
 | 
				
			||||||
 | 
								SourceColor, input, DefaultColor, ErrorColor, err, DefaultColor)
 | 
				
			||||||
		return
 | 
							return
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	defer file.Close()
 | 
						defer file.Close()
 | 
				
			||||||
@@ -203,7 +268,8 @@ func ReadFromFile(input string, output chan *LinkInstruction, status chan error,
 | 
				
			|||||||
		line := scanner.Text()
 | 
							line := scanner.Text()
 | 
				
			||||||
		instruction, err := ParseInstruction(line, filepath.Dir(input))
 | 
							instruction, err := ParseInstruction(line, filepath.Dir(input))
 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
			log.Printf("Error parsing line: %s'%s'%s, error: %s%+v%s", SourceColor, line, DefaultColor, ErrorColor, err, DefaultColor)
 | 
								log.Printf("Error parsing line: %s'%s'%s, error: %s%+v%s",
 | 
				
			||||||
 | 
									SourceColor, line, DefaultColor, ErrorColor, err, DefaultColor)
 | 
				
			||||||
			continue
 | 
								continue
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		log.Printf("Read instruction: %s", instruction.String())
 | 
							log.Printf("Read instruction: %s", instruction.String())
 | 
				
			||||||
@@ -216,11 +282,11 @@ func ReadFromArgs(output chan *LinkInstruction, status chan error) {
 | 
				
			|||||||
	defer close(status)
 | 
						defer close(status)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	workdir, _ := os.Getwd()
 | 
						workdir, _ := os.Getwd()
 | 
				
			||||||
	log.Printf("Reading input from args")
 | 
						LogInfo("Reading input from args")
 | 
				
			||||||
	for _, arg := range flag.Args() {
 | 
						for _, arg := range flag.Args() {
 | 
				
			||||||
		instruction, err := ParseInstruction(arg, workdir)
 | 
							instruction, err := ParseInstruction(arg, workdir)
 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
			log.Printf("Error parsing arg: %s'%s'%s, error: %s%+v%s", SourceColor, arg, DefaultColor, ErrorColor, err, DefaultColor)
 | 
								LogError("Error parsing arg '%s': %v", arg, err)
 | 
				
			||||||
			continue
 | 
								continue
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		output <- &instruction
 | 
							output <- &instruction
 | 
				
			||||||
@@ -232,20 +298,20 @@ func ReadFromStdin(output chan *LinkInstruction, status chan error) {
 | 
				
			|||||||
	defer close(status)
 | 
						defer close(status)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	workdir, _ := os.Getwd()
 | 
						workdir, _ := os.Getwd()
 | 
				
			||||||
	log.Printf("Reading input from stdin")
 | 
						LogInfo("Reading input from stdin")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	scanner := bufio.NewScanner(os.Stdin)
 | 
						scanner := bufio.NewScanner(os.Stdin)
 | 
				
			||||||
	for scanner.Scan() {
 | 
						for scanner.Scan() {
 | 
				
			||||||
		line := scanner.Text()
 | 
							line := scanner.Text()
 | 
				
			||||||
		instruction, err := ParseInstruction(line, workdir)
 | 
							instruction, err := ParseInstruction(line, workdir)
 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
			log.Printf("Error parsing line: %s'%s'%s, error: %s%+v%s", SourceColor, line, DefaultColor, ErrorColor, err, DefaultColor)
 | 
								LogError("Error parsing line '%s': %v", line, err)
 | 
				
			||||||
			continue
 | 
								continue
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		output <- &instruction
 | 
							output <- &instruction
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	if err := scanner.Err(); err != nil {
 | 
						if err := scanner.Err(); err != nil {
 | 
				
			||||||
		log.Fatalf("Error reading from stdin: %s%+v%s", ErrorColor, err, DefaultColor)
 | 
							LogError("Error reading from stdin: %v", err)
 | 
				
			||||||
		status <- err
 | 
							status <- err
 | 
				
			||||||
		return
 | 
							return
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										52
									
								
								release.sh
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								release.sh
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,52 @@
 | 
				
			|||||||
 | 
					#!/bin/bash
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					echo "Figuring out the tag..."
 | 
				
			||||||
 | 
					TAG=$(git describe --tags --exact-match 2>/dev/null || echo "")
 | 
				
			||||||
 | 
					if [ -z "$TAG" ]; then
 | 
				
			||||||
 | 
					  # Get the latest tag
 | 
				
			||||||
 | 
					  LATEST_TAG=$(git describe --tags $(git rev-list --tags --max-count=1))
 | 
				
			||||||
 | 
					  # Increment the patch version
 | 
				
			||||||
 | 
					  IFS='.' read -r -a VERSION_PARTS <<< "$LATEST_TAG"
 | 
				
			||||||
 | 
					  VERSION_PARTS[2]=$((VERSION_PARTS[2]+1))
 | 
				
			||||||
 | 
					  TAG="${VERSION_PARTS[0]}.${VERSION_PARTS[1]}.${VERSION_PARTS[2]}"
 | 
				
			||||||
 | 
					  # Create a new tag
 | 
				
			||||||
 | 
					  git tag $TAG
 | 
				
			||||||
 | 
					  git push origin $TAG
 | 
				
			||||||
 | 
					fi
 | 
				
			||||||
 | 
					echo "Tag: $TAG"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					echo "Building the thing..."
 | 
				
			||||||
 | 
					sh build.sh
 | 
				
			||||||
 | 
					sh install.sh
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					echo "Creating a release..."
 | 
				
			||||||
 | 
					TOKEN="$GITEA_API_KEY"
 | 
				
			||||||
 | 
					GITEA="https://git.site.quack-lab.dev"
 | 
				
			||||||
 | 
					REPO="dave/synclib"
 | 
				
			||||||
 | 
					# Create a release
 | 
				
			||||||
 | 
					RELEASE_RESPONSE=$(curl -s -X POST \
 | 
				
			||||||
 | 
					  -H "Authorization: token $TOKEN" \
 | 
				
			||||||
 | 
					  -H "Accept: application/json" \
 | 
				
			||||||
 | 
					  -H "Content-Type: application/json" \
 | 
				
			||||||
 | 
					  -d '{
 | 
				
			||||||
 | 
					    "tag_name": "'"$TAG"'",
 | 
				
			||||||
 | 
					    "name": "'"$TAG"'",
 | 
				
			||||||
 | 
					    "draft": false,
 | 
				
			||||||
 | 
					    "prerelease": false
 | 
				
			||||||
 | 
					  }' \
 | 
				
			||||||
 | 
					  $GITEA/api/v1/repos/$REPO/releases)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Extract the release ID
 | 
				
			||||||
 | 
					echo $RELEASE_RESPONSE
 | 
				
			||||||
 | 
					RELEASE_ID=$(echo $RELEASE_RESPONSE | awk -F'"id":' '{print $2+0; exit}')
 | 
				
			||||||
 | 
					echo "Release ID: $RELEASE_ID"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					echo "Uploading the things..."
 | 
				
			||||||
 | 
					curl -X POST \
 | 
				
			||||||
 | 
					  -H "Authorization: token $TOKEN" \
 | 
				
			||||||
 | 
					  -F "attachment=@cln.exe" \
 | 
				
			||||||
 | 
					  "$GITEA/api/v1/repos/$REPO/releases/${RELEASE_ID}/assets?name=cln.exe"
 | 
				
			||||||
 | 
					curl -X POST \
 | 
				
			||||||
 | 
					  -H "Authorization: token $TOKEN" \
 | 
				
			||||||
 | 
					  -F "attachment=@cln" \
 | 
				
			||||||
 | 
					  "$GITEA/api/v1/repos/$REPO/releases/${RELEASE_ID}/assets?name=cln"
 | 
				
			||||||
							
								
								
									
										6
									
								
								sync.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								sync.yaml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,6 @@
 | 
				
			|||||||
 | 
					- source: A/**/*
 | 
				
			||||||
 | 
					  target: B
 | 
				
			||||||
 | 
					- source: A/go.mod
 | 
				
			||||||
 | 
					  target: B/go.mod
 | 
				
			||||||
 | 
					- source: A
 | 
				
			||||||
 | 
					  target: B/foo
 | 
				
			||||||
							
								
								
									
										26
									
								
								sync.yaml.example
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								sync.yaml.example
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,26 @@
 | 
				
			|||||||
 | 
					# Example sync.yaml file
 | 
				
			||||||
 | 
					# You can use this format to define symbolic links
 | 
				
			||||||
 | 
					# Each link specifies source, target, and optional flags
 | 
				
			||||||
 | 
					links:
 | 
				
			||||||
 | 
					  - source: ~/Documents/config.ini
 | 
				
			||||||
 | 
					    target: ~/.config/app/config.ini
 | 
				
			||||||
 | 
					    # This will create a symbolic link, overwriting any existing symlink
 | 
				
			||||||
 | 
					    force: true
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  - source: ~/Pictures
 | 
				
			||||||
 | 
					    target: ~/Documents/Pictures
 | 
				
			||||||
 | 
					    # This will create a hard link instead of a symbolic link
 | 
				
			||||||
 | 
					    hard: true
 | 
				
			||||||
 | 
					    force: true
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  - source: ~/Scripts/script.sh
 | 
				
			||||||
 | 
					    target: ~/bin/script.sh
 | 
				
			||||||
 | 
					    # This will delete a non-symlink file at the target location
 | 
				
			||||||
 | 
					    # 'delete: true' implies 'force: true'
 | 
				
			||||||
 | 
					    delete: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Alternative format:
 | 
				
			||||||
 | 
					# Instead of using the 'links' property, you can define an array directly:
 | 
				
			||||||
 | 
					# - source: ~/Documents/config.ini
 | 
				
			||||||
 | 
					#   target: ~/.config/app/config.ini
 | 
				
			||||||
 | 
					#   force: true 
 | 
				
			||||||
							
								
								
									
										86
									
								
								util.go
									
									
									
									
									
								
							
							
						
						
									
										86
									
								
								util.go
									
									
									
									
									
								
							@@ -2,13 +2,11 @@ package main
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
import (
 | 
					import (
 | 
				
			||||||
	"fmt"
 | 
						"fmt"
 | 
				
			||||||
	"log"
 | 
					 | 
				
			||||||
	"os"
 | 
						"os"
 | 
				
			||||||
	"path/filepath"
 | 
						"path/filepath"
 | 
				
			||||||
	"strings"
 | 
						"strings"
 | 
				
			||||||
	"sync"
 | 
					
 | 
				
			||||||
	"sync/atomic"
 | 
						"github.com/bmatcuk/doublestar/v4"
 | 
				
			||||||
	"time"
 | 
					 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func IsSymlink(path string) (bool, error) {
 | 
					func IsSymlink(path string) (bool, error) {
 | 
				
			||||||
@@ -33,12 +31,12 @@ func NormalizePath(input, workdir string) string {
 | 
				
			|||||||
	input = strings.ReplaceAll(input, "\"", "")
 | 
						input = strings.ReplaceAll(input, "\"", "")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if !filepath.IsAbs(input) {
 | 
						if !filepath.IsAbs(input) {
 | 
				
			||||||
		log.Printf("Input '%s' is not absolute, prepending work dir '%s'", input, workdir)
 | 
							LogInfo("Input '%s' is not absolute, prepending work dir '%s'", input, workdir)
 | 
				
			||||||
		var err error
 | 
							var err error
 | 
				
			||||||
		input = filepath.Join(workdir, input)
 | 
							input = filepath.Join(workdir, input)
 | 
				
			||||||
		input, err = filepath.Abs(input)
 | 
							input, err = filepath.Abs(input)
 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
			log.Printf("Failed to get absolute path for %s%s%s: %s%+v%s", SourceColor, input, DefaultColor, ErrorColor, err, DefaultColor)
 | 
								LogError("Failed to get absolute path for %s: %v", FormatSourcePath(input), err)
 | 
				
			||||||
			return input
 | 
								return input
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -77,76 +75,24 @@ func GetSyncFilesRecursively(input string, output chan string, status chan error
 | 
				
			|||||||
	defer close(output)
 | 
						defer close(output)
 | 
				
			||||||
	defer close(status)
 | 
						defer close(status)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	var filesProcessed int32
 | 
						workdir, _ := os.Getwd()
 | 
				
			||||||
	var foldersProcessed int32
 | 
						input = NormalizePath(input, workdir)
 | 
				
			||||||
	progressTicker := time.NewTicker(200 * time.Millisecond)
 | 
						LogInfo("Searching for sync files recursively starting in %s", FormatPathValue(input))
 | 
				
			||||||
	defer progressTicker.Stop()
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
	var wg sync.WaitGroup
 | 
						// Use doublestar to find all sync.yml and sync.yaml files recursively
 | 
				
			||||||
	var initial sync.Once
 | 
						pattern := "**/sync.y*ml"
 | 
				
			||||||
	var done bool
 | 
						files, err := doublestar.Glob(os.DirFS(input), pattern)
 | 
				
			||||||
	wg.Add(1)
 | 
					 | 
				
			||||||
	directories := make(chan string, 100000)
 | 
					 | 
				
			||||||
	workerPool := make(chan struct{}, 4000)
 | 
					 | 
				
			||||||
	directories <- input
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	go func() {
 | 
					 | 
				
			||||||
		for {
 | 
					 | 
				
			||||||
			fmt.Printf("\rFiles processed: %d; Folders processed: %d; Workers: %d; Directory Stack Size: %d;", atomic.LoadInt32((&filesProcessed)), atomic.LoadInt32(&foldersProcessed), len(workerPool), len(directories))
 | 
					 | 
				
			||||||
			<-progressTicker.C
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
	}()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	// log.Printf("%+v", len(workerPool))
 | 
					 | 
				
			||||||
	go func() {
 | 
					 | 
				
			||||||
		for directory := range directories {
 | 
					 | 
				
			||||||
			workerPool <- struct{}{}
 | 
					 | 
				
			||||||
			wg.Add(1)
 | 
					 | 
				
			||||||
			go func(directory string) {
 | 
					 | 
				
			||||||
				atomic.AddInt32(&foldersProcessed, 1)
 | 
					 | 
				
			||||||
				defer wg.Done()
 | 
					 | 
				
			||||||
				defer func() { <-workerPool }()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
				files, err := os.ReadDir(directory)
 | 
					 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
					log.Printf("Error reading directory %s: %+v", directory, err)
 | 
							LogError("Failed to search for pattern %s: %v", pattern, err)
 | 
				
			||||||
 | 
							status <- err
 | 
				
			||||||
		return
 | 
							return
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	for _, file := range files {
 | 
						for _, file := range files {
 | 
				
			||||||
					// log.Printf("Processing file %s", file.Name())
 | 
							fullPath := filepath.Join(input, file)
 | 
				
			||||||
					if file.IsDir() {
 | 
							LogInfo("Found sync file: %s", FormatPathValue(fullPath))
 | 
				
			||||||
						directories <- filepath.Join(directory, file.Name())
 | 
							output <- fullPath
 | 
				
			||||||
					} else {
 | 
					 | 
				
			||||||
						// log.Println(file.Name(), DirRegex.MatchString(file.Name()))
 | 
					 | 
				
			||||||
						if FileRegex.MatchString(file.Name()) {
 | 
					 | 
				
			||||||
							// log.Printf("Writing")
 | 
					 | 
				
			||||||
							output <- filepath.Join(directory, file.Name())
 | 
					 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
						atomic.AddInt32(&filesProcessed, 1)
 | 
					 | 
				
			||||||
					}
 | 
					 | 
				
			||||||
				}
 | 
					 | 
				
			||||||
				// log.Printf("Done reading directory %s", directory)
 | 
					 | 
				
			||||||
				done = len(directories) == 0
 | 
					 | 
				
			||||||
				if done {
 | 
					 | 
				
			||||||
					initial.Do(func() {
 | 
					 | 
				
			||||||
						wg.Done()
 | 
					 | 
				
			||||||
					})
 | 
					 | 
				
			||||||
				}
 | 
					 | 
				
			||||||
			}(directory)
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
	}()
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// This actually does not go through ALL files sadly...
 | 
						LogInfo("Completed recursive search for sync files")
 | 
				
			||||||
	// It so happens (very often) that we manage to quit between one iteration ending
 | 
					 | 
				
			||||||
	// And another beginning
 | 
					 | 
				
			||||||
	// In such a state workgroup is decreased and, before it has a chance to increase, we are done
 | 
					 | 
				
			||||||
	// What I should do here is only terminate if directories is empty
 | 
					 | 
				
			||||||
	// ...but how do I do that?
 | 
					 | 
				
			||||||
	// I might be wrong... Fuck knows...
 | 
					 | 
				
			||||||
	// It also sometimes happens that wg.Wait triggers after wg.Done on line 97 but before the next (what would be!) wg.Add on line 94
 | 
					 | 
				
			||||||
	// This happens much more often with a small number of workers
 | 
					 | 
				
			||||||
	// Such is the nature of race conditions...
 | 
					 | 
				
			||||||
	wg.Wait()
 | 
					 | 
				
			||||||
	log.Printf("Files processed: %d; Folders processed: %d", filesProcessed, foldersProcessed)
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user