@nodecg/types
Version:
Dynamic broadcast graphics rendered in a browser
212 lines (176 loc) • 6.54 kB
text/typescript
import * as path from "node:path";
import { Command, FileSystem } from "@effect/platform";
import * as NodeContext from "@effect/platform-node/NodeContext";
import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem";
import { Chunk, Data, Effect, Layer, Option, Stream } from "effect";
import { assert, describe, expect, test } from "vitest";
import { GitService } from "./git-service.js";
import { testEffect } from "./test-effect.js";
const GitServiceLive = GitService.Default;
const TestLayer = Layer.mergeAll(
GitServiceLive,
NodeFileSystem.layer,
NodeContext.layer,
);
class GitCommandError extends Data.TaggedError("GitCommandError")<{
args: string[];
exitCode: number;
}> {}
// Helper to run git command and return trimmed stdout (fails on non-zero exit)
const git = (...args: string[]) =>
Effect.fn("git")(function* (cwd: string) {
const process = yield* Command.make("git", ...args).pipe(
Command.workingDirectory(cwd),
Command.start,
);
const [stdout, exitCode] = yield* Effect.all(
[
process.stdout.pipe(
Stream.decodeText(),
Stream.runCollect,
Effect.map((chunks) => Chunk.join(chunks, "")),
),
process.exitCode,
],
{ concurrency: "unbounded" },
);
if (exitCode !== 0) {
return yield* new GitCommandError({ args, exitCode });
}
return stdout.trim();
});
describe("GitService", () => {
test(
"returns None when no .git directory exists",
testEffect(
Effect.gen(function* () {
const fsService = yield* FileSystem.FileSystem;
const tempDir = yield* fsService.makeTempDirectoryScoped();
const gitService = yield* GitService;
const result = yield* gitService.getGitHead(tempDir);
expect(Option.isNone(result)).toBe(true);
}).pipe(Effect.provide(TestLayer)),
),
);
test(
"returns HEAD data for a valid git repository",
testEffect(
Effect.gen(function* () {
const fsService = yield* FileSystem.FileSystem;
const tempDir = yield* fsService.makeTempDirectoryScoped();
yield* git("init")(tempDir);
yield* git("config", "user.name", "Test User")(tempDir);
yield* git("config", "user.email", "test@example.com")(tempDir);
yield* fsService.writeFileString(
path.join(tempDir, "test.txt"),
"hello",
);
yield* git("add", "test.txt")(tempDir);
yield* git("commit", "-m", "Initial commit")(tempDir);
const gitService = yield* GitService;
const result = yield* gitService.getGitHead(tempDir);
assert(Option.isSome(result));
assert(Option.isSome(result.value.branch));
expect(["master", "main"]).toContain(result.value.branch.value);
expect(result.value.hash).toHaveLength(40);
expect(result.value.shortHash).toHaveLength(7);
expect(result.value.message).toBe("Initial commit\n");
expect(result.value.date).toMatch(/^\d{4}-\d{2}-\d{2}T/);
}).pipe(Effect.provide(TestLayer)),
),
);
test(
"returns None when .git exists but no commits",
testEffect(
Effect.gen(function* () {
const fsService = yield* FileSystem.FileSystem;
const tempDir = yield* fsService.makeTempDirectoryScoped();
yield* git("init")(tempDir);
const gitService = yield* GitService;
const result = yield* gitService.getGitHead(tempDir);
expect(Option.isNone(result)).toBe(true);
}).pipe(Effect.provide(TestLayer)),
),
);
test(
"returns HEAD data without branch for detached HEAD",
testEffect(
Effect.gen(function* () {
const fsService = yield* FileSystem.FileSystem;
const tempDir = yield* fsService.makeTempDirectoryScoped();
yield* git("init")(tempDir);
yield* git("config", "user.name", "Test User")(tempDir);
yield* git("config", "user.email", "test@example.com")(tempDir);
yield* fsService.writeFileString(
path.join(tempDir, "test.txt"),
"hello",
);
yield* git("add", "test.txt")(tempDir);
yield* git("commit", "-m", "Initial commit")(tempDir);
const commitHash = yield* git("rev-parse", "HEAD")(tempDir);
// Checkout the commit directly (detached HEAD)
yield* git("checkout", commitHash)(tempDir);
const gitService = yield* GitService;
const result = yield* gitService.getGitHead(tempDir);
assert(Option.isSome(result));
expect(Option.isNone(result.value.branch)).toBe(true);
expect(result.value.hash).toBe(commitHash);
expect(result.value.shortHash).toHaveLength(7);
expect(result.value.message).toBe("Initial commit\n");
}).pipe(Effect.provide(TestLayer)),
),
);
test(
"fails with GitBranchReadError when .git/HEAD is corrupted",
testEffect(
Effect.gen(function* () {
const fsService = yield* FileSystem.FileSystem;
const tempDir = yield* fsService.makeTempDirectoryScoped();
// Create .git directory with corrupted HEAD
yield* git("init")(tempDir);
yield* fsService.writeFileString(
path.join(tempDir, ".git", "HEAD"),
"invalid content",
);
const gitService = yield* GitService;
const result = yield* gitService.getGitHead(tempDir).pipe(Effect.flip);
assert(result._tag === "GitBranchReadError");
expect(result.path).toBe(tempDir);
}).pipe(Effect.provide(TestLayer)),
),
);
test(
"fails with GitHeadReadError when object data is corrupted",
testEffect(
Effect.gen(function* () {
const fsService = yield* FileSystem.FileSystem;
const tempDir = yield* fsService.makeTempDirectoryScoped();
yield* git("init")(tempDir);
yield* git("config", "user.name", "Test User")(tempDir);
yield* git("config", "user.email", "test@example.com")(tempDir);
yield* fsService.writeFileString(
path.join(tempDir, "test.txt"),
"hello",
);
yield* git("add", "test.txt")(tempDir);
yield* git("commit", "-m", "Initial commit")(tempDir);
const commitHash = yield* git("rev-parse", "HEAD")(tempDir);
// Corrupt the commit object by overwriting it with invalid data
// Real git creates objects as read-only, so we need to make it writable first
const objectDir = path.join(
tempDir,
".git",
"objects",
commitHash.slice(0, 2),
);
const objectFile = path.join(objectDir, commitHash.slice(2));
yield* fsService.chmod(objectFile, 0o644);
yield* fsService.writeFileString(objectFile, "corrupted data");
const gitService = yield* GitService;
const result = yield* gitService.getGitHead(tempDir).pipe(Effect.flip);
assert(result._tag === "GitHeadReadError");
expect(result.path).toBe(tempDir);
}).pipe(Effect.provide(TestLayer)),
),
);
});