Skip to content

Commit 6ea90b0

Browse files
authored
feat(cli): add nn w attach subcommand for existing branches (#11)
* feat(cli): add nn w attach subcommand for existing branches Attach a worktree to an existing local or remote branch and populate state.pr via gh lookup so /nn-dev can resume review polling. Extracts showExitPrompt + abnormal-exit handling into _shared.ts for reuse between create and attach. * fix: address review feedback * fix: address local review findings - branchExists: use `show-ref --verify` instead of `branch --list` to avoid glob expansion - attach: verify-before-strip `origin/` prefix so legitimate `origin/foo` branches survive - attach: print "Updated PR: #N" when resuming with --pr so state mutation isn't silent - remove stale reviews/ artifact * fix: address review feedback - fetchBranch: also set upstream tracking so aheadBehind/exit-prompt safeguards work correctly on attached branches. Use plain `git fetch <remote> <branch>` (which updates refs/remotes via the default refspec) instead of the explicit `<branch>:<branch>` refspec that would leave --set-upstream-to with no remote ref to point at. - remoteBranchExists: let errors propagate instead of swallowing them as "branch not found"; add a scoped try/catch at the call site in attach.ts. - origin/ verify-before-strip: drop the remote probe (too fragile under network failure) — local-only check is enough to protect legit `origin/foo` branches. * fix: address review feedback - attach: error out when resume target worktree is on a different branch than requested, instead of silently ignoring the requested branch - attach: rename misleading "gh not available" message to "PR lookup failed" * fix: address review feedback attach on resume: read prior state, recreate it when missing, and re-run PR lookup when state.pr is null — so re-attaching can recover a PR number that wasn't persisted earlier and worktrees imported from outside nn get a state file. * fix: address review feedback - rename fetchBranch to fetchAndTrackBranch to reflect that it also creates the local branch and sets upstream tracking - relabel the attach-side error to "Failed to fetch/track branch" so a failure during the tracking step isn't mis-reported as a fetch failure - shorten redundant "note: PR lookup failed — skipping PR lookup" to "note: PR lookup failed — skipping"
1 parent ba8bc0f commit 6ea90b0

6 files changed

Lines changed: 375 additions & 114 deletions

File tree

packages/cli/src/cli.ts

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,23 @@ program
1010
[
1111
'',
1212
'Worktree commands:',
13-
' nn w <name> Create or resume a worktree',
14-
' nn w ls [--json] List all worktrees',
15-
' nn w rm <name> [-f] [-y] Remove a worktree',
16-
' nn w prune Clean up stale worktree metadata',
13+
' nn w <name> Create or resume a worktree',
14+
' nn w attach <branch> Attach worktree to an existing branch + PR',
15+
' nn w ls [--json] List all worktrees',
16+
' nn w rm <name> [-f] [-y] Remove a worktree',
17+
' nn w prune Clean up stale worktree metadata',
1718
'',
1819
'Examples:',
19-
' nn w planA Create worktree with branch planA',
20-
' nn w feat/planA Branch feat/planA, directory feat-planA',
21-
' nn w planA --branch fix Create worktree with branch fix',
22-
' nn w planA --print-path Print worktree path (for scripting)',
23-
' nn w ls List all worktrees',
24-
' nn w rm planA Remove worktree and delete branch',
25-
' nn w rm planA -f Force remove (even if dirty)',
20+
' nn w planA Create worktree with branch planA',
21+
' nn w feat/planA Branch feat/planA, directory feat-planA',
22+
' nn w planA --branch fix Create worktree with branch fix',
23+
' nn w planA --print-path Print worktree path (for scripting)',
24+
' nn w attach feat/add-export Attach to existing branch + open PR',
25+
' nn w attach feat/x --as work Use custom worktree directory name',
26+
' nn w attach feat/x --pr 123 Force PR number (skip gh lookup)',
27+
' nn w ls List all worktrees',
28+
' nn w rm planA Remove worktree and delete branch',
29+
' nn w rm planA -f Force remove (even if dirty)',
2630
].join('\n'),
2731
)
2832

