Skip to content

Commit de0f109

Browse files
Matthieu Ciapparaclaude
andcommitted
feat: diagnose existing-branch failures in wtclone add
`wtclone add` previously rejected any pre-existing local branch ref with a single generic message ("already exists locally; use 'git worktree add ...' directly"). That conflated four meaningfully different states and gave the same suggestion to all of them, including ones where it was wrong (branch checked out elsewhere) or unsafe (local diverged from origin and the user might lose unpushed work). Replace with a `diagnose_existing_branch` helper that distinguishes: 1. branch checked out in another worktree → name the worktree path; 2. orphan ref matching origin/<branch> → "safe to attach" + the single attach command; 3. orphan ref diverged from origin → show ahead/behind counts and offer both the attach-and-keep path and the `branch -D` + recreate path; 4. orphan ref with no origin counterpart → flag as local-only and suggest the direct attach. Move `git fetch origin --prune` ahead of the existing-branch check so the divergence calculation in case 3 runs against fresh remote-tracking refs rather than potentially stale ones. The success path still calls fetch exactly once. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d7158d7 commit de0f109

3 files changed

Lines changed: 134 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Changed
1111

1212
- `wtclone rm` now detects gitignored entries (caches, build artifacts, vendored deps) before delegating to `git worktree remove`. These pass git's cleanliness check but block the final `rmdir` with the cryptic `failed to delete '...': Directory not empty`. The new pre-check fails with a message that names the offending entries and points to the two remediation paths: `wtclone rm <branch> --force`, or `git -C <wt_path> clean -fdX` to clean first. Behavior is unchanged — `--force` still bypasses the check.
13+
- `wtclone add` now diagnoses the "branch already exists locally" failure with a tailored message per case: (1) branch checked out in another worktree (shows the worktree path); (2) orphan ref matching `origin/<branch>` (suggests the safe `git worktree add <branch>` attach); (3) orphan ref diverged from origin (reports ahead/behind counts and offers both attach-and-keep and `branch -D` + recreate paths); (4) orphan ref with no remote counterpart (suggests local-only attach). The `fetch origin` step now runs before the existing-branch check so the diagnosis works against fresh remote-tracking refs.
1314

1415
## [0.2.0] - 2026-04-24
1516

