diff --git a/README.md b/README.md index 4febefc..5980348 100644 --- a/README.md +++ b/README.md @@ -23,10 +23,28 @@ go run . ~/.dotfiles/ / 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./` 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 diff --git a/internal/core/config.go b/internal/core/config.go index 49eff69..70624b0 100644 --- a/internal/core/config.go +++ b/internal/core/config.go @@ -89,10 +89,7 @@ func LoadConfig(path string) (*packageConfig, error) { } func SelectTarget(cfg *packageConfig) (string, error) { - osKey := runtime.GOOS - if osKey == "darwin" { - osKey = "macos" - } + osKey := OSKey() if cfg.disabled[osKey] { return "", ErrTargetDisabled } @@ -105,6 +102,18 @@ func SelectTarget(cfg *packageConfig) (string, error) { return "", fmt.Errorf("missing target for %s and default", osKey) } +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 { diff --git a/internal/core/ops.go b/internal/core/ops.go index a3ba013..a30781a 100644 --- a/internal/core/ops.go +++ b/internal/core/ops.go @@ -11,42 +11,74 @@ import ( ) 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 } - 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 + for _, filesDir := range filesDirs { + if _, err := os.Stat(filesDir); err != nil { + if errors.Is(err, os.ErrNotExist) { + continue } - return nil + return err } - targetPath := filepath.Join(targetRoot, rel) + err := 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 EnsureDir(targetPath) - } + rel, err := filepath.Rel(filesDir, path) + if err != nil { + return err + } - srcAbs, err := filepath.Abs(path) + 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 LinkFile(srcAbs, targetPath) - }) + } + return nil } func LinkFile(src, dst string) error { @@ -88,68 +120,158 @@ func EnsureDir(path string) error { } func FindStaleLinks(filesDir, targetRoot string) ([]string, error) { - filesAbs, err := filepath.Abs(filesDir) - if err != nil { - return nil, err - } + return FindStaleLinksMulti([]string{filesDir}, targetRoot) +} +func FindStaleLinksMulti(filesDirs []string, targetRoot string) ([]string, error) { 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 { + // Collect all valid source directories (prefixes) for checking + var sourcePrefixes []string + for _, filesDir := range filesDirs { + filesAbs, err := filepath.Abs(filesDir) if err != nil { - return err + return nil, 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 _, err := os.Stat(filesDir); err != nil { if errors.Is(err, os.ErrNotExist) { - // Symlink doesn't exist - not stale, just not applied + 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 { + 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 } - return err + + 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 { - // Not a symlink, skip - return nil + continue } src, err := os.Readlink(targetPath) if err != nil { - return err + continue } 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 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 the source file in repo still exists + // Check if source 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 diff --git a/internal/core/ops_test.go b/internal/core/ops_test.go index b40502e..2c63f5f 100644 --- a/internal/core/ops_test.go +++ b/internal/core/ops_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "regexp" + "strings" "testing" ) @@ -304,3 +305,121 @@ func TestRestoreOne(t *testing.T) { 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]) + } +} diff --git a/main.go b/main.go index 34c68df..8f0a083 100644 --- a/main.go +++ b/main.go @@ -10,7 +10,7 @@ import ( "sigil/internal/core" ) -const version = "0.1.3" +const version = "0.2.0" func main() { if len(os.Args) < 2 { @@ -111,18 +111,30 @@ func applyCmd(args []string) error { } filesDir := filepath.Join(pkgDir, core.FilesDirName) - if _, err := os.Stat(filesDir); err != nil { - if errors.Is(err, os.ErrNotExist) { - continue - } - return err + variantDir := filepath.Join(pkgDir, core.VariantDirName()) + + filesDirs := []string{filesDir} + if _, err := os.Stat(variantDir); err == nil { + filesDirs = append(filesDirs, variantDir) } - if err := core.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 := core.FindStaleLinks(filesDir, targetRoot) + stale, err := core.FindStaleLinksMulti(filesDirs, targetRoot) if err != nil { return fmt.Errorf("%s: %w", entry.Name(), err) } @@ -243,6 +255,8 @@ func addCmd(args []string) error { 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") @@ -259,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 } @@ -278,7 +300,12 @@ func addCmd(args []string) error { return err } - return core.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 { @@ -325,7 +352,14 @@ func statusCmd() error { } filesDir := filepath.Join(pkgDir, core.FilesDirName) - stale, err := core.FindStaleLinks(filesDir, targetRoot) + 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) }