diff --git a/.gitignore b/.gitignore index 97c9f8096..9a5f28709 100644 --- a/.gitignore +++ b/.gitignore @@ -186,6 +186,7 @@ build/ /gsd-core/bin/lib/workstream.cjs /gsd-core/bin/lib/roadmap.cjs /gsd-core/bin/lib/audit.cjs +/gsd-core/bin/lib/git-base-branch.cjs __pycache__/ *.pyc .venv/ diff --git a/gsd-core/bin/lib/git-base-branch.cjs b/gsd-core/bin/lib/git-base-branch.cjs deleted file mode 100644 index b30cfda93..000000000 --- a/gsd-core/bin/lib/git-base-branch.cjs +++ /dev/null @@ -1,220 +0,0 @@ -"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: ` - * 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; -}