Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-03
Original file line number Diff line number Diff line change
@@ -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.<source>.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`).
Original file line number Diff line number Diff line change
@@ -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.<source>.guardexBase` is set for the source branch
- **THEN** that per-branch value is used ahead of the repo-wide configured base and detection
Original file line number Diff line number Diff line change
@@ -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/<your-name>/<branch-slug>`; 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/<your-name>/<branch-slug>`. 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/<your-name>/<branch-slug> --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/<your-name>/<branch-slug> --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).
58 changes: 51 additions & 7 deletions src/git/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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);
}

/**
Expand Down Expand Up @@ -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.<source>.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);
}

/**
Expand Down Expand Up @@ -1166,6 +1209,7 @@ module.exports = {
ensureSubmoduleAutoSync,
writeProtectedBranches,
readGitConfig,
detectDefaultBaseBranch,
resolveBaseBranch,
resolveSyncStrategy,
currentBranchName,
Expand Down
37 changes: 37 additions & 0 deletions test/git-base-branch.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
Loading