feat: GitClient shell-out with temp clone + integration test

Co-Authored-By: Claude
This commit is contained in:
2026-06-19 02:00:21 +00:00
parent d41cbc467e
commit 32263df062
2 changed files with 127 additions and 0 deletions

52
src/git.test.ts Normal file
View File

@@ -0,0 +1,52 @@
import { afterAll, beforeAll, expect, test } from "vitest";
import { execFileSync } from "node:child_process";
import { mkdtempSync, mkdirSync, rmSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ChildProcessGitClient } from "./git";
let root: string, bare: string, base: string;
beforeAll(() => {
root = mkdtempSync(join(tmpdir(), "jp-git-"));
bare = join(root, "remote.git");
base = join(root, "work");
mkdirSync(bare); mkdirSync(base);
execFileSync("git", ["init", "--bare", "-b", "main", bare]);
// seed an initial commit so the branch exists
const seed = join(root, "seed");
mkdirSync(seed);
execFileSync("git", ["init", "-b", "main", seed]);
execFileSync("git", ["-C", seed, "config", "user.email", "t@t"]);
execFileSync("git", ["-C", seed, "config", "user.name", "t"]);
execFileSync("git", ["-C", seed, "commit", "--allow-empty", "-m", "init"]);
execFileSync("git", ["-C", seed, "remote", "add", "origin", bare]);
execFileSync("git", ["-C", seed, "push", "origin", "main"]);
});
afterAll(() => rmSync(root, { recursive: true, force: true }));
test("clone, write, commit, push lands files in the remote", async () => {
const client = new ChildProcessGitClient({ baseDir: base });
await client.syncClone({ url: bare, branch: "main" });
await client.writeFiles([
{ repoPath: "_posts/2026-06-19-hi.md", data: "---\nlayout: post\n---\n\nHi\n" },
{ repoPath: "assets/img/hi.png", data: Buffer.from([1, 2, 3]) },
]);
await client.commitAndPush({ message: "Publish: Hi", branch: "main", authorName: "t", authorEmail: "t@t" });
const verify = join(root, "verify");
execFileSync("git", ["clone", bare, verify]);
expect(readFileSync(join(verify, "_posts/2026-06-19-hi.md"), "utf8")).toContain("Hi");
expect(Array.from(readFileSync(join(verify, "assets/img/hi.png")))).toEqual([1, 2, 3]);
});
test("second sync resets cleanly (fetch+reset path)", async () => {
const client = new ChildProcessGitClient({ baseDir: base });
await client.syncClone({ url: bare, branch: "main" });
await client.writeFiles([{ repoPath: "_posts/second.md", data: "x" }]);
await client.commitAndPush({ message: "second", branch: "main" });
const verify = join(root, "verify2");
execFileSync("git", ["clone", bare, verify]);
expect(readFileSync(join(verify, "_posts/second.md"), "utf8")).toBe("x");
});

75
src/git.ts Normal file
View File

@@ -0,0 +1,75 @@
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { tmpdir } from "node:os";
import { promisify } from "node:util";
const run = promisify(execFile);
export interface GitFile {
repoPath: string;
data: Buffer | string;
}
export interface GitClient {
syncClone(o: { url: string; branch: string }): Promise<void>;
writeFiles(files: GitFile[]): Promise<void>;
commitAndPush(o: { message: string; branch: string; authorName?: string; authorEmail?: string }): Promise<void>;
}
export class ChildProcessGitClient implements GitClient {
private readonly baseDir: string;
private readonly env: NodeJS.ProcessEnv;
private workdir = "";
constructor(opts?: { env?: NodeJS.ProcessEnv; baseDir?: string }) {
this.baseDir = opts?.baseDir ?? join(tmpdir(), "obsidian-jekyll-publish");
this.env = {
...process.env,
...opts?.env,
GIT_TERMINAL_PROMPT: "0",
};
}
private async git(cwd: string, args: string[]): Promise<string> {
const { stdout } = await run("git", args, { cwd, env: this.env, maxBuffer: 64 * 1024 * 1024 });
return stdout.toString();
}
async syncClone(o: { url: string; branch: string }): Promise<void> {
const key = createHash("sha1").update(`${o.url}#${o.branch}`).digest("hex").slice(0, 16);
this.workdir = join(this.baseDir, key);
mkdirSync(this.baseDir, { recursive: true });
let cloned = true;
try {
await this.git(this.workdir, ["rev-parse", "--is-inside-work-tree"]);
} catch {
cloned = false;
}
if (cloned) {
await this.git(this.workdir, ["fetch", "origin", o.branch]);
await this.git(this.workdir, ["reset", "--hard", `origin/${o.branch}`]);
await this.git(this.workdir, ["clean", "-fd"]);
} else {
await this.git(this.baseDir, ["clone", "--depth", "1", "--branch", o.branch, o.url, this.workdir]);
}
}
async writeFiles(files: GitFile[]): Promise<void> {
for (const f of files) {
const abs = join(this.workdir, f.repoPath);
mkdirSync(dirname(abs), { recursive: true });
writeFileSync(abs, f.data);
}
}
async commitAndPush(o: { message: string; branch: string; authorName?: string; authorEmail?: string }): Promise<void> {
await this.git(this.workdir, ["add", "-A"]);
const cfg: string[] = [];
if (o.authorName) cfg.push("-c", `user.name=${o.authorName}`);
if (o.authorEmail) cfg.push("-c", `user.email=${o.authorEmail}`);
await this.git(this.workdir, [...cfg, "commit", "-m", o.message]);
await this.git(this.workdir, ["push", "origin", `HEAD:${o.branch}`]);
}
}