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. Load more
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(" ")}`,