80 Commits

Author SHA1 Message Date
9c5f503ef6 Move all summary logic out of main into filesystem.go 2025-11-20 16:14:38 +01:00
2586386cc3 Fix up the summary to log as a table 2025-11-20 15:59:14 +01:00
cb53596c46 Log errors in red in summary 2025-11-20 15:41:36 +01:00
88ef93e9af Fix up the filesystem summary logger 2025-11-20 15:37:58 +01:00
610ba20276 Add version flag 2025-11-20 15:32:59 +01:00
1eda2fcc82 Add a dry run flag 2025-11-20 15:29:27 +01:00
079fc82ab9 Fix the absolutely retarded single match crutch 2025-11-20 15:12:03 +01:00
b35697d227 Implement the Files flag and add some tests 2025-11-20 15:12:03 +01:00
ade7c4d2b2 Don't crash when referenced file not found 2025-11-09 19:27:45 +01:00
dbd736ae81 Fix home ~ resolution 2025-10-19 17:45:15 +02:00
ff76a5399c Fix home resolution issue 2025-10-17 09:33:43 +02:00
3f0791466b Add regression tests for home resolution 2025-10-17 09:33:22 +02:00
dc5eb9cb80 Disable CGO for linux 2025-10-16 17:20:58 +02:00
25a8e2b65a Ensure we're cleaning up after our tests 2025-10-16 17:14:48 +02:00
59faaa181d Fix some hallocinated tests 2025-10-16 15:48:19 +02:00
7bff91679d Implement parallel processing of instructions 2025-10-16 15:42:12 +02:00
cfa7fc73c9 Add more tests 2025-10-16 15:42:12 +02:00
a568a736aa Consolidate tests that had a lot in common 2025-10-16 15:25:59 +02:00
db72688aa2 Improve more assertions across tests 2025-10-16 15:13:12 +02:00
05082d8ff3 Ignore test directory 2025-10-16 14:57:31 +02:00
a4f90c2bc8 Refine tests more 2025-10-16 14:57:16 +02:00
bec5b3cb9c Add more glob tests 2025-10-16 14:45:45 +02:00
018c0797f5 Fix circular reference explosion 2025-10-16 14:41:13 +02:00
a7d5317114 Fix retarded tests 2025-10-16 14:39:51 +02:00
89e29eacee Rework processing into 2 steps (preprocess - process) 2025-10-16 14:28:15 +02:00
4e4e58af83 Hallucinate a fix to -from 2025-10-16 14:20:58 +02:00
eb81ec4162 Implement a "from" to config files that loads other files 2025-10-06 22:36:52 +02:00
21d7f56ccf Write tests for the new refactored functions 2025-10-06 22:05:32 +02:00
8653191df2 Refactor main to decouple logic from actual main() 2025-10-06 20:11:01 +02:00
9da47ce0cf Hallucinate some tests 2025-10-06 20:05:15 +02:00
29bfa2d776 Also attach the linux binary to release
oopsie
2025-10-01 13:22:39 +02:00
c94a7ae8ab Remove log?
Why was log here??
2025-10-01 13:21:15 +02:00
ca57ee728e Add build script 2025-10-01 13:21:08 +02:00
b53628e698 Hallucinate a fix to recurse using doublestar 2025-07-10 20:50:37 +02:00
3f7fd36f84 Fix files being linked as folders 2025-04-15 20:28:49 +02:00
8da1f023a7 Add simple release script 2025-04-13 19:53:30 +02:00
33b3a3d2b6 Improve globbing to better handle both patterns and non patterns 2025-04-13 19:47:52 +02:00
78536c3e19 Implement doublestar wildcards 2025-04-13 19:28:57 +02:00
3a5a333c62 Add LogSuccess 2025-03-11 21:37:55 +01:00
5a2520e3b1 Fix deleting by not deleting at all 2025-03-10 19:08:07 +01:00
12d71dba1c Fix flag inheritance for wildcard links 2025-03-10 19:02:44 +01:00
d94f8db27a Stop logging W as an L 2025-03-10 18:58:34 +01:00
913a279011 Redo old format
Why did I even remove it?
2025-03-10 18:55:25 +01:00
af956110be Implement undoing 2025-03-10 18:53:41 +01:00
8c5d783d2c More betterify logging 2025-03-10 18:49:50 +01:00
6359705714 Fix up logging a little 2025-03-10 18:36:11 +01:00
62bfb91246 Implement wildcards 2025-03-10 18:17:37 +01:00
083d42a9dd No longer support old format 2025-03-10 18:10:59 +01:00
71ea17122c Clean house 2025-03-10 18:08:23 +01:00
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
20 changed files with 6552 additions and 508 deletions

