Step 8: Polish — lint, clock abstraction, e2e test.

- golangci-lint config with errcheck, govet, staticcheck, errorlint
- Fix all lint issues (unchecked error returns in cleanup paths, De Morgan)
- Inject jonboulle/clockwork into Garden for deterministic timestamps
- Add manifest.NewWithTime() for clock-aware initialization
- E2e lifecycle test: init → add → checkpoint → modify → status → restore → verify
- Update CLAUDE.md, PROJECT_PLAN.md, PROGRESS.md

Phase 1 (local) is now complete. All 9 CLI commands implemented and tested.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-23 22:03:53 -07:00
parent 08e24b44e0
commit d1a6e533a4
11 changed files with 200 additions and 37 deletions

116
garden/e2e_test.go Normal file
View File

@@ -0,0 +1,116 @@
package garden
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/jonboulle/clockwork"
)
// TestE2E exercises the full lifecycle: init → add → checkpoint → modify →
// status → restore → verify.
func TestE2E(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
// Use a fake clock so timestamps are deterministic.
fakeClock := clockwork.NewFakeClockAt(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
// 1. Init
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
g.SetClock(fakeClock)
// 2. Create and add files.
bashrc := filepath.Join(root, "bashrc")
gitconfig := filepath.Join(root, "gitconfig")
if err := os.WriteFile(bashrc, []byte("export PS1='$ '\n"), 0o644); err != nil {
t.Fatalf("writing bashrc: %v", err)
}
if err := os.WriteFile(gitconfig, []byte("[user]\n\tname = test\n"), 0o644); err != nil {
t.Fatalf("writing gitconfig: %v", err)
}
if err := g.Add([]string{bashrc, gitconfig}); err != nil {
t.Fatalf("Add: %v", err)
}
if len(g.manifest.Files) != 2 {
t.Fatalf("expected 2 entries, got %d", len(g.manifest.Files))
}
// 3. Checkpoint.
fakeClock.Advance(time.Hour)
if err := g.Checkpoint("initial checkpoint"); err != nil {
t.Fatalf("Checkpoint: %v", err)
}
if g.manifest.Message != "initial checkpoint" {
t.Errorf("expected message 'initial checkpoint', got %q", g.manifest.Message)
}
// 4. Modify a file.
if err := os.WriteFile(bashrc, []byte("export PS1='> '\n"), 0o644); err != nil {
t.Fatalf("modifying bashrc: %v", err)
}
// 5. Status — should detect modification.
statuses, err := g.Status()
if err != nil {
t.Fatalf("Status: %v", err)
}
stateMap := make(map[string]string)
for _, s := range statuses {
stateMap[s.Path] = s.State
}
bashrcPath := toTildePath(bashrc)
gitconfigPath := toTildePath(gitconfig)
if stateMap[bashrcPath] != "modified" {
t.Errorf("bashrc should be modified, got %s", stateMap[bashrcPath])
}
if stateMap[gitconfigPath] != "ok" {
t.Errorf("gitconfig should be ok, got %s", stateMap[gitconfigPath])
}
// 6. Restore — force to overwrite modified file.
if err := g.Restore([]string{bashrc}, true, nil); err != nil {
t.Fatalf("Restore: %v", err)
}
got, err := os.ReadFile(bashrc)
if err != nil {
t.Fatalf("reading restored bashrc: %v", err)
}
if string(got) != "export PS1='$ '\n" {
t.Errorf("bashrc not restored correctly, got %q", got)
}
// 7. Status after restore — should be ok.
statuses, err = g.Status()
if err != nil {
t.Fatalf("Status after restore: %v", err)
}
for _, s := range statuses {
if s.State != "ok" {
t.Errorf("after restore, %s should be ok, got %s", s.Path, s.State)
}
}
// 8. Verify — all blobs should be intact.
results, err := g.Verify()
if err != nil {
t.Fatalf("Verify: %v", err)
}
for _, r := range results {
if !r.OK {
t.Errorf("verify failed for %s: %s", r.Path, r.Detail)
}
}
}

View File

@@ -10,6 +10,7 @@ import (
"strings"
"time"
"github.com/jonboulle/clockwork"
"github.com/kisom/sgard/manifest"
"github.com/kisom/sgard/store"
)
@@ -20,6 +21,7 @@ type Garden struct {
store *store.Store
root string // repository root directory
manifestPath string // path to manifest.yaml
clock clockwork.Clock
}
// Init creates a new sgard repository at root. It creates the directory
@@ -44,7 +46,8 @@ func Init(root string) (*Garden, error) {
return nil, fmt.Errorf("creating store: %w", err)
}
m := manifest.New()
clk := clockwork.NewRealClock()
m := manifest.NewWithTime(clk.Now().UTC())
if err := m.Save(manifestPath); err != nil {
return nil, fmt.Errorf("saving initial manifest: %w", err)
}
@@ -54,6 +57,7 @@ func Init(root string) (*Garden, error) {
store: s,
root: absRoot,
manifestPath: manifestPath,
clock: clk,
}, nil
}
@@ -80,14 +84,20 @@ func Open(root string) (*Garden, error) {
store: s,
root: absRoot,
manifestPath: manifestPath,
clock: clockwork.NewRealClock(),
}, nil
}
// SetClock replaces the clock used for timestamps. Intended for testing.
func (g *Garden) SetClock(c clockwork.Clock) {
g.clock = c
}
// 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.
func (g *Garden) Add(paths []string) error {
now := time.Now().UTC()
now := g.clock.Now().UTC()
for _, p := range paths {
abs, err := filepath.Abs(p)
@@ -159,7 +169,7 @@ type FileStatus struct {
// updates the manifest timestamps. The optional message is recorded in
// the manifest.
func (g *Garden) Checkpoint(message string) error {
now := time.Now().UTC()
now := g.clock.Now().UTC()
for i := range g.manifest.Files {
entry := &g.manifest.Files[i]
@@ -359,7 +369,7 @@ func (g *Garden) restoreFile(abs string, entry *manifest.Entry) error {
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)
_ = os.Remove(abs)
if err := os.Symlink(entry.Target, abs); err != nil {
return fmt.Errorf("creating symlink %s -> %s: %w", abs, entry.Target, err)

View File

@@ -321,7 +321,7 @@ func TestCheckpointMissingFileSkipped(t *testing.T) {
}
// Remove the file before checkpoint.
os.Remove(testFile)
_ = os.Remove(testFile)
// Checkpoint should not fail.
if err := g.Checkpoint(""); err != nil {
@@ -360,7 +360,7 @@ func TestStatusReportsCorrectly(t *testing.T) {
if err := os.WriteFile(modFile, []byte("changed"), 0o644); err != nil {
t.Fatalf("modifying file: %v", err)
}
os.Remove(missingFile)
_ = os.Remove(missingFile)
statuses, err := g.Status()
if err != nil {
@@ -412,7 +412,7 @@ func TestRestoreFile(t *testing.T) {
}
// Delete the file, then restore it.
os.Remove(testFile)
_ = os.Remove(testFile)
if err := g.Restore(nil, true, nil); err != nil {
t.Fatalf("Restore: %v", err)
@@ -445,7 +445,7 @@ func TestRestorePermissions(t *testing.T) {
t.Fatalf("Add: %v", err)
}
os.Remove(testFile)
_ = os.Remove(testFile)
if err := g.Restore(nil, true, nil); err != nil {
t.Fatalf("Restore: %v", err)
@@ -482,7 +482,7 @@ func TestRestoreSymlink(t *testing.T) {
t.Fatalf("Add: %v", err)
}
os.Remove(link)
_ = os.Remove(link)
if err := g.Restore(nil, true, nil); err != nil {
t.Fatalf("Restore: %v", err)
@@ -520,7 +520,7 @@ func TestRestoreCreatesParentDirs(t *testing.T) {
}
// Remove the entire directory tree.
os.RemoveAll(filepath.Join(root, "a"))
_ = os.RemoveAll(filepath.Join(root, "a"))
if err := g.Restore(nil, true, nil); err != nil {
t.Fatalf("Restore: %v", err)
@@ -557,8 +557,8 @@ func TestRestoreSelectivePaths(t *testing.T) {
t.Fatalf("Add: %v", err)
}
os.Remove(file1)
os.Remove(file2)
_ = os.Remove(file1)
_ = os.Remove(file2)
// Restore only file1.
if err := g.Restore([]string{file1}, true, nil); err != nil {