fix: worktree branch safety — namespacing and slice branch base selection (#92)
* fix: worktree branch namespacing and fresh-start flow - Namespace slice branches by worktree name (gsd/<wt>/<M>/<S>) to prevent git checkout conflicts when multiple worktrees work on the same milestone - getMainBranch() returns worktree/<name> inside a worktree so slice merges target the worktree branch instead of main (which is checked out elsewhere) - Add continue/fresh-start prompt when creating a worktree with existing milestones - Restyle all worktree command output with consistent semantic color palette - Add parseSliceBranch() and SLICE_BRANCH_RE for robust branch name parsing - Fix duplicate getCurrentBranch import in auto.ts - Add 40-assertion integration test covering full worktree lifecycle * fix: branch slice from current branch, not main ensureSliceBranch always branched from getMainBranch() (main/master), but planning artifacts (CONTEXT, ROADMAP, etc.) may only exist on the working branch (e.g. "developer"). The slice branch would lose all planning artifacts, causing deriveState to see pre-planning and the rebuildState post-hook to overwrite STATE.md with a blank state. Now branches from the current branch when it is not itself a slice branch. Falls back to main when on a slice branch to avoid chaining. Adds regression tests for both cases.
This commit is contained in:
parent
3230cd65e9
commit
0607fba4dc
7 changed files with 622 additions and 57 deletions
|
|
@ -61,7 +61,9 @@ import {
|
|||
autoCommitCurrentBranch,
|
||||
ensureSliceBranch,
|
||||
getCurrentBranch,
|
||||
getMainBranch,
|
||||
getSliceBranchName,
|
||||
parseSliceBranch,
|
||||
switchToMain,
|
||||
mergeSliceToMain,
|
||||
} from "./worktree.ts";
|
||||
|
|
@ -905,10 +907,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;
|
||||
|
|
@ -922,8 +924,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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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;
|
||||
|
|
@ -82,7 +160,14 @@ export function ensureSliceBranch(basePath: string, milestoneId: string, sliceId
|
|||
let created = false;
|
||||
|
||||
if (!branchExists(basePath, branch)) {
|
||||
runGit(basePath, ["branch", branch, mainBranch]);
|
||||
// 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 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
|
||||
|
|
@ -152,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);
|
||||
|
|
@ -183,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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue