* 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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user