Add directory recursion for Add and mirror up/down commands.

Add now recursively walks directories instead of creating a single
"directory" type entry. Extract addEntry helper for reuse. Implement
MirrorUp (sync filesystem state into manifest) and MirrorDown (sync
manifest state to filesystem with untracked file cleanup). Add CLI
mirror command with up/down subcommands.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-23 23:42:58 -07:00
parent b1313c1048
commit b4bfce1291
5 changed files with 639 additions and 41 deletions

72
cmd/sgard/mirror.go Normal file
View File

@@ -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 <path>...",
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 <path>...",
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)
}

View File

@@ -98,9 +98,55 @@ func (g *Garden) SetClock(c clockwork.Clock) {
g.clock = c 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 // Add tracks new files, directories, or symlinks. Each path is resolved
// to an absolute path, inspected for its type, and added to the manifest. // 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 { func (g *Garden) Add(paths []string) error {
now := g.clock.Now().UTC() now := g.clock.Now().UTC()
@@ -115,45 +161,29 @@ func (g *Garden) Add(paths []string) error {
return fmt.Errorf("stat %s: %w", abs, err) return fmt.Errorf("stat %s: %w", abs, err)
} }
tilded := toTildePath(abs) if info.IsDir() {
// Recursively walk the directory, adding all files and symlinks.
// Check if already tracked. err := filepath.WalkDir(abs, func(path string, d os.DirEntry, err error) error {
if g.findEntry(tilded) != nil { if err != nil {
return fmt.Errorf("already tracking %s", tilded) return err
} }
if d.IsDir() {
entry := manifest.Entry{ return nil // skip directory entries themselves
Path: tilded, }
Mode: fmt.Sprintf("%04o", info.Mode().Perm()), fi, err := os.Lstat(path)
Updated: now, if err != nil {
} return fmt.Errorf("stat %s: %w", path, err)
}
switch { return g.addEntry(path, fi, now, true)
case info.Mode()&os.ModeSymlink != 0: })
target, err := os.Readlink(abs)
if err != nil { if err != nil {
return fmt.Errorf("reading symlink %s: %w", abs, err) return fmt.Errorf("walking directory %s: %w", abs, err)
} }
entry.Type = "link" } else {
entry.Target = target if err := g.addEntry(abs, info, now, false); err != nil {
return err
case info.IsDir():
entry.Type = "directory"
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)
} }
g.manifest.Updated = now g.manifest.Updated = now

View File

@@ -135,17 +135,29 @@ func TestAddDirectory(t *testing.T) {
if err := os.Mkdir(testDir, 0o755); err != nil { if err := os.Mkdir(testDir, 0o755); err != nil {
t.Fatalf("creating test dir: %v", err) 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 { if err := g.Add([]string{testDir}); err != nil {
t.Fatalf("Add: %v", err) t.Fatalf("Add: %v", err)
} }
entry := g.manifest.Files[0] if len(g.manifest.Files) != 1 {
if entry.Type != "directory" { t.Fatalf("expected 1 file, got %d", len(g.manifest.Files))
t.Errorf("expected type directory, got %s", entry.Type)
} }
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)
} }
} }

187
garden/mirror.go Normal file
View File

@@ -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
}

297
garden/mirror_test.go Normal file
View File

@@ -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")
}
}