From c552a3657ffbe854b0118cbc0054d31a79487491 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Mon, 23 Mar 2026 21:41:53 -0700 Subject: [PATCH] Step 6: Restore with timestamp logic and confirm callback. Restore writes tracked files back to their original locations. Supports selective path restoration, force mode, and a confirm callback for files where the on-disk mtime >= manifest timestamp (truncated to seconds for cross-platform reliability). Creates parent directories, recreates symlinks, and sets file permissions. CLI: sgard restore [path...] [--force]. 6 new tests (file, permissions, symlink, parent dirs, selective, confirm skip). Co-Authored-By: Claude Opus 4.6 (1M context) --- PROGRESS.md | 8 +- PROJECT_PLAN.md | 6 +- cmd/sgard/restore.go | 46 +++++++++ garden/garden.go | 132 +++++++++++++++++++++++++ garden/garden_test.go | 220 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 407 insertions(+), 5 deletions(-) create mode 100644 cmd/sgard/restore.go diff --git a/PROGRESS.md b/PROGRESS.md index 93354f1..812b2f8 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -7,7 +7,7 @@ ARCHITECTURE.md for design details. ## Current Status -**Phase:** Steps 1–5 complete. Ready for Step 6 (Restore). +**Phase:** Steps 1–6 complete. Ready for Step 7 (Remaining Commands). **Last updated:** 2026-03-23 @@ -27,6 +27,9 @@ ARCHITECTURE.md for design details. - **Step 5: Checkpoint and Status** — `Checkpoint()` re-hashes all tracked files, stores changed blobs, updates timestamps. `Status()` reports ok/modified/missing per entry. CLI `checkpoint` (with `-m` flag) and `status` commands. 4 tests. +- **Step 6: Restore** — `Restore()` with selective paths, force mode, confirm + callback, timestamp-based auto-restore, parent dir creation, symlink support, + file permission restoration. CLI `restore` with `--force` flag. 6 tests. ## In Progress @@ -34,7 +37,7 @@ ARCHITECTURE.md for design details. ## Up Next -Step 6: Restore. +Step 7: Remaining Commands (remove, verify, list, diff). ## Known Issues / Decisions Deferred @@ -53,3 +56,4 @@ Step 6: Restore. | 2026-03-23 | 3 | Store package complete. Content-addressable blob store, 11 tests. | | 2026-03-23 | 4 | Garden core complete. Init, Open, Add with file/dir/symlink support, CLI commands. 8 tests. | | 2026-03-23 | 5 | Checkpoint and Status complete. Re-hash, store changed blobs, status reporting. 4 tests. | +| 2026-03-23 | 6 | Restore complete. Selective paths, force/confirm, timestamp logic, symlinks, permissions. 6 tests. | diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 313db55..505ebd3 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -63,15 +63,15 @@ Depends on Step 4. Depends on Step 5. -- [ ] `garden/garden.go`: `Restore(paths []string, force bool) error` +- [x] `garden/garden.go`: `Restore(paths []string, force bool, confirm func) error` - Restore all files if paths is empty, otherwise just the specified paths - Timestamp comparison: skip prompt if manifest `updated` is newer than file mtime - Prompt user if file on disk is newer or times match (unless `--force`) - Create parent directories as needed - Recreate symlinks for `link` type entries - Set file permissions from manifest `mode` -- [ ] `garden/garden_test.go`: restore writes correct content, respects permissions, handles symlinks -- [ ] Wire up CLI: `cmd/sgard/restore.go` +- [x] `garden/garden_test.go`: restore writes correct content, respects permissions, handles symlinks +- [x] Wire up CLI: `cmd/sgard/restore.go` ## Step 7: Remaining Commands diff --git a/cmd/sgard/restore.go b/cmd/sgard/restore.go new file mode 100644 index 0000000..2d1f761 --- /dev/null +++ b/cmd/sgard/restore.go @@ -0,0 +1,46 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/kisom/sgard/garden" + "github.com/spf13/cobra" +) + +var forceRestore bool + +var restoreCmd = &cobra.Command{ + Use: "restore [path...]", + Short: "Restore tracked files to their original locations", + 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("Overwrite %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.Restore(args, forceRestore, confirm); err != nil { + return err + } + + fmt.Println("Restore complete.") + return nil + }, +} + +func init() { + restoreCmd.Flags().BoolVarP(&forceRestore, "force", "f", false, "overwrite without prompting") + rootCmd.AddCommand(restoreCmd) +} diff --git a/garden/garden.go b/garden/garden.go index 589a69c..1cc2056 100644 --- a/garden/garden.go +++ b/garden/garden.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "strconv" "strings" "time" @@ -269,6 +270,137 @@ func (g *Garden) Status() ([]FileStatus, error) { return results, nil } +// Restore writes tracked files back to their original locations. If paths is +// empty, all entries are restored. If force is true, existing files are +// overwritten without prompting. Otherwise, confirm is called for files where +// the on-disk version is newer than or equal to the manifest timestamp; +// if confirm returns false, that file is skipped. +func (g *Garden) Restore(paths []string, force bool, confirm func(path string) bool) error { + entries := g.manifest.Files + if len(paths) > 0 { + entries = g.filterEntries(paths) + if len(entries) == 0 { + return fmt.Errorf("no matching tracked entries") + } + } + + for i := range entries { + entry := &entries[i] + + abs, err := ExpandTildePath(entry.Path) + if err != nil { + return fmt.Errorf("expanding path %s: %w", entry.Path, err) + } + + // Check if the file exists and whether we need confirmation. + if !force { + if info, err := os.Lstat(abs); err == nil { + // File exists. If on-disk mtime >= manifest updated, ask. + // Truncate to seconds because filesystem mtime granularity + // varies across platforms. + diskTime := info.ModTime().Truncate(time.Second) + entryTime := entry.Updated.Truncate(time.Second) + if !diskTime.Before(entryTime) { + if confirm == nil || !confirm(entry.Path) { + continue + } + } + } + } + + // Create parent directories. + dir := filepath.Dir(abs) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("creating directory %s: %w", dir, err) + } + + switch entry.Type { + case "file": + if err := g.restoreFile(abs, entry); err != nil { + return err + } + + case "link": + if err := restoreLink(abs, entry); err != nil { + return err + } + + case "directory": + mode, err := parseMode(entry.Mode) + if err != nil { + return fmt.Errorf("parsing mode for %s: %w", entry.Path, err) + } + if err := os.MkdirAll(abs, mode); err != nil { + return fmt.Errorf("creating directory %s: %w", abs, err) + } + } + } + + return nil +} + +func (g *Garden) restoreFile(abs string, entry *manifest.Entry) error { + data, err := g.store.Read(entry.Hash) + if err != nil { + return fmt.Errorf("reading blob for %s: %w", entry.Path, err) + } + + mode, err := parseMode(entry.Mode) + if err != nil { + return fmt.Errorf("parsing mode for %s: %w", entry.Path, err) + } + + if err := os.WriteFile(abs, data, mode); err != nil { + return fmt.Errorf("writing %s: %w", abs, err) + } + + return nil +} + +func restoreLink(abs string, entry *manifest.Entry) error { + // Remove existing file/link at the target path so we can create the symlink. + os.Remove(abs) + + if err := os.Symlink(entry.Target, abs); err != nil { + return fmt.Errorf("creating symlink %s -> %s: %w", abs, entry.Target, err) + } + return nil +} + +// filterEntries returns manifest entries whose tilde paths match any of the +// given paths (resolved to tilde form). +func (g *Garden) filterEntries(paths []string) []manifest.Entry { + wanted := make(map[string]bool) + for _, p := range paths { + abs, err := filepath.Abs(p) + if err != nil { + wanted[p] = true + continue + } + wanted[toTildePath(abs)] = true + } + + var result []manifest.Entry + for _, e := range g.manifest.Files { + if wanted[e.Path] { + result = append(result, e) + } + } + return result +} + +// parseMode converts a mode string like "0644" to an os.FileMode. +func parseMode(s string) (os.FileMode, error) { + if s == "" { + return 0o644, nil + } + v, err := strconv.ParseUint(s, 8, 32) + if err != nil { + return 0, fmt.Errorf("invalid mode %q: %w", s, err) + } + return os.FileMode(v), nil +} + // findEntry returns the entry for the given tilde path, or nil if not found. func (g *Garden) findEntry(tildePath string) *manifest.Entry { for i := range g.manifest.Files { diff --git a/garden/garden_test.go b/garden/garden_test.go index f5a8a49..a65106c 100644 --- a/garden/garden_test.go +++ b/garden/garden_test.go @@ -391,6 +391,226 @@ func TestStatusReportsCorrectly(t *testing.T) { } } +func TestRestoreFile(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 file and add it. + testFile := filepath.Join(root, "testfile") + content := []byte("restore me\n") + if err := os.WriteFile(testFile, content, 0o644); err != nil { + t.Fatalf("writing test file: %v", err) + } + + if err := g.Add([]string{testFile}); err != nil { + t.Fatalf("Add: %v", err) + } + + // Delete the file, then restore it. + os.Remove(testFile) + + if err := g.Restore(nil, true, nil); err != nil { + t.Fatalf("Restore: %v", err) + } + + got, err := os.ReadFile(testFile) + if err != nil { + t.Fatalf("reading restored file: %v", err) + } + if string(got) != string(content) { + t.Errorf("restored content = %q, want %q", got, content) + } +} + +func TestRestorePermissions(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "repo") + + g, err := Init(repoDir) + if err != nil { + t.Fatalf("Init: %v", err) + } + + testFile := filepath.Join(root, "secret") + if err := os.WriteFile(testFile, []byte("secret"), 0o600); err != nil { + t.Fatalf("writing test file: %v", err) + } + + if err := g.Add([]string{testFile}); err != nil { + t.Fatalf("Add: %v", err) + } + + os.Remove(testFile) + + if err := g.Restore(nil, true, nil); err != nil { + t.Fatalf("Restore: %v", err) + } + + info, err := os.Stat(testFile) + if err != nil { + t.Fatalf("stat restored file: %v", err) + } + if info.Mode().Perm() != 0o600 { + t.Errorf("permissions = %04o, want 0600", info.Mode().Perm()) + } +} + +func TestRestoreSymlink(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "repo") + + g, err := Init(repoDir) + if err != nil { + t.Fatalf("Init: %v", err) + } + + target := filepath.Join(root, "target") + if err := os.WriteFile(target, []byte("target"), 0o644); err != nil { + t.Fatalf("writing target: %v", err) + } + link := filepath.Join(root, "link") + if err := os.Symlink(target, link); err != nil { + t.Fatalf("creating symlink: %v", err) + } + + if err := g.Add([]string{link}); err != nil { + t.Fatalf("Add: %v", err) + } + + os.Remove(link) + + if err := g.Restore(nil, true, nil); err != nil { + t.Fatalf("Restore: %v", err) + } + + got, err := os.Readlink(link) + if err != nil { + t.Fatalf("readlink: %v", err) + } + if got != target { + t.Errorf("symlink target = %q, want %q", got, target) + } +} + +func TestRestoreCreatesParentDirs(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "repo") + + g, err := Init(repoDir) + if err != nil { + t.Fatalf("Init: %v", err) + } + + // Create nested file. + nested := filepath.Join(root, "a", "b", "c") + if err := os.MkdirAll(filepath.Dir(nested), 0o755); err != nil { + t.Fatalf("creating dirs: %v", err) + } + if err := os.WriteFile(nested, []byte("deep"), 0o644); err != nil { + t.Fatalf("writing nested file: %v", err) + } + + if err := g.Add([]string{nested}); err != nil { + t.Fatalf("Add: %v", err) + } + + // Remove the entire directory tree. + os.RemoveAll(filepath.Join(root, "a")) + + if err := g.Restore(nil, true, nil); err != nil { + t.Fatalf("Restore: %v", err) + } + + got, err := os.ReadFile(nested) + if err != nil { + t.Fatalf("reading restored nested file: %v", err) + } + if string(got) != "deep" { + t.Errorf("content = %q, want %q", got, "deep") + } +} + +func TestRestoreSelectivePaths(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "repo") + + g, err := Init(repoDir) + if err != nil { + t.Fatalf("Init: %v", err) + } + + file1 := filepath.Join(root, "file1") + file2 := filepath.Join(root, "file2") + if err := os.WriteFile(file1, []byte("one"), 0o644); err != nil { + t.Fatalf("writing file1: %v", err) + } + if err := os.WriteFile(file2, []byte("two"), 0o644); err != nil { + t.Fatalf("writing file2: %v", err) + } + + if err := g.Add([]string{file1, file2}); err != nil { + t.Fatalf("Add: %v", err) + } + + os.Remove(file1) + os.Remove(file2) + + // Restore only file1. + if err := g.Restore([]string{file1}, true, nil); err != nil { + t.Fatalf("Restore: %v", err) + } + + if _, err := os.Stat(file1); err != nil { + t.Error("file1 should have been restored") + } + if _, err := os.Stat(file2); err == nil { + t.Error("file2 should NOT have been restored") + } +} + +func TestRestoreConfirmSkips(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "repo") + + g, err := Init(repoDir) + if err != nil { + t.Fatalf("Init: %v", err) + } + + testFile := filepath.Join(root, "testfile") + if err := os.WriteFile(testFile, []byte("original"), 0o644); err != nil { + t.Fatalf("writing test file: %v", err) + } + + if err := g.Add([]string{testFile}); err != nil { + t.Fatalf("Add: %v", err) + } + + // Overwrite with newer content (file mtime will be >= manifest updated). + if err := os.WriteFile(testFile, []byte("newer on disk"), 0o644); err != nil { + t.Fatalf("modifying test file: %v", err) + } + + // Confirm returns false — should skip the file. + alwaysNo := func(path string) bool { return false } + if err := g.Restore(nil, false, alwaysNo); err != nil { + t.Fatalf("Restore: %v", err) + } + + got, err := os.ReadFile(testFile) + if err != nil { + t.Fatalf("reading file: %v", err) + } + if string(got) != "newer on disk" { + t.Error("file should not have been overwritten when confirm returns false") + } +} + func TestExpandTildePath(t *testing.T) { home, err := os.UserHomeDir() if err != nil {