@@ -39,6 +43,27 @@ const w = program
3943
await createCommand(name, opts)
4044
})
4145

46+
w.command('attach')
47+
.description('Attach a worktree to an existing branch (and its PR)')
48+
.argument(
49+
'<branch>',
50+
'existing branch name (tried locally first, then on origin)',
51+
)
52+
.option('--as <name>', 'worktree directory name (default: derived from branch)')
53+
.option('--pr <n>', 'PR number (skips gh lookup)', (v) => {
54+
const n = Number(v)
55+
if (!Number.isFinite(n) || n <= 0 || !Number.isInteger(n)) {
56+
console.error(`Invalid --pr value: "${v}" (expected positive integer)`)
57+
process.exit(1)
58+
}
59+
return n
60+
})
61+
.option('--print-path', 'print worktree path and exit')
62+
.action(async (branch, opts) => {
63+
const { attachCommand } = await import('./commands/worktree/attach.js')
64+
await attachCommand(branch, opts)
65+
})
66+
4267
w.command('ls')
4368
.description('List all worktrees')
4469
.option('--json', 'JSON output')
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import {
2+
aheadBehind,
3+
deleteBranch,
4+
isDirty,
5+
removeWorktree,
6+
} from '../../lib/git.js'
7+
import { prompt } from '../../lib/prompt.js'
8+
import { readState } from '../../lib/state.js'
9+
10+
export async function showExitPrompt(
11+
name: string,
12+
wtPath: string,
13+
branchName: string | null,
14+
) {
15+
const dirty = await isDirty(wtPath)
16+
const { ahead, hasUpstream } = await aheadBehind(wtPath)
17+
const state = await readState(wtPath)
18+
const pr = state?.pr ? `#${state.pr}` : 'not created'
19+
20+
const aheadText = !hasUpstream
21+
? 'no upstream (not pushed)'
22+
: ahead > 0
23+
? `${ahead} commits unpushed`
24+
: '0'
25+
26+
console.log()
27+
console.log(`── Worktree: ${name} ────────────────────────`)
28+
console.log(` branch: ${branchName ?? '(detached)'}`)
29+
console.log(` dirty: ${dirty ? 'yes' : 'no'}`)
30+
console.log(` ahead: ${aheadText}`)
31+
console.log(` PR: ${pr}`)
32+
console.log('───────────────────────────────────────────')
33+
console.log()
34+
console.log(' [k] Keep (default)')
35+
console.log(` [d] Delete worktree${branchName ? ' + branch' : ''}`)
36+
console.log()
37+
38+
const choice = await prompt('choice [k/d]: ')
39+
40+
if (choice.toLowerCase() !== 'd') {
41+
console.log('Kept.')
42+
return
43+
}
44+
45+
if (dirty || ahead > 0 || !hasUpstream) {
46+
const warnings: string[] = []
47+
if (dirty) warnings.push('uncommitted changes')
48+
if (!hasUpstream) warnings.push('branch has no upstream (never pushed)')
49+
else if (ahead > 0) warnings.push(`${ahead} unpushed commits`)
50+
51+
console.log()
52+
console.log(`Warning: You have ${warnings.join(' and ')}.`)
53+
const confirm = await prompt("Type 'yes' to confirm deletion: ")
54+
if (confirm !== 'yes') {
55+
console.log('Kept.')
56+
return
57+
}
58+
}
59+
60+
try {
61+
await removeWorktree(wtPath, true)
62+
console.log(`Removed worktree: ${wtPath}`)
63+
} catch (err) {
64+
console.error(`Failed to remove worktree: ${err}`)
65+
return
66+
}
67+
68+
if (branchName) {
69+
try {
70+
await deleteBranch(branchName, false)
71+
console.log(`Deleted branch: ${branchName}`)
72+
} catch {
73+
try {
74+
await deleteBranch(branchName, true)
75+
console.log(
76+
`Force-deleted branch: ${branchName} (had unmerged commits)`,
77+
)
78+
} catch {
79+
console.log(`Branch ${branchName} could not be deleted`)
80+
}
81+
}
82+
}
83+
}
84+
85+
export async function handleSubshellExit(
86+
name: string,
87+
wtPath: string,
88+
branchName: string | null,
89+
result: { code: number | null; signal: NodeJS.Signals | null },
90+
) {
91+
if (result.signal) {
92+
console.log()
93+
console.log(`Warning: Subshell exited abnormally (${result.signal})`)
94+
console.log('Worktree preserved. Resume with:')
95+
console.log(` nn w ${name}`)
96+
return
97+
}
98+
99+
if (result.code !== null && result.code !== 0) {
100+
console.log()
101+
console.log(`Warning: Subshell exited abnormally (code ${result.code})`)
102+
console.log('Worktree preserved. Resume with:')
103+
console.log(` nn w ${name}`)
104+
return
105+
}
106+
107+
await showExitPrompt(name, wtPath, branchName)
108+
}
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import fs from 'node:fs/promises'
2+
import path from 'node:path'
3+
import { getPrForBranch } from '../../lib/gh.js'
4+
import {
5+
addWorktreeExisting,
6+
branchExists,
7+
fetchAndTrackBranch,
8+
listWorktrees,
9+
remoteBranchExists,
10+
} from '../../lib/git.js'
11+
import {
12+
assertValidName,
13+
getRepoInfo,
14+
worktreeDirFor,
15+
} from '../../lib/paths.js'
16+
import { type NNState, readState, writeState } from '../../lib/state.js'
17+
import { enterSubshell } from '../../lib/subshell.js'
18+
import { handleSubshellExit } from './_shared.js'
19+
20+
export async function attachCommand(
21+
rawBranch: string,
22+
opts: { as?: string; pr?: number; printPath?: boolean },
23+
) {
24+
// Accept "origin/foo" as a convenience when users paste from `git branch -r`.
25+
// Only strip the prefix if no local branch exists with that literal name —
26+
// this preserves legitimate local branches named "origin/foo".
27+
let branch = rawBranch
28+
if (rawBranch.startsWith('origin/') && !(await branchExists(rawBranch))) {
29+
branch = rawBranch.slice('origin/'.length)
30+
}
31+
const name = opts.as ?? branch
32+
assertValidName(name)
33+
34+
const wtPath = await worktreeDirFor(name)
35+
const existing = await listWorktrees()
36+
const found = existing.find((e) => e.path === wtPath)
37+
38+
let resolvedBranch: string | null = branch
39+
let isResume = false
40+
41+
if (found) {
42+
// Worktree already exists — only resume if it matches the requested branch.
43+
resolvedBranch = found.branch?.replace('refs/heads/', '') ?? null
44+
if (resolvedBranch !== branch) {
45+
const actual = resolvedBranch ?? '(detached HEAD)'
46+
console.error(
47+
`Worktree already exists at "${wtPath}" but is on "${actual}", not requested branch "${branch}". Use a different --as name or switch the existing worktree to the requested branch first.`,
48+
)
49+
process.exit(1)
50+
}
51+
isResume = true
52+
if (!opts.printPath) {
53+
console.log(`Worktree already exists: ${wtPath}`)
54+
}
55+
} else {
56+
// Resolve branch: local first, then origin/<branch>.
57+
const hasLocal = await branchExists(branch)
58+
if (!hasLocal) {
59+
let hasRemote: boolean
60+
try {
61+
hasRemote = await remoteBranchExists(branch)
62+
} catch (err) {
63+
const stderr =
64+
err && typeof err === 'object' && 'stderr' in err
65+
? String(err.stderr).trim()
66+
: String(err)
67+
console.error(`Failed to check remote branch: ${stderr}`)
68+
process.exit(1)
69+
}
70+
if (!hasRemote) {
71+
console.error(
72+
`Branch "${branch}" not found locally or on origin. Fetch it first, or use "nn w <name>" to create a new branch.`,
73+
)
74+
process.exit(1)
75+
}
76+
try {
77+
await fetchAndTrackBranch('origin', branch)
78+
} catch (err) {
79+
const stderr =
80+
err && typeof err === 'object' && 'stderr' in err
81+
? String(err.stderr).trim()
82+
: String(err)
83+
console.error(`Failed to fetch/track branch: ${stderr}`)
84+
process.exit(1)
85+
}
86+
}
87+
88+
await fs.mkdir(path.dirname(wtPath), { recursive: true })
89+
90+
try {
91+
await addWorktreeExisting(wtPath, branch)
92+
} catch (err) {
93+
const stderr =
94+
err && typeof err === 'object' && 'stderr' in err
95+
? String(err.stderr).trim()
96+
: String(err)
97+
console.error(`Failed to create worktree: ${stderr}`)
98+
process.exit(1)
99+
}
100+
}
101+
102+
// On resume, read existing state so we can preserve createdAt/lastReviewId
103+
// and decide whether a PR lookup is still needed.
104+
const prevState = isResume ? await readState(wtPath) : null
105+
106+
// Resolve PR number. Lookup runs for: fresh attach, explicit --pr, resume
107+
// with missing state, or resume where state.pr is null (so that re-attaching
108+
// can recover a PR number that wasn't persisted earlier).
109+
let prNumber: number | null = prevState?.pr ?? null
110+
if (opts.pr !== undefined) {
111+
prNumber = opts.pr
112+
} else if (!isResume || !prevState || prevState.pr == null) {
113+
const lookup = await getPrForBranch(branch)
114+
if (lookup.kind === 'found') {
115+
prNumber = lookup.pr
116+
} else if (!opts.printPath) {
117+
if (lookup.kind === 'unavailable') {
118+
console.log('note: PR lookup failed — skipping')
119+
} else {
120+
console.log(`note: no open PR found for "${branch}"`)
121+
}
122+
}
123+
}
124+
125+
if (!found) {
126+
// Fresh attach.
127+
const { root } = await getRepoInfo()
128+
const state: NNState = {
129+
name,
130+
branch: resolvedBranch ?? branch,
131+
repo: root,
132+
createdAt: new Date().toISOString(),
133+
pr: prNumber,
134+
lastReviewId: null,
135+
}
136+
await writeState(wtPath, state)
137+
138+
if (!opts.printPath) {
139+
console.log(`Attached worktree: ${wtPath}`)
140+
console.log(` branch: ${resolvedBranch ?? branch}`)
141+
if (prNumber !== null) console.log(` PR: #${prNumber}`)
142+
}
143+
} else if (!prevState) {
144+
// Resume with no state file — recreate it (e.g. worktree imported from
145+
// outside nn, or state file was deleted).
146+
const { root } = await getRepoInfo()
147+
const state: NNState = {
148+
name,
149+
branch: resolvedBranch ?? branch,
150+
repo: root,
151+
createdAt: new Date().toISOString(),
152+
pr: prNumber,
153+
lastReviewId: null,
154+
}
155+
await writeState(wtPath, state)
156+
157+
if (!opts.printPath) {
158+
console.log(`Restored state: ${wtPath}`)
159+
if (prNumber !== null) console.log(` PR: #${prNumber}`)
160+
}
161+
} else if (prevState.pr !== prNumber) {
162+
// Resume: update state.pr if --pr was passed or a lookup discovered one.
163+
await writeState(wtPath, { ...prevState, pr: prNumber })
164+
if (!opts.printPath && prNumber !== null) {
165+
console.log(`Updated PR: #${prNumber}`)
166+
}
167+
}
168+
169+
if (opts.printPath) {
170+
console.log(wtPath)
171+
return
172+
}
173+
174+
console.log('Entering subshell... (exit to return)')
175+
console.log()
176+
177+
const result = await enterSubshell(wtPath, name)
178+
179+
await handleSubshellExit(name, wtPath, resolvedBranch, result)
180+
}

0 commit comments

Comments
 (0)