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
3 changes: 2 additions & 1 deletion QUICKSTART.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ dub doctor
dub ready
dub prune # preview stale tracked branches
dub prune --apply # apply stale metadata cleanup
dub merge-check # verify stack order and GitHub mergeability
```

## 8) Handle Restack Conflicts
Expand Down Expand Up @@ -319,7 +320,7 @@ dub undo
| `dub doctor` | Run stack health checks |
| `dub ready` | Run pre-submit checklist |
| `dub prune` | Preview/remove stale tracked metadata |
| `dub merge-check` | Validate stack merge order |
| `dub merge-check` | Validate stack merge order and GitHub mergeability |
| `dub merge-next` / `dub land` | Merge next safe PR + maintenance |
| `dub post-merge` | Repair state/retarget after manual merges |
| `dub restack` | Rebase stack onto updated parents |
Expand Down
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,15 @@ Checks include:
- submit branching blockers
- local/remote SHA drift
- structural parent/child ancestry drift that can leave GitHub conflicted while local refs look clean
- remote GitHub base drift, where the remote PR head is no longer descended from the base branch GitHub is actually evaluating

If `dub doctor` reports a GitHub base mismatch, refresh that base first, then replay and resubmit:

```bash
git checkout main && git pull --ff-only origin main
dub restack
dub submit --path current
```

### `dub ready`

Expand All @@ -543,7 +552,7 @@ dub prune --all --apply

### `dub merge-check`

Validate merge order for a stack PR.
Validate merge order and GitHub mergeability for a stack PR.

```bash
# check current branch PR
Expand Down Expand Up @@ -861,7 +870,7 @@ dub restack --continue
| Need stack-aware branch deletion | Use `dub delete` with `--upstack` / `--downstack` |
| Sync skipped branch | Re-run with `--interactive` or `--force` as appropriate |
| Wrong operation during create/restack | Use `dub undo` (single-level) |
| PR merge blocked by order | Run `dub merge-check --pr <number>` and merge previous PR first |
| PR merge blocked by order or GitHub conflict | Run `dub merge-check --pr <number>` to verify stack order and remote mergeability |
| Manual merge left stack inconsistent | Run `dub post-merge` |

### Stale Branch Recovery
Expand Down
14 changes: 13 additions & 1 deletion apps/docs/content/docs/commands/doctor.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Checks include:
- 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
- remote GitHub base drift, where the remote PR head is no longer descended from the base branch GitHub is actually evaluating

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

Expand All @@ -37,6 +38,14 @@ If the stack looks healthy after restacking and you still need to refresh the PR
dub submit --path current
```

If `dub doctor` reports that a branch is not based on its GitHub base, refresh that base first, then replay and resubmit the branch:

```bash
git checkout main && git pull --ff-only origin main
dub restack
dub submit --path current
```

## dub ready

Run pre-submit checklist (doctor + submit preflight):
Expand All @@ -62,7 +71,7 @@ dub prune --all --apply

## dub merge-check

Validate merge order for a stack PR:
Validate merge order and GitHub mergeability for a stack PR:

```bash
# Check current branch PR
Expand Down Expand Up @@ -136,4 +145,7 @@ dub doctor

# 4) Refresh the current PR path if needed
dub submit --path current

