From 971269c971fb7f2df96c2c69262041564c9002a7 Mon Sep 17 00:00:00 2001 From: Tom Elizaga Date: Tue, 28 Apr 2026 13:29:02 -0700 Subject: [PATCH] Skip excluded directory subtrees during copy --- CHANGELOG.md | 4 + lib/copy.sh | 211 ++++++++++++++++++++++++++++++----------- tests/copy_safety.bats | 139 +++++++++++++++++++++++++++ 3 files changed, 298 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ecc8fa0..1e1a2d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ## [Unreleased] +### Fixed + +- Directory copying now skips excluded child subtrees during copy instead of cloning them and deleting them afterward. + ## [2.7.1] - 2026-04-28 ### Added diff --git a/lib/copy.sh b/lib/copy.sh index ba60f54..0b7f7cb 100644 --- a/lib/copy.sh +++ b/lib/copy.sh @@ -259,6 +259,54 @@ EOF return 0 } +# Return the portion of an exclude pattern that is relative to a copied directory. +# Supports nested include paths and glob prefixes like "vendor/*/cache" or "*/.cache". +# Usage: _directory_exclude_suffix +_directory_exclude_suffix() { + local dir_path="$1" exclude_pattern="$2" + + case "$exclude_pattern" in + */*) ;; + *) return 1 ;; + esac + + case "$exclude_pattern" in + .git|*/.git|.git/*|*/.git/*) return 1 ;; + esac + + local prefix suffix matched_suffix + prefix="${exclude_pattern%%/*}" + suffix="${exclude_pattern#*/}" + matched_suffix="" + + while :; do + [ -z "$suffix" ] && break + + # Intentional glob pattern matching for directory prefix + # shellcheck disable=SC2254 + case "$dir_path" in + $prefix) + matched_suffix="$suffix" + ;; + esac + + case "$suffix" in + */*) + prefix="$prefix/${suffix%%/*}" + suffix="${suffix#*/}" + ;; + *) break ;; + esac + done + + if [ -n "$matched_suffix" ]; then + printf '%s\n' "$matched_suffix" + return 0 + fi + + return 1 +} + # Remove excluded subdirectories from a copied directory. # Supports patterns like "node_modules/.cache", "*/.cache", "node_modules/*", "*/.*" # Usage: _apply_directory_excludes @@ -276,68 +324,112 @@ _apply_directory_excludes() { continue fi - # Only process patterns with directory separators - case "$exclude_pattern" in - */*) - local pattern_prefix="${exclude_pattern%%/*}" - local pattern_suffix="${exclude_pattern#*/}" - - # Reject empty suffixes and protect Git metadata from removal - case "$pattern_suffix" in - "") - log_warn "Skipping overly broad exclude suffix: $exclude_pattern" - continue - ;; - esac + local pattern_suffix + pattern_suffix=$(_directory_exclude_suffix "$dir_path" "$exclude_pattern") || true + if [ -z "$pattern_suffix" ]; then + case "$exclude_pattern" in + */) + log_warn "Skipping overly broad exclude suffix: $exclude_pattern" + ;; + .git|*/.git|.git/*|*/.git/*) + log_warn "Skipping exclude pattern targeting .git metadata: $exclude_pattern" + ;; + esac + continue + fi - case "$exclude_pattern" in - .git|*/.git|.git/*|*/.git/*) - log_warn "Skipping exclude pattern targeting .git metadata: $exclude_pattern" - continue - ;; + local exclude_old_pwd + exclude_old_pwd=$(pwd) + cd "$dest_parent/$dir_path" 2>/dev/null || continue + + local exclude_shopt_save + exclude_shopt_save="$(shopt -p dotglob 2>/dev/null || true)" + shopt -s dotglob 2>/dev/null || true + + local removed_any=0 + # shellcheck disable=SC2086 + for matched_path in $pattern_suffix; do + if [ -e "$matched_path" ]; then + # Never remove .git directory via exclude patterns + case "$matched_path" in + .git|.git/*) continue ;; esac + if rm -rf "$matched_path" 2>/dev/null; then + removed_any=1 + fi + fi + done - # Intentional glob pattern matching for directory prefix - # shellcheck disable=SC2254 - case "$dir_path" in - $pattern_prefix) - local exclude_old_pwd - exclude_old_pwd=$(pwd) - cd "$dest_parent/$dir_path" 2>/dev/null || continue - - local exclude_shopt_save - exclude_shopt_save="$(shopt -p dotglob 2>/dev/null || true)" - shopt -s dotglob 2>/dev/null || true - - local removed_any=0 - # shellcheck disable=SC2086 - for matched_path in $pattern_suffix; do - if [ -e "$matched_path" ]; then - # Never remove .git directory via exclude patterns - case "$matched_path" in - .git|.git/*) continue ;; - esac - if rm -rf "$matched_path" 2>/dev/null; then - removed_any=1 - fi - fi - done - - eval "$exclude_shopt_save" 2>/dev/null || true - cd "$exclude_old_pwd" || true - - if [ "$removed_any" -eq 1 ]; then - log_info "Excluded subdirectory $exclude_pattern" - fi - ;; - esac - ;; - esac + eval "$exclude_shopt_save" 2>/dev/null || true + cd "$exclude_old_pwd" || true + + if [ "$removed_any" -eq 1 ]; then + log_info "Excluded subdirectory $exclude_pattern" + fi done < +_has_subdir_excludes() { + local dir_path="$1" excludes="$2" + + [ -z "$excludes" ] && return 1 + + local exclude_pattern + while IFS= read -r exclude_pattern; do + [ -z "$exclude_pattern" ] && continue + + if _is_unsafe_path "$exclude_pattern"; then + continue + fi + + if _directory_exclude_suffix "$dir_path" "$exclude_pattern" >/dev/null; then + return 0 + fi + done < +_selective_copy_dir() { + local dir_path="$1" dst_root="$2" excludes="$3" + local dest_dir="$dst_root/$dir_path" + + mkdir -p "$dest_dir" || return 1 + + local find_results + find_results=$(find "$dir_path" -mindepth 1 -maxdepth 1 2>/dev/null || true) + + local child_path child_name child_rel + while IFS= read -r child_path; do + [ -z "$child_path" ] && continue + + child_name=$(basename "$child_path") + child_rel="$dir_path/$child_name" + + if is_excluded "$child_rel" "$excludes" || is_excluded "$child_rel/" "$excludes"; then + log_info "Skipped excluded directory $child_rel" + continue + fi + + if ! _fast_copy_dir "$child_path" "$dest_dir/"; then + return 1 + fi + done < "$src/.claude/settings/config.json" + echo "skip" > "$src/.claude/worktrees/session.json" + + cd "$src" + _selective_copy_dir ".claude" "$dst" $'.claude/worktrees' + + [ -f "$dst/.claude/settings/config.json" ] + [ ! -e "$dst/.claude/worktrees" ] +} + +@test "_selective_copy_dir skips trailing-slash excluded direct child" { + _test_tmpdir=$(mktemp -d) + local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst" + mkdir -p "$src/.claude/settings" "$src/.claude/worktrees" "$dst" + echo "keep" > "$src/.claude/settings/config.json" + echo "skip" > "$src/.claude/worktrees/session.json" + + cd "$src" + _selective_copy_dir ".claude" "$dst" $'.claude/worktrees/' + + [ -f "$dst/.claude/settings/config.json" ] + [ ! -e "$dst/.claude/worktrees" ] +} + +@test "_selective_copy_dir still applies deeper excludes after copy" { + _test_tmpdir=$(mktemp -d) + local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst" + mkdir -p "$src/.claude/worktrees/cache" "$src/.claude/worktrees/keep" "$dst" + echo "skip" > "$src/.claude/worktrees/cache/blob" + echo "keep" > "$src/.claude/worktrees/keep/session.json" + + cd "$src" + _selective_copy_dir ".claude" "$dst" $'.claude/worktrees/cache' + + [ ! -e "$dst/.claude/worktrees/cache" ] + [ -f "$dst/.claude/worktrees/keep/session.json" ] +} + +@test "copy_directories does not copy excluded direct child subtree" { + _test_tmpdir=$(mktemp -d) + local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst" copy_log="$_test_tmpdir/copy.log" + mkdir -p "$src/.claude/settings" "$src/.claude/worktrees" "$dst" + echo "keep" > "$src/.claude/settings/config.json" + echo "skip" > "$src/.claude/worktrees/session.json" + + _fast_copy_dir() { + printf '%s\n' "$1" >> "$copy_log" + cp -RP "$1" "$2" + } + + copy_directories "$src" "$dst" ".claude" $'.claude/worktrees' + + [ -f "$dst/.claude/settings/config.json" ] + [ ! -e "$dst/.claude/worktrees" ] + ! grep -qx ".claude/worktrees" "$copy_log" +} + +@test "copy_directories does not copy excluded child under nested include path" { + _test_tmpdir=$(mktemp -d) + local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst" copy_log="$_test_tmpdir/copy.log" + mkdir -p "$src/vendor/bundle/cache" "$src/vendor/bundle/gems" "$dst" + echo "skip" > "$src/vendor/bundle/cache/blob" + echo "keep" > "$src/vendor/bundle/gems/spec" + + _fast_copy_dir() { + printf '%s\n' "$1" >> "$copy_log" + cp -RP "$1" "$2" + } + + copy_directories "$src" "$dst" "vendor/bundle" $'vendor/bundle/cache' + + [ -f "$dst/vendor/bundle/gems/spec" ] + [ ! -e "$dst/vendor/bundle/cache" ] + ! grep -qx "vendor/bundle/cache" "$copy_log" +}