feat: GitClient shell-out with temp clone + integration test
Co-Authored-By: Claude
This commit is contained in:
52
src/git.test.ts
Normal file
52
src/git.test.ts
Normal 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
75
src/git.ts
Normal 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}`]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user