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