diff options
Diffstat (limited to 'src/lib/git')
-rw-r--r-- | src/lib/git/index.test.ts | 40 | ||||
-rw-r--r-- | src/lib/git/index.ts | 16 | ||||
-rw-r--r-- | src/lib/git/log.test.ts | 71 | ||||
-rw-r--r-- | src/lib/git/log.ts | 131 | ||||
-rw-r--r-- | src/lib/git/types.ts | 27 |
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; + }; +}; |