diff --git a/PROGRESS.md b/PROGRESS.md index f3b9ae3..93354f1 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -7,7 +7,7 @@ ARCHITECTURE.md for design details. ## Current Status -**Phase:** Steps 1–4 complete. Ready for Step 5 (Checkpoint and Status). +**Phase:** Steps 1–5 complete. Ready for Step 6 (Restore). **Last updated:** 2026-03-23 @@ -24,6 +24,9 @@ ARCHITECTURE.md for design details. - **Step 4: Garden Core — Init and Add** — `Garden` struct tying manifest + store, `Init()`, `Open()`, `Add()` handling files/dirs/symlinks, `HashFile()`, tilde path conversion, CLI `init` and `add` commands. 8 tests. +- **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. ## In Progress @@ -31,7 +34,7 @@ ARCHITECTURE.md for design details. ## Up Next -Step 5: Checkpoint and Status. +Step 6: Restore. ## Known Issues / Decisions Deferred @@ -49,3 +52,4 @@ Step 5: Checkpoint and Status. | 2026-03-23 | 2 | Manifest package complete. Structs, Load/Save with atomic write, full test suite. | | 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. | diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index c3ac5f2..313db55 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -54,10 +54,10 @@ Depends on Steps 2 and 3. Depends on Step 4. -- [ ] `garden/garden.go`: `Checkpoint(message string) error` — re-hash all tracked files, store changed blobs, update manifest timestamps -- [ ] `garden/garden.go`: `Status() ([]FileStatus, error)` — compare current hashes to manifest; report modified/missing/ok -- [ ] `garden/garden_test.go`: checkpoint detects changed files, status reports correctly -- [ ] Wire up CLI: `cmd/sgard/checkpoint.go`, `cmd/sgard/status.go` +- [x] `garden/garden.go`: `Checkpoint(message string) error` — re-hash all tracked files, store changed blobs, update manifest timestamps +- [x] `garden/garden.go`: `Status() ([]FileStatus, error)` — compare current hashes to manifest; report modified/missing/ok +- [x] `garden/garden_test.go`: checkpoint detects changed files, status reports correctly +- [x] Wire up CLI: `cmd/sgard/checkpoint.go`, `cmd/sgard/status.go` ## Step 6: Restore diff --git a/cmd/sgard/checkpoint.go b/cmd/sgard/checkpoint.go new file mode 100644 index 0000000..312547f --- /dev/null +++ b/cmd/sgard/checkpoint.go @@ -0,0 +1,33 @@ +package main + +import ( + "fmt" + + "github.com/kisom/sgard/garden" + "github.com/spf13/cobra" +) + +var checkpointMessage string + +var checkpointCmd = &cobra.Command{ + Use: "checkpoint", + Short: "Re-hash all tracked files and update the manifest", + RunE: func(cmd *cobra.Command, args []string) error { + g, err := garden.Open(repoFlag) + if err != nil { + return err + } + + if err := g.Checkpoint(checkpointMessage); err != nil { + return err + } + + fmt.Println("Checkpoint complete.") + return nil + }, +} + +func init() { + checkpointCmd.Flags().StringVarP(&checkpointMessage, "message", "m", "", "checkpoint message") + rootCmd.AddCommand(checkpointCmd) +} diff --git a/cmd/sgard/status.go b/cmd/sgard/status.go new file mode 100644 index 0000000..a7d8ee1 --- /dev/null +++ b/cmd/sgard/status.go @@ -0,0 +1,34 @@ +package main + +import ( + "fmt" + + "github.com/kisom/sgard/garden" + "github.com/spf13/cobra" +) + +var statusCmd = &cobra.Command{ + Use: "status", + Short: "Show status of tracked files: ok, modified, or missing", + RunE: func(cmd *cobra.Command, args []string) error { + g, err := garden.Open(repoFlag) + if err != nil { + return err + } + + statuses, err := g.Status() + if err != nil { + return err + } + + for _, s := range statuses { + fmt.Printf("%-10s %s\n", s.State, s.Path) + } + + return nil + }, +} + +func init() { + rootCmd.AddCommand(statusCmd) +} diff --git a/garden/garden.go b/garden/garden.go index 45a26f0..589a69c 100644 --- a/garden/garden.go +++ b/garden/garden.go @@ -148,6 +148,127 @@ func (g *Garden) Add(paths []string) error { return nil } +// FileStatus reports the state of a tracked entry relative to the filesystem. +type FileStatus struct { + Path string // tilde path from manifest + State string // "ok", "modified", "missing" +} + +// Checkpoint re-hashes all tracked files, stores any changed blobs, and +// updates the manifest timestamps. The optional message is recorded in +// the manifest. +func (g *Garden) Checkpoint(message string) error { + now := time.Now().UTC() + + for i := range g.manifest.Files { + entry := &g.manifest.Files[i] + + abs, err := ExpandTildePath(entry.Path) + if err != nil { + return fmt.Errorf("expanding path %s: %w", entry.Path, err) + } + + info, err := os.Lstat(abs) + if err != nil { + // File is missing — leave the manifest entry as-is so status + // can report it. Don't fail the whole checkpoint. + continue + } + + entry.Mode = fmt.Sprintf("%04o", info.Mode().Perm()) + + switch entry.Type { + case "file": + data, err := os.ReadFile(abs) + if err != nil { + return fmt.Errorf("reading %s: %w", abs, err) + } + hash, err := g.store.Write(data) + if err != nil { + return fmt.Errorf("storing blob for %s: %w", abs, err) + } + if hash != entry.Hash { + entry.Hash = hash + entry.Updated = now + } + + case "link": + target, err := os.Readlink(abs) + if err != nil { + return fmt.Errorf("reading symlink %s: %w", abs, err) + } + if target != entry.Target { + entry.Target = target + entry.Updated = now + } + + case "directory": + // Nothing to hash; just update mode (already done above). + } + } + + g.manifest.Updated = now + g.manifest.Message = message + if err := g.manifest.Save(g.manifestPath); err != nil { + return fmt.Errorf("saving manifest: %w", err) + } + + return nil +} + +// Status compares each tracked entry against the current filesystem state +// and returns a status for each. +func (g *Garden) Status() ([]FileStatus, error) { + var results []FileStatus + + for i := range g.manifest.Files { + entry := &g.manifest.Files[i] + + abs, err := ExpandTildePath(entry.Path) + if err != nil { + return nil, fmt.Errorf("expanding path %s: %w", entry.Path, err) + } + + _, err = os.Lstat(abs) + if os.IsNotExist(err) { + results = append(results, FileStatus{Path: entry.Path, State: "missing"}) + continue + } + if err != nil { + return nil, fmt.Errorf("stat %s: %w", abs, err) + } + + switch entry.Type { + case "file": + hash, err := HashFile(abs) + if err != nil { + return nil, fmt.Errorf("hashing %s: %w", abs, err) + } + if hash != entry.Hash { + results = append(results, FileStatus{Path: entry.Path, State: "modified"}) + } else { + results = append(results, FileStatus{Path: entry.Path, State: "ok"}) + } + + case "link": + target, err := os.Readlink(abs) + if err != nil { + return nil, fmt.Errorf("reading symlink %s: %w", abs, err) + } + if target != entry.Target { + results = append(results, FileStatus{Path: entry.Path, State: "modified"}) + } else { + results = append(results, FileStatus{Path: entry.Path, State: "ok"}) + } + + case "directory": + results = append(results, FileStatus{Path: entry.Path, State: "ok"}) + } + } + + return results, 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 15ca30f..f5a8a49 100644 --- a/garden/garden_test.go +++ b/garden/garden_test.go @@ -218,6 +218,179 @@ func TestHashFile(t *testing.T) { } } +func TestCheckpointDetectsChanges(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) + } + + origHash := g.manifest.Files[0].Hash + + // Modify the file. + if err := os.WriteFile(testFile, []byte("modified"), 0o644); err != nil { + t.Fatalf("modifying test file: %v", err) + } + + if err := g.Checkpoint("test checkpoint"); err != nil { + t.Fatalf("Checkpoint: %v", err) + } + + if g.manifest.Files[0].Hash == origHash { + t.Error("checkpoint did not update hash for modified file") + } + if g.manifest.Message != "test checkpoint" { + t.Errorf("expected message 'test checkpoint', got %q", g.manifest.Message) + } + + // Verify new blob exists in store. + if !g.store.Exists(g.manifest.Files[0].Hash) { + t.Error("new blob not found in store") + } + + // Verify manifest persisted. + g2, err := Open(repoDir) + if err != nil { + t.Fatalf("re-Open: %v", err) + } + if g2.manifest.Files[0].Hash == origHash { + t.Error("persisted manifest still has old hash") + } +} + +func TestCheckpointUnchangedFile(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("same"), 0o644); err != nil { + t.Fatalf("writing test file: %v", err) + } + + if err := g.Add([]string{testFile}); err != nil { + t.Fatalf("Add: %v", err) + } + + origHash := g.manifest.Files[0].Hash + origUpdated := g.manifest.Files[0].Updated + + if err := g.Checkpoint(""); err != nil { + t.Fatalf("Checkpoint: %v", err) + } + + if g.manifest.Files[0].Hash != origHash { + t.Error("hash should not change for unmodified file") + } + if !g.manifest.Files[0].Updated.Equal(origUpdated) { + t.Error("entry timestamp should not change for unmodified file") + } +} + +func TestCheckpointMissingFileSkipped(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("data"), 0o644); err != nil { + t.Fatalf("writing test file: %v", err) + } + + if err := g.Add([]string{testFile}); err != nil { + t.Fatalf("Add: %v", err) + } + + // Remove the file before checkpoint. + os.Remove(testFile) + + // Checkpoint should not fail. + if err := g.Checkpoint(""); err != nil { + t.Fatalf("Checkpoint should not fail for missing file: %v", err) + } +} + +func TestStatusReportsCorrectly(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "repo") + + g, err := Init(repoDir) + if err != nil { + t.Fatalf("Init: %v", err) + } + + // Create and add two files. + okFile := filepath.Join(root, "okfile") + if err := os.WriteFile(okFile, []byte("unchanged"), 0o644); err != nil { + t.Fatalf("writing ok file: %v", err) + } + modFile := filepath.Join(root, "modfile") + if err := os.WriteFile(modFile, []byte("original"), 0o644); err != nil { + t.Fatalf("writing mod file: %v", err) + } + missingFile := filepath.Join(root, "missingfile") + if err := os.WriteFile(missingFile, []byte("will vanish"), 0o644); err != nil { + t.Fatalf("writing missing file: %v", err) + } + + if err := g.Add([]string{okFile, modFile, missingFile}); err != nil { + t.Fatalf("Add: %v", err) + } + + // Modify one file, remove another. + if err := os.WriteFile(modFile, []byte("changed"), 0o644); err != nil { + t.Fatalf("modifying file: %v", err) + } + os.Remove(missingFile) + + statuses, err := g.Status() + if err != nil { + t.Fatalf("Status: %v", err) + } + + if len(statuses) != 3 { + t.Fatalf("expected 3 statuses, got %d", len(statuses)) + } + + stateMap := make(map[string]string) + for _, s := range statuses { + stateMap[s.Path] = s.State + } + + okPath := toTildePath(okFile) + modPath := toTildePath(modFile) + missingPath := toTildePath(missingFile) + + if stateMap[okPath] != "ok" { + t.Errorf("okfile: expected ok, got %s", stateMap[okPath]) + } + if stateMap[modPath] != "modified" { + t.Errorf("modfile: expected modified, got %s", stateMap[modPath]) + } + if stateMap[missingPath] != "missing" { + t.Errorf("missingfile: expected missing, got %s", stateMap[missingPath]) + } +} + func TestExpandTildePath(t *testing.T) { home, err := os.UserHomeDir() if err != nil {