From 1c0bda4643cedf4e0606bd5bedef78619457ba1c Mon Sep 17 00:00:00 2001 From: Mike Lyons Date: Fri, 21 Aug 2026 20:06:44 -0400 Subject: [PATCH] Fix rename_worktree refusing every freshly created worktree The published-branch guard treated any configured upstream as proof the branch had been pushed. Git's default branch.autoSetupMerge points a branch cut from origin/main at origin/main, so every new worktree looked published and the kickoff-prompt rename flow failed 100% of the time. Check for a remote ref named after the branch itself instead, which also catches a plain `git push origin ` that never set an upstream. Co-Authored-By: Claude Opus 4.7 --- src/main/worktree-rename.integration.test.ts | 130 +++++++++++++++++++ src/main/worktree.ts | 42 ++++-- 2 files changed, 164 insertions(+), 8 deletions(-) create mode 100644 src/main/worktree-rename.integration.test.ts diff --git a/src/main/worktree-rename.integration.test.ts b/src/main/worktree-rename.integration.test.ts new file mode 100644 index 00000000..7a8632ff --- /dev/null +++ b/src/main/worktree-rename.integration.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import fs from 'fs' +import os from 'os' +import { execFileSync } from 'child_process' +import { join } from 'path' + +import { renameWorktreeBranch } from './worktree' + +// REAL git, no mocks. A freshly created worktree tracks the base it was cut +// from (origin/main) thanks to git's default branch.autoSetupMerge, which is +// NOT the same thing as having been published. + +function git(cwd: string, ...args: string[]): string { + return execFileSync('git', args, { cwd, stdio: 'pipe' }).toString() +} + +let tmp: string +let clone: string + +beforeAll(() => { + tmp = fs.mkdtempSync(join(os.tmpdir(), 'wt-rename-')) + + const seed = join(tmp, 'seed') + fs.mkdirSync(seed) + git(seed, 'init', '-q', '-b', 'main') + git(seed, 'config', 'user.email', 't@t.t') + git(seed, 'config', 'user.name', 'T') + fs.writeFileSync(join(seed, 'seed.txt'), 'seed\n') + git(seed, 'add', '.') + git(seed, 'commit', '-q', '-m', 'seed') + + const origin = join(tmp, 'origin.git') + execFileSync('git', ['clone', '-q', '--bare', seed, origin], { stdio: 'pipe' }) + + clone = join(tmp, 'clone') + execFileSync('git', ['clone', '-q', origin, clone], { stdio: 'pipe' }) + git(clone, 'config', 'user.email', 't@t.t') + git(clone, 'config', 'user.name', 'T') +}) + +afterAll(() => { + fs.rmSync(tmp, { recursive: true, force: true }) +}) + +/** A worktree cut from origin/main, exactly as addWorktree creates one. */ +function makeWorktree(branch: string): string { + const path = join(tmp, branch.replace(/\//g, '-')) + git(clone, 'worktree', 'add', '-q', path, '-b', branch, 'origin/main') + return path +} + +function upstreamOf(cwd: string): string { + try { + return git(cwd, 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}').trim() + } catch { + return '' + } +} + +function headOf(cwd: string): string { + return git(cwd, 'symbolic-ref', '--short', 'HEAD').trim() +} + +describe('renameWorktreeBranch (real git)', () => { + it('renames a fresh worktree branch even though it tracks origin/main', async () => { + const worktree = makeWorktree('fresh') + expect(upstreamOf(worktree)).toBe('origin/main') + + const result = await renameWorktreeBranch(worktree, 'fresh-renamed') + + expect(result).toEqual({ + ok: true, + oldBranch: 'fresh', + branch: 'fresh-renamed', + renamed: true + }) + expect(headOf(worktree)).toBe('fresh-renamed') + }) + + it('renames a branch whose name contains a slash', async () => { + const worktree = makeWorktree('fix/login') + + const result = await renameWorktreeBranch(worktree, 'fix/logout') + + expect(result.ok).toBe(true) + expect(headOf(worktree)).toBe('fix/logout') + }) + + it('refuses once the branch has been pushed with -u', async () => { + const worktree = makeWorktree('pushed-tracking') + git(worktree, 'push', '-q', '-u', 'origin', 'pushed-tracking') + + const result = await renameWorktreeBranch(worktree, 'pushed-tracking-renamed') + + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error).toContain('origin/pushed-tracking') + expect(headOf(worktree)).toBe('pushed-tracking') + }) + + it('refuses after a plain push that left the upstream on origin/main', async () => { + const worktree = makeWorktree('pushed-plain') + git(worktree, 'push', '-q', 'origin', 'pushed-plain') + expect(upstreamOf(worktree)).toBe('origin/main') + + const result = await renameWorktreeBranch(worktree, 'pushed-plain-renamed') + + expect(result.ok).toBe(false) + expect(headOf(worktree)).toBe('pushed-plain') + }) + + it('refuses a published branch whose name contains a slash', async () => { + const worktree = makeWorktree('feat/published') + git(worktree, 'push', '-q', '-u', 'origin', 'feat/published') + + const result = await renameWorktreeBranch(worktree, 'feat/renamed') + + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error).toContain('origin/feat/published') + }) + + it('reports the existing branch by name when the target is taken', async () => { + const worktree = makeWorktree('collides') + git(clone, 'branch', 'taken') + + const result = await renameWorktreeBranch(worktree, 'taken') + + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error).toContain('"taken" already exists') + }) +}) diff --git a/src/main/worktree.ts b/src/main/worktree.ts index 9c61b55a..66439c06 100644 --- a/src/main/worktree.ts +++ b/src/main/worktree.ts @@ -1290,6 +1290,37 @@ export type RenameBranchResult = | { ok: true; oldBranch: string; branch: string; renamed: boolean } | { ok: false; error: string } +/** + * The remote ref `branch` would keep pushing to after a rename, or '' if it + * has never been pushed. + * + * A tracked upstream on its own does NOT mean published: git's default + * `branch.autoSetupMerge` points a branch cut from `origin/main` at + * `origin/main`, so every freshly created worktree looks tracked. What + * matters is whether a remote ref named after *this branch* exists — that's + * the ref a renamed branch would silently keep pushing to. The upstream is + * only considered when it names this branch (covers a branch whose remote + * counterpart was deleted, which `git push` would recreate under the old + * name). + */ +async function publishedRemoteRef(worktreePath: string, branch: string): Promise { + const remoteRef = await execGitRead( + ['for-each-ref', '--format=%(refname:short)', `refs/remotes/*/${branch}`], + { cwd: worktreePath } + ) + .then(({ stdout }) => stdout.trim().split('\n')[0]?.trim() || '') + .catch(() => '') + if (remoteRef) return remoteRef + + const upstream = await execGitRead( + ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], + { cwd: worktreePath } + ) + .then(({ stdout }) => stdout.trim()) + .catch(() => '') + return upstream.endsWith(`/${branch}`) ? upstream : '' +} + /** * Rename the branch a worktree has checked out (`git branch -m`). The * directory on disk keeps its original name — it's the key every other @@ -1317,17 +1348,12 @@ export async function renameWorktreeBranch( return { ok: true, oldBranch, branch: newBranch, renamed: false } } - const upstream = await execGitRead( - ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], - { cwd: worktreePath } - ) - .then(({ stdout }) => stdout.trim()) - .catch(() => '') - if (upstream) { + const remoteRef = await publishedRemoteRef(worktreePath, oldBranch) + if (remoteRef) { return { ok: false, error: - `branch "${oldBranch}" is already published (tracking ${upstream}) — renaming it locally ` + + `branch "${oldBranch}" is already published (pushed to ${remoteRef}) — renaming it locally ` + `would leave it pushing to the old remote branch. Set a display alias instead.` } }