import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { canonicalSnapshot, parseManifest, parseSnapshot, snapshotManifest, verifySnapshotManifest, type TaskTreeBackupManifest, type TaskTreeSnapshot, } from "./backup-contracts.ts"; export type TaskTreeGitBackupConfig = { repoSshUrl: string; branch: string; basePath: string; worktreePath: string; target: string; sshCommand: string; authorName: string; authorEmail: string; commitMessagePrefix: string; }; export type PublishedTaskTreeBackup = { changed: boolean; contentSha256: string; gitCommit: string; pushAttempts: number; manifest: TaskTreeBackupManifest; }; let gitQueue: Promise = Promise.resolve(); export class TaskTreeGitBackup { constructor(readonly config: TaskTreeGitBackupConfig) {} async publish(snapshot: TaskTreeSnapshot, reason: string): Promise { return enqueueGit(async () => { this.ensureWorktree(); const manifest = snapshotManifest(snapshot, { target: this.config.target }); const current = this.readCurrent(false); if (current?.manifest.contentSha256 === manifest.contentSha256) { return { changed: false, contentSha256: manifest.contentSha256, gitCommit: this.head(), pushAttempts: 0, manifest: current.manifest, }; } const base = this.basePath(); const snapshotPath = join(this.config.worktreePath, base, "snapshot.json"); const manifestPath = join(this.config.worktreePath, base, "manifest.json"); mkdirSync(dirname(snapshotPath), { recursive: true }); writeFileSync(snapshotPath, canonicalSnapshot(snapshot), "utf8"); writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); this.git(["add", base]); const diff = this.git(["diff", "--cached", "--quiet"], true); if (diff.exitCode === 0) { return { changed: false, contentSha256: manifest.contentSha256, gitCommit: this.head(), pushAttempts: 0, manifest, }; } const env = { GIT_AUTHOR_NAME: this.config.authorName, GIT_AUTHOR_EMAIL: this.config.authorEmail, GIT_COMMITTER_NAME: this.config.authorName, GIT_COMMITTER_EMAIL: this.config.authorEmail, }; this.git(["commit", "-m", `${this.config.commitMessagePrefix} ${reason}`], false, env); let pushAttempts = 0; for (let attempt = 1; attempt <= 3; attempt += 1) { pushAttempts = attempt; if (this.git(["push", "origin", this.config.branch], true).ok) { return { changed: true, contentSha256: manifest.contentSha256, gitCommit: this.head(), pushAttempts, manifest, }; } if (!this.git(["fetch", "origin", this.config.branch], true).ok) continue; const rebase = this.git(["rebase", `origin/${this.config.branch}`], true); if (!rebase.ok) { this.git(["rebase", "--abort"], true); throw codedError("backup_git_rebase_failed", "TaskTree backup Git rebase failed"); } } throw codedError("backup_git_push_failed", "TaskTree backup Git push failed after retries"); }); } readCurrent(sync = true): { snapshot: TaskTreeSnapshot; manifest: TaskTreeBackupManifest; gitCommit: string } | null { if (sync) this.ensureWorktree(); const base = this.basePath(); const snapshotPath = join(this.config.worktreePath, base, "snapshot.json"); const manifestPath = join(this.config.worktreePath, base, "manifest.json"); if (!existsSync(snapshotPath) || !existsSync(manifestPath)) return null; const snapshot = parseSnapshot(readFileSync(snapshotPath, "utf8")); const manifest = parseManifest(readFileSync(manifestPath, "utf8")); if (manifest.target !== this.config.target) throw codedError("backup_target_mismatch", "backup manifest target does not match runtime target"); verifySnapshotManifest(snapshot, manifest); return { snapshot, manifest, gitCommit: this.head() }; } private ensureWorktree(): void { if (!this.config.repoSshUrl) throw codedError("backup_repo_missing", "TaskTree backup Git repository is not configured"); mkdirSync(dirname(this.config.worktreePath), { recursive: true }); if (!existsSync(join(this.config.worktreePath, ".git"))) { if (existsSync(this.config.worktreePath)) rmSync(this.config.worktreePath, { recursive: true, force: true }); this.git(["clone", this.config.repoSshUrl, this.config.worktreePath], false, {}, dirname(this.config.worktreePath)); } this.git(["remote", "set-url", "origin", this.config.repoSshUrl]); const fetched = this.git(["fetch", "origin", this.config.branch], true); if (fetched.ok) { this.git(["checkout", "-B", this.config.branch, `origin/${this.config.branch}`]); this.git(["pull", "--ff-only", "origin", this.config.branch]); return; } if (this.git(["rev-parse", "--verify", "HEAD"], true).ok) this.git(["checkout", "-B", this.config.branch]); else this.git(["symbolic-ref", "HEAD", `refs/heads/${this.config.branch}`]); } private basePath(): string { const raw = this.config.basePath.trim().replace(/^\/+/u, "").replace(/\/+$/u, ""); if (!raw || raw === "." || raw.split("/").some((part) => part === "..")) { throw codedError("backup_base_path_invalid", "TaskTree backup base path must be a non-root relative path"); } return raw; } private head(): string { return this.git(["rev-parse", "HEAD"]).stdout.trim(); } private git( args: string[], allowFailure = false, extraEnv: Record = {}, cwd = this.config.worktreePath, ): { ok: boolean; exitCode: number; stdout: string; stderr: string } { const env = { ...process.env, ...extraEnv }; if (this.config.sshCommand) env.GIT_SSH_COMMAND = this.config.sshCommand; const result = Bun.spawnSync(["git", ...args], { cwd, env, stdout: "pipe", stderr: "pipe" }); const output = { ok: result.exitCode === 0, exitCode: result.exitCode, stdout: new TextDecoder().decode(result.stdout), stderr: new TextDecoder().decode(result.stderr), }; if (!output.ok && !allowFailure) { throw codedError("backup_git_command_failed", `TaskTree backup Git command failed: git ${args.join(" ")}: ${output.stderr.slice(-1000)}`); } return output; } } function enqueueGit(operation: () => Promise): Promise { const next = gitQueue.then(operation, operation); gitQueue = next.catch(() => undefined); return next; } function codedError(code: string, message: string): Error { return Object.assign(new Error(message), { code }); }