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
14 changes: 11 additions & 3 deletions .agents/skills/dubstack/references/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,13 @@ dub sync --no-interactive
If you want automatic restack after sync:

```bash
dub sync --restack
dub sync
```

If you need to skip rebases for one run:

```bash
dub sync --no-restack
```

If you explicitly want destructive reconciliation:
Expand Down Expand Up @@ -82,12 +88,14 @@ dub merge-next
dub merge-next
```

If manual merges happened:
If manual merges happened in GitHub or another UI:

```bash
dub post-merge
dub sync
```

`dub post-merge` remains available when you want the explicit repair command.

## 6) Conflict Recovery During Restack

```bash
Expand Down
10 changes: 8 additions & 2 deletions QUICKSTART.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ Common sync variants:
dub sync --all
dub sync --no-interactive
dub sync --force
dub sync --restack
dub sync --no-restack
```

## 7) Preflight And Cleanup
Expand Down Expand Up @@ -255,7 +255,13 @@ History note:
- `main` is configured for linear, squash-style merges.
- For stacks that target intermediate base branches, merge the top stack branch into `main` after lower layers merge so the full stack lands on `main`.

If merges happened manually:
If merges happened manually in GitHub or another UI, the happy path is:

```bash
dub sync
```

`dub post-merge` is still available as an explicit repair command:

```bash
dub post-merge
Expand Down
24 changes: 20 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -455,7 +455,8 @@ dub pr 123

### `dub sync`

Synchronize tracked branches with remote refs.
Synchronize tracked branches with remote refs and repair stack state after
manual merges.

```bash
# sync current stack
Expand All @@ -470,16 +471,31 @@ dub sync --no-interactive
# force destructive sync decisions
dub sync --force

# include post-sync restack
dub sync --restack
# keep sync conservative if you need to skip rebases
dub sync --no-restack
```

Current sync behavior includes:
- fetch tracked refs from `origin`
- attempt trunk fast-forward (or overwrite with `--force`)
- auto-clean local branches for merged PRs (and closed PRs confirmed in trunk)
- retarget surviving child PRs after merged-parent cleanup
- refresh affected branch PRs after post-merge maintenance
- reconcile local/remote divergence states per branch
- optional restack when `--restack` is set
- restack by default unless `--no-restack` is set

Recommended post-merge flow:

```bash
# merged in GitHub or another UI
dub sync
```

If sync hits a real conflict, prefer:

```bash
dub continue --ai
```

### `dub doctor`

Expand Down
78 changes: 77 additions & 1 deletion packages/cli/src/commands/post-merge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,12 +195,88 @@ describe('postMerge', () => {

expect(mockRestack).toHaveBeenCalled();
expect(mockSubmit).toHaveBeenCalledWith('/repo', false, {
path: 'current',
path: 'stack',
fix: true,
});
expect(mockCheckoutBranch).toHaveBeenCalledWith('main', '/repo');
});

