fix(#1146): single base-branch resolver across forking workflows (#1198)

* 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:
Tom Boucher
2026-06-14 08:22:40 -04:00
committed by GitHub
parent a375c4b354
commit e9f9ae49c8
16 changed files with 723 additions and 22 deletions

View 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)

View File

@@ -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`.

View File

@@ -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",

View File

@@ -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` |

View File

@@ -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',
],
},

View File

@@ -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') {

View 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;
}

View File

@@ -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.

View File

@@ -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; }

View File

@@ -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:

View File

@@ -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" \

View File

@@ -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>

View File

@@ -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
View 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;
}

View 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).',
);
}
});
});

View File

@@ -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,