Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,21 @@ git gtr config add gtr.copy.excludeDirs "node_modules/.*" # Exclude all hid
git gtr config add gtr.copy.excludeDirs "*/.cache" # Exclude .cache at any level
```

File exclusions in `gtr.copy.exclude` also apply inside directories selected by
`gtr.copy.includeDirs`, for both `git gtr new` and `git gtr copy`. They match
repository-relative file paths using the same rules as file-pattern copying.
For example, `*.pem` skips PEM files throughout an included directory. Files
are skipped before copying; existing excluded files in the destination are
left unchanged. File exclusions do not exclude directories with matching names;
use `gtr.copy.excludeDirs` to skip a whole directory.

When exclusions require selective copying, new destination directories stay
owner-only until their contents are copied successfully. Missing ancestors of
nested directory selections stay private until all selected directories finish.
Their final permission bits come from the corresponding source directory,
restricted by your umask. Existing destination directory permissions are left
unchanged.

Directory exclusions never descend through symlinks. An exclusion matching a
symlink itself removes only the link, including when the pattern ends in `/`;
the link target is preserved. Exclusions are skipped when the copied directory
Expand All @@ -362,7 +377,7 @@ it does not pin the contents of delegated scripts or dependencies. Copy path
checks cannot isolate execution from other users who can mutate those paths.

> [!WARNING]
> Dependency directories may contain sensitive files (credentials, tokens, cached secrets). Always use `gtr.copy.excludeDirs` to exclude sensitive subdirectories if needed.
> Dependency directories may contain sensitive files (credentials, tokens, cached secrets). Use `gtr.copy.excludeDirs` to exclude sensitive subdirectories and `gtr.copy.exclude` to exclude files.

**Use cases:**

Expand Down
4 changes: 2 additions & 2 deletions lib/commands/copy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,13 @@ cmd_copy() {
log_step "[dry-run] Would copy to: $dst_branch"
[ -n "$patterns" ] && copy_patterns "$src_path" "$dst_path" "$patterns" "$excludes" "true" "true"
if [ -n "$resolved_dirs" ]; then
_copy_resolved_directories "$src_path" "$dst_path" "$resolved_dirs" "$dir_excludes" "true" || return 1
_copy_resolved_directories "$src_path" "$dst_path" "$resolved_dirs" "$dir_excludes" "true" "$excludes" || return 1
fi
else
log_step "Copying to: $dst_branch"
[ -n "$patterns" ] && copy_patterns "$src_path" "$dst_path" "$patterns" "$excludes" "true"
if [ -n "$resolved_dirs" ]; then
_copy_resolved_directories "$src_path" "$dst_path" "$resolved_dirs" "$dir_excludes" || return 1
_copy_resolved_directories "$src_path" "$dst_path" "$resolved_dirs" "$dir_excludes" "false" "$excludes" || return 1
fi
fi
copied_any=1
Expand Down
2 changes: 1 addition & 1 deletion lib/commands/create.sh
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ _post_create_copy() {

if [ -n "$dir_includes" ]; then
log_step "Copying directories..."
copy_directories "$repo_root" "$worktree_path" "$dir_includes" "$dir_excludes" || return 1
copy_directories "$repo_root" "$worktree_path" "$dir_includes" "$dir_excludes" "false" "$excludes" || return 1
fi
}

Expand Down
113 changes: 98 additions & 15 deletions lib/copy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -575,25 +575,45 @@ EOF
return 1
}

# Print the permission bits for a new directory, restricted by the caller's umask.
# Usage: _copy_directory_mode <source_directory>
_copy_directory_mode() {
local source_mode copy_umask
source_mode=$(stat -c '%a' "$1" 2>/dev/null) ||
source_mode=$(stat -f '%Lp' "$1" 2>/dev/null) || return 1
copy_umask=$(umask)
printf '%o' "$((8#$source_mode & 0777 & ~8#$copy_umask))"
}

# Copy a directory one direct child at a time, skipping excluded child subtrees
# before they are copied. Deeper excludes are still removed after copy.
# Usage: _selective_copy_dir <dir_path> <dst_root> <excludes>
# New directories stay owner-only until complete, then receive the source's
# permission bits masked by the caller's umask. Existing directory modes stay intact.
# Usage: _selective_copy_dir <dir_path> <dst_root> <excludes> [file_excludes]
_selective_copy_dir() {
local dir_path="$1" dst_root="$2" excludes="$3"
local file_excludes="${4:-}"
local dest_dir="$dst_root/$dir_path"

_copy_components_safe "." "$dir_path" || return 1
_copy_components_safe "$dst_root" "$dir_path" || return 1
mkdir -p "$dest_dir" || return 1
local final_mode=""
if [ ! -d "$dest_dir" ]; then
final_mode=$(_copy_directory_mode "$dir_path") || return 1
# Explicit owner write access also permits populating read-only sources.
# Do not use -p here: an existing directory must never be reclassified as new.
mkdir -m 700 "$dest_dir" || return 1
fi

local find_results
find_results=$(find "$dir_path" -mindepth 1 -maxdepth 1 -print0 2>/dev/null | _copy_discovery_records)
find_results=$(set -o pipefail; find "$dir_path" -mindepth 1 -maxdepth 1 -print0 2>/dev/null |
_copy_discovery_records) || return 1

local child_path child_name child_rel
while IFS= read -r child_path; do
[ -z "$child_path" ] && continue

child_name=$(basename "$child_path")
child_name="${child_path##*/}"
child_rel="$dir_path/$child_name"

if is_excluded "$child_rel" "$excludes" || is_excluded "$child_rel/" "$excludes"; then
Expand All @@ -607,15 +627,24 @@ _selective_copy_dir() {
continue
fi
if [ -d "$child_path" ] && [ ! -L "$child_path" ]; then
_selective_copy_dir "$child_rel" "$dst_root" "$excludes" || return 1
elif ! _fast_copy_dir "$child_path" "$dest_dir/"; then
return 1
_selective_copy_dir "$child_rel" "$dst_root" "$excludes" "$file_excludes" || return 1
else
# File exclusions use repo-relative paths, just like copy_patterns.
# Skip before copying; never remove pre-existing destination files.
is_excluded "$child_rel" "$file_excludes" && continue
_fast_copy_dir "$child_path" "$dest_dir/" || return 1
fi
done <<EOF
$find_results
EOF

_apply_directory_excludes "$dst_root" "$dir_path" "$excludes"
_apply_directory_excludes "$dst_root" "$dir_path" "$excludes" || return 1
if [ -n "$final_mode" ]; then
# Recheck before chmod so a replacement symlink cannot redirect it.
_copy_components_safe "$dst_root" "$dir_path" || return 1
chmod "$final_mode" "$dest_dir" || return 1
fi
return 0
}

# Return the number of path components in a directory pattern.
Expand Down Expand Up @@ -721,14 +750,15 @@ EOF
}

# Copy already-resolved directories from a source root.
# Usage: _copy_resolved_directories src_root dst_root resolved_dirs excludes [dry_run]
# Usage: _copy_resolved_directories src_root dst_root resolved_dirs excludes [dry_run] [file_excludes]
# Returns nonzero if any directory copy fails; callers must guard the status.
_copy_resolved_directories() {
local src_root="$1"
local dst_root="$2"
local resolved_dirs="$3"
local excludes="$4"
local dry_run="${5:-false}"
local file_excludes="${6:-}"

if [ -z "$resolved_dirs" ]; then
return 0
Expand All @@ -743,6 +773,9 @@ _copy_resolved_directories() {
cd "$src_root" || return 1

local copied_count=0 failed=0
# Missing ancestors of nested selections must remain private across all
# selections, including siblings under a read-only source parent.
local -a parent_paths=() parent_modes=()
local dir_path
while IFS= read -r dir_path; do
[ -z "$dir_path" ] && continue
Expand All @@ -765,13 +798,46 @@ _copy_resolved_directories() {
continue
fi

if ! mkdir -p "$dest_parent"; then
if [ ! -d "$dst_root" ] && ! mkdir -p "$dst_root"; then
failed=1
log_warn "Failed to prepare destination root $dst_root"
continue
fi
local parent_path="" remaining component parent_mode parents_ready=1
case "$dir_path" in
*/*) remaining="${dir_path%/*}" ;;
*) remaining="" ;;
esac
while [ -n "$remaining" ]; do
component="${remaining%%/*}"
case "$remaining" in
*/*) remaining="${remaining#*/}" ;;
*) remaining="" ;;
esac
case "$component" in ""|.) continue ;; esac
parent_path="${parent_path:+$parent_path/}$component"
if ! _copy_components_safe "$dst_root" "$parent_path"; then
parents_ready=0
break
fi
if [ ! -d "$dst_root/$parent_path" ]; then
if ! parent_mode=$(_copy_directory_mode "$parent_path") ||
! mkdir -m 700 "$dst_root/$parent_path"; then
parents_ready=0
break
fi
parent_paths+=("$parent_path")
parent_modes+=("$parent_mode")
fi
done
if [ "$parents_ready" -eq 0 ]; then
failed=1
log_warn "Failed to prepare parent directories for $dir_path"
continue
fi

if { if _has_subdir_excludes "$dir_path" "$excludes"; then
_selective_copy_dir "$dir_path" "$dst_root" "$excludes"
if { if [ -n "$file_excludes" ] || _has_subdir_excludes "$dir_path" "$excludes"; then
_selective_copy_dir "$dir_path" "$dst_root" "$excludes" "$file_excludes"
Comment thread
shiqui marked this conversation as resolved.
Resolved
else
_fast_copy_dir "$dir_path" "$dest_parent/"
fi; }; then
Expand All @@ -785,6 +851,21 @@ _copy_resolved_directories() {
$resolved_dirs
EOF

# Finalize deepest-first only after every selected directory has completed.
# On failure, leave the newly created ancestors private around partial data.
local parent_index=${#parent_paths[@]}
if [ "$failed" -eq 0 ]; then
while [ "$parent_index" -gt 0 ]; do
parent_index=$((parent_index - 1))
if ! _copy_components_safe "$dst_root" "${parent_paths[$parent_index]}" ||
! chmod "${parent_modes[$parent_index]}" "$dst_root/${parent_paths[$parent_index]}"; then
log_warn "Failed to finalize parent directory ${parent_paths[$parent_index]}"
failed=1
break
fi
done
fi

cd "$old_pwd" || return 1

if [ "$copied_count" -gt 0 ]; then
Expand All @@ -799,19 +880,21 @@ EOF
}

# Copy directories matching patterns (typically git-ignored directories like node_modules)
# Usage: copy_directories src_root dst_root dir_patterns excludes [dry_run]
# Usage: copy_directories src_root dst_root dir_patterns excludes [dry_run] [file_excludes]
# dir_patterns: newline-separated repo-relative paths or glob patterns
# excludes: newline-separated directory patterns to exclude (supports globs like "node_modules/.cache")
# file_excludes: newline-separated file patterns, matched against repo-relative leaf paths
# WARNING: This copies entire directories including potentially sensitive files.
# Use gtr.copy.excludeDirs to exclude sensitive directories.
# Use gtr.copy.excludeDirs for directories and gtr.copy.exclude for files.
copy_directories() {
local src_root="$1"
local dst_root="$2"
local dir_patterns="$3"
local excludes="$4"
local dry_run="${5:-false}"
local file_excludes="${6:-}"

local resolved_dirs
resolved_dirs=$(_resolve_directory_patterns "$src_root" "$dir_patterns") || return 1
_copy_resolved_directories "$src_root" "$dst_root" "$resolved_dirs" "$excludes" "$dry_run"
_copy_resolved_directories "$src_root" "$dst_root" "$resolved_dirs" "$excludes" "$dry_run" "$file_excludes"
}
24 changes: 24 additions & 0 deletions tests/cmd_copy.bats
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,30 @@ teardown() {
[ ! -e "$TEST_WORKTREES_DIR/copy-target/.zed/cache/token" ]
}

@test "cmd_copy --all applies file exclusions inside configured includeDirs" {
create_test_worktree "copy-target-2"
mkdir -p "$TEST_REPO/vendor/sub"
echo fixture > "$TEST_REPO/vendor/root.pem"
echo fixture > "$TEST_REPO/vendor/sub/nested.pem"
echo keep > "$TEST_REPO/vendor/sub/keep.txt"
git config gtr.copy.includeDirs vendor
git config gtr.copy.exclude '*.pem'

run "$PROJECT_ROOT/bin/gtr" copy --all --dry-run
[ "$status" -eq 0 ]
[ ! -e "$TEST_WORKTREES_DIR/copy-target/vendor" ]
[ ! -e "$TEST_WORKTREES_DIR/copy-target-2/vendor" ]

run "$PROJECT_ROOT/bin/gtr" copy --all
[ "$status" -eq 0 ]
local target
for target in copy-target copy-target-2; do
[ "$(cat "$TEST_WORKTREES_DIR/$target/vendor/sub/keep.txt")" = keep ]
[ ! -e "$TEST_WORKTREES_DIR/$target/vendor/root.pem" ]
[ ! -e "$TEST_WORKTREES_DIR/$target/vendor/sub/nested.pem" ]
done
}

# ── --all flag ───────────────────────────────────────────────────────────────

@test "cmd_copy --all copies to all worktrees" {
Expand Down
32 changes: 32 additions & 0 deletions tests/cmd_create_integration.bats
Original file line number Diff line number Diff line change
Expand Up @@ -288,3 +288,35 @@ _cmd_create_capture_stdout() {
[ ! -e "$TEST_WORKTREES_DIR/normalized-copy/vendor/cache" ]
[ ! -e "$TEST_WORKTREES_DIR/normalized-copy/cache" ]
}

@test "cmd_create applies file exclusions inside ignored includeDirs" {
mkdir -p "$TEST_REPO/vendor/sub" "$TEST_REPO/vendor/cache"
echo fixture > "$TEST_REPO/vendor/root.pem"
echo fixture > "$TEST_REPO/vendor/sub/nested.pem"
echo keep > "$TEST_REPO/vendor/sub/keep.txt"
echo excluded > "$TEST_REPO/vendor/cache/marker"
echo vendor/ > "$TEST_REPO/.gitignore"
git config -f "$TEST_REPO/.gtrconfig" copy.exclude '*.pem'
git config -f "$TEST_REPO/.gtrconfig" copy.excludeDirs vendor/cache/
git add .gitignore .gtrconfig
git commit -m "fixture copy exclusions" --quiet
git check-ignore vendor/root.pem vendor/sub/nested.pem
[ -z "$(git ls-files vendor)" ]

# File-pattern copying is the control; both paths must honor copy.exclude.
local mode
for mode in files directories; do
if [ "$mode" = files ]; then
git config gtr.copy.include 'vendor/**/*'
else
git config --unset gtr.copy.include
git config gtr.copy.includeDirs vendor
fi
run "$PROJECT_ROOT/bin/gtr" new "$mode" --from HEAD --no-fetch --no-hooks --porcelain
[ "$status" -eq 0 ]
[ "$(cat "$TEST_WORKTREES_DIR/$mode/vendor/sub/keep.txt")" = keep ]
[ ! -e "$TEST_WORKTREES_DIR/$mode/vendor/root.pem" ]
[ ! -e "$TEST_WORKTREES_DIR/$mode/vendor/sub/nested.pem" ]
done
[ ! -e "$TEST_WORKTREES_DIR/directories/vendor/cache" ]
}
Loading
Loading