-
Notifications
You must be signed in to change notification settings - Fork 112
fix(cli): harden PR merge path against stale-base dead-ends (empty-diff no-op + conflict escape) #1943
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix(cli): harden PR merge path against stale-base dead-ends (empty-diff no-op + conflict escape) #1943
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -640,6 +640,40 @@ async function finalizePullRequestMerge( | |
| } as MergeResult); | ||
| } | ||
|
|
||
| /** | ||
| * Finalize a task whose branch has no commits relative to the base branch | ||
| * ("No commits between ...") as a terminal no-op DONE, mirroring the engine's | ||
| * canonical zero-commits decision (merger-ai.ts `noOpResult` → done, PR #1920). | ||
| * Marking it `failed` here left an empty-diff follow-up task pinning the merge | ||
| * slot + file leases indefinitely; a no-op branch is not a failure — there was | ||
| * simply nothing to merge. | ||
| */ | ||
| async function finalizeNoOpMergeTask( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This CLI change affects published Context Used: AGENTS.md (source) |
||
| store: TaskStore, | ||
| cwd: string, | ||
| task: TaskDetail, | ||
| reason: string, | ||
| pool?: WorktreePool, | ||
| ): Promise<void> { | ||
| const branch = task.branch ?? getTaskBranchName(task.id); | ||
| await cleanupMergedTaskArtifacts(cwd, task, { pool }); | ||
| await store.updateTask(task.id, { status: null, mergeRetries: 0 }); | ||
| const movedTask = await store.moveTask(task.id, "done"); | ||
| const mergedTask = movedTask ?? (await store.getTask(task.id)); | ||
| await store.logEntry(task.id, reason, `Branch ${branch} has no commits relative to the base branch; nothing to merge.`); | ||
| store.emit("task:merged", { | ||
| task: mergedTask, | ||
| branch: mergedTask.branch ?? branch, | ||
| merged: false, | ||
| noOp: true, | ||
| ok: true, | ||
| reason, | ||
| mergeConfirmed: true, | ||
| worktreeRemoved: false, | ||
| branchDeleted: false, | ||
| } as MergeResult); | ||
| } | ||
|
|
||
| /** | ||
| * Result of processing a PR merge task. | ||
| * - "waiting": PR exists but not ready to merge (checks pending, reviews needed) | ||
|
|
@@ -774,8 +808,7 @@ export async function processPullRequestMergeTask( | |
| const message = err instanceof Error ? err.message : String(err); | ||
| if (message.includes("No commits between")) { | ||
| await store.updateBranchGroup(branchGroup.id, { prState: "none", prNumber: null, prUrl: null }); | ||
| await store.updateTask(task.id, { status: "failed", error: `No pull request created for ${branchGroup.branchName}: no commits relative to ${projectDefaultBranch}.` }); | ||
| await store.logEntry(task.id, "No group pull request created", message); | ||
| await finalizeNoOpMergeTask(store, cwd, task, "No group pull request created (no commits vs base) — finalizing as no-op", pool); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a shared branch-group PR has no commits relative to the base, this branch clears the group PR state but finalizes only the current task. Sibling members can remain in Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time! |
||
| return "skipped"; | ||
| } | ||
| throw err; | ||
|
|
@@ -878,9 +911,7 @@ export async function processPullRequestMergeTask( | |
| } catch (err: unknown) { | ||
| const message = err instanceof Error ? err.message : String(err); | ||
| if (message.includes("No commits between")) { | ||
| const error = `No pull request created for ${branch}: the branch has no commits relative to the base branch.`; | ||
| await store.updateTask(task.id, { status: "failed", error }); | ||
| await store.logEntry(task.id, error, message); | ||
| await finalizeNoOpMergeTask(store, cwd, task, "No pull request created (no commits vs base) — finalizing as no-op", pool); | ||
| return "skipped"; | ||
| } | ||
| throw err; | ||
|
|
@@ -924,7 +955,23 @@ export async function processPullRequestMergeTask( | |
|
|
||
| if (!mergeStatus.mergeReady) { | ||
| if (mergeStatus.prInfo.status === "open") { | ||
| await store.updateTask(task.id, { status: "awaiting-pr-checks" }); | ||
| // A stale-base PR that GitHub reports as CONFLICTING never becomes | ||
| // mergeable on its own — nothing in the PR path rebases the head branch — | ||
| // so an unbounded `awaiting-pr-checks` wait pins the merge slot + file | ||
| // leases forever (FN-485). Count each conflicting poll against | ||
| // `mergeRetries` so the existing `getInReviewStallReason` | ||
| // "merge-retries-exhausted" escape disposes the task after | ||
| // `maxAutoMergeRetries` cycles. Pending/behind PRs still wait indefinitely | ||
| // (checks legitimately run; "behind" is resolved by the pre-merge rebase). | ||
| // ponytail: bounded escape via the existing stall counter, not an in-path | ||
| // rebase. Upgrade path: rebase the head branch onto base + force-push here | ||
| // before giving up, then reset mergeRetries. | ||
| await store.updateTask(task.id, { | ||
| status: "awaiting-pr-checks", | ||
| ...(mergeStatus.prInfo.mergeable === "conflicting" | ||
| ? { mergeRetries: (task.mergeRetries ?? 0) + 1 } | ||
| : {}), | ||
| }); | ||
| } else { | ||
| await store.updateTask(task.id, { status: null }); | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
Add regression coverage for the shared-branch-group surface.
Both new tests (and the reworked no-delta test) exercise only the per-task PR creation/merge path. The same two behaviors also have a shared-branch-group surface: no-op finalization on
"No commits between"(Line 799-812) and the not-ready/conflicting handling (Line 858-861). Please enumerate and assert the invariant on the shared-group surface too — at minimum a shared-group "No commits between" → no-opdonecase, and (if the gap flagged intask-lifecycle.tsis addressed) a shared-group conflicting case.As per coding guidelines: "When fixing a bug, regression tests must assert the general invariant across all known surfaces, and bug-fix work must include symptom verification and surface enumeration rather than only the single reproduction."
🤖 Prompt for AI Agents
Source: Coding guidelines