From a56de312a2d9ebcddf0f58ff4ea5905403e858ee Mon Sep 17 00:00:00 2001 From: Hans Elizaga Date: Tue, 9 Dec 2025 21:48:03 -0800 Subject: [PATCH 1/3] feat: add 'copy' command to facilitate file copying between worktrees --- bin/gtr | 141 +++++++++++++++++++++++++++++++++++++++++++ completions/_git-gtr | 15 ++++- completions/gtr.bash | 13 +++- completions/gtr.fish | 7 +++ lib/copy.sh | 44 +++++++++----- lib/core.sh | 18 ++++++ 6 files changed, 222 insertions(+), 16 deletions(-) diff --git a/bin/gtr b/bin/gtr index cf44a58..27b0699 100755 --- a/bin/gtr +++ b/bin/gtr @@ -78,6 +78,9 @@ main() { ai) cmd_ai "$@" ;; + copy) + cmd_copy "$@" + ;; ls|list) cmd_list "$@" ;; @@ -467,6 +470,130 @@ cmd_run() { (cd "$worktree_path" && "${run_args[@]}") } +# Copy command (copy files between worktrees) +cmd_copy() { + local source="1" # Default: main repo + local targets="" + local patterns="" + local all_mode=0 + local dry_run=0 + + # Parse arguments (patterns come after -- separator, like git pathspec) + while [ $# -gt 0 ]; do + case "$1" in + --from) + source="$2" + shift 2 + ;; + -n|--dry-run) + dry_run=1 + shift + ;; + -a|--all) + all_mode=1 + shift + ;; + --) + shift + # Remaining args are patterns (like git pathspec) + while [ $# -gt 0 ]; do + if [ -n "$patterns" ]; then + patterns="$patterns"$'\n'"$1" + else + patterns="$1" + fi + shift + done + break + ;; + -*) + log_error "Unknown flag: $1" + exit 1 + ;; + *) + targets="$targets $1" + shift + ;; + esac + done + + # Validation + if [ "$all_mode" -eq 0 ] && [ -z "$targets" ]; then + log_error "Usage: git gtr copy ... [-n] [-a] [--from ] [-- ...]" + exit 1 + fi + + # Get repo context + local repo_root base_dir prefix + repo_root=$(discover_repo_root) || exit 1 + base_dir=$(resolve_base_dir "$repo_root") + prefix=$(cfg_default gtr.worktrees.prefix GTR_WORKTREES_PREFIX "") + + # Resolve source path + local src_target src_path + src_target=$(resolve_target "$source" "$repo_root" "$base_dir" "$prefix") || exit 1 + src_path=$(echo "$src_target" | cut -f2) + + # Get patterns (flag > config) + if [ -z "$patterns" ]; then + patterns=$(cfg_get_all gtr.copy.include copy.include) + # Also check .worktreeinclude + if [ -f "$repo_root/.worktreeinclude" ]; then + local file_patterns + file_patterns=$(parse_pattern_file "$repo_root/.worktreeinclude") + if [ -n "$file_patterns" ]; then + if [ -n "$patterns" ]; then + patterns="$patterns"$'\n'"$file_patterns" + else + patterns="$file_patterns" + fi + fi + fi + fi + + if [ -z "$patterns" ]; then + log_error "No patterns specified. Use '-- ...' or configure gtr.copy.include" + exit 1 + fi + + local excludes + excludes=$(cfg_get_all gtr.copy.exclude copy.exclude) + + # Build target list for --all mode + if [ "$all_mode" -eq 1 ]; then + targets=$(list_worktree_branches "$repo_root" "$base_dir" "$prefix") + if [ -z "$targets" ]; then + log_error "No worktrees found" + exit 1 + fi + fi + + # Process each target + local copied_any=0 + for target_id in $targets; do + local dst_target dst_path dst_branch + dst_target=$(resolve_target "$target_id" "$repo_root" "$base_dir" "$prefix") || continue + dst_path=$(echo "$dst_target" | cut -f2) + dst_branch=$(echo "$dst_target" | cut -f3) + + # Skip if source == destination + [ "$src_path" = "$dst_path" ] && continue + + if [ "$dry_run" -eq 1 ]; then + log_step "[dry-run] Would copy to: $dst_branch" + copy_patterns "$src_path" "$dst_path" "$patterns" "$excludes" "true" "true" + else + log_step "Copying to: $dst_branch" + copy_patterns "$src_path" "$dst_path" "$patterns" "$excludes" "true" + fi + copied_any=1 + done + + if [ "$copied_any" -eq 0 ]; then + log_warn "No files copied (source and target may be the same)" + fi +} + # Editor command cmd_editor() { local identifier="" @@ -1062,6 +1189,20 @@ CORE COMMANDS (daily workflow): --force: force removal (dirty worktree) --yes: skip confirmation + copy ... [options] [-- ...] + Copy files from main repo to worktree(s) + -n, --dry-run: preview without copying + -a, --all: copy to all worktrees + --from : copy from different worktree (default: main repo) + Patterns after -- override gtr.copy.include config + + Examples: + git gtr copy my-feature # Uses configured patterns + git gtr copy my-feature -- ".env*" # Explicit pattern + git gtr copy my-feature -- ".env*" "*.json" # Multiple patterns + git gtr copy -a -- ".env*" # Update all worktrees + git gtr copy my-feature -n -- "**/.env*" # Dry-run preview + ──────────────────────────────────────────────────────────────────────────────── SETUP & MAINTENANCE: diff --git a/completions/_git-gtr b/completions/_git-gtr index 3217236..675f03d 100644 --- a/completions/_git-gtr +++ b/completions/_git-gtr @@ -19,6 +19,7 @@ _git-gtr() { 'new:Create a new worktree' 'go:Navigate to worktree' 'run:Execute command in worktree' + 'copy:Copy files between worktrees' 'rm:Remove worktree(s)' 'editor:Open worktree in editor' 'ai:Start AI coding tool' @@ -65,7 +66,7 @@ _git-gtr() { # Complete arguments to the subcommand elif (( CURRENT == 4 )); then case "$words[3]" in - go|run|rm) + go|run|rm|copy) _describe 'branch names' all_options ;; editor) @@ -93,6 +94,18 @@ _git-gtr() { '--force[Force removal even if dirty]' \ '--yes[Non-interactive mode]' ;; + copy) + _arguments \ + '-n[Dry-run preview]' \ + '--dry-run[Preview without copying]' \ + '-a[Copy to all worktrees]' \ + '--all[Copy to all worktrees]' \ + '--from[Source worktree]:source:->worktrees' \ + '*:target:->worktrees' + case "$state" in + worktrees) _describe 'branch names' all_options ;; + esac + ;; config) case "$words[4]" in get|set|add|unset) diff --git a/completions/gtr.bash b/completions/gtr.bash index 9d00952..1d1fad4 100644 --- a/completions/gtr.bash +++ b/completions/gtr.bash @@ -22,7 +22,7 @@ _git_gtr() { # If we're completing the first argument after 'git gtr' if [ "$cword" -eq 2 ]; then - COMPREPLY=($(compgen -W "new go run editor ai rm ls list clean doctor adapter config help version" -- "$cur")) + COMPREPLY=($(compgen -W "new go run copy editor ai rm ls list clean doctor adapter config help version" -- "$cur")) return 0 fi @@ -45,6 +45,17 @@ _git_gtr() { esac fi ;; + copy) + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "-n --dry-run -a --all --from" -- "$cur")) + else + # Complete with branch names and special ID '1' for main repo + local branches all_options + branches=$(git branch --format='%(refname:short)' 2>/dev/null || true) + all_options="1 $branches" + COMPREPLY=($(compgen -W "$all_options" -- "$cur")) + fi + ;; new) # Complete flags if [[ "$cur" == -* ]]; then diff --git a/completions/gtr.fish b/completions/gtr.fish index f678d7c..eb64352 100644 --- a/completions/gtr.fish +++ b/completions/gtr.fish @@ -35,6 +35,7 @@ complete -f -c git -n '__fish_git_gtr_needs_command' -a new -d 'Create a new wor complete -f -c git -n '__fish_git_gtr_needs_command' -a go -d 'Navigate to worktree' complete -f -c git -n '__fish_git_gtr_needs_command' -a run -d 'Execute command in worktree' complete -f -c git -n '__fish_git_gtr_needs_command' -a rm -d 'Remove worktree(s)' +complete -f -c git -n '__fish_git_gtr_needs_command' -a copy -d 'Copy files between worktrees' complete -f -c git -n '__fish_git_gtr_needs_command' -a editor -d 'Open worktree in editor' complete -f -c git -n '__fish_git_gtr_needs_command' -a ai -d 'Start AI coding tool' complete -f -c git -n '__fish_git_gtr_needs_command' -a ls -d 'List all worktrees' @@ -61,6 +62,11 @@ complete -c git -n '__fish_git_gtr_using_command rm' -l delete-branch -d 'Delete complete -c git -n '__fish_git_gtr_using_command rm' -l force -d 'Force removal even if dirty' complete -c git -n '__fish_git_gtr_using_command rm' -l yes -d 'Non-interactive mode' +# Copy command options +complete -c git -n '__fish_git_gtr_using_command copy' -s n -l dry-run -d 'Preview without copying' +complete -c git -n '__fish_git_gtr_using_command copy' -s a -l all -d 'Copy to all worktrees' +complete -c git -n '__fish_git_gtr_using_command copy' -l from -d 'Source worktree' -r + # Config command complete -f -c git -n '__fish_git_gtr_using_command config' -a 'get set add unset' complete -f -c git -n '__fish_git_gtr_using_command config' -a " @@ -88,6 +94,7 @@ end # Complete branch names for commands that need them complete -f -c git -n '__fish_git_gtr_using_command go' -a '(__gtr_worktree_branches)' complete -f -c git -n '__fish_git_gtr_using_command run' -a '(__gtr_worktree_branches)' +complete -f -c git -n '__fish_git_gtr_using_command copy' -a '(__gtr_worktree_branches)' complete -f -c git -n '__fish_git_gtr_using_command editor' -a '(__gtr_worktree_branches)' complete -f -c git -n '__fish_git_gtr_using_command ai' -a '(__gtr_worktree_branches)' complete -f -c git -n '__fish_git_gtr_using_command rm' -a '(__gtr_worktree_branches)' diff --git a/lib/copy.sh b/lib/copy.sh index 2d89222..06e1f86 100644 --- a/lib/copy.sh +++ b/lib/copy.sh @@ -16,16 +16,18 @@ parse_pattern_file() { } # Copy files matching patterns from source to destination -# Usage: copy_patterns src_root dst_root includes excludes [preserve_paths] +# Usage: copy_patterns src_root dst_root includes excludes [preserve_paths] [dry_run] # includes: newline-separated glob patterns to include # excludes: newline-separated glob patterns to exclude # preserve_paths: true (default) to preserve directory structure +# dry_run: true to only show what would be copied without copying copy_patterns() { local src_root="$1" local dst_root="$2" local includes="$3" local excludes="$4" local preserve_paths="${5:-true}" + local dry_run="${6:-false}" if [ -z "$includes" ]; then # No patterns to copy @@ -101,17 +103,22 @@ EOF dest_file="$dst_root/$(basename "$file")" fi - # Create destination directory + # Create destination directory (skip in dry-run mode) local dest_dir dest_dir=$(dirname "$dest_file") - mkdir -p "$dest_dir" - # Copy the file - if cp "$file" "$dest_file" 2>/dev/null; then - log_info "Copied $file" + # Copy the file (or show what would be copied in dry-run mode) + if [ "$dry_run" = "true" ]; then + log_info "[dry-run] Would copy: $file" copied_count=$((copied_count + 1)) else - log_warn "Failed to copy $file" + mkdir -p "$dest_dir" + if cp "$file" "$dest_file" 2>/dev/null; then + log_info "Copied $file" + copied_count=$((copied_count + 1)) + else + log_warn "Failed to copy $file" + fi fi done </dev/null) @@ -154,17 +161,22 @@ EOF dest_file="$dst_root/$(basename "$file")" fi - # Create destination directory + # Create destination directory (skip in dry-run mode) local dest_dir dest_dir=$(dirname "$dest_file") - mkdir -p "$dest_dir" - # Copy the file - if cp "$file" "$dest_file" 2>/dev/null; then - log_info "Copied $file" + # Copy the file (or show what would be copied in dry-run mode) + if [ "$dry_run" = "true" ]; then + log_info "[dry-run] Would copy: $file" copied_count=$((copied_count + 1)) else - log_warn "Failed to copy $file" + mkdir -p "$dest_dir" + if cp "$file" "$dest_file" 2>/dev/null; then + log_info "Copied $file" + copied_count=$((copied_count + 1)) + else + log_warn "Failed to copy $file" + fi fi done fi @@ -178,7 +190,11 @@ EOF cd "$old_pwd" || return 1 if [ "$copied_count" -gt 0 ]; then - log_info "Copied $copied_count file(s)" + if [ "$dry_run" = "true" ]; then + log_info "[dry-run] Would copy $copied_count file(s)" + else + log_info "Copied $copied_count file(s)" + fi fi return 0 diff --git a/lib/core.sh b/lib/core.sh index 92ce408..9ce764a 100644 --- a/lib/core.sh +++ b/lib/core.sh @@ -436,3 +436,21 @@ remove_worktree() { list_worktrees() { git worktree list } + +# List all worktree branch names (excluding main repo) +# Usage: list_worktree_branches repo_root base_dir prefix +# Returns: newline-separated list of branch names +list_worktree_branches() { + local repo_root="$1" + local base_dir="$2" + local prefix="$3" + + [ ! -d "$base_dir" ] && return 0 + + for dir in "$base_dir/${prefix}"*; do + [ -d "$dir" ] || continue + local branch + branch=$(current_branch "$dir") + [ -n "$branch" ] && echo "$branch" + done +} From f922c1407eab8f90a490726f3ff3f0c1e882d058 Mon Sep 17 00:00:00 2001 From: Hans Elizaga Date: Tue, 9 Dec 2025 21:50:59 -0800 Subject: [PATCH 2/3] docs: update README and CLAUDE.md with examples for 'copy' command and test cases --- CLAUDE.md | 15 +++++++++++++++ README.md | 18 ++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 7874c8c..986bd8c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -154,6 +154,21 @@ git config --add gtr.copy.excludeDirs "*/.cache" # Exclude .cache at any level ./bin/gtr new test-wildcard # Expected: Copies .venv and node_modules, excludes all .cache directories +# Test copy command (copy files to existing worktrees) +echo "TEST=value" > .env.example +./bin/gtr new test-copy +./bin/gtr copy test-copy -- ".env.example" +# Expected: Copies .env.example to worktree + +./bin/gtr copy test-copy -n -- "*.md" +# Expected: Dry-run shows what would be copied without copying + +./bin/gtr copy -a -- ".env.example" +# Expected: Copies to all worktrees + +./bin/gtr rm test-copy --force --yes +rm .env.example + # Test post-create and post-remove hooks git config --add gtr.hook.postCreate "echo 'Created!' > /tmp/gtr-test" ./bin/gtr new test-hooks diff --git a/README.md b/README.md index 71734f8..07d5e7c 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,24 @@ git gtr rm my-feature --delete-branch --force # Delete branch and force **Options:** `--delete-branch`, `--force`, `--yes` +### `git gtr copy ... [options] [-- ...]` + +Copy files from main repo to existing worktree(s). Useful for syncing env files after worktree creation. + +```bash +git gtr copy my-feature # Uses gtr.copy.include patterns +git gtr copy my-feature -- ".env*" # Explicit pattern +git gtr copy my-feature -- ".env*" "*.json" # Multiple patterns +git gtr copy -a -- ".env*" # Copy to all worktrees +git gtr copy my-feature -n -- "**/.env*" # Dry-run preview +``` + +**Options:** + +- `-n, --dry-run`: Preview without copying +- `-a, --all`: Copy to all worktrees +- `--from `: Copy from different worktree (default: main repo) + ### `git gtr list [--porcelain]` List all worktrees. Use `--porcelain` for machine-readable output. From b7d1ef05a845e03bb863c140ab758c452ae1669e Mon Sep 17 00:00:00 2001 From: Hans Elizaga Date: Tue, 9 Dec 2025 21:55:51 -0800 Subject: [PATCH 3/3] refactor: update list_worktree_branches function parameters for clarity --- bin/gtr | 2 +- lib/core.sh | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/bin/gtr b/bin/gtr index 27b0699..93706a9 100755 --- a/bin/gtr +++ b/bin/gtr @@ -561,7 +561,7 @@ cmd_copy() { # Build target list for --all mode if [ "$all_mode" -eq 1 ]; then - targets=$(list_worktree_branches "$repo_root" "$base_dir" "$prefix") + targets=$(list_worktree_branches "$base_dir" "$prefix") if [ -z "$targets" ]; then log_error "No worktrees found" exit 1 diff --git a/lib/core.sh b/lib/core.sh index 9ce764a..b2bd0f3 100644 --- a/lib/core.sh +++ b/lib/core.sh @@ -438,12 +438,11 @@ list_worktrees() { } # List all worktree branch names (excluding main repo) -# Usage: list_worktree_branches repo_root base_dir prefix +# Usage: list_worktree_branches base_dir prefix # Returns: newline-separated list of branch names list_worktree_branches() { - local repo_root="$1" - local base_dir="$2" - local prefix="$3" + local base_dir="$1" + local prefix="$2" [ ! -d "$base_dir" ] && return 0