Merge branch 'worktree-agent-a0166844'

# Conflicts:
#	garden/garden.go
This commit is contained in:
2026-03-23 23:44:30 -07:00
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

@@ -127,27 +127,16 @@ func (g *Garden) ReplaceManifest(m *manifest.Manifest) error {
return nil return nil
} }
// Add tracks new files, directories, or symlinks. Each path is resolved // addEntry adds a single file or symlink to the manifest. The abs path must
// to an absolute path, inspected for its type, and added to the manifest. // already be resolved and info must come from os.Lstat. If skipDup is true,
// Regular files are hashed and stored in the blob store. // already-tracked paths are silently skipped instead of returning an error.
func (g *Garden) Add(paths []string) error { func (g *Garden) addEntry(abs string, info os.FileInfo, now time.Time, skipDup bool) 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)
}
info, err := os.Lstat(abs)
if err != nil {
return fmt.Errorf("stat %s: %w", abs, err)
}
tilded := toTildePath(abs) tilded := toTildePath(abs)
// Check if already tracked.
if g.findEntry(tilded) != nil { if g.findEntry(tilded) != nil {
if skipDup {
return nil
}
return fmt.Errorf("already tracking %s", tilded) return fmt.Errorf("already tracking %s", tilded)
} }
@@ -166,9 +155,6 @@ func (g *Garden) Add(paths []string) error {
entry.Type = "link" entry.Type = "link"
entry.Target = target entry.Target = target
case info.IsDir():
entry.Type = "directory"
default: default:
data, err := os.ReadFile(abs) data, err := os.ReadFile(abs)
if err != nil { if err != nil {
@@ -183,6 +169,50 @@ func (g *Garden) Add(paths []string) error {
} }
g.manifest.Files = append(g.manifest.Files, entry) 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. 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()
for _, p := range paths {
abs, err := filepath.Abs(p)
if err != nil {
return fmt.Errorf("resolving path %s: %w", p, err)
}
info, err := os.Lstat(abs)
if err != nil {
return fmt.Errorf("stat %s: %w", abs, err)
}
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("walking directory %s: %w", abs, err)
}
} else {
if err := g.addEntry(abs, info, now, false); err != nil {
return err
}
}
} }
g.manifest.Updated = now g.manifest.Updated = now

View File

@@ -139,17 +139,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")
}
}