Add file exclusion support (sgard exclude/include).
Paths added to the manifest's exclude list are skipped during Add, MirrorUp, and MirrorDown directory walks. Excluding a directory excludes everything under it. Already-tracked entries matching a new exclusion are removed from the manifest. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
331
garden/exclude_test.go
Normal file
331
garden/exclude_test.go
Normal file
@@ -0,0 +1,331 @@
|
||||
package garden
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExcludeAddsToManifest(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
repoDir := filepath.Join(root, "repo")
|
||||
|
||||
g, err := Init(repoDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
|
||||
secretFile := filepath.Join(root, "secret.key")
|
||||
if err := os.WriteFile(secretFile, []byte("secret"), 0o600); err != nil {
|
||||
t.Fatalf("writing secret: %v", err)
|
||||
}
|
||||
|
||||
if err := g.Exclude([]string{secretFile}); err != nil {
|
||||
t.Fatalf("Exclude: %v", err)
|
||||
}
|
||||
|
||||
if len(g.manifest.Exclude) != 1 {
|
||||
t.Fatalf("expected 1 exclusion, got %d", len(g.manifest.Exclude))
|
||||
}
|
||||
|
||||
expected := toTildePath(secretFile)
|
||||
if g.manifest.Exclude[0] != expected {
|
||||
t.Errorf("exclude[0] = %q, want %q", g.manifest.Exclude[0], expected)
|
||||
}
|
||||
|
||||
// Verify persistence.
|
||||
g2, err := Open(repoDir)
|
||||
if err != nil {
|
||||
t.Fatalf("re-Open: %v", err)
|
||||
}
|
||||
if len(g2.manifest.Exclude) != 1 {
|
||||
t.Errorf("persisted excludes = %d, want 1", len(g2.manifest.Exclude))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExcludeDeduplicates(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
repoDir := filepath.Join(root, "repo")
|
||||
|
||||
g, err := Init(repoDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
|
||||
secretFile := filepath.Join(root, "secret.key")
|
||||
if err := os.WriteFile(secretFile, []byte("secret"), 0o600); err != nil {
|
||||
t.Fatalf("writing secret: %v", err)
|
||||
}
|
||||
|
||||
if err := g.Exclude([]string{secretFile}); err != nil {
|
||||
t.Fatalf("first Exclude: %v", err)
|
||||
}
|
||||
if err := g.Exclude([]string{secretFile}); err != nil {
|
||||
t.Fatalf("second Exclude: %v", err)
|
||||
}
|
||||
|
||||
if len(g.manifest.Exclude) != 1 {
|
||||
t.Errorf("expected 1 exclusion after dedup, got %d", len(g.manifest.Exclude))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExcludeRemovesTrackedEntry(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
repoDir := filepath.Join(root, "repo")
|
||||
|
||||
g, err := Init(repoDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
|
||||
secretFile := filepath.Join(root, "secret.key")
|
||||
if err := os.WriteFile(secretFile, []byte("secret"), 0o600); err != nil {
|
||||
t.Fatalf("writing secret: %v", err)
|
||||
}
|
||||
|
||||
// Add the file first.
|
||||
if err := g.Add([]string{secretFile}); err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
if len(g.manifest.Files) != 1 {
|
||||
t.Fatalf("expected 1 file, got %d", len(g.manifest.Files))
|
||||
}
|
||||
|
||||
// Now exclude it — should remove from tracked files.
|
||||
if err := g.Exclude([]string{secretFile}); err != nil {
|
||||
t.Fatalf("Exclude: %v", err)
|
||||
}
|
||||
|
||||
if len(g.manifest.Files) != 0 {
|
||||
t.Errorf("expected 0 files after exclude, got %d", len(g.manifest.Files))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncludeRemovesFromExcludeList(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
repoDir := filepath.Join(root, "repo")
|
||||
|
||||
g, err := Init(repoDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
|
||||
secretFile := filepath.Join(root, "secret.key")
|
||||
if err := os.WriteFile(secretFile, []byte("secret"), 0o600); err != nil {
|
||||
t.Fatalf("writing secret: %v", err)
|
||||
}
|
||||
|
||||
if err := g.Exclude([]string{secretFile}); err != nil {
|
||||
t.Fatalf("Exclude: %v", err)
|
||||
}
|
||||
if len(g.manifest.Exclude) != 1 {
|
||||
t.Fatalf("expected 1 exclusion, got %d", len(g.manifest.Exclude))
|
||||
}
|
||||
|
||||
if err := g.Include([]string{secretFile}); err != nil {
|
||||
t.Fatalf("Include: %v", err)
|
||||
}
|
||||
if len(g.manifest.Exclude) != 0 {
|
||||
t.Errorf("expected 0 exclusions after include, got %d", len(g.manifest.Exclude))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddSkipsExcludedFile(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, "config")
|
||||
if err := os.MkdirAll(testDir, 0o755); err != nil {
|
||||
t.Fatalf("creating dir: %v", err)
|
||||
}
|
||||
|
||||
normalFile := filepath.Join(testDir, "settings.yaml")
|
||||
secretFile := filepath.Join(testDir, "credentials.key")
|
||||
if err := os.WriteFile(normalFile, []byte("settings"), 0o644); err != nil {
|
||||
t.Fatalf("writing normal file: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(secretFile, []byte("secret"), 0o600); err != nil {
|
||||
t.Fatalf("writing secret file: %v", err)
|
||||
}
|
||||
|
||||
// Exclude the secret file before adding the directory.
|
||||
if err := g.Exclude([]string{secretFile}); err != nil {
|
||||
t.Fatalf("Exclude: %v", err)
|
||||
}
|
||||
|
||||
if err := g.Add([]string{testDir}); err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
|
||||
if len(g.manifest.Files) != 1 {
|
||||
t.Fatalf("expected 1 file, got %d", len(g.manifest.Files))
|
||||
}
|
||||
|
||||
expectedPath := toTildePath(normalFile)
|
||||
if g.manifest.Files[0].Path != expectedPath {
|
||||
t.Errorf("tracked file = %q, want %q", g.manifest.Files[0].Path, expectedPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddSkipsExcludedDirectory(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, "config")
|
||||
subDir := filepath.Join(testDir, "secrets")
|
||||
if err := os.MkdirAll(subDir, 0o755); err != nil {
|
||||
t.Fatalf("creating dirs: %v", err)
|
||||
}
|
||||
|
||||
normalFile := filepath.Join(testDir, "settings.yaml")
|
||||
secretFile := filepath.Join(subDir, "token.key")
|
||||
if err := os.WriteFile(normalFile, []byte("settings"), 0o644); err != nil {
|
||||
t.Fatalf("writing normal file: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(secretFile, []byte("token"), 0o600); err != nil {
|
||||
t.Fatalf("writing secret file: %v", err)
|
||||
}
|
||||
|
||||
// Exclude the entire secrets subdirectory.
|
||||
if err := g.Exclude([]string{subDir}); err != nil {
|
||||
t.Fatalf("Exclude: %v", err)
|
||||
}
|
||||
|
||||
if err := g.Add([]string{testDir}); err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
|
||||
if len(g.manifest.Files) != 1 {
|
||||
t.Fatalf("expected 1 file, got %d", len(g.manifest.Files))
|
||||
}
|
||||
|
||||
expectedPath := toTildePath(normalFile)
|
||||
if g.manifest.Files[0].Path != expectedPath {
|
||||
t.Errorf("tracked file = %q, want %q", g.manifest.Files[0].Path, expectedPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMirrorUpSkipsExcluded(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, "config")
|
||||
if err := os.MkdirAll(testDir, 0o755); err != nil {
|
||||
t.Fatalf("creating dir: %v", err)
|
||||
}
|
||||
|
||||
normalFile := filepath.Join(testDir, "settings.yaml")
|
||||
secretFile := filepath.Join(testDir, "credentials.key")
|
||||
if err := os.WriteFile(normalFile, []byte("settings"), 0o644); err != nil {
|
||||
t.Fatalf("writing normal file: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(secretFile, []byte("secret"), 0o600); err != nil {
|
||||
t.Fatalf("writing secret file: %v", err)
|
||||
}
|
||||
|
||||
// Exclude the secret file.
|
||||
if err := g.Exclude([]string{secretFile}); err != nil {
|
||||
t.Fatalf("Exclude: %v", err)
|
||||
}
|
||||
|
||||
if err := g.MirrorUp([]string{testDir}); err != nil {
|
||||
t.Fatalf("MirrorUp: %v", err)
|
||||
}
|
||||
|
||||
// Only the normal file should be tracked.
|
||||
if len(g.manifest.Files) != 1 {
|
||||
t.Fatalf("expected 1 file, got %d", len(g.manifest.Files))
|
||||
}
|
||||
|
||||
expectedPath := toTildePath(normalFile)
|
||||
if g.manifest.Files[0].Path != expectedPath {
|
||||
t.Errorf("tracked file = %q, want %q", g.manifest.Files[0].Path, expectedPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMirrorDownLeavesExcludedAlone(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, "config")
|
||||
if err := os.MkdirAll(testDir, 0o755); err != nil {
|
||||
t.Fatalf("creating dir: %v", err)
|
||||
}
|
||||
|
||||
normalFile := filepath.Join(testDir, "settings.yaml")
|
||||
secretFile := filepath.Join(testDir, "credentials.key")
|
||||
if err := os.WriteFile(normalFile, []byte("settings"), 0o644); err != nil {
|
||||
t.Fatalf("writing normal file: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(secretFile, []byte("secret"), 0o600); err != nil {
|
||||
t.Fatalf("writing secret file: %v", err)
|
||||
}
|
||||
|
||||
// Add only the normal file.
|
||||
if err := g.Add([]string{normalFile}); err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
|
||||
// Exclude the secret file.
|
||||
if err := g.Exclude([]string{secretFile}); err != nil {
|
||||
t.Fatalf("Exclude: %v", err)
|
||||
}
|
||||
|
||||
// MirrorDown with force — excluded file should NOT be deleted.
|
||||
if err := g.MirrorDown([]string{testDir}, true, nil); err != nil {
|
||||
t.Fatalf("MirrorDown: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(secretFile); err != nil {
|
||||
t.Error("excluded file should not have been deleted by MirrorDown")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsExcludedDirectoryPrefix(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
repoDir := filepath.Join(root, "repo")
|
||||
|
||||
g, err := Init(repoDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
|
||||
// Exclude a directory.
|
||||
g.manifest.Exclude = []string{"~/config/secrets"}
|
||||
|
||||
if !g.manifest.IsExcluded("~/config/secrets") {
|
||||
t.Error("exact match should be excluded")
|
||||
}
|
||||
if !g.manifest.IsExcluded("~/config/secrets/token.key") {
|
||||
t.Error("file under excluded dir should be excluded")
|
||||
}
|
||||
if !g.manifest.IsExcluded("~/config/secrets/nested/deep.key") {
|
||||
t.Error("deeply nested file under excluded dir should be excluded")
|
||||
}
|
||||
if g.manifest.IsExcluded("~/config/secrets-backup/file.key") {
|
||||
t.Error("path with similar prefix but different dir should not be excluded")
|
||||
}
|
||||
if g.manifest.IsExcluded("~/config/other.yaml") {
|
||||
t.Error("unrelated path should not be excluded")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user