/** * update CLI execute — Execution logic, no validation. * * Safely brings the CURRENT branch up to date with its BASE branch * (feature/release ← develop, hotfix ← main) by operating on the fetched * origin/ ref directly — the develop worktree is never touched, so no * alignment dance is needed. FAILURE = NO-OP: a conflicted merge/rebase is * aborted and the stash restored, leaving the branch exactly as found. */ import type { UpdateSpec, UpdateResult } from './types.js'; import { readConfig, resolveConfigPath } from '../lib/config.js'; import { detectBranchType, getBaseBranch } from '../lib/branch.js'; import { getCurrentBranch, fetchAll, getAheadBehind, getStatus, stash, stashPop, rebase, abortRebase, abortMerge, mergeRefNoFf, fastForwardOnly, push, remoteRefExists, resolveBaseRef, detectInProgressOp, } from '../lib/git.js'; import { ensureWorkTreeUsable } from '../lib/worktree.js'; import { decideBaseUpdate, buildConflictGuidance } from '../lib/update-policy.js'; export async function execute(spec: UpdateSpec, cwd?: string): Promise { cwd = cwd ?? process.cwd(); const base: UpdateResult = { success: false, branch: '', branchType: 'other', baseBranch: '', baseRef: '', ahead: 0, behind: 0, action: 'none', updated: false, pushed: false, conflicts: false, }; const warnings: string[] = []; const done = (r: Partial): UpdateResult => ({ ...base, ...r, warnings: warnings.length ? warnings : undefined, }); try { // Repair a worktree git wrongly treats as bare before any work-tree op. const usable = await ensureWorkTreeUsable(cwd); if (!usable.ok) return done({ error: usable.error }); // Never start on top of a half-finished operation. const inProgress = await detectInProgressOp(cwd); if (inProgress) { return done({ error: `A ${inProgress} is already in progress — resolve it or run /gitflow abort first.` }); } const branch = await getCurrentBranch(cwd); base.branch = branch; const branchType = detectBranchType(branch); base.branchType = branchType; if (branchType === 'main' || branchType === 'develop') { return done({ error: `'${branch}' has no base branch — update runs on feature/release/hotfix branches; use /gitflow sync to align with origin.`, }); } if (branchType === 'other') { return done({ error: `Unknown branch type for '${branch}' — update runs on feature/release/hotfix branches.` }); } const config = await readConfig((await resolveConfigPath(cwd)) ?? undefined); const baseBranch = getBaseBranch(branchType, config); base.baseBranch = baseBranch; // A failed fetch must surface, not be swallowed → never compare/update // against STALE refs. const fetched = await fetchAll(cwd); if (fetched.exitCode !== 0) { return done({ error: `git fetch failed: ${fetched.stderr || 'unknown error'}` }); } // Prefer origin/ — operating on the remote-tracking ref directly means // the local base branch (and its worktree) is never read nor touched. The // resolved ref must actually exist: otherwise getAheadBehind's // missing-remote fallback would report ahead=N/behind=0, a fake "up to date". const baseRef = await resolveBaseRef(baseBranch, cwd); base.baseRef = baseRef; if (!(await remoteRefExists(baseRef, cwd))) { return done({ error: `Base branch '${baseBranch}' not found locally or on origin.` }); } // Anti-loss gate: updating (then force-pushing after a rebase) while BEHIND // origin/ would silently drop the remote-only commits — the lease is // the tracking ref we just fetched, so --force-with-lease would NOT protect. const remoteExists = await remoteRefExists(`origin/${branch}`, cwd); if (remoteExists) { const vsOrigin = await getAheadBehind(branch, `origin/${branch}`, cwd); if (vsOrigin.behind > 0) { return done({ error: `'${branch}' is ${vsOrigin.behind} commit(s) behind origin/${branch} — run /gitflow sync first.`, }); } } const { ahead, behind } = await getAheadBehind(branch, baseRef, cwd); base.ahead = ahead; base.behind = behind; const action = decideBaseUpdate({ ahead, behind, strategy: spec.strategy }); base.action = action; if (spec.dryRun) { // Planned action only. Conflict prediction (git merge-tree --write-tree, // git ≥ 2.38) deliberately left out of v1 — the real run is already a // guaranteed no-op on conflict. return done({ success: true, dryRun: true }); } if (action === 'none') { return done({ success: true }); } // Stash guard — TRACKED changes only: with untracked-only changes a plain // `git stash` saves nothing, then `stash pop` fails → phantom conflict. const status = await getStatus(cwd); let stashed = false; if (status.staged.length + status.modified.length > 0) { const s = await stash(cwd); stashed = s.exitCode === 0 && !/No local changes/i.test(s.stdout); } if (stashed) base.stashed = true; // Execute the decided action — FAILURE = NO-OP (abort + stash restore). let conflictFiles: string[] = []; let failedOp: 'merge' | 'rebase' | null = null; if (action === 'ff') { const ff = await fastForwardOnly(baseRef, cwd); if (ff.exitCode !== 0) { if (stashed) { const pop = await stashPop(cwd); if (pop.exitCode !== 0) warnings.push('stash pop reported conflicts — your changes are kept in the stash.'); } return done({ error: `fast-forward to ${baseRef} failed: ${ff.stderr || ff.stdout || 'unknown error'}` }); } } else if (action === 'merge') { const m = await mergeRefNoFf(baseRef, `Merge ${baseBranch} into ${branch}`, cwd); if (!m.ok) { conflictFiles = m.conflicts; failedOp = 'merge'; } } else { const r = await rebase(baseRef, cwd); if (!r.success) { conflictFiles = r.conflicts; failedOp = 'rebase'; } } if (failedOp) { const abort = failedOp === 'merge' ? await abortMerge(cwd) : await abortRebase(cwd); const rolledBack = abort.exitCode === 0; let stashPopConflict: boolean | undefined; if (!rolledBack) { // Never claim a clean rollback that didn't happen. warnings.push(`${failedOp} abort failed — repository left mid-${failedOp}; run /gitflow abort.`); if (stashed) warnings.push('Your local changes remain stashed — run `git stash pop` after resolving.'); } else if (stashed) { const pop = await stashPop(cwd); if (pop.exitCode !== 0) { stashPopConflict = true; warnings.push('stash pop reported conflicts — your changes are kept in the stash.'); } } return done({ conflicts: true, conflictFiles, rolledBack, stashPopConflict, guidance: buildConflictGuidance({ operation: failedOp, baseRef, branch, conflictFiles }), }); } // Update landed. A failed stash pop does NOT undo it — surface it // explicitly instead of faking a failure. let stashPopConflict: boolean | undefined; if (stashed) { const pop = await stashPop(cwd); if (pop.exitCode !== 0) { stashPopConflict = true; warnings.push('stash pop reported conflicts — resolve them in the working tree (the branch update itself succeeded).'); } } // Push handling: a rebase rewrites history, so origin/ now diverges // and only --force-with-lease can update it (plain push for a never-pushed // branch). merge/ff stay plain pushes — step 6 guaranteed we're not behind. let pushed = false; let forcePushed: boolean | undefined; if (spec.push) { const needsForce = action === 'rebase' && remoteExists; const p = await push(branch, cwd, false, needsForce); pushed = p.exitCode === 0; if (pushed && needsForce) forcePushed = true; if (!pushed) warnings.push(`push failed: ${p.stderr || 'unknown error'} — the local update itself succeeded.`); } else if (action === 'rebase' && remoteExists) { warnings.push( `rebase rewrote history — origin/${branch} now diverges; push with --force-with-lease (or re-run /gitflow update with push:true).`, ); } return done({ success: true, updated: true, pushed, forcePushed, stashPopConflict }); } catch (err: unknown) { return done({ error: (err as Error).message }); } }