Skip to content

Commit d14582f

Browse files
authored
Skip excluded directory subtrees during copy (#176)
1 parent 86563fb commit d14582f

3 files changed

Lines changed: 298 additions & 56 deletions

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: 155 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,54 @@ EOF
259259
return 0
260260
}
261261

262+
# Return the portion of an exclude pattern that is relative to a copied directory.
263+
# Supports nested include paths and glob prefixes like "vendor/*/cache" or "*/.cache".
264+
# Usage: _directory_exclude_suffix <dir_path> <exclude_pattern>
265+
_directory_exclude_suffix() {
266+
local dir_path="$1" exclude_pattern="$2"
267+
268+
case "$exclude_pattern" in
269+
*/*) ;;
270+
*) return 1 ;;
271+
esac
272+
273+
case "$exclude_pattern" in
274+
.git|*/.git|.git/*|*/.git/*) return 1 ;;
275+
esac
276+
277+
local prefix suffix matched_suffix
278+
prefix="${exclude_pattern%%/*}"
279+
suffix="${exclude_pattern#*/}"
280+
matched_suffix=""
281+
282+
while :; do
283+
[ -z "$suffix" ] && break
284+
285+
# Intentional glob pattern matching for directory prefix
286+
# shellcheck disable=SC2254
287+
case "$dir_path" in
288+
$prefix)
289+
matched_suffix="$suffix"
290+
;;
291+
esac
292+
293+
case "$suffix" in
294+
*/*)
295+
prefix="$prefix/${suffix%%/*}"
296+
suffix="${suffix#*/}"
297+
;;
298+
*) break ;;
299+
esac
300+
done
301+
302+
if [ -n "$matched_suffix" ]; then
303+
printf '%s\n' "$matched_suffix"
304+
return 0
305+
fi
306+
307+
return 1
308+
}
309+
262310
# Remove excluded subdirectories from a copied directory.
263311
# Supports patterns like "node_modules/.cache", "*/.cache", "node_modules/*", "*/.*"
264312
# Usage: _apply_directory_excludes <dest_parent> <dir_path> <excludes>
@@ -276,68 +324,112 @@ _apply_directory_excludes() {
276324
continue
277325
fi
278326

279-
# Only process patterns with directory separators
280-
case "$exclude_pattern" in
281-
*/*)
282-
local pattern_prefix="${exclude_pattern%%/*}"
283-
local pattern_suffix="${exclude_pattern#*/}"
284-
285-
# Reject empty suffixes and protect Git metadata from removal
286-
case "$pattern_suffix" in
287-
"")
288-
log_warn "Skipping overly broad exclude suffix: $exclude_pattern"
289-
continue
290-
;;
291-
esac
327+
local pattern_suffix
328+
pattern_suffix=$(_directory_exclude_suffix "$dir_path" "$exclude_pattern") || true
329+
if [ -z "$pattern_suffix" ]; then
330+
case "$exclude_pattern" in
331+
*/)
332+
log_warn "Skipping overly broad exclude suffix: $exclude_pattern"
333+
;;
334+
.git|*/.git|.git/*|*/.git/*)
335+
log_warn "Skipping exclude pattern targeting .git metadata: $exclude_pattern"
336+
;;
337+
esac
338+
continue
339+
fi
292340

293-
case "$exclude_pattern" in
294-
.git|*/.git|.git/*|*/.git/*)
295-
log_warn "Skipping exclude pattern targeting .git metadata: $exclude_pattern"
296-
continue
297-
;;
341+
local exclude_old_pwd
342+
exclude_old_pwd=$(pwd)
343+
cd "$dest_parent/$dir_path" 2>/dev/null || continue
344+
345+
local exclude_shopt_save
346+
exclude_shopt_save="$(shopt -p dotglob 2>/dev/null || true)"
347+
shopt -s dotglob 2>/dev/null || true
348+
349+
local removed_any=0
350+
# shellcheck disable=SC2086
351+
for matched_path in $pattern_suffix; do
352+
if [ -e "$matched_path" ]; then
353+
# Never remove .git directory via exclude patterns
354+
case "$matched_path" in
355+
.git|.git/*) continue ;;
298356
esac
357+
if rm -rf "$matched_path" 2>/dev/null; then
358+
removed_any=1
359+
fi
360+
fi
361+
done
299362

300-
# Intentional glob pattern matching for directory prefix
301-
# shellcheck disable=SC2254
302-
case "$dir_path" in
303-
$pattern_prefix)
304-
local exclude_old_pwd
305-
exclude_old_pwd=$(pwd)
306-
cd "$dest_parent/$dir_path" 2>/dev/null || continue
307-
308-
local exclude_shopt_save
309-
exclude_shopt_save="$(shopt -p dotglob 2>/dev/null || true)"
310-
shopt -s dotglob 2>/dev/null || true
311-
312-
local removed_any=0
313-
# shellcheck disable=SC2086
314-
for matched_path in $pattern_suffix; do
315-
if [ -e "$matched_path" ]; then
316-
# Never remove .git directory via exclude patterns
317-
case "$matched_path" in
318-
.git|.git/*) continue ;;
319-
esac
320-
if rm -rf "$matched_path" 2>/dev/null; then
321-
removed_any=1
322-
fi
323-
fi
324-
done
325-
326-
eval "$exclude_shopt_save" 2>/dev/null || true
327-
cd "$exclude_old_pwd" || true
328-
329-
if [ "$removed_any" -eq 1 ]; then
330-
log_info "Excluded subdirectory $exclude_pattern"
331-
fi
332-
;;
333-
esac
334-
;;
335-
esac
363+
eval "$exclude_shopt_save" 2>/dev/null || true
364+
cd "$exclude_old_pwd" || true
365+
366+
if [ "$removed_any" -eq 1 ]; then
367+
log_info "Excluded subdirectory $exclude_pattern"
368+
fi
336369
done <<EOF
337370
$excludes
338371
EOF
339372
}
340373

374+
# Check whether any exclude pattern applies beneath a copied directory.
375+
# Usage: _has_subdir_excludes <dir_path> <excludes>
376+
_has_subdir_excludes() {
377+
local dir_path="$1" excludes="$2"
378+
379+
[ -z "$excludes" ] && return 1
380+
381+
local exclude_pattern
382+
while IFS= read -r exclude_pattern; do
383+
[ -z "$exclude_pattern" ] && continue
384+
385+
if _is_unsafe_path "$exclude_pattern"; then
386+
continue
387+
fi
388+
389+
if _directory_exclude_suffix "$dir_path" "$exclude_pattern" >/dev/null; then
390+
return 0
391+
fi
392+
done <<EOF
393+
$excludes
394+
EOF
395+
396+
return 1
397+
}
398+
399+
# Copy a directory one direct child at a time, skipping excluded child subtrees
400+
# before they are copied. Deeper excludes are still removed after copy.
401+
# Usage: _selective_copy_dir <dir_path> <dst_root> <excludes>
402+
_selective_copy_dir() {
403+
local dir_path="$1" dst_root="$2" excludes="$3"
404+
local dest_dir="$dst_root/$dir_path"
405+
406+
mkdir -p "$dest_dir" || return 1
407+
408+
local find_results
409+
find_results=$(find "$dir_path" -mindepth 1 -maxdepth 1 2>/dev/null || true)
410+
411+
local child_path child_name child_rel
412+
while IFS= read -r child_path; do
413+
[ -z "$child_path" ] && continue
414+
415+
child_name=$(basename "$child_path")
416+
child_rel="$dir_path/$child_name"
417+
418+
if is_excluded "$child_rel" "$excludes" || is_excluded "$child_rel/" "$excludes"; then
419+
log_info "Skipped excluded directory $child_rel"
420+
continue
421+
fi
422+
423+
if ! _fast_copy_dir "$child_path" "$dest_dir/"; then
424+
return 1
425+
fi
426+
done <<EOF
427+
$find_results
428+
EOF
429+
430+
_apply_directory_excludes "$dst_root" "$dir_path" "$excludes"
431+
}
432+
341433
# Copy directories matching patterns (typically git-ignored directories like node_modules)
342434
# Usage: copy_directories src_root dst_root dir_patterns excludes [dry_run]
343435
# dir_patterns: newline-separated directory names to copy (e.g., "node_modules", ".venv")
@@ -403,10 +495,17 @@ copy_directories() {
403495
mkdir -p "$dest_parent"
404496

405497
# Copy directory using CoW when available (preserves symlinks as symlinks)
406-
if _fast_copy_dir "$dir_path" "$dest_parent/"; then
498+
if _has_subdir_excludes "$dir_path" "$excludes"; then
499+
if _selective_copy_dir "$dir_path" "$dst_root" "$excludes"; then
500+
log_info "Copied directory $dir_path"
501+
copied_count=$((copied_count + 1))
502+
else
503+
log_warn "Failed to copy directory $dir_path"
504+
fi
505+
elif _fast_copy_dir "$dir_path" "$dest_parent/"; then
407506
log_info "Copied directory $dir_path"
408507
copied_count=$((copied_count + 1))
409-
_apply_directory_excludes "$dest_parent" "$dir_path" "$excludes"
508+
_apply_directory_excludes "$dst_root" "$dir_path" "$excludes"
410509
else
411510
log_warn "Failed to copy directory $dir_path"
412511
fi

tests/copy_safety.bats

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,3 +203,142 @@ 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 "_has_subdir_excludes supports nested include paths" {
224+
_has_subdir_excludes "vendor/bundle" $'vendor/bundle/cache'
225+
}
226+
227+
@test "_apply_directory_excludes supports nested include paths" {
228+
_test_tmpdir=$(mktemp -d)
229+
local dest="$_test_tmpdir/dest"
230+
mkdir -p "$dest/vendor/bundle/cache" "$dest/vendor/bundle/gems"
231+
touch "$dest/vendor/bundle/cache/blob"
232+
touch "$dest/vendor/bundle/gems/spec"
233+
234+
_apply_directory_excludes "$dest" "vendor/bundle" $'vendor/bundle/cache'
235+
236+
[ ! -e "$dest/vendor/bundle/cache" ]
237+
[ -f "$dest/vendor/bundle/gems/spec" ]
238+
}
239+
240+
@test "_apply_directory_excludes supports nested glob prefixes" {
241+
_test_tmpdir=$(mktemp -d)
242+
local dest="$_test_tmpdir/dest"
243+
mkdir -p "$dest/vendor/bundle/cache" "$dest/vendor/bundle/gems"
244+
touch "$dest/vendor/bundle/cache/blob"
245+
touch "$dest/vendor/bundle/gems/spec"
246+
247+
_apply_directory_excludes "$dest" "vendor/bundle" $'vendor/*/cache'
248+
249+
[ ! -e "$dest/vendor/bundle/cache" ]
250+
[ -f "$dest/vendor/bundle/gems/spec" ]
251+
}
252+
253+
@test "_apply_directory_excludes uses deepest matching glob prefix" {
254+
_test_tmpdir=$(mktemp -d)
255+
local dest="$_test_tmpdir/dest"
256+
mkdir -p "$dest/vendor/bundle/cache/tmp" "$dest/vendor/bundle/cache/keep"
257+
touch "$dest/vendor/bundle/cache/tmp/blob"
258+
touch "$dest/vendor/bundle/cache/keep/spec"
259+
260+
_apply_directory_excludes "$dest" "vendor/bundle" $'*/bundle/cache/tmp'
261+
262+
[ ! -e "$dest/vendor/bundle/cache/tmp" ]
263+
[ -f "$dest/vendor/bundle/cache/keep/spec" ]
264+
}
265+
266+
@test "_selective_copy_dir skips excluded direct child" {
267+
_test_tmpdir=$(mktemp -d)
268+
local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst"
269+
mkdir -p "$src/.claude/settings" "$src/.claude/worktrees" "$dst"
270+
echo "keep" > "$src/.claude/settings/config.json"
271+
echo "skip" > "$src/.claude/worktrees/session.json"
272+
273+
cd "$src"
274+
_selective_copy_dir ".claude" "$dst" $'.claude/worktrees'
275+
276+
[ -f "$dst/.claude/settings/config.json" ]
277+
[ ! -e "$dst/.claude/worktrees" ]
278+
}
279+
280+
@test "_selective_copy_dir skips trailing-slash excluded direct child" {
281+
_test_tmpdir=$(mktemp -d)
282+
local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst"
283+
mkdir -p "$src/.claude/settings" "$src/.claude/worktrees" "$dst"
284+
echo "keep" > "$src/.claude/settings/config.json"
285+
echo "skip" > "$src/.claude/worktrees/session.json"
286+
287+
cd "$src"
288+
_selective_copy_dir ".claude" "$dst" $'.claude/worktrees/'
289+
290+
[ -f "$dst/.claude/settings/config.json" ]
291+
[ ! -e "$dst/.claude/worktrees" ]
292+
}
293+
294+
@test "_selective_copy_dir still applies deeper excludes after copy" {
295+
_test_tmpdir=$(mktemp -d)
296+
local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst"
297+
mkdir -p "$src/.claude/worktrees/cache" "$src/.claude/worktrees/keep" "$dst"
298+
echo "skip" > "$src/.claude/worktrees/cache/blob"
299+
echo "keep" > "$src/.claude/worktrees/keep/session.json"
300+
301+
cd "$src"
302+
_selective_copy_dir ".claude" "$dst" $'.claude/worktrees/cache'
303+
304+
[ ! -e "$dst/.claude/worktrees/cache" ]
305+
[ -f "$dst/.claude/worktrees/keep/session.json" ]
306+
}
307+
308+
@test "copy_directories does not copy excluded direct child subtree" {
309+
_test_tmpdir=$(mktemp -d)
310+
local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst" copy_log="$_test_tmpdir/copy.log"
311+
mkdir -p "$src/.claude/settings" "$src/.claude/worktrees" "$dst"
312+
echo "keep" > "$src/.claude/settings/config.json"
313+
echo "skip" > "$src/.claude/worktrees/session.json"
314+
315+
_fast_copy_dir() {
316+
printf '%s\n' "$1" >> "$copy_log"
317+
cp -RP "$1" "$2"
318+
}
319+
320+
copy_directories "$src" "$dst" ".claude" $'.claude/worktrees'
321+
322+
[ -f "$dst/.claude/settings/config.json" ]
323+
[ ! -e "$dst/.claude/worktrees" ]
324+
! grep -qx ".claude/worktrees" "$copy_log"
325+
}
326+
327+
@test "copy_directories does not copy excluded child under nested include path" {
328+
_test_tmpdir=$(mktemp -d)
329+
local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst" copy_log="$_test_tmpdir/copy.log"
330+
mkdir -p "$src/vendor/bundle/cache" "$src/vendor/bundle/gems" "$dst"
331+
echo "skip" > "$src/vendor/bundle/cache/blob"
332+
echo "keep" > "$src/vendor/bundle/gems/spec"
333+
334+
_fast_copy_dir() {
335+
printf '%s\n' "$1" >> "$copy_log"
336+
cp -RP "$1" "$2"
337+
}
338+
339+
copy_directories "$src" "$dst" "vendor/bundle" $'vendor/bundle/cache'
340+
341+
[ -f "$dst/vendor/bundle/gems/spec" ]
342+
[ ! -e "$dst/vendor/bundle/cache" ]
343+
! grep -qx "vendor/bundle/cache" "$copy_log"
344+
}

0 commit comments

Comments
 (0)