fix: harden ghost-conflict recovery after forced sync#26
Conversation
Add regression coverage for forced-sync ghost conflicts, keep local-ahead branches safe when PR bases mismatch, and clarify doctor recovery guidance in the CLI and docs. Completes ds-0b8 Completes ds-eil Completes ds-3sp
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Pull request overview
This PR hardens DubStack’s sync/doctor workflow around the “ghost conflict after forced sync” scenario by preserving more accurate baseline metadata during remote adoption, avoiding destructive resets when local work is ahead, and surfacing structural parent/child ancestry drift via dub doctor, with regression tests and updated docs.
Changes:
- Prevent PR-base-mismatch handling from overriding
local-aheadbranches (avoids losing unpushed local commits). - Adjust sync baseline updates during remote adoption to better preserve/clear
parent_revisionand base SHA in forced-sync recovery paths. - Add
dub doctordetection for structural parent/child ancestry drift (parent-mismatch) plus tests and user-facing recovery docs.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| packages/cli/src/commands/sync.ts | Avoids parent-mismatch override for local-ahead; updates markBranchSynced() remote-adoption baseline handling. |
| packages/cli/src/commands/sync.test.ts | Adds regression coverage for forced-sync ghost-conflict + local-ahead-with-parent-mismatch behavior. |
| packages/cli/src/commands/doctor.ts | Adds parent-mismatch issue when a child branch is not descended from its tracked parent tip. |
| packages/cli/src/commands/doctor.test.ts | Adds test coverage for the new parent-mismatch doctor check. |
| apps/docs/content/docs/commands/doctor.mdx | Documents the new check and adds “Hidden Conflict Recovery” guidance. |
| README.md | Updates the doctor checklist bullets to mention structural ancestry drift. |
Comments suppressed due to low confidence (1)
packages/cli/src/commands/doctor.ts:221
appendBranchHealthIssues()now wraps both the local/remote SHA drift check and the new parent-ancestry check in the same try/catch, but the error reported ("Could not compare local/remote SHAs") can now also be triggered by failures reading the parent ref or running the ancestry comparison. This makes the issue summary misleading for users.
Consider splitting the parent-mismatch check into its own try/catch (with a parent-specific summary), or broadening the existing summary/details so it accurately reflects all operations inside the block.
try {
const localSha = await getRefSha(branch.name, cwd);
const remoteSha = await getRefSha(`origin/${branch.name}`, cwd);
if (localSha !== remoteSha) {
issues.push({
code: 'remote-drift',
summary: `Branch '${branch.name}' differs from origin/${branch.name}.`,
details:
'Local and remote SHAs diverge. Reconcile before submit for predictable PR updates.',
fixes: ['dub sync --no-restack', 'dub sync --force --no-restack'],
});
}
if (branch.parent) {
const parentSha = await getRefSha(branch.parent, cwd);
const basedOnParent = await isAncestor(parentSha, localSha, cwd);
if (!basedOnParent) {
issues.push({
code: 'parent-mismatch',
summary: `Branch '${branch.name}' is no longer based on '${branch.parent}'.`,
details:
'The tracked child branch is not descended from the current tip of its tracked parent, so structural stack drift is present and local submit/readiness checks would be misleading.',
fixes: ['dub restack', 'dub doctor', 'dub submit --path current'],
});
}
}
} catch (error) {
if (error instanceof DubError) {
issues.push({
code: 'remote-check-failed',
summary: `Could not compare local/remote SHAs for '${branch.name}'.`,
details: error.message,
fixes: ['git fetch --all --prune', 'dub sync --no-restack'],
});
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @@ -722,12 +738,30 @@ async function markBranchSynced( | |||
| base_branch: resolvedBaseBranch, | |||
| source: options.source, | |||
| }; | |||
| try { | |||
| if (await isAncestor(resolvedBaseSha, headSha, cwd)) { | |||
| if (isAdoptingRemote) { | |||
| if (canPreservePriorBaseSha) { | |||
| entry.parent_revision = resolvedBaseSha; | |||
| } else { | |||
| try { | |||
| entry.parent_revision = (await isAncestor( | |||
| resolvedBaseSha, | |||
| headSha, | |||
| cwd, | |||
| )) | |||
| ? resolvedBaseSha | |||
| : null; | |||
| } catch { | |||
| entry.parent_revision = null; | |||
| } | |||
| } | |||
There was a problem hiding this comment.
In markBranchSynced(), when isAdoptingRemote and canPreservePriorBaseSha are true, entry.parent_revision is set to resolvedBaseSha without verifying that it is actually an ancestor of the adopted headSha. If the remote branch was force-pushed/rebased (so the stored baseline base SHA is no longer in the adopted branch history), this can write an invalid parent_revision that later breaks dub restack (it uses parent_revision as parentOldTip).
Consider running an ancestry check even in the “preserve” path (and clearing parent_revision / avoiding preserving the base SHA when it’s not an ancestor of headSha).
Summary
doctorsurfaces structural parent mismatches after remote adoptionlocal-aheadbranches that still have unpushed local commitsdub doctorrecovery guidance in the CLI docs and docs site, including the GitHub-conflict-but-local-clean flowVerification
pnpm checkspnpm typecheckGOOGLE_CLOUD_PROJECT= pnpm testContext
This follows the March 9, 2026 user repro where
dub sync --forcecould leave stacked child PRs conflicting in GitHub while local health checks looked clean.