Step 21: Lock/unlock toggle commands.

garden/lock.go: Lock() and Unlock() toggle the locked flag on
existing tracked entries. Errors on untracked paths. Persists
to manifest.

cmd/sgard/lock.go: sgard lock <path>..., sgard unlock <path>...

6 tests: lock/unlock existing entry, persistence, error on untracked,
checkpoint behavior changes after lock, status changes between
drifted and modified after unlock.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 11:07:40 -07:00
parent 0cf81ab6a1
commit d2bba75365
5 changed files with 293 additions and 6 deletions

39
garden/lock.go Normal file
View File

@@ -0,0 +1,39 @@
package garden
import (
"fmt"
"path/filepath"
)
// Lock marks existing tracked entries as locked (repo-authoritative).
func (g *Garden) Lock(paths []string) error {
return g.setLocked(paths, true)
}
// Unlock removes the locked flag from existing tracked entries.
func (g *Garden) Unlock(paths []string) error {
return g.setLocked(paths, false)
}
func (g *Garden) setLocked(paths []string, locked bool) error {
for _, p := range paths {
abs, err := filepath.Abs(p)
if err != nil {
return fmt.Errorf("resolving path %s: %w", p, err)
}
tilded := toTildePath(abs)
entry := g.findEntry(tilded)
if entry == nil {
return fmt.Errorf("not tracked: %s", tilded)
}
entry.Locked = locked
}
if err := g.manifest.Save(g.manifestPath); err != nil {
return fmt.Errorf("saving manifest: %w", err)
}
return nil
}

197
garden/lock_test.go Normal file
View File

@@ -0,0 +1,197 @@
package garden
import (
"os"
"path/filepath"
"testing"
)
func TestLockExistingEntry(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: %v", err)
}
// Add without lock.
if err := g.Add([]string{testFile}); err != nil {
t.Fatalf("Add: %v", err)
}
if g.manifest.Files[0].Locked {
t.Fatal("should not be locked initially")
}
// Lock it.
if err := g.Lock([]string{testFile}); err != nil {
t.Fatalf("Lock: %v", err)
}
if !g.manifest.Files[0].Locked {
t.Error("should be locked after Lock()")
}
// Verify persisted.
g2, err := Open(repoDir)
if err != nil {
t.Fatalf("Open: %v", err)
}
if !g2.manifest.Files[0].Locked {
t.Error("locked state should persist")
}
}
func TestUnlockExistingEntry(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: %v", err)
}
if err := g.Add([]string{testFile}, AddOptions{Lock: true}); err != nil {
t.Fatalf("Add: %v", err)
}
if !g.manifest.Files[0].Locked {
t.Fatal("should be locked")
}
if err := g.Unlock([]string{testFile}); err != nil {
t.Fatalf("Unlock: %v", err)
}
if g.manifest.Files[0].Locked {
t.Error("should not be locked after Unlock()")
}
}
func TestLockUntrackedErrors(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, "nottracked")
if err := os.WriteFile(testFile, []byte("data"), 0o644); err != nil {
t.Fatalf("writing: %v", err)
}
if err := g.Lock([]string{testFile}); err == nil {
t.Fatal("Lock on untracked path should error")
}
}
func TestLockChangesCheckpointBehavior(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: %v", err)
}
// Add unlocked, checkpoint picks up changes.
if err := g.Add([]string{testFile}); err != nil {
t.Fatalf("Add: %v", err)
}
origHash := g.manifest.Files[0].Hash
if err := os.WriteFile(testFile, []byte("changed"), 0o644); err != nil {
t.Fatalf("modifying: %v", err)
}
if err := g.Checkpoint(""); err != nil {
t.Fatalf("Checkpoint: %v", err)
}
if g.manifest.Files[0].Hash == origHash {
t.Fatal("unlocked file: checkpoint should update hash")
}
newHash := g.manifest.Files[0].Hash
// Now lock it and modify again — checkpoint should NOT update.
if err := g.Lock([]string{testFile}); err != nil {
t.Fatalf("Lock: %v", err)
}
if err := os.WriteFile(testFile, []byte("system overwrote"), 0o644); err != nil {
t.Fatalf("overwriting: %v", err)
}
if err := g.Checkpoint(""); err != nil {
t.Fatalf("Checkpoint: %v", err)
}
if g.manifest.Files[0].Hash != newHash {
t.Error("locked file: checkpoint should not update hash")
}
}
func TestUnlockChangesStatusBehavior(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: %v", err)
}
if err := g.Add([]string{testFile}, AddOptions{Lock: true}); err != nil {
t.Fatalf("Add: %v", err)
}
if err := os.WriteFile(testFile, []byte("changed"), 0o644); err != nil {
t.Fatalf("modifying: %v", err)
}
// Locked: should be "drifted".
statuses, err := g.Status()
if err != nil {
t.Fatalf("Status: %v", err)
}
if statuses[0].State != "drifted" {
t.Errorf("locked: expected drifted, got %s", statuses[0].State)
}
// Unlock: should now be "modified".
if err := g.Unlock([]string{testFile}); err != nil {
t.Fatalf("Unlock: %v", err)
}
statuses, err = g.Status()
if err != nil {
t.Fatalf("Status: %v", err)
}
if statuses[0].State != "modified" {
t.Errorf("unlocked: expected modified, got %s", statuses[0].State)
}
}