Files

181 lines
6.9 KiB
TypeScript

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<unknown> = Promise.resolve();
export class TaskTreeGitBackup {
readonly config: TaskTreeGitBackupConfig;
constructor(config: TaskTreeGitBackupConfig) {
this.config = {
...config,
worktreePath: resolve(config.worktreePath),
};
}
async publish(snapshot: TaskTreeSnapshot, reason: string): Promise<PublishedTaskTreeBackup> {
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<string, string> = {},
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<T>(operation: () => Promise<T>): Promise<T> {
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 });
}