Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,7 @@ Checks include:
- missing tracked local/remote branches
- submit branching blockers
- local/remote SHA drift
- structural parent/child ancestry drift that can leave GitHub conflicted while local refs look clean

### `dub ready`

Expand Down
38 changes: 37 additions & 1 deletion apps/docs/content/docs/commands/doctor.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,25 @@ dub doctor --all
dub doctor --no-fetch
```

Checks include: in-progress operation detection, missing tracked branches, submit blockers, and local/remote SHA drift.
Checks include:
- in-progress operation detection
- missing tracked branches
- submit blockers
- local/remote SHA drift
- structural parent/child ancestry drift, where a child branch is no longer based on its tracked parent even if local and remote refs match

If `dub doctor` reports that a branch is no longer based on its parent, start with:

```bash
dub restack
dub doctor
```

If the stack looks healthy after restacking and you still need to refresh the PR from your current branch, follow with:

```bash
dub submit --path current
```

## dub ready

Expand Down Expand Up @@ -101,3 +119,21 @@ dub ready
# 5) Submit current linear path
dub submit --path current
```

## Hidden Conflict Recovery

If GitHub shows a stacked child PR as conflicting but your local branch looks clean:

```bash
# 1) Surface structural drift locally
dub doctor

# 2) Replay children onto their tracked parents
dub restack

# 3) Re-check health before pushing
dub doctor

# 4) Refresh the current PR path if needed
dub submit --path current
```
45 changes: 45 additions & 0 deletions packages/cli/src/commands/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ vi.mock('../lib/git.js', () => ({
fetchBranches: vi.fn(),
getCurrentBranch: vi.fn(),
getRefSha: vi.fn(),
isAncestor: vi.fn(),
remoteBranchExists: vi.fn(),
}));

Expand All @@ -25,6 +26,7 @@ import {
fetchBranches,
getCurrentBranch,
getRefSha,
isAncestor,
remoteBranchExists,
} from '../lib/git';
import { detectActiveOperation } from '../lib/operation-state';
Expand All @@ -36,6 +38,7 @@ const mockBranchExists = branchExists as ReturnType<typeof vi.fn>;
const mockFetchBranches = fetchBranches as ReturnType<typeof vi.fn>;
const mockGetCurrentBranch = getCurrentBranch as ReturnType<typeof vi.fn>;
const mockGetRefSha = getRefSha as ReturnType<typeof vi.fn>;
const mockIsAncestor = isAncestor as ReturnType<typeof vi.fn>;
const mockRemoteBranchExists = remoteBranchExists as ReturnType<typeof vi.fn>;
const mockDetectActiveOperation = detectActiveOperation as ReturnType<
typeof vi.fn
Expand Down Expand Up @@ -67,6 +70,7 @@ beforeEach(() => {
mockBranchExists.mockResolvedValue(true);
mockRemoteBranchExists.mockResolvedValue(true);
mockGetRefSha.mockImplementation(async (ref: string) => `${ref}-sha`);
mockIsAncestor.mockResolvedValue(true);
});

describe('doctor', () => {
Expand Down Expand Up @@ -127,4 +131,45 @@ describe('doctor', () => {
);
expect(branching?.details).toContain('main -> feat/a, feat/b');
});

it('reports a child branch that matches remote but is no longer based on its parent', async () => {
mockReadState.mockResolvedValue(
makeState([
{ name: 'main', parent: null, type: 'root' },
{ name: 'feat/a', parent: 'main' },
{ name: 'feat/b', parent: 'feat/a' },
]),
);
mockGetRefSha.mockImplementation(async (ref: string) => {
switch (ref) {
case 'feat/a':
case 'origin/feat/a':
return 'parent-new-sha';
case 'feat/b':
case 'origin/feat/b':
return 'child-remote-sha';
default:
return `${ref}-sha`;
}
});
mockIsAncestor.mockImplementation(async (left: string, right: string) => {
if (left === 'parent-new-sha' && right === 'child-remote-sha') {
return false;
}
return true;
});

const result = await doctor('/repo');
const issue = result.issues.find(
(entry) => entry.code === 'parent-mismatch',
);

expect(issue?.summary).toContain(
"Branch 'feat/b' is no longer based on 'feat/a'",
);
expect(issue?.details).toContain('structural stack drift');
expect(issue?.fixes[0]).toBe('dub restack');
expect(issue?.fixes).toContain('dub doctor');
expect(result.healthy).toBe(false);
});
});
16 changes: 16 additions & 0 deletions packages/cli/src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
fetchBranches,
getCurrentBranch,
getRefSha,
isAncestor,
remoteBranchExists,
} from '../lib/git';
import { detectActiveOperation } from '../lib/operation-state';
Expand All @@ -19,6 +20,7 @@ export type DoctorIssueCode =
| 'operation-in-progress'
| 'untracked-current-branch'
| 'submit-branching-blocker'
| 'parent-mismatch'
| 'missing-local'
| 'missing-remote'
| 'remote-drift'
Expand Down Expand Up @@ -195,6 +197,20 @@ async function appendBranchHealthIssues(
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({
Expand Down
Loading