From e9f9ae49c8072ccc621e72792ad566f16ebbdd1f Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 14 Jun 2026 08:22:40 -0400 Subject: [PATCH] fix(#1146): single base-branch resolver across forking workflows (#1198) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(#1146): single base-branch resolver across forking workflows Replaces duplicated per-workflow bash detection that silently fell through to :-main on repos where origin/HEAD is unset (git init+remote add+fetch without set-head, most CI checkouts, many worktrees). New CJS module git-base-branch.cjs exposes `gsd_run query git.base-branch` with full precedence ladder: git.base_branch config override → origin/HEAD symref → git remote show origin (authoritative) → local branch presence → "main". All git subprocesses bounded with timeouts; degrades gracefully. Wires execute-phase, quick, ship, complete-milestone, and pr-branch to the single resolver. Removes 14 lines of duplicated detection bash across the five workflows. Includes 7 behavioral tests covering the full precedence ladder including the key regression case (master repo, origin/HEAD unset → must return "master", NOT "main") and an anti-regression guard that fails if any workflow re-introduces the :-main/:-master fallback pattern. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(changeset): backfill PR number #1198 Co-Authored-By: Claude Opus 4.8 (1M context) * chore(#1146): drop stray PR-body file from branch pr-1146-body.md was committed during changeset backfill but must not be tracked in the repo. Content preserved externally for PR body use. Co-Authored-By: Claude Sonnet 4.6 * test(#1146): add tests for flat base_branch config key and both-branch tie-break Closes two mutation gaps identified in adversarial review: - A2: flat {base_branch: ...} at config root (legacy key form) was covered by code but unguarded against mutation of lines 74-75 in resolver - H: tier-4 tie-break when both main+master exist locally (main wins, per tryLocalBranch JSDoc) was documented but untested 9/9 tests pass. Co-Authored-By: Claude Sonnet 4.6 * test(#1146): allowlist workflow-literal guard as runtime-contract exemption Co-Authored-By: Claude Opus 4.8 (1M context) * fix(#1146): degrade gracefully when gsd_run unavailable in handle_branching bash blocks handle_branching (execute-phase.md) and step 2.5 (quick.md) are extracted and run verbatim by behavioral tests that lack the gsd_run preamble. Adding a || fallback ladder (git symbolic-ref then echo main) keeps the unified resolver as primary in real workflows while letting the test harness succeed without gsd_run defined. Also propagates updated runtime-launcher preamble to pr-branch.md (added in origin/next MemPalace PR) and regenerates workflow-size-baseline.json. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .changeset/brave-foxes-leap.md | 5 + CONTEXT.md | 3 + docs/INVENTORY-MANIFEST.json | 1 + docs/INVENTORY.md | 1 + eslint.config.mjs | 2 + gsd-core/bin/gsd-tools.cjs | 20 +- gsd-core/bin/lib/git-base-branch.cjs | 194 +++++++++++++ gsd-core/workflows/complete-milestone.md | 6 +- gsd-core/workflows/execute-phase.md | 5 +- gsd-core/workflows/pr-branch.md | 6 +- gsd-core/workflows/quick.md | 5 +- gsd-core/workflows/ship.md | 6 +- .../lint-allow-test-rule-refs.allowlist.json | 2 + src/git-base-branch.cts | 218 +++++++++++++++ tests/git-base-branch.test.cjs | 261 ++++++++++++++++++ tests/workflow-size-baseline.json | 10 +- 16 files changed, 723 insertions(+), 22 deletions(-) create mode 100644 .changeset/brave-foxes-leap.md create mode 100644 gsd-core/bin/lib/git-base-branch.cjs create mode 100644 src/git-base-branch.cts create mode 100644 tests/git-base-branch.test.cjs diff --git a/.changeset/brave-foxes-leap.md b/.changeset/brave-foxes-leap.md new file mode 100644 index 000000000..c76d53be6 --- /dev/null +++ b/.changeset/brave-foxes-leap.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 1198 +--- +**Forking workflows target wrong base branch on `master` repos when `origin/HEAD` is unset** — `execute-phase`, `quick`, `ship`, `complete-milestone`, and `pr-branch` detection bash fell through to a hardcoded `main` fallback whenever `origin/HEAD` was absent (common in `git init` + `remote add` + `fetch` without `set-head`, CI checkouts, and worktrees), causing GSD to fork phase branches off a non-existent `main` on `master` repos. Replaced with a single `gsd_run query git.base-branch` resolver that walks the full precedence ladder: config override → `origin/HEAD` symref → `git remote show origin` → local branch presence → `"main"`. (#1198) diff --git a/CONTEXT.md b/CONTEXT.md index bad5d354e..e5f072bfa 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -103,6 +103,9 @@ Workflow contract seam covering agent worktree lifecycle orchestration rules. Th ### Worktree Root Resolution Adapter Module Adapter Module owning linked-worktree root mapping and metadata-prune policy (`git worktree prune` non-destructive default) for planning/workstream callers. +### Git Base-Branch Resolver Module +Module owning the single source of truth for detecting the repository's default/base branch (`gsd_run query git.base-branch`). Implements a full precedence ladder: (1) `git.base_branch` config override from `.planning/config.json`; (2) `git symbolic-ref --short refs/remotes/origin/HEAD`; (3) `git remote show origin` HEAD branch (authoritative when origin/HEAD is unset — the common case for `git init + remote add + fetch` without `set-head`); (4) local branch existence (`master` present and `main` absent → `master`; `main` present → `main`); (5) `"main"` last-resort default. All git subprocesses are bounded with timeouts (5–15 s) and degrade gracefully to the next tier; the function never throws. Replaces duplicated per-workflow bash detection that silently fell through to `:-main` on master repos (#1146). Source: `src/git-base-branch.cts` → `gsd-core/bin/lib/git-base-branch.cjs`. Wired into `execute-phase.md`, `quick.md`, `ship.md`, `complete-milestone.md`, and `pr-branch.md`. + ### Runtime Name Policy Module Module owning runtime identity normalization at runtime-selection seams. Canonicalizes alias signals from env/config (`GSD_RUNTIME`, `.planning/config.json:runtime`) to supported runtime IDs so output emitters and query runtime gates stay consistent across naming variants (for example `codex-app`/`codex-cli` -> `codex`). Sources: `gsd-core/bin/lib/runtime-name-policy.cjs`, alias manifest `gsd-core/bin/shared/runtime-aliases.manifest.json`. diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 54fd39a8e..98cfba0c8 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -306,6 +306,7 @@ "federated-config.cjs", "frontmatter.cjs", "gap-checker.cjs", + "git-base-branch.cjs", "graphify-command-router.cjs", "graphify.cjs", "gsd2-import.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 51bf10b99..0861799f9 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -418,6 +418,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `federated-config.cjs` | Defensive merge of capability-declared config slices into the loadConfig return value — ADR-857 phase 3b; exports `mergeFederatedConfig({ configSchema, isCentralKey, userConfig })` → `{ values, validKeys, warnings }`; live for migrated Capability keys that are atomically removed from the central config schema | | `frontmatter.cjs` | YAML frontmatter CRUD operations | | `gap-checker.cjs` | Post-planning gap analysis (#2493): unified REQUIREMENTS.md + CONTEXT.md decisions vs PLAN.md coverage report (`gsd-tools gap-analysis`) | +| `git-base-branch.cjs` | Single base-branch resolver (`gsd_run query git.base-branch`) with full precedence ladder: config override → origin/HEAD symref → `git remote show origin` → local branch presence → "main". Eliminates per-workflow duplicated bash detection (#1146) | | `graphify.cjs` | Knowledge-graph build/query/status/diff for `/gsd-graphify` | | `graphify-command-router.cjs` | ADR-959 capability command router for `gsd-tools graphify` — dispatches build/query/status/diff subcommands; first real capability command cutover (phase 4d-impl-2) | | `gsd2-import.cjs` | External-plan ingest for `/gsd-import --from-gsd2` | diff --git a/eslint.config.mjs b/eslint.config.mjs index 687a0b805..7144f6ff6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -152,6 +152,8 @@ export default tseslint.config( 'gsd-core/bin/lib/research-store.cjs', 'gsd-core/bin/lib/research-provider.cjs', 'gsd-core/bin/lib/package-legitimacy.cjs', + // ADR-457: tsc-generated runtime artifact — lint the src/git-base-branch.cts source. + 'gsd-core/bin/lib/git-base-branch.cjs', ], }, diff --git a/gsd-core/bin/gsd-tools.cjs b/gsd-core/bin/gsd-tools.cjs index a779c5079..c4e30e517 100755 --- a/gsd-core/bin/gsd-tools.cjs +++ b/gsd-core/bin/gsd-tools.cjs @@ -225,6 +225,7 @@ const { routeAgentCommand } = require('./lib/agent-command-router.cjs'); const { routeCheckCommand } = require('./lib/check-command-router.cjs'); const { routeTaskCommand } = require('./lib/task-command-router.cjs'); const { parseNamedArgs, parseMultiwordArg } = require('./lib/command-arg-projection.cjs'); +const { cmdGitBaseBranch } = require('./lib/git-base-branch.cjs'); // ─── Bridge collapsed (Phase 4) ──────────────────────────────────────────────── // Non-family commands now run through their CJS handlers directly. Keep the @@ -511,7 +512,7 @@ async function main() { 'current-timestamp, detect-custom-files, docs-init, effort, extract-messages, find-phase, ' + 'from-gsd2, frontmatter, gap-analysis, generate-claude-md, generate-claude-profile, ' + 'generate-dev-preferences, generate-slug, graphify, history-digest, init, intel, ' + - 'capability, classify-confidence, learnings, list-todos, loop, milestone, package-legitimacy, phase, phase-plan-index, phases, profile-questionnaire, ' + + 'capability, classify-confidence, git, learnings, list-todos, loop, milestone, package-legitimacy, phase, phase-plan-index, phases, profile-questionnaire, ' + 'profile-sample, progress, prompt-budget, requirements, research-plan, research-store, resolve-granularity, resolve-model, roadmap, scaffold, state, ' + 'task, template, user-story, validate, verify, verify-path-exists, verify-summary, workstream, worktree\n\n' + 'Global flags:\n' + @@ -1948,6 +1949,23 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand // Each slot must be non-empty and contain non-whitespace content. // // No .planning/ access needed — pure string validation. + + // #1146: single base-branch resolver for all forking workflows. + // Workflows call `gsd_run query git.base-branch` (dotted form normalised to + // command='git', args=['git','base-branch']). + case 'git': { + const subcommand = args[1]; + if (subcommand !== 'base-branch') { + error( + `Unknown git subcommand: ${subcommand || '(none)'}. Available: base-branch`, + ERROR_REASON.SDK_UNKNOWN_COMMAND, + ); + break; + } + cmdGitBaseBranch(cwd, args.slice(2)); + break; + } + case 'user-story': { const subcommand = args[1]; if (subcommand !== 'validate') { diff --git a/gsd-core/bin/lib/git-base-branch.cjs b/gsd-core/bin/lib/git-base-branch.cjs new file mode 100644 index 000000000..a564379d7 --- /dev/null +++ b/gsd-core/bin/lib/git-base-branch.cjs @@ -0,0 +1,194 @@ +"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.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'; +} +// ─── 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; +} diff --git a/gsd-core/workflows/complete-milestone.md b/gsd-core/workflows/complete-milestone.md index 28234fea9..7abf9db13 100644 --- a/gsd-core/workflows/complete-milestone.md +++ b/gsd-core/workflows/complete-milestone.md @@ -612,11 +612,7 @@ Extract `branching_strategy`, `phase_branch_template`, `milestone_branch_templat Detect base branch: ```bash -BASE_BRANCH=$(gsd_run query config-get git.base_branch 2>/dev/null || echo "") -if [ -z "$BASE_BRANCH" ] || [ "$BASE_BRANCH" = "null" ]; then - BASE_BRANCH=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|^refs/remotes/origin/||') - BASE_BRANCH="${BASE_BRANCH:-main}" -fi +BASE_BRANCH=$(gsd_run query git.base-branch) ``` **If "none":** Skip to git_tag. diff --git a/gsd-core/workflows/execute-phase.md b/gsd-core/workflows/execute-phase.md index 1bff4bb24..452f24454 100644 --- a/gsd-core/workflows/execute-phase.md +++ b/gsd-core/workflows/execute-phase.md @@ -296,8 +296,9 @@ Check `branching_strategy` from init: Fork the new phase branch off `origin/HEAD` (the project's default branch), not the current HEAD — otherwise consecutive phases compound and stay unpushed (#2916). If `$BRANCH_NAME` already exists locally, reuse it as-is. ```bash -DEFAULT_BRANCH=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's|^origin/||') -DEFAULT_BRANCH=${DEFAULT_BRANCH:-main} +DEFAULT_BRANCH=$(gsd_run query git.base-branch 2>/dev/null \ + || git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's|^origin/||' \ + || echo main) if git show-ref --verify --quiet "refs/heads/$BRANCH_NAME"; then git switch "$BRANCH_NAME" || { echo "ERROR: Could not switch to existing branch '$BRANCH_NAME'." >&2; exit 1; } diff --git a/gsd-core/workflows/pr-branch.md b/gsd-core/workflows/pr-branch.md index e8582d324..443ebd770 100644 --- a/gsd-core/workflows/pr-branch.md +++ b/gsd-core/workflows/pr-branch.md @@ -10,11 +10,13 @@ Uses git cherry-pick with path filtering to rebuild a clean history. -Parse `$ARGUMENTS` for target branch (default: `main`). +Parse `$ARGUMENTS` for target branch. If no argument is supplied, detect the +default branch via the single resolver (#1146). ```bash +_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi; if [ -n "${CLAUDE_ENV_FILE:-}" ] && [ -n "${GSD_TOOLS:-}" ]; then printf "export PATH='%s':\"\$PATH\"\n" "${GSD_TOOLS%/*}" >> "$CLAUDE_ENV_FILE" 2>/dev/null || true; fi CURRENT_BRANCH=$(git branch --show-current) -TARGET=${1:-main} +TARGET=${1:-$(gsd_run query git.base-branch)} ``` Check preconditions: diff --git a/gsd-core/workflows/quick.md b/gsd-core/workflows/quick.md index b11ec9423..975ef17d8 100644 --- a/gsd-core/workflows/quick.md +++ b/gsd-core/workflows/quick.md @@ -184,8 +184,9 @@ compound on top of each other and stay unpushed (#2916). If `$branch_name` already exists locally, reuse it as-is so resumed work is not rebased. ```bash -DEFAULT_BRANCH=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's|^origin/||') -DEFAULT_BRANCH=${DEFAULT_BRANCH:-main} +DEFAULT_BRANCH=$(gsd_run query git.base-branch 2>/dev/null \ + || git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's|^origin/||' \ + || echo main) if git show-ref --verify --quiet "refs/heads/$branch_name"; then git switch "$branch_name" \ diff --git a/gsd-core/workflows/ship.md b/gsd-core/workflows/ship.md index bbb9c9d8e..ff966789f 100644 --- a/gsd-core/workflows/ship.md +++ b/gsd-core/workflows/ship.md @@ -40,11 +40,7 @@ Extract: `branching_strategy`, `branch_name`. Detect base branch for PRs and merges: ```bash -BASE_BRANCH=$(gsd_run query config-get git.base_branch 2>/dev/null || echo "") -if [ -z "$BASE_BRANCH" ] || [ "$BASE_BRANCH" = "null" ]; then - BASE_BRANCH=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|^refs/remotes/origin/||') - BASE_BRANCH="${BASE_BRANCH:-main}" -fi +BASE_BRANCH=$(gsd_run query git.base-branch) ``` diff --git a/scripts/lint-allow-test-rule-refs.allowlist.json b/scripts/lint-allow-test-rule-refs.allowlist.json index 6916df904..4c09d7fd7 100644 --- a/scripts/lint-allow-test-rule-refs.allowlist.json +++ b/scripts/lint-allow-test-rule-refs.allowlist.json @@ -215,6 +215,8 @@ "tests/forensics.test.cjs :: source-text-is-the-product", "tests/frontmatter-cli.test.cjs :: source-text-is-the-product", "tests/gates-taxonomy.test.cjs :: source-text-is-the-product", + "tests/git-base-branch.test.cjs :: runtime-contract-is-the-product", + "tests/git-base-branch.test.cjs :: runtime-contract-is-the-product — the workflow .md content IS", "tests/gsd-check-update-worker-platform-gate.test.cjs :: structural assertion on spawn-options shape; the behavior", "tests/gsd-researcher-app-aware.test.cjs :: source-text-is-the-product", "tests/gsd-researcher-flow-diagram.test.cjs :: source-text-is-the-product", diff --git a/src/git-base-branch.cts b/src/git-base-branch.cts new file mode 100644 index 000000000..965223520 --- /dev/null +++ b/src/git-base-branch.cts @@ -0,0 +1,218 @@ +/** + * 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. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { execGit as execGitSeam } from './shell-command-projection.cjs'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +type ExecGitFn = ( + args: string[], + opts?: { cwd?: string; env?: Record; timeout?: number } +) => { exitCode: number | null; stdout: string; stderr: string; signal: string | null; error: unknown }; + +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; +} + +// ─── 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. + 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. + */ +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; + } +} + +/** + * Resolve the default/base branch for the repository at `cwd`. + * + * Consults the full precedence ladder and always returns a non-empty string. + * Never throws. + */ +export function resolveBaseBranch( + cwd: string, + deps?: BaseBranchDeps +): string { + const execGit: ExecGitFn = deps?.execGit ?? execGitSeam; + + // Derive .planning dir relative to cwd (mirrors planningDir() in core.cjs) + const planningDir = path.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'; +} + +// ─── 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 = resolveBaseBranch(cwd, deps); + const write = deps?.write ?? ((s: string) => process.stdout.write(s)); + write(branch + '\n'); + return branch; +} diff --git a/tests/git-base-branch.test.cjs b/tests/git-base-branch.test.cjs new file mode 100644 index 000000000..3ebfb4e46 --- /dev/null +++ b/tests/git-base-branch.test.cjs @@ -0,0 +1,261 @@ +'use strict'; +/** + * #1146: git.base-branch resolver — single source of truth for default-branch detection. + * + * Tests: + * A. Config override wins (git.base_branch set → returned as-is, no git calls needed) + * B. origin/HEAD symref resolves → used + * C. origin/HEAD unset but git remote show origin knows HEAD → AUTHORITATIVE fallback + * (key regression: master repo with no origin/HEAD → must return "master", NOT "main") + * D. No origin/HEAD, no remote show, local branch "master" present → returns "master" + * E. No origin/HEAD, no remote show, local branch "main" present → returns "main" + * F. No origin/HEAD, no remote show, no local branches → returns "main" (last resort) + * G. Anti-regression guard: five affected workflows must NOT contain the + * duplicated bare `:-main` / `:-master` fallback pattern that was the root cause. + * They must call `gsd_run query git.base-branch` instead. + * (allow-test-rule: runtime-contract-is-the-product — the workflow .md content IS + * the runtime surface; the absence of the bad pattern is what ships to agents.) + */ + +// allow-test-rule: runtime-contract-is-the-product +// Justification: the workflow .md files ARE the product surface — agents read and +// execute them directly. Guard G asserts that the resolved command appears in all five +// workflows, which requires reading those workflow files. Per TESTING-STANDARDS.md §6. + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { execSync } = require('node:child_process'); + +const { runGsdTools, cleanup } = require('./helpers.cjs'); + +// ─── helpers ────────────────────────────────────────────────────────────────── + +/** + * Create a minimal git repo in a temp dir, optionally setting up a remote + * and local branches. + */ +function createGitRepo(opts = {}) { + const { prefix = 'gsd-1146-', defaultBranch = 'master' } = opts; + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + execSync(`git init -b ${defaultBranch}`, { cwd: dir, stdio: 'pipe' }); + execSync('git config user.email "test@test.com"', { cwd: dir, stdio: 'pipe' }); + execSync('git config user.name "Test"', { cwd: dir, stdio: 'pipe' }); + execSync('git config commit.gpgsign false', { cwd: dir, stdio: 'pipe' }); + // Need at least one commit so branches exist + fs.writeFileSync(path.join(dir, 'README.md'), '# test\n'); + execSync('git add README.md', { cwd: dir, stdio: 'pipe' }); + execSync('git commit -m "init"', { cwd: dir, stdio: 'pipe' }); + return dir; +} + +/** + * Create a .planning dir so gsd-tools resolveProjectRoot doesn't bail. + */ +function addPlanning(dir) { + fs.mkdirSync(path.join(dir, '.planning', 'phases'), { recursive: true }); +} + +/** + * Write a gsd config.json with git.base_branch set. + */ +function setGsdConfig(dir, key, value) { + const cfgDir = path.join(dir, '.planning'); + fs.mkdirSync(cfgDir, { recursive: true }); + const cfgPath = path.join(cfgDir, 'config.json'); + let cfg = {}; + try { cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); } catch (_) { /* new file */ } + // Set nested key (dot notation) + const parts = key.split('.'); + let obj = cfg; + for (let i = 0; i < parts.length - 1; i++) { + if (typeof obj[parts[i]] !== 'object' || obj[parts[i]] === null) obj[parts[i]] = {}; + obj = obj[parts[i]]; + } + obj[parts[parts.length - 1]] = value; + fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + '\n'); +} + +// Paths to the five affected workflow files +const WORKFLOW_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows'); +const AFFECTED_WORKFLOWS = [ + path.join(WORKFLOW_DIR, 'execute-phase.md'), + path.join(WORKFLOW_DIR, 'quick.md'), + path.join(WORKFLOW_DIR, 'ship.md'), + path.join(WORKFLOW_DIR, 'complete-milestone.md'), + path.join(WORKFLOW_DIR, 'pr-branch.md'), +]; + +// ─── Test suite ─────────────────────────────────────────────────────────────── + +describe('#1146: git.base-branch resolver', () => { + + test('A. config override git.base_branch → returned immediately', (t) => { + const dir = createGitRepo({ prefix: 'gsd-1146-a-', defaultBranch: 'master' }); + t.after(() => cleanup(dir)); + addPlanning(dir); + setGsdConfig(dir, 'git.base_branch', 'develop'); + + const result = runGsdTools(['query', 'git.base-branch'], dir); + assert.ok(result.success, `git.base-branch with config override failed:\n${result.error}`); + const branch = result.output.trim(); + assert.strictEqual(branch, 'develop', + `Expected config override 'develop', got: '${branch}'`); + }); + + test('B. origin/HEAD symref resolves → returned', (t) => { + // Create an "origin" bare repo with main branch + const originDir = createGitRepo({ prefix: 'gsd-1146-b-origin-', defaultBranch: 'main' }); + const worktreeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1146-b-wt-')); + t.after(() => { cleanup(originDir); cleanup(worktreeDir); }); + + // Clone from origin — this sets origin/HEAD + execSync(`git clone "${originDir}" "${worktreeDir}"`, { stdio: 'pipe' }); + execSync('git config user.email "test@test.com"', { cwd: worktreeDir, stdio: 'pipe' }); + execSync('git config user.name "Test"', { cwd: worktreeDir, stdio: 'pipe' }); + addPlanning(worktreeDir); + + // Verify origin/HEAD is set (it should be after clone) + const symref = execSync('git symbolic-ref refs/remotes/origin/HEAD', { cwd: worktreeDir, encoding: 'utf8' }).trim(); + assert.ok(symref.includes('origin/main'), `Expected origin/HEAD→origin/main, got: ${symref}`); + + const result = runGsdTools(['query', 'git.base-branch'], worktreeDir); + assert.ok(result.success, `git.base-branch symref test failed:\n${result.error}`); + const branch = result.output.trim(); + assert.strictEqual(branch, 'main', + `Expected 'main' from origin/HEAD, got: '${branch}'`); + }); + + test('C. KEY REGRESSION — master repo, origin/HEAD unset → returns "master" not "main"', (t) => { + // This is the bug: git init + remote add without git remote set-head → no origin/HEAD + // Current code falls back to :-main → wrong. Fixed code uses `git remote show origin`. + const originDir = createGitRepo({ prefix: 'gsd-1146-c-origin-', defaultBranch: 'master' }); + const cloneDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1146-c-clone-')); + t.after(() => { cleanup(originDir); cleanup(cloneDir); }); + + // Manually add remote WITHOUT cloning (so origin/HEAD is never set) + execSync('git init', { cwd: cloneDir, stdio: 'pipe' }); + execSync('git config user.email "test@test.com"', { cwd: cloneDir, stdio: 'pipe' }); + execSync('git config user.name "Test"', { cwd: cloneDir, stdio: 'pipe' }); + execSync('git config commit.gpgsign false', { cwd: cloneDir, stdio: 'pipe' }); + execSync(`git remote add origin "${originDir}"`, { cwd: cloneDir, stdio: 'pipe' }); + execSync('git fetch origin', { cwd: cloneDir, stdio: 'pipe' }); + // Explicitly delete origin/HEAD in case git fetch auto-set it (newer git versions may do this) + try { + execSync('git remote set-head origin --delete', { cwd: cloneDir, stdio: 'pipe' }); + } catch (_) { /* ignore — may not exist */ } + addPlanning(cloneDir); + + // Confirm origin/HEAD is unset + let hasSymref = true; + try { + execSync('git symbolic-ref refs/remotes/origin/HEAD', { cwd: cloneDir, stdio: 'pipe' }); + } catch (_) { + hasSymref = false; + } + assert.strictEqual(hasSymref, false, 'Test setup: origin/HEAD must be unset for this test case'); + + const result = runGsdTools(['query', 'git.base-branch'], cloneDir); + assert.ok(result.success, `git.base-branch regression test failed:\n${result.error}`); + const branch = result.output.trim(); + assert.strictEqual(branch, 'master', + `BUG REGRESSION: master repo with origin/HEAD unset must return 'master', got: '${branch}'`); + }); + + test('D. No remote, local branch "master" present, "main" absent → returns "master"', (t) => { + const dir = createGitRepo({ prefix: 'gsd-1146-d-', defaultBranch: 'master' }); + t.after(() => cleanup(dir)); + addPlanning(dir); + // No remote configured — falls through to local branch detection + + const result = runGsdTools(['query', 'git.base-branch'], dir); + assert.ok(result.success, `git.base-branch local branch test failed:\n${result.error}`); + const branch = result.output.trim(); + assert.strictEqual(branch, 'master', + `Expected 'master' from local branch detection, got: '${branch}'`); + }); + + test('E. No remote, local branch "main" present → returns "main"', (t) => { + const dir = createGitRepo({ prefix: 'gsd-1146-e-', defaultBranch: 'main' }); + t.after(() => cleanup(dir)); + addPlanning(dir); + + const result = runGsdTools(['query', 'git.base-branch'], dir); + assert.ok(result.success, `git.base-branch main branch test failed:\n${result.error}`); + const branch = result.output.trim(); + assert.strictEqual(branch, 'main', + `Expected 'main' from local branch detection, got: '${branch}'`); + }); + + test('F. No remote, no main/master local branch → returns "main" (last resort default)', (t) => { + const dir = createGitRepo({ prefix: 'gsd-1146-f-', defaultBranch: 'develop' }); + t.after(() => cleanup(dir)); + addPlanning(dir); + // Branch named "develop" — neither main nor master + + const result = runGsdTools(['query', 'git.base-branch'], dir); + assert.ok(result.success, `git.base-branch default fallback test failed:\n${result.error}`); + const branch = result.output.trim(); + assert.strictEqual(branch, 'main', + `Expected 'main' as last resort default, got: '${branch}'`); + }); + + test('A2. config override with flat base_branch key (legacy form) → returned immediately', (t) => { + const dir = createGitRepo({ prefix: 'gsd-1146-a2-', defaultBranch: 'master' }); + t.after(() => cleanup(dir)); + addPlanning(dir); + // Write flat base_branch directly to config root (legacy form, not nested under "git") + const cfgPath = require('node:path').join(dir, '.planning', 'config.json'); + require('node:fs').writeFileSync(cfgPath, JSON.stringify({ base_branch: 'release' }, null, 2) + '\n'); + + const result = runGsdTools(['query', 'git.base-branch'], dir); + assert.ok(result.success, `git.base-branch with flat config key failed:\n${result.error}`); + const branch = result.output.trim(); + assert.strictEqual(branch, 'release', + `Expected flat config override 'release', got: '${branch}'`); + }); + + test('H. No remote, both "main" and "master" local branches exist → returns "main" (main wins tie-break)', (t) => { + // Tier-4 tie-break: when both main and master exist locally and no remote info is available, + // "main" wins (documented in tryLocalBranch JSDoc — modern default). + const dir = createGitRepo({ prefix: 'gsd-1146-h-', defaultBranch: 'master' }); + t.after(() => cleanup(dir)); + addPlanning(dir); + // Create a "main" branch alongside the existing "master" + const { execSync: exec } = require('node:child_process'); + exec('git branch main', { cwd: dir, stdio: 'pipe' }); + // No remote configured — falls to tier-4 (local branch existence) + + const result = runGsdTools(['query', 'git.base-branch'], dir); + assert.ok(result.success, `git.base-branch both-branches test failed:\n${result.error}`); + const branch = result.output.trim(); + assert.strictEqual(branch, 'main', + `Expected 'main' to win when both main and master exist locally, got: '${branch}'`); + }); + + test('G. Anti-regression: all five affected workflows use gsd_run query git.base-branch, not bare :-main / :-master', () => { + // The root-cause pattern: DEFAULT_BRANCH=${DEFAULT_BRANCH:-main} or BASE_BRANCH="${BASE_BRANCH:-main}" + // After fix: workflows call gsd_run query git.base-branch and remove the bare fallback. + const BAD_PATTERN = /\$\{(?:DEFAULT_BRANCH|BASE_BRANCH):-(?:main|master)\}/; + const RESOLVER_CALL = /gsd_run query git\.base-branch/; + + for (const wfPath of AFFECTED_WORKFLOWS) { + const name = path.basename(wfPath); + const content = fs.readFileSync(wfPath, 'utf8'); + + assert.ok( + !BAD_PATTERN.test(content), + `${name} still contains the bare :-main/:-master fallback pattern. ` + + 'Must be replaced with gsd_run query git.base-branch (Issue #1146).', + ); + + assert.ok( + RESOLVER_CALL.test(content), + `${name} does not call \`gsd_run query git.base-branch\`. ` + + 'All five affected workflows must delegate to the single resolver (Issue #1146).', + ); + } + }); +}); diff --git a/tests/workflow-size-baseline.json b/tests/workflow-size-baseline.json index 8c70aa6dd..95c1bccad 100644 --- a/tests/workflow-size-baseline.json +++ b/tests/workflow-size-baseline.json @@ -13,7 +13,7 @@ "cleanup.md": 9897, "code-review-fix.md": 23890, "code-review.md": 31602, - "complete-milestone.md": 30228, + "complete-milestone.md": 29987, "debug.md": 13505, "diagnose-issues.md": 12425, "discovery-phase.md": 8651, @@ -24,7 +24,7 @@ "docs-update.md": 54770, "edit-phase.md": 12883, "eval-review.md": 9923, - "execute-phase.md": 92934, + "execute-phase.md": 92961, "execute-plan.md": 29980, "explore.md": 10497, "extract-learnings.md": 12849, @@ -54,10 +54,10 @@ "plan-phase.md": 92120, "plan-review-convergence.md": 22949, "plant-seed.md": 11741, - "pr-branch.md": 4994, + "pr-branch.md": 9561, "profile-user.md": 20650, "progress.md": 29387, - "quick.md": 46412, + "quick.md": 46439, "reapply-patches.md": 20393, "remove-phase.md": 8469, "remove-workspace.md": 7507, @@ -69,7 +69,7 @@ "settings-advanced.md": 39621, "settings-integrations.md": 15801, "settings.md": 32133, - "ship.md": 24629, + "ship.md": 24388, "sketch-wrap-up.md": 14223, "sketch.md": 19960, "spec-phase.md": 23094,