/** * GitFlow Git Operations — Thin async wrappers around git commands. * Self-contained: only uses node built-ins. */ import { execFile } from 'child_process'; import type { AheadBehind, GitStatus } from './types.js'; export interface ExecGitResult { stdout: string; stderr: string; exitCode: number; } export function execGit(args: string[], cwd?: string): Promise { return new Promise((resolve) => { execFile('git', args, { cwd, encoding: 'utf-8', timeout: 30_000, maxBuffer: 10 * 1024 * 1024, }, (error, stdout, stderr) => { resolve({ // trimEnd() only — NEVER trim(). `git status --porcelain` (and other // column-significant output) encodes meaning in the FIRST character of // each line (porcelain XY columns: X=index/staged, Y=worktree). A full // trim() eats the leading space of the first line → the first entry is // misread as staged and its path (slice(3)) loses a char. stdout: (stdout || '').trimEnd(), stderr: (stderr || '').trim(), exitCode: error ? (typeof (error as NodeJS.ErrnoException).code === 'number' ? (error as NodeJS.ErrnoException).code as unknown as number : 1) : 0, }); }); }); } export async function execGitOrFail(args: string[], cwd?: string): Promise { const result = await execGit(args, cwd); if (result.exitCode !== 0) { throw new Error(`git ${args[0]} failed: ${result.stderr || result.stdout}`); } return result.stdout; } export async function getCurrentBranch(cwd?: string): Promise { return (await execGit(['branch', '--show-current'], cwd)).stdout || 'HEAD'; } export async function fetch(remote = 'origin', cwd?: string): Promise { return execGit(['fetch', remote, '--quiet'], cwd); } export async function fetchAll(cwd?: string): Promise { return execGit(['fetch', '--all', '--quiet'], cwd); } export async function push(branch: string, cwd?: string, setUpstream = false, force = false): Promise { const args = ['push']; if (setUpstream) args.push('-u'); if (force) args.push('--force-with-lease'); args.push('origin', branch); return execGit(args, cwd); } /** * Ahead/behind of `branch` vs `remote` (e.g. develop vs origin/develop). * * Exit-code aware — the OLD version ignored it, so a failed `rev-list` (bad ref, * git error, or a worktree git wrongly treats as bare) silently parsed ''→0 and * reported `0 ahead / 0 behind` = "in sync": the false positive behind the * stale-base bugs. Now: both refs valid → real counts; `remote` missing (branch * never pushed) → all commits ahead / 0 behind (so callers push); any OTHER * failure → throw (never a fake "in sync"). */ export async function getAheadBehind(branch: string, remote: string, cwd?: string): Promise { const aheadResult = await execGit(['rev-list', '--count', `${remote}..${branch}`], cwd); const behindResult = await execGit(['rev-list', '--count', `${branch}..${remote}`], cwd); if (aheadResult.exitCode === 0 && behindResult.exitCode === 0) { return { ahead: parseInt(aheadResult.stdout, 10) || 0, behind: parseInt(behindResult.stdout, 10) || 0, }; } // A missing `remote` ref (branch never pushed) is legitimate, not an error: // everything on `branch` is "ahead", nothing is "behind". if (!(await remoteRefExists(remote, cwd))) { const total = await execGit(['rev-list', '--count', branch], cwd); if (total.exitCode === 0) return { ahead: parseInt(total.stdout, 10) || 0, behind: 0 }; } throw new Error( `git rev-list failed comparing '${branch}' and '${remote}': ${aheadResult.stderr || behindResult.stderr || 'unknown error'}`, ); } /** * Parse `git status --porcelain` (v1) into staged / modified / untracked path * lists. Pure — feed it raw stdout. Column-aware (X = index/staged, * Y = worktree). Rename/copy lines (`R old -> new`, `C old -> new`) carry BOTH * paths; the meaningful one is the NEW path (after ` -> `). Without that split, * `slice(3)` keeps "old -> new" verbatim → a renamed migration is * mis-identified and the EF guard's `existsSync` skips it, so a destructive * `Up()` could slip through. Relies on intact XY columns — see execGit * (`.trimEnd`, never `.trim`, which would eat the first line's leading space). */ export function parsePorcelainStatus( stdout: string, ): { staged: string[]; modified: string[]; untracked: string[] } { const staged: string[] = []; const modified: string[] = []; const untracked: string[] = []; for (const line of stdout.split('\n')) { if (line.length < 4) continue; // XY + space + ≥1 path char const index = line[0]; const working = line[1]; let file = line.slice(3); // Rename/copy: keep the worktree path that actually exists (after the arrow). if (index === 'R' || index === 'C' || working === 'R' || working === 'C') { const arrow = file.indexOf(' -> '); if (arrow !== -1) file = file.slice(arrow + 4); } if (index === '?') { untracked.push(file); } else { if (index !== ' ' && index !== '?') staged.push(file); if (working !== ' ' && working !== '?') modified.push(file); } } return { staged, modified, untracked }; } export async function getStatus(cwd?: string): Promise { const result = await execGit(['status', '--porcelain'], cwd); // Exit-code aware: a FAILED `git status` (e.g. a worktree git treats as bare — // "fatal: this operation must be run in a work tree", exit 128) yields EMPTY // stdout. The old code read that as `dirty:false` → a false "clean tree". // Surface the failure so callers never act on a phantom-clean working tree. if (result.exitCode !== 0) { throw new Error(`git status failed${cwd ? ` in '${cwd}'` : ''}: ${result.stderr || 'cannot read the working tree'}`); } const { staged, modified, untracked } = parsePorcelainStatus(result.stdout); const dirty = result.stdout.split('\n').filter(Boolean).length > 0; return { staged, modified, untracked, dirty }; } /** * Count staged vs unstaged entries from `git status --porcelain` output. * * Parses by COLUMN (porcelain v1: X = index/staged, Y = worktree/unstaged), so * it is robust to combined states (`MM` = staged + unstaged, `AM`, …). Untracked * (`??`) counts as neither. Relies on the leading XY columns being intact — see * execGit (`.trimEnd`, never `.trim`, which would eat the first line's space). */ export function countWorkingTree(porcelain: string): { staged: number; unstaged: number } { let staged = 0; let unstaged = 0; for (const line of porcelain.split('\n')) { if (line.length < 2) continue; const x = line[0]; // index (staged) column const y = line[1]; // worktree (unstaged) column if (x === '?' && y === '?') continue; // untracked → neither if (x !== ' ' && x !== '?') staged++; if (y !== ' ' && y !== '?') unstaged++; } return { staged, unstaged }; } export async function getLog(count: number, format?: string, cwd?: string): Promise { const fmt = format || '%H|%aI|%s'; const result = await execGit(['log', `--format=${fmt}`, `-${count}`], cwd); return result.stdout.split('\n').filter(Boolean); } export async function createTag(name: string, message: string, cwd?: string): Promise { await execGitOrFail(['tag', '-a', name, '-m', message], cwd); } export async function pushTag(name: string, cwd?: string): Promise { await execGitOrFail(['push', 'origin', name], cwd); } export async function tagExists(name: string, cwd?: string): Promise { return (await execGit(['rev-parse', '-q', '--verify', `refs/tags/${name}`], cwd)).exitCode === 0; } /** True when `path` is a tracked file (so it can be safely `git add`ed/committed). */ export async function isTracked(path: string, cwd?: string): Promise { return (await execGit(['ls-files', '--error-unmatch', path], cwd)).exitCode === 0; } /** * Annotated tag pointing at a SPECIFIC ref (e.g. `origin/main`) — worktree-safe, * no checkout required. Used by finish to tag the released main commit. */ export async function createTagAt(name: string, ref: string, message: string, cwd?: string): Promise { await execGitOrFail(['tag', '-a', '-m', message, name, ref], cwd); } /** * Merge a ref (e.g. `origin/main`) with `--no-ff`. Non-throwing variant of * mergeNoFf: returns `{ ok:false, conflicts }` instead of throwing so the caller * can abort cleanly. Used by finish for the main → develop merge-back. */ export async function mergeRefNoFf(ref: string, message: string, cwd?: string): Promise<{ ok: boolean; conflicts: string[] }> { const result = await execGit(['merge', ref, '--no-ff', '-m', message], cwd); if (result.exitCode === 0) return { ok: true, conflicts: [] }; const status = await execGit(['diff', '--name-only', '--diff-filter=U'], cwd); return { ok: false, conflicts: status.stdout.split('\n').filter(Boolean) }; } export async function rebase(onto: string, cwd?: string): Promise<{ success: boolean; conflicts: string[] }> { const result = await execGit(['rebase', onto], cwd); if (result.exitCode === 0) { return { success: true, conflicts: [] }; } const status = await execGit(['diff', '--name-only', '--diff-filter=U'], cwd); return { success: false, conflicts: status.stdout.split('\n').filter(Boolean) }; } export async function abortRebase(cwd?: string): Promise { return execGit(['rebase', '--abort'], cwd); } export async function abortMerge(cwd?: string): Promise { return execGit(['merge', '--abort'], cwd); } export async function mergeNoFf(branch: string, message: string, cwd?: string): Promise { await execGitOrFail(['merge', branch, '--no-ff', '-m', message], cwd); } export async function checkout(branch: string, cwd?: string): Promise { await execGitOrFail(['checkout', branch], cwd); } /** Create AND check out a new branch from a base in one operation: `git checkout -b `. */ export async function checkoutNew(branch: string, base: string, cwd?: string): Promise { await execGitOrFail(['checkout', '-b', branch, base], cwd); } export async function pull(remote = 'origin', branch?: string, cwd?: string): Promise { const args = ['pull', remote]; if (branch) args.push(branch); return execGit(args, cwd); } export async function stash(cwd?: string): Promise { return execGit(['stash'], cwd); } export async function stashPop(cwd?: string): Promise { return execGit(['stash', 'pop'], cwd); } export async function branchExists(branch: string, remote = false, cwd?: string): Promise { if (remote) { const result = await execGit(['ls-remote', '--heads', 'origin', branch], cwd); return result.stdout.length > 0; } const result = await execGit(['rev-parse', '--verify', branch], cwd); return result.exitCode === 0; } export async function isMerged(branch: string, into: string, cwd?: string): Promise { // 1) Remote-tracking branches: origin/ appears in the merged list. const result = await execGit(['branch', '-r', '--merged', `origin/${into}`], cwd); if (result.stdout.includes(`origin/${branch}`)) return true; // 2) Robust fallback for a LOCAL-only (but already-merged) branch — e.g. a // release merged via the provider PR yet never pushed to origin, so step 1 // can't see it. If the branch tip is an ancestor of origin/, every // one of its commits is already in ⇒ merged. `merge-base // --is-ancestor` exits 0 = ancestor, 1 = not, ≠0/1 on a missing ref → // treated as "not merged" (never a false positive). execGit returns the // exit code without throwing, so a non-zero status is safe to read here. const ancestor = await execGit(['merge-base', '--is-ancestor', branch, `origin/${into}`], cwd); return ancestor.exitCode === 0; } export async function getRemoteUrl(cwd?: string): Promise { const result = await execGit(['remote', 'get-url', 'origin'], cwd); return result.exitCode === 0 ? result.stdout : null; } export async function detectInProgressOp(cwd?: string): Promise<'rebase' | 'merge' | 'cherry-pick' | null> { const gitDir = (await execGit(['rev-parse', '--git-dir'], cwd)).stdout; if (!gitDir) return null; const { existsSync } = await import('fs'); const { join, isAbsolute } = await import('path'); // `--git-dir` is absolute in a linked worktree, relative (".git") at the root. const base = isAbsolute(gitDir) ? gitDir : join(cwd ?? process.cwd(), gitDir); if (existsSync(join(base, 'rebase-merge')) || existsSync(join(base, 'rebase-apply'))) return 'rebase'; if (existsSync(join(base, 'MERGE_HEAD'))) return 'merge'; if (existsSync(join(base, 'CHERRY_PICK_HEAD'))) return 'cherry-pick'; return null; } export async function isGitRepo(cwd?: string): Promise { return (await execGit(['rev-parse', '--git-dir'], cwd)).exitCode === 0; } /** * True only when `cwd` is a usable (non-bare) work tree. Distinguishes a real * working tree from a linked worktree git wrongly treats as bare (core.bare=true * inherited via extensions.worktreeConfig with no per-worktree config.worktree) — * the condition that makes status/merge/checkout fail with "must be run in a * work tree" while the files are present on disk. */ export async function isInsideWorkTree(cwd?: string): Promise { const r = await execGit(['rev-parse', '--is-inside-work-tree'], cwd); return r.exitCode === 0 && r.stdout.trim() === 'true'; } /** True when a ref (e.g. `origin/develop`) resolves. Quiet, exit-code only. */ export async function remoteRefExists(ref: string, cwd?: string): Promise { return (await execGit(['rev-parse', '--verify', '--quiet', ref], cwd)).exitCode === 0; } /** * Prefer the fetched remote-tracking ref for a shared base branch — the LOCAL * ref can be stale (the "two develops" trap: a months-old local develop made * `feature 23/0 vs develop` while origin/develop was really 2 commits ahead). * Falls back to the local ref name only when origin/ doesn't exist. * Callers must still verify the returned ref resolves (remoteRefExists) before * comparing against it — the fallback name may not exist either. */ export async function resolveBaseRef(branchName: string, cwd?: string): Promise { return (await remoteRefExists(`origin/${branchName}`, cwd)) ? `origin/${branchName}` : branchName; } /** Fast-forward the checked-out branch to `ref` (e.g. origin/develop). Non-throwing. */ export async function fastForwardOnly(ref: string, cwd?: string): Promise { return execGit(['merge', '--ff-only', ref], cwd); } export async function getGitCommonDir(cwd?: string): Promise { const result = await execGit(['rev-parse', '--git-common-dir'], cwd); return result.exitCode === 0 ? result.stdout : null; } export async function getDiffStats(base: string, cwd?: string): Promise { return (await execGit(['diff', '--stat', base], cwd)).stdout; } export async function getDiffFiles(base: string, cwd?: string): Promise { const result = await execGit(['diff', '--name-only', base], cwd); return result.stdout.split('\n').filter(Boolean); } export async function addFiles(files: string[], cwd?: string): Promise { if (files.length === 0) return; await execGitOrFail(['add', ...files], cwd); } /** Paths currently staged (index vs HEAD), repo-root-relative. */ export async function getStagedFiles(cwd?: string): Promise { const result = await execGit(['diff', '--cached', '--name-only'], cwd); return result.exitCode === 0 ? result.stdout.split('\n').filter(Boolean) : []; } export async function commit(message: string, cwd?: string): Promise { return execGit(['commit', '-m', message], cwd); } export async function deleteBranch(branch: string, remote = false, force = false, cwd?: string): Promise { if (remote) { return execGit(['push', 'origin', '--delete', branch], cwd); } return execGit(['branch', force ? '-D' : '-d', branch], cwd); }