# 5) Confirm GitHub now sees the PR as mergeable
dub merge-check
```
165 changes: 165 additions & 0 deletions packages/cli/src/commands/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ vi.mock('../lib/git.js', () => ({
remoteBranchExists: vi.fn(),
}));

vi.mock('../lib/github.js', () => ({
getBranchPrSyncInfo: vi.fn(),
}));

vi.mock('../lib/operation-state.js', () => ({
detectActiveOperation: vi.fn(),
}));
Expand All @@ -29,6 +33,7 @@ import {
isAncestor,
remoteBranchExists,
} from '../lib/git';
import { getBranchPrSyncInfo } from '../lib/github';
import { detectActiveOperation } from '../lib/operation-state';
import type { DubState } from '../lib/state';
import { readState } from '../lib/state';
Expand All @@ -40,6 +45,7 @@ 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 mockGetBranchPrSyncInfo = getBranchPrSyncInfo as ReturnType<typeof vi.fn>;
const mockDetectActiveOperation = detectActiveOperation as ReturnType<
typeof vi.fn
>;
Expand Down Expand Up @@ -69,6 +75,10 @@ beforeEach(() => {
mockFetchBranches.mockResolvedValue(undefined);
mockBranchExists.mockResolvedValue(true);
mockRemoteBranchExists.mockResolvedValue(true);
mockGetBranchPrSyncInfo.mockResolvedValue({
state: 'NONE',
baseRefName: null,
});
mockGetRefSha.mockImplementation(async (ref: string) => `${ref}-sha`);
mockIsAncestor.mockResolvedValue(true);
});
Expand Down Expand Up @@ -172,4 +182,159 @@ describe('doctor', () => {
expect(issue?.fixes).toContain('dub doctor');
expect(result.healthy).toBe(false);
});

it('reports a branch that is still based on its local parent but no longer based on the remote PR base', async () => {
mockGetCurrentBranch.mockResolvedValue('feat/hub-performance-streaming');
mockReadState.mockResolvedValue(
makeState([
{ name: 'main', parent: null, type: 'root' },
{ name: 'docs/parity-pulse', parent: 'main' },
{
name: 'feat/hub-performance-streaming',
parent: 'docs/parity-pulse',
},
]),
);
mockGetBranchPrSyncInfo.mockImplementation(async (branch: string) => {
if (branch === 'feat/hub-performance-streaming') {
return {
state: 'OPEN',
baseRefName: 'main',
};
}
return {
state: 'NONE',
baseRefName: null,
};
});
mockGetRefSha.mockImplementation(async (ref: string) => {
switch (ref) {
case 'docs/parity-pulse':
return 'docs-remote-sha';
case 'feat/hub-performance-streaming':
case 'origin/feat/hub-performance-streaming':
return 'tip-sha';
case 'main':
return 'main-local-sha';
case 'origin/main':
return 'main-remote-sha';
default:
return `${ref}-sha`;
}
});
mockIsAncestor.mockImplementation(async (left: string, right: string) => {
if (left === 'docs-remote-sha' && right === 'tip-sha') {
return true;
}
if (left === 'main-remote-sha' && right === 'tip-sha') {
return false;
}
return true;
});

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

expect(issue?.summary).toContain(
"Branch 'feat/hub-performance-streaming' is not based on GitHub base 'main'",
);
expect(issue?.details).toContain('GitHub is evaluating this PR against');
expect(issue?.fixes[0]).toBe(
'git checkout main && git pull --ff-only origin main',
);
expect(issue?.fixes).toContain('dub restack');
expect(issue?.fixes).toContain('dub submit --path current');
expect(result.healthy).toBe(false);
});

it('fetches the GitHub base ref before checking remote-base mismatch', async () => {
mockGetCurrentBranch.mockResolvedValue('feat/hub-performance-streaming');
mockReadState.mockResolvedValue(
makeState([
{ name: 'main', parent: null, type: 'root' },
{
name: 'feat/hub-performance-streaming',
parent: 'main',
},
]),
);
const fetchedRefs = new Set<string>();
mockGetBranchPrSyncInfo.mockResolvedValue({
state: 'OPEN',
baseRefName: 'release/1.95',
});
mockFetchBranches.mockImplementation(async (refs: string[]) => {
for (const ref of refs) fetchedRefs.add(ref);
});
mockRemoteBranchExists.mockImplementation(async (branch: string) => {
if (branch === 'release/1.95') {
return fetchedRefs.has('release/1.95');
}
return true;
});
mockGetRefSha.mockImplementation(async (ref: string) => {
switch (ref) {
case 'feat/hub-performance-streaming':
case 'origin/feat/hub-performance-streaming':
return 'tip-sha';
case 'main':
return 'main-local-sha';
case 'origin/release/1.95':
return 'release-remote-sha';
default:
return `${ref}-sha`;
}
});
mockIsAncestor.mockImplementation(async (left: string, right: string) => {
if (left === 'main-local-sha' && right === 'tip-sha') {
return true;
}
if (left === 'release-remote-sha' && right === 'tip-sha') {
return false;
}
return true;
});

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

expect(mockFetchBranches).toHaveBeenCalledWith(
expect.arrayContaining(['release/1.95']),
'/repo',
);
expect(issue?.summary).toContain(
"Branch 'feat/hub-performance-streaming' is not based on GitHub base 'release/1.95'",
);
});

it('surfaces a remote-check-failed issue when the GitHub base query fails', async () => {
mockGetCurrentBranch.mockResolvedValue('feat/hub-performance-streaming');
mockReadState.mockResolvedValue(
makeState([
{ name: 'main', parent: null, type: 'root' },
{
name: 'feat/hub-performance-streaming',
parent: 'main',
},
]),
);
mockGetBranchPrSyncInfo.mockRejectedValue(new Error('gh auth failed'));

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

expect(issue?.summary).toContain(
"Could not query GitHub PR info for 'feat/hub-performance-streaming'.",
);
expect(issue?.details).toContain('gh auth failed');
expect(issue?.fixes).toContain('gh auth status');
expect(issue?.fixes).toContain('gh auth login');
expect(result.healthy).toBe(false);
});
});
75 changes: 69 additions & 6 deletions packages/cli/src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
isAncestor,
remoteBranchExists,
} from '../lib/git';
import { getBranchPrSyncInfo } from '../lib/github';
import { detectActiveOperation } from '../lib/operation-state';
import {
type Branch,
Expand All @@ -21,6 +22,7 @@ export type DoctorIssueCode =
| 'untracked-current-branch'
| 'submit-branching-blocker'
| 'parent-mismatch'
| 'remote-base-mismatch'
| 'missing-local'
| 'missing-remote'
| 'remote-drift'
Expand Down Expand Up @@ -211,14 +213,75 @@ async function appendBranchHealthIssues(
});
}
}

let githubBaseRef: string | null = null;
try {
const prInfo = await getBranchPrSyncInfo(branch.name, cwd);
githubBaseRef = prInfo.state === 'OPEN' ? prInfo.baseRefName : null;
} catch (error) {
pushGithubCheckFailure(
issues,
branch.name,
error,
`Could not query GitHub PR info for '${branch.name}'.`,
);
githubBaseRef = null;
}

if (githubBaseRef) {
await fetchBranches([githubBaseRef], cwd);
}

if (githubBaseRef && (await remoteBranchExists(githubBaseRef, cwd))) {
const remoteBaseSha = await getRefSha(`origin/${githubBaseRef}`, cwd);
const basedOnRemoteBase = await isAncestor(remoteBaseSha, remoteSha, cwd);
if (!basedOnRemoteBase) {
issues.push({
code: 'remote-base-mismatch',
summary: `Branch '${branch.name}' is not based on GitHub base '${githubBaseRef}'.`,
details: `GitHub is evaluating this PR against origin/${githubBaseRef}, but the remote branch tip is not descended from that base. GitHub may still report merge conflicts even when local parent checks pass.`,
fixes: [
`git checkout ${githubBaseRef} && git pull --ff-only origin ${githubBaseRef}`,
'dub restack',
'dub submit --path current',
'dub merge-check',
],
});
}
}
} 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'],
});
pushRemoteCheckFailed(
issues,
`Could not compare local/remote SHAs for '${branch.name}'.`,
error.message,
);
}
}
}

function pushGithubCheckFailure(
issues: DoctorIssue[],
branchName: string,
error: unknown,
summary: string,
): void {
const details =
error instanceof Error && error.message
? error.message
: `The GitHub PR base-drift check could not be performed for '${branchName}'. Ensure the GitHub CLI ('gh') is installed, authenticated, and that the repository has network access.`;
pushRemoteCheckFailed(issues, summary, details);
}

function pushRemoteCheckFailed(
issues: DoctorIssue[],
summary: string,
details: string,
): void {
issues.push({
code: 'remote-check-failed',
summary,
details,
fixes: ['gh auth status', 'gh auth login', 'git fetch --all --prune'],
});
}
Loading