From 98cd434566e9355605f09cc36c4e4ca3a342a3c5 Mon Sep 17 00:00:00 2001 From: Tom Elizaga Date: Wed, 12 Aug 2026 00:46:06 -0700 Subject: [PATCH 1/3] fix(copy): bound includeDirs discovery --- README.md | 1 + docs/configuration.md | 7 ++ lib/commands/copy.sh | 11 ++- lib/commands/help.sh | 1 + lib/copy.sh | 169 +++++++++++++++++++++++++++-------------- tests/cmd_copy.bats | 19 +++++ tests/copy_safety.bats | 39 ++++++++++ 7 files changed, 189 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 03adf33..b1d86d6 100644 --- a/README.md +++ b/README.md @@ -405,6 +405,7 @@ git gtr config set gtr.ui.color never include = **/.env.example exclude = **/.env includeDirs = node_modules + includeDirs = packages/*/generated excludeDirs = node_modules/.cache [hooks] diff --git a/docs/configuration.md b/docs/configuration.md index 25c81b5..80adbb2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -263,7 +263,14 @@ Copy entire directories (like `node_modules`, `.venv`, `vendor`) to avoid reinst git gtr config add gtr.copy.includeDirs "node_modules" git gtr config add gtr.copy.includeDirs ".venv" git gtr config add gtr.copy.includeDirs "vendor" +git gtr config add gtr.copy.includeDirs "packages/*/generated" +``` + +Directory include patterns are relative to the repository root. Literal paths +are checked directly, `*` and `?` match within the pattern's explicit depth, +and `**` enables recursive matching when a full-tree search is intentional. +```bash # Exclude specific nested directories (supports glob patterns) git gtr config add gtr.copy.excludeDirs "node_modules/.cache" # Exclude exact path git gtr config add gtr.copy.excludeDirs "node_modules/.npm" # Exclude npm cache (may contain tokens) diff --git a/lib/commands/copy.sh b/lib/commands/copy.sh index 6b1c6f5..4e49da7 100644 --- a/lib/commands/copy.sh +++ b/lib/commands/copy.sh @@ -59,6 +59,13 @@ cmd_copy() { exit 1 fi + # Directory discovery depends only on the source. Resolve it once so --all + # does not repeat the same filesystem scan for every target worktree. + local resolved_dirs="" + if [ -n "$dir_includes" ]; then + resolved_dirs=$(_resolve_directory_patterns "$src_path" "$dir_includes") || exit 1 + fi + # Build target list for --all mode if [ "$all_mode" -eq 1 ]; then local all_branches @@ -88,11 +95,11 @@ cmd_copy() { if [ "$dry_run" -eq 1 ]; then log_step "[dry-run] Would copy to: $dst_branch" [ -n "$patterns" ] && copy_patterns "$src_path" "$dst_path" "$patterns" "$excludes" "true" "true" - [ -n "$dir_includes" ] && copy_directories "$src_path" "$dst_path" "$dir_includes" "$dir_excludes" "true" + [ -n "$resolved_dirs" ] && _copy_resolved_directories "$src_path" "$dst_path" "$resolved_dirs" "$dir_excludes" "true" else log_step "Copying to: $dst_branch" [ -n "$patterns" ] && copy_patterns "$src_path" "$dst_path" "$patterns" "$excludes" "true" - [ -n "$dir_includes" ] && copy_directories "$src_path" "$dst_path" "$dir_includes" "$dir_excludes" + [ -n "$resolved_dirs" ] && _copy_resolved_directories "$src_path" "$dst_path" "$resolved_dirs" "$dir_excludes" fi copied_any=1 done diff --git a/lib/commands/help.sh b/lib/commands/help.sh index 9af8711..e56d098 100644 --- a/lib/commands/help.sh +++ b/lib/commands/help.sh @@ -699,6 +699,7 @@ CONFIGURATION OPTIONS: gtr.copy.exclude Files to exclude (multi-valued) gtr.copy.includeDirs Directories to copy (multi-valued) Example: node_modules, .venv, vendor + Supports repo-relative globs; ** matches recursively WARNING: May include sensitive files! Use gtr.copy.excludeDirs to exclude them. gtr.copy.excludeDirs Directories to exclude (multi-valued) diff --git a/lib/copy.sh b/lib/copy.sh index 0b7f7cb..55e3a20 100644 --- a/lib/copy.sh +++ b/lib/copy.sh @@ -430,29 +430,37 @@ EOF _apply_directory_excludes "$dst_root" "$dir_path" "$excludes" } -# Copy directories matching patterns (typically git-ignored directories like node_modules) -# Usage: copy_directories src_root dst_root dir_patterns excludes [dry_run] -# dir_patterns: newline-separated directory names to copy (e.g., "node_modules", ".venv") -# excludes: newline-separated directory patterns to exclude (supports globs like "node_modules/.cache") -# WARNING: This copies entire directories including potentially sensitive files. -# Use gtr.copy.excludeDirs to exclude sensitive directories. -copy_directories() { - local src_root="$1" - local dst_root="$2" - local dir_patterns="$3" - local excludes="$4" - local dry_run="${5:-false}" +# Return the number of path components in a directory pattern. +# Usage: _directory_pattern_depth +_directory_pattern_depth() { + local pattern="$1" + local depth=1 - if [ -z "$dir_patterns" ]; then - return 0 - fi + while :; do + case "$pattern" in + */*) + depth=$((depth + 1)) + pattern="${pattern#*/}" + ;; + *) break ;; + esac + done + + printf '%s\n' "$depth" +} +# Resolve directory patterns once relative to a source root. +# Literal paths use a direct filesystem check. Single-star glob patterns are +# bounded to their explicit component depth; only ** patterns recurse freely. +# Usage: _resolve_directory_patterns +_resolve_directory_patterns() { + local src_root="$1" + local dir_patterns="$2" local old_pwd old_pwd=$(pwd) cd "$src_root" || return 1 - local copied_count=0 - + local pattern while IFS= read -r pattern; do [ -z "$pattern" ] && continue @@ -461,59 +469,90 @@ copy_directories() { continue fi - # Find directories matching the pattern - # Use -path for patterns with slashes (e.g., vendor/bundle), -name for basenames - # Note: case inside $() inside heredocs breaks Bash 3.2, so compute first - # Use -maxdepth 1 for simple basenames to avoid scanning entire repo (e.g., node_modules) - # Falls back to recursive search if shallow search finds nothing - local find_results + local find_results="" case "$pattern" in - */*) find_results=$(find . -type d -path "./$pattern" 2>/dev/null || true) ;; - *) find_results=$(find . -maxdepth 1 -type d -name "$pattern" 2>/dev/null || true) - if [ -z "$find_results" ]; then - find_results=$(find . -type d -name "$pattern" 2>/dev/null || true) - fi ;; + *[\*\?\[]*) + case "$pattern" in + *'**'*) + find_results=$(find . -type d -path "./$pattern" 2>/dev/null || true) + ;; + *) + local max_depth + max_depth=$(_directory_pattern_depth "$pattern") + find_results=$(find . -maxdepth "$max_depth" -type d -path "./$pattern" 2>/dev/null || true) + ;; + esac + ;; + *) + if [ -d "$pattern" ]; then + find_results="./$pattern" + fi + ;; esac - while IFS= read -r dir_path; do - [ -z "$dir_path" ] && continue - dir_path="${dir_path#./}" + [ -n "$find_results" ] && printf '%s\n' "$find_results" + done < "$TEST_REPO/.zed/settings.json" + git config --add gtr.copy.includeDirs ".zed" + local resolve_log="$BATS_TEST_TMPDIR/resolve.log" + + _resolve_directory_patterns() { + printf 'called\n' >> "$resolve_log" + printf './.zed\n' + } + + run cmd_copy --all + [ "$status" -eq 0 ] + [ "$(wc -l < "$resolve_log" | tr -d ' ')" -eq 1 ] + [ -f "$TEST_WORKTREES_DIR/copy-target/.zed/settings.json" ] + [ -f "$TEST_WORKTREES_DIR/copy-target-2/.zed/settings.json" ] +} + @test "cmd_copy --from copies configured includeDirs from source worktree" { create_test_worktree "copy-source" mkdir -p "$TEST_WORKTREES_DIR/copy-source/.idea" diff --git a/tests/copy_safety.bats b/tests/copy_safety.bats index 9f1f28b..e422146 100644 --- a/tests/copy_safety.bats +++ b/tests/copy_safety.bats @@ -342,3 +342,42 @@ teardown() { [ ! -e "$dst/vendor/bundle/cache" ] ! grep -qx "vendor/bundle/cache" "$copy_log" } + +@test "literal directory patterns do not invoke find" { + _test_tmpdir=$(mktemp -d) + local src="$_test_tmpdir/src" find_log="$_test_tmpdir/find.log" + mkdir -p "$src/build/generated" + + find() { + printf 'called\n' >> "$find_log" + return 1 + } + + local resolved + resolved=$(_resolve_directory_patterns "$src" $'build/generated\nmissing') + + [ "$resolved" = "./build/generated" ] + [ ! -e "$find_log" ] +} + +@test "single-star directory patterns are bounded to their explicit depth" { + _test_tmpdir=$(mktemp -d) + local src="$_test_tmpdir/src" + mkdir -p "$src/one/cache" "$src/one/deep/cache" + + local resolved + resolved=$(_resolve_directory_patterns "$src" "*/cache") + + [ "$resolved" = "./one/cache" ] +} + +@test "double-star directory patterns retain recursive matching" { + _test_tmpdir=$(mktemp -d) + local src="$_test_tmpdir/src" + mkdir -p "$src/one/cache" "$src/one/deep/cache" + + local resolved + resolved=$(_resolve_directory_patterns "$src" "**/cache") + + [ "$resolved" = $'./one/cache\n./one/deep/cache' ] +} From 4a11d4a4663a0a56e8795237d85e51c29cf0453e Mon Sep 17 00:00:00 2001 From: Tom Elizaga Date: Wed, 12 Aug 2026 00:49:14 -0700 Subject: [PATCH 2/3] test(copy): allow filesystem result order --- tests/copy_safety.bats | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/copy_safety.bats b/tests/copy_safety.bats index e422146..ec42b71 100644 --- a/tests/copy_safety.bats +++ b/tests/copy_safety.bats @@ -379,5 +379,7 @@ teardown() { local resolved resolved=$(_resolve_directory_patterns "$src" "**/cache") - [ "$resolved" = $'./one/cache\n./one/deep/cache' ] + [ "$(printf '%s\n' "$resolved" | grep -c '^./one/cache$')" -eq 1 ] + [ "$(printf '%s\n' "$resolved" | grep -c '^./one/deep/cache$')" -eq 1 ] + [ "$(printf '%s\n' "$resolved" | wc -l | tr -d ' ')" -eq 2 ] } From 587bebbbc2356d19f0388c40c93bdf0cdf52199f Mon Sep 17 00:00:00 2001 From: Tom Elizaga Date: Wed, 12 Aug 2026 01:04:35 -0700 Subject: [PATCH 3/3] fix(copy): preserve basename fallback --- docs/configuration.md | 4 ++- lib/copy.sh | 40 +++++++++++++++++++++++++---- tests/copy_safety.bats | 57 +++++++++++++++++++++++++++++++++++++++--- 3 files changed, 92 insertions(+), 9 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 80adbb2..847bd27 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -268,7 +268,9 @@ git gtr config add gtr.copy.includeDirs "packages/*/generated" Directory include patterns are relative to the repository root. Literal paths are checked directly, `*` and `?` match within the pattern's explicit depth, -and `**` enables recursive matching when a full-tree search is intentional. +and `**` enables recursive matching when a full-tree search is intentional. For +backward compatibility, a bare basename that has no root-level match falls back +to a recursive search. Prefer an explicit nested path to avoid that scan. ```bash # Exclude specific nested directories (supports glob patterns) diff --git a/lib/copy.sh b/lib/copy.sh index 55e3a20..1569821 100644 --- a/lib/copy.sh +++ b/lib/copy.sh @@ -450,8 +450,9 @@ _directory_pattern_depth() { } # Resolve directory patterns once relative to a source root. -# Literal paths use a direct filesystem check. Single-star glob patterns are -# bounded to their explicit component depth; only ** patterns recurse freely. +# Literal paths use a direct filesystem check. Missing bare basenames retain +# the legacy recursive fallback, batched into one scan. Single-star path globs +# are bounded to their explicit component depth; only ** patterns recurse. # Usage: _resolve_directory_patterns _resolve_directory_patterns() { local src_root="$1" @@ -461,6 +462,9 @@ _resolve_directory_patterns() { cd "$src_root" || return 1 local pattern + local bounded_max_depth=0 + local -a bounded_expr=() + local -a recursive_expr=() while IFS= read -r pattern; do [ -z "$pattern" ] && continue @@ -474,18 +478,36 @@ _resolve_directory_patterns() { *[\*\?\[]*) case "$pattern" in *'**'*) - find_results=$(find . -type d -path "./$pattern" 2>/dev/null || true) + [ "${#recursive_expr[@]}" -gt 0 ] && recursive_expr+=("-o") + recursive_expr+=("-path" "./$pattern") ;; - *) + */*) local max_depth max_depth=$(_directory_pattern_depth "$pattern") - find_results=$(find . -maxdepth "$max_depth" -type d -path "./$pattern" 2>/dev/null || true) + [ "$max_depth" -gt "$bounded_max_depth" ] && bounded_max_depth="$max_depth" + [ "${#bounded_expr[@]}" -gt 0 ] && bounded_expr+=("-o") + bounded_expr+=("-path" "./$pattern") + ;; + *) + find_results=$(find . -maxdepth 1 -type d -name "$pattern" 2>/dev/null || true) + if [ -z "$find_results" ]; then + [ "${#recursive_expr[@]}" -gt 0 ] && recursive_expr+=("-o") + recursive_expr+=("-name" "$pattern") + fi ;; esac ;; *) if [ -d "$pattern" ]; then find_results="./$pattern" + else + case "$pattern" in + */*) ;; + *) + [ "${#recursive_expr[@]}" -gt 0 ] && recursive_expr+=("-o") + recursive_expr+=("-name" "$pattern") + ;; + esac fi ;; esac @@ -495,6 +517,14 @@ _resolve_directory_patterns() { $dir_patterns EOF + if [ "${#bounded_expr[@]}" -gt 0 ]; then + find . -maxdepth "$bounded_max_depth" -type d \( "${bounded_expr[@]}" \) 2>/dev/null || true + fi + + if [ "${#recursive_expr[@]}" -gt 0 ]; then + find . -type d \( "${recursive_expr[@]}" \) 2>/dev/null || true + fi + cd "$old_pwd" || return 1 } diff --git a/tests/copy_safety.bats b/tests/copy_safety.bats index ec42b71..aebdcf5 100644 --- a/tests/copy_safety.bats +++ b/tests/copy_safety.bats @@ -346,7 +346,7 @@ teardown() { @test "literal directory patterns do not invoke find" { _test_tmpdir=$(mktemp -d) local src="$_test_tmpdir/src" find_log="$_test_tmpdir/find.log" - mkdir -p "$src/build/generated" + mkdir -p "$src/build/generated" "$src/node_modules" find() { printf 'called\n' >> "$find_log" @@ -354,12 +354,63 @@ teardown() { } local resolved - resolved=$(_resolve_directory_patterns "$src" $'build/generated\nmissing') + resolved=$(_resolve_directory_patterns "$src" $'build/generated\nnode_modules\nbuild/missing') - [ "$resolved" = "./build/generated" ] + [ "$resolved" = $'./build/generated\n./node_modules' ] [ ! -e "$find_log" ] } +@test "missing basename retains recursive matching" { + _test_tmpdir=$(mktemp -d) + local src="$_test_tmpdir/src" + mkdir -p "$src/packages/a/vendor" + + local resolved + resolved=$(_resolve_directory_patterns "$src" "vendor") + + [ "$resolved" = "./packages/a/vendor" ] +} + +@test "copy_directories copies nested basename after root miss" { + _test_tmpdir=$(mktemp -d) + local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst" + mkdir -p "$src/packages/a/vendor" "$dst" + echo "nested" > "$src/packages/a/vendor/marker" + + copy_directories "$src" "$dst" "vendor" "" + + [ -f "$dst/packages/a/vendor/marker" ] +} + +@test "missing basename patterns share one recursive scan" { + _test_tmpdir=$(mktemp -d) + local src="$_test_tmpdir/src" find_log="$_test_tmpdir/find.log" + mkdir -p "$src/packages/a/vendor" "$src/packages/b/cache" + + find() { + printf 'called\n' >> "$find_log" + command find "$@" + } + + local resolved + resolved=$(_resolve_directory_patterns "$src" $'vendor\ncache') + + [ "$(wc -l < "$find_log" | tr -d ' ')" -eq 1 ] + [ "$(printf '%s\n' "$resolved" | grep -c '^./packages/a/vendor$')" -eq 1 ] + [ "$(printf '%s\n' "$resolved" | grep -c '^./packages/b/cache$')" -eq 1 ] +} + +@test "missing basename glob retains recursive matching" { + _test_tmpdir=$(mktemp -d) + local src="$_test_tmpdir/src" + mkdir -p "$src/packages/a/vendor-cache" + + local resolved + resolved=$(_resolve_directory_patterns "$src" "vendor-*") + + [ "$resolved" = "./packages/a/vendor-cache" ] +} + @test "single-star directory patterns are bounded to their explicit depth" { _test_tmpdir=$(mktemp -d) local src="$_test_tmpdir/src"