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