Add exact recursive source checkout, independent Git/jj developer workbenches and source-only backup/recovery commands, with Workspace instance binding outside source. Qualify two empty Reader Graphs, packaged source commands, Agent VM compilation, exact Gitea read grants and reconciled Reader settings/annotation stability. Publish checked Reader Package/root revisions to canonical review branches and retention tags, retain all 57 repositories independently in private versioned storage, verify downloaded-backup recovery and advance the example pin. Add bounded Central pin acquisition and selection-specific read-only identities, qualified against all 57 canonical repositories without write authority. Replace regex fork dependency discovery with compiler-parsed lock imports and rewrite proposals; preserve source identities and unrelated files, passing instance binding through packed and ordinary host checks. Hosted run-pin versus authoring preparation and maintained duplicate removal remain pending. Live source selections, Template defaults and default source branches are unchanged.
118 lines
5.5 KiB
TypeScript
118 lines
5.5 KiB
TypeScript
import { execFile as callback } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
import { createHash } from "node:crypto";
|
|
import { createReadStream } from "node:fs";
|
|
import { lstat, mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { retentionTagForCommit } from "../resource-lock/index.js";
|
|
import { materializeWorkspaceSources, workspaceSourcePin, type WorkspaceSourcePin } from "./source-closure.js";
|
|
|
|
const exec = promisify(callback);
|
|
type SourceBackup = {
|
|
formatVersion: 1;
|
|
root: WorkspaceSourcePin;
|
|
repositories: Array<{ repository: string; commits: string[]; bundle: string; sha256: string }>;
|
|
};
|
|
async function hash(filename: string) {
|
|
const digest = createHash("sha256");
|
|
for await (const chunk of createReadStream(filename)) digest.update(chunk);
|
|
return digest.digest("hex");
|
|
}
|
|
|
|
/** Source-only backup: no Graph state, checkout instructions or credentials.
|
|
* Git bundles retain exact commits and their ancestors without needing a forge.
|
|
* Storage/retention of the resulting directory belongs to the operator. */
|
|
export async function backupWorkspaceSources(options: {
|
|
source: WorkspaceSourcePin;
|
|
directory: string;
|
|
output: string;
|
|
locations?: Record<string, string>;
|
|
}): Promise<SourceBackup> {
|
|
const output = path.resolve(options.output);
|
|
if (
|
|
await lstat(output).catch((error: NodeJS.ErrnoException) => {
|
|
if (error.code !== "ENOENT") throw error;
|
|
return null;
|
|
})
|
|
)
|
|
throw Error("Source backup destination must not exist");
|
|
const closure = await materializeWorkspaceSources(options);
|
|
const grouped = new Map<string, Map<string, string>>();
|
|
for (const entry of closure.resources) {
|
|
const commits = grouped.get(entry.repository) ?? new Map<string, string>();
|
|
commits.set(entry.commit, path.join(path.resolve(options.directory), entry.directory));
|
|
grouped.set(entry.repository, commits);
|
|
}
|
|
await mkdir(path.dirname(output), { recursive: true });
|
|
const staging = await mkdtemp(path.join(path.dirname(output), ".source-backup-"));
|
|
const manifest: SourceBackup = { formatVersion: 1, root: closure.root, repositories: [] };
|
|
try {
|
|
for (const [repository, commits] of [...grouped].sort(([a], [b]) => a.localeCompare(b))) {
|
|
const bundle = createHash("sha256").update(repository).digest("hex") + ".bundle";
|
|
const gitDirectory = await mkdtemp(path.join(staging, ".git-"));
|
|
try {
|
|
await exec("git", ["init", "--bare", gitDirectory]);
|
|
const refs: string[] = [];
|
|
for (const [commit, checkout] of commits) {
|
|
const ref = retentionTagForCommit(commit);
|
|
await exec("git", ["-C", gitDirectory, "fetch", "--no-tags", "--", checkout, `${ref}:${ref}`]);
|
|
refs.push(ref);
|
|
}
|
|
await exec("git", ["-C", gitDirectory, "bundle", "create", path.join(staging, bundle), ...refs]);
|
|
} finally {
|
|
await rm(gitDirectory, { recursive: true, force: true });
|
|
}
|
|
manifest.repositories.push({
|
|
repository,
|
|
commits: [...commits.keys()].sort(),
|
|
bundle,
|
|
sha256: await hash(path.join(staging, bundle)),
|
|
});
|
|
}
|
|
await writeFile(path.join(staging, "backup.json"), JSON.stringify(manifest, null, 2) + "\n", { mode: 0o600 });
|
|
await rename(staging, output);
|
|
return manifest;
|
|
} finally {
|
|
await rm(staging, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
export async function restoreWorkspaceSources(options: { backup: string; directory: string }) {
|
|
const backup = path.resolve(options.backup);
|
|
const filename = path.join(backup, "backup.json");
|
|
const info = await lstat(filename);
|
|
if (!info.isFile() || info.isSymbolicLink() || info.size > 1024 * 1024) throw Error("Invalid source backup manifest");
|
|
const manifest = JSON.parse(await readFile(filename, "utf8")) as SourceBackup;
|
|
if (
|
|
manifest.formatVersion !== 1 ||
|
|
!Array.isArray(manifest.repositories) ||
|
|
!manifest.repositories.length ||
|
|
manifest.repositories.length > 256
|
|
)
|
|
throw Error("Invalid source backup manifest");
|
|
const root = workspaceSourcePin(manifest.root);
|
|
const locations: Record<string, string> = {};
|
|
for (const entry of manifest.repositories) {
|
|
if (!entry || !Array.isArray(entry.commits) || !entry.commits.length || entry.commits.length > 256)
|
|
throw Error("Invalid source backup entry");
|
|
for (const commit of entry.commits) workspaceSourcePin({ repository: entry.repository, commit });
|
|
if (Object.hasOwn(locations, entry.repository)) throw Error("Duplicate source backup repository");
|
|
const expected = createHash("sha256").update(entry.repository).digest("hex") + ".bundle";
|
|
if (entry.bundle !== expected || !/^[a-f0-9]{64}$/.test(entry.sha256))
|
|
throw Error("Invalid source backup bundle identity");
|
|
const bundle = path.join(backup, expected);
|
|
const stat = await lstat(bundle);
|
|
if (!stat.isFile() || stat.isSymbolicLink() || (await hash(bundle)) !== entry.sha256)
|
|
throw Error("Source backup bundle integrity mismatch");
|
|
locations[entry.repository] = pathToFileURL(bundle).href;
|
|
}
|
|
if (
|
|
!manifest.repositories.some((entry) => entry.repository === root.repository && entry.commits.includes(root.commit))
|
|
)
|
|
throw Error("Source backup does not retain its root commit");
|
|
// Every discovered dependency must use the backup. Never fall through to the
|
|
// original forge if a manifest omits a required repository.
|
|
return materializeWorkspaceSources({ source: root, directory: options.directory, locations, requireLocations: true });
|
|
}
|