181 lines
6.9 KiB
TypeScript
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 });
|
|
}
|