import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; import { existsSync } from "node:fs"; import { mkdir, mkdtemp, realpath, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { createGit, GitError } from "../src/git.ts"; const git = createGit(); const GIT_ENV = { ...process.env, GIT_AUTHOR_NAME: "pi-worktree-test", GIT_AUTHOR_EMAIL: "test@example.com", GIT_COMMITTER_NAME: "pi-worktree-test", GIT_COMMITTER_EMAIL: "test@example.com", }; function raw(args: string[], cwd: string): string { return execFileSync("git", args, { cwd, env: GIT_ENV, encoding: "utf8" }); } /** Creates /repo as a git repo; sibling dirs under host worktrees. */ async function makeRepo(): Promise<{ parent: string; repo: string }> { const parent = await mkdtemp(join(tmpdir(), "pi-worktree-git-")); const repo = join(parent, "repo"); await mkdir(repo); raw(["init", "-b", "main"], repo); raw(["config", "user.name", "pi-worktree-test"], repo); raw(["config", "user.email", "test@example.com"], repo); await writeFile(join(repo, "file.txt"), "base\n", "utf8"); raw(["add", "-A"], repo); raw(["commit", "-m", "init"], repo); return { parent, repo }; } async function commitFile(dir: string, name: string, content: string, message: string): Promise { await writeFile(join(dir, name), content, "utf8"); raw(["add", "-A"], dir); raw(["commit", "-m", message], dir); } async function worktreeOf(parent: string, repo: string, name: string): Promise { const path = join(parent, name); await git.addWorktree(repo, path, `wt/${name}`, "HEAD"); return path; } test("addWorktree creates the directory and branch", async () => { const { parent, repo } = await makeRepo(); const wt = await worktreeOf(parent, repo, "feat-a"); assert.ok(existsSync(wt)); assert.equal(await git.getCurrentBranch(wt), "wt/feat-a"); assert.ok(await git.branchExists(repo, "wt/feat-a")); }); test("ff-only merge brings source HEAD to the worktree HEAD", async () => { const { parent, repo } = await makeRepo(); const wt = await worktreeOf(parent, repo, "feat-b"); await commitFile(wt, "new.txt", "work\n", "wt commit"); const rebaseResult = await git.rebase(wt, "main"); assert.deepEqual(rebaseResult, { ok: true, conflict: false }); assert.equal(await git.aheadCount(wt, "main", "wt/feat-b"), 1); await git.mergeFfOnly(repo, "wt/feat-b"); assert.equal(await git.getHeadSha(repo), await git.getHeadSha(wt)); }); test("rebase conflict stays in progress, lists unmerged files, and aborts cleanly", async () => { const { parent, repo } = await makeRepo(); const wt = await worktreeOf(parent, repo, "feat-c"); await commitFile(wt, "file.txt", "worktree version\n", "wt edit"); await commitFile(repo, "file.txt", "source version\n", "source edit"); const before = await git.getHeadSha(wt); const result = await git.rebase(wt, "main"); assert.deepEqual(result, { ok: false, conflict: true }); assert.equal(await git.rebaseInProgress(wt), true); assert.deepEqual(await git.unmergedFiles(wt), ["file.txt"]); await git.rebaseAbort(wt); assert.equal(await git.rebaseInProgress(wt), false); assert.equal(await git.getHeadSha(wt), before); assert.ok(!(await git.isDirty(wt))); }); test("rebaseContinue finishes once conflicts are resolved and added", async () => { const { parent, repo } = await makeRepo(); const wt = await worktreeOf(parent, repo, "feat-c2"); await commitFile(wt, "file.txt", "worktree version\n", "wt edit"); await commitFile(repo, "file.txt", "source version\n", "source edit"); const result = await git.rebase(wt, "main"); assert.deepEqual(result, { ok: false, conflict: true }); assert.equal(await git.rebaseInProgress(wt), true); await writeFile(join(wt, "file.txt"), "resolved\n", "utf8"); raw(["add", "file.txt"], wt); const continued = await git.rebaseContinue(wt); assert.deepEqual(continued, { ok: true, conflict: false }); assert.equal(await git.rebaseInProgress(wt), false); assert.equal(await git.isAncestor(repo, "main", "wt/feat-c2"), true); }); test("mergeSquash produces exactly one new commit on the source", async () => { const { parent, repo } = await makeRepo(); const wt = await worktreeOf(parent, repo, "feat-d"); await commitFile(wt, "a.txt", "1\n", "c1"); await commitFile(wt, "b.txt", "2\n", "c2"); const before = await git.getHeadSha(repo); await git.rebase(wt, "main"); await git.mergeSquash(repo, "wt/feat-d", "squashed"); const after = await git.getHeadSha(repo); assert.notEqual(before, after); assert.equal(raw(["rev-list", "--count", `${before}..${after}`], repo).trim(), "1"); assert.equal(await readFile(join(repo, "a.txt"), "utf8"), "1\n"); }); test("stashPush reports whether anything was stashed and stashPop restores it", async () => { const { repo } = await makeRepo(); assert.equal(await git.stashPush(repo, "nothing"), false); await writeFile(join(repo, "dirty.txt"), "dirty\n", "utf8"); assert.equal(await git.stashPush(repo, "pi-worktree test"), true); assert.equal(existsSync(join(repo, "dirty.txt")), false); await git.stashPop(repo); assert.equal(await readFile(join(repo, "dirty.txt"), "utf8"), "dirty\n"); }); test("rebase onto a nonexistent ref throws the original rebase error", async () => { const { parent, repo } = await makeRepo(); const wt = await worktreeOf(parent, repo, "feat-e"); await assert.rejects( () => git.rebase(wt, "no-such-ref"), (error: unknown) => { assert.ok(error instanceof GitError, `expected GitError, got ${String(error)}`); assert.equal(error.args[0], "rebase"); assert.ok(!error.message.includes("--abort"), error.message); return true; }, ); assert.equal(await git.rebaseInProgress(wt), false); }); test("removeWorktree with force deletes the directory while the branch stays", async () => { const { parent, repo } = await makeRepo(); const wt = await worktreeOf(parent, repo, "feat-f"); await writeFile(join(wt, "junk.bin"), "x", "utf8"); await git.removeWorktree(repo, wt, true); assert.equal(existsSync(wt), false); assert.ok(await git.branchExists(repo, "wt/feat-f")); }); test("listWorktrees parses main, linked, and detached entries", async () => { const { parent, repo } = await makeRepo(); const wt = await worktreeOf(parent, repo, "feat-g"); const detachedPath = join(parent, "detached-wt"); raw(["worktree", "add", "--detach", detachedPath, "HEAD"], repo); const entries = await git.listWorktrees(repo); assert.equal(entries.length, 3); const byPath = new Map( await Promise.all( entries.map(async (e) => [(await realpath(e.path).catch(() => e.path)), e] as const), ), ); const main = entries[0]; assert.equal(main?.branch, "main"); assert.equal(byPath.get(await realpath(wt))?.branch, "wt/feat-g"); assert.equal(byPath.get(await realpath(detachedPath))?.detached, true); assert.equal(await git.getMainWorktreePath(repo), main?.path); });