Compare commits
54 Commits
e812db0dec
...
v1.3.0
Author | SHA1 | Date | |
---|---|---|---|
eb81ec4162 | |||
21d7f56ccf | |||
8653191df2 | |||
9da47ce0cf | |||
29bfa2d776 | |||
c94a7ae8ab | |||
ca57ee728e | |||
b53628e698 | |||
3f7fd36f84 | |||
8da1f023a7 | |||
33b3a3d2b6 | |||
78536c3e19 | |||
3a5a333c62 | |||
5a2520e3b1 | |||
12d71dba1c | |||
d94f8db27a | |||
913a279011 | |||
af956110be | |||
8c5d783d2c | |||
6359705714 | |||
62bfb91246 | |||
083d42a9dd | |||
71ea17122c | |||
83477d5f18 | |||
125bf78c16 | |||
edaa699c20 | |||
5b2da09eb2 | |||
1c23ad0cfd | |||
653e883742 | |||
41bac18525 | |||
02106824fd | |||
cd42bc1fb8 | |||
41123846d1 | |||
205f8314d6 | |||
290e6fdaba | |||
d98ecd787a | |||
1a6992e2a7 | |||
fc59878389 | |||
ff1af19088 | |||
e6bb1f0c53 | |||
e149103d21 | |||
ebccc49d34 | |||
55dc061c31 | |||
2c49b65502 | |||
1f21965288 | |||
b8e7bc3576 | |||
![]() |
595a11552c | ||
2a7740d8d7 | |||
7580ca5399 | |||
58cce74ce8 | |||
e022a838ba | |||
0a627ae9ca | |||
d72644aec3 | |||
eeb8dac3a0 |
9
.gitignore
vendored
9
.gitignore
vendored
@@ -1,2 +1,7 @@
|
|||||||
|
*.exe
|
||||||
main.exe
|
*.exe
|
||||||
|
cln
|
||||||
|
cln.log
|
||||||
|
.qodo
|
||||||
|
*.log
|
||||||
|
*.out
|
||||||
|
16
.vscode/launch.json
vendored
Normal file
16
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
// Use IntelliSense to learn about possible attributes.
|
||||||
|
// Hover to view descriptions of existing attributes.
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Ereshor Workspace",
|
||||||
|
"type": "go",
|
||||||
|
"request": "launch",
|
||||||
|
"mode": "auto",
|
||||||
|
"program": "${workspaceFolder}",
|
||||||
|
"cwd": "C:\\Users\\Administrator\\Seafile\\Games-Ereshor"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
89
README.md
Normal file
89
README.md
Normal 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
2
build.sh
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
GOOS=windows GOARCH=amd64 go build -o cln.exe .
|
||||||
|
GOOS=linux GOARCH=amd64 go build -o cln .
|
99
colors.go
Normal file
99
colors.go
Normal 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)])
|
||||||
|
}
|
@@ -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
|
14
go.mod
14
go.mod
@@ -1,3 +1,15 @@
|
|||||||
module main
|
module cln
|
||||||
|
|
||||||
go 1.21.7
|
go 1.21.7
|
||||||
|
|
||||||
|
require gopkg.in/yaml.v3 v3.0.1
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/bmatcuk/doublestar/v4 v4.8.1
|
||||||
|
github.com/stretchr/testify v1.11.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
)
|
||||||
|
12
go.sum
Normal file
12
go.sum
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38=
|
||||||
|
github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
582
instruction.go
582
instruction.go
@@ -1,132 +1,450 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"os"
|
||||||
"os"
|
"path/filepath"
|
||||||
"regexp"
|
"strings"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"github.com/bmatcuk/doublestar/v4"
|
||||||
)
|
"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)
|
From []string `yaml:"from,omitempty"`
|
||||||
instruction := LinkInstruction{}
|
}
|
||||||
|
|
||||||
if len(parts) < 2 {
|
func (instruction *LinkInstruction) Tidy() {
|
||||||
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.ReplaceAll(instruction.Source, "\\", "/")
|
||||||
|
instruction.Source = strings.TrimSpace(instruction.Source)
|
||||||
instruction.Source = parts[0]
|
|
||||||
instruction.Target = parts[1]
|
instruction.Target = strings.ReplaceAll(instruction.Target, "\"", "")
|
||||||
instruction.Force = false
|
instruction.Target = strings.ReplaceAll(instruction.Target, "\\", "/")
|
||||||
if len(parts) > 2 {
|
instruction.Target = strings.TrimSpace(instruction.Target)
|
||||||
res, _ := regexp.MatchString("^(?i)T|TRUE$", parts[2])
|
}
|
||||||
instruction.Force = res
|
|
||||||
}
|
func (instruction *LinkInstruction) String() string {
|
||||||
|
var flags []string
|
||||||
instruction.Source, _ = ConvertHome(instruction.Source)
|
if instruction.Force {
|
||||||
instruction.Target, _ = ConvertHome(instruction.Target)
|
flags = append(flags, "force=true")
|
||||||
|
}
|
||||||
instruction.Source = NormalizePath(instruction.Source)
|
if instruction.Hard {
|
||||||
instruction.Target = NormalizePath(instruction.Target)
|
flags = append(flags, "hard=true")
|
||||||
|
}
|
||||||
return instruction, nil
|
if instruction.Delete {
|
||||||
}
|
flags = append(flags, "delete=true")
|
||||||
|
}
|
||||||
func (instruction *LinkInstruction) RunSync() error {
|
|
||||||
if !FileExists(instruction.Source) {
|
flagsStr := ""
|
||||||
return fmt.Errorf("instruction source %s%s%s does not exist", SourceColor, instruction.Source, DefaultColor)
|
if len(flags) > 0 {
|
||||||
}
|
flagsStr = " [" + strings.Join(flags, ", ") + "]"
|
||||||
|
}
|
||||||
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)
|
return fmt.Sprintf("%s → %s%s",
|
||||||
return nil
|
FormatSourcePath(instruction.Source),
|
||||||
}
|
FormatTargetPath(instruction.Target),
|
||||||
|
flagsStr)
|
||||||
if FileExists(instruction.Target) {
|
}
|
||||||
if instruction.Force {
|
|
||||||
isSymlink, err := IsSymlink(instruction.Target)
|
func (instruction *LinkInstruction) Undo() {
|
||||||
if err != nil {
|
if !FileExists(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)
|
LogInfo("%s does not exist, skipping", FormatTargetPath(instruction.Target))
|
||||||
}
|
return
|
||||||
|
}
|
||||||
if isSymlink {
|
|
||||||
log.Printf("Removing symlink at %s%s%s", TargetColor, instruction.Target, DefaultColor)
|
isSymlink, err := IsSymlink(instruction.Target)
|
||||||
err = os.Remove(instruction.Target)
|
if err != nil {
|
||||||
if err != nil {
|
LogError("could not determine whether %s is a sym link or not, stopping; err: %v",
|
||||||
return fmt.Errorf("failed deleting %s%s%s due to %s%+v%s", TargetColor, instruction.Target, DefaultColor, ErrorColor, err, DefaultColor)
|
FormatTargetPath(instruction.Target), err)
|
||||||
}
|
return
|
||||||
} else {
|
}
|
||||||
return fmt.Errorf("refusing to delte actual (non symlink) file %s%s%s", TargetColor, instruction.Target, DefaultColor)
|
|
||||||
}
|
if isSymlink {
|
||||||
} else {
|
LogInfo("Removing symlink at %s", FormatTargetPath(instruction.Target))
|
||||||
return fmt.Errorf("target %s%s%s exists - handle manually or set the 'forced' flag (3rd field)", TargetColor, instruction.Target, DefaultColor)
|
err = os.Remove(instruction.Target)
|
||||||
}
|
if err != nil {
|
||||||
}
|
LogError("could not remove symlink at %s; err: %v",
|
||||||
|
FormatTargetPath(instruction.Target), err)
|
||||||
err := os.Symlink(instruction.Source, instruction.Target)
|
}
|
||||||
if err != nil {
|
LogSuccess("Removed symlink at %s", FormatTargetPath(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)
|
} else {
|
||||||
}
|
LogInfo("%s is not a symlink, skipping", FormatTargetPath(instruction.Target))
|
||||||
log.Printf("Created symlink between %s%s%s and %s%s%s", SourceColor, instruction.Source, DefaultColor, TargetColor, instruction.Target, DefaultColor)
|
}
|
||||||
|
}
|
||||||
return nil
|
|
||||||
}
|
func ParseInstruction(line, workdir string) (LinkInstruction, error) {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
func (instruction *LinkInstruction) RunAsync(status chan (error)) {
|
if strings.HasPrefix(line, "#") {
|
||||||
defer close(status)
|
return LinkInstruction{}, fmt.Errorf("comment line")
|
||||||
if !FileExists(instruction.Source) {
|
}
|
||||||
status <- fmt.Errorf("instruction source %s%s%s does not exist", SourceColor, instruction.Source, DefaultColor)
|
|
||||||
return
|
parts := strings.Split(line, deliminer)
|
||||||
}
|
instruction := LinkInstruction{}
|
||||||
|
|
||||||
if AreSame(instruction.Source, instruction.Target) {
|
if len(parts) < 2 {
|
||||||
status <- fmt.Errorf("source %s%s%s and target %s%s%s are the same, %snothing to do...%s", SourceColor, instruction.Source, DefaultColor, TargetColor, instruction.Target, DefaultColor, PathColor, DefaultColor)
|
return instruction, fmt.Errorf("invalid format - not enough parameters (must have at least source and target)")
|
||||||
return
|
}
|
||||||
}
|
|
||||||
|
instruction.Source = strings.TrimSpace(parts[0])
|
||||||
if FileExists(instruction.Target) {
|
instruction.Target = strings.TrimSpace(parts[1])
|
||||||
if instruction.Force {
|
|
||||||
isSymlink, err := IsSymlink(instruction.Target)
|
for i := 2; i < len(parts); i++ {
|
||||||
if err != nil {
|
flagPart := strings.TrimSpace(parts[i])
|
||||||
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
|
// Support for legacy format (backward compatibility)
|
||||||
}
|
if !strings.Contains(flagPart, "=") {
|
||||||
|
// Legacy format: positional boolean flags
|
||||||
if isSymlink {
|
switch i {
|
||||||
log.Printf("Removing symlink at %s%s%s", TargetColor, instruction.Target, DefaultColor)
|
case 2: // Force flag (3rd position)
|
||||||
err = os.Remove(instruction.Target)
|
instruction.Force = isTrue(flagPart)
|
||||||
if err != nil {
|
case 3: // Hard flag (4th position)
|
||||||
status <- fmt.Errorf("failed deleting %s%s%s due to %s%+v%s", TargetColor, instruction.Target, DefaultColor, ErrorColor, err, DefaultColor)
|
instruction.Hard = isTrue(flagPart)
|
||||||
return
|
case 4: // Delete flag (5th position)
|
||||||
}
|
instruction.Delete = isTrue(flagPart)
|
||||||
} else {
|
if instruction.Delete {
|
||||||
status <- fmt.Errorf("refusing to delte actual (non symlink) file %s%s%s", TargetColor, instruction.Target, DefaultColor)
|
instruction.Force = true // Delete implies Force
|
||||||
return
|
}
|
||||||
}
|
}
|
||||||
} else {
|
continue
|
||||||
status <- fmt.Errorf("target %s%s%s exists - handle manually or set the 'forced' flag (3rd field)", TargetColor, instruction.Target, DefaultColor)
|
}
|
||||||
return
|
|
||||||
}
|
// New format: named flags (name=value)
|
||||||
}
|
nameValue := strings.SplitN(flagPart, "=", 2)
|
||||||
|
if len(nameValue) != 2 {
|
||||||
err := os.Symlink(instruction.Source, instruction.Target)
|
// Skip malformed flags
|
||||||
if err != nil {
|
continue
|
||||||
status <- fmt.Errorf("failed creating symlink between %s%s%s and %s%s%s with error %s%+v%s", SourceColor, instruction.Source, DefaultColor, TargetColor, instruction.Target, DefaultColor, ErrorColor, err, DefaultColor)
|
}
|
||||||
return
|
|
||||||
}
|
flagName := strings.ToLower(strings.TrimSpace(nameValue[0]))
|
||||||
log.Printf("Created symlink between %s%s%s and %s%s%s", SourceColor, instruction.Source, DefaultColor, TargetColor, instruction.Target, DefaultColor)
|
flagValue := strings.TrimSpace(nameValue[1])
|
||||||
|
|
||||||
status <- nil
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
instruction.Tidy()
|
||||||
|
instruction.Source, _ = ConvertHome(instruction.Source)
|
||||||
|
instruction.Target, _ = ConvertHome(instruction.Target)
|
||||||
|
|
||||||
|
instruction.Source = NormalizePath(instruction.Source, workdir)
|
||||||
|
instruction.Target = NormalizePath(instruction.Target, workdir)
|
||||||
|
|
||||||
|
return instruction, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTrue(value string) bool {
|
||||||
|
value = strings.ToLower(strings.TrimSpace(value))
|
||||||
|
return value == "true" || value == "t" || value == "yes" || value == "y" || value == "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (instruction *LinkInstruction) RunAsync(status chan (error)) {
|
||||||
|
defer close(status)
|
||||||
|
if undo {
|
||||||
|
instruction.Undo()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !FileExists(instruction.Source) {
|
||||||
|
status <- fmt.Errorf("instruction source %s does not exist", FormatSourcePath(instruction.Source))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !instruction.Force && AreSame(instruction.Source, instruction.Target) {
|
||||||
|
//status <- fmt.Errorf("source %s and target %s are the same, nothing to do...",
|
||||||
|
// FormatSourcePath(instruction.Source),
|
||||||
|
// FormatTargetPath(instruction.Target))
|
||||||
|
LogInfo("Source %s and target %s are the same, nothing to do...",
|
||||||
|
FormatSourcePath(instruction.Source),
|
||||||
|
FormatTargetPath(instruction.Target))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if FileExists(instruction.Target) {
|
||||||
|
if instruction.Force {
|
||||||
|
isSymlink, err := IsSymlink(instruction.Target)
|
||||||
|
if err != nil {
|
||||||
|
status <- fmt.Errorf("could not determine whether %s is a sym link or not, stopping; err: %v",
|
||||||
|
FormatTargetPath(instruction.Target), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if instruction.Hard {
|
||||||
|
info, err := os.Stat(instruction.Target)
|
||||||
|
if err != nil {
|
||||||
|
status <- fmt.Errorf("could not stat %s, stopping; err: %v",
|
||||||
|
FormatTargetPath(instruction.Target), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if info.Mode().IsRegular() && info.Name() == filepath.Base(instruction.Source) {
|
||||||
|
LogTarget("Overwriting existing file %s", instruction.Target)
|
||||||
|
err := os.Remove(instruction.Target)
|
||||||
|
if err != nil {
|
||||||
|
status <- fmt.Errorf("could not remove existing file %s; err: %v",
|
||||||
|
FormatTargetPath(instruction.Target), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if isSymlink {
|
||||||
|
LogTarget("Removing symlink at %s", instruction.Target)
|
||||||
|
err = os.Remove(instruction.Target)
|
||||||
|
if err != nil {
|
||||||
|
status <- fmt.Errorf("failed deleting %s due to %v",
|
||||||
|
FormatTargetPath(instruction.Target), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if !instruction.Delete {
|
||||||
|
status <- fmt.Errorf("refusing to delte actual (non symlink) file %s",
|
||||||
|
FormatTargetPath(instruction.Target))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
LogImportant("Deleting (!!!) %s", instruction.Target)
|
||||||
|
err = os.RemoveAll(instruction.Target)
|
||||||
|
if err != nil {
|
||||||
|
status <- fmt.Errorf("failed deleting %s due to %v",
|
||||||
|
FormatTargetPath(instruction.Target), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
status <- fmt.Errorf("target %s exists - handle manually or set the 'forced' flag (3rd field)",
|
||||||
|
FormatTargetPath(instruction.Target))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
targetDir := filepath.Dir(instruction.Target)
|
||||||
|
if _, err := os.Stat(targetDir); os.IsNotExist(err) {
|
||||||
|
err = os.MkdirAll(targetDir, 0755)
|
||||||
|
if err != nil {
|
||||||
|
status <- fmt.Errorf("failed creating directory %s due to %v",
|
||||||
|
FormatTargetPath(targetDir), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
if instruction.Hard {
|
||||||
|
err = os.Link(instruction.Source, instruction.Target)
|
||||||
|
} else {
|
||||||
|
err = os.Symlink(instruction.Source, instruction.Target)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
status <- fmt.Errorf("failed creating symlink between %s and %s with error %v",
|
||||||
|
FormatSourcePath(instruction.Source),
|
||||||
|
FormatTargetPath(instruction.Target),
|
||||||
|
err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
LogSuccess("Created symlink between %s and %s",
|
||||||
|
FormatSourcePath(instruction.Source),
|
||||||
|
FormatTargetPath(instruction.Target))
|
||||||
|
|
||||||
|
status <- nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseYAMLFile(filename, workdir string) ([]LinkInstruction, error) {
|
||||||
|
data, err := os.ReadFile(filename)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error reading YAML file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
expanded := []LinkInstruction{}
|
||||||
|
for _, link := range config.Links {
|
||||||
|
LogSource("Expanding pattern source %s in YAML file %s", link.Source, filename)
|
||||||
|
newlinks, err := ExpandPattern(link.Source, workdir, link.Target)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error expanding pattern: %w", err)
|
||||||
|
}
|
||||||
|
// "Clone" the original link instruction for each expanded link
|
||||||
|
for i := range newlinks {
|
||||||
|
newlinks[i].Delete = link.Delete
|
||||||
|
newlinks[i].Hard = link.Hard
|
||||||
|
newlinks[i].Force = link.Force
|
||||||
|
}
|
||||||
|
LogInfo("Expanded pattern %s in YAML file %s to %d links",
|
||||||
|
FormatSourcePath(link.Source), FormatSourcePath(filename), len(newlinks))
|
||||||
|
expanded = append(expanded, newlinks...)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range expanded {
|
||||||
|
link := &expanded[i]
|
||||||
|
|
||||||
|
link.Tidy()
|
||||||
|
link.Source, _ = ConvertHome(link.Source)
|
||||||
|
link.Target, _ = ConvertHome(link.Target)
|
||||||
|
link.Source = NormalizePath(link.Source, workdir)
|
||||||
|
link.Target = NormalizePath(link.Target, workdir)
|
||||||
|
|
||||||
|
// If Delete is true, Force must also be true
|
||||||
|
if link.Delete {
|
||||||
|
link.Force = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return expanded, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseYAMLFileRecursive parses a YAML file and recursively processes any "From" references
|
||||||
|
func ParseYAMLFileRecursive(filename, workdir string) ([]LinkInstruction, error) {
|
||||||
|
visited := make(map[string]bool)
|
||||||
|
return parseYAMLFileRecursive(filename, workdir, visited)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseYAMLFileRecursive is the internal recursive function that tracks visited files to prevent cycles
|
||||||
|
func parseYAMLFileRecursive(filename, workdir string, visited map[string]bool) ([]LinkInstruction, error) {
|
||||||
|
// Normalize the filename to prevent cycles with different path representations
|
||||||
|
normalizedFilename, err := filepath.Abs(filename)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error normalizing filename: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for cycles
|
||||||
|
if visited[normalizedFilename] {
|
||||||
|
return nil, fmt.Errorf("circular reference detected: %s", filename)
|
||||||
|
}
|
||||||
|
visited[normalizedFilename] = true
|
||||||
|
defer delete(visited, normalizedFilename)
|
||||||
|
|
||||||
|
// Parse the current file
|
||||||
|
instructions, err := ParseYAMLFile(filename, workdir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the file to check for "From" references
|
||||||
|
data, err := os.ReadFile(filename)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error reading YAML file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var config YAMLConfig
|
||||||
|
err = yaml.Unmarshal(data, &config)
|
||||||
|
if err != nil {
|
||||||
|
// If parsing as YAMLConfig fails, there are no "From" references to process
|
||||||
|
return instructions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process "From" references
|
||||||
|
for _, fromFile := range config.From {
|
||||||
|
// Convert relative paths to absolute paths based on the current file's directory
|
||||||
|
fromPath := fromFile
|
||||||
|
if !filepath.IsAbs(fromPath) {
|
||||||
|
currentDir := filepath.Dir(filename)
|
||||||
|
fromPath = filepath.Join(currentDir, fromPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize the path
|
||||||
|
fromPath = filepath.Clean(fromPath)
|
||||||
|
|
||||||
|
// Recursively parse the referenced file
|
||||||
|
// Use the directory of the referenced file as the workdir for pattern expansion
|
||||||
|
fromWorkdir := filepath.Dir(fromPath)
|
||||||
|
fromInstructions, err := parseYAMLFileRecursive(fromPath, fromWorkdir, visited)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error parsing referenced file %s: %w", fromFile, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append the instructions from the referenced file
|
||||||
|
instructions = append(instructions, fromInstructions...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return instructions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExpandPattern(source, workdir, target string) (links []LinkInstruction, err error) {
|
||||||
|
static, pattern := doublestar.SplitPattern(source)
|
||||||
|
if static == "" || static == "." {
|
||||||
|
static = workdir
|
||||||
|
}
|
||||||
|
LogInfo("Static part: %s", static)
|
||||||
|
LogInfo("Pattern part: %s", pattern)
|
||||||
|
|
||||||
|
files, err := doublestar.Glob(os.DirFS(static), pattern)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error expanding pattern: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
targetIsFile := false
|
||||||
|
if info, err := os.Stat(target); err == nil && !info.IsDir() {
|
||||||
|
targetIsFile = true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
if len(files) == 1 {
|
||||||
|
// Special case: if there is only one file
|
||||||
|
// This should only ever happen if our source is a path (and not a glob!)
|
||||||
|
// And our target is a path
|
||||||
|
// ...but it will also happen if the source IS a glob and it happens to match ONE file
|
||||||
|
// I think that should happen rarely enough to not be an issue...
|
||||||
|
links = append(links, LinkInstruction{
|
||||||
|
Source: filepath.Join(static, file),
|
||||||
|
Target: target,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if info, err := os.Stat(file); err == nil && info.IsDir() {
|
||||||
|
// We don't care about matched directories
|
||||||
|
// We want files within them
|
||||||
|
LogInfo("Skipping directory %s", file)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetPath string
|
||||||
|
if targetIsFile && len(files) == 1 {
|
||||||
|
// Special case: target is a file, and glob matches exactly one file.
|
||||||
|
// Use target directly (don't append filename).
|
||||||
|
targetPath = target
|
||||||
|
} else {
|
||||||
|
// Default: append filename to target dir.
|
||||||
|
targetPath = filepath.Join(target, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
links = append(links, LinkInstruction{
|
||||||
|
Source: filepath.Join(static, file),
|
||||||
|
Target: targetPath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
LogInfo("Expanded pattern %s to %d links", FormatSourcePath(source), len(links))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsYAMLFile(filename string) bool {
|
||||||
|
ext := strings.ToLower(filepath.Ext(filename))
|
||||||
|
return ext == ".yaml" || ext == ".yml"
|
||||||
|
}
|
||||||
|
2343
instruction_test.go
Normal file
2343
instruction_test.go
Normal file
File diff suppressed because it is too large
Load Diff
99
logger.go
Normal file
99
logger.go
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Message type prefixes
|
||||||
|
const (
|
||||||
|
InfoPrefix = "INFO"
|
||||||
|
ErrorPrefix = "ERROR"
|
||||||
|
WarningPrefix = "WARN"
|
||||||
|
SourcePrefix = "SOURCE"
|
||||||
|
TargetPrefix = "TARGET"
|
||||||
|
PathPrefix = "PATH"
|
||||||
|
ImportantPrefix = "IMPORTANT"
|
||||||
|
SuccessPrefix = "DONE"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LogInfo logs an informational message
|
||||||
|
func LogInfo(format string, args ...interface{}) {
|
||||||
|
message := fmt.Sprintf(format, args...)
|
||||||
|
log.Printf("%s[%s]%s %s", BGreen, InfoPrefix, Reset, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogSuccess logs a success message
|
||||||
|
func LogSuccess(format string, args ...interface{}) {
|
||||||
|
message := fmt.Sprintf(format, args...)
|
||||||
|
log.Printf("%s%s[%s]%s %s", BBlue, On_Blue, SuccessPrefix, Reset, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogSource logs a message about a source file/path with proper coloring
|
||||||
|
func LogSource(format string, args ...interface{}) {
|
||||||
|
message := fmt.Sprintf(format, args...)
|
||||||
|
log.Printf("%s[%s]%s %s%s%s", BPurple, SourcePrefix, Reset, SourceColor, message, Reset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogTarget logs a message about a target file/path with proper coloring
|
||||||
|
func LogTarget(format string, args ...interface{}) {
|
||||||
|
message := fmt.Sprintf(format, args...)
|
||||||
|
log.Printf("%s[%s]%s %s%s%s", BYellow, TargetPrefix, Reset, TargetColor, message, Reset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogPath logs a message about a path with proper coloring
|
||||||
|
func LogPath(format string, args ...interface{}) {
|
||||||
|
message := fmt.Sprintf(format, args...)
|
||||||
|
log.Printf("%s[%s]%s %s%s%s", BGreen, PathPrefix, Reset, PathColor, message, Reset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogImportant logs a message that needs attention with proper coloring
|
||||||
|
func LogImportant(format string, args ...interface{}) {
|
||||||
|
message := fmt.Sprintf(format, args...)
|
||||||
|
log.Printf("%s[%s]%s %s%s%s", BRed, ImportantPrefix, Reset, ImportantColor, message, Reset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogError logs an error message with proper coloring that won't be cut off
|
||||||
|
func LogError(format string, args ...interface{}) {
|
||||||
|
// Format the message first with normal text (no red coloring)
|
||||||
|
message := fmt.Sprintf(format, args...)
|
||||||
|
|
||||||
|
// The Error prefix itself is bold red on a light background for maximum visibility
|
||||||
|
prefix := fmt.Sprintf("%s%s[%s]%s ", BRed, On_White, ErrorPrefix, Reset)
|
||||||
|
|
||||||
|
// The message is in default color (no red), only the [ERROR] prefix is colored
|
||||||
|
log.Printf("%s%s", prefix, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatSourcePath returns a source path with proper coloring
|
||||||
|
func FormatSourcePath(path string) string {
|
||||||
|
return fmt.Sprintf("%s%s%s", SourceColor, path, Reset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatTargetPath returns a target path with proper coloring
|
||||||
|
func FormatTargetPath(path string) string {
|
||||||
|
return fmt.Sprintf("%s%s%s", TargetColor, path, Reset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatPathValue returns a path with proper coloring
|
||||||
|
func FormatPathValue(path string) string {
|
||||||
|
return fmt.Sprintf("%s%s%s", PathColor, path, Reset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatErrorValue returns an error value without any additional formatting
|
||||||
|
// Since error messages are no longer red, we don't need special formatting
|
||||||
|
func FormatErrorValue(err error) string {
|
||||||
|
if err == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// Just return the error string with no color formatting
|
||||||
|
return fmt.Sprintf("%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatErrorMessage formats an error message with no additional color formatting,
|
||||||
|
// while preserving the special formatting of embedded source/target/path elements.
|
||||||
|
func FormatErrorMessage(format string, args ...interface{}) string {
|
||||||
|
// This just formats the message with no additional color formatting
|
||||||
|
// The path formatting will still be preserved
|
||||||
|
return fmt.Sprintf(format, args...)
|
||||||
|
}
|
549
main.go
549
main.go
@@ -1,238 +1,311 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"flag"
|
"flag"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
)
|
)
|
||||||
|
|
||||||
const deliminer = ","
|
const deliminer = ","
|
||||||
const (
|
const SourceColor = Purple
|
||||||
Black = "\033[30m"
|
const TargetColor = Yellow
|
||||||
Red = "\033[31m"
|
const ErrorColor = Red
|
||||||
Green = "\033[32m"
|
const ImportantColor = BRed
|
||||||
Yellow = "\033[33m"
|
const DefaultColor = Reset
|
||||||
Blue = "\033[34m"
|
const PathColor = Green
|
||||||
Magenta = "\033[35m"
|
|
||||||
Cyan = "\033[36m"
|
var programName = os.Args[0]
|
||||||
White = "\033[37m"
|
var undo = false
|
||||||
)
|
|
||||||
const SourceColor = Magenta
|
func main() {
|
||||||
const TargetColor = Yellow
|
recurse := flag.String("r", "", "recurse into directories")
|
||||||
const ErrorColor = Red
|
file := flag.String("f", "", "file to read instructions from")
|
||||||
const DefaultColor = White
|
debug := flag.Bool("d", false, "debug")
|
||||||
const PathColor = Green
|
undoF := flag.Bool("u", false, "undo")
|
||||||
|
flag.Parse()
|
||||||
var DirRegex, _ = regexp.Compile(`^(.+?)[/\\]sync$`)
|
undo = *undoF
|
||||||
var FileRegex, _ = regexp.Compile(`^sync$`)
|
|
||||||
var programName = os.Args[0]
|
setupLogging(*debug)
|
||||||
|
|
||||||
func main() {
|
instructions := make(chan *LinkInstruction, 1000)
|
||||||
recurse := flag.String("r", "", "recurse into directories")
|
status := make(chan error)
|
||||||
file := flag.String("f", "", "file to read instructions from")
|
|
||||||
debug := flag.Bool("d", false, "debug")
|
startInputSource(*recurse, *file, instructions, status)
|
||||||
flag.Parse()
|
|
||||||
|
go handleStatusErrors(status)
|
||||||
if *debug {
|
|
||||||
log.SetFlags(log.Lmicroseconds | log.Lshortfile)
|
instructionsDone := processInstructions(instructions)
|
||||||
logFile, err := os.Create("main.log")
|
|
||||||
if err != nil {
|
if instructionsDone == 0 {
|
||||||
log.Printf("Error creating log file: %v", err)
|
LogInfo("No instructions were processed")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
logger := io.MultiWriter(os.Stdout, logFile)
|
LogInfo("All done")
|
||||||
log.SetOutput(logger)
|
}
|
||||||
} else {
|
|
||||||
log.SetFlags(log.Lmicroseconds)
|
// setupLogging configures logging based on debug flag
|
||||||
}
|
func setupLogging(debug bool) {
|
||||||
|
if debug {
|
||||||
log.Printf("Recurse: %s", *recurse)
|
log.SetFlags(log.Lmicroseconds | log.Lshortfile)
|
||||||
log.Printf("File: %s", *file)
|
logFile, err := os.Create(programName + ".log")
|
||||||
|
if err != nil {
|
||||||
instructions := make(chan LinkInstruction, 1000)
|
LogError("Error creating log file: %v", err)
|
||||||
status := make(chan error)
|
os.Exit(1)
|
||||||
if *recurse != "" {
|
}
|
||||||
go ReadFromFilesRecursively(*recurse, instructions, status)
|
logger := io.MultiWriter(os.Stdout, logFile)
|
||||||
} else if *file != "" {
|
log.SetOutput(logger)
|
||||||
go ReadFromFile(*file, instructions, status, true)
|
} else {
|
||||||
} else if len(os.Args) > 1 {
|
log.SetFlags(log.Lmicroseconds)
|
||||||
go ReadFromArgs(instructions, status)
|
}
|
||||||
} else {
|
}
|
||||||
go ReadFromStdin(instructions, status)
|
|
||||||
}
|
// startInputSource determines and starts the appropriate input source
|
||||||
|
func startInputSource(recurse, file string, instructions chan *LinkInstruction, status chan error) {
|
||||||
go func() {
|
// Check input sources in priority order
|
||||||
for {
|
switch {
|
||||||
err, ok := <-status
|
case recurse != "":
|
||||||
if !ok {
|
LogInfo("Recurse: %s", recurse)
|
||||||
break
|
go ReadFromFilesRecursively(recurse, instructions, status)
|
||||||
}
|
|
||||||
if err != nil {
|
case file != "":
|
||||||
log.Println(err)
|
LogInfo("File: %s", file)
|
||||||
}
|
go ReadFromFile(file, instructions, status, true)
|
||||||
}
|
|
||||||
}()
|
case len(flag.Args()) > 0:
|
||||||
|
LogInfo("Reading from command line arguments")
|
||||||
var instructionsDone int32
|
go ReadFromArgs(instructions, status)
|
||||||
var wg sync.WaitGroup
|
|
||||||
for {
|
// case IsPipeInput():
|
||||||
instruction, ok := <-instructions
|
// LogInfo("Reading from stdin pipe")
|
||||||
if !ok {
|
// go ReadFromStdin(instructions, status)
|
||||||
log.Printf("No more instructions to process")
|
|
||||||
break
|
default:
|
||||||
}
|
startDefaultInputSource(instructions, status)
|
||||||
log.Printf("Processing: %s", instruction.String())
|
}
|
||||||
status := make(chan error)
|
}
|
||||||
go instruction.RunAsync(status)
|
|
||||||
wg.Add(1)
|
// startDefaultInputSource tries to find default sync files
|
||||||
err := <-status
|
func startDefaultInputSource(instructions chan *LinkInstruction, status chan error) {
|
||||||
if err != nil {
|
if _, err := os.Stat("sync"); err == nil {
|
||||||
log.Printf("Failed parsing instruction %s%s%s due to %s%+v%s", SourceColor, instruction.String(), DefaultColor, ErrorColor, err, DefaultColor)
|
LogInfo("Using default sync file")
|
||||||
}
|
go ReadFromFile("sync", instructions, status, true)
|
||||||
atomic.AddInt32(&instructionsDone, 1)
|
} else if _, err := os.Stat("sync.yaml"); err == nil {
|
||||||
wg.Done()
|
LogInfo("Using default sync.yaml file")
|
||||||
}
|
go ReadFromFile("sync.yaml", instructions, status, true)
|
||||||
wg.Wait()
|
} else if _, err := os.Stat("sync.yml"); err == nil {
|
||||||
log.Println("All done")
|
LogInfo("Using default sync.yml file")
|
||||||
if instructionsDone == 0 {
|
go ReadFromFile("sync.yml", instructions, status, true)
|
||||||
log.Printf("No input provided")
|
} else {
|
||||||
log.Printf("Provide input as: ")
|
showUsageAndExit()
|
||||||
log.Printf("Arguments - %s <source>,<target>,<force?>", programName)
|
}
|
||||||
log.Printf("File - %s -f <file>", programName)
|
}
|
||||||
log.Printf("Folder (finding sync files in folder recursively) - %s -r <folder>", programName)
|
|
||||||
log.Printf("stdin - (cat <file> | %s)", programName)
|
// showUsageAndExit displays usage information and exits
|
||||||
os.Exit(1)
|
func showUsageAndExit() {
|
||||||
}
|
LogInfo("No input provided")
|
||||||
}
|
LogInfo("Provide input as: ")
|
||||||
|
LogInfo("Arguments - %s <source>,<target>,<force?>", programName)
|
||||||
func ReadFromFilesRecursively(input string, output chan LinkInstruction, status chan error) {
|
LogInfo("File - %s -f <file>", programName)
|
||||||
defer close(output)
|
LogInfo("YAML File - %s -f <file.yaml>", programName)
|
||||||
defer close(status)
|
LogInfo("Folder (finding sync files in folder recursively) - %s -r <folder>", programName)
|
||||||
|
LogInfo("stdin - (cat <file> | %s)", programName)
|
||||||
input = NormalizePath(input)
|
os.Exit(1)
|
||||||
log.Printf("Reading input from files recursively starting in %s%s%s", PathColor, input, DefaultColor)
|
}
|
||||||
|
|
||||||
files := make(chan string, 128)
|
// handleStatusErrors processes status channel errors
|
||||||
recurseStatus := make(chan error)
|
func handleStatusErrors(status chan error) {
|
||||||
go GetSyncFilesRecursively(input, files, recurseStatus)
|
for {
|
||||||
go func() {
|
err, ok := <-status
|
||||||
for {
|
if !ok {
|
||||||
err, ok := <-recurseStatus
|
break
|
||||||
if !ok {
|
}
|
||||||
break
|
if err != nil {
|
||||||
}
|
LogError("%v", err)
|
||||||
if err != nil {
|
}
|
||||||
log.Printf("Failed to get sync files recursively: %s%+v%s", ErrorColor, err, DefaultColor)
|
}
|
||||||
status <- err
|
}
|
||||||
}
|
|
||||||
}
|
// processInstructions processes all instructions from the channel
|
||||||
}()
|
func processInstructions(instructions chan *LinkInstruction) int32 {
|
||||||
|
var instructionsDone int32 = 0
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
for {
|
for {
|
||||||
file, ok := <-files
|
instruction, ok := <-instructions
|
||||||
if !ok {
|
if !ok {
|
||||||
log.Printf("No more files to process")
|
LogInfo("No more instructions to process")
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
wg.Add(1)
|
LogInfo("Processing: %s", instruction.String())
|
||||||
go func() {
|
status := make(chan error)
|
||||||
defer wg.Done()
|
go instruction.RunAsync(status)
|
||||||
log.Println(file)
|
wg.Add(1)
|
||||||
file = NormalizePath(file)
|
err := <-status
|
||||||
log.Printf("Processing file: %s%s%s", PathColor, file, DefaultColor)
|
if err != nil {
|
||||||
|
LogError("Failed processing instruction: %v", err)
|
||||||
// This "has" to be done because instructions are resolved in relation to cwd
|
}
|
||||||
fileDir := DirRegex.FindStringSubmatch(file)
|
atomic.AddInt32(&instructionsDone, 1)
|
||||||
if fileDir == nil {
|
wg.Done()
|
||||||
log.Printf("Failed to extract directory from %s%s%s", SourceColor, file, DefaultColor)
|
}
|
||||||
return
|
wg.Wait()
|
||||||
}
|
return instructionsDone
|
||||||
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 {
|
func IsPipeInput() bool {
|
||||||
log.Printf("Failed to change directory to %s%s%s: %s%+v%s", SourceColor, fileDir[1], DefaultColor, ErrorColor, err, DefaultColor)
|
info, err := os.Stdin.Stat()
|
||||||
return
|
if err != nil {
|
||||||
}
|
return false
|
||||||
|
}
|
||||||
ReadFromFile(file, output, status, false)
|
return info.Mode()&os.ModeNamedPipe != 0
|
||||||
}()
|
}
|
||||||
}
|
|
||||||
wg.Wait()
|
func ReadFromFilesRecursively(input string, output chan *LinkInstruction, status chan error) {
|
||||||
}
|
defer close(output)
|
||||||
func ReadFromFile(input string, output chan LinkInstruction, status chan error, doclose bool) {
|
defer close(status)
|
||||||
if doclose {
|
|
||||||
defer close(output)
|
workdir, _ := os.Getwd()
|
||||||
defer close(status)
|
input = NormalizePath(input, workdir)
|
||||||
}
|
LogInfo("Reading input from files recursively starting in %s", FormatPathValue(input))
|
||||||
|
|
||||||
input = NormalizePath(input)
|
files := make(chan string, 128)
|
||||||
log.Printf("Reading input from file: %s%s%s", PathColor, input, DefaultColor)
|
fileStatus := make(chan error)
|
||||||
file, err := os.Open(input)
|
go GetSyncFilesRecursively(input, files, fileStatus)
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to open file %s%s%s: %s%+v%s", SourceColor, input, DefaultColor, ErrorColor, err, DefaultColor)
|
// Collect all files first
|
||||||
return
|
var syncFiles []string
|
||||||
}
|
for {
|
||||||
defer file.Close()
|
file, ok := <-files
|
||||||
|
if !ok {
|
||||||
scanner := bufio.NewScanner(file)
|
break
|
||||||
for scanner.Scan() {
|
}
|
||||||
line := scanner.Text()
|
syncFiles = append(syncFiles, file)
|
||||||
instruction, err := ParseInstruction(line)
|
}
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error parsing line: %s'%s'%s, error: %s%+v%s", SourceColor, line, DefaultColor, ErrorColor, err, DefaultColor)
|
// Check for errors from file search
|
||||||
continue
|
for {
|
||||||
}
|
err, ok := <-fileStatus
|
||||||
log.Printf("Read instruction: %s", instruction.String())
|
if !ok {
|
||||||
output <- instruction
|
break
|
||||||
}
|
}
|
||||||
}
|
if err != nil {
|
||||||
func ReadFromArgs(output chan LinkInstruction, status chan error) {
|
LogError("Failed to get sync files recursively: %v", err)
|
||||||
defer close(output)
|
status <- err
|
||||||
defer close(status)
|
}
|
||||||
|
}
|
||||||
log.Printf("Reading input from args")
|
|
||||||
for _, arg := range os.Args[1:] {
|
// Process each file
|
||||||
instruction, err := ParseInstruction(arg)
|
for _, file := range syncFiles {
|
||||||
if err != nil {
|
file = NormalizePath(file, workdir)
|
||||||
log.Printf("Error parsing arg: %s'%s'%s, error: %s%+v%s", SourceColor, arg, DefaultColor, ErrorColor, err, DefaultColor)
|
LogInfo("Processing file: %s", FormatPathValue(file))
|
||||||
continue
|
|
||||||
}
|
// Change to the directory containing the sync file
|
||||||
output <- instruction
|
fileDir := filepath.Dir(file)
|
||||||
}
|
originalDir, _ := os.Getwd()
|
||||||
}
|
err := os.Chdir(fileDir)
|
||||||
func ReadFromStdin(output chan LinkInstruction, status chan error) {
|
if err != nil {
|
||||||
defer close(output)
|
LogError("Failed to change directory to %s: %v", FormatSourcePath(fileDir), err)
|
||||||
defer close(status)
|
continue
|
||||||
|
}
|
||||||
log.Printf("Reading input from stdin")
|
|
||||||
|
// Read and process the file
|
||||||
info, err := os.Stdin.Stat()
|
ReadFromFile(file, output, status, false)
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to stat stdin: %s%+v%s", ErrorColor, err, DefaultColor)
|
// Return to original directory
|
||||||
status <- err
|
os.Chdir(originalDir)
|
||||||
return
|
}
|
||||||
}
|
}
|
||||||
if info.Mode()&os.ModeNamedPipe != 0 {
|
|
||||||
scanner := bufio.NewScanner(os.Stdin)
|
func ReadFromFile(input string, output chan *LinkInstruction, status chan error, doclose bool) {
|
||||||
for scanner.Scan() {
|
if doclose {
|
||||||
line := scanner.Text()
|
defer close(output)
|
||||||
instruction, err := ParseInstruction(line)
|
defer close(status)
|
||||||
if err != nil {
|
}
|
||||||
log.Printf("Error parsing line: %s'%s'%s, error: %s%+v%s", SourceColor, line, DefaultColor, ErrorColor, err, DefaultColor)
|
|
||||||
continue
|
input = NormalizePath(input, filepath.Dir(input))
|
||||||
}
|
LogInfo("Reading input from file: %s", FormatPathValue(input))
|
||||||
output <- instruction
|
|
||||||
}
|
// Check if this is a YAML file
|
||||||
if err := scanner.Err(); err != nil {
|
if IsYAMLFile(input) {
|
||||||
log.Fatalf("Error reading from stdin: %s%+v%s", ErrorColor, err, DefaultColor)
|
LogInfo("Parsing as YAML file")
|
||||||
status <- err
|
instructions, err := ParseYAMLFileRecursive(input, filepath.Dir(input))
|
||||||
return
|
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
52
release.sh
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "Figuring out the tag..."
|
||||||
|
TAG=$(git describe --tags --exact-match 2>/dev/null || echo "")
|
||||||
|
if [ -z "$TAG" ]; then
|
||||||
|
# Get the latest tag
|
||||||
|
LATEST_TAG=$(git describe --tags $(git rev-list --tags --max-count=1))
|
||||||
|
# Increment the patch version
|
||||||
|
IFS='.' read -r -a VERSION_PARTS <<< "$LATEST_TAG"
|
||||||
|
VERSION_PARTS[2]=$((VERSION_PARTS[2]+1))
|
||||||
|
TAG="${VERSION_PARTS[0]}.${VERSION_PARTS[1]}.${VERSION_PARTS[2]}"
|
||||||
|
# Create a new tag
|
||||||
|
git tag $TAG
|
||||||
|
git push origin $TAG
|
||||||
|
fi
|
||||||
|
echo "Tag: $TAG"
|
||||||
|
|
||||||
|
echo "Building the thing..."
|
||||||
|
sh build.sh
|
||||||
|
sh install.sh
|
||||||
|
|
||||||
|
echo "Creating a release..."
|
||||||
|
TOKEN="$GITEA_API_KEY"
|
||||||
|
GITEA="https://git.site.quack-lab.dev"
|
||||||
|
REPO="dave/synclib"
|
||||||
|
# Create a release
|
||||||
|
RELEASE_RESPONSE=$(curl -s -X POST \
|
||||||
|
-H "Authorization: token $TOKEN" \
|
||||||
|
-H "Accept: application/json" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"tag_name": "'"$TAG"'",
|
||||||
|
"name": "'"$TAG"'",
|
||||||
|
"draft": false,
|
||||||
|
"prerelease": false
|
||||||
|
}' \
|
||||||
|
$GITEA/api/v1/repos/$REPO/releases)
|
||||||
|
|
||||||
|
# Extract the release ID
|
||||||
|
echo $RELEASE_RESPONSE
|
||||||
|
RELEASE_ID=$(echo $RELEASE_RESPONSE | awk -F'"id":' '{print $2+0; exit}')
|
||||||
|
echo "Release ID: $RELEASE_ID"
|
||||||
|
|
||||||
|
echo "Uploading the things..."
|
||||||
|
curl -X POST \
|
||||||
|
-H "Authorization: token $TOKEN" \
|
||||||
|
-F "attachment=@cln.exe" \
|
||||||
|
"$GITEA/api/v1/repos/$REPO/releases/${RELEASE_ID}/assets?name=cln.exe"
|
||||||
|
curl -X POST \
|
||||||
|
-H "Authorization: token $TOKEN" \
|
||||||
|
-F "attachment=@cln" \
|
||||||
|
"$GITEA/api/v1/repos/$REPO/releases/${RELEASE_ID}/assets?name=cln"
|
6
sync.yaml
Normal file
6
sync.yaml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
- source: A/**/*
|
||||||
|
target: B
|
||||||
|
- source: A/go.mod
|
||||||
|
target: B/go.mod
|
||||||
|
- source: A
|
||||||
|
target: B/foo
|
26
sync.yaml.example
Normal file
26
sync.yaml.example
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Example sync.yaml file
|
||||||
|
# You can use this format to define symbolic links
|
||||||
|
# Each link specifies source, target, and optional flags
|
||||||
|
links:
|
||||||
|
- source: ~/Documents/config.ini
|
||||||
|
target: ~/.config/app/config.ini
|
||||||
|
# This will create a symbolic link, overwriting any existing symlink
|
||||||
|
force: true
|
||||||
|
|
||||||
|
- source: ~/Pictures
|
||||||
|
target: ~/Documents/Pictures
|
||||||
|
# This will create a hard link instead of a symbolic link
|
||||||
|
hard: true
|
||||||
|
force: true
|
||||||
|
|
||||||
|
- source: ~/Scripts/script.sh
|
||||||
|
target: ~/bin/script.sh
|
||||||
|
# This will delete a non-symlink file at the target location
|
||||||
|
# 'delete: true' implies 'force: true'
|
||||||
|
delete: true
|
||||||
|
|
||||||
|
# Alternative format:
|
||||||
|
# Instead of using the 'links' property, you can define an array directly:
|
||||||
|
# - source: ~/Documents/config.ini
|
||||||
|
# target: ~/.config/app/config.ini
|
||||||
|
# force: true
|
230
util.go
230
util.go
@@ -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)
|
|
||||||
}
|
|
||||||
|
Reference in New Issue
Block a user