Re-home the 6 implementation functions squatting in the core.cjs re-export spine (ADR-857) into the modules whose interface they belong to, with core re-exporting them BY REFERENCE so all 32 callers + the shim-identity tests keep resolving unchanged: - worktree-safety: resolveWorktreeRoot, pruneOrphanedWorktrees - git-base-branch (broadened to the Git Query Module): gitWorktreeInfoInternal - agent-install-check (new leaf): getAgentsDir, checkAgentsInstalled - delete the _resetRuntimeWarningCacheForTests wrapper; consumers use a shared resetRuntimeWarningCaches() helper in tests/helpers.cjs Add scripts/lint-core-spine-imports.cjs (migration-convergence lint with a 30-importer allowlist, wired into lint:ci) so the staged spine retirement provably converges: CI fails on any new ./core import. Register the new generated agent-install-check.cjs in eslint-ignore + .gitignore + INVENTORY-MANIFEST.json. No behaviour change. First tranche (T0) of epic #1267. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
221 lines
8.5 KiB
JavaScript
221 lines
8.5 KiB
JavaScript
"use strict";
|
|
/**
|
|
* 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.
|
|
*
|
|
* 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.
|
|
*/
|
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
};
|
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
exports.readConfigBaseBranch = readConfigBaseBranch;
|
|
exports.trySymbolicRef = trySymbolicRef;
|
|
exports.tryRemoteShow = tryRemoteShow;
|
|
exports.tryLocalBranch = tryLocalBranch;
|
|
exports.resolveBaseBranch = resolveBaseBranch;
|
|
exports.gitWorktreeInfoInternal = gitWorktreeInfoInternal;
|
|
exports.cmdGitBaseBranch = cmdGitBaseBranch;
|
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
const node_path_1 = __importDefault(require("node:path"));
|
|
const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
|
|
// ─── 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.
|
|
*/
|
|
function readConfigBaseBranch(planningDir, deps) {
|
|
const readFile = deps?.readFile ??
|
|
((p) => { try {
|
|
return node_fs_1.default.readFileSync(p, 'utf8');
|
|
}
|
|
catch {
|
|
return null;
|
|
} });
|
|
const configPath = node_path_1.default.join(planningDir, 'config.json');
|
|
const raw = readFile(configPath);
|
|
if (!raw)
|
|
return null;
|
|
let cfg;
|
|
try {
|
|
cfg = JSON.parse(raw);
|
|
}
|
|
catch {
|
|
return null;
|
|
}
|
|
if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg))
|
|
return null;
|
|
const top = cfg;
|
|
// 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.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.
|
|
*/
|
|
function trySymbolicRef(cwd, execGit) {
|
|
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.
|
|
*/
|
|
function tryRemoteShow(cwd, execGit) {
|
|
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.
|
|
if (!branch || 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.
|
|
*/
|
|
function tryLocalBranch(cwd, execGit) {
|
|
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;
|
|
}
|
|
}
|
|
/**
|
|
* Resolve the default/base branch for the repository at `cwd`.
|
|
*
|
|
* Consults the full precedence ladder and always returns a non-empty string.
|
|
* Never throws.
|
|
*/
|
|
function resolveBaseBranch(cwd, deps) {
|
|
const execGit = deps?.execGit ?? shell_command_projection_cjs_1.execGit;
|
|
// Derive .planning dir relative to cwd (mirrors planningDir() in core.cjs)
|
|
const planningDir = node_path_1.default.join(cwd, '.planning');
|
|
// 1. Config override
|
|
const configured = readConfigBaseBranch(planningDir, deps);
|
|
if (configured)
|
|
return configured;
|
|
// 2. symbolic-ref (fast, no network)
|
|
const symref = trySymbolicRef(cwd, execGit);
|
|
if (symref)
|
|
return symref;
|
|
// 3. git remote show origin (authoritative when origin/HEAD unset)
|
|
const remoteShow = tryRemoteShow(cwd, execGit);
|
|
if (remoteShow)
|
|
return remoteShow;
|
|
// 4. Local branch existence
|
|
const local = tryLocalBranch(cwd, execGit);
|
|
if (local)
|
|
return local;
|
|
// 5. Last-resort default
|
|
return 'main';
|
|
}
|
|
/**
|
|
* Detect whether `cwd` sits inside a git worktree, and if so, return the
|
|
* absolute path of the worktree root.
|
|
*/
|
|
function gitWorktreeInfoInternal(cwd) {
|
|
try {
|
|
const insideResult = (0, shell_command_projection_cjs_1.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 = (0, shell_command_projection_cjs_1.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`.
|
|
*/
|
|
function cmdGitBaseBranch(cwd, _args, deps) {
|
|
const branch = resolveBaseBranch(cwd, deps);
|
|
const write = deps?.write ?? ((s) => process.stdout.write(s));
|
|
write(branch + '\n');
|
|
return branch;
|
|
}
|