diff --git a/garden/garden.go b/garden/garden.go index 5b437d0..209de5f 100644 --- a/garden/garden.go +++ b/garden/garden.go @@ -139,9 +139,8 @@ func (g *Garden) DeleteBlob(hash string) 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 { +// already-tracked paths are silently skipped. +func (g *Garden) addEntry(abs string, info os.FileInfo, now time.Time, skipDup bool, o AddOptions) error { tilded := toTildePath(abs) if g.findEntry(tilded) != nil { @@ -154,7 +153,9 @@ 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, + Locked: o.Lock, + Only: o.Only, + Never: o.Never, Updated: now, } @@ -173,7 +174,7 @@ func (g *Garden) addEntry(abs string, info os.FileInfo, now time.Time, skipDup, return fmt.Errorf("reading file %s: %w", abs, err) } - if encrypt { + if o.Encrypt { if g.dek == nil { return fmt.Errorf("DEK not unlocked; cannot encrypt %s", abs) } @@ -200,9 +201,11 @@ func (g *Garden) addEntry(abs string, info os.FileInfo, now time.Time, skipDup, // 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 + 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 + Only []string // per-machine targeting: only apply on matching machines + Never []string // per-machine targeting: never apply on matching machines } // Add tracks new files, directories, or symlinks. Each path is resolved @@ -243,6 +246,8 @@ func (g *Garden) Add(paths []string, opts ...AddOptions) error { Type: "directory", Mode: fmt.Sprintf("%04o", info.Mode().Perm()), Locked: o.Lock, + Only: o.Only, + Never: o.Never, Updated: now, } g.manifest.Files = append(g.manifest.Files, entry) @@ -258,14 +263,14 @@ func (g *Garden) Add(paths []string, opts ...AddOptions) error { if err != nil { return fmt.Errorf("stat %s: %w", path, err) } - return g.addEntry(path, fi, now, true, o.Encrypt, o.Lock) + return g.addEntry(path, fi, now, true, o) }) if err != nil { return fmt.Errorf("walking directory %s: %w", abs, err) } } } else { - if err := g.addEntry(abs, info, now, false, o.Encrypt, o.Lock); err != nil { + if err := g.addEntry(abs, info, now, false, o); err != nil { return err } } @@ -290,10 +295,19 @@ type FileStatus struct { // the manifest. func (g *Garden) Checkpoint(message string) error { now := g.clock.Now().UTC() + labels := g.Identity() for i := range g.manifest.Files { entry := &g.manifest.Files[i] + applies, err := EntryApplies(entry, labels) + if err != nil { + return err + } + if !applies { + continue + } + abs, err := ExpandTildePath(entry.Path) if err != nil { return fmt.Errorf("expanding path %s: %w", entry.Path, err) @@ -378,10 +392,20 @@ func (g *Garden) Checkpoint(message string) error { // and returns a status for each. func (g *Garden) Status() ([]FileStatus, error) { var results []FileStatus + labels := g.Identity() for i := range g.manifest.Files { entry := &g.manifest.Files[i] + applies, err := EntryApplies(entry, labels) + if err != nil { + return nil, err + } + if !applies { + results = append(results, FileStatus{Path: entry.Path, State: "skipped"}) + continue + } + abs, err := ExpandTildePath(entry.Path) if err != nil { return nil, fmt.Errorf("expanding path %s: %w", entry.Path, err) @@ -450,9 +474,19 @@ func (g *Garden) Restore(paths []string, force bool, confirm func(path string) b } } + labels := g.Identity() + for i := range entries { entry := &entries[i] + applies, err := EntryApplies(entry, labels) + if err != nil { + return err + } + if !applies { + continue + } + abs, err := ExpandTildePath(entry.Path) if err != nil { return fmt.Errorf("expanding path %s: %w", entry.Path, err) diff --git a/garden/mirror.go b/garden/mirror.go index ac0ff25..7af3a8f 100644 --- a/garden/mirror.go +++ b/garden/mirror.go @@ -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, false) + return g.addEntry(path, fi, now, true, AddOptions{}) }) if err != nil { return fmt.Errorf("walking directory %s: %w", abs, err) diff --git a/garden/targeting_ops_test.go b/garden/targeting_ops_test.go new file mode 100644 index 0000000..7d37649 --- /dev/null +++ b/garden/targeting_ops_test.go @@ -0,0 +1,190 @@ +package garden + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestCheckpointSkipsNonMatching(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 with only:os:fakeos — won't match this machine. + if err := g.Add([]string{testFile}, AddOptions{Only: []string{"os:fakeos"}}); err != nil { + t.Fatalf("Add: %v", err) + } + + origHash := g.manifest.Files[0].Hash + + // Modify file. + if err := os.WriteFile(testFile, []byte("modified"), 0o644); err != nil { + t.Fatalf("modifying: %v", err) + } + + // Checkpoint should skip this entry. + if err := g.Checkpoint(""); err != nil { + t.Fatalf("Checkpoint: %v", err) + } + + if g.manifest.Files[0].Hash != origHash { + t.Error("checkpoint should skip non-matching entry") + } +} + +func TestCheckpointProcessesMatching(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 with only matching current OS. + if err := g.Add([]string{testFile}, AddOptions{Only: []string{"os:" + runtime.GOOS}}); err != nil { + t.Fatalf("Add: %v", err) + } + + origHash := g.manifest.Files[0].Hash + + if err := os.WriteFile(testFile, []byte("modified"), 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 process matching entry") + } +} + +func TestStatusReportsSkipped(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{Only: []string{"os:fakeos"}}); err != nil { + t.Fatalf("Add: %v", err) + } + + statuses, err := g.Status() + if err != nil { + t.Fatalf("Status: %v", err) + } + if len(statuses) != 1 || statuses[0].State != "skipped" { + t.Errorf("expected skipped, got %v", statuses) + } +} + +func TestRestoreSkipsNonMatching(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{Only: []string{"os:fakeos"}}); err != nil { + t.Fatalf("Add: %v", err) + } + + // Delete file and try to restore — should skip. + _ = os.Remove(testFile) + if err := g.Restore(nil, true, nil); err != nil { + t.Fatalf("Restore: %v", err) + } + + // File should NOT have been restored. + if _, err := os.Stat(testFile); !os.IsNotExist(err) { + t.Error("restore should skip non-matching entry — file should not exist") + } +} + +func TestAddWithTargeting(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{ + Only: []string{"os:linux", "tag:work"}, + }); err != nil { + t.Fatalf("Add: %v", err) + } + + entry := g.manifest.Files[0] + if len(entry.Only) != 2 { + t.Fatalf("expected 2 only labels, got %d", len(entry.Only)) + } + if entry.Only[0] != "os:linux" || entry.Only[1] != "tag:work" { + t.Errorf("only = %v, want [os:linux tag:work]", entry.Only) + } +} + +func TestAddWithNever(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{ + Never: []string{"arch:arm64"}, + }); err != nil { + t.Fatalf("Add: %v", err) + } + + entry := g.manifest.Files[0] + if len(entry.Never) != 1 || entry.Never[0] != "arch:arm64" { + t.Errorf("never = %v, want [arch:arm64]", entry.Never) + } +}