bin/wtclone

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -134,19 +134,87 @@ cmd_init() {
134134

135135
# ---- cmd_add -------------------------------------------------------------
136136

137+
# Print a tailored diagnosis for an existing refs/heads/<branch> that blocks
138+
# `wtclone add`. Distinguishes (1) checked out in another worktree, (2) orphan
139+
# ref matching origin, (3) orphan ref diverged from origin, (4) orphan ref with
140+
# no origin counterpart. Writes to stderr; caller is responsible for exit.
141+
diagnose_existing_branch() {
142+
local branch="$1" checked_out_at
143+
checked_out_at=$(git --git-dir=.bare worktree list --porcelain | awk -v b="refs/heads/$branch" '
144+
$1=="worktree" { path=$2 }
145+
$1=="branch" && $2==b { print path; exit }
146+
')
147+
if [ -n "$checked_out_at" ]; then
148+
{
149+
printf "wtclone: branch '%s' is already checked out at:\n" "$branch"
150+
printf " %s\n" "$checked_out_at"
151+
printf '\n'
152+
printf "wtclone: a branch can only be checked out in one worktree at a time.\n"
153+
} >&2
154+
return
155+
fi
156+
157+
local local_sha
158+
local_sha=$(git --git-dir=.bare rev-parse --short "refs/heads/$branch")
159+
160+
if ! git --git-dir=.bare show-ref --verify --quiet "refs/remotes/origin/$branch"; then
161+
{
162+
printf "wtclone: branch '%s' exists locally with no worktree attached (orphan ref).\n" "$branch"
163+
printf " local: refs/heads/%s @ %s\n" "$branch" "$local_sha"
164+
printf " origin: (no refs/remotes/origin/%s — branch is not on the remote)\n" "$branch"
165+
printf '\n'
166+
printf "wtclone: this is a local-only branch; attach it with:\n"
167+
printf " git worktree add %s\n" "$branch"
168+
} >&2
169+
return
170+
fi
171+
172+
local origin_sha
173+
origin_sha=$(git --git-dir=.bare rev-parse --short "refs/remotes/origin/$branch")
174+
if [ "$local_sha" = "$origin_sha" ]; then
175+
{
176+
printf "wtclone: branch '%s' exists locally with no worktree attached (orphan ref).\n" "$branch"
177+
printf " local: refs/heads/%s @ %s\n" "$branch" "$local_sha"
178+
printf " origin: refs/remotes/origin/%s @ %s\n" "$branch" "$origin_sha"
179+
printf '\n'
180+
printf "wtclone: local matches origin — safe to attach with:\n"
181+
printf " git worktree add %s\n" "$branch"
182+
} >&2
183+
return
184+
fi
185+
186+
local ahead behind
187+
ahead=$(git --git-dir=.bare rev-list --count "refs/remotes/origin/$branch..refs/heads/$branch" 2>/dev/null || echo "?")
188+
behind=$(git --git-dir=.bare rev-list --count "refs/heads/$branch..refs/remotes/origin/$branch" 2>/dev/null || echo "?")
189+
{
190+
printf "wtclone: branch '%s' exists locally with no worktree attached (orphan ref).\n" "$branch"
191+
printf " local: refs/heads/%s @ %s\n" "$branch" "$local_sha"
192+
printf " origin: refs/remotes/origin/%s @ %s\n" "$branch" "$origin_sha"
193+
printf " divergence: %s ahead, %s behind origin/%s\n" "$ahead" "$behind" "$branch"
194+
printf '\n'
195+
printf "wtclone: local and origin differ. Choose:\n"
196+
printf " - attach the local branch (keeps any unpushed work):\n"
197+
printf " git worktree add %s\n" "$branch"
198+
printf " - discard the local ref and re-create from origin/%s:\n" "$branch"
199+
printf " git --git-dir=.bare branch -D %s\n" "$branch"
200+
printf " wtclone add %s\n" "$branch"
201+
} >&2
202+
}
203+
137204
cmd_add() {
138205
local branch="${1:-}" base="${2:-}"
139206
[ -n "$branch" ] || die "usage: wtclone add <branch> [base]"
140207

141208
ensure_layout
142209

143-
if git --git-dir=.bare show-ref --verify --quiet "refs/heads/$branch"; then
144-
die "branch '$branch' already exists locally; use 'git worktree add $branch' directly"
145-
fi
146-
147210
log "fetching origin"
148211
git --git-dir=.bare fetch origin --prune 1>&2
149212

213+
if git --git-dir=.bare show-ref --verify --quiet "refs/heads/$branch"; then
214+
diagnose_existing_branch "$branch"
215+
exit 1
216+
fi
217+
150218
if git --git-dir=.bare show-ref --verify --quiet "refs/remotes/origin/$branch"; then
151219
log "worktree $branch will track origin/$branch"
152220
git worktree add -b "$branch" "$branch" "origin/$branch" 1>&2

test/wtclone.bats

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,7 @@ make_v010_layout() {
343343
rm -rf "$src" "$dest"
344344
}
345345

346-
@test "add: refuses when branch already exists locally" {
346+
@test "add: refuses when branch is checked out in another worktree" {
347347
local src dest
348348
src=$(mktemp -d); dest=$(mktemp -d)
349349
make_fixture_remote "$src"
@@ -352,7 +352,66 @@ make_v010_layout() {
352352
cd "$dest/proj"
353353
run "$BIN" add main
354354
[ "$status" -ne 0 ]
355-
[[ "$output" == *"already exists locally"* ]]
355+
[[ "$output" == *"already checked out at"* ]]
356+
[[ "$output" == *"$dest/proj/main"* ]]
357+
358+
rm -rf "$src" "$dest"
359+
}
360+
361+
@test "add: orphan ref matching origin → suggests safe attach" {
362+
local src dest
363+
src=$(mktemp -d); dest=$(mktemp -d)
364+
make_fixture_remote "$src"
365+
WTCLONE_ROOT="$dest" "$BIN" init "$src" proj 1>/dev/null
366+
367+
cd "$dest/proj"
368+
# Create a refs/heads/staging pointing at origin/staging, no worktree.
369+
git --git-dir=.bare branch staging origin/staging
370+
371+
run "$BIN" add staging
372+
[ "$status" -ne 0 ]
373+
[[ "$output" == *"orphan ref"* ]]
374+
[[ "$output" == *"local matches origin"* ]]
375+
[[ "$output" == *"git worktree add staging"* ]]
376+
377+
rm -rf "$src" "$dest"
378+
}
379+
380+
@test "add: orphan ref diverged from origin → shows ahead/behind and both options" {
381+
local src dest
382+
src=$(mktemp -d); dest=$(mktemp -d)
383+
make_fixture_remote "$src"
384+
WTCLONE_ROOT="$dest" "$BIN" init "$src" proj 1>/dev/null
385+
386+
cd "$dest/proj"
387+
# Orphan ref pointing at origin/main (different SHA than origin/staging).
388+
git --git-dir=.bare branch staging origin/main
389+
390+
run "$BIN" add staging
391+
[ "$status" -ne 0 ]
392+
[[ "$output" == *"orphan ref"* ]]
393+
[[ "$output" == *"local and origin differ"* ]]
394+
[[ "$output" == *"divergence:"* ]]
395+
[[ "$output" == *"branch -D staging"* ]]
396+
[[ "$output" == *"git worktree add staging"* ]]
397+
398+
rm -rf "$src" "$dest"
399+
}
400+
401+
@test "add: orphan ref with no origin counterpart → suggests local-only attach" {
402+
local src dest
403+
src=$(mktemp -d); dest=$(mktemp -d)
404+
make_fixture_remote "$src"
405+
WTCLONE_ROOT="$dest" "$BIN" init "$src" proj 1>/dev/null
406+
407+
cd "$dest/proj"
408+
git --git-dir=.bare branch only-local origin/main
409+
410+
run "$BIN" add only-local
411+
[ "$status" -ne 0 ]
412+
[[ "$output" == *"orphan ref"* ]]
413+
[[ "$output" == *"not on the remote"* ]]
414+
[[ "$output" == *"git worktree add only-local"* ]]
356415

357416
rm -rf "$src" "$dest"
358417
}

0 commit comments

Comments
 (0)