Merge origin/main into fix/auto-mode-infinite-loop-96-109
Resolve conflicts keeping PR's improvements (idempotency, recovery backoff, self-heal, completedKeySet) merged with main's existing partial fixes (dispatch reorder, alternating loop detection, slice-branch-chaining guard). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
527b63ac36
18 changed files with 897 additions and 178 deletions
|
|
@ -48,7 +48,7 @@ GSD v2 solves all of these because it's not a prompt framework anymore — it's
|
|||
|
||||
### Migrating from v1
|
||||
|
||||
> **Note:** A `ROADMAP.md` file is **required** for migration. If your project doesn't have one, you'll need to create it before running the migration command.
|
||||
> **Note:** Migration works best with a `ROADMAP.md` file for milestone structure. Without one, milestones are inferred from the `phases/` directory.
|
||||
|
||||
If you have projects with `.planning` directories from the original Get Shit Done, you can migrate them to GSD-2's `.gsd` format:
|
||||
|
||||
|
|
|
|||
34
src/cli.ts
34
src/cli.ts
|
|
@ -7,6 +7,7 @@ import {
|
|||
createAgentSession,
|
||||
InteractiveMode,
|
||||
runPrintMode,
|
||||
runRpcMode,
|
||||
} from '@mariozechner/pi-coding-agent'
|
||||
import { existsSync, readdirSync, renameSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
|
@ -49,6 +50,22 @@ function parseCliArgs(argv: string[]): CliFlags {
|
|||
flags.appendSystemPrompt = args[++i]
|
||||
} else if (arg === '--tools' && i + 1 < args.length) {
|
||||
flags.tools = args[++i].split(',')
|
||||
} else if (arg === '--version' || arg === '-v') {
|
||||
process.stdout.write((process.env.GSD_VERSION || '0.0.0') + '\n')
|
||||
process.exit(0)
|
||||
} else if (arg === '--help' || arg === '-h') {
|
||||
process.stdout.write(`GSD v${process.env.GSD_VERSION || '0.0.0'} — Get Shit Done\n\n`)
|
||||
process.stdout.write('Usage: gsd [options] [message...]\n\n')
|
||||
process.stdout.write('Options:\n')
|
||||
process.stdout.write(' --mode <text|json|rpc> Output mode (default: interactive)\n')
|
||||
process.stdout.write(' --print, -p Single-shot print mode\n')
|
||||
process.stdout.write(' --model <id> Override model (e.g. claude-opus-4-6)\n')
|
||||
process.stdout.write(' --no-session Disable session persistence\n')
|
||||
process.stdout.write(' --extension <path> Load additional extension\n')
|
||||
process.stdout.write(' --tools <a,b,c> Restrict available tools\n')
|
||||
process.stdout.write(' --version, -v Print version and exit\n')
|
||||
process.stdout.write(' --help, -h Print this help and exit\n')
|
||||
process.exit(0)
|
||||
} else if (!arg.startsWith('--') && !arg.startsWith('-')) {
|
||||
flags.messages.push(arg)
|
||||
}
|
||||
|
|
@ -164,8 +181,14 @@ if (isPrintMode) {
|
|||
}
|
||||
|
||||
const mode = cliFlags.mode || 'text'
|
||||
|
||||
if (mode === 'rpc') {
|
||||
await runRpcMode(session)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
await runPrintMode(session, {
|
||||
mode: mode === 'rpc' ? 'json' : mode,
|
||||
mode,
|
||||
messages: cliFlags.messages,
|
||||
})
|
||||
process.exit(0)
|
||||
|
|
@ -267,5 +290,14 @@ if (enabledModelPatterns && enabledModelPatterns.length > 0) {
|
|||
}
|
||||
}
|
||||
|
||||
if (!process.stdin.isTTY) {
|
||||
process.stderr.write('[gsd] Error: Interactive mode requires a terminal (TTY).\n')
|
||||
process.stderr.write('[gsd] Non-interactive alternatives:\n')
|
||||
process.stderr.write('[gsd] gsd --print "your message" Single-shot prompt\n')
|
||||
process.stderr.write('[gsd] gsd --mode rpc JSON-RPC over stdin/stdout\n')
|
||||
process.stderr.write('[gsd] gsd --mode text "message" Text output mode\n')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const interactiveMode = new InteractiveMode(session)
|
||||
await interactiveMode.run()
|
||||
|
|
|
|||
|
|
@ -141,7 +141,7 @@ export default function (pi: ExtensionAPI) {
|
|||
try {
|
||||
const ai = getClient();
|
||||
const response = await ai.models.generateContent({
|
||||
model: "gemini-2.5-flash",
|
||||
model: process.env.GEMINI_SEARCH_MODEL || "gemini-2.5-flash",
|
||||
contents: params.query,
|
||||
config: {
|
||||
tools: [{ googleSearch: {} }],
|
||||
|
|
|
|||
|
|
@ -61,7 +61,9 @@ import {
|
|||
autoCommitCurrentBranch,
|
||||
ensureSliceBranch,
|
||||
getCurrentBranch,
|
||||
getMainBranch,
|
||||
getSliceBranchName,
|
||||
parseSliceBranch,
|
||||
switchToMain,
|
||||
mergeSliceToMain,
|
||||
} from "./worktree.ts";
|
||||
|
|
@ -333,6 +335,7 @@ export async function startAuto(
|
|||
stepMode = requestedStepMode;
|
||||
cmdCtx = ctx;
|
||||
basePath = base;
|
||||
unitDispatchCount.clear();
|
||||
// Re-initialize metrics in case ledger was lost during pause
|
||||
if (!getLedger()) initMetrics(base);
|
||||
ctx.ui.setStatus("gsd-auto", stepMode ? "next" : "auto");
|
||||
|
|
@ -974,10 +977,10 @@ async function dispatchNextUnit(
|
|||
// - complete-milestone runs on a slice branch (last slice bypass)
|
||||
{
|
||||
const currentBranch = getCurrentBranch(basePath);
|
||||
const branchMatch = currentBranch.match(/^gsd\/(M\d+)\/(S\d+)$/);
|
||||
if (branchMatch) {
|
||||
const branchMid = branchMatch[1]!;
|
||||
const branchSid = branchMatch[2]!;
|
||||
const parsedBranch = parseSliceBranch(currentBranch);
|
||||
if (parsedBranch) {
|
||||
const branchMid = parsedBranch.milestoneId;
|
||||
const branchSid = parsedBranch.sliceId;
|
||||
// Check if this slice is marked done in the roadmap
|
||||
const roadmapFile = resolveMilestoneFile(basePath, branchMid, "ROADMAP");
|
||||
const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null;
|
||||
|
|
@ -991,8 +994,9 @@ async function dispatchNextUnit(
|
|||
const mergeResult = mergeSliceToMain(
|
||||
basePath, branchMid, branchSid, sliceTitleForMerge,
|
||||
);
|
||||
const targetBranch = getMainBranch(basePath);
|
||||
ctx.ui.notify(
|
||||
`Merged ${mergeResult.branch} → main.`,
|
||||
`Merged ${mergeResult.branch} → ${targetBranch}.`,
|
||||
"info",
|
||||
);
|
||||
// Re-derive state from main so downstream logic sees merged state
|
||||
|
|
@ -1071,112 +1075,114 @@ async function dispatchNextUnit(
|
|||
// can perform the UAT manually. On next resume, result file will exist → skip.
|
||||
let pauseAfterUatDispatch = false;
|
||||
|
||||
// ── Adaptive Replanning: check if last completed slice needs reassessment ──
|
||||
// After a slice completes, we reassess the roadmap before moving to the next slice.
|
||||
// Skip reassessment for the final slice (milestone complete) or if already assessed.
|
||||
const needsReassess = await checkNeedsReassessment(basePath, mid, state);
|
||||
// ── Phase-first dispatch: complete-slice MUST run before reassessment ──
|
||||
// If the current phase is "summarizing", complete-slice is responsible for
|
||||
// mergeSliceToMain. Reassessment must wait until the merge is done.
|
||||
if (state.phase === "summarizing") {
|
||||
// complete-slice MUST run before reassessment to guarantee mergeSliceToMain
|
||||
const sid = state.activeSlice!.id;
|
||||
const sTitle = state.activeSlice!.title;
|
||||
unitType = "complete-slice";
|
||||
unitId = `${mid}/${sid}`;
|
||||
prompt = await buildCompleteSlicePrompt(mid, midTitle!, sid, sTitle, basePath);
|
||||
} else {
|
||||
// ── Adaptive Replanning: check if last completed slice needs reassessment ──
|
||||
// Computed here (after summarizing guard) so complete-slice always runs first.
|
||||
const needsReassess = await checkNeedsReassessment(basePath, mid, state);
|
||||
if (needsRunUat) {
|
||||
const { sliceId, uatType } = needsRunUat;
|
||||
unitType = "run-uat";
|
||||
unitId = `${mid}/${sliceId}`;
|
||||
const uatFile = resolveSliceFile(basePath, mid, sliceId, "UAT")!;
|
||||
const uatContent = await loadFile(uatFile);
|
||||
prompt = await buildRunUatPrompt(
|
||||
mid, sliceId, relSliceFile(basePath, mid, sliceId, "UAT"), uatContent ?? "", basePath,
|
||||
);
|
||||
// For non-artifact-driven UAT types, pause after the prompt is dispatched.
|
||||
// The agent receives the prompt, writes S0x-UAT-RESULT.md surfacing the UAT,
|
||||
// then auto-mode pauses for human execution. On resume, result file exists → skip.
|
||||
if (uatType !== "artifact-driven") {
|
||||
pauseAfterUatDispatch = true;
|
||||
}
|
||||
} else if (needsReassess) {
|
||||
unitType = "reassess-roadmap";
|
||||
unitId = `${mid}/${needsReassess.sliceId}`;
|
||||
prompt = await buildReassessRoadmapPrompt(mid, midTitle!, needsReassess.sliceId, basePath);
|
||||
} else if (state.phase === "pre-planning") {
|
||||
// Need roadmap — check if context exists
|
||||
const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT");
|
||||
const hasContext = !!(contextFile && await loadFile(contextFile));
|
||||
|
||||
} else if (needsRunUat) {
|
||||
const { sliceId, uatType } = needsRunUat;
|
||||
unitType = "run-uat";
|
||||
unitId = `${mid}/${sliceId}`;
|
||||
const uatFile = resolveSliceFile(basePath, mid, sliceId, "UAT")!;
|
||||
const uatContent = await loadFile(uatFile);
|
||||
prompt = await buildRunUatPrompt(
|
||||
mid, sliceId, relSliceFile(basePath, mid, sliceId, "UAT"), uatContent ?? "", basePath,
|
||||
);
|
||||
// For non-artifact-driven UAT types, pause after the prompt is dispatched.
|
||||
// The agent receives the prompt, writes S0x-UAT-RESULT.md surfacing the UAT,
|
||||
// then auto-mode pauses for human execution. On resume, result file exists → skip.
|
||||
if (uatType !== "artifact-driven") {
|
||||
pauseAfterUatDispatch = true;
|
||||
}
|
||||
} else if (needsReassess) {
|
||||
unitType = "reassess-roadmap";
|
||||
unitId = `${mid}/${needsReassess.sliceId}`;
|
||||
prompt = await buildReassessRoadmapPrompt(mid, midTitle!, needsReassess.sliceId, basePath);
|
||||
} else if (state.phase === "pre-planning") {
|
||||
// Need roadmap — check if context exists
|
||||
const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT");
|
||||
const hasContext = !!(contextFile && await loadFile(contextFile));
|
||||
if (!hasContext) {
|
||||
await stopAuto(ctx, pi);
|
||||
ctx.ui.notify("No context or roadmap yet. Run /gsd to discuss first.", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasContext) {
|
||||
// Research before roadmap if no research exists
|
||||
const researchFile = resolveMilestoneFile(basePath, mid, "RESEARCH");
|
||||
const hasResearch = !!(researchFile && await loadFile(researchFile));
|
||||
|
||||
if (!hasResearch) {
|
||||
unitType = "research-milestone";
|
||||
unitId = mid;
|
||||
prompt = await buildResearchMilestonePrompt(mid, midTitle!, basePath);
|
||||
} else {
|
||||
unitType = "plan-milestone";
|
||||
unitId = mid;
|
||||
prompt = await buildPlanMilestonePrompt(mid, midTitle!, basePath);
|
||||
}
|
||||
|
||||
} else if (state.phase === "planning") {
|
||||
// Slice needs planning — but research first if no research exists
|
||||
const sid = state.activeSlice!.id;
|
||||
const sTitle = state.activeSlice!.title;
|
||||
const researchFile = resolveSliceFile(basePath, mid, sid, "RESEARCH");
|
||||
const hasResearch = !!(researchFile && await loadFile(researchFile));
|
||||
|
||||
if (!hasResearch) {
|
||||
unitType = "research-slice";
|
||||
unitId = `${mid}/${sid}`;
|
||||
prompt = await buildResearchSlicePrompt(mid, midTitle!, sid, sTitle, basePath);
|
||||
} else {
|
||||
unitType = "plan-slice";
|
||||
unitId = `${mid}/${sid}`;
|
||||
prompt = await buildPlanSlicePrompt(mid, midTitle!, sid, sTitle, basePath);
|
||||
}
|
||||
|
||||
} else if (state.phase === "replanning-slice") {
|
||||
// Blocker discovered — replan the slice before continuing
|
||||
const sid = state.activeSlice!.id;
|
||||
const sTitle = state.activeSlice!.title;
|
||||
unitType = "replan-slice";
|
||||
unitId = `${mid}/${sid}`;
|
||||
prompt = await buildReplanSlicePrompt(mid, midTitle!, sid, sTitle, basePath);
|
||||
|
||||
} else if (state.phase === "executing" && state.activeTask) {
|
||||
// Execute next task
|
||||
const sid = state.activeSlice!.id;
|
||||
const sTitle = state.activeSlice!.title;
|
||||
const tid = state.activeTask.id;
|
||||
const tTitle = state.activeTask.title;
|
||||
unitType = "execute-task";
|
||||
unitId = `${mid}/${sid}/${tid}`;
|
||||
prompt = await buildExecuteTaskPrompt(mid, sid, sTitle, tid, tTitle, basePath);
|
||||
|
||||
} else if (state.phase === "completing-milestone") {
|
||||
// All slices done — complete the milestone
|
||||
unitType = "complete-milestone";
|
||||
unitId = mid;
|
||||
prompt = await buildCompleteMilestonePrompt(mid, midTitle!, basePath);
|
||||
|
||||
} else {
|
||||
if (currentUnit) {
|
||||
const modelId = ctx.model?.id ?? "unknown";
|
||||
snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
|
||||
saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
|
||||
}
|
||||
await stopAuto(ctx, pi);
|
||||
ctx.ui.notify("No context or roadmap yet. Run /gsd to discuss first.", "warning");
|
||||
ctx.ui.notify(`Unexpected phase: ${state.phase}. Stopping auto-mode.`, "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
// Research before roadmap if no research exists
|
||||
const researchFile = resolveMilestoneFile(basePath, mid, "RESEARCH");
|
||||
const hasResearch = !!(researchFile && await loadFile(researchFile));
|
||||
|
||||
if (!hasResearch) {
|
||||
unitType = "research-milestone";
|
||||
unitId = mid;
|
||||
prompt = await buildResearchMilestonePrompt(mid, midTitle!, basePath);
|
||||
} else {
|
||||
unitType = "plan-milestone";
|
||||
unitId = mid;
|
||||
prompt = await buildPlanMilestonePrompt(mid, midTitle!, basePath);
|
||||
}
|
||||
|
||||
} else if (state.phase === "planning") {
|
||||
// Slice needs planning — but research first if no research exists
|
||||
const sid = state.activeSlice!.id;
|
||||
const sTitle = state.activeSlice!.title;
|
||||
const researchFile = resolveSliceFile(basePath, mid, sid, "RESEARCH");
|
||||
const hasResearch = !!(researchFile && await loadFile(researchFile));
|
||||
|
||||
if (!hasResearch) {
|
||||
unitType = "research-slice";
|
||||
unitId = `${mid}/${sid}`;
|
||||
prompt = await buildResearchSlicePrompt(mid, midTitle!, sid, sTitle, basePath);
|
||||
} else {
|
||||
unitType = "plan-slice";
|
||||
unitId = `${mid}/${sid}`;
|
||||
prompt = await buildPlanSlicePrompt(mid, midTitle!, sid, sTitle, basePath);
|
||||
}
|
||||
|
||||
} else if (state.phase === "replanning-slice") {
|
||||
// Blocker discovered — replan the slice before continuing
|
||||
const sid = state.activeSlice!.id;
|
||||
const sTitle = state.activeSlice!.title;
|
||||
unitType = "replan-slice";
|
||||
unitId = `${mid}/${sid}`;
|
||||
prompt = await buildReplanSlicePrompt(mid, midTitle!, sid, sTitle, basePath);
|
||||
|
||||
} else if (state.phase === "executing" && state.activeTask) {
|
||||
// Execute next task
|
||||
const sid = state.activeSlice!.id;
|
||||
const sTitle = state.activeSlice!.title;
|
||||
const tid = state.activeTask.id;
|
||||
const tTitle = state.activeTask.title;
|
||||
unitType = "execute-task";
|
||||
unitId = `${mid}/${sid}/${tid}`;
|
||||
prompt = await buildExecuteTaskPrompt(mid, sid, sTitle, tid, tTitle, basePath);
|
||||
|
||||
} else if (state.phase === "completing-milestone") {
|
||||
// All slices done — complete the milestone
|
||||
unitType = "complete-milestone";
|
||||
unitId = mid;
|
||||
prompt = await buildCompleteMilestonePrompt(mid, midTitle!, basePath);
|
||||
|
||||
} else {
|
||||
if (currentUnit) {
|
||||
const modelId = ctx.model?.id ?? "unknown";
|
||||
snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
|
||||
saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
|
||||
}
|
||||
await stopAuto(ctx, pi);
|
||||
ctx.ui.notify(`Unexpected phase: ${state.phase}. Stopping auto-mode.`, "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
await emitObservabilityWarnings(ctx, unitType, unitId);
|
||||
|
|
|
|||
|
|
@ -347,21 +347,23 @@ export function parseSummary(content: string): Summary {
|
|||
const [fmLines, body] = splitFrontmatter(content);
|
||||
|
||||
const fm = fmLines ? parseFrontmatterMap(fmLines) : {};
|
||||
const asStringArray = (v: unknown): string[] =>
|
||||
Array.isArray(v) ? v : (typeof v === 'string' && v ? [v] : []);
|
||||
const frontmatter: SummaryFrontmatter = {
|
||||
id: (fm.id as string) || '',
|
||||
parent: (fm.parent as string) || '',
|
||||
milestone: (fm.milestone as string) || '',
|
||||
provides: (fm.provides as string[]) || [],
|
||||
provides: asStringArray(fm.provides),
|
||||
requires: ((fm.requires as Array<Record<string, string>>) || []).map(r => ({
|
||||
slice: r.slice || '',
|
||||
provides: r.provides || '',
|
||||
})),
|
||||
affects: (fm.affects as string[]) || [],
|
||||
key_files: (fm.key_files as string[]) || [],
|
||||
key_decisions: (fm.key_decisions as string[]) || [],
|
||||
patterns_established: (fm.patterns_established as string[]) || [],
|
||||
drill_down_paths: (fm.drill_down_paths as string[]) || [],
|
||||
observability_surfaces: (fm.observability_surfaces as string[]) || [],
|
||||
affects: asStringArray(fm.affects),
|
||||
key_files: asStringArray(fm.key_files),
|
||||
key_decisions: asStringArray(fm.key_decisions),
|
||||
patterns_established: asStringArray(fm.patterns_established),
|
||||
drill_down_paths: asStringArray(fm.drill_down_paths),
|
||||
observability_surfaces: asStringArray(fm.observability_surfaces),
|
||||
duration: (fm.duration as string) || '',
|
||||
verification_result: (fm.verification_result as string) || 'untested',
|
||||
completed_at: (fm.completed_at as string) || '',
|
||||
|
|
|
|||
|
|
@ -184,8 +184,10 @@ export default function (pi: ExtensionAPI) {
|
|||
});
|
||||
|
||||
// ── Ctrl+Alt+G shortcut — GSD dashboard overlay ────────────────────────
|
||||
// Requires Kitty keyboard protocol or modifyOtherKeys support.
|
||||
// Terminals without support (macOS Terminal.app, JetBrains): use /gsd status instead.
|
||||
pi.registerShortcut(Key.ctrlAlt("g"), {
|
||||
description: "Open GSD dashboard",
|
||||
description: "Open GSD dashboard (or use /gsd status)",
|
||||
handler: async (ctx) => {
|
||||
// Only show if .gsd/ exists
|
||||
if (!existsSync(join(process.cwd(), ".gsd"))) {
|
||||
|
|
|
|||
|
|
@ -96,7 +96,10 @@ export async function handleMigrate(
|
|||
|
||||
if (!existsSync(sourcePath)) {
|
||||
ctx.ui.notify(
|
||||
`Directory not found: ${sourcePath}\n\nMake sure the path points to a project root with a .planning directory.`,
|
||||
`Directory not found: ${sourcePath}\n\n` +
|
||||
'Migration converts a .planning/ directory (from older GSD versions) into .gsd/ format.\n' +
|
||||
'If you are starting a new project, use /gsd:new-project instead.\n' +
|
||||
'If migrating, ensure the path contains a .planning/ directory.',
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ function issue(file: string, severity: ValidationSeverity, message: string): Val
|
|||
/**
|
||||
* Validate that a .planning directory has the minimum required structure.
|
||||
* Returns structured issues with severity levels:
|
||||
* - fatal: directory doesn't exist or ROADMAP.md missing (migration cannot proceed)
|
||||
* - fatal: directory doesn't exist (migration cannot proceed)
|
||||
* - warning: optional files missing (migration can proceed with reduced data)
|
||||
*/
|
||||
export async function validatePlanningDirectory(path: string): Promise<ValidationResult> {
|
||||
|
|
@ -26,9 +26,11 @@ export async function validatePlanningDirectory(path: string): Promise<Validatio
|
|||
return { valid: false, issues };
|
||||
}
|
||||
|
||||
// ROADMAP.md is required (fatal if missing)
|
||||
// ROADMAP.md — warn if missing (transformer falls back to filesystem phases)
|
||||
if (!existsSync(join(path, 'ROADMAP.md'))) {
|
||||
issues.push(issue('ROADMAP.md', 'fatal', 'ROADMAP.md is required for migration'));
|
||||
issues.push(issue('ROADMAP.md', 'warning',
|
||||
'ROADMAP.md not found — milestone structure will be inferred from phases/ directory',
|
||||
));
|
||||
}
|
||||
|
||||
// Optional files — warn if missing
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ Titles live inside file content (headings, frontmatter), not in file or director
|
|||
- **Slices** are demoable vertical increments (S01, S02, ...) ordered by risk. After each slice completes, the roadmap is reassessed before the next slice begins.
|
||||
- **Tasks** are single-context-window units of work (T01, T02, ...)
|
||||
- Checkboxes in roadmap and plan files track completion (`[ ]` → `[x]`)
|
||||
- Each slice gets its own git branch: `gsd/M001/S01`
|
||||
- Each slice gets its own git branch: `gsd/M001/S01` (or `gsd/<worktree>/M001/S01` when inside a worktree)
|
||||
- Slices are squash-merged to main when complete
|
||||
- Summaries compress prior work — read them instead of re-reading all task details
|
||||
- `STATE.md` is the quick-glance status file — keep it updated after changes
|
||||
|
|
|
|||
|
|
@ -725,8 +725,8 @@ Another orphan.
|
|||
}
|
||||
}
|
||||
|
||||
// ─── Test 12: Validation — missing ROADMAP.md → fatal ─────────────────
|
||||
console.log('\n=== Validation: missing ROADMAP.md → fatal ===');
|
||||
// ─── Test 12: Validation — missing ROADMAP.md → warning (not fatal) ───
|
||||
console.log('\n=== Validation: missing ROADMAP.md → warning (not fatal) ===');
|
||||
{
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
|
|
@ -736,10 +736,10 @@ Another orphan.
|
|||
|
||||
const result = await validatePlanningDirectory(planning);
|
||||
|
||||
assertEq(result.valid, false, 'no roadmap: validation fails');
|
||||
assertEq(result.valid, true, 'no roadmap: validation still passes');
|
||||
assert(
|
||||
result.issues.some(i => i.severity === 'fatal' && i.file.includes('ROADMAP')),
|
||||
'no roadmap: fatal issue mentions ROADMAP'
|
||||
result.issues.some(i => i.severity === 'warning' && i.file.includes('ROADMAP')),
|
||||
'no roadmap: warning issue mentions ROADMAP'
|
||||
);
|
||||
} finally {
|
||||
cleanup(base);
|
||||
|
|
|
|||
|
|
@ -211,15 +211,15 @@ async function main(): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
console.log('\n=== Validator: missing ROADMAP.md → fatal ===');
|
||||
console.log('\n=== Validator: missing ROADMAP.md → warning (not fatal) ===');
|
||||
{
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
const planning = createPlanningDir(base);
|
||||
writeFileSync(join(planning, 'PROJECT.md'), SAMPLE_PROJECT);
|
||||
const result = await validatePlanningDirectory(planning);
|
||||
assertEq(result.valid, false, 'no roadmap: validation fails');
|
||||
assert(result.issues.some(i => i.severity === 'fatal' && i.file.includes('ROADMAP')), 'no roadmap: fatal issue mentions ROADMAP');
|
||||
assertEq(result.valid, true, 'no roadmap: validation still passes');
|
||||
assert(result.issues.some(i => i.severity === 'warning' && i.file.includes('ROADMAP')), 'no roadmap: warning issue mentions ROADMAP');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1248,6 +1248,100 @@ console.log('\n=== parseRequirementCounts: total is sum of all section counts ==
|
|||
assertEq(counts.total, counts.active + counts.validated + counts.deferred + counts.outOfScope, 'total is exact sum');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// parseSummary: bare scalar frontmatter fields (regression test for #91)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== parseSummary: bare scalar "none" coerced to string array (#91) ===');
|
||||
{
|
||||
const content = `---
|
||||
id: T04
|
||||
parent: S03
|
||||
milestone: M001
|
||||
provides:
|
||||
- iOS rules
|
||||
key_files:
|
||||
- .claude/rules/swift-style.md
|
||||
key_decisions: none
|
||||
patterns_established: none
|
||||
drill_down_paths: none
|
||||
observability_surfaces: none — static reference files
|
||||
affects: single-value
|
||||
---
|
||||
|
||||
# T04: iOS Rules
|
||||
|
||||
**Created iOS-specific rules.**
|
||||
|
||||
## What Happened
|
||||
|
||||
Added rules.
|
||||
|
||||
## Deviations
|
||||
|
||||
None.
|
||||
`;
|
||||
|
||||
const s = parseSummary(content);
|
||||
|
||||
// Array fields should remain arrays
|
||||
assertEq(s.frontmatter.provides.length, 1, '#91: provides array preserved');
|
||||
assertEq(s.frontmatter.provides[0], 'iOS rules', '#91: provides value');
|
||||
assertEq(s.frontmatter.key_files.length, 1, '#91: key_files array preserved');
|
||||
|
||||
// Bare scalar "none" must be coerced to ["none"], not crash
|
||||
assertEq(Array.isArray(s.frontmatter.key_decisions), true, '#91: key_decisions is array');
|
||||
assertEq(s.frontmatter.key_decisions.length, 1, '#91: key_decisions has 1 element');
|
||||
assertEq(s.frontmatter.key_decisions[0], 'none', '#91: key_decisions[0] is "none"');
|
||||
|
||||
assertEq(Array.isArray(s.frontmatter.patterns_established), true, '#91: patterns_established is array');
|
||||
assertEq(s.frontmatter.patterns_established.length, 1, '#91: patterns_established coerced');
|
||||
|
||||
assertEq(Array.isArray(s.frontmatter.drill_down_paths), true, '#91: drill_down_paths is array');
|
||||
assertEq(s.frontmatter.drill_down_paths.length, 1, '#91: drill_down_paths coerced');
|
||||
|
||||
// Scalar with spaces: "none — static reference files"
|
||||
assertEq(Array.isArray(s.frontmatter.observability_surfaces), true, '#91: observability_surfaces is array');
|
||||
assertEq(s.frontmatter.observability_surfaces.length, 1, '#91: observability_surfaces coerced');
|
||||
assertEq(s.frontmatter.observability_surfaces[0], 'none — static reference files', '#91: full scalar preserved');
|
||||
|
||||
// Single value (not "none") also coerced
|
||||
assertEq(Array.isArray(s.frontmatter.affects), true, '#91: affects is array');
|
||||
assertEq(s.frontmatter.affects.length, 1, '#91: affects single value coerced');
|
||||
assertEq(s.frontmatter.affects[0], 'single-value', '#91: affects value');
|
||||
|
||||
// .slice().join() must not crash (the original bug)
|
||||
const decisions = s.frontmatter.key_decisions.slice(0, 2).join('; ');
|
||||
assertEq(decisions, 'none', '#91: .slice().join() works on coerced array');
|
||||
}
|
||||
|
||||
console.log('\n=== parseSummary: missing/empty frontmatter fields yield empty arrays ===');
|
||||
{
|
||||
const content = `---
|
||||
id: T05
|
||||
parent: S04
|
||||
milestone: M001
|
||||
---
|
||||
|
||||
# T05: Minimal Summary
|
||||
|
||||
**Minimal.**
|
||||
|
||||
## What Happened
|
||||
|
||||
Nothing.
|
||||
`;
|
||||
|
||||
const s = parseSummary(content);
|
||||
assertEq(s.frontmatter.provides.length, 0, 'missing provides = empty array');
|
||||
assertEq(s.frontmatter.key_decisions.length, 0, 'missing key_decisions = empty array');
|
||||
assertEq(s.frontmatter.affects.length, 0, 'missing affects = empty array');
|
||||
assertEq(s.frontmatter.key_files.length, 0, 'missing key_files = empty array');
|
||||
assertEq(s.frontmatter.patterns_established.length, 0, 'missing patterns_established = empty array');
|
||||
assertEq(s.frontmatter.drill_down_paths.length, 0, 'missing drill_down_paths = empty array');
|
||||
assertEq(s.frontmatter.observability_surfaces.length, 0, 'missing observability_surfaces = empty array');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Results
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
|
|
|||
253
src/resources/extensions/gsd/tests/worktree-integration.test.ts
Normal file
253
src/resources/extensions/gsd/tests/worktree-integration.test.ts
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
/**
|
||||
* Worktree Integration Tests
|
||||
*
|
||||
* Tests the full lifecycle of GSD operations inside a worktree:
|
||||
* - Branch namespacing (gsd/<wt>/<M>/<S> instead of gsd/<M>/<S>)
|
||||
* - getMainBranch returns worktree/<name> inside a worktree
|
||||
* - switchToMain goes to worktree/<name>, not main
|
||||
* - mergeSliceToMain merges into worktree/<name>
|
||||
* - Parallel worktrees don't conflict on branch names
|
||||
* - State derivation works correctly inside worktrees
|
||||
*/
|
||||
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
import {
|
||||
createWorktree,
|
||||
listWorktrees,
|
||||
removeWorktree,
|
||||
worktreePath,
|
||||
worktreeBranchName,
|
||||
} from "../worktree-manager.ts";
|
||||
|
||||
import {
|
||||
detectWorktreeName,
|
||||
ensureSliceBranch,
|
||||
getActiveSliceBranch,
|
||||
getCurrentBranch,
|
||||
getMainBranch,
|
||||
getSliceBranchName,
|
||||
isOnSliceBranch,
|
||||
mergeSliceToMain,
|
||||
switchToMain,
|
||||
autoCommitCurrentBranch,
|
||||
} from "../worktree.ts";
|
||||
|
||||
import { deriveState } from "../state.ts";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function assert(condition: boolean, message: string): void {
|
||||
if (condition) passed++;
|
||||
else {
|
||||
failed++;
|
||||
console.error(` FAIL: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertEq<T>(actual: T, expected: T, message: string): void {
|
||||
if (JSON.stringify(actual) === JSON.stringify(expected)) passed++;
|
||||
else {
|
||||
failed++;
|
||||
console.error(` FAIL: ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function run(command: string, cwd: string): string {
|
||||
return execSync(command, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
|
||||
}
|
||||
|
||||
// ─── Test repo setup ──────────────────────────────────────────────────────────
|
||||
|
||||
const base = mkdtempSync(join(tmpdir(), "gsd-wt-integration-"));
|
||||
run("git init -b main", base);
|
||||
run("git config user.name 'Pi Test'", base);
|
||||
run("git config user.email 'pi@example.com'", base);
|
||||
|
||||
// Create a project with one milestone and two slices
|
||||
mkdirSync(join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks"), { recursive: true });
|
||||
mkdirSync(join(base, ".gsd", "milestones", "M001", "slices", "S02", "tasks"), { recursive: true });
|
||||
writeFileSync(join(base, "README.md"), "# Test Project\n", "utf-8");
|
||||
writeFileSync(
|
||||
join(base, ".gsd", "milestones", "M001", "M001-ROADMAP.md"),
|
||||
[
|
||||
"# M001: Demo",
|
||||
"",
|
||||
"## Slices",
|
||||
"- [ ] **S01: First** `risk:low` `depends:[]`",
|
||||
" > After this: part one works",
|
||||
"- [ ] **S02: Second** `risk:low` `depends:[]`",
|
||||
" > After this: part two works",
|
||||
].join("\n") + "\n",
|
||||
"utf-8",
|
||||
);
|
||||
writeFileSync(
|
||||
join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md"),
|
||||
"# S01: First\n\n**Goal:** Demo\n**Demo:** Demo\n\n## Must-Haves\n- done\n\n## Tasks\n- [ ] **T01: Implement** `est:10m`\n do it\n",
|
||||
"utf-8",
|
||||
);
|
||||
writeFileSync(
|
||||
join(base, ".gsd", "milestones", "M001", "slices", "S02", "S02-PLAN.md"),
|
||||
"# S02: Second\n\n**Goal:** Demo\n**Demo:** Demo\n\n## Must-Haves\n- done\n\n## Tasks\n- [ ] **T01: Implement** `est:10m`\n do it\n",
|
||||
"utf-8",
|
||||
);
|
||||
run("git add .", base);
|
||||
run("git commit -m 'chore: init'", base);
|
||||
|
||||
async function main(): Promise<void> {
|
||||
// ── Verify main tree baseline ──────────────────────────────────────────────
|
||||
|
||||
console.log("\n=== Main tree baseline ===");
|
||||
assertEq(getMainBranch(base), "main", "main tree getMainBranch returns main");
|
||||
assertEq(detectWorktreeName(base), null, "main tree not detected as worktree");
|
||||
|
||||
// ── Create worktree and verify detection ───────────────────────────────────
|
||||
|
||||
console.log("\n=== Create worktree ===");
|
||||
const wt = createWorktree(base, "alpha");
|
||||
assert(existsSync(wt.path), "worktree created on disk");
|
||||
assertEq(wt.branch, "worktree/alpha", "worktree branch name");
|
||||
|
||||
console.log("\n=== Worktree detection ===");
|
||||
assertEq(detectWorktreeName(wt.path), "alpha", "detectWorktreeName inside worktree");
|
||||
assertEq(getMainBranch(wt.path), "worktree/alpha", "getMainBranch returns worktree branch inside worktree");
|
||||
|
||||
// ── Verify current branch inside worktree ──────────────────────────────────
|
||||
|
||||
console.log("\n=== Worktree initial branch ===");
|
||||
assertEq(getCurrentBranch(wt.path), "worktree/alpha", "worktree starts on its own branch");
|
||||
|
||||
// ── ensureSliceBranch inside worktree ──────────────────────────────────────
|
||||
|
||||
console.log("\n=== ensureSliceBranch in worktree ===");
|
||||
const created = ensureSliceBranch(wt.path, "M001", "S01");
|
||||
assert(created, "slice branch created");
|
||||
assertEq(getCurrentBranch(wt.path), "gsd/alpha/M001/S01", "worktree-namespaced slice branch");
|
||||
assert(isOnSliceBranch(wt.path), "isOnSliceBranch returns true");
|
||||
assertEq(getActiveSliceBranch(wt.path), "gsd/alpha/M001/S01", "getActiveSliceBranch returns namespaced branch");
|
||||
|
||||
// ── Verify branch name helper ──────────────────────────────────────────────
|
||||
|
||||
console.log("\n=== getSliceBranchName with worktree ===");
|
||||
assertEq(getSliceBranchName("M001", "S01", "alpha"), "gsd/alpha/M001/S01", "explicit worktree param");
|
||||
assertEq(getSliceBranchName("M001", "S01"), "gsd/M001/S01", "no worktree param = plain branch");
|
||||
|
||||
// ── Do work on slice branch, then merge to worktree branch ─────────────────
|
||||
|
||||
console.log("\n=== Work and merge slice in worktree ===");
|
||||
writeFileSync(join(wt.path, "feature.txt"), "new feature\n", "utf-8");
|
||||
run("git add .", wt.path);
|
||||
run("git commit -m 'feat: add feature'", wt.path);
|
||||
|
||||
// switchToMain should go to worktree/alpha, NOT main
|
||||
switchToMain(wt.path);
|
||||
assertEq(getCurrentBranch(wt.path), "worktree/alpha", "switchToMain goes to worktree branch, not main");
|
||||
|
||||
// mergeSliceToMain should merge into worktree/alpha
|
||||
const merge = mergeSliceToMain(wt.path, "M001", "S01", "First");
|
||||
assertEq(merge.branch, "gsd/alpha/M001/S01", "merged the namespaced branch");
|
||||
assert(merge.deletedBranch, "slice branch deleted after merge");
|
||||
assertEq(getCurrentBranch(wt.path), "worktree/alpha", "still on worktree branch after merge");
|
||||
assert(readFileSync(join(wt.path, "feature.txt"), "utf-8").includes("new feature"), "merge brought feature to worktree branch");
|
||||
|
||||
// Verify slice branch is gone
|
||||
const branches = run("git branch", base);
|
||||
assert(!branches.includes("gsd/alpha/M001/S01"), "slice branch cleaned up");
|
||||
|
||||
// ── Second slice in same worktree ──────────────────────────────────────────
|
||||
|
||||
console.log("\n=== Second slice in worktree ===");
|
||||
const created2 = ensureSliceBranch(wt.path, "M001", "S02");
|
||||
assert(created2, "S02 branch created");
|
||||
assertEq(getCurrentBranch(wt.path), "gsd/alpha/M001/S02", "on S02 namespaced branch");
|
||||
|
||||
writeFileSync(join(wt.path, "feature2.txt"), "second feature\n", "utf-8");
|
||||
run("git add .", wt.path);
|
||||
run("git commit -m 'feat: add feature 2'", wt.path);
|
||||
|
||||
switchToMain(wt.path);
|
||||
const merge2 = mergeSliceToMain(wt.path, "M001", "S02", "Second");
|
||||
assertEq(merge2.branch, "gsd/alpha/M001/S02", "S02 merge correct");
|
||||
assertEq(getCurrentBranch(wt.path), "worktree/alpha", "back on worktree branch");
|
||||
|
||||
// ── Main tree can still do its own slice work independently ────────────────
|
||||
|
||||
console.log("\n=== Main tree independent slice work ===");
|
||||
assertEq(getCurrentBranch(base), "main", "main tree still on main");
|
||||
const mainCreated = ensureSliceBranch(base, "M001", "S01");
|
||||
assert(mainCreated, "main tree can create S01 branch (no conflict with worktree)");
|
||||
assertEq(getCurrentBranch(base), "gsd/M001/S01", "main tree on plain branch name");
|
||||
|
||||
writeFileSync(join(base, "main-feature.txt"), "main work\n", "utf-8");
|
||||
run("git add .", base);
|
||||
run("git commit -m 'feat: main work'", base);
|
||||
|
||||
switchToMain(base);
|
||||
assertEq(getCurrentBranch(base), "main", "main tree switchToMain goes to main");
|
||||
const mainMerge = mergeSliceToMain(base, "M001", "S01", "First");
|
||||
assertEq(mainMerge.branch, "gsd/M001/S01", "main tree merge uses plain branch");
|
||||
|
||||
// ── Parallel worktrees don't conflict ──────────────────────────────────────
|
||||
|
||||
console.log("\n=== Parallel worktrees ===");
|
||||
const wt2 = createWorktree(base, "beta");
|
||||
assertEq(getMainBranch(wt2.path), "worktree/beta", "second worktree has its own base branch");
|
||||
|
||||
// Both worktrees can create S01 branches without conflict
|
||||
const betaCreated = ensureSliceBranch(wt2.path, "M001", "S01");
|
||||
assert(betaCreated, "beta worktree can create S01");
|
||||
assertEq(getCurrentBranch(wt2.path), "gsd/beta/M001/S01", "beta has its own namespaced branch");
|
||||
|
||||
// Alpha worktree can re-create S01 too (it was already merged+deleted earlier)
|
||||
const alphaReCreated = ensureSliceBranch(wt.path, "M001", "S01");
|
||||
assert(alphaReCreated, "alpha worktree can re-create S01");
|
||||
assertEq(getCurrentBranch(wt.path), "gsd/alpha/M001/S01", "alpha re-created S01");
|
||||
|
||||
// Both exist simultaneously
|
||||
const allBranches = run("git branch", base);
|
||||
assert(allBranches.includes("gsd/alpha/M001/S01"), "alpha S01 branch exists");
|
||||
assert(allBranches.includes("gsd/beta/M001/S01"), "beta S01 branch exists");
|
||||
|
||||
// ── State derivation in worktree ───────────────────────────────────────────
|
||||
|
||||
console.log("\n=== State derivation in worktree ===");
|
||||
// Switch alpha back to its base so deriveState sees milestone files
|
||||
switchToMain(wt.path);
|
||||
const state = await deriveState(wt.path);
|
||||
assert(state.activeMilestone !== null, "worktree has active milestone");
|
||||
assertEq(state.activeMilestone?.id, "M001", "correct milestone");
|
||||
|
||||
// ── autoCommitCurrentBranch in worktree ────────────────────────────────────
|
||||
|
||||
console.log("\n=== autoCommitCurrentBranch in worktree ===");
|
||||
ensureSliceBranch(wt2.path, "M001", "S01"); // re-checkout if needed
|
||||
writeFileSync(join(wt2.path, "dirty.txt"), "uncommitted\n", "utf-8");
|
||||
const commitMsg = autoCommitCurrentBranch(wt2.path, "execute-task", "M001/S01/T01");
|
||||
assert(commitMsg !== null, "auto-commit works in worktree");
|
||||
assertEq(run("git status --short", wt2.path), "", "worktree clean after auto-commit");
|
||||
|
||||
// ── Cleanup ────────────────────────────────────────────────────────────────
|
||||
|
||||
console.log("\n=== Cleanup ===");
|
||||
// Switch worktrees back to their base branches before removal
|
||||
switchToMain(wt.path);
|
||||
switchToMain(wt2.path);
|
||||
removeWorktree(base, "alpha", { deleteBranch: true });
|
||||
removeWorktree(base, "beta", { deleteBranch: true });
|
||||
assertEq(listWorktrees(base).length, 0, "all worktrees removed");
|
||||
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
|
||||
console.log(`\nResults: ${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
console.log("All tests passed ✓");
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -1,15 +1,19 @@
|
|||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
import {
|
||||
autoCommitCurrentBranch,
|
||||
detectWorktreeName,
|
||||
ensureSliceBranch,
|
||||
getActiveSliceBranch,
|
||||
getCurrentBranch,
|
||||
getSliceBranchName,
|
||||
isOnSliceBranch,
|
||||
mergeSliceToMain,
|
||||
parseSliceBranch,
|
||||
SLICE_BRANCH_RE,
|
||||
switchToMain,
|
||||
} from "../worktree.ts";
|
||||
import { deriveState } from "../state.ts";
|
||||
|
|
@ -136,6 +140,117 @@ async function main(): Promise<void> {
|
|||
|
||||
console.log("\n=== getSliceBranchName ===");
|
||||
assertEq(getSliceBranchName("M001", "S01"), "gsd/M001/S01", "branch name format correct");
|
||||
assertEq(getSliceBranchName("M001", "S01", null), "gsd/M001/S01", "null worktree = plain branch");
|
||||
assertEq(getSliceBranchName("M001", "S01", "my-wt"), "gsd/my-wt/M001/S01", "worktree-namespaced branch");
|
||||
|
||||
console.log("\n=== parseSliceBranch ===");
|
||||
const plain = parseSliceBranch("gsd/M001/S01");
|
||||
assert(plain !== null, "parses plain branch");
|
||||
assertEq(plain!.worktreeName, null, "plain branch has no worktree name");
|
||||
assertEq(plain!.milestoneId, "M001", "plain branch milestone");
|
||||
assertEq(plain!.sliceId, "S01", "plain branch slice");
|
||||
|
||||
const namespaced = parseSliceBranch("gsd/feature-auth/M001/S01");
|
||||
assert(namespaced !== null, "parses worktree-namespaced branch");
|
||||
assertEq(namespaced!.worktreeName, "feature-auth", "worktree name extracted");
|
||||
assertEq(namespaced!.milestoneId, "M001", "namespaced branch milestone");
|
||||
assertEq(namespaced!.sliceId, "S01", "namespaced branch slice");
|
||||
|
||||
const invalid = parseSliceBranch("main");
|
||||
assertEq(invalid, null, "non-slice branch returns null");
|
||||
|
||||
const worktreeBranch = parseSliceBranch("worktree/foo");
|
||||
assertEq(worktreeBranch, null, "worktree/ prefix is not a slice branch");
|
||||
|
||||
console.log("\n=== SLICE_BRANCH_RE ===");
|
||||
assert(SLICE_BRANCH_RE.test("gsd/M001/S01"), "regex matches plain branch");
|
||||
assert(SLICE_BRANCH_RE.test("gsd/my-wt/M001/S01"), "regex matches worktree branch");
|
||||
assert(!SLICE_BRANCH_RE.test("main"), "regex rejects main");
|
||||
assert(!SLICE_BRANCH_RE.test("gsd/"), "regex rejects bare gsd/");
|
||||
assert(!SLICE_BRANCH_RE.test("worktree/foo"), "regex rejects worktree/foo");
|
||||
|
||||
console.log("\n=== detectWorktreeName ===");
|
||||
assertEq(detectWorktreeName("/projects/myapp"), null, "no worktree in plain path");
|
||||
assertEq(detectWorktreeName("/projects/myapp/.gsd/worktrees/feature-auth"), "feature-auth", "detects worktree name");
|
||||
assertEq(detectWorktreeName("/projects/myapp/.gsd/worktrees/my-wt/subdir"), "my-wt", "detects worktree with subdir");
|
||||
|
||||
// ── Regression: slice branch from non-main working branch ───────────
|
||||
// Reproduces the bug where planning artifacts committed to a working
|
||||
// branch (e.g. "developer") are lost when the slice branch is created
|
||||
// from "main" which doesn't have them.
|
||||
console.log("\n=== ensureSliceBranch from non-main working branch ===");
|
||||
const base2 = mkdtempSync(join(tmpdir(), "gsd-branch-base-test-"));
|
||||
run("git init -b main", base2);
|
||||
run("git config user.name 'Pi Test'", base2);
|
||||
run("git config user.email 'pi@example.com'", base2);
|
||||
writeFileSync(join(base2, "README.md"), "hello\n", "utf-8");
|
||||
run("git add .", base2);
|
||||
run("git commit -m 'chore: init'", base2);
|
||||
|
||||
// Create a "developer" branch with planning artifacts (like the real scenario)
|
||||
run("git checkout -b developer", base2);
|
||||
mkdirSync(join(base2, ".gsd", "milestones", "M001", "slices", "S01", "tasks"), { recursive: true });
|
||||
writeFileSync(join(base2, ".gsd", "milestones", "M001", "M001-CONTEXT.md"), "# M001 Context\nGoal: fix eslint\n", "utf-8");
|
||||
writeFileSync(join(base2, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), [
|
||||
"# M001: ESLint Cleanup", "", "## Slices",
|
||||
"- [ ] **S01: Config Fix** `risk:low` `depends:[]`", " > Fix config",
|
||||
].join("\n") + "\n", "utf-8");
|
||||
run("git add .", base2);
|
||||
run("git commit -m 'docs(M001): context and roadmap'", base2);
|
||||
|
||||
// Verify main does NOT have the artifacts
|
||||
const mainRoadmap = run("git show main:.gsd/milestones/M001/M001-ROADMAP.md 2>&1 || echo MISSING", base2);
|
||||
assert(mainRoadmap.includes("MISSING") || mainRoadmap.includes("does not exist"), "main branch lacks roadmap");
|
||||
|
||||
// Now create slice branch from developer — should inherit artifacts
|
||||
assertEq(getCurrentBranch(base2), "developer", "on developer branch before ensure");
|
||||
const created3 = ensureSliceBranch(base2, "M001", "S01");
|
||||
assert(created3, "slice branch created from developer");
|
||||
assertEq(getCurrentBranch(base2), "gsd/M001/S01", "switched to slice branch");
|
||||
|
||||
// The critical assertion: planning artifacts must exist on the slice branch
|
||||
assert(existsSync(join(base2, ".gsd", "milestones", "M001", "M001-ROADMAP.md")), "roadmap exists on slice branch");
|
||||
assert(existsSync(join(base2, ".gsd", "milestones", "M001", "M001-CONTEXT.md")), "context exists on slice branch");
|
||||
|
||||
// Verify deriveState sees the correct phase (not pre-planning)
|
||||
const state2 = await deriveState(base2);
|
||||
assertEq(state2.phase, "planning", "deriveState sees planning phase on slice branch");
|
||||
assert(state2.activeSlice !== null, "active slice found");
|
||||
assertEq(state2.activeSlice!.id, "S01", "active slice is S01");
|
||||
|
||||
rmSync(base2, { recursive: true, force: true });
|
||||
|
||||
// ── Slice branch from another slice branch falls back to main ───────
|
||||
console.log("\n=== ensureSliceBranch from slice branch falls back to main ===");
|
||||
const base3 = mkdtempSync(join(tmpdir(), "gsd-branch-chain-test-"));
|
||||
run("git init -b main", base3);
|
||||
run("git config user.name 'Pi Test'", base3);
|
||||
run("git config user.email 'pi@example.com'", base3);
|
||||
mkdirSync(join(base3, ".gsd", "milestones", "M001", "slices", "S01", "tasks"), { recursive: true });
|
||||
mkdirSync(join(base3, ".gsd", "milestones", "M001", "slices", "S02", "tasks"), { recursive: true });
|
||||
writeFileSync(join(base3, "README.md"), "hello\n", "utf-8");
|
||||
writeFileSync(join(base3, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), [
|
||||
"# M001: Demo", "", "## Slices",
|
||||
"- [ ] **S01: First** `risk:low` `depends:[]`", " > first",
|
||||
"- [ ] **S02: Second** `risk:low` `depends:[]`", " > second",
|
||||
].join("\n") + "\n", "utf-8");
|
||||
run("git add .", base3);
|
||||
run("git commit -m 'chore: init'", base3);
|
||||
|
||||
ensureSliceBranch(base3, "M001", "S01");
|
||||
assertEq(getCurrentBranch(base3), "gsd/M001/S01", "on S01 slice branch");
|
||||
|
||||
// Creating S02 while on S01 should NOT chain from S01 — should use main
|
||||
const created4 = ensureSliceBranch(base3, "M001", "S02");
|
||||
assert(created4, "S02 branch created");
|
||||
assertEq(getCurrentBranch(base3), "gsd/M001/S02", "switched to S02");
|
||||
|
||||
// S02 should be based on main, not on gsd/M001/S01
|
||||
const s02Base = run("git merge-base main gsd/M001/S02", base3);
|
||||
const mainHead = run("git rev-parse main", base3);
|
||||
assertEq(s02Base, mainHead, "S02 is based on main, not on S01 slice branch");
|
||||
|
||||
rmSync(base3, { recursive: true, force: true });
|
||||
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
console.log(`\nResults: ${passed} passed, ${failed} failed`);
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import {
|
|||
} from "./paths.ts";
|
||||
import { deriveState } from "./state.ts";
|
||||
import { type ValidationIssue, validateCompleteBoundary, validatePlanBoundary } from "./observability-validator.ts";
|
||||
import { getSliceBranchName } from "./worktree.ts";
|
||||
import { getSliceBranchName, detectWorktreeName } from "./worktree.ts";
|
||||
|
||||
export interface WorkspaceTaskTarget {
|
||||
id: string;
|
||||
|
|
@ -112,7 +112,7 @@ async function indexSlice(basePath: string, milestoneId: string, sliceId: string
|
|||
summaryPath,
|
||||
uatPath,
|
||||
tasksDir,
|
||||
branch: getSliceBranchName(milestoneId, sliceId),
|
||||
branch: getSliceBranchName(milestoneId, sliceId, detectWorktreeName(basePath)),
|
||||
tasks,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-cod
|
|||
import { loadPrompt } from "./prompt-loader.js";
|
||||
import { autoCommitCurrentBranch } from "./worktree.js";
|
||||
import { showConfirm } from "../shared/confirm-ui.js";
|
||||
import { gsdRoot, milestonesDir } from "./paths.js";
|
||||
import {
|
||||
createWorktree,
|
||||
listWorktrees,
|
||||
|
|
@ -28,7 +29,7 @@ import {
|
|||
worktreePath,
|
||||
} from "./worktree-manager.js";
|
||||
import type { FileLineStat } from "./worktree-manager.js";
|
||||
import { existsSync, realpathSync, readFileSync, utimesSync } from "node:fs";
|
||||
import { existsSync, realpathSync, readFileSync, readdirSync, rmSync, unlinkSync, utimesSync } from "node:fs";
|
||||
import { join, resolve, sep } from "node:path";
|
||||
|
||||
/**
|
||||
|
|
@ -304,6 +305,44 @@ export function registerWorktreeCommand(pi: ExtensionAPI): void {
|
|||
|
||||
// ─── Handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if the worktree has existing GSD milestones that would
|
||||
* cause auto-mode to continue previous work instead of starting fresh.
|
||||
*/
|
||||
function hasExistingMilestones(wtPath: string): boolean {
|
||||
const mDir = milestonesDir(wtPath);
|
||||
if (!existsSync(mDir)) return false;
|
||||
try {
|
||||
const entries = readdirSync(mDir, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory() && /^M\d+/.test(d.name));
|
||||
return entries.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear GSD planning artifacts so auto-mode starts fresh with the discuss flow.
|
||||
* Keeps the .gsd/ directory structure intact but removes milestones and root planning files.
|
||||
*/
|
||||
function clearGSDPlans(wtPath: string): void {
|
||||
const mDir = milestonesDir(wtPath);
|
||||
if (existsSync(mDir)) {
|
||||
rmSync(mDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// Remove root planning files — PROJECT.md, DECISIONS.md, QUEUE.md, REQUIREMENTS.md
|
||||
// Keep STATE.md (gitignored, will be rebuilt) and other runtime files
|
||||
const root = gsdRoot(wtPath);
|
||||
const planningFiles = ["PROJECT.md", "DECISIONS.md", "QUEUE.md", "REQUIREMENTS.md"];
|
||||
for (const file of planningFiles) {
|
||||
const filePath = join(root, file);
|
||||
if (existsSync(filePath)) {
|
||||
unlinkSync(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate(
|
||||
basePath: string,
|
||||
name: string,
|
||||
|
|
@ -324,16 +363,45 @@ async function handleCreate(
|
|||
process.chdir(info.path);
|
||||
nudgeGitBranchCache(prevCwd);
|
||||
|
||||
const commitNote = commitMsg ? `\n Auto-committed on previous branch before switching.` : "";
|
||||
// If the worktree inherited existing milestones, ask whether to keep or clear them
|
||||
let clearedPlans = false;
|
||||
if (hasExistingMilestones(info.path)) {
|
||||
// confirmLabel = Continue (safe default, on the left / first)
|
||||
// declineLabel = Start fresh (destructive, on the right)
|
||||
const keepExisting = await showConfirm(ctx, {
|
||||
title: "Worktree Setup",
|
||||
message: [
|
||||
`This worktree inherited existing GSD milestones from the main branch.`,
|
||||
``,
|
||||
` Continue — keep milestones and pick up where main left off`,
|
||||
` Start fresh — clear milestones so /gsd auto starts a new project`,
|
||||
].join("\n"),
|
||||
confirmLabel: "Continue",
|
||||
declineLabel: "Start fresh",
|
||||
});
|
||||
if (!keepExisting) {
|
||||
clearGSDPlans(info.path);
|
||||
clearedPlans = true;
|
||||
}
|
||||
}
|
||||
|
||||
const commitNote = commitMsg
|
||||
? ` ${CLR.muted("Auto-committed on previous branch before switching.")}`
|
||||
: "";
|
||||
const freshNote = clearedPlans
|
||||
? ` ${CLR.ok("✓")} Cleared milestones — ${CLR.hint("/gsd auto")} will start fresh.`
|
||||
: "";
|
||||
ctx.ui.notify(
|
||||
[
|
||||
`Worktree "${name}" created and activated.`,
|
||||
` Path: ${info.path}`,
|
||||
` Branch: ${info.branch}`,
|
||||
`${CLR.ok("✓")} Worktree ${CLR.name(name)} created and activated.`,
|
||||
"",
|
||||
` ${CLR.label("path")} ${CLR.path(info.path)}`,
|
||||
` ${CLR.label("branch")} ${CLR.branch(info.branch)}`,
|
||||
commitNote,
|
||||
`Session is now in the worktree. All commands run here.`,
|
||||
`Use /worktree merge ${name} to merge back when done.`,
|
||||
`Use /worktree return to switch back to the main tree.`,
|
||||
freshNote,
|
||||
"",
|
||||
` ${CLR.hint(`/worktree merge ${name}`)} ${CLR.muted("merge back when done")}`,
|
||||
` ${CLR.hint("/worktree return")}${" ".repeat(Math.max(1, name.length - 2))} ${CLR.muted("switch back to main tree")}`,
|
||||
].filter(Boolean).join("\n"),
|
||||
"info",
|
||||
);
|
||||
|
|
@ -370,14 +438,18 @@ async function handleSwitch(
|
|||
process.chdir(wtPath);
|
||||
nudgeGitBranchCache(prevCwd);
|
||||
|
||||
const commitNote = commitMsg ? `\n Auto-committed on previous branch before switching.` : "";
|
||||
const commitNote = commitMsg
|
||||
? ` ${CLR.muted("Auto-committed on previous branch before switching.")}`
|
||||
: "";
|
||||
ctx.ui.notify(
|
||||
[
|
||||
`Switched to worktree "${name}".`,
|
||||
` Path: ${wtPath}`,
|
||||
` Branch: ${worktreeBranchName(name)}`,
|
||||
`${CLR.ok("✓")} Switched to worktree ${CLR.name(name)}.`,
|
||||
"",
|
||||
` ${CLR.label("path")} ${CLR.path(wtPath)}`,
|
||||
` ${CLR.label("branch")} ${CLR.branch(worktreeBranchName(name))}`,
|
||||
commitNote,
|
||||
`Use /worktree return to switch back to the main tree.`,
|
||||
"",
|
||||
` ${CLR.hint("/worktree return")} ${CLR.muted("switch back to main tree")}`,
|
||||
].filter(Boolean).join("\n"),
|
||||
"info",
|
||||
);
|
||||
|
|
@ -403,26 +475,56 @@ async function handleReturn(ctx: ExtensionCommandContext): Promise<void> {
|
|||
process.chdir(returnTo);
|
||||
nudgeGitBranchCache(prevCwd);
|
||||
|
||||
const commitNote = commitMsg ? `\n Auto-committed on worktree branch before returning.` : "";
|
||||
const commitNote = commitMsg
|
||||
? ` ${CLR.muted("Auto-committed on worktree branch before returning.")}`
|
||||
: "";
|
||||
ctx.ui.notify(
|
||||
[
|
||||
`Returned to main project tree.`,
|
||||
` Path: ${returnTo}`,
|
||||
`${CLR.ok("✓")} Returned to main project tree.`,
|
||||
"",
|
||||
` ${CLR.label("path")} ${CLR.path(returnTo)}`,
|
||||
commitNote,
|
||||
].filter(Boolean).join("\n"),
|
||||
"info",
|
||||
);
|
||||
}
|
||||
|
||||
// ANSI helpers for list formatting
|
||||
const BOLD = "\x1b[1m";
|
||||
const DIM = "\x1b[2m";
|
||||
const RESET = "\x1b[0m";
|
||||
const CYAN = "\x1b[36m";
|
||||
const GREEN = "\x1b[32m";
|
||||
const RED = "\x1b[31m";
|
||||
// ─── ANSI styling ─────────────────────────────────────────────────────────
|
||||
// Consistent palette for all worktree command output.
|
||||
|
||||
const BOLD = "\x1b[1m";
|
||||
const DIM = "\x1b[2m";
|
||||
const RESET = "\x1b[0m";
|
||||
const CYAN = "\x1b[36m";
|
||||
const GREEN = "\x1b[32m";
|
||||
const RED = "\x1b[31m";
|
||||
const YELLOW = "\x1b[33m";
|
||||
const WHITE = "\x1b[37m";
|
||||
const WHITE = "\x1b[37m";
|
||||
const MAGENTA = "\x1b[35m";
|
||||
|
||||
// Semantic aliases for consistent use across all handlers
|
||||
const CLR = {
|
||||
/** Worktree names and primary emphasis */
|
||||
name: (s: string) => `${BOLD}${CYAN}${s}${RESET}`,
|
||||
/** Active worktree name */
|
||||
nameActive: (s: string) => `${BOLD}${GREEN}${s}${RESET}`,
|
||||
/** Branch names */
|
||||
branch: (s: string) => `${MAGENTA}${s}${RESET}`,
|
||||
/** File paths */
|
||||
path: (s: string) => `${DIM}${s}${RESET}`,
|
||||
/** Labels (key in key:value pairs) */
|
||||
label: (s: string) => `${WHITE}${s}${RESET}`,
|
||||
/** Hints and commands the user can run */
|
||||
hint: (s: string) => `${DIM}${CYAN}${s}${RESET}`,
|
||||
/** Success messages and checks */
|
||||
ok: (s: string) => `${GREEN}${s}${RESET}`,
|
||||
/** Warning badges */
|
||||
warn: (s: string) => `${YELLOW}${s}${RESET}`,
|
||||
/** Section headers */
|
||||
header: (s: string) => `${BOLD}${WHITE}${s}${RESET}`,
|
||||
/** Muted secondary info */
|
||||
muted: (s: string) => `${DIM}${s}${RESET}`,
|
||||
} as const;
|
||||
|
||||
async function handleList(
|
||||
basePath: string,
|
||||
|
|
@ -438,22 +540,26 @@ async function handleList(
|
|||
}
|
||||
|
||||
const cwd = process.cwd();
|
||||
const lines = [`${BOLD}${WHITE}GSD Worktrees${RESET}`, ""];
|
||||
const lines = [CLR.header("GSD Worktrees"), ""];
|
||||
for (const wt of worktrees) {
|
||||
const isCurrent = cwd === wt.path
|
||||
|| (existsSync(cwd) && existsSync(wt.path)
|
||||
&& realpathSync(cwd) === realpathSync(wt.path));
|
||||
|
||||
const nameColor = isCurrent ? GREEN : CYAN;
|
||||
const badge = isCurrent ? ` ${GREEN}● active${RESET}` : !wt.exists ? ` ${YELLOW}✗ missing${RESET}` : "";
|
||||
lines.push(` ${BOLD}${nameColor}${wt.name}${RESET}${badge}`);
|
||||
lines.push(` ${DIM} branch${RESET} ${wt.branch}`);
|
||||
lines.push(` ${DIM} path${RESET} ${DIM}${wt.path}${RESET}`);
|
||||
const styledName = isCurrent ? CLR.nameActive(wt.name) : CLR.name(wt.name);
|
||||
const badge = isCurrent
|
||||
? ` ${CLR.ok("● active")}`
|
||||
: !wt.exists
|
||||
? ` ${CLR.warn("✗ missing")}`
|
||||
: "";
|
||||
lines.push(` ${styledName}${badge}`);
|
||||
lines.push(` ${CLR.label("branch")} ${CLR.branch(wt.branch)}`);
|
||||
lines.push(` ${CLR.label("path")} ${CLR.path(wt.path)}`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (originalCwd) {
|
||||
lines.push(`${DIM}Main tree: ${originalCwd}${RESET}`);
|
||||
lines.push(` ${CLR.label("main tree")} ${CLR.path(originalCwd)}`);
|
||||
}
|
||||
|
||||
ctx.ui.notify(lines.join("\n"), "info");
|
||||
|
|
@ -491,7 +597,7 @@ async function handleMerge(
|
|||
|
||||
const totalChanges = diffSummary.added.length + diffSummary.modified.length + diffSummary.removed.length;
|
||||
if (totalChanges === 0 && !commitLog.trim()) {
|
||||
ctx.ui.notify(`Worktree "${name}" has no changes to merge.`, "info");
|
||||
ctx.ui.notify(`Worktree ${CLR.name(name)} has no changes to merge.`, "info");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -516,15 +622,15 @@ async function handleMerge(
|
|||
// Format a file line with +/- stats
|
||||
const formatFileLine = (prefix: string, file: string): string => {
|
||||
const s = statMap.get(file);
|
||||
const stat = s ? ` ${GREEN}+${s.added}${RESET} ${RED}-${s.removed}${RESET}` : "";
|
||||
const stat = s ? ` ${CLR.ok(`+${s.added}`)} ${RED}-${s.removed}${RESET}` : "";
|
||||
return ` ${prefix} ${file}${stat}`;
|
||||
};
|
||||
|
||||
// Preview confirmation before merge dispatch
|
||||
const previewLines = [
|
||||
`Merge worktree "${name}" → ${mainBranch}`,
|
||||
`Merge ${CLR.name(name)} → ${CLR.branch(mainBranch)}`,
|
||||
"",
|
||||
` ${totalChanges} file${totalChanges === 1 ? "" : "s"} changed, ${GREEN}+${totalAdded}${RESET} ${RED}-${totalRemoved}${RESET} lines (${codeChanges} code, ${gsdChanges} GSD)`,
|
||||
` ${totalChanges} file${totalChanges === 1 ? "" : "s"} changed, ${CLR.ok(`+${totalAdded}`)} ${RED}-${totalRemoved}${RESET} lines ${CLR.muted(`(${codeChanges} code, ${gsdChanges} GSD)`)}`,
|
||||
];
|
||||
|
||||
const appendFileList = (label: string, files: string[], prefix: string, limit = 10) => {
|
||||
|
|
@ -590,7 +696,7 @@ async function handleMerge(
|
|||
);
|
||||
|
||||
ctx.ui.notify(
|
||||
`Merge helper started for worktree "${name}" (${codeChanges} code + ${gsdChanges} GSD artifact change${totalChanges === 1 ? "" : "s"}).`,
|
||||
`${CLR.ok("✓")} Merge helper started for ${CLR.name(name)} ${CLR.muted(`(${codeChanges} code + ${gsdChanges} GSD artifact change${totalChanges === 1 ? "" : "s"})`)}`,
|
||||
"info",
|
||||
);
|
||||
} catch (error) {
|
||||
|
|
@ -617,7 +723,7 @@ async function handleRemove(
|
|||
|
||||
const confirmed = await showConfirm(ctx, {
|
||||
title: "Remove Worktree",
|
||||
message: `Remove worktree "${name}" and delete branch ${wt.branch}?`,
|
||||
message: `Remove worktree ${CLR.name(name)} and delete branch ${CLR.branch(wt.branch)}?`,
|
||||
confirmLabel: "Remove",
|
||||
declineLabel: "Cancel",
|
||||
});
|
||||
|
|
@ -635,7 +741,7 @@ async function handleRemove(
|
|||
originalCwd = null;
|
||||
}
|
||||
|
||||
ctx.ui.notify(`Worktree "${name}" removed (branch deleted).`, "info");
|
||||
ctx.ui.notify(`${CLR.ok("✓")} Worktree ${CLR.name(name)} removed ${CLR.muted("(branch deleted)")}.`, "info");
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
ctx.ui.notify(`Failed to remove worktree: ${msg}`, "error");
|
||||
|
|
@ -658,7 +764,7 @@ async function handleRemoveAll(
|
|||
const names = worktrees.map(w => w.name);
|
||||
const confirmed = await showConfirm(ctx, {
|
||||
title: "Remove All Worktrees",
|
||||
message: `This will remove ${worktrees.length} worktree${worktrees.length === 1 ? "" : "s"} and delete their branches:\n\n${names.map(n => ` • ${n}`).join("\n")}`,
|
||||
message: `Remove ${worktrees.length} worktree${worktrees.length === 1 ? "" : "s"} and delete their branches?\n\n${names.map(n => ` • ${CLR.name(n)}`).join("\n")}`,
|
||||
confirmLabel: "Remove all",
|
||||
declineLabel: "Cancel",
|
||||
});
|
||||
|
|
@ -687,8 +793,8 @@ async function handleRemoveAll(
|
|||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
if (removed.length > 0) lines.push(`Removed: ${removed.join(", ")}`);
|
||||
if (failed.length > 0) lines.push(`Failed: ${failed.join(", ")}`);
|
||||
if (removed.length > 0) lines.push(`${CLR.ok("✓")} Removed: ${removed.map(n => CLR.name(n)).join(", ")}`);
|
||||
if (failed.length > 0) lines.push(`${CLR.warn("✗")} Failed: ${failed.map(n => CLR.name(n)).join(", ")}`);
|
||||
ctx.ui.notify(lines.join("\n"), failed.length > 0 ? "warning" : "info");
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
|
||||
import { existsSync } from "node:fs";
|
||||
import { execSync } from "node:child_process";
|
||||
import { sep } from "node:path";
|
||||
|
||||
export interface MergeSliceResult {
|
||||
branch: string;
|
||||
|
|
@ -34,11 +35,83 @@ function runGit(basePath: string, args: string[], options: { allowFailure?: bool
|
|||
}
|
||||
}
|
||||
|
||||
export function getSliceBranchName(milestoneId: string, sliceId: string): string {
|
||||
/**
|
||||
* Detect the active worktree name from the current working directory.
|
||||
* Returns null if not inside a GSD worktree (.gsd/worktrees/<name>/).
|
||||
*/
|
||||
export function detectWorktreeName(basePath: string): string | null {
|
||||
const marker = `${sep}.gsd${sep}worktrees${sep}`;
|
||||
const idx = basePath.indexOf(marker);
|
||||
if (idx === -1) return null;
|
||||
const afterMarker = basePath.slice(idx + marker.length);
|
||||
const name = afterMarker.split(sep)[0] ?? afterMarker.split("/")[0];
|
||||
return name || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the slice branch name, namespaced by worktree when inside one.
|
||||
*
|
||||
* In the main tree: gsd/<milestoneId>/<sliceId>
|
||||
* In a worktree: gsd/<worktreeName>/<milestoneId>/<sliceId>
|
||||
*
|
||||
* This prevents branch conflicts when multiple worktrees work on the
|
||||
* same milestone/slice IDs — git doesn't allow a branch to be checked
|
||||
* out in more than one worktree simultaneously.
|
||||
*/
|
||||
export function getSliceBranchName(milestoneId: string, sliceId: string, worktreeName?: string | null): string {
|
||||
if (worktreeName) {
|
||||
return `gsd/${worktreeName}/${milestoneId}/${sliceId}`;
|
||||
}
|
||||
return `gsd/${milestoneId}/${sliceId}`;
|
||||
}
|
||||
|
||||
/** Regex that matches both plain and worktree-namespaced slice branches. */
|
||||
export const SLICE_BRANCH_RE = /^gsd\/(?:([a-zA-Z0-9_-]+)\/)?(M\d+)\/(S\d+)$/;
|
||||
|
||||
/**
|
||||
* Parse a slice branch name into its components.
|
||||
* Handles both `gsd/M001/S01` and `gsd/myworktree/M001/S01`.
|
||||
*/
|
||||
export function parseSliceBranch(branchName: string): {
|
||||
worktreeName: string | null;
|
||||
milestoneId: string;
|
||||
sliceId: string;
|
||||
} | null {
|
||||
const match = branchName.match(SLICE_BRANCH_RE);
|
||||
if (!match) return null;
|
||||
return {
|
||||
worktreeName: match[1] ?? null,
|
||||
milestoneId: match[2]!,
|
||||
sliceId: match[3]!,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the "main" branch for GSD slice operations.
|
||||
*
|
||||
* In the main working tree: returns main/master (the repo's default branch).
|
||||
* In a worktree: returns worktree/<name> — the worktree's own base branch.
|
||||
*
|
||||
* This is critical because git doesn't allow a branch to be checked out
|
||||
* in more than one worktree. Slice branches merge into the worktree's base
|
||||
* branch, and the worktree branch later merges into the real main via
|
||||
* /worktree merge.
|
||||
*/
|
||||
export function getMainBranch(basePath: string): string {
|
||||
// When inside a worktree, slice branches should merge into the worktree's
|
||||
// own branch (worktree/<name>), not main — main is checked out by the
|
||||
// parent working tree and git would refuse the checkout.
|
||||
const wtName = detectWorktreeName(basePath);
|
||||
if (wtName) {
|
||||
const wtBranch = `worktree/${wtName}`;
|
||||
// Verify the branch exists (it should — createWorktree made it)
|
||||
const exists = runGit(basePath, ["show-ref", "--verify", `refs/heads/${wtBranch}`], { allowFailure: true });
|
||||
if (exists) return wtBranch;
|
||||
// Worktree branch is gone — return current branch rather than falling
|
||||
// through to main/master which would cause a checkout conflict
|
||||
return runGit(basePath, ["branch", "--show-current"]);
|
||||
}
|
||||
|
||||
const symbolic = runGit(basePath, ["symbolic-ref", "refs/remotes/origin/HEAD"], { allowFailure: true });
|
||||
if (symbolic) {
|
||||
const match = symbolic.match(/refs\/remotes\/origin\/(.+)$/);
|
||||
|
|
@ -69,11 +142,16 @@ function branchExists(basePath: string, branch: string): boolean {
|
|||
|
||||
/**
|
||||
* Ensure the slice branch exists and is checked out.
|
||||
* Creates the branch from main if it doesn't exist.
|
||||
* Creates the branch from the current branch if it's not a slice branch,
|
||||
* otherwise from main. This preserves planning artifacts (CONTEXT, ROADMAP,
|
||||
* etc.) that were committed on the working branch — which may differ from
|
||||
* the repo's default branch (e.g. `developer` vs `main`).
|
||||
* When inside a worktree, the branch is namespaced to avoid conflicts.
|
||||
* Returns true if the branch was newly created.
|
||||
*/
|
||||
export function ensureSliceBranch(basePath: string, milestoneId: string, sliceId: string): boolean {
|
||||
const branch = getSliceBranchName(milestoneId, sliceId);
|
||||
const wtName = detectWorktreeName(basePath);
|
||||
const branch = getSliceBranchName(milestoneId, sliceId, wtName);
|
||||
const current = getCurrentBranch(basePath);
|
||||
|
||||
if (current === branch) return false;
|
||||
|
|
@ -81,8 +159,25 @@ export function ensureSliceBranch(basePath: string, milestoneId: string, sliceId
|
|||
let created = false;
|
||||
|
||||
if (!branchExists(basePath, branch)) {
|
||||
runGit(basePath, ["branch", branch]);
|
||||
// Branch from the current branch when it's a normal working branch
|
||||
// (not itself a slice branch). This ensures the new slice branch
|
||||
// inherits planning artifacts that may only exist on the working
|
||||
// branch and haven't been merged to main yet.
|
||||
// If we're already on a slice branch (e.g. creating S02 while S01
|
||||
// wasn't merged yet), fall back to main to avoid chaining slice branches.
|
||||
const mainBranch = getMainBranch(basePath);
|
||||
const base = SLICE_BRANCH_RE.test(current) ? mainBranch : current;
|
||||
runGit(basePath, ["branch", branch, base]);
|
||||
created = true;
|
||||
} else {
|
||||
// Check if the branch is already checked out in another worktree
|
||||
const worktreeList = runGit(basePath, ["worktree", "list", "--porcelain"]);
|
||||
if (worktreeList.includes(`branch refs/heads/${branch}`)) {
|
||||
throw new Error(
|
||||
`Branch "${branch}" is already in use by another worktree. ` +
|
||||
`Remove that worktree first, or switch it to a different branch.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-commit dirty files before checkout to prevent "would be overwritten" errors.
|
||||
|
|
@ -142,7 +237,8 @@ export function switchToMain(basePath: string): void {
|
|||
export function mergeSliceToMain(
|
||||
basePath: string, milestoneId: string, sliceId: string, sliceTitle: string,
|
||||
): MergeSliceResult {
|
||||
const branch = getSliceBranchName(milestoneId, sliceId);
|
||||
const wtName = detectWorktreeName(basePath);
|
||||
const branch = getSliceBranchName(milestoneId, sliceId, wtName);
|
||||
const mainBranch = getMainBranch(basePath);
|
||||
|
||||
const current = getCurrentBranch(basePath);
|
||||
|
|
@ -173,19 +269,21 @@ export function mergeSliceToMain(
|
|||
|
||||
/**
|
||||
* Check if we're currently on a slice branch (not main).
|
||||
* Handles both plain (gsd/M001/S01) and worktree-namespaced (gsd/wt/M001/S01) branches.
|
||||
*/
|
||||
export function isOnSliceBranch(basePath: string): boolean {
|
||||
const current = getCurrentBranch(basePath);
|
||||
return current.startsWith("gsd/");
|
||||
return SLICE_BRANCH_RE.test(current);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active slice branch name, or null if on main.
|
||||
* Handles both plain and worktree-namespaced branch patterns.
|
||||
*/
|
||||
export function getActiveSliceBranch(basePath: string): string | null {
|
||||
try {
|
||||
const current = getCurrentBranch(basePath);
|
||||
return current.startsWith("gsd/") ? current : null;
|
||||
return SLICE_BRANCH_RE.test(current) ? current : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,6 +64,13 @@ const serverDetailCache = new Map<string, McpServerDetail>();
|
|||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function escapeShellArg(arg: string): string {
|
||||
if (process.platform === "win32") {
|
||||
return `"${arg.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return `'${arg.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
async function runMcporter(
|
||||
args: string[],
|
||||
signal?: AbortSignal,
|
||||
|
|
@ -72,18 +79,17 @@ async function runMcporter(
|
|||
// Cross-platform: use execFile on Windows to avoid quote handling issues
|
||||
// On Windows, cmd.exe doesn't strip single quotes like Unix shells do
|
||||
if (process.platform === "win32") {
|
||||
// Use execFile directly - Windows doesn't need shell for PATH resolution here
|
||||
const { stdout } = await execFileAsync("mcporter", args, {
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: 1024 * 1024,
|
||||
signal,
|
||||
env: { ...process.env },
|
||||
shell: true, // Enable shell for PATH resolution on Windows
|
||||
shell: true,
|
||||
});
|
||||
return stdout;
|
||||
}
|
||||
// Use shell exec so PATH resolution works on Unix
|
||||
const escaped = args.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" ");
|
||||
const escaped = args.map((a) => escapeShellArg(a)).join(" ");
|
||||
const { stdout } = await execAsync(`mcporter ${escaped}`, {
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: 1024 * 1024,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue