fix: merge slice branches to integration branch instead of main (#200)

When working on a feature branch (e.g. f-123-new-thing), GSD creates
slice branches correctly from the current branch but merges them back
to main instead of the feature branch. This is because getMainBranch()
always resolved to the repo default branch with no concept of where
the user started.

Fix: record the current branch as the "integration branch" in a
per-milestone metadata file (.gsd/milestones/<MID>/<MID>-META.json)
when auto-mode starts. getMainBranch() checks this metadata before
falling back to repo defaults, so switchToMain() and mergeSliceToMain()
target the correct branch.

Key details:
- Integration branch is captured once per milestone (idempotent)
- Committed immediately so it survives branch switches (.gsd/ files
  are discarded during checkout)
- main_branch preference still takes highest priority
- Falls back to existing detection if metadata missing (backward compat)
- Per-milestone: different milestones can target different branches
- Validates branch still exists before using it

Tests: 41 new assertions across git-service.test.ts and worktree.test.ts
covering the full lifecycle, multi-slice workflows, resume scenarios,
backward compatibility, and edge cases.
This commit is contained in:
Adam Dry 2026-03-13 16:34:28 +00:00 committed by GitHub
parent 74e9f366bb
commit 2ed7c830d9
5 changed files with 733 additions and 6 deletions

View file

@ -60,10 +60,12 @@ import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync } from
import { execSync, execFileSync } from "node:child_process";
import {
autoCommitCurrentBranch,
captureIntegrationBranch,
ensureSliceBranch,
getCurrentBranch,
getMainBranch,
parseSliceBranch,
setActiveMilestoneId,
switchToMain,
mergeSliceToMain,
} from "./worktree.ts";
@ -361,6 +363,8 @@ export async function startAuto(
unitDispatchCount.clear();
// Re-initialize metrics in case ledger was lost during pause
if (!getLedger()) initMetrics(base);
// Ensure milestone ID is set on git service for integration branch resolution
if (currentMilestoneId) setActiveMilestoneId(base, currentMilestoneId);
ctx.ui.setStatus("gsd-auto", stepMode ? "next" : "auto");
ctx.ui.setFooter(hideFooter);
ctx.ui.notify(stepMode ? "Step-mode resumed." : "Auto-mode resumed.", "info");
@ -468,6 +472,15 @@ export async function startAuto(
originalModelId = ctx.model?.id ?? null;
originalModelProvider = ctx.model?.provider ?? null;
// Capture the integration branch — records the branch the user was on when
// auto-mode started. Slice branches will merge back to this branch instead
// of the repo's default (main/master). Idempotent: only writes if not
// already recorded, so restarts/resumes don't overwrite.
if (currentMilestoneId) {
captureIntegrationBranch(base, currentMilestoneId);
setActiveMilestoneId(base, currentMilestoneId);
}
// Initialize metrics — loads existing ledger from disk
initMetrics(base);
@ -1002,8 +1015,13 @@ async function dispatchNextUnit(
// Reset stuck detection for new milestone
unitDispatchCount.clear();
unitRecoveryCount.clear();
// Capture integration branch for the new milestone and update git service
captureIntegrationBranch(basePath, mid);
}
if (mid) {
currentMilestoneId = mid;
setActiveMilestoneId(basePath, mid);
}
if (mid) currentMilestoneId = mid;
if (!mid) {
// Save final session before stopping

View file

@ -9,7 +9,8 @@
*/
import { execSync } from "node:child_process";
import { sep } from "node:path";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join, sep } from "node:path";
import {
detectWorktreeName,
@ -68,6 +69,86 @@ export const RUNTIME_EXCLUSION_PATHS: readonly string[] = [
".gsd/STATE.md",
];
// ─── Integration Branch Metadata ───────────────────────────────────────────
/**
* Path to the milestone metadata file that stores the integration branch.
* Format: .gsd/milestones/<MID>/<MID>-META.json
*/
function milestoneMetaPath(basePath: string, milestoneId: string): string {
return join(basePath, ".gsd", "milestones", milestoneId, `${milestoneId}-META.json`);
}
/**
* Read the integration branch recorded for a milestone.
* Returns null if no metadata file exists or the branch isn't set.
*/
export function readIntegrationBranch(basePath: string, milestoneId: string): string | null {
try {
const metaFile = milestoneMetaPath(basePath, milestoneId);
if (!existsSync(metaFile)) return null;
const data = JSON.parse(readFileSync(metaFile, "utf-8"));
const branch = data?.integrationBranch;
if (typeof branch === "string" && branch.trim() !== "" && VALID_BRANCH_NAME.test(branch)) {
return branch;
}
return null;
} catch {
return null;
}
}
/**
* Persist the integration branch for a milestone.
*
* Called once when auto-mode starts on a milestone. Records the branch
* the user was on at that point, so that slice branches merge back to it
* instead of the repo's default branch.
*
* The file is committed immediately so it survives branch switches the
* pre-switch auto-commit excludes `.gsd/` to avoid merge conflicts, and
* uncommitted `.gsd/` files are discarded during checkout.
*
* Skips writing if an integration branch is already recorded (idempotent
* across restarts) or if the current branch is already a GSD slice branch.
*/
export function writeIntegrationBranch(basePath: string, milestoneId: string, branch: string): void {
// Don't record slice branches as the integration target
if (SLICE_BRANCH_RE.test(branch)) return;
// Don't overwrite an existing integration branch
if (readIntegrationBranch(basePath, milestoneId) !== null) return;
// Validate
if (!VALID_BRANCH_NAME.test(branch)) return;
const metaFile = milestoneMetaPath(basePath, milestoneId);
mkdirSync(join(basePath, ".gsd", "milestones", milestoneId), { recursive: true });
// Merge with existing metadata if present
let existing: Record<string, unknown> = {};
try {
if (existsSync(metaFile)) {
existing = JSON.parse(readFileSync(metaFile, "utf-8"));
}
} catch { /* corrupt file — overwrite */ }
existing.integrationBranch = branch;
writeFileSync(metaFile, JSON.stringify(existing, null, 2) + "\n", "utf-8");
// Commit immediately — .gsd/ files are discarded during branch switches
// (ensureSliceBranch excludes .gsd/ from pre-switch auto-commit and runs
// git checkout -- .gsd/ to prevent checkout conflicts). Without this
// commit, the metadata would be lost on the first branch switch.
try {
runGit(basePath, ["add", "--force", metaFile]);
runGit(basePath, ["commit", "-F", "-"], {
input: `chore(${milestoneId}): record integration branch`,
});
} catch {
// Non-fatal — file is on disk even if commit fails (e.g. nothing to commit
// because the file was already tracked with identical content)
}
}
// ─── Git Helper ────────────────────────────────────────────────────────────
/**
@ -115,11 +196,23 @@ export class GitServiceImpl {
readonly basePath: string;
readonly prefs: GitPreferences;
/** Active milestone ID — used to resolve the integration branch. */
private _milestoneId: string | null = null;
constructor(basePath: string, prefs: GitPreferences = {}) {
this.basePath = basePath;
this.prefs = prefs;
}
/**
* Set the active milestone ID for integration branch resolution.
* When set, getMainBranch() will check the milestone's metadata file
* for a recorded integration branch before falling back to repo defaults.
*/
setMilestoneId(milestoneId: string | null): void {
this._milestoneId = milestoneId;
}
/** Convenience wrapper: run git in this repo's basePath. */
private git(args: string[], options: { allowFailure?: boolean; input?: string } = {}): string {
return runGit(this.basePath, args, options);
@ -212,9 +305,18 @@ export class GitServiceImpl {
// ─── Branch Queries ────────────────────────────────────────────────────
/**
* Get the "main" branch for this repo.
* In a worktree: returns worktree/<name> (the worktree's base branch).
* In the main tree: origin/HEAD symbolic-ref main/master fallback current branch.
* Get the "main" (integration) branch for this repo.
*
* Resolution order:
* 1. Explicit `main_branch` preference (user override, highest priority)
* 2. Milestone integration branch from metadata file (recorded at milestone start)
* 3. Worktree base branch (worktree/<name>)
* 4. origin/HEAD symbolic-ref main/master fallback current branch
*
* The integration branch (step 2) is what makes feature-branch workflows
* work correctly: when a user starts GSD on `f-123-new-thing`, that branch
* is recorded as the integration target, and all slice branches merge back
* to it instead of the repo's default branch.
*/
getMainBranch(): string {
// Explicit preference takes priority (double-check validity as defense-in-depth)
@ -222,6 +324,16 @@ export class GitServiceImpl {
return this.prefs.main_branch;
}
// Check milestone integration branch — recorded when auto-mode starts
if (this._milestoneId) {
const integrationBranch = readIntegrationBranch(this.basePath, this._milestoneId);
if (integrationBranch) {
// Verify the branch still exists locally (could have been deleted)
const exists = this.git(["show-ref", "--verify", `refs/heads/${integrationBranch}`], { allowFailure: true });
if (exists) return integrationBranch;
}
}
const wtName = detectWorktreeName(this.basePath);
if (wtName) {
const wtBranch = `worktree/${wtName}`;

View file

@ -9,6 +9,8 @@ import {
RUNTIME_EXCLUSION_PATHS,
VALID_BRANCH_NAME,
runGit,
readIntegrationBranch,
writeIntegrationBranch,
type GitPreferences,
type CommitOptions,
type MergeSliceResult,
@ -1370,6 +1372,230 @@ async function main(): Promise<void> {
assert(true, "PreMergeCheckResult type exported and usable");
}
// ═══════════════════════════════════════════════════════════════════════
// Integration branch — feature-branch workflow support
// ═══════════════════════════════════════════════════════════════════════
// ─── writeIntegrationBranch / readIntegrationBranch: round-trip ────────
console.log("\n=== Integration branch: write and read ===");
{
const repo = initBranchTestRepo();
// Initially no integration branch
assertEq(readIntegrationBranch(repo, "M001"), null, "readIntegrationBranch returns null when no metadata");
// Write integration branch
writeIntegrationBranch(repo, "M001", "f-123-new-thing");
assertEq(readIntegrationBranch(repo, "M001"), "f-123-new-thing", "readIntegrationBranch returns written branch");
rmSync(repo, { recursive: true, force: true });
}
// ─── writeIntegrationBranch: idempotent — doesn't overwrite ───────────
console.log("\n=== Integration branch: idempotent write ===");
{
const repo = initBranchTestRepo();
writeIntegrationBranch(repo, "M001", "f-123-first");
writeIntegrationBranch(repo, "M001", "f-456-second"); // should NOT overwrite
assertEq(readIntegrationBranch(repo, "M001"), "f-123-first", "second write does not overwrite existing integration branch");
rmSync(repo, { recursive: true, force: true });
}
// ─── writeIntegrationBranch: rejects slice branches ───────────────────
console.log("\n=== Integration branch: rejects slice branches ===");
{
const repo = initBranchTestRepo();
writeIntegrationBranch(repo, "M001", "gsd/M001/S01");
assertEq(readIntegrationBranch(repo, "M001"), null, "slice branches are not recorded as integration branch");
rmSync(repo, { recursive: true, force: true });
}
// ─── writeIntegrationBranch: rejects invalid branch names ─────────────
console.log("\n=== Integration branch: rejects invalid names ===");
{
const repo = initBranchTestRepo();
writeIntegrationBranch(repo, "M001", "bad; rm -rf /");
assertEq(readIntegrationBranch(repo, "M001"), null, "invalid branch name is not recorded");
rmSync(repo, { recursive: true, force: true });
}
// ─── getMainBranch: uses integration branch when milestone set ────────
console.log("\n=== getMainBranch: integration branch from milestone metadata ===");
{
const repo = initBranchTestRepo();
// Create a feature branch
run("git checkout -b f-123-feature", repo);
run("git checkout main", repo);
// Write integration branch metadata
writeIntegrationBranch(repo, "M001", "f-123-feature");
// Without milestone set, getMainBranch returns "main"
const svc = new GitServiceImpl(repo);
assertEq(svc.getMainBranch(), "main", "getMainBranch returns main when no milestone set");
// With milestone set, getMainBranch returns the integration branch
svc.setMilestoneId("M001");
assertEq(svc.getMainBranch(), "f-123-feature", "getMainBranch returns integration branch when milestone set");
rmSync(repo, { recursive: true, force: true });
}
// ─── getMainBranch: main_branch pref still takes priority ─────────────
console.log("\n=== getMainBranch: main_branch pref overrides integration branch ===");
{
const repo = initBranchTestRepo();
run("git checkout -b f-123-feature", repo);
run("git checkout -b trunk", repo);
run("git checkout main", repo);
writeIntegrationBranch(repo, "M001", "f-123-feature");
// Explicit preference still wins
const svc = new GitServiceImpl(repo, { main_branch: "trunk" });
svc.setMilestoneId("M001");
assertEq(svc.getMainBranch(), "trunk", "main_branch preference overrides integration branch");
rmSync(repo, { recursive: true, force: true });
}
// ─── getMainBranch: falls back when integration branch deleted ────────
console.log("\n=== getMainBranch: fallback when integration branch deleted ===");
{
const repo = initBranchTestRepo();
// Write metadata pointing to a branch that doesn't exist
writeIntegrationBranch(repo, "M001", "deleted-branch");
const svc = new GitServiceImpl(repo);
svc.setMilestoneId("M001");
assertEq(svc.getMainBranch(), "main", "getMainBranch falls back to main when integration branch no longer exists");
rmSync(repo, { recursive: true, force: true });
}
// ─── End-to-end: feature branch workflow ──────────────────────────────
console.log("\n=== End-to-end: feature branch workflow ===");
{
const repo = initBranchTestRepo();
// Simulate: user creates feature branch and starts GSD
run("git checkout -b f-123-new-thing", repo);
createFile(repo, "setup.txt", "initial setup");
run("git add -A", repo);
run("git commit -m 'initial feature setup'", repo);
// Record integration branch (this is what auto.ts does at startup)
writeIntegrationBranch(repo, "M001", "f-123-new-thing");
// Create GitServiceImpl with milestone set
const svc = new GitServiceImpl(repo);
svc.setMilestoneId("M001");
// Verify getMainBranch returns the feature branch, not "main"
assertEq(svc.getMainBranch(), "f-123-new-thing", "e2e: getMainBranch returns feature branch");
// Create slice branch — should branch from f-123-new-thing (current)
svc.ensureSliceBranch("M001", "S01");
assertEq(svc.getCurrentBranch(), "gsd/M001/S01", "e2e: slice branch created");
// The slice branch should have the feature branch's commit
const log = run("git log --oneline", repo);
assert(log.includes("initial feature setup"), "e2e: slice branch inherits feature branch content");
// Do work on the slice branch
createFile(repo, "src/feature.ts", "export const feature = true;");
svc.commit({ message: "feat: add feature module" });
// switchToMain should go to feature branch
svc.switchToMain();
assertEq(svc.getCurrentBranch(), "f-123-new-thing", "e2e: switchToMain goes to feature branch, not main");
// mergeSliceToMain should merge into feature branch
const result = svc.mergeSliceToMain("M001", "S01", "Add feature module");
assertEq(result.mergedCommitMessage, "feat(M001/S01): Add feature module", "e2e: merge commit message correct");
assertEq(svc.getCurrentBranch(), "f-123-new-thing", "e2e: after merge, still on feature branch");
// The feature branch should have the merged work
const files = run("git ls-files", repo);
assert(files.includes("src/feature.ts"), "e2e: merged file exists on feature branch");
// Main should NOT have the merged work
run("git checkout main", repo);
const mainFiles = run("git ls-files", repo);
assert(!mainFiles.includes("src/feature.ts"), "e2e: main does NOT have merged work — it stays on the feature branch");
rmSync(repo, { recursive: true, force: true });
}
// ─── Per-milestone isolation: different milestones, different targets ──
console.log("\n=== Integration branch: per-milestone isolation ===");
{
const repo = initBranchTestRepo();
run("git checkout -b feature-a", repo);
run("git checkout -b feature-b", repo);
run("git checkout main", repo);
writeIntegrationBranch(repo, "M001", "feature-a");
writeIntegrationBranch(repo, "M002", "feature-b");
const svc = new GitServiceImpl(repo);
svc.setMilestoneId("M001");
assertEq(svc.getMainBranch(), "feature-a", "M001 integration branch is feature-a");
svc.setMilestoneId("M002");
assertEq(svc.getMainBranch(), "feature-b", "M002 integration branch is feature-b");
svc.setMilestoneId(null);
assertEq(svc.getMainBranch(), "main", "no milestone set → falls back to main");
rmSync(repo, { recursive: true, force: true });
}
// ─── Backward compatibility: no metadata → existing behavior ──────────
console.log("\n=== Integration branch: backward compat ===");
{
const repo = initBranchTestRepo();
const svc = new GitServiceImpl(repo);
// Set milestone but no metadata file exists
svc.setMilestoneId("M001");
assertEq(svc.getMainBranch(), "main", "backward compat: no metadata file → falls back to main");
rmSync(repo, { recursive: true, force: true });
}
// ─── untrackRuntimeFiles: removes tracked runtime files from index ───
console.log("\n=== untrackRuntimeFiles ===");

View file

@ -5,17 +5,21 @@ import { execSync } from "node:child_process";
import {
autoCommitCurrentBranch,
captureIntegrationBranch,
detectWorktreeName,
ensureSliceBranch,
getActiveSliceBranch,
getCurrentBranch,
getMainBranch,
getSliceBranchName,
isOnSliceBranch,
mergeSliceToMain,
parseSliceBranch,
setActiveMilestoneId,
SLICE_BRANCH_RE,
switchToMain,
} from "../worktree.ts";
import { readIntegrationBranch } from "../git-service.ts";
import { deriveState } from "../state.ts";
import { indexWorkspace } from "../workspace-index.ts";
@ -252,6 +256,354 @@ async function main(): Promise<void> {
rmSync(base3, { recursive: true, force: true });
// ═══════════════════════════════════════════════════════════════════════
// Integration branch — facade-level tests
//
// These exercise the same codepath auto.ts uses:
// captureIntegrationBranch() → setActiveMilestoneId() → getMainBranch()
// → switchToMain() → mergeSliceToMain()
// ═══════════════════════════════════════════════════════════════════════
// ── captureIntegrationBranch on a feature branch ──────────────────────
console.log("\n=== captureIntegrationBranch: records current branch ===");
{
const repo = mkdtempSync(join(tmpdir(), "gsd-integ-facade-"));
run("git init -b main", repo);
run("git config user.name 'Pi Test'", repo);
run("git config user.email 'pi@example.com'", repo);
writeFileSync(join(repo, "README.md"), "init\n");
run("git add -A && git commit -m init", repo);
run("git checkout -b f-123-thing", repo);
assertEq(getCurrentBranch(repo), "f-123-thing", "on feature branch");
captureIntegrationBranch(repo, "M001");
assertEq(readIntegrationBranch(repo, "M001"), "f-123-thing",
"captureIntegrationBranch records the current branch");
// Verify it was committed (not just written to disk)
const logOut = run("git log --oneline -1", repo);
assert(logOut.includes("integration branch"), "metadata committed to git");
rmSync(repo, { recursive: true, force: true });
}
// ── captureIntegrationBranch is idempotent on same lineage ──────────
console.log("\n=== captureIntegrationBranch: idempotent ===");
{
const repo = mkdtempSync(join(tmpdir(), "gsd-integ-idem-"));
run("git init -b main", repo);
run("git config user.name 'Pi Test'", repo);
run("git config user.email 'pi@example.com'", repo);
writeFileSync(join(repo, "README.md"), "init\n");
run("git add -A && git commit -m init", repo);
run("git checkout -b f-first", repo);
captureIntegrationBranch(repo, "M001");
setActiveMilestoneId(repo, "M001");
assertEq(readIntegrationBranch(repo, "M001"), "f-first",
"first capture records f-first");
// Capture again on the same branch (simulates restart/resume) — should NOT overwrite
captureIntegrationBranch(repo, "M001");
assertEq(readIntegrationBranch(repo, "M001"), "f-first",
"second capture on same branch does not overwrite");
// After creating a slice branch (which inherits the metadata commit),
// capture should still be idempotent
ensureSliceBranch(repo, "M001", "S01");
// Now on gsd/M001/S01 — capture should be no-op (slice branch rejected)
captureIntegrationBranch(repo, "M001");
switchToMain(repo);
assertEq(readIntegrationBranch(repo, "M001"), "f-first",
"capture from slice branch is no-op, original preserved");
assertEq(getCurrentBranch(repo), "f-first",
"switchToMain returns to feature branch, confirming integration branch works");
rmSync(repo, { recursive: true, force: true });
}
// ── captureIntegrationBranch skips slice branches ─────────────────────
console.log("\n=== captureIntegrationBranch: skips slice branches ===");
{
const repo = mkdtempSync(join(tmpdir(), "gsd-integ-skip-"));
run("git init -b main", repo);
run("git config user.name 'Pi Test'", repo);
run("git config user.email 'pi@example.com'", repo);
writeFileSync(join(repo, "README.md"), "init\n");
run("git add -A && git commit -m init", repo);
run("git checkout -b gsd/M001/S01", repo);
captureIntegrationBranch(repo, "M001");
assertEq(readIntegrationBranch(repo, "M001"), null,
"capture from slice branch is a no-op");
rmSync(repo, { recursive: true, force: true });
}
// ── setActiveMilestoneId makes getMainBranch return integration branch ─
console.log("\n=== setActiveMilestoneId + getMainBranch ===");
{
const repo = mkdtempSync(join(tmpdir(), "gsd-integ-main-"));
run("git init -b main", repo);
run("git config user.name 'Pi Test'", repo);
run("git config user.email 'pi@example.com'", repo);
writeFileSync(join(repo, "README.md"), "init\n");
run("git add -A && git commit -m init", repo);
run("git checkout -b my-feature", repo);
captureIntegrationBranch(repo, "M001");
// Without milestone set, getMainBranch returns "main"
setActiveMilestoneId(repo, null);
assertEq(getMainBranch(repo), "main",
"getMainBranch returns main without milestone set");
// With milestone set, getMainBranch returns feature branch
setActiveMilestoneId(repo, "M001");
assertEq(getMainBranch(repo), "my-feature",
"getMainBranch returns integration branch with milestone set");
rmSync(repo, { recursive: true, force: true });
}
// ── Full multi-slice lifecycle on a feature branch ────────────────────
//
// Simulates what auto.ts does: start on feature branch, capture it,
// create S01, work, merge S01 back to feature branch, then S02 branches
// from feature branch (not main), works, merges to feature branch.
// Main stays untouched throughout.
console.log("\n=== Multi-slice lifecycle on feature branch ===");
{
const repo = mkdtempSync(join(tmpdir(), "gsd-integ-multi-"));
run("git init -b main", repo);
run("git config user.name 'Pi Test'", repo);
run("git config user.email 'pi@example.com'", repo);
writeFileSync(join(repo, "README.md"), "base\n");
run("git add -A && git commit -m init", repo);
// User creates feature branch
run("git checkout -b feature/big-change", repo);
writeFileSync(join(repo, "setup.txt"), "feature setup\n");
run("git add -A && git commit -m 'feat: initial setup'", repo);
// auto.ts startup: capture + set milestone
captureIntegrationBranch(repo, "M001");
setActiveMilestoneId(repo, "M001");
assertEq(getMainBranch(repo), "feature/big-change",
"multi: getMainBranch returns feature branch");
// ── S01 lifecycle ──────────────────────────────────────────────────
ensureSliceBranch(repo, "M001", "S01");
assertEq(getCurrentBranch(repo), "gsd/M001/S01", "multi: on S01");
// Verify S01 has feature branch content
assert(existsSync(join(repo, "setup.txt")),
"multi: S01 inherited feature branch content");
writeFileSync(join(repo, "s01-work.txt"), "s01 output\n");
run("git add -A && git commit -m 'feat(S01): work'", repo);
switchToMain(repo);
assertEq(getCurrentBranch(repo), "feature/big-change",
"multi: switchToMain goes to feature branch");
const s01merge = mergeSliceToMain(repo, "M001", "S01", "First slice");
assertEq(getCurrentBranch(repo), "feature/big-change",
"multi: after S01 merge, on feature branch");
assert(existsSync(join(repo, "s01-work.txt")),
"multi: S01 work merged to feature branch");
assert(s01merge.deletedBranch, "multi: S01 branch deleted");
// Main should NOT have S01 work
run("git stash", repo); // stash any .gsd changes
run("git checkout main", repo);
assert(!existsSync(join(repo, "s01-work.txt")),
"multi: main does NOT have S01 work");
run("git checkout feature/big-change", repo);
run("git stash pop || true", repo);
// ── S02 lifecycle ──────────────────────────────────────────────────
// S02 should branch from feature/big-change which now has S01's work
ensureSliceBranch(repo, "M001", "S02");
assertEq(getCurrentBranch(repo), "gsd/M001/S02", "multi: on S02");
// S02 should have S01's merged output (branched from feature branch)
assert(existsSync(join(repo, "s01-work.txt")),
"multi: S02 has S01 output (inherited via feature branch)");
writeFileSync(join(repo, "s02-work.txt"), "s02 output\n");
run("git add -A && git commit -m 'feat(S02): work'", repo);
switchToMain(repo);
assertEq(getCurrentBranch(repo), "feature/big-change",
"multi: switchToMain goes to feature branch after S02");
const s02merge = mergeSliceToMain(repo, "M001", "S02", "Second slice");
assertEq(getCurrentBranch(repo), "feature/big-change",
"multi: after S02 merge, on feature branch");
assert(existsSync(join(repo, "s02-work.txt")),
"multi: S02 work merged to feature branch");
assert(existsSync(join(repo, "s01-work.txt")),
"multi: S01 work still on feature branch after S02 merge");
assert(s02merge.deletedBranch, "multi: S02 branch deleted");
// Final check: main still untouched
run("git stash", repo);
run("git checkout main", repo);
assert(!existsSync(join(repo, "s01-work.txt")),
"multi: main still lacks S01 work at end");
assert(!existsSync(join(repo, "s02-work.txt")),
"multi: main still lacks S02 work at end");
assertEq(readFileSync(join(repo, "README.md"), "utf-8").trim(), "base",
"multi: main README unchanged");
rmSync(repo, { recursive: true, force: true });
}
// ── Resume scenario: milestone ID re-set after restart ────────────────
//
// Simulates crash + restart: the cached GitServiceImpl is lost, but the
// metadata file persists on disk. Re-calling setActiveMilestoneId should
// restore integration branch resolution.
console.log("\n=== Resume: milestone ID re-set restores integration branch ===");
{
const repo = mkdtempSync(join(tmpdir(), "gsd-integ-resume-"));
run("git init -b main", repo);
run("git config user.name 'Pi Test'", repo);
run("git config user.email 'pi@example.com'", repo);
writeFileSync(join(repo, "README.md"), "init\n");
run("git add -A && git commit -m init", repo);
run("git checkout -b my-feature", repo);
captureIntegrationBranch(repo, "M001");
setActiveMilestoneId(repo, "M001");
// Create a slice and do some work
ensureSliceBranch(repo, "M001", "S01");
writeFileSync(join(repo, "work.txt"), "wip\n");
run("git add -A && git commit -m 'wip'", repo);
// Simulate "restart" — clear milestone ID (fresh service instance)
setActiveMilestoneId(repo, null);
assertEq(getMainBranch(repo), "main",
"resume: getMainBranch returns main when milestone cleared");
// Re-set milestone ID (what auto.ts does on resume)
setActiveMilestoneId(repo, "M001");
assertEq(getMainBranch(repo), "my-feature",
"resume: getMainBranch returns feature branch after re-set");
// Full lifecycle still works after resume
switchToMain(repo);
assertEq(getCurrentBranch(repo), "my-feature",
"resume: switchToMain goes to feature branch after re-set");
const result = mergeSliceToMain(repo, "M001", "S01", "Resume slice");
assertEq(getCurrentBranch(repo), "my-feature",
"resume: merge lands on feature branch after re-set");
assert(existsSync(join(repo, "work.txt")),
"resume: merged work exists on feature branch");
rmSync(repo, { recursive: true, force: true });
}
// ── Backward compat: no metadata file, plain main workflow ────────────
//
// Simulates existing projects that were created before this feature.
// No metadata file exists, milestone ID is set — getMainBranch should
// still return "main" and the entire slice lifecycle works unchanged.
console.log("\n=== Backward compat: no metadata, main workflow ===");
{
const repo = mkdtempSync(join(tmpdir(), "gsd-integ-compat-"));
run("git init -b main", repo);
run("git config user.name 'Pi Test'", repo);
run("git config user.email 'pi@example.com'", repo);
writeFileSync(join(repo, "README.md"), "init\n");
run("git add -A && git commit -m init", repo);
// Set milestone but DON'T capture integration branch (simulates old project)
setActiveMilestoneId(repo, "M001");
assertEq(getMainBranch(repo), "main",
"compat: getMainBranch returns main without metadata");
// Full lifecycle on main still works
ensureSliceBranch(repo, "M001", "S01");
writeFileSync(join(repo, "feature.txt"), "new\n");
run("git add -A && git commit -m 'feat: work'", repo);
switchToMain(repo);
assertEq(getCurrentBranch(repo), "main",
"compat: switchToMain goes to main");
const result = mergeSliceToMain(repo, "M001", "S01", "Compat slice");
assertEq(getCurrentBranch(repo), "main",
"compat: merge lands on main");
assert(existsSync(join(repo, "feature.txt")),
"compat: merged work exists on main");
assert(result.deletedBranch, "compat: branch deleted");
rmSync(repo, { recursive: true, force: true });
}
// ── ensureSliceBranch from another slice with integration branch ──────
//
// When on gsd/M001/S01 and creating S02, the code falls back to
// getMainBranch() (not the current slice). With integration branch set,
// S02 should branch from the feature branch.
console.log("\n=== ensureSliceBranch: S02 from S01 uses integration branch as base ===");
{
const repo = mkdtempSync(join(tmpdir(), "gsd-integ-chain-"));
run("git init -b main", repo);
run("git config user.name 'Pi Test'", repo);
run("git config user.email 'pi@example.com'", repo);
writeFileSync(join(repo, "README.md"), "init\n");
run("git add -A && git commit -m init", repo);
run("git checkout -b dev-branch", repo);
writeFileSync(join(repo, "dev-only.txt"), "from dev\n");
run("git add -A && git commit -m 'dev setup'", repo);
captureIntegrationBranch(repo, "M001");
setActiveMilestoneId(repo, "M001");
// Create S01 (from dev-branch)
ensureSliceBranch(repo, "M001", "S01");
writeFileSync(join(repo, "s01.txt"), "s01\n");
run("git add -A && git commit -m 's01 work'", repo);
// While on S01, create S02 — should fall back to integration branch
ensureSliceBranch(repo, "M001", "S02");
assertEq(getCurrentBranch(repo), "gsd/M001/S02", "chain: on S02");
// S02 should be based on dev-branch (the integration branch)
assert(existsSync(join(repo, "dev-only.txt")),
"chain: S02 has dev-branch content");
assert(!existsSync(join(repo, "s01.txt")),
"chain: S02 does NOT have S01 content (not chained from S01)");
rmSync(repo, { recursive: true, force: true });
}
rmSync(base, { recursive: true, force: true });
console.log(`\nResults: ${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);

View file

@ -17,7 +17,7 @@
import { sep } from "node:path";
import { GitServiceImpl } from "./git-service.ts";
import { GitServiceImpl, writeIntegrationBranch } from "./git-service.ts";
import { loadEffectiveGSDPreferences } from "./preferences.ts";
// Re-export MergeSliceResult from the canonical source (D014 — type-only re-export)
@ -43,6 +43,25 @@ function getService(basePath: string): GitServiceImpl {
return cachedService;
}
/**
* Set the active milestone ID on the cached GitServiceImpl.
* This enables integration branch resolution in getMainBranch().
*/
export function setActiveMilestoneId(basePath: string, milestoneId: string | null): void {
getService(basePath).setMilestoneId(milestoneId);
}
/**
* Record the current branch as the integration branch for a milestone.
* Called once when auto-mode starts captures where slice branches should
* merge back to. No-op if already recorded or if on a GSD slice branch.
*/
export function captureIntegrationBranch(basePath: string, milestoneId: string): void {
const svc = getService(basePath);
const current = svc.getCurrentBranch();
writeIntegrationBranch(basePath, milestoneId, current);
}
// ─── Pure Utility Functions (unchanged) ────────────────────────────────────
/**