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
15 changes: 15 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,24 @@ git gtr rm my-feature --delete-branch --force # Delete branch and force

**Options:** `--delete-branch`, `--force`, `--yes`

### `git gtr copy <target>... [options] [-- <pattern>...]`

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 <source>`: Copy from different worktree (default: main repo)

### `git gtr list [--porcelain]`

List all worktrees. Use `--porcelain` for machine-readable output.
Expand Down
141 changes: 141 additions & 0 deletions bin/gtr
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ main() {
ai)
cmd_ai "$@"
;;
copy)
cmd_copy "$@"
;;
ls|list)
cmd_list "$@"
;;
Expand Down Expand Up @@ -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 <target>... [-n] [-a] [--from <source>] [-- <pattern>...]"
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 '-- <pattern>...' 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 "$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=""
Expand Down Expand Up @@ -1062,6 +1189,20 @@ CORE COMMANDS (daily workflow):
--force: force removal (dirty worktree)
--yes: skip confirmation

copy <target>... [options] [-- <pattern>...]
Copy files from main repo to worktree(s)
-n, --dry-run: preview without copying
-a, --all: copy to all worktrees
--from <source>: 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:
Expand Down
15 changes: 14 additions & 1 deletion completions/_git-gtr
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
13 changes: 12 additions & 1 deletion completions/gtr.bash
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions completions/gtr.fish
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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 "
Expand Down Expand Up @@ -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)'
44 changes: 30 additions & 14 deletions lib/copy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <<EOF
$(find . -path "./$pattern" -type f 2>/dev/null)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading