31 Commits

Author SHA1 Message Date
83477d5f18 Maybe fix the directory crawler 2025-02-26 11:17:51 +01:00
125bf78c16 Implement yaml format 2025-02-26 11:15:56 +01:00
edaa699c20 Ignore lines with # 2025-02-02 21:19:47 +01:00
5b2da09eb2 Add empty go.sum 2025-02-02 21:19:44 +01:00
1c23ad0cfd Fix issue with defaulting to reading from file while providing other args 2024-11-10 15:58:52 +01:00
653e883742 Fix issue with incorrectly parsing ~ 2024-10-18 20:19:31 +02:00
41bac18525 Replace ~ only if the path begins with ~ 2024-10-08 14:11:38 +02:00
02106824fd Discover go install 2024-10-03 10:53:16 +02:00
cd42bc1fb8 Fix issue with non absolute paths not being absoluted 2024-09-23 16:16:47 +02:00
41123846d1 Convert all paths to absolute
To fix issue where folders were being linked as files
2024-09-20 18:24:10 +02:00
205f8314d6 Improve filepath resolution for relative paths 2024-09-11 19:43:43 +02:00
290e6fdaba Now force forces creation 2024-09-11 19:37:07 +02:00
d98ecd787a Add colors and refine logs a little 2024-09-11 19:35:33 +02:00
1a6992e2a7 Remove sync 2024-09-11 19:32:02 +02:00
fc59878389 Implement deleting real actual files 2024-09-11 19:32:01 +02:00
ff1af19088 Ensure target exists by creating directories 2024-09-11 18:53:22 +02:00
e6bb1f0c53 Update 2024-08-16 14:57:20 +02:00
e149103d21 Print "hard" property on instruction 2024-08-16 09:46:15 +02:00
ebccc49d34 Make forced hard links overwrite target if it has the same name as source
Or something like that... Maybe...
2024-08-16 09:45:05 +02:00
55dc061c31 Default file to "sync" 2024-08-16 09:39:14 +02:00
2c49b65502 Move hard out of flag args and into command 2024-08-16 09:38:35 +02:00
1f21965288 Update sync file 2024-08-15 22:46:34 +02:00
b8e7bc3576 Add support for hard links 2024-08-15 22:44:03 +02:00
PhatPhuckDave
595a11552c Trim instruction before parse 2024-07-21 13:44:26 +02:00
2a7740d8d7 Refactor reading args 2024-07-03 13:31:12 +02:00
7580ca5399 Enable build for linux 2024-07-03 13:31:03 +02:00
58cce74ce8 Remove escapes from code "blocks" 2024-07-01 21:06:01 +02:00
e022a838ba Solve the race condition when recursively reading files, hopefully 2024-07-01 21:00:38 +02:00
0a627ae9ca Add readme 2024-07-01 20:38:38 +02:00
d72644aec3 Code format
I think? Don't know what changed
2024-07-01 20:30:19 +02:00
eeb8dac3a0 Add insane ramblings 2024-07-01 20:28:49 +02:00
12 changed files with 957 additions and 507 deletions

5
.gitignore vendored
View File

@@ -1,2 +1,3 @@
*.exe
main.exe *.exe
cln

89
README.md Normal file
View File

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

99
colors.go Normal file
View File

@@ -0,0 +1,99 @@
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/
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 +1,2 @@
go build main && cp main.exe "/c/Program Files/Git/usr/bin/cln.exe" GOOS=windows GOARCH=amd64 go build -o main.exe main && cp main.exe "/c/Program Files/Git/usr/bin/cln.exe"
GOOS=linux GOARCH=amd64 go build -o main_linux main

4
go.mod
View File

@@ -1,3 +1,5 @@
module main module cln
go 1.21.7 go 1.21.7
require gopkg.in/yaml.v3 v3.0.1

4
go.sum Normal file
View File