10
.gitignore vendored
View File

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

16
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,16 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Ereshor Workspace",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}",
"cwd": "C:\\Users\\Administrator\\Seafile\\Games-Ereshor"
}
]
}

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

2
build.sh Normal file
View File

@@ -0,0 +1,2 @@
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 Normal file
View File

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

395
filesystem.go Normal file
View File

@@ -0,0 +1,395 @@
package main
import (
"fmt"
"os"
"sort"
"strings"
"sync"
)
var filesystem FileSystem = NewRealFileSystem()
// FileSystem abstracts filesystem operations so we can swap implementations
type FileSystem interface {
Remove(path string) error
RemoveAll(path string) error
MkdirAll(path string, perm os.FileMode) error
Symlink(source, target string) error
Link(source, target string) error
RecordLinkAttempt(kind string, source, target string, err error, dryRun bool)
SummaryLines() []string
SummaryRecords() []operationRecord
IsDryRun() bool
}
const (
opRemove = "remove"
opRemoveAll = "remove_all"
opMkdirAll = "mkdir_all"
opSymlink = "symlink"
opHardlink = "hardlink"
)
type operationRecord struct {
kind string
source string
target string
err error
dryRun bool
}
func (r operationRecord) summaryLine() (string, bool) {
detail := r.detail()
if detail != "" {
return fmt.Sprintf("%s %s → %s (%s) %s",
r.kindLabel(),
r.formattedSource(),
r.formattedTarget(),
r.resultLabel(),
detail), true
}
return fmt.Sprintf("%s %s → %s (%s)",
r.kindLabel(),
r.formattedSource(),
r.formattedTarget(),
r.resultLabel()), true
}
func (r operationRecord) kindLabel() string {
switch r.kind {
case opSymlink:
return "Symlink"
case opHardlink:
return "Hardlink"
case opRemove:
return "Remove"
case opRemoveAll:
return "RemoveAll"
case opMkdirAll:
return "MkdirAll"
default:
return r.kind
}
}
func (r operationRecord) resultLabel() string {
if r.err != nil {
return fmt.Sprintf("%sFAIL%s", BRed, Reset)
}
if r.dryRun {
return fmt.Sprintf("%sDRY-RUN%s", BCyan, Reset)
}
return fmt.Sprintf("%sOK%s", BGreen, Reset)
}
func (r operationRecord) resultLabelPlain() string {
if r.err != nil {
return "FAIL"
}
if r.dryRun {
return "DRY-RUN"
}
return "OK"
}
func (r operationRecord) formattedSource() string {
if r.source == "" {
return "-"
}
return FormatSourcePath(r.source)
}
func (r operationRecord) plainSource() string {
if r.source == "" {
return "-"
}
return r.source
}
func (r operationRecord) formattedTarget() string {
if r.target == "" {
return "-"
}
return FormatTargetPath(r.target)
}
func (r operationRecord) plainTarget() string {
if r.target == "" {
return "-"
}
return r.target
}
func (r operationRecord) detail() string {
if r.err != nil {
return r.err.Error()
}
if r.dryRun {
return "dry-run"
}
return ""
}
type baseFileSystem struct {
mu sync.Mutex
operations []operationRecord
}
func (b *baseFileSystem) addOperation(kind, source, target string, err error, dryRun bool) {
b.mu.Lock()
defer b.mu.Unlock()
b.operations = append(b.operations, operationRecord{
kind: kind,
source: source,
target: target,
err: err,
dryRun: dryRun,
})
}
func (b *baseFileSystem) snapshot() []operationRecord {
b.mu.Lock()
defer b.mu.Unlock()
ops := make([]operationRecord, len(b.operations))
copy(ops, b.operations)
return ops
}
func summarizeOperations(records []operationRecord) []string {
var lines []string
for _, record := range records {
if line, ok := record.summaryLine(); ok {
lines = append(lines, line)
}
}
return lines
}
type realFileSystem struct {
baseFileSystem
}
// NewRealFileSystem returns a filesystem implementation that writes to disk
func NewRealFileSystem() FileSystem {
return &realFileSystem{}
}
func (fs *realFileSystem) Remove(path string) error {
err := os.Remove(path)
fs.addOperation(opRemove, "", path, err, false)
return err
}
func (fs *realFileSystem) RemoveAll(path string) error {
err := os.RemoveAll(path)
fs.addOperation(opRemoveAll, "", path, err, false)
return err
}
func (fs *realFileSystem) MkdirAll(path string, perm os.FileMode) error {
err := os.MkdirAll(path, perm)
fs.addOperation(opMkdirAll, "", path, err, false)
return err
}
func (fs *realFileSystem) Symlink(source, target string) error {
err := os.Symlink(source, target)
fs.RecordLinkAttempt(opSymlink, source, target, err, false)
return err
}
func (fs *realFileSystem) Link(source, target string) error {
err := os.Link(source, target)
fs.RecordLinkAttempt(opHardlink, source, target, err, false)
return err
}
func (fs *realFileSystem) RecordLinkAttempt(kind string, source, target string, err error, dryRun bool) {
fs.addOperation(kind, source, target, err, dryRun)
}
func (fs *realFileSystem) SummaryLines() []string {
return summarizeOperations(fs.snapshot())
}
func (fs *realFileSystem) SummaryRecords() []operationRecord {
return fs.snapshot()
}
func (fs *realFileSystem) IsDryRun() bool {
return false
}
type dryRunFileSystem struct {
baseFileSystem
}
// NewDryRunFileSystem returns a filesystem implementation that only records operations
func NewDryRunFileSystem() FileSystem {
return &dryRunFileSystem{}
}
func (fs *dryRunFileSystem) Remove(path string) error {
fs.addOperation(opRemove, "", path, nil, true)
return nil
}
func (fs *dryRunFileSystem) RemoveAll(path string) error {
fs.addOperation(opRemoveAll, "", path, nil, true)
return nil
}
func (fs *dryRunFileSystem) MkdirAll(path string, perm os.FileMode) error {
fs.addOperation(opMkdirAll, "", path, nil, true)
return nil
}
func (fs *dryRunFileSystem) Symlink(source, target string) error {
fs.RecordLinkAttempt(opSymlink, source, target, nil, true)
return nil
}
func (fs *dryRunFileSystem) Link(source, target string) error {
fs.RecordLinkAttempt(opHardlink, source, target, nil, true)
return nil
}
func (fs *dryRunFileSystem) RecordLinkAttempt(kind string, source, target string, err error, dryRun bool) {
fs.addOperation(kind, source, target, err, dryRun)
}
func (fs *dryRunFileSystem) SummaryLines() []string {
return summarizeOperations(fs.snapshot())
}
func (fs *dryRunFileSystem) SummaryRecords() []operationRecord {
return fs.snapshot()
}
func (fs *dryRunFileSystem) IsDryRun() bool {
return true
}
func BuildSummaryLines(records []operationRecord) []string {
if len(records) == 0 {
return nil
}
sorted := make([]operationRecord, len(records))
copy(sorted, records)
opOrder := map[string]int{
opRemove: 0,
opRemoveAll: 1,
opMkdirAll: 2,
opSymlink: 3,
opHardlink: 4,
}
sort.SliceStable(sorted, func(i, j int) bool {
ti := strings.ToLower(sorted[i].plainTarget())
tj := strings.ToLower(sorted[j].plainTarget())
if ti != tj {
return ti < tj
}
si := strings.ToLower(sorted[i].plainSource())
sj := strings.ToLower(sorted[j].plainSource())
if si != sj {
return si < sj
}
oi := opOrderValue(sorted[i].kind, opOrder)
oj := opOrderValue(sorted[j].kind, opOrder)
if oi != oj {
return oi < oj
}
return sorted[i].detail() < sorted[j].detail()
})
header := []string{"RESULT", "OPERATION", "SOURCE", "TARGET", "DETAIL"}
widths := make([]int, len(header))
for i, h := range header {
widths[i] = len(h)
}
plainRows := make([][]string, len(sorted))
coloredRows := make([][]string, len(sorted))
for i, record := range sorted {
detail := record.detail()
if detail == "" {
detail = "-"
}
plain := []string{
record.resultLabelPlain(),
record.kindLabel(),
record.plainSource(),
record.plainTarget(),
detail,
}
colored := []string{
record.resultLabel(),
record.kindLabel(),
record.formattedSource(),
record.formattedTarget(),
detail,
}
plainRows[i] = plain
coloredRows[i] = colored
for j, val := range plain {
if val == "" {
val = "-"
}
if len(val) > widths[j] {
widths[j] = len(val)
}
}
}
lines := make([]string, 0, len(sorted)+1)
lines = append(lines, formatSummaryRow(header, header, widths))
for i := range coloredRows {
lines = append(lines, formatSummaryRow(coloredRows[i], plainRows[i], widths))
}
return lines
}
func formatSummaryRow(colored, plain []string, widths []int) string {
var b strings.Builder
for i := range colored {
p := plain[i]
if p == "" {
p = "-"
}
col := colored[i]
if col == "" {
col = p
}
pad := widths[i] - len(p)
if pad < 0 {
pad = 0
}
b.WriteString(col)
if pad > 0 {
b.WriteString(strings.Repeat(" ", pad))
}
if i < len(colored)-1 {
b.WriteString(" ")
}
}
return b.String()
}
func opOrderValue(kind string, order map[string]int) int {
if v, ok := order[kind]; ok {
return v
}
return len(order)
}

