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:
80
garden/exclude.go
Normal file
80
garden/exclude.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package garden
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Exclude adds the given paths to the manifest's exclusion list. Excluded
|
||||
// paths are skipped during Add and MirrorUp directory walks. If any of the
|
||||
// paths are already tracked, they are removed from the manifest.
|
||||
func (g *Garden) Exclude(paths []string) error {
|
||||
existing := make(map[string]bool, len(g.manifest.Exclude))
|
||||
for _, e := range g.manifest.Exclude {
|
||||
existing[e] = true
|
||||
}
|
||||
|
||||
for _, p := range paths {
|
||||
abs, err := filepath.Abs(p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolving path %s: %w", p, err)
|
||||
}
|
||||
|
||||
tilded := toTildePath(abs)
|
||||
|
||||
if existing[tilded] {
|
||||
continue
|
||||
}
|
||||
|
||||
g.manifest.Exclude = append(g.manifest.Exclude, tilded)
|
||||
existing[tilded] = true
|
||||
|
||||
// Remove any already-tracked entries that match this exclusion.
|
||||
g.removeExcludedEntries(tilded)
|
||||
}
|
||||
|
||||
if err := g.manifest.Save(g.manifestPath); err != nil {
|
||||
return fmt.Errorf("saving manifest: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Include removes the given paths from the manifest's exclusion list,
|
||||
// allowing them to be tracked again.
|
||||
func (g *Garden) Include(paths []string) error {
|
||||
remove := make(map[string]bool, len(paths))
|
||||
for _, p := range paths {
|
||||
abs, err := filepath.Abs(p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolving path %s: %w", p, err)
|
||||
}
|
||||
remove[toTildePath(abs)] = true
|
||||
}
|
||||
|
||||
filtered := g.manifest.Exclude[:0]
|
||||
for _, e := range g.manifest.Exclude {
|
||||
if !remove[e] {
|
||||
filtered = append(filtered, e)
|
||||
}
|
||||
}
|
||||
g.manifest.Exclude = filtered
|
||||
|
||||
if err := g.manifest.Save(g.manifestPath); err != nil {
|
||||
return fmt.Errorf("saving manifest: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeExcludedEntries drops manifest entries that match the given
|
||||
// exclusion path (exact match or under an excluded directory).
|
||||
func (g *Garden) removeExcludedEntries(tildePath string) {
|
||||
kept := g.manifest.Files[:0]
|
||||
for _, e := range g.manifest.Files {
|
||||
if !g.manifest.IsExcluded(e.Path) {
|
||||
kept = append(kept, e)
|
||||
}
|
||||
}
|
||||
g.manifest.Files = kept
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -256,6 +256,13 @@ func (g *Garden) Add(paths []string, opts ...AddOptions) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tilded := toTildePath(path)
|
||||
if g.manifest.IsExcluded(tilded) {
|
||||
if d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -30,6 +30,13 @@ func (g *Garden) MirrorUp(paths []string) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
tilded := toTildePath(path)
|
||||
if g.manifest.IsExcluded(tilded) {
|
||||
if d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
@@ -154,6 +161,9 @@ func (g *Garden) MirrorDown(paths []string, force bool, confirm func(string) boo
|
||||
return walkErr
|
||||
}
|
||||
if d.IsDir() {
|
||||
if g.manifest.IsExcluded(toTildePath(path)) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
// Collect directories for potential cleanup (post-order).
|
||||
if path != abs {
|
||||
emptyDirs = append(emptyDirs, path)
|
||||
@@ -163,6 +173,10 @@ func (g *Garden) MirrorDown(paths []string, force bool, confirm func(string) boo
|
||||
if tracked[path] {
|
||||
return nil
|
||||
}
|
||||
// Excluded paths are left alone on disk.
|
||||
if g.manifest.IsExcluded(toTildePath(path)) {
|
||||
return nil
|
||||
}
|
||||
// Untracked file/symlink on disk.
|
||||
if !force {
|
||||
if confirm == nil || !confirm(path) {
|
||||
|
||||
Reference in New Issue
Block a user