/** * @file Tests for server/lib/worktree.js against a REAL git repository created * in a temp directory. Every behaviour worth testing here is git's own — branch * collisions, what `clean -fd` spares, what `worktree list` reports — so mocking * git would only test our idea of git. * @author Nguyễn Ngọc Trí Vĩ */ const { describe, it, before, after } = require("node:test"); const assert = require("node:assert/strict"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const { execFileSync } = require("node:child_process"); const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-wt-")); process.env.LANES_ROOT = path.join(ROOT, "lanes"); const wt = require("../lib/worktree"); const SRC = path.join(ROOT, "src-repo"); const g = (cwd, ...args) => { // Scrub git hook environment variables (GIT_DIR, GIT_INDEX_FILE, etc.) // so the fixture builder doesn't inherit them from the test harness. const env = { ...process.env }; delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_COMMON_DIR; delete env.GIT_OBJECT_DIRECTORY; delete env.GIT_ALTERNATE_OBJECT_DIRECTORIES; delete env.GIT_PREFIX; delete env.GIT_NAMESPACE; delete env.GIT_CONFIG_PARAMETERS; env.GIT_TERMINAL_PROMPT = "0"; return execFileSync("git", args, { cwd, encoding: "utf8", env }); }; before(() => { fs.mkdirSync(SRC, { recursive: true }); g(SRC, "init", "-b", "main"); g(SRC, "config", "user.email", "t@example.com"); g(SRC, "config", "user.name", "Test"); fs.writeFileSync(path.join(SRC, "README.md"), "hello\n"); fs.writeFileSync(path.join(SRC, ".gitignore"), "node_modules/\n.env\n"); g(SRC, "add", "-A"); g(SRC, "commit", "-m", "init"); }); after(() => fs.rmSync(ROOT, { recursive: true, force: true })); function laneFor(dir, branch, over = {}) { return { id: 1, kind: "managed", cwd: dir, branch, source_repo: SRC, base_branch: "main", ...over, }; } describe("worktree", () => { it("slugifies a title into a safe single segment", () => { assert.equal(wt.slugify("Rename Metric → Rule!"), "rename-metric-rule"); assert.equal(wt.slugify(" a//b "), "a-b"); assert.ok(wt.slugify("x".repeat(80)).length <= 40); }); it("resolves the base branch, falling back when origin has none", async () => { assert.equal(await wt.resolveBase(SRC, "main"), "main"); assert.equal(await wt.resolveBase(SRC, "does-not-exist"), "main"); }); it("creates a worktree on a new branch and lists it", async () => { const dir = path.join(process.env.LANES_ROOT, "src-repo__alpha"); const r = await wt.addWorktree({ sourceRepo: SRC, dir, branch: "feat/alpha", base: "main" }); assert.equal(r.created, true); assert.ok(fs.existsSync(path.join(dir, "README.md"))); const list = await wt.listWorktrees(SRC); assert.ok(list.some((w) => w.path === dir && w.branch === "feat/alpha")); }); it("refuses a branch already checked out in another worktree", async () => { const dir2 = path.join(process.env.LANES_ROOT, "src-repo__alpha2"); await assert.rejects( () => wt.addWorktree({ sourceRepo: SRC, dir: dir2, branch: "feat/alpha", base: "main" }), (e) => e.code === "EBRANCHBUSY" && typeof e.checkedOutAt === "string" ); }); it("counts dirty, untracked and unpushed work", async () => { const dir = path.join(process.env.LANES_ROOT, "src-repo__alpha"); fs.appendFileSync(path.join(dir, "README.md"), "edit\n"); fs.writeFileSync(path.join(dir, "scratch.txt"), "untracked\n"); fs.mkdirSync(path.join(dir, "node_modules"), { recursive: true }); fs.writeFileSync(path.join(dir, "node_modules", "dep.js"), "x\n"); const s = await wt.statusCounts(dir); assert.equal(s.dirty, 1); assert.equal(s.untracked, 1); // node_modules is ignored, so it does not count assert.match(s.head, /^[0-9a-f]{7,40}$/); // The fixture has no remotes. Given the lane's base branch, unpushedCount // measures base..HEAD — this worktree has committed nothing of its own, so 0. assert.equal(await wt.unpushedCount(dir, "main"), 0); // With no base to measure against (an adopted lane has none), it falls back // to the total commit count, since every commit is then at risk. assert.equal(await wt.unpushedCount(dir), 1); }); it("reset restores base, drops untracked files, and spares gitignored ones", async () => { const dir = path.join(process.env.LANES_ROOT, "src-repo__alpha"); await wt.resetWorktree(laneFor(dir, "feat/alpha")); assert.equal(fs.readFileSync(path.join(dir, "README.md"), "utf8"), "hello\n"); assert.equal(fs.existsSync(path.join(dir, "scratch.txt")), false); assert.equal(fs.existsSync(path.join(dir, "node_modules", "dep.js")), true); const s = await wt.statusCounts(dir); assert.equal(s.dirty, 0); // Verify we're on the feature branch, not left on base const branch = g(dir, "rev-parse", "--abbrev-ref", "HEAD").trim(); assert.equal(branch, "feat/alpha"); }); it("reset against a bogus base_branch throws ENOBASE and leaves worktree untouched", async () => { // Create a new worktree with a bogus base that will exist as a branch // but we'll change it to non-existent in the lane const dir = path.join(process.env.LANES_ROOT, "src-repo__bogus-test"); await wt.addWorktree({ sourceRepo: SRC, dir, branch: "feat/bogus-test", base: "main" }); // Make a modification to detect if the worktree is mutated fs.writeFileSync(path.join(dir, "test-file.txt"), "test\n"); // Get the current state before the failed reset const branchBefore = g(dir, "rev-parse", "--abbrev-ref", "HEAD").trim(); const statusBefore = wt.statusCounts(dir); // Try to reset against a bogus base_branch await assert.rejects( () => wt.resetWorktree(laneFor(dir, "feat/bogus-test", { base_branch: "non-existent-branch" })), (e) => e.code === "ENOBASE" && e.message.includes("non-existent-branch") ); // Verify the worktree was not mutated: still on the same branch const branchAfter = g(dir, "rev-parse", "--abbrev-ref", "HEAD").trim(); assert.equal(branchAfter, branchBefore); // Verify the file still exists (no mutations happened) assert.ok(fs.existsSync(path.join(dir, "test-file.txt"))); // Clean up await wt.removeWorktree(laneFor(dir, "feat/bogus-test")); }); it("refuses to destroy an adopted lane, a path outside LANES_ROOT, or a non-worktree", async () => { const dir = path.join(process.env.LANES_ROOT, "src-repo__alpha"); await assert.rejects( () => wt.assertDestroyable(laneFor(dir, "feat/alpha", { kind: "adopted" })), (e) => e.code === "ENOTMANAGED" ); await assert.rejects( () => wt.assertDestroyable(laneFor("/tmp", "feat/alpha")), (e) => e.code === "EOUTSIDEROOT" ); const ghost = path.join(process.env.LANES_ROOT, "src-repo__ghost"); fs.mkdirSync(ghost, { recursive: true }); await assert.rejects( () => wt.assertDestroyable(laneFor(ghost, "feat/ghost")), (e) => e.code === "ENOTWORKTREE" ); }); // These call removeWorktree DIRECTLY. The route short-circuits adopted lanes // before reaching it, so route-level tests can never pin its guard: deleting // `await assertDestroyable(lane)` from removeWorktree left all 844 server tests // green. Each case asserts the error code AND that nothing was destroyed. describe("removeWorktree refuses what it must not destroy", () => { it("refuses an adopted lane and leaves its directory and files alone", async () => { const dir = path.join(ROOT, "adopted-project"); fs.mkdirSync(dir, { recursive: true }); const file = path.join(dir, "real-work.txt"); fs.writeFileSync(file, "the user's own project\n"); await assert.rejects( () => wt.removeWorktree(laneFor(dir, "feat/whatever", { kind: "adopted" })), (e) => e.code === "ENOTMANAGED" ); assert.equal(fs.existsSync(dir), true); assert.equal(fs.readFileSync(file, "utf8"), "the user's own project\n"); }); it("refuses a cwd outside LANES_ROOT and leaves that directory alone", async () => { const dir = path.join(ROOT, "outside-root"); fs.mkdirSync(dir, { recursive: true }); const file = path.join(dir, "keep.txt"); fs.writeFileSync(file, "outside the sandbox\n"); await assert.rejects( () => wt.removeWorktree(laneFor(dir, "feat/outside")), (e) => e.code === "EOUTSIDEROOT" ); assert.equal(fs.existsSync(dir), true); assert.equal(fs.readFileSync(file, "utf8"), "outside the sandbox\n"); }); it("refuses a real directory inside LANES_ROOT that git does not list as a worktree", async () => { const dir = path.join(process.env.LANES_ROOT, "src-repo__not-a-worktree"); fs.mkdirSync(dir, { recursive: true }); const file = path.join(dir, "keep.txt"); fs.writeFileSync(file, "never registered with git\n"); await assert.rejects( () => wt.removeWorktree(laneFor(dir, "feat/not-a-worktree")), (e) => e.code === "ENOTWORKTREE" ); assert.equal(fs.existsSync(dir), true); assert.equal(fs.readFileSync(file, "utf8"), "never registered with git\n"); }); }); it("removes a lane whose worktree was deleted by hand, pruning git's stale record", async () => { // The design promises "the lane reports `missing` and only `remove` is // offered, taking the prune path". Before this, check 2 mapped the vanished // path to EOUTSIDEROOT and the lane could never be removed at all. const dir = path.join(process.env.LANES_ROOT, "src-repo__hand-deleted"); await wt.addWorktree({ sourceRepo: SRC, dir, branch: "feat/hand-deleted", base: "main" }); fs.rmSync(dir, { recursive: true, force: true }); assert.ok( (await wt.listWorktrees(SRC)).some((w) => w.path === dir), "git should still list the hand-deleted worktree" ); await wt.removeWorktree(laneFor(dir, "feat/hand-deleted")); assert.equal( (await wt.listWorktrees(SRC)).some((w) => w.path === dir), false ); assert.equal(g(SRC, "branch", "--list", "feat/hand-deleted").trim(), ""); }); it("removes a lane git never knew about, when its directory is already gone", async () => { const dir = path.join(process.env.LANES_ROOT, "src-repo__never-existed"); assert.equal(fs.existsSync(dir), false); // No throw: there is nothing on disk and nothing in git to clean up. await wt.removeWorktree(laneFor(dir, "feat/never-existed")); }); it("still refuses the prune path for a missing cwd outside LANES_ROOT", async () => { // The directory is gone, so check 2 cannot realpath it — but it must still // hold, or a lane pointing anywhere could prune a source repo's worktrees. await assert.rejects( () => wt.removeWorktree(laneFor(path.join(ROOT, "gone-and-outside"), "feat/gone")), (e) => e.code === "EOUTSIDEROOT" ); }); it("one prunable sibling worktree does not break reset or remove for other lanes", async () => { // git keeps listing a hand-deleted worktree as `prunable`. realpathSync on // every listed entry threw ENOENT out of assertDestroyable, so a single // stale sibling produced an opaque 500 naming an unrelated directory for // every managed lane in the same repo. const victimDir = path.join(process.env.LANES_ROOT, "src-repo__prunable-victim"); await wt.addWorktree({ sourceRepo: SRC, dir: victimDir, branch: "feat/victim", base: "main" }); const siblingDir = path.join(process.env.LANES_ROOT, "src-repo__prunable-sibling"); await wt.addWorktree({ sourceRepo: SRC, dir: siblingDir, branch: "feat/sibling", base: "main", }); fs.rmSync(siblingDir, { recursive: true, force: true }); assert.ok( (await wt.listWorktrees(SRC)).some((w) => w.path === siblingDir), "git should still list the prunable sibling" ); await wt.assertDestroyable(laneFor(victimDir, "feat/victim")); await wt.resetWorktree(laneFor(victimDir, "feat/victim")); await wt.removeWorktree(laneFor(victimDir, "feat/victim")); assert.equal(fs.existsSync(victimDir), false); // Clean up the stale record so later tests see a tidy list. await wt.removeWorktree(laneFor(siblingDir, "feat/sibling")); }); it("removes a lane whose worktree's .git pointer is corrupt (unreadable), deregistering it without touching its directory", async () => { // `git worktree remove --force` (even --force --force) refuses outright // when the worktree's OWN .git file fails git's validation — this is what // `unreadable` in the preflight actually is. All three assertDestroyable // checks still pass (the directory exists, is inside LANES_ROOT, and is // still listed by the source repo), so removeWorktree must not get stuck. const dir = path.join(process.env.LANES_ROOT, "src-repo__corrupt"); await wt.addWorktree({ sourceRepo: SRC, dir, branch: "feat/corrupt", base: "main" }); fs.writeFileSync(path.join(dir, "keep.txt"), "still here\n"); fs.writeFileSync(path.join(dir, ".git"), "garbage\n"); assert.throws( () => g(dir, "status"), "sanity: git itself must refuse to operate inside the corrupt worktree" ); await wt.removeWorktree(laneFor(dir, "feat/corrupt")); assert.equal( (await wt.listWorktrees(SRC)).some((w) => w.path === dir), false, "git should no longer list the corrupt worktree" ); assert.equal(g(SRC, "branch", "--list", "feat/corrupt").trim(), ""); // The directory and its files were never touched. assert.equal(fs.existsSync(dir), true); assert.equal(fs.readFileSync(path.join(dir, "keep.txt"), "utf8"), "still here\n"); }); it("removes the worktree and its branch, leaving git's list clean", async () => { const dir = path.join(process.env.LANES_ROOT, "src-repo__alpha"); await wt.removeWorktree(laneFor(dir, "feat/alpha")); assert.equal(fs.existsSync(dir), false); const list = await wt.listWorktrees(SRC); assert.equal( list.some((w) => w.path === dir), false ); const branches = g(SRC, "branch", "--list", "feat/alpha").trim(); assert.equal(branches, ""); }); it("never deletes the base branch even if a lane claims it", async () => { const dir = path.join(process.env.LANES_ROOT, "src-repo__beta"); await wt.addWorktree({ sourceRepo: SRC, dir, branch: "feat/beta", base: "main" }); await wt.removeWorktree(laneFor(dir, "main")); // lane lies about its branch assert.match(g(SRC, "branch", "--list", "main"), /main/); }); it("scrubs git hook environment variables from child processes", async () => { // Regression test: GIT_DIR, GIT_INDEX_FILE, etc. from git hooks must not // corrupt git operations on worktrees (where .git is a file, not a directory). // This test verifies that addWorktree and statusCounts work even when these // variables are set to bogus values. const oldGitDir = process.env.GIT_DIR; const oldGitIndexFile = process.env.GIT_INDEX_FILE; try { process.env.GIT_DIR = "/nonexistent/.git"; process.env.GIT_INDEX_FILE = "/nonexistent/.git/index"; const dir = path.join(process.env.LANES_ROOT, "src-repo__scrub-test"); await wt.addWorktree({ sourceRepo: SRC, dir, branch: "feat/scrub-test", base: "main" }); const s = await wt.statusCounts(dir); assert.ok(s.head); // should have a commit hash assert.equal(s.dirty, 0); // should be clean // Clean up await wt.removeWorktree(laneFor(dir, "feat/scrub-test")); } finally { // Restore original values if (oldGitDir !== undefined) { process.env.GIT_DIR = oldGitDir; } else { delete process.env.GIT_DIR; } if (oldGitIndexFile !== undefined) { process.env.GIT_INDEX_FILE = oldGitIndexFile; } else { delete process.env.GIT_INDEX_FILE; } } }); }); describe("gitFacts", () => { it("reports branch, short head, subject and working-tree counts", async () => { const dir = path.join(ROOT, "facts-repo"); fs.mkdirSync(dir, { recursive: true }); g(dir, "init", "-b", "feat/facts"); g(dir, "config", "user.email", "t@example.com"); g(dir, "config", "user.name", "Test"); fs.writeFileSync(path.join(dir, "tracked.txt"), "one\n"); g(dir, "add", "-A"); g(dir, "commit", "-m", "seed the facts fixture"); // one modified tracked file, one file git has never seen fs.writeFileSync(path.join(dir, "tracked.txt"), "two\n"); fs.writeFileSync(path.join(dir, "brand-new.txt"), "x\n"); const facts = await wt.gitFacts(dir); assert.equal(facts.branch, "feat/facts"); assert.equal(facts.head, g(dir, "rev-parse", "--short", "HEAD").trim()); assert.equal(facts.subject, "seed the facts fixture"); assert.equal(facts.dirty, 1); assert.equal(facts.untracked, 1); }); it("reports the literal HEAD git gives for a detached checkout", async () => { const dir = path.join(ROOT, "facts-detached"); fs.mkdirSync(dir, { recursive: true }); g(dir, "init", "-b", "main"); g(dir, "config", "user.email", "t@example.com"); g(dir, "config", "user.name", "Test"); fs.writeFileSync(path.join(dir, "a.txt"), "a\n"); g(dir, "add", "-A"); g(dir, "commit", "-m", "only commit"); g(dir, "checkout", "--detach", "HEAD"); const facts = await wt.gitFacts(dir); assert.equal(facts.branch, "HEAD"); assert.equal(facts.dirty, 0); assert.equal(facts.untracked, 0); }); it("rejects rather than reporting facts for a directory that is not a repo", async () => { const dir = path.join(ROOT, "facts-plain"); fs.mkdirSync(dir, { recursive: true }); await assert.rejects(() => wt.gitFacts(dir)); }); }); describe("listBranches", () => { it("lists local branches and names the current one", async () => { const dir = path.join(ROOT, "branches-repo"); fs.mkdirSync(dir, { recursive: true }); g(dir, "init", "-b", "main"); g(dir, "config", "user.email", "t@example.com"); g(dir, "config", "user.name", "Test"); fs.writeFileSync(path.join(dir, "a.txt"), "a\n"); g(dir, "add", "-A"); g(dir, "commit", "-m", "init"); g(dir, "branch", "feat/one"); g(dir, "branch", "feat/two"); const { branches, current } = await wt.listBranches(dir); assert.deepEqual([...branches].sort(), ["feat/one", "feat/two", "main"]); assert.equal(current, "main"); }); it("reports no current branch for a detached HEAD, but still lists branches", async () => { const dir = path.join(ROOT, "branches-detached"); fs.mkdirSync(dir, { recursive: true }); g(dir, "init", "-b", "main"); g(dir, "config", "user.email", "t@example.com"); g(dir, "config", "user.name", "Test"); fs.writeFileSync(path.join(dir, "a.txt"), "a\n"); g(dir, "add", "-A"); g(dir, "commit", "-m", "init"); g(dir, "checkout", "--detach", "HEAD"); const { branches, current } = await wt.listBranches(dir); assert.deepEqual(branches, ["main"]); assert.equal(current, null); }); it("returns an empty branch list for a repo with no commits yet", async () => { const dir = path.join(ROOT, "branches-empty"); fs.mkdirSync(dir, { recursive: true }); g(dir, "init", "-b", "main"); const { branches } = await wt.listBranches(dir); assert.deepEqual(branches, []); }); });