Skip to content

Commit 424036e

Browse files
frenchie4111claude
andauthored
Fix rename_worktree refusing every freshly created worktree (#293)
## Summary - `renameWorktreeBranch` refused the rename whenever `@{upstream}` was set. Git's default `branch.autoSetupMerge` points a branch cut from `origin/main` at `origin/main`, so **every** worktree Ness creates looked already-published — the kickoff-prompt rename flow failed 100% of the time with `branch "..." is already published (tracking origin/main)`. - The guard now looks for a remote ref named after the branch itself (`refs/remotes/*/<branch>`), falling back to the upstream only when the upstream names that same branch. That also closes a hole in the old check: a plain `git push origin <branch>` updates the remote-tracking ref without setting an upstream, and used to sail straight through. - Added `src/main/worktree-rename.integration.test.ts` — real git, no mocks. ## Test plan - [x] `npx vitest run src/main/worktree-rename.integration.test.ts` — renames succeed for a fresh branch tracking `origin/main` and for a slash-containing name; still refused after `push -u`, after a plain `push`, and for a published slashed branch; target-name collision still reports the existing branch. - [x] `npm run typecheck` - [x] `npx electron-vite build` - [x] `npx vitest run` — remaining failures (`git-ops-state`, `path-fix`, `worktree-watcher`) are pre-existing timeouts under parallel load and pass in isolation. - [x] Verified the new `for-each-ref` glob against this repo: the unpushed branch matches nothing, `main` matches all seven remotes' refs. Note: a running Ness carries the old main-process code, so the tool keeps refusing until the app restarts on this build. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent f516f96 commit 424036e

2 files changed

Lines changed: 164 additions & 8 deletions

File tree

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
2+
import fs from 'fs'
3+
import os from 'os'
4+
import { execFileSync } from 'child_process'
5+
import { join } from 'path'
6+
7+
import { renameWorktreeBranch } from './worktree'
8+
9+
// REAL git, no mocks. A freshly created worktree tracks the base it was cut
10+
// from (origin/main) thanks to git's default branch.autoSetupMerge, which is
11+
// NOT the same thing as having been published.
12+
13+
function git(cwd: string, ...args: string[]): string {
14+
return execFileSync('git', args, { cwd, stdio: 'pipe' }).toString()
15+
}
16+
17+
let tmp: string
18+
let clone: string
19+
20+
beforeAll(() => {
21+
tmp = fs.mkdtempSync(join(os.tmpdir(), 'wt-rename-'))
22+
23+
const seed = join(tmp, 'seed')
24+
fs.mkdirSync(seed)
25+
git(seed, 'init', '-q', '-b', 'main')
26+
git(seed, 'config', 'user.email', 't@t.t')
27+
git(seed, 'config', 'user.name', 'T')
28+
fs.writeFileSync(join(seed, 'seed.txt'), 'seed\n')
29+
git(seed, 'add', '.')
30+
git(seed, 'commit', '-q', '-m', 'seed')
31+
32+
const origin = join(tmp, 'origin.git')
33+
execFileSync('git', ['clone', '-q', '--bare', seed, origin], { stdio: 'pipe' })
34+
35+
clone = join(tmp, 'clone')
36+
execFileSync('git', ['clone', '-q', origin, clone], { stdio: 'pipe' })
37+
git(clone, 'config', 'user.email', 't@t.t')
38+
git(clone, 'config', 'user.name', 'T')
39+
})
40+
41+
afterAll(() => {
42+
fs.rmSync(tmp, { recursive: true, force: true })
43+
})
44+
45+
/** A worktree cut from origin/main, exactly as addWorktree creates one. */
46+
function makeWorktree(branch: string): string {
47+
const path = join(tmp, branch.replace(/\//g, '-'))
48+
git(clone, 'worktree', 'add', '-q', path, '-b', branch, 'origin/main')
49+
return path
50+
}
51+
52+
function upstreamOf(cwd: string): string {
53+
try {
54+
return git(cwd, 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}').trim()
55+
} catch {
56+
return ''
57+
}
58+
}
59+
60+
function headOf(cwd: string): string {
61+
return git(cwd, 'symbolic-ref', '--short', 'HEAD').trim()
62+
}
63+
64+
describe('renameWorktreeBranch (real git)', () => {
65+
it('renames a fresh worktree branch even though it tracks origin/main', async () => {
66+
const worktree = makeWorktree('fresh')
67+
expect(upstreamOf(worktree)).toBe('origin/main')
68+
69+
const result = await renameWorktreeBranch(worktree, 'fresh-renamed')
70+
71+
expect(result).toEqual({
72+
ok: true,
73+
oldBranch: 'fresh',
74+
branch: 'fresh-renamed',
75+
renamed: true
76+
})
77+
expect(headOf(worktree)).toBe('fresh-renamed')
78+
})
79+
80+
it('renames a branch whose name contains a slash', async () => {
81+
const worktree = makeWorktree('fix/login')
82+
83+
const result = await renameWorktreeBranch(worktree, 'fix/logout')
84+
85+
expect(result.ok).toBe(true)
86+
expect(headOf(worktree)).toBe('fix/logout')
87+
})
88+
89+
it('refuses once the branch has been pushed with -u', async () => {
90+
const worktree = makeWorktree('pushed-tracking')
91+
git(worktree, 'push', '-q', '-u', 'origin', 'pushed-tracking')
92+
93+
const result = await renameWorktreeBranch(worktree, 'pushed-tracking-renamed')
94+
95+
expect(result.ok).toBe(false)
96+
if (!result.ok) expect(result.error).toContain('origin/pushed-tracking')
97+
expect(headOf(worktree)).toBe('pushed-tracking')
98+
})
99+
100+
it('refuses after a plain push that left the upstream on origin/main', async () => {
101+
const worktree = makeWorktree('pushed-plain')
102+
git(worktree, 'push', '-q', 'origin', 'pushed-plain')
103+
expect(upstreamOf(worktree)).toBe('origin/main')
104+
105+
const result = await renameWorktreeBranch(worktree, 'pushed-plain-renamed')
106+
107+
expect(result.ok).toBe(false)
108+
expect(headOf(worktree)).toBe('pushed-plain')
109+
})
110+
111+
it('refuses a published branch whose name contains a slash', async () => {
112+
const worktree = makeWorktree('feat/published')
113+
git(worktree, 'push', '-q', '-u', 'origin', 'feat/published')
114+
115+
const result = await renameWorktreeBranch(worktree, 'feat/renamed')
116+
117+
expect(result.ok).toBe(false)
118+
if (!result.ok) expect(result.error).toContain('origin/feat/published')
119+
})
120+
121+
it('reports the existing branch by name when the target is taken', async () => {
122+
const worktree = makeWorktree('collides')
123+
git(clone, 'branch', 'taken')
124+
125+
const result = await renameWorktreeBranch(worktree, 'taken')
126+
127+
expect(result.ok).toBe(false)
128+
if (!result.ok) expect(result.error).toContain('"taken" already exists')
129+
})
130+
})

src/main/worktree.ts

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1290,6 +1290,37 @@ export type RenameBranchResult =
12901290
| { ok: true; oldBranch: string; branch: string; renamed: boolean }
12911291
| { ok: false; error: string }
12921292

1293+
/**
1294+
* The remote ref `branch` would keep pushing to after a rename, or '' if it
1295+
* has never been pushed.
1296+
*
1297+
* A tracked upstream on its own does NOT mean published: git's default
1298+
* `branch.autoSetupMerge` points a branch cut from `origin/main` at
1299+
* `origin/main`, so every freshly created worktree looks tracked. What
1300+
* matters is whether a remote ref named after *this branch* exists — that's
1301+
* the ref a renamed branch would silently keep pushing to. The upstream is
1302+
* only considered when it names this branch (covers a branch whose remote
1303+
* counterpart was deleted, which `git push` would recreate under the old
1304+
* name).
1305+
*/
1306+
async function publishedRemoteRef(worktreePath: string, branch: string): Promise<string> {
1307+
const remoteRef = await execGitRead(
1308+
['for-each-ref', '--format=%(refname:short)', `refs/remotes/*/${branch}`],
1309+
{ cwd: worktreePath }
1310+
)
1311+
.then(({ stdout }) => stdout.trim().split('\n')[0]?.trim() || '')
1312+
.catch(() => '')
1313+
if (remoteRef) return remoteRef
1314+
1315+
const upstream = await execGitRead(
1316+
['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'],
1317+
{ cwd: worktreePath }
1318+
)
1319+
.then(({ stdout }) => stdout.trim())
1320+
.catch(() => '')
1321+
return upstream.endsWith(`/${branch}`) ? upstream : ''
1322+
}
1323+
12931324
/**
12941325
* Rename the branch a worktree has checked out (`git branch -m`). The
12951326
* directory on disk keeps its original name — it's the key every other
@@ -1317,17 +1348,12 @@ export async function renameWorktreeBranch(
13171348
return { ok: true, oldBranch, branch: newBranch, renamed: false }
13181349
}
13191350

1320-
const upstream = await execGitRead(
1321-
['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'],
1322-
{ cwd: worktreePath }
1323-
)
1324-
.then(({ stdout }) => stdout.trim())
1325-
.catch(() => '')
1326-
if (upstream) {
1351+
const remoteRef = await publishedRemoteRef(worktreePath, oldBranch)
1352+
if (remoteRef) {
13271353
return {
13281354
ok: false,
13291355
error:
1330-
`branch "${oldBranch}" is already published (tracking ${upstream}) — renaming it locally ` +
1356+
`branch "${oldBranch}" is already published (pushed to ${remoteRef}) — renaming it locally ` +
13311357
`would leave it pushing to the old remote branch. Set a display alias instead.`
13321358
}
13331359
}

0 commit comments

Comments
 (0)