/** * execute.ts — Execution logic. Imports from ../lib/. NO validation. * Receives validated data, returns typed result. */ import type { StatusSpec, StatusResult, BranchInfo, Comparison, WorkingTreeStatus, Action } from './types.js'; import { execGit, detectInProgressOp, countWorkingTree, remoteRefExists, resolveBaseRef } from '../lib/git.js'; import { listWorktrees, ensureWorkTreeUsable } from '../lib/worktree.js'; import { readConfig, resolveConfigPath } from '../lib/config.js'; import { detectBranchType } from '../lib/branch.js'; export async function execute(spec: StatusSpec, workDir: string = process.cwd()): Promise { try { // Get current branch const currentBranch = await getCurrentBranch(workDir); // Fetch all remotes await execGit(['fetch', '--all', '--quiet'], workDir); // Best-effort: repair a worktree git wrongly treats as bare (inherited // core.bare) so the working-tree status below is truthful, not phantom-clean. await ensureWorkTreeUsable(workDir); // Get all branches with tracking info const branches = await getAllBranches(workDir); // Get all tags const tags = await getAllTags(workDir); // Get working tree status const workingTree = await getWorkingTreeStatus(workDir); // Get worktrees const worktrees = await listWorktrees(workDir); // Get comparisons (main vs develop, develop vs current) let comparisons: Comparison[] = []; try { const config = await readConfig(await resolveConfigPath(workDir) ?? undefined); comparisons = await getComparisons(config.git.branches.main, config.git.branches.develop, currentBranch, workDir); } catch { // Config not found, skip comparisons } // Generate suggested actions const actions = generateActions(currentBranch, branches, workingTree, comparisons); return { success: true, currentBranch, branches, tags, comparisons: spec.verbose ? comparisons : undefined, workingTree, worktrees: spec.verbose ? worktrees : undefined, // Actions are the actionable signal (e.g. "feature behind develop") — // surface them whenever there are any, not only in verbose mode. actions: actions.length > 0 ? actions : undefined, }; } catch (err) { return { success: false, currentBranch: 'unknown', branches: [], tags: [], workingTree: { isDirty: false, isRebasing: false, isMerging: false, isCherryPicking: false, unstagedCount: 0, stagedCount: 0, }, error: (err as Error).message, }; } } async function getCurrentBranch(cwd: string): Promise { const result = await execGit(['branch', '--show-current'], cwd); return result.stdout || 'HEAD'; } async function getAllBranches(cwd: string): Promise { const result = await execGit(['branch', '-v', '--all', '--format=%(refname:short)|%(upstream:short)|%(committerdate:short)|%(authorname)'], cwd); if (result.exitCode !== 0) return []; const branches: BranchInfo[] = []; for (const line of result.stdout.split('\n')) { if (!line.trim()) continue; const parts = line.split('|'); const name = parts[0].trim(); const tracking = parts[1]?.trim(); // Get ahead/behind const aheadBehind = await getAheadBehind(name, cwd); branches.push({ name, type: detectBranchType(name), ahead: aheadBehind.ahead, behind: aheadBehind.behind, tracking: tracking || undefined, lastCommit: parts[2]?.trim(), lastAuthor: parts[3]?.trim(), }); } return branches; } async function getAheadBehind(branch: string, cwd: string): Promise<{ ahead: number; behind: number }> { const trackingResult = await execGit(['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], cwd); let tracking: string; if (trackingResult.exitCode === 0) { tracking = trackingResult.stdout; } else if (await remoteRefExists(`origin/${branch}`, cwd)) { // No upstream configured, but origin/ exists: compare against it // anyway so a branch silently BEHIND its origin (the develop-no-upstream // trap that made `sync` a no-op) is detected instead of reported 0/0. tracking = `origin/${branch}`; } else { return { ahead: 0, behind: 0 }; } const aheadResult = await execGit(['rev-list', '--count', `${tracking}..${branch}`], cwd); const behindResult = await execGit(['rev-list', '--count', `${branch}..${tracking}`], cwd); return { ahead: aheadResult.exitCode === 0 ? parseInt(aheadResult.stdout, 10) || 0 : 0, behind: behindResult.exitCode === 0 ? parseInt(behindResult.stdout, 10) || 0 : 0, }; } async function getAllTags(cwd: string): Promise { const result = await execGit(['tag', '--list', '--sort=-version:refname'], cwd); if (result.exitCode !== 0) return []; return result.stdout.split('\n').filter((t) => t.trim()); } async function getWorkingTreeStatus(cwd: string): Promise { const statusResult = await execGit(['status', '--porcelain'], cwd); // Column-based (X=staged, Y=unstaged) — robust to combined states like `MM`, // unlike the old `/^ [MADRC]/` regexes which also broke on the first line once // execGit's leading-space bug is removed. const { staged: stagedCount, unstaged: unstagedCount } = countWorkingTree(statusResult.stdout); // `git rev-parse --git-path X` always exits 0 (it only resolves the path, // existing or not), so the old exitCode check reported every op as in-progress. // Detect by actual state-file existence instead. const inProgress = await detectInProgressOp(cwd); return { isDirty: statusResult.exitCode === 0 && statusResult.stdout.length > 0, isRebasing: inProgress === 'rebase', isMerging: inProgress === 'merge', isCherryPicking: inProgress === 'cherry-pick', unstagedCount, stagedCount, }; } async function getComparisons(mainBranch: string, developBranch: string, currentBranch: string, cwd: string): Promise { const comparisons: Comparison[] = []; const mainRef = await resolveBaseRef(mainBranch, cwd); const developRef = await resolveBaseRef(developBranch, cwd); // Main vs develop (compared via origin refs; labelled with the logical names) const mainVsDevelop = await getComparison(mainRef, developRef, cwd); if (mainVsDevelop) { comparisons.push({ branches: [mainBranch, developBranch], fromBranch: mainBranch, toBranch: developBranch, aheadCount: mainVsDevelop.behind, behindCount: mainVsDevelop.ahead, }); } // Develop vs current (origin/develop vs the LOCAL branch you're on) if (currentBranch !== developBranch && currentBranch !== mainBranch) { const developVsCurrent = await getComparison(developRef, currentBranch, cwd); if (developVsCurrent) { comparisons.push({ branches: [developBranch, currentBranch], fromBranch: developBranch, toBranch: currentBranch, aheadCount: developVsCurrent.behind, behindCount: developVsCurrent.ahead, }); } } return comparisons; } async function getComparison(branchA: string, branchB: string, cwd: string): Promise<{ ahead: number; behind: number } | null> { const aheadResult = await execGit(['rev-list', '--count', `${branchB}..${branchA}`], cwd); const behindResult = await execGit(['rev-list', '--count', `${branchA}..${branchB}`], cwd); if (aheadResult.exitCode === 0 && behindResult.exitCode === 0) { return { ahead: parseInt(aheadResult.stdout, 10) || 0, behind: parseInt(behindResult.stdout, 10) || 0, }; } return null; } /** Pure — exported for tests. */ export function generateActions(currentBranch: string, branches: BranchInfo[], workingTree: WorkingTreeStatus, comparisons: Comparison[]): Action[] { const actions: Action[] = []; // Base-staleness: the develop→current comparison (counts are toBranch-relative, // so behindCount > 0 = the current branch is missing base commits). This is // the "feature behind develop" signal — actionable via /gitflow update. for (const c of comparisons) { if (c.toBranch === currentBranch && c.behindCount > 0) { actions.push({ priority: 'medium', action: `Update '${currentBranch}' from ${c.fromBranch}: /gitflow update`, reason: `${c.fromBranch} is ahead by ${c.behindCount} commit(s)`, }); } } // Check for dirty working tree if (workingTree.isDirty) { actions.push({ priority: 'high', action: 'Commit or stash changes', reason: 'Working tree has uncommitted changes', }); } // Check for active rebase/merge/cherry-pick if (workingTree.isRebasing) { actions.push({ priority: 'critical', action: 'Resolve or abort rebase', reason: 'Rebase in progress', }); } if (workingTree.isMerging) { actions.push({ priority: 'critical', action: 'Resolve or abort merge', reason: 'Merge in progress', }); } if (workingTree.isCherryPicking) { actions.push({ priority: 'critical', action: 'Resolve or abort cherry-pick', reason: 'Cherry-pick in progress', }); } // Check for branches with upstream tracking issues for (const branch of branches) { if (branch.ahead > 0 && !branch.tracking) { actions.push({ priority: 'medium', action: `Push branch: ${branch.name}`, reason: `Local branch is ahead by ${branch.ahead} commit(s) and has no upstream`, }); } if (branch.behind > 0) { actions.push({ priority: 'medium', action: `Update branch: ${branch.name}`, reason: `Remote branch is ahead by ${branch.behind} commit(s)`, }); } } return actions.sort((a, b) => { const priorityOrder = { critical: 0, high: 1, medium: 2, low: 3 }; return priorityOrder[a.priority] - priorityOrder[b.priority]; }); }