From 03e414420d2c607ce8c98743725fc7cc425b4f48 Mon Sep 17 00:00:00 2001 From: Shi Qi Date: Thu, 5 Nov 2026 15:16:34 -0800 Subject: [PATCH 1/3] fix: preserve exact worktree identities in copy --all --- lib/commands/copy.sh | 34 +++++++-- tests/cmd_copy.bats | 169 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 195 insertions(+), 8 deletions(-) diff --git a/lib/commands/copy.sh b/lib/commands/copy.sh index 087087e..48cae04 100644 --- a/lib/commands/copy.sh +++ b/lib/commands/copy.sh @@ -68,21 +68,31 @@ cmd_copy() { # Build target list for --all mode if [ "$all_mode" -eq 1 ]; then - local all_branches - all_branches=$(list_worktree_branches "$base_dir" "$prefix" "$repo_root") - if [ -z "$all_branches" ]; then + local records line is_main="" found_worktree=0 + records=$(list_worktree_records "$repo_root") || exit 1 + # Branch names cannot distinguish duplicate checkouts or detached HEADs. + # Keep paths escaped until resolve_worktree validates each exact identity. + while IFS= read -r line; do + case "$line" in + "is_main "*) is_main="${line#is_main }" ;; + "path "*) + if [ "$is_main" = "0" ]; then + targets+=("path:${line#path }") + found_worktree=1 + fi + ;; + esac + done <<< "$records" + if [ "$found_worktree" -eq 0 ]; then log_error "No worktrees found" exit 1 fi - local _branch - while IFS= read -r _branch; do - [ -n "$_branch" ] && targets+=("$_branch") - done <<< "$all_branches" fi # Process each target local copied_any=0 - local target_id + local target_id processed_path + local -a processed_paths=() for target_id in "${targets[@]}"; do local dst_path dst_branch resolve_worktree "$target_id" "$repo_root" "$base_dir" "$prefix" || continue @@ -92,6 +102,14 @@ cmd_copy() { # Skip if source == destination [ "$src_path" = "$dst_path" ] && continue + # Explicit targets can overlap --all, including via branch/folder aliases. + if [ "$all_mode" -eq 1 ]; then + for processed_path in "${processed_paths[@]}"; do + [ "$dst_path" = "$processed_path" ] && continue 2 + done + processed_paths+=("$dst_path") + fi + if [ "$dry_run" -eq 1 ]; then log_step "[dry-run] Would copy to: $dst_branch" if [ -n "$patterns" ]; then diff --git a/tests/cmd_copy.bats b/tests/cmd_copy.bats index a8e3a2c..bcd807b 100644 --- a/tests/cmd_copy.bats +++ b/tests/cmd_copy.bats @@ -185,6 +185,175 @@ teardown() { [ -f "$TEST_WORKTREES_DIR/copy-target-2/.env" ] } +@test "copy --all copies each registered checkout once including duplicate branches and detached HEAD" { + local duplicate="$TEST_WORKTREES_DIR/nested/duplicate" + local detached="$TEST_WORKTREES_DIR/detached" + local external="$(cd "$BATS_TEST_TMPDIR" && pwd -P)/external checkout" + git worktree add --quiet --force "$duplicate" copy-target + git worktree add --quiet --detach "$detached" HEAD + # A linked checkout of the main branch must not resolve back to the source. + git worktree add --quiet --force "$external" "$(get_current_branch "$TEST_REPO")" + mkdir -p "$TEST_WORKTREES_DIR/stray" + + run "$PROJECT_ROOT/bin/gtr" copy --all -- .env + + [ "$status" -eq 0 ] + local path + for path in "$TEST_WT_PATH" "$duplicate" "$detached" "$external"; do + [ "$(cat "$path/.env")" = "secret=value" ] + done + [ "$(printf '%s\n' "$output" | grep -cF 'Copied 1 file(s)')" -eq 4 ] + [ ! -e "$TEST_WORKTREES_DIR/nested/.env" ] + [ ! -e "$TEST_WORKTREES_DIR/stray/.env" ] +} + +@test "copy --all succeeds when the only linked worktree is detached" { + git worktree remove "$TEST_WT_PATH" + local detached="$TEST_WORKTREES_DIR/detached" + git worktree add --quiet --detach "$detached" HEAD + + run "$PROJECT_ROOT/bin/gtr" copy --all -- .env + + [ "$status" -eq 0 ] + [ "$(cat "$detached/.env")" = "secret=value" ] + [ "$(printf '%s\n' "$output" | grep -cF 'Copied 1 file(s)')" -eq 1 ] +} + +@test "copy --all --from skips only the source checkout of a shared branch" { + local duplicate="$TEST_WORKTREES_DIR/duplicate" + local detached="$TEST_WORKTREES_DIR/detached" + git worktree add --quiet --force "$duplicate" copy-target + git worktree add --quiet --detach "$detached" HEAD + echo 'source=value' > "$TEST_WT_PATH/.env" + + run "$PROJECT_ROOT/bin/gtr" copy --all --from copy-target -- .env + + [ "$status" -eq 0 ] + [ "$(cat "$duplicate/.env")" = 'source=value' ] + [ "$(cat "$detached/.env")" = 'source=value' ] + [ "$(cat "$TEST_WT_PATH/.env")" = 'source=value' ] + [ "$(cat "$TEST_REPO/.env")" = 'secret=value' ] + [ "$(printf '%s\n' "$output" | grep -cF 'Copied 1 file(s)')" -eq 2 ] +} + +@test "copy --all accepts a detached source and deduplicates overlapping explicit targets" { + local detached="$TEST_WORKTREES_DIR/detached" + git worktree add --quiet --detach "$detached" HEAD + echo 'source=value' > "$detached/.env" + + run "$PROJECT_ROOT/bin/gtr" copy 1 copy-target "path:$TEST_WT_PATH" \ + --all --from "path:$detached" -- .env + + [ "$status" -eq 0 ] + [ "$(cat "$TEST_WT_PATH/.env")" = 'source=value' ] + [ "$(cat "$TEST_REPO/.env")" = 'source=value' ] + [ "$(cat "$detached/.env")" = 'source=value' ] + [ "$(printf '%s\n' "$output" | grep -cF 'Copied 1 file(s)')" -eq 2 ] +} + +@test "copy --all preserves escaped path characters with old Git listing fallback" { + local path="$TEST_WORKTREES_DIR/\"quotes\" café \\n \\t \$(touch should-not-exist)"$'\t' + git worktree add --quiet --detach "$path" HEAD + git() { + case "$*" in *"worktree list --porcelain -z") return 1 ;; esac + command git "$@" + } + + run cmd_copy --all -- .env + + [ "$status" -eq 0 ] + [ "$(cat "$path/.env")" = 'secret=value' ] + [ "$(cat "$TEST_WT_PATH/.env")" = 'secret=value' ] + [ "$(printf '%s\n' "$output" | grep -cF 'Copied 1 file(s)')" -eq 2 ] + [ ! -e "$TEST_REPO/should-not-exist" ] +} + +@test "copy --all keeps literal escapes and trailing newlines in distinct registered paths" { + if ! git worktree list --porcelain -z >/dev/null 2>&1; then + skip "git worktree list --porcelain -z is not available" + fi + local newline_path="$TEST_WORKTREES_DIR/line"$'\n' + local literal_path="$TEST_WORKTREES_DIR/line\\n" + git worktree add --quiet --detach "$newline_path" HEAD + git worktree add --quiet --detach "$literal_path" HEAD + + run "$PROJECT_ROOT/bin/gtr" copy --all -- .env + + [ "$status" -eq 0 ] + [ "$(cat "$newline_path/.env")" = 'secret=value' ] + [ "$(cat "$literal_path/.env")" = 'secret=value' ] + [ "$(cat "$TEST_WT_PATH/.env")" = 'secret=value' ] + [ "$(printf '%s\n' "$output" | grep -cF 'Copied 1 file(s)')" -eq 3 ] + [ ! -e "$TEST_WORKTREES_DIR/line/.env" ] +} + +@test "copy --all dry-run and configured patterns cover each duplicate and detached checkout once" { + local duplicate="$TEST_WORKTREES_DIR/duplicate" + local detached="$TEST_WORKTREES_DIR/detached" + git worktree add --quiet --force "$duplicate" copy-target + git worktree add --quiet --detach "$detached" HEAD + mkdir -p "$TEST_REPO/vendor/cache" + echo settings > "$TEST_REPO/vendor/settings.json" + echo excluded > "$TEST_REPO/vendor/token.pem" + echo excluded > "$TEST_REPO/vendor/cache/data" + echo excluded > "$TEST_REPO/.env.private" + git config gtr.copy.include '.env*' + git config --add gtr.copy.exclude '.env.private' + git config --add gtr.copy.exclude '*.pem' + git config gtr.copy.includeDirs vendor/ + git config gtr.copy.excludeDirs vendor/cache + + run "$PROJECT_ROOT/bin/gtr" copy copy-target --all --dry-run + + [ "$status" -eq 0 ] + [ "$(printf '%s\n' "$output" | grep -cF 'Would copy 1 file(s)')" -eq 3 ] + [ "$(printf '%s\n' "$output" | grep -cF 'Would copy 1 directories')" -eq 3 ] + [ "$(printf '%s\n' "$output" | grep -cF 'Would copy to: copy-target')" -eq 2 ] + [ "$(printf '%s\n' "$output" | grep -cF 'Would copy to: (detached)')" -eq 1 ] + local path + for path in "$TEST_WT_PATH" "$duplicate" "$detached"; do + [ ! -e "$path/.env" ] + [ ! -e "$path/vendor" ] + done + + run "$PROJECT_ROOT/bin/gtr" copy copy-target --all + + [ "$status" -eq 0 ] + [ "$(printf '%s\n' "$output" | grep -cF 'Copied 1 file(s)')" -eq 3 ] + [ "$(printf '%s\n' "$output" | grep -cF 'Copied 1 directories')" -eq 3 ] + for path in "$TEST_WT_PATH" "$duplicate" "$detached"; do + [ "$(cat "$path/.env")" = 'secret=value' ] + [ "$(cat "$path/vendor/settings.json")" = settings ] + [ ! -e "$path/.env.private" ] + [ ! -e "$path/vendor/token.pem" ] + [ ! -e "$path/vendor/cache" ] + done +} + +@test "copy --all propagates copy failures in detached worktrees" { + local detached="$TEST_WORKTREES_DIR/detached" + git worktree add --quiet --detach "$detached" HEAD + mkdir "$detached/.env" + + run "$PROJECT_ROOT/bin/gtr" copy --all -- .env + + [ "$status" -eq 1 ] + [[ "$output" == *"Failed to copy .env"* ]] + [ -d "$detached/.env" ] + [ ! -e "$detached/.env/.env" ] +} + +@test "copy --all fails when only unregistered directories remain" { + git worktree remove "$TEST_WT_PATH" + mkdir "$TEST_WORKTREES_DIR/stray" + + run "$PROJECT_ROOT/bin/gtr" copy --all -- .env + + [ "$status" -eq 1 ] + [[ "$output" == *"No worktrees found"* ]] + [ ! -e "$TEST_WORKTREES_DIR/stray/.env" ] +} + @test "cmd_copy --all copies to nested registered worktree only" { mkdir -p "$TEST_WORKTREES_DIR/jsmith" git -C "$TEST_REPO" worktree add "$TEST_WORKTREES_DIR/jsmith/my-feature" -b jsmith/my-feature --quiet From d9482b65532e883b8d7c4e3b69978eb52e4c467a Mon Sep 17 00:00:00 2001 From: Shi Qi Date: Thu, 5 Nov 2026 15:25:55 -0800 Subject: [PATCH 2/3] test: support Git 2.17 in copy and resolver fixtures --- tests/cmd_copy.bats | 28 ++++++++++++++-------------- tests/core_resolve_target.bats | 32 ++++++++++++++++---------------- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/tests/cmd_copy.bats b/tests/cmd_copy.bats index bcd807b..640fd99 100644 --- a/tests/cmd_copy.bats +++ b/tests/cmd_copy.bats @@ -189,10 +189,10 @@ teardown() { local duplicate="$TEST_WORKTREES_DIR/nested/duplicate" local detached="$TEST_WORKTREES_DIR/detached" local external="$(cd "$BATS_TEST_TMPDIR" && pwd -P)/external checkout" - git worktree add --quiet --force "$duplicate" copy-target - git worktree add --quiet --detach "$detached" HEAD + git worktree add --force "$duplicate" copy-target + git worktree add --detach "$detached" HEAD # A linked checkout of the main branch must not resolve back to the source. - git worktree add --quiet --force "$external" "$(get_current_branch "$TEST_REPO")" + git worktree add --force "$external" "$(get_current_branch "$TEST_REPO")" mkdir -p "$TEST_WORKTREES_DIR/stray" run "$PROJECT_ROOT/bin/gtr" copy --all -- .env @@ -210,7 +210,7 @@ teardown() { @test "copy --all succeeds when the only linked worktree is detached" { git worktree remove "$TEST_WT_PATH" local detached="$TEST_WORKTREES_DIR/detached" - git worktree add --quiet --detach "$detached" HEAD + git worktree add --detach "$detached" HEAD run "$PROJECT_ROOT/bin/gtr" copy --all -- .env @@ -222,8 +222,8 @@ teardown() { @test "copy --all --from skips only the source checkout of a shared branch" { local duplicate="$TEST_WORKTREES_DIR/duplicate" local detached="$TEST_WORKTREES_DIR/detached" - git worktree add --quiet --force "$duplicate" copy-target - git worktree add --quiet --detach "$detached" HEAD + git worktree add --force "$duplicate" copy-target + git worktree add --detach "$detached" HEAD echo 'source=value' > "$TEST_WT_PATH/.env" run "$PROJECT_ROOT/bin/gtr" copy --all --from copy-target -- .env @@ -238,7 +238,7 @@ teardown() { @test "copy --all accepts a detached source and deduplicates overlapping explicit targets" { local detached="$TEST_WORKTREES_DIR/detached" - git worktree add --quiet --detach "$detached" HEAD + git worktree add --detach "$detached" HEAD echo 'source=value' > "$detached/.env" run "$PROJECT_ROOT/bin/gtr" copy 1 copy-target "path:$TEST_WT_PATH" \ @@ -253,7 +253,7 @@ teardown() { @test "copy --all preserves escaped path characters with old Git listing fallback" { local path="$TEST_WORKTREES_DIR/\"quotes\" café \\n \\t \$(touch should-not-exist)"$'\t' - git worktree add --quiet --detach "$path" HEAD + git worktree add --detach "$path" HEAD git() { case "$*" in *"worktree list --porcelain -z") return 1 ;; esac command git "$@" @@ -274,8 +274,8 @@ teardown() { fi local newline_path="$TEST_WORKTREES_DIR/line"$'\n' local literal_path="$TEST_WORKTREES_DIR/line\\n" - git worktree add --quiet --detach "$newline_path" HEAD - git worktree add --quiet --detach "$literal_path" HEAD + git worktree add --detach "$newline_path" HEAD + git worktree add --detach "$literal_path" HEAD run "$PROJECT_ROOT/bin/gtr" copy --all -- .env @@ -290,8 +290,8 @@ teardown() { @test "copy --all dry-run and configured patterns cover each duplicate and detached checkout once" { local duplicate="$TEST_WORKTREES_DIR/duplicate" local detached="$TEST_WORKTREES_DIR/detached" - git worktree add --quiet --force "$duplicate" copy-target - git worktree add --quiet --detach "$detached" HEAD + git worktree add --force "$duplicate" copy-target + git worktree add --detach "$detached" HEAD mkdir -p "$TEST_REPO/vendor/cache" echo settings > "$TEST_REPO/vendor/settings.json" echo excluded > "$TEST_REPO/vendor/token.pem" @@ -332,7 +332,7 @@ teardown() { @test "copy --all propagates copy failures in detached worktrees" { local detached="$TEST_WORKTREES_DIR/detached" - git worktree add --quiet --detach "$detached" HEAD + git worktree add --detach "$detached" HEAD mkdir "$detached/.env" run "$PROJECT_ROOT/bin/gtr" copy --all -- .env @@ -356,7 +356,7 @@ teardown() { @test "cmd_copy --all copies to nested registered worktree only" { mkdir -p "$TEST_WORKTREES_DIR/jsmith" - git -C "$TEST_REPO" worktree add "$TEST_WORKTREES_DIR/jsmith/my-feature" -b jsmith/my-feature --quiet + git -C "$TEST_REPO" worktree add "$TEST_WORKTREES_DIR/jsmith/my-feature" -b jsmith/my-feature run cmd_copy --all -- ".env" [ "$status" -eq 0 ] diff --git a/tests/core_resolve_target.bats b/tests/core_resolve_target.bats index c5acf89..b980beb 100644 --- a/tests/core_resolve_target.bats +++ b/tests/core_resolve_target.bats @@ -71,7 +71,7 @@ teardown() { @test "resolve_target prefers the exact registered branch over a colliding folder" { create_test_worktree "feat-login" - git worktree add "$TEST_WORKTREES_DIR/login-custom" -b feat/login --quiet + git worktree add "$TEST_WORKTREES_DIR/login-custom" -b feat/login resolve_worktree "feat/login" "$TEST_REPO" "$TEST_WORKTREES_DIR" "" [ "$_ctx_worktree_path" = "$TEST_WORKTREES_DIR/login-custom" ] @@ -79,8 +79,8 @@ teardown() { } @test "resolve_target prefers an external exact branch over a literal folder alias" { - git worktree add "$TEST_WORKTREES_DIR/alias" -b other-branch --quiet - git worktree add "${TEST_REPO}-external" -b alias --quiet + git worktree add "$TEST_WORKTREES_DIR/alias" -b other-branch + git worktree add "${TEST_REPO}-external" -b alias resolve_worktree "alias" "$TEST_REPO" "$TEST_WORKTREES_DIR" "" [ "$_ctx_worktree_path" = "${TEST_REPO}-external" ] @@ -97,8 +97,8 @@ teardown() { @test "resolve_target keeps prefixed custom folders and named duplicate worktrees" { create_worktree "$TEST_WORKTREES_DIR" "wt-" "feature/login" "HEAD" "none" "1" "0" >/dev/null - git worktree add --force "$TEST_WORKTREES_DIR/wt-feature-login-backend" feature/login --quiet - git worktree add "$TEST_WORKTREES_DIR/wt-custom folder" -b another-branch --quiet + git worktree add --force "$TEST_WORKTREES_DIR/wt-feature-login-backend" feature/login + git worktree add "$TEST_WORKTREES_DIR/wt-custom folder" -b another-branch resolve_worktree "feature/login" "$TEST_REPO" "$TEST_WORKTREES_DIR" "wt-" [ "$_ctx_worktree_path" = "$TEST_WORKTREES_DIR/wt-feature-login" ] @@ -110,7 +110,7 @@ teardown() { } @test "resolve_target keeps detached registered folder aliases" { - git worktree add --detach "$TEST_WORKTREES_DIR/review" HEAD --quiet + git worktree add --detach "$TEST_WORKTREES_DIR/review" HEAD resolve_worktree "review" "$TEST_REPO" "$TEST_WORKTREES_DIR" "" [ "$_ctx_worktree_path" = "$TEST_WORKTREES_DIR/review" ] @@ -157,7 +157,7 @@ teardown() { } @test "resolve_target ID 1 takes precedence over a registered branch named 1" { - git worktree add "$TEST_WORKTREES_DIR/numeric" -b 1 --quiet + git worktree add "$TEST_WORKTREES_DIR/numeric" -b 1 resolve_worktree "1" "$TEST_REPO" "$TEST_WORKTREES_DIR" "" [ "$_ctx_is_main" = "1" ] @@ -167,7 +167,7 @@ teardown() { @test "resolve_target main branch takes precedence over a duplicate worktree" { local main_branch main_branch=$(git rev-parse --abbrev-ref HEAD) - git worktree add --force "$TEST_WORKTREES_DIR/$main_branch" "$main_branch" --quiet + git worktree add --force "$TEST_WORKTREES_DIR/$main_branch" "$main_branch" resolve_worktree "$main_branch" "$TEST_REPO" "$TEST_WORKTREES_DIR" "" [ "$_ctx_is_main" = "1" ] @@ -224,7 +224,7 @@ teardown() { @test "resolve_worktree preserves tab in externally registered worktree path" { local tab_path="${TEST_REPO}-external"$'\t'"tab" - git -C "$TEST_REPO" worktree add "$tab_path" -b resolve-tab-path --quiet + git -C "$TEST_REPO" worktree add "$tab_path" -b resolve-tab-path local expected_path expected_path=$(cd "$tab_path" && pwd -P) @@ -245,7 +245,7 @@ teardown() { @test "resolve_target finds externally-created worktree via porcelain fallback" { # Create a worktree with raw git (outside gtr-managed directory) local ext_dir="${TEST_REPO}-external" - git -C "$TEST_REPO" worktree add "$ext_dir" -b external-branch --quiet + git -C "$TEST_REPO" worktree add "$ext_dir" -b external-branch local result result=$(resolve_target "external-branch" "$TEST_REPO" "$TEST_WORKTREES_DIR" "") # Assert: found with is_main=0, correct branch, path ends with expected suffix @@ -254,7 +254,7 @@ teardown() { @test "resolve_target finds nested externally-created worktree under base dir" { mkdir -p "$TEST_WORKTREES_DIR/jsmith" - git -C "$TEST_REPO" worktree add "$TEST_WORKTREES_DIR/jsmith/my-feature" -b jsmith/my-feature --quiet + git -C "$TEST_REPO" worktree add "$TEST_WORKTREES_DIR/jsmith/my-feature" -b jsmith/my-feature local result result=$(resolve_target "jsmith/my-feature" "$TEST_REPO" "$TEST_WORKTREES_DIR" "") @@ -266,7 +266,7 @@ teardown() { @test "list_worktree_records reports normal detached and locked worktrees" { create_test_worktree "records-normal" - git -C "$TEST_REPO" worktree add --detach "$TEST_WORKTREES_DIR/records-detached" HEAD --quiet + git -C "$TEST_REPO" worktree add --detach "$TEST_WORKTREES_DIR/records-detached" HEAD create_test_worktree "records-locked" git -C "$TEST_REPO" worktree lock "$TEST_WORKTREES_DIR/records-locked" --reason "test lock" @@ -289,7 +289,7 @@ teardown() { @test "worktree_status handles registered path containing tab" { local tab_path="$TEST_WORKTREES_DIR/with"$'\t'"tab" - git -C "$TEST_REPO" worktree add "$tab_path" -b tab-path --quiet + git -C "$TEST_REPO" worktree add "$tab_path" -b tab-path local status status=$(worktree_status "$tab_path") @@ -303,7 +303,7 @@ teardown() { fi local newline_path="${TEST_REPO}-external"$'\n'"newline" - git -C "$TEST_REPO" worktree add "$newline_path" -b newline-path --quiet + git -C "$TEST_REPO" worktree add "$newline_path" -b newline-path local expected_path expected_path=$(cd "$newline_path" && pwd -P) @@ -365,7 +365,7 @@ teardown() { @test "registered path identities select duplicates without changing normal target precedence" { create_test_worktree shared local selected="$TEST_WORKTREES_DIR/second" - git worktree add --quiet --force "$selected" shared + git worktree add --force "$selected" shared resolve_worktree "path:$selected" "$TEST_REPO" "$TEST_WORKTREES_DIR" "" [ "$_ctx_worktree_path" = "$selected" ] @@ -408,7 +408,7 @@ teardown() { @test "path identities preserve quotes tabs and shell syntax with old Git fallback" { local path="$TEST_WORKTREES_DIR/\"quotes\" café \\ \$(touch should-not-exist)"$'\t' - git worktree add --quiet --detach "$path" HEAD + git worktree add --detach "$path" HEAD git() { case "$*" in *"worktree list --porcelain -z") return 1 ;; esac command git "$@" From 3561df4d837f6118b3cffe17ed444442eef4a72a Mon Sep 17 00:00:00 2001 From: Shi Qi Date: Thu, 5 Nov 2026 19:45:58 -0800 Subject: [PATCH 3/3] fix: fail copy --all when no worktree resolves --- lib/commands/copy.sh | 11 +++++++- tests/cmd_copy.bats | 60 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/lib/commands/copy.sh b/lib/commands/copy.sh index 48cae04..687a0f6 100644 --- a/lib/commands/copy.sh +++ b/lib/commands/copy.sh @@ -90,7 +90,7 @@ cmd_copy() { fi # Process each target - local copied_any=0 + local copied_any=0 resolved_worktree=0 local target_id processed_path local -a processed_paths=() for target_id in "${targets[@]}"; do @@ -99,6 +99,10 @@ cmd_copy() { dst_path="$_ctx_worktree_path" dst_branch="$_ctx_branch" + # Count resolved non-main targets before excluding the source so a + # source-only --all remains a valid no-op. + [ "$_ctx_is_main" = "0" ] && resolved_worktree=1 + # Skip if source == destination [ "$src_path" = "$dst_path" ] && continue @@ -130,6 +134,11 @@ cmd_copy() { copied_any=1 done + if [ "$all_mode" -eq 1 ] && [ "$resolved_worktree" -eq 0 ]; then + log_error "No worktrees found" + return 1 + fi + if [ "$copied_any" -eq 0 ]; then log_warn "No files copied (source and target may be the same)" fi diff --git a/tests/cmd_copy.bats b/tests/cmd_copy.bats index 640fd99..1798757 100644 --- a/tests/cmd_copy.bats +++ b/tests/cmd_copy.bats @@ -354,6 +354,66 @@ teardown() { [ ! -e "$TEST_WORKTREES_DIR/stray/.env" ] } +@test "copy --all fails when its only registered detached worktree is missing" { + git worktree remove "$TEST_WT_PATH" + local detached="$TEST_WORKTREES_DIR/detached" + git worktree add --detach "$detached" HEAD + rm -rf "$detached" + + run cmd_copy --all -- .env + [ "$status" -eq 1 ] + [[ "$output" == *"No worktrees found"* ]] + + run "$PROJECT_ROOT/bin/gtr" copy --all --dry-run -- .env + [ "$status" -eq 1 ] + [[ "$output" == *"No worktrees found"* ]] + [ ! -e "$detached" ] +} + +@test "copy --all fails when a missing attached worktree and explicit main source are its only targets" { + rm -rf "$TEST_WT_PATH" + + run "$PROJECT_ROOT/bin/gtr" copy 1 --all -- .env + + [ "$status" -eq 1 ] + [[ "$output" == *"No worktrees found"* ]] + [ "$(cat "$TEST_REPO/.env")" = 'secret=value' ] + [ ! -e "$TEST_WT_PATH" ] +} + +@test "copy --all succeeds for a live destination alongside a missing worktree" { + local detached="$TEST_WORKTREES_DIR/detached" + git worktree add --detach "$detached" HEAD + rm -rf "$detached" + + run "$PROJECT_ROOT/bin/gtr" copy --all --dry-run -- .env + [ "$status" -eq 0 ] + [ "$(printf '%s\n' "$output" | grep -cF 'Would copy 1 file(s)')" -eq 1 ] + [ ! -e "$TEST_WT_PATH/.env" ] + [ ! -e "$detached" ] + + run "$PROJECT_ROOT/bin/gtr" copy --all -- .env + [ "$status" -eq 0 ] + [ "$(printf '%s\n' "$output" | grep -cF 'Copied 1 file(s)')" -eq 1 ] + [ "$(cat "$TEST_WT_PATH/.env")" = 'secret=value' ] + [ ! -e "$detached" ] +} + +@test "copy --all remains a successful no-op when only its source worktree resolves" { + local detached="$TEST_WORKTREES_DIR/detached" + git worktree add --detach "$detached" HEAD + rm -rf "$detached" + echo 'source=value' > "$TEST_WT_PATH/.env" + + run "$PROJECT_ROOT/bin/gtr" copy --all --from copy-target -- .env + + [ "$status" -eq 0 ] + [[ "$output" == *"No files copied"* ]] + [ "$(cat "$TEST_WT_PATH/.env")" = 'source=value' ] + [ "$(cat "$TEST_REPO/.env")" = 'secret=value' ] + [ ! -e "$detached" ] +} + @test "cmd_copy --all copies to nested registered worktree only" { mkdir -p "$TEST_WORKTREES_DIR/jsmith" git -C "$TEST_REPO" worktree add "$TEST_WORKTREES_DIR/jsmith/my-feature" -b jsmith/my-feature