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
71 changes: 48 additions & 23 deletions packages/cli/src/commands/absorb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ export interface AbsorbResult {
restacked: string[];
/** True when the rebase paused on a conflict; user must `dub continue`. */
conflict: boolean;
/** True when invoked with `--dry-run`; no mutations were performed. */
dryRun: boolean;
}

const WIP_SUBJECT_RE = /^(wip\b|fix\b|tmp\b|tweak\b|address|feedback)/i;
Expand Down Expand Up @@ -150,17 +152,19 @@ export async function absorb(
);
}

await saveUndoEntry(
{
operation: 'absorb',
timestamp: new Date().toISOString(),
previousBranch: originalBranch,
previousState: structuredClone(state),
branchTips: await snapshotStackTips(stack, cwd),
createdBranches: [],
},
cwd,
);
if (!options.dryRun) {
await saveUndoEntry(
{
operation: 'absorb',
timestamp: new Date().toISOString(),
previousBranch: originalBranch,
previousState: structuredClone(state),
branchTips: await snapshotStackTips(stack, cwd),
createdBranches: [],
},
cwd,
);
}

if (mode === 'auto') {
return runAutoMode(cwd, originalBranch, state, stack, options);
Expand Down Expand Up @@ -201,6 +205,7 @@ async function runAutoMode(
movedTo: [],
restacked: [],
conflict: false,
dryRun: options.dryRun ?? false,
};
}

Expand All @@ -213,6 +218,7 @@ async function runAutoMode(
movedTo: [],
restacked: [],
conflict: false,
dryRun: options.dryRun ?? false,
};
}

Expand All @@ -235,6 +241,7 @@ async function runAutoMode(
movedTo: [],
restacked: [],
conflict: true,
dryRun: options.dryRun ?? false,
};
}
await clearAbsorbProgress(cwd);
Expand Down Expand Up @@ -271,6 +278,7 @@ async function runAiMode(
movedTo: [],
restacked: [],
conflict: false,
dryRun: options.dryRun ?? false,
};
}

Expand All @@ -285,27 +293,31 @@ async function runAiMode(
);
}

const config = await deps.readConfig(cwd);
const assignments = await aiPickTargets(
wipCommits,
candidates,
commits,
deps,
config.ai.provider,
);

// Bail before the AI call in dry-run so the preview does not bill against
// the user's provider. The plan reports the candidate WIP commits as the
// upper bound on what would be absorbed.
if (options.dryRun) {
return {
mode: 'ai',
branch: originalBranch,
absorbed: assignments.filter((a) => a.targetSha !== null).length,
skipped: assignments.filter((a) => a.targetSha === null).length,
absorbed: wipCommits.length,
skipped: 0,
movedTo: [],
restacked: [],
conflict: false,
dryRun: true,
};
}

const config = await deps.readConfig(cwd);
const assignments = await aiPickTargets(
wipCommits,
candidates,
commits,
deps,
config.ai.provider,
);

const todo = buildCustomRebaseTodo(commits, assignments);
const assignedCount = assignments.filter((a) => a.targetSha !== null).length;
const skippedCount = assignments.length - assignedCount;
Expand All @@ -319,6 +331,7 @@ async function runAiMode(
movedTo: [],
restacked: [],
conflict: false,
dryRun: options.dryRun ?? false,
};
}

Expand All @@ -341,6 +354,7 @@ async function runAiMode(
movedTo: [],
restacked: [],
conflict: true,
dryRun: options.dryRun ?? false,
};
}
await clearAbsorbProgress(cwd);
Expand Down Expand Up @@ -381,6 +395,7 @@ async function runStackMode(
movedTo: [],
restacked: [],
conflict: false,
dryRun: options.dryRun ?? false,
};
}

Expand All @@ -393,6 +408,7 @@ async function runStackMode(
movedTo: Array.from(new Set(crossFixups.map((f) => f.targetBranch))),
restacked: [],
conflict: false,
dryRun: options.dryRun ?? false,
};
}

