/** * sync CLI execute — Execution logic, no validation */ import type { SyncSpec, SyncResult } from './types.js'; import { readConfig, resolveConfigPath } from '../lib/config.js'; import { getBaseBranch, detectBranchType } from '../lib/branch.js'; import { getCurrentBranch, fetchAll, getAheadBehind, push, getStatus, stash, pull, stashPop, rebase, abortRebase, remoteRefExists, resolveBaseRef } from '../lib/git.js'; import { ensureWorkTreeUsable } from '../lib/worktree.js'; import { buildConflictGuidance } from '../lib/update-policy.js'; export async function execute(spec: SyncSpec, cwd?: string): Promise { try { cwd = cwd ?? process.cwd(); // Resolve config from the worktree cwd (= --workdir), NOT process.cwd(). const config = await readConfig((await resolveConfigPath(cwd)) ?? undefined); const branch = await getCurrentBranch(cwd); const branchType = detectBranchType(branch); const baseBranch = getBaseBranch(branchType, config); let pushed = false; let pulled = false; let rebased = false; let conflicts = false; let conflictFiles: string[] = []; const warnings: string[] = []; // Repair a worktree git wrongly treats as bare before any work-tree op. const usable = await ensureWorkTreeUsable(cwd); if (!usable.ok) { return { success: false, error: usable.error, branch, baseBranch, pushed: false, pulled: false, rebased: false, conflicts: false }; } // A failed fetch must surface, not be swallowed → never compare/sync against // STALE refs (the old fetchAll returned void → silent stale comparisons). const fetched = await fetchAll(cwd); if (fetched.exitCode !== 0) { return { success: false, error: `git fetch failed: ${fetched.stderr || 'unknown error'}`, branch, baseBranch, pushed: false, pulled: false, rebased: false, conflicts: false }; } const aheadBehind = await getAheadBehind(branch, `origin/${branch}`, cwd); if (aheadBehind.ahead > 0) { // Exit-code aware: the old code set `pushed = true` unconditionally, so a // FAILED push (auth, protected branch, non-fast-forward) was silently // reported as a successful sync. Reflect the real outcome + surface it. const pushResult = await push(branch, cwd); pushed = pushResult.exitCode === 0; if (!pushed) { warnings.push(`git push failed: ${pushResult.stderr || 'unknown error'} — local commits remain unpushed on ${branch}.`); } } if (aheadBehind.behind > 0) { const status = await getStatus(cwd); if (status.dirty) { await stash(cwd); } const pullResult = await pull('origin', branch, cwd); pulled = pullResult.exitCode === 0; if (status.dirty) { const popResult = await stashPop(cwd); if (popResult.exitCode !== 0) { // Stash pop failed - might have conflicts conflicts = true; } } } if (spec.rebase) { // Rebase onto the RESOLVED base ref (origin/ when it exists) — the // LOCAL base can be stale (the "two develops" trap). For refreshing from // the base branch, /gitflow update is the preferred, safer path. const baseRef = await resolveBaseRef(baseBranch, cwd); if (await remoteRefExists(baseRef, cwd)) { const baseAheadBehind = await getAheadBehind(branch, baseRef, cwd); if (baseAheadBehind.behind > 0) { // Stash guard — TRACKED changes only (untracked-only: `git stash` // saves nothing, then pop fails → phantom conflict). const st = await getStatus(cwd); let stashedHere = false; if (st.staged.length + st.modified.length > 0) { const s = await stash(cwd); stashedHere = s.exitCode === 0 && !/No local changes/i.test(s.stdout); } const rebaseResult = await rebase(baseRef, cwd); rebased = rebaseResult.success; if (!rebaseResult.success) { conflictFiles = rebaseResult.conflicts; conflicts = true; // Never leave a half-done rebase behind: abort = the run is a no-op. await abortRebase(cwd); warnings.push( buildConflictGuidance({ operation: 'rebase', baseRef, branch, conflictFiles }) + ' Prefer /gitflow update (merge by default, rebase opt-in).', ); } if (stashedHere) { const pop = await stashPop(cwd); if (pop.exitCode !== 0) { conflicts = true; warnings.push('stash pop reported conflicts after rebase — resolve them in the working tree.'); } } if (rebaseResult.success && (await remoteRefExists(`origin/${branch}`, cwd))) { warnings.push( `rebase rewrote history — origin/${branch} now diverges; push with --force-with-lease, or use /gitflow update with push:true.`, ); } } } } return { success: !conflicts, branch, baseBranch, pushed, pulled, rebased, conflicts, conflictFiles: conflicts ? conflictFiles : undefined, warnings: warnings.length ? warnings : undefined, }; } catch (err: unknown) { return { success: false, error: (err as Error).message, branch: '', baseBranch: '', pushed: false, pulled: false, rebased: false, conflicts: true, }; } }