it('refreshes from the surviving child branch when the original branch is trunk', async () => {
mockGetCurrentBranch.mockResolvedValue('main');
mockReadState.mockResolvedValue({
stacks: [
{
id: 'stack-1',
branches: [
{
name: 'main',
type: 'root',
parent: null,
pr_number: null,
pr_link: null,
},
{
name: 'feat/a',
parent: 'main',
pr_number: 1,
pr_link: 'https://x/1',
},
{
name: 'feat/b',
parent: 'feat/a',
pr_number: 2,
pr_link: 'https://x/2',
},
{
name: 'feat/c',
parent: 'feat/b',
pr_number: 3,
pr_link: 'https://x/3',
},
],
},
],
});
mockGetBranchPrLifecycleState.mockImplementation(async (branch: string) => {
if (branch === 'feat/a') return 'MERGED';
if (branch === 'feat/b' || branch === 'feat/c') return 'OPEN';
return 'NONE';
});
mockGetBranchPrSyncInfo.mockImplementation(async (branch: string) => {
if (branch === 'feat/b') {
return { state: 'OPEN', baseRefName: 'feat/a' };
}
if (branch === 'feat/c') {
return { state: 'OPEN', baseRefName: 'feat/b' };
}
return { state: 'NONE', baseRefName: null };
});
mockSubmit.mockImplementation(async (_cwd, _dryRun, options) => {
const lastCheckout = mockCheckoutBranch.mock.calls.at(-1)?.[0];
if (lastCheckout !== 'feat/b') {
throw new Error(
`expected surviving child checkout, got ${lastCheckout}`,
);
}
if (options?.path !== 'stack') {
throw new Error(`expected full-stack refresh, got ${options?.path}`);
}
return {
pushed: ['feat/b', 'feat/c'],
created: [],
updated: ['feat/b', 'feat/c'],
path: 'stack',
dryRun: false,
fallbackApplied: false,
};
});

const result = await postMerge('/repo');

expect(result.submittedBranches).toEqual(['feat/b', 'feat/c']);
expect(mockCheckoutBranch).toHaveBeenCalledWith('feat/b', '/repo');
});