Expand Down Expand Up @@ -460,6 +476,7 @@ async function runStackMode(
movedTo: Array.from(movedTo),
restacked: [],
conflict: true,
dryRun: options.dryRun ?? false,
};
}
await clearAbsorbProgress(cwd);
Expand Down Expand Up @@ -496,7 +513,13 @@ async function finishAbsorbWithRestack(
try {
const restacked = await restackAfterAbsorb(cwd, state, stack);
await clearAbsorbProgress(cwd);
return { mode, ...fields, restacked, conflict: false };
return {
mode,
...fields,
restacked,
conflict: false,
dryRun: false,
};
} catch (error) {
if (error instanceof RestackConflictDuringAbsorb) {
await clearAbsorbProgress(cwd);
Expand All @@ -505,6 +528,7 @@ async function finishAbsorbWithRestack(
...fields,
restacked: error.rebased,
conflict: true,
dryRun: false,
};
}
await clearAbsorbProgress(cwd);
Expand Down Expand Up @@ -640,6 +664,7 @@ export async function absorbContinue(cwd: string): Promise<AbsorbResult> {
movedTo: [],
restacked: [],
conflict: false,
dryRun: false,
};
}

Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/commands/continue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ describe('continue command', () => {
vi.mocked(restackContinue).mockResolvedValue({
status: 'success',
rebased: ['feat/a'],
dryRun: false,
});
vi.mocked(rebaseContinue).mockResolvedValue(undefined);
vi.mocked(aiResolve).mockResolvedValue(undefined);
Expand Down
74 changes: 59 additions & 15 deletions packages/cli/src/commands/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,19 @@ import {
hasStagedChanges,
interactiveStage,
isValidBranchName,
isWorkingTreeClean,
stageAll,
stageUpdate,
} from '../lib/git';
import { readMetadataTemplates } from '../lib/metadata-templates';
import {
addBranchToStack,
type DubState,
ensureState,
findStackForBranch,
getDefaultTrunk,
getStackTrunk,
readStateForDryRun,
writeState,
} from '../lib/state';
import { withTempMarkdownFile } from '../lib/temp-text-file';
Expand All @@ -43,12 +46,14 @@ interface CreateOptions {
all?: boolean;
update?: boolean;
patch?: boolean;
dryRun?: boolean;
}

interface CreateResult {
branch: string;
parent: string;
committed?: string;
dryRun: boolean;
}

type CreateDependencies = AiMetadataDependencies;
Expand Down Expand Up @@ -126,7 +131,14 @@ export async function create(
]);
}

const state = await ensureState(cwd);
const dryRun = normalizedOptions.dryRun ?? false;
// Dry-run must not create state on disk; fall back to an empty in-memory
// state ONLY when the repo has never been initialized (matches `ensureState`
// semantics). Corruption and IO errors still propagate so dry-run can't
// promise a plan a real run won't execute.
const state: DubState = dryRun
? await readStateForDryRun(cwd)
: await ensureState(cwd);
const currentBranch = await getCurrentBranch(cwd);
const currentStack = findStackForBranch(state, currentBranch);
const stackTrunk = currentStack
Expand All @@ -137,19 +149,29 @@ export async function create(
let commitMessage = normalizedOptions.message?.trim();

if (commitMessage || useAi) {
if (normalizedOptions.patch) {
await interactiveStage(cwd);
} else if (normalizedOptions.all) {
await stageAll(cwd);
} else if (normalizedOptions.update) {
await stageUpdate(cwd);
if (!dryRun) {
if (normalizedOptions.patch) {
await interactiveStage(cwd);
} else if (normalizedOptions.all) {
await stageAll(cwd);
} else if (normalizedOptions.update) {
await stageUpdate(cwd);
}
}

if (!(await hasStagedChanges(cwd))) {
const isAggregateFlag =
normalizedOptions.all ||
normalizedOptions.update ||
normalizedOptions.patch;
const isAggregateFlag =
normalizedOptions.all ||
normalizedOptions.update ||
normalizedOptions.patch;
// In dry-run we never mutated the index, so checking the post-stage state
// would always be empty when --all/--update/--patch is set. Instead, look
// at the working tree — that's what an aggregate flag would have staged.
const nothingToCommit = dryRun
? isAggregateFlag
? await isWorkingTreeClean(cwd)
: !(await hasStagedChanges(cwd))
: !(await hasStagedChanges(cwd));
if (nothingToCommit) {
const message = isAggregateFlag
? 'No changes to commit.'
: 'No staged changes.';
Expand All @@ -171,7 +193,7 @@ export async function create(
}
Comment thread
dubscode marked this conversation as resolved.
}

if (useAi) {
if (useAi && !dryRun) {
if (!config.aiAssistantEnabled) {
throw new DubError('AI assistant is disabled for this repo.', [
"Run 'dub config ai-assistant on' to enable AI for this repo.",
Expand Down Expand Up @@ -203,6 +225,14 @@ export async function create(
}

if (!branchName) {
if (dryRun && useAi) {
return {
branch: '<ai-generated>',
parent,
...(commitMessage ? { committed: commitMessage } : {}),
dryRun: true,
};
}
throw new DubError('Branch name is required.', [
"Pass '<branch-name>' as the first argument to 'dub create'.",
"Pass '--ai' to AI-generate the branch name from staged changes.",
Expand All @@ -224,6 +254,15 @@ export async function create(
]);
}

if (dryRun) {
return {
branch: branchName,
parent,
...(commitMessage ? { committed: commitMessage } : {}),
dryRun: true,
};
}

await saveUndoEntry(
{
operation: 'create',
Expand Down Expand Up @@ -269,8 +308,13 @@ export async function create(
],
);
}
return { branch: branchName, parent, committed: commitMessage };
return {
branch: branchName,
parent,
committed: commitMessage,
dryRun: false,
};
}

return { branch: branchName, parent };
return { branch: branchName, parent, dryRun: false };
}
3 changes: 3 additions & 0 deletions packages/cli/src/commands/delete.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ describe('delete command', () => {
vi.mocked(deleteTrackedBranch).mockResolvedValue({
deleted: ['feat/a'],
reparented: [],
dryRun: false,
});
});

Expand All @@ -32,6 +33,7 @@ describe('delete command', () => {
upstack: false,
downstack: false,
force: true,
dryRun: false,
});
});

Expand All @@ -50,6 +52,7 @@ describe('delete command', () => {
upstack: true,
downstack: true,
force: true,
dryRun: false,
});
});

Expand Down
Loading
Loading