/** * Git Base-Branch Resolver — issue #1146. * * Single source of truth for detecting the repository's default branch. * Replaces the duplicated per-workflow bash detection that only consulted * `refs/remotes/origin/HEAD` then hardcoded `:-main`, which silently * returned "main" for repos whose default branch is "master" whenever * origin/HEAD was unset (git init + remote add / fetch without set-head / * most CI checkouts / many worktrees). * * Precedence ladder (highest to lowest): * 1. `git.base_branch` config override from .planning/config.json * 2. `git symbolic-ref --short refs/remotes/origin/HEAD` (fast, no network) * 3. `git remote show origin` HEAD branch ← AUTHORITATIVE; works when #2 unset * 4. Local branch existence: "master" present + "main" absent → "master"; * "main" present → "main" * 5. "main" (last-resort default) * * Every git subprocess is bounded with a timeout (≤ 30 s); on timeout/error * the resolver degrades gracefully to the next tier — it never throws. Tier 5 * is reachable two ways that `resolveBaseBranch()` alone cannot tell apart: a * repository that genuinely has no candidate branch (every git query on tiers * 2-4 completed and cleanly answered "nothing"), or a total resolution * failure (some query timed out / could not run). `resolveBaseBranchDiagnostics()` * distinguishes the two via `verified`; `cmdGitBaseBranch` surfaces the * unverified case as a stderr diagnostic without changing its stdout contract * (#3057 B4). * * Pure/testable: all I/O is injectable via the `deps` argument so unit * tests can run without touching the real filesystem or spawning real git. */ import fs from 'node:fs'; import path from 'node:path'; import { execGit as execGitSeam } from './shell-command-projection.cjs'; // ─── Types ──────────────────────────────────────────────────────────────────── type ExecGitFn = typeof execGitSeam; export interface BaseBranchDeps { /** Override the git runner (default: execGit from shell-command-projection) */ execGit?: ExecGitFn; /** Override filesystem reads (default: fs.readFileSync / fs.existsSync) */ readFile?: (p: string) => string | null; /** Inject the write function used by cmdGitBaseBranch (default: process.stdout.write) */ write?: (s: string) => void; /** * Inject the diagnostic-write function used by cmdGitBaseBranch when the * last-resort default is reached WITHOUT verification (default: * process.stderr.write). Never used for the resolved branch itself — only * for the "this is an unverified guess" warning (#3057 B4). */ writeDiagnostic?: (s: string) => void; } // ─── Helpers ────────────────────────────────────────────────────────────────── /** * Safely look up `git.base_branch` from the project's config.json. * Returns the configured value (a non-empty, non-null string) or null. */ export function readConfigBaseBranch( planningDir: string, deps?: Pick ): string | null { const readFile: (p: string) => string | null = deps?.readFile ?? ((p: string) => { try { return fs.readFileSync(p, 'utf8'); } catch { return null; } }); const configPath = path.join(planningDir, 'config.json'); const raw = readFile(configPath); if (!raw) return null; let cfg: unknown; try { cfg = JSON.parse(raw); } catch { return null; } if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) return null; const top = cfg as Record; // Support both "git.base_branch" (nested) and "base_branch" (flat legacy) const gitSection = top.git; if (gitSection && typeof gitSection === 'object' && !Array.isArray(gitSection)) { const nested = (gitSection as Record).base_branch; if (typeof nested === 'string' && nested.trim()) return nested.trim(); } const flat = top.base_branch; if (typeof flat === 'string' && flat.trim()) return flat.trim(); return null; } /** * Try `git symbolic-ref --short refs/remotes/origin/HEAD` (no network). * Strips the `origin/` prefix to return just the branch name. * Returns null if unset or on error/timeout. */ export function trySymbolicRef( cwd: string, execGit: ExecGitFn ): string | null { try { const r = execGit( ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], { cwd, timeout: 5_000 } ); if (r.exitCode !== 0 || !r.stdout) return null; // Output is e.g. "origin/main" — strip the prefix const branch = r.stdout.trim().replace(/^origin\//, ''); return branch || null; } catch { return null; } } /** * Try `git remote show origin` to read the HEAD branch. * This is authoritative when origin/HEAD is unset locally. * Requires network access but succeeds in the common CI case where * origin/HEAD was never set after `git init && git remote add origin`. * * Parses the line: `HEAD branch: ` * Returns null on error, timeout, or if the output is malformed. */ export function tryRemoteShow( cwd: string, execGit: ExecGitFn ): string | null { try { const r = execGit( ['remote', 'show', 'origin'], { cwd, timeout: 15_000 } ); if (r.exitCode !== 0 || !r.stdout) return null; // The line looks like: " HEAD branch: master" const m = r.stdout.match(/^\s*HEAD branch:\s*(\S+)\s*$/m); if (!m) return null; const branch = m[1]; // git emits "(unknown)" when the remote is offline but the local cache // resolved it; treat that as non-authoritative and fall through. // No `!branch ||` guard: m[1] comes from the `(\S+)` capture group above, so it is never empty. if (branch === '(unknown)') return null; return branch; } catch { return null; } } /** * Detect local branch existence as a tie-breaker when no remote info is available. * * Rules: * - "master" present AND "main" absent → "master" * - "main" present → "main" * - Neither → null (fall through to default) * * Returns null on error/timeout. */ export function tryLocalBranch( cwd: string, execGit: ExecGitFn ): string | null { try { const r = execGit( ['branch', '--list', 'main', 'master'], { cwd, timeout: 5_000 } ); if (r.exitCode !== 0 || !r.stdout) return null; // `git branch --list main master` outputs one line per matching branch const lines = r.stdout.split('\n').map(l => l.trim().replace(/^\*\s*/, '')); const hasMain = lines.includes('main'); const hasMaster = lines.includes('master'); if (hasMaster && !hasMain) return 'master'; if (hasMain) return 'main'; return null; } catch { return null; } } /** * Result of {@link resolveBaseBranchDiagnostics}: the resolved branch plus * whether it was actually verified against the repository. */ export interface ResolvedBaseBranch { branch: string; /** * True when `branch` came from a tier that ran to completion and produced * a real answer: a config override, a resolved git query (tiers 2-4), or * the tier-5 default reached because every git query on tiers 2-4 * completed and cleanly reported no candidate. False when the tier-5 * default was reached because at least one of those git queries timed out * or failed to run (e.g. git missing) — collapsing that case into the same * `'main'` as a verified "no candidate" answer is the fail-open branch * fixed by #3057 B4: the two are no longer indistinguishable. */ verified: boolean; } /** * Resolve the default/base branch for the repository at `cwd`, along with * whether the tier-5 last-resort default (if reached) was verified. * * Consults the full precedence ladder and always returns a non-empty string. * Never throws. */ export function resolveBaseBranchDiagnostics( cwd: string, deps?: BaseBranchDeps ): ResolvedBaseBranch { const rawExecGit: ExecGitFn = deps?.execGit ?? execGitSeam; // A genuine execGit failure (timeout, or the call could not even spawn — // e.g. git missing, surfaced as exitCode 127 with `error` set) is distinct // from git completing and cleanly reporting a negative answer (non-zero // exit with no useful output, or exit 0 with empty stdout). Only the former // means a tier's answer was never actually obtained. Wrapping execGit here // observes every tier's calls uniformly without changing trySymbolicRef / // tryRemoteShow / tryLocalBranch's own return contracts. let anyGitFailure = false; const execGit: ExecGitFn = (args, opts) => { const r = rawExecGit(args, opts); if (r.timedOut || r.error) anyGitFailure = true; return r; }; // Derive .planning dir relative to cwd (mirrors planningDir() in planning-workspace.cjs) const planningDir = path.join(cwd, '.planning'); // 1. Config override const configured = readConfigBaseBranch(planningDir, deps); if (configured) return { branch: configured, verified: true }; // 2. symbolic-ref (fast, no network) const symref = trySymbolicRef(cwd, execGit); if (symref) return { branch: symref, verified: true }; // 3. git remote show origin (authoritative when origin/HEAD unset) const remoteShow = tryRemoteShow(cwd, execGit); if (remoteShow) return { branch: remoteShow, verified: true }; // 4. Local branch existence const local = tryLocalBranch(cwd, execGit); if (local) return { branch: local, verified: true }; // 5. Last-resort default. `verified:false` when at least one tier-2/3/4 // execGit call timed out or failed to run — the default was never actually // checked against this repository, it is just what's left after git could // not answer (#3057 B4). return { branch: 'main', verified: !anyGitFailure }; } /** * Resolve the default/base branch for the repository at `cwd`. * * Consults the full precedence ladder and always returns a non-empty string. * Never throws. See {@link resolveBaseBranchDiagnostics} for a caller that * needs to distinguish a verified answer from an unverified fallback. */ export function resolveBaseBranch( cwd: string, deps?: BaseBranchDeps ): string { return resolveBaseBranchDiagnostics(cwd, deps).branch; } // ─── gitWorktreeInfoInternal (moved from core.cjs, ADR-857 T0 #1268) ───────── export interface GitWorktreeInfo { inside: boolean; worktreeRoot: string | null; } /** * Detect whether `cwd` sits inside a git worktree, and if so, return the * absolute path of the worktree root. */ export function gitWorktreeInfoInternal( cwd: string, deps?: Pick ): GitWorktreeInfo { const execGit: ExecGitFn = deps?.execGit ?? execGitSeam; try { const insideResult = execGit(['rev-parse', '--is-inside-work-tree'], { cwd, timeout: 5000 }); if (insideResult.exitCode !== 0) { return { inside: false, worktreeRoot: null }; } const insideStdout = String(insideResult.stdout || '').trim(); if (insideStdout !== 'true') { return { inside: false, worktreeRoot: null }; } const rootResult = execGit(['rev-parse', '--show-toplevel'], { cwd, timeout: 5000 }); if (rootResult.exitCode !== 0) { return { inside: true, worktreeRoot: null }; } const root = String(rootResult.stdout || '').trim(); return { inside: true, worktreeRoot: root || null }; } catch { return { inside: false, worktreeRoot: null }; } } // ─── CLI entry point ────────────────────────────────────────────────────────── /** * CLI command: `gsd-tools git base-branch` * Resolves the default branch and writes it to stdout (raw string, newline-terminated). * Called by workflows via `gsd_run query git.base-branch`. */ export function cmdGitBaseBranch( cwd: string, _args: string[], deps?: BaseBranchDeps ): string { const { branch, verified } = resolveBaseBranchDiagnostics(cwd, deps); if (!verified) { const writeDiagnostic = deps?.writeDiagnostic ?? ((s: string) => process.stderr.write(s)); writeDiagnostic( `⚠ git-base-branch: defaulted to 'main' WITHOUT verifying against this repository — ` + `a git query timed out or could not run. See #3057.\n` ); } const write = deps?.write ?? ((s: string) => process.stdout.write(s)); write(branch + '\n'); return branch; }