it('submits each stack when --all is enabled', async () => {
const allStacksState: DubState = {
stacks: [
Expand Down
100 changes: 25 additions & 75 deletions packages/cli/src/commands/post-merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ import {
checkGhAuth,
ensureGhInstalled,
getBranchPrLifecycleState,
getBranchPrSyncInfo,
retargetPrBase,
} from '../lib/github';
import {
findStackForBranch,
Expand All @@ -14,7 +12,12 @@ import {
writeState,
} from '../lib/state';
import { restack } from './restack';
import { submit } from './submit';
import {
hasNonRootBranches,
resolvePreferredBranch,
retargetOpenPrBranches,
submitRefreshedStacks,
} from './stack-maintenance';

export interface PostMergeResult {
cleaned: string[];
Expand Down Expand Up @@ -67,28 +70,23 @@ export async function postMerge(
dryRun,
};
let preferredBranch: string | null = null;
const reparentedBranchNames = new Set<string>();

for (const stack of workingStacks) {
const mergedBottom = await getMergedBottomBranches(stack, cwd);
for (const branchName of mergedBottom) {
result.cleaned.push(branchName);
const reparented = removeBranchFromStack(stack, branchName);
result.reparented.push(...reparented);
}
}

for (const stack of workingStacks) {
for (const branch of stack.branches) {
if (branch.type === 'root' || !branch.parent) continue;
const prInfo = await getBranchPrSyncInfo(branch.name, cwd);
if (prInfo.state !== 'OPEN') continue;
if (prInfo.baseRefName === branch.parent) continue;
result.retargeted.push(branch.name);
if (!dryRun) {
await retargetPrBase(branch.name, branch.parent, cwd);
for (const entry of reparented) {
reparentedBranchNames.add(entry.branch);
}
}
}
result.retargeted = await retargetOpenPrBranches(workingStacks, cwd, {
dryRun,
branches: [...reparentedBranchNames],
});

if (!dryRun) {
await writeState(state, cwd);
Expand All @@ -106,7 +104,7 @@ export async function postMerge(
if (restackResult.status === 'conflict') {
throw new DubError(
`Post-merge restack hit conflicts on '${restackResult.conflictBranch ?? 'unknown'}'.\n` +
"Resolve conflicts, then run 'dub continue'. Run 'dub abort' to cancel.",
"Resolve conflicts, then run 'dub continue --ai' to let DubStack try the small conflict for you. Run 'dub continue' to resume manually or 'dub abort' to cancel.",
);
}
}
Expand All @@ -125,14 +123,17 @@ export async function postMerge(
}

if (!dryRun && shouldSubmit) {
const submitResult = options.all
? await submitAllStacks(cwd, workingStacks)
: await submit(cwd, false, {
path: 'current',
fix: true,
});
result.submitted = true;
result.submittedBranches = submitResult.pushed;
const hasRefreshableBranches = workingStacks.some(hasNonRootBranches);
if (hasRefreshableBranches) {
result.submittedBranches = await submitRefreshedStacks(
cwd,
workingStacks,
{
all: options.all ?? false,
},
);
result.submitted = true;
}
}
if (!dryRun && preferredBranch) {
await checkoutBranch(preferredBranch, cwd);
Expand All @@ -143,29 +144,6 @@ export async function postMerge(
return result;
}

async function submitAllStacks(
cwd: string,
stacks: Stack[],
): Promise<{ pushed: string[] }> {
const submitTargets = stacks
.map(
(stack) => stack.branches.find((branch) => branch.type !== 'root')?.name,
)
.filter((branchName): branchName is string => Boolean(branchName));
const pushed = new Set<string>();
for (const branchName of submitTargets) {
await checkoutBranch(branchName, cwd);
const submitResult = await submit(cwd, false, {
path: 'current',
fix: true,
});
for (const branch of submitResult.pushed) {
pushed.add(branch);
}
}
return { pushed: [...pushed].sort() };
}

async function getMergedBottomBranches(
stack: Stack,
cwd: string,
Expand Down Expand Up @@ -217,31 +195,3 @@ function removeBranchFromStack(
);
return reparented;
}

function hasNonRootBranches(stack: Stack): boolean {
return stack.branches.some((branch) => branch.type !== 'root');
}

function resolvePreferredBranch(
workingStacks: Stack[],
originalBranch: string,
scopeStacks: Stack[],
): string | null {
const inWorking = findStackForBranch(
{ stacks: workingStacks },
originalBranch,
);
if (inWorking) {
return originalBranch;
}

const scopedIds = new Set(scopeStacks.map((stack) => stack.id));
const preferredStack =
workingStacks.find((stack) => scopedIds.has(stack.id)) ?? workingStacks[0];
if (!preferredStack) return null;
return (
preferredStack.branches.find((branch) => branch.type !== 'root')?.name ??
preferredStack.branches.find((branch) => branch.type === 'root')?.name ??
null
);
}
31 changes: 31 additions & 0 deletions packages/cli/src/commands/restack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,37 @@ describe('restack', () => {
});

describe('squash-merge-then-restack', () => {
it('preserves parent_revision when patch-equivalent work is skipped', async () => {
await create('feat/a', dir);
fs.writeFileSync(path.join(dir, 'file-a.txt'), 'feature a content');
await gitInRepo(dir, ['add', '.']);
await gitInRepo(dir, ['commit', '-m', 'add file-a']);

const stateBeforeMerge = await readState(dir);
const featARevisionBefore = stateBeforeMerge.stacks[0].branches.find(
(b) => b.name === 'feat/a',
)?.parent_revision;
expect(featARevisionBefore).toBeTruthy();

await gitInRepo(dir, ['checkout', 'main']);
await gitInRepo(dir, ['merge', '--squash', 'feat/a']);
await gitInRepo(dir, ['commit', '-m', 'squash A']);
const mainTipAfterSquash = await getBranchTip('main', dir);

await gitInRepo(dir, ['checkout', 'feat/a']);
const result = await restack(dir);

expect(result.status).toBe('up-to-date');
expect(result.rebased).toHaveLength(0);

const stateAfterRestack = await readState(dir);
const featAAfter = stateAfterRestack.stacks[0].branches.find(
(b) => b.name === 'feat/a',
);
expect(featAAfter?.parent_revision).toBe(featARevisionBefore);
expect(featAAfter?.parent_revision).not.toBe(mainTipAfterSquash);
});

it('produces no false conflicts after squash-merge', async () => {
// Create main → feat/a with a commit on file-a.txt
await create('feat/a', dir);
Expand Down
Loading