Step 5: Checkpoint and Status.
Checkpoint re-hashes all tracked files, stores changed blobs, and updates per-file timestamps only when content changes. Missing files are skipped gracefully. Status compares each tracked entry against the filesystem and reports ok/modified/missing. CLI: sgard checkpoint [-m message], sgard status. 4 new tests (changed file, unchanged file, missing file, status). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,7 @@ ARCHITECTURE.md for design details.
|
|||||||
|
|
||||||
## Current Status
|
## 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
|
**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 +
|
- **Step 4: Garden Core — Init and Add** — `Garden` struct tying manifest +
|
||||||
store, `Init()`, `Open()`, `Add()` handling files/dirs/symlinks, `HashFile()`,
|
store, `Init()`, `Open()`, `Add()` handling files/dirs/symlinks, `HashFile()`,
|
||||||
tilde path conversion, CLI `init` and `add` commands. 8 tests.
|
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
|
## In Progress
|
||||||
|
|
||||||
@@ -31,7 +34,7 @@ ARCHITECTURE.md for design details.
|
|||||||
|
|
||||||
## Up Next
|
## Up Next
|
||||||
|
|
||||||
Step 5: Checkpoint and Status.
|
Step 6: Restore.
|
||||||
|
|
||||||
## Known Issues / Decisions Deferred
|
## 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 | 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 | 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 | 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. |
|
||||||
|
|||||||
@@ -54,10 +54,10 @@ Depends on Steps 2 and 3.
|
|||||||
|
|
||||||
Depends on Step 4.
|
Depends on Step 4.
|
||||||
|
|
||||||
- [ ] `garden/garden.go`: `Checkpoint(message string) error` — re-hash all tracked files, store changed blobs, update manifest timestamps
|
- [x] `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
|
- [x] `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
|
- [x] `garden/garden_test.go`: checkpoint detects changed files, status reports correctly
|
||||||
- [ ] Wire up CLI: `cmd/sgard/checkpoint.go`, `cmd/sgard/status.go`
|
- [x] Wire up CLI: `cmd/sgard/checkpoint.go`, `cmd/sgard/status.go`
|
||||||
|
|
||||||
## Step 6: Restore
|
## Step 6: Restore
|
||||||
|
|
||||||
|
|||||||
33
cmd/sgard/checkpoint.go
Normal file
33
cmd/sgard/checkpoint.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
34
cmd/sgard/status.go
Normal file
34
cmd/sgard/status.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
121
garden/garden.go
121
garden/garden.go
@@ -148,6 +148,127 @@ func (g *Garden) Add(paths []string) error {
|
|||||||
return nil
|
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.
|
// findEntry returns the entry for the given tilde path, or nil if not found.
|
||||||
func (g *Garden) findEntry(tildePath string) *manifest.Entry {
|
func (g *Garden) findEntry(tildePath string) *manifest.Entry {
|
||||||
for i := range g.manifest.Files {
|
for i := range g.manifest.Files {
|
||||||
|
|||||||
@@ -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) {
|
func TestExpandTildePath(t *testing.T) {
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user