diff --git a/PROGRESS.md b/PROGRESS.md index 2a405d2..9f5877a 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -7,7 +7,7 @@ ARCHITECTURE.md for design details. ## Current Status -**Phase:** Phase 3 complete. v2.0.0 released. Phase 4 planned, ready for Step 21. +**Phase:** Phase 4 in progress. Step 21 complete, ready for Step 22. **Last updated:** 2026-03-24 @@ -42,7 +42,7 @@ ARCHITECTURE.md for design details. ## Up Next -Phase 4: Hardening + Completeness. Step 21 (lock/unlock toggle) is next. +Step 22: Shell Completion. ## Known Issues / Decisions Deferred @@ -84,3 +84,4 @@ Phase 4: Hardening + Completeness. Step 21 (lock/unlock toggle) is next. | 2026-03-24 | 20 | Polish: encryption e2e test, all docs updated, flake vendorHash updated. | | 2026-03-24 | — | Locked files + dir-only entries. v2.0.0 released. | | 2026-03-24 | — | Phase 4 planned (Steps 21–27): lock/unlock, shell completion, TLS, DEK rotation, real FIDO2, test cleanup. | +| 2026-03-24 | 21 | Lock/unlock toggle commands. garden/lock.go, cmd/sgard/lock.go, 6 tests. | diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index f89ae75..2202e62 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -226,10 +226,9 @@ Depends on Steps 17, 18. ### Step 21: Lock/Unlock Toggle Commands -- [ ] `garden/garden.go`: `Lock(paths []string) error` — set `locked: true` on existing entries -- [ ] `garden/garden.go`: `Unlock(paths []string) error` — set `locked: false` on existing entries -- [ ] `cmd/sgard/lock.go`: `sgard lock ...`, `sgard unlock ...` -- [ ] Tests: lock existing entry, unlock it, verify behavior changes +- [x] `garden/lock.go`: `Lock(paths)`, `Unlock(paths)` — toggle locked flag on existing entries +- [x] `cmd/sgard/lock.go`: `sgard lock ...`, `sgard unlock ...` +- [x] Tests: lock/unlock existing entry, persist, error on untracked, checkpoint/status behavior changes (6 tests) ### Step 22: Shell Completion diff --git a/cmd/sgard/lock.go b/cmd/sgard/lock.go new file mode 100644 index 0000000..7d4e759 --- /dev/null +++ b/cmd/sgard/lock.go @@ -0,0 +1,51 @@ +package main + +import ( + "fmt" + + "github.com/kisom/sgard/garden" + "github.com/spf13/cobra" +) + +var lockCmd = &cobra.Command{ + Use: "lock ...", + Short: "Mark tracked files as locked (repo-authoritative)", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + g, err := garden.Open(repoFlag) + if err != nil { + return err + } + + if err := g.Lock(args); err != nil { + return err + } + + fmt.Printf("Locked %d path(s).\n", len(args)) + return nil + }, +} + +var unlockCmd = &cobra.Command{ + Use: "unlock ...", + Short: "Remove locked flag from tracked files", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + g, err := garden.Open(repoFlag) + if err != nil { + return err + } + + if err := g.Unlock(args); err != nil { + return err + } + + fmt.Printf("Unlocked %d path(s).\n", len(args)) + return nil + }, +} + +func init() { + rootCmd.AddCommand(lockCmd) + rootCmd.AddCommand(unlockCmd) +} diff --git a/garden/lock.go b/garden/lock.go new file mode 100644 index 0000000..b431a40 --- /dev/null +++ b/garden/lock.go @@ -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 +} diff --git a/garden/lock_test.go b/garden/lock_test.go new file mode 100644 index 0000000..07965b2 --- /dev/null +++ b/garden/lock_test.go @@ -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) + } +}