Add locked files and directory-only entries.

Locked files (--lock): repo-authoritative entries. Checkpoint skips
them (preserves repo version). Status reports "drifted" instead of
"modified". Restore always overwrites if hash differs, no prompt.
Use case: system-managed files the OS overwrites.

Directory-only entries (--dir): track directory itself without
recursing. Restore ensures directory exists with correct permissions.
Use case: directories that must exist but contents are managed
elsewhere.

Add refactored to use AddOptions struct (Encrypt, Lock, DirOnly)
instead of variadic bools.

Proto: ManifestEntry gains locked field. convert.go updated.
7 new tests. ARCHITECTURE.md and README.md updated.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 09:56:57 -07:00
parent 7accc6cac6
commit 0929d77e90
13 changed files with 363 additions and 44 deletions

View File

@@ -45,7 +45,7 @@ func TestEncryptionE2E(t *testing.T) {
}
// Encrypted files.
if err := g.Add([]string{sshConfig, awsCreds}, true); err != nil {
if err := g.Add([]string{sshConfig, awsCreds}, AddOptions{Encrypt: true}); err != nil {
t.Fatalf("Add encrypted: %v", err)
}
// Plaintext file.

View File

@@ -234,7 +234,7 @@ func TestEncryptedRoundTripWithFIDO2(t *testing.T) {
t.Fatalf("writing: %v", err)
}
if err := g.Add([]string{secretFile}, true); err != nil {
if err := g.Add([]string{secretFile}, AddOptions{Encrypt: true}); err != nil {
t.Fatalf("Add: %v", err)
}

View File

@@ -128,7 +128,7 @@ func TestAddEncrypted(t *testing.T) {
t.Fatalf("writing secret file: %v", err)
}
if err := g.Add([]string{secretFile}, true); err != nil {
if err := g.Add([]string{secretFile}, AddOptions{Encrypt: true}); err != nil {
t.Fatalf("Add encrypted: %v", err)
}
@@ -205,7 +205,7 @@ func TestEncryptedRestoreRoundTrip(t *testing.T) {
t.Fatalf("writing: %v", err)
}
if err := g.Add([]string{secretFile}, true); err != nil {
if err := g.Add([]string{secretFile}, AddOptions{Encrypt: true}); err != nil {
t.Fatalf("Add: %v", err)
}
@@ -243,7 +243,7 @@ func TestEncryptedCheckpoint(t *testing.T) {
t.Fatalf("writing: %v", err)
}
if err := g.Add([]string{secretFile}, true); err != nil {
if err := g.Add([]string{secretFile}, AddOptions{Encrypt: true}); err != nil {
t.Fatalf("Add: %v", err)
}
@@ -285,7 +285,7 @@ func TestEncryptedStatus(t *testing.T) {
t.Fatalf("writing: %v", err)
}
if err := g.Add([]string{secretFile}, true); err != nil {
if err := g.Add([]string{secretFile}, AddOptions{Encrypt: true}); err != nil {
t.Fatalf("Add: %v", err)
}
@@ -330,7 +330,7 @@ func TestEncryptedDiff(t *testing.T) {
t.Fatalf("writing: %v", err)
}
if err := g.Add([]string{secretFile}, true); err != nil {
if err := g.Add([]string{secretFile}, AddOptions{Encrypt: true}); err != nil {
t.Fatalf("Add: %v", err)
}
@@ -372,7 +372,7 @@ func TestAddEncryptedRequiresDEK(t *testing.T) {
t.Fatalf("writing: %v", err)
}
err = g.Add([]string{testFile}, true)
err = g.Add([]string{testFile}, AddOptions{Encrypt: true})
if err == nil {
t.Fatal("Add --encrypt without DEK should fail")
}

View File

@@ -138,11 +138,10 @@ func (g *Garden) DeleteBlob(hash string) error {
return g.store.Delete(hash)
}
// addEntry adds a single file or symlink to the manifest. The abs path must
// already be resolved and info must come from os.Lstat. If skipDup is true,
// already-tracked paths are silently skipped instead of returning an error.
// If encrypt is true, the file blob is encrypted before storing.
func (g *Garden) addEntry(abs string, info os.FileInfo, now time.Time, skipDup, encrypt bool) error {
// addEntry adds a single file or symlink to the manifest. If skipDup is true,
// already-tracked paths are silently skipped. If encrypt is true, the file
// blob is encrypted before storing. If lock is true, the entry is marked locked.
func (g *Garden) addEntry(abs string, info os.FileInfo, now time.Time, skipDup, encrypt, lock bool) error {
tilded := toTildePath(abs)
if g.findEntry(tilded) != nil {
@@ -155,6 +154,7 @@ func (g *Garden) addEntry(abs string, info os.FileInfo, now time.Time, skipDup,
entry := manifest.Entry{
Path: tilded,
Mode: fmt.Sprintf("%04o", info.Mode().Perm()),
Locked: lock,
Updated: now,
}
@@ -198,15 +198,23 @@ func (g *Garden) addEntry(abs string, info os.FileInfo, now time.Time, skipDup,
return nil
}
// AddOptions controls the behavior of Add.
type AddOptions struct {
Encrypt bool // encrypt file blobs before storing
Lock bool // mark entries as locked (repo-authoritative)
DirOnly bool // for directories: track the directory itself, don't recurse
}
// Add tracks new files, directories, or symlinks. Each path is resolved
// to an absolute path, inspected for its type, and added to the manifest.
// Regular files are hashed and stored in the blob store. Directories are
// recursively walked and all leaf files and symlinks are added individually.
// If encrypt is true, file blobs are encrypted before storing (requires
// the DEK to be unlocked).
func (g *Garden) Add(paths []string, encrypt ...bool) error {
enc := len(encrypt) > 0 && encrypt[0]
if enc && g.dek == nil {
// recursively walked unless opts.DirOnly is set.
func (g *Garden) Add(paths []string, opts ...AddOptions) error {
var o AddOptions
if len(opts) > 0 {
o = opts[0]
}
if o.Encrypt && g.dek == nil {
return fmt.Errorf("DEK not unlocked; run sgard encrypt init or unlock first")
}
@@ -224,24 +232,40 @@ func (g *Garden) Add(paths []string, encrypt ...bool) error {
}
if info.IsDir() {
err := filepath.WalkDir(abs, func(path string, d os.DirEntry, err error) error {
if o.DirOnly {
// Track the directory itself as a structural entry.
tilded := toTildePath(abs)
if g.findEntry(tilded) != nil {
return fmt.Errorf("already tracking %s", tilded)
}
entry := manifest.Entry{
Path: tilded,
Type: "directory",
Mode: fmt.Sprintf("%04o", info.Mode().Perm()),
Locked: o.Lock,
Updated: now,
}
g.manifest.Files = append(g.manifest.Files, entry)
} else {
err := filepath.WalkDir(abs, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
fi, err := os.Lstat(path)
if err != nil {
return fmt.Errorf("stat %s: %w", path, err)
}
return g.addEntry(path, fi, now, true, o.Encrypt, o.Lock)
})
if err != nil {
return err
return fmt.Errorf("walking directory %s: %w", abs, err)
}
if d.IsDir() {
return nil
}
fi, err := os.Lstat(path)
if err != nil {
return fmt.Errorf("stat %s: %w", path, err)
}
return g.addEntry(path, fi, now, true, enc)
})
if err != nil {
return fmt.Errorf("walking directory %s: %w", abs, err)
}
} else {
if err := g.addEntry(abs, info, now, false, enc); err != nil {
if err := g.addEntry(abs, info, now, false, o.Encrypt, o.Lock); err != nil {
return err
}
}
@@ -284,6 +308,11 @@ func (g *Garden) Checkpoint(message string) error {
entry.Mode = fmt.Sprintf("%04o", info.Mode().Perm())
// Locked entries are repo-authoritative — checkpoint skips them.
if entry.Locked {
continue
}
switch entry.Type {
case "file":
data, err := os.ReadFile(abs)
@@ -379,7 +408,11 @@ func (g *Garden) Status() ([]FileStatus, error) {
compareHash = entry.PlaintextHash
}
if hash != compareHash {
results = append(results, FileStatus{Path: entry.Path, State: "modified"})
if entry.Locked {
results = append(results, FileStatus{Path: entry.Path, State: "drifted"})
} else {
results = append(results, FileStatus{Path: entry.Path, State: "modified"})
}
} else {
results = append(results, FileStatus{Path: entry.Path, State: "ok"})
}
@@ -425,12 +458,21 @@ func (g *Garden) Restore(paths []string, force bool, confirm func(path string) b
return fmt.Errorf("expanding path %s: %w", entry.Path, err)
}
// Check if the file exists and whether we need confirmation.
if !force {
// Locked entries always restore if content differs — no prompt.
if entry.Locked && entry.Type == "file" {
if currentHash, err := HashFile(abs); err == nil {
compareHash := entry.Hash
if entry.Encrypted && entry.PlaintextHash != "" {
compareHash = entry.PlaintextHash
}
if currentHash == compareHash {
continue // already matches, skip
}
}
// File is missing or hash differs — proceed to restore.
} else if !force {
// Normal entries: check timestamp for confirmation.
if info, err := os.Lstat(abs); err == nil {
// File exists. If on-disk mtime >= manifest updated, ask.
// Truncate to seconds because filesystem mtime granularity
// varies across platforms.
diskTime := info.ModTime().Truncate(time.Second)
entryTime := entry.Updated.Truncate(time.Second)
if !diskTime.Before(entryTime) {

229
garden/locked_test.go Normal file
View File

@@ -0,0 +1,229 @@
package garden
import (
"os"
"path/filepath"
"testing"
)
func TestAddLocked(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("locked content\n"), 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.Error("entry should be locked")
}
}
func TestCheckpointSkipsLocked(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)
}
origHash := g.manifest.Files[0].Hash
// Modify the file — checkpoint should NOT update the hash.
if err := os.WriteFile(testFile, []byte("system overwrote this"), 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.Error("checkpoint should skip locked files — hash should not change")
}
}
func TestStatusReportsDrifted(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)
}
// Modify — status should report "drifted" not "modified".
if err := os.WriteFile(testFile, []byte("system changed this"), 0o644); err != nil {
t.Fatalf("modifying: %v", err)
}
statuses, err := g.Status()
if err != nil {
t.Fatalf("Status: %v", err)
}
if len(statuses) != 1 || statuses[0].State != "drifted" {
t.Errorf("expected drifted, got %v", statuses)
}
}
func TestRestoreAlwaysRestoresLocked(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("correct content"), 0o644); err != nil {
t.Fatalf("writing: %v", err)
}
if err := g.Add([]string{testFile}, AddOptions{Lock: true}); err != nil {
t.Fatalf("Add: %v", err)
}
// System overwrites the file.
if err := os.WriteFile(testFile, []byte("system garbage"), 0o644); err != nil {
t.Fatalf("overwriting: %v", err)
}
// Restore without --force — locked files should still be restored.
if err := g.Restore(nil, false, nil); err != nil {
t.Fatalf("Restore: %v", err)
}
got, err := os.ReadFile(testFile)
if err != nil {
t.Fatalf("reading: %v", err)
}
if string(got) != "correct content" {
t.Errorf("content = %q, want %q", got, "correct content")
}
}
func TestRestoreSkipsLockedWhenHashMatches(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("content"), 0o644); err != nil {
t.Fatalf("writing: %v", err)
}
if err := g.Add([]string{testFile}, AddOptions{Lock: true}); err != nil {
t.Fatalf("Add: %v", err)
}
// File is unchanged — restore should skip it (no unnecessary writes).
if err := g.Restore(nil, false, nil); err != nil {
t.Fatalf("Restore: %v", err)
}
// If we got here without error, it means it didn't try to overwrite
// an identical file, which is correct.
}
func TestAddDirOnly(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
// Create a directory with a file inside.
testDir := filepath.Join(root, "testdir")
if err := os.MkdirAll(testDir, 0o755); err != nil {
t.Fatalf("creating dir: %v", err)
}
if err := os.WriteFile(filepath.Join(testDir, "file"), []byte("data"), 0o644); err != nil {
t.Fatalf("writing: %v", err)
}
// Add with --dir — should NOT recurse.
if err := g.Add([]string{testDir}, AddOptions{DirOnly: true}); err != nil {
t.Fatalf("Add: %v", err)
}
if len(g.manifest.Files) != 1 {
t.Fatalf("expected 1 entry (directory), got %d", len(g.manifest.Files))
}
if g.manifest.Files[0].Type != "directory" {
t.Errorf("type = %s, want directory", g.manifest.Files[0].Type)
}
if g.manifest.Files[0].Hash != "" {
t.Error("directory entry should have no hash")
}
}
func TestDirOnlyRestoreCreatesDirectory(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
testDir := filepath.Join(root, "testdir")
if err := os.MkdirAll(testDir, 0o755); err != nil {
t.Fatalf("creating dir: %v", err)
}
if err := g.Add([]string{testDir}, AddOptions{DirOnly: true}); err != nil {
t.Fatalf("Add: %v", err)
}
// Remove directory.
_ = os.RemoveAll(testDir)
// Restore should recreate it.
if err := g.Restore(nil, true, nil); err != nil {
t.Fatalf("Restore: %v", err)
}
info, err := os.Stat(testDir)
if err != nil {
t.Fatalf("directory not restored: %v", err)
}
if !info.IsDir() {
t.Error("restored path should be a directory")
}
}

View File

@@ -37,7 +37,7 @@ func (g *Garden) MirrorUp(paths []string) error {
if lstatErr != nil {
return fmt.Errorf("stat %s: %w", path, lstatErr)
}
return g.addEntry(path, fi, now, true, false)
return g.addEntry(path, fi, now, true, false, false)
})
if err != nil {
return fmt.Errorf("walking directory %s: %w", abs, err)