fix(#3849): phase allocation counts numbers held by sibling git worktrees (#4042)

* test(#3849): phase allocation must skip numbers held by sibling worktrees (failing first)

* fix(#3849): phase allocation counts numbers held by sibling git worktrees

Both allocators (cmdPhaseAdd, cmdPhaseAddBatch) chose max+1 over numbers
gathered from ONE checkout — headers, bullets (add only), on-disk dirs.
Every sibling git worktree carries its own .planning/ on its own branch,
so a phase minted there was invisible and the same number was allocated
twice (the reported incident: two Phase 441s, one with six written
plans, surfaced a day late by human memory).

New shared horizon collectSiblingWorktreePhaseNums: one
git worktree list --porcelain, then per sibling — phase-dir names (the
cheap scan that would have caught the incident) and the WHOLE sibling
ROADMAP.md headers (a row can predate its dir; milestone-scoping would
be wrong — a number used under any milestone on another branch is
taken). Widen, never refuse: unreadable sibling / no .planning / not a
git repo / git unavailable each contribute nothing and allocation is
unchanged. Reuses isSentinelPhaseId and the allocators' own patterns.

Secondary (#1229 never reached batch): cmdPhaseAddBatch now also scans
roadmap bullets — a bullet-only 'Phase N' row was invisible to batch
allocation, exactly the condition #1229 was filed for.

Also fixes the two new tests' result-key access (output.phases, not
output.results).

* fix(#3849): review fold-ins — subprocess band, bounded test git, linked-worktree fixture

- execFileSync options now match the repo's git band (10s window,
  windowsHide, 4MiB maxBuffer) — a spurious 4s timeout silently reverted
  to the pre-fix collision.
- test git helper bounded (15s) per local/no-unbounded-spawn.
- new fixture: allocation FROM a linked worktree counts the main
  checkout — the incident's actual topology direction.
- fixture-setup rmSync carries the sanctioned lint-disable (setup, not
  teardown; cleanup() still owns directory removal).

* fix(#3849): exempt the sibling-worktree scan from the enumeration-drift guard

collectSiblingWorktreePhaseNums reads a SIBLING checkout's phases dir —
a different question from the cwd-scoped listMilestonePhaseDirs the
guard routes everything to (which cannot see another worktree's
.planning at all). Function-scoped, per ADR-3180 Decision 4(a): any
other re-derivation in phase.cts is still caught. The GREEN bench
caught the omission.

* chore(#3849): changeset fragment (pr number backfilled after PR creation)

* chore(#3849): backfill changeset PR number (4042)

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-29 11:35:19 -04:00
committed by GitHub
parent 519ac23ebb
commit 0bf778c352
4 changed files with 255 additions and 1 deletions

View File

@@ -18,6 +18,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- io.cjs is an export= CommonJS module
import ioMod = require('./io.cjs');
const { output, error, ERROR_REASON, formatDiagnosticToken } = ioMod;
@@ -1141,6 +1142,68 @@ function assertDescriptionPreservesMilestoneScope(description: string, command:
);
}
/**
* #3849 — widen "used phase numbers" beyond this checkout. Every sibling git
* worktree carries its own `.planning/` on its own branch, so a phase minted
* there is invisible to the cwd-scoped sources (headers, bullets, on-disk
* dirs). Scan each sibling's phase-directory names (cheap — dir names alone
* caught the real incident) and its WHOLE ROADMAP.md headers (a row can exist
* before any directory does; milestone-scoping is wrong here because a number
* used under any milestone on another branch is still taken).
*
* Widen, never refuse: a missing `.planning/`, an unreadable sibling, a
* non-git cwd, or an unavailable git binary each leave `used` untouched —
* allocation then behaves exactly as it did before this horizon existed.
* Sentinels reuse the canonical `isSentinelPhaseId`; the dir pattern is the
* same one the on-disk scan uses, so decimal sub-phases (`411.1-foo`) are
* correctly not integers.
*/
function collectSiblingWorktreePhaseNums(cwd: string, used: Set<number>): void {
let porcelain: string;
try {
porcelain = execFileSync('git', ['worktree', 'list', '--porcelain'], {
cwd,
encoding: 'utf-8',
// Same subprocess band as the other git call sites (smart-entry, check-command-router):
// inside the 5-30s git window, hidden console window on Windows, bounded buffer.
timeout: 10_000,
windowsHide: true,
maxBuffer: 4 * 1024 * 1024,
});
} catch {
return; // not a git repo / git unavailable — unchanged behavior
}
const dirNumPattern = /^(?:[A-Z][A-Z0-9]*-)?(\d+)-/;
// Same header shape the allocators scan locally (#1729 tag tolerance).
const headerPattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*(?:\s*\([^)\n]{0,200}\))?:/gi;
for (const line of porcelain.split('\n')) {
if (!line.startsWith('worktree ')) continue;
const wt = line.slice('worktree '.length).trim();
if (!wt || path.resolve(wt) === path.resolve(cwd)) continue;
try {
for (const entry of fs.readdirSync(path.join(wt, '.planning', 'phases'))) {
const match = entry.match(dirNumPattern);
if (!match) continue;
const num = parseInt(match[1], 10);
if (!isSentinelPhaseId(num)) used.add(num);
}
} catch {
/* worktree has no .planning — normal, contributes nothing */
}
try {
const content = fs.readFileSync(path.join(wt, '.planning', 'ROADMAP.md'), 'utf-8');
let m: RegExpExecArray | null;
headerPattern.lastIndex = 0;
while ((m = headerPattern.exec(content)) !== null) {
const num = parseInt(m[1], 10);
if (!isSentinelPhaseId(num)) used.add(num);
}
} catch {
/* no roadmap in that worktree — normal, contributes nothing */
}
}
}
function cmdPhaseAdd(cwd: string, description: string, raw: boolean, customId?: string): void {
if (!description) {
error('description required for phase add');
@@ -1214,6 +1277,9 @@ function cmdPhaseAdd(cwd: string, description: string, raw: boolean, customId?:
// section headers, roadmap bullets, AND on-disk dirs above is what prevents the
// #1229 collision (a bullet-only Phase N is now counted), so max+1 cannot reuse
// an existing number.
// 4) Sibling git worktrees (#3849) — same max+1, wider horizon: a number
// taken on another branch is still taken.
collectSiblingWorktreePhaseNums(cwd, usedPhaseNums);
const maxUsed = usedPhaseNums.size > 0 ? Math.max(...usedPhaseNums) : 0;
_newPhaseId = maxUsed + 1;
const paddedNum = String(_newPhaseId).padStart(2, '0');
@@ -1292,8 +1358,12 @@ function cmdPhaseAddBatch(cwd: string, descriptions: string[], raw: boolean): vo
const content = extractCurrentMilestone(rawContent, cwd);
let maxPhase = 0;
if (config.phase_naming !== 'custom') {
// Same three cwd-scoped sources as cmdPhaseAdd (#1229): headers, roadmap
// bullets, on-disk dirs. The bullet scan was missing here — a bullet-only
// `Phase N` row was invisible to batch allocation (#3849 secondary).
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*(?:\s*\([^)\n]{0,200}\))?:/gi;
const bulletPattern = /^[ \t]*-[ \t]*\[[^\]]{0,200}\][ \t]*\*{0,2}Phase[ \t]+(\d+)(?=[:.\s*]|$)/gim;
let m: RegExpExecArray | null;
while ((m = phasePattern.exec(content)) !== null) {
const num = parseInt(m[1], 10);
@@ -1301,6 +1371,11 @@ function cmdPhaseAddBatch(cwd: string, descriptions: string[], raw: boolean): vo
if (isSentinelPhaseId(num)) continue;
if (num > maxPhase) maxPhase = num;
}
while ((m = bulletPattern.exec(content)) !== null) {
const num = parseInt(m[1], 10);
if (isSentinelPhaseId(num)) continue;
if (num > maxPhase) maxPhase = num;
}
const phasesOnDisk = path.join(planningDir(cwd), 'phases');
if (fs.existsSync(phasesOnDisk)) {
const dirNumPattern = /^(?:[A-Z][A-Z0-9]*-)?(\d+)-/;
@@ -1313,6 +1388,12 @@ function cmdPhaseAddBatch(cwd: string, descriptions: string[], raw: boolean): vo
if (num > maxPhase) maxPhase = num;
}
}
// 4) Sibling git worktrees (#3849) — same max+1, wider horizon.
const siblingNums = new Set<number>();
collectSiblingWorktreePhaseNums(cwd, siblingNums);
for (const num of siblingNums) {
if (num > maxPhase) maxPhase = num;
}
}
const added: Record<string, unknown>[] = [];
for (const description of descriptions) {