diff --git a/src/git.test.ts b/src/git.test.ts new file mode 100644 index 0000000..9929c27 --- /dev/null +++ b/src/git.test.ts @@ -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"); +}); diff --git a/src/git.ts b/src/git.ts new file mode 100644 index 0000000..2160c34 --- /dev/null +++ b/src/git.ts @@ -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; + writeFiles(files: GitFile[]): Promise; + commitAndPush(o: { message: string; branch: string; authorName?: string; authorEmail?: string }): Promise; +} + +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 { + 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 { + 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 { + 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 { + 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}`]); + } +}