fix(#1995): widen worktree branch regex to accept agent-<id> namespace (#2548)

* test(#1995): regression test for agent-<id> branch namespace

Add failing-first tests proving that normalizeCleanupManifestEntry and
planWorktreeRecordAgent reject Claude Code's current agent-<id> isolation
branches (only worktree-agent-<id> is accepted). Boundary tests cover both
namespaces plus rejection cases.

* fix(#1995): widen worktree branch regex to accept agent-<id> namespace

Claude Code's isolation="worktree" branch naming changed from
worktree-agent-<id> to agent-<id>. Widen the regex in all 7 locations
from ^worktree-agent-[A-Za-z0-9._/-]+$ to ^(worktree-)?agent-[A-Za-z0-9._/-]+$
so both namespaces are accepted. Introduce a shared WORKTREE_AGENT_BRANCH_RE
constant in src/worktree-safety.cts to prevent future drift.

Closes #1995

* fix(#1995): update workflow guards, test assertions, and baselines

Widen the branch-check regex in execute-phase.md and execute-plan.md.
Update all test assertions that checked for ^worktree-agent- to expect
the widened ^(worktree-)?agent- pattern. Regenerate golden-install-parity
fixtures, agent-size-baseline, and workflow-size-baseline.

Closes #1995

* fix(#1995): update extractCwdGuardBash sanity check for widened regex

The e2e test's sanity check verified the extracted bash block contained
'worktree-agent-'. After widening to '(worktree-)?agent-', update the
check to match the new pattern.

* fix(#1995): widen missed workflow-guard branch check + changeset + lint fixes

- hooks/gsd-workflow-guard.js: widen startsWith('worktree-agent-') to
  /^(worktree-)?agent-/ regex — same defect class, was missed in prior commit
- tests/worktree.test.cjs: fix indentation regression from prior edit
- Add .changeset/1995-worktree-agent-branch-namespace.md (pr:0 placeholder)

Found by orthogonal code review (Step 4).

* fix(#1995): regenerate golden + size baselines for workflow-guard change

* docs(#1995): backfill changeset PR number (2548)
This commit is contained in:
Tom Boucher
2026-07-23 07:36:53 -04:00
committed by GitHub
parent 4fc89497d0
commit 77bf21b3a6
33 changed files with 254 additions and 140 deletions

View File

@@ -662,6 +662,16 @@ describe('planWorktreeRecordAgent', () => {
assert.equal(readBack.reason, 'empty_manifest');
});
test('accepts the current agent-<id> namespace (#1995)', () => {
const plan = planWorktreeRecordAgent('{"worktrees":[]}', { ...VALID, branch: 'agent-a1' });
assert.equal(plan.ok, true);
assert.equal(plan.entry.branch, 'agent-a1');
const readBack = planWorktreeWaveCleanup('/repo/main', JSON.parse(plan.manifest));
assert.equal(readBack.ok, true);
assert.equal(readBack.entries.length, 1);
assert.equal(readBack.entries[0].branch, 'agent-a1');
});
test('fails loudly on malformed manifest JSON instead of clobbering it', () => {
const plan = planWorktreeRecordAgent('{not valid json', VALID);
assert.equal(plan.ok, false);
@@ -749,7 +759,9 @@ describe('planWorktreeRecordAgent', () => {
describe('planWorktreeRecordAgent — fast-check parity invariant (#1298)', () => {
const seg = fc.stringMatching(/^[A-Za-z0-9._/-]+$/); // include '/' — the namespace allows it
const agentBranch = seg.map((s) => `worktree-agent-${s}`);
const legacyAgentBranch = seg.map((s) => `worktree-agent-${s}`);
const currentAgentBranch = seg.map((s) => `agent-${s}`);
const agentBranch = fc.oneof(legacyAgentBranch, currentAgentBranch);
const nonEmpty = fc.stringMatching(/^\S[\S ]*$/); // no leading whitespace, not blank
test('any writer-accepted entry round-trips through the cleanup reader unchanged', () => {
@@ -776,7 +788,7 @@ describe('planWorktreeRecordAgent — fast-check parity invariant (#1298)', () =
agentId: nonEmpty,
worktreePath: nonEmpty,
// Any branch that does NOT match the disposable namespace.
branch: fc.string({ minLength: 1 }).filter((b) => !/^worktree-agent-[A-Za-z0-9._/-]+$/.test(b.trim())),
branch: fc.string({ minLength: 1 }).filter((b) => !/^(worktree-)?agent-[A-Za-z0-9._/-]+$/.test(b.trim())),
base: nonEmpty,
}),
(fields) => {
@@ -788,6 +800,79 @@ describe('planWorktreeRecordAgent — fast-check parity invariant (#1298)', () =
});
});
// ─── branch namespace boundary tests (#1995) ─────────────────────────────────
// Claude Code's isolation="worktree" branch naming changed from worktree-agent-<id>
// to agent-<id>. Both namespaces must be accepted; non-agent branches rejected.
describe('worktree branch namespace boundaries (#1995)', () => {
const ACCEPTED_BRANCHES = [
'agent-a1',
'agent-abc123',
'agent-session.42',
'worktree-agent-a1',
'worktree-agent-abc123',
'worktree-agent-session.42',
];
const REJECTED_BRANCHES = [
'feature-x',
'main',
'agent',
'worktree-agent',
'xagent-y',
'worktree-foo',
'worktree-agent-',
'agent-',
'',
];
for (const branch of ACCEPTED_BRANCHES) {
test(`planWorktreeRecordAgent accepts branch "${branch}"`, () => {
const plan = planWorktreeRecordAgent('{"worktrees":[]}', {
agentId: 'a1', worktreePath: '/repo/wt', branch, base: 'abc123',
});
assert.equal(plan.ok, true, `expected branch "${branch}" to be accepted`);
assert.equal(plan.entry.branch, branch);
});
}
for (const branch of REJECTED_BRANCHES) {
test(`planWorktreeRecordAgent rejects branch "${branch}"`, () => {
const plan = planWorktreeRecordAgent('{"worktrees":[]}', {
agentId: 'a1', worktreePath: '/repo/wt', branch, base: 'abc123',
});
if (branch === '') {
assert.equal(plan.reason, 'missing_field');
} else {
assert.equal(plan.ok, false, `expected branch "${branch}" to be rejected`);
assert.equal(plan.reason, 'invalid_entry');
assert.match(plan.hint, /\(worktree-\)\?agent-/);
}
});
}
test('cleanup-wave retains agent-<id> entries alongside worktree-agent-<id> entries', () => {
const manifest = {
orchestrator_root: '/repo/main',
worktrees: [
{ agent_id: 'a1', worktree_path: '/repo/wt-a1', branch: 'agent-a1', expected_base: 'abc' },
{ agent_id: 'a2', worktree_path: '/repo/wt-a2', branch: 'worktree-agent-a2', expected_base: 'def' },
],
};
const result = planWorktreeWaveCleanup('/repo/main', manifest);
assert.equal(result.ok, true);
assert.equal(result.entries.length, 2);
assert.deepEqual(result.entries.map((e) => e.branch), ['agent-a1', 'worktree-agent-a2']);
});
test('hint string names the widened accepted pattern', () => {
const plan = planWorktreeRecordAgent('{"worktrees":[]}', {
agentId: 'a1', worktreePath: '/repo/wt', branch: 'feature-x', base: 'abc123',
});
assert.equal(plan.ok, false);
assert.match(plan.hint, /\(worktree-\)\?agent-\[A-Za-z0-9\._\/-\]\+/);
});
});
// ─── cmdWorktreeRecordAgent (#1298 CLI wrapper) ───────────────────────────────
describe('cmdWorktreeRecordAgent', () => {
@@ -824,6 +909,26 @@ describe('cmdWorktreeRecordAgent', () => {
assert.match(out.join(''), /"ok": true/);
});
test('accepts the current agent-<id> namespace via CLI (#1995)', () => {
let writtenContent = null;
const agentArgs = [
'--manifest', 'manifest.json',
'--agent-id', 'a1',
'--path', '/repo/.claude/worktrees/agent-a1',
'--branch', 'agent-a1',
'--base', 'abc123',
];
const result = cmdWorktreeRecordAgent('/repo/main', agentArgs, {
readFile: () => '{"orchestrator_root":"/repo/main","worktrees":[]}',
writeFile: (_p, c) => { writtenContent = c; },
write: () => {},
writeErr: () => {},
});
assert.equal(result.ok, true);
const written = JSON.parse(writtenContent);
assert.equal(written.worktrees[0].branch, 'agent-a1');
});
test('exits 2 with usage when --manifest is missing', () => {
withExitCode(() => {
const errs = [];
@@ -2620,10 +2725,10 @@ describe('bug #3384: adjacent worktree data-loss guards', () => {
const fragmentSource = fs.readFileSync(WORKTREE_BRANCH_CHECK_FRAGMENT, 'utf8');
const branchCheck = fragmentSource.indexOf('HEAD_REF=$(git symbolic-ref --quiet HEAD || echo');
const namespaceCheck = fragmentSource.indexOf('^worktree-agent-');
const namespaceCheck = fragmentSource.indexOf('(worktree-)?agent-');
assert.ok(branchCheck > 0, 'canonical fragment must assert HEAD before any work');
assert.ok(namespaceCheck > branchCheck, 'canonical fragment must require disposable worktree-agent branch');
assert.ok(namespaceCheck > branchCheck, 'canonical fragment must require disposable agent/worktree-agent branch');
// #48: verify-only — the destructive self-recovery is gone; the fragment fails closed instead.
assert.ok(!fragmentSource.includes('git reset --hard {EXPECTED_BASE}'), 'canonical fragment must not self-recover via reset --hard — orchestrator owns recovery (#48)');
assert.ok(fragmentSource.includes('exit 42'), 'canonical fragment must fail closed with exit 42 on base mismatch (#48)');