Skip to content

Commit 0ac8fe4

Browse files
jwaldripclaude
andauthored
fix: haiku_repair stale-ref loop and hardcoded mainline branch (#225)
* fix: haiku_repair stale-ref loop and hardcoded mainline branch Repair was creating worktrees from stale local refs, reapplying fixes already on the remote, failing to push non-fast-forward, and reporting the same issues forever. It also blew up on repos where the remote default branch isn't main/master. - Fetch origin upfront in repairAllBranches so origin/HEAD and every temp worktree reflect current remote state - addTempWorktree gains preferRemote flag; repair call sites use origin/<branch> so scans see remote truth, not stale local tips - commitAndPushFromWorktree rebases onto origin/<branch> and retries once on non-fast-forward; aborts rebase cleanly on conflict - getMainlineBranch resolves origin/HEAD via git symbolic-ref first, so repos with dev/trunk/etc. as default work correctly - haiku_review diff base falls back to getMainlineBranch() instead of hardcoded "main" Fixes #206 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: tighten NFF regex and biome format Review feedback on #225: - Drop bare "rejected" from non-fast-forward detection so we don't rebase on protected-branch rejections or hook failures that also print "rejected" - Biome format fix for the rebase execFileSync call Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 97a4f92 commit 0ac8fe4

File tree

2 files changed

+104
-22
lines changed

2 files changed

+104
-22
lines changed

packages/haiku/src/git-worktree.ts

Lines changed: 91 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,25 @@ export function branchExists(branch: string): boolean {
3737
return tryRun(["git", "rev-parse", "--verify", branch]) !== ""
3838
}
3939

40-
/** Detect the mainline branch (main, master, or git config init.defaultBranch). */
40+
/** Detect the mainline branch.
41+
* Order of resolution:
42+
* 1. `origin/HEAD` symbolic ref — the remote's actual default branch (handles `dev`, `trunk`, etc.)
43+
* 2. `main`, `master` as local or remote refs
44+
* 3. `git config init.defaultBranch`
45+
* 4. `"main"` as a last-resort string (also used in non-git environments).
46+
*/
4147
export function getMainlineBranch(): string {
4248
if (!isGitRepo()) return "main"
49+
const originHead = tryRun([
50+
"git",
51+
"symbolic-ref",
52+
"--short",
53+
"refs/remotes/origin/HEAD",
54+
])
55+
if (originHead) {
56+
const m = originHead.match(/^origin\/(.+)$/)
57+
if (m) return m[1]
58+
}
4359
for (const candidate of ["main", "master"]) {
4460
if (tryRun(["git", "rev-parse", "--verify", candidate])) return candidate
4561
if (tryRun(["git", "rev-parse", "--verify", `origin/${candidate}`]))
@@ -49,6 +65,18 @@ export function getMainlineBranch(): string {
4965
return configured || "main"
5066
}
5167

68+
/** Fetch from origin so subsequent ref lookups and worktree creations see the
69+
* current remote state. Non-fatal — returns false on failure (offline, no remote). */
70+
export function fetchOrigin(): boolean {
71+
if (!isGitRepo()) return false
72+
try {
73+
execFileSync("git", ["fetch", "--prune", "origin"], { stdio: "pipe" })
74+
return true
75+
} catch {
76+
return false
77+
}
78+
}
79+
5280
/** List all H·AI·K·U intent branches (`haiku/<slug>/main`) — local + remote, deduped.
5381
* Returns intent slugs in stable sort order. */
5482
export function listIntentBranches(): string[] {
@@ -188,19 +216,28 @@ export function isBranchMerged(branch: string, mainline: string): boolean {
188216
return false
189217
}
190218

191-
/** Add a temporary worktree for an existing branch. Returns the worktree path. */
219+
/** Add a temporary worktree for an existing branch. Returns the worktree path.
220+
* When `preferRemote` is true, resolves to `origin/<branch>` first so the
221+
* worktree reflects the current remote state rather than a stale local ref. */
192222
export function addTempWorktree(
193223
branch: string,
194224
label = "haiku-repair",
225+
preferRemote = false,
195226
): string {
196227
if (!isGitRepo()) throw new Error("not a git repo")
197228
const path = join(
198229
"/tmp",
199230
`${label}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`,
200231
)
201-
// Prefer the local branch if it exists, fall back to origin
202-
const localExists = tryRun(["git", "rev-parse", "--verify", branch])
203-
const ref = localExists ? branch : `origin/${branch}`
232+
const localRef = tryRun(["git", "rev-parse", "--verify", branch])
233+
const remoteRef = tryRun(["git", "rev-parse", "--verify", `origin/${branch}`])
234+
let ref: string
235+
if (preferRemote) {
236+
ref = remoteRef ? `origin/${branch}` : localRef ? branch : ""
237+
} else {
238+
ref = localRef ? branch : remoteRef ? `origin/${branch}` : ""
239+
}
240+
if (!ref) throw new Error(`branch '${branch}' not found locally or on origin`)
204241
run(["git", "worktree", "add", "--detach", path, ref])
205242
return path
206243
}
@@ -242,20 +279,57 @@ export function commitAndPushFromWorktree(
242279
pushError: err instanceof Error ? err.message : String(err),
243280
}
244281
}
245-
try {
246-
execFileSync(
247-
"git",
248-
["-C", worktreePath, "push", "origin", `HEAD:refs/heads/${branch}`],
249-
{ stdio: "pipe" },
250-
)
251-
return { committed: true, pushed: true }
252-
} catch (err) {
253-
return {
254-
committed: true,
255-
pushed: false,
256-
pushError: err instanceof Error ? err.message : String(err),
282+
const tryPush = (): { ok: boolean; error?: string } => {
283+
try {
284+
execFileSync(
285+
"git",
286+
["-C", worktreePath, "push", "origin", `HEAD:refs/heads/${branch}`],
287+
{ stdio: "pipe" },
288+
)
289+
return { ok: true }
290+
} catch (err) {
291+
return {
292+
ok: false,
293+
error: err instanceof Error ? err.message : String(err),
294+
}
295+
}
296+
}
297+
298+
const first = tryPush()
299+
if (first.ok) return { committed: true, pushed: true }
300+
301+
// Non-fast-forward recovery: fetch + rebase onto origin/<branch>, retry push.
302+
// Without this, a stale-ref repair run loops forever — each run re-applies
303+
// fixes, push rejects as non-fast-forward, and the worktree's stale view of
304+
// the repo keeps reporting issues that are already fixed on the remote. (#206)
305+
//
306+
// Matching is intentionally narrow: we only recover from genuine NFF errors.
307+
// A bare "rejected" would also match protected-branch rejections, pre-receive
308+
// hook failures, and permission errors — rebasing on those would be wrong.
309+
const isNonFastForward =
310+
/non-fast-forward|fetch first|behind the remote/i.test(first.error ?? "")
311+
if (isNonFastForward) {
312+
tryRun(["git", "-C", worktreePath, "fetch", "origin", branch])
313+
try {
314+
execFileSync("git", ["-C", worktreePath, "rebase", `origin/${branch}`], {
315+
stdio: "pipe",
316+
})
317+
} catch (err) {
318+
tryRun(["git", "-C", worktreePath, "rebase", "--abort"])
319+
return {
320+
committed: true,
321+
pushed: false,
322+
pushError: `non-fast-forward; rebase onto origin/${branch} failed: ${
323+
err instanceof Error ? err.message : String(err)
324+
}`,
325+
}
257326
}
327+
const retry = tryPush()
328+
if (retry.ok) return { committed: true, pushed: true }
329+
return { committed: true, pushed: false, pushError: retry.error }
258330
}
331+
332+
return { committed: true, pushed: false, pushError: first.error }
259333
}
260334

261335
/** Detect a PR/MR creation tool (`gh` or `glab`) on PATH. */

packages/haiku/src/state-tools.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
addTempWorktree,
2121
commitAndPushFromWorktree,
2222
consolidateStageBranches,
23+
fetchOrigin,
2324
getCurrentBranch,
2425
getMainlineBranch,
2526
isBranchMerged,
@@ -1109,6 +1110,12 @@ function repairAllBranches(autoApply: boolean): {
11091110
mainline: string
11101111
archivedSummary?: BranchRepairSummary
11111112
} {
1113+
// Fetch upfront so getMainlineBranch() sees current origin/HEAD and every
1114+
// worktree created below reflects the latest remote state. Without this,
1115+
// a stale local ref could cause the repair tool to "fix" issues that were
1116+
// already fixed on the remote by a previous run, then fail to push with
1117+
// non-fast-forward, and loop forever. (#206)
1118+
fetchOrigin()
11121119
const mainline = getMainlineBranch()
11131120
const summaries: BranchRepairSummary[] = []
11141121

@@ -1202,7 +1209,7 @@ function repairAllBranches(autoApply: boolean): {
12021209
merged: false,
12031210
}
12041211
try {
1205-
worktreePath = addTempWorktree(branch, "haiku-repair")
1212+
worktreePath = addTempWorktree(branch, "haiku-repair", true)
12061213
} catch (err) {
12071214
summary.error = `Failed to create worktree: ${err instanceof Error ? err.message : String(err)}`
12081215
summaries.push(summary)
@@ -1312,7 +1319,7 @@ function repairArchivedOnMainline(
13121319

13131320
let worktreePath = ""
13141321
try {
1315-
worktreePath = addTempWorktree(mainline, "haiku-repair-archived")
1322+
worktreePath = addTempWorktree(mainline, "haiku-repair-archived", true)
13161323
} catch (err) {
13171324
// Worktree setup failed — surface a dedicated failure shape so the report
13181325
// labels this as "Mainline worktree setup failed" rather than "0 archived
@@ -3193,8 +3200,9 @@ export function handleStateTool(
31933200

31943201
// ── Review ──
31953202
case "haiku_review": {
3196-
// Determine diff base
3197-
let base = "main"
3203+
// Determine diff base — prefer the tracked upstream, fall back to the
3204+
// detected mainline (origin/HEAD-aware), then to a last-resort "main".
3205+
let base = getMainlineBranch()
31983206
try {
31993207
const upstream = spawnSync(
32003208
"git",
@@ -3205,7 +3213,7 @@ export function handleStateTool(
32053213
base = upstream.stdout.trim()
32063214
}
32073215
} catch {
3208-
/* fallback to main */
3216+
/* fallback to detected mainline */
32093217
}
32103218

32113219
// Get diff, stat, and changed files

0 commit comments

Comments
 (0)