Skip to content

Commit f82ddc4

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

3 files changed

Lines changed: 266 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: 150 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,49 @@ 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
278+
prefix="${exclude_pattern%%/*}"
279+
suffix="${exclude_pattern#*/}"
280+
281+
while :; do
282+
[ -z "$suffix" ] && break
283+
284+
# Intentional glob pattern matching for directory prefix
285+
# shellcheck disable=SC2254
286+
case "$dir_path" in
287+
$prefix)
288+
printf '%s\n' "$suffix"
289+
return 0
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+
return 1
303+
}
304+
262305
# Remove excluded subdirectories from a copied directory.
263306
# Supports patterns like "node_modules/.cache", "*/.cache", "node_modules/*", "*/.*"
264307
# Usage: _apply_directory_excludes <dest_parent> <dir_path> <excludes>
@@ -276,68 +319,112 @@ _apply_directory_excludes() {
276319
continue
277320
fi
278321

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
322+
local pattern_suffix
323+
pattern_suffix=$(_directory_exclude_suffix "$dir_path" "$exclude_pattern") || true
324+
if [ -z "$pattern_suffix" ]; then
325+
case "$exclude_pattern" in
326+
*/)
327+
log_warn "Skipping overly broad exclude suffix: $exclude_pattern"
328+
;;
329+
.git|*/.git|.git/*|*/.git/*)
330+
log_warn "Skipping exclude pattern targeting .git metadata: $exclude_pattern"
331+
;;
332+
esac
333+
continue
334+
fi
292335

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

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
358+
eval "$exclude_shopt_save" 2>/dev/null || true
359+
cd "$exclude_old_pwd" || true
360+
361+
if [ "$removed_any" -eq 1 ]; then
362+
log_info "Excluded subdirectory $exclude_pattern"
363+
fi
336364
done <<EOF
337365
$excludes
338366
EOF
339367
}
340368

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

405492
# Copy directory using CoW when available (preserves symlinks as symlinks)
406-
if _fast_copy_dir "$dir_path" "$dest_parent/"; then
493+
if _has_subdir_excludes "$dir_path" "$excludes"; then
494+
if _selective_copy_dir "$dir_path" "$dst_root" "$excludes"; then
495+
log_info "Copied directory $dir_path"
496+
copied_count=$((copied_count + 1))
497+
else
498+
log_warn "Failed to copy directory $dir_path"
499+
fi
500+
elif _fast_copy_dir "$dir_path" "$dest_parent/"; then
407501
log_info "Copied directory $dir_path"
408502
copied_count=$((copied_count + 1))
409-
_apply_directory_excludes "$dest_parent" "$dir_path" "$excludes"
503+
_apply_directory_excludes "$dst_root" "$dir_path" "$excludes"
410504
else
411505
log_warn "Failed to copy directory $dir_path"
412506
fi

tests/copy_safety.bats

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,3 +203,115 @@ 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 "_selective_copy_dir skips excluded direct child" {
254+
_test_tmpdir=$(mktemp -d)
255+
local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst"
256+
mkdir -p "$src/.claude/settings" "$src/.claude/worktrees" "$dst"
257+
echo "keep" > "$src/.claude/settings/config.json"
258+
echo "skip" > "$src/.claude/worktrees/session.json"
259+
260+
cd "$src"
261+
_selective_copy_dir ".claude" "$dst" $'.claude/worktrees'
262+
263+
[ -f "$dst/.claude/settings/config.json" ]
264+
[ ! -e "$dst/.claude/worktrees" ]
265+
}
266+
267+
@test "_selective_copy_dir still applies deeper excludes after copy" {
268+
_test_tmpdir=$(mktemp -d)
269+
local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst"
270+
mkdir -p "$src/.claude/worktrees/cache" "$src/.claude/worktrees/keep" "$dst"
271+
echo "skip" > "$src/.claude/worktrees/cache/blob"
272+
echo "keep" > "$src/.claude/worktrees/keep/session.json"
273+
274+
cd "$src"
275+
_selective_copy_dir ".claude" "$dst" $'.claude/worktrees/cache'
276+
277+
[ ! -e "$dst/.claude/worktrees/cache" ]
278+
[ -f "$dst/.claude/worktrees/keep/session.json" ]
279+
}
280+
281+
@test "copy_directories does not copy excluded direct child subtree" {
282+
_test_tmpdir=$(mktemp -d)
283+
local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst" copy_log="$_test_tmpdir/copy.log"
284+
mkdir -p "$src/.claude/settings" "$src/.claude/worktrees" "$dst"
285+
echo "keep" > "$src/.claude/settings/config.json"
286+
echo "skip" > "$src/.claude/worktrees/session.json"
287+
288+
_fast_copy_dir() {
289+
printf '%s\n' "$1" >> "$copy_log"
290+
cp -RP "$1" "$2"
291+
}
292+
293+
copy_directories "$src" "$dst" ".claude" $'.claude/worktrees'
294+
295+
[ -f "$dst/.claude/settings/config.json" ]
296+
[ ! -e "$dst/.claude/worktrees" ]
297+
! grep -qx ".claude/worktrees" "$copy_log"
298+
}
299+
300+
@test "copy_directories does not copy excluded child under nested include path" {
301+
_test_tmpdir=$(mktemp -d)
302+
local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst" copy_log="$_test_tmpdir/copy.log"
303+
mkdir -p "$src/vendor/bundle/cache" "$src/vendor/bundle/gems" "$dst"
304+
echo "skip" > "$src/vendor/bundle/cache/blob"
305+
echo "keep" > "$src/vendor/bundle/gems/spec"
306+
307+
_fast_copy_dir() {
308+
printf '%s\n' "$1" >> "$copy_log"
309+
cp -RP "$1" "$2"
310+
}
311+
312+
copy_directories "$src" "$dst" "vendor/bundle" $'vendor/bundle/cache'
313+
314+
[ -f "$dst/vendor/bundle/gems/spec" ]
315+
[ ! -e "$dst/vendor/bundle/cache" ]
316+
! grep -qx "vendor/bundle/cache" "$copy_log"
317+
}

0 commit comments

Comments
 (0)