Skip to content

fix: harden ghost-conflict recovery after forced sync#26

Merged
dubscode merged 1 commit intomainfrom
codex/fix-ghost-conflict-followups
Mar 10, 2026
Merged

fix: harden ghost-conflict recovery after forced sync#26
dubscode merged 1 commit intomainfrom
codex/fix-ghost-conflict-followups

Conversation

@dubscode
Copy link
Contributor

@dubscode dubscode commented Mar 10, 2026

Summary

  • add regression coverage for the forced-sync ghost-conflict path and verify doctor surfaces structural parent mismatches after remote adoption
  • prevent PR-base mismatch handling from force-resetting local-ahead branches that still have unpushed local commits
  • clarify dub doctor recovery guidance in the CLI docs and docs site, including the GitHub-conflict-but-local-clean flow

Verification

  • pnpm checks
  • pnpm typecheck
  • GOOGLE_CLOUD_PROJECT= pnpm test

Context

This follows the March 9, 2026 user repro where dub sync --force could leave stacked child PRs conflicting in GitHub while local health checks looked clean.

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
Copilot AI review requested due to automatic review settings March 10, 2026 01:49
@vercel
Copy link

vercel bot commented Mar 10, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
dubstack Ready Ready Preview, Comment Mar 10, 2026 1:49am

@dubscode dubscode merged commit 682f0c2 into main Mar 10, 2026
8 checks passed
@dubscode dubscode deleted the codex/fix-ghost-conflict-followups branch March 10, 2026 01:51
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-ahead branches (avoids losing unpushed local commits).
  • Adjust sync baseline updates during remote adoption to better preserve/clear parent_revision and base SHA in forced-sync recovery paths.
  • Add dub doctor detection 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.

Comment on lines 703 to +756
@@ -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;
}
}
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants