From 4ddd2f317f2d08fd4b72cc00ac69439486de48b6 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Wed, 3 Jun 2026 14:06:30 +0200 Subject: [PATCH] feat(git): detect repo default branch instead of hardcoded dev fallback resolveBaseBranch / resolveFinishBaseBranch fell back to the hardcoded DEFAULT_BASE_BRANCH ('dev') when no base was given or configured. On repos whose real default is 'main' (or 'master'), the finish/PR flow then targeted a non-existent 'dev', breaking PR creation and merges. - Add detectDefaultBaseBranch(repoRoot): prefer the remote symbolic origin/HEAD, then the first existing branch among main/master/dev (local or origin), then fall back to DEFAULT_BASE_BRANCH. Export it. - Wire it as the final fallback in resolveBaseBranch and resolveFinishBaseBranch. - resolveFinishBaseBranch now honors per-branch branch..guardexBase (recorded at branch-start) before the repo-wide configured base. - Add test/git-base-branch.test.js (precedence + fallback + explicit-base). Detection uses read-only git probes (symbolic-ref, show-ref). Behavior changes only on the no-config fallback path; explicit --base and configured base unchanged. Verification: node --test 573 pass / 34 fail (baseline unchanged, +5 new passing); openspec validate --specs + change --strict pass. --- .../.openspec.yaml | 2 + .../proposal.md | 15 +++++ .../spec.md | 20 +++++++ .../tasks.md | 34 +++++++++++ src/git/index.js | 58 ++++++++++++++++--- test/git-base-branch.test.js | 37 ++++++++++++ 6 files changed, 159 insertions(+), 7 deletions(-) create mode 100644 openspec/changes/agent-claude-land-detectdefaultbasebranch-base-branch-2026-06-03-13-55/.openspec.yaml create mode 100644 openspec/changes/agent-claude-land-detectdefaultbasebranch-base-branch-2026-06-03-13-55/proposal.md create mode 100644 openspec/changes/agent-claude-land-detectdefaultbasebranch-base-branch-2026-06-03-13-55/specs/land-detectdefaultbasebranch-base-branch-detection/spec.md create mode 100644 openspec/changes/agent-claude-land-detectdefaultbasebranch-base-branch-2026-06-03-13-55/tasks.md create mode 100644 test/git-base-branch.test.js diff --git a/openspec/changes/agent-claude-land-detectdefaultbasebranch-base-branch-2026-06-03-13-55/.openspec.yaml b/openspec/changes/agent-claude-land-detectdefaultbasebranch-base-branch-2026-06-03-13-55/.openspec.yaml new file mode 100644 index 00000000..0ba725fb --- /dev/null +++ b/openspec/changes/agent-claude-land-detectdefaultbasebranch-base-branch-2026-06-03-13-55/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-03 diff --git a/openspec/changes/agent-claude-land-detectdefaultbasebranch-base-branch-2026-06-03-13-55/proposal.md b/openspec/changes/agent-claude-land-detectdefaultbasebranch-base-branch-2026-06-03-13-55/proposal.md new file mode 100644 index 00000000..cd09dd56 --- /dev/null +++ b/openspec/changes/agent-claude-land-detectdefaultbasebranch-base-branch-2026-06-03-13-55/proposal.md @@ -0,0 +1,15 @@ +## Why + +- `resolveBaseBranch` / `resolveFinishBaseBranch` fell back to the hardcoded `DEFAULT_BASE_BRANCH` (`dev`) when no base was explicitly given or configured. On repos whose real default is `main` (or `master`), the finish/PR flow then targeted a non-existent `dev`, breaking PR creation and merges. + +## What Changes + +- Add `detectDefaultBaseBranch(repoRoot)`: prefer the remote's symbolic `origin/HEAD`, then the first existing branch among `main` / `master` / `dev` (local or on origin), then fall back to `DEFAULT_BASE_BRANCH`. +- Wire it as the final fallback in `resolveBaseBranch` and `resolveFinishBaseBranch`. +- `resolveFinishBaseBranch` now honors the per-branch `branch..guardexBase` (recorded at branch-start) before the repo-wide configured base. +- Export `detectDefaultBaseBranch`; add focused tests (`test/git-base-branch.test.js`). + +## Impact + +- Surface: `src/git/index.js` base-branch resolution (used by finish / PR / inspect flows). Behavior changes ONLY on the no-config fallback path; explicit `--base` and configured base are unchanged. +- Risk: low. Zero new test failures vs base; new precedence covered by tests. Detection uses read-only git probes (`symbolic-ref`, `show-ref`). diff --git a/openspec/changes/agent-claude-land-detectdefaultbasebranch-base-branch-2026-06-03-13-55/specs/land-detectdefaultbasebranch-base-branch-detection/spec.md b/openspec/changes/agent-claude-land-detectdefaultbasebranch-base-branch-2026-06-03-13-55/specs/land-detectdefaultbasebranch-base-branch-detection/spec.md new file mode 100644 index 00000000..96526204 --- /dev/null +++ b/openspec/changes/agent-claude-land-detectdefaultbasebranch-base-branch-2026-06-03-13-55/specs/land-detectdefaultbasebranch-base-branch-detection/spec.md @@ -0,0 +1,20 @@ +## ADDED Requirements + +### Requirement: Default base-branch detection +When no base branch is explicitly provided or configured, the system SHALL detect the repository's real default branch instead of assuming a hardcoded fallback. + +#### Scenario: Remote default via origin/HEAD +- **WHEN** base resolution finds no explicit and no configured base and `origin/HEAD` resolves +- **THEN** the system uses the branch named by `origin/HEAD` + +#### Scenario: First existing conventional branch +- **WHEN** `origin/HEAD` is unset and no base is configured +- **THEN** the system uses the first existing branch among `main`, `master`, `dev` (local or on origin) + +#### Scenario: Hardcoded fallback +- **WHEN** there is no `origin/HEAD`, no configured base, and none of `main` / `master` / `dev` exist +- **THEN** the system falls back to `DEFAULT_BASE_BRANCH` + +#### Scenario: Per-branch finish base takes precedence +- **WHEN** resolving the finish base and `branch..guardexBase` is set for the source branch +- **THEN** that per-branch value is used ahead of the repo-wide configured base and detection diff --git a/openspec/changes/agent-claude-land-detectdefaultbasebranch-base-branch-2026-06-03-13-55/tasks.md b/openspec/changes/agent-claude-land-detectdefaultbasebranch-base-branch-2026-06-03-13-55/tasks.md new file mode 100644 index 00000000..31b4cf59 --- /dev/null +++ b/openspec/changes/agent-claude-land-detectdefaultbasebranch-base-branch-2026-06-03-13-55/tasks.md @@ -0,0 +1,34 @@ +## Definition of Done + +This change is complete only when **all** of the following are true: + +- Every checkbox below is checked. +- The agent branch reaches `MERGED` state on `origin` and the PR URL + state are recorded in the completion handoff. +- If any step blocks (test failure, conflict, ambiguous result), append a `BLOCKED:` line under section 4 explaining the blocker and **STOP**. Do not tick remaining cleanup boxes; do not silently skip the cleanup pipeline. + +## Handoff + +- Handoff: change=`agent-claude-land-detectdefaultbasebranch-base-branch-2026-06-03-13-55`; branch=`agent//`; scope=`TODO`; action=`continue this sandbox or finish cleanup after a usage-limit/manual takeover`. +- Copy prompt: Continue `agent-claude-land-detectdefaultbasebranch-base-branch-2026-06-03-13-55` on branch `agent//`. Work inside the existing sandbox, review `openspec/changes/agent-claude-land-detectdefaultbasebranch-base-branch-2026-06-03-13-55/tasks.md`, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent// --base dev --via-pr --wait-for-merge --cleanup`. + +## 1. Specification + +- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-claude-land-detectdefaultbasebranch-base-branch-2026-06-03-13-55`. +- [x] 1.2 Define normative requirements in `specs/land-detectdefaultbasebranch-base-branch-detection/spec.md`. + +## 2. Implementation + +- [x] 2.1 Implement scoped behavior changes. +- [x] 2.2 Add/update focused regression coverage. + +## 3. Verification + +- [x] 3.1 Run targeted project verification commands. +- [x] 3.2 Run `openspec validate agent-claude-land-detectdefaultbasebranch-base-branch-2026-06-03-13-55 --type change --strict`. +- [x] 3.3 Run `openspec validate --specs`. + +## 4. Cleanup (mandatory; run before claiming completion) + +- [ ] 4.1 Run the cleanup pipeline: `gx branch finish --branch agent// --base dev --via-pr --wait-for-merge --cleanup`. This handles commit -> push -> PR create -> merge wait -> worktree prune in one invocation. +- [ ] 4.2 Record the PR URL and final merge state (`MERGED`) in the completion handoff. +- [ ] 4.3 Confirm the sandbox worktree is gone (`git worktree list` no longer shows the agent path; `git branch -a` shows no surviving local/remote refs for the branch). diff --git a/src/git/index.js b/src/git/index.js index 2c02f2f5..feffd270 100644 --- a/src/git/index.js +++ b/src/git/index.js @@ -619,9 +619,44 @@ function readGitConfig(repoRoot, key) { return (result.stdout || '').trim(); } +/** + * Detect the repository's real default branch when nothing is configured. + * + * Prefers the symbolic `origin/HEAD` (what the remote calls its default), + * then the first existing branch among `main`, `master`, `dev` (local or + * on origin), and only then falls back to the hardcoded `DEFAULT_BASE_BRANCH`. + * This stops the finish/PR flow from targeting a non-existent `dev` on repos + * whose base is actually `main`. + * + * @param {string} repoRoot Repo to inspect. + * @returns {string} Detected default base branch name. + */ +function detectDefaultBaseBranch(repoRoot) { + const symbolic = gitRun(repoRoot, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], { + allowFailure: true, + }); + if (symbolic.status === 0) { + const ref = String(symbolic.stdout || '').trim().replace(/^origin\//, ''); + if (ref) { + return ref; + } + } + + for (const candidate of ['main', 'master', 'dev']) { + if ( + gitRefExists(repoRoot, `refs/heads/${candidate}`) || + gitRefExists(repoRoot, `refs/remotes/origin/${candidate}`) + ) { + return candidate; + } + } + + return DEFAULT_BASE_BRANCH; +} + /** * Resolve the base branch to use (explicit CLI value wins; otherwise config - * key `GIT_BASE_BRANCH_KEY`; otherwise `DEFAULT_BASE_BRANCH`). + * key `GIT_BASE_BRANCH_KEY`; otherwise the repo's detected default branch). * * @param {string} repoRoot Repo to inspect. * @param {string} [explicitBase] Value passed on the CLI, if any. @@ -632,7 +667,7 @@ function resolveBaseBranch(repoRoot, explicitBase) { return explicitBase; } const configured = readGitConfig(repoRoot, GIT_BASE_BRANCH_KEY); - return configured || DEFAULT_BASE_BRANCH; + return configured || detectDefaultBaseBranch(repoRoot); } /** @@ -1057,25 +1092,33 @@ function branchExists(repoRoot, branch) { /** * Resolve the base branch for the finish flow: CLI override wins; otherwise - * the configured base; otherwise `DEFAULT_BASE_BRANCH`. The `_sourceBranch` - * parameter is currently unused but reserved for future per-branch policy. + * the per-branch `branch..guardexBase` recorded at branch-start; + * otherwise the repo-wide configured base; otherwise the repo's detected + * default branch. * * @param {string} repoRoot Repo to inspect. - * @param {string} _sourceBranch Source agent branch (unused; reserved). + * @param {string} sourceBranch Source agent branch (used for per-branch base). * @param {string} [explicitBase] CLI override, if any. * @returns {string} Resolved base branch name. */ -function resolveFinishBaseBranch(repoRoot, _sourceBranch, explicitBase) { +function resolveFinishBaseBranch(repoRoot, sourceBranch, explicitBase) { if (explicitBase) { return explicitBase; } + if (sourceBranch) { + const perBranch = readGitConfig(repoRoot, `branch.${sourceBranch}.guardexBase`); + if (perBranch) { + return perBranch; + } + } + const configured = readGitConfig(repoRoot, GIT_BASE_BRANCH_KEY); if (configured) { return configured; } - return DEFAULT_BASE_BRANCH; + return detectDefaultBaseBranch(repoRoot); } /** @@ -1166,6 +1209,7 @@ module.exports = { ensureSubmoduleAutoSync, writeProtectedBranches, readGitConfig, + detectDefaultBaseBranch, resolveBaseBranch, resolveSyncStrategy, currentBranchName, diff --git a/test/git-base-branch.test.js b/test/git-base-branch.test.js new file mode 100644 index 00000000..8ab3cab2 --- /dev/null +++ b/test/git-base-branch.test.js @@ -0,0 +1,37 @@ +const { + test, + assert, + initRepo, + seedCommit, +} = require('./helpers/install-test-helpers'); +const { detectDefaultBaseBranch, resolveBaseBranch } = require('../src/git'); + +test('detectDefaultBaseBranch returns the local default branch when main exists', () => { + const repoDir = initRepo({ branch: 'main' }); + seedCommit(repoDir); + assert.equal(detectDefaultBaseBranch(repoDir), 'main'); +}); + +test('detectDefaultBaseBranch detects master when only master exists', () => { + const repoDir = initRepo({ branch: 'master' }); + seedCommit(repoDir); + assert.equal(detectDefaultBaseBranch(repoDir), 'master'); +}); + +test('detectDefaultBaseBranch falls back to DEFAULT_BASE_BRANCH (dev) when no main/master/dev and no origin', () => { + const repoDir = initRepo({ branch: 'wip-feature' }); + seedCommit(repoDir); + assert.equal(detectDefaultBaseBranch(repoDir), 'dev'); +}); + +test('resolveBaseBranch uses the detected default when nothing is configured', () => { + const repoDir = initRepo({ branch: 'main' }); + seedCommit(repoDir); + assert.equal(resolveBaseBranch(repoDir), 'main'); +}); + +test('resolveBaseBranch honors an explicit base over detection', () => { + const repoDir = initRepo({ branch: 'main' }); + seedCommit(repoDir); + assert.equal(resolveBaseBranch(repoDir, 'release/1.x'), 'release/1.x'); +});