/**
* 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 });
}
}