diff --git a/packages/cli/src/commands/absorb.ts b/packages/cli/src/commands/absorb.ts index 0a603dcc..590214aa 100644 --- a/packages/cli/src/commands/absorb.ts +++ b/packages/cli/src/commands/absorb.ts @@ -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; @@ -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); @@ -201,6 +205,7 @@ async function runAutoMode( movedTo: [], restacked: [], conflict: false, + dryRun: options.dryRun ?? false, }; } @@ -213,6 +218,7 @@ async function runAutoMode( movedTo: [], restacked: [], conflict: false, + dryRun: options.dryRun ?? false, }; } @@ -235,6 +241,7 @@ async function runAutoMode( movedTo: [], restacked: [], conflict: true, + dryRun: options.dryRun ?? false, }; } await clearAbsorbProgress(cwd); @@ -271,6 +278,7 @@ async function runAiMode( movedTo: [], restacked: [], conflict: false, + dryRun: options.dryRun ?? false, }; } @@ -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; @@ -319,6 +331,7 @@ async function runAiMode( movedTo: [], restacked: [], conflict: false, + dryRun: options.dryRun ?? false, }; } @@ -341,6 +354,7 @@ async function runAiMode( movedTo: [], restacked: [], conflict: true, + dryRun: options.dryRun ?? false, }; } await clearAbsorbProgress(cwd); @@ -381,6 +395,7 @@ async function runStackMode( movedTo: [], restacked: [], conflict: false, + dryRun: options.dryRun ?? false, }; } @@ -393,6 +408,7 @@ async function runStackMode( movedTo: Array.from(new Set(crossFixups.map((f) => f.targetBranch))), restacked: [], conflict: false, + dryRun: options.dryRun ?? false, }; } @@ -460,6 +476,7 @@ async function runStackMode( movedTo: Array.from(movedTo), restacked: [], conflict: true, + dryRun: options.dryRun ?? false, }; } await clearAbsorbProgress(cwd); @@ -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); @@ -505,6 +528,7 @@ async function finishAbsorbWithRestack( ...fields, restacked: error.rebased, conflict: true, + dryRun: false, }; } await clearAbsorbProgress(cwd); @@ -640,6 +664,7 @@ export async function absorbContinue(cwd: string): Promise { movedTo: [], restacked: [], conflict: false, + dryRun: false, }; } diff --git a/packages/cli/src/commands/continue.test.ts b/packages/cli/src/commands/continue.test.ts index bf2ad632..c1512ad9 100644 --- a/packages/cli/src/commands/continue.test.ts +++ b/packages/cli/src/commands/continue.test.ts @@ -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); diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts index b199129d..e2de8c86 100644 --- a/packages/cli/src/commands/create.ts +++ b/packages/cli/src/commands/create.ts @@ -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'; @@ -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; @@ -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 @@ -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.'; @@ -171,7 +193,7 @@ export async function create( } } - 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.", @@ -203,6 +225,14 @@ export async function create( } if (!branchName) { + if (dryRun && useAi) { + return { + branch: '', + parent, + ...(commitMessage ? { committed: commitMessage } : {}), + dryRun: true, + }; + } throw new DubError('Branch name is required.', [ "Pass '' as the first argument to 'dub create'.", "Pass '--ai' to AI-generate the branch name from staged changes.", @@ -224,6 +254,15 @@ export async function create( ]); } + if (dryRun) { + return { + branch: branchName, + parent, + ...(commitMessage ? { committed: commitMessage } : {}), + dryRun: true, + }; + } + await saveUndoEntry( { operation: 'create', @@ -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 }; } diff --git a/packages/cli/src/commands/delete.test.ts b/packages/cli/src/commands/delete.test.ts index 68bb14bc..77b0fd63 100644 --- a/packages/cli/src/commands/delete.test.ts +++ b/packages/cli/src/commands/delete.test.ts @@ -19,6 +19,7 @@ describe('delete command', () => { vi.mocked(deleteTrackedBranch).mockResolvedValue({ deleted: ['feat/a'], reparented: [], + dryRun: false, }); }); @@ -32,6 +33,7 @@ describe('delete command', () => { upstack: false, downstack: false, force: true, + dryRun: false, }); }); @@ -50,6 +52,7 @@ describe('delete command', () => { upstack: true, downstack: true, force: true, + dryRun: false, }); }); diff --git a/packages/cli/src/commands/delete.ts b/packages/cli/src/commands/delete.ts index de354c9c..237bbde5 100644 --- a/packages/cli/src/commands/delete.ts +++ b/packages/cli/src/commands/delete.ts @@ -12,12 +12,14 @@ interface DeleteCommandOptions { force?: boolean; quiet?: boolean; interactive?: boolean; + dryRun?: boolean; } interface DeleteCommandResult { deleted: string[]; reparented: Array<{ branch: string; parent: string | null }>; cancelled?: boolean; + dryRun: boolean; } function isInteractiveShell(): boolean { @@ -44,13 +46,14 @@ export async function deleteCommand( ): Promise { const branch = branchArg ?? (await getCurrentBranch(cwd)); const interactive = options.interactive ?? isInteractiveShell(); + const dryRun = options.dryRun ?? false; const preview = await getDeletePreview(cwd, { branch, upstack: options.upstack, downstack: options.downstack, }); - if (!options.force && !options.quiet) { + if (!dryRun && !options.force && !options.quiet) { if (!interactive) { throw new DubError('Delete requires confirmation.', [ "Rerun 'dub delete --force' to skip the confirmation prompt.", @@ -60,7 +63,7 @@ export async function deleteCommand( } const confirmed = await confirmDelete(preview.targets); if (!confirmed) { - return { deleted: [], reparented: [], cancelled: true }; + return { deleted: [], reparented: [], cancelled: true, dryRun }; } } @@ -81,8 +84,9 @@ export async function deleteCommand( upstack: options.upstack ?? false, downstack: options.downstack ?? false, force: options.force ?? false, + dryRun, }); - if (previousState && result.deleted.length > 0) { + if (!dryRun && previousState && result.deleted.length > 0) { await saveUndoEntry( { operation: 'delete', diff --git a/packages/cli/src/commands/fold.ts b/packages/cli/src/commands/fold.ts index ccbf84f2..3d4a2c33 100644 --- a/packages/cli/src/commands/fold.ts +++ b/packages/cli/src/commands/fold.ts @@ -19,6 +19,7 @@ import { restack } from './restack'; export interface FoldCommandOptions extends FoldOptions { force?: boolean; interactive?: boolean; + dryRun?: boolean; } export interface FoldCommandResult extends FoldResult { @@ -29,6 +30,7 @@ export interface FoldCommandResult extends FoldResult { * PR was (or wasn't) closed. */ prPriorState: BranchPrLifecycleState | null; restacked: boolean; + dryRun: boolean; } function isInteractiveShell(): boolean { @@ -73,6 +75,23 @@ export async function fold( options: FoldCommandOptions = {}, ): Promise { const interactive = options.interactive ?? isInteractiveShell(); + const dryRun = options.dryRun ?? false; + + if (dryRun) { + const preview = await getFoldPreview(cwd, options); + return { + ...EMPTY_FOLD_RESULT_BASE, + branch: preview.branch, + parent: preview.parent, + foldedCommits: preview.foldedCommits, + childrenReparented: preview.childrenReparented, + cancelled: false, + prClosed: false, + prPriorState: null, + restacked: false, + dryRun: true, + }; + } if (!options.force) { if (!interactive) { @@ -100,6 +119,7 @@ export async function fold( prClosed: false, prPriorState: null, restacked: false, + dryRun: false, }; } } @@ -155,5 +175,6 @@ export async function fold( prClosed, prPriorState, restacked, + dryRun: false, }; } diff --git a/packages/cli/src/commands/freeze.test.ts b/packages/cli/src/commands/freeze.test.ts index df1098cc..6b74c346 100644 --- a/packages/cli/src/commands/freeze.test.ts +++ b/packages/cli/src/commands/freeze.test.ts @@ -182,6 +182,21 @@ describe('freeze', () => { } }); + it('--dry-run reports a non-empty plan but does not mutate state or save undo', async () => { + await create('feat/a', dir); + await create('feat/b', dir); + const undoBefore = await readUndoEntry(dir); + + const result = await freeze(dir, 'feat/a', { upstack: true, dryRun: true }); + + expect(result.dryRun).toBe(true); + expect(result.changed.sort()).toEqual(['feat/a', 'feat/b']); + expect(await frozenSet()).toEqual(new Set()); + const undoAfter = await readUndoEntry(dir); + expect(undoAfter.operation).toBe(undoBefore.operation); + expect(undoAfter.timestamp).toBe(undoBefore.timestamp); + }); + it('cascade with --downstack freezes safe branches while skipping a worktree-checked-out ancestor', async () => { await create('feat/a', dir); await create('feat/b', dir); diff --git a/packages/cli/src/commands/freeze.ts b/packages/cli/src/commands/freeze.ts index f3149935..092a31e1 100644 --- a/packages/cli/src/commands/freeze.ts +++ b/packages/cli/src/commands/freeze.ts @@ -3,6 +3,7 @@ import { applyFreezeFlag, type FreezeResult } from '../lib/freeze'; export interface FreezeCommandOptions { upstack?: boolean; downstack?: boolean; + dryRun?: boolean; } /** @@ -24,7 +25,12 @@ export async function freeze( ): Promise { return applyFreezeFlag({ cwd, - options: { branch, upstack: options.upstack, downstack: options.downstack }, + options: { + branch, + upstack: options.upstack, + downstack: options.downstack, + dryRun: options.dryRun, + }, frozen: true, commandLabel: 'dub freeze', undoOperation: 'freeze', diff --git a/packages/cli/src/commands/modify.ts b/packages/cli/src/commands/modify.ts index 5acebf7f..4b4dec3c 100644 --- a/packages/cli/src/commands/modify.ts +++ b/packages/cli/src/commands/modify.ts @@ -41,6 +41,23 @@ interface ModifyOptions { update?: boolean; /** Show unified diff. */ verbose?: number; + /** Preview the planned mutation without amending, committing, or restacking. */ + dryRun?: boolean; +} + +/** + * Structured plan returned by `dub modify --dry-run`. Describes the staging, + * commit, and restack actions that would otherwise mutate the repo. + */ +export interface ModifyPlan { + branch: string; + action: 'amend' | 'commit' | 'interactive-rebase'; + stage: 'all' | 'update' | 'patch' | 'none'; + hasStagedChanges: boolean; + message: string | undefined; + rebaseOnto?: string; + descendantsToRestack: string[]; + dryRun: true; } /** @@ -54,9 +71,24 @@ interface ModifyOptions { export async function modify( cwd: string, options: ModifyOptions, -): Promise { +): Promise { const currentBranch = await getCurrentBranch(cwd); const state = await readState(cwd); + const dryRun = options.dryRun ?? false; + + const stage: ModifyPlan['stage'] = options.patch + ? 'patch' + : options.all + ? 'all' + : options.update + ? 'update' + : 'none'; + + const stack = findStackForBranch(state, currentBranch); + const descendantsToRestack = stack + ? getDescendants(stack, currentBranch) + : []; + const message = normalizeMessage(options.message); if (options.interactiveRebase) { const parent = getParent(state, currentBranch); @@ -70,6 +102,19 @@ export async function modify( ); } + if (dryRun) { + return { + branch: currentBranch, + action: 'interactive-rebase', + stage, + hasStagedChanges: await hasStagedChanges(cwd), + message, + rebaseOnto: parent, + descendantsToRestack, + dryRun: true, + }; + } + const parentTip = await getBranchTip(parent, cwd); console.log(`Starting interactive rebase on top of '${parent}'...`); @@ -80,6 +125,18 @@ export async function modify( return; } + if (dryRun) { + return { + branch: currentBranch, + action: options.commit ? 'commit' : 'amend', + stage, + hasStagedChanges: await hasStagedChanges(cwd), + message, + descendantsToRestack, + dryRun: true, + }; + } + if (options.patch) { await interactiveStage(cwd); } else if (options.all) { @@ -92,7 +149,6 @@ export async function modify( const hasStaged = await hasStagedChanges(cwd); const shouldCreateNew = options.commit; - const message = normalizeMessage(options.message); const noEdit = !options.edit && !!message; if (shouldCreateNew) { diff --git a/packages/cli/src/commands/move.test.ts b/packages/cli/src/commands/move.test.ts index a6d6610c..b4b19632 100644 --- a/packages/cli/src/commands/move.test.ts +++ b/packages/cli/src/commands/move.test.ts @@ -102,7 +102,11 @@ beforeEach(() => { }); vi.mocked(appendCleanupOperation).mockResolvedValue(undefined); vi.mocked(clearCleanupJournal).mockResolvedValue(undefined); - vi.mocked(restack).mockResolvedValue({ status: 'success', rebased: [] }); + vi.mocked(restack).mockResolvedValue({ + status: 'success', + rebased: [], + dryRun: false, + }); vi.mocked(saveUndoEntry).mockResolvedValue(undefined); }); @@ -237,6 +241,7 @@ describe('move command (unit)', () => { status: 'conflict', rebased: [], conflictBranch: 'feat/auth-login', + dryRun: false, }); const result = await move(cwd, 'feat/inserted', { diff --git a/packages/cli/src/commands/move.ts b/packages/cli/src/commands/move.ts index 097bbad9..aed2ef36 100644 --- a/packages/cli/src/commands/move.ts +++ b/packages/cli/src/commands/move.ts @@ -32,6 +32,7 @@ import { restack } from './restack'; export interface MoveOptions { before?: string; after?: string; + dryRun?: boolean; } export type MovePosition = 'before' | 'after'; @@ -48,12 +49,21 @@ export interface MoveResult { rebased: string[]; /** Branches whose PR base was retargeted. */ retargeted: string[]; + /** + * Branches that *would* have their PR base retargeted (dry-run only). + * Listed as "candidates" rather than "would-retarget" because dry-run does + * not call `gh pr view` to confirm each PR is open and has the wrong base, + * so this is an upper bound on what a real run would touch. + */ + retargetCandidates?: string[]; /** True when nothing changed (target was already in the requested position). */ noOp: boolean; /** Human-readable explanation set when `noOp` is true. */ noOpReason?: string; /** Set when the cascading restack hit a conflict and needs `dub continue`. */ conflictBranch?: string; + /** True when invoked with `--dry-run`; no mutations were performed. */ + dryRun: boolean; } interface ReparentPlan { @@ -179,6 +189,7 @@ export async function move( position, }); + const dryRun = options.dryRun ?? false; if (reparents.length === 0) { const reason = position === 'before' @@ -194,13 +205,16 @@ export async function move( retargeted: [], noOp: true, noOpReason: reason, + dryRun, }; } - await assertBranchesNotCheckedOutElsewhere( - cwd, - [branch, target, ...reparents.map((reparent) => reparent.branch)], - 'dub move', - ); + if (!dryRun) { + await assertBranchesNotCheckedOutElsewhere( + cwd, + [branch, target, ...reparents.map((reparent) => reparent.branch)], + 'dub move', + ); + } // Validate the planned mutation is acyclic on a clone before touching disk. const probeStack: Stack = structuredClone(stack); @@ -238,7 +252,7 @@ export async function move( .filter((entry): entry is Branch => Boolean(entry?.pr_number)); const plannedRetargets: Array<{ branch: string; newBase: string }> = []; - if (candidateRetargetBranches.length > 0) { + if (!dryRun && candidateRetargetBranches.length > 0) { await ensureGhInstalled(); await checkGhAuth(); @@ -255,6 +269,28 @@ export async function move( } } + if (dryRun) { + return { + branch, + target, + position, + newParent: + reparents.find((r) => r.branch === branch)?.newParent ?? + branchEntry.parent ?? + '', + reparented: reparents.map((r) => r.branch), + rebased: [], + // No retarget actually ran — that requires a live `gh pr view` to + // confirm the PR is OPEN with a stale base. Surface the candidates + // separately so callers can distinguish "did happen" from "might + // happen". + retargeted: [], + retargetCandidates: candidateRetargetBranches.map((entry) => entry.name), + noOp: false, + dryRun: true, + }; + } + // The journal lets `dub continue` resume a half-applied move. We journal // every planned mutation BEFORE touching disk so the replay path can finish // anything we don't get to. @@ -322,6 +358,7 @@ export async function move( ...(restackResult.status === 'conflict' ? { conflictBranch: restackResult.conflictBranch } : {}), + dryRun: false, }; } diff --git a/packages/cli/src/commands/pop.ts b/packages/cli/src/commands/pop.ts index 356babae..b32d0394 100644 --- a/packages/cli/src/commands/pop.ts +++ b/packages/cli/src/commands/pop.ts @@ -3,6 +3,7 @@ import { countCommitsAhead, getBranchTip, getCurrentBranch, + getRefSha, isWorkingTreeClean, softResetHead, } from '../lib/git'; @@ -13,6 +14,7 @@ import { assertBranchesNotCheckedOutElsewhere } from '../lib/worktree-guards'; interface PopOptions { /** Number of commits to pop. Defaults to 1. */ steps?: number; + dryRun?: boolean; } interface PopResult { @@ -20,6 +22,7 @@ interface PopResult { steps: number; previousTip: string; newTip: string; + dryRun: boolean; } /** @@ -64,7 +67,10 @@ export async function pop( "Run 'dub log' to inspect the stack and confirm tracking state.", ]); } - await assertBranchesNotCheckedOutElsewhere(cwd, [branch], 'dub pop'); + const dryRun = options.dryRun ?? false; + if (!dryRun) { + await assertBranchesNotCheckedOutElsewhere(cwd, [branch], 'dub pop'); + } const branchCommitCount = await countCommitsAhead(branch, parent, cwd); if (branchCommitCount === 0) { @@ -88,6 +94,11 @@ export async function pop( const previousTip = await getBranchTip(branch, cwd); + if (dryRun) { + const projectedTip = await getRefSha(`HEAD~${steps}`, cwd); + return { branch, steps, previousTip, newTip: projectedTip, dryRun }; + } + await saveUndoEntry( { operation: 'pop', @@ -104,5 +115,5 @@ export async function pop( const newTip = await getBranchTip(branch, cwd); - return { branch, steps, previousTip, newTip }; + return { branch, steps, previousTip, newTip, dryRun }; } diff --git a/packages/cli/src/commands/rename.ts b/packages/cli/src/commands/rename.ts index bca6a2c2..160f933b 100644 --- a/packages/cli/src/commands/rename.ts +++ b/packages/cli/src/commands/rename.ts @@ -20,6 +20,7 @@ interface RenameOptions { * Defaults to pushing when the tracked branch has a PR number recorded. */ noPush?: boolean; + dryRun?: boolean; } export interface RenameResult { @@ -30,6 +31,7 @@ export interface RenameResult { pushed: boolean; /** True when the old branch was previously pushed and may linger on the remote. */ oldRemoteCleanupHint: boolean; + dryRun: boolean; } /** @@ -119,7 +121,10 @@ export async function rename( ]); } - await assertBranchesNotCheckedOutElsewhere(cwd, [oldName], 'dub rename'); + const dryRun = options.dryRun ?? false; + if (!dryRun) { + await assertBranchesNotCheckedOutElsewhere(cwd, [oldName], 'dub rename'); + } const childBranches = sourceStack.branches.filter( (b) => b.parent === oldName, @@ -127,6 +132,18 @@ export async function rename( const prNumber = sourceBranch.pr_number; const hadRemote = sourceBranch.last_submitted_version != null; + if (dryRun) { + return { + oldName, + newName, + reparentedChildren: childBranches.map((c) => c.name), + prNumber, + pushed: false, + oldRemoteCleanupHint: hadRemote || prNumber != null, + dryRun: true, + }; + } + await saveUndoEntry( { operation: 'rename', @@ -182,5 +199,6 @@ export async function rename( prNumber, pushed, oldRemoteCleanupHint: hadRemote || pushed, + dryRun: false, }; } diff --git a/packages/cli/src/commands/reorder.ts b/packages/cli/src/commands/reorder.ts index 227118f2..7fec42f4 100644 --- a/packages/cli/src/commands/reorder.ts +++ b/packages/cli/src/commands/reorder.ts @@ -48,6 +48,8 @@ export interface ReorderOptions { * conflict-path branches are covered without a TTY. */ promptConflict?: (branch: string) => Promise<'continue' | 'cancel' | 'exit'>; + /** Preview the reorderable commits without launching the picker. */ + dryRun?: boolean; } /** @@ -56,7 +58,7 @@ export interface ReorderOptions { * dispatch shape it uses for `dub restack` and `dub move`. */ export interface ReorderResult { - status: 'success' | 'conflict' | 'cancelled' | 'no-op' | 'exit'; + status: 'success' | 'conflict' | 'cancelled' | 'no-op' | 'exit' | 'dry-run'; /** Commits in the new order (oldest-first), excluding dropped commits. */ finalPicks: string[]; /** Commits the user marked as `drop`. */ @@ -65,6 +67,10 @@ export interface ReorderResult { rebased: string[]; /** Set when the cascading restack hit a conflict. */ conflictBranch?: string; + /** True when invoked with `--dry-run`; no mutations were performed. */ + dryRun: boolean; + /** Reorderable commits (oldest-first) returned by `--dry-run`. */ + reorderableCommits?: string[]; /** * Discriminates between the reorder rebase itself producing a conflict * (`'reorder'`) and the cascading descendant restack producing one @@ -174,11 +180,14 @@ export async function reorder( ); } - await assertBranchesNotCheckedOutElsewhere( - cwd, - [currentBranch], - 'dub reorder', - ); + const dryRun = options.dryRun ?? false; + if (!dryRun) { + await assertBranchesNotCheckedOutElsewhere( + cwd, + [currentBranch], + 'dub reorder', + ); + } const parent = getParent(state, currentBranch); if (!parent) { @@ -215,6 +224,22 @@ export async function reorder( ); } + if (dryRun) { + const reorderableCommits = commits + .slice() + .reverse() + .map((c) => c.sha); + return { + status: 'dry-run', + finalPicks: reorderableCommits, + dropped: [], + rebased: [], + noOpReason: `Would launch picker for ${commits.length} commit(s) on '${currentBranch}'.`, + dryRun: true, + reorderableCommits, + }; + } + const pickerResult = options.entries ? { kind: 'done' as const, @@ -229,6 +254,7 @@ export async function reorder( dropped: [], rebased: [], noOpReason: 'Cancelled in picker', + dryRun: false, }; } @@ -246,6 +272,7 @@ export async function reorder( dropped: [], rebased: [], noOpReason: 'No reorder or drop changes were made in the picker', + dryRun: false, }; } @@ -289,6 +316,7 @@ export async function reorder( rebased: [], noOpReason: 'Cancelled mid-conflict; rolled back to pre-reorder state', + dryRun: false, }; } if (decision === 'exit') { @@ -299,6 +327,7 @@ export async function reorder( rebased: [], conflictBranch: currentBranch, conflictSource: 'reorder', + dryRun: false, }; } // 'continue' — user resolves manually then runs `git rebase --continue` @@ -312,6 +341,7 @@ export async function reorder( rebased: [], conflictBranch: currentBranch, conflictSource: 'reorder', + dryRun: false, }; } throw error; @@ -355,6 +385,7 @@ export async function reorder( conflictSource: 'restack' as const, } : {}), + dryRun: false, }; } diff --git a/packages/cli/src/commands/restack.ts b/packages/cli/src/commands/restack.ts index f6c87f7e..6bb7e920 100644 --- a/packages/cli/src/commands/restack.ts +++ b/packages/cli/src/commands/restack.ts @@ -45,6 +45,12 @@ interface RestackResult { status: 'success' | 'conflict' | 'up-to-date'; rebased: string[]; conflictBranch?: string; + /** True when invoked with `--dry-run`; no rebase ran and no state was written. */ + dryRun: boolean; + /** Branches that would be rebased onto their parent's new tip. */ + plannedRebases?: string[]; + /** Branches that would be skipped (frozen, frozen ancestor, worktree checkout, or already current). */ + plannedSkips?: string[]; } export interface RestackOptions { @@ -54,6 +60,8 @@ export interface RestackOptions { * or conflict inside restack doesn't overwrite the outer operation's entry. */ skipUndoEntry?: boolean; + /** Preview the planned rebases without mutating refs or state. */ + dryRun?: boolean; } /** @@ -119,9 +127,48 @@ export async function restack( } const steps = await buildRestackSteps(targetStacks, cwd, worktreeCheckouts); + const dryRun = options.dryRun ?? false; if (steps.length === 0) { - return { status: 'up-to-date', rebased: [] }; + return { + status: 'up-to-date', + rebased: [], + dryRun, + ...(dryRun ? { plannedRebases: [], plannedSkips: [] } : {}), + }; + } + + if (dryRun) { + const plannedRebases: string[] = []; + const plannedSkips: string[] = []; + for (const step of steps) { + if (step.status === 'skipped') { + plannedSkips.push(step.branch); + continue; + } + const parentNewTip = await getBranchTip(step.parent, cwd); + if (parentNewTip === step.parentOldTip) { + plannedSkips.push(step.branch); + continue; + } + const hasUniquePatches = await hasUniquePatchCommits( + parentNewTip, + step.branch, + cwd, + ); + if (!hasUniquePatches) { + plannedSkips.push(step.branch); + continue; + } + plannedRebases.push(step.branch); + } + return { + status: plannedRebases.length === 0 ? 'up-to-date' : 'success', + rebased: [], + dryRun: true, + plannedRebases, + plannedSkips, + }; } for (const step of steps) { @@ -150,7 +197,7 @@ export async function restack( } if (steps.every((step) => step.status === 'skipped')) { - return { status: 'up-to-date', rebased: [] }; + return { status: 'up-to-date', rebased: [], dryRun }; } if (!options.skipUndoEntry) { @@ -270,7 +317,12 @@ async function executeRestackSteps( step.status = 'conflicted'; step.parentNewTip = parentNewTip; await writeProgress(progress, cwd); - return { status: 'conflict', rebased, conflictBranch: step.branch }; + return { + status: 'conflict', + rebased, + conflictBranch: step.branch, + dryRun: false, + }; } throw error; } @@ -290,6 +342,7 @@ async function executeRestackSteps( return { status: rebased.length === 0 && allSkipped ? 'up-to-date' : 'success', rebased, + dryRun: false, }; } finally { bar?.stop(); diff --git a/packages/cli/src/commands/revert.ts b/packages/cli/src/commands/revert.ts index f599517c..c63984a7 100644 --- a/packages/cli/src/commands/revert.ts +++ b/packages/cli/src/commands/revert.ts @@ -20,6 +20,7 @@ import { type DubState, ensureState, findStackForBranch, + readStateForDryRun, writeState, } from '../lib/state'; import { clearUndoEntry, saveUndoEntry } from '../lib/undo-log'; @@ -32,6 +33,8 @@ export interface RevertOptions { submit?: boolean; /** Open the editor for the revert commit message instead of `--no-edit`. */ editMessage?: boolean; + /** Preview the planned revert without creating branches or committing. */ + dryRun?: boolean; } export interface RevertResult { @@ -45,6 +48,8 @@ export interface RevertResult { /** PR number when invoked with a PR number; null when invoked with a SHA. */ prNumber: number | null; submitResult: SubmitResult | null; + /** True when invoked with `--dry-run`; no mutations were performed. */ + dryRun: boolean; } const PR_NUMBER_PATTERN = /^#?\d+$/; @@ -84,7 +89,13 @@ export async function revert( ]); } - const state = await ensureState(cwd); + const dryRun = options.dryRun ?? false; + // Dry-run uses the read-only loader so corrupted state (or any error other + // than "not initialized") still surfaces — preserves the invariant that a + // successful dry-run plan corresponds to something a real run could do. + const state: DubState = dryRun + ? await readStateForDryRun(cwd) + : await ensureState(cwd); const currentBranch = await getCurrentBranch(cwd); const trunk = await resolveRevertTrunk(state, currentBranch, cwd); @@ -118,6 +129,20 @@ export async function revert( } const startBranch = currentBranch; + + if (dryRun) { + return { + branch: branchName, + trunk, + revertedSha: resolved.sha, + revertedShortSha: resolved.shortSha, + sourceLabel: resolved.sourceLabel, + prNumber: resolved.prNumber, + submitResult: null, + dryRun: true, + }; + } + const trunkStartPoint = await resolveTrunkStartPoint(trunk, cwd); // Save undo BEFORE the first git mutation. If we crash between `git @@ -248,6 +273,7 @@ export async function revert( sourceLabel: resolved.sourceLabel, prNumber: resolved.prNumber, submitResult, + dryRun: false, }; } catch (error) { progress.stop(); diff --git a/packages/cli/src/commands/split.test.ts b/packages/cli/src/commands/split.test.ts index cfda07c2..bd0d979c 100644 --- a/packages/cli/src/commands/split.test.ts +++ b/packages/cli/src/commands/split.test.ts @@ -241,28 +241,19 @@ describe('split --ai', () => { await writeConfig({ aiAssistantEnabled: true }, dir); }); - it('dry-run returns the proposal without touching branches', async () => { + it('dry-run skips the AI call and returns no proposal', async () => { + // Per DUB-70 (Copilot review on split.ts:286): `--ai --dry-run` must + // bail BEFORE invoking the AI provider so previews never bill. The + // result intentionally omits `aiProposal` — callers who want the + // model's proposed shape re-run without --dry-run. await create('feat/source', dir); await writeAndCommit('runtime.ts', 'r1\n', 'feat: runtime'); await writeAndCommit('docs.md', 'docs\n', 'docs: notes'); const before = await getBranchTip('feat/source', dir); - - const fakeProposal = JSON.stringify({ - splits: [ - { - branch: 'feat/runtime', - files: ['runtime.ts'], - summary: 'runtime changes', - }, - { - branch: 'docs/notes', - files: ['docs.md'], - summary: 'docs only', - }, - ], + const generateText = vi.fn().mockImplementation(() => { + throw new Error('AI provider must not be called in dry-run'); }); - const generateText = vi.fn().mockResolvedValueOnce({ text: fakeProposal }); const deps = { generateText, createGoogleGenerativeAI: vi.fn().mockReturnValue(() => 'model'), @@ -271,7 +262,6 @@ describe('split --ai', () => { fromIni: vi.fn(), fromNodeProviderChain: vi.fn(), }; - process.env.DUBSTACK_GEMINI_API_KEY = 'test-key'; const result = await split( dir, @@ -280,11 +270,9 @@ describe('split --ai', () => { deps as any, ); - delete process.env.DUBSTACK_GEMINI_API_KEY; - - expect(result.aiProposal).toBeDefined(); - expect(result.aiProposal).toHaveLength(2); - expect(result.aiProposal?.[0].branch).toBe('feat/runtime'); + expect(generateText).not.toHaveBeenCalled(); + expect(result.dryRun).toBe(true); + expect(result.aiProposal).toBeUndefined(); expect(result.created).toHaveLength(0); const after = await getBranchTip('feat/source', dir); expect(after).toBe(before); diff --git a/packages/cli/src/commands/split.ts b/packages/cli/src/commands/split.ts index 6280a692..bc51a520 100644 --- a/packages/cli/src/commands/split.ts +++ b/packages/cli/src/commands/split.ts @@ -82,7 +82,7 @@ export interface SplitOptions { closeOldPr?: boolean; /** Skip the auto-restack after the split completes. */ noRestack?: boolean; - /** AI mode only: print the proposal and exit without applying. */ + /** Preview the planned split without mutating refs, state, or PRs. */ dryRun?: boolean; /** AI mode only: skip the interactive approval prompt. */ yes?: boolean; @@ -114,6 +114,10 @@ export interface SplitResult { restacked: boolean; /** AI mode only: the proposal returned by the model (filled even on --dry-run). */ aiProposal?: AiSplitProposal[]; + /** True when invoked with `--dry-run`; no mutations were performed. */ + dryRun: boolean; + /** Planned new-branch names for non-AI dry-run modes. */ + plannedBranches?: string[]; } type SplitDependencies = AiMetadataDependencies; @@ -175,7 +179,14 @@ export async function split( ]); } const parentBranch = sourceMeta.parent; - await assertBranchesNotCheckedOutElsewhere(cwd, [sourceBranch], 'dub split'); + const dryRun = options.dryRun ?? false; + if (!dryRun) { + await assertBranchesNotCheckedOutElsewhere( + cwd, + [sourceBranch], + 'dub split', + ); + } const parentTip = await getBranchTip(parentBranch, cwd); const sourceTipBefore = await getBranchTip(sourceBranch, cwd); const existingPrNumber = sourceMeta.pr_number ?? null; @@ -193,18 +204,45 @@ export async function split( } } } - await saveUndoEntry( - { - operation: 'split', - timestamp: new Date().toISOString(), - previousBranch: sourceBranch, - previousState: structuredClone(state), - branchTips: splitBranchTipsBefore, - createdBranches: [], - summary: `split ${sourceBranch}`, - }, - cwd, - ); + if (!dryRun) { + await saveUndoEntry( + { + operation: 'split', + timestamp: new Date().toISOString(), + previousBranch: sourceBranch, + previousState: structuredClone(state), + branchTips: splitBranchTipsBefore, + createdBranches: [], + summary: `split ${sourceBranch}`, + }, + cwd, + ); + } + + // Non-AI dry-run: validate scope, emit a plan, and bail before any mutation. + // AI dry-run still falls through so the model proposal is included below. + if (dryRun && options.mode !== 'ai') { + let plannedBranches: string[] = []; + if (options.mode === 'by-file') { + plannedBranches = options.name ? [options.name] : []; + } else if (options.mode === 'by-commit') { + const commits = await listCommitsBetween(parentBranch, sourceBranch, cwd); + plannedBranches = commits.map((_, i) => ``); + } else if (options.mode === 'by-hunk') { + plannedBranches = ['']; + } + return { + sourceBranch, + parentBranch, + created: [], + sourceEmpty: false, + existingPrNumber, + prClosed: false, + restacked: false, + dryRun: true, + plannedBranches, + }; + } const created: SplitNewBranchResult[] = []; let aiProposal: AiSplitProposal[] | undefined; @@ -225,15 +263,10 @@ export async function split( "Rerun 'dub split --by-file ' or '--by-commit' to drive the split manually.", ]); } - aiProposal = await proposeAiSplit({ - cwd, - sourceBranch, - parentBranch, - parentTip, - sourceTip: sourceTipBefore, - deps: depsArg ?? (await loadAiDeps()), - providerConfig: config.ai.provider, - }); + // Bail BEFORE calling the AI so `--ai --dry-run` never bills the provider. + // Matches the contract documented for absorb --ai --dry-run. The plan is + // intentionally proposal-less; users who want the model's proposed shape + // re-run without --dry-run. if (options.dryRun) { return { sourceBranch, @@ -243,9 +276,18 @@ export async function split( existingPrNumber, prClosed: false, restacked: false, - aiProposal, + dryRun: true, }; } + aiProposal = await proposeAiSplit({ + cwd, + sourceBranch, + parentBranch, + parentTip, + sourceTip: sourceTipBefore, + deps: depsArg ?? (await loadAiDeps()), + providerConfig: config.ai.provider, + }); if (!options.yes && options.interactive !== false) { await confirmAiProposal(aiProposal); } @@ -464,6 +506,7 @@ export async function split( prClosed, restacked, aiProposal, + dryRun: false, }; } diff --git a/packages/cli/src/commands/squash.ts b/packages/cli/src/commands/squash.ts index c273de85..e69e5819 100644 --- a/packages/cli/src/commands/squash.ts +++ b/packages/cli/src/commands/squash.ts @@ -32,6 +32,8 @@ export interface SquashOptions { message?: string; /** Generate a summary commit message from the squashed commits via AI. */ ai?: boolean; + /** Preview the planned squash without resetting/committing/restacking. */ + dryRun?: boolean; } export interface SquashResult { @@ -45,6 +47,8 @@ export interface SquashResult { restacked: boolean; /** Set when the squash was a no-op for 0/1 commits. */ noopReason?: 'no-commits' | 'single-commit'; + /** True when invoked with `--dry-run`; no mutations were performed. */ + dryRun: boolean; } interface SquashDependencies { @@ -107,7 +111,10 @@ export async function squash( "Run 'dub log' to inspect the stack and confirm tracking state.", ]); } - await assertBranchesNotCheckedOutElsewhere(cwd, [branch], 'dub squash'); + const dryRun = options.dryRun ?? false; + if (!dryRun) { + await assertBranchesNotCheckedOutElsewhere(cwd, [branch], 'dub squash'); + } const commitCount = await countCommitsAhead(branch, parent, cwd); if (commitCount <= 1) { @@ -117,6 +124,31 @@ export async function squash( squashedCommits: 0, restacked: false, noopReason: commitCount === 0 ? 'no-commits' : 'single-commit', + dryRun, + }; + } + + if (dryRun) { + const originalMessages = await getCommitMessagesBetween( + parent, + branch, + cwd, + ); + let plannedMessage: string; + if (options.message?.trim()) { + plannedMessage = options.message.trim(); + } else if (options.ai) { + plannedMessage = ''; + } else { + plannedMessage = originalMessages.join('\n\n'); + } + return { + branch, + parent, + squashedCommits: commitCount, + message: plannedMessage, + restacked: false, + dryRun: true, }; } @@ -187,6 +219,7 @@ export async function squash( squashedCommits: commitCount, message, restacked, + dryRun: false, }; } diff --git a/packages/cli/src/commands/stash.ts b/packages/cli/src/commands/stash.ts index 0732a276..773f32f8 100644 --- a/packages/cli/src/commands/stash.ts +++ b/packages/cli/src/commands/stash.ts @@ -19,6 +19,8 @@ import { ensureState } from '../lib/state'; export interface StashPushOptions { /** Optional user-supplied message. When omitted, a default with branch + timestamp is used. */ message?: string; + /** Preview the planned stash without invoking `git stash push`. */ + dryRun?: boolean; } export interface StashPushResult { @@ -26,6 +28,7 @@ export interface StashPushResult { sha: string; message: string; createdAt: string; + dryRun: boolean; } /** @@ -40,7 +43,8 @@ export async function stashPush( cwd: string, options: StashPushOptions = {}, ): Promise { - await ensureState(cwd); + const dryRun = options.dryRun ?? false; + if (!dryRun) await ensureState(cwd); const branch = await getCurrentBranch(cwd); if (await isWorkingTreeClean(cwd)) { @@ -54,6 +58,10 @@ export async function stashPush( const message = options.message?.trim() || `dub stash: ${branch} @ ${createdAt}`; + if (dryRun) { + return { branch, sha: '', message, createdAt, dryRun: true }; + } + const sha = await gitStashPushIncludeUntracked(message, cwd); if (!sha) { throw new DubError('Git reported no changes to stash.', [ @@ -75,7 +83,7 @@ export async function stashPush( ); } - return { branch, sha, message, createdAt }; + return { branch, sha, message, createdAt, dryRun: false }; } export interface StashPopOptions { @@ -83,6 +91,8 @@ export interface StashPopOptions { on?: string; /** Allow popping onto the current branch even when it differs from the source branch. */ force?: boolean; + /** Preview the planned pop without checking out or applying the stash. */ + dryRun?: boolean; } export interface StashPopResult { @@ -96,6 +106,7 @@ export interface StashPopResult { message: string; /** True when the user passed `--on` and we checked out before popping. */ checkedOut: boolean; + dryRun: boolean; } /** @@ -113,7 +124,8 @@ export async function stashPop( cwd: string, options: StashPopOptions = {}, ): Promise { - await ensureState(cwd); + const dryRun = options.dryRun ?? false; + if (!dryRun) await ensureState(cwd); const log = await readStashLog(cwd); if (log.length === 0) { throw new DubError('No dub stash entries to pop.', [ @@ -129,10 +141,12 @@ export async function stashPop( // Clean up the dangling log entry so the user can move forward, but // never let a log-write error mask the actionable "no longer present" // DubError below — the log cleanup is best-effort. - try { - await removeStashLogEntry(entry.sha, cwd); - } catch { - // best-effort: leave the dangling entry; the user can re-pop later. + if (!dryRun) { + try { + await removeStashLogEntry(entry.sha, cwd); + } catch { + // best-effort: leave the dangling entry; the user can re-pop later. + } } throw new DubError( `Recorded stash for '${entry.branch}' (${entry.sha.slice(0, 7)}) is no longer in 'git stash list'.`, @@ -162,7 +176,7 @@ export async function stashPop( ]); } if (desired !== currentBranch) { - await checkoutBranch(desired, cwd); + if (!dryRun) await checkoutBranch(desired, cwd); checkedOut = true; } targetBranch = desired; @@ -177,6 +191,17 @@ export async function stashPop( ); } + if (dryRun) { + return { + branch: targetBranch, + sourceBranch: entry.branch, + sha: entry.sha, + message: entry.message, + checkedOut, + dryRun: true, + }; + } + await gitStashPop(match.ref, cwd); // git has already removed the stash from its stack; if writing the log fails // here (disk full, permissions), the next `dub stash pop` will surface a @@ -196,6 +221,7 @@ export async function stashPop( sha: entry.sha, message: entry.message, checkedOut, + dryRun: false, }; } diff --git a/packages/cli/src/commands/sync.ts b/packages/cli/src/commands/sync.ts index 42b68a0b..f12eb922 100644 --- a/packages/cli/src/commands/sync.ts +++ b/packages/cli/src/commands/sync.ts @@ -458,10 +458,14 @@ export async function sync( all?: boolean; interactive?: boolean; fresh?: boolean; + dryRun?: boolean; } = {}, ): Promise { - await ensureGhInstalled(); - await checkGhAuth(); + const dryRun = rawOptions.dryRun ?? false; + if (!dryRun) { + await ensureGhInstalled(); + await checkGhAuth(); + } const options: SyncOptions = { restack: rawOptions.restack ?? true, @@ -469,6 +473,7 @@ export async function sync( all: rawOptions.all ?? false, interactive: rawOptions.interactive ?? isInteractiveShell(), fresh: rawOptions.fresh ?? false, + dryRun, }; const showAiPromptOptions = options.interactive ? await canShowAiPrompt(cwd) @@ -539,7 +544,18 @@ export async function sync( branches: [], restacked: false, reconcileSources: {}, + dryRun, }; + + if (dryRun) { + return { + ...result, + plannedScope: { + roots: Array.from(roots), + branches: Array.from(stackBranches), + }, + }; + } const rootHasRemote = new Map(); let pendingError: Error | null = null; let restoreTarget = originalBranch; diff --git a/packages/cli/src/commands/track.test.ts b/packages/cli/src/commands/track.test.ts index bea9db09..378467ed 100644 --- a/packages/cli/src/commands/track.test.ts +++ b/packages/cli/src/commands/track.test.ts @@ -16,6 +16,7 @@ describe('track command', () => { branch: 'feat/a', parent: 'main', status: 'tracked', + dryRun: false, }); }); @@ -28,6 +29,7 @@ describe('track command', () => { expect(trackBranch).toHaveBeenCalledWith(cwd, { branch: 'feat/a', parent: 'main', + dryRun: false, }); }); @@ -40,6 +42,7 @@ describe('track command', () => { expect(trackBranch).toHaveBeenCalledWith(cwd, { branch: 'feat/a', parent: 'feat/base', + dryRun: false, }); }); @@ -51,6 +54,7 @@ describe('track command', () => { expect(trackBranch).toHaveBeenCalledWith(cwd, { branch: 'feat/a', parent: 'develop', + dryRun: false, }); }); }); diff --git a/packages/cli/src/commands/track.ts b/packages/cli/src/commands/track.ts index 6363d442..f3948cce 100644 --- a/packages/cli/src/commands/track.ts +++ b/packages/cli/src/commands/track.ts @@ -9,6 +9,7 @@ import { saveUndoEntry } from '../lib/undo-log'; interface TrackOptions { parent?: string; interactive?: boolean; + dryRun?: boolean; } function isInteractiveShell(): boolean { @@ -82,9 +83,10 @@ export async function track( ]); } + const dryRun = options.dryRun ?? false; const previousState = await readState(cwd).catch(() => null); - const result = await trackBranch(cwd, { branch, parent }); - if (previousState && result.status !== 'unchanged') { + const result = await trackBranch(cwd, { branch, parent, dryRun }); + if (!dryRun && previousState && result.status !== 'unchanged') { await saveUndoEntry( { operation: 'track', diff --git a/packages/cli/src/commands/unfreeze.ts b/packages/cli/src/commands/unfreeze.ts index 932bc501..c1e0084e 100644 --- a/packages/cli/src/commands/unfreeze.ts +++ b/packages/cli/src/commands/unfreeze.ts @@ -3,6 +3,7 @@ import { applyFreezeFlag, type FreezeResult } from '../lib/freeze'; export interface UnfreezeCommandOptions { upstack?: boolean; downstack?: boolean; + dryRun?: boolean; } /** @@ -20,7 +21,12 @@ export async function unfreeze( ): Promise { return applyFreezeFlag({ cwd, - options: { branch, upstack: options.upstack, downstack: options.downstack }, + options: { + branch, + upstack: options.upstack, + downstack: options.downstack, + dryRun: options.dryRun, + }, frozen: false, commandLabel: 'dub unfreeze', undoOperation: 'unfreeze', diff --git a/packages/cli/src/commands/unlink.ts b/packages/cli/src/commands/unlink.ts index 1f731a66..d721a636 100644 --- a/packages/cli/src/commands/unlink.ts +++ b/packages/cli/src/commands/unlink.ts @@ -32,6 +32,7 @@ export interface UnlinkOptions { * original stack) instead of moving them with `` into the new stack. */ orphanChildren?: boolean; + dryRun?: boolean; } export interface UnlinkResult { @@ -53,6 +54,8 @@ export interface UnlinkResult { prNumber?: number; /** True when `--no-retarget` short-circuited a PR retarget; surface a warning. */ retargetSkipped: boolean; + /** True when invoked with `--dry-run`; no mutations were performed. */ + dryRun: boolean; } /** @@ -112,7 +115,10 @@ export async function unlink( `Run 'dub untrack ${branch}' to drop the root from tracking entirely.`, ]); } - await assertBranchesNotCheckedOutElsewhere(cwd, [branch], 'dub unlink'); + const dryRun = options.dryRun ?? false; + if (!dryRun) { + await assertBranchesNotCheckedOutElsewhere(cwd, [branch], 'dub unlink'); + } const previousParent = entry.parent; if (!previousParent) { @@ -146,7 +152,7 @@ export async function unlink( newBase: string; prNumber: number; } | null = null; - if (!options.noRetarget && entry.pr_number != null) { + if (!dryRun && !options.noRetarget && entry.pr_number != null) { await ensureGhInstalled(); await checkGhAuth(); const info = await getBranchPrSyncInfo(branch, cwd); @@ -159,6 +165,26 @@ export async function unlink( } } + if (dryRun) { + // The real run allocates a fresh UUID for the new stack. Reporting a + // randomUUID here would (a) make the JSON plan nondeterministic across + // identical inputs and (b) guarantee the value never matches the SHA of + // the eventual real run, misleading scripted callers. Use a stable + // placeholder so consumers know the field is projected, not authoritative. + return { + branch, + previousParent, + newStackId: '', + trunk: trunkName, + movedDescendants: options.orphanChildren ? [] : descendants, + orphanedChildren: options.orphanChildren ? directChildren : [], + retargeted: false, + ...(entry.pr_number != null ? { prNumber: entry.pr_number } : {}), + retargetSkipped: options.noRetarget === true && entry.pr_number != null, + dryRun: true, + }; + } + const originalBranch = await getCurrentBranch(cwd); const previousState = structuredClone(state); @@ -271,5 +297,6 @@ export async function unlink( retargeted, ...(prNumber != null ? { prNumber } : {}), retargetSkipped, + dryRun: false, }; } diff --git a/packages/cli/src/commands/untrack.test.ts b/packages/cli/src/commands/untrack.test.ts index 1d91e9c6..697ae64b 100644 --- a/packages/cli/src/commands/untrack.test.ts +++ b/packages/cli/src/commands/untrack.test.ts @@ -23,6 +23,7 @@ describe('untrack command', () => { vi.mocked(untrackBranch).mockResolvedValue({ removed: ['feat/a'], reparented: [], + dryRun: false, }); }); @@ -34,6 +35,7 @@ describe('untrack command', () => { expect(untrackBranch).toHaveBeenCalledWith(cwd, { branch: 'feat/a', downstack: false, + dryRun: false, }); }); @@ -53,6 +55,7 @@ describe('untrack command', () => { expect(untrackBranch).toHaveBeenCalledWith(cwd, { branch: 'feat/a', downstack: true, + dryRun: false, }); }); diff --git a/packages/cli/src/commands/untrack.ts b/packages/cli/src/commands/untrack.ts index e87e8f04..dd6f951c 100644 --- a/packages/cli/src/commands/untrack.ts +++ b/packages/cli/src/commands/untrack.ts @@ -13,6 +13,7 @@ import { interface UntrackCommandOptions { downstack?: boolean; interactive?: boolean; + dryRun?: boolean; } function isInteractiveShell(): boolean { @@ -55,10 +56,11 @@ export async function untrack( downstack = await confirmDownstack(branch, context.descendants); } + const dryRun = options.dryRun ?? false; const previousState = await readState(cwd).catch(() => null); const currentBranch = await getCurrentBranch(cwd).catch(() => branch); - const result = await untrackBranch(cwd, { branch, downstack }); - if (previousState && result.removed.length > 0) { + const result = await untrackBranch(cwd, { branch, downstack, dryRun }); + if (!dryRun && previousState && result.removed.length > 0) { await saveUndoEntry( { operation: 'untrack', diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 33dea5bd..2154fa2c 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -138,6 +138,36 @@ function emitJsonError(error: DubError): void { ); } +/** + * Emits the dry-run plan for a mutating command. Used by every command that + * supports `--dry-run --json` to serialise the plan with the standard schema + * envelope. + */ +function emitDryRunPlan(plan: object): void { + // JSON mode should already be active (see `maybeActivateDryRunJsonMode`) + // so any pre-emit error has already been envelope-formatted. We still call + // it here for safety in case a caller bypassed the early-activation hook. + activateJsonMode(); + console.log(JSON.stringify(withSchemaVersion(plan), null, 2)); +} + +/** + * Switch the top-level error handler to JSON-envelope output whenever an + * action sees `--dry-run --json`. Called at the head of each mutating + * command's action so a `DubError` thrown during validation (before + * `emitDryRunPlan` would otherwise fire) still emits the standard + * `jsonErrorEnvelope` shape for scripted callers, instead of the human + * red-error formatting that breaks JSON parsers. + */ +function maybeActivateDryRunJsonMode(options: { + dryRun?: boolean; + json?: boolean; +}): void { + if (options.dryRun && options.json) { + activateJsonMode(); + } +} + async function canShowAiPrompt(cwd: string): Promise { try { return await isAiPromptOptionEnabled(cwd); @@ -390,6 +420,11 @@ program 'AI-generate branch + conventional commit from staged changes', ) .option('--no-ai', 'Disable AI generation for this invocation') + .option( + '--dry-run', + 'Print the planned create without mutating refs or state', + ) + .option('--json', 'Pair with --dry-run to emit the plan as JSON') .addHelpText( 'after', ` @@ -399,6 +434,7 @@ Examples: $ dub create feat/api -am "feat: add API" Stage all + create + commit $ dub create --ai AI-generate branch + commit from staged $ dub create --no-ai feat/api Override repo AI defaults for one create + $ dub create feat/api --dry-run Preview the plan without mutating See also: dub modify, dub flow, dub track, dub log`, @@ -413,6 +449,8 @@ See also: patch?: boolean; ai?: boolean; noAi?: boolean; + dryRun?: boolean; + json?: boolean; }, ) => { const result = await create(branchName, process.cwd(), { @@ -422,7 +460,20 @@ See also: patch: options.patch, ai: options.ai, noAi: options.noAi, + dryRun: options.dryRun, }); + if (result.dryRun) { + if (options.json) { + emitDryRunPlan(result); + return; + } + console.log( + chalk.green( + `✔ Dry-run: would create '${result.branch}' on '${result.parent}'${result.committed ? ` • ${result.committed}` : ''}`, + ), + ); + return; + } if (result.committed) { console.log( chalk.green( @@ -759,13 +810,16 @@ program '--no-interactive', 'Disable parent prompt and require deterministic behavior', ) + .option('--dry-run', 'Print the planned track without mutating state') + .option('--json', 'Pair with --dry-run to emit the plan as JSON') .description('Track a branch or update its parent relationship') .addHelpText( 'after', ` Examples: - $ dub track Adopt the current branch (DubStack picks the parent) - $ dub track feat/a --parent main Adopt feat/a with main as the explicit parent + $ dub track Adopt the current branch (DubStack picks the parent) + $ dub track feat/a --parent main Adopt feat/a with main as the explicit parent + $ dub track feat/a --parent main --dry-run Preview the plan without mutating See also: dub untrack, dub create, dub log, dub doctor`, @@ -773,12 +827,30 @@ See also: .action( async ( branch: string | undefined, - options: { parent?: string; interactive?: boolean }, + options: { + parent?: string; + interactive?: boolean; + dryRun?: boolean; + json?: boolean; + }, ) => { const result = await track(process.cwd(), branch, { parent: options.parent, interactive: options.interactive, + dryRun: options.dryRun, }); + if (result.dryRun) { + if (options.json) { + emitDryRunPlan(result); + return; + } + console.log( + chalk.green( + `✔ Dry-run: would ${result.status} '${result.branch}' on '${result.parent}'`, + ), + ); + return; + } if (result.status === 'tracked') { console.log( chalk.green(`✔ Tracking '${result.branch}' on '${result.parent}'`), @@ -811,6 +883,8 @@ program .argument('[branch]', 'Branch to untrack (defaults to current branch)') .option('--downstack', 'Also untrack descendants recursively') .option('--no-interactive', 'Disable prompts and require explicit flags') + .option('--dry-run', 'Print the planned untrack without mutating state') + .option('--json', 'Pair with --dry-run to emit the plan as JSON') .description( 'Remove branch metadata from DubStack without deleting git branches', ) @@ -820,6 +894,7 @@ program Examples: $ dub untrack Drop tracking metadata for the current branch $ dub untrack feat/a --downstack Untrack feat/a and its ancestors toward trunk + $ dub untrack feat/a --dry-run Preview the plan without mutating See also: dub track, dub delete, dub prune`, @@ -827,12 +902,30 @@ See also: .action( async ( branch: string | undefined, - options: { downstack?: boolean; interactive?: boolean }, + options: { + downstack?: boolean; + interactive?: boolean; + dryRun?: boolean; + json?: boolean; + }, ) => { const result = await untrack(process.cwd(), branch, { downstack: options.downstack, interactive: options.interactive, + dryRun: options.dryRun, }); + if (result.dryRun) { + if (options.json) { + emitDryRunPlan(result); + return; + } + console.log( + chalk.green( + `✔ Dry-run: would untrack ${result.removed.length} branch(es): ${result.removed.join(', ')}`, + ), + ); + return; + } console.log( chalk.green( `✔ Untracked ${result.removed.length} branch(es): ${result.removed.join(', ')}`, @@ -856,6 +949,11 @@ program .option('-f, --force', 'Delete branches even when not merged') .option('-q, --quiet', 'Skip confirmation prompts') .option('--no-interactive', 'Disable prompts and require explicit flags') + .option( + '--dry-run', + 'Print the planned delete without mutating refs or state', + ) + .option('--json', 'Pair with --dry-run to emit the plan as JSON') .description('Delete local branches and update DubStack metadata') .addHelpText( 'after', @@ -863,6 +961,7 @@ program Examples: $ dub delete feat/a Delete feat/a (with confirmation) $ dub delete feat/a --upstack -f -q Delete feat/a + descendants, force, quiet + $ dub delete feat/a --dry-run Preview the plan without mutating See also: dub untrack, dub prune, dub fold`, @@ -876,6 +975,8 @@ See also: force?: boolean; quiet?: boolean; interactive?: boolean; + dryRun?: boolean; + json?: boolean; }, ) => { const result = await deleteCommand(process.cwd(), branch, { @@ -884,7 +985,20 @@ See also: force: options.force, quiet: options.quiet, interactive: options.interactive, + dryRun: options.dryRun, }); + if (result.dryRun) { + if (options.json) { + emitDryRunPlan(result); + return; + } + console.log( + chalk.green( + `✔ Dry-run: would delete ${result.deleted.length} branch(es): ${result.deleted.join(', ')}`, + ), + ); + return; + } if (result.cancelled) { console.log(chalk.yellow('⚠ Delete cancelled.')); return; @@ -919,6 +1033,8 @@ program 'Preserve commits as separate commits on the parent (default)', ) .option('--no-interactive', 'Disable prompts and require --force') + .option('--dry-run', 'Print the planned fold without mutating refs or state') + .option('--json', 'Pair with --dry-run to emit the plan as JSON') .addHelpText( 'after', ` @@ -926,6 +1042,7 @@ Examples: $ dub fold Fold current branch into parent (keeps commits) $ dub fold --squash Collapse current branch into a single commit on parent $ dub fold --force Skip the confirmation prompt + $ dub fold --dry-run Preview the plan without mutating See also: dub squash, dub delete, dub move`, @@ -936,6 +1053,8 @@ See also: squash?: boolean; keepCommits?: boolean; interactive?: boolean; + dryRun?: boolean; + json?: boolean; }) => { if (options.squash && options.keepCommits) { throw new DubError( @@ -950,7 +1069,20 @@ See also: force: options.force, squash: options.squash, interactive: options.interactive, + dryRun: options.dryRun, }); + if (result.dryRun) { + if (options.json) { + emitDryRunPlan(result); + return; + } + console.log( + chalk.green( + `✔ Dry-run: would fold '${result.branch}' (${result.foldedCommits} commit(s)) into '${result.parent}'`, + ), + ); + return; + } if (result.cancelled) { console.log(chalk.yellow('⚠ Fold cancelled.')); return; @@ -994,6 +1126,8 @@ program .argument('', 'Branch to move within the stack') .option('--before ', 'Insert as the new parent of ') .option('--after ', 'Insert as the new child of ') + .option('--dry-run', 'Print the planned move without mutating refs or state') + .option('--json', 'Pair with --dry-run to emit the plan as JSON') .description( 'Reorder a tracked branch within its stack (insert before or after another branch)', ) @@ -1003,13 +1137,45 @@ program Examples: $ dub move feat/inserted --before feat/auth-login Insert before $ dub move feat/inserted --after feat/auth-base Insert after + $ dub move feat/x --after feat/y --dry-run Preview the plan without mutating See also: dub reorder, dub unlink, dub restack`, ) .action( - async (branch: string, options: { before?: string; after?: string }) => { - const result = await move(process.cwd(), branch, options); + async ( + branch: string, + options: { + before?: string; + after?: string; + dryRun?: boolean; + json?: boolean; + }, + ) => { + const result = await move(process.cwd(), branch, { + before: options.before, + after: options.after, + dryRun: options.dryRun, + }); + if (result.dryRun) { + if (options.json) { + emitDryRunPlan(result); + return; + } + console.log( + chalk.green( + `✔ Dry-run: would move '${result.branch}' ${result.position} '${result.target}' (new parent: '${result.newParent}')`, + ), + ); + if (result.retargetCandidates && result.retargetCandidates.length > 0) { + console.log( + chalk.dim( + ` ↳ retarget candidates (PRs with pr_number; real run verifies state): ${result.retargetCandidates.join(', ')}`, + ), + ); + } + return; + } if (result.noOp) { console.log( chalk.yellow( @@ -1066,6 +1232,7 @@ program 'Move fixup commits whose target lives on a different branch in the stack', ) .option('--dry-run', 'Print what would be absorbed without mutating') + .option('--json', 'Pair with --dry-run to emit the plan as JSON') .addHelpText( 'after', ` @@ -1079,8 +1246,17 @@ See also: dub modify, dub squash, dub restack`, ) .action( - async (options: { ai?: boolean; stack?: boolean; dryRun?: boolean }) => { + async (options: { + ai?: boolean; + stack?: boolean; + dryRun?: boolean; + json?: boolean; + }) => { const result = await absorb(process.cwd(), options); + if (result.dryRun && options.json) { + emitDryRunPlan(result); + return; + } if (result.conflict) { console.log( chalk.yellow( @@ -1132,6 +1308,11 @@ program '--orphan-children', 'Re-parent direct children onto the original parent instead of moving them', ) + .option( + '--dry-run', + 'Print the planned unlink without mutating refs or state', + ) + .option('--json', 'Pair with --dry-run to emit the plan as JSON') .description( 'Detach a tracked branch from its parent, splitting it into its own stack', ) @@ -1142,6 +1323,7 @@ Examples: $ dub unlink feat/auth-login Promote feat/auth-login to a new stack root $ dub unlink feat/auth-login --orphan-children Leave descendants on the original parent $ dub unlink feat/auth-login --no-retarget Skip PR retarget (warns about drift) + $ dub unlink feat/auth-login --dry-run Preview the plan without mutating See also: dub move, dub track, dub untrack`, @@ -1153,6 +1335,8 @@ See also: retarget?: boolean; keepChildren?: boolean; orphanChildren?: boolean; + dryRun?: boolean; + json?: boolean; }, ) => { if (options.keepChildren && options.orphanChildren) { @@ -1167,7 +1351,20 @@ See also: const result = await unlink(process.cwd(), branch, { noRetarget: options.retarget === false, orphanChildren: options.orphanChildren ?? false, + dryRun: options.dryRun, }); + if (result.dryRun) { + if (options.json) { + emitDryRunPlan(result); + return; + } + console.log( + chalk.green( + `✔ Dry-run: would unlink '${result.branch}' from '${result.previousParent}'`, + ), + ); + return; + } console.log( chalk.green( `✔ Unlinked '${result.branch}' from '${result.previousParent}'`, @@ -1414,6 +1611,11 @@ program '--fresh', 'Force a full fetch of every tracked branch (skip 5-minute freshness cache)', ) + .option( + '--dry-run', + 'Print the planned sync scope without fetching or mutating', + ) + .option('--json', 'Pair with --dry-run to emit the plan as JSON') .addHelpText( 'after', ` @@ -1423,6 +1625,7 @@ Examples: $ dub sync --no-restack Sync without restacking (manual fix-up later) $ dub sync --fresh Force-fetch each branch even if cached $ dub sync -f Skip prompts on reset/reconcile decisions + $ dub sync --dry-run Preview the planned scope without fetching See also: dub restack, dub post-merge, dub merge-next`, @@ -1434,8 +1637,29 @@ See also: all?: boolean; interactive?: boolean; fresh?: boolean; + dryRun?: boolean; + json?: boolean; }) => { - await sync(process.cwd(), options); + const result = await sync(process.cwd(), { + restack: options.restack, + force: options.force, + all: options.all, + interactive: options.interactive, + fresh: options.fresh, + dryRun: options.dryRun, + }); + if (result.dryRun) { + if (options.json) { + emitDryRunPlan(result); + return; + } + const scope = result.plannedScope; + console.log( + chalk.green( + `✔ Dry-run: would sync ${scope?.roots.length ?? 0} trunk(s) and ${scope?.branches.length ?? 0} branch(es)`, + ), + ); + } }, ); @@ -1443,79 +1667,123 @@ program .command('restack') .description('Rebase all branches in the stack onto their updated parents') .option('--continue', 'Continue restacking after resolving conflicts') + .option( + '--dry-run', + 'Print the planned restack without rebasing or mutating state', + ) + .option('--json', 'Pair with --dry-run to emit the plan as JSON') .addHelpText( 'after', ` Examples: $ dub restack Rebase the current stack onto its updated parents $ dub restack --continue Continue after resolving conflicts + $ dub restack --dry-run Preview which branches would be rebased See also: dub continue, dub abort, dub sync, dub post-merge`, ) - .action(async (options: { continue?: boolean }) => { - const result = options.continue - ? await restackContinue(process.cwd()) - : await restack(process.cwd()); - - if (result.status === 'up-to-date') { - console.log(chalk.green('✔ Stack is already up to date')); - } else if (result.status === 'conflict') { - const interactive = Boolean(process.stdout.isTTY && process.stdin.isTTY); - const conflictBranch = result.conflictBranch ?? 'unknown'; - const showAiOption = interactive - ? await canShowAiPrompt(process.cwd()) - : false; - const decision = await resolveRestackConflictDecision({ - branch: conflictBranch, - interactive, - showAiOption, - promptChoice: (branchName) => - restackConflictPrompt({ branch: branchName, showAiOption }), - }); - if (decision === 'ai') { - await continueCommand(process.cwd(), { ai: true }); - return; + .action( + async (options: { + continue?: boolean; + dryRun?: boolean; + json?: boolean; + }) => { + if (options.continue && options.dryRun) { + // `--continue` resumes an in-flight rebase mid-conflict — there is + // no non-mutating preview for it. Refuse the combination instead of + // silently ignoring one flag. + throw new DubError( + "'--continue' cannot be combined with '--dry-run'.", + [ + "Run 'dub restack --continue' to resume the in-flight restack after resolving conflicts.", + "Run 'dub restack --dry-run' to preview the planned rebases (no in-flight restack required).", + ], + ); } - if (decision === 'cancel') { - const rollback = await rollbackRestack(process.cwd()); + const result = options.continue + ? await restackContinue(process.cwd()) + : await restack(process.cwd(), { dryRun: options.dryRun }); + + if (result.dryRun) { + if (options.json) { + emitDryRunPlan(result); + return; + } + const planned = result.plannedRebases ?? []; + const skipped = result.plannedSkips ?? []; console.log( chalk.green( - `✔ Rolled back ${rollback.branchesRestored} branch(es) to pre-restack state.`, + `✔ Dry-run: would rebase ${planned.length} branch(es); skip ${skipped.length}`, ), ); + for (const branch of planned) { + console.log(chalk.dim(` ↳ rebase: ${branch}`)); + } return; } - if (decision === 'exit') { + + if (result.status === 'up-to-date') { + console.log(chalk.green('✔ Stack is already up to date')); + } else if (result.status === 'conflict') { + const interactive = Boolean( + process.stdout.isTTY && process.stdin.isTTY, + ); + const conflictBranch = result.conflictBranch ?? 'unknown'; + const showAiOption = interactive + ? await canShowAiPrompt(process.cwd()) + : false; + const decision = await resolveRestackConflictDecision({ + branch: conflictBranch, + interactive, + showAiOption, + promptChoice: (branchName) => + restackConflictPrompt({ branch: branchName, showAiOption }), + }); + if (decision === 'ai') { + await continueCommand(process.cwd(), { ai: true }); + return; + } + if (decision === 'cancel') { + const rollback = await rollbackRestack(process.cwd()); + console.log( + chalk.green( + `✔ Rolled back ${rollback.branchesRestored} branch(es) to pre-restack state.`, + ), + ); + return; + } + if (decision === 'exit') { + console.log( + chalk.yellow( + `⚠ Restack left in its current state on '${conflictBranch}'.`, + ), + ); + console.log( + chalk.dim( + ' Run: dub continue (or dub continue --ai), or dub abort to roll back.', + ), + ); + return; + } console.log( - chalk.yellow( - `⚠ Restack left in its current state on '${conflictBranch}'.`, - ), + chalk.yellow(`⚠ Conflict while restacking '${conflictBranch}'`), ); console.log( chalk.dim( - ' Run: dub continue (or dub continue --ai), or dub abort to roll back.', + ' Resolve conflicts, stage changes, then run: dub continue --ai (or dub restack --continue)', ), ); - return; - } - console.log( - chalk.yellow(`⚠ Conflict while restacking '${conflictBranch}'`), - ); - console.log( - chalk.dim( - ' Resolve conflicts, stage changes, then run: dub continue --ai (or dub restack --continue)', - ), - ); - } else { - console.log( - chalk.green(`✔ Restacked ${result.rebased.length} branch(es)`), - ); - for (const branch of result.rebased) { - console.log(chalk.dim(` ↳ ${branch}`)); + } else { + console.log( + chalk.green(`✔ Restacked ${result.rebased.length} branch(es)`), + ); + for (const branch of result.rebased) { + console.log(chalk.dim(` ↳ ${branch}`)); + } } - } - }); + }, + ); program .command('continue') @@ -3265,6 +3533,11 @@ program '--interactive-rebase', 'Start an interactive rebase on the branch commits', ) + .option( + '--dry-run', + 'Print the planned modify without staging, committing, or restacking', + ) + .option('--json', 'Pair with --dry-run to emit the plan as JSON') // .option("--into ", "Amend staged changes to the specified branch") // TODO: Implement --into // .option("--reset-author", "Set the author to the current user") // TODO: Implement --reset-author // .option("-v, --verbose", "Show unified diff") // TODO: Implement verbose @@ -3289,7 +3562,25 @@ See also: ? options.message[0] : options.message, }; - await modify(process.cwd(), normalizedOptions); + const result = await modify(process.cwd(), normalizedOptions); + if (result && 'dryRun' in result && result.dryRun) { + if (options.json) { + emitDryRunPlan(result); + return; + } + console.log( + chalk.green( + `✔ Dry-run: would ${result.action} on '${result.branch}'${result.message ? ` • ${result.message.split('\n')[0]}` : ''}`, + ), + ); + if (result.descendantsToRestack.length > 0) { + console.log( + chalk.dim( + ` ↳ would restack ${result.descendantsToRestack.length} descendant(s)`, + ), + ); + } + } }); program @@ -3302,6 +3593,11 @@ program '--ai', 'Generate a Conventional Commit summary from the squashed commits', ) + .option( + '--dry-run', + 'Print the planned squash without resetting or committing', + ) + .option('--json', 'Pair with --dry-run to emit the plan as JSON') .addHelpText( 'after', ` @@ -3309,42 +3605,72 @@ Examples: $ dub squash Squash and concatenate original messages $ dub squash -m "feat: rewrite api" Squash with a custom commit message $ dub squash --ai Squash with an AI-generated summary + $ dub squash --dry-run Preview the plan without mutating See also: dub fold, dub modify, dub absorb`, ) - .action(async (options: { message?: string; ai?: boolean }) => { - const result = await squash(process.cwd(), { - message: options.message, - ai: options.ai, - }); + .action( + async (options: { + message?: string; + ai?: boolean; + dryRun?: boolean; + json?: boolean; + }) => { + const result = await squash(process.cwd(), { + message: options.message, + ai: options.ai, + dryRun: options.dryRun, + }); + + if (result.dryRun) { + if (options.json) { + emitDryRunPlan(result); + return; + } + if (result.noopReason) { + console.log( + chalk.dim( + `Dry-run: nothing to squash on '${result.branch}' (${result.noopReason}).`, + ), + ); + return; + } + console.log( + chalk.green( + `✔ Dry-run: would squash ${result.squashedCommits} commit(s) on '${result.branch}' into '${result.parent}'`, + ), + ); + return; + } + + if (result.noopReason === 'no-commits') { + console.log( + chalk.dim( + `Nothing to squash — '${result.branch}' has no commits above '${result.parent}'.`, + ), + ); + return; + } + if (result.noopReason === 'single-commit') { + console.log( + chalk.dim( + `Nothing to squash — '${result.branch}' already has a single commit above '${result.parent}'.`, + ), + ); + return; + } - if (result.noopReason === 'no-commits') { - console.log( - chalk.dim( - `Nothing to squash — '${result.branch}' has no commits above '${result.parent}'.`, - ), - ); - return; - } - if (result.noopReason === 'single-commit') { console.log( - chalk.dim( - `Nothing to squash — '${result.branch}' already has a single commit above '${result.parent}'.`, + chalk.green( + `✔ Squashed ${result.squashedCommits} commit(s) on '${result.branch}' into one.`, ), ); - return; - } - - console.log( - chalk.green( - `✔ Squashed ${result.squashedCommits} commit(s) on '${result.branch}' into one.`, - ), - ); - if (result.restacked) { - console.log(chalk.dim(' ↳ Descendants restacked.')); - } - }); + if (result.restacked) { + console.log(chalk.dim(' ↳ Descendants restacked.')); + } + }, + ); program .command('split') @@ -3379,8 +3705,9 @@ program ) .option( '--dry-run', - 'AI mode only: show the proposal and exit without applying', + 'Preview the planned split without mutating refs, state, or PRs', ) + .option('--json', 'Pair with --dry-run to emit the plan as JSON') .option('-y, --yes', 'AI mode only: skip the approval prompt') .option('--no-interactive', 'Disable interactive prompts and require flags') .addHelpText( @@ -3418,6 +3745,8 @@ program 'Number of commits to pop (default: 1)', parsePositiveInt, ) + .option('--dry-run', 'Print the planned pop without resetting HEAD') + .option('--json', 'Pair with --dry-run to emit the plan as JSON') .addHelpText( 'after', ` @@ -3425,41 +3754,78 @@ Examples: $ dub pop Pop last commit into staged changes $ dub pop --steps 3 Squash last 3 commits into staged changes $ dub pop && dub m -a -m "..." Pop, edit, re-commit (descendants restack lazily) + $ dub pop --dry-run Preview the plan without mutating See also: dub modify, dub split, dub undo`, ) - .action(async (options: { steps?: number }) => { - const result = await pop(process.cwd(), { steps: options.steps }); - const noun = result.steps === 1 ? 'commit' : 'commits'; - console.log( - chalk.green( - `✔ Popped ${result.steps} ${noun} from '${result.branch}' into staged changes`, - ), - ); - console.log( - chalk.dim( - ' Edit, then run \'dub modify -a -m ""\' to recommit. Descendants restack on next modify.', - ), - ); - }); + .action( + async (options: { steps?: number; dryRun?: boolean; json?: boolean }) => { + const result = await pop(process.cwd(), { + steps: options.steps, + dryRun: options.dryRun, + }); + if (result.dryRun) { + if (options.json) { + emitDryRunPlan(result); + return; + } + console.log( + chalk.green( + `✔ Dry-run: would pop ${result.steps} commit(s) from '${result.branch}'`, + ), + ); + return; + } + const noun = result.steps === 1 ? 'commit' : 'commits'; + console.log( + chalk.green( + `✔ Popped ${result.steps} ${noun} from '${result.branch}' into staged changes`, + ), + ); + console.log( + chalk.dim( + ' Edit, then run \'dub modify -a -m ""\' to recommit. Descendants restack on next modify.', + ), + ); + }, + ); program .command('reorder') .description( 'Interactively reorder or drop commits within the current branch', ) + .option( + '--dry-run', + 'Print the reorderable commits without launching the picker', + ) + .option('--json', 'Pair with --dry-run to emit the plan as JSON') .addHelpText( 'after', ` Examples: - $ dub reorder Open the picker for the current branch's commits + $ dub reorder Open the picker for the current branch's commits + $ dub reorder --dry-run Preview the reorderable commits without launching the picker See also: dub modify, dub split, dub move`, ) - .action(async () => { - const result = await reorder(process.cwd()); + .action(async (options: { dryRun?: boolean; json?: boolean }) => { + const result = await reorder(process.cwd(), { dryRun: options.dryRun }); + + if (result.dryRun) { + if (options.json) { + emitDryRunPlan(result); + return; + } + console.log( + chalk.green( + `✔ Dry-run: ${result.reorderableCommits?.length ?? 0} commit(s) eligible to reorder.`, + ), + ); + return; + } if (result.status === 'no-op') { console.log( @@ -3546,6 +3912,8 @@ program .argument('[branch]', 'Branch to freeze (defaults to current branch)') .option('--downstack', 'Also freeze ancestors toward trunk') .option('--upstack', 'Also freeze descendants') + .option('--dry-run', 'Print the planned freeze without mutating state') + .option('--json', 'Pair with --dry-run to emit the plan as JSON') .description( "Set the 'frozen' flag on a tracked branch so restack/sync/post-merge skip it", ) @@ -3557,6 +3925,7 @@ Examples: $ dub freeze feat/auth-login Freeze a specific tracked branch $ dub freeze feat/auth-login --downstack Freeze the branch and its ancestors $ dub freeze --upstack Freeze the current branch and its descendants + $ dub freeze --dry-run Preview the plan without mutating See also: dub unfreeze, dub restack`, @@ -3564,9 +3933,30 @@ See also: .action( async ( branch: string | undefined, - options: { downstack?: boolean; upstack?: boolean }, + options: { + downstack?: boolean; + upstack?: boolean; + dryRun?: boolean; + json?: boolean; + }, ) => { - const result = await freeze(process.cwd(), branch, options); + const result = await freeze(process.cwd(), branch, { + downstack: options.downstack, + upstack: options.upstack, + dryRun: options.dryRun, + }); + if (result.dryRun) { + if (options.json) { + emitDryRunPlan(result); + return; + } + console.log( + chalk.green( + `✔ Dry-run: would freeze ${result.changed.length} branch(es): ${result.changed.join(', ') || '(none)'}`, + ), + ); + return; + } printFreezeResult(result, 'frozen'); }, ); @@ -3576,6 +3966,8 @@ program .argument('[branch]', 'Branch to unfreeze (defaults to current branch)') .option('--downstack', 'Also unfreeze ancestors toward trunk') .option('--upstack', 'Also unfreeze descendants') + .option('--dry-run', 'Print the planned unfreeze without mutating state') + .option('--json', 'Pair with --dry-run to emit the plan as JSON') .description( "Clear the 'frozen' flag so restack/sync/post-merge can mutate the branch again", ) @@ -3585,6 +3977,7 @@ program Examples: $ dub unfreeze Unfreeze the current branch $ dub unfreeze feat/auth-login --upstack Unfreeze a branch and its descendants + $ dub unfreeze --dry-run Preview the plan without mutating See also: dub freeze, dub restack`, @@ -3592,9 +3985,30 @@ See also: .action( async ( branch: string | undefined, - options: { downstack?: boolean; upstack?: boolean }, + options: { + downstack?: boolean; + upstack?: boolean; + dryRun?: boolean; + json?: boolean; + }, ) => { - const result = await unfreeze(process.cwd(), branch, options); + const result = await unfreeze(process.cwd(), branch, { + downstack: options.downstack, + upstack: options.upstack, + dryRun: options.dryRun, + }); + if (result.dryRun) { + if (options.json) { + emitDryRunPlan(result); + return; + } + console.log( + chalk.green( + `✔ Dry-run: would unfreeze ${result.changed.length} branch(es): ${result.changed.join(', ') || '(none)'}`, + ), + ); + return; + } printFreezeResult(result, 'unfrozen'); }, ); @@ -3607,6 +4021,11 @@ program 'Rename a tracked branch and propagate the change through state, children, and remote', ) .option('--no-push', 'Skip pushing the renamed branch even if a PR exists') + .option( + '--dry-run', + 'Print the planned rename without mutating refs, state, or remote', + ) + .option('--json', 'Pair with --dry-run to emit the plan as JSON') .addHelpText( 'after', ` @@ -3614,6 +4033,7 @@ Examples: $ dub rename feat/new-name Rename the current tracked branch $ dub rename feat/old feat/new Rename a specific tracked branch $ dub rename --no-push feat/new-name Rename without pushing the renamed branch + $ dub rename feat/new --dry-run Preview the plan without mutating See also: dub track, dub submit, dub pr`, @@ -3622,11 +4042,24 @@ See also: async ( firstName: string, secondName: string | undefined, - options: { push?: boolean }, + options: { push?: boolean; dryRun?: boolean; json?: boolean }, ) => { const result = await rename(process.cwd(), firstName, secondName, { noPush: options.push === false, + dryRun: options.dryRun, }); + if (result.dryRun) { + if (options.json) { + emitDryRunPlan(result); + return; + } + console.log( + chalk.green( + `✔ Dry-run: would rename '${result.oldName}' to '${result.newName}'`, + ), + ); + return; + } console.log( chalk.green(`✔ Renamed '${result.oldName}' to '${result.newName}'`), ); @@ -3663,6 +4096,11 @@ program '--edit-message', "Open the editor for the revert commit message instead of '--no-edit'", ) + .option( + '--dry-run', + 'Print the planned revert without creating branches or committing', + ) + .option('--json', 'Pair with --dry-run to emit the plan as JSON') .description( 'Create a branch on trunk that reverts a merged PR or commit and track it', ) @@ -3674,6 +4112,7 @@ Examples: $ dub revert abc1234 Revert commit abc1234 onto trunk $ dub revert 123 --submit Revert + push + open a PR $ dub revert 123 -b revert/api-rollback Use a custom branch name + $ dub revert 123 --dry-run Preview the plan without mutating See also: dub submit, dub merge-next, dub log`, @@ -3681,13 +4120,36 @@ See also: .action( async ( target: string, - options: { branch?: string; submit?: boolean; editMessage?: boolean }, + options: { + branch?: string; + submit?: boolean; + editMessage?: boolean; + dryRun?: boolean; + json?: boolean; + }, ) => { const result = await revert(process.cwd(), target, { branchName: options.branch, submit: options.submit, editMessage: options.editMessage, + dryRun: options.dryRun, }); + if (result.dryRun) { + if (options.json) { + emitDryRunPlan(result); + return; + } + const origin = + result.prNumber != null + ? `PR #${result.prNumber}` + : `commit ${result.revertedShortSha}`; + console.log( + chalk.green( + `✔ Dry-run: would create revert branch '${result.branch}' on '${result.trunk}' (reverts ${origin})`, + ), + ); + return; + } const origin = result.prNumber != null ? `PR #${result.prNumber}` @@ -3723,12 +4185,18 @@ const stashCommand = program 'Override the default stash message (default: branch + timestamp)', ) .option('--list', "Alias for 'dub stash list' — show recorded stashes") + .option( + '--dry-run', + 'Print the planned stash without invoking git stash push', + ) + .option('--json', 'Pair with --dry-run to emit the plan as JSON') .addHelpText( 'after', ` Examples: $ dub stash Stash on current branch $ dub stash -m "wip: refactor" Stash with custom message + $ dub stash --dry-run Preview the plan $ dub stash pop Pop most recent (same branch only) $ dub stash pop --on feat/other Checkout feat/other, then pop $ dub stash pop --force Pop onto current branch regardless @@ -3737,24 +4205,44 @@ Examples: See also: dub stash pop, dub stash list, git stash`, ) - .action(async (options: { message?: string; list?: boolean }) => { - if (options.list) { - await runStashList(); - return; - } - const result = await stashPush(process.cwd(), { message: options.message }); - console.log( - chalk.green( - `✔ Stashed on '${result.branch}' (${result.sha.slice(0, 7)})`, - ), - ); - console.log(chalk.dim(` ↳ message: ${result.message}`)); - console.log( - chalk.dim( - ` ↳ run 'dub stash pop' on '${result.branch}' to restore, or 'dub stash pop --on ' to move it.`, - ), - ); - }); + .action( + async (options: { + message?: string; + list?: boolean; + dryRun?: boolean; + json?: boolean; + }) => { + if (options.list) { + await runStashList(); + return; + } + const result = await stashPush(process.cwd(), { + message: options.message, + dryRun: options.dryRun, + }); + if (result.dryRun) { + if (options.json) { + emitDryRunPlan(result); + return; + } + console.log( + chalk.green(`✔ Dry-run: would stash on '${result.branch}'`), + ); + return; + } + console.log( + chalk.green( + `✔ Stashed on '${result.branch}' (${result.sha.slice(0, 7)})`, + ), + ); + console.log(chalk.dim(` ↳ message: ${result.message}`)); + console.log( + chalk.dim( + ` ↳ run 'dub stash pop' on '${result.branch}' to restore, or 'dub stash pop --on ' to move it.`, + ), + ); + }, + ); stashCommand.addCommand( new Command('pop') @@ -3764,6 +4252,11 @@ stashCommand.addCommand( '--force', "Pop onto the current branch even if it doesn't match the recorded branch", ) + .option( + '--dry-run', + 'Print the planned pop without checking out or applying', + ) + .option('--json', 'Pair with --dry-run to emit the plan as JSON') .addHelpText( 'after', ` @@ -3771,25 +4264,46 @@ Examples: $ dub stash pop Pop most recent (same branch only) $ dub stash pop --on feat/other Checkout feat/other, then pop $ dub stash pop --force Pop onto current branch regardless + $ dub stash pop --dry-run Preview the plan without mutating See also: dub stash, dub stash list`, ) - .action(async (options: { on?: string; force?: boolean }) => { - const result = await stashPop(process.cwd(), { - on: options.on, - force: options.force, - }); - if (result.checkedOut) { - console.log(chalk.green(`✔ Switched to '${result.branch}'`)); - } - const label = - result.sourceBranch === result.branch - ? `'${result.branch}'` - : `'${result.branch}' (originally on '${result.sourceBranch}')`; - console.log(chalk.green(`✔ Popped stash on ${label}`)); - console.log(chalk.dim(` ↳ message: ${result.message}`)); - }), + .action( + async (options: { + on?: string; + force?: boolean; + dryRun?: boolean; + json?: boolean; + }) => { + const result = await stashPop(process.cwd(), { + on: options.on, + force: options.force, + dryRun: options.dryRun, + }); + if (result.dryRun) { + if (options.json) { + emitDryRunPlan(result); + return; + } + console.log( + chalk.green( + `✔ Dry-run: would pop stash ${result.sha.slice(0, 7)} on '${result.branch}'`, + ), + ); + return; + } + if (result.checkedOut) { + console.log(chalk.green(`✔ Switched to '${result.branch}'`)); + } + const label = + result.sourceBranch === result.branch + ? `'${result.branch}'` + : `'${result.branch}' (originally on '${result.sourceBranch}')`; + console.log(chalk.green(`✔ Popped stash on ${label}`)); + console.log(chalk.dim(` ↳ message: ${result.message}`)); + }, + ), ); stashCommand.addCommand( @@ -3950,6 +4464,7 @@ async function runSplit(options: { closeOldPr?: boolean; restack?: boolean; dryRun?: boolean; + json?: boolean; yes?: boolean; interactive?: boolean; }) { @@ -3991,13 +4506,29 @@ async function runSplit(options: { interactive: options.interactive, }); - if (mode === 'ai' && options.dryRun) { - console.log(chalk.green('✔ Dry-run: AI proposed the following split:')); - for (const [i, p] of (result.aiProposal ?? []).entries()) { - console.log(chalk.dim(` ${i + 1}. ${p.branch}`)); - if (p.summary) console.log(chalk.dim(` ${p.summary}`)); - for (const f of p.files) console.log(chalk.dim(` • ${f}`)); + if (result.dryRun) { + if (options.json) { + emitDryRunPlan(result); + return; } + if (mode === 'ai') { + console.log( + chalk.green( + `✔ Dry-run: would propose an AI split of '${result.sourceBranch}' against '${result.parentBranch}'.`, + ), + ); + console.log( + chalk.dim( + " ↳ AI call skipped to avoid billing. Re-run without --dry-run to see the model's proposed slices.", + ), + ); + return; + } + console.log( + chalk.green( + `✔ Dry-run: would split '${result.sourceBranch}' into ${result.plannedBranches?.length ?? 0} new branch(es).`, + ), + ); return; } @@ -4336,6 +4867,16 @@ program.hook('preAction', async (_thisCommand, actionCommand) => { }); } + // For `--dry-run --json` we must flip the top-level error handler to JSON + // envelopes BEFORE the action runs — otherwise a DubError thrown during + // validation (before `emitDryRunPlan` would fire) leaks human-formatted + // red text and breaks scripted JSON parsers. + const actionOpts = actionCommand.opts(); + maybeActivateDryRunJsonMode({ + dryRun: Boolean(actionOpts.dryRun), + json: Boolean(actionOpts.json), + }); + const isRestoreFromRefs = actionCommand.name() === 'init' && Boolean(actionCommand.opts().restoreFromRefs); diff --git a/packages/cli/src/lib/delete.ts b/packages/cli/src/lib/delete.ts index c6a833ac..fbea1f4a 100644 --- a/packages/cli/src/lib/delete.ts +++ b/packages/cli/src/lib/delete.ts @@ -9,11 +9,13 @@ export interface DeleteTrackedOptions { upstack?: boolean; downstack?: boolean; force?: boolean; + dryRun?: boolean; } export interface DeleteTrackedResult { deleted: string[]; reparented: Array<{ branch: string; parent: string | null }>; + dryRun: boolean; } export interface DeletePreview { @@ -58,15 +60,18 @@ export async function deleteTrackedBranch( const targets = collectTargets(stack, options); const deleteSet = new Set(targets); + const dryRun = options.dryRun ?? false; const currentBranch = await getCurrentBranch(cwd); if (deleteSet.has(currentBranch)) { const fallback = resolveFallbackBranch(stack, options.branch, deleteSet); - await checkoutBranch(fallback, cwd); + if (!dryRun) await checkoutBranch(fallback, cwd); } - for (const branch of targets) { - await deleteLocalBranch(branch, cwd, options.force ?? false); + if (!dryRun) { + for (const branch of targets) { + await deleteLocalBranch(branch, cwd, options.force ?? false); + } } const deletedParent = new Map(); @@ -96,11 +101,12 @@ export async function deleteTrackedBranch( (candidate) => candidate.branches.length > 0, ); assertStateInvariants(state.stacks); - await writeState(state, cwd); + if (!dryRun) await writeState(state, cwd); return { deleted: targets, reparented, + dryRun, }; } diff --git a/packages/cli/src/lib/freeze.ts b/packages/cli/src/lib/freeze.ts index 984c1ae8..392ae359 100644 --- a/packages/cli/src/lib/freeze.ts +++ b/packages/cli/src/lib/freeze.ts @@ -19,6 +19,7 @@ interface FreezeOptions { branch?: string; upstack?: boolean; downstack?: boolean; + dryRun?: boolean; } export interface FreezeResult { @@ -28,6 +29,8 @@ export interface FreezeResult { unchanged: string[]; /** Tracked branches skipped because they are checked out in another worktree. */ skipped: Array<{ branch: string; worktree: string }>; + /** True when invoked with `--dry-run`; no mutations were performed. */ + dryRun: boolean; } interface ApplyFreezeOptions { @@ -112,8 +115,19 @@ export async function applyFreezeFlag( } } + const dryRun = options.dryRun ?? false; + if (changedBranches.length === 0) { - return { changed: [], unchanged, skipped }; + return { changed: [], unchanged, skipped, dryRun }; + } + + if (dryRun) { + return { + changed: changedBranches.map((b) => b.name), + unchanged, + skipped, + dryRun, + }; } await saveUndoEntry( @@ -143,6 +157,7 @@ export async function applyFreezeFlag( changed: changedBranches.map((b) => b.name), unchanged, skipped, + dryRun, }; } diff --git a/packages/cli/src/lib/state.ts b/packages/cli/src/lib/state.ts index f17995c9..ea58fd1a 100644 --- a/packages/cli/src/lib/state.ts +++ b/packages/cli/src/lib/state.ts @@ -354,6 +354,30 @@ export async function ensureState(cwd: string): Promise { } } +/** + * Read-only state loader for `--dry-run` commands. + * + * Returns an empty in-memory state when DubStack has never been initialized + * in this repo (so `dub track --dry-run` etc. can still produce a plan), but + * propagates every other error — corrupted state, missing git repo, IO + * failures — so dry-run never lies about what a real run would do. Without + * this narrowing, a corrupted `state.json` would silently degrade to an + * empty-plan preview and the real run would explode. + */ +export async function readStateForDryRun(cwd: string): Promise { + try { + return await readState(cwd); + } catch (error) { + if ( + error instanceof DubError && + error.message.includes('not initialized') + ) { + return { trunks: [], stacks: [] }; + } + throw error; + } +} + /** * Finds the stack containing a given branch. * @returns The matching stack, or `undefined` if the branch isn't tracked. diff --git a/packages/cli/src/lib/sync/types.ts b/packages/cli/src/lib/sync/types.ts index 0c6b930f..ca4780a8 100644 --- a/packages/cli/src/lib/sync/types.ts +++ b/packages/cli/src/lib/sync/types.ts @@ -58,6 +58,7 @@ export interface SyncOptions { all: boolean; interactive: boolean; fresh: boolean; + dryRun: boolean; } export interface BranchSyncOutcome { @@ -83,4 +84,8 @@ export interface SyncResult { branches: BranchSyncOutcome[]; restacked: boolean; reconcileSources: ReconcileSourceHistogram; + /** True when invoked with `--dry-run`; no mutations were performed. */ + dryRun: boolean; + /** Branches in the planned sync scope (set on `--dry-run`). */ + plannedScope?: { roots: string[]; branches: string[] }; } diff --git a/packages/cli/src/lib/track.ts b/packages/cli/src/lib/track.ts index 29b50199..80f1eae5 100644 --- a/packages/cli/src/lib/track.ts +++ b/packages/cli/src/lib/track.ts @@ -5,22 +5,26 @@ import { getDescendants } from './graph'; import { assertStateInvariants } from './invariants'; import { addBranchToStack, + type DubState, ensureConfiguredTrunk, ensureState, findStackForBranch, getStackTrunk, + readStateForDryRun, writeState, } from './state'; export interface TrackBranchOptions { branch: string; parent: string; + dryRun?: boolean; } export interface TrackBranchResult { branch: string; parent: string; status: 'tracked' | 'reparented' | 'unchanged'; + dryRun: boolean; } export async function validateTrackParent( @@ -49,6 +53,7 @@ export async function trackBranch( options: TrackBranchOptions, ): Promise { const { branch, parent } = options; + const dryRun = options.dryRun ?? false; if (!(await branchExists(branch, cwd))) { throw new DubError(`Branch '${branch}' does not exist locally.`, [ `Run 'git checkout -b ${branch}' to create the branch first.`, @@ -57,15 +62,20 @@ export async function trackBranch( } await validateTrackParent(cwd, branch, parent); - const state = await ensureState(cwd); + // Dry-run must never create state on disk but must surface corruption / + // IO errors that a real run would hit. `readStateForDryRun` narrows the + // fallback to the "not initialized" case only. + const state: DubState = dryRun + ? await readStateForDryRun(cwd) + : await ensureState(cwd); const sourceStack = findStackForBranch(state, branch); const destinationStack = findStackForBranch(state, parent); if (!sourceStack) { addBranchToStack(state, branch, parent); assertStateInvariants(state.stacks); - await writeState(state, cwd); - return { branch, parent, status: 'tracked' }; + if (!dryRun) await writeState(state, cwd); + return { branch, parent, status: 'tracked', dryRun }; } const branchEntry = sourceStack.branches.find( @@ -87,7 +97,7 @@ export async function trackBranch( ); } if (branchEntry.parent === parent) { - return { branch, parent, status: 'unchanged' }; + return { branch, parent, status: 'unchanged', dryRun }; } const descendants = new Set(getDescendants(sourceStack, branch)); @@ -104,8 +114,8 @@ export async function trackBranch( if (sourceStack.id === destinationStack?.id) { branchEntry.parent = parent; assertStateInvariants(state.stacks); - await writeState(state, cwd); - return { branch, parent, status: 'reparented' }; + if (!dryRun) await writeState(state, cwd); + return { branch, parent, status: 'reparented', dryRun }; } const movingNames = new Set([branch, ...descendants]); @@ -152,6 +162,6 @@ export async function trackBranch( state.stacks = state.stacks.filter((stack) => stack.branches.length > 0); assertStateInvariants(state.stacks); - await writeState(state, cwd); - return { branch, parent, status: 'reparented' }; + if (!dryRun) await writeState(state, cwd); + return { branch, parent, status: 'reparented', dryRun }; } diff --git a/packages/cli/src/lib/untrack.ts b/packages/cli/src/lib/untrack.ts index b4449801..1af2acc1 100644 --- a/packages/cli/src/lib/untrack.ts +++ b/packages/cli/src/lib/untrack.ts @@ -6,11 +6,13 @@ import { findStackForBranch, readState, type Stack, writeState } from './state'; export interface UntrackOptions { branch: string; downstack?: boolean; + dryRun?: boolean; } export interface UntrackResult { removed: string[]; reparented: Array<{ branch: string; parent: string | null }>; + dryRun: boolean; } export interface UntrackContext { @@ -97,10 +99,12 @@ export async function untrackBranch( ); assertStateInvariants(state.stacks); - await writeState(state, cwd); + const dryRun = options.dryRun ?? false; + if (!dryRun) await writeState(state, cwd); return { removed: [options.branch, ...(options.downstack ? descendants : [])], reparented, + dryRun, }; } diff --git a/packages/cli/test/commands/dry-run-coverage.test.ts b/packages/cli/test/commands/dry-run-coverage.test.ts new file mode 100644 index 00000000..62fd905e --- /dev/null +++ b/packages/cli/test/commands/dry-run-coverage.test.ts @@ -0,0 +1,545 @@ +/** + * End-to-end smoke coverage for the `--dry-run` contract added in DUB-70. + * + * For every mutating command the issue lists, we verify: + * 1. The result carries `dryRun: true`. + * 2. No mutation reaches `.git/dubstack/state.json` (mtime unchanged). + * 3. No new undo entry is appended (head of the ring stays put). + * + * The point of a single shared suite is that a regression in any one + * command's mutation guard immediately fails this file — no need to remember + * to update each command's individual test file. + */ + +import * as fs from 'node:fs'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { absorb } from '../../src/commands/absorb'; +import { create } from '../../src/commands/create'; +import { deleteCommand } from '../../src/commands/delete'; +import { fold } from '../../src/commands/fold'; +import { freeze } from '../../src/commands/freeze'; +import { init } from '../../src/commands/init'; +import { modify } from '../../src/commands/modify'; +import { move } from '../../src/commands/move'; +import { pop } from '../../src/commands/pop'; +import { rename } from '../../src/commands/rename'; +import { reorder } from '../../src/commands/reorder'; +import { restack } from '../../src/commands/restack'; +import { revert } from '../../src/commands/revert'; +import { split } from '../../src/commands/split'; +import { squash } from '../../src/commands/squash'; +import { stashPop, stashPush } from '../../src/commands/stash'; +import { sync } from '../../src/commands/sync'; +import { track } from '../../src/commands/track'; +import { unfreeze } from '../../src/commands/unfreeze'; +import { unlink } from '../../src/commands/unlink'; +import { untrack } from '../../src/commands/untrack'; +import { writeConfig } from '../../src/lib/config'; +import { readUndoLog } from '../../src/lib/undo-log'; +import { createTestRepo, gitInRepo } from '../helpers'; + +let dir: string; +let cleanup: () => Promise; + +interface Snapshot { + stateMtimeMs: number; + stateBytes: string; + undoLogCount: number; + undoLogTail: string; +} + +async function readSnapshot(): Promise { + const statePath = `${dir}/.git/dubstack/state.json`; + const stateStat = fs.statSync(statePath); + const stateBytes = fs.readFileSync(statePath, 'utf-8'); + const log = await readUndoLog(dir); + return { + stateMtimeMs: stateStat.mtimeMs, + stateBytes, + undoLogCount: log.length, + undoLogTail: + log.length === 0 + ? '' + : `${log[log.length - 1].operation}:${log[log.length - 1].timestamp}`, + }; +} + +function expectNoMutation(before: Snapshot, after: Snapshot): void { + expect(after.stateBytes).toBe(before.stateBytes); + expect(after.undoLogCount).toBe(before.undoLogCount); + expect(after.undoLogTail).toBe(before.undoLogTail); +} + +async function setupStack(): Promise { + await init(dir); + // `init` writes/updates `.gitignore`; commit it so the working tree is + // clean before commands that gate on `isWorkingTreeClean`. + await gitInRepo(dir, ['add', '.gitignore']); + await gitInRepo(dir, ['commit', '-m', 'init: gitignore']); + await create('feat/a', dir); + await gitInRepo(dir, ['commit', '--allow-empty', '-m', 'feat-a-1']); + await create('feat/b', dir); + await gitInRepo(dir, ['commit', '--allow-empty', '-m', 'feat-b-1']); + await gitInRepo(dir, ['commit', '--allow-empty', '-m', 'feat-b-2']); +} + +beforeEach(async () => { + const repo = await createTestRepo(); + dir = repo.dir; + cleanup = repo.cleanup; +}); + +afterEach(async () => { + await cleanup(); +}); + +describe('--dry-run contract', () => { + it('create returns a plan and does not write state or undo', async () => { + await setupStack(); + const before = await readSnapshot(); + + const result = await create('feat/c', dir, { dryRun: true }); + + expect(result.dryRun).toBe(true); + expect(result.branch).toBe('feat/c'); + expect(result.parent).toBe('feat/b'); + expectNoMutation(before, await readSnapshot()); + // No actual ref created. + await expect( + gitInRepo(dir, ['rev-parse', '--verify', 'feat/c']), + ).rejects.toThrow(); + }); + + it('create --dry-run -m surfaces "no staged changes" same as a real run', async () => { + // Per Copilot review: dry-run should still run read-only validation so + // the plan matches what an actual run would do. With no staged changes + // and a non-aggregate flag, the real run errors — dry-run must too. + await setupStack(); + + await expect( + create('feat/c', dir, { message: 'feat: x', dryRun: true }), + ).rejects.toThrow(/No staged changes/); + }); + + it('create --dry-run -a -m surfaces "no changes to commit" on a clean tree', async () => { + await setupStack(); + + await expect( + create('feat/c', dir, { + message: 'feat: x', + all: true, + dryRun: true, + }), + ).rejects.toThrow(/No changes to commit/); + }); + + it('modify returns a plan with no commit or restack', async () => { + await setupStack(); + const before = await readSnapshot(); + const tipBefore = ( + await gitInRepo(dir, ['rev-parse', 'HEAD']) + ).stdout.trim(); + + const result = await modify(dir, { dryRun: true, message: 'msg' }); + + expect(result).toBeDefined(); + expect(result?.dryRun).toBe(true); + expect(result?.branch).toBe('feat/b'); + expect(result?.action).toBe('amend'); + expectNoMutation(before, await readSnapshot()); + expect((await gitInRepo(dir, ['rev-parse', 'HEAD'])).stdout.trim()).toBe( + tipBefore, + ); + }); + + it('restack rejects --continue + --dry-run as an invalid combo at the library level', async () => { + // Per Copilot review: `--continue` resumes an in-flight rebase, which + // can't be previewed. The CLI rejects the combo; here we assert the + // surface stays clear by checking that a plain `restack --dry-run` + // (no in-flight rebase) succeeds. + await setupStack(); + const result = await restack(dir, { dryRun: true }); + expect(result.dryRun).toBe(true); + // Status with no in-flight work and no rebase needed: + expect(['up-to-date', 'success']).toContain(result.status); + }); + + it('restack reports planned rebases without touching refs', async () => { + await setupStack(); + // Force feat/a's commit beyond what feat/b expects, so restack would do work. + await gitInRepo(dir, ['checkout', 'feat/a']); + await gitInRepo(dir, ['commit', '--allow-empty', '-m', 'feat-a-2']); + const before = await readSnapshot(); + const tipBefore = ( + await gitInRepo(dir, ['rev-parse', 'feat/b']) + ).stdout.trim(); + + const result = await restack(dir, { dryRun: true }); + + expect(result.dryRun).toBe(true); + expectNoMutation(before, await readSnapshot()); + expect((await gitInRepo(dir, ['rev-parse', 'feat/b'])).stdout.trim()).toBe( + tipBefore, + ); + }); + + it('sync reports planned scope without fetching or mutating', async () => { + await setupStack(); + const before = await readSnapshot(); + + const result = await sync(dir, { dryRun: true, all: true }); + + expect(result.dryRun).toBe(true); + expect(result.plannedScope).toBeDefined(); + expect(result.plannedScope?.branches.sort()).toEqual(['feat/a', 'feat/b']); + expectNoMutation(before, await readSnapshot()); + }); + + it('delete reports targets without deleting branches', async () => { + await setupStack(); + const before = await readSnapshot(); + + const result = await deleteCommand(dir, 'feat/b', { + force: true, + quiet: true, + dryRun: true, + }); + + expect(result.dryRun).toBe(true); + expect(result.deleted).toContain('feat/b'); + expectNoMutation(before, await readSnapshot()); + await gitInRepo(dir, ['rev-parse', '--verify', 'feat/b']); + }); + + it('untrack reports removed branches without writing state', async () => { + await setupStack(); + const before = await readSnapshot(); + + const result = await untrack(dir, 'feat/b', { + interactive: false, + dryRun: true, + }); + + expect(result.dryRun).toBe(true); + expect(result.removed).toContain('feat/b'); + expectNoMutation(before, await readSnapshot()); + }); + + it('track --dry-run works in a repo with no DubStack state on disk', async () => { + // Per Copilot review: dry-run must not require state.json to exist — + // ensureState would write a fresh state file, but dry-run cannot mutate + // disk. Verify a `dub track --dry-run` on a fresh repo succeeds. + await gitInRepo(dir, ['checkout', '-b', 'feat/loose']); + await gitInRepo(dir, ['commit', '--allow-empty', '-m', 'loose-1']); + const stateBefore = fs.existsSync(`${dir}/.git/dubstack/state.json`); + + const result = await track(dir, 'feat/loose', { + parent: 'main', + interactive: false, + dryRun: true, + }); + + expect(result.dryRun).toBe(true); + expect(result.branch).toBe('feat/loose'); + expect(result.parent).toBe('main'); + // No state file created. + expect(fs.existsSync(`${dir}/.git/dubstack/state.json`)).toBe(stateBefore); + }); + + it('track reports the planned parent without writing state', async () => { + await setupStack(); + await gitInRepo(dir, ['checkout', '-b', 'feat/loose', 'feat/b']); + const before = await readSnapshot(); + + const result = await track(dir, 'feat/loose', { + parent: 'feat/b', + interactive: false, + dryRun: true, + }); + + expect(result.dryRun).toBe(true); + expect(result.branch).toBe('feat/loose'); + expect(result.parent).toBe('feat/b'); + expectNoMutation(before, await readSnapshot()); + }); + + it('pop reports planned commits without resetting HEAD', async () => { + await setupStack(); + const before = await readSnapshot(); + const tipBefore = ( + await gitInRepo(dir, ['rev-parse', 'HEAD']) + ).stdout.trim(); + + const result = await pop(dir, { steps: 1, dryRun: true }); + + expect(result.dryRun).toBe(true); + expect(result.steps).toBe(1); + expect(result.previousTip).toBe(tipBefore); + expectNoMutation(before, await readSnapshot()); + expect((await gitInRepo(dir, ['rev-parse', 'HEAD'])).stdout.trim()).toBe( + tipBefore, + ); + }); + + it('squash reports planned squash without resetting or committing', async () => { + await setupStack(); + const before = await readSnapshot(); + const tipBefore = ( + await gitInRepo(dir, ['rev-parse', 'HEAD']) + ).stdout.trim(); + + const result = await squash(dir, { dryRun: true, message: 'feat: combo' }); + + expect(result.dryRun).toBe(true); + expect(result.branch).toBe('feat/b'); + expect(result.squashedCommits).toBeGreaterThanOrEqual(2); + expect(result.message).toBe('feat: combo'); + expectNoMutation(before, await readSnapshot()); + expect((await gitInRepo(dir, ['rev-parse', 'HEAD'])).stdout.trim()).toBe( + tipBefore, + ); + }); + + it('fold reports planned merge into parent without mutating refs', async () => { + // fold requires a non-trunk parent. Build feat/a -> feat/b -> feat/c so + // fold on feat/c merges into feat/b (not main). + await setupStack(); + await create('feat/c', dir); + await gitInRepo(dir, ['commit', '--allow-empty', '-m', 'feat-c-1']); + const before = await readSnapshot(); + const cTipBefore = ( + await gitInRepo(dir, ['rev-parse', 'feat/c']) + ).stdout.trim(); + const bTipBefore = ( + await gitInRepo(dir, ['rev-parse', 'feat/b']) + ).stdout.trim(); + + const result = await fold(dir, { dryRun: true }); + + expect(result.dryRun).toBe(true); + expect(result.branch).toBe('feat/c'); + expect(result.parent).toBe('feat/b'); + expectNoMutation(before, await readSnapshot()); + expect((await gitInRepo(dir, ['rev-parse', 'feat/c'])).stdout.trim()).toBe( + cTipBefore, + ); + expect((await gitInRepo(dir, ['rev-parse', 'feat/b'])).stdout.trim()).toBe( + bTipBefore, + ); + }); + + it('rename reports new name without renaming the branch', async () => { + await setupStack(); + const before = await readSnapshot(); + + const result = await rename(dir, 'feat/b-renamed', undefined, { + dryRun: true, + }); + + expect(result.dryRun).toBe(true); + expect(result.oldName).toBe('feat/b'); + expect(result.newName).toBe('feat/b-renamed'); + expect(result.pushed).toBe(false); + expectNoMutation(before, await readSnapshot()); + await gitInRepo(dir, ['rev-parse', '--verify', 'feat/b']); + await expect( + gitInRepo(dir, ['rev-parse', '--verify', 'feat/b-renamed']), + ).rejects.toThrow(); + }); + + it('move reports planned reparent without mutating state or refs', async () => { + await setupStack(); + await gitInRepo(dir, ['checkout', 'feat/a']); + await create('feat/parallel', dir); + await gitInRepo(dir, ['commit', '--allow-empty', '-m', 'parallel-1']); + const before = await readSnapshot(); + + const result = await move(dir, 'feat/parallel', { + after: 'feat/b', + dryRun: true, + }); + + expect(result.dryRun).toBe(true); + expectNoMutation(before, await readSnapshot()); + }); + + it('freeze and unfreeze report planned flips without writing state', async () => { + await setupStack(); + const before = await readSnapshot(); + + const frozen = await freeze(dir, 'feat/b', { dryRun: true }); + expect(frozen.dryRun).toBe(true); + expect(frozen.changed).toContain('feat/b'); + expectNoMutation(before, await readSnapshot()); + + // The dry-run freeze never wrote, so unfreeze still reports unchanged. + const unfrozen = await unfreeze(dir, 'feat/b', { dryRun: true }); + expect(unfrozen.dryRun).toBe(true); + expect(unfrozen.unchanged).toContain('feat/b'); + expectNoMutation(before, await readSnapshot()); + }); + + it('reorder reports reorderable commits without launching the picker', async () => { + await setupStack(); + const before = await readSnapshot(); + const tipBefore = ( + await gitInRepo(dir, ['rev-parse', 'feat/b']) + ).stdout.trim(); + + const result = await reorder(dir, { dryRun: true }); + + expect(result.dryRun).toBe(true); + expect(result.status).toBe('dry-run'); + expect(result.reorderableCommits?.length).toBeGreaterThan(0); + expectNoMutation(before, await readSnapshot()); + expect((await gitInRepo(dir, ['rev-parse', 'feat/b'])).stdout.trim()).toBe( + tipBefore, + ); + }); + + it('unlink reports planned promotion without writing state or PR retarget', async () => { + await setupStack(); + const before = await readSnapshot(); + + const result = await unlink(dir, 'feat/b', { dryRun: true }); + + expect(result.dryRun).toBe(true); + expect(result.branch).toBe('feat/b'); + expect(result.previousParent).toBe('feat/a'); + expect(result.retargeted).toBe(false); + // Plan must be deterministic — no per-call UUIDs leaking into the JSON + // envelope. + expect(result.newStackId).toBe(''); + expectNoMutation(before, await readSnapshot()); + }); + + it('revert reports planned revert branch without creating it', async () => { + await setupStack(); + const before = await readSnapshot(); + const sha = (await gitInRepo(dir, ['rev-parse', 'HEAD'])).stdout.trim(); + // revert wants an absolute trunk branch — main is set up via init. + + const result = await revert(dir, sha, { dryRun: true }); + + expect(result.dryRun).toBe(true); + expect(result.trunk).toBe('main'); + expect(result.revertedSha).toBe(sha); + expectNoMutation(before, await readSnapshot()); + await expect( + gitInRepo(dir, ['rev-parse', '--verify', result.branch]), + ).rejects.toThrow(); + }); + + it('absorb reports planned autosquash without rebasing', async () => { + await setupStack(); + // Add a literal `fixup!` commit so absorb has something to plan. + const subject = ( + await gitInRepo(dir, ['log', '-1', '--format=%s']) + ).stdout.trim(); + await gitInRepo(dir, [ + 'commit', + '--allow-empty', + '-m', + `fixup! ${subject}`, + ]); + const before = await readSnapshot(); + const tipBefore = ( + await gitInRepo(dir, ['rev-parse', 'HEAD']) + ).stdout.trim(); + + const result = await absorb(dir, { dryRun: true }); + + expect(result.dryRun).toBe(true); + expect(result.absorbed).toBeGreaterThan(0); + expectNoMutation(before, await readSnapshot()); + expect((await gitInRepo(dir, ['rev-parse', 'HEAD'])).stdout.trim()).toBe( + tipBefore, + ); + }); + + it('split --ai --dry-run bails before invoking the AI provider', async () => { + // Per Copilot review: dry-run must skip the AI call so previews never + // bill the configured provider. Enable AI in the repo config and pass + // a deps stub whose generateText throws — if dry-run reaches it, the + // test fails. A clean bail means the plan is returned with no proposal. + await setupStack(); + await writeConfig({ aiAssistantEnabled: true }, dir); + + const result = await split(dir, { mode: 'ai', dryRun: true }, { + generateText: (() => { + throw new Error('AI provider must not be called in dry-run'); + }) as never, + } as never); + + expect(result.dryRun).toBe(true); + expect(result.aiProposal).toBeUndefined(); + }); + + it('split (by-file) reports planned slices without mutating refs', async () => { + await setupStack(); + // Write two files on feat/b so by-file split has something to extract. + fs.writeFileSync(`${dir}/a.txt`, 'a'); + fs.writeFileSync(`${dir}/b.txt`, 'b'); + await gitInRepo(dir, ['add', '.']); + await gitInRepo(dir, ['commit', '-m', 'two-file commit']); + const before = await readSnapshot(); + const tipBefore = ( + await gitInRepo(dir, ['rev-parse', 'feat/b']) + ).stdout.trim(); + + const result = await split(dir, { + mode: 'by-file', + files: ['a.txt'], + name: 'feat/split-a', + dryRun: true, + }); + + expect(result.dryRun).toBe(true); + expect(result.plannedBranches).toContain('feat/split-a'); + expectNoMutation(before, await readSnapshot()); + expect((await gitInRepo(dir, ['rev-parse', 'feat/b'])).stdout.trim()).toBe( + tipBefore, + ); + await expect( + gitInRepo(dir, ['rev-parse', '--verify', 'feat/split-a']), + ).rejects.toThrow(); + }); + + it('stash push reports planned stash without invoking git stash', async () => { + await setupStack(); + // Create dirty working tree so stash has something to capture. + fs.writeFileSync(`${dir}/dirty.txt`, 'wip'); + await gitInRepo(dir, ['add', 'dirty.txt']); + const before = await readSnapshot(); + + const result = await stashPush(dir, { dryRun: true, message: 'preview' }); + + expect(result.dryRun).toBe(true); + expect(result.message).toBe('preview'); + expect(result.branch).toBe('feat/b'); + expectNoMutation(before, await readSnapshot()); + // The working-tree change is still present — stash never ran. + expect(fs.existsSync(`${dir}/dirty.txt`)).toBe(true); + + // Clean up so afterEach doesn't see dirt. + await gitInRepo(dir, ['reset', '--hard', 'HEAD']); + fs.rmSync(`${dir}/dirty.txt`, { force: true }); + }); + + it('stash pop reports the planned pop without applying it', async () => { + await setupStack(); + fs.writeFileSync(`${dir}/dirty.txt`, 'wip'); + await gitInRepo(dir, ['add', 'dirty.txt']); + await stashPush(dir, { message: 'real-stash' }); + const before = await readSnapshot(); + + const result = await stashPop(dir, { dryRun: true }); + + expect(result.dryRun).toBe(true); + expect(result.branch).toBe('feat/b'); + expectNoMutation(before, await readSnapshot()); + // The stash entry is still present after the dry-run. + const stashList = (await gitInRepo(dir, ['stash', 'list'])).stdout; + expect(stashList).toContain('real-stash'); + }); +});