Compare commits
	
		
			28 Commits
		
	
	
		
			1c23ad0cfd
			...
			v1.4.0
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | 
							
								
								
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -1,3 +1,7 @@
 | 
			
		||||
*.exe
 | 
			
		||||
*.exe
 | 
			
		||||
cln
 | 
			
		||||
cln.log
 | 
			
		||||
.qodo
 | 
			
		||||
*.log
 | 
			
		||||
*.out
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										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
 | 
			
		||||
 | 
			
		||||
## 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:
 | 
			
		||||
`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
 | 
			
		||||
  - `echo "this,that" | sync`
 | 
			
		||||
- Run arguments
 | 
			
		||||
  - `sync this,that foo,bar "foo 2","C:/bar"`
 | 
			
		||||
- Files
 | 
			
		||||
	- `sync -f <file>`
 | 
			
		||||
	- Where the file contains instructions, one instruction per line
 | 
			
		||||
  - `sync -f <file>` (CSV format)
 | 
			
		||||
  - `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
 | 
			
		||||
  - `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
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										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 .
 | 
			
		||||
							
								
								
									
										12
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								go.mod
									
									
									
									
									
								
							@@ -1,3 +1,15 @@
 | 
			
		||||
module cln
 | 
			
		||||
 | 
			
		||||
go 1.21.7
 | 
			
		||||
 | 
			
		||||
require gopkg.in/yaml.v3 v3.0.1
 | 
			
		||||
 | 
			
		||||
require (
 | 
			
		||||
	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
 | 
			
		||||
)
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										12
									
								
								go.sum
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								go.sum
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,12 @@
 | 
			
		||||
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=
 | 
			
		||||
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=
 | 
			
		||||
							
								
								
									
										374
									
								
								instruction.go
									
									
									
									
									
								
							
							
						
						
									
										374
									
								
								instruction.go
									
									
									
									
									
								
							@@ -2,20 +2,25 @@ package main
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"log"
 | 
			
		||||
	"os"
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
	"regexp"
 | 
			
		||||
	"strconv"
 | 
			
		||||
	"strings"
 | 
			
		||||
 | 
			
		||||
	"github.com/bmatcuk/doublestar/v4"
 | 
			
		||||
	"gopkg.in/yaml.v3"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type LinkInstruction struct {
 | 
			
		||||
	Source string
 | 
			
		||||
	Target string
 | 
			
		||||
	Force  bool
 | 
			
		||||
	Hard   bool
 | 
			
		||||
	Delete bool
 | 
			
		||||
	Source string `yaml:"source"`
 | 
			
		||||
	Target string `yaml:"target"`
 | 
			
		||||
	Force  bool   `yaml:"force,omitempty"`
 | 
			
		||||
	Hard   bool   `yaml:"hard,omitempty"`
 | 
			
		||||
	Delete bool   `yaml:"delete,omitempty"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type YAMLConfig struct {
 | 
			
		||||
	Links []LinkInstruction `yaml:"links"`
 | 
			
		||||
	From  []string          `yaml:"from,omitempty"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (instruction *LinkInstruction) Tidy() {
 | 
			
		||||
@@ -29,11 +34,60 @@ func (instruction *LinkInstruction) Tidy() {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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) {
 | 
			
		||||
	line = strings.TrimSpace(line)
 | 
			
		||||
	if strings.HasPrefix(line, "#") {
 | 
			
		||||
		return LinkInstruction{}, fmt.Errorf("comment line")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	parts := strings.Split(line, deliminer)
 | 
			
		||||
	instruction := LinkInstruction{}
 | 
			
		||||
 | 
			
		||||
@@ -43,19 +97,48 @@ func ParseInstruction(line, workdir string) (LinkInstruction, error) {
 | 
			
		||||
 | 
			
		||||
	instruction.Source = strings.TrimSpace(parts[0])
 | 
			
		||||
	instruction.Target = strings.TrimSpace(parts[1])
 | 
			
		||||
	instruction.Force = false
 | 
			
		||||
	if len(parts) > 2 {
 | 
			
		||||
		res, _ := regexp.MatchString(`^(?i)\s*T|TRUE\s*$`, parts[2])
 | 
			
		||||
		instruction.Force = res
 | 
			
		||||
 | 
			
		||||
	for i := 2; i < len(parts); i++ {
 | 
			
		||||
		flagPart := strings.TrimSpace(parts[i])
 | 
			
		||||
 | 
			
		||||
		// 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()
 | 
			
		||||
@@ -68,15 +151,30 @@ func ParseInstruction(line, workdir string) (LinkInstruction, error) {
 | 
			
		||||
	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)) {
 | 
			
		||||
	defer close(status)
 | 
			
		||||
	if undo {
 | 
			
		||||
		instruction.Undo()
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	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
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	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
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@@ -84,47 +182,54 @@ func (instruction *LinkInstruction) RunAsync(status chan (error)) {
 | 
			
		||||
		if instruction.Force {
 | 
			
		||||
			isSymlink, err := IsSymlink(instruction.Target)
 | 
			
		||||
			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
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if instruction.Hard {
 | 
			
		||||
				info, err := os.Stat(instruction.Target)
 | 
			
		||||
				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
 | 
			
		||||
				}
 | 
			
		||||
				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)
 | 
			
		||||
					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
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			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)
 | 
			
		||||
				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
 | 
			
		||||
				}
 | 
			
		||||
			} else {
 | 
			
		||||
				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
 | 
			
		||||
				}
 | 
			
		||||
				log.Printf("%sDeleting (!!!)%s %s%s%s", ImportantColor, DefaultColor, TargetColor, instruction.Target, DefaultColor)
 | 
			
		||||
				LogImportant("Deleting (!!!) %s", instruction.Target)
 | 
			
		||||
				err = os.RemoveAll(instruction.Target)
 | 
			
		||||
				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
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		} 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
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
@@ -133,7 +238,8 @@ func (instruction *LinkInstruction) RunAsync(status chan (error)) {
 | 
			
		||||
	if _, err := os.Stat(targetDir); os.IsNotExist(err) {
 | 
			
		||||
		err = os.MkdirAll(targetDir, 0755)
 | 
			
		||||
		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
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
@@ -145,10 +251,208 @@ func (instruction *LinkInstruction) RunAsync(status chan (error)) {
 | 
			
		||||
		err = os.Symlink(instruction.Source, instruction.Target)
 | 
			
		||||
	}
 | 
			
		||||
	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
 | 
			
		||||
	}
 | 
			
		||||
	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
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// First try to parse as a YAMLConfig with links and from fields
 | 
			
		||||
	var config YAMLConfig
 | 
			
		||||
	err = yaml.Unmarshal(data, &config)
 | 
			
		||||
	LogInfo("First parsing attempt: err=%v, links=%d, from=%d", err, len(config.Links), len(config.From))
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		// If that fails, try parsing 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)
 | 
			
		||||
		}
 | 
			
		||||
		// Filter out invalid instructions (empty source)
 | 
			
		||||
		validInstructions := []LinkInstruction{}
 | 
			
		||||
		for _, instr := range instructions {
 | 
			
		||||
			if instr.Source != "" {
 | 
			
		||||
				validInstructions = append(validInstructions, instr)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		config.Links = validInstructions
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	expanded := []LinkInstruction{}
 | 
			
		||||
	for _, link := range config.Links {
 | 
			
		||||
		LogSource("Expanding pattern source %s in YAML file %s", link.Source, filename)
 | 
			
		||||
		newlinks, err := ExpandPattern(link.Source, workdir, link.Target)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return nil, fmt.Errorf("error expanding pattern: %w", err)
 | 
			
		||||
		}
 | 
			
		||||
		// "Clone" the original link instruction for each expanded link
 | 
			
		||||
		for i := range newlinks {
 | 
			
		||||
			newlinks[i].Delete = link.Delete
 | 
			
		||||
			newlinks[i].Hard = link.Hard
 | 
			
		||||
			newlinks[i].Force = link.Force
 | 
			
		||||
		}
 | 
			
		||||
		LogInfo("Expanded pattern %s in YAML file %s to %d links",
 | 
			
		||||
			FormatSourcePath(link.Source), FormatSourcePath(filename), len(newlinks))
 | 
			
		||||
		expanded = append(expanded, newlinks...)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for i := range expanded {
 | 
			
		||||
		link := &expanded[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 expanded, 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
 | 
			
		||||
	instructions, err := ParseYAMLFile(filename, workdir)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Read the file to check for "From" references
 | 
			
		||||
	data, err := os.ReadFile(filename)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("error reading YAML file: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var config YAMLConfig
 | 
			
		||||
	err = yaml.Unmarshal(data, &config)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		// If parsing as YAMLConfig fails, there are no "From" references to process
 | 
			
		||||
		return instructions, nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Process "From" references
 | 
			
		||||
	for _, fromFile := range config.From {
 | 
			
		||||
		// Convert relative paths to absolute paths based on the current file's directory
 | 
			
		||||
		fromPath := fromFile
 | 
			
		||||
		if !filepath.IsAbs(fromPath) {
 | 
			
		||||
			currentDir := filepath.Dir(filename)
 | 
			
		||||
			fromPath = filepath.Join(currentDir, fromPath)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Normalize the path
 | 
			
		||||
		fromPath = filepath.Clean(fromPath)
 | 
			
		||||
 | 
			
		||||
		// Recursively parse the referenced file
 | 
			
		||||
		// Use the directory of the referenced file as the workdir for pattern expansion
 | 
			
		||||
		fromWorkdir := filepath.Dir(fromPath)
 | 
			
		||||
		fromInstructions, err := parseYAMLFileRecursive(fromPath, fromWorkdir, visited)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return nil, fmt.Errorf("error parsing referenced file %s: %w", fromFile, err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Append the instructions from the referenced file
 | 
			
		||||
		instructions = append(instructions, fromInstructions...)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return instructions, 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"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										2343
									
								
								instruction_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2343
									
								
								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...)
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										237
									
								
								main.go
									
									
									
									
									
								
							
							
						
						
									
										237
									
								
								main.go
									
									
									
									
									
								
							@@ -7,7 +7,6 @@ import (
 | 
			
		||||
	"log"
 | 
			
		||||
	"os"
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
	"regexp"
 | 
			
		||||
	"sync"
 | 
			
		||||
	"sync/atomic"
 | 
			
		||||
)
 | 
			
		||||
@@ -15,26 +14,47 @@ import (
 | 
			
		||||
const deliminer = ","
 | 
			
		||||
const SourceColor = Purple
 | 
			
		||||
const TargetColor = Yellow
 | 
			
		||||
const ErrorColor = URed
 | 
			
		||||
const ErrorColor = Red
 | 
			
		||||
const ImportantColor = BRed
 | 
			
		||||
const DefaultColor = White
 | 
			
		||||
const DefaultColor = Reset
 | 
			
		||||
const PathColor = Green
 | 
			
		||||
 | 
			
		||||
var DirRegex, _ = regexp.Compile(`^(.+?)[/\\]sync$`)
 | 
			
		||||
var FileRegex, _ = regexp.Compile(`^sync$`)
 | 
			
		||||
var programName = os.Args[0]
 | 
			
		||||
var undo = false
 | 
			
		||||
 | 
			
		||||
func main() {
 | 
			
		||||
	recurse := flag.String("r", "", "recurse into directories")
 | 
			
		||||
	file := flag.String("f", "", "file to read instructions from")
 | 
			
		||||
	debug := flag.Bool("d", false, "debug")
 | 
			
		||||
	undoF := flag.Bool("u", false, "undo")
 | 
			
		||||
	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)
 | 
			
		||||
		logFile, err := os.Create("main.log")
 | 
			
		||||
		logFile, err := os.Create(programName + ".log")
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			log.Printf("Error creating log file: %v", err)
 | 
			
		||||
			LogError("Error creating log file: %v", err)
 | 
			
		||||
			os.Exit(1)
 | 
			
		||||
		}
 | 
			
		||||
		logger := io.MultiWriter(os.Stdout, logFile)
 | 
			
		||||
@@ -42,80 +62,97 @@ func main() {
 | 
			
		||||
	} else {
 | 
			
		||||
		log.SetFlags(log.Lmicroseconds)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
	instructions := make(chan *LinkInstruction, 1000)
 | 
			
		||||
	status := make(chan error)
 | 
			
		||||
 | 
			
		||||
// startInputSource determines and starts the appropriate input source
 | 
			
		||||
func startInputSource(recurse, file string, instructions chan *LinkInstruction, status chan error) {
 | 
			
		||||
	// Check input sources in priority order
 | 
			
		||||
	switch {
 | 
			
		||||
	case *recurse != "":
 | 
			
		||||
		log.Printf("Recurse: %s", *recurse)
 | 
			
		||||
		go ReadFromFilesRecursively(*recurse, instructions, status)
 | 
			
		||||
	case recurse != "":
 | 
			
		||||
		LogInfo("Recurse: %s", recurse)
 | 
			
		||||
		go ReadFromFilesRecursively(recurse, instructions, status)
 | 
			
		||||
 | 
			
		||||
	case *file != "":
 | 
			
		||||
		log.Printf("File: %s", *file)
 | 
			
		||||
		go ReadFromFile(*file, instructions, status, true)
 | 
			
		||||
	case file != "":
 | 
			
		||||
		LogInfo("File: %s", file)
 | 
			
		||||
		go ReadFromFile(file, instructions, status, true)
 | 
			
		||||
 | 
			
		||||
	case len(flag.Args()) > 0:
 | 
			
		||||
		log.Printf("Reading from command line arguments")
 | 
			
		||||
		LogInfo("Reading from command line arguments")
 | 
			
		||||
		go ReadFromArgs(instructions, status)
 | 
			
		||||
 | 
			
		||||
	case IsPipeInput():
 | 
			
		||||
		log.Printf("Reading from stdin pipe")
 | 
			
		||||
		go ReadFromStdin(instructions, status)
 | 
			
		||||
	// case IsPipeInput():
 | 
			
		||||
	// 	LogInfo("Reading from stdin pipe")
 | 
			
		||||
	// 	go ReadFromStdin(instructions, status)
 | 
			
		||||
 | 
			
		||||
	default:
 | 
			
		||||
		if _, err := os.Stat("sync"); err == nil {
 | 
			
		||||
			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)
 | 
			
		||||
		}
 | 
			
		||||
		startDefaultInputSource(instructions, status)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
	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 {
 | 
			
		||||
		err, ok := <-status
 | 
			
		||||
		if !ok {
 | 
			
		||||
			break
 | 
			
		||||
		}
 | 
			
		||||
		if err != nil {
 | 
			
		||||
				log.Println(err)
 | 
			
		||||
			LogError("%v", err)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	}()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
	var instructionsDone int32
 | 
			
		||||
// processInstructions processes all instructions from the channel
 | 
			
		||||
func processInstructions(instructions chan *LinkInstruction) int32 {
 | 
			
		||||
	var instructionsDone int32 = 0
 | 
			
		||||
	var wg sync.WaitGroup
 | 
			
		||||
	for {
 | 
			
		||||
		instruction, ok := <-instructions
 | 
			
		||||
		if !ok {
 | 
			
		||||
			log.Printf("No more instructions to process")
 | 
			
		||||
			LogInfo("No more instructions to process")
 | 
			
		||||
			break
 | 
			
		||||
		}
 | 
			
		||||
		log.Printf("Processing: %s", instruction.String())
 | 
			
		||||
		LogInfo("Processing: %s", instruction.String())
 | 
			
		||||
		status := make(chan error)
 | 
			
		||||
		go instruction.RunAsync(status)
 | 
			
		||||
		wg.Add(1)
 | 
			
		||||
		err := <-status
 | 
			
		||||
		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)
 | 
			
		||||
		}
 | 
			
		||||
		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 {
 | 
			
		||||
@@ -132,55 +169,54 @@ func ReadFromFilesRecursively(input string, output chan *LinkInstruction, status
 | 
			
		||||
 | 
			
		||||
	workdir, _ := os.Getwd()
 | 
			
		||||
	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)
 | 
			
		||||
	recurseStatus := make(chan error)
 | 
			
		||||
	go GetSyncFilesRecursively(input, files, recurseStatus)
 | 
			
		||||
	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
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}()
 | 
			
		||||
	fileStatus := make(chan error)
 | 
			
		||||
	go GetSyncFilesRecursively(input, files, fileStatus)
 | 
			
		||||
 | 
			
		||||
	var wg sync.WaitGroup
 | 
			
		||||
	// Collect all files first
 | 
			
		||||
	var syncFiles []string
 | 
			
		||||
	for {
 | 
			
		||||
		file, ok := <-files
 | 
			
		||||
		if !ok {
 | 
			
		||||
			log.Printf("No more files to process")
 | 
			
		||||
			break
 | 
			
		||||
		}
 | 
			
		||||
		wg.Add(1)
 | 
			
		||||
		go func() {
 | 
			
		||||
			defer wg.Done()
 | 
			
		||||
			log.Println(file)
 | 
			
		||||
			file = NormalizePath(file, workdir)
 | 
			
		||||
			log.Printf("Processing file: %s%s%s", PathColor, file, DefaultColor)
 | 
			
		||||
 | 
			
		||||
			// This "has" to be done because instructions are resolved in relation to cwd
 | 
			
		||||
			fileDir := DirRegex.FindStringSubmatch(file)
 | 
			
		||||
			if fileDir == nil {
 | 
			
		||||
				log.Printf("Failed to extract directory from %s%s%s", SourceColor, file, DefaultColor)
 | 
			
		||||
				return
 | 
			
		||||
		syncFiles = append(syncFiles, file)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Check for errors from file search
 | 
			
		||||
	for {
 | 
			
		||||
		err, ok := <-fileStatus
 | 
			
		||||
		if !ok {
 | 
			
		||||
			break
 | 
			
		||||
		}
 | 
			
		||||
			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 {
 | 
			
		||||
				log.Printf("Failed to change directory to %s%s%s: %s%+v%s", SourceColor, fileDir[1], DefaultColor, ErrorColor, err, DefaultColor)
 | 
			
		||||
				return
 | 
			
		||||
			LogError("Failed to get sync files recursively: %v", err)
 | 
			
		||||
			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) {
 | 
			
		||||
@@ -190,10 +226,32 @@ func ReadFromFile(input string, output chan *LinkInstruction, status chan error,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	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)
 | 
			
		||||
	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
 | 
			
		||||
	}
 | 
			
		||||
	defer file.Close()
 | 
			
		||||
@@ -203,7 +261,8 @@ func ReadFromFile(input string, output chan *LinkInstruction, status chan error,
 | 
			
		||||
		line := scanner.Text()
 | 
			
		||||
		instruction, err := ParseInstruction(line, filepath.Dir(input))
 | 
			
		||||
		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
 | 
			
		||||
		}
 | 
			
		||||
		log.Printf("Read instruction: %s", instruction.String())
 | 
			
		||||
@@ -216,11 +275,11 @@ func ReadFromArgs(output chan *LinkInstruction, status chan error) {
 | 
			
		||||
	defer close(status)
 | 
			
		||||
 | 
			
		||||
	workdir, _ := os.Getwd()
 | 
			
		||||
	log.Printf("Reading input from args")
 | 
			
		||||
	LogInfo("Reading input from args")
 | 
			
		||||
	for _, arg := range flag.Args() {
 | 
			
		||||
		instruction, err := ParseInstruction(arg, workdir)
 | 
			
		||||
		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
 | 
			
		||||
		}
 | 
			
		||||
		output <- &instruction
 | 
			
		||||
@@ -232,20 +291,20 @@ func ReadFromStdin(output chan *LinkInstruction, status chan error) {
 | 
			
		||||
	defer close(status)
 | 
			
		||||
 | 
			
		||||
	workdir, _ := os.Getwd()
 | 
			
		||||
	log.Printf("Reading input from stdin")
 | 
			
		||||
	LogInfo("Reading input from stdin")
 | 
			
		||||
 | 
			
		||||
	scanner := bufio.NewScanner(os.Stdin)
 | 
			
		||||
	for scanner.Scan() {
 | 
			
		||||
		line := scanner.Text()
 | 
			
		||||
		instruction, err := ParseInstruction(line, workdir)
 | 
			
		||||
		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
 | 
			
		||||
		}
 | 
			
		||||
		output <- &instruction
 | 
			
		||||
	}
 | 
			
		||||
	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
 | 
			
		||||
		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 (
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"log"
 | 
			
		||||
	"os"
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"sync"
 | 
			
		||||
	"sync/atomic"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"github.com/bmatcuk/doublestar/v4"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func IsSymlink(path string) (bool, error) {
 | 
			
		||||
@@ -33,12 +31,12 @@ func NormalizePath(input, workdir string) string {
 | 
			
		||||
	input = strings.ReplaceAll(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
 | 
			
		||||
		input = filepath.Join(workdir, input)
 | 
			
		||||
		input, err = filepath.Abs(input)
 | 
			
		||||
		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
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
@@ -77,76 +75,24 @@ func GetSyncFilesRecursively(input string, output chan string, status chan error
 | 
			
		||||
	defer close(output)
 | 
			
		||||
	defer close(status)
 | 
			
		||||
 | 
			
		||||
	var filesProcessed int32
 | 
			
		||||
	var foldersProcessed int32
 | 
			
		||||
	progressTicker := time.NewTicker(200 * time.Millisecond)
 | 
			
		||||
	defer progressTicker.Stop()
 | 
			
		||||
	workdir, _ := os.Getwd()
 | 
			
		||||
	input = NormalizePath(input, workdir)
 | 
			
		||||
	LogInfo("Searching for sync files recursively starting in %s", FormatPathValue(input))
 | 
			
		||||
 | 
			
		||||
	var wg sync.WaitGroup
 | 
			
		||||
	var initial sync.Once
 | 
			
		||||
	var done bool
 | 
			
		||||
	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)
 | 
			
		||||
	// Use doublestar to find all sync.yml and sync.yaml files recursively
 | 
			
		||||
	pattern := "**/sync.y*ml"
 | 
			
		||||
	files, err := doublestar.Glob(os.DirFS(input), pattern)
 | 
			
		||||
	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
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, file := range files {
 | 
			
		||||
					// log.Printf("Processing file %s", file.Name())
 | 
			
		||||
					if file.IsDir() {
 | 
			
		||||
						directories <- filepath.Join(directory, file.Name())
 | 
			
		||||
					} else {
 | 
			
		||||
						// log.Println(file.Name(), DirRegex.MatchString(file.Name()))
 | 
			
		||||
						if FileRegex.MatchString(file.Name()) {
 | 
			
		||||
							// log.Printf("Writing")
 | 
			
		||||
							output <- filepath.Join(directory, file.Name())
 | 
			
		||||
		fullPath := filepath.Join(input, file)
 | 
			
		||||
		LogInfo("Found sync file: %s", FormatPathValue(fullPath))
 | 
			
		||||
		output <- fullPath
 | 
			
		||||
	}
 | 
			
		||||
						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...
 | 
			
		||||
	// 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)
 | 
			
		||||
	LogInfo("Completed recursive search for sync files")
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user