Twenty-two branches in the orphan-reaping path are unreachable from the test suite, and the reason is not that they are hard to reach — it is that nothing can reach them. The reaper already accepts an injectable dependency bag, but every test drives real git and injects only the clock and the liveness probe, so each fail-closed return inside it has never executed under test. The two entry points above it took no dependencies at all, so a test could not drive them even if it wanted to. Both now accept the same bag and thread it down, with stdout and stderr writers defaulting to the process streams. The worktree-info probe in the base-branch resolver called its git seam directly while a sibling function in the same file already modelled the injectable form; it now follows that sibling rather than inventing a second convention. Every parameter defaults to today's real implementation, so no existing caller changes behavior. This is a testability seam, not a redesign. Two guards are removed as genuinely dead, each excluded by a check a few lines above it. A NaN test on a value captured by a digits-only pattern cannot fire, because parseInt of digits is never NaN. An emptiness test on a capture group that matched one-or-more non-space characters cannot fire either. Each site keeps a one-line note naming the guard that excludes it, so neither gets restored by a future reader. A third guard was proposed for deletion on the same grounds and is NOT removed, because the claim was wrong. The local-branch fallback returns null when git prints output that is non-empty but names neither branch — the emptiness check above it only catches the empty string, so a single newline reaches the fallback with both flags false. Deleting it would have changed which branch the resolver reports. It stays, and it gets a test. Refs #3057 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
324 lines
12 KiB
TypeScript
324 lines
12 KiB
TypeScript
/**
|
|
* 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<BaseBranchDeps, 'readFile'>
|
|
): 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<string, unknown>;
|
|
// 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<string, unknown>).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: <name>`
|
|
* 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<BaseBranchDeps, 'execGit'>
|
|
): 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;
|
|
}
|