1 Commits
v1.5.2 ... dev

Author SHA1 Message Date
195c4ab3ad Add insane ramblings 2024-07-01 20:08:23 +02:00
18 changed files with 515 additions and 5846 deletions

10
.gitignore vendored
View File

@@ -1,8 +1,2 @@
*.exe
*.exe main.exe
cln
cln.log
.qodo
*.log
*.out
test_temp

16
.vscode/launch.json vendored
View File

@@ -1,16 +0,0 @@
{
// 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"
}
]
}

View File

@@ -1,89 +0,0 @@
# synclib
A small Go tool for creating symbolic links
Created out of infuriating difficulty of creating symbolic links on windows
## Instruction Formats
The tool supports two formats for defining symbolic links:
### 1. CSV Format (Legacy)
Simple comma-separated values with the format: `<source>,<destination>[,force][,hard][,delete]`
For example:
```
source_path,target_path
source_path,target_path,true
source_path,target_path,true,true
source_path,target_path,true,true,true
```
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>` (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", "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
I have a lot of folders (documents, projects, configurations) backed up via Seafile and to have the software using those folders find them at their usual location I'm creating soft symbolic links from the seafile drive to their original location
It would be problematic to have to redo all (or some part) of these symlinks when reinstalling the OS or having something somewhere explode (say software uninstalled) so I have all the instructions in sync files in individual folders in the seafile drive
Which means I can easily back up my configuration and `sync -r ~/Seafile` to symlink it where it belongs

View File

@@ -1,2 +0,0 @@
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o cln.exe .
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o cln .

100
colors.go
View File

@@ -1,100 +0,0 @@
package main
import (
"fmt"
"math/rand/v2"
)
const (
// Reset
Reset = "\033[0m" // Text Reset
// Regular Colors
Black = "\033[0;30m" // Black
Red = "\033[0;31m" // Red
Green = "\033[0;32m" // Green
Yellow = "\033[0;33m" // Yellow
Blue = "\033[0;34m" // Blue
Purple = "\033[0;35m" // Purple
Cyan = "\033[0;36m" // Cyan
White = "\033[0;37m" // White
// Bold
BBlack = "\033[1;30m" // Black
BRed = "\033[1;31m" // Red
BGreen = "\033[1;32m" // Green
BYellow = "\033[1;33m" // Yellow
BBlue = "\033[1;34m" // Blue
BPurple = "\033[1;35m" // Purple
BCyan = "\033[1;36m" // Cyan
BWhite = "\033[1;37m" // White
// Underline
UBlack = "\033[4;30m" // Black
URed = "\033[4;31m" // Red
UGreen = "\033[4;32m" // Green
UYellow = "\033[4;33m" // Yellow
UBlue = "\033[4;34m" // Blue
UPurple = "\033[4;35m" // Purple
UCyan = "\033[4;36m" // Cyan
UWhite = "\033[4;37m" // White
// Background
On_Black = "\033[40m" // Black
On_Red = "\033[41m" // Red
On_Green = "\033[42m" // Green
On_Yellow = "\033[43m" // Yellow
On_Blue = "\033[44m" // Blue
On_Purple = "\033[45m" // Purple
On_Cyan = "\033[46m" // Cyan
On_White = "\033[47m" // White
// High Intensty
IBlack = "\033[0;90m" // Black
IRed = "\033[0;91m" // Red
IGreen = "\033[0;92m" // Green
IYellow = "\033[0;93m" // Yellow
IBlue = "\033[0;94m" // Blue
IPurple = "\033[0;95m" // Purple
ICyan = "\033[0;96m" // Cyan
IWhite = "\033[0;97m" // White
// Bold High Intensty
BIBlack = "\033[1;90m" // Black
BIRed = "\033[1;91m" // Red
BIGreen = "\033[1;92m" // Green
BIYellow = "\033[1;93m" // Yellow
BIBlue = "\033[1;94m" // Blue
BIPurple = "\033[1;95m" // Purple
BICyan = "\033[1;96m" // Cyan
BIWhite = "\033[1;97m" // White
// High Intensty backgrounds
On_IBlack = "\033[0;100m" // Black
On_IRed = "\033[0;101m" // Red
On_IGreen = "\033[0;102m" // Green
On_IYellow = "\033[0;103m" // Yellow
On_IBlue = "\033[0;104m" // Blue
On_IPurple = "\033[10;95m" // Purple
On_ICyan = "\033[0;106m" // Cyan
On_IWhite = "\033[0;107m" // White
)
// The acceptable range is [16, 231] but here we remove some very dark colors
// That make text unreadable on a dark terminal
// See https://www.hackitu.de/termcolor256/
// Wait - why are we hardcoding this? lol do for loops not exist in our universe?
var colors = []int{22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 57, 62, 63, 64, 65, 67, 68, 69, 70, 71, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 148, 149, 150, 151, 152, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, 182, 184, 185, 186, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, 226, 227, 228, 229, 230}
var colorsIndex int = -1
var shuffled bool
func GenerateRandomAnsiColor() string {
if !shuffled {
rand.Shuffle(len(colors), func(i int, j int) {
colors[i], colors[j] = colors[j], colors[i]
})
shuffled = true
}
colorsIndex++
return fmt.Sprintf("\033[1;4;38;5;%dm", colors[colorsIndex%len(colors)])
}

View File

@@ -1,2 +1 @@
GOOS=windows GOARCH=amd64 go build -o main.exe main && cp main.exe "/c/Program Files/Git/usr/bin/cln.exe" go build main && cp main.exe "/c/Program Files/Git/usr/bin/cln.exe"
GOOS=linux GOARCH=amd64 go build -o main_linux main

18
go.mod
View File

@@ -1,17 +1,3 @@
module cln module main
go 1.23.6 go 1.21.7
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
View File

@@ -1,16 +0,0 @@
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=

View File

@@ -1,49 +0,0 @@
package main
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
)
func TestHomeDirectoryPatternExpansion(t *testing.T) {
testDir := getTestSubDir(t)
// Ensure we're working within the project directory
ensureInProjectDir(t, testDir)
// Change to test directory
originalDir, _ := os.Getwd()
defer os.Chdir(originalDir)
os.Chdir(testDir)
// Get the actual home directory
homeDir, err := os.UserHomeDir()
assert.NoError(t, err)
// Create a test directory in the home folder
testHomeDir := filepath.Join(homeDir, "synclib_test")
err = os.MkdirAll(testHomeDir, 0755)
assert.NoError(t, err)
defer os.RemoveAll(testHomeDir) // Cleanup
// Create a test file in the home directory
testFile := filepath.Join(testHomeDir, "testhome.csv")
err = os.WriteFile(testFile, []byte("test content"), 0644)
assert.NoError(t, err)
// Test the pattern with ~/ that should match the file
pattern := "~/synclib_test/testhome.csv"
links, err := ExpandPattern(pattern, testDir, "target.csv")
// This should work but currently fails due to the bug
assert.NoError(t, err)
assert.Equal(t, 1, len(links), "Pattern should match exactly 1 file")
if len(links) > 0 {
assert.Contains(t, links[0].Source, "testhome.csv")
assert.Equal(t, "target.csv", links[0].Target)
}
}

View File

@@ -1,508 +1,132 @@
package main package main
import ( import (
"fmt" "fmt"
"os" "log"
"path/filepath" "os"
"strings" "regexp"
"strconv"
"github.com/bmatcuk/doublestar/v4" "strings"
"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 `yaml:"hard,omitempty"`
Delete bool `yaml:"delete,omitempty"` func (instruction *LinkInstruction) String() string {
} return fmt.Sprintf("%s%s%s%s%s%s%s%s%s", SourceColor, instruction.Source, DefaultColor, deliminer, TargetColor, instruction.Target, DefaultColor, deliminer, strconv.FormatBool(instruction.Force))
}
type YAMLConfig struct {
Links []LinkInstruction `yaml:"links"` func ParseInstruction(line string) (LinkInstruction, error) {
From []string `yaml:"from,omitempty"` parts := strings.Split(line, deliminer)
} instruction := LinkInstruction{}
func (instruction *LinkInstruction) Tidy() { if len(parts) < 2 {
instruction.Source = strings.ReplaceAll(instruction.Source, "\"", "") return instruction, fmt.Errorf("invalid format - not enough parameters (must have at least source and target)")
instruction.Source = strings.ReplaceAll(instruction.Source, "\\", "/") }
instruction.Source = strings.TrimSpace(instruction.Source)
instruction.Source = parts[0]
instruction.Target = strings.ReplaceAll(instruction.Target, "\"", "") instruction.Target = parts[1]
instruction.Target = strings.ReplaceAll(instruction.Target, "\\", "/") instruction.Force = false
instruction.Target = strings.TrimSpace(instruction.Target) if len(parts) > 2 {
} res, _ := regexp.MatchString("^(?i)T|TRUE$", parts[2])
instruction.Force = res
func (instruction *LinkInstruction) String() string { }
var flags []string
if instruction.Force { instruction.Source, _ = ConvertHome(instruction.Source)
flags = append(flags, "force=true") instruction.Target, _ = ConvertHome(instruction.Target)
}
if instruction.Hard { instruction.Source = NormalizePath(instruction.Source)
flags = append(flags, "hard=true") instruction.Target = NormalizePath(instruction.Target)
}
if instruction.Delete { return instruction, nil
flags = append(flags, "delete=true") }
}
func (instruction *LinkInstruction) RunSync() error {
flagsStr := "" if !FileExists(instruction.Source) {
if len(flags) > 0 { return fmt.Errorf("instruction source %s%s%s does not exist", SourceColor, instruction.Source, DefaultColor)
flagsStr = " [" + strings.Join(flags, ", ") + "]" }
}
if AreSame(instruction.Source, instruction.Target) {
return fmt.Sprintf("%s → %s%s", log.Printf("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)
FormatSourcePath(instruction.Source), return nil
FormatTargetPath(instruction.Target), }
flagsStr)
} if FileExists(instruction.Target) {
if instruction.Force {
func (instruction *LinkInstruction) Undo() { isSymlink, err := IsSymlink(instruction.Target)
if !FileExists(instruction.Target) { if err != nil {
LogInfo("%s does not exist, skipping", FormatTargetPath(instruction.Target)) return 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)
return }
}
if isSymlink {
isSymlink, err := IsSymlink(instruction.Target) log.Printf("Removing symlink at %s%s%s", TargetColor, instruction.Target, DefaultColor)
if err != nil { err = os.Remove(instruction.Target)
LogError("could not determine whether %s is a sym link or not, stopping; err: %v", if err != nil {
FormatTargetPath(instruction.Target), err) return fmt.Errorf("failed deleting %s%s%s due to %s%+v%s", TargetColor, instruction.Target, DefaultColor, ErrorColor, err, DefaultColor)
return }
} } else {
return fmt.Errorf("refusing to delte actual (non symlink) file %s%s%s", TargetColor, instruction.Target, DefaultColor)
if isSymlink { }
LogInfo("Removing symlink at %s", FormatTargetPath(instruction.Target)) } else {
err = os.Remove(instruction.Target) return fmt.Errorf("target %s%s%s exists - handle manually or set the 'forced' flag (3rd field)", TargetColor, instruction.Target, DefaultColor)
if err != nil { }
LogError("could not remove symlink at %s; err: %v", }
FormatTargetPath(instruction.Target), err)
} err := os.Symlink(instruction.Source, instruction.Target)
LogSuccess("Removed symlink at %s", FormatTargetPath(instruction.Target)) if err != nil {
} else { return 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)
LogInfo("%s is not a symlink, skipping", FormatTargetPath(instruction.Target)) }
} log.Printf("Created symlink between %s%s%s and %s%s%s", SourceColor, instruction.Source, DefaultColor, TargetColor, instruction.Target, DefaultColor)
}
return nil
func ParseInstruction(line, workdir string) (LinkInstruction, error) { }
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "#") { func (instruction *LinkInstruction) RunAsync(status chan (error)) {
return LinkInstruction{}, fmt.Errorf("comment line") defer close(status)
} if !FileExists(instruction.Source) {
status <- fmt.Errorf("instruction source %s%s%s does not exist", SourceColor, instruction.Source, DefaultColor)
parts := strings.Split(line, deliminer) return
instruction := LinkInstruction{} }
if len(parts) < 2 { if AreSame(instruction.Source, instruction.Target) {
return instruction, fmt.Errorf("invalid format - not enough parameters (must have at least source and 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)
} return
}
instruction.Source = strings.TrimSpace(parts[0])
instruction.Target = strings.TrimSpace(parts[1]) if FileExists(instruction.Target) {
if instruction.Force {
for i := 2; i < len(parts); i++ { isSymlink, err := IsSymlink(instruction.Target)
flagPart := strings.TrimSpace(parts[i]) 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)
// Support for legacy format (backward compatibility) return
if !strings.Contains(flagPart, "=") { }
// Legacy format: positional boolean flags
switch i { if isSymlink {
case 2: // Force flag (3rd position) log.Printf("Removing symlink at %s%s%s", TargetColor, instruction.Target, DefaultColor)
instruction.Force = isTrue(flagPart) err = os.Remove(instruction.Target)
case 3: // Hard flag (4th position) if err != nil {
instruction.Hard = isTrue(flagPart) status <- fmt.Errorf("failed deleting %s%s%s due to %s%+v%s", TargetColor, instruction.Target, DefaultColor, ErrorColor, err, DefaultColor)
case 4: // Delete flag (5th position) return
instruction.Delete = isTrue(flagPart) }
if instruction.Delete { } else {
instruction.Force = true // Delete implies Force status <- fmt.Errorf("refusing to delte actual (non symlink) file %s%s%s", TargetColor, instruction.Target, DefaultColor)
} return
} }
continue } else {
} status <- fmt.Errorf("target %s%s%s exists - handle manually or set the 'forced' flag (3rd field)", TargetColor, instruction.Target, DefaultColor)
return
// New format: named flags (name=value) }
nameValue := strings.SplitN(flagPart, "=", 2) }
if len(nameValue) != 2 {
// Skip malformed flags err := os.Symlink(instruction.Source, instruction.Target)
continue 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)
return
flagName := strings.ToLower(strings.TrimSpace(nameValue[0])) }
flagValue := strings.TrimSpace(nameValue[1]) log.Printf("Created symlink between %s%s%s and %s%s%s", SourceColor, instruction.Source, DefaultColor, TargetColor, instruction.Target, DefaultColor)
switch flagName { status <- nil
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
}
}
}
instruction.Tidy()
instruction.Source, _ = ConvertHome(instruction.Source)
instruction.Target, _ = ConvertHome(instruction.Target)
instruction.Source = NormalizePath(instruction.Source, workdir)
instruction.Target = NormalizePath(instruction.Target, workdir)
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 does not exist", FormatSourcePath(instruction.Source))
return
}
if !instruction.Force && AreSame(instruction.Source, instruction.Target) {
//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
}
if FileExists(instruction.Target) {
if instruction.Force {
isSymlink, err := IsSymlink(instruction.Target)
if err != nil {
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, stopping; err: %v",
FormatTargetPath(instruction.Target), err)
return
}
if info.Mode().IsRegular() && info.Name() == filepath.Base(instruction.Source) {
LogTarget("Overwriting existing file %s", instruction.Target)
err := os.Remove(instruction.Target)
if err != nil {
status <- fmt.Errorf("could not remove existing file %s; err: %v",
FormatTargetPath(instruction.Target), err)
return
}
}
}
if isSymlink {
LogTarget("Removing symlink at %s", instruction.Target)
err = os.Remove(instruction.Target)
if err != nil {
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",
FormatTargetPath(instruction.Target))
return
}
LogImportant("Deleting (!!!) %s", instruction.Target)
err = os.RemoveAll(instruction.Target)
if err != nil {
status <- fmt.Errorf("failed deleting %s due to %v",
FormatTargetPath(instruction.Target), err)
return
}
}
} else {
status <- fmt.Errorf("target %s exists - handle manually or set the 'forced' flag (3rd field)",
FormatTargetPath(instruction.Target))
return
}
}
targetDir := filepath.Dir(instruction.Target)
if _, err := os.Stat(targetDir); os.IsNotExist(err) {
err = os.MkdirAll(targetDir, 0755)
if err != nil {
status <- fmt.Errorf("failed creating directory %s due to %v",
FormatTargetPath(targetDir), err)
return
}
}
var err error
if instruction.Hard {
err = os.Link(instruction.Source, instruction.Target)
} else {
err = os.Symlink(instruction.Source, instruction.Target)
}
if err != nil {
status <- fmt.Errorf("failed creating symlink between %s and %s with error %v",
FormatSourcePath(instruction.Source),
FormatTargetPath(instruction.Target),
err)
return
}
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)
}
// 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) {
// First convert home directory if it starts with ~
fromPath, err := ConvertHome(fromFile)
if err != nil {
return nil, fmt.Errorf("error converting home directory: %w", err)
}
// Convert relative paths to absolute paths based on the current file's directory
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) {
// Convert home directory (~) before expanding pattern
convertedSource, err := ConvertHome(instr.Source)
if err != nil {
return nil, fmt.Errorf("error converting home directory in source %s: %w", instr.Source, err)
}
LogSource("Expanding pattern source %s in YAML file %s", convertedSource, filename)
newlinks, err := ExpandPattern(convertedSource, 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) {
// Convert home directory (~) before splitting pattern
source, err = ConvertHome(source)
if err != nil {
return nil, fmt.Errorf("error converting home directory in source %s: %w", source, err)
}
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"
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,99 +0,0 @@
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...)
}

556
main.go
View File

@@ -1,318 +1,238 @@
package main package main
import ( import (
"bufio" "bufio"
"flag" "flag"
"io" "io"
"log" "log"
"os" "os"
"path/filepath" "regexp"
"sync/atomic" "sync"
utils "git.site.quack-lab.dev/dave/cyutils" "sync/atomic"
) )
const deliminer = "," const deliminer = ","
const SourceColor = Purple const (
const TargetColor = Yellow Black = "\033[30m"
const ErrorColor = Red Red = "\033[31m"
const ImportantColor = BRed Green = "\033[32m"
const DefaultColor = Reset Yellow = "\033[33m"
const PathColor = Green Blue = "\033[34m"
Magenta = "\033[35m"
var programName = os.Args[0] Cyan = "\033[36m"
var undo = false White = "\033[37m"
)
func main() { const SourceColor = Magenta
recurse := flag.String("r", "", "recurse into directories") const TargetColor = Yellow
file := flag.String("f", "", "file to read instructions from") const ErrorColor = Red
debug := flag.Bool("d", false, "debug") const DefaultColor = White
undoF := flag.Bool("u", false, "undo") const PathColor = Green
flag.Parse()
undo = *undoF var DirRegex, _ = regexp.Compile(`^(.+?)[/\\]sync$`)
var FileRegex, _ = regexp.Compile(`^sync$`)
setupLogging(*debug) var programName = os.Args[0]
instructions := make(chan *LinkInstruction, 1000) func main() {
status := make(chan error) recurse := flag.String("r", "", "recurse into directories")
file := flag.String("f", "", "file to read instructions from")
startInputSource(*recurse, *file, instructions, status) debug := flag.Bool("d", false, "debug")
flag.Parse()
go handleStatusErrors(status)
if *debug {
instructionsDone := processInstructions(instructions) log.SetFlags(log.Lmicroseconds | log.Lshortfile)
logFile, err := os.Create("main.log")
if instructionsDone == 0 { if err != nil {
LogInfo("No instructions were processed") log.Printf("Error creating log file: %v", err)
os.Exit(1) os.Exit(1)
} }
LogInfo("All done") logger := io.MultiWriter(os.Stdout, logFile)
} log.SetOutput(logger)
} else {
// setupLogging configures logging based on debug flag log.SetFlags(log.Lmicroseconds)
func setupLogging(debug bool) { }
if debug {
log.SetFlags(log.Lmicroseconds | log.Lshortfile) log.Printf("Recurse: %s", *recurse)
logFile, err := os.Create(programName + ".log") log.Printf("File: %s", *file)
if err != nil {
LogError("Error creating log file: %v", err) instructions := make(chan LinkInstruction, 1000)
os.Exit(1) status := make(chan error)
} if *recurse != "" {
logger := io.MultiWriter(os.Stdout, logFile) go ReadFromFilesRecursively(*recurse, instructions, status)
log.SetOutput(logger) } else if *file != "" {
} else { go ReadFromFile(*file, instructions, status, true)
log.SetFlags(log.Lmicroseconds) } else if len(os.Args) > 1 {
} go ReadFromArgs(instructions, status)
} } else {
go ReadFromStdin(instructions, status)
// 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 go func() {
switch { for {
case recurse != "": err, ok := <-status
LogInfo("Recurse: %s", recurse) if !ok {
go ReadFromFilesRecursively(recurse, instructions, status) break
}
case file != "": if err != nil {
LogInfo("File: %s", file) log.Println(err)
go ReadFromFile(file, instructions, status, true) }
}
case len(flag.Args()) > 0: }()
LogInfo("Reading from command line arguments")
go ReadFromArgs(instructions, status) var instructionsDone int32
var wg sync.WaitGroup
// case IsPipeInput(): for {
// LogInfo("Reading from stdin pipe") instruction, ok := <-instructions
// go ReadFromStdin(instructions, status) if !ok {
log.Printf("No more instructions to process")
default: break
startDefaultInputSource(instructions, status) }
} log.Printf("Processing: %s", instruction.String())
} status := make(chan error)
go instruction.RunAsync(status)
// startDefaultInputSource tries to find default sync files wg.Add(1)
func startDefaultInputSource(instructions chan *LinkInstruction, status chan error) { err := <-status
if _, err := os.Stat("sync"); err == nil { if err != nil {
LogInfo("Using default sync file") log.Printf("Failed parsing instruction %s%s%s due to %s%+v%s", SourceColor, instruction.String(), DefaultColor, ErrorColor, err, DefaultColor)
go ReadFromFile("sync", instructions, status, true) }
} else if _, err := os.Stat("sync.yaml"); err == nil { atomic.AddInt32(&instructionsDone, 1)
LogInfo("Using default sync.yaml file") wg.Done()
go ReadFromFile("sync.yaml", instructions, status, true) }
} else if _, err := os.Stat("sync.yml"); err == nil { wg.Wait()
LogInfo("Using default sync.yml file") log.Println("All done")
go ReadFromFile("sync.yml", instructions, status, true) if instructionsDone == 0 {
} else { log.Printf("No input provided")
showUsageAndExit() 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)
// showUsageAndExit displays usage information and exits log.Printf("stdin - (cat <file> | %s)", programName)
func showUsageAndExit() { os.Exit(1)
LogInfo("No input provided") }
LogInfo("Provide input as: ") }
LogInfo("Arguments - %s <source>,<target>,<force?>", programName)
LogInfo("File - %s -f <file>", programName) func ReadFromFilesRecursively(input string, output chan LinkInstruction, status chan error) {
LogInfo("YAML File - %s -f <file.yaml>", programName) defer close(output)
LogInfo("Folder (finding sync files in folder recursively) - %s -r <folder>", programName) defer close(status)
LogInfo("stdin - (cat <file> | %s)", programName)
os.Exit(1) input = NormalizePath(input)
} log.Printf("Reading input from files recursively starting in %s%s%s", PathColor, input, DefaultColor)
// handleStatusErrors processes status channel errors files := make(chan string, 128)
func handleStatusErrors(status chan error) { recurseStatus := make(chan error)
for { go GetSyncFilesRecursively(input, files, recurseStatus)
err, ok := <-status go func() {
if !ok { for {
break err, ok := <-recurseStatus
} if !ok {
if err != nil { break
LogError("%v", err) }
} if err != nil {
} log.Printf("Failed to get sync files recursively: %s%+v%s", ErrorColor, err, DefaultColor)
} status <- err
}
// processInstructions processes all instructions from the channel using parallel workers }
func processInstructions(instructions chan *LinkInstruction) int32 { }()
var instructionsDone int32 = 0
var wg sync.WaitGroup
// Collect all instructions first for {
var allInstructions []*LinkInstruction file, ok := <-files
for { if !ok {
instruction, ok := <-instructions log.Printf("No more files to process")
if !ok { break
LogInfo("No more instructions to process") }
break wg.Add(1)
} go func() {
allInstructions = append(allInstructions, instruction) defer wg.Done()
} log.Println(file)
file = NormalizePath(file)
// Process instructions in parallel using cyutils.WithWorkers log.Printf("Processing file: %s%s%s", PathColor, file, DefaultColor)
// Let the library handle worker count - use 4 workers as a reasonable default
utils.WithWorkers(4, allInstructions, func(workerID int, _ int, instruction *LinkInstruction) { // This "has" to be done because instructions are resolved in relation to cwd
LogInfo("Processing: %s", instruction.String()) fileDir := DirRegex.FindStringSubmatch(file)
status := make(chan error) if fileDir == nil {
go instruction.RunAsync(status) log.Printf("Failed to extract directory from %s%s%s", SourceColor, file, DefaultColor)
err := <-status return
if err != nil { }
LogError("Failed processing instruction: %v", err) log.Printf("Changing directory to %s%s%s (for %s%s%s)", PathColor, fileDir[1], DefaultColor, PathColor, file, DefaultColor)
} else { err := os.Chdir(fileDir[1])
atomic.AddInt32(&instructionsDone, 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
}
return instructionsDone
} ReadFromFile(file, output, status, false)
}()
func IsPipeInput() bool { }
info, err := os.Stdin.Stat() wg.Wait()
if err != nil { }
return false func ReadFromFile(input string, output chan LinkInstruction, status chan error, doclose bool) {
} if doclose {
return info.Mode()&os.ModeNamedPipe != 0 defer close(output)
} defer close(status)
}
func ReadFromFilesRecursively(input string, output chan *LinkInstruction, status chan error) {
defer close(output) input = NormalizePath(input)
defer close(status) log.Printf("Reading input from file: %s%s%s", PathColor, input, DefaultColor)
file, err := os.Open(input)
workdir, _ := os.Getwd() if err != nil {
input = NormalizePath(input, workdir) log.Fatalf("Failed to open file %s%s%s: %s%+v%s", SourceColor, input, DefaultColor, ErrorColor, err, DefaultColor)
LogInfo("Reading input from files recursively starting in %s", FormatPathValue(input)) return
}
files := make(chan string, 128) defer file.Close()
fileStatus := make(chan error)
go GetSyncFilesRecursively(input, files, fileStatus) scanner := bufio.NewScanner(file)
for scanner.Scan() {
// Collect all files first line := scanner.Text()
var syncFiles []string instruction, err := ParseInstruction(line)
for { if err != nil {
file, ok := <-files log.Printf("Error parsing line: %s'%s'%s, error: %s%+v%s", SourceColor, line, DefaultColor, ErrorColor, err, DefaultColor)
if !ok { continue
break }
} log.Printf("Read instruction: %s", instruction.String())
syncFiles = append(syncFiles, file) output <- instruction
} }
}
// Check for errors from file search func ReadFromArgs(output chan LinkInstruction, status chan error) {
for { defer close(output)
err, ok := <-fileStatus defer close(status)
if !ok {
break log.Printf("Reading input from args")
} for _, arg := range os.Args[1:] {
if err != nil { instruction, err := ParseInstruction(arg)
LogError("Failed to get sync files recursively: %v", err) if err != nil {
status <- err log.Printf("Error parsing arg: %s'%s'%s, error: %s%+v%s", SourceColor, arg, DefaultColor, ErrorColor, err, DefaultColor)
} continue
} }
output <- instruction
// Process each file }
for _, file := range syncFiles { }
file = NormalizePath(file, workdir) func ReadFromStdin(output chan LinkInstruction, status chan error) {
LogInfo("Processing file: %s", FormatPathValue(file)) defer close(output)
defer close(status)
// Change to the directory containing the sync file
fileDir := filepath.Dir(file) log.Printf("Reading input from stdin")
originalDir, _ := os.Getwd()
err := os.Chdir(fileDir) info, err := os.Stdin.Stat()
if err != nil { if err != nil {
LogError("Failed to change directory to %s: %v", FormatSourcePath(fileDir), err) log.Fatalf("Failed to stat stdin: %s%+v%s", ErrorColor, err, DefaultColor)
continue status <- err
} return
}
// Read and process the file if info.Mode()&os.ModeNamedPipe != 0 {
ReadFromFile(file, output, status, false) scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
// Return to original directory line := scanner.Text()
os.Chdir(originalDir) instruction, err := ParseInstruction(line)
} if err != nil {
} log.Printf("Error parsing line: %s'%s'%s, error: %s%+v%s", SourceColor, line, DefaultColor, ErrorColor, err, DefaultColor)
continue
func ReadFromFile(input string, output chan *LinkInstruction, status chan error, doclose bool) { }
if doclose { output <- instruction
defer close(output) }
defer close(status) if err := scanner.Err(); err != nil {
} log.Fatalf("Error reading from stdin: %s%+v%s", ErrorColor, err, DefaultColor)
status <- err
input = NormalizePath(input, filepath.Dir(input)) return
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)
return
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
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)
continue
}
log.Printf("Read instruction: %s", instruction.String())
output <- &instruction
}
}
func ReadFromArgs(output chan *LinkInstruction, status chan error) {
defer close(output)
defer close(status)
workdir, _ := os.Getwd()
LogInfo("Reading input from args")
for _, arg := range flag.Args() {
instruction, err := ParseInstruction(arg, workdir)
if err != nil {
LogError("Error parsing arg '%s': %v", arg, err)
continue
}
output <- &instruction
}
}
func ReadFromStdin(output chan *LinkInstruction, status chan error) {
defer close(output)
defer close(status)
workdir, _ := os.Getwd()
LogInfo("Reading input from stdin")
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
line := scanner.Text()
instruction, err := ParseInstruction(line, workdir)
if err != nil {
LogError("Error parsing line '%s': %v", line, err)
continue
}
output <- &instruction
}
if err := scanner.Err(); err != nil {
LogError("Error reading from stdin: %v", err)
status <- err
return
}
}

View File

@@ -1,52 +0,0 @@
#!/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"

1
sync Normal file
View File

@@ -0,0 +1 @@
test,testdir/test3

View File

@@ -1,6 +0,0 @@
- source: A/**/*
target: B
- source: A/go.mod
target: B/go.mod
- source: A
target: B/foo

View File

@@ -1,26 +0,0 @@
# 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

237
util.go
View File

@@ -1,98 +1,139 @@
package main package main
import ( import (
"fmt" "fmt"
"os" "log"
"path/filepath" "os"
"strings" "path/filepath"
"strings"
"github.com/bmatcuk/doublestar/v4" "sync"
) "sync/atomic"
"time"
func IsSymlink(path string) (bool, error) { )
fileInfo, err := os.Lstat(path)
if err != nil { func IsSymlink(path string) (bool, error) {
return false, err fileInfo, err := os.Lstat(path)
} if err != nil {
return false, err
// os.ModeSymlink is a bitmask that identifies the symlink mode. }
// If the file mode & os.ModeSymlink is non-zero, the file is a symlink.
return fileInfo.Mode()&os.ModeSymlink != 0, nil // os.ModeSymlink is a bitmask that identifies the symlink mode.
} // If the file mode & os.ModeSymlink is non-zero, the file is a symlink.
return fileInfo.Mode()&os.ModeSymlink != 0, nil
func FileExists(path string) bool { }
_, err := os.Lstat(path)
return err == nil func FileExists(path string) bool {
} _, err := os.Lstat(path)
return err == nil
func NormalizePath(input, workdir string) string { }
input = filepath.Clean(input)
input = filepath.ToSlash(input) func NormalizePath(input string) string {
input = strings.ReplaceAll(input, "\"", "") workingdirectory, _ := os.Getwd()
input = strings.ReplaceAll(input, "\\", "/")
if !filepath.IsAbs(input) { input = strings.ReplaceAll(input, "\"", "")
LogInfo("Input '%s' is not absolute, prepending work dir '%s'", input, workdir)
var err error if !filepath.IsAbs(input) {
input = filepath.Join(workdir, input) input = workingdirectory + "/" + input
input, err = filepath.Abs(input) }
if err != nil {
LogError("Failed to get absolute path for %s: %v", FormatSourcePath(input), err) return filepath.Clean(input)
return input }
}
} func AreSame(lhs string, rhs string) bool {
lhsinfo, err := os.Stat(lhs)
input = filepath.Clean(input) if err != nil {
input = filepath.ToSlash(input) return false
return input }
} rhsinfo, err := os.Stat(rhs)
if err != nil {
func AreSame(lhs string, rhs string) bool { return false
lhsinfo, err := os.Stat(lhs) }
if err != nil {
return false return os.SameFile(lhsinfo, rhsinfo)
} }
rhsinfo, err := os.Stat(rhs)
if err != nil { func ConvertHome(input string) (string, error) {
return false if strings.Contains(input, "~") {
} homedir, err := os.UserHomeDir()
if err != nil {
return os.SameFile(lhsinfo, rhsinfo) return input, fmt.Errorf("unable to convert ~ to user directory with error %+v", err)
} }
func ConvertHome(input string) (string, error) { return strings.Replace(input, "~", homedir, 1), nil
if strings.HasPrefix(input, "~/") { }
homedir, err := os.UserHomeDir() return input, nil
if err != nil { }
return input, fmt.Errorf("unable to convert ~ to user directory with error %+v", err)
} func GetSyncFilesRecursively(input string, output chan string, status chan error) {
defer close(output)
return strings.Replace(input, "~", homedir, 1), nil defer close(status)
}
return input, nil var filesProcessed int32
} var foldersProcessed int32
progressTicker := time.NewTicker(200 * time.Millisecond)
func GetSyncFilesRecursively(input string, output chan string, status chan error) { defer progressTicker.Stop()
defer close(output)
defer close(status) var wg sync.WaitGroup
var initial sync.Once
workdir, _ := os.Getwd() wg.Add(1)
input = NormalizePath(input, workdir) directories := make(chan string, 100000)
LogInfo("Searching for sync files recursively starting in %s", FormatPathValue(input)) workerPool := make(chan struct{}, 4000)
directories <- input
// Use doublestar to find all sync.yml and sync.yaml files recursively
pattern := "**/sync.y*ml" go func() {
files, err := doublestar.Glob(os.DirFS(input), pattern) for {
if err != nil { fmt.Printf("\rFiles processed: %d; Folders processed: %d; Workers: %d; Directory Stack Size: %d;", filesProcessed, foldersProcessed, len(workerPool), len(directories))
LogError("Failed to search for pattern %s: %v", pattern, err) <-progressTicker.C
status <- err }
return }()
}
log.Printf("%+v", len(workerPool))
for _, file := range files { go func() {
fullPath := filepath.Join(input, file) for directory := range directories {
LogInfo("Found sync file: %s", FormatPathValue(fullPath)) workerPool <- struct{}{}
output <- fullPath wg.Add(1)
} go func(directory string) {
atomic.AddInt32(&foldersProcessed, 1)
LogInfo("Completed recursive search for sync files") defer wg.Done()
} defer func() { <-workerPool }()
files, err := os.ReadDir(directory)
if err != nil {
log.Printf("Error reading directory %s: %+v", directory, 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())
}
atomic.AddInt32(&filesProcessed, 1)
}
}
// log.Printf("Done reading directory %s", directory)
initial.Do(func() {
// Parallelism is very difficult...
time.Sleep(250 * time.Millisecond)
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...
wg.Wait()
log.Printf("Files processed: %d; Folders processed: %d", filesProcessed, foldersProcessed)
}