19
filesystem_test.go Normal file
View File

@@ -0,0 +1,19 @@
package main
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
)
func TestSummaryLineMarksFailuresInRed(t *testing.T) {
fs := NewRealFileSystem()
fs.RecordLinkAttempt(opSymlink, "/tmp/source", "/tmp/target", fmt.Errorf("boom"), false)
lines := fs.SummaryLines()
assert.Equal(t, 1, len(lines))
assert.Contains(t, lines[0], BRed+"FAIL"+Reset)
assert.Contains(t, lines[0], "boom")
}

18
go.mod
View File

@@ -1,3 +1,17 @@
module main module cln
go 1.21.7 go 1.23.6
require gopkg.in/yaml.v3 v3.0.1
require (
git.site.quack-lab.dev/dave/cyutils v1.4.0
github.com/bmatcuk/doublestar/v4 v4.8.1
github.com/stretchr/testify v1.11.1
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
golang.org/x/time v0.12.0 // indirect
)

16
go.sum Normal file
View File

@@ -0,0 +1,16 @@
git.site.quack-lab.dev/dave/cyutils v1.4.0 h1:/Xo3QfLIFNab+axHneWmUK4MyfuObl+qq8whF9vTQpk=
git.site.quack-lab.dev/dave/cyutils v1.4.0/go.mod h1:fBjALu2Cp2u2bDr+E4zbGVMBeIgFzROg+4TCcTNAiQU=
github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38=
github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

