summaryrefslogtreecommitdiff
path: root/src/lib/git
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib/git')
-rw-r--r--src/lib/git/index.test.ts40
-rw-r--r--src/lib/git/index.ts16
-rw-r--r--src/lib/git/log.test.ts71
-rw-r--r--src/lib/git/log.ts131
-rw-r--r--src/lib/git/types.ts27
5 files changed, 285 insertions, 0 deletions
diff --git a/src/lib/git/index.test.ts b/src/lib/git/index.test.ts
new file mode 100644
index 0000000..4eedaca
--- /dev/null
+++ b/src/lib/git/index.test.ts
@@ -0,0 +1,40 @@
+import { describe, it } from "@std/testing/bdd";
+import { assertEquals } from "@std/assert";
+import {
+ assertSpyCall,
+ assertSpyCalls,
+ returnsNext,
+ stub,
+} from "@std/testing/mock";
+
+// IMPORTANT: Delay the import of `gitDir` to after the stub
+let gitDir: typeof import("./index.ts").gitDir;
+
+describe("gitDir", () => {
+ it("resolves with trimmed decoded stdout", async () => {
+ const encoded = new TextEncoder().encode(
+ " /home/user/project \n",
+ ) as Uint8Array<ArrayBuffer>;
+ const fakeOutput = Promise.resolve({
+ success: true,
+ code: 0,
+ stdout: encoded,
+ stderr: new Uint8Array(),
+ signal: null,
+ });
+
+ using outputStub = stub(
+ Deno.Command.prototype,
+ "output",
+ returnsNext([fakeOutput]),
+ );
+
+ // Now import gitDir AFTER stubbing
+ ({ gitDir } = await import("./index.ts"));
+
+ const result = await gitDir();
+ assertEquals(result.pathname, "/home/user/project");
+ assertSpyCall(outputStub, 0, { args: [], returned: fakeOutput });
+ assertSpyCalls(outputStub, 1);
+ });
+});
diff --git a/src/lib/git/index.ts b/src/lib/git/index.ts
new file mode 100644
index 0000000..23a13eb
--- /dev/null
+++ b/src/lib/git/index.ts
@@ -0,0 +1,16 @@
+import { get, instanciate } from "../../utils/anonymous.ts";
+
+let cachedGitDir: Promise<URL> | undefined;
+
+export function gitDir(): Promise<URL> {
+ if (!cachedGitDir) {
+ cachedGitDir = new Deno.Command("git", {
+ args: ["rev-parse", "--show-toplevel"],
+ }).output()
+ .then(get("stdout"))
+ .then((x) => `file://${new TextDecoder().decode(x).trim()}/`)
+ .then(instanciate(URL));
+ }
+
+ return cachedGitDir;
+}
diff --git a/src/lib/git/log.test.ts b/src/lib/git/log.test.ts
new file mode 100644
index 0000000..09acb1c
--- /dev/null
+++ b/src/lib/git/log.test.ts
@@ -0,0 +1,71 @@
+import { describe, it } from "@std/testing/bdd";
+import { assertEquals, assertExists } from "@std/assert";
+import {
+ assertSpyCall,
+ assertSpyCalls,
+ returnsNext,
+ stub,
+} from "@std/testing/mock";
+import { getLastCommitForOneOfFiles } from "./log.ts";
+import {
+ emptyCommandOutput,
+ gitDiffTreeCommandOutput,
+ gitDir,
+ gitLogPrettyCommandOutput,
+ gitRevParseCommandOutput,
+} from "../../../tests/fixtures/test_data.ts";
+
+describe("getLastCommitForOneOfFiles", () => {
+ it("returns parsed commit with signature and file info", async () => {
+ const outputs = [
+ gitLogPrettyCommandOutput,
+ gitDiffTreeCommandOutput,
+ gitRevParseCommandOutput,
+ ];
+ using logStub = stub(
+ Deno.Command.prototype,
+ "output",
+ returnsNext(outputs),
+ );
+
+ const file = new URL("file.ts", gitDir);
+ const result = await getLastCommitForOneOfFiles(file);
+
+ assertExists(result);
+ assertEquals(result.hash.short, "abcdef1");
+ assertEquals(result.hash.long, "abcdef1234567890abcdef1234567890abcdef12");
+
+ assertEquals(result.author.name, "Alice");
+ assertEquals(result.committer.email, "bob@example.com");
+
+ assertEquals(result.files.length, 1);
+ assertEquals(result.files[0], {
+ path: file,
+ status: "modified",
+ });
+
+ assertEquals(result.signature?.type, "gpg");
+ assertEquals(result.signature?.signer, "bob@example.com");
+
+ for (let i = 0; i < outputs.length; i++) {
+ assertSpyCall(logStub, i, { args: [], returned: outputs[i] });
+ }
+ assertSpyCalls(logStub, outputs.length);
+ });
+
+ it("returns undefined for empty commit output", async () => {
+ using logStub = stub(
+ Deno.Command.prototype,
+ "output",
+ returnsNext([emptyCommandOutput]),
+ );
+
+ const result = await getLastCommitForOneOfFiles(
+ [new URL("nonexistent.ts", gitDir)],
+ );
+
+ assertEquals(result, undefined);
+ assertSpyCall(logStub, 0, { args: [], returned: emptyCommandOutput });
+ assertSpyCalls(logStub, 1);
+ });
+});
diff --git a/src/lib/git/log.ts b/src/lib/git/log.ts
new file mode 100644
index 0000000..86bbe7b
--- /dev/null
+++ b/src/lib/git/log.ts
@@ -0,0 +1,131 @@
+import { defined } from "../../utils/anonymous.ts";
+import { type MaybeIterable, surelyIterable } from "../../utils/iterator.ts";
+import { gitDir } from "./index.ts";
+import type { Commit, CommitFile } from "./types.ts";
+
+const format = [
+ "H",
+ "h",
+ "aI",
+ "aN",
+ "aE",
+ "cI",
+ "cN",
+ "cE",
+ // "G?",
+ "GS",
+ "GK",
+ "GF",
+ "GG",
+];
+
+export async function getLastCommitForOneOfFiles(
+ sources: MaybeIterable<URL>,
+): Promise<Commit | undefined> {
+ const files = surelyIterable(sources);
+ const gitLog = new Deno.Command("git", {
+ args: [
+ "log",
+ "-1",
+ `--pretty=format:${format.map((x) => `%${x}`).join("%n")}`,
+ "--",
+ ...Iterator.from(files).map((x) => x.pathname),
+ ],
+ });
+
+ const { stdout } = await gitLog.output();
+ const result = new TextDecoder().decode(stdout).trim();
+
+ if (result.length <= 0) {
+ return undefined;
+ }
+
+ const [
+ hash,
+ abbrHash,
+ authorDate,
+ authorName,
+ authorEmail,
+ committerDate,
+ committerName,
+ committerEmail,
+ // signatureValidation,
+ signer,
+ key,
+ keyFingerPrint,
+ ...rawLines
+ ] = result.split("\n");
+
+ const raw = rawLines.join("\n").trim();
+
+ const commit: Commit = {
+ files: await fileStatusFromCommit(hash, Iterator.from(files)),
+ hash: { long: hash, short: abbrHash },
+ author: {
+ date: new Date(authorDate),
+ name: authorName,
+ email: authorEmail,
+ },
+ committer: {
+ date: new Date(committerDate),
+ name: committerName,
+ email: committerEmail,
+ },
+ };
+
+ if (raw.length > 0) {
+ commit.signature = {
+ type: raw.startsWith("gpgsm:")
+ ? "x509"
+ : raw.startsWith("gpg:")
+ ? "gpg"
+ : "ssh",
+ signer,
+ key: { long: keyFingerPrint, short: key },
+ rawMessage: raw,
+ };
+ }
+
+ return commit;
+}
+
+async function fileStatusFromCommit(
+ hash: string,
+ files: Iterable<URL>,
+): Promise<CommitFile[]> {
+ const gitDiffTree = new Deno.Command("git", {
+ args: [
+ "diff-tree",
+ "--no-commit-id",
+ "--name-status",
+ "-r",
+ hash,
+ ],
+ });
+
+ const { stdout } = await gitDiffTree.output();
+ const result = new TextDecoder().decode(stdout).trim().split("\n").filter(
+ defined,
+ );
+
+ const dir = await gitDir();
+ return result.map((line) => {
+ const [status, path] = line.split("\t");
+ if (
+ Iterator.from(files).some((file) =>
+ file.pathname.replace(dir.pathname, "").includes(path)
+ )
+ ) {
+ return {
+ path: new URL(path, dir),
+ status: status === "A"
+ ? "added"
+ : status === "D"
+ ? "deleted"
+ : "modified",
+ } as const;
+ }
+
+ return undefined;
+ }).filter(defined);
+}
diff --git a/src/lib/git/types.ts b/src/lib/git/types.ts
new file mode 100644
index 0000000..672d242
--- /dev/null
+++ b/src/lib/git/types.ts
@@ -0,0 +1,27 @@
+export type CommitFile = {
+ path: URL;
+ status: "added" | "modified" | "deleted";
+};
+
+export type Hash = { long: string; short: string };
+
+export type Contributor = {
+ name: string;
+ email: string;
+ date: Date;
+};
+
+export type SignatureType = "ssh" | "gpg" | "x509";
+
+export type Commit = {
+ files: CommitFile[];
+ hash: Hash;
+ author: Contributor;
+ committer: Contributor;
+ signature?: {
+ type: SignatureType;
+ signer: string;
+ key: Hash;
+ rawMessage: string;
+ };
+};