* 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) <noreply@anthropic.com> * docs(changeset): backfill PR number #1198 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * test(#1146): allowlist workflow-literal guard as runtime-contract exemption Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
5
.changeset/brave-foxes-leap.md
Normal file
5
.changeset/brave-foxes-leap.md
Normal file
@@ -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)
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@@ -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') {
|
||||
|
||||
194
gsd-core/bin/lib/git-base-branch.cjs
Normal file
194
gsd-core/bin/lib/git-base-branch.cjs
Normal file
@@ -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: <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';
|
||||
}
|
||||
// ─── 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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -10,11 +10,13 @@ Uses git cherry-pick with path filtering to rebuild a clean history.
|
||||
<process>
|
||||
|
||||
<step name="detect_state">
|
||||
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:
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
</step>
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
218
src/git-base-branch.cts
Normal file
218
src/git-base-branch.cts
Normal file
@@ -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<string, string>; 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<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.
|
||||
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;
|
||||
}
|
||||
261
tests/git-base-branch.test.cjs
Normal file
261
tests/git-base-branch.test.cjs
Normal file
@@ -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).',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user