Step 7: Remaining commands — remove, verify, list, diff.

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/<cmd>.go) for parallel development.
Remove, verify, list implemented by parallel worktree agents; diff manual.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-23 21:55:37 -07:00
parent d03378c9c1
commit 08e24b44e0
5 changed files with 232 additions and 8 deletions

View File

@@ -7,7 +7,7 @@ ARCHITECTURE.md for design details.
## Current Status
**Phase:** Steps 16 complete. Ready for Step 7 (Remaining Commands).
**Phase:** Steps 17 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. |

View File

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

37
cmd/sgard/diff.go Normal file
View File

@@ -0,0 +1,37 @@
package main
import (
"fmt"
"github.com/kisom/sgard/garden"
"github.com/spf13/cobra"
)
var diffCmd = &cobra.Command{
Use: "diff <path>",
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)
}

89
garden/diff.go Normal file
View File

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

94
garden/diff_test.go Normal file
View File

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