// @generated by scripts/build-runtime.mjs; do not edit. // @ts-nocheck -- generated JavaScript uses a .ts extension for Pi's Jiti loader. import { normalizeGitBranch, normalizeGitDirectory, normalizeGitRemote, normalizeGitRemoteIdentity, stateDir, validateGitNamespace } from "./chunk-F5QL2GPY.ts"; import { SyncBackendConflictError, SyncBackendPublicationOutcomeUnknownError } from "./chunk-5MQ76BK5.ts"; import { portableSnapshotSelection, posixJoin, snapshotSelectionInclude } from "./chunk-YQ6UW7IF.ts"; // src/git-backend.ts import { createHash as createHash2, randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; // src/git-runner.ts import { spawn } from "node:child_process"; import process2 from "node:process"; var DEFAULT_TIMEOUT_MS = 3e4; var DEFAULT_OUTPUT_LIMIT = 1024 * 1024; var GitCommandError = class extends Error { constructor(message, exitCode, stderr, options) { super(message, options); this.exitCode = exitCode; this.stderr = stderr; this.name = "GitCommandError"; } exitCode; stderr; code = "GIT_COMMAND_FAILED"; }; async function runGit(args, options = {}) { throwIfAborted(options.signal); const hooksPath = process2.platform === "win32" ? "NUL" : "/dev/null"; const protocolArgs = [ "-c", `core.hooksPath=${hooksPath}`, "-c", "gc.auto=0", "-c", "maintenance.auto=false", "-c", "protocol.allow=never", "-c", "protocol.https.allow=always", "-c", "protocol.ssh.allow=always" ]; if (options.allowFileProtocol) protocolArgs.push("-c", "protocol.file.allow=always"); const commandArgs = [ ...options.gitDir ? [`--git-dir=${options.gitDir}`] : [], ...protocolArgs, ...args ]; const inheritedEnvironment = Object.fromEntries( Object.entries(process2.env).filter( ([key]) => !key.startsWith("GIT_") && key !== "PAGER" && key !== "EDITOR" && key !== "VISUAL" && key !== "SSH_ASKPASS" && key !== "SSH_ASKPASS_REQUIRE" ) ); const allowedGitOverrides = /* @__PURE__ */ new Set([ "GIT_INDEX_FILE", "GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_AUTHOR_DATE", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL", "GIT_COMMITTER_DATE" ]); const suppliedEnvironment = Object.fromEntries( Object.entries(options.env ?? {}).filter( ([key]) => !key.startsWith("GIT_") || allowedGitOverrides.has(key) ) ); const env = { ...inheritedEnvironment, ...suppliedEnvironment, LC_ALL: "C", LANG: "C", GIT_CONFIG_NOSYSTEM: "1", GIT_TERMINAL_PROMPT: "0", GCM_INTERACTIVE: "Never", GIT_PAGER: "cat", PAGER: "cat", GIT_EDITOR: "true", EDITOR: "true", VISUAL: "true", GIT_ASKPASS: "", SSH_ASKPASS: "", SSH_ASKPASS_REQUIRE: "never", GIT_SSH_COMMAND: "ssh -oBatchMode=yes" }; const child = spawn("git", commandArgs, { cwd: options.cwd, env, stdio: ["pipe", "pipe", "pipe"], detached: process2.platform !== "win32", windowsHide: true }); const stdout = []; const stderr = []; let total = 0; let settled = false; let terminationError; let escalationTimer; const limit = options.maxOutputBytes ?? DEFAULT_OUTPUT_LIMIT; const terminate = (error) => { if (settled || terminationError) return; terminationError = error; if (child.pid && process2.platform !== "win32") { try { process2.kill(-child.pid, "SIGTERM"); } catch { child.kill("SIGTERM"); } } else { child.kill("SIGTERM"); if (child.pid && process2.platform === "win32") { const killer = spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"], { stdio: "ignore", windowsHide: true }); killer.on("error", () => void 0); killer.unref(); } } escalationTimer = setTimeout(() => { if (settled) return; if (child.pid && process2.platform !== "win32") { try { process2.kill(-child.pid, "SIGKILL"); } catch { child.kill("SIGKILL"); } } else { child.kill("SIGKILL"); } }, 2e3); }; const collect = (target) => (chunk) => { total += chunk.byteLength; if (total > limit) { terminate(new Error(`Git output exceeds the ${limit}-byte limit.`)); return; } target.push(Buffer.from(chunk)); }; child.stdout.on("data", collect(stdout)); child.stderr.on("data", collect(stderr)); child.stdin.on("error", () => void 0); child.stdin.end(options.input); const onAbort = () => terminate( options.signal?.reason instanceof Error ? options.signal.reason : new DOMException("The operation was aborted", "AbortError") ); options.signal?.addEventListener("abort", onAbort, { once: true }); const timer = setTimeout( () => terminate( new Error(`Git command timed out after ${options.timeoutMs ?? DEFAULT_TIMEOUT_MS}ms.`) ), options.timeoutMs ?? DEFAULT_TIMEOUT_MS ); let result; try { result = await new Promise((resolve, reject) => { child.once("error", reject); child.once("close", (code) => { settled = true; if (escalationTimer) clearTimeout(escalationTimer); const stdoutBuffer = Buffer.concat(stdout); const stderrBuffer = Buffer.concat(stderr); if (terminationError) { reject(terminationError); return; } if (code !== 0) { const stderrText = stderrBuffer.toString("utf8").trim(); reject( new GitCommandError( stderrText || `Git exited with status ${code ?? "unknown"}.`, code, stderrText ) ); return; } resolve({ stdout: stdoutBuffer, stderr: stderrBuffer }); }); }); } finally { clearTimeout(timer); if (escalationTimer) clearTimeout(escalationTimer); options.signal?.removeEventListener("abort", onAbort); } return result; } function parseGitBlobBatch(output, expectedCount, maxContentBytes) { if (!Number.isSafeInteger(expectedCount) || expectedCount < 0) { throw new Error("Invalid Git batch object count."); } if (!Number.isSafeInteger(maxContentBytes) || maxContentBytes < 0) { throw new Error("Invalid Git batch content limit."); } const blobs = []; let offset = 0; let contentBytes = 0; for (let index = 0; index < expectedCount; index += 1) { const headerEnd = output.indexOf(10, offset); if (headerEnd < 0) throw new Error("Git cat-file batch response is truncated."); const header = output.subarray(offset, headerEnd).toString("utf8"); if (header.endsWith(" missing")) throw new Error("Git cat-file batch object is missing."); const match = /^(?[0-9a-f]{40}) blob (?0|[1-9][0-9]*)$/u.exec(header); if (!match?.groups) throw new Error("Git cat-file batch response is malformed."); const size = Number(match.groups.size); if (!Number.isSafeInteger(size)) throw new Error("Git cat-file batch size is malformed."); contentBytes += size; if (contentBytes > maxContentBytes) { throw new Error(`Git cat-file batch content exceeds the ${maxContentBytes}-byte limit.`); } const contentStart = headerEnd + 1; const contentEnd = contentStart + size; if (contentEnd >= output.length) throw new Error("Git cat-file batch response is truncated."); if (output[contentEnd] !== 10) { throw new Error("Git cat-file batch response is malformed."); } blobs.push(Buffer.from(output.subarray(contentStart, contentEnd))); offset = contentEnd + 1; } if (offset !== output.length) throw new Error("Git cat-file batch response has trailing data."); return blobs; } async function readGitBlobs(objects, options) { if (objects.length === 0) return []; if (!Number.isSafeInteger(options.maxOutputBytes) || options.maxOutputBytes < 0) { throw new Error("Invalid Git batch output limit."); } if (objects.some((object) => !/^[0-9a-f]{40}$/u.test(object))) { throw new Error("Invalid Git blob object id."); } const protocolOverhead = objects.length * 96; if (!Number.isSafeInteger(protocolOverhead + options.maxOutputBytes)) { throw new Error("Invalid Git batch output limit."); } const result = await runGit(["cat-file", "--batch"], { ...options, input: `${objects.join("\n")} `, maxOutputBytes: options.maxOutputBytes + protocolOverhead }); return parseGitBlobBatch(result.stdout, objects.length, options.maxOutputBytes); } function throwIfAborted(signal) { if (!signal?.aborted) return; throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError"); } // src/git-storage.ts import { createHash } from "node:crypto"; var GIT_MANIFEST_VERSION = 2; var MAX_GIT_MANIFEST_BYTES = 1024 * 1024; var MAX_GIT_TREE_OUTPUT_BYTES = 16 * 1024 * 1024; var MAX_GIT_PAYLOAD_BYTES = 100 * 1024 * 1024; var MAX_GIT_SNAPSHOT_BYTES = 512 * 1024 * 1024; var SNAPSHOT_VERSION = 1; function isGitPayloadSizeAllowed(size) { return Number.isSafeInteger(size) && size >= 0 && size <= MAX_GIT_PAYLOAD_BYTES; } function requireGitManifest(value) { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error("Git publication manifest is malformed."); } const manifest = value; if (manifest.version === 1) { throw new Error( "Git publication uses the unsupported pre-release gzip format; recreate this pi-sync-owned test branch." ); } if (manifest.version !== GIT_MANIFEST_VERSION || manifest.snapshotVersion !== SNAPSHOT_VERSION || typeof manifest.snapshotId !== "string" || manifest.snapshotId.length > 512 || !/^[A-Za-z0-9._-]+$/u.test(manifest.snapshotId) || typeof manifest.createdAt !== "string" || manifest.createdAt.length > 64 || hasControlCharacter(manifest.createdAt) || Number.isNaN(Date.parse(manifest.createdAt)) || typeof manifest.machine !== "string" || manifest.machine.length > 256 || hasControlCharacter(manifest.machine) || typeof manifest.profile !== "string" || manifest.profile.length === 0 || manifest.profile.length > 256 || hasControlCharacter(manifest.profile) || typeof manifest.syncSessions !== "boolean" || manifest.snapshotSyncSessions !== void 0 && typeof manifest.snapshotSyncSessions !== "boolean" || !Array.isArray(manifest.files) || !hasExactKeys(manifest, [ "version", "snapshotVersion", "snapshotId", "createdAt", "machine", "profile", "syncSessions", ...manifest.snapshotSyncSessions === void 0 ? [] : ["snapshotSyncSessions"], ...manifest.selection === void 0 ? [] : ["selection"], "files" ])) { throw new Error("Git publication manifest is malformed."); } if (manifest.selection !== void 0) portableSnapshotSelection(manifest.selection); let total = 0; const paths = /* @__PURE__ */ new Set(); for (const rawFile of manifest.files) { if (!rawFile || typeof rawFile !== "object" || Array.isArray(rawFile)) { throw new Error("Git publication manifest file is malformed."); } const file = rawFile; if (!hasExactKeys(file, ["path", "sha256", "size"]) || !isSafeSnapshotPath(file.path) || typeof file.sha256 !== "string" || !/^[0-9a-f]{64}$/u.test(file.sha256) || typeof file.size !== "number" || !isGitPayloadSizeAllowed(file.size) || paths.has(file.path)) { throw new Error("Git publication manifest file is malformed."); } total += file.size; if (!Number.isSafeInteger(total) || total > MAX_GIT_SNAPSHOT_BYTES) { throw new Error(`Git snapshot content exceeds the ${MAX_GIT_SNAPSHOT_BYTES}-byte limit.`); } paths.add(file.path); } assertNoPathConflicts([...paths]); return manifest; } function validateGitSnapshot(snapshot, manifest, namespace) { const prepared = prepareGitSnapshot(snapshot, namespace); const syncSessions = snapshot.syncSessions === true || snapshot.files.some((file) => file.path.startsWith("sessions/")); if (snapshot.id !== manifest.snapshotId || snapshot.createdAt !== manifest.createdAt || snapshot.machine !== manifest.machine || snapshot.profile !== manifest.profile || snapshot.syncSessions !== manifest.snapshotSyncSessions || syncSessions !== manifest.syncSessions || !sameOptionalInclude( snapshotSelectionInclude(snapshot), manifest.selection === void 0 ? void 0 : portableSnapshotSelection(manifest.selection).include ) || prepared.length !== manifest.files.length || prepared.some((file, index) => { const expected = manifest.files[index]; return !expected || file.path !== expected.path || file.sha256 !== expected.sha256 || file.size !== expected.size; })) { throw new Error("Git snapshot identity does not match its publication manifest."); } } function prepareGitSnapshot(snapshot, namespace) { snapshotSelectionInclude(snapshot); if (snapshot.version !== SNAPSHOT_VERSION || typeof snapshot.id !== "string" || !snapshot.id || snapshot.id.length > 512 || !/^[A-Za-z0-9._-]+$/u.test(snapshot.id) || snapshot.profile !== namespace || !Array.isArray(snapshot.files) || typeof snapshot.createdAt !== "string" || !snapshot.createdAt || snapshot.createdAt.length > 64 || hasControlCharacter(snapshot.createdAt) || Number.isNaN(Date.parse(snapshot.createdAt)) || typeof snapshot.machine !== "string" || snapshot.machine.length > 256 || hasControlCharacter(snapshot.machine)) { throw new Error("Invalid Git snapshot publication."); } const paths = /* @__PURE__ */ new Set(); const prepared = []; let total = 0; for (const file of snapshot.files) { if (!isSafeSnapshotPath(file.path) || typeof file.contentBase64 !== "string" || typeof file.sha256 !== "string" || !/^[0-9a-f]{64}$/u.test(file.sha256) || paths.has(file.path)) { throw new Error("Invalid Git snapshot file."); } const content = Buffer.from(file.contentBase64, "base64"); if (content.toString("base64") !== file.contentBase64 || sha256(content) !== file.sha256) { throw new Error("Git snapshot file checksum mismatch."); } if (!isGitPayloadSizeAllowed(content.byteLength)) { throw new Error( `Git snapshot file exceeds GitHub's ${MAX_GIT_PAYLOAD_BYTES}-byte regular-Git limit: ${file.path}` ); } total += content.byteLength; if (!Number.isSafeInteger(total) || total > MAX_GIT_SNAPSHOT_BYTES) { throw new Error(`Git snapshot content exceeds the ${MAX_GIT_SNAPSHOT_BYTES}-byte limit.`); } paths.add(file.path); prepared.push({ path: file.path, sha256: file.sha256, size: content.byteLength, content }); } assertNoPathConflicts([...paths]); return prepared; } function parseGitTree(output) { if (output.byteLength === 0) return []; if (output.at(-1) !== 0) throw new Error("Git publication tree response is malformed."); return output.subarray(0, -1).toString("utf8").split("\0").map((line) => { const match = /^(?[0-9]{6}) (?blob|tree|commit) (?[0-9a-f]{40})\t(?.+)$/u.exec( line ); if (!match?.groups || hasControlCharacter(match.groups.path)) { throw new Error("Git publication tree response is malformed."); } return { mode: match.groups.mode, type: match.groups.type, object: match.groups.object, path: match.groups.path }; }); } function validateGitPublicationTree(entries, manifest, manifestPath, filePath) { const byPath = /* @__PURE__ */ new Map(); for (const entry of entries) { if (byPath.has(entry.path)) throw new Error("Git publication tree contains duplicate paths."); byPath.set(entry.path, entry); } const expectedPaths = [manifestPath, ...manifest.files.map((file) => filePath(file.path))]; if (entries.length !== expectedPaths.length || expectedPaths.some((path2) => !byPath.has(path2))) { throw new Error("Git publication tree has missing or extra files."); } for (const expectedPath of expectedPaths) { const entry = byPath.get(expectedPath); if (entry?.mode !== "100644" || entry.type !== "blob") { throw new Error(`Git publication tree contains a non-regular file: ${expectedPath}`); } } return manifest.files.map((file) => byPath.get(filePath(file.path))); } function sameOptionalInclude(left, right) { if (!left || !right) return left === right; return left.length === right.length && left.every((item, index) => item === right[index]); } function isSafeSnapshotPath(value) { return typeof value === "string" && value.length > 0 && value.length <= 4096 && !value.startsWith("/") && !value.includes("\\") && !hasControlCharacter(value) && value.split("/").every( (segment) => segment && segment !== "." && segment !== ".." && segment.toLowerCase() !== ".git" ); } function hasExactKeys(value, expected) { const keys = Object.keys(value).sort(); const expectedKeys = [...expected].sort(); return keys.length === expectedKeys.length && keys.every((key, index) => key === expectedKeys[index]); } function assertNoPathConflicts(paths) { const sorted = [...paths].sort(); for (let index = 1; index < sorted.length; index += 1) { const parent = sorted[index - 1]; const child = sorted[index]; if (parent && child?.startsWith(`${parent}/`)) { throw new Error(`Git snapshot file path conflict: ${parent} and ${child}`); } } } function sha256(value) { return createHash("sha256").update(value).digest("hex"); } function hasControlCharacter(value) { return /[\u0000-\u001f\u007f-\u009f]/u.test(value); } // src/git-backend.ts var COMMAND_TIMEOUT_MS = 3e4; var POST_COMMIT_TIMEOUT_MS = 45e3; var gitCacheMutationQueues = /* @__PURE__ */ new Map(); var GitSyncBackend = class { constructor(config, options = {}) { this.config = config; assertGitDestination(config); this.allowLocalRemotes = options.allowLocalRemotes === true; if (!this.allowLocalRemotes) assertProductionRemote(config.profile.remote); this.identity = gitBackendIdentity(config); this.destination = gitDestination(config); this.cacheRoot = options.cacheRoot ?? path.join(stateDir(), "git"); this.cacheDir = path.join(this.cacheRoot, this.identity.slice("git:".length), "repository.git"); this.commandTimeoutMs = options.commandTimeoutMs ?? COMMAND_TIMEOUT_MS; this.postCommitTimeoutMs = options.postCommitTimeoutMs ?? POST_COMMIT_TIMEOUT_MS; this.afterPushForTest = options.afterPushForTest; this.afterLsRemoteForTest = options.afterLsRemoteForTest; this.afterPayloadWriteForTest = options.afterPayloadWriteForTest; } config; identity; destination; capability = "lease-protected"; cacheRoot; cacheDir; allowLocalRemotes; commandTimeoutMs; postCommitTimeoutMs; afterPushForTest; afterLsRemoteForTest; afterPayloadWriteForTest; cacheReady; sameRevision(left, right) { return decodeRevision(left, this.identity) === decodeRevision(right, this.identity); } async readHead(signal) { const sha = await this.fetchRemoteHead(signal); if (!sha) return void 0; const { manifest } = await this.readPublication(sha, signal); return remoteHead(sha, manifest, this.identity); } async readSnapshot(reference, signal) { const head = await this.fetchRemoteHead(signal); if (!head) throw new Error(`Git snapshot publication was not found: ${reference}`); const commit = await this.resolveSnapshotReference(reference, head, signal); try { await this.git(["cat-file", "-e", `${commit}^{commit}`], { signal }); await this.git(["merge-base", "--is-ancestor", commit, head], { signal }); } catch (error) { throw new Error(`Git snapshot publication was not found: ${reference}`, { cause: error }); } const { manifest, payloadEntries } = await this.readPublication(commit, signal); let blobs; try { blobs = await readGitBlobs( payloadEntries.map((entry) => entry.object), { gitDir: this.cacheDir, signal, timeoutMs: this.commandTimeoutMs, allowFileProtocol: this.allowLocalRemotes, maxOutputBytes: manifest.files.reduce((total, file) => total + file.size, 0) } ); } catch (error) { if (error instanceof Error && /exceeds/u.test(error.message)) { throw new Error("Git snapshot file content exceeds its manifest size.", { cause: error }); } throw this.redactedError(error); } throwIfAborted2(signal); const files = manifest.files.map((file, index) => { const content = blobs[index]; if (!content || content.byteLength !== file.size || sha2562(content) !== file.sha256) { throw new Error(`Git snapshot file checksum or size mismatch: ${file.path}`); } return { path: file.path, contentBase64: content.toString("base64"), sha256: file.sha256 }; }); const snapshot = { version: manifest.snapshotVersion, id: manifest.snapshotId, createdAt: manifest.createdAt, machine: manifest.machine, profile: manifest.profile, ...manifest.snapshotSyncSessions === void 0 ? {} : { syncSessions: manifest.snapshotSyncSessions }, ...manifest.selection === void 0 ? {} : { selection: manifest.selection }, files }; validateGitSnapshot(snapshot, manifest, this.config.destination.namespace); return snapshot; } async publishSnapshot(snapshot, expected, options = {}) { throwIfAborted2(options.signal); const files = prepareGitSnapshot(snapshot, this.config.destination.namespace); const observed = await this.fetchRemoteHead(options.signal); if (!matchesExpected(observed, expected, this.identity)) { throw new SyncBackendConflictError( "Git remote changed while preparing publication. Run /sync status and retry.", { currentHead: observed ? await this.headForSha(observed, options.signal) : void 0 } ); } throwIfAborted2(options.signal); const manifest = { version: GIT_MANIFEST_VERSION, snapshotVersion: snapshot.version, snapshotId: snapshot.id, createdAt: snapshot.createdAt, machine: snapshot.machine, profile: snapshot.profile, syncSessions: snapshot.syncSessions === true || snapshot.files.some((file) => file.path.startsWith("sessions/")), ...snapshot.syncSessions === void 0 ? {} : { snapshotSyncSessions: snapshot.syncSessions }, ...snapshot.selection === void 0 ? {} : { selection: snapshot.selection }, files: files.map(({ path: filePath, sha256: fileSha, size }) => ({ path: filePath, sha256: fileSha, size })) }; let candidate; try { candidate = await this.createCommit(snapshot, files, manifest, observed, options.signal); } catch (error) { throw this.redactedError(error); } throwIfAborted2(options.signal); options.onCommit?.(); const ref = this.remoteRef(); const lease = `--force-with-lease=${ref}:${observed ?? ""}`; let pushError; try { await this.git( [ "push", "--porcelain", "--no-verify", lease, this.config.profile.remote, `${candidate}:${ref}` ], { timeoutMs: this.postCommitTimeoutMs } ); await this.afterPushForTest?.(); } catch (error) { pushError = error; } let current; try { current = await this.fetchRemoteHead(AbortSignal.timeout(this.postCommitTimeoutMs)); } catch (error) { throw new SyncBackendPublicationOutcomeUnknownError( `Git publication outcome is unknown: ${this.safeError(pushError ?? error)}`, { cause: pushError ?? error } ); } if (current !== candidate) { if (pushError && current === observed) { throw new Error( `Git publication failed without updating the owned branch: ${this.safeError(pushError)}`, { cause: pushError } ); } throw new SyncBackendConflictError( pushError ? `Git publication lease was rejected: ${this.safeError(pushError)}` : "Git remote changed immediately after publication.", { phase: "after-commit", currentHead: current ? await this.headForSha(current) : void 0, candidateMayHaveBeenActive: true, cause: pushError instanceof Error ? pushError : void 0 } ); } const head = await this.headForSha(candidate); return { head, warnings: [] }; } async listHistory(signal) { const sha = await this.fetchRemoteHead(signal); if (!sha) return []; const result = await this.git( ["rev-list", "--first-parent", "--reverse", "--max-count=100", sha], { signal } ); const commits = result.stdout.toString("utf8").trim().split("\n").filter(Boolean); const entries = []; for (const commit of commits) { const { manifest } = await this.readPublication(commit, signal); entries.push({ snapshotRef: commit, snapshotId: manifest.snapshotId, createdAt: manifest.createdAt, machine: manifest.machine, syncSessions: manifest.syncSessions }); } return entries; } async diagnose(signal) { const diagnostics = []; try { const version = await runGit(["--version"], { signal, timeoutMs: this.commandTimeoutMs }); const versionText = version.stdout.toString("utf8").trim(); const supported = isSupportedGitVersion(versionText); diagnostics.push({ key: "git-version", level: supported ? "info" : "error", message: supported ? versionText : `${versionText || "unknown Git version"}; pi-sync requires Git 2.30 or newer` }); } catch (error) { return [{ key: "git-version", level: "error", message: this.safeError(error) }]; } try { const head = await this.readHead(signal); if (head) await this.readSnapshot(head.snapshotRef, signal); diagnostics.push({ key: "git-remote", level: "info", message: head ? `git remote: reachable; owned branch ${this.config.destination.branch} is valid` : `git remote: reachable; owned branch ${this.config.destination.branch} is not created yet` }); diagnostics.push({ key: "git-cache", level: "info", message: "git cache: private bare repository is healthy" }); } catch (error) { diagnostics.push({ key: "git-remote", level: "error", message: `git remote: ${this.safeError(error)}` }); } return diagnostics; } async resolveSnapshotReference(reference, head, signal) { if (isCommitSha(reference) || /^[0-9a-f]{64}$/u.test(reference)) { requireCommitSha(reference); return reference; } if (!reference || reference.length > 512 || !/^[A-Za-z0-9._-]+$/u.test(reference)) { throw new Error("Invalid Git publication reference."); } const result = await this.git(["rev-list", "--first-parent", "--max-count=100", head], { signal }); const commits = result.stdout.toString("utf8").trim().split("\n").filter(Boolean); const matches = []; for (const commit of commits) { const { manifest } = await this.readPublication(commit, signal); if (manifest.snapshotId === reference) matches.push(commit); } if (matches.length === 0) { throw new Error(`Git snapshot publication was not found: ${reference}`); } if (matches.length > 1) { throw new Error( `Git snapshot id is ambiguous; use a commit reference from /sync history: ${reference}` ); } return matches[0]; } async headForSha(sha, signal) { const { manifest } = await this.readPublication(sha, signal); return remoteHead(sha, manifest, this.identity); } async fetchRemoteHead(signal) { await this.ensureCache(signal); const result = await this.git( ["ls-remote", "--refs", this.config.profile.remote, this.remoteRef()], { signal } ); const line = result.stdout.toString("utf8").trim(); if (!line) return void 0; const [sha, ref, ...extra] = line.split(/\s+/u); if (extra.length > 0 || ref !== this.remoteRef() || !sha) { throw new Error("Git remote returned a malformed owned-ref response."); } requireCommitSha(sha); await this.afterLsRemoteForTest?.(); return withGitCacheMutation( this.cacheDir, async () => { const localRef = `refs/pisync/fetch/${process.pid}-${randomUUID()}`; try { await this.git( [ "fetch", "--no-tags", "--force", this.config.profile.remote, `${this.remoteRef()}:${localRef}` ], { signal } ); const fetched = (await this.git(["rev-parse", "--verify", localRef], { signal })).stdout.toString("utf8").trim(); requireCommitSha(fetched); return fetched; } finally { await this.git(["update-ref", "-d", localRef], { timeoutMs: 5e3 }).catch( () => void 0 ); } }, signal ); } async readManifest(commit, signal) { requireCommitSha(commit); const bytes = await this.showFile(commit, this.manifestPath(), signal, MAX_GIT_MANIFEST_BYTES); let parsed; try { parsed = JSON.parse(bytes.toString("utf8")); } catch (error) { throw new Error("Git publication manifest is malformed.", { cause: error }); } return requireGitManifest(parsed); } showFile(commit, filePath, signal, maxOutputBytes) { return this.git(["show", `${commit}:${filePath}`], { signal, maxOutputBytes }).then( (result) => result.stdout ); } async createCommit(snapshot, files, manifest, parent, signal) { const manifestBytes = Buffer.from(`${JSON.stringify(manifest)} `, "utf8"); if (manifestBytes.byteLength > MAX_GIT_MANIFEST_BYTES) { throw new Error(`Git publication manifest exceeds the ${MAX_GIT_MANIFEST_BYTES}-byte limit.`); } await this.ensureCache(signal); const temporaryDirectory = await fs.mkdtemp(path.join(path.dirname(this.cacheDir), ".index-")); const indexPath = path.join(temporaryDirectory, "index"); const payloadDirectory = path.join(temporaryDirectory, "payloads"); const env = { GIT_INDEX_FILE: indexPath }; try { await fs.mkdir(payloadDirectory, { mode: 448 }); const uniqueFiles = [...new Map(files.map((file) => [file.sha256, file])).values()].sort( (left, right) => left.sha256.localeCompare(right.sha256) ); for (const file of uniqueFiles) { throwIfAborted2(signal); await fs.writeFile(path.join(payloadDirectory, file.sha256), file.content, { flag: "wx", mode: 384 }); } await this.afterPayloadWriteForTest?.(); throwIfAborted2(signal); const hashed = await this.git(["hash-object", "-w", "--no-filters", "--stdin-paths"], { cwd: payloadDirectory, input: uniqueFiles.map((file) => file.sha256).join("\n") + (uniqueFiles.length ? "\n" : ""), signal, maxOutputBytes: Math.max(1024, uniqueFiles.length * 64) }); const objectIds = hashed.stdout.toString("utf8").trim().split("\n").filter(Boolean); if (objectIds.length !== uniqueFiles.length || objectIds.some((id) => !isCommitSha(id))) { throw new Error("Git hash-object returned a malformed payload response."); } const objectsBySha256 = new Map( uniqueFiles.map((file, index) => [file.sha256, objectIds[index]]) ); const manifestBlob = (await this.git(["hash-object", "-w", "--stdin"], { input: manifestBytes, signal })).stdout.toString("utf8").trim(); if (!isCommitSha(manifestBlob)) throw new Error("Git returned an invalid manifest blob id."); await this.git(["read-tree", "--empty"], { env, signal }); const indexLines = [ `100644 ${manifestBlob} ${this.manifestPath()}`, ...files.map((file) => { const object = objectsBySha256.get(file.sha256); if (!object) throw new Error("Git payload object is missing after hashing."); return `100644 ${object} ${this.filePath(file.path)}`; }) ]; await this.git(["update-index", "-z", "--index-info"], { env, signal, input: Buffer.from(`${indexLines.join("\0")}\0`, "utf8") }); const tree = (await this.git(["write-tree"], { env, signal })).stdout.toString("utf8").trim(); const date = Number.isNaN(Date.parse(snapshot.createdAt)) ? (/* @__PURE__ */ new Date()).toISOString() : snapshot.createdAt; const commit = await this.git( ["commit-tree", tree, ...parent ? ["-p", parent] : [], "-F", "-"], { signal, input: `pi-sync snapshot ${snapshot.id} `, env: { GIT_AUTHOR_NAME: "pi-sync", GIT_AUTHOR_EMAIL: "pi-sync@localhost", GIT_COMMITTER_NAME: "pi-sync", GIT_COMMITTER_EMAIL: "pi-sync@localhost", GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date } } ); const sha = commit.stdout.toString("utf8").trim(); requireCommitSha(sha); return sha; } finally { await fs.rm(temporaryDirectory, { recursive: true, force: true }); } } ensureCache(signal) { if (!this.cacheReady) { const operation = withGitCacheMutation( this.cacheDir, () => this.initializeCache(signal), signal ); const wrapped = operation.catch((error) => { if (this.cacheReady === wrapped) this.cacheReady = void 0; throw this.redactedError(error); }); this.cacheReady = wrapped; } return this.cacheReady; } async initializeCache(signal) { const version = await runGit(["--version"], { signal, timeoutMs: this.commandTimeoutMs }); const versionText = version.stdout.toString("utf8").trim(); if (!isSupportedGitVersion(versionText)) { throw new Error( `${versionText || "Unknown Git version"}; pi-sync requires Git 2.30 or newer.` ); } const parent = path.dirname(this.cacheDir); const cacheParent = path.dirname(this.cacheRoot); await assertNotSymlink(cacheParent, "Git cache parent"); await assertNotSymlink(this.cacheRoot, "Git cache root"); await fs.mkdir(this.cacheRoot, { recursive: true, mode: 448 }); await assertNotSymlink(cacheParent, "Git cache parent"); await assertNotSymlink(this.cacheRoot, "Git cache root"); await assertNotSymlink(parent, "Git cache identity directory"); await fs.mkdir(parent, { recursive: true, mode: 448 }); await assertNotSymlink(parent, "Git cache identity directory"); let recreate = false; try { const stat = await fs.lstat(this.cacheDir); if (stat.isSymbolicLink()) throw new Error("Refusing symlinked Git cache."); if (!stat.isDirectory()) recreate = true; else { try { recreate = !await this.cacheUsesSha1(signal); } catch { recreate = true; } } } catch (error) { if (error.code !== "ENOENT") throw error; } if (recreate) await fs.rm(this.cacheDir, { recursive: true, force: true }); try { await fs.access(this.cacheDir); } catch { try { await runGit(["init", "--bare", "--object-format=sha1", this.cacheDir], { signal, timeoutMs: this.commandTimeoutMs, allowFileProtocol: this.allowLocalRemotes }); } catch (initError) { const concurrent = await this.cacheUsesSha1(signal).catch(() => false); if (!concurrent) throw initError; } } if (process.platform !== "win32") await fs.chmod(parent, 448); } async cacheUsesSha1(signal) { const result = await this.git(["rev-parse", "--is-bare-repository", "--show-object-format"], { signal }); return result.stdout.toString("utf8").trim() === "true\nsha1"; } git(args, options = {}) { return runGit(args, { gitDir: this.cacheDir, allowFileProtocol: this.allowLocalRemotes, timeoutMs: options.timeoutMs ?? this.commandTimeoutMs, ...options }).catch((error) => { throw this.redactedError(error); }); } remoteRef() { return `refs/heads/${this.config.destination.branch}`; } publicationPath() { return this.config.destination.directory; } manifestPath() { return posixJoin(this.publicationPath(), "manifest.json"); } filePath(filePath) { return posixJoin(this.publicationPath(), "files", filePath); } async readPublication(commit, signal) { const manifest = await this.readManifest(commit, signal); const entries = await this.readPublicationTree(commit, signal); const payloadEntries = validateGitPublicationTree( entries, manifest, this.manifestPath(), (filePath) => this.filePath(filePath) ); return { manifest, payloadEntries }; } async readPublicationTree(commit, signal) { const result = await this.git(["ls-tree", "-r", "-z", commit], { signal, maxOutputBytes: MAX_GIT_TREE_OUTPUT_BYTES }); return parseGitTree(result.stdout); } redactedError(error) { if (error instanceof Error && error.name === "AbortError") return error; return new Error(this.safeError(error)); } safeError(error) { const raw = error instanceof GitCommandError ? error.stderr || error.message : error instanceof Error ? error.message : String(error); return redactGitError(raw, this.config.profile.remote, this.cacheDir); } }; function gitBackendIdentity(config) { let remoteIdentity; try { remoteIdentity = normalizeGitRemoteIdentity(config.profile.remote); } catch { remoteIdentity = config.profile.remote; } const canonical = JSON.stringify([ remoteIdentity, config.destination.branch, config.destination.directory ]); return `git:${sha2562(Buffer.from(canonical))}`; } function gitDestination(config) { let host = "Git remote"; const remote = config.profile.remote; if (remote.includes("://")) { try { host = new URL(remote).host; } catch { host = "Git remote"; } } else { const match = /^(?:[^@]+@)?(?\[[^\]]+\]|[^:]+):/u.exec(remote); if (match?.groups?.host) host = match.groups.host; } return `${host} \xB7 ${config.destination.branch}:${config.destination.directory}`; } function remoteHead(sha, manifest, identity) { return { snapshotRef: sha, snapshotId: manifest.snapshotId, revision: `${identity}:${sha}`, createdAt: manifest.createdAt, machine: manifest.machine, syncSessions: manifest.syncSessions, ...manifest.selection === void 0 ? {} : { selection: manifest.selection } }; } function matchesExpected(current, expected, identity) { if (expected.kind === "missing") return current === void 0; try { return current === decodeRevision(expected.revision, identity); } catch { return false; } } function decodeRevision(revision, identity) { const prefix = `${identity}:`; const sha = revision.startsWith(prefix) ? revision.slice(prefix.length) : ""; if (!/^[0-9a-f]{40}$/u.test(sha)) throw new Error("Invalid Git remote revision."); return sha; } function isCommitSha(value) { return /^[0-9a-f]{40}$/u.test(value); } function requireCommitSha(value) { if (/^[0-9a-f]{64}$/u.test(value)) { throw new Error("Unsupported Git SHA-256 repository; pi-sync currently requires SHA-1 refs."); } if (!/^[0-9a-f]{40}$/u.test(value)) throw new Error("Invalid Git publication reference."); } function isSupportedGitVersion(value) { const match = /git version (\d+)\.(\d+)/u.exec(value); if (!match) return false; const major = Number(match[1]); const minor = Number(match[2]); return major > 2 || major === 2 && minor >= 30; } function assertGitDestination(config) { try { if (normalizeGitBranch(config.destination.branch) !== config.destination.branch || normalizeGitDirectory(config.destination.directory) !== config.destination.directory) { throw new Error("Git storage location is not normalized."); } validateGitNamespace(config.destination.namespace); } catch (error) { throw new Error("Invalid Git storage location.", { cause: error }); } } function assertProductionRemote(remote) { let normalized; try { normalized = normalizeGitRemote(remote); } catch (error) { throw new Error(error instanceof Error ? error.message : "Invalid Git remote.", { cause: error }); } if (!normalized || normalized !== remote) throw new Error("Invalid or non-normalized Git remote."); } async function withGitCacheMutation(cacheDir, run, signal) { const previous = gitCacheMutationQueues.get(cacheDir) ?? Promise.resolve(); const operation = previous.catch(() => void 0).then(() => { throwIfAborted2(signal); return run(); }); const tail = operation.then( () => void 0, () => void 0 ); gitCacheMutationQueues.set(cacheDir, tail); void tail.then(() => { if (gitCacheMutationQueues.get(cacheDir) === tail) gitCacheMutationQueues.delete(cacheDir); }); if (!signal) return operation; throwIfAborted2(signal); let rejectAbort; const aborted = new Promise((_resolve, reject) => { rejectAbort = reject; }); const onAbort = () => rejectAbort?.(abortReason(signal)); signal.addEventListener("abort", onAbort, { once: true }); if (signal.aborted) onAbort(); try { return await Promise.race([operation, aborted]); } finally { signal.removeEventListener("abort", onAbort); } } async function assertNotSymlink(target, label) { try { const stat = await fs.lstat(target); if (stat.isSymbolicLink()) throw new Error(`Refusing symlinked ${label}.`); } catch (error) { if (error.code !== "ENOENT") throw error; } } function redactGitError(value, remote, cacheDir) { return value.replaceAll(remote, "").replaceAll(cacheDir, "").replace(/https:\/\/[^/@\s]+@/gu, "https://@").replace(/\b(password|token|authorization)=\S+/giu, "$1=").replace(/\bBearer\s+\S+/giu, "Bearer ").replace(/[\u0000-\u001f\u007f-\u009f]/gu, " ").trim().slice(0, 4096); } function sha2562(value) { return createHash2("sha256").update(value).digest("hex"); } function abortReason(signal) { return signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError"); } function throwIfAborted2(signal) { if (signal?.aborted) throw abortReason(signal); } export { GitSyncBackend, gitBackendIdentity, isSupportedGitVersion }; //# sourceMappingURL=git-backend-E3UOUUOM.ts.map