fix(#3819): widen executor's pre-commit guard beyond worktree mode (#4343)

* fix(#3819): widen executor's pre-commit guard beyond worktree mode

The pre-commit protected-branch assertion in the executor agent (#2924)
only fired inside a Claude Code worktree and matched a hardcoded
five-name branch list. It never ran in an ordinary checkout and never
covered this repo's own default branch ("next"), so gsd-executor could
commit planning-repo documents directly onto a shared checkout's
default branch with no PR ever created.

Widen the guard to run in every isolation mode, and resolve the
protected branch via the repository's actual default branch (with the
existing five-name list retained as a fallback when the resolver
itself cannot be invoked) plus any configured git.protected_branches.
Add a git.allow_default_branch_commits escape hatch for projects that
intentionally execute on their default branch. Also point the
separate <final_commit> commit helper back at the same guard, so it
cannot be sidestepped by that path.

Emitted-Drift-Ack-Growth: gsd-executor.md — widened pre-commit protected-branch guard (#3819); tightened comments to stay under the size cap.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#3819): backfill changeset PR number

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-09-05 18:35:42 -04:00
committed by GitHub
parent 4e1c449281
commit c20675cc4d
8 changed files with 425 additions and 16 deletions

View File

@@ -902,6 +902,7 @@ function loadConfigResolved(cwd: string, options: Record<string, unknown> = {}):
branching_strategy: get('branching_strategy', { section: 'git', field: 'branching_strategy' }) ?? defaults.branching_strategy,
base_branch: get('base_branch', { section: 'git', field: 'base_branch' }),
protected_branches: getNested('git', 'protected_branches'),
allow_default_branch_commits: getNested('git', 'allow_default_branch_commits'),
phase_branch_template: get('phase_branch_template', { section: 'git', field: 'phase_branch_template' }) ?? defaults.phase_branch_template,
milestone_branch_template: get('milestone_branch_template', { section: 'git', field: 'milestone_branch_template' }) ?? defaults.milestone_branch_template,
quick_branch_template: get('quick_branch_template', { section: 'git', field: 'quick_branch_template' }) ?? defaults.quick_branch_template,

View File

@@ -77,6 +77,15 @@ interface EffectiveGitConfig {
protectedBranches: string[];
/** Rendered form of every entry rejected as unusable, for the caller to report. */
rejectedProtectedBranches: string[];
/**
* `git.allow_default_branch_commits` escape hatch (#3819). When `true`, the
* resolved base branch is no longer auto-added to `protectedBranches` — for
* a project that legitimately runs GSD directly on its default branch.
* Explicitly configured `protected_branches` entries are unaffected: this
* flag narrows only the automatic base-branch protection, never a name the
* project named on purpose.
*/
allowDefaultBranchCommits: boolean;
}
/** Render a rejected config value for a diagnostic without throwing on exotic input. */
@@ -150,6 +159,7 @@ function readEffectiveGitConfig(
config = {
base_branch: _readGitKey(normalized, 'base_branch'),
protected_branches: _readGitNested(normalized, 'protected_branches'),
allow_default_branch_commits: _readGitNested(normalized, 'allow_default_branch_commits'),
};
}
} catch { /* malformed direct edit contributes no policy values */ }
@@ -186,7 +196,11 @@ function readEffectiveGitConfig(
rejectedProtectedBranches.push(renderRejected(rawProtectedBranches));
}
return { baseBranch, protectedBranches, rejectedProtectedBranches };
// #3819: nested-only read (mirrors protected_branches — new key, no legacy
// flat form, so a top-level spelling must not become an undocumented alias).
const allowDefaultBranchCommits = config.allow_default_branch_commits === true;
return { baseBranch, protectedBranches, rejectedProtectedBranches, allowDefaultBranchCommits };
}
/**
@@ -374,6 +388,8 @@ export interface ProtectedBranchStatus {
rejectedProtectedBranches: string[];
isProtected: boolean;
verified: boolean;
/** Mirrors the `git.allow_default_branch_commits` config value (#3819). */
allowDefaultBranchCommits: boolean;
}
/** Resolve the base branch plus configured protected-branch extensions. */
@@ -388,13 +404,17 @@ export function resolveProtectedBranchStatus(
effectiveConfig.baseBranch,
deps,
);
const protectedBranches = [...new Set([baseBranch, ...effectiveConfig.protectedBranches])];
const protectedBranches = [...new Set([
...(effectiveConfig.allowDefaultBranchCommits ? [] : [baseBranch]),
...effectiveConfig.protectedBranches,
])];
return {
baseBranch,
protectedBranches,
rejectedProtectedBranches: effectiveConfig.rejectedProtectedBranches,
isProtected: protectedBranches.includes(currentBranch),
verified,
allowDefaultBranchCommits: effectiveConfig.allowDefaultBranchCommits,
};
}