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:
2026-03-23 21:36:55 -07:00
parent 1550bdf940
commit 661c050d83
6 changed files with 371 additions and 6 deletions

View File

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

View File

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