930 lines · 35.2 KB · TypeScript
RawDownload
import { existsSync } from "node:fs";import { mkdtemp, rm, writeFile } from "node:fs/promises";import { tmpdir } from "node:os";import path from "node:path";import { describe, expect, it, vi } from "vitest"; import {  GitCoreService,  checkoutGitBranch,  createGitBranch,  createGitWorktree,  initGitRepo,  listGitBranches,  pullGitBranch,  removeGitWorktree,  runTerminalCommand,} from "./git"; // ── Helpers ── /** Run a raw git command for test setup (not under test). */async function git(cwd: string, command: string): Promise<string> {  const result = await runTerminalCommand({    command: `git ${command}`,    cwd,    timeoutMs: 10_000,  });  if (result.code !== 0) {    throw new Error(`git ${command} failed: ${result.stderr}`);  }  return result.stdout.trim();} /** Create a disposable temp directory that cleans up automatically. */async function makeTmpDir() {  const dir = await mkdtemp(path.join(tmpdir(), "git-test-"));  return {    path: dir,    [Symbol.asyncDispose]: async () => {      await rm(dir, { recursive: true, force: true });    },  };} /** Create a repo with an initial commit so branches work. */async function initRepoWithCommit(cwd: string): Promise<void> {  await initGitRepo({ cwd });  await git(cwd, "config user.email 'test@test.com'");  await git(cwd, "config user.name 'Test'");  await writeFile(path.join(cwd, "README.md"), "# test\n");  await git(cwd, "add .");  await git(cwd, "commit -m 'initial commit'");} async function commitWithDate(  cwd: string,  fileName: string,  fileContents: string,  dateIsoString: string,  message: string,): Promise<void> {  await writeFile(path.join(cwd, fileName), fileContents);  await git(cwd, `add ${fileName}`);  const commitResult = await runTerminalCommand({    command: `GIT_AUTHOR_DATE="${dateIsoString}" GIT_COMMITTER_DATE="${dateIsoString}" git commit -m "${message}"`,    cwd,    timeoutMs: 10_000,  });  if (commitResult.code !== 0) {    throw new Error(`git dated commit failed: ${commitResult.stderr}`);  }} // ── Tests ── describe("git integration", () => {  describe("runTerminalCommand", () => {    it("caps captured output when maxOutputBytes is exceeded", async () => {      const result = await runTerminalCommand({        command: `node -e "process.stdout.write('x'.repeat(2000))"`,        cwd: process.cwd(),        timeoutMs: 10_000,        maxOutputBytes: 128,      });       expect(result.code).toBe(0);      expect(result.stdout.length).toBeLessThanOrEqual(128);      expect(result.stderr).toContain("output truncated");    });  });   // ── initGitRepo ──   describe("initGitRepo", () => {    it("creates a valid git repo", async () => {      await using tmp = await makeTmpDir();      await initGitRepo({ cwd: tmp.path });      expect(existsSync(path.join(tmp.path, ".git"))).toBe(true);    });     it("listGitBranches reports isRepo: true after init + commit", async () => {      await using tmp = await makeTmpDir();      await initRepoWithCommit(tmp.path);      const result = await listGitBranches({ cwd: tmp.path });      expect(result.isRepo).toBe(true);      expect(result.branches.length).toBeGreaterThanOrEqual(1);    });  });   // ── listGitBranches ──   describe("listGitBranches", () => {    it("returns isRepo: false for non-git directory", async () => {      await using tmp = await makeTmpDir();      const result = await listGitBranches({ cwd: tmp.path });      expect(result.isRepo).toBe(false);      expect(result.branches).toEqual([]);    });     it("returns the current branch with current: true", async () => {      await using tmp = await makeTmpDir();      await initRepoWithCommit(tmp.path);      const result = await listGitBranches({ cwd: tmp.path });      const current = result.branches.find((b) => b.current);      expect(current).toBeDefined();      expect(current!.current).toBe(true);    });     it("keeps current branch first and sorts the remaining branches by recency", async () => {      await using tmp = await makeTmpDir();      await initRepoWithCommit(tmp.path);      const initialBranch = (await listGitBranches({ cwd: tmp.path })).branches.find(        (branch) => branch.current,      )!.name;       await createGitBranch({ cwd: tmp.path, branch: "older-branch" });      await checkoutGitBranch({ cwd: tmp.path, branch: "older-branch" });      await commitWithDate(        tmp.path,        "older.txt",        "older branch change\n",        "Thu, 1 Jan 2037 00:00:00 +0000",        "older branch change",      );       await checkoutGitBranch({ cwd: tmp.path, branch: initialBranch });      await createGitBranch({ cwd: tmp.path, branch: "newer-branch" });      await checkoutGitBranch({ cwd: tmp.path, branch: "newer-branch" });      await commitWithDate(        tmp.path,        "newer.txt",        "newer branch change\n",        "Fri, 1 Jan 2038 00:00:00 +0000",        "newer branch change",      );       // Switch away to show current branch is pinned, then remaining branches are recency-sorted.      await checkoutGitBranch({ cwd: tmp.path, branch: "older-branch" });       const result = await listGitBranches({ cwd: tmp.path });      expect(result.branches[0]!.name).toBe("older-branch");      expect(result.branches[1]!.name).toBe("newer-branch");    });     it("keeps default branch right after current branch", async () => {      await using tmp = await makeTmpDir();      await using remote = await makeTmpDir();      await initRepoWithCommit(tmp.path);      const defaultBranch = (await listGitBranches({ cwd: tmp.path })).branches.find(        (branch) => branch.current,      )!.name;       await git(remote.path, "init --bare");      await git(tmp.path, `remote add origin ${JSON.stringify(remote.path)}`);      await git(tmp.path, `push -u origin ${defaultBranch}`);      await git(tmp.path, `remote set-head origin ${defaultBranch}`);       await createGitBranch({ cwd: tmp.path, branch: "current-branch" });      await checkoutGitBranch({ cwd: tmp.path, branch: "current-branch" });      await commitWithDate(        tmp.path,        "current.txt",        "current change\n",        "Thu, 1 Jan 2037 00:00:00 +0000",        "current change",      );       await checkoutGitBranch({ cwd: tmp.path, branch: defaultBranch });      await createGitBranch({ cwd: tmp.path, branch: "newer-branch" });      await checkoutGitBranch({ cwd: tmp.path, branch: "newer-branch" });      await commitWithDate(        tmp.path,        "newer.txt",        "newer change\n",        "Fri, 1 Jan 2038 00:00:00 +0000",        "newer change",      );       await checkoutGitBranch({ cwd: tmp.path, branch: "current-branch" });       const result = await listGitBranches({ cwd: tmp.path });      expect(result.branches[0]!.name).toBe("current-branch");      expect(result.branches[1]!.name).toBe(defaultBranch);      expect(result.branches[2]!.name).toBe("newer-branch");    });     it("lists multiple branches after creating them", async () => {      await using tmp = await makeTmpDir();      await initRepoWithCommit(tmp.path);      await createGitBranch({ cwd: tmp.path, branch: "feature-a" });      await createGitBranch({ cwd: tmp.path, branch: "feature-b" });       const result = await listGitBranches({ cwd: tmp.path });      const names = result.branches.map((b) => b.name);      expect(names).toContain("feature-a");      expect(names).toContain("feature-b");    });     it("isDefault is false when no remote exists", async () => {      await using tmp = await makeTmpDir();      await initRepoWithCommit(tmp.path);      const result = await listGitBranches({ cwd: tmp.path });      expect(result.branches.every((b) => b.isDefault === false)).toBe(true);    });  });   // ── checkoutGitBranch ──   describe("checkoutGitBranch", () => {    it("checks out an existing branch", async () => {      await using tmp = await makeTmpDir();      await initRepoWithCommit(tmp.path);      await createGitBranch({ cwd: tmp.path, branch: "feature" });       await checkoutGitBranch({ cwd: tmp.path, branch: "feature" });       const result = await listGitBranches({ cwd: tmp.path });      const current = result.branches.find((b) => b.current);      expect(current!.name).toBe("feature");    });     it("refreshes upstream behind count after checkout when remote branch advanced", async () => {      await using remote = await makeTmpDir();      await using source = await makeTmpDir();      await using clone = await makeTmpDir();      await git(remote.path, "init --bare");       await initRepoWithCommit(source.path);      const defaultBranch = (await listGitBranches({ cwd: source.path })).branches.find(        (branch) => branch.current,      )!.name;      await git(source.path, `remote add origin ${JSON.stringify(remote.path)}`);      await git(source.path, `push -u origin ${defaultBranch}`);       const featureBranch = "feature-behind";      await createGitBranch({ cwd: source.path, branch: featureBranch });      await checkoutGitBranch({ cwd: source.path, branch: featureBranch });      await writeFile(path.join(source.path, "feature.txt"), "feature base\n");      await git(source.path, "add feature.txt");      await git(source.path, "commit -m 'feature base'");      await git(source.path, `push -u origin ${featureBranch}`);      await checkoutGitBranch({ cwd: source.path, branch: defaultBranch });       await git(clone.path, `clone ${JSON.stringify(remote.path)} .`);      await git(clone.path, "config user.email 'test@test.com'");      await git(clone.path, "config user.name 'Test'");      await git(clone.path, `checkout -b ${featureBranch} --track origin/${featureBranch}`);      await writeFile(path.join(clone.path, "feature.txt"), "feature from remote\n");      await git(clone.path, "add feature.txt");      await git(clone.path, "commit -m 'remote feature update'");      await git(clone.path, `push origin ${featureBranch}`);       await checkoutGitBranch({ cwd: source.path, branch: featureBranch });      const core = new GitCoreService();      await vi.waitFor(async () => {        const details = await core.statusDetails(source.path);        expect(details.branch).toBe(featureBranch);        expect(details.aheadCount).toBe(0);        expect(details.behindCount).toBe(1);      });    });     it("keeps checkout successful when upstream refresh fails", async () => {      await using remote = await makeTmpDir();      await using source = await makeTmpDir();      await git(remote.path, "init --bare");       await initRepoWithCommit(source.path);      const defaultBranch = (await listGitBranches({ cwd: source.path })).branches.find(        (branch) => branch.current,      )!.name;      await git(source.path, `remote add origin ${JSON.stringify(remote.path)}`);      await git(source.path, `push -u origin ${defaultBranch}`);       const featureBranch = "feature-refresh-failure";      await git(source.path, `branch ${featureBranch}`);      await git(source.path, `checkout ${featureBranch}`);      await writeFile(path.join(source.path, "feature.txt"), "feature base\n");      await git(source.path, "add feature.txt");      await git(source.path, "commit -m 'feature base'");      await git(source.path, `push -u origin ${featureBranch}`);      await git(source.path, `checkout ${defaultBranch}`);       class RefreshFailingGitCoreService extends GitCoreService {        refreshFetchAttempts = 0;         override async git(          cwd: string,          args: readonly string[],          allowNonZeroExit = false,        ): Promise<void> {          if (args[0] === "fetch") {            this.refreshFetchAttempts += 1;            throw new Error("simulated fetch timeout");          }          await super.git(cwd, args, allowNonZeroExit);        }      }       const core = new RefreshFailingGitCoreService();      await expect(core.checkoutBranch({ cwd: source.path, branch: featureBranch })).resolves.toBe(        undefined,      );      await vi.waitFor(() => {        expect(core.refreshFetchAttempts).toBe(1);      });      expect(await git(source.path, "branch --show-current")).toBe(featureBranch);    });     it("refresh fetch is scoped to the checked out branch upstream refspec", async () => {      await using remote = await makeTmpDir();      await using source = await makeTmpDir();      await git(remote.path, "init --bare");       await initRepoWithCommit(source.path);      const defaultBranch = (await listGitBranches({ cwd: source.path })).branches.find(        (branch) => branch.current,      )!.name;      await git(source.path, `remote add origin ${JSON.stringify(remote.path)}`);      await git(source.path, `push -u origin ${defaultBranch}`);       const featureBranch = "feature/scoped-fetch";      await git(source.path, `checkout -b ${featureBranch}`);      await writeFile(path.join(source.path, "feature.txt"), "feature base\n");      await git(source.path, "add feature.txt");      await git(source.path, "commit -m 'feature base'");      await git(source.path, `push -u origin ${featureBranch}`);      await git(source.path, `checkout ${defaultBranch}`);       class FetchRecordingGitCoreService extends GitCoreService {        fetchArgs: readonly string[] | null = null;         override async git(          cwd: string,          args: readonly string[],          allowNonZeroExit = false,        ): Promise<void> {          if (args[0] === "fetch") {            this.fetchArgs = args;            return;          }          await super.git(cwd, args, allowNonZeroExit);        }      }       const core = new FetchRecordingGitCoreService();      await core.checkoutBranch({ cwd: source.path, branch: featureBranch });      await vi.waitFor(() => {        expect(core.fetchArgs).not.toBeNull();      });       expect(await git(source.path, "branch --show-current")).toBe(featureBranch);      expect(core.fetchArgs).toEqual([        "fetch",        "--quiet",        "--no-tags",        "origin",        `+refs/heads/${featureBranch}:refs/remotes/origin/${featureBranch}`,      ]);    });     it("returns checkout result before background upstream refresh completes", async () => {      await using remote = await makeTmpDir();      await using source = await makeTmpDir();      await git(remote.path, "init --bare");       await initRepoWithCommit(source.path);      const defaultBranch = (await listGitBranches({ cwd: source.path })).branches.find(        (branch) => branch.current,      )!.name;      await git(source.path, `remote add origin ${JSON.stringify(remote.path)}`);      await git(source.path, `push -u origin ${defaultBranch}`);       const featureBranch = "feature/background-refresh";      await git(source.path, `checkout -b ${featureBranch}`);      await writeFile(path.join(source.path, "feature.txt"), "feature base\n");      await git(source.path, "add feature.txt");      await git(source.path, "commit -m 'feature base'");      await git(source.path, `push -u origin ${featureBranch}`);      await git(source.path, `checkout ${defaultBranch}`);       class SlowRefreshGitCoreService extends GitCoreService {        fetchStarted = false;        private readonly waitForReleasePromise: Promise<void>;        private releaseWait: () => void = () => undefined;         constructor() {          super();          this.waitForReleasePromise = new Promise<void>((resolve) => {            this.releaseWait = resolve;          });        }         releaseFetch(): void {          this.releaseWait();        }         override async git(          cwd: string,          args: readonly string[],          allowNonZeroExit = false,        ): Promise<void> {          if (args[0] === "fetch") {            this.fetchStarted = true;            await this.waitForReleasePromise;            return;          }          await super.git(cwd, args, allowNonZeroExit);        }      }       const core = new SlowRefreshGitCoreService();      await expect(core.checkoutBranch({ cwd: source.path, branch: featureBranch })).resolves.toBe(        undefined,      );      await vi.waitFor(() => {        expect(core.fetchStarted).toBe(true);      });      expect(await git(source.path, "branch --show-current")).toBe(featureBranch);      core.releaseFetch();    });     it("throws when branch does not exist", async () => {      await using tmp = await makeTmpDir();      await initRepoWithCommit(tmp.path);      await expect(checkoutGitBranch({ cwd: tmp.path, branch: "nonexistent" })).rejects.toThrow();    });     it("throws when checkout would overwrite uncommitted changes", async () => {      await using tmp = await makeTmpDir();      await initRepoWithCommit(tmp.path);      await createGitBranch({ cwd: tmp.path, branch: "other" });       // Create a conflicting change: modify README on current branch      await writeFile(path.join(tmp.path, "README.md"), "modified\n");      await git(tmp.path, "add README.md");       // First, checkout other branch cleanly      await git(tmp.path, "stash");      await checkoutGitBranch({ cwd: tmp.path, branch: "other" });      await writeFile(path.join(tmp.path, "README.md"), "other content\n");      await git(tmp.path, "add .");      await git(tmp.path, "commit -m 'other change'");       // Go back to default branch      const defaultBranch = (await listGitBranches({ cwd: tmp.path })).branches.find(        (b) => !b.current,      )!.name;      await checkoutGitBranch({ cwd: tmp.path, branch: defaultBranch });       // Make uncommitted changes to the same file      await writeFile(path.join(tmp.path, "README.md"), "conflicting local\n");       // Checkout should fail due to uncommitted changes      await expect(checkoutGitBranch({ cwd: tmp.path, branch: "other" })).rejects.toThrow();    });  });   // ── createGitBranch ──   describe("createGitBranch", () => {    it("creates a new branch visible in listGitBranches", async () => {      await using tmp = await makeTmpDir();      await initRepoWithCommit(tmp.path);      await createGitBranch({ cwd: tmp.path, branch: "new-feature" });       const result = await listGitBranches({ cwd: tmp.path });      expect(result.branches.some((b) => b.name === "new-feature")).toBe(true);    });     it("throws when branch already exists", async () => {      await using tmp = await makeTmpDir();      await initRepoWithCommit(tmp.path);      await createGitBranch({ cwd: tmp.path, branch: "dupe" });      await expect(createGitBranch({ cwd: tmp.path, branch: "dupe" })).rejects.toThrow();    });  });   // ── createGitWorktree + removeGitWorktree ── 

Lines 1–500 of 930.