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:
@@ -7,7 +7,7 @@ ARCHITECTURE.md for design details.
|
|||||||
|
|
||||||
## Current Status
|
## 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
|
**Last updated:** 2026-03-24
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ ARCHITECTURE.md for design details.
|
|||||||
|
|
||||||
## Up Next
|
## Up Next
|
||||||
|
|
||||||
Phase 4: Hardening + Completeness. Step 21 (lock/unlock toggle) is next.
|
Step 22: Shell Completion.
|
||||||
|
|
||||||
## Known Issues / Decisions Deferred
|
## 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 | 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 | — | 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 | — | 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. |
|
||||||
|
|||||||
@@ -226,10 +226,9 @@ Depends on Steps 17, 18.
|
|||||||
|
|
||||||
### Step 21: Lock/Unlock Toggle Commands
|
### Step 21: Lock/Unlock Toggle Commands
|
||||||
|
|
||||||
- [ ] `garden/garden.go`: `Lock(paths []string) error` — set `locked: true` on existing entries
|
- [x] `garden/lock.go`: `Lock(paths)`, `Unlock(paths)` — toggle locked flag on existing entries
|
||||||
- [ ] `garden/garden.go`: `Unlock(paths []string) error` — set `locked: false` on existing entries
|
- [x] `cmd/sgard/lock.go`: `sgard lock <path>...`, `sgard unlock <path>...`
|
||||||
- [ ] `cmd/sgard/lock.go`: `sgard lock <path>...`, `sgard unlock <path>...`
|
- [x] Tests: lock/unlock existing entry, persist, error on untracked, checkpoint/status behavior changes (6 tests)
|
||||||
- [ ] Tests: lock existing entry, unlock it, verify behavior changes
|
|
||||||
|
|
||||||
### Step 22: Shell Completion
|
### Step 22: Shell Completion
|
||||||
|
|
||||||
|
|||||||
51
cmd/sgard/lock.go
Normal file
51
cmd/sgard/lock.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/kisom/sgard/garden"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var lockCmd = &cobra.Command{
|
||||||
|
Use: "lock <path>...",
|
||||||
|
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 <path>...",
|
||||||
|
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)
|
||||||
|
}
|
||||||
39
garden/lock.go
Normal file
39
garden/lock.go
Normal 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
197
garden/lock_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user