49
home_test.go Normal file
View File

@@ -0,0 +1,49 @@
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", false)
// 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,132 +1,612 @@
package main package main
import ( import (
"fmt" "errors"
"log" "fmt"
"os" "os"
"regexp" "path/filepath"
"strconv" "strings"
"strings"
) "github.com/bmatcuk/doublestar/v4"
"gopkg.in/yaml.v3"
type LinkInstruction struct { )
Source string
Target string type LinkInstruction struct {
Force bool Source string `yaml:"source"`
} Target string `yaml:"target"`
Force bool `yaml:"force,omitempty"`
func (instruction *LinkInstruction) String() string { Hard bool `yaml:"hard,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)) Delete bool `yaml:"delete,omitempty"`
} Files bool `yaml:"files,omitempty"`
}
func ParseInstruction(line string) (LinkInstruction, error) {
parts := strings.Split(line, deliminer) type YAMLConfig struct {
instruction := LinkInstruction{} Links []LinkInstruction `yaml:"links"`
From []string `yaml:"from,omitempty"`
if len(parts) < 2 { }
return instruction, fmt.Errorf("invalid format - not enough parameters (must have at least source and target)")
} func (instruction *LinkInstruction) Tidy() {
instruction.Source = strings.ReplaceAll(instruction.Source, "\"", "")
instruction.Source = parts[0] instruction.Source = strings.ReplaceAll(instruction.Source, "\\", "/")
instruction.Target = parts[1] instruction.Source = strings.TrimSpace(instruction.Source)
instruction.Force = false
if len(parts) > 2 { instruction.Target = strings.ReplaceAll(instruction.Target, "\"", "")
res, _ := regexp.MatchString("^(?i)T|TRUE$", parts[2]) instruction.Target = strings.ReplaceAll(instruction.Target, "\\", "/")
instruction.Force = res instruction.Target = strings.TrimSpace(instruction.Target)
} }
instruction.Source, _ = ConvertHome(instruction.Source) func (instruction *LinkInstruction) String() string {
instruction.Target, _ = ConvertHome(instruction.Target) var flags []string
if instruction.Force {
instruction.Source = NormalizePath(instruction.Source) flags = append(flags, "force=true")
instruction.Target = NormalizePath(instruction.Target) }
if instruction.Hard {
return instruction, nil flags = append(flags, "hard=true")
} }
if instruction.Delete {
func (instruction *LinkInstruction) RunSync() error { flags = append(flags, "delete=true")
if !FileExists(instruction.Source) { }
return fmt.Errorf("instruction source %s%s%s does not exist", SourceColor, instruction.Source, DefaultColor) if instruction.Files {
} flags = append(flags, "files=true")
}
if AreSame(instruction.Source, instruction.Target) {
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) flagsStr := ""
return nil if len(flags) > 0 {
} flagsStr = " [" + strings.Join(flags, ", ") + "]"
}
if FileExists(instruction.Target) {
if instruction.Force { return fmt.Sprintf("%s → %s%s",
isSymlink, err := IsSymlink(instruction.Target) FormatSourcePath(instruction.Source),
if err != nil { 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) flagsStr)
} }
if isSymlink { func (instruction *LinkInstruction) Undo() {
log.Printf("Removing symlink at %s%s%s", TargetColor, instruction.Target, DefaultColor) if !FileExists(instruction.Target) {
err = os.Remove(instruction.Target) LogInfo("%s does not exist, skipping", FormatTargetPath(instruction.Target))
if err != nil { return
return fmt.Errorf("failed deleting %s%s%s due to %s%+v%s", TargetColor, instruction.Target, DefaultColor, ErrorColor, err, DefaultColor) }
}
} else { isSymlink, err := IsSymlink(instruction.Target)
return fmt.Errorf("refusing to delte actual (non symlink) file %s%s%s", TargetColor, instruction.Target, DefaultColor) if err != nil {
} LogError("could not determine whether %s is a sym link or not, stopping; err: %v",
} else { FormatTargetPath(instruction.Target), err)
return fmt.Errorf("target %s%s%s exists - handle manually or set the 'forced' flag (3rd field)", TargetColor, instruction.Target, DefaultColor) return
} }
}
if isSymlink {
err := os.Symlink(instruction.Source, instruction.Target) LogInfo("Removing symlink at %s", FormatTargetPath(instruction.Target))
if err != nil { err = filesystem.Remove(instruction.Target)
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) if err != nil {
} LogError("could not remove symlink at %s; err: %v",
log.Printf("Created symlink between %s%s%s and %s%s%s", SourceColor, instruction.Source, DefaultColor, TargetColor, instruction.Target, DefaultColor) FormatTargetPath(instruction.Target), err)
} else {
return nil if filesystem.IsDryRun() {
} LogInfo("[DRY-RUN] Would remove symlink at %s", FormatTargetPath(instruction.Target))
} else {
func (instruction *LinkInstruction) RunAsync(status chan (error)) { LogSuccess("Removed symlink at %s", FormatTargetPath(instruction.Target))
defer close(status) }
if !FileExists(instruction.Source) { }
status <- fmt.Errorf("instruction source %s%s%s does not exist", SourceColor, instruction.Source, DefaultColor) } else {
return LogInfo("%s is not a symlink, skipping", FormatTargetPath(instruction.Target))
} }
}
if 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) func ParseInstruction(line, workdir string) (LinkInstruction, error) {
return line = strings.TrimSpace(line)
} if strings.HasPrefix(line, "#") {
return LinkInstruction{}, fmt.Errorf("comment line")
if FileExists(instruction.Target) { }
if instruction.Force {
isSymlink, err := IsSymlink(instruction.Target) parts := strings.Split(line, deliminer)
if err != nil { instruction := LinkInstruction{}
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 len(parts) < 2 {
} return instruction, fmt.Errorf("invalid format - not enough parameters (must have at least source and target)")
}
if isSymlink {
log.Printf("Removing symlink at %s%s%s", TargetColor, instruction.Target, DefaultColor) instruction.Source = strings.TrimSpace(parts[0])
err = os.Remove(instruction.Target) instruction.Target = strings.TrimSpace(parts[1])
if err != nil {
status <- fmt.Errorf("failed deleting %s%s%s due to %s%+v%s", TargetColor, instruction.Target, DefaultColor, ErrorColor, err, DefaultColor) for i := 2; i < len(parts); i++ {
return flagPart := strings.TrimSpace(parts[i])
}
} else { // Support for legacy format (backward compatibility)
status <- fmt.Errorf("refusing to delte actual (non symlink) file %s%s%s", TargetColor, instruction.Target, DefaultColor) if !strings.Contains(flagPart, "=") {
return // Legacy format: positional boolean flags
} switch i {
} else { case 2: // Force flag (3rd position)
status <- fmt.Errorf("target %s%s%s exists - handle manually or set the 'forced' flag (3rd field)", TargetColor, instruction.Target, DefaultColor) instruction.Force = isTrue(flagPart)
return case 3: // Hard flag (4th position)
} instruction.Hard = isTrue(flagPart)
} case 4: // Delete flag (5th position)
instruction.Delete = isTrue(flagPart)
err := os.Symlink(instruction.Source, instruction.Target) if instruction.Delete {
if err != nil { instruction.Force = true // Delete implies Force
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 case 5: // Files flag (6th position)
} instruction.Files = isTrue(flagPart)
log.Printf("Created symlink between %s%s%s and %s%s%s", SourceColor, instruction.Source, DefaultColor, TargetColor, instruction.Target, DefaultColor) }
continue
status <- nil }
}
// New format: named flags (name=value)
nameValue := strings.SplitN(flagPart, "=", 2)
if len(nameValue) != 2 {
// Skip malformed flags
continue
}
flagName := strings.ToLower(strings.TrimSpace(nameValue[0]))
flagValue := strings.TrimSpace(nameValue[1])
switch flagName {
case "force", "f":
instruction.Force = isTrue(flagValue)
case "hard", "h":
instruction.Hard = isTrue(flagValue)
case "delete", "d":
instruction.Delete = isTrue(flagValue)
if instruction.Delete {
instruction.Force = true // Delete implies Force
}
case "files":
instruction.Files = isTrue(flagValue)
}
}
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
}
linkKind := opSymlink
if instruction.Hard {
linkKind = opHardlink
}
reportErr := func(err error) {
filesystem.RecordLinkAttempt(linkKind, instruction.Source, instruction.Target, err, filesystem.IsDryRun())
status <- err
}
if !FileExists(instruction.Source) {
reportErr(fmt.Errorf("instruction source %s does not exist", FormatSourcePath(instruction.Source)))
return
}
if instruction.Files {
info, err := os.Stat(instruction.Source)
if err != nil {
reportErr(fmt.Errorf("could not stat source %s; err: %v", FormatSourcePath(instruction.Source), err))
return
}
if info.IsDir() {
reportErr(fmt.Errorf("source %s is a directory but files=true", 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.Files {
if info, err := os.Stat(instruction.Target); err == nil && info.IsDir() {
reportErr(fmt.Errorf("target %s is a directory but files=true", FormatTargetPath(instruction.Target)))
return
}
}
if instruction.Force {
isSymlink, err := IsSymlink(instruction.Target)
if err != nil {
reportErr(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 {
reportErr(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 := filesystem.Remove(instruction.Target)
if err != nil {
reportErr(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 = filesystem.Remove(instruction.Target)
if err != nil {
reportErr(fmt.Errorf("failed deleting %s due to %v",
FormatTargetPath(instruction.Target), err))
return
}
} else {
if !instruction.Delete {
reportErr(fmt.Errorf("refusing to delte actual (non symlink) file %s",
FormatTargetPath(instruction.Target)))
return
}
LogImportant("Deleting (!!!) %s", instruction.Target)
err = filesystem.RemoveAll(instruction.Target)
if err != nil {
reportErr(fmt.Errorf("failed deleting %s due to %v",
FormatTargetPath(instruction.Target), err))
return
}
}
} else {
reportErr(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 = filesystem.MkdirAll(targetDir, 0755)
if err != nil {
reportErr(fmt.Errorf("failed creating directory %s due to %v",
FormatTargetPath(targetDir), err))
return
}
}
linkType := "symlink"
if instruction.Hard {
linkType = "hardlink"
}
var err error
if instruction.Hard {
err = filesystem.Link(instruction.Source, instruction.Target)
} else {
err = filesystem.Symlink(instruction.Source, instruction.Target)
}
if err != nil {
status <- fmt.Errorf("failed creating %s between %s and %s with error %v",
linkType,
FormatSourcePath(instruction.Source),
FormatTargetPath(instruction.Target),
err)
return
}
if filesystem.IsDryRun() {
LogInfo("[DRY-RUN] Would create %s between %s and %s",
linkType,
FormatSourcePath(instruction.Source),
FormatTargetPath(instruction.Target))
} else {
LogSuccess("Created %s between %s and %s",
linkType,
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 {
var absRefErr *absoluteReferenceError
if errors.As(err, &absRefErr) {
LogError("Referenced file not found: %s (from %s), skipping", instr.Source, filename)
continue
}
if errors.Is(err, os.ErrNotExist) {
LogError("Referenced file not found: %s (from %s), stopping", instr.Source, filename)
}
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
type absoluteReferenceError struct {
path string
err error
}
func (e *absoluteReferenceError) Error() string {
return e.err.Error()
}
func (e *absoluteReferenceError) Unwrap() error {
return e.err
}
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)
}
refIsAbsolute := filepath.IsAbs(fromPath)
// 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)
links, err := parseYAMLFileRecursive(fromPath, fromWorkdir, visited)
if err != nil {
if errors.Is(err, os.ErrNotExist) && refIsAbsolute {
return nil, &absoluteReferenceError{path: fromPath, err: err}
}
return nil, err
}
return links, nil
}
// 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, instr.Files)
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
newlinks[i].Files = instr.Files
}
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, filesOnly bool) (links []LinkInstruction, err error) {
if strings.TrimSpace(source) == "" {
return nil, nil
}
// 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)
}
// Normalize path to convert backslashes to forward slashes before pattern processing
source = NormalizePath(source, workdir)
if !strings.ContainsAny(source, "*?[{") {
info, statErr := os.Stat(source)
if statErr != nil {
if os.IsNotExist(statErr) {
LogInfo("Literal source %s does not exist, skipping", FormatSourcePath(source))
return nil, nil
}
return nil, fmt.Errorf("failed to stat literal source %s: %w", source, statErr)
}
if filesOnly && info.IsDir() {
LogInfo("Files-only mode: skipping directory %s", FormatSourcePath(source))
return nil, nil
}
return []LinkInstruction{
{
Source: source,
Target: target,
},
}, nil
}
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)
}
for _, file := range files {
fullPath := filepath.Join(static, file)
info, err := os.Stat(fullPath)
if err != nil {
LogError("Failed to stat %s: %v", FormatSourcePath(fullPath), err)
continue
}
if filesOnly && info.IsDir() {
LogInfo("Files-only mode: skipping directory %s", FormatSourcePath(fullPath))
continue
}
if info.IsDir() {
// We don't care about matched directories
// We want files within them
LogInfo("Skipping directory %s", file)
continue
}
targetPath := filepath.Join(target, file)
links = append(links, LinkInstruction{
Source: fullPath,
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"
}

4561
instruction_test.go Normal file

File diff suppressed because it is too large Load Diff

99
logger.go Normal file
View File

@@ -0,0 +1,99 @@
package main
import (
"fmt"
"log"
)
// Message type prefixes
const (
InfoPrefix = "INFO"
ErrorPrefix = "ERROR"
WarningPrefix = "WARN"
SourcePrefix = "SOURCE"
TargetPrefix = "TARGET"
PathPrefix = "PATH"
ImportantPrefix = "IMPORTANT"
SuccessPrefix = "DONE"
)
// LogInfo logs an informational message
func LogInfo(format string, args ...interface{}) {
message := fmt.Sprintf(format, args...)
log.Printf("%s[%s]%s %s", BGreen, InfoPrefix, Reset, message)
}
// LogSuccess logs a success message
func LogSuccess(format string, args ...interface{}) {
message := fmt.Sprintf(format, args...)
log.Printf("%s%s[%s]%s %s", BBlue, On_Blue, SuccessPrefix, Reset, message)
}
// LogSource logs a message about a source file/path with proper coloring
func LogSource(format string, args ...interface{}) {
message := fmt.Sprintf(format, args...)
log.Printf("%s[%s]%s %s%s%s", BPurple, SourcePrefix, Reset, SourceColor, message, Reset)
}
// LogTarget logs a message about a target file/path with proper coloring
func LogTarget(format string, args ...interface{}) {
message := fmt.Sprintf(format, args...)
log.Printf("%s[%s]%s %s%s%s", BYellow, TargetPrefix, Reset, TargetColor, message, Reset)
}
// LogPath logs a message about a path with proper coloring
func LogPath(format string, args ...interface{}) {
message := fmt.Sprintf(format, args...)
log.Printf("%s[%s]%s %s%s%s", BGreen, PathPrefix, Reset, PathColor, message, Reset)
}
// LogImportant logs a message that needs attention with proper coloring
func LogImportant(format string, args ...interface{}) {
message := fmt.Sprintf(format, args...)
log.Printf("%s[%s]%s %s%s%s", BRed, ImportantPrefix, Reset, ImportantColor, message, Reset)
}
// LogError logs an error message with proper coloring that won't be cut off
func LogError(format string, args ...interface{}) {
// Format the message first with normal text (no red coloring)
message := fmt.Sprintf(format, args...)
// The Error prefix itself is bold red on a light background for maximum visibility
prefix := fmt.Sprintf("%s%s[%s]%s ", BRed, On_White, ErrorPrefix, Reset)
// The message is in default color (no red), only the [ERROR] prefix is colored
log.Printf("%s%s", prefix, message)
}
// FormatSourcePath returns a source path with proper coloring
func FormatSourcePath(path string) string {
return fmt.Sprintf("%s%s%s", SourceColor, path, Reset)
}
// FormatTargetPath returns a target path with proper coloring
func FormatTargetPath(path string) string {
return fmt.Sprintf("%s%s%s", TargetColor, path, Reset)
}
// FormatPathValue returns a path with proper coloring
func FormatPathValue(path string) string {
return fmt.Sprintf("%s%s%s", PathColor, path, Reset)
}
// FormatErrorValue returns an error value without any additional formatting
// Since error messages are no longer red, we don't need special formatting
func FormatErrorValue(err error) string {
if err == nil {
return ""
}
// Just return the error string with no color formatting
return fmt.Sprintf("%v", err)
}
// FormatErrorMessage formats an error message with no additional color formatting,
// while preserving the special formatting of embedded source/target/path elements.
func FormatErrorMessage(format string, args ...interface{}) string {
// This just formats the message with no additional color formatting
// The path formatting will still be preserved
return fmt.Sprintf(format, args...)
}

624
main.go
View File

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

52
release.sh Normal file
View File

@@ -0,0 +1,52 @@
#!/bin/bash
echo "Figuring out the tag..."
TAG=$(git describe --tags --exact-match 2>/dev/null || echo "")
if [ -z "$TAG" ]; then
# Get the latest tag
LATEST_TAG=$(git describe --tags $(git rev-list --tags --max-count=1))
# Increment the patch version
IFS='.' read -r -a VERSION_PARTS <<< "$LATEST_TAG"
VERSION_PARTS[2]=$((VERSION_PARTS[2]+1))
TAG="${VERSION_PARTS[0]}.${VERSION_PARTS[1]}.${VERSION_PARTS[2]}"
# Create a new tag
git tag $TAG
git push origin $TAG
fi
echo "Tag: $TAG"
echo "Building the thing..."
sh build.sh
sh install.sh
echo "Creating a release..."
TOKEN="$GITEA_API_KEY"
GITEA="https://git.site.quack-lab.dev"
REPO="dave/synclib"
# Create a release
RELEASE_RESPONSE=$(curl -s -X POST \
-H "Authorization: token $TOKEN" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{
"tag_name": "'"$TAG"'",
"name": "'"$TAG"'",
"draft": false,
"prerelease": false
}' \
$GITEA/api/v1/repos/$REPO/releases)
# Extract the release ID
echo $RELEASE_RESPONSE
RELEASE_ID=$(echo $RELEASE_RESPONSE | awk -F'"id":' '{print $2+0; exit}')
echo "Release ID: $RELEASE_ID"
echo "Uploading the things..."
curl -X POST \
-H "Authorization: token $TOKEN" \
-F "attachment=@cln.exe" \
"$GITEA/api/v1/repos/$REPO/releases/${RELEASE_ID}/assets?name=cln.exe"
curl -X POST \
-H "Authorization: token $TOKEN" \
-F "attachment=@cln" \
"$GITEA/api/v1/repos/$REPO/releases/${RELEASE_ID}/assets?name=cln"

1
sync
View File

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

6
sync.yaml Normal file
View File

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

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

230
util.go
View File

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