test(sf): make worktree suites explicit

This commit is contained in:
Mikael Hugo 2026-05-02 11:40:18 +02:00
parent 26be0b4153
commit ff60f5f62f
2 changed files with 242 additions and 277 deletions

View file

@ -34,7 +34,7 @@ import {
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe } from 'vitest';
import { describe, test } from "vitest";
import {
syncProjectRootToWorktree,
syncSfStateToWorktree,
@ -51,10 +51,8 @@ function cleanup(base: string): void {
rmSync(base, { recursive: true, force: true });
}
describe("worktree-sync-milestones", async () => {
// ─── 1. Milestone directory synced from main to worktree ──────────────
console.log("\n=== 1. milestone directory synced from main to worktree ===");
{
describe("worktree-sync-milestones", () => {
test("milestone directory synced from main to worktree", () => {
const mainBase = createBase("main");
const wtBase = createBase("wt");
@ -92,11 +90,9 @@ describe("worktree-sync-milestones", async () => {
cleanup(mainBase);
cleanup(wtBase);
}
}
});
// ─── 2. Missing slices synced ──────────────────────────────────────────
console.log("\n=== 2. missing slices within milestone are synced ===");
{
test("missing slices within milestone are synced", () => {
const mainBase = createBase("main");
const wtBase = createBase("wt");
@ -146,11 +142,9 @@ describe("worktree-sync-milestones", async () => {
cleanup(mainBase);
cleanup(wtBase);
}
}
});
// ─── 3. empty sf.db deleted in worktree after sync ────────────────────
console.log("\n=== 3. empty sf.db deleted in worktree after sync ===");
{
test("empty sf.db deleted in worktree after sync", () => {
const mainBase = createBase("main");
const wtBase = createBase("wt");
@ -176,13 +170,9 @@ describe("worktree-sync-milestones", async () => {
cleanup(mainBase);
cleanup(wtBase);
}
}
});
// ─── 3b. non-empty sf.db preserved in worktree after sync (#2815) ───
console.log(
"\n=== 3b. non-empty sf.db preserved in worktree after sync (#2815) ===",
);
{
test("non-empty sf.db preserved in worktree after sync (#2815)", () => {
const mainBase = createBase("main");
const wtBase = createBase("wt");
@ -208,11 +198,9 @@ describe("worktree-sync-milestones", async () => {
cleanup(mainBase);
cleanup(wtBase);
}
}
});
// ─── 4. No-op when paths are equal ────────────────────────────────────
console.log("\n=== 4. no-op when paths are equal ===");
{
test("no-op when paths are equal", () => {
const base = createBase("same");
try {
// Should not throw
@ -221,11 +209,9 @@ describe("worktree-sync-milestones", async () => {
} finally {
cleanup(base);
}
}
});
// ─── 5. No-op when milestoneId is null ────────────────────────────────
console.log("\n=== 5. no-op when milestoneId is null ===");
{
test("no-op when milestoneId is null", () => {
const mainBase = createBase("main");
const wtBase = createBase("wt");
try {
@ -235,22 +221,18 @@ describe("worktree-sync-milestones", async () => {
cleanup(mainBase);
cleanup(wtBase);
}
}
});
// ─── 6. Non-existent directories handled gracefully ───────────────────
console.log("\n=== 6. non-existent directories → no-op ===");
syncProjectRootToWorktree(
"/tmp/does-not-exist-main",
"/tmp/does-not-exist-wt",
"M001",
);
assert.ok(true, "no crash on missing directories");
test("non-existent directories are handled gracefully", () => {
syncProjectRootToWorktree(
"/tmp/does-not-exist-main",
"/tmp/does-not-exist-wt",
"M001",
);
assert.ok(true, "no crash on missing directories");
});
// ─── 7. milestones/ directory created in worktree when missing ────────
console.log(
"\n=== 7. milestones/ directory created in worktree when missing ===",
);
{
test("milestones/ directory created in worktree when missing", () => {
const mainBase = createBase("main");
const wtBase = mkdtempSync(join(tmpdir(), "sf-wt-sync-wt-"));
@ -296,13 +278,9 @@ describe("worktree-sync-milestones", async () => {
cleanup(mainBase);
rmSync(wtBase, { recursive: true, force: true });
}
}
});
// ─── 8. syncWorktreeStateBack recurses into tasks/ (#1678) ───────────
console.log(
"\n=== 8. syncWorktreeStateBack copies tasks/ subdirectory (#1678) ===",
);
{
test("syncWorktreeStateBack recurses into tasks/ (#1678)", () => {
const mainBase = mkdtempSync(join(tmpdir(), "sf-wt-back-main-"));
const wtBase = mkdtempSync(join(tmpdir(), "sf-wt-back-wt-"));
@ -361,13 +339,9 @@ describe("worktree-sync-milestones", async () => {
rmSync(mainBase, { recursive: true, force: true });
rmSync(wtBase, { recursive: true, force: true });
}
}
});
// ─── 9. syncWorktreeStateBack syncs root-level .sf/ files ──────────
console.log(
"\n=== 9. syncWorktreeStateBack syncs root-level files (REQUIREMENTS, PROJECT) ===",
);
{
test("syncWorktreeStateBack syncs root-level .sf/ files", () => {
const mainBase = mkdtempSync(join(tmpdir(), "sf-wt-back-root-main-"));
const wtBase = mkdtempSync(join(tmpdir(), "sf-wt-back-root-wt-"));
@ -439,13 +413,9 @@ describe("worktree-sync-milestones", async () => {
rmSync(mainBase, { recursive: true, force: true });
rmSync(wtBase, { recursive: true, force: true });
}
}
});
// ─── 10. syncWorktreeStateBack syncs ALL milestone directories ─────
console.log(
"\n=== 10. syncWorktreeStateBack syncs all milestone dirs, not just current ===",
);
{
test("syncWorktreeStateBack syncs all milestone dirs, not just current", () => {
const mainBase = mkdtempSync(join(tmpdir(), "sf-wt-back-all-main-"));
const wtBase = mkdtempSync(join(tmpdir(), "sf-wt-back-all-wt-"));
@ -524,13 +494,9 @@ describe("worktree-sync-milestones", async () => {
rmSync(mainBase, { recursive: true, force: true });
rmSync(wtBase, { recursive: true, force: true });
}
}
});
// ─── 11. Full M006→M007 transition scenario ───────────────────────────
console.log(
"\n=== 11. complete-milestone creates next-milestone artifacts that survive sync ===",
);
{
test("complete-milestone creates next-milestone artifacts that survive sync", () => {
const mainBase = mkdtempSync(join(tmpdir(), "sf-wt-transition-main-"));
const wtBase = mkdtempSync(join(tmpdir(), "sf-wt-transition-wt-"));
@ -652,13 +618,9 @@ describe("worktree-sync-milestones", async () => {
rmSync(mainBase, { recursive: true, force: true });
rmSync(wtBase, { recursive: true, force: true });
}
}
});
// ─── 12. syncWorktreeStateBack no-op for root files that don't exist ──
console.log(
"\n=== 12. root files not in worktree are not created in main ===",
);
{
test("root files not in worktree are not created in main", () => {
const mainBase = mkdtempSync(join(tmpdir(), "sf-wt-back-noroot-main-"));
const wtBase = mkdtempSync(join(tmpdir(), "sf-wt-back-noroot-wt-"));
@ -690,13 +652,9 @@ describe("worktree-sync-milestones", async () => {
rmSync(mainBase, { recursive: true, force: true });
rmSync(wtBase, { recursive: true, force: true });
}
}
});
// ─── 13. syncWorktreeStateBack syncs QUEUE.md and completed-units.json (#1787) ──
console.log(
"\n=== 13. QUEUE.md and completed-units.json synced from worktree (#1787) ===",
);
{
test("QUEUE.md and completed-units.json synced from worktree (#1787)", () => {
const mainBase = mkdtempSync(join(tmpdir(), "sf-wt-back-queue-main-"));
const wtBase = mkdtempSync(join(tmpdir(), "sf-wt-back-queue-wt-"));
@ -764,13 +722,9 @@ describe("worktree-sync-milestones", async () => {
rmSync(mainBase, { recursive: true, force: true });
rmSync(wtBase, { recursive: true, force: true });
}
}
});
// ─── 14. syncSfStateToWorktree syncs non-standard milestone dir names (#1547) ──
console.log(
"\n=== 14. syncSfStateToWorktree syncs non-standard milestone dir names (#1547) ===",
);
{
test("syncSfStateToWorktree syncs non-standard milestone dir names (#1547)", () => {
const mainBase = createBase("main");
const wtBase = createBase("wt");
@ -821,13 +775,9 @@ describe("worktree-sync-milestones", async () => {
cleanup(mainBase);
cleanup(wtBase);
}
}
});
// ─── 15. syncWorktreeStateBack syncs non-standard milestone dir names (#1547) ──
console.log(
"\n=== 15. syncWorktreeStateBack syncs non-standard milestone dir names (#1547) ===",
);
{
test("syncWorktreeStateBack syncs non-standard milestone dir names (#1547)", () => {
const mainBase = mkdtempSync(join(tmpdir(), "sf-wt-back-custom-main-"));
const wtBase = mkdtempSync(join(tmpdir(), "sf-wt-back-custom-wt-"));
@ -861,5 +811,5 @@ describe("worktree-sync-milestones", async () => {
rmSync(mainBase, { recursive: true, force: true });
rmSync(wtBase, { recursive: true, force: true });
}
}
});
});

View file

@ -10,7 +10,7 @@ import {
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe } from 'vitest';
import { afterAll, describe, test } from "vitest";
import { readIntegrationBranch } from "../git-service.ts";
import { _resetHasChangesCache } from "../native-git-bridge.ts";
import { _clearSfRootCache } from "../paths.ts";
@ -69,126 +69,147 @@ writeFileSync(
run("git add .", base);
run('git commit -m "chore: init"', base);
describe("worktree", async () => {
console.log("\n=== autoCommitCurrentBranch ===");
// Clean — should return null
const cleanResult = autoCommitCurrentBranch(
base,
"execute-task",
"M001/S01/T01",
);
assert.deepStrictEqual(cleanResult, null, "returns null for clean repo");
describe("worktree", () => {
afterAll(() => {
rmSync(base, { recursive: true, force: true });
delete process.env.SF_PROJECT_ROOT;
delete process.env.SF_HOME;
_clearSfRootCache();
_resetServiceCache();
_resetHasChangesCache();
});
// Make dirty — reset the nativeHasChanges cache so the fresh dirt is detected
_resetHasChangesCache();
writeFileSync(join(base, "dirty.txt"), "uncommitted\n", "utf-8");
const dirtyResult = autoCommitCurrentBranch(
base,
"execute-task",
"M001/S01/T01",
);
assert.ok(dirtyResult !== null, "returns commit message for dirty repo");
assert.ok(
dirtyResult!.includes("M001/S01/T01"),
"commit message includes unit id",
);
assert.deepStrictEqual(
run("git status --short", base),
"",
"repo is clean after auto-commit",
);
test("autoCommitCurrentBranch", () => {
// Clean — should return null
const cleanResult = autoCommitCurrentBranch(
base,
"execute-task",
"M001/S01/T01",
);
assert.deepStrictEqual(cleanResult, null, "returns null for clean repo");
console.log("\n=== getSliceBranchName ===");
assert.deepStrictEqual(
getSliceBranchName("M001", "S01"),
"sf/M001/S01",
"branch name format correct",
);
assert.deepStrictEqual(
getSliceBranchName("M001", "S01", null),
"sf/M001/S01",
"null worktree = plain branch",
);
assert.deepStrictEqual(
getSliceBranchName("M001", "S01", "my-wt"),
"sf/my-wt/M001/S01",
"worktree-namespaced branch",
);
// Make dirty — reset the nativeHasChanges cache so the fresh dirt is detected
_resetHasChangesCache();
writeFileSync(join(base, "dirty.txt"), "uncommitted\n", "utf-8");
const dirtyResult = autoCommitCurrentBranch(
base,
"execute-task",
"M001/S01/T01",
);
assert.ok(dirtyResult !== null, "returns commit message for dirty repo");
assert.ok(
dirtyResult!.includes("M001/S01/T01"),
"commit message includes unit id",
);
assert.deepStrictEqual(
run("git status --short", base),
"",
"repo is clean after auto-commit",
);
});
console.log("\n=== parseSliceBranch ===");
const plain = parseSliceBranch("sf/M001/S01");
assert.ok(plain !== null, "parses plain branch");
assert.deepStrictEqual(
plain!.worktreeName,
null,
"plain branch has no worktree name",
);
assert.deepStrictEqual(plain!.milestoneId, "M001", "plain branch milestone");
assert.deepStrictEqual(plain!.sliceId, "S01", "plain branch slice");
test("getSliceBranchName", () => {
assert.deepStrictEqual(
getSliceBranchName("M001", "S01"),
"sf/M001/S01",
"branch name format correct",
);
assert.deepStrictEqual(
getSliceBranchName("M001", "S01", null),
"sf/M001/S01",
"null worktree = plain branch",
);
assert.deepStrictEqual(
getSliceBranchName("M001", "S01", "my-wt"),
"sf/my-wt/M001/S01",
"worktree-namespaced branch",
);
});
const namespaced = parseSliceBranch("sf/feature-auth/M001/S01");
assert.ok(namespaced !== null, "parses worktree-namespaced branch");
assert.deepStrictEqual(
namespaced!.worktreeName,
"feature-auth",
"worktree name extracted",
);
assert.deepStrictEqual(
namespaced!.milestoneId,
"M001",
"namespaced branch milestone",
);
assert.deepStrictEqual(namespaced!.sliceId, "S01", "namespaced branch slice");
test("parseSliceBranch", () => {
const plain = parseSliceBranch("sf/M001/S01");
assert.ok(plain !== null, "parses plain branch");
assert.deepStrictEqual(
plain!.worktreeName,
null,
"plain branch has no worktree name",
);
assert.deepStrictEqual(
plain!.milestoneId,
"M001",
"plain branch milestone",
);
assert.deepStrictEqual(plain!.sliceId, "S01", "plain branch slice");
const invalid = parseSliceBranch("main");
assert.deepStrictEqual(invalid, null, "non-slice branch returns null");
const namespaced = parseSliceBranch("sf/feature-auth/M001/S01");
assert.ok(namespaced !== null, "parses worktree-namespaced branch");
assert.deepStrictEqual(
namespaced!.worktreeName,
"feature-auth",
"worktree name extracted",
);
assert.deepStrictEqual(
namespaced!.milestoneId,
"M001",
"namespaced branch milestone",
);
assert.deepStrictEqual(
namespaced!.sliceId,
"S01",
"namespaced branch slice",
);
const worktreeBranch = parseSliceBranch("worktree/foo");
assert.deepStrictEqual(
worktreeBranch,
null,
"worktree/ prefix is not a slice branch",
);
const invalid = parseSliceBranch("main");
assert.deepStrictEqual(invalid, null, "non-slice branch returns null");
console.log("\n=== SLICE_BRANCH_RE ===");
assert.ok(SLICE_BRANCH_RE.test("sf/M001/S01"), "regex matches plain branch");
assert.ok(
SLICE_BRANCH_RE.test("sf/my-wt/M001/S01"),
"regex matches worktree branch",
);
assert.ok(!SLICE_BRANCH_RE.test("main"), "regex rejects main");
assert.ok(!SLICE_BRANCH_RE.test("sf/"), "regex rejects bare sf/");
assert.ok(
!SLICE_BRANCH_RE.test("worktree/foo"),
"regex rejects worktree/foo",
);
const worktreeBranch = parseSliceBranch("worktree/foo");
assert.deepStrictEqual(
worktreeBranch,
null,
"worktree/ prefix is not a slice branch",
);
});
console.log("\n=== detectWorktreeName ===");
assert.deepStrictEqual(
detectWorktreeName("/projects/myapp"),
null,
"no worktree in plain path",
);
assert.deepStrictEqual(
detectWorktreeName("/projects/myapp/.sf/worktrees/feature-auth"),
"feature-auth",
"detects worktree name",
);
assert.deepStrictEqual(
detectWorktreeName("/projects/myapp/.sf/worktrees/my-wt/subdir"),
"my-wt",
"detects worktree with subdir",
);
test("SLICE_BRANCH_RE", () => {
assert.ok(
SLICE_BRANCH_RE.test("sf/M001/S01"),
"regex matches plain branch",
);
assert.ok(
SLICE_BRANCH_RE.test("sf/my-wt/M001/S01"),
"regex matches worktree branch",
);
assert.ok(!SLICE_BRANCH_RE.test("main"), "regex rejects main");
assert.ok(!SLICE_BRANCH_RE.test("sf/"), "regex rejects bare sf/");
assert.ok(
!SLICE_BRANCH_RE.test("worktree/foo"),
"regex rejects worktree/foo",
);
});
test("detectWorktreeName", () => {
assert.deepStrictEqual(
detectWorktreeName("/projects/myapp"),
null,
"no worktree in plain path",
);
assert.deepStrictEqual(
detectWorktreeName("/projects/myapp/.sf/worktrees/feature-auth"),
"feature-auth",
"detects worktree name",
);
assert.deepStrictEqual(
detectWorktreeName("/projects/myapp/.sf/worktrees/my-wt/subdir"),
"my-wt",
"detects worktree with subdir",
);
});
// ═══════════════════════════════════════════════════════════════════════
// Integration branch — facade-level tests
// ═══════════════════════════════════════════════════════════════════════
// ── captureIntegrationBranch on a feature branch ──────────────────────
console.log("\n=== captureIntegrationBranch: records current branch ===");
{
test("captureIntegrationBranch records current branch", () => {
const repo = mkdtempSync(join(tmpdir(), "sf-integ-facade-"));
run("git init -b main", repo);
run("git config user.name 'Pi Test'", repo);
@ -220,13 +241,9 @@ describe("worktree", async () => {
);
rmSync(repo, { recursive: true, force: true });
}
});
// ── captureIntegrationBranch skips slice branches ─────────────────────
console.log("\n=== captureIntegrationBranch: skips slice branches ===");
{
test("captureIntegrationBranch skips slice branches", () => {
const repo = mkdtempSync(join(tmpdir(), "sf-integ-skip-"));
run("git init -b main", repo);
run("git config user.name 'Pi Test'", repo);
@ -244,13 +261,9 @@ describe("worktree", async () => {
);
rmSync(repo, { recursive: true, force: true });
}
});
// ── setActiveMilestoneId makes getMainBranch return integration branch ─
console.log("\n=== setActiveMilestoneId + getMainBranch ===");
{
test("setActiveMilestoneId + getMainBranch", () => {
const repo = mkdtempSync(join(tmpdir(), "sf-integ-main-"));
run("git init -b main", repo);
run("git config user.name 'Pi Test'", repo);
@ -293,77 +306,81 @@ describe("worktree", async () => {
}
rmSync(repo, { recursive: true, force: true });
}
});
// ── detectWorktreeName: symlink-resolved paths ───────────────────────────
console.log("\n=== detectWorktreeName (symlink-resolved paths) ===");
assert.deepStrictEqual(
detectWorktreeName("/Users/fran/.sf/projects/89e1c9ad49bf/worktrees/M001"),
"M001",
"detects milestone in symlink-resolved path",
);
assert.deepStrictEqual(
detectWorktreeName("/Users/fran/.sf/projects/abc123/worktrees/M002/subdir"),
"M002",
"detects milestone with trailing subdir in symlink-resolved path",
);
assert.deepStrictEqual(
detectWorktreeName("/Users/fran/.sf/projects/abc123"),
null,
"returns null for project root without worktrees segment",
);
assert.deepStrictEqual(
detectWorktreeName("/foo/.sf/worktrees/M001"),
"M001",
"still detects direct layout path",
);
test("detectWorktreeName with symlink-resolved paths", () => {
assert.deepStrictEqual(
detectWorktreeName(
"/Users/fran/.sf/projects/89e1c9ad49bf/worktrees/M001",
),
"M001",
"detects milestone in symlink-resolved path",
);
assert.deepStrictEqual(
detectWorktreeName(
"/Users/fran/.sf/projects/abc123/worktrees/M002/subdir",
),
"M002",
"detects milestone with trailing subdir in symlink-resolved path",
);
assert.deepStrictEqual(
detectWorktreeName("/Users/fran/.sf/projects/abc123"),
null,
"returns null for project root without worktrees segment",
);
assert.deepStrictEqual(
detectWorktreeName("/foo/.sf/worktrees/M001"),
"M001",
"still detects direct layout path",
);
});
// ── resolveProjectRoot: symlink-resolved paths ──────────────────────────
console.log("\n=== resolveProjectRoot (symlink-resolved paths) ===");
test("resolveProjectRoot with symlink-resolved paths", () => {
// BUG FIX: symlink-resolved paths that land inside ~/.sf should NOT
// resolve to the home directory. When the .git file fallback can't find
// the real project root (no git worktree metadata in these synthetic paths),
// resolveProjectRoot returns the input unchanged rather than returning ~.
// BUG FIX: symlink-resolved paths that land inside ~/.sf should NOT
// resolve to the home directory. When the .git file fallback can't find
// the real project root (no git worktree metadata in these synthetic paths),
// resolveProjectRoot returns the input unchanged rather than returning ~.
// With SF_PROJECT_ROOT env var set (layer 1 — coordinator passes it)
process.env.SF_PROJECT_ROOT = "/real/project";
assert.deepStrictEqual(
resolveProjectRoot(
"/Users/fran/.sf/projects/89e1c9ad49bf/worktrees/M001",
),
"/real/project",
"uses SF_PROJECT_ROOT when set",
);
delete process.env.SF_PROJECT_ROOT;
// With SF_PROJECT_ROOT env var set (layer 1 — coordinator passes it)
process.env.SF_PROJECT_ROOT = "/real/project";
assert.deepStrictEqual(
resolveProjectRoot("/Users/fran/.sf/projects/89e1c9ad49bf/worktrees/M001"),
"/real/project",
"uses SF_PROJECT_ROOT when set",
);
delete process.env.SF_PROJECT_ROOT;
// Without SF_PROJECT_ROOT, direct layout still works (no ~/.sf collision)
assert.deepStrictEqual(
resolveProjectRoot("/some/repo"),
"/some/repo",
"ignores SF_PROJECT_ROOT override for non-worktree paths",
);
delete process.env.SF_PROJECT_ROOT;
// Without SF_PROJECT_ROOT, direct layout still works (no ~/.sf collision)
assert.deepStrictEqual(
resolveProjectRoot("/some/repo"),
"/some/repo",
"ignores SF_PROJECT_ROOT override for non-worktree paths",
);
delete process.env.SF_PROJECT_ROOT;
// Without SF_PROJECT_ROOT, direct layout still works (no ~/.sf collision)
assert.deepStrictEqual(
resolveProjectRoot("/foo/.sf/worktrees/M001"),
"/foo",
"still resolves direct layout path",
);
assert.deepStrictEqual(
resolveProjectRoot("/some/repo"),
"/some/repo",
"returns unchanged for non-worktree path",
);
// Without SF_PROJECT_ROOT, direct layout still works (no ~/.sf collision)
assert.deepStrictEqual(
resolveProjectRoot("/foo/.sf/worktrees/M001"),
"/foo",
"still resolves direct layout path",
);
assert.deepStrictEqual(
resolveProjectRoot("/some/repo"),
"/some/repo",
"returns unchanged for non-worktree path",
);
// Without SF_PROJECT_ROOT, direct layout with nested subdirs
assert.deepStrictEqual(
resolveProjectRoot("/data/.sf/worktrees/M003/nested"),
"/data",
"resolves correctly with nested subdirs after worktree name (direct layout)",
);
});
// Without SF_PROJECT_ROOT, direct layout with nested subdirs
assert.deepStrictEqual(
resolveProjectRoot("/data/.sf/worktrees/M003/nested"),
"/data",
"resolves correctly with nested subdirs after worktree name (direct layout)",
);
// Real symlink + git worktree scenario, with deep nested path from cwd
{
test("resolveProjectRoot with real symlink + git worktree", () => {
const fakeHome = mkdtempSync(join(tmpdir(), "sf-home-"));
const project = realpathSync(mkdtempSync(join(tmpdir(), "sf-proj-")));
const storage = join(fakeHome, ".sf", "projects", "abc123def456");
@ -406,7 +423,5 @@ describe("worktree", async () => {
rmSync(project, { recursive: true, force: true });
rmSync(fakeHome, { recursive: true, force: true });
}
rmSync(base, { recursive: true, force: true });
});
});