@@ -0,0 +1,4 @@
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,132 +1,255 @@
package main package main
import ( import (
"fmt" "fmt"
"log" "log"
"os" "os"
"regexp" "path/filepath"
"strconv" "strings"
"strings"
) "gopkg.in/yaml.v3"
)
type LinkInstruction struct {
Source string type LinkInstruction struct {
Target string Source string `yaml:"source"`
Force bool Target string `yaml:"target"`
} Force bool `yaml:"force,omitempty"`
Hard bool `yaml:"hard,omitempty"`
func (instruction *LinkInstruction) String() string { Delete bool `yaml:"delete,omitempty"`
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 {
func ParseInstruction(line string) (LinkInstruction, error) { Links []LinkInstruction `yaml:"links"`
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 → %s%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) SourceColor, instruction.Source, DefaultColor,
return nil TargetColor, instruction.Target, DefaultColor,
} flagsStr)
}
if FileExists(instruction.Target) {
if instruction.Force { func ParseInstruction(line, workdir string) (LinkInstruction, error) {
isSymlink, err := IsSymlink(instruction.Target) line = strings.TrimSpace(line)
if err != nil { if strings.HasPrefix(line, "#") {
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 LinkInstruction{}, fmt.Errorf("comment line")
} }
if isSymlink { parts := strings.Split(line, deliminer)
log.Printf("Removing symlink at %s%s%s", TargetColor, instruction.Target, DefaultColor) instruction := LinkInstruction{}
err = os.Remove(instruction.Target)
if err != nil { if len(parts) < 2 {
return fmt.Errorf("failed deleting %s%s%s due to %s%+v%s", TargetColor, instruction.Target, DefaultColor, ErrorColor, err, DefaultColor) return instruction, fmt.Errorf("invalid format - not enough parameters (must have at least source and target)")
} }
} else {
return fmt.Errorf("refusing to delte actual (non symlink) file %s%s%s", TargetColor, instruction.Target, DefaultColor) instruction.Source = strings.TrimSpace(parts[0])
} instruction.Target = strings.TrimSpace(parts[1])
} else {
return fmt.Errorf("target %s%s%s exists - handle manually or set the 'forced' flag (3rd field)", TargetColor, instruction.Target, DefaultColor) for i := 2; i < len(parts); i++ {
} flagPart := strings.TrimSpace(parts[i])
}
// Support for legacy format (backward compatibility)
err := os.Symlink(instruction.Source, instruction.Target) if !strings.Contains(flagPart, "=") {
if err != nil { // Legacy format: positional boolean flags
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) switch i {
} case 2: // Force flag (3rd position)
log.Printf("Created symlink between %s%s%s and %s%s%s", SourceColor, instruction.Source, DefaultColor, TargetColor, instruction.Target, DefaultColor) instruction.Force = isTrue(flagPart)
case 3: // Hard flag (4th position)
return nil instruction.Hard = isTrue(flagPart)
} case 4: // Delete flag (5th position)
instruction.Delete = isTrue(flagPart)
func (instruction *LinkInstruction) RunAsync(status chan (error)) { if instruction.Delete {
defer close(status) instruction.Force = true // Delete implies Force
if !FileExists(instruction.Source) { }
status <- fmt.Errorf("instruction source %s%s%s does not exist", SourceColor, instruction.Source, DefaultColor) }
return continue
} }
if AreSame(instruction.Source, instruction.Target) { // New format: named flags (name=value)
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) nameValue := strings.SplitN(flagPart, "=", 2)
return if len(nameValue) != 2 {
} // Skip malformed flags
continue
if FileExists(instruction.Target) { }
if instruction.Force {
isSymlink, err := IsSymlink(instruction.Target) flagName := strings.ToLower(strings.TrimSpace(nameValue[0]))
if err != nil { flagValue := strings.TrimSpace(nameValue[1])
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)
return switch flagName {
} case "force", "f":
instruction.Force = isTrue(flagValue)
if isSymlink { case "hard", "h":
log.Printf("Removing symlink at %s%s%s", TargetColor, instruction.Target, DefaultColor) instruction.Hard = isTrue(flagValue)
err = os.Remove(instruction.Target) case "delete", "d":
if err != nil { instruction.Delete = isTrue(flagValue)
status <- fmt.Errorf("failed deleting %s%s%s due to %s%+v%s", TargetColor, instruction.Target, DefaultColor, ErrorColor, err, DefaultColor) if instruction.Delete {
return instruction.Force = true // Delete implies Force
} }
} else { }
status <- fmt.Errorf("refusing to delte actual (non symlink) file %s%s%s", TargetColor, instruction.Target, DefaultColor) }
return
} instruction.Tidy()
} else { instruction.Source, _ = ConvertHome(instruction.Source)
status <- fmt.Errorf("target %s%s%s exists - handle manually or set the 'forced' flag (3rd field)", TargetColor, instruction.Target, DefaultColor) instruction.Target, _ = ConvertHome(instruction.Target)
return
} instruction.Source = NormalizePath(instruction.Source, workdir)
} instruction.Target = NormalizePath(instruction.Target, workdir)
err := os.Symlink(instruction.Source, instruction.Target) return instruction, nil
if err != nil { }
status <- fmt.Errorf("failed creating symlink between %s%s%s and %s%s%s with error %s%+v%s", SourceColor, instruction.Source, DefaultColor, TargetColor, instruction.Target, DefaultColor, ErrorColor, err, DefaultColor)
return func isTrue(value string) bool {
} value = strings.ToLower(strings.TrimSpace(value))
log.Printf("Created symlink between %s%s%s and %s%s%s", SourceColor, instruction.Source, DefaultColor, TargetColor, instruction.Target, DefaultColor) return value == "true" || value == "t" || value == "yes" || value == "y" || value == "1"
}
status <- nil
} func (instruction *LinkInstruction) RunAsync(status chan (error)) {
defer close(status)
if !FileExists(instruction.Source) {
status <- fmt.Errorf("instruction source %s%s%s does not exist", SourceColor, instruction.Source, DefaultColor)
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)
return
}
if FileExists(instruction.Target) {
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)
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)
return
}
if info.Mode().IsRegular() && info.Name() == filepath.Base(instruction.Source) {
log.Printf("Overwriting existing file %s%s%s", TargetColor, instruction.Target, DefaultColor)
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)
return
}
}
}
if isSymlink {
log.Printf("Removing symlink at %s%s%s", TargetColor, instruction.Target, DefaultColor)
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)
return
}
} else {
if !instruction.Delete {
status <- fmt.Errorf("refusing to delte actual (non symlink) file %s%s%s", TargetColor, instruction.Target, DefaultColor)
return
}
log.Printf("%sDeleting (!!!)%s %s%s%s", ImportantColor, DefaultColor, TargetColor, instruction.Target, DefaultColor)
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)
return
}
}
} else {
status <- fmt.Errorf("target %s%s%s exists - handle manually or set the 'forced' flag (3rd field)", TargetColor, instruction.Target, DefaultColor)
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%s%s due to %s%+v%s", TargetColor, targetDir, DefaultColor, ErrorColor, err, DefaultColor)
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%s%s and %s%s%s with error %s%+v%s", SourceColor, instruction.Source, DefaultColor, TargetColor, instruction.Target, DefaultColor, ErrorColor, err, DefaultColor)
return
}
log.Printf("Created symlink between %s%s%s and %s%s%s", SourceColor, instruction.Source, DefaultColor, TargetColor, instruction.Target, DefaultColor)
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 list of link instructions
var config YAMLConfig
err = yaml.Unmarshal(data, &config)
if err != nil || len(config.Links) == 0 {
// 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)
}
config.Links = instructions
}
for i := range config.Links {
config.Links[i].Tidy()
config.Links[i].Source, _ = ConvertHome(config.Links[i].Source)
config.Links[i].Target, _ = ConvertHome(config.Links[i].Target)
config.Links[i].Source = NormalizePath(config.Links[i].Source, workdir)
config.Links[i].Target = NormalizePath(config.Links[i].Target, workdir)
// If Delete is true, Force must also be true
if config.Links[i].Delete {
config.Links[i].Force = true
}
}
return config.Links, nil
}
func IsYAMLFile(filename string) bool {
ext := strings.ToLower(filepath.Ext(filename))
return ext == ".yaml" || ext == ".yml"
}

