Skip to content

Commit 16abe9e

Browse files
committed
Skip excluded directory subtrees during copy
1 parent 86563fb commit 16abe9e

3 files changed

Lines changed: 149 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
66

77
## [Unreleased]
88

9+
### Fixed
10+
11+
- Directory copying now skips excluded child subtrees during copy instead of cloning them and deleting them afterward.
12+
913
## [2.7.1] - 2026-04-28
1014

1115
### Added

lib/copy.sh

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,80 @@ $excludes
338338
EOF
339339
}
340340

341+
# Check whether any exclude pattern applies beneath a copied directory.
342+
# Usage: _has_subdir_excludes <dir_path> <excludes>
343+
_has_subdir_excludes() {
344+
local dir_path="$1" excludes="$2"
345+
346+
[ -z "$excludes" ] && return 1
347+
348+
local exclude_pattern
349+
while IFS= read -r exclude_pattern; do
350+
[ -z "$exclude_pattern" ] && continue
351+
352+
if _is_unsafe_path "$exclude_pattern"; then
353+
continue
354+
fi
355+
356+
case "$exclude_pattern" in
357+
*/*)
358+
local pattern_prefix="${exclude_pattern%%/*}"
359+
local pattern_suffix="${exclude_pattern#*/}"
360+
361+
[ -z "$pattern_suffix" ] && continue
362+
363+
case "$exclude_pattern" in
364+
.git|*/.git|.git/*|*/.git/*) continue ;;
365+
esac
366+
367+
# Intentional glob pattern matching for directory prefix
368+
# shellcheck disable=SC2254
369+
case "$dir_path" in
370+
$pattern_prefix) return 0 ;;
371+
esac
372+
;;
373+
esac
374+
done <<EOF
375+
$excludes
376+
EOF
377+
378+
return 1
379+
}
380+
381+
# Copy a directory one direct child at a time, skipping excluded child subtrees
382+
# before they are copied. Deeper excludes are still removed after copy.
383+
# Usage: _selective_copy_dir <dir_path> <dest_parent> <excludes>
384+
_selective_copy_dir() {
385+
local dir_path="$1" dest_parent="$2" excludes="$3"
386+
local dest_dir="$dest_parent/$dir_path"
387+
388+
mkdir -p "$dest_dir" || return 1
389+
390+
local find_results
391+
find_results=$(find "$dir_path" -mindepth 1 -maxdepth 1 2>/dev/null || true)
392+
393+
local child_path child_name child_rel
394+
while IFS= read -r child_path; do
395+
[ -z "$child_path" ] && continue
396+
397+
child_name=$(basename "$child_path")
398+
child_rel="$dir_path/$child_name"
399+
400+
if is_excluded "$child_rel" "$excludes"; then
401+
log_info "Skipped excluded directory $child_rel"
402+
continue
403+
fi
404+
405+
if ! _fast_copy_dir "$child_path" "$dest_dir/"; then
406+
return 1
407+
fi
408+
done <<EOF
409+
$find_results
410+
EOF
411+
412+
_apply_directory_excludes "$dest_parent" "$dir_path" "$excludes"
413+
}
414+
341415
# Copy directories matching patterns (typically git-ignored directories like node_modules)
342416
# Usage: copy_directories src_root dst_root dir_patterns excludes [dry_run]
343417
# dir_patterns: newline-separated directory names to copy (e.g., "node_modules", ".venv")
@@ -403,7 +477,14 @@ copy_directories() {
403477
mkdir -p "$dest_parent"
404478

405479
# Copy directory using CoW when available (preserves symlinks as symlinks)
406-
if _fast_copy_dir "$dir_path" "$dest_parent/"; then
480+
if _has_subdir_excludes "$dir_path" "$excludes"; then
481+
if _selective_copy_dir "$dir_path" "$dest_parent" "$excludes"; then
482+
log_info "Copied directory $dir_path"
483+
copied_count=$((copied_count + 1))
484+
else
485+
log_warn "Failed to copy directory $dir_path"
486+
fi
487+
elif _fast_copy_dir "$dir_path" "$dest_parent/"; then
407488
log_info "Copied directory $dir_path"
408489
copied_count=$((copied_count + 1))
409490
_apply_directory_excludes "$dest_parent" "$dir_path" "$excludes"

tests/copy_safety.bats

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,3 +203,66 @@ teardown() {
203203

204204
[ -e "$dest/node_modules/.git" ]
205205
}
206+
207+
@test "_has_subdir_excludes returns true for child exclude" {
208+
_has_subdir_excludes ".claude" $'.claude/worktrees'
209+
}
210+
211+
@test "_has_subdir_excludes returns false for unrelated exclude" {
212+
! _has_subdir_excludes ".claude" $'node_modules\n.venv'
213+
}
214+
215+
@test "_has_subdir_excludes returns false for exact parent exclude" {
216+
! _has_subdir_excludes ".claude" ".claude"
217+
}
218+
219+
@test "_has_subdir_excludes supports glob prefixes" {
220+
_has_subdir_excludes ".claude" $'*/worktrees'
221+
}
222+
223+
@test "_selective_copy_dir skips excluded direct child" {
224+
_test_tmpdir=$(mktemp -d)
225+
local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst"
226+
mkdir -p "$src/.claude/settings" "$src/.claude/worktrees" "$dst"
227+
echo "keep" > "$src/.claude/settings/config.json"
228+
echo "skip" > "$src/.claude/worktrees/session.json"
229+
230+
cd "$src"
231+
_selective_copy_dir ".claude" "$dst" $'.claude/worktrees'
232+
233+
[ -f "$dst/.claude/settings/config.json" ]
234+
[ ! -e "$dst/.claude/worktrees" ]
235+
}
236+
237+
@test "_selective_copy_dir still applies deeper excludes after copy" {
238+
_test_tmpdir=$(mktemp -d)
239+
local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst"
240+
mkdir -p "$src/.claude/worktrees/cache" "$src/.claude/worktrees/keep" "$dst"
241+
echo "skip" > "$src/.claude/worktrees/cache/blob"
242+
echo "keep" > "$src/.claude/worktrees/keep/session.json"
243+
244+
cd "$src"
245+
_selective_copy_dir ".claude" "$dst" $'.claude/worktrees/cache'
246+
247+
[ ! -e "$dst/.claude/worktrees/cache" ]
248+
[ -f "$dst/.claude/worktrees/keep/session.json" ]
249+
}
250+
251+
@test "copy_directories does not copy excluded direct child subtree" {
252+
_test_tmpdir=$(mktemp -d)
253+
local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst" copy_log="$_test_tmpdir/copy.log"
254+
mkdir -p "$src/.claude/settings" "$src/.claude/worktrees" "$dst"
255+
echo "keep" > "$src/.claude/settings/config.json"
256+
echo "skip" > "$src/.claude/worktrees/session.json"
257+
258+
_fast_copy_dir() {
259+
printf '%s\n' "$1" >> "$copy_log"
260+
cp -RP "$1" "$2"
261+
}
262+
263+
copy_directories "$src" "$dst" ".claude" $'.claude/worktrees'
264+
265+
[ -f "$dst/.claude/settings/config.json" ]
266+
[ ! -e "$dst/.claude/worktrees" ]
267+
! grep -qx ".claude/worktrees" "$copy_log"
268+
}

0 commit comments

Comments
 (0)