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
338366EOF
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
0 commit comments