Compare commits
6 Commits
c6695aef29
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5dfc83ad7a | |||
| 3e5d2dcc54 | |||
| 1d3253b8b7 | |||
| 5e9c4b2172 | |||
| 0215a53fcf | |||
| bc02c93acc |
@@ -7,12 +7,30 @@ This repo is managed by **Sigil**, a minimal symlink-based dotfile tool.
|
||||
~/.dotfiles/
|
||||
<package>/
|
||||
config.lua
|
||||
files/
|
||||
...
|
||||
files/ # common files (all OSes)
|
||||
files.linux/ # Linux-specific overrides
|
||||
files.macos/ # macOS-specific overrides
|
||||
files.windows/ # Windows-specific overrides
|
||||
```
|
||||
|
||||
Each package has a `config.lua` that defines its target path per OS.
|
||||
|
||||
### Per-OS file variants
|
||||
|
||||
Create `files.<os>/` directories alongside `files/` for OS-specific overlays:
|
||||
|
||||
```
|
||||
pi-agent/
|
||||
files/
|
||||
settings.json # shared config
|
||||
files.linux/
|
||||
agent.json # Linux-specific
|
||||
files.macos/
|
||||
agent.json # macOS-specific
|
||||
```
|
||||
|
||||
On Linux, `agent.json` links to `files.linux/agent.json`. On macOS, it links to `files.macos/agent.json`. Files in `files/` are applied first, then OS-specific variants overlay on top.
|
||||
|
||||
## Common commands
|
||||
- `sigil apply` — apply symlinks (prompts for stale links)
|
||||
- `sigil apply --prune` — prune stale links without prompting
|
||||
@@ -43,3 +61,11 @@ sigil unlink ~/.config/wezterm/wezterm.lua
|
||||
## Development
|
||||
- When modifying this repo, rebuild and install the binary: `go build -o ~/.local/bin/sigil .`
|
||||
- Bump the version constant in `main.go` for every change (follow semver: bugfix = patch, feature = minor, breaking = major)
|
||||
|
||||
## IMPORTANT: Update AGENTS.md files when Sigil changes
|
||||
|
||||
Whenever Sigil is modified (new features, behavior changes, new commands), you MUST update the AGENTS.md documentation in BOTH locations:
|
||||
1. This repo: `/home/thomasgl/programming/sigil/AGENTS.md`
|
||||
2. Dotfiles repo: `/home/thomasgl/.dotfiles/AGENTS.md`
|
||||
|
||||
Keep them in sync so agents have correct instructions regardless of which directory they're working in.
|
||||
|
||||
@@ -2,9 +2,25 @@
|
||||
|
||||
Sigil is a minimal symlink-based dotfile manager with per-package Lua config.
|
||||
|
||||
## Install (dev)
|
||||
## Install
|
||||
|
||||
### Build and install to ~/.local/bin
|
||||
```bash
|
||||
cd ~/programming/sigil
|
||||
go build -o ~/.local/bin/sigil .
|
||||
```
|
||||
|
||||
### Install via go install
|
||||
```bash
|
||||
cd ~/programming/sigil
|
||||
go install .
|
||||
# Binary will be at $(go env GOPATH)/bin/sigil
|
||||
# Make sure $(go env GOPATH)/bin is in your PATH
|
||||
```
|
||||
|
||||
### Run without installing (dev)
|
||||
```bash
|
||||
cd ~/programming/sigil
|
||||
go run . <command>
|
||||
```
|
||||
|
||||
@@ -23,10 +39,28 @@ go run . <command>
|
||||
~/.dotfiles/
|
||||
<package>/
|
||||
config.lua
|
||||
files/
|
||||
...
|
||||
files/ # common files (all OSes)
|
||||
files.linux/ # Linux-specific overrides
|
||||
files.macos/ # macOS-specific overrides
|
||||
files.windows/ # Windows-specific overrides
|
||||
```
|
||||
|
||||
### Per-OS file variants
|
||||
|
||||
Create `files.<os>/` directories alongside `files/` for OS-specific overlays:
|
||||
|
||||
```
|
||||
pi-agent/
|
||||
files/
|
||||
settings.json # shared config
|
||||
files.linux/
|
||||
agent.json # Linux-specific
|
||||
files.macos/
|
||||
agent.json # macOS-specific
|
||||
```
|
||||
|
||||
On Linux, `agent.json` links to `files.linux/agent.json`. On macOS, it links to `files.macos/agent.json`. Files in `files/` are applied first, then OS-specific variants overlay on top.
|
||||
|
||||
## `config.lua`
|
||||
|
||||
```lua
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
configFileName = "config.lua"
|
||||
filesDirName = "files"
|
||||
ConfigFileName = "config.lua"
|
||||
FilesDirName = "files"
|
||||
)
|
||||
|
||||
type packageConfig struct {
|
||||
@@ -25,9 +25,9 @@ type packageConfig struct {
|
||||
compiledIgnores []*regexp.Regexp
|
||||
}
|
||||
|
||||
var errTargetDisabled = errors.New("target disabled for this platform")
|
||||
var ErrTargetDisabled = errors.New("target disabled for this platform")
|
||||
|
||||
func loadConfig(path string) (*packageConfig, error) {
|
||||
func LoadConfig(path string) (*packageConfig, error) {
|
||||
L := lua.NewState()
|
||||
defer L.Close()
|
||||
L.OpenLibs()
|
||||
@@ -69,18 +69,18 @@ func loadConfig(path string) (*packageConfig, error) {
|
||||
return
|
||||
}
|
||||
|
||||
targets[string(ks)] = expandHome(string(vs))
|
||||
targets[string(ks)] = ExpandHome(string(vs))
|
||||
})
|
||||
|
||||
if len(targets) == 0 && len(disabled) == 0 {
|
||||
return nil, errors.New("config.target is empty")
|
||||
}
|
||||
|
||||
ignore, err := parseIgnore(tbl)
|
||||
ignore, err := ParseIgnore(tbl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
compiledIgnores, err := compileIgnorePatterns(ignore)
|
||||
compiledIgnores, err := CompileIgnorePatterns(ignore)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -88,24 +88,33 @@ func loadConfig(path string) (*packageConfig, error) {
|
||||
return &packageConfig{targets: targets, disabled: disabled, ignore: ignore, compiledIgnores: compiledIgnores}, nil
|
||||
}
|
||||
|
||||
func selectTarget(cfg *packageConfig) (string, error) {
|
||||
osKey := runtime.GOOS
|
||||
if osKey == "darwin" {
|
||||
osKey = "macos"
|
||||
}
|
||||
func SelectTarget(cfg *packageConfig) (string, error) {
|
||||
osKey := OSKey()
|
||||
if cfg.disabled[osKey] {
|
||||
return "", errTargetDisabled
|
||||
return "", ErrTargetDisabled
|
||||
}
|
||||
if target, ok := cfg.targets[osKey]; ok {
|
||||
return expandHome(target), nil
|
||||
return ExpandHome(target), nil
|
||||
}
|
||||
if target, ok := cfg.targets["default"]; ok {
|
||||
return expandHome(target), nil
|
||||
return ExpandHome(target), nil
|
||||
}
|
||||
return "", fmt.Errorf("missing target for %s and default", osKey)
|
||||
}
|
||||
|
||||
func parseIgnore(cfgTbl *lua.LTable) ([]string, error) {
|
||||
func OSKey() string {
|
||||
osKey := runtime.GOOS
|
||||
if osKey == "darwin" {
|
||||
osKey = "macos"
|
||||
}
|
||||
return osKey
|
||||
}
|
||||
|
||||
func VariantDirName() string {
|
||||
return FilesDirName + "." + OSKey()
|
||||
}
|
||||
|
||||
func ParseIgnore(cfgTbl *lua.LTable) ([]string, error) {
|
||||
ignoreVal := cfgTbl.RawGetString("ignore")
|
||||
if ignoreVal == lua.LNil {
|
||||
return nil, nil
|
||||
@@ -148,10 +157,10 @@ func parseIgnore(cfgTbl *lua.LTable) ([]string, error) {
|
||||
return ignore, nil
|
||||
}
|
||||
|
||||
func compileIgnorePatterns(patterns []string) ([]*regexp.Regexp, error) {
|
||||
func CompileIgnorePatterns(patterns []string) ([]*regexp.Regexp, error) {
|
||||
compiled := make([]*regexp.Regexp, 0, len(patterns))
|
||||
for _, pattern := range patterns {
|
||||
re, err := globToRegexp(pattern)
|
||||
re, err := GlobToRegexp(pattern)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid ignore pattern %q: %w", pattern, err)
|
||||
}
|
||||
@@ -160,7 +169,7 @@ func compileIgnorePatterns(patterns []string) ([]*regexp.Regexp, error) {
|
||||
return compiled, nil
|
||||
}
|
||||
|
||||
func globToRegexp(pattern string) (*regexp.Regexp, error) {
|
||||
func GlobToRegexp(pattern string) (*regexp.Regexp, error) {
|
||||
pattern = strings.ReplaceAll(pattern, "\\", "/")
|
||||
pattern = strings.TrimPrefix(pattern, "./")
|
||||
if strings.HasPrefix(pattern, "/") {
|
||||
@@ -192,7 +201,7 @@ func globToRegexp(pattern string) (*regexp.Regexp, error) {
|
||||
return regexp.Compile(b.String())
|
||||
}
|
||||
|
||||
func shouldIgnorePath(rel string, cfg *packageConfig) bool {
|
||||
func ShouldIgnorePath(rel string, cfg *packageConfig) bool {
|
||||
if cfg == nil || len(cfg.compiledIgnores) == 0 {
|
||||
return false
|
||||
}
|
||||
@@ -207,13 +216,13 @@ func shouldIgnorePath(rel string, cfg *packageConfig) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func writeConfig(path, targetRoot string) error {
|
||||
func WriteConfig(path, targetRoot string) error {
|
||||
osKey := "linux"
|
||||
if runtime.GOOS == "darwin" {
|
||||
osKey = "macos"
|
||||
}
|
||||
|
||||
prettyTarget := compressHome(targetRoot)
|
||||
prettyTarget := CompressHome(targetRoot)
|
||||
content := fmt.Sprintf("---@class SigilConfig\n---@field target table<string, string|boolean>\n---@field ignore? string[]\n\n---@type SigilConfig\nlocal config = {\n\ttarget = {\n\t\t%s = %q,\n\t\tdefault = %q,\n\t},\n\tignore = {\n\t\t-- \"**/.DS_Store\",\n\t\t-- \"**/*.tmp\",\n\t\t-- \"cache/**\",\n\t},\n}\n\nreturn config\n", osKey, prettyTarget, prettyTarget)
|
||||
return os.WriteFile(path, []byte(content), 0o644)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package core
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
@@ -50,9 +50,9 @@ func TestGlobToRegexp(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.pattern, func(t *testing.T) {
|
||||
re, err := globToRegexp(tt.pattern)
|
||||
re, err := GlobToRegexp(tt.pattern)
|
||||
if err != nil {
|
||||
t.Fatalf("globToRegexp(%q) error: %v", tt.pattern, err)
|
||||
t.Fatalf("GlobToRegexp(%q) error: %v", tt.pattern, err)
|
||||
}
|
||||
|
||||
for _, m := range tt.matches {
|
||||
@@ -76,20 +76,20 @@ func TestShouldIgnorePath(t *testing.T) {
|
||||
}
|
||||
|
||||
// Test with nil config
|
||||
if shouldIgnorePath("foo.txt", nil) {
|
||||
t.Error("shouldIgnorePath with nil config should return false")
|
||||
if ShouldIgnorePath("foo.txt", nil) {
|
||||
t.Error("ShouldIgnorePath with nil config should return false")
|
||||
}
|
||||
|
||||
// Test with empty ignores
|
||||
if shouldIgnorePath("foo.txt", cfg) {
|
||||
t.Error("shouldIgnorePath with empty ignores should return false")
|
||||
if ShouldIgnorePath("foo.txt", cfg) {
|
||||
t.Error("ShouldIgnorePath with empty ignores should return false")
|
||||
}
|
||||
|
||||
// Add some patterns and re-test
|
||||
patterns := []string{"*.log", "cache/**", "**/.DS_Store"}
|
||||
compiled, err := compileIgnorePatterns(patterns)
|
||||
compiled, err := CompileIgnorePatterns(patterns)
|
||||
if err != nil {
|
||||
t.Fatalf("compileIgnorePatterns error: %v", err)
|
||||
t.Fatalf("CompileIgnorePatterns error: %v", err)
|
||||
}
|
||||
cfg.compiledIgnores = compiled
|
||||
|
||||
@@ -110,9 +110,9 @@ func TestShouldIgnorePath(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.path, func(t *testing.T) {
|
||||
got := shouldIgnorePath(tt.path, cfg)
|
||||
got := ShouldIgnorePath(tt.path, cfg)
|
||||
if got != tt.ignored {
|
||||
t.Errorf("shouldIgnorePath(%q) = %v, want %v", tt.path, got, tt.ignored)
|
||||
t.Errorf("ShouldIgnorePath(%q) = %v, want %v", tt.path, got, tt.ignored)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ApplyPackage(filesDir, targetRoot string, cfg *packageConfig) error {
|
||||
return ApplyPackages([]string{filesDir}, targetRoot, cfg)
|
||||
}
|
||||
|
||||
func ApplyPackages(filesDirs []string, targetRoot string, cfg *packageConfig) error {
|
||||
if err := EnsureDir(targetRoot); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, filesDir := range filesDirs {
|
||||
if _, err := os.Stat(filesDir); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
err := filepath.WalkDir(filesDir, func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if path == filesDir {
|
||||
return nil
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(filesDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if ShouldIgnorePath(rel, cfg) {
|
||||
if entry.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
targetPath := filepath.Join(targetRoot, rel)
|
||||
|
||||
if entry.IsDir() {
|
||||
return EnsureDir(targetPath)
|
||||
}
|
||||
|
||||
srcAbs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// For overlay mode: remove existing symlink if it points to a different source
|
||||
if info, err := os.Lstat(targetPath); err == nil && info.Mode()&os.ModeSymlink != 0 {
|
||||
current, err := os.Readlink(targetPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if current != srcAbs {
|
||||
// Remove the old symlink to allow overlay
|
||||
if err := os.Remove(targetPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return LinkFile(srcAbs, targetPath)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func LinkFile(src, dst string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info, err := os.Lstat(dst); err == nil {
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
current, err := os.Readlink(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if current == src {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("conflict at %s (symlink points to %s, expected %s). Run 'sigil unlink %s' to restore and remove the conflicting link", dst, current, src, dst)
|
||||
}
|
||||
return fmt.Errorf("conflict at %s (file exists and is not a symlink). Back up or remove this file first", dst)
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.Symlink(src, dst)
|
||||
}
|
||||
|
||||
func EnsureDir(path string) error {
|
||||
info, err := os.Lstat(path)
|
||||
if err == nil {
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s exists and is not a directory", path)
|
||||
}
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return os.MkdirAll(path, 0o755)
|
||||
}
|
||||
|
||||
func FindStaleLinks(filesDir, targetRoot string) ([]string, error) {
|
||||
return FindStaleLinksMulti([]string{filesDir}, targetRoot)
|
||||
}
|
||||
|
||||
func FindStaleLinksMulti(filesDirs []string, targetRoot string) ([]string, error) {
|
||||
targetAbs, err := filepath.Abs(targetRoot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Collect all valid source directories (prefixes) for checking
|
||||
var sourcePrefixes []string
|
||||
for _, filesDir := range filesDirs {
|
||||
filesAbs, err := filepath.Abs(filesDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := os.Stat(filesDir); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
sourcePrefixes = append(sourcePrefixes, filesAbs)
|
||||
}
|
||||
|
||||
if len(sourcePrefixes) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var stale []string
|
||||
seen := make(map[string]bool)
|
||||
|
||||
// First pass: walk source directories to find files that exist
|
||||
for _, filesDir := range filesDirs {
|
||||
if _, err := os.Stat(filesDir); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
filesAbs, err := filepath.Abs(filesDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = filepath.WalkDir(filesDir, func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(filesAbs, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
targetPath := filepath.Join(targetAbs, rel)
|
||||
seen[targetPath] = true
|
||||
|
||||
info, err := os.Lstat(targetPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if info.Mode()&os.ModeSymlink == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
src, err := os.Readlink(targetPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !filepath.IsAbs(src) {
|
||||
src = filepath.Join(filepath.Dir(targetPath), src)
|
||||
}
|
||||
src = filepath.Clean(src)
|
||||
|
||||
// Check if symlink points to any of our source directories
|
||||
pointsToRepo := false
|
||||
for _, prefix := range sourcePrefixes {
|
||||
if strings.HasPrefix(src, prefix+string(os.PathSeparator)) || src == prefix {
|
||||
pointsToRepo = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !pointsToRepo {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if the source file in repo still exists
|
||||
if _, err := os.Stat(src); errors.Is(err, os.ErrNotExist) {
|
||||
stale = append(stale, targetPath)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: walk target directory to find orphaned symlinks
|
||||
// (symlinks pointing to our repo but source file no longer exists)
|
||||
targetEntries, err := os.ReadDir(targetAbs)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return stale, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, entry := range targetEntries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
targetPath := filepath.Join(targetAbs, entry.Name())
|
||||
if seen[targetPath] {
|
||||
continue
|
||||
}
|
||||
|
||||
info, err := os.Lstat(targetPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
src, err := os.Readlink(targetPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if !filepath.IsAbs(src) {
|
||||
src = filepath.Join(filepath.Dir(targetPath), src)
|
||||
}
|
||||
src = filepath.Clean(src)
|
||||
|
||||
// Check if symlink points to any of our source directories
|
||||
pointsToRepo := false
|
||||
for _, prefix := range sourcePrefixes {
|
||||
if strings.HasPrefix(src, prefix+string(os.PathSeparator)) || src == prefix {
|
||||
pointsToRepo = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !pointsToRepo {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if source still exists
|
||||
if _, err := os.Stat(src); errors.Is(err, os.ErrNotExist) {
|
||||
stale = append(stale, targetPath)
|
||||
}
|
||||
}
|
||||
|
||||
return stale, nil
|
||||
}
|
||||
|
||||
func RemoveLinks(paths []string, dryRun bool) error {
|
||||
for _, path := range paths {
|
||||
if dryRun {
|
||||
fmt.Printf("dry-run: remove %s\n", path)
|
||||
continue
|
||||
}
|
||||
if err := os.Remove(path); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("removed %s\n", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func HandleStaleLinks(stales []string) error {
|
||||
if len(stales) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
repo, err := RepoPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reader := newReader()
|
||||
for _, path := range stales {
|
||||
fmt.Printf("stale: %s\n", path)
|
||||
|
||||
canUnlink, err := StaleHasRepoFile(path, repo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
prompt := "action [p=prune, u=unlink, i=ignore]: "
|
||||
if !canUnlink {
|
||||
prompt = "action [p=prune, i=ignore]: "
|
||||
}
|
||||
|
||||
fmt.Print(prompt)
|
||||
choice, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
choice = strings.TrimSpace(strings.ToLower(choice))
|
||||
if choice == "" || choice == "i" {
|
||||
continue
|
||||
}
|
||||
if choice == "p" {
|
||||
if err := os.Remove(path); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("removed %s\n", path)
|
||||
continue
|
||||
}
|
||||
if choice == "u" && canUnlink {
|
||||
if err := UnlinkStale(path, repo); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
fmt.Println("invalid choice; skipping")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func StaleHasRepoFile(targetPath, repo string) (bool, error) {
|
||||
RepoPath, err := RepoPathForTarget(targetPath, repo)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if RepoPath == "" {
|
||||
return false, nil
|
||||
}
|
||||
if _, err := os.Stat(RepoPath); errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
} else if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func UnlinkStale(targetPath, repo string) error {
|
||||
RepoPath, err := RepoPathForTarget(targetPath, repo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if RepoPath == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.Remove(targetPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := CopyFile(RepoPath, targetPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.Remove(RepoPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("unlinked %s (removed %s)\n", targetPath, RepoPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func RestorePackage(filesDir, targetRoot string, dryRun bool) error {
|
||||
filesAbs, err := filepath.Abs(filesDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return filepath.WalkDir(filesDir, func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if path == filesDir {
|
||||
return nil
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(filesDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
targetPath := filepath.Join(targetRoot, rel)
|
||||
return RestoreOne(path, targetPath, filesAbs, dryRun)
|
||||
})
|
||||
}
|
||||
|
||||
func RestorePath(filesDir, targetRoot, relPath string, dryRun bool) error {
|
||||
filesAbs, err := filepath.Abs(filesDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
relPath = filepath.Clean(relPath)
|
||||
if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) {
|
||||
return fmt.Errorf("invalid relative path %q", relPath)
|
||||
}
|
||||
|
||||
sourcePath := filepath.Join(filesDir, relPath)
|
||||
info, err := os.Lstat(sourcePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
return filepath.WalkDir(sourcePath, func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(filesDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
targetPath := filepath.Join(targetRoot, rel)
|
||||
return RestoreOne(path, targetPath, filesAbs, dryRun)
|
||||
})
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(filesDir, sourcePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return RestoreOne(sourcePath, filepath.Join(targetRoot, rel), filesAbs, dryRun)
|
||||
}
|
||||
|
||||
func RestoreOne(sourcePath, targetPath, filesAbs string, dryRun bool) error {
|
||||
info, err := os.Lstat(targetPath)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
src, err := os.Readlink(targetPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !filepath.IsAbs(src) {
|
||||
src = filepath.Join(filepath.Dir(targetPath), src)
|
||||
}
|
||||
src = filepath.Clean(src)
|
||||
|
||||
if !strings.HasPrefix(src, filesAbs+string(os.PathSeparator)) && src != filesAbs {
|
||||
return nil
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
fmt.Printf("dry-run: restore %s\n", targetPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.Remove(targetPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("restored %s\n", targetPath)
|
||||
return CopyFile(sourcePath, targetPath)
|
||||
}
|
||||
|
||||
func CopyFile(src, dst string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
srcFile, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
info, err := srcFile.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dstFile, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
if _, err := io.Copy(dstFile, srcFile); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func MoveDirContents(srcDir, destDir string) error {
|
||||
entries, err := os.ReadDir(srcDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
srcPath := filepath.Join(srcDir, entry.Name())
|
||||
destPath := filepath.Join(destDir, entry.Name())
|
||||
|
||||
if _, err := os.Stat(destPath); err == nil {
|
||||
return fmt.Errorf("destination already exists: %s", destPath)
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.Rename(srcPath, destPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func RemoveOrUnlink(args []string, isRemove bool) error {
|
||||
_ = isRemove // commands are identical, flag kept for clarity
|
||||
|
||||
flags, pkgSpec, err := ParsePackageFlags(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pkgName, relPath, err := ResolvePackageSpec(pkgSpec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
repo, err := RepoPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pkgDir := filepath.Join(repo, pkgName)
|
||||
configPath := filepath.Join(pkgDir, ConfigFileName)
|
||||
cfg, err := LoadConfig(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
targetRoot, err := SelectTarget(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
filesDir := filepath.Join(pkgDir, FilesDirName)
|
||||
if _, err := os.Stat(filesDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if relPath == "" {
|
||||
if err := RestorePackage(filesDir, targetRoot, flags.dryRun); err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.dryRun {
|
||||
return nil
|
||||
}
|
||||
if err := os.RemoveAll(pkgDir); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("removed package %s\n", pkgName)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := RestorePath(filesDir, targetRoot, relPath, flags.dryRun); err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.dryRun {
|
||||
return nil
|
||||
}
|
||||
if err := os.RemoveAll(filepath.Join(filesDir, relPath)); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("removed %s:%s\n", pkgName, relPath)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLinkFile(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
|
||||
src := filepath.Join(tmp, "source.txt")
|
||||
dst := filepath.Join(tmp, "link.txt")
|
||||
|
||||
// Create source file
|
||||
if err := os.WriteFile(src, []byte("hello"), 0o644); err != nil {
|
||||
t.Fatalf("failed to create source: %v", err)
|
||||
}
|
||||
|
||||
// Test: create symlink
|
||||
if err := LinkFile(src, dst); err != nil {
|
||||
t.Errorf("LinkFile failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify symlink
|
||||
info, err := os.Lstat(dst)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to stat link: %v", err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink == 0 {
|
||||
t.Error("dst is not a symlink")
|
||||
}
|
||||
|
||||
// Test: idempotent - same symlink again should succeed
|
||||
if err := LinkFile(src, dst); err != nil {
|
||||
t.Errorf("LinkFile second time failed: %v", err)
|
||||
}
|
||||
|
||||
// Test: conflict - different source should error
|
||||
src2 := filepath.Join(tmp, "source2.txt")
|
||||
if err := os.WriteFile(src2, []byte("world"), 0o644); err != nil {
|
||||
t.Fatalf("failed to create source2: %v", err)
|
||||
}
|
||||
|
||||
if err := LinkFile(src2, dst); err == nil {
|
||||
t.Error("LinkFile should fail with conflicting symlink")
|
||||
}
|
||||
|
||||
// Test: conflict - non-symlink file should error
|
||||
dst2 := filepath.Join(tmp, "regular.txt")
|
||||
if err := os.WriteFile(dst2, []byte("content"), 0o644); err != nil {
|
||||
t.Fatalf("failed to create regular file: %v", err)
|
||||
}
|
||||
|
||||
if err := LinkFile(src, dst2); err == nil {
|
||||
t.Error("LinkFile should fail when target exists and is not a symlink")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindStaleLinks(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
filesDir := filepath.Join(tmp, "files")
|
||||
targetDir := filepath.Join(tmp, "target")
|
||||
|
||||
if err := os.MkdirAll(filesDir, 0o755); err != nil {
|
||||
t.Fatalf("failed to create files dir: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
||||
t.Fatalf("failed to create target dir: %v", err)
|
||||
}
|
||||
|
||||
// Create a file in files/
|
||||
src := filepath.Join(filesDir, "test.txt")
|
||||
if err := os.WriteFile(src, []byte("content"), 0o644); err != nil {
|
||||
t.Fatalf("failed to create source: %v", err)
|
||||
}
|
||||
|
||||
// Create a subdirectory with a file
|
||||
subDir := filepath.Join(filesDir, "subdir")
|
||||
if err := os.MkdirAll(subDir, 0o755); err != nil {
|
||||
t.Fatalf("failed to create subdir: %v", err)
|
||||
}
|
||||
src2 := filepath.Join(subDir, "nested.txt")
|
||||
if err := os.WriteFile(src2, []byte("nested"), 0o644); err != nil {
|
||||
t.Fatalf("failed to create nested source: %v", err)
|
||||
}
|
||||
|
||||
// Create symlinks
|
||||
dst := filepath.Join(targetDir, "test.txt")
|
||||
if err := os.Symlink(src, dst); err != nil {
|
||||
t.Fatalf("failed to create symlink: %v", err)
|
||||
}
|
||||
|
||||
targetSubDir := filepath.Join(targetDir, "subdir")
|
||||
if err := os.MkdirAll(targetSubDir, 0o755); err != nil {
|
||||
t.Fatalf("failed to create target subdir: %v", err)
|
||||
}
|
||||
dst2 := filepath.Join(targetSubDir, "nested.txt")
|
||||
if err := os.Symlink(src2, dst2); err != nil {
|
||||
t.Fatalf("failed to create nested symlink: %v", err)
|
||||
}
|
||||
|
||||
// Test: no stale links when sources exist
|
||||
stale, err := FindStaleLinks(filesDir, targetDir)
|
||||
if err != nil {
|
||||
t.Errorf("FindStaleLinks failed: %v", err)
|
||||
}
|
||||
if len(stale) != 0 {
|
||||
t.Errorf("expected 0 stale links, got %d", len(stale))
|
||||
}
|
||||
|
||||
// Note: The current implementation walks filesDir, so it can't detect
|
||||
// stale links for files that have been deleted from the repo.
|
||||
// This is a known limitation - we'd need to walk the target directory
|
||||
// to find orphaned symlinks, but that has performance issues with
|
||||
// large target directories (like ~).
|
||||
}
|
||||
|
||||
func TestFindStaleLinks_IgnoresNonRepoSymlinks(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
filesDir := filepath.Join(tmp, "files")
|
||||
targetDir := filepath.Join(tmp, "target")
|
||||
|
||||
if err := os.MkdirAll(filesDir, 0o755); err != nil {
|
||||
t.Fatalf("failed to create files dir: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
||||
t.Fatalf("failed to create target dir: %v", err)
|
||||
}
|
||||
|
||||
// Create a file outside the repo
|
||||
external := filepath.Join(tmp, "external.txt")
|
||||
if err := os.WriteFile(external, []byte("external"), 0o644); err != nil {
|
||||
t.Fatalf("failed to create external file: %v", err)
|
||||
}
|
||||
|
||||
// Create a symlink to external file (should be ignored by FindStaleLinks)
|
||||
dst := filepath.Join(targetDir, "link.txt")
|
||||
if err := os.Symlink(external, dst); err != nil {
|
||||
t.Fatalf("failed to create symlink: %v", err)
|
||||
}
|
||||
|
||||
// Test: should not report external symlinks as stale
|
||||
stale, err := FindStaleLinks(filesDir, targetDir)
|
||||
if err != nil {
|
||||
t.Errorf("FindStaleLinks failed: %v", err)
|
||||
}
|
||||
if len(stale) != 0 {
|
||||
t.Errorf("expected 0 stale links (external symlinks ignored), got %d", len(stale))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPackage(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
filesDir := filepath.Join(tmp, "files")
|
||||
targetDir := filepath.Join(tmp, "target")
|
||||
|
||||
// Create files structure
|
||||
subDir := filepath.Join(filesDir, "config")
|
||||
if err := os.MkdirAll(subDir, 0o755); err != nil {
|
||||
t.Fatalf("failed to create subdir: %v", err)
|
||||
}
|
||||
|
||||
file1 := filepath.Join(filesDir, "readme.txt")
|
||||
if err := os.WriteFile(file1, []byte("readme"), 0o644); err != nil {
|
||||
t.Fatalf("failed to create file1: %v", err)
|
||||
}
|
||||
|
||||
file2 := filepath.Join(subDir, "app.conf")
|
||||
if err := os.WriteFile(file2, []byte("config"), 0o644); err != nil {
|
||||
t.Fatalf("failed to create file2: %v", err)
|
||||
}
|
||||
|
||||
cfg := &packageConfig{} // empty config, no ignores
|
||||
|
||||
// Apply package
|
||||
if err := ApplyPackage(filesDir, targetDir, cfg); err != nil {
|
||||
t.Errorf("ApplyPackage failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify symlinks exist
|
||||
targetFile1 := filepath.Join(targetDir, "readme.txt")
|
||||
info, err := os.Lstat(targetFile1)
|
||||
if err != nil {
|
||||
t.Errorf("target file1 not found: %v", err)
|
||||
} else if info.Mode()&os.ModeSymlink == 0 {
|
||||
t.Error("target file1 is not a symlink")
|
||||
}
|
||||
|
||||
targetFile2 := filepath.Join(targetDir, "config", "app.conf")
|
||||
info, err = os.Lstat(targetFile2)
|
||||
if err != nil {
|
||||
t.Errorf("target file2 not found: %v", err)
|
||||
} else if info.Mode()&os.ModeSymlink == 0 {
|
||||
t.Error("target file2 is not a symlink")
|
||||
}
|
||||
|
||||
// Verify content is accessible through symlink
|
||||
content, err := os.ReadFile(targetFile1)
|
||||
if err != nil {
|
||||
t.Errorf("failed to read through symlink: %v", err)
|
||||
}
|
||||
if string(content) != "readme" {
|
||||
t.Errorf("wrong content through symlink: %s", string(content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPackage_WithIgnores(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
filesDir := filepath.Join(tmp, "files")
|
||||
targetDir := filepath.Join(tmp, "target")
|
||||
|
||||
if err := os.MkdirAll(filesDir, 0o755); err != nil {
|
||||
t.Fatalf("failed to create files dir: %v", err)
|
||||
}
|
||||
|
||||
// Create files, some to be ignored
|
||||
mainFile := filepath.Join(filesDir, "main.txt")
|
||||
if err := os.WriteFile(mainFile, []byte("main"), 0o644); err != nil {
|
||||
t.Fatalf("failed to create main file: %v", err)
|
||||
}
|
||||
|
||||
cacheDir := filepath.Join(filesDir, "cache")
|
||||
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
|
||||
t.Fatalf("failed to create cache dir: %v", err)
|
||||
}
|
||||
cacheFile := filepath.Join(cacheDir, "data.tmp")
|
||||
if err := os.WriteFile(cacheFile, []byte("temp"), 0o644); err != nil {
|
||||
t.Fatalf("failed to create cache file: %v", err)
|
||||
}
|
||||
|
||||
// Create config with ignore pattern
|
||||
cfg := &packageConfig{
|
||||
compiledIgnores: []*regexp.Regexp{},
|
||||
}
|
||||
re, _ := GlobToRegexp("cache/**")
|
||||
cfg.compiledIgnores = append(cfg.compiledIgnores, re)
|
||||
|
||||
// Apply package
|
||||
if err := ApplyPackage(filesDir, targetDir, cfg); err != nil {
|
||||
t.Errorf("ApplyPackage failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify main file is linked
|
||||
targetMain := filepath.Join(targetDir, "main.txt")
|
||||
if _, err := os.Lstat(targetMain); err != nil {
|
||||
t.Errorf("main file should be linked: %v", err)
|
||||
}
|
||||
|
||||
// Verify cache file is not linked (the directory may exist since we walk it
|
||||
// before checking ignore patterns, but it should be empty)
|
||||
targetCacheFile := filepath.Join(targetDir, "cache", "data.tmp")
|
||||
if _, err := os.Lstat(targetCacheFile); !os.IsNotExist(err) {
|
||||
t.Error("cache/data.tmp should not exist in target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreOne(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
filesDir := filepath.Join(tmp, "files")
|
||||
targetDir := filepath.Join(tmp, "target")
|
||||
|
||||
if err := os.MkdirAll(filesDir, 0o755); err != nil {
|
||||
t.Fatalf("failed to create files dir: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
||||
t.Fatalf("failed to create target dir: %v", err)
|
||||
}
|
||||
|
||||
// Create source file in repo
|
||||
src := filepath.Join(filesDir, "test.txt")
|
||||
if err := os.WriteFile(src, []byte("original"), 0o644); err != nil {
|
||||
t.Fatalf("failed to create source: %v", err)
|
||||
}
|
||||
|
||||
// Create symlink in target
|
||||
dst := filepath.Join(targetDir, "test.txt")
|
||||
if err := os.Symlink(src, dst); err != nil {
|
||||
t.Fatalf("failed to create symlink: %v", err)
|
||||
}
|
||||
|
||||
// Restore (removes symlink, copies file)
|
||||
filesAbs, _ := filepath.Abs(filesDir)
|
||||
if err := RestoreOne(src, dst, filesAbs, false); err != nil {
|
||||
t.Errorf("RestoreOne failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify symlink is gone
|
||||
info, err := os.Lstat(dst)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to stat restored file: %v", err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
t.Error("file should not be a symlink after restore")
|
||||
}
|
||||
|
||||
// Verify content is preserved
|
||||
content, err := os.ReadFile(dst)
|
||||
if err != nil {
|
||||
t.Errorf("failed to read restored file: %v", err)
|
||||
}
|
||||
if string(content) != "original" {
|
||||
t.Errorf("wrong content after restore: %s", string(content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPackages_Overlay(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
baseDir := filepath.Join(tmp, "files")
|
||||
linuxDir := filepath.Join(tmp, "files.linux")
|
||||
targetDir := filepath.Join(tmp, "target")
|
||||
|
||||
// Create base files
|
||||
if err := os.MkdirAll(baseDir, 0o755); err != nil {
|
||||
t.Fatalf("failed to create base dir: %v", err)
|
||||
}
|
||||
baseFile := filepath.Join(baseDir, "common.txt")
|
||||
if err := os.WriteFile(baseFile, []byte("common"), 0o644); err != nil {
|
||||
t.Fatalf("failed to create base file: %v", err)
|
||||
}
|
||||
sharedFile := filepath.Join(baseDir, "shared.txt")
|
||||
if err := os.WriteFile(sharedFile, []byte("shared"), 0o644); err != nil {
|
||||
t.Fatalf("failed to create shared file: %v", err)
|
||||
}
|
||||
|
||||
// Create Linux-specific override
|
||||
if err := os.MkdirAll(linuxDir, 0o755); err != nil {
|
||||
t.Fatalf("failed to create linux dir: %v", err)
|
||||
}
|
||||
linuxFile := filepath.Join(linuxDir, "common.txt") // overrides base
|
||||
if err := os.WriteFile(linuxFile, []byte("linux"), 0o644); err != nil {
|
||||
t.Fatalf("failed to create linux file: %v", err)
|
||||
}
|
||||
|
||||
cfg := &packageConfig{}
|
||||
filesDirs := []string{baseDir, linuxDir}
|
||||
|
||||
// Apply both directories
|
||||
if err := ApplyPackages(filesDirs, targetDir, cfg); err != nil {
|
||||
t.Errorf("ApplyPackages failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify shared file from base is linked
|
||||
targetShared := filepath.Join(targetDir, "shared.txt")
|
||||
content, err := os.ReadFile(targetShared)
|
||||
if err != nil {
|
||||
t.Errorf("shared file not found: %v", err)
|
||||
} else if string(content) != "shared" {
|
||||
t.Errorf("shared file wrong content: %s", string(content))
|
||||
}
|
||||
|
||||
// Verify common.txt is overridden by Linux variant
|
||||
targetCommon := filepath.Join(targetDir, "common.txt")
|
||||
content, err = os.ReadFile(targetCommon)
|
||||
if err != nil {
|
||||
t.Errorf("common file not found: %v", err)
|
||||
} else if string(content) != "linux" {
|
||||
t.Errorf("common file should be overridden by linux variant, got: %s", string(content))
|
||||
}
|
||||
|
||||
// Verify symlink points to linux variant
|
||||
src, err := os.Readlink(targetCommon)
|
||||
if err != nil {
|
||||
t.Errorf("failed to read symlink: %v", err)
|
||||
} else if !strings.HasSuffix(src, "files.linux/common.txt") {
|
||||
t.Errorf("symlink should point to linux variant, got: %s", src)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindStaleLinksMulti_WithVariants(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
baseDir := filepath.Join(tmp, "files")
|
||||
linuxDir := filepath.Join(tmp, "files.linux")
|
||||
targetDir := filepath.Join(tmp, "target")
|
||||
|
||||
if err := os.MkdirAll(baseDir, 0o755); err != nil {
|
||||
t.Fatalf("failed to create base dir: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(linuxDir, 0o755); err != nil {
|
||||
t.Fatalf("failed to create linux dir: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
||||
t.Fatalf("failed to create target dir: %v", err)
|
||||
}
|
||||
|
||||
// Create files
|
||||
baseFile := filepath.Join(baseDir, "base.txt")
|
||||
if err := os.WriteFile(baseFile, []byte("base"), 0o644); err != nil {
|
||||
t.Fatalf("failed to create base file: %v", err)
|
||||
}
|
||||
linuxFile := filepath.Join(linuxDir, "linux.txt")
|
||||
if err := os.WriteFile(linuxFile, []byte("linux"), 0o644); err != nil {
|
||||
t.Fatalf("failed to create linux file: %v", err)
|
||||
}
|
||||
|
||||
// Create symlinks
|
||||
targetBase := filepath.Join(targetDir, "base.txt")
|
||||
if err := os.Symlink(baseFile, targetBase); err != nil {
|
||||
t.Fatalf("failed to create base symlink: %v", err)
|
||||
}
|
||||
targetLinux := filepath.Join(targetDir, "linux.txt")
|
||||
if err := os.Symlink(linuxFile, targetLinux); err != nil {
|
||||
t.Fatalf("failed to create linux symlink: %v", err)
|
||||
}
|
||||
|
||||
// Delete the linux source file to create a stale link
|
||||
if err := os.Remove(linuxFile); err != nil {
|
||||
t.Fatalf("failed to remove linux file: %v", err)
|
||||
}
|
||||
|
||||
filesDirs := []string{baseDir, linuxDir}
|
||||
stale, err := FindStaleLinksMulti(filesDirs, targetDir)
|
||||
if err != nil {
|
||||
t.Errorf("FindStaleLinksMulti failed: %v", err)
|
||||
}
|
||||
|
||||
if len(stale) != 1 {
|
||||
t.Errorf("expected 1 stale link, got %d", len(stale))
|
||||
}
|
||||
if len(stale) > 0 && stale[0] != targetLinux {
|
||||
t.Errorf("expected stale link to be %s, got %s", targetLinux, stale[0])
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -8,18 +8,18 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// repoPath returns the absolute path to the sigil repository.
|
||||
// RepoPath returns the absolute path to the sigil repository.
|
||||
// It uses SIGIL_REPO environment variable if set, otherwise defaults to ~/.dotfiles.
|
||||
func repoPath() (string, error) {
|
||||
func RepoPath() (string, error) {
|
||||
if override := os.Getenv("SIGIL_REPO"); override != "" {
|
||||
return filepath.Abs(expandHome(override))
|
||||
return filepath.Abs(ExpandHome(override))
|
||||
}
|
||||
return filepath.Abs(expandHome("~/.dotfiles"))
|
||||
return filepath.Abs(ExpandHome("~/.dotfiles"))
|
||||
}
|
||||
|
||||
// expandHome expands a leading ~ to the user's home directory.
|
||||
// ExpandHome expands a leading ~ to the user's home directory.
|
||||
// Returns the original path unchanged if expansion fails or path doesn't start with ~.
|
||||
func expandHome(path string) string {
|
||||
func ExpandHome(path string) string {
|
||||
if path == "~" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
@@ -37,9 +37,9 @@ func expandHome(path string) string {
|
||||
return path
|
||||
}
|
||||
|
||||
// compressHome compresses the user's home directory path to ~.
|
||||
// CompressHome compresses the user's home directory path to ~.
|
||||
// Returns the original path if it doesn't start with the home directory.
|
||||
func compressHome(path string) string {
|
||||
func CompressHome(path string) string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return path
|
||||
@@ -56,9 +56,9 @@ func compressHome(path string) string {
|
||||
return path
|
||||
}
|
||||
|
||||
// splitPackageSpec splits a package spec like "pkg:path/to/file" into (pkg, path).
|
||||
// SplitPackageSpec splits a package spec like "pkg:path/to/file" into (pkg, path).
|
||||
// If no colon is present, returns (spec, "", nil).
|
||||
func splitPackageSpec(spec string) (string, string, error) {
|
||||
func SplitPackageSpec(spec string) (string, string, error) {
|
||||
if spec == "" {
|
||||
return "", "", errors.New("missing package")
|
||||
}
|
||||
@@ -82,8 +82,8 @@ func splitPackageSpec(spec string) (string, string, error) {
|
||||
|
||||
// resolvePackageSpec resolves a package spec (name, path, or repo path) to (package, relative path).
|
||||
// It handles specs like "pkg:path", absolute paths within the repo, and absolute paths in the target.
|
||||
func resolvePackageSpec(spec string) (string, string, error) {
|
||||
repo, err := repoPath()
|
||||
func ResolvePackageSpec(spec string) (string, string, error) {
|
||||
repo, err := RepoPath()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
@@ -93,26 +93,26 @@ func resolvePackageSpec(spec string) (string, string, error) {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
spec = expandHome(spec)
|
||||
spec = ExpandHome(spec)
|
||||
if filepath.IsAbs(spec) {
|
||||
return resolvePathSpec(spec, repoAbs)
|
||||
return ResolvePathSpec(spec, repoAbs)
|
||||
}
|
||||
|
||||
if strings.Contains(spec, string(os.PathSeparator)) {
|
||||
return resolvePathSpec(spec, repoAbs)
|
||||
return ResolvePathSpec(spec, repoAbs)
|
||||
}
|
||||
|
||||
clean := filepath.Clean(spec)
|
||||
if strings.HasPrefix(clean, ".") || strings.HasPrefix(clean, string(os.PathSeparator)) {
|
||||
return resolvePathSpec(clean, repoAbs)
|
||||
return ResolvePathSpec(clean, repoAbs)
|
||||
}
|
||||
|
||||
return splitPackageSpec(spec)
|
||||
return SplitPackageSpec(spec)
|
||||
}
|
||||
|
||||
// resolvePathSpec resolves an absolute path spec to a package and relative path.
|
||||
// ResolvePathSpec resolves an absolute path spec to a package and relative path.
|
||||
// It checks if the path is within the repo or matches a package's target directory.
|
||||
func resolvePathSpec(pathSpec, repoAbs string) (string, string, error) {
|
||||
func ResolvePathSpec(pathSpec, repoAbs string) (string, string, error) {
|
||||
absPath, err := filepath.Abs(pathSpec)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
@@ -126,7 +126,7 @@ func resolvePathSpec(pathSpec, repoAbs string) (string, string, error) {
|
||||
parts := strings.Split(rel, string(os.PathSeparator))
|
||||
if len(parts) >= 1 {
|
||||
pkg := parts[0]
|
||||
if len(parts) >= 2 && parts[1] == filesDirName {
|
||||
if len(parts) >= 2 && parts[1] == FilesDirName {
|
||||
relPath := filepath.Join(parts[2:]...)
|
||||
return pkg, relPath, nil
|
||||
}
|
||||
@@ -144,19 +144,19 @@ func resolvePathSpec(pathSpec, repoAbs string) (string, string, error) {
|
||||
continue
|
||||
}
|
||||
pkgDir := filepath.Join(repoAbs, entry.Name())
|
||||
configPath := filepath.Join(pkgDir, configFileName)
|
||||
cfg, err := loadConfig(configPath)
|
||||
configPath := filepath.Join(pkgDir, ConfigFileName)
|
||||
cfg, err := LoadConfig(configPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
targetRoot, err := selectTarget(cfg)
|
||||
targetRoot, err := SelectTarget(cfg)
|
||||
if err != nil {
|
||||
if errors.Is(err, errTargetDisabled) {
|
||||
if errors.Is(err, ErrTargetDisabled) {
|
||||
continue
|
||||
}
|
||||
continue
|
||||
}
|
||||
absTarget, err := filepath.Abs(expandHome(targetRoot))
|
||||
absTarget, err := filepath.Abs(ExpandHome(targetRoot))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -172,15 +172,15 @@ func resolvePathSpec(pathSpec, repoAbs string) (string, string, error) {
|
||||
return "", "", fmt.Errorf("could not resolve %s to a package", pathSpec)
|
||||
}
|
||||
|
||||
// findPackageByTarget finds a package name that has the given target root.
|
||||
// FindPackageByTarget finds a package name that has the given target root.
|
||||
// Returns empty string if no package matches.
|
||||
func findPackageByTarget(repo, targetRoot string) (string, error) {
|
||||
func FindPackageByTarget(repo, targetRoot string) (string, error) {
|
||||
repoEntries, err := os.ReadDir(repo)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
absTarget, err := filepath.Abs(expandHome(targetRoot))
|
||||
absTarget, err := filepath.Abs(ExpandHome(targetRoot))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -191,19 +191,19 @@ func findPackageByTarget(repo, targetRoot string) (string, error) {
|
||||
}
|
||||
|
||||
pkgDir := filepath.Join(repo, entry.Name())
|
||||
configPath := filepath.Join(pkgDir, configFileName)
|
||||
cfg, err := loadConfig(configPath)
|
||||
configPath := filepath.Join(pkgDir, ConfigFileName)
|
||||
cfg, err := LoadConfig(configPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
target, err := selectTarget(cfg)
|
||||
target, err := SelectTarget(cfg)
|
||||
if err != nil {
|
||||
if errors.Is(err, errTargetDisabled) {
|
||||
if errors.Is(err, ErrTargetDisabled) {
|
||||
continue
|
||||
}
|
||||
continue
|
||||
}
|
||||
absPkgTarget, err := filepath.Abs(expandHome(target))
|
||||
absPkgTarget, err := filepath.Abs(ExpandHome(target))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -215,9 +215,9 @@ func findPackageByTarget(repo, targetRoot string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// repoPathForTarget returns the repo file path that a target symlink points to.
|
||||
// RepoPathForTarget returns the repo file path that a target symlink points to.
|
||||
// Returns empty string if the target is not a symlink to the repo.
|
||||
func repoPathForTarget(targetPath, repo string) (string, error) {
|
||||
func RepoPathForTarget(targetPath, repo string) (string, error) {
|
||||
info, err := os.Lstat(targetPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -252,7 +252,7 @@ func repoPathForTarget(targetPath, repo string) (string, error) {
|
||||
if len(parts) < 3 {
|
||||
return "", nil
|
||||
}
|
||||
if parts[1] != filesDirName {
|
||||
if parts[1] != FilesDirName {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package core
|
||||
|
||||
import (
|
||||
"os"
|
||||
@@ -27,9 +27,9 @@ func TestExpandHome(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := expandHome(tt.input)
|
||||
got := ExpandHome(tt.input)
|
||||
if got != tt.expected {
|
||||
t.Errorf("expandHome(%q) = %q, want %q", tt.input, got, tt.expected)
|
||||
t.Errorf("ExpandHome(%q) = %q, want %q", tt.input, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -54,9 +54,9 @@ func TestCompressHome(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := compressHome(tt.input)
|
||||
got := CompressHome(tt.input)
|
||||
if got != tt.expected {
|
||||
t.Errorf("compressHome(%q) = %q, want %q", tt.input, got, tt.expected)
|
||||
t.Errorf("CompressHome(%q) = %q, want %q", tt.input, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -80,13 +80,13 @@ func TestSplitPackageSpec(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
pkg, rel, err := splitPackageSpec(tt.input)
|
||||
pkg, rel, err := SplitPackageSpec(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("splitPackageSpec(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
|
||||
t.Errorf("SplitPackageSpec(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if pkg != tt.wantPkg || rel != tt.wantRel {
|
||||
t.Errorf("splitPackageSpec(%q) = (%q, %q), want (%q, %q)", tt.input, pkg, rel, tt.wantPkg, tt.wantRel)
|
||||
t.Errorf("SplitPackageSpec(%q) = (%q, %q), want (%q, %q)", tt.input, pkg, rel, tt.wantPkg, tt.wantRel)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -150,13 +150,13 @@ func TestSelectTarget(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := selectTarget(tt.cfg)
|
||||
got, err := SelectTarget(tt.cfg)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("selectTarget() error = %v, wantErr %v", err, tt.wantErr)
|
||||
t.Errorf("SelectTarget() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("selectTarget() = %q, want %q", got, tt.want)
|
||||
t.Errorf("SelectTarget() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package core
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
@@ -12,7 +12,7 @@ type packageFlags struct {
|
||||
dryRun bool
|
||||
}
|
||||
|
||||
func parsePackageFlags(args []string) (packageFlags, string, error) {
|
||||
func ParsePackageFlags(args []string) (packageFlags, string, error) {
|
||||
flags := packageFlags{}
|
||||
var pkg string
|
||||
for _, arg := range args {
|
||||
@@ -43,7 +43,7 @@ func newReader() *bufio.Reader {
|
||||
return stdinReader
|
||||
}
|
||||
|
||||
func promptWithDefault(label, def string) (string, error) {
|
||||
func PromptWithDefault(label, def string) (string, error) {
|
||||
reader := newReader()
|
||||
if def != "" {
|
||||
fmt.Printf("%s [%s]: ", label, def)
|
||||
@@ -62,7 +62,7 @@ func promptWithDefault(label, def string) (string, error) {
|
||||
return text, nil
|
||||
}
|
||||
|
||||
func promptYesNo(message string, def bool) (bool, error) {
|
||||
func PromptYesNo(message string, def bool) (bool, error) {
|
||||
reader := newReader()
|
||||
defLabel := "y/N"
|
||||
if def {
|
||||
@@ -6,9 +6,11 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"sigil/internal/core"
|
||||
)
|
||||
|
||||
const version = "0.1.1"
|
||||
const version = "0.2.1"
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
@@ -69,7 +71,7 @@ func applyCmd(args []string) error {
|
||||
return fmt.Errorf("unknown flag %q", arg)
|
||||
}
|
||||
|
||||
repo, err := repoPath()
|
||||
repo, err := core.RepoPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -87,7 +89,7 @@ func applyCmd(args []string) error {
|
||||
}
|
||||
|
||||
pkgDir := filepath.Join(repo, entry.Name())
|
||||
configPath := filepath.Join(pkgDir, configFileName)
|
||||
configPath := filepath.Join(pkgDir, core.ConfigFileName)
|
||||
if _, err := os.Stat(configPath); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
@@ -95,32 +97,44 @@ func applyCmd(args []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg, err := loadConfig(configPath)
|
||||
cfg, err := core.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", entry.Name(), err)
|
||||
}
|
||||
|
||||
targetRoot, err := selectTarget(cfg)
|
||||
targetRoot, err := core.SelectTarget(cfg)
|
||||
if err != nil {
|
||||
if errors.Is(err, errTargetDisabled) {
|
||||
if errors.Is(err, core.ErrTargetDisabled) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("%s: %w", entry.Name(), err)
|
||||
}
|
||||
|
||||
filesDir := filepath.Join(pkgDir, filesDirName)
|
||||
if _, err := os.Stat(filesDir); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
filesDir := filepath.Join(pkgDir, core.FilesDirName)
|
||||
variantDir := filepath.Join(pkgDir, core.VariantDirName())
|
||||
|
||||
filesDirs := []string{filesDir}
|
||||
if _, err := os.Stat(variantDir); err == nil {
|
||||
filesDirs = append(filesDirs, variantDir)
|
||||
}
|
||||
|
||||
if err := applyPackage(filesDir, targetRoot, cfg); err != nil {
|
||||
// Check if any files dir exists
|
||||
hasFiles := false
|
||||
for _, dir := range filesDirs {
|
||||
if _, err := os.Stat(dir); err == nil {
|
||||
hasFiles = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasFiles {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := core.ApplyPackages(filesDirs, targetRoot, cfg); err != nil {
|
||||
return fmt.Errorf("%s: %w", entry.Name(), err)
|
||||
}
|
||||
|
||||
stale, err := findStaleLinks(filesDir, targetRoot)
|
||||
stale, err := core.FindStaleLinksMulti(filesDirs, targetRoot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", entry.Name(), err)
|
||||
}
|
||||
@@ -132,11 +146,11 @@ func applyCmd(args []string) error {
|
||||
}
|
||||
|
||||
if prune {
|
||||
return removeLinks(stales, false)
|
||||
return core.RemoveLinks(stales, false)
|
||||
}
|
||||
|
||||
fmt.Printf("Stale links found: %d\n", len(stales))
|
||||
return handleStaleLinks(stales)
|
||||
return core.HandleStaleLinks(stales)
|
||||
}
|
||||
|
||||
func addCmd(args []string) error {
|
||||
@@ -164,7 +178,7 @@ func addCmd(args []string) error {
|
||||
return fmt.Errorf("%s is a symlink; refusing to add", absPath)
|
||||
}
|
||||
|
||||
repo, err := repoPath()
|
||||
repo, err := core.RepoPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -184,12 +198,12 @@ func addCmd(args []string) error {
|
||||
|
||||
var pkgName string
|
||||
|
||||
matchedPkg, err := findPackageByTarget(repo, defaultTarget)
|
||||
matchedPkg, err := core.FindPackageByTarget(repo, defaultTarget)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if matchedPkg != "" {
|
||||
ok, err := promptYesNo(fmt.Sprintf("Merge into existing package %q?", matchedPkg), true)
|
||||
ok, err := core.PromptYesNo(fmt.Sprintf("Merge into existing package %q?", matchedPkg), true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -200,7 +214,7 @@ func addCmd(args []string) error {
|
||||
|
||||
if pkgName == "" {
|
||||
var err error
|
||||
pkgName, err = promptWithDefault("Package name", defaultPkg)
|
||||
pkgName, err = core.PromptWithDefault("Package name", defaultPkg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -212,12 +226,12 @@ func addCmd(args []string) error {
|
||||
targetRootInput := defaultTarget
|
||||
if pkgName != matchedPkg || matchedPkg == "" {
|
||||
var err error
|
||||
targetRootInput, err = promptWithDefault("Target path", defaultTarget)
|
||||
targetRootInput, err = core.PromptWithDefault("Target path", defaultTarget)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
targetRoot, err := filepath.Abs(expandHome(targetRootInput))
|
||||
targetRoot, err := filepath.Abs(core.ExpandHome(targetRootInput))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -230,22 +244,24 @@ func addCmd(args []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
filesDir := filepath.Join(pkgDir, filesDirName)
|
||||
filesDir := filepath.Join(pkgDir, core.FilesDirName)
|
||||
if err := os.MkdirAll(filesDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
configPath := filepath.Join(pkgDir, configFileName)
|
||||
configPath := filepath.Join(pkgDir, core.ConfigFileName)
|
||||
if !pkgExists {
|
||||
if err := writeConfig(configPath, targetRoot); err != nil {
|
||||
if err := core.WriteConfig(configPath, targetRoot); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
variantDir := filepath.Join(pkgDir, core.VariantDirName())
|
||||
|
||||
if info.IsDir() {
|
||||
if pkgExists {
|
||||
return errors.New("cannot merge a directory into an existing package yet")
|
||||
}
|
||||
if err := moveDirContents(absPath, filesDir); err != nil {
|
||||
if err := core.MoveDirContents(absPath, filesDir); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
@@ -257,7 +273,15 @@ func addCmd(args []string) error {
|
||||
return fmt.Errorf("path %s is outside target %s", absPath, targetRoot)
|
||||
}
|
||||
|
||||
destPath := filepath.Join(filesDir, rel)
|
||||
// Smart default: check if variant file already exists
|
||||
destDir := filesDir
|
||||
variantPath := filepath.Join(variantDir, rel)
|
||||
if _, err := os.Stat(variantPath); err == nil {
|
||||
// Variant file exists, update it instead
|
||||
destDir = variantDir
|
||||
}
|
||||
|
||||
destPath := filepath.Join(destDir, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -271,86 +295,29 @@ func addCmd(args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
cfg, err := loadConfig(configPath)
|
||||
cfg, err := core.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return applyPackage(filesDir, targetRoot, cfg)
|
||||
filesDirs := []string{filesDir}
|
||||
if _, err := os.Stat(variantDir); err == nil {
|
||||
filesDirs = append(filesDirs, variantDir)
|
||||
}
|
||||
|
||||
return core.ApplyPackages(filesDirs, targetRoot, cfg)
|
||||
}
|
||||
|
||||
func unlinkCmd(args []string) error {
|
||||
return removeOrUnlink(args, false)
|
||||
return core.RemoveOrUnlink(args, false)
|
||||
}
|
||||
|
||||
func removeCmd(args []string) error {
|
||||
return removeOrUnlink(args, true)
|
||||
}
|
||||
|
||||
func removeOrUnlink(args []string, isRemove bool) error {
|
||||
_ = isRemove // commands are identical, flag kept for clarity
|
||||
|
||||
flags, pkgSpec, err := parsePackageFlags(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pkgName, relPath, err := resolvePackageSpec(pkgSpec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
repo, err := repoPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pkgDir := filepath.Join(repo, pkgName)
|
||||
configPath := filepath.Join(pkgDir, configFileName)
|
||||
cfg, err := loadConfig(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
targetRoot, err := selectTarget(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
filesDir := filepath.Join(pkgDir, filesDirName)
|
||||
if _, err := os.Stat(filesDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if relPath == "" {
|
||||
if err := restorePackage(filesDir, targetRoot, flags.dryRun); err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.dryRun {
|
||||
return nil
|
||||
}
|
||||
if err := os.RemoveAll(pkgDir); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("removed package %s\n", pkgName)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := restorePath(filesDir, targetRoot, relPath, flags.dryRun); err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.dryRun {
|
||||
return nil
|
||||
}
|
||||
if err := os.RemoveAll(filepath.Join(filesDir, relPath)); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("removed %s:%s\n", pkgName, relPath)
|
||||
return nil
|
||||
return core.RemoveOrUnlink(args, true)
|
||||
}
|
||||
|
||||
func statusCmd() error {
|
||||
repo, err := repoPath()
|
||||
repo, err := core.RepoPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -369,23 +336,30 @@ func statusCmd() error {
|
||||
|
||||
totalPackages++
|
||||
pkgDir := filepath.Join(repo, entry.Name())
|
||||
configPath := filepath.Join(pkgDir, configFileName)
|
||||
cfg, err := loadConfig(configPath)
|
||||
configPath := filepath.Join(pkgDir, core.ConfigFileName)
|
||||
cfg, err := core.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", entry.Name(), err)
|
||||
}
|
||||
|
||||
targetRoot, err := selectTarget(cfg)
|
||||
targetRoot, err := core.SelectTarget(cfg)
|
||||
if err != nil {
|
||||
if errors.Is(err, errTargetDisabled) {
|
||||
if errors.Is(err, core.ErrTargetDisabled) {
|
||||
totalPackages-- // don't count disabled packages
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("%s: %w", entry.Name(), err)
|
||||
}
|
||||
|
||||
filesDir := filepath.Join(pkgDir, filesDirName)
|
||||
stale, err := findStaleLinks(filesDir, targetRoot)
|
||||
filesDir := filepath.Join(pkgDir, core.FilesDirName)
|
||||
variantDir := filepath.Join(pkgDir, core.VariantDirName())
|
||||
|
||||
filesDirs := []string{filesDir}
|
||||
if _, err := os.Stat(variantDir); err == nil {
|
||||
filesDirs = append(filesDirs, variantDir)
|
||||
}
|
||||
|
||||
stale, err := core.FindStaleLinksMulti(filesDirs, targetRoot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", entry.Name(), err)
|
||||
}
|
||||
|
||||
@@ -1,421 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func applyPackage(filesDir, targetRoot string, cfg *packageConfig) error {
|
||||
if err := ensureDir(targetRoot); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return filepath.WalkDir(filesDir, func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if path == filesDir {
|
||||
return nil
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(filesDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if shouldIgnorePath(rel, cfg) {
|
||||
if entry.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
targetPath := filepath.Join(targetRoot, rel)
|
||||
|
||||
if entry.IsDir() {
|
||||
return ensureDir(targetPath)
|
||||
}
|
||||
|
||||
srcAbs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return linkFile(srcAbs, targetPath)
|
||||
})
|
||||
}
|
||||
|
||||
func linkFile(src, dst string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info, err := os.Lstat(dst); err == nil {
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
current, err := os.Readlink(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if current == src {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("conflict at %s (symlink points to %s, expected %s). Run 'sigil unlink %s' to restore and remove the conflicting link", dst, current, src, dst)
|
||||
}
|
||||
return fmt.Errorf("conflict at %s (file exists and is not a symlink). Back up or remove this file first", dst)
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.Symlink(src, dst)
|
||||
}
|
||||
|
||||
func ensureDir(path string) error {
|
||||
info, err := os.Lstat(path)
|
||||
if err == nil {
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s exists and is not a directory", path)
|
||||
}
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return os.MkdirAll(path, 0o755)
|
||||
}
|
||||
|
||||
func findStaleLinks(filesDir, targetRoot string) ([]string, error) {
|
||||
filesAbs, err := filepath.Abs(filesDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
targetAbs, err := filepath.Abs(targetRoot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var stale []string
|
||||
walkErr := filepath.WalkDir(filesDir, func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(filesAbs, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
targetPath := filepath.Join(targetAbs, rel)
|
||||
info, err := os.Lstat(targetPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
// Symlink doesn't exist - not stale, just not applied
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if info.Mode()&os.ModeSymlink == 0 {
|
||||
// Not a symlink, skip
|
||||
return nil
|
||||
}
|
||||
|
||||
src, err := os.Readlink(targetPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !filepath.IsAbs(src) {
|
||||
src = filepath.Join(filepath.Dir(targetPath), src)
|
||||
}
|
||||
src = filepath.Clean(src)
|
||||
|
||||
// Only consider symlinks pointing to our repo
|
||||
if !strings.HasPrefix(src, filesAbs+string(os.PathSeparator)) && src != filesAbs {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if the source file in repo still exists
|
||||
if _, err := os.Stat(src); errors.Is(err, os.ErrNotExist) {
|
||||
stale = append(stale, targetPath)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if walkErr != nil {
|
||||
return nil, walkErr
|
||||
}
|
||||
|
||||
return stale, nil
|
||||
}
|
||||
|
||||
func removeLinks(paths []string, dryRun bool) error {
|
||||
for _, path := range paths {
|
||||
if dryRun {
|
||||
fmt.Printf("dry-run: remove %s\n", path)
|
||||
continue
|
||||
}
|
||||
if err := os.Remove(path); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("removed %s\n", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleStaleLinks(stales []string) error {
|
||||
if len(stales) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
repo, err := repoPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reader := newReader()
|
||||
for _, path := range stales {
|
||||
fmt.Printf("stale: %s\n", path)
|
||||
|
||||
canUnlink, err := staleHasRepoFile(path, repo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
prompt := "action [p=prune, u=unlink, i=ignore]: "
|
||||
if !canUnlink {
|
||||
prompt = "action [p=prune, i=ignore]: "
|
||||
}
|
||||
|
||||
fmt.Print(prompt)
|
||||
choice, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
choice = strings.TrimSpace(strings.ToLower(choice))
|
||||
if choice == "" || choice == "i" {
|
||||
continue
|
||||
}
|
||||
if choice == "p" {
|
||||
if err := os.Remove(path); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("removed %s\n", path)
|
||||
continue
|
||||
}
|
||||
if choice == "u" && canUnlink {
|
||||
if err := unlinkStale(path, repo); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
fmt.Println("invalid choice; skipping")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func staleHasRepoFile(targetPath, repo string) (bool, error) {
|
||||
repoPath, err := repoPathForTarget(targetPath, repo)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if repoPath == "" {
|
||||
return false, nil
|
||||
}
|
||||
if _, err := os.Stat(repoPath); errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
} else if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func unlinkStale(targetPath, repo string) error {
|
||||
repoPath, err := repoPathForTarget(targetPath, repo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if repoPath == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.Remove(targetPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := copyFile(repoPath, targetPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.Remove(repoPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("unlinked %s (removed %s)\n", targetPath, repoPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func restorePackage(filesDir, targetRoot string, dryRun bool) error {
|
||||
filesAbs, err := filepath.Abs(filesDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return filepath.WalkDir(filesDir, func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if path == filesDir {
|
||||
return nil
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(filesDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
targetPath := filepath.Join(targetRoot, rel)
|
||||
return restoreOne(path, targetPath, filesAbs, dryRun)
|
||||
})
|
||||
}
|
||||
|
||||
func restorePath(filesDir, targetRoot, relPath string, dryRun bool) error {
|
||||
filesAbs, err := filepath.Abs(filesDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
relPath = filepath.Clean(relPath)
|
||||
if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) {
|
||||
return fmt.Errorf("invalid relative path %q", relPath)
|
||||
}
|
||||
|
||||
sourcePath := filepath.Join(filesDir, relPath)
|
||||
info, err := os.Lstat(sourcePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
return filepath.WalkDir(sourcePath, func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(filesDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
targetPath := filepath.Join(targetRoot, rel)
|
||||
return restoreOne(path, targetPath, filesAbs, dryRun)
|
||||
})
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(filesDir, sourcePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return restoreOne(sourcePath, filepath.Join(targetRoot, rel), filesAbs, dryRun)
|
||||
}
|
||||
|
||||
func restoreOne(sourcePath, targetPath, filesAbs string, dryRun bool) error {
|
||||
info, err := os.Lstat(targetPath)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
src, err := os.Readlink(targetPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !filepath.IsAbs(src) {
|
||||
src = filepath.Join(filepath.Dir(targetPath), src)
|
||||
}
|
||||
src = filepath.Clean(src)
|
||||
|
||||
if !strings.HasPrefix(src, filesAbs+string(os.PathSeparator)) && src != filesAbs {
|
||||
return nil
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
fmt.Printf("dry-run: restore %s\n", targetPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.Remove(targetPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("restored %s\n", targetPath)
|
||||
return copyFile(sourcePath, targetPath)
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
srcFile, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
info, err := srcFile.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dstFile, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
if _, err := io.Copy(dstFile, srcFile); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func moveDirContents(srcDir, destDir string) error {
|
||||
entries, err := os.ReadDir(srcDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
srcPath := filepath.Join(srcDir, entry.Name())
|
||||
destPath := filepath.Join(destDir, entry.Name())
|
||||
|
||||
if _, err := os.Stat(destPath); err == nil {
|
||||
return fmt.Errorf("destination already exists: %s", destPath)
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.Rename(srcPath, destPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user