From 5e0bb51704fbf9781882d499f59619996c132373 Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Mon, 10 Aug 2026 11:03:31 -0400 Subject: [PATCH] Support adding directory trees --- README.md | 2 +- integration/add_test.go | 18 ++++++ internal/application/add/preflight.go | 72 +++++++++++++++++++--- internal/application/add/preflight_test.go | 13 ++++ internal/cli/add.go | 2 +- 5 files changed, 96 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 80aec8f..f3d31d9 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ See [repository layout](docs/repository-layout.md) for the full grammar. | `cattery status [GROUP ...]` | report drift without changing anything | | `cattery diff [GROUP ...]` | show safe content differences | | `cattery apply [GROUP ...]` | reconcile targets with the repository | -| `cattery add [OPTIONS] FILE ...` | adopt target content into the repository | +| `cattery add [OPTIONS] TARGET ...` | adopt target files or directories into the repository | | `cattery version` | print version, commit, and build metadata | Global options are `--repo PATH` and `--verbose`. Apply accepts `--dry-run`, diff --git a/integration/add_test.go b/integration/add_test.go index 669cd67..4b83341 100644 --- a/integration/add_test.go +++ b/integration/add_test.go @@ -13,6 +13,7 @@ func TestExecutableAdd(t *testing.T) { run func(*testing.T) }{ {"ordinary adoption", testExecAddOrdinary}, + {"directory adoption", testExecAddDirectory}, {"raw argument order", testExecAddOrder}, {"explicit presence", testExecAddPresence}, {"dry run", testExecAddDryRun}, @@ -44,6 +45,23 @@ func testExecAddOrdinary(t *testing.T) { } } +func testExecAddDirectory(t *testing.T) { + env := newExecEnv(t) + env.initRepository(t) + writeFile(t, filepath.Join(env.home, ".config", "git", "config"), []byte("[user]\nname = Aly\n")) + result := env.run(t, nil, "add", ".config/git", "--group", "git") + if result.Code != 0 { + t.Fatalf("add directory: code=%d stderr=%q", result.Code, result.Stderr) + } + content, err := os.ReadFile(filepath.Join(env.repo, "git", ".config", "git", "config")) + if err != nil { + t.Fatalf("directory source: %v", err) + } + if string(content) != "[user]\nname = Aly\n" { + t.Fatalf("source content = %q", content) + } +} + func testExecAddOrder(t *testing.T) { env := newExecEnv(t) env.initRepository(t) diff --git a/internal/application/add/preflight.go b/internal/application/add/preflight.go index d0d8380..7ee72dc 100644 --- a/internal/application/add/preflight.go +++ b/internal/application/add/preflight.go @@ -1,6 +1,7 @@ package add import ( + "os" "path/filepath" "strings" @@ -19,24 +20,77 @@ type preflightContext struct { } // resolveTargets canonicalizes each raw argument against the working -// directory, requires it to be a strict descendant of home, and rejects -// duplicate canonical paths. It is the canonicalization half of preflight: -// inference needs canonical absolute targets, so resolution runs first. +// directory, requires it to be a strict descendant of home, expands directory +// arguments into descendants, and rejects duplicate canonical paths. It is the +// canonicalization half of preflight: inference needs canonical absolute +// targets, so resolution runs first. func resolveTargets(workingDir, home string, raw []string) ([]string, error) { - canonical := make([]string, 0, len(raw)) - seen := make(map[string]bool, len(raw)) + targets := make([]string, 0, len(raw)) + seenArguments := make(map[string]bool, len(raw)) + seenTargets := make(map[string]bool, len(raw)) for _, argument := range raw { resolved, err := resolveOneTarget(workingDir, home, argument) if err != nil { return nil, err } - if seen[resolved] { + if seenArguments[resolved] { return nil, failure.New(failure.InvalidInput, "add: duplicate target "+argument, nil) } - seen[resolved] = true - canonical = append(canonical, resolved) + seenArguments[resolved] = true + + expanded, err := expandTarget(resolved) + if err != nil { + return nil, err + } + for _, target := range expanded { + if seenTargets[target] { + return nil, failure.New(failure.InvalidInput, "add: duplicate target "+target, nil) + } + seenTargets[target] = true + targets = append(targets, target) + } } - return canonical, nil + return targets, nil +} + +// expandTarget returns target itself unless it is a directory, in which case +// it returns every non-directory descendant. Directories are not repository +// entries, so empty directories are intentionally omitted. +func expandTarget(target string) ([]string, error) { + info, err := os.Lstat(target) + if err != nil || !info.IsDir() { + return []string{target}, nil + } + return expandDirectory(target) +} + +// expandDirectory recursively collects descendants in lexical order. It uses +// Lstat so a symlinked directory remains a leaf for preflight to reject rather +// than redirecting the traversal outside the requested tree. +func expandDirectory(directory string) ([]string, error) { + entries, err := os.ReadDir(directory) + if err != nil { + return nil, failure.New(failure.InvalidInput, "add: read directory "+directory, err) + } + + targets := make([]string, 0, len(entries)) + for _, entry := range entries { + path := filepath.Join(directory, entry.Name()) + info, err := os.Lstat(path) + if err != nil { + return nil, failure.New(failure.InvalidInput, "add: stat target "+path, err) + } + if !info.IsDir() { + targets = append(targets, path) + continue + } + children, err := expandDirectory(path) + if err != nil { + return nil, err + } + targets = append(targets, children...) + } + return targets, nil } // resolveOneTarget resolves one argument to its canonical absolute form and diff --git a/internal/application/add/preflight_test.go b/internal/application/add/preflight_test.go index 44bc8d8..00f7f30 100644 --- a/internal/application/add/preflight_test.go +++ b/internal/application/add/preflight_test.go @@ -15,6 +15,7 @@ func TestAddBatchPreflight(t *testing.T) { run func(*testing.T) }{ {"resolves relative argument", testResolveRelative}, + {"expands directory argument", testResolveDirectory}, {"rejects target outside home", testResolveOutsideHome}, {"rejects home itself", testResolveHomeItself}, {"rejects duplicate canonical path", testResolveDuplicate}, @@ -40,6 +41,18 @@ func testResolveRelative(t *testing.T) { } } +func testResolveDirectory(t *testing.T) { + home := materializeHome(t, directoryEntry) + targets, err := resolveTargets(home, home, []string{"subdir"}) + if err != nil { + t.Fatal(err) + } + want := []string{filepath.Join(home, "subdir", "inside")} + if len(targets) != len(want) || targets[0] != want[0] { + t.Fatalf("targets = %q, want %q", targets, want) + } +} + func testResolveOutsideHome(t *testing.T) { home := materializeHome(t, dotFile) if _, err := resolveTargets(home, home, []string{filepath.Join(home, "..", "escape")}); err == nil { diff --git a/internal/cli/add.go b/internal/cli/add.go index caa8d99..0397da0 100644 --- a/internal/cli/add.go +++ b/internal/cli/add.go @@ -20,7 +20,7 @@ type AddService interface { func newAddCommand(service AddService, runtime Runtime, options *Options) *cobra.Command { command := &cobra.Command{ Use: "add TARGET...", - Short: "Adopt target files into the repository", + Short: "Adopt target files or directories into the repository", Args: cobra.MinimumNArgs(1), RunE: func(command *cobra.Command, args []string) error { request := addRequest(command, addRequestInput{runtime: runtime, options: *options, targets: args}) -- 2.51.2