1862 lines · 72.5 KB · TypeScript
RawDownload
import { existsSync } from "node:fs";import path from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices";import { it } from "@effect/vitest";import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect";import { describe, expect, vi } from "vitest"; import { GitCoreLive, makeGitCore } from "./GitCore.ts";import { GitCore, type GitCoreShape } from "../Services/GitCore.ts";import { GitCommandError } from "../Errors.ts";import { type ProcessRunResult, runProcess } from "../../processRunner.ts";import { ServerConfig } from "../../config.ts"; // ── Helpers ── const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-core-test-" });const GitCoreTestLayer = GitCoreLive.pipe(  Layer.provide(ServerConfigLayer),  Layer.provide(NodeServices.layer),);const TestLayer = Layer.mergeAll(NodeServices.layer, GitCoreTestLayer); function makeTmpDir(  prefix = "git-test-",): Effect.Effect<string, PlatformError.PlatformError, FileSystem.FileSystem | Scope.Scope> {  return Effect.gen(function* () {    const fileSystem = yield* FileSystem.FileSystem;    return yield* fileSystem.makeTempDirectoryScoped({ prefix });  });} function writeTextFile(  filePath: string,  contents: string,): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> {  return Effect.gen(function* () {    const fileSystem = yield* FileSystem.FileSystem;    yield* fileSystem.writeFileString(filePath, contents);  });} /** Run a raw git command for test setup (not under test). */function git(  cwd: string,  args: ReadonlyArray<string>,  env?: NodeJS.ProcessEnv,): Effect.Effect<string, GitCommandError, GitCore> {  return Effect.gen(function* () {    const gitCore = yield* GitCore;    const result = yield* gitCore.execute({      operation: "GitCore.test.git",      cwd,      args,      ...(env ? { env } : {}),      timeoutMs: 10_000,    });    return result.stdout.trim();  });} function runShellCommand(input: {  command: string;  cwd: string;  timeoutMs?: number;  maxOutputBytes?: number;}): Effect.Effect<ProcessRunResult, Error> {  return Effect.promise(() => {    const shellPath =      process.platform === "win32"        ? (process.env.ComSpec ?? "cmd.exe")        : (process.env.SHELL ?? "/bin/sh");     const args =      process.platform === "win32" ? ["/d", "/s", "/c", input.command] : ["-lc", input.command];     return runProcess(shellPath, args, {      cwd: input.cwd,      timeoutMs: input.timeoutMs ?? 30_000,      allowNonZeroExit: true,      maxBufferBytes: input.maxOutputBytes ?? 1_000_000,      outputMode: "truncate",    });  });} const makeIsolatedGitCore = (executeOverride: GitCoreShape["execute"]) =>  makeGitCore({ executeOverride }).pipe(    Effect.provide(Layer.provideMerge(ServerConfigLayer, NodeServices.layer)),  ); /** Create a repo with an initial commit so branches work. */function initRepoWithCommit(  cwd: string,): Effect.Effect<  { initialBranch: string },  GitCommandError | PlatformError.PlatformError,  GitCore | FileSystem.FileSystem> {  return Effect.gen(function* () {    const core = yield* GitCore;    yield* core.initRepo({ cwd });    yield* git(cwd, ["config", "user.email", "test@test.com"]);    yield* git(cwd, ["config", "user.name", "Test"]);    yield* writeTextFile(path.join(cwd, "README.md"), "# test\n");    yield* git(cwd, ["add", "."]);    yield* git(cwd, ["commit", "-m", "initial commit"]);    const initialBranch = yield* git(cwd, ["branch", "--show-current"]);    return { initialBranch };  });} function commitWithDate(  cwd: string,  fileName: string,  fileContents: string,  dateIsoString: string,  message: string,): Effect.Effect<  void,  GitCommandError | PlatformError.PlatformError,  GitCore | FileSystem.FileSystem> {  return Effect.gen(function* () {    yield* writeTextFile(path.join(cwd, fileName), fileContents);    yield* git(cwd, ["add", fileName]);    yield* git(cwd, ["commit", "-m", message], {      ...process.env,      GIT_AUTHOR_DATE: dateIsoString,      GIT_COMMITTER_DATE: dateIsoString,    });  });} function buildLargeText(lineCount = 20_000): string {  return Array.from({ length: lineCount }, (_, index) => `line ${String(index).padStart(5, "0")}`)    .join("\n")    .concat("\n");} // ── Tests ── it.layer(TestLayer)("git integration", (it) => {  describe("shell process execution", () => {    it.effect("caps captured output when maxOutputBytes is exceeded", () =>      Effect.gen(function* () {        const result = yield* runShellCommand({          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.stdoutTruncated || result.stderrTruncated).toBe(true);      }),    );  });   // ── initGitRepo ──   describe("initGitRepo", () => {    it.effect("creates a valid git repo", () =>      Effect.gen(function* () {        const tmp = yield* makeTmpDir();        yield* (yield* GitCore).initRepo({ cwd: tmp });        expect(existsSync(path.join(tmp, ".git"))).toBe(true);      }),    );     it.effect("listGitBranches reports isRepo: true after init + commit", () =>      Effect.gen(function* () {        const tmp = yield* makeTmpDir();        yield* initRepoWithCommit(tmp);        const result = yield* (yield* GitCore).listBranches({ cwd: tmp });        expect(result.isRepo).toBe(true);        expect(result.hasOriginRemote).toBe(false);        expect(result.branches.length).toBeGreaterThanOrEqual(1);      }),    );  });   // ── listGitBranches ──   describe("listGitBranches", () => {    it.effect("returns isRepo: false for non-git directory", () =>      Effect.gen(function* () {        const tmp = yield* makeTmpDir();        const result = yield* (yield* GitCore).listBranches({ cwd: tmp });        expect(result.isRepo).toBe(false);        expect(result.hasOriginRemote).toBe(false);        expect(result.branches).toEqual([]);      }),    );     it.effect("returns the current branch with current: true", () =>      Effect.gen(function* () {        const tmp = yield* makeTmpDir();        yield* initRepoWithCommit(tmp);        const result = yield* (yield* GitCore).listBranches({ cwd: tmp });        const current = result.branches.find((b) => b.current);        expect(current).toBeDefined();        expect(current!.current).toBe(true);      }),    );     it.effect("does not include detached HEAD pseudo-refs as branches", () =>      Effect.gen(function* () {        const tmp = yield* makeTmpDir();        yield* initRepoWithCommit(tmp);        yield* git(tmp, ["checkout", "--detach", "HEAD"]);         const result = yield* (yield* GitCore).listBranches({ cwd: tmp });        expect(result.branches.some((branch) => branch.name.startsWith("("))).toBe(false);        expect(result.branches.some((branch) => branch.current)).toBe(false);      }),    );     it.effect("keeps current branch first and sorts the remaining branches by recency", () =>      Effect.gen(function* () {        const tmp = yield* makeTmpDir();        yield* initRepoWithCommit(tmp);        const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find(          (branch) => branch.current,        )!.name;         yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "older-branch" });        yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "older-branch" });        yield* commitWithDate(          tmp,          "older.txt",          "older branch change\n",          "Thu, 1 Jan 2037 00:00:00 +0000",          "older branch change",        );         yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: initialBranch });        yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "newer-branch" });        yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "newer-branch" });        yield* commitWithDate(          tmp,          "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.        yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "older-branch" });         const result = yield* (yield* GitCore).listBranches({ cwd: tmp });        expect(result.branches[0]!.name).toBe("older-branch");        expect(result.branches[1]!.name).toBe("newer-branch");      }),    );     it.effect("keeps default branch right after current branch", () =>      Effect.gen(function* () {        const tmp = yield* makeTmpDir();        const remote = yield* makeTmpDir();        yield* initRepoWithCommit(tmp);        const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find(          (branch) => branch.current,        )!.name;         yield* git(remote, ["init", "--bare"]);        yield* git(tmp, ["remote", "add", "origin", remote]);        yield* git(tmp, ["push", "-u", "origin", defaultBranch]);        yield* git(tmp, ["remote", "set-head", "origin", defaultBranch]);         yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "current-branch" });        yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "current-branch" });        yield* commitWithDate(          tmp,          "current.txt",          "current change\n",          "Thu, 1 Jan 2037 00:00:00 +0000",          "current change",        );         yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: defaultBranch });        yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "newer-branch" });        yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "newer-branch" });        yield* commitWithDate(          tmp,          "newer.txt",          "newer change\n",          "Fri, 1 Jan 2038 00:00:00 +0000",          "newer change",        );         yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "current-branch" });         const result = yield* (yield* GitCore).listBranches({ cwd: tmp });        expect(result.branches[0]!.name).toBe("current-branch");        expect(result.branches[1]!.name).toBe(defaultBranch);        expect(result.branches[2]!.name).toBe("newer-branch");      }),    );     it.effect("lists multiple branches after creating them", () =>      Effect.gen(function* () {        const tmp = yield* makeTmpDir();        yield* initRepoWithCommit(tmp);        yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-a" });        yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-b" });         const result = yield* (yield* GitCore).listBranches({ cwd: tmp });        const names = result.branches.map((b) => b.name);        expect(names).toContain("feature-a");        expect(names).toContain("feature-b");      }),    );     it.effect("isDefault is false when no remote exists", () =>      Effect.gen(function* () {        const tmp = yield* makeTmpDir();        yield* initRepoWithCommit(tmp);        const result = yield* (yield* GitCore).listBranches({ cwd: tmp });        expect(result.branches.every((b) => b.isDefault === false)).toBe(true);      }),    );     it.effect("lists local branches first and remote branches last", () =>      Effect.gen(function* () {        const remote = yield* makeTmpDir();        const tmp = yield* makeTmpDir();         yield* git(remote, ["init", "--bare"]);        yield* initRepoWithCommit(tmp);        const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find(          (branch) => branch.current,        )!.name;         yield* git(tmp, ["remote", "add", "origin", remote]);        yield* git(tmp, ["push", "-u", "origin", defaultBranch]);         yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/local-only" });         const remoteOnlyBranch = "feature/remote-only";        yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: defaultBranch });        yield* git(tmp, ["checkout", "-b", remoteOnlyBranch]);        yield* git(tmp, ["push", "-u", "origin", remoteOnlyBranch]);        yield* git(tmp, ["checkout", defaultBranch]);        yield* git(tmp, ["branch", "-D", remoteOnlyBranch]);         const result = yield* (yield* GitCore).listBranches({ cwd: tmp });        const firstRemoteIndex = result.branches.findIndex((branch) => branch.isRemote);         expect(result.hasOriginRemote).toBe(true);        expect(firstRemoteIndex).toBeGreaterThan(0);        expect(result.branches.slice(0, firstRemoteIndex).every((branch) => !branch.isRemote)).toBe(          true,        );        expect(result.branches.slice(firstRemoteIndex).every((branch) => branch.isRemote)).toBe(          true,        );        expect(          result.branches.some(            (branch) => branch.name === "feature/local-only" && !branch.isRemote,          ),        ).toBe(true);        expect(          result.branches.some(            (branch) => branch.name === "origin/feature/remote-only" && branch.isRemote,          ),        ).toBe(true);      }),    );     it.effect("includes remoteName metadata for remotes with slash in the name", () =>      Effect.gen(function* () {        const remote = yield* makeTmpDir();        const tmp = yield* makeTmpDir();        const remoteName = "my-org/upstream";         yield* git(remote, ["init", "--bare"]);        yield* initRepoWithCommit(tmp);        const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find(          (branch) => branch.current,        )!.name;         yield* git(tmp, ["remote", "add", remoteName, remote]);        yield* git(tmp, ["push", "-u", remoteName, defaultBranch]);         const remoteOnlyBranch = "feature/remote-with-remote-name";        yield* git(tmp, ["checkout", "-b", remoteOnlyBranch]);        yield* git(tmp, ["push", "-u", remoteName, remoteOnlyBranch]);        yield* git(tmp, ["checkout", defaultBranch]);        yield* git(tmp, ["branch", "-D", remoteOnlyBranch]);         const result = yield* (yield* GitCore).listBranches({ cwd: tmp });        const remoteBranch = result.branches.find(          (branch) => branch.name === `${remoteName}/${remoteOnlyBranch}`,        );         expect(remoteBranch).toBeDefined();        expect(remoteBranch?.isRemote).toBe(true);        expect(remoteBranch?.remoteName).toBe(remoteName);      }),    );  });   // ── checkoutGitBranch ──   describe("checkoutGitBranch", () => {    it.effect("checks out an existing branch", () =>      Effect.gen(function* () {        const tmp = yield* makeTmpDir();        yield* initRepoWithCommit(tmp);        yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature" });         yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature" });         const result = yield* (yield* GitCore).listBranches({ cwd: tmp });        const current = result.branches.find((b) => b.current);        expect(current!.name).toBe("feature");      }),    );     it.effect("refreshes upstream behind count after checkout when remote branch advanced", () =>      Effect.gen(function* () {        const services = yield* Effect.services();        const runPromise = Effect.runPromiseWith(services);         const remote = yield* makeTmpDir();        const source = yield* makeTmpDir();        const clone = yield* makeTmpDir();        yield* git(remote, ["init", "--bare"]);         yield* initRepoWithCommit(source);        const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find(          (branch) => branch.current,        )!.name;        yield* git(source, ["remote", "add", "origin", remote]);        yield* git(source, ["push", "-u", "origin", defaultBranch]);         const featureBranch = "feature-behind";        yield* (yield* GitCore).createBranch({ cwd: source, branch: featureBranch });        yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: featureBranch });        yield* writeTextFile(path.join(source, "feature.txt"), "feature base\n");        yield* git(source, ["add", "feature.txt"]);        yield* git(source, ["commit", "-m", "feature base"]);        yield* git(source, ["push", "-u", "origin", featureBranch]);        yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: defaultBranch });         yield* git(clone, ["clone", remote, "."]);        yield* git(clone, ["config", "user.email", "test@test.com"]);        yield* git(clone, ["config", "user.name", "Test"]);        yield* git(clone, ["checkout", "-b", featureBranch, "--track", `origin/${featureBranch}`]);        yield* writeTextFile(path.join(clone, "feature.txt"), "feature from remote\n");        yield* git(clone, ["add", "feature.txt"]);        yield* git(clone, ["commit", "-m", "remote feature update"]);        yield* git(clone, ["push", "origin", featureBranch]);         yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: featureBranch });        const core = yield* GitCore;        yield* Effect.promise(() =>          vi.waitFor(async () => {            const details = await runPromise(core.statusDetails(source));            expect(details.branch).toBe(featureBranch);            expect(details.aheadCount).toBe(0);            expect(details.behindCount).toBe(1);          }),        );      }),    );     it.effect("keeps checkout successful when upstream refresh fails", () =>      Effect.gen(function* () {        const remote = yield* makeTmpDir();        const source = yield* makeTmpDir();        yield* git(remote, ["init", "--bare"]);         yield* initRepoWithCommit(source);        const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find(          (branch) => branch.current,        )!.name;        yield* git(source, ["remote", "add", "origin", remote]);        yield* git(source, ["push", "-u", "origin", defaultBranch]);         const featureBranch = "feature-refresh-failure";        yield* git(source, ["branch", featureBranch]);        yield* git(source, ["checkout", featureBranch]);        yield* writeTextFile(path.join(source, "feature.txt"), "feature base\n");        yield* git(source, ["add", "feature.txt"]);        yield* git(source, ["commit", "-m", "feature base"]);        yield* git(source, ["push", "-u", "origin", featureBranch]);        yield* git(source, ["checkout", defaultBranch]);         const realGitCore = yield* GitCore;        let refreshFetchAttempts = 0;        const core = yield* makeIsolatedGitCore((input) => {          if (input.args[0] === "fetch") {            refreshFetchAttempts += 1;            return Effect.fail(              new GitCommandError({                operation: "git.test.refreshFailure",                command: `git ${input.args.join(" ")}`,

Lines 1–500 of 1862.