520
main.go
View File

@@ -1,238 +1,282 @@
package main package main
import ( import (
"bufio" "bufio"
"flag" "flag"
"io" "io"
"log" "log"
"os" "os"
"regexp" "path/filepath"
"sync" "regexp"
"sync/atomic" "sync"
) "sync/atomic"
)
const deliminer = ","
const ( const deliminer = ","
Black = "\033[30m" const SourceColor = Purple
Red = "\033[31m" const TargetColor = Yellow
Green = "\033[32m" const ErrorColor = URed
Yellow = "\033[33m" const ImportantColor = BRed
Blue = "\033[34m" const DefaultColor = White
Magenta = "\033[35m" const PathColor = Green
Cyan = "\033[36m"
White = "\033[37m" var DirRegex, _ = regexp.Compile(`^(.+?)[/\\]sync(?:\.ya?ml)?$`)
) var FileRegex, _ = regexp.Compile(`^sync(?:\.ya?ml)?$`)
const SourceColor = Magenta var programName = os.Args[0]
const TargetColor = Yellow
const ErrorColor = Red func main() {
const DefaultColor = White recurse := flag.String("r", "", "recurse into directories")
const PathColor = Green file := flag.String("f", "", "file to read instructions from")
debug := flag.Bool("d", false, "debug")
var DirRegex, _ = regexp.Compile(`^(.+?)[/\\]sync$`) flag.Parse()
var FileRegex, _ = regexp.Compile(`^sync$`)
var programName = os.Args[0] if *debug {
log.SetFlags(log.Lmicroseconds | log.Lshortfile)
func main() { logFile, err := os.Create("main.log")
recurse := flag.String("r", "", "recurse into directories") if err != nil {
file := flag.String("f", "", "file to read instructions from") log.Printf("Error creating log file: %v", err)
debug := flag.Bool("d", false, "debug") os.Exit(1)
flag.Parse() }
logger := io.MultiWriter(os.Stdout, logFile)
if *debug { log.SetOutput(logger)
log.SetFlags(log.Lmicroseconds | log.Lshortfile) } else {
logFile, err := os.Create("main.log") log.SetFlags(log.Lmicroseconds)
if err != nil { }
log.Printf("Error creating log file: %v", err)
os.Exit(1) instructions := make(chan *LinkInstruction, 1000)
} status := make(chan error)
logger := io.MultiWriter(os.Stdout, logFile)
log.SetOutput(logger) // Check input sources in priority order
} else { switch {
log.SetFlags(log.Lmicroseconds) case *recurse != "":
} log.Printf("Recurse: %s", *recurse)
go ReadFromFilesRecursively(*recurse, instructions, status)
log.Printf("Recurse: %s", *recurse)
log.Printf("File: %s", *file) case *file != "":
log.Printf("File: %s", *file)
instructions := make(chan LinkInstruction, 1000) go ReadFromFile(*file, instructions, status, true)
status := make(chan error)
if *recurse != "" { case len(flag.Args()) > 0:
go ReadFromFilesRecursively(*recurse, instructions, status) log.Printf("Reading from command line arguments")
} else if *file != "" { go ReadFromArgs(instructions, status)
go ReadFromFile(*file, instructions, status, true)
} else if len(os.Args) > 1 { case IsPipeInput():
go ReadFromArgs(instructions, status) log.Printf("Reading from stdin pipe")
} else { go ReadFromStdin(instructions, status)
go ReadFromStdin(instructions, status)
} default:
if _, err := os.Stat("sync"); err == nil {
go func() { log.Printf("Using default sync file")
for { go ReadFromFile("sync", instructions, status, true)
err, ok := <-status } else if _, err := os.Stat("sync.yaml"); err == nil {
if !ok { log.Printf("Using default sync.yaml file")
break go ReadFromFile("sync.yaml", instructions, status, true)
} } else if _, err := os.Stat("sync.yml"); err == nil {
if err != nil { log.Printf("Using default sync.yml file")
log.Println(err) go ReadFromFile("sync.yml", instructions, status, true)
} } else {
} log.Printf("No input provided")
}() log.Printf("Provide input as: ")
log.Printf("Arguments - %s <source>,<target>,<force?>", programName)
var instructionsDone int32 log.Printf("File - %s -f <file>", programName)
var wg sync.WaitGroup log.Printf("YAML File - %s -f <file.yaml>", programName)
for { log.Printf("Folder (finding sync files in folder recursively) - %s -r <folder>", programName)
instruction, ok := <-instructions log.Printf("stdin - (cat <file> | %s)", programName)
if !ok { os.Exit(1)
log.Printf("No more instructions to process") }
break }
}
log.Printf("Processing: %s", instruction.String()) go func() {
status := make(chan error) for {
go instruction.RunAsync(status) err, ok := <-status
wg.Add(1) if !ok {
err := <-status break
if err != nil { }
log.Printf("Failed parsing instruction %s%s%s due to %s%+v%s", SourceColor, instruction.String(), DefaultColor, ErrorColor, err, DefaultColor) if err != nil {
} log.Println(err)
atomic.AddInt32(&instructionsDone, 1) }
wg.Done() }
} }()
wg.Wait()
log.Println("All done") var instructionsDone int32
if instructionsDone == 0 { var wg sync.WaitGroup
log.Printf("No input provided") for {
log.Printf("Provide input as: ") instruction, ok := <-instructions
log.Printf("Arguments - %s <source>,<target>,<force?>", programName) if !ok {
log.Printf("File - %s -f <file>", programName) log.Printf("No more instructions to process")
log.Printf("Folder (finding sync files in folder recursively) - %s -r <folder>", programName) break
log.Printf("stdin - (cat <file> | %s)", programName) }
os.Exit(1) log.Printf("Processing: %s", instruction.String())
} status := make(chan error)
} go instruction.RunAsync(status)
wg.Add(1)
func ReadFromFilesRecursively(input string, output chan LinkInstruction, status chan error) { err := <-status
defer close(output) if err != nil {
defer close(status) log.Printf("Failed parsing instruction %s%s%s due to %s%+v%s", SourceColor, instruction.String(), DefaultColor, ErrorColor, err, DefaultColor)
}
input = NormalizePath(input) atomic.AddInt32(&instructionsDone, 1)
log.Printf("Reading input from files recursively starting in %s%s%s", PathColor, input, DefaultColor) wg.Done()
}
files := make(chan string, 128) wg.Wait()
recurseStatus := make(chan error) log.Println("All done")
go GetSyncFilesRecursively(input, files, recurseStatus) if instructionsDone == 0 {
go func() { log.Printf("No instructions were processed")
for { os.Exit(1)
err, ok := <-recurseStatus }
if !ok { }
break
} func IsPipeInput() bool {
if err != nil { info, err := os.Stdin.Stat()
log.Printf("Failed to get sync files recursively: %s%+v%s", ErrorColor, err, DefaultColor) if err != nil {
status <- err return false
} }
} return info.Mode()&os.ModeNamedPipe != 0
}() }
var wg sync.WaitGroup func ReadFromFilesRecursively(input string, output chan *LinkInstruction, status chan error) {
for { defer close(output)
file, ok := <-files defer close(status)
if !ok {
log.Printf("No more files to process") workdir, _ := os.Getwd()
break input = NormalizePath(input, workdir)
} log.Printf("Reading input from files recursively starting in %s%s%s", PathColor, input, DefaultColor)
wg.Add(1)
go func() { files := make(chan string, 128)
defer wg.Done() recurseStatus := make(chan error)
log.Println(file) go GetSyncFilesRecursively(input, files, recurseStatus)
file = NormalizePath(file) go func() {
log.Printf("Processing file: %s%s%s", PathColor, file, DefaultColor) for {
err, ok := <-recurseStatus
// This "has" to be done because instructions are resolved in relation to cwd if !ok {
fileDir := DirRegex.FindStringSubmatch(file) break
if fileDir == nil { }
log.Printf("Failed to extract directory from %s%s%s", SourceColor, file, DefaultColor) if err != nil {
return log.Printf("Failed to get sync files recursively: %s%+v%s", ErrorColor, err, DefaultColor)
} status <- err
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 var wg sync.WaitGroup
} for {
file, ok := <-files
ReadFromFile(file, output, status, false) if !ok {
}() log.Printf("No more files to process")
} break
wg.Wait() }
} wg.Add(1)
func ReadFromFile(input string, output chan LinkInstruction, status chan error, doclose bool) { go func() {
if doclose { defer wg.Done()
defer close(output) log.Println(file)
defer close(status) file = NormalizePath(file, workdir)
} log.Printf("Processing file: %s%s%s", PathColor, file, DefaultColor)
input = NormalizePath(input) // This "has" to be done because instructions are resolved in relation to cwd
log.Printf("Reading input from file: %s%s%s", PathColor, input, DefaultColor) fileDir := DirRegex.FindStringSubmatch(file)
file, err := os.Open(input) if fileDir == nil {
if err != nil { log.Printf("Failed to extract directory from %s%s%s", SourceColor, file, DefaultColor)
log.Fatalf("Failed to open file %s%s%s: %s%+v%s", SourceColor, input, DefaultColor, ErrorColor, err, DefaultColor) return
return }
} log.Printf("Changing directory to %s%s%s (for %s%s%s)", PathColor, fileDir[1], DefaultColor, PathColor, file, DefaultColor)
defer file.Close() err := os.Chdir(fileDir[1])
if err != nil {
scanner := bufio.NewScanner(file) log.Printf("Failed to change directory to %s%s%s: %s%+v%s", SourceColor, fileDir[1], DefaultColor, ErrorColor, err, DefaultColor)
for scanner.Scan() { return
line := scanner.Text() }
instruction, err := ParseInstruction(line)
if err != nil { ReadFromFile(file, output, status, false)
log.Printf("Error parsing line: %s'%s'%s, error: %s%+v%s", SourceColor, line, DefaultColor, ErrorColor, err, DefaultColor) }()
continue }
} wg.Wait()
log.Printf("Read instruction: %s", instruction.String()) }
output <- instruction
} func ReadFromFile(input string, output chan *LinkInstruction, status chan error, doclose bool) {
} if doclose {
func ReadFromArgs(output chan LinkInstruction, status chan error) { defer close(output)
defer close(output) defer close(status)
defer close(status) }
log.Printf("Reading input from args") input = NormalizePath(input, filepath.Dir(input))
for _, arg := range os.Args[1:] { log.Printf("Reading input from file: %s%s%s", PathColor, input, DefaultColor)
instruction, err := ParseInstruction(arg)
if err != nil { // Check if this is a YAML file
log.Printf("Error parsing arg: %s'%s'%s, error: %s%+v%s", SourceColor, arg, DefaultColor, ErrorColor, err, DefaultColor) if IsYAMLFile(input) {
continue log.Printf("Parsing as YAML file")
} instructions, err := ParseYAMLFile(input, filepath.Dir(input))
output <- instruction if err != nil {
} log.Printf("Failed to parse YAML file %s%s%s: %s%+v%s",
} SourceColor, input, DefaultColor, ErrorColor, err, DefaultColor)
func ReadFromStdin(output chan LinkInstruction, status chan error) { status <- err
defer close(output) return
defer close(status) }
log.Printf("Reading input from stdin") for _, instruction := range instructions {
instr := instruction // Create a copy to avoid reference issues
info, err := os.Stdin.Stat() log.Printf("Read YAML instruction: %s", instr.String())
if err != nil { output <- &instr
log.Fatalf("Failed to stat stdin: %s%+v%s", ErrorColor, err, DefaultColor) }
status <- err return
return }
}
if info.Mode()&os.ModeNamedPipe != 0 { // Handle CSV format (legacy)
scanner := bufio.NewScanner(os.Stdin) file, err := os.Open(input)
for scanner.Scan() { if err != nil {
line := scanner.Text() log.Fatalf("Failed to open file %s%s%s: %s%+v%s",
instruction, err := ParseInstruction(line) SourceColor, input, DefaultColor, ErrorColor, err, DefaultColor)
if err != nil { return
log.Printf("Error parsing line: %s'%s'%s, error: %s%+v%s", SourceColor, line, DefaultColor, ErrorColor, err, DefaultColor) }
continue defer file.Close()
}
output <- instruction scanner := bufio.NewScanner(file)
} for scanner.Scan() {
if err := scanner.Err(); err != nil { line := scanner.Text()
log.Fatalf("Error reading from stdin: %s%+v%s", ErrorColor, err, DefaultColor) instruction, err := ParseInstruction(line, filepath.Dir(input))
status <- err if err != nil {
return 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()
log.Printf("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)
continue
}
output <- &instruction
}
}
func ReadFromStdin(output chan *LinkInstruction, status chan error) {
defer close(output)
defer close(status)
workdir, _ := os.Getwd()
log.Printf("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)
continue
}
output <- &instruction
}
if err := scanner.Err(); err != nil {
log.Fatalf("Error reading from stdin: %s%+v%s", ErrorColor, err, DefaultColor)
status <- err
return
}
}

1
sync
View File

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

10
sync.yaml Normal file
View File

@@ -0,0 +1,10 @@
- source: main.go
target: test/main.go
- source: README.md
target: test/README.md
- source: sync.yaml
target: test/sync.yaml

26
sync.yaml.example Normal file
View 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

316
util.go
View File

@@ -1,132 +1,184 @@
package main package main
import ( import (
"fmt" "fmt"
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
) )
func IsSymlink(path string) (bool, error) { func IsSymlink(path string) (bool, error) {
fileInfo, err := os.Lstat(path) fileInfo, err := os.Lstat(path)
if err != nil { if err != nil {
return false, err return false, err
} }
// os.ModeSymlink is a bitmask that identifies the symlink mode. // os.ModeSymlink is a bitmask that identifies the symlink mode.
// If the file mode & os.ModeSymlink is non-zero, the file is a symlink. // If the file mode & os.ModeSymlink is non-zero, the file is a symlink.
return fileInfo.Mode()&os.ModeSymlink != 0, nil return fileInfo.Mode()&os.ModeSymlink != 0, nil
} }
func FileExists(path string) bool { func FileExists(path string) bool {
_, err := os.Lstat(path) _, err := os.Lstat(path)
return err == nil return err == nil
} }
func NormalizePath(input string) string { func NormalizePath(input, workdir string) string {
workingdirectory, _ := os.Getwd() input = filepath.Clean(input)
input = strings.ReplaceAll(input, "\\", "/") input = filepath.ToSlash(input)
input = strings.ReplaceAll(input, "\"", "") input = strings.ReplaceAll(input, "\"", "")
if !filepath.IsAbs(input) { if !filepath.IsAbs(input) {
input = workingdirectory + "/" + input log.Printf("Input '%s' is not absolute, prepending work dir '%s'", input, workdir)
} var err error
input = filepath.Join(workdir, input)
return filepath.Clean(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)
func AreSame(lhs string, rhs string) bool { return input
lhsinfo, err := os.Stat(lhs) }
if err != nil { }
return false
} input = filepath.Clean(input)
rhsinfo, err := os.Stat(rhs) input = filepath.ToSlash(input)
if err != nil { return input
return false }
}
func AreSame(lhs string, rhs string) bool {
return os.SameFile(lhsinfo, rhsinfo) lhsinfo, err := os.Stat(lhs)
} if err != nil {
return false
func ConvertHome(input string) (string, error) { }
if strings.Contains(input, "~") { rhsinfo, err := os.Stat(rhs)
homedir, err := os.UserHomeDir() if err != nil {
if err != nil { return false
return input, fmt.Errorf("unable to convert ~ to user directory with error %+v", err) }
}
return os.SameFile(lhsinfo, rhsinfo)
return strings.Replace(input, "~", homedir, 1), nil }
}
return input, nil func ConvertHome(input string) (string, error) {
} if strings.HasPrefix(input, "~/") {
homedir, err := os.UserHomeDir()
func GetSyncFilesRecursively(input string, output chan string, status chan error) { if err != nil {
defer close(output) return input, fmt.Errorf("unable to convert ~ to user directory with error %+v", err)
defer close(status) }
var filesProcessed int32 return strings.Replace(input, "~", homedir, 1), nil
var foldersProcessed int32 }
progressTicker := time.NewTicker(200 * time.Millisecond) return input, nil
defer progressTicker.Stop() }
var wg sync.WaitGroup func GetSyncFilesRecursively(input string, output chan string, status chan error) {
var initial sync.Once defer close(output)
wg.Add(1) defer close(status)
directories := make(chan string, 100000)
workerPool := make(chan struct{}, 4000) var filesProcessed int32
directories <- input var foldersProcessed int32
var activeWorkers int32
go func() {
for { progressTicker := time.NewTicker(200 * time.Millisecond)
fmt.Printf("\rFiles processed: %d; Folders processed: %d; Workers: %d; Directory Stack Size: %d;", filesProcessed, foldersProcessed, len(workerPool), len(directories)) defer progressTicker.Stop()
<-progressTicker.C
} done := make(chan struct{})
}() defer close(done)
log.Printf("%+v", len(workerPool)) directories := make(chan string, 100000)
go func() { workerPool := make(chan struct{}, 4000)
for directory := range directories { directories <- input
workerPool <- struct{}{}
wg.Add(1) go func() {
go func(directory string) { for {
atomic.AddInt32(&foldersProcessed, 1) select {
defer wg.Done() case <-progressTicker.C:
defer func() { <-workerPool }() dirCount := len(directories)
workers := atomic.LoadInt32(&activeWorkers)
files, err := os.ReadDir(directory) fmt.Printf("\rFiles processed: %d; Folders processed: %d; Active workers: %d; Directory queue: %d",
if err != nil { atomic.LoadInt32(&filesProcessed),
log.Printf("Error reading directory %s: %+v", directory, err) atomic.LoadInt32(&foldersProcessed),
return workers,
} dirCount)
case <-done:
for _, file := range files { // Final progress update
// log.Printf("Processing file %s", file.Name()) fmt.Printf("\rFiles processed: %d; Folders processed: %d; Completed successfully\n",
if file.IsDir() { atomic.LoadInt32(&filesProcessed),
directories <- filepath.Join(directory, file.Name()) atomic.LoadInt32(&foldersProcessed))
} else { return
// log.Println(file.Name(), DirRegex.MatchString(file.Name())) }
if FileRegex.MatchString(file.Name()) { }
// log.Printf("Writing") }()
output <- filepath.Join(directory, file.Name())
} allDone := make(chan struct{})
atomic.AddInt32(&filesProcessed, 1)
} go func() {
} var wg sync.WaitGroup
// log.Printf("Done reading directory %s", directory)
go func() {
initial.Do(func() { for {
// Parallelism is very difficult... if atomic.LoadInt32(&activeWorkers) == 0 && len(directories) == 0 {
time.Sleep(250 * time.Millisecond) time.Sleep(10 * time.Millisecond)
wg.Done() if atomic.LoadInt32(&activeWorkers) == 0 && len(directories) == 0 {
}) close(allDone)
}(directory) return
} }
}() }
time.Sleep(50 * time.Millisecond)
wg.Wait() }
log.Printf("Files processed: %d; Folders processed: %d", filesProcessed, foldersProcessed) }()
}
for {
select {
case directory, ok := <-directories:
if !ok {
wg.Wait()
return
}
atomic.AddInt32(&activeWorkers, 1)
go func(dir string) {
workerPool <- struct{}{}
atomic.AddInt32(&foldersProcessed, 1)
processDirectory(dir, directories, output, &filesProcessed)
<-workerPool
atomic.AddInt32(&activeWorkers, -1)
}(directory)
}
}
}()
<-allDone
log.Printf("Files processed: %d; Folders processed: %d",
atomic.LoadInt32(&filesProcessed),
atomic.LoadInt32(&foldersProcessed))
}
func processDirectory(directory string, directories chan<- string, output chan<- string, filesProcessed *int32) {
files, err := os.ReadDir(directory)
if err != nil {
log.Printf("Error reading directory %s: %+v", directory, err)
return
}
for _, file := range files {
if file.IsDir() {
directories <- filepath.Join(directory, file.Name())
} else {
if FileRegex.MatchString(file.Name()) || IsYAMLSyncFile(file.Name()) {
output <- filepath.Join(directory, file.Name())
}
atomic.AddInt32(filesProcessed, 1)
}
}
}
func IsYAMLSyncFile(filename string) bool {
return filename == "sync.yaml" || filename == "sync.yml"
}