@@ -338,6 +338,80 @@ $excludes
338338EOF
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 "
0 commit comments