diff --git a/cmd/sgard/mirror.go b/cmd/sgard/mirror.go new file mode 100644 index 0000000..5efa954 --- /dev/null +++ b/cmd/sgard/mirror.go @@ -0,0 +1,72 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/kisom/sgard/garden" + "github.com/spf13/cobra" +) + +var forceMirror bool + +var mirrorCmd = &cobra.Command{ + Use: "mirror", + Short: "Sync directory contents between filesystem and manifest", +} + +var mirrorUpCmd = &cobra.Command{ + Use: "up ...", + Short: "Sync filesystem state into manifest (add new, remove deleted, rehash changed)", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + g, err := garden.Open(repoFlag) + if err != nil { + return err + } + + if err := g.MirrorUp(args); err != nil { + return err + } + + fmt.Println("Mirror up complete.") + return nil + }, +} + +var mirrorDownCmd = &cobra.Command{ + Use: "down ...", + Short: "Sync manifest state to filesystem (restore tracked, delete untracked)", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + g, err := garden.Open(repoFlag) + if err != nil { + return err + } + + confirm := func(path string) bool { + fmt.Printf("Delete untracked file %s? [y/N] ", path) + scanner := bufio.NewScanner(os.Stdin) + if scanner.Scan() { + answer := strings.TrimSpace(strings.ToLower(scanner.Text())) + return answer == "y" || answer == "yes" + } + return false + } + + if err := g.MirrorDown(args, forceMirror, confirm); err != nil { + return err + } + + fmt.Println("Mirror down complete.") + return nil + }, +} + +func init() { + mirrorDownCmd.Flags().BoolVarP(&forceMirror, "force", "f", false, "delete untracked files without prompting") + mirrorCmd.AddCommand(mirrorUpCmd, mirrorDownCmd) + rootCmd.AddCommand(mirrorCmd) +} diff --git a/garden/garden.go b/garden/garden.go index 64949b0..eb781d3 100644 --- a/garden/garden.go +++ b/garden/garden.go @@ -98,9 +98,55 @@ func (g *Garden) SetClock(c clockwork.Clock) { g.clock = c } +// addEntry adds a single file or symlink to the manifest. The abs path must +// already be resolved and info must come from os.Lstat. If skipDup is true, +// already-tracked paths are silently skipped instead of returning an error. +func (g *Garden) addEntry(abs string, info os.FileInfo, now time.Time, skipDup bool) error { + tilded := toTildePath(abs) + + if g.findEntry(tilded) != nil { + if skipDup { + return nil + } + return fmt.Errorf("already tracking %s", tilded) + } + + entry := manifest.Entry{ + Path: tilded, + Mode: fmt.Sprintf("%04o", info.Mode().Perm()), + Updated: now, + } + + switch { + case info.Mode()&os.ModeSymlink != 0: + target, err := os.Readlink(abs) + if err != nil { + return fmt.Errorf("reading symlink %s: %w", abs, err) + } + entry.Type = "link" + entry.Target = target + + default: + data, err := os.ReadFile(abs) + if err != nil { + return fmt.Errorf("reading file %s: %w", abs, err) + } + hash, err := g.store.Write(data) + if err != nil { + return fmt.Errorf("storing blob for %s: %w", abs, err) + } + entry.Type = "file" + entry.Hash = hash + } + + g.manifest.Files = append(g.manifest.Files, entry) + return nil +} + // Add tracks new files, directories, or symlinks. Each path is resolved // to an absolute path, inspected for its type, and added to the manifest. -// Regular files are hashed and stored in the blob store. +// Regular files are hashed and stored in the blob store. Directories are +// recursively walked and all leaf files and symlinks are added individually. func (g *Garden) Add(paths []string) error { now := g.clock.Now().UTC() @@ -115,45 +161,29 @@ func (g *Garden) Add(paths []string) error { return fmt.Errorf("stat %s: %w", abs, err) } - tilded := toTildePath(abs) - - // Check if already tracked. - if g.findEntry(tilded) != nil { - return fmt.Errorf("already tracking %s", tilded) - } - - entry := manifest.Entry{ - Path: tilded, - Mode: fmt.Sprintf("%04o", info.Mode().Perm()), - Updated: now, - } - - switch { - case info.Mode()&os.ModeSymlink != 0: - target, err := os.Readlink(abs) + if info.IsDir() { + // Recursively walk the directory, adding all files and symlinks. + err := filepath.WalkDir(abs, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil // skip directory entries themselves + } + fi, err := os.Lstat(path) + if err != nil { + return fmt.Errorf("stat %s: %w", path, err) + } + return g.addEntry(path, fi, now, true) + }) if err != nil { - return fmt.Errorf("reading symlink %s: %w", abs, err) + return fmt.Errorf("walking directory %s: %w", abs, err) } - entry.Type = "link" - entry.Target = target - - case info.IsDir(): - entry.Type = "directory" - - default: - data, err := os.ReadFile(abs) - if err != nil { - return fmt.Errorf("reading file %s: %w", abs, err) + } else { + if err := g.addEntry(abs, info, now, false); err != nil { + return err } - hash, err := g.store.Write(data) - if err != nil { - return fmt.Errorf("storing blob for %s: %w", abs, err) - } - entry.Type = "file" - entry.Hash = hash } - - g.manifest.Files = append(g.manifest.Files, entry) } g.manifest.Updated = now diff --git a/garden/garden_test.go b/garden/garden_test.go index 421f580..c77f8b1 100644 --- a/garden/garden_test.go +++ b/garden/garden_test.go @@ -135,17 +135,29 @@ func TestAddDirectory(t *testing.T) { if err := os.Mkdir(testDir, 0o755); err != nil { t.Fatalf("creating test dir: %v", err) } + testFile := filepath.Join(testDir, "inside.txt") + if err := os.WriteFile(testFile, []byte("inside"), 0o644); err != nil { + t.Fatalf("writing file inside dir: %v", err) + } if err := g.Add([]string{testDir}); err != nil { t.Fatalf("Add: %v", err) } - entry := g.manifest.Files[0] - if entry.Type != "directory" { - t.Errorf("expected type directory, got %s", entry.Type) + if len(g.manifest.Files) != 1 { + t.Fatalf("expected 1 file, got %d", len(g.manifest.Files)) } - if entry.Hash != "" { - t.Error("directories should have no hash") + + entry := g.manifest.Files[0] + if entry.Type != "file" { + t.Errorf("expected type file, got %s", entry.Type) + } + if entry.Hash == "" { + t.Error("expected non-empty hash") + } + expectedPath := toTildePath(testFile) + if entry.Path != expectedPath { + t.Errorf("expected path %s, got %s", expectedPath, entry.Path) } } diff --git a/garden/mirror.go b/garden/mirror.go new file mode 100644 index 0000000..b40df71 --- /dev/null +++ b/garden/mirror.go @@ -0,0 +1,187 @@ +package garden + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// MirrorUp synchronises the manifest with the current filesystem state for +// each given directory path. New files/symlinks are added, deleted files are +// removed from the manifest, and changed files are re-hashed. +func (g *Garden) MirrorUp(paths []string) error { + now := g.clock.Now().UTC() + + for _, p := range paths { + abs, err := filepath.Abs(p) + if err != nil { + return fmt.Errorf("resolving path %s: %w", p, err) + } + + tildePrefix := toTildePath(abs) + // Ensure we match entries *under* the directory, not just the dir itself. + if !strings.HasSuffix(tildePrefix, "/") { + tildePrefix += "/" + } + + // 1. Walk the directory and add any new files/symlinks. + err = filepath.WalkDir(abs, func(path string, d os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + return nil + } + fi, lstatErr := os.Lstat(path) + if lstatErr != nil { + return fmt.Errorf("stat %s: %w", path, lstatErr) + } + return g.addEntry(path, fi, now, true) + }) + if err != nil { + return fmt.Errorf("walking directory %s: %w", abs, err) + } + + // 2. Remove manifest entries whose files no longer exist on disk. + kept := g.manifest.Files[:0] + for _, e := range g.manifest.Files { + if strings.HasPrefix(e.Path, tildePrefix) { + expanded, err := ExpandTildePath(e.Path) + if err != nil { + return fmt.Errorf("expanding path %s: %w", e.Path, err) + } + if _, err := os.Lstat(expanded); err != nil { + // File no longer exists — drop entry. + continue + } + } + kept = append(kept, e) + } + g.manifest.Files = kept + + // 3. Re-hash remaining file entries under the prefix (like Checkpoint). + for i := range g.manifest.Files { + entry := &g.manifest.Files[i] + if !strings.HasPrefix(entry.Path, tildePrefix) { + continue + } + if entry.Type != "file" { + continue + } + + expanded, err := ExpandTildePath(entry.Path) + if err != nil { + return fmt.Errorf("expanding path %s: %w", entry.Path, err) + } + + data, err := os.ReadFile(expanded) + if err != nil { + return fmt.Errorf("reading %s: %w", expanded, err) + } + hash, err := g.store.Write(data) + if err != nil { + return fmt.Errorf("storing blob for %s: %w", expanded, err) + } + if hash != entry.Hash { + entry.Hash = hash + entry.Updated = now + } + } + } + + g.manifest.Updated = now + if err := g.manifest.Save(g.manifestPath); err != nil { + return fmt.Errorf("saving manifest: %w", err) + } + + return nil +} + +// MirrorDown synchronises the filesystem with the manifest for each given +// directory path. Tracked entries are restored and untracked files on disk +// are deleted. If force is false, confirm is called before each deletion; +// a false return skips that file. +func (g *Garden) MirrorDown(paths []string, force bool, confirm func(string) bool) error { + for _, p := range paths { + abs, err := filepath.Abs(p) + if err != nil { + return fmt.Errorf("resolving path %s: %w", p, err) + } + + tildePrefix := toTildePath(abs) + if !strings.HasSuffix(tildePrefix, "/") { + tildePrefix += "/" + } + + // 1. Collect manifest entries under this prefix. + tracked := make(map[string]bool) + for i := range g.manifest.Files { + entry := &g.manifest.Files[i] + if !strings.HasPrefix(entry.Path, tildePrefix) { + continue + } + + expanded, err := ExpandTildePath(entry.Path) + if err != nil { + return fmt.Errorf("expanding path %s: %w", entry.Path, err) + } + tracked[expanded] = true + + // Create parent directories. + dir := filepath.Dir(expanded) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("creating directory %s: %w", dir, err) + } + + // Restore the entry. + switch entry.Type { + case "file": + if err := g.restoreFile(expanded, entry); err != nil { + return err + } + case "link": + if err := restoreLink(expanded, entry); err != nil { + return err + } + } + } + + // 2. Walk disk and delete files not in manifest. + var emptyDirs []string + err = filepath.WalkDir(abs, func(path string, d os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + // Collect directories for potential cleanup (post-order). + if path != abs { + emptyDirs = append(emptyDirs, path) + } + return nil + } + if tracked[path] { + return nil + } + // Untracked file/symlink on disk. + if !force { + if confirm == nil || !confirm(path) { + return nil + } + } + _ = os.Remove(path) + return nil + }) + if err != nil { + return fmt.Errorf("walking directory %s: %w", abs, err) + } + + // 3. Clean up empty directories (reverse order so children come first). + for i := len(emptyDirs) - 1; i >= 0; i-- { + // os.Remove only removes empty directories. + _ = os.Remove(emptyDirs[i]) + } + } + + return nil +} diff --git a/garden/mirror_test.go b/garden/mirror_test.go new file mode 100644 index 0000000..bfb069a --- /dev/null +++ b/garden/mirror_test.go @@ -0,0 +1,297 @@ +package garden + +import ( + "os" + "path/filepath" + "testing" +) + +func TestAddRecursesDirectory(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "repo") + + g, err := Init(repoDir) + if err != nil { + t.Fatalf("Init: %v", err) + } + + // Create a directory tree with nested files. + dir := filepath.Join(root, "dotfiles") + if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil { + t.Fatalf("creating dirs: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "a.conf"), []byte("aaa"), 0o644); err != nil { + t.Fatalf("writing a.conf: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "sub", "b.conf"), []byte("bbb"), 0o644); err != nil { + t.Fatalf("writing b.conf: %v", err) + } + + if err := g.Add([]string{dir}); err != nil { + t.Fatalf("Add: %v", err) + } + + if len(g.manifest.Files) != 2 { + t.Fatalf("expected 2 files, got %d", len(g.manifest.Files)) + } + + for _, e := range g.manifest.Files { + if e.Type == "directory" { + t.Errorf("should not have directory type entries, got %+v", e) + } + if e.Type != "file" { + t.Errorf("expected type file, got %s for %s", e.Type, e.Path) + } + if e.Hash == "" { + t.Errorf("expected non-empty hash for %s", e.Path) + } + } +} + +func TestAddRecursesSkipsDuplicates(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "repo") + + g, err := Init(repoDir) + if err != nil { + t.Fatalf("Init: %v", err) + } + + dir := filepath.Join(root, "dotfiles") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("creating dir: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "f.txt"), []byte("data"), 0o644); err != nil { + t.Fatalf("writing file: %v", err) + } + + if err := g.Add([]string{dir}); err != nil { + t.Fatalf("first Add: %v", err) + } + + // Second add of the same directory should not error or create duplicates. + if err := g.Add([]string{dir}); err != nil { + t.Fatalf("second Add should not error: %v", err) + } + + if len(g.manifest.Files) != 1 { + t.Errorf("expected 1 entry, got %d", len(g.manifest.Files)) + } +} + +func TestMirrorUpAddsNew(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "repo") + + g, err := Init(repoDir) + if err != nil { + t.Fatalf("Init: %v", err) + } + + dir := filepath.Join(root, "dotfiles") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("creating dir: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "existing.txt"), []byte("old"), 0o644); err != nil { + t.Fatalf("writing file: %v", err) + } + + if err := g.Add([]string{dir}); err != nil { + t.Fatalf("Add: %v", err) + } + + if len(g.manifest.Files) != 1 { + t.Fatalf("expected 1 file after Add, got %d", len(g.manifest.Files)) + } + + // Create a new file inside the directory. + if err := os.WriteFile(filepath.Join(dir, "new.txt"), []byte("new"), 0o644); err != nil { + t.Fatalf("writing new file: %v", err) + } + + if err := g.MirrorUp([]string{dir}); err != nil { + t.Fatalf("MirrorUp: %v", err) + } + + if len(g.manifest.Files) != 2 { + t.Fatalf("expected 2 files after MirrorUp, got %d", len(g.manifest.Files)) + } +} + +func TestMirrorUpRemovesDeleted(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "repo") + + g, err := Init(repoDir) + if err != nil { + t.Fatalf("Init: %v", err) + } + + dir := filepath.Join(root, "dotfiles") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("creating dir: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "keep.txt"), []byte("keep"), 0o644); err != nil { + t.Fatalf("writing keep file: %v", err) + } + deleteFile := filepath.Join(dir, "delete.txt") + if err := os.WriteFile(deleteFile, []byte("delete"), 0o644); err != nil { + t.Fatalf("writing delete file: %v", err) + } + + if err := g.Add([]string{dir}); err != nil { + t.Fatalf("Add: %v", err) + } + + if len(g.manifest.Files) != 2 { + t.Fatalf("expected 2 files, got %d", len(g.manifest.Files)) + } + + // Delete one file from disk. + _ = os.Remove(deleteFile) + + if err := g.MirrorUp([]string{dir}); err != nil { + t.Fatalf("MirrorUp: %v", err) + } + + if len(g.manifest.Files) != 1 { + t.Fatalf("expected 1 file after MirrorUp, got %d", len(g.manifest.Files)) + } + + if g.manifest.Files[0].Path != toTildePath(filepath.Join(dir, "keep.txt")) { + t.Errorf("remaining entry should be keep.txt, got %s", g.manifest.Files[0].Path) + } +} + +func TestMirrorUpRehashesChanged(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "repo") + + g, err := Init(repoDir) + if err != nil { + t.Fatalf("Init: %v", err) + } + + dir := filepath.Join(root, "dotfiles") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("creating dir: %v", err) + } + f := filepath.Join(dir, "config.txt") + if err := os.WriteFile(f, []byte("original"), 0o644); err != nil { + t.Fatalf("writing file: %v", err) + } + + if err := g.Add([]string{dir}); err != nil { + t.Fatalf("Add: %v", err) + } + + origHash := g.manifest.Files[0].Hash + + // Modify the file. + if err := os.WriteFile(f, []byte("modified"), 0o644); err != nil { + t.Fatalf("modifying file: %v", err) + } + + if err := g.MirrorUp([]string{dir}); err != nil { + t.Fatalf("MirrorUp: %v", err) + } + + if g.manifest.Files[0].Hash == origHash { + t.Error("MirrorUp did not update hash for modified file") + } +} + +func TestMirrorDownRestoresAndCleans(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "repo") + + g, err := Init(repoDir) + if err != nil { + t.Fatalf("Init: %v", err) + } + + dir := filepath.Join(root, "dotfiles") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("creating dir: %v", err) + } + tracked := filepath.Join(dir, "tracked.txt") + if err := os.WriteFile(tracked, []byte("tracked"), 0o644); err != nil { + t.Fatalf("writing tracked file: %v", err) + } + + if err := g.Add([]string{dir}); err != nil { + t.Fatalf("Add: %v", err) + } + if err := g.Checkpoint(""); err != nil { + t.Fatalf("Checkpoint: %v", err) + } + + // Modify the tracked file and create an untracked file. + if err := os.WriteFile(tracked, []byte("overwritten"), 0o644); err != nil { + t.Fatalf("modifying tracked file: %v", err) + } + extra := filepath.Join(dir, "extra.txt") + if err := os.WriteFile(extra, []byte("extra"), 0o644); err != nil { + t.Fatalf("writing extra file: %v", err) + } + + if err := g.MirrorDown([]string{dir}, true, nil); err != nil { + t.Fatalf("MirrorDown: %v", err) + } + + // Tracked file should be restored to original content. + got, err := os.ReadFile(tracked) + if err != nil { + t.Fatalf("reading tracked file: %v", err) + } + if string(got) != "tracked" { + t.Errorf("tracked file content = %q, want %q", got, "tracked") + } + + // Extra file should be deleted. + if _, err := os.Stat(extra); err == nil { + t.Error("extra file should have been deleted by MirrorDown with force") + } +} + +func TestMirrorDownConfirmSkips(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "repo") + + g, err := Init(repoDir) + if err != nil { + t.Fatalf("Init: %v", err) + } + + dir := filepath.Join(root, "dotfiles") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("creating dir: %v", err) + } + tracked := filepath.Join(dir, "tracked.txt") + if err := os.WriteFile(tracked, []byte("tracked"), 0o644); err != nil { + t.Fatalf("writing tracked file: %v", err) + } + + if err := g.Add([]string{dir}); err != nil { + t.Fatalf("Add: %v", err) + } + if err := g.Checkpoint(""); err != nil { + t.Fatalf("Checkpoint: %v", err) + } + + // Create an untracked file. + extra := filepath.Join(dir, "extra.txt") + if err := os.WriteFile(extra, []byte("extra"), 0o644); err != nil { + t.Fatalf("writing extra file: %v", err) + } + + // Confirm returns false — should NOT delete. + alwaysNo := func(path string) bool { return false } + if err := g.MirrorDown([]string{dir}, false, alwaysNo); err != nil { + t.Fatalf("MirrorDown: %v", err) + } + + if _, err := os.Stat(extra); err != nil { + t.Error("extra file should NOT have been deleted when confirm returns false") + } +}