489 lines · 14.9 KB · TypeScript
RawDownload
import fs from "node:fs";import os from "node:os";import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { GitManager } from "./gitManager";import {  type ProcessRunOptions,  type ProcessRunResult,  runProcess,} from "./processRunner"; interface FakeGhScenario {  prListSequence?: string[];  createdPrUrl?: string;  defaultBranch?: string;  failWith?: Error;} interface FakeGitTextGenerator {  generateCommitMessage: (input: {    cwd: string;    branch: string | null;    stagedSummary: string;    stagedPatch: string;  }) => Promise<{ subject: string; body: string }>;  generatePrContent: (input: {    cwd: string;    baseBranch: string;    headBranch: string;    commitSummary: string;    diffSummary: string;    diffPatch: string;  }) => Promise<{ title: string; body: string }>;} const tempDirs: string[] = []; function makeTempDir(prefix: string): string {  const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));  tempDirs.push(dir);  return dir;} async function runGit(  cwd: string,  args: readonly string[],  allowNonZeroExit = false,): Promise<ProcessRunResult> {  return runProcess("git", args, { cwd, allowNonZeroExit });} async function runGitStdout(cwd: string, args: readonly string[]): Promise<string> {  const result = await runGit(cwd, args);  return result.stdout.trim();} async function initRepo(cwd: string): Promise<void> {  await runGit(cwd, ["init", "--initial-branch=main"]);  await runGit(cwd, ["config", "user.email", "test@example.com"]);  await runGit(cwd, ["config", "user.name", "Test User"]);  fs.writeFileSync(path.join(cwd, "README.md"), "hello\n");  await runGit(cwd, ["add", "README.md"]);  await runGit(cwd, ["commit", "-m", "Initial commit"]);} async function createBareRemote(): Promise<string> {  const remoteDir = makeTempDir("t3code-git-remote-");  await runProcess("git", ["init", "--bare"], { cwd: remoteDir });  return remoteDir;} function createTextGenerator(  overrides: Partial<FakeGitTextGenerator> = {},): FakeGitTextGenerator {  return {    generateCommitMessage: async () => ({      subject: "Implement stacked git actions",      body: "",    }),    generatePrContent: async () => ({      title: "Add stacked git actions",      body: "## Summary\n- Add stacked git workflow\n\n## Testing\n- Not run",    }),    ...overrides,  };} function createRunnerWithFakeGh(scenario: FakeGhScenario = {}): {  runner: (    command: string,    args: readonly string[],    options?: ProcessRunOptions,  ) => Promise<ProcessRunResult>;  ghCalls: string[];} {  const prListQueue = [...(scenario.prListSequence ?? [])];  const ghCalls: string[] = [];   const runner = async (    command: string,    args: readonly string[],    options?: ProcessRunOptions,  ): Promise<ProcessRunResult> => {    if (command !== "gh") {      return runProcess(command, args, options);    }     ghCalls.push(args.join(" "));    if (scenario.failWith) {      throw scenario.failWith;    }     if (args[0] === "pr" && args[1] === "list") {      const stdout = (prListQueue.shift() ?? "[]") + "\n";      return {        stdout,        stderr: "",        code: 0,        signal: null,        timedOut: false,      };    }     if (args[0] === "pr" && args[1] === "create") {      return {        stdout:          (scenario.createdPrUrl ??            "https://github.com/pingdotgg/codething-mvp/pull/101") + "\n",        stderr: "",        code: 0,        signal: null,        timedOut: false,      };    }     if (args[0] === "pr" && args[1] === "view") {      return {        stdout: "",        stderr: "",        code: 0,        signal: null,        timedOut: false,      };    }     if (args[0] === "repo" && args[1] === "view") {      return {        stdout: `${scenario.defaultBranch ?? "main"}\n`,        stderr: "",        code: 0,        signal: null,        timedOut: false,      };    }     throw new Error(`Unexpected gh command: ${args.join(" ")}`);  };   return { runner, ghCalls };} afterEach(() => {  for (const dir of tempDirs.splice(0, tempDirs.length)) {    fs.rmSync(dir, { recursive: true, force: true });  }}); describe("GitManager", () => {  it("status includes open PR metadata when branch already has an open PR", async () => {    const repoDir = makeTempDir("t3code-git-manager-");    await initRepo(repoDir);    await runGit(repoDir, ["checkout", "-b", "feature/status-open-pr"]);    const remoteDir = await createBareRemote();    await runGit(repoDir, ["remote", "add", "origin", remoteDir]);    await runGit(repoDir, ["push", "-u", "origin", "feature/status-open-pr"]);     const { runner } = createRunnerWithFakeGh({      prListSequence: [        JSON.stringify([          {            number: 13,            title: "Existing PR",            url: "https://github.com/pingdotgg/codething-mvp/pull/13",            baseRefName: "main",            headRefName: "feature/status-open-pr",          },        ]),      ],    });     const manager = new GitManager({      runProcess: runner,      textGenerator: createTextGenerator(),    });     const status = await manager.status({ cwd: repoDir });    expect(status.branch).toBe("feature/status-open-pr");    expect(status.openPr).toEqual({      number: 13,      title: "Existing PR",      url: "https://github.com/pingdotgg/codething-mvp/pull/13",      baseBranch: "main",      headBranch: "feature/status-open-pr",    });  });   it("status is resilient to gh lookup failures and returns openPr null", async () => {    const repoDir = makeTempDir("t3code-git-manager-");    await initRepo(repoDir);    await runGit(repoDir, ["checkout", "-b", "feature/status-no-gh"]);    const remoteDir = await createBareRemote();    await runGit(repoDir, ["remote", "add", "origin", remoteDir]);    await runGit(repoDir, ["push", "-u", "origin", "feature/status-no-gh"]);     const { runner } = createRunnerWithFakeGh({      failWith: new Error("Command not found: gh"),    });    const manager = new GitManager({      runProcess: runner,      textGenerator: createTextGenerator(),    });     const status = await manager.status({ cwd: repoDir });    expect(status.branch).toBe("feature/status-no-gh");    expect(status.openPr).toBeNull();  });   it("creates a commit when working tree is dirty", async () => {    const repoDir = makeTempDir("t3code-git-manager-");    await initRepo(repoDir);    fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nworld\n");     const manager = new GitManager({      textGenerator: createTextGenerator(),    });    const result = await manager.runStackedAction({      cwd: repoDir,      action: "commit",    });     expect(result.commit.status).toBe("created");    expect(result.push.status).toBe("skipped_not_requested");    expect(result.pr.status).toBe("skipped_not_requested");    expect(await runGitStdout(repoDir, ["log", "-1", "--pretty=%s"])).toBe(      "Implement stacked git actions",    );  });   it("uses custom commit message when provided", async () => {    const repoDir = makeTempDir("t3code-git-manager-");    await initRepo(repoDir);    fs.writeFileSync(path.join(repoDir, "README.md"), "hello\ncustom\n");    let generatedCount = 0;     const manager = new GitManager({      textGenerator: createTextGenerator({        generateCommitMessage: async () => {          generatedCount += 1;          return {            subject: "this should not be used",            body: "",          };        },      }),    });    const result = await manager.runStackedAction({      cwd: repoDir,      action: "commit",      commitMessage: "feat: custom summary line\n\n- details from user",    });     expect(result.commit.status).toBe("created");    expect(result.commit.subject).toBe("feat: custom summary line");    expect(generatedCount).toBe(0);    expect(await runGitStdout(repoDir, ["log", "-1", "--pretty=%s"])).toBe(      "feat: custom summary line",    );    expect(await runGitStdout(repoDir, ["log", "-1", "--pretty=%b"])).toContain(      "- details from user",    );  });   it("skips commit when there are no uncommitted changes", async () => {    const repoDir = makeTempDir("t3code-git-manager-");    await initRepo(repoDir);     const manager = new GitManager({      textGenerator: createTextGenerator(),    });    const result = await manager.runStackedAction({      cwd: repoDir,      action: "commit",    });     expect(result.commit.status).toBe("skipped_no_changes");    expect(result.push.status).toBe("skipped_not_requested");    expect(result.pr.status).toBe("skipped_not_requested");  });   it("commits and pushes with upstream auto-setup when needed", async () => {    const repoDir = makeTempDir("t3code-git-manager-");    await initRepo(repoDir);    await runGit(repoDir, ["checkout", "-b", "feature/stacked-flow"]);    const remoteDir = await createBareRemote();    await runGit(repoDir, ["remote", "add", "origin", remoteDir]);    fs.writeFileSync(path.join(repoDir, "feature.txt"), "feature\n");     const manager = new GitManager({      textGenerator: createTextGenerator(),    });    const result = await manager.runStackedAction({      cwd: repoDir,      action: "commit_push",    });     expect(result.commit.status).toBe("created");    expect(result.push.status).toBe("pushed");    expect(result.push.setUpstream).toBe(true);    expect(await runGitStdout(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe(      "origin/feature/stacked-flow",    );  });   it("skips push when branch is already up to date", async () => {    const repoDir = makeTempDir("t3code-git-manager-");    await initRepo(repoDir);    await runGit(repoDir, ["checkout", "-b", "feature/up-to-date"]);    const remoteDir = await createBareRemote();    await runGit(repoDir, ["remote", "add", "origin", remoteDir]);    await runGit(repoDir, ["push", "-u", "origin", "feature/up-to-date"]);     const manager = new GitManager({      textGenerator: createTextGenerator(),    });    const result = await manager.runStackedAction({      cwd: repoDir,      action: "commit_push",    });     expect(result.commit.status).toBe("skipped_no_changes");    expect(result.push.status).toBe("skipped_up_to_date");  });   it("returns existing PR metadata for commit/push/pr action", async () => {    const repoDir = makeTempDir("t3code-git-manager-");    await initRepo(repoDir);    await runGit(repoDir, ["checkout", "-b", "feature/existing-pr"]);    const remoteDir = await createBareRemote();    await runGit(repoDir, ["remote", "add", "origin", remoteDir]);    await runGit(repoDir, ["push", "-u", "origin", "feature/existing-pr"]);     const { runner, ghCalls } = createRunnerWithFakeGh({      prListSequence: [        JSON.stringify([          {            number: 42,            title: "Existing PR",            url: "https://github.com/pingdotgg/codething-mvp/pull/42",            baseRefName: "main",            headRefName: "feature/existing-pr",          },        ]),      ],    });     const manager = new GitManager({      runProcess: runner,      textGenerator: createTextGenerator(),    });    const result = await manager.runStackedAction({      cwd: repoDir,      action: "commit_push_pr",    });     expect(result.pr.status).toBe("opened_existing");    expect(result.pr.number).toBe(42);    expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false);  });   it("creates PR when one does not already exist", async () => {    const repoDir = makeTempDir("t3code-git-manager-");    await initRepo(repoDir);    await runGit(repoDir, ["checkout", "-b", "feature-create-pr"]);    const remoteDir = await createBareRemote();    await runGit(repoDir, ["remote", "add", "origin", remoteDir]);    fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n");    await runGit(repoDir, ["add", "changes.txt"]);    await runGit(repoDir, ["commit", "-m", "Feature commit"]);    await runGit(repoDir, ["push", "-u", "origin", "feature-create-pr"]);    await runGit(repoDir, ["config", "branch.feature-create-pr.gh-merge-base", "main"]);     const { runner, ghCalls } = createRunnerWithFakeGh({      prListSequence: [        "[]",        JSON.stringify([          {            number: 88,            title: "Add stacked git actions",            url: "https://github.com/pingdotgg/codething-mvp/pull/88",            baseRefName: "main",            headRefName: "feature-create-pr",          },        ]),      ],    });     const manager = new GitManager({      runProcess: runner,      textGenerator: createTextGenerator(),    });    const result = await manager.runStackedAction({      cwd: repoDir,      action: "commit_push_pr",    });     expect(result.pr.status).toBe("created");    expect(result.pr.number).toBe(88);    expect(      ghCalls.some((call) => call.includes("pr create --base main --head feature-create-pr")),    ).toBe(true);    expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false);  });   it("rejects push/pr actions from detached HEAD", async () => {    const repoDir = makeTempDir("t3code-git-manager-");    await initRepo(repoDir);    await runGit(repoDir, ["checkout", "--detach", "HEAD"]);     const manager = new GitManager({      textGenerator: createTextGenerator(),    });    await expect(      manager.runStackedAction({        cwd: repoDir,        action: "commit_push",      }),    ).rejects.toThrow("detached HEAD");  });   it("surfaces missing gh binary errors", async () => {    const repoDir = makeTempDir("t3code-git-manager-");    await initRepo(repoDir);    await runGit(repoDir, ["checkout", "-b", "feature/gh-missing"]);    const remoteDir = await createBareRemote();    await runGit(repoDir, ["remote", "add", "origin", remoteDir]);    await runGit(repoDir, ["push", "-u", "origin", "feature/gh-missing"]);     const { runner } = createRunnerWithFakeGh({      failWith: new Error("Command not found: gh"),    });    const manager = new GitManager({      runProcess: runner,      textGenerator: createTextGenerator(),    });     await expect(      manager.runStackedAction({        cwd: repoDir,        action: "commit_push_pr",      }),    ).rejects.toThrow("GitHub CLI (`gh`) is required");  });   it("surfaces gh auth errors with guidance", async () => {    const repoDir = makeTempDir("t3code-git-manager-");    await initRepo(repoDir);    await runGit(repoDir, ["checkout", "-b", "feature/gh-auth"]);    const remoteDir = await createBareRemote();    await runGit(repoDir, ["remote", "add", "origin", remoteDir]);    await runGit(repoDir, ["push", "-u", "origin", "feature/gh-auth"]);     const { runner } = createRunnerWithFakeGh({      failWith: new Error("authentication failed"),    });    const manager = new GitManager({      runProcess: runner,      textGenerator: createTextGenerator(),    });     await expect(      manager.runStackedAction({        cwd: repoDir,        action: "commit_push_pr",      }),    ).rejects.toThrow("gh auth login");  });});