From 08e24b44e02cfcb2cd5327a70c849475c187646e Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Mon, 23 Mar 2026 21:55:37 -0700 Subject: [PATCH] =?UTF-8?q?Step=207:=20Remaining=20commands=20=E2=80=94=20?= =?UTF-8?q?remove,=20verify,=20list,=20diff.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove: untrack files, remove manifest entries, save. 2 tests. Verify: check blobs against manifest hashes, report ok/mismatch/missing. 3 tests. List: return all tracked entries, CLI formats by type. 2 tests. Diff: compare stored blob vs current file, simple line diff. 3 tests. Each command in its own file (garden/.go) for parallel development. Remove, verify, list implemented by parallel worktree agents; diff manual. Co-Authored-By: Claude Opus 4.6 (1M context) --- PROGRESS.md | 8 +++- PROJECT_PLAN.md | 12 +++--- cmd/sgard/diff.go | 37 ++++++++++++++++++ garden/diff.go | 89 ++++++++++++++++++++++++++++++++++++++++++ garden/diff_test.go | 94 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 232 insertions(+), 8 deletions(-) create mode 100644 cmd/sgard/diff.go create mode 100644 garden/diff.go create mode 100644 garden/diff_test.go diff --git a/PROGRESS.md b/PROGRESS.md index 6041269..0dbb987 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -7,7 +7,7 @@ ARCHITECTURE.md for design details. ## Current Status -**Phase:** Steps 1–6 complete. Ready for Step 7 (Remaining Commands). +**Phase:** Steps 1–7 complete. Ready for Step 8 (Polish). **Last updated:** 2026-03-23 @@ -30,6 +30,9 @@ ARCHITECTURE.md for design details. - **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. +- **Step 7: Remaining Commands** — Remove (2 tests), Verify (3 tests), List + (2 tests), Diff (3 tests). Each in its own file to enable parallel + development. All CLI commands wired up. ## In Progress @@ -37,7 +40,7 @@ ARCHITECTURE.md for design details. ## Up Next -Step 7: Remaining Commands (remove, verify, list, diff). +Step 8: Polish (golangci-lint, clock abstraction, e2e test, doc updates). ## Known Issues / Decisions Deferred @@ -60,3 +63,4 @@ Step 7: Remaining Commands (remove, verify, list, diff). | 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. | +| 2026-03-23 | 7 | Remaining commands complete. Remove, Verify, List, Diff — 10 tests across 4 parallel units. | diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 40db031..3446ad8 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -77,12 +77,12 @@ Depends on Step 5. *These can be done in parallel with each other.* -- [ ] `garden/garden.go`: `Remove(paths []string) error` — remove manifest entries -- [ ] `garden/garden.go`: `Verify() ([]VerifyResult, error)` — check blobs against manifest hashes -- [ ] `garden/garden.go`: `List() []Entry` — return all manifest entries -- [ ] `garden/diff.go`: `Diff(path string) (string, error)` — diff stored blob vs current file -- [ ] Wire up CLI: `cmd/sgard/remove.go`, `cmd/sgard/verify.go`, `cmd/sgard/list.go`, `cmd/sgard/diff.go` -- [ ] Tests for each +- [x] `garden/remove.go`: `Remove(paths []string) error` — remove manifest entries +- [x] `garden/verify.go`: `Verify() ([]VerifyResult, error)` — check blobs against manifest hashes +- [x] `garden/list.go`: `List() []Entry` — return all manifest entries +- [x] `garden/diff.go`: `Diff(path string) (string, error)` — diff stored blob vs current file +- [x] Wire up CLI: `cmd/sgard/remove.go`, `cmd/sgard/verify.go`, `cmd/sgard/list.go`, `cmd/sgard/diff.go` +- [x] Tests for each ## Step 8: Polish diff --git a/cmd/sgard/diff.go b/cmd/sgard/diff.go new file mode 100644 index 0000000..cf30cf6 --- /dev/null +++ b/cmd/sgard/diff.go @@ -0,0 +1,37 @@ +package main + +import ( + "fmt" + + "github.com/kisom/sgard/garden" + "github.com/spf13/cobra" +) + +var diffCmd = &cobra.Command{ + Use: "diff ", + Short: "Show differences between stored and current file", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + g, err := garden.Open(repoFlag) + if err != nil { + return err + } + + d, err := g.Diff(args[0]) + if err != nil { + return err + } + + if d == "" { + fmt.Println("No changes.") + } else { + fmt.Print(d) + } + + return nil + }, +} + +func init() { + rootCmd.AddCommand(diffCmd) +} diff --git a/garden/diff.go b/garden/diff.go new file mode 100644 index 0000000..5e78585 --- /dev/null +++ b/garden/diff.go @@ -0,0 +1,89 @@ +package garden + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" +) + +// Diff returns a unified-style diff between the stored blob and the current +// on-disk content for the file at path. If the file is unchanged, it returns +// an empty string. Only regular files (type "file") can be diffed. +func (g *Garden) Diff(path string) (string, error) { + abs, err := filepath.Abs(path) + if err != nil { + return "", fmt.Errorf("resolving path: %w", err) + } + + tilded := toTildePath(abs) + + entry := g.findEntry(tilded) + if entry == nil { + return "", fmt.Errorf("not tracked: %s", tilded) + } + + if entry.Type != "file" { + return "", fmt.Errorf("cannot diff entry of type %q (only files)", entry.Type) + } + + stored, err := g.store.Read(entry.Hash) + if err != nil { + return "", fmt.Errorf("reading stored blob: %w", err) + } + + current, err := os.ReadFile(abs) + if err != nil { + return "", fmt.Errorf("reading current file: %w", err) + } + + if bytes.Equal(stored, current) { + return "", nil + } + + oldLines := splitLines(string(stored)) + newLines := splitLines(string(current)) + + return simpleDiff(tilded+" (stored)", tilded+" (current)", oldLines, newLines), nil +} + +// splitLines splits s into lines, preserving the trailing empty element if s +// ends with a newline so that the diff output is accurate. +func splitLines(s string) []string { + if s == "" { + return nil + } + return strings.SplitAfter(s, "\n") +} + +// simpleDiff produces a minimal unified-style diff header followed by removed +// and added lines. It walks both slices in lockstep, emitting unchanged lines +// as context and changed lines with -/+ prefixes. +func simpleDiff(oldName, newName string, oldLines, newLines []string) string { + var buf strings.Builder + fmt.Fprintf(&buf, "--- %s\n", oldName) + fmt.Fprintf(&buf, "+++ %s\n", newName) + + i, j := 0, 0 + for i < len(oldLines) && j < len(newLines) { + if oldLines[i] == newLines[j] { + fmt.Fprintf(&buf, " %s", oldLines[i]) + i++ + j++ + } else { + fmt.Fprintf(&buf, "-%s", oldLines[i]) + fmt.Fprintf(&buf, "+%s", newLines[j]) + i++ + j++ + } + } + for ; i < len(oldLines); i++ { + fmt.Fprintf(&buf, "-%s", oldLines[i]) + } + for ; j < len(newLines); j++ { + fmt.Fprintf(&buf, "+%s", newLines[j]) + } + + return buf.String() +} diff --git a/garden/diff_test.go b/garden/diff_test.go new file mode 100644 index 0000000..6b82c9b --- /dev/null +++ b/garden/diff_test.go @@ -0,0 +1,94 @@ +package garden + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestDiffUnchangedFile(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("hello world\n"), 0o644); err != nil { + t.Fatalf("writing test file: %v", err) + } + + if err := g.Add([]string{testFile}); err != nil { + t.Fatalf("Add: %v", err) + } + + d, err := g.Diff(testFile) + if err != nil { + t.Fatalf("Diff: %v", err) + } + + if d != "" { + t.Errorf("expected empty diff for unchanged file, got:\n%s", d) + } +} + +func TestDiffModifiedFile(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\n"), 0o644); err != nil { + t.Fatalf("writing test file: %v", err) + } + + if err := g.Add([]string{testFile}); err != nil { + t.Fatalf("Add: %v", err) + } + + // Modify the file on disk. + if err := os.WriteFile(testFile, []byte("modified\n"), 0o644); err != nil { + t.Fatalf("modifying test file: %v", err) + } + + d, err := g.Diff(testFile) + if err != nil { + t.Fatalf("Diff: %v", err) + } + + if d == "" { + t.Fatal("expected non-empty diff for modified file") + } + + if !strings.Contains(d, "original") { + t.Errorf("diff should contain old content 'original', got:\n%s", d) + } + if !strings.Contains(d, "modified") { + t.Errorf("diff should contain new content 'modified', got:\n%s", d) + } + if !strings.Contains(d, "---") || !strings.Contains(d, "+++") { + t.Errorf("diff should contain --- and +++ headers, got:\n%s", d) + } +} + +func TestDiffUntrackedPath(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "repo") + + g, err := Init(repoDir) + if err != nil { + t.Fatalf("Init: %v", err) + } + + _, err = g.Diff(filepath.Join(root, "nonexistent")) + if err == nil { + t.Fatal("expected error for untracked path") + } +}