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..847bd27 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -263,7 +263,16 @@ 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. 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) 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..1569821 100644 --- a/lib/copy.sh +++ b/lib/copy.sh @@ -430,29 +430,41 @@ 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. 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" + local dir_patterns="$2" local old_pwd old_pwd=$(pwd) cd "$src_root" || return 1 - local copied_count=0 - + local pattern + local bounded_max_depth=0 + local -a bounded_expr=() + local -a recursive_expr=() while IFS= read -r pattern; do [ -z "$pattern" ] && continue @@ -461,59 +473,116 @@ 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 + *'**'*) + [ "${#recursive_expr[@]}" -gt 0 ] && recursive_expr+=("-o") + recursive_expr+=("-path" "./$pattern") + ;; + */*) + local max_depth + max_depth=$(_directory_pattern_depth "$pattern") + [ "$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 - while IFS= read -r dir_path; do - [ -z "$dir_path" ] && continue - dir_path="${dir_path#./}" + [ -n "$find_results" ] && printf '%s\n' "$find_results" + done </dev/null || true + fi - local dest_dir="$dst_root/$dir_path" - local dest_parent - dest_parent=$(dirname "$dest_dir") + if [ "${#recursive_expr[@]}" -gt 0 ]; then + find . -type d \( "${recursive_expr[@]}" \) 2>/dev/null || true + fi - if [ "$dry_run" = "true" ]; then - log_info "[dry-run] Would copy directory $dir_path" - copied_count=$((copied_count + 1)) - continue - fi + cd "$old_pwd" || return 1 +} - mkdir -p "$dest_parent" +# Copy already-resolved directories from a source root. +# Usage: _copy_resolved_directories src_root dst_root resolved_dirs excludes [dry_run] +_copy_resolved_directories() { + local src_root="$1" + local dst_root="$2" + local resolved_dirs="$3" + local excludes="$4" + local dry_run="${5:-false}" - # Copy directory using CoW when available (preserves symlinks as symlinks) - if _has_subdir_excludes "$dir_path" "$excludes"; then - if _selective_copy_dir "$dir_path" "$dst_root" "$excludes"; then - log_info "Copied directory $dir_path" - copied_count=$((copied_count + 1)) - else - log_warn "Failed to copy directory $dir_path" - fi - elif _fast_copy_dir "$dir_path" "$dest_parent/"; then + if [ -z "$resolved_dirs" ]; then + return 0 + fi + + local old_pwd + old_pwd=$(pwd) + cd "$src_root" || return 1 + + local copied_count=0 + local dir_path + while IFS= read -r dir_path; do + [ -z "$dir_path" ] && continue + dir_path="${dir_path#./}" + + is_excluded "$dir_path" "$excludes" && continue + [ ! -d "$dir_path" ] && continue + + local dest_dir="$dst_root/$dir_path" + local dest_parent + dest_parent=$(dirname "$dest_dir") + + if [ "$dry_run" = "true" ]; then + log_info "[dry-run] Would copy directory $dir_path" + copied_count=$((copied_count + 1)) + continue + fi + + mkdir -p "$dest_parent" + + # Copy directory using CoW when available (preserves symlinks as symlinks) + if _has_subdir_excludes "$dir_path" "$excludes"; then + if _selective_copy_dir "$dir_path" "$dst_root" "$excludes"; then log_info "Copied directory $dir_path" copied_count=$((copied_count + 1)) - _apply_directory_excludes "$dst_root" "$dir_path" "$excludes" else log_warn "Failed to copy directory $dir_path" fi - 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..aebdcf5 100644 --- a/tests/copy_safety.bats +++ b/tests/copy_safety.bats @@ -342,3 +342,95 @@ 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" "$src/node_modules" + + find() { + printf 'called\n' >> "$find_log" + return 1 + } + + local resolved + resolved=$(_resolve_directory_patterns "$src" $'build/generated\nnode_modules\nbuild/missing') + + [ "$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" + 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") + + [ "$(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 ] +}