fix(workflows): assert HEAD on per-agent branch before worktree commits (#2924) (#2941)

* fix(workflows): assert HEAD on per-agent branch before worktree commits

Worktree-mode setup could leave HEAD attached to a protected branch (master),
causing agent commits to land there. The previous response was a destructive
self-recovery via 'git update-ref refs/heads/master <sha>', which silently
rewinds the protected branch and destroys concurrent commits in multi-active
scenarios (parallel agents, user committing while agent runs).

- Reorder <worktree_branch_check> in execute-phase.md and quick.md to assert
  HEAD via 'git symbolic-ref' BEFORE any 'git reset --hard'. HALT with a
  blocker if HEAD is on main/master/develop/trunk/release/* or detached.
- Add a per-commit HEAD assertion (step 0) to gsd-executor.md
  <task_commit_protocol>; HEAD attachment can drift after 'git checkout <sha>'.
- Forbid 'git update-ref refs/heads/<protected>' in
  <destructive_git_prohibition>; surface the blocker rather than self-heal.
- Remove '--no-verify' as the worktree-mode default in execute-phase.md,
  execute-plan.md, quick.md, and references/git-integration.md. Hooks now
  run on every executor commit; opt out only via workflow.worktree_skip_hooks.
- Add regression test that parses the worktree_branch_check blocks structurally
  and asserts the symbolic-ref check precedes the reset --hard, no workflow
  performs update-ref on a protected ref, and --no-verify is no longer the
  default in any parallel-execution prompt.

* fix(#2924): address CodeRabbit review findings on worktree HEAD PR

- Add positive worktree-agent-* allow-list to <task_commit_protocol> step 0
  in gsd-executor.md and to <worktree_branch_check> in execute-phase.md and
  quick.md. The deny-list (main|master|develop|trunk|release/*) silently
  allowed feature/* and other arbitrary branches outside the agent namespace.
- Register workflow.worktree_skip_hooks in both config schemas
  (sdk/src/query/config-schema.ts and get-shit-done/bin/lib/config-schema.cjs)
  and document it in docs/CONFIGURATION.md so config-set accepts it.
- Fix stash lifecycle in execute-phase.md post-wave hook validation: stash
  under a named ref and pop after the hook run; warn on pop failure.
- Pre-dispatch PLAN.md commit in quick.md: gate on git diff --cached --quiet
  for idempotency and exit 1 with a clear error on commit failure (both the
  --no-verify and the normal branches) — no more swallowing real errors.
- Test fixes (tests/bug-2924-worktree-head-attachment.test.cjs):
  - Parse the protected-branch alternation structurally and require
    main, master, develop, trunk, release/.* (release/* was previously
    skipped by the \\b...\\b regex).
  - Use fs.readdirSync(dir, { recursive: true }) so workflows in nested
    subdirectories are also asserted against the update-ref ban.
  - Add allow-list assertions for execute-phase.md, quick.md, and
    gsd-executor.md to lock in the new positive namespace check.

* test(#2924): assert sub-section end marker exists before slicing

* test(#2924): use section boundary instead of fixed window for parallel-agents slice
This commit is contained in:
Tom Boucher
2026-05-01 09:23:02 -04:00
committed by GitHub
parent 2cc8796265
commit 8de8acee46
9 changed files with 583 additions and 43 deletions

View File

@@ -358,6 +358,30 @@ If RED or GREEN gate commits are missing, add a warning to SUMMARY.md under a `#
<task_commit_protocol>
After each task completes (verification passed, done criteria met), commit immediately.
**0. Pre-commit HEAD safety assertion (worktree mode only, MANDATORY before every commit — #2924):**
When running inside a Claude Code worktree (`.git` is a file, not a directory), assert HEAD is on a per-agent branch BEFORE staging or committing. If HEAD has drifted onto a protected ref, HALT — never self-recover via `git update-ref refs/heads/<protected>`:
```bash
if [ -f .git ]; then # worktree
HEAD_REF=$(git symbolic-ref --quiet HEAD || echo "DETACHED")
ACTUAL_BRANCH=$(git rev-parse --abbrev-ref HEAD)
# Deny-list: never commit on a protected ref.
if [ "$HEAD_REF" = "DETACHED" ] || \
echo "$ACTUAL_BRANCH" | grep -Eq '^(main|master|develop|trunk|release/.*)$'; then
echo "FATAL: refusing to commit — worktree HEAD is on '$ACTUAL_BRANCH' (expected per-agent branch)." >&2
echo "DO NOT use 'git update-ref' to rewind the protected branch — surface as blocker (#2924)." >&2
exit 1
fi
# Positive allow-list: HEAD must be on the canonical Claude Code worktree-agent
# branch namespace (`worktree-agent-<id>`). This catches feature/* and any other
# arbitrary branch that the deny-list would silently allow (#2924).
if ! echo "$ACTUAL_BRANCH" | grep -Eq '^worktree-agent-[A-Za-z0-9._/-]+$'; then
echo "FATAL: refusing to commit — worktree HEAD '$ACTUAL_BRANCH' is not in the worktree-agent-* namespace." >&2
echo "Agent commits must live on per-agent branches; surface as blocker (#2924)." >&2
exit 1
fi
fi
```
**1. Check modified files:** `git status --short`
**2. Stage task-related files individually** (NEVER `git add .` or `git add -A`):
@@ -426,6 +450,15 @@ back, those deletions appear on the main branch, destroying prior-wave work (#20
- `git rm` on files not explicitly created by the current task
- `git checkout -- .` or `git restore .` (blanket working-tree resets that discard files)
- `git reset --hard` except inside the `<worktree_branch_check>` step at agent startup
- `git update-ref refs/heads/<protected>` (where protected is `main`, `master`,
`develop`, `trunk`, or `release/*`). This is an absolute prohibition (#2924).
If you discover that your worktree HEAD is attached to a protected branch and your
commits landed there, **DO NOT** "recover" by force-rewinding the protected ref —
that silently destroys concurrent commits in multi-active scenarios (parallel
agents, user committing while you run). HALT and surface a blocker. The setup-time
`<worktree_branch_check>` and per-commit `<pre_commit_head_assertion>` are the
correct prevention; if either fails, the workflow MUST stop, not self-heal.
- `git push --force` / `git push -f` to any branch you did not create.
If you need to discard changes to a specific file you modified during this task, use:
```bash

View File

@@ -191,6 +191,7 @@ All workflow toggles follow the **absent = enabled** pattern. If a key is missin
| `workflow.skip_discuss` | boolean | `false` | When `true`, `/gsd-autonomous` bypasses the discuss-phase entirely, writing minimal CONTEXT.md from the ROADMAP phase goal. Useful for projects where developer preferences are fully captured in PROJECT.md/REQUIREMENTS.md. Added in v1.28 |
| `workflow.text_mode` | boolean | `false` | Replaces AskUserQuestion TUI menus with plain-text numbered lists. Required for Claude Code remote sessions (`/rc` mode) where TUI menus don't render. Can also be set per-session with `--text` flag on discuss-phase. Added in v1.28 |
| `workflow.use_worktrees` | boolean | `true` | When `false`, disables git worktree isolation for parallel execution. Users who prefer sequential execution or whose environment does not support worktrees can disable this. Added in v1.31 |
| `workflow.worktree_skip_hooks` | boolean | `false` | When `true`, executor agents in worktree mode pass `--no-verify` (skipping pre-commit hooks) and post-wave hook validation runs against the merged result instead. Opt-in escape hatch for projects whose hooks cannot run in agent worktrees. Default `false` runs hooks on every commit (#2924). |
| `workflow.code_review` | boolean | `true` | Enable `/gsd-code-review` and `/gsd-code-review-fix` commands. When `false`, the commands exit with a configuration gate message. Added in v1.34 |
| `workflow.code_review_depth` | string | `standard` | Default review depth for `/gsd-code-review`: `quick` (pattern-matching only), `standard` (per-file analysis), or `deep` (cross-file with import graphs). Can be overridden per-run with `--depth=`. Added in v1.34 |
| `workflow.plan_bounce` | boolean | `false` | Run external validation script against generated plans. When enabled, the plan-phase orchestrator pipes each PLAN.md through the script specified by `plan_bounce_script` and blocks on non-zero exit. Added in v1.36 |

View File

@@ -26,6 +26,7 @@ const VALID_CONFIG_KEYS = new Set([
'workflow.skip_discuss',
'workflow.auto_prune_state',
'workflow.use_worktrees',
'workflow.worktree_skip_hooks',
'workflow.code_review',
'workflow.code_review_depth',
'workflow.code_review_command',

View File

@@ -62,8 +62,11 @@ gsd-sdk query commit "docs: initialize [project-name] ([N] phases)" --files .pla
Each task gets its own commit immediately after completion.
> **Parallel agents:** When running as a parallel executor (spawned by execute-phase),
> use `--no-verify` on all commits to avoid pre-commit hook lock contention.
> The orchestrator validates hooks once after all agents complete.
> run commits normally — let pre-commit hooks run. Do NOT pass `--no-verify` by default
> (#2924). Hooks should fire on the introducing commit; silent bypass violates project
> CLAUDE.md guidance. If a project explicitly opts out via
> `workflow.worktree_skip_hooks=true`, the orchestrator surfaces that flag in the
> executor prompt; absent that signal, hooks run normally.
```
{type}({phase}-{plan}): {task-name}

View File

@@ -506,40 +506,37 @@ increases monotonically across waves. `{status}` is `complete` (success),
</objective>
<worktree_branch_check>
FIRST ACTION before any other work: verify this worktree's branch is based on the correct commit.
Run:
FIRST ACTION: HEAD assertion MUST run before any reset/checkout. Worktrees
spawned by Claude Code's `isolation="worktree"` use the `worktree-agent-<id>`
namespace. If HEAD is on a protected ref (main/master/develop/trunk/release/*)
or detached, HALT — do NOT self-recover by force-rewinding via `git update-ref`,
that destroys concurrent commits in multi-active scenarios (#2924). Only after
Step 1 passes is `git reset --hard` safe (#2015 — affects all platforms).
```bash
ACTUAL_BASE=$(git merge-base HEAD {EXPECTED_BASE})
```
If `ACTUAL_BASE` != `{EXPECTED_BASE}` (i.e. the worktree branch was created from an older
base such as `main` instead of the feature branch HEAD), hard-reset to the correct base:
```bash
# Safe: this runs before any agent work, so no uncommitted changes to lose
git reset --hard {EXPECTED_BASE}
# Verify correction succeeded
if [ "$(git rev-parse HEAD)" != "{EXPECTED_BASE}" ]; then
echo "ERROR: Could not correct worktree base — aborting to prevent data loss"
HEAD_REF=$(git symbolic-ref --quiet HEAD || echo "DETACHED")
ACTUAL_BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [ "$HEAD_REF" = "DETACHED" ] || echo "$ACTUAL_BRANCH" | grep -Eq '^(main|master|develop|trunk|release/.*)$'; then
echo "FATAL: worktree HEAD on '$ACTUAL_BRANCH' (expected worktree-agent-*); refusing to self-recover via 'git update-ref' (#2924)." >&2
exit 1
fi
if ! echo "$ACTUAL_BRANCH" | grep -Eq '^worktree-agent-[A-Za-z0-9._/-]+$'; then
echo "FATAL: worktree HEAD '$ACTUAL_BRANCH' is not in the worktree-agent-* namespace; refusing to commit (#2924)." >&2
exit 1
fi
ACTUAL_BASE=$(git merge-base HEAD {EXPECTED_BASE})
if [ "$ACTUAL_BASE" != "{EXPECTED_BASE}" ]; then
git reset --hard {EXPECTED_BASE}
[ "$(git rev-parse HEAD)" != "{EXPECTED_BASE}" ] && { echo "ERROR: could not correct worktree base"; exit 1; }
fi
```
`reset --hard` is safe here because this is a fresh worktree with no user changes. It
resets both the HEAD pointer AND the working tree to the correct base commit (#2015).
If `ACTUAL_BASE` == `{EXPECTED_BASE}`: the branch base is correct, proceed immediately.
This check fixes a known issue where `EnterWorktree` creates branches from
`main` instead of the current feature branch HEAD (affects all platforms).
Per-commit HEAD assertion lives in `agents/gsd-executor.md` `<task_commit_protocol>` step 0.
</worktree_branch_check>
<parallel_execution>
You are running as a PARALLEL executor agent in a git worktree.
Use --no-verify on all git commits to avoid pre-commit hook contention
with other agents. The orchestrator validates hooks once after all agents complete.
For `gsd-sdk query commit` (or legacy `gsd-tools.cjs` commit): add --no-verify flag when needed.
For direct git commits: use git commit --no-verify -m "..."
Run `git commit` normally — hooks run by default. Do NOT pass `--no-verify`
unless the orchestrator surfaces `workflow.worktree_skip_hooks=true` in this
prompt; silent bypass violates project CLAUDE.md guidance (#2924).
IMPORTANT: Do NOT modify STATE.md or ROADMAP.md. execute-plan.md
auto-detects worktree mode (`.git` is a file, not a directory) and skips
@@ -656,13 +653,16 @@ increases monotonically across waves. `{status}` is `complete` (success),
**This fallback applies automatically to all runtimes.** Claude Code's Task() normally
returns synchronously, but the fallback ensures resilience if it doesn't.
5. **Post-wave hook validation (parallel mode only):**
When agents committed with `--no-verify`, run pre-commit hooks once after the wave:
5. **Post-wave hook validation (parallel mode only):** Hooks run on every executor commit by default (#2924); this post-wave run only fires when `workflow.worktree_skip_hooks=true` opted out of per-commit hooks:
```bash
# Run project's pre-commit hooks on the current state
git diff --cached --quiet || git stash # stash any unstaged changes
git hook run pre-commit 2>&1 || echo "⚠ Pre-commit hooks failed — review before continuing"
SKIP_HOOKS=$(gsd-sdk query config-get workflow.worktree_skip_hooks 2>/dev/null || echo "false")
if [ "$SKIP_HOOKS" = "true" ]; then
# Stash uncommitted changes under a named ref so we always pop (bare `git stash` strands them on hook/script failure).
STASHED=false
if (! git diff --quiet || ! git diff --cached --quiet) && git stash push -u -m "gsd-post-wave-hook-$$" >/dev/null 2>&1; then STASHED=true; fi
git hook run pre-commit 2>&1 || echo "⚠ Pre-commit hooks failed — review before continuing"
[ "$STASHED" = "true" ] && (git stash pop >/dev/null 2>&1 || echo "⚠ Could not pop gsd-post-wave-hook stash — recover manually")
fi
```
If hooks fail: report the failure and ask "Fix hook issues now?" or "Continue to next wave?"

View File

@@ -81,7 +81,7 @@ Otherwise: Apply checkpoint-based routing below.
| Verify-only | B (segmented) | Segments between checkpoints. After none/human-verify → SUBAGENT. After decision/human-action → MAIN |
| Decision | C (main) | Execute entirely in main context |
**Pattern A:** init_agent_tracking → capture `EXPECTED_BASE=$(git rev-parse HEAD)` → spawn Task(subagent_type="gsd-executor", model=executor_model) with prompt: execute plan at [path], autonomous, all tasks + SUMMARY + commit, follow deviation/auth rules, report: plan name, tasks, SUMMARY path, commit hash → track agent_id → wait → update tracking → report. **Include `isolation="worktree"` only if `workflow.use_worktrees` is not `false`** (read via `config-get workflow.use_worktrees`). **When using `isolation="worktree"`, include a `<worktree_branch_check>` block in the prompt** instructing the executor to run `git merge-base HEAD {EXPECTED_BASE}` and, if the result differs from `{EXPECTED_BASE}`, hard-reset the branch with `git reset --hard {EXPECTED_BASE}` before starting work (safe — runs before any agent work), then verify with `[ "$(git rev-parse HEAD)" != "{EXPECTED_BASE}" ] && exit 1`. This corrects a known issue where `EnterWorktree` creates branches from `main` instead of the feature branch HEAD (affects all platforms).
**Pattern A:** init_agent_tracking → capture `EXPECTED_BASE=$(git rev-parse HEAD)` → spawn Task(subagent_type="gsd-executor", model=executor_model) with prompt: execute plan at [path], autonomous, all tasks + SUMMARY + commit, follow deviation/auth rules, report: plan name, tasks, SUMMARY path, commit hash → track agent_id → wait → update tracking → report. **Include `isolation="worktree"` only if `workflow.use_worktrees` is not `false`** (read via `config-get workflow.use_worktrees`). **When using `isolation="worktree"`, include a `<worktree_branch_check>` block in the prompt** instructing the executor to: (1) FIRST assert `git symbolic-ref HEAD` resolves to a per-agent branch (NOT a protected ref like `main`/`master`/`develop`/`trunk`/`release/*`) and HALT with a blocker if not — never self-recover via `git update-ref refs/heads/<protected>` (#2924); (2) only after that assertion passes, run `git merge-base HEAD {EXPECTED_BASE}` and, if the result differs from `{EXPECTED_BASE}`, hard-reset the branch with `git reset --hard {EXPECTED_BASE}` before starting work, then verify with `[ "$(git rev-parse HEAD)" != "{EXPECTED_BASE}" ] && exit 1`. The HEAD assertion (Step 1) MUST run before any reset/checkout. This corrects a known issue where `EnterWorktree` creates branches from `main` instead of the feature branch HEAD (affects all platforms — #2015) and prevents the destructive HEAD-on-master self-recovery path (#2924).
**Pattern B:** Execute segment-by-segment. Autonomous segments: spawn subagent for assigned tasks only (no SUMMARY/commit). Checkpoints: main context. After all segments: aggregate, create SUMMARY, commit. See segment_execution.
@@ -239,7 +239,12 @@ See `~/.claude/get-shit-done/references/tdd.md` for structure.
Your commits may trigger pre-commit hooks. Auto-fix hooks handle themselves transparently — files get fixed and re-staged automatically.
**If running as a parallel executor agent (spawned by execute-phase):**
Use `--no-verify` on all commits. Pre-commit hooks cause build lock contention when multiple agents commit simultaneously (e.g., cargo lock fights in Rust projects). The orchestrator validates once after all agents complete.
Run commits normally — let pre-commit hooks run. Do NOT use `--no-verify` by default
(#2924). Hooks should run so issues surface at the introducing commit, and silent
bypass violates project CLAUDE.md guidance. If a project explicitly opts out via
`workflow.worktree_skip_hooks=true`, the orchestrator will surface that flag in the
prompt; absent that signal, hooks run normally. If a hook fails, follow the
sequential-mode handling below.
**If running as the sole executor (sequential mode):**
If a commit is BLOCKED by a hook:

View File

@@ -637,7 +637,21 @@ if [ "${USE_WORKTREES}" != "false" ]; then
COMMIT_DOCS=$(gsd-sdk query config-get commit_docs 2>/dev/null || echo "true")
if [ "$COMMIT_DOCS" != "false" ]; then
git add "${QUICK_DIR}/${quick_id}-PLAN.md"
git commit --no-verify -m "docs(${quick_id}): pre-dispatch plan for ${DESCRIPTION}" -- "${QUICK_DIR}/${quick_id}-PLAN.md" || true
# No-op skip if nothing actually staged (idempotent re-runs).
if git diff --cached --quiet -- "${QUICK_DIR}/${quick_id}-PLAN.md"; then
echo "ℹ Pre-dispatch PLAN.md commit skipped (no staged changes)"
else
# Run hooks normally (#2924). If a project opts out via
# workflow.worktree_skip_hooks=true, honor that opt-in only.
SKIP_HOOKS=$(gsd-sdk query config-get workflow.worktree_skip_hooks 2>/dev/null || echo "false")
if [ "$SKIP_HOOKS" = "true" ]; then
git commit --no-verify -m "docs(${quick_id}): pre-dispatch plan for ${DESCRIPTION}" -- "${QUICK_DIR}/${quick_id}-PLAN.md" \
|| { echo "ERROR: pre-dispatch PLAN.md commit failed (--no-verify path). Aborting before executor dispatch." >&2; exit 1; }
else
git commit -m "docs(${quick_id}): pre-dispatch plan for ${DESCRIPTION}" -- "${QUICK_DIR}/${quick_id}-PLAN.md" \
|| { echo "ERROR: pre-dispatch PLAN.md commit failed — likely a pre-commit hook failure. Fix the hook output above (or set workflow.worktree_skip_hooks=true to bypass) and re-run." >&2; exit 1; }
fi
fi
fi
fi
```
@@ -660,12 +674,31 @@ Execute quick task ${quick_id}.
${USE_WORKTREES !== "false" ? `
<worktree_branch_check>
FIRST ACTION before any other work: verify this worktree branch is based on the correct commit.
Run: git merge-base HEAD ${EXPECTED_BASE}
If the result differs from ${EXPECTED_BASE}, hard-reset to the correct base (safe — runs before any agent work):
git reset --hard ${EXPECTED_BASE}
Then verify: if [ "$(git rev-parse HEAD)" != "${EXPECTED_BASE}" ]; then echo "ERROR: Could not correct worktree base"; exit 1; fi
This corrects a known issue where EnterWorktree creates branches from main instead of the feature branch HEAD (affects all platforms).
FIRST ACTION before any other work: verify this worktree's HEAD is bound to a per-agent
branch and that the branch is based on the correct commit.
Step 1 — HEAD attachment assertion (MANDATORY, runs before any reset/commit):
HEAD_REF=$(git symbolic-ref --quiet HEAD || echo "DETACHED")
ACTUAL_BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [ "$HEAD_REF" = "DETACHED" ] || echo "$ACTUAL_BRANCH" | grep -Eq '^(main|master|develop|trunk|release/.*)$'; then
echo "FATAL: worktree HEAD is on '$ACTUAL_BRANCH' (expected per-agent branch like worktree-agent-*)." >&2
echo "Refusing to commit/reset on a protected ref. DO NOT self-recover via 'git update-ref refs/heads/$ACTUAL_BRANCH' — that destroys concurrent work (#2924)." >&2
echo "Aborting before any commits. Surface as a blocker for human review." >&2
exit 1
fi
if ! echo "$ACTUAL_BRANCH" | grep -Eq '^worktree-agent-[A-Za-z0-9._/-]+$'; then
echo "FATAL: worktree HEAD '$ACTUAL_BRANCH' is not in the worktree-agent-* namespace (Claude Code's per-agent worktree branch namespace)." >&2
echo "Refusing to commit; surface as blocker (#2924)." >&2
exit 1
fi
Step 2 — Base correctness (only after Step 1 passes):
Run: git merge-base HEAD ${EXPECTED_BASE}
If the result differs from ${EXPECTED_BASE}, hard-reset to the correct base (safe — Step 1 confirmed HEAD is on a per-agent branch and the worktree is fresh):
git reset --hard ${EXPECTED_BASE}
Then verify: if [ "$(git rev-parse HEAD)" != "${EXPECTED_BASE}" ]; then echo "ERROR: Could not correct worktree base"; exit 1; fi
This corrects a known issue where EnterWorktree creates branches from main instead of the feature branch HEAD (#2015) and prevents the destructive HEAD-on-master self-recovery path (#2924).
</worktree_branch_check>
` : ''}

View File

@@ -28,6 +28,7 @@ export const VALID_CONFIG_KEYS: ReadonlySet<string> = new Set([
'workflow.skip_discuss',
'workflow.auto_prune_state',
'workflow.use_worktrees',
'workflow.worktree_skip_hooks',
'workflow.code_review',
'workflow.code_review_depth',
'workflow.code_review_command',

View File

@@ -0,0 +1,463 @@
/**
* Regression tests for #2924: worktree HEAD attaches to a protected branch
* (master/main) so agent commits land there; the workflow then "self-recovers"
* by force-rewinding the protected branch via `git update-ref refs/heads/master`,
* destroying concurrent work in multi-active scenarios.
*
* Fixes asserted by these tests (parsed structurally — not via raw content
* regex/includes — per project test policy):
*
* 1. The <worktree_branch_check> block in execute-phase.md and quick.md
* contains a HEAD-attachment assertion (symbolic-ref + protected-branch
* check) that runs BEFORE any `git reset --hard`.
* 2. The parallel-execution prompt in execute-phase.md and execute-plan.md
* no longer mandates `--no-verify` as the default for worktree-mode commits.
* 3. gsd-executor.md prohibits `git update-ref refs/heads/<protected>` as a
* "recovery" path and includes a pre-commit HEAD assertion in the task
* commit protocol.
* 4. No workflow file in get-shit-done/workflows/ contains an unconditional
* `git update-ref refs/heads/master` (or main/develop/trunk) call.
*/
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const REPO_ROOT = path.join(__dirname, '..');
const EXECUTE_PHASE_PATH = path.join(REPO_ROOT, 'get-shit-done', 'workflows', 'execute-phase.md');
const EXECUTE_PLAN_PATH = path.join(REPO_ROOT, 'get-shit-done', 'workflows', 'execute-plan.md');
const QUICK_PATH = path.join(REPO_ROOT, 'get-shit-done', 'workflows', 'quick.md');
const EXECUTOR_AGENT_PATH = path.join(REPO_ROOT, 'agents', 'gsd-executor.md');
const GIT_INTEGRATION_PATH = path.join(REPO_ROOT, 'get-shit-done', 'references', 'git-integration.md');
/**
* Extract the inner body of a named XML-like block (e.g. <worktree_branch_check>...</worktree_branch_check>)
* from a markdown document. Returns null when not found.
*/
function extractNamedBlock(markdown, blockName) {
const open = `<${blockName}>`;
const close = `</${blockName}>`;
const start = markdown.indexOf(open);
if (start === -1) return null;
const end = markdown.indexOf(close, start + open.length);
if (end === -1) return null;
return markdown.slice(start + open.length, end);
}
/**
* Extract all fenced code blocks (```...```) from a markdown chunk.
* Returns array of { lang, body } objects.
*/
function extractFencedCodeBlocks(markdown) {
const blocks = [];
const lines = markdown.split('\n');
let inFence = false;
let fenceLang = '';
let buffer = [];
for (const line of lines) {
const trimmed = line.trimStart();
if (trimmed.startsWith('```')) {
if (!inFence) {
inFence = true;
fenceLang = trimmed.slice(3).trim();
buffer = [];
} else {
blocks.push({ lang: fenceLang, body: buffer.join('\n') });
inFence = false;
fenceLang = '';
buffer = [];
}
} else if (inFence) {
buffer.push(line);
}
}
return blocks;
}
/**
* Tokenize a shell-like script into individual statements (split on `;`, `&&`, `||`, newlines)
* and return commands as arrays of word tokens. Handles `$(cmd ...)` command substitution
* and `VAR=$(cmd ...)` assignments by extracting the inner command. This is intentionally
* simple — adequate for asserting on the presence of well-known git invocations.
*/
function shellStatements(script) {
const statements = [];
const lines = script.split('\n');
for (let raw of lines) {
const line = raw.replace(/#.*$/, '').trim();
if (!line) continue;
// Split on shell statement separators
const parts = line.split(/(?:&&|\|\||;)/);
for (const part of parts) {
let trimmed = part.trim();
if (!trimmed) continue;
// Strip leading `VAR=` assignments so the substituted command surfaces as cmd[0].
// Then unwrap `$(...)` command substitution.
const assignMatch = trimmed.match(/^[A-Za-z_][A-Za-z0-9_]*=(.*)$/);
if (assignMatch) trimmed = assignMatch[1];
const subMatch = trimmed.match(/^\$\((.*?)\)?$/);
if (subMatch) trimmed = subMatch[1];
// Also handle leading `$(` without closing paren (paren may have been split off)
if (trimmed.startsWith('$(')) trimmed = trimmed.slice(2);
// Strip trailing closing parens left over from substitution
trimmed = trimmed.replace(/\)+\s*$/, '').trim();
if (!trimmed) continue;
// Strip surrounding quotes on the leading word
statements.push(trimmed.split(/\s+/).filter(Boolean));
}
}
return statements;
}
/**
* Find the line index of the first command matching a predicate.
* Returns -1 when not found.
*/
function findCommandIndex(statements, predicate) {
for (let i = 0; i < statements.length; i++) {
if (predicate(statements[i])) return i;
}
return -1;
}
describe('bug #2924: worktree HEAD attachment + destructive recovery', () => {
describe('execute-phase.md worktree_branch_check', () => {
const content = fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8');
const block = extractNamedBlock(content, 'worktree_branch_check');
test('block exists', () => {
assert.ok(block, 'execute-phase.md must contain a <worktree_branch_check> block');
});
test('block invokes `git symbolic-ref` to inspect HEAD attachment', () => {
const codeBlocks = extractFencedCodeBlocks(block);
const allStatements = codeBlocks.flatMap(({ body }) => shellStatements(body));
const idx = findCommandIndex(allStatements, (cmd) =>
cmd[0] === 'git' && cmd[1] === 'symbolic-ref' && cmd.includes('HEAD')
);
assert.notStrictEqual(
idx, -1,
'worktree_branch_check must run `git symbolic-ref ... HEAD` to verify HEAD attachment before any reset'
);
});
test('HEAD-attachment assertion runs BEFORE `git reset --hard`', () => {
const codeBlocks = extractFencedCodeBlocks(block);
const allStatements = codeBlocks.flatMap(({ body }) => shellStatements(body));
const symbolicRefIdx = findCommandIndex(allStatements, (cmd) =>
cmd[0] === 'git' && cmd[1] === 'symbolic-ref' && cmd.includes('HEAD')
);
const resetHardIdx = findCommandIndex(allStatements, (cmd) =>
cmd[0] === 'git' && cmd[1] === 'reset' && cmd.includes('--hard')
);
assert.notStrictEqual(symbolicRefIdx, -1, 'symbolic-ref check must exist');
assert.notStrictEqual(resetHardIdx, -1, 'reset --hard must exist');
assert.ok(
symbolicRefIdx < resetHardIdx,
'HEAD attachment assertion (symbolic-ref) must precede `git reset --hard` so a stale HEAD never moves a protected branch'
);
});
test('block names protected branches that must NOT be the agent branch', () => {
// The protected-branch list must be enforced by name. Parse it out of the
// shell scripts and verify required names are present.
const codeBlocks = extractFencedCodeBlocks(block);
const scripts = codeBlocks.map(({ body }) => body).join('\n');
// Look for an assignment whose value is a regex/list naming protected refs.
// Acceptable forms: PROTECTED_BRANCHES_RE='...' or grep -Eq '^(main|...)$'
// Parse the alternation list out of the grep -E pattern so we assert
// structurally on the protected-branch enumeration rather than via
// raw substring matching (release/* contains regex-special chars and
// can't be safely tested with `\b...\b`).
const altMatch = scripts.match(/grep\s+-Eq?\s+'\^\(([^)]+)\)\$'/);
assert.ok(
altMatch,
'worktree_branch_check must contain a `grep -Eq` protected-branch alternation pattern'
);
const branches = altMatch[1].split('|').map((b) => b.trim());
const required = ['main', 'master', 'develop', 'trunk', 'release/.*'];
for (const name of required) {
assert.ok(
branches.includes(name),
`worktree_branch_check protected-branch alternation must include '${name}' (found: ${branches.join(', ')})`
);
}
});
test('block enforces positive worktree-agent-* allow-list (#2924 hardening)', () => {
const codeBlocks = extractFencedCodeBlocks(block);
const scripts = codeBlocks.map(({ body }) => body).join('\n');
// Allow-list must reference the canonical Claude Code worktree-agent-<id>
// namespace via a regex assertion (grep -Eq '^worktree-agent-...').
const allowListRe = /grep\s+-Eq?\s+'\^worktree-agent-/;
assert.ok(
allowListRe.test(scripts),
'worktree_branch_check must enforce a positive allow-list matching ^worktree-agent-* (#2924 hardening)'
);
});
test('block forbids `git update-ref` self-recovery in its guidance text', () => {
// The forbidding statement is documentation text, not a shell command,
// so structural shell parsing does not apply. Verify the prohibition
// appears as standalone guidance somewhere in the block.
assert.ok(
block.includes('update-ref'),
'worktree_branch_check must explicitly forbid `git update-ref` self-recovery'
);
});
});
describe('execute-phase.md no longer defaults to --no-verify in parallel mode', () => {
const content = fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8');
const block = extractNamedBlock(content, 'parallel_execution');
test('parallel_execution block exists', () => {
assert.ok(block, 'execute-phase.md must contain a <parallel_execution> block');
});
test('parallel_execution does NOT instruct agents to use --no-verify by default', () => {
// Tokenize the block as plain words and look for an unconditional
// imperative naming `--no-verify`. The acceptable presence is in a
// negated/opt-out context (e.g. "Do NOT pass --no-verify"); reject
// any sentence whose first verb is "Use --no-verify".
const sentences = block
.replace(/\n+/g, ' ')
.split(/(?<=[.!?])\s+/);
for (const sentence of sentences) {
if (!sentence.includes('--no-verify')) continue;
const lower = sentence.toLowerCase();
const isProhibition =
/\b(do not|don't|never|no longer)\b/.test(lower) ||
/\bopt[\s-]?out\b/.test(lower) ||
/\bopt[\s-]?in\b/.test(lower) ||
/\bif\b/.test(lower);
assert.ok(
isProhibition,
`parallel_execution sentence appears to mandate --no-verify by default: "${sentence.trim()}"`
);
}
});
});
describe('execute-plan.md no longer mandates --no-verify for parallel executor', () => {
const content = fs.readFileSync(EXECUTE_PLAN_PATH, 'utf-8');
const block = extractNamedBlock(content, 'precommit_failure_handling');
test('precommit_failure_handling block exists', () => {
assert.ok(block, 'execute-plan.md must contain a <precommit_failure_handling> block');
});
test('parallel-executor sub-section does not unconditionally mandate --no-verify', () => {
// Locate the parallel-executor sub-section heading and parse the
// sentences under it.
const headingIdx = block.indexOf('parallel executor');
assert.notStrictEqual(headingIdx, -1, 'must contain a parallel-executor sub-section');
const endIdx = block.indexOf('**If running as the sole', headingIdx);
assert.notStrictEqual(endIdx, -1, 'parallel-executor sub-section terminator must exist');
const subBlock = block.slice(headingIdx, endIdx);
assert.ok(subBlock.length > 0, 'sub-section must have content');
const sentences = subBlock.replace(/\n+/g, ' ').split(/(?<=[.!?])\s+/);
for (const sentence of sentences) {
if (!sentence.includes('--no-verify')) continue;
const lower = sentence.toLowerCase();
const isProhibition =
/\b(do not|don't|never|no longer)\b/.test(lower) ||
/\bopt[\s-]?out\b/.test(lower) ||
/\bopt[\s-]?in\b/.test(lower) ||
/\bif\b/.test(lower);
assert.ok(
isProhibition,
`parallel-executor guidance sentence appears to mandate --no-verify: "${sentence.trim()}"`
);
}
});
});
describe('quick.md worktree_branch_check', () => {
const content = fs.readFileSync(QUICK_PATH, 'utf-8');
const block = extractNamedBlock(content, 'worktree_branch_check');
test('block exists', () => {
assert.ok(block, 'quick.md must contain a <worktree_branch_check> block');
});
test('block references `git symbolic-ref` for HEAD attachment assertion', () => {
// quick.md uses inline `git symbolic-ref ... HEAD` rather than a fenced
// block, so search the block as a token stream of statements.
const statements = shellStatements(block);
const idx = findCommandIndex(statements, (cmd) =>
cmd[0] === 'git' && cmd[1] === 'symbolic-ref' && cmd.includes('HEAD')
);
assert.notStrictEqual(
idx, -1,
'quick.md worktree_branch_check must run `git symbolic-ref ... HEAD`'
);
});
test('HEAD assertion precedes `git reset --hard`', () => {
const symbolicRefByteIdx = block.indexOf('symbolic-ref');
const resetHardByteIdx = block.indexOf('reset --hard');
assert.notStrictEqual(symbolicRefByteIdx, -1);
assert.notStrictEqual(resetHardByteIdx, -1);
assert.ok(
symbolicRefByteIdx < resetHardByteIdx,
'symbolic-ref HEAD assertion must appear before `git reset --hard` in quick.md worktree_branch_check'
);
});
test('block forbids `git update-ref` self-recovery', () => {
assert.ok(
block.includes('update-ref'),
'quick.md worktree_branch_check must explicitly forbid `git update-ref` self-recovery'
);
});
test('block enforces positive worktree-agent-* allow-list (#2924 hardening)', () => {
const allowListRe = /grep\s+-Eq?\s+'\^worktree-agent-/;
assert.ok(
allowListRe.test(block),
'quick.md worktree_branch_check must enforce a positive allow-list matching ^worktree-agent-* (#2924 hardening)'
);
});
});
describe('quick.md pre-dispatch plan commit no longer hard-codes --no-verify', () => {
const content = fs.readFileSync(QUICK_PATH, 'utf-8');
const codeBlocks = extractFencedCodeBlocks(content);
// Find the bash block containing the pre-dispatch plan commit
const target = codeBlocks.find(({ body }) =>
body.includes('pre-dispatch plan') && body.includes('git commit')
);
test('pre-dispatch plan commit block exists', () => {
assert.ok(target, 'quick.md must contain the pre-dispatch plan commit block');
});
test('pre-dispatch plan commit gates --no-verify behind a config flag', () => {
// The block must contain BOTH a `git commit` without --no-verify AND
// gate any --no-verify variant inside an `if` block reading a config
// value (workflow.worktree_skip_hooks).
const statements = shellStatements(target.body);
const noVerifyCommits = statements.filter((cmd) =>
cmd[0] === 'git' && cmd[1] === 'commit' && cmd.includes('--no-verify')
);
const cleanCommits = statements.filter((cmd) =>
cmd[0] === 'git' && cmd[1] === 'commit' && !cmd.includes('--no-verify')
);
assert.ok(
cleanCommits.length >= 1,
'must include at least one `git commit` without --no-verify (default path)'
);
// If --no-verify still appears, the block must reference the opt-in flag.
if (noVerifyCommits.length > 0) {
assert.ok(
target.body.includes('worktree_skip_hooks'),
'--no-verify commits must be gated behind workflow.worktree_skip_hooks config flag'
);
}
});
});
describe('gsd-executor.md prohibits update-ref self-recovery', () => {
const content = fs.readFileSync(EXECUTOR_AGENT_PATH, 'utf-8');
const block = extractNamedBlock(content, 'destructive_git_prohibition');
test('destructive_git_prohibition block exists', () => {
assert.ok(block, 'gsd-executor.md must contain a <destructive_git_prohibition> block');
});
test('block prohibits `git update-ref refs/heads/<protected>`', () => {
assert.ok(
block.includes('update-ref'),
'destructive_git_prohibition must enumerate `git update-ref` as a prohibited command'
);
assert.ok(
block.includes('protected') || block.includes('main') || block.includes('master'),
'destructive_git_prohibition must call out protected branches in the update-ref prohibition'
);
});
test('block references issue #2924', () => {
assert.ok(
block.includes('#2924'),
'destructive_git_prohibition should cite #2924 as the source of the update-ref prohibition'
);
});
});
describe('gsd-executor.md task_commit_protocol enforces worktree-agent-* allow-list', () => {
const content = fs.readFileSync(EXECUTOR_AGENT_PATH, 'utf-8');
const block = extractNamedBlock(content, 'task_commit_protocol');
test('task_commit_protocol block exists', () => {
assert.ok(block, 'gsd-executor.md must contain a <task_commit_protocol> block');
});
test('step 0 enforces positive worktree-agent-* allow-list (#2924 hardening)', () => {
const codeBlocks = extractFencedCodeBlocks(block);
const scripts = codeBlocks.map(({ body }) => body).join('\n');
const allowListRe = /grep\s+-Eq?\s+'\^worktree-agent-/;
assert.ok(
allowListRe.test(scripts),
'task_commit_protocol step 0 must enforce a positive allow-list matching ^worktree-agent-* in addition to the protected-ref deny-list (#2924 hardening)'
);
});
});
describe('no workflow file performs unconditional update-ref on a protected branch', () => {
const workflowsDir = path.join(REPO_ROOT, 'get-shit-done', 'workflows');
const workflowFiles = fs
.readdirSync(workflowsDir, { recursive: true })
.filter((f) => typeof f === 'string' && f.endsWith('.md'))
.map((f) => path.join(workflowsDir, f));
for (const filePath of workflowFiles) {
test(`${path.basename(filePath)} contains no update-ref of a protected ref`, () => {
const content = fs.readFileSync(filePath, 'utf-8');
const blocks = extractFencedCodeBlocks(content);
for (const { body } of blocks) {
const statements = shellStatements(body);
for (const cmd of statements) {
if (cmd[0] !== 'git') continue;
if (cmd[1] !== 'update-ref') continue;
// Reject any update-ref that targets a protected ref.
const target = cmd[2] || '';
const protectedRe = /^refs\/heads\/(main|master|develop|trunk|release\/.+)$/;
assert.ok(
!protectedRe.test(target),
`${path.basename(filePath)} contains forbidden 'git update-ref ${target}' (#2924)`
);
}
}
});
}
});
describe('git-integration.md guidance reflects new default', () => {
const content = fs.readFileSync(GIT_INTEGRATION_PATH, 'utf-8');
test('parallel-agents guidance no longer mandates --no-verify', () => {
// Find the parallel-agents callout and parse its sentences.
const idx = content.indexOf('Parallel agents');
assert.notStrictEqual(idx, -1, 'must contain a "Parallel agents" callout');
const section = content.slice(idx);
const endMatch = section.slice(1).match(/\n#{1,6}\s/);
assert.ok(endMatch, 'Parallel agents section must terminate at the next heading');
const tail = section.slice(0, 1 + endMatch.index);
const sentences = tail.replace(/\n+/g, ' ').split(/(?<=[.!?])\s+/);
for (const sentence of sentences) {
if (!sentence.includes('--no-verify')) continue;
const lower = sentence.toLowerCase();
const isProhibition =
/\b(do not|don't|never|no longer)\b/.test(lower) ||
/\bopt[\s-]?out\b/.test(lower) ||
/\bopt[\s-]?in\b/.test(lower) ||
/\bif\b/.test(lower);
assert.ok(
isProhibition,
`git-integration.md "Parallel agents" sentence appears to mandate --no-verify: "${sentence.trim()}"`
);
}
});
});
});