import * as plugins from './plugins.js'; import { resolveAGLHomePaths } from './classes.aglhome.js'; import { assertCanonicalDirectory, assertCanonicalFilesystemObject, assertCanonicalFilesystemObjects, CanonicalDirectoryError, filesystemIdentitiesEqual, resolveCanonicalFilesystemObject, } from './functions.canonicaldirectory.js'; import type { IControllerFilesystemIdentity, IControllerProjectFilesystemIdentity, TControllerFilesystemObjectType, } from './interfaces.projects.js'; import type { IGitReversionApplyPlan, IGitReversionApplyRequest, IGitReversionApplyWal, IGitReversionCaptureRequest, IGitReversionCaptureState, IGitReversionFaultContext, IGitReversionIdentity, IGitReversionLimits, IGitReversionManifest, IGitReversionMetadata, IGitReversionMetadataRequest, IGitReversionOptions, IGitReversionReleaseRequest, IGitReversionRepositoryCapture, IGitReversionRepositoryIdentity, IGitReversionSide, IGitReversionSourcePack, IGitReversionWorktreeDescriptor, IGitReversionWorktreeRecord, IGitReversionWorktreeRemoveRequest, IGitReversionWorktreeRequest, TGitReversionApplyInspection, TGitReversionCaptureInspection, TGitReversionDirection, TGitReversionFinalizedOutcome, TGitReversionReference, TGitReversionSideName, } from './interfaces.gitreversion.js'; interface IGitResult { stdout: Buffer; stderr: Buffer; exitCode: number; } interface IRegisteredProject { projectId: string; directory: string; identity: IControllerProjectFilesystemIdentity; } export type TGitReversionDiscoveryLimitName = | 'maximumRepositories' | 'maximumScannedDirectories'; interface IProjectRepositoryCatalog { schemaVersion: 1; projectId: string; projectDirectory: string; projectIdentity: IControllerProjectFilesystemIdentity; repositories: IGitReversionRepositoryIdentity[]; } interface IOwnership { token: string; projectId: string; kind: 'capture' | 'apply'; } interface IDurableCaptureRecord { schemaVersion: 1; kind: 'capture'; ownerKey: string; captureKey: string; manifest: IGitReversionManifest; state?: IGitReversionCaptureState; generationIds: Record; revision: number; } type TCaptureLifecyclePhase = | 'creation' | 'prepared-publication' | 'active' | 'finalized' | 'release-intent' | 'deleting' | 'removed'; interface ICaptureLifecycleRecord { schemaVersion: 1; kind: 'capture-lifecycle'; ownerKey: string; captureKey: string; projectId: string; phase: TCaptureLifecyclePhase; publication: 'prepare' | 'finalize' | null; } interface IGenerationUsage { bytes: number; objects: number; } interface IStoreReservation extends IGenerationUsage { reservationId: string; captureKey: string; repositoryId?: string; generationId?: string; generations: number; } interface IGenerationContext { captureKey: string; generationId: string; objectDirectory: string; environment: Record; } interface IGenerationPack { packHash: string; packBytes: number; objectCount: number; packPath: string; indexPath: string; } interface IGitInputFile { handle: plugins.fs.promises.FileHandle; stat: plugins.fs.Stats; } interface IGitOutputFile { handle: plugins.fs.promises.FileHandle; maximumBytes: number; beforeWrite?: (nextBytesArg: number, chunkArg: Buffer) => Promise; } interface IWorktreeRegistration { path: string; headOid?: string; branchRef?: string; detached: boolean; locked: boolean; lockReason?: string; } interface IRecordCopy { repository: IGitReversionRepositoryIdentity; ref: string; oid: string; value: T; } interface IApplyDecisionRecord { schemaVersion: 1; kind: 'apply-decision'; captureKey: string; operationKey: string; attempt: number; decision: 'source' | 'target'; } interface IGitReversionFinalizationIntent { schemaVersion: 1; kind: 'finalization-intent'; captureKey: string; candidateId: string; preparedManifest: IGitReversionManifest; manifest: IGitReversionManifest; state: IGitReversionCaptureState; } interface IApplyRecoveryOperation { operationKey: string; wal: IGitReversionApplyWal; decision?: 'source' | 'target'; fenced: boolean; } interface IOwnershipWaiter extends IOwnership { commonIds: string[]; resolve: () => void; reject: (error: Error) => void; signal?: AbortSignal; abort: () => void; settled: boolean; } interface IGitCommandWaiter { resolve: (releaseArg: () => void) => void; reject: (errorArg: Error) => void; signal?: AbortSignal; abort: () => void; } interface IGenerationCleanup { captureKey: string; repositoryId: string; generationId: string; } interface IPrivatePublicationIntentEntry { repository: IGitReversionRepositoryIdentity; ref: string; expectedOid: string | null; intendedOid: string; intendedValue: object; } interface IPrivateDeletionIntentEntry { repository: IGitReversionRepositoryIdentity; ref: string; expectedOid: string; } interface IPrivateIntentBase { schemaVersion: 1; ownerKey: string; intentId: string; projectId: string; projectDirectory: string; projectIdentity: IControllerProjectFilesystemIdentity; captureKey: string; } interface IPrivatePublicationIntent extends IPrivateIntentBase { kind: 'publication-intent'; operation: 'prepare' | 'participant-extension'; phase: 'prepared' | 'committed'; entries: IPrivatePublicationIntentEntry[]; } interface IPrivateDeletionIntent extends IPrivateIntentBase { kind: 'deletion-intent'; operation: 'release'; phase: 'deleting'; entries: IPrivateDeletionIntentEntry[]; } type TPrivateIntent = IPrivatePublicationIntent | IPrivateDeletionIntent; type TCaptureLifecycleClaimPurpose = 'creation' | 'finalization' | 'deletion'; interface IProjectDurableRecoveryState { captures: IDurableCaptureRecord[]; lifecycleRecords: ICaptureLifecycleRecord[]; catalog?: IProjectRepositoryCatalog; privateIntents: TPrivateIntent[]; worktreeRecords: IGitReversionWorktreeRecord[]; } interface IPrivateRefCharge { oid: string; bytes: number; } interface IPrivateDirectRef { ref: string; oid: string; } interface IMetadataWriteReservation { repositoryId: string; ref: string; captureKey: string; bytes: number; storeReservationId: string; } const privateRefRoot = 'refs/harness-controller/reversion/v1'; const objectIdPattern = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u; const identifierMaximumBytes = 512; const pathMaximumBytesHard = 16 * 1024 * 1024; const metadataMaximumBytesHard = 4 * 1024 * 1024; const commandOutputMaximumBytesHard = 64 * 1024 * 1024; const captureRecordMaximumBytesHard = 4 * 1024 * 1024; const captureAggregateMaximumBytesHard = 256 * 1024 * 1024; const privateMetadataAggregateMaximumBytesHard = 1024 * 1024 * 1024; const intentFileMaximumBytesHard = 16 * 1024 * 1024; const intentAggregateMaximumBytesHard = 256 * 1024 * 1024; const generationMaximumBytesHard = 16 * 1024 * 1024 * 1024; const controllerStoreMaximumBytesHard = 64 * 1024 * 1024 * 1024; const stableScanAttempts = 3; const commandTerminationGraceMs = 1_000; const worktreeRemovalRecoveryRetryMs = 1_000; const discoveryLimitReasonCode = 'git.discovery-limit'; const captureLifecycleClaimsByRoot = new Map< string, Map >(); const isMissingProcess = (errorArg: unknown): boolean => ( typeof errorArg === 'object' && errorArg !== null && 'code' in errorArg && errorArg.code === 'ESRCH' ); const isProcessAlive = (processIdArg: number): boolean => { try { process.kill(processIdArg, 0); return true; } catch (errorArg) { if (isMissingProcess(errorArg)) return false; throw errorArg; } }; const isProcessGroupAlive = (processGroupIdArg: number): boolean => { try { process.kill(-processGroupIdArg, 0); return true; } catch (errorArg) { if (isMissingProcess(errorArg)) return false; throw errorArg; } }; const signalProcessGroup = (processGroupIdArg: number, signalArg: NodeJS.Signals): void => { try { process.kill(-processGroupIdArg, signalArg); } catch (errorArg) { if (!isMissingProcess(errorArg)) throw errorArg; } }; const waitFor = async (predicateArg: () => boolean, timeoutMsArg: number): Promise => { const deadline = Date.now() + timeoutMsArg; while (predicateArg()) { const remaining = deadline - Date.now(); if (remaining <= 0) return false; await new Promise((resolve) => setTimeout(resolve, Math.min(20, remaining))); } return true; }; const waitBounded = async (promiseArg: Promise, timeoutMsArg: number): Promise => { let timeout: NodeJS.Timeout | undefined; try { return await Promise.race([ promiseArg.then(() => true), new Promise((resolve) => { timeout = setTimeout(() => resolve(false), timeoutMsArg); }), ]); } finally { if (timeout) clearTimeout(timeout); } }; const inspectOwnedPrivateDirectory = async ( pathArg: string, descriptionArg: string, ): Promise<{ device: number; inode: number }> => { const pathStat = await plugins.fs.promises.lstat(pathArg); if ( !pathStat.isDirectory() || pathStat.isSymbolicLink() || (pathStat.mode & 0o077) !== 0 || (typeof process.getuid === 'function' && pathStat.uid !== process.getuid()) ) throw new Error(`${descriptionArg} is not an exclusive owned directory.`); let handle: plugins.fs.promises.FileHandle | undefined; try { handle = await plugins.fs.promises.open( pathArg, plugins.fs.constants.O_RDONLY | plugins.fs.constants.O_DIRECTORY | plugins.fs.constants.O_NOFOLLOW, ); const openedStat = await handle.stat(); if ( !openedStat.isDirectory() || openedStat.dev !== pathStat.dev || openedStat.ino !== pathStat.ino || openedStat.uid !== pathStat.uid || openedStat.mode !== pathStat.mode ) throw new Error(`${descriptionArg} changed while it was validated.`); return { device: openedStat.dev, inode: openedStat.ino }; } finally { await handle?.close(); } }; export const ensureGitReversionParentDirectory = async ( dataRootArg: string, ): Promise => { if ( !plugins.path.isAbsolute(dataRootArg) || plugins.path.normalize(dataRootArg) !== dataRootArg || dataRootArg.includes('\0') ) throw new Error('The committed controller data root must be a canonical absolute path.'); const parentBefore = await inspectOwnedPrivateDirectory( dataRootArg, 'The committed controller data root', ); const gitReversionParent = plugins.path.join(dataRootArg, 'git-reversion'); try { await plugins.fs.promises.mkdir(gitReversionParent, { mode: 0o700 }); } catch (errorArg) { if ((errorArg as NodeJS.ErrnoException).code !== 'EEXIST') throw errorArg; } const parentAfter = await inspectOwnedPrivateDirectory( dataRootArg, 'The committed controller data root', ); if ( parentAfter.device !== parentBefore.device || parentAfter.inode !== parentBefore.inode ) throw new Error('The committed controller data root changed during Git startup.'); await inspectOwnedPrivateDirectory( gitReversionParent, 'The Git reversion parent directory', ); return gitReversionParent; }; const safeGitConfigurationArguments = [ '-c', `core.hooksPath=${plugins.os.devNull}`, '-c', 'core.fsmonitor=false', '-c', 'maintenance.auto=false', '-c', 'gc.auto=0', '-c', 'commit.gpgSign=false', ] as const; const defaultLimits: IGitReversionLimits = { maximumProjects: 1024, maximumRepositories: 64, maximumScannedDirectories: 20_000, maximumScanDepth: 32, maximumPaths: 100_000, maximumPathBytes: 8 * 1024 * 1024, maximumMetadataBytes: 1024 * 1024, maximumPrivateRefs: 100_000, maximumPrivateMetadataAggregateBytes: 64 * 1024 * 1024, maximumIntentFiles: 256, maximumIntentEntries: 4096, maximumIntentFileBytes: 4 * 1024 * 1024, maximumIntentAggregateBytes: 32 * 1024 * 1024, maximumScavengeEntries: 100_000, maximumIgnorePolicyFiles: 4096, maximumIgnorePolicyBytes: 4 * 1024 * 1024, maximumWorktrees: 128, maximumCaptureRecords: 1024, maximumCaptureRecordBytes: 1024 * 1024, maximumCaptureAggregateBytes: 64 * 1024 * 1024, maximumCaptures: 1024, maximumGenerationsPerCapture: 1024, maximumGenerationBytes: 1024 * 1024 * 1024, maximumGenerationObjects: 1_000_000, maximumCaptureStoreBytes: 4 * 1024 * 1024 * 1024, maximumCaptureStoreObjects: 4_000_000, maximumControllerStoreBytes: 16 * 1024 * 1024 * 1024, maximumControllerStoreObjects: 16_000_000, maximumControllerGenerations: 16_384, maximumMetadataRecordsPerCapture: 100_000, maximumMetadataRecordBytes: 1024 * 1024, maximumMetadataAggregateBytes: 64 * 1024 * 1024, maximumLifecycleEntries: 1024, maximumLifecycleRecordBytes: 16 * 1024, maximumLifecycleAggregateBytes: 16 * 1024 * 1024, maximumPrivateRefsPerCapture: 100_000, commandTimeoutMs: 30_000, worktreeCommandTimeoutMs: 120_000, commandCloseConfirmationMs: 5_000, maximumCommandOutputBytes: 8 * 1024 * 1024, maximumConcurrentGitCommands: 4, maximumQueuedGitCommands: 256, }; const sha256 = (valueArg: string | Buffer): string => plugins.crypto .createHash('sha256') .update(valueArg) .digest('hex'); const compareStrings = (leftArg: string, rightArg: string): number => ( leftArg < rightArg ? -1 : leftArg > rightArg ? 1 : 0 ); const addSafeLimit = (...valuesArg: number[]): number => { let result = 0; for (const value of valuesArg) { result += value; if (!Number.isSafeInteger(result)) throw new Error('A Git reversion aggregate limit overflowed.'); } return result; }; const multiplySafeLimit = (leftArg: number, rightArg: number): number => { const result = leftArg * rightArg; if (!Number.isSafeInteger(result)) throw new Error('A Git reversion aggregate limit overflowed.'); return result; }; const privateTreeTraversalLimit = (limitsArg: IGitReversionLimits): number => { const repositoryStores = multiplySafeLimit( limitsArg.maximumCaptures, limitsArg.maximumRepositories, ); const objectStores = addSafeLimit(repositoryStores, limitsArg.maximumControllerGenerations); return addSafeLimit( // A packed object may have pack, index, and reverse-index files; loose objects use one file. multiplySafeLimit(limitsArg.maximumControllerStoreObjects, 3), // Every object store has bounded control/fan-out directories in addition to its object files. multiplySafeLimit(objectStores, 260), // A private ref may contribute a distinct directory at every admitted ref depth. multiplySafeLimit( limitsArg.maximumPrivateRefs, addSafeLimit(limitsArg.maximumScanDepth, 2), ), multiplySafeLimit(repositoryStores, 16), multiplySafeLimit(limitsArg.maximumControllerGenerations, 4), multiplySafeLimit(limitsArg.maximumCaptures, 8), ); }; const canonicalValue = (valueArg: unknown): unknown => { if (Array.isArray(valueArg)) return valueArg.map(canonicalValue); if (!valueArg || typeof valueArg !== 'object') return valueArg; return Object.fromEntries( Object.entries(valueArg) .filter(([, value]) => value !== undefined) .sort(([left], [right]) => compareStrings(left, right)) .map(([key, value]) => [key, canonicalValue(value)]), ); }; const canonicalJson = (valueArg: unknown): string => JSON.stringify(canonicalValue(valueArg)); const recordsEqual = (leftArg: unknown, rightArg: unknown): boolean => ( canonicalJson(leftArg) === canonicalJson(rightArg) ); const trimLine = (valueArg: Buffer): string => valueArg.toString('utf8').trim(); const splitExactGitLines = ( valueArg: Buffer, expectedCountArg: number, descriptionArg: string, ): string[] => { const value = decodeUtf8(valueArg, descriptionArg); if (!value.endsWith('\n')) throw new GitReversionFencedError(`${descriptionArg} is truncated.`); const lines = value.slice(0, -1).split('\n'); if (lines.length !== expectedCountArg || lines.some((line) => line.length === 0)) { throw new GitReversionFencedError(`${descriptionArg} has an unexpected field count.`); } return lines; }; const zeroOid = (repositoryArg: IGitReversionRepositoryIdentity): string => ( '0'.repeat(repositoryArg.objectFormat === 'sha256' ? 64 : 40) ); const isWithin = (parentArg: string, candidateArg: string): boolean => { const relative = plugins.path.relative(parentArg, candidateArg); return relative === '' || ( relative !== '..' && !relative.startsWith(`..${plugins.path.sep}`) && !plugins.path.isAbsolute(relative) ); }; const compareRepositories = ( leftArg: IGitReversionRepositoryIdentity, rightArg: IGitReversionRepositoryIdentity, ): number => leftArg.repositoryId.localeCompare(rightArg.repositoryId); const decodeUtf8 = (valueArg: Buffer, descriptionArg: string): string => { try { return new TextDecoder('utf-8', { fatal: true }).decode(valueArg); } catch (errorArg) { throw new Error(`${descriptionArg} is not valid UTF-8.`, { cause: errorArg }); } }; const splitNul = (valueArg: Buffer, descriptionArg: string): string[] => decodeUtf8( valueArg, descriptionArg, ).split('\0').filter((entry) => entry.length > 0); const assertIdentifier = (valueArg: string, nameArg: string): void => { if ( typeof valueArg !== 'string' || valueArg.trim().length === 0 || valueArg.includes('\0') || Buffer.byteLength(valueArg, 'utf8') > identifierMaximumBytes ) throw new Error(`${nameArg} is invalid.`); }; const assertReference = (valueArg: TGitReversionReference): void => { if ( !valueArg || typeof valueArg !== 'object' || Array.isArray(valueArg) || Object.keys(valueArg).sort().join(',') !== 'captureKey,manifestId,schemaVersion' || valueArg.schemaVersion !== 1 || !/^[0-9a-f]{64}$/u.test(valueArg.captureKey) || !/^[0-9a-f]{64}$/u.test(valueArg.manifestId) ) throw new Error('The Git reversion reference is invalid.'); }; const assertIdentity = (identityArg: IGitReversionIdentity): void => { assertIdentifier(identityArg.projectId, 'The Git reversion project ID'); assertIdentifier(identityArg.sessionId, 'The Git reversion session ID'); assertIdentifier(identityArg.runId, 'The Git reversion run ID'); assertIdentifier(identityArg.captureId, 'The Git reversion capture ID'); }; const sideNameForDirection = ( directionArg: TGitReversionDirection, ): { source: TGitReversionSideName; target: TGitReversionSideName } => directionArg === 'undo' ? { source: 'after', target: 'before' } : { source: 'before', target: 'after' }; export class GitReversionConflictError extends Error { constructor( messageArg = 'The Git reversion conflicts with later workspace changes.', optionsArg?: ErrorOptions, ) { super(messageArg, optionsArg); this.name = 'GitReversionConflictError'; } } export class GitReversionFencedError extends Error { constructor( messageArg = 'The Git reversion outcome is unknown and has been fenced.', optionsArg?: ErrorOptions, ) { super(messageArg, optionsArg); this.name = 'GitReversionFencedError'; } } export class GitReversionRecoveryRequiredError extends Error { constructor( messageArg = 'Git reversion recovery must complete before this operation.', optionsArg?: ErrorOptions, ) { super(messageArg, optionsArg); this.name = 'GitReversionRecoveryRequiredError'; } } export class GitReversionDirtyWorktreeError extends Error { constructor(messageArg = 'The owned Git worktree is dirty and was retained.') { super(messageArg); this.name = 'GitReversionDirtyWorktreeError'; } } export class GitReversionDiscoveryLimitError extends Error { constructor(public readonly limit: TGitReversionDiscoveryLimitName) { super(limit === 'maximumRepositories' ? 'The Git repository limit was exceeded.' : 'The Git repository discovery directory limit was exceeded.'); this.name = 'GitReversionDiscoveryLimitError'; } } class GitProcessError extends Error { constructor( messageArg: string, public readonly result: IGitResult, ) { super(messageArg); this.name = 'GitProcessError'; } } class GitReversionInjectedFaultError extends Error { constructor(errorArg: unknown) { super(errorArg instanceof Error ? errorArg.message : String(errorArg), { cause: errorArg instanceof Error ? errorArg : undefined, }); this.name = errorArg instanceof Error ? errorArg.name : 'GitReversionInjectedFaultError'; } } class GitReversionConcurrentRecordReadError extends Error { constructor(messageArg: string) { super(messageArg); this.name = 'GitReversionConcurrentRecordReadError'; } } class GitReversionSourceKeepOwnershipError extends GitReversionFencedError { constructor(messageArg: string, optionsArg?: ErrorOptions) { super(messageArg, optionsArg); this.name = 'GitReversionSourceKeepOwnershipError'; } } export class ControllerGitReversion { public readonly protocolVersion = 2 as const; private readonly ownerId: string; private readonly ownerKey: string; private readonly rootDirectory: string; private readonly limits: IGitReversionLimits; private readonly maximumPrivateTreeEntries: number; private readonly faultInjector?: IGitReversionOptions['testOnlyFaultInjector']; private readonly projects = new Map(); private readonly ownershipByCommonId = new Map(); private readonly ownershipWaiters = new Set(); private readonly ownershipWaitersByCommonId = new Map>(); private readonly lockTails = new Map>(); private readonly projectCatalogTails = new Map>(); private readonly activeProjectCatalogOperations = new Set>(); private readonly activeLockCohorts = new Set>(); private readonly activeGitCommands = new Set>(); private readonly unconfirmedGitChildren = new Set(); private readonly retainedGitProcessGroups = new Set(); private readonly gitCommandWaiters: IGitCommandWaiter[] = []; private readonly validatedRefNames = new Set(); private activeGitCommandPermits = 0; private readonly captureRecordBytes = new Map(); private captureAggregateBytes = 0; private captureMutationTail = Promise.resolve(); private readonly activeCaptureMutations = new Set>(); private readonly privateIntentBytes = new Map(); private privateIntentAggregateBytes = 0; private readonly privateRefCharges = new Map(); private privateRefAggregateBytes = 0; private readonly metadataWriteReservations = new Map(); private readonly lifecycleRecordBytes = new Map(); private lifecycleAggregateBytes = 0; private readonly storeReservations = new Map(); private readonly pendingGenerationCleanups = new Map(); private readonly pendingTemporaryIndexCleanups = new Set(); private maintenanceCleanupPromise?: Promise; private readonly pendingWorktreeRemovalProjectIds = new Set(); private readonly activeWorktreeRemovalRecoveryProjectIds = new Set(); private worktreeRemovalRecoveryTimer?: NodeJS.Timeout; private worktreeRemovalRecoveryTask?: Promise; private metadataQuotaLoaded = false; private metadataMutationTail = Promise.resolve(); private readonly activeMetadataMutations = new Set>(); private readonly recoveredProjects = new Map(); private readonly projectRecoveryPromises = new Map>(); private initializationPromise?: Promise; private recoveryPromise?: Promise; private recoverySucceeded = false; private activeOperations = 0; private readonly operationDrainWaiters = new Set<() => void>(); private readonly lifecycleAbortController = new AbortController(); private initialized = false; private sealed = false; private sealError?: Error; private closed = false; private closePromise?: Promise; private readonly onSealedForCleanup?: (error: Error) => void; constructor(optionsArg: IGitReversionOptions) { assertIdentifier(optionsArg.ownerId, 'The Git reversion owner ID'); this.ownerId = optionsArg.ownerId; this.ownerKey = sha256(optionsArg.ownerId).slice(0, 32); this.limits = { ...defaultLimits, ...optionsArg.limits }; this.validateLimits(); this.maximumPrivateTreeEntries = privateTreeTraversalLimit(this.limits); this.rootDirectory = optionsArg.temporaryRoot ?? plugins.path.join( resolveAGLHomePaths().gitReversion, sha256(optionsArg.ownerId), ); if (!plugins.path.isAbsolute(this.rootDirectory)) { throw new Error('The Git reversion temporary root must be absolute.'); } if (Buffer.byteLength(this.rootDirectory, 'utf8') > 4096) { throw new Error('The Git reversion temporary root is too long.'); } this.faultInjector = optionsArg.testOnlyFaultInjector; this.onSealedForCleanup = optionsArg.onSealedForCleanup; } /** * True once the engine sealed itself for incomplete Git process cleanup or * closed. Such an engine rejects every operation; its owner must close it * and construct a replacement. */ public get requiresReplacement(): boolean { return this.sealed || this.closed; } public init(signalArg?: AbortSignal): Promise { return this.withPublicOperation(signalArg, async (signal) => { await this.ensureRecovery(undefined, signal); }); } public registerProject( projectIdArg: string, directoryArg: string, identityArg: IControllerProjectFilesystemIdentity, ): void { this.assertUsable(); assertIdentifier(projectIdArg, 'The Git reversion project ID'); if ( !plugins.path.isAbsolute(directoryArg) || directoryArg.includes('\0') || Buffer.byteLength(directoryArg, 'utf8') > 4096 ) throw new Error('The Git reversion project directory must be an absolute bounded path.'); const existing = this.projects.get(projectIdArg); if (existing && ( existing.directory !== directoryArg || !filesystemIdentitiesEqual(existing.identity, identityArg) )) { throw new Error('The Git reversion project is already registered at another directory.'); } if (existing) return; this.projects.set(projectIdArg, { projectId: projectIdArg, directory: directoryArg, identity: structuredClone(identityArg), }); if (this.projects.size > this.limits.maximumProjects) { this.projects.delete(projectIdArg); throw new Error('The Git reversion project limit was exceeded.'); } this.recoveredProjects.delete(projectIdArg); } public unregisterProject(projectIdArg: string): void { this.assertUsable(); if ( this.projectRecoveryPromises.has(projectIdArg) || this.pendingWorktreeRemovalProjectIds.has(projectIdArg) || this.activeWorktreeRemovalRecoveryProjectIds.has(projectIdArg) ) { throw new Error('The Git reversion project is still recovering.'); } const owned = [...this.ownershipByCommonId.values()].some( (ownership) => ownership.projectId === projectIdArg, ); if (owned) throw new Error('The Git reversion project still owns an active capture or apply.'); this.projects.delete(projectIdArg); this.recoveredProjects.delete(projectIdArg); } public recover(signalArg?: AbortSignal): Promise { return this.withPublicOperation(signalArg, async (signal) => { await this.ensureRecovery(undefined, signal); }); } public async close(): Promise { if (this.closed) return; if (this.closePromise) return this.closePromise; if (this.worktreeRemovalRecoveryTimer) { clearTimeout(this.worktreeRemovalRecoveryTimer); this.worktreeRemovalRecoveryTimer = undefined; } this.sealed = true; this.lifecycleAbortController.abort(new Error('The Git reversion engine is closing.')); const closeError = new Error('The Git reversion engine is closing.'); for (const waiter of [...this.ownershipWaiters]) waiter.reject(closeError); this.ownershipWaiters.clear(); this.ownershipWaitersByCommonId.clear(); for (const waiter of this.gitCommandWaiters.splice(0)) waiter.reject(closeError); const attempt = (async () => { const deadline = Date.now() + Math.max(this.limits.commandTimeoutMs, this.limits.worktreeCommandTimeoutMs) + commandTerminationGraceMs + this.limits.commandCloseConfirmationMs; while ( this.activeLockCohorts.size > 0 || this.activeGitCommands.size > 0 || this.activeProjectCatalogOperations.size > 0 || this.activeCaptureMutations.size > 0 || this.activeMetadataMutations.size > 0 || this.activeOperations > 0 ) { const remaining = deadline - Date.now(); if (remaining <= 0 || !await waitBounded(Promise.allSettled([ ...this.activeLockCohorts, ...this.activeGitCommands, ...this.activeProjectCatalogOperations, ...this.activeCaptureMutations, ...this.activeMetadataMutations, ...(this.worktreeRemovalRecoveryTask ? [this.worktreeRemovalRecoveryTask] : []), this.waitForOperationsToDrain(), ]), remaining)) { throw new Error('Git reversion operations did not settle before the close deadline.'); } } await this.reapRetainedGitProcessGroups(); const childrenClosed = await waitFor( () => this.unconfirmedGitChildren.size > 0, this.limits.commandCloseConfirmationMs, ); if (!childrenClosed) { throw new Error('A terminated Git child has not confirmed close; engine ownership is retained.'); } await this.retryPendingMaintenanceCleanups(true); await this.releaseStrandedMetadataReservations(); if ( this.pendingGenerationCleanups.size > 0 || this.pendingTemporaryIndexCleanups.size > 0 || this.storeReservations.size > 0 ) throw new Error('Git reversion cleanup ownership remains after close maintenance.'); this.ownershipByCommonId.clear(); this.initialized = false; this.closed = true; })(); this.closePromise = attempt; try { await attempt; } finally { if (!this.closed && this.closePromise === attempt) this.closePromise = undefined; } } public prepare(requestArg: IGitReversionCaptureRequest): Promise { return this.withPublicOperation(requestArg.signal, async (signal) => { assertIdentity(requestArg); await this.ensureNonSourceOperationReady(requestArg.projectId, signal); const project = this.requireProject(requestArg.projectId); const captureKey = this.captureKey(requestArg); if (this.claimCaptureLifecycle(captureKey, 'creation')) { throw new GitReversionRecoveryRequiredError( 'The Git capture already has an active lifecycle operation.', ); } let newCapture = false; let ownershipAcquired = false; let persisted = false; let privatePublicationStarted = false; let faultInterrupted = false; try { const initialDurable = await this.readDurableCaptureRecord(captureKey); if (initialDurable) { this.assertManifestIdentity(initialDurable.manifest, requestArg); if (initialDurable.manifest.reasonCode === discoveryLimitReasonCode) { await this.assertDiscoveryLimitCapture(project, initialDurable); return; } } const definitivelyNew = initialDurable === undefined && await this.captureIsDefinitivelyNew(captureKey); let discoveryBarrierEligible = false; if (definitivelyNew) { const projectState = await this.inspectProjectDurableRecoveryState(project); this.assertProjectCaptureLifecycleState(projectState); discoveryBarrierEligible = projectState.catalog === undefined && projectState.privateIntents.length === 0 && projectState.worktreeRecords.length === 0 && projectState.captures.every((capture) => ( capture.manifest.reasonCode === discoveryLimitReasonCode )); if (discoveryBarrierEligible) { for (const capture of projectState.captures) { await this.assertDiscoveryLimitCapture(project, capture); } } } let repositories: IGitReversionRepositoryIdentity[]; try { repositories = await this.discoverProjectRepositories(project, signal); } catch (errorArg) { if (!(errorArg instanceof GitReversionDiscoveryLimitError) || !discoveryBarrierEligible) { throw errorArg; } newCapture = true; await this.withLocks([], async () => { await this.beginCaptureCreation(captureKey, requestArg.projectId); const manifest: IGitReversionManifest = { schemaVersion: 1, kind: 'manifest', ownerKey: this.ownerKey, captureKey, identity: this.copyIdentity(requestArg), projectDirectory: await this.assertProjectDirectory(project), projectIdentity: structuredClone(project.identity), phase: 'prepared', repositories: [], reasonCode: discoveryLimitReasonCode, affectedWorkspaces: [], }; await this.writeDurableCaptureRecord({ schemaVersion: 1, kind: 'capture', ownerKey: this.ownerKey, captureKey, manifest, generationIds: {}, revision: 0, }); await this.publishCaptureLifecycle(captureKey, requestArg.projectId, 'prepare'); persisted = true; }); return; } const existing = await this.findManifest(project, repositories, captureKey, signal); if (existing) { this.assertManifestIdentity(existing, requestArg); await this.assertFreshRepositoryBindings(project, existing, repositories, signal); if (existing.phase === 'prepared') { await this.acquireOwnership( [...new Set(existing.repositories.map((entry) => entry.repository.commonId))].sort(), captureKey, requestArg.projectId, 'capture', signal, ); } return; } if (!definitivelyNew) { throw new GitReversionFencedError( 'The Git capture has incomplete durable state and cannot be recreated.', ); } newCapture = true; const commonIds = [...new Set(repositories.map((entry) => entry.commonId))].sort(); await this.acquireOwnership( commonIds, captureKey, requestArg.projectId, 'capture', signal, ); ownershipAcquired = true; await this.withLocks(commonIds, async () => { await this.beginCaptureCreation(captureKey, requestArg.projectId); const generationIds: Record = {}; const sides = await this.captureStableRepositories(repositories, signal, { captureKey, persistent: true, generationIds, }); await this.fault({ point: 'capture.after-before-scan', captureKey }); const manifest: IGitReversionManifest = { schemaVersion: 1, kind: 'manifest', ownerKey: this.ownerKey, captureKey, identity: this.copyIdentity(requestArg), projectDirectory: await this.assertProjectDirectory(project), projectIdentity: structuredClone(project.identity), phase: 'prepared', repositories: repositories.map((repository, index) => ({ repository, before: sides[index]!, })), affectedWorkspaces: [], }; if (repositories.length > 0) { privatePublicationStarted = true; await this.createPreparedRecords(manifest, signal); } await this.writeDurableCaptureRecord({ schemaVersion: 1, kind: 'capture', ownerKey: this.ownerKey, captureKey, manifest, generationIds, revision: 0, }); await this.publishCaptureLifecycle(captureKey, requestArg.projectId, 'prepare'); persisted = true; }); } catch (errorArg) { faultInterrupted = errorArg instanceof GitReversionInjectedFaultError; throw errorArg; } finally { try { if (newCapture && !persisted && !privatePublicationStarted && !faultInterrupted) { const lifecycle = (await this.listLifecycleRecords()).find((entry) => ( entry.captureKey === captureKey )); if (lifecycle) { await this.deleteCaptureRootWithLifecycle(lifecycle, 'creation').catch(() => undefined); } } } finally { this.releaseCaptureLifecycleClaim(captureKey, 'creation'); if (newCapture && ownershipAcquired && !persisted) this.releaseOwnership(captureKey); } } }); } public inspectCapture( requestArg: IGitReversionCaptureRequest, ): Promise { return this.withPublicOperation(requestArg.signal, async (signal) => { assertIdentity(requestArg); const captureKey = this.captureKey(requestArg); const snapshot = await this.readCaptureSnapshot(requestArg, captureKey, signal); if (!snapshot) return { status: 'missing' }; if (snapshot.manifest.phase === 'prepared') return { status: 'prepared' }; if (!snapshot.state) return { status: 'unknown' }; return { status: 'finalized', outcome: snapshot.state.outcome }; }); } public finalize( requestArg: IGitReversionCaptureRequest, ): Promise { return this.withPublicOperation(requestArg.signal, async (signal) => { assertIdentity(requestArg); const captureKey = this.captureKey(requestArg); await this.ensureNonSourceOperationReady(requestArg.projectId, signal, captureKey); if (this.claimCaptureLifecycle(captureKey, 'finalization')) { throw new GitReversionRecoveryRequiredError( 'The Git capture already has an active lifecycle operation.', ); } try { const project = this.requireProject(requestArg.projectId); const durableBeforeDiscovery = await this.readDurableCaptureRecord(captureKey); if (durableBeforeDiscovery?.manifest.reasonCode === discoveryLimitReasonCode) { this.assertManifestIdentity(durableBeforeDiscovery.manifest, requestArg); await this.assertDiscoveryLimitCapture(project, durableBeforeDiscovery); if (durableBeforeDiscovery.manifest.phase === 'finalized') { return durableBeforeDiscovery.state!.outcome; } const lifecycle = await this.readLifecycleRecord(captureKey); if ( !lifecycle || lifecycle.projectId !== requestArg.projectId || lifecycle.phase !== 'active' || lifecycle.publication !== null ) throw new GitReversionRecoveryRequiredError( 'The discovery-limit Git capture lifecycle is not prepared.', ); const manifest: IGitReversionManifest = { ...structuredClone(durableBeforeDiscovery.manifest), phase: 'finalized', }; const manifestId = sha256(canonicalJson(manifest)); const reference: TGitReversionReference = { schemaVersion: 1, captureKey, manifestId, }; const outcome: TGitReversionFinalizedOutcome = { disposition: 'nonrevertible', reference, reasonCode: discoveryLimitReasonCode, }; const state: IGitReversionCaptureState = { schemaVersion: 1, kind: 'capture-state', captureKey, manifestId, currentSide: 'after', revision: 0, outcome, }; await this.fault({ point: 'capture.before-finalize-record', captureKey }); const current = await this.readDurableCaptureRecord(captureKey); if (!current || !recordsEqual(current, durableBeforeDiscovery)) { throw new GitReversionFencedError( 'The discovery-limit Git capture changed before finalization.', ); } await this.publishFinalizedCaptureRecord({ ...current, manifest, state, revision: current.revision + 1, }, requestArg.projectId); return outcome; } const discovered = await this.discoverProjectRepositories(project, signal); await this.recoverFinalizations(project, discovered, captureKey, signal); const manifest = await this.findManifest(project, discovered, captureKey, signal); if (!manifest) throw new Error('The prepared Git capture is missing.'); this.assertManifestIdentity(manifest, requestArg); if (manifest.phase === 'finalized') { const outcome = (await this.loadState(manifest, signal)).outcome; this.releaseOwnership(captureKey); return outcome; } const commonIds = [...new Set([ ...manifest.repositories.map((entry) => entry.repository.commonId), ...discovered.map((repository) => repository.commonId), ])].sort(compareStrings); await this.acquireOwnership( commonIds, captureKey, requestArg.projectId, 'capture', signal, ); let finalized = false; try { return await this.withLocks(commonIds, async () => { const preparedManifest = structuredClone(manifest); const durable = await this.readDurableCaptureRecord(captureKey); if (!durable) throw new GitReversionFencedError('The durable prepared capture is missing.'); const generationIds = structuredClone(durable.generationIds); const repositories = manifest.repositories.map((entry) => entry.repository); const discoveredById = new Map(discovered.map((repository) => [ repository.repositoryId, repository, ])); if (repositories.some((repository) => { const fresh = discoveredById.get(repository.repositoryId); return !fresh || !this.repositoryBindingsEqual(fresh, repository); })) throw new GitReversionFencedError( 'A prepared Git repository no longer exactly matches fresh discovery.', ); const repositoryTopologyChanged = repositories.length !== discovered.length; const stableRepositories = repositories.filter((repository) => { const current = discoveredById.get(repository.repositoryId); return current && recordsEqual(current, repository); }); const stableAfter = await this.captureStableRepositories(stableRepositories, signal, { captureKey, persistent: true, generationIds, }); await this.enrollGenerationIds(captureKey, generationIds); const stableAfterById = new Map(stableRepositories.map((repository, index) => [ repository.repositoryId, stableAfter[index]!, ])); let reasonCode: string | undefined = repositoryTopologyChanged ? 'git.topology-changed' : undefined; const affectedById = new Map(); if (repositoryTopologyChanged) { for (const repository of [...repositories, ...discovered]) { affectedById.set(repository.repositoryId, { id: repository.repositoryId, label: repository.label, }); } } for (let index = 0; index < manifest.repositories.length; index += 1) { const entry = manifest.repositories[index]!; entry.after = stableAfterById.get(entry.repository.repositoryId) ?? { ...entry.before, unsupportedReason: 'git.topology-changed', }; const reason = this.captureRepositoryReason(entry.before, entry.after); reasonCode ??= reason; if (reason || !this.semanticSidesEqual(entry.before, entry.after)) { affectedById.set(entry.repository.repositoryId, { id: entry.repository.repositoryId, label: entry.repository.label, }); } } const affectedWorkspaces = [...affectedById.values()] .sort((left, right) => compareStrings(left.id, right.id)) .slice(0, this.limits.maximumRepositories); manifest.reasonCode = reasonCode; manifest.affectedWorkspaces = affectedWorkspaces; manifest.phase = 'finalized'; const manifestId = sha256(canonicalJson(manifest)); const reference: TGitReversionReference = { schemaVersion: 1, captureKey, manifestId, }; const changed = manifest.repositories.some((entry) => ( !this.semanticSidesEqual(entry.before, entry.after!) )); const outcome: TGitReversionFinalizedOutcome = reasonCode ? { disposition: 'nonrevertible', reference, reasonCode, ...(affectedWorkspaces.length > 0 ? { affectedWorkspaces } : {}), } : changed ? { disposition: 'revertible', reference, affectedWorkspaces } : { disposition: 'no-change', reference }; const state: IGitReversionCaptureState = { schemaVersion: 1, kind: 'capture-state', captureKey, manifestId, currentSide: 'after', revision: 0, outcome, }; await this.fault({ point: 'capture.before-finalize-record', captureKey }); if (repositories.length > 0) { await this.finalizeRecords(preparedManifest, manifest, state, signal); } const enrolledDurable = await this.readDurableCaptureRecord(captureKey); if (!enrolledDurable) throw new GitReversionFencedError('The enrolled capture record is missing.'); await this.publishFinalizedCaptureRecord({ schemaVersion: 1, kind: 'capture', ownerKey: this.ownerKey, captureKey, manifest, state, generationIds, revision: enrolledDurable.revision + 1, }, requestArg.projectId); finalized = true; return outcome; }); } finally { if (finalized) this.releaseOwnership(captureKey); } } finally { this.releaseCaptureLifecycleClaim(captureKey, 'finalization'); } }); } public inspectApply( requestArg: IGitReversionApplyRequest, ): Promise { return this.withPublicOperation(requestArg.signal, async (signal) => { this.assertApplyRequest(requestArg); const snapshot = await this.readFinalizedCaptureSnapshot( requestArg.identity, requestArg.reference, signal, ); if (!snapshot) return { status: 'unknown' }; const { manifest, state } = snapshot; if (state.outcome.disposition !== 'revertible') return { status: 'unknown' }; let operations: IApplyRecoveryOperation[]; try { operations = await this.inspectApplyOperations(manifest, signal); } catch { return { status: 'unknown' }; } if (operations.some((entry) => entry.fenced || entry.wal.phase === 'fenced')) { return { status: 'unknown' }; } const operationKey = sha256(requestArg.operationId); const operation = operations.find((entry) => entry.operationKey === operationKey); if (operation && ( operation.wal.operationId !== requestArg.operationId || operation.wal.direction !== requestArg.direction )) return { status: 'unknown' }; const wal = operation?.wal; if (wal?.decision === 'target' && ['decision', 'committed'].includes(wal.phase)) { return { status: 'applied' }; } if (wal?.decision === 'source' && wal.phase === 'compensated') { return { status: 'not-applied' }; } if (wal && ['intent', 'preflighted'].includes(wal.phase) && wal.touchedRepositoryIds.length === 0) { return { status: 'not-applied' }; } if (wal) return { status: 'unknown' }; if (operations.some((entry) => !['committed', 'compensated'].includes(entry.wal.phase))) { return { status: 'unknown' }; } const sides = sideNameForDirection(requestArg.direction); return state.currentSide === sides.target ? { status: 'applied' } : state.currentSide === sides.source ? { status: 'not-applied' } : { status: 'unknown' }; }); } public apply(requestArg: IGitReversionApplyRequest): Promise { return this.withPublicOperation(requestArg.signal, async (signal) => { await this.ensureRecovery(requestArg.identity.projectId, signal); this.assertApplyRequest(requestArg); const loaded = await this.loadFinalizedCapture( requestArg.identity, requestArg.reference, signal, ); const { manifest } = loaded; let state = loaded.state; if (state.outcome.disposition !== 'revertible') { throw new Error('The Git capture is not revertible.'); } if ((await this.listLatestApplyWals(manifest, signal)).some((entry) => ( entry.phase === 'fenced' ))) throw new GitReversionFencedError(); const sideNames = sideNameForDirection(requestArg.direction); if (state.currentSide === sideNames.target) return; if (state.currentSide !== sideNames.source) throw new GitReversionFencedError(); const commonIds = [...new Set( manifest.repositories.map((entry) => entry.repository.commonId), )].sort(); const operationKey = sha256(requestArg.operationId); const ownershipToken = `apply:${manifest.captureKey}:${operationKey}`; await this.acquireOwnership( commonIds, ownershipToken, manifest.identity.projectId, 'apply', signal, ); try { await this.withLocks(commonIds, async () => { await this.requireFinalizedCaptureLifecycle( manifest.captureKey, manifest.identity.projectId, ); state = await this.loadState(manifest, signal); if (state.currentSide === sideNames.target) return; if (state.currentSide !== sideNames.source) throw new GitReversionFencedError(); const prior = await this.loadLatestApplyWal( manifest, requestArg.operationId, signal, ); if (prior?.phase === 'fenced') throw new GitReversionFencedError(); if (prior && !['committed', 'compensated'].includes(prior.phase)) { await this.recoverWal(manifest, state, prior, signal); state = await this.loadState(manifest, signal); if (state.currentSide === sideNames.target) return; } const wal: IGitReversionApplyWal = { schemaVersion: 1, kind: 'apply-wal', captureKey: manifest.captureKey, manifestId: requestArg.reference.manifestId, operationId: requestArg.operationId, operationKey, direction: requestArg.direction, attempt: (prior?.attempt ?? 0) + 1, sourceSide: sideNames.source, targetSide: sideNames.target, sourceRevision: state.revision, phase: 'intent', touchedRepositoryIds: [], plans: [], generationIds: {}, }; let pendingGenerationIds: Record = {}; let generationsAcknowledged = false; let walPublicationStarted = false; try { const generated = await this.createApplyPlans( manifest, sideNames, operationKey, wal.attempt, signal, ); wal.plans = generated.plans; wal.generationIds = generated.generationIds; pendingGenerationIds = generated.generationIds; wal.phase = 'preflighted'; await this.enrollGenerationIds(manifest.captureKey, wal.generationIds); generationsAcknowledged = true; walPublicationStarted = true; await this.writeInitialWal(manifest, wal, prior, signal); await this.fault({ point: 'apply.after-intent', captureKey: manifest.captureKey, operationId: requestArg.operationId, }); await this.fault({ point: 'apply.after-preflight', captureKey: manifest.captureKey, operationId: requestArg.operationId, }); await this.verifyPlansUnchanged(wal.plans, signal); let predecessor = structuredClone(wal); for (const plan of wal.plans) { predecessor = structuredClone(wal); wal.phase = 'applying'; wal.touchedRepositoryIds.push(plan.repository.repositoryId); await this.updateWal(manifest, wal, predecessor, signal); await this.applyPlan(plan, signal); await this.fault({ point: 'apply.after-repository', captureKey: manifest.captureKey, operationId: requestArg.operationId, repositoryId: plan.repository.repositoryId, }); } await this.verifyTargetPlans(wal.plans, signal); predecessor = structuredClone(wal); wal.phase = 'targetVerified'; await this.updateWal(manifest, wal, predecessor, signal); await this.fault({ point: 'apply.after-target-verification', captureKey: manifest.captureKey, operationId: requestArg.operationId, }); predecessor = structuredClone(wal); wal.phase = 'decision'; wal.decision = 'target'; await this.updateWal(manifest, wal, predecessor, signal); await this.fault({ point: 'apply.after-decision', captureKey: manifest.captureKey, operationId: requestArg.operationId, }); state = { ...state, currentSide: sideNames.target, revision: state.revision + 1, }; await this.replaceState(manifest, state, { ...state, currentSide: sideNames.source, revision: state.revision - 1, }, 'normal', signal); await this.removeSourceKeeps(wal.plans, signal); predecessor = structuredClone(wal); wal.phase = 'committed'; await this.updateWal(manifest, wal, predecessor, signal); } catch (errorArg) { if (!generationsAcknowledged) { await this.deleteGenerationIds(manifest, pendingGenerationIds).catch(() => undefined); } if (!walPublicationStarted) throw errorArg; if (errorArg instanceof GitReversionSourceKeepOwnershipError) { await this.fenceWal(manifest, wal, signal).catch((fenceError) => { throw new GitReversionFencedError(undefined, { cause: fenceError instanceof Error ? fenceError : errorArg, }); }); throw errorArg; } await this.recoverWal(manifest, undefined, wal, signal).catch((recoveryError) => { throw new GitReversionFencedError(undefined, { cause: recoveryError instanceof Error ? recoveryError : errorArg, }); }); throw errorArg; } }); } finally { this.releaseOwnership(ownershipToken); } }); } public release(requestArg: IGitReversionReleaseRequest): Promise { return this.withPublicOperation(requestArg.signal, async (signal) => { assertIdentity(requestArg.identity); assertReference(requestArg.reference); await this.ensureNonSourceOperationReady(requestArg.identity.projectId, signal); if (requestArg.reference.captureKey !== this.captureKey(requestArg.identity)) { throw new Error('The Git reversion reference does not match its identity.'); } this.requireProject(requestArg.identity.projectId); const durable = await this.readDurableCaptureRecord(requestArg.reference.captureKey); if (!durable) { if (await this.readLifecycleRecord(requestArg.reference.captureKey)) { throw new GitReversionRecoveryRequiredError( 'The Git capture deletion is incomplete.', ); } return; } const manifest = durable.manifest; this.assertManifestIdentity(manifest, requestArg.identity); await this.deleteFinalizedCapture(durable, requestArg.reference.manifestId, signal); }); } private async deleteFinalizedCapture( durableArg: IDurableCaptureRecord, manifestIdArg: string, signalArg: AbortSignal, ): Promise { const manifest = durableArg.manifest; if (manifest.phase !== 'finalized') throw new Error('The Git capture is still active.'); if (sha256(canonicalJson(manifest)) !== manifestIdArg) { throw new Error('The Git reversion manifest ID does not match.'); } const commonIds = [...new Set( manifest.repositories.map((entry) => entry.repository.commonId), )].sort(); if (this.claimCaptureLifecycle(manifest.captureKey, 'deletion')) { throw new GitReversionRecoveryRequiredError( 'The Git capture already has an active lifecycle operation.', ); } const ownershipToken = `release:${manifest.captureKey}:${manifestIdArg}`; try { await this.acquireOwnership( commonIds, ownershipToken, manifest.identity.projectId, 'capture', signalArg, ); try { await this.withLocks(commonIds, async () => { const current = await this.readDurableCaptureRecord(manifest.captureKey); if (!current) { if (!await this.readLifecycleRecord(manifest.captureKey)) return; throw new GitReversionRecoveryRequiredError( 'The Git capture deletion is incomplete.', ); } const currentManifest = current.manifest; this.assertManifestIdentity(currentManifest, manifest.identity); if ( currentManifest.phase !== 'finalized' || sha256(canonicalJson(currentManifest)) !== manifestIdArg ) throw new GitReversionFencedError( 'The finalized Git capture changed before release.', ); const lifecycle = await this.requireFinalizedCaptureLifecycle( currentManifest.captureKey, currentManifest.identity.projectId, ); const wals = await this.listLatestApplyWals(currentManifest, signalArg); if (wals.some((wal) => !['committed', 'compensated'].includes(wal.phase))) { throw new Error('The Git capture has an unresolved or fenced apply.'); } await this.deleteCaptureRootWithLifecycle(lifecycle, 'deletion'); }); } finally { this.releaseOwnership(ownershipToken); } } finally { this.releaseCaptureLifecycleClaim(manifest.captureKey, 'deletion'); } } public releaseProjectCaptures( projectIdArg: string, signalArg?: AbortSignal, ): Promise { return this.withPublicOperation(signalArg, async (signal) => { await this.ensureNonSourceOperationReady(projectIdArg, signal, undefined, true); this.requireProject(projectIdArg); const captures = (await this.listDurableCaptureRecords()) .filter((record) => record.manifest.identity.projectId === projectIdArg) .sort((left, right) => ( Number(left.manifest.phase === 'finalized') - Number(right.manifest.phase === 'finalized') || left.captureKey.localeCompare(right.captureKey) )); for (const capture of captures) { const manifest = capture.manifest; this.assertManifest(manifest); if (manifest.phase === 'prepared') { const lifecycle = (await this.listLifecycleRecords()).find((entry) => ( entry.captureKey === manifest.captureKey )) ?? this.lifecycleRecord(manifest.captureKey, projectIdArg, 'active'); await this.deleteCaptureRootWithLifecycle(lifecycle); this.releaseOwnership(manifest.captureKey); continue; } const state = capture.state; if (!state) throw new Error('The finalized Git capture has no state.'); await this.deleteFinalizedCapture(capture, state.manifestId, signal); } const remaining = (await this.listDurableCaptureRecords()).some((record) => ( record.manifest.identity.projectId === projectIdArg )); const remainingLifecycle = (await this.listLifecycleRecords()).some((record) => ( record.projectId === projectIdArg )); if (remaining || remainingLifecycle) { throw new Error('The project Git capture cleanup is incomplete.'); } }); } public cleanupProjectWorktrees( projectIdArg: string, signalArg?: AbortSignal, ): Promise { return this.withPublicOperation(signalArg, async (signal) => { await this.ensureRecovery(projectIdArg, signal); const project = this.requireProject(projectIdArg); // Retiring a project tears down what the engine has records for. The remembered catalog is // the exact repository set every owned worktree record was created against, and // removeWorktreeRecord re-verifies each record's live source binding before it touches the // repository, so cleanup never has to traverse the project tree. Without this, a project // directory larger than the discovery limit could never finish its removal. const catalog = await this.readProjectRepositoryCatalog(project); const repositories = catalog?.repositories ?? []; const records = await this.listWorktreeRecords(project, repositories, signal); for (const copy of records) { signal.throwIfAborted(); await this.withLocks([copy.repository.commonId], async () => { if (this.ownershipByCommonId.has(copy.repository.commonId)) { throw new Error('The Git worktree participates in an active capture or apply.'); } await this.removeWorktreeRecord(copy, signal); }); } const remaining = await this.listWorktreeRecords(project, repositories, signal); if (remaining.length > 0) throw new Error('Owned Git worktree cleanup is incomplete.'); this.pendingWorktreeRemovalProjectIds.delete(projectIdArg); if ((await this.listDurableCaptureRecords()).some((record) => ( record.manifest.identity.projectId === projectIdArg ))) { throw new Error('Project Git captures must be released before repository ownership is forgotten.'); } await this.deleteProjectRepositoryCatalog(project, signal); }); } public listAffected( requestArg: IGitReversionMetadataRequest, ): Promise> { return this.withPublicOperation(requestArg.signal, async (signal) => { const snapshot = await this.readFinalizedCaptureSnapshot( requestArg.identity, requestArg.reference, signal, ); if (!snapshot) throw new Error('The Git reversion capture is missing.'); return snapshot.manifest.affectedWorkspaces.map((entry) => ({ ...entry })); }); } public getReversionMetadata( requestArg: IGitReversionMetadataRequest, ): Promise { return this.withPublicOperation(requestArg.signal, async (signal) => { const snapshot = await this.readFinalizedCaptureSnapshot( requestArg.identity, requestArg.reference, signal, ); if (!snapshot) throw new Error('The Git reversion capture is missing.'); const { manifest } = snapshot; let { state } = snapshot; let operations: IApplyRecoveryOperation[]; try { operations = await this.inspectApplyOperations(manifest, signal); } catch (errorArg) { throw new GitReversionRecoveryRequiredError( 'Git reversion metadata is unavailable until recovery completes.', { cause: errorArg instanceof Error ? errorArg : undefined }, ); } for (const operation of operations.sort((left, right) => ( left.wal.sourceRevision - right.wal.sourceRevision ))) { const wal = operation.wal; if (operation.fenced || ['applying', 'targetVerified', 'compensating', 'fenced'].includes(wal.phase)) { throw new GitReversionRecoveryRequiredError(); } if (wal.decision === 'target' && ['decision', 'committed'].includes(wal.phase)) { state = { ...state, currentSide: wal.targetSide, revision: wal.sourceRevision + 1 }; } } return { reference: requestArg.reference, disposition: state.outcome.disposition, currentSide: state.currentSide, revision: state.revision, affectedWorkspaces: manifest.affectedWorkspaces.map((entry) => ({ ...entry })), ...(manifest.reasonCode ? { reasonCode: manifest.reasonCode } : {}), }; }); } public createDetachedSessionWorktree( requestArg: IGitReversionWorktreeRequest, ): Promise { return this.withPublicOperation(requestArg.signal, async (signal) => { await this.ensureRecovery(requestArg.projectId, signal); assertIdentifier(requestArg.projectId, 'The Git worktree project ID'); assertIdentifier(requestArg.sessionId, 'The Git worktree session ID'); const project = this.requireProject(requestArg.projectId); const repositories = await this.discoverProjectRepositories(project, signal); const source = this.selectContainingRepository(project, repositories); if (!source) throw new Error('The project is not in a supported Git repository.'); return this.withLocks([source.commonId], async () => { const ownership = this.ownershipByCommonId.get(source.commonId); if (ownership?.kind === 'apply') { throw new Error('A detached Git worktree cannot be created during reversion apply.'); } const { unsupportedReason } = await this.checkPathSupport(source, signal); if (unsupportedReason) { throw new Error(`The Git repository cannot create a safe worktree: ${unsupportedReason}`); } const worktreeId = `worktree-${plugins.crypto.randomBytes(16).toString('base64url')}`; const path = plugins.path.join(this.worktreeRoot(), worktreeId); const parentResolution = await resolveCanonicalFilesystemObject( this.worktreeRoot(), 'directory', ); const createdAt = new Date().toISOString(); const record: IGitReversionWorktreeRecord = { schemaVersion: 1, kind: 'worktree', ownerKey: this.ownerKey, worktreeId, projectId: requestArg.projectId, sessionId: requestArg.sessionId, path, parentPath: this.worktreeRoot(), parentIdentity: { ancestry: parentResolution.ancestry }, sourceRoot: source.root, sourceCommonDir: source.commonDir, sourceRepository: structuredClone(source), repositoryId: source.repositoryId, createdAt, state: 'intent', }; const ref = this.worktreeRef(worktreeId); await this.withMetadataMutation(async () => { if ((await this.listWorktreeRecordRefs()).length >= this.limits.maximumWorktrees) { throw new Error('The controller-wide owned Git worktree limit was reached.'); } await this.writeWorktreeRecord(ref, record, undefined); }); await this.fault({ point: 'worktree.after-intent', worktreeId }); try { await this.assertWorktreeParentBinding(record); await this.assertRepositoryBinding(source); await this.git(source, [ 'worktree', 'add', '--detach', '--lock', '--reason', this.worktreeLockReason(worktreeId), path, 'HEAD', ], signal, [0], undefined, undefined, this.limits.worktreeCommandTimeoutMs); await this.fault({ point: 'worktree.after-create', worktreeId }); await this.assertWorktreeParentBinding(record); const pathResolution = await resolveCanonicalFilesystemObject(record.path, 'directory'); record.pathIdentity = { ancestry: pathResolution.ancestry }; record.state = 'created'; await this.replaceRecord(source, ref, record, signal); const registration = await this.requireOwnedWorktreeRegistration( source, record, signal, ); if (!registration.detached) throw new Error('The created Git worktree is not detached.'); record.state = 'registered'; await this.replaceRecord(source, ref, record, signal); if (ownership?.kind === 'capture') { await this.addWorktreeCaptureParticipant( project, source, record, ownership.token, signal, ); } record.state = 'active'; await this.replaceRecord(source, ref, record, signal); return this.worktreeDescriptor(record); } catch (errorArg) { // The durable lifecycle record is intentionally retained for recover(). this.invalidateRecovery(); throw errorArg; } }); }); } public listDetachedSessionWorktrees( requestArg: IGitReversionWorktreeRequest, ): Promise { return this.withPublicOperation(requestArg.signal, async (signal) => { assertIdentifier(requestArg.projectId, 'The Git worktree project ID'); assertIdentifier(requestArg.sessionId, 'The Git worktree session ID'); const project = this.requireProject(requestArg.projectId); signal.throwIfAborted(); const catalog = await this.readProjectRepositoryCatalog(project); if (!catalog) return []; const records = await this.listWorktreeRecords(project, catalog.repositories, signal); return records .map((entry) => entry.value) .filter((record) => record.sessionId === requestArg.sessionId) .filter((record) => ['active', 'retainedDirty', 'fenced'].includes(record.state)) .map((record) => this.worktreeDescriptor(record)) .sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.worktreeId.localeCompare(right.worktreeId)); }); } public removeDetachedSessionWorktree( requestArg: IGitReversionWorktreeRemoveRequest, ): Promise { return this.withPublicOperation(requestArg.signal, async (signal) => { await this.ensureRecovery(requestArg.projectId, signal); assertIdentifier(requestArg.projectId, 'The Git worktree project ID'); assertIdentifier(requestArg.sessionId, 'The Git worktree session ID'); assertIdentifier(requestArg.worktreeId, 'The Git worktree ID'); const project = this.requireProject(requestArg.projectId); const repositories = await this.discoverProjectRepositories(project, signal); const records = await this.listWorktreeRecords(project, repositories, signal); const found = records.find((entry) => entry.value.worktreeId === requestArg.worktreeId); if ( !found || found.value.projectId !== requestArg.projectId || found.value.sessionId !== requestArg.sessionId ) throw new Error('The owned detached Git worktree was not found.'); await this.withLocks([found.repository.commonId], async () => { if (this.ownershipByCommonId.has(found.repository.commonId)) { throw new Error('The Git worktree participates in an active capture or apply.'); } await this.removeWorktreeRecord(found, signal); }); }); } public compensateDetachedSessionWorktreeCreation( requestArg: IGitReversionWorktreeRemoveRequest, ): Promise { return this.withPublicOperation(undefined, async (signal) => { await this.ensureRecovery(requestArg.projectId, signal); assertIdentifier(requestArg.projectId, 'The Git worktree project ID'); assertIdentifier(requestArg.sessionId, 'The Git worktree session ID'); assertIdentifier(requestArg.worktreeId, 'The Git worktree ID'); const project = this.requireProject(requestArg.projectId); const repositories = await this.discoverProjectRepositories(project, signal); const records = await this.listWorktreeRecords(project, repositories, signal); const found = records.find((entry) => entry.value.worktreeId === requestArg.worktreeId); if (!found) return; if ( found.value.projectId !== requestArg.projectId || found.value.sessionId !== requestArg.sessionId ) throw new Error('The owned detached Git worktree identity does not match.'); await this.withLocks([found.repository.commonId], async () => { const loaded = await this.readWorktreeRecordByRef(found.ref); if (!loaded) return; let current: IRecordCopy = { ...found, oid: loaded.oid, value: loaded.value, }; if ( current.value.projectId !== requestArg.projectId || current.value.sessionId !== requestArg.sessionId || current.value.worktreeId !== requestArg.worktreeId ) throw new Error('The owned detached Git worktree identity does not match.'); if (current.value.state === 'removed') return; if (current.value.state === 'retainedDirty') throw new GitReversionDirtyWorktreeError(); if (current.value.state === 'fenced') { throw new GitReversionFencedError('The owned Git worktree is fenced and retained.'); } if (current.value.state !== 'active' && current.value.state !== 'removing') { throw new GitReversionRecoveryRequiredError( 'The owned Git worktree must recover before cancellation cleanup.', ); } if (current.value.state === 'active') { const removing = { ...current.value, state: 'removing' as const }; const oid = await this.replaceRecord( current.repository, current.ref, removing, signal, ); current = { ...current, oid, value: removing }; } if (this.ownershipByCommonId.has(current.repository.commonId)) { this.queueWorktreeRemovalRecovery(requestArg.projectId); return; } try { await this.removeWorktreeRecord(current, signal); } catch (errorArg) { if (await this.hasRemovingWorktreeRecord(requestArg.projectId)) { this.queueWorktreeRemovalRecovery(requestArg.projectId); } throw errorArg; } }); }); } private withPublicOperation( signalArg: AbortSignal | undefined, operationArg: (signalArg: AbortSignal) => Promise, ): Promise { this.assertUsable(); const signal = signalArg ? AbortSignal.any([signalArg, this.lifecycleAbortController.signal]) : this.lifecycleAbortController.signal; this.activeOperations += 1; const operation = (async () => { signal.throwIfAborted(); await this.retryPendingMaintenanceCleanups(); signal.throwIfAborted(); this.assertUsable(); const result = await operationArg(signal); await this.retryPendingMaintenanceCleanups(); return result; })(); void operation.finally(() => { this.activeOperations -= 1; if (this.activeOperations === 0) { for (const resolve of this.operationDrainWaiters) resolve(); this.operationDrainWaiters.clear(); } }).catch(() => undefined); return operation; } private waitForOperationsToDrain(): Promise { if (this.activeOperations === 0) return Promise.resolve(); return new Promise((resolve) => this.operationDrainWaiters.add(resolve)); } private projectRecoveryKey(projectArg: IRegisteredProject): string { return sha256(canonicalJson({ directory: projectArg.directory, identity: projectArg.identity, })); } private invalidateRecovery(): void { this.recoverySucceeded = false; this.recoveryPromise = undefined; this.recoveredProjects.clear(); this.metadataQuotaLoaded = false; this.privateRefCharges.clear(); this.privateRefAggregateBytes = 0; } private sealForGitProcessCleanup(errorArg: Error): void { if (this.closed || this.sealed) return; const error = new Error('Git process cleanup is incomplete; close is required.', { cause: errorArg, }); this.sealError = error; this.sealed = true; this.lifecycleAbortController.abort(error); for (const waiter of [...this.ownershipWaiters]) waiter.reject(error); this.ownershipWaiters.clear(); this.ownershipWaitersByCommonId.clear(); for (const waiter of this.gitCommandWaiters.splice(0)) waiter.reject(error); if (this.onSealedForCleanup) { try { this.onSealedForCleanup(error); } catch { // Owner notification failures must not change the sealed state. } } } private retryPendingMaintenanceCleanups(allowSealedArg = false): Promise { if (this.maintenanceCleanupPromise) return this.maintenanceCleanupPromise; if ( this.pendingGenerationCleanups.size === 0 && this.pendingTemporaryIndexCleanups.size === 0 ) return Promise.resolve(); if (allowSealedArg) this.assertMaintenanceUsable(); else this.assertUsable(); const cleanup = (async () => { while ( this.pendingGenerationCleanups.size > 0 || this.pendingTemporaryIndexCleanups.size > 0 ) { for (const generation of [...this.pendingGenerationCleanups.values()]) { await this.performGenerationCleanup(generation); } if (this.pendingTemporaryIndexCleanups.size > 0) { await this.removeTemporaryIndexPaths([...this.pendingTemporaryIndexCleanups]); } } })(); this.maintenanceCleanupPromise = cleanup; void cleanup.finally(() => { if (this.maintenanceCleanupPromise === cleanup) this.maintenanceCleanupPromise = undefined; }).catch(() => undefined); return cleanup; } private async hasRemovingWorktreeRecord(projectIdArg: string): Promise { for (const ref of await this.listWorktreeRecordRefs()) { const loaded = await this.readWorktreeRecordByRef(ref); if (!loaded) continue; this.assertWorktreeRecord(loaded.value); if (loaded.value.projectId === projectIdArg && loaded.value.state === 'removing') { return true; } } return false; } private queueWorktreeRemovalRecovery(projectIdArg: string): void { this.pendingWorktreeRemovalProjectIds.add(projectIdArg); this.scheduleWorktreeRemovalRecovery(); } private scheduleWorktreeRemovalRecovery(delayMsArg = 0): void { if ( this.sealed || this.closed || this.worktreeRemovalRecoveryTimer || this.worktreeRemovalRecoveryTask ) return; this.worktreeRemovalRecoveryTimer = setTimeout(() => { this.worktreeRemovalRecoveryTimer = undefined; void this.retryPendingWorktreeRemovalRecoveries(); }, delayMsArg); this.worktreeRemovalRecoveryTimer.unref(); } private retryPendingWorktreeRemovalRecoveries(): Promise { if (this.worktreeRemovalRecoveryTask) return this.worktreeRemovalRecoveryTask; let task!: Promise; task = (async () => { for (const projectId of [...this.pendingWorktreeRemovalProjectIds]) { if ([...this.ownershipByCommonId.values()].some((entry) => ( entry.projectId === projectId ))) continue; if (!this.projects.has(projectId)) { this.pendingWorktreeRemovalProjectIds.delete(projectId); continue; } this.pendingWorktreeRemovalProjectIds.delete(projectId); this.activeWorktreeRemovalRecoveryProjectIds.add(projectId); this.recoveredProjects.delete(projectId); try { await this.withPublicOperation(undefined, (signal) => ( this.ensureRecovery(projectId, signal) )); if (await this.hasRemovingWorktreeRecord(projectId)) { this.pendingWorktreeRemovalProjectIds.add(projectId); } } catch { if (await this.hasRemovingWorktreeRecord(projectId).catch(() => true)) { this.pendingWorktreeRemovalProjectIds.add(projectId); } } finally { this.activeWorktreeRemovalRecoveryProjectIds.delete(projectId); } } })(); this.worktreeRemovalRecoveryTask = task; void task.finally(() => { if (this.worktreeRemovalRecoveryTask === task) { this.worktreeRemovalRecoveryTask = undefined; } const retryable = [...this.pendingWorktreeRemovalProjectIds].some((projectId) => ( this.projects.has(projectId) && ![...this.ownershipByCommonId.values()].some((entry) => ( entry.projectId === projectId )) )); if (retryable) this.scheduleWorktreeRemovalRecovery(worktreeRemovalRecoveryRetryMs); }).catch(() => undefined); return task; } private async releaseStrandedMetadataReservations(): Promise { this.assertMaintenanceUsable(); const reservationIds = new Set( [...this.metadataWriteReservations.values()].map((reservation) => ( reservation.storeReservationId )), ); for (const reservationId of this.storeReservations.keys()) { if (reservationId.startsWith('metadata:')) reservationIds.add(reservationId); } this.metadataWriteReservations.clear(); if (reservationIds.size === 0) return; await this.withCaptureMutation(async () => { for (const reservationId of reservationIds) this.storeReservations.delete(reservationId); }, true); } private async ensureInitialized(signalArg: AbortSignal): Promise { if (this.initialized) return; if (!this.initializationPromise) { const initialization = (async () => { const version = trimLine((await this.runGit(['--version'], { signal: signalArg })).stdout); const match = /^git version (\d+)\.(\d+)(?:\.(\d+))?/u.exec(version); if (!match || Number(match[1]) < 2 || ( Number(match[1]) === 2 && Number(match[2]) < 43 )) throw new Error('Git 2.43 or newer is required for workspace reversion.'); await this.ensurePrivateDirectory(this.rootDirectory); await this.ensurePrivateDirectory(this.indexRoot()); await this.ensurePrivateDirectory(this.worktreeRoot()); await this.ensurePrivateDirectory(this.worktreeRecordRoot()); await this.ensurePrivateDirectory(this.projectCatalogRoot()); await this.ensurePrivateDirectory(this.captureRecordRoot()); await this.ensurePrivateDirectory(this.lifecycleRoot()); await this.ensurePrivateDirectory(this.privateIntentRoot()); await this.cleanupCaptureRecordTemporaryFiles(); await this.cleanupLifecycleTemporaryFiles(); await this.cleanupPrivateIntentTemporaryFiles(); await this.cleanupProjectCatalogTemporaryFiles(); await this.cleanupWorktreeRecordTemporaryFiles(); await this.cleanupOwnedTemporaryIndexDirectory(); await this.cleanupUnenrolledGenerationResidue(); await this.loadCaptureRecordQuota(); await this.loadLifecycleQuota(); await this.listPrivateIntents(); signalArg.throwIfAborted(); if (this.sealed || this.closed) { throw new Error('The Git reversion engine closed during initialization.'); } this.initialized = true; })(); this.initializationPromise = initialization; void initialization.catch(() => { if (this.initializationPromise === initialization) this.initializationPromise = undefined; }); } await this.initializationPromise; } private async cleanupUnenrolledGenerationResidue(): Promise { const captures = await this.privateDirectoryEntries(this.captureRecordRoot()); for (const capture of captures) { if (!/^[0-9a-f]{64}$/u.test(capture.name) || !capture.isDirectory() || capture.isSymbolicLink()) { throw new GitReversionFencedError('A capture root is invalid during generation cleanup.'); } const record = await this.readDurableCaptureRecord(capture.name); const storesRoot = this.captureStoresRoot(capture.name); let stores: plugins.fs.Dirent[]; try { stores = await this.privateDirectoryEntries(storesRoot); } catch (errorArg) { if ((errorArg as NodeJS.ErrnoException).code === 'ENOENT') continue; throw errorArg; } for (const store of stores) { if (!/^git-[0-9a-f]{32}$/u.test(store.name) || !store.isDirectory() || store.isSymbolicLink()) { throw new GitReversionFencedError('A private Git store is invalid during generation cleanup.'); } const root = this.generationRoot(capture.name, store.name); let generations: plugins.fs.Dirent[]; try { generations = await this.privateDirectoryEntries(root); } catch (errorArg) { if ((errorArg as NodeJS.ErrnoException).code === 'ENOENT') continue; throw errorArg; } const enrolled = new Set(record?.generationIds[store.name] ?? []); let removed = false; for (const generation of generations) { if ( !/^[0-9a-f]{64}$/u.test(generation.name) || !generation.isDirectory() || generation.isSymbolicLink() ) throw new GitReversionFencedError('An object generation is invalid during cleanup.'); if (enrolled.has(generation.name)) continue; const path = this.generationDirectory(capture.name, store.name, generation.name); await this.hardenPrivateTree(path); await this.removePrivateTree(path); removed = true; } if (removed) await this.syncDirectory(root); } } } private async ensureNonSourceOperationReady( projectIdArg: string, signalArg: AbortSignal, allowedRemovingCaptureKeyArg?: string, allowPendingWorktreeRecoveryArg = false, ): Promise { await this.ensureInitialized(signalArg); await this.recoverCaptureLifecycles(signalArg); await this.rebuildPrivateRefQuota(signalArg); if (await this.recoverPrivateIntents(projectIdArg, signalArg)) { await this.rebuildPrivateRefQuota(signalArg); } await this.assertNoPendingSourceRecovery( projectIdArg, signalArg, allowedRemovingCaptureKeyArg, allowPendingWorktreeRecoveryArg, ); } private async assertNoPendingSourceRecovery( projectIdArg: string, signalArg?: AbortSignal, allowedRemovingCaptureKeyArg?: string, allowPendingWorktreeRecoveryArg = false, ): Promise { if (this.projectRecoveryPromises.has(projectIdArg)) { throw new GitReversionRecoveryRequiredError(); } const captures = (await this.listDurableCaptureRecords()).filter((record) => ( record.manifest.identity.projectId === projectIdArg && record.manifest.phase === 'finalized' )); for (const capture of captures) { signalArg?.throwIfAborted(); let operations: IApplyRecoveryOperation[]; try { operations = await this.inspectApplyOperations(capture.manifest, signalArg); } catch (errorArg) { throw new GitReversionRecoveryRequiredError(undefined, { cause: errorArg instanceof Error ? errorArg : undefined, }); } if (operations.some((entry) => ( entry.fenced || !['committed', 'compensated'].includes(entry.wal.phase) ))) throw new GitReversionRecoveryRequiredError(); } for (const ref of await this.listWorktreeRecordRefs()) { const loaded = await this.readWorktreeRecordByRef(ref); if (!loaded) continue; this.assertWorktreeRecord(loaded.value); const ownership = this.ownershipByCommonId.get(loaded.value.sourceRepository.commonId); const removingOwnedByCapture = loaded.value.state === 'removing' && allowedRemovingCaptureKeyArg !== undefined && ownership?.kind === 'capture' && ownership.projectId === projectIdArg && ownership.token === allowedRemovingCaptureKeyArg; if ( loaded.value.projectId === projectIdArg && !allowPendingWorktreeRecoveryArg && !removingOwnedByCapture && !['active', 'retainedDirty'].includes(loaded.value.state) ) throw new GitReversionRecoveryRequiredError(); } } private async readCaptureSnapshot( identityArg: IGitReversionIdentity, captureKeyArg: string, signalArg?: AbortSignal, ): Promise { signalArg?.throwIfAborted(); const project = this.requireProject(identityArg.projectId); await this.assertPrivateCaptureTree(captureKeyArg); const record = await this.readDurableCaptureRecord(captureKeyArg); if (!record) return undefined; this.assertManifestIdentity(record.manifest, identityArg); if ( record.manifest.projectDirectory !== project.directory || !filesystemIdentitiesEqual(record.manifest.projectIdentity, project.identity) ) throw new GitReversionFencedError('The Git capture project binding diverged.'); await this.assertProjectDirectory(project); for (const entry of record.manifest.repositories) { signalArg?.throwIfAborted(); await this.assertRepositoryBinding(entry.repository); } if (record.state) this.assertCaptureState(record.state, record.manifest); return record; } private async readFinalizedCaptureSnapshot( identityArg: IGitReversionIdentity, referenceArg: TGitReversionReference, signalArg?: AbortSignal, ): Promise<{ manifest: IGitReversionManifest; state: IGitReversionCaptureState } | undefined> { assertIdentity(identityArg); assertReference(referenceArg); if (referenceArg.captureKey !== this.captureKey(identityArg)) { throw new Error('The Git reversion reference does not match its identity.'); } const record = await this.readCaptureSnapshot(identityArg, referenceArg.captureKey, signalArg); if (!record) return undefined; if (record.manifest.phase !== 'finalized' || !record.state) { throw new Error('The Git reversion capture is not finalized.'); } if (sha256(canonicalJson(record.manifest)) !== referenceArg.manifestId) { throw new Error('The Git reversion manifest ID does not match.'); } return { manifest: record.manifest, state: record.state }; } private async ensureRecovery( projectIdArg: string | undefined, signalArg: AbortSignal, ): Promise { await this.ensureInitialized(signalArg); if (!this.recoverySucceeded) { if (!this.recoveryPromise) { const recovery = (async () => { await this.recoverCaptureLifecycles(signalArg); await this.rebuildPrivateRefQuota(signalArg); if (await this.recoverPrivateIntents(undefined, signalArg)) { await this.rebuildPrivateRefQuota(signalArg); } let recoveredCount = 0; while (true) { const next = [...this.projects.values()] .sort((left, right) => left.projectId.localeCompare(right.projectId)) .find((project) => ( this.recoveredProjects.get(project.projectId) !== this.projectRecoveryKey(project) )); if (!next) break; recoveredCount += 1; if (recoveredCount > this.limits.maximumProjects) { throw new Error('The Git reversion project recovery limit was exceeded.'); } await this.recoverRegisteredProject(next, signalArg); } await this.cleanupPrivateIntentTemporaryFiles(); await this.cleanupProjectCatalogTemporaryFiles(); await this.cleanupWorktreeRecordTemporaryFiles(); await this.cleanupOwnedTemporaryIndexDirectory(); signalArg.throwIfAborted(); if (this.sealed || this.closed) { throw new Error('The Git reversion engine closed during recovery.'); } this.recoverySucceeded = true; })(); this.recoveryPromise = recovery; void recovery.catch(() => { this.recoverySucceeded = false; this.recoveredProjects.clear(); if (this.recoveryPromise === recovery) this.recoveryPromise = undefined; }); } await this.recoveryPromise; } if (projectIdArg) { const project = this.requireProject(projectIdArg); if (this.recoveredProjects.get(projectIdArg) !== this.projectRecoveryKey(project)) { let recovery = this.projectRecoveryPromises.get(projectIdArg); if (!recovery) { recovery = this.recoverRegisteredProject(project, signalArg); this.projectRecoveryPromises.set(projectIdArg, recovery); void recovery.finally(() => { if (this.projectRecoveryPromises.get(projectIdArg) === recovery) { this.projectRecoveryPromises.delete(projectIdArg); } }).catch(() => undefined); } await recovery; } } else if (this.projectRecoveryPromises.size > 0) { await Promise.all([...this.projectRecoveryPromises.values()]); } } private async recoverRegisteredProject( projectArg: IRegisteredProject, signalArg: AbortSignal, ): Promise { const registered = this.projects.get(projectArg.projectId); if (!registered || this.projectRecoveryKey(registered) !== this.projectRecoveryKey(projectArg)) { throw new Error('The Git reversion project changed during recovery.'); } const recoveryKey = this.projectRecoveryKey(projectArg); if (this.recoveredProjects.get(projectArg.projectId) === recoveryKey) return; signalArg.throwIfAborted(); const durableState = await this.inspectProjectDurableRecoveryState(projectArg); this.assertProjectCaptureLifecycleState(durableState); const catalogOnly = durableState.captures.length === 0 && durableState.lifecycleRecords.length === 0 && durableState.privateIntents.length === 0 && durableState.worktreeRecords.length === 0; const barrierOnly = durableState.captures.length > 0 && durableState.captures.every((capture) => ( capture.manifest.reasonCode === discoveryLimitReasonCode )) && durableState.catalog === undefined && durableState.privateIntents.length === 0 && durableState.worktreeRecords.length === 0; if (catalogOnly || barrierOnly) { await this.assertProjectDirectory(projectArg); // A discovery catalog alone has no captures or mutations to recover. // Validate its remembered bindings and owned sidecars without traversing // unrelated project directories. Source operations still discover afresh. for (const repository of durableState.catalog?.repositories ?? []) { signalArg.throwIfAborted(); await this.cleanupRepositoryTemporaryIndexes(repository); } for (const capture of durableState.captures) { await this.assertDiscoveryLimitCapture(projectArg, capture); } await this.assertProjectDirectory(projectArg); signalArg.throwIfAborted(); if (this.sealed || this.closed) throw new Error('The Git reversion engine closed during recovery.'); this.recoveredProjects.set(projectArg.projectId, recoveryKey); return; } const repositories = await this.discoverProjectRepositories(projectArg, signalArg); for (const repository of repositories) await this.cleanupRepositoryTemporaryIndexes(repository); await this.rebuildPrivateRefQuota(signalArg); if (await this.recoverPrivateIntents(projectArg.projectId, signalArg)) { await this.rebuildPrivateRefQuota(signalArg); } await this.recoverFinalizations(projectArg, repositories, undefined, signalArg); const captures = (await this.listDurableCaptureRecords()).filter((record) => ( record.manifest.identity.projectId === projectArg.projectId )); for (const capture of captures) { const manifest = capture.manifest; this.assertManifest(manifest); const commonIds = [...new Set( manifest.repositories.map((entry) => entry.repository.commonId), )].sort(); if (manifest.phase === 'prepared') { await this.acquireOwnership( commonIds, manifest.captureKey, manifest.identity.projectId, 'capture', signalArg, ); continue; } await this.assertFreshRepositoryBindings(projectArg, manifest, repositories, signalArg); await this.recoverPendingApplyWals(manifest, signalArg); await this.loadState(manifest, signalArg); } for (const capture of captures) { const current = await this.readDurableCaptureRecord(capture.captureKey); if (!current) continue; await this.scavengeUnenrolledGenerations(current); for (const entry of current.manifest.repositories) { await this.prunePrivateMetadataStore( entry.repository, current.captureKey, signalArg, ); } } await this.recoverWorktrees(projectArg, repositories, signalArg); for (const record of captures) { if (record.manifest.identity.projectId !== projectArg.projectId) continue; if ( record.manifest.projectDirectory !== await this.assertProjectDirectory(projectArg) || !filesystemIdentitiesEqual(record.manifest.projectIdentity, projectArg.identity) ) { throw new GitReversionFencedError( 'A repository-free capture project identity diverged.', ); } if (record.state) this.assertCaptureState(record.state, record.manifest); } signalArg.throwIfAborted(); if (this.sealed || this.closed) throw new Error('The Git reversion engine closed during recovery.'); this.recoveredProjects.set(projectArg.projectId, recoveryKey); } private async inspectProjectDurableRecoveryState( projectArg: IRegisteredProject, ): Promise { const captures = (await this.listDurableCaptureRecords()).filter((record) => ( record.manifest.identity.projectId === projectArg.projectId )); const lifecycleRecords = (await this.listLifecycleRecords()).filter((record) => ( record.projectId === projectArg.projectId )); const catalog = await this.readProjectRepositoryCatalog(projectArg); const privateIntents = (await this.listPrivateIntents()).filter((intent) => ( intent.projectId === projectArg.projectId )); const worktreeRecords: IGitReversionWorktreeRecord[] = []; for (const ref of await this.listWorktreeRecordRefs()) { const loaded = await this.readWorktreeRecordByRef(ref); if (!loaded) continue; this.assertWorktreeRecord(loaded.value); if (loaded.value.projectId === projectArg.projectId) worktreeRecords.push(loaded.value); } return { captures, lifecycleRecords, ...(catalog ? { catalog } : {}), privateIntents, worktreeRecords, }; } private assertProjectCaptureLifecycleState(stateArg: IProjectDurableRecoveryState): void { const captures = new Map(stateArg.captures.map((capture) => [capture.captureKey, capture])); const lifecycleRecords = new Map(stateArg.lifecycleRecords.map((record) => [ record.captureKey, record, ])); if ( captures.size !== stateArg.captures.length || lifecycleRecords.size !== stateArg.lifecycleRecords.length ) throw new GitReversionFencedError('The project Git capture lifecycle is duplicated.'); for (const capture of stateArg.captures) { const lifecycle = lifecycleRecords.get(capture.captureKey); const expectedPhase = capture.manifest.phase === 'prepared' ? 'active' : 'finalized'; if ( !lifecycle || lifecycle.phase !== expectedPhase || lifecycle.publication !== null ) throw new GitReversionFencedError( 'The project Git capture lifecycle does not match its durable capture.', ); } for (const lifecycle of stateArg.lifecycleRecords) { if (!captures.has(lifecycle.captureKey)) { throw new GitReversionFencedError( 'A project Git capture lifecycle has no durable capture.', ); } } } private validateLimits(): void { const integerLimits: Array<[keyof IGitReversionLimits, number, number]> = [ ['maximumProjects', 1, 100_000], ['maximumRepositories', 1, 64], ['maximumScannedDirectories', 1, 100_000], ['maximumScanDepth', 1, 128], ['maximumPaths', 1, 1_000_000], ['maximumPathBytes', 1, pathMaximumBytesHard], ['maximumMetadataBytes', 1, metadataMaximumBytesHard], ['maximumPrivateRefs', 1, 1_000_000], ['maximumPrivateMetadataAggregateBytes', 1, privateMetadataAggregateMaximumBytesHard], ['maximumIntentFiles', 1, 100_000], ['maximumIntentEntries', 1, 1_000_000], ['maximumIntentFileBytes', 1, intentFileMaximumBytesHard], ['maximumIntentAggregateBytes', 1, intentAggregateMaximumBytesHard], ['maximumScavengeEntries', 1, 1_000_000], ['maximumIgnorePolicyFiles', 1, 1_000_000], ['maximumIgnorePolicyBytes', 1, metadataMaximumBytesHard], ['maximumWorktrees', 1, 1024], ['maximumCaptureRecords', 1, 100_000], ['maximumCaptureRecordBytes', 1, captureRecordMaximumBytesHard], ['maximumCaptureAggregateBytes', 1, captureAggregateMaximumBytesHard], ['maximumCaptures', 1, 100_000], ['maximumGenerationsPerCapture', 1, 100_000], ['maximumGenerationBytes', 1, generationMaximumBytesHard], ['maximumGenerationObjects', 1, 10_000_000], ['maximumCaptureStoreBytes', 1, controllerStoreMaximumBytesHard], ['maximumCaptureStoreObjects', 1, 20_000_000], ['maximumControllerStoreBytes', 1, controllerStoreMaximumBytesHard], ['maximumControllerStoreObjects', 1, 100_000_000], ['maximumControllerGenerations', 1, 1_000_000], ['maximumMetadataRecordsPerCapture', 1, 1_000_000], ['maximumMetadataRecordBytes', 1, metadataMaximumBytesHard], ['maximumMetadataAggregateBytes', 1, privateMetadataAggregateMaximumBytesHard], ['maximumLifecycleEntries', 1, 100_000], ['maximumLifecycleRecordBytes', 1, intentFileMaximumBytesHard], ['maximumLifecycleAggregateBytes', 1, intentAggregateMaximumBytesHard], ['maximumPrivateRefsPerCapture', 1, 1_000_000], ['commandTimeoutMs', 1, 30 * 60 * 1000], ['worktreeCommandTimeoutMs', 1, 30 * 60 * 1000], ['commandCloseConfirmationMs', 50, 60_000], ['maximumCommandOutputBytes', 1, commandOutputMaximumBytesHard], ['maximumConcurrentGitCommands', 1, 64], ['maximumQueuedGitCommands', 1, 100_000], ]; for (const [name, minimum, maximum] of integerLimits) { const value = this.limits[name]; if (!Number.isSafeInteger(value) || value < minimum || value > maximum) { throw new Error(`Git reversion limit ${name} is invalid.`); } } if ( this.limits.maximumGenerationBytes > this.limits.maximumCaptureStoreBytes || this.limits.maximumCaptureStoreBytes > this.limits.maximumControllerStoreBytes || this.limits.maximumGenerationObjects > this.limits.maximumCaptureStoreObjects || this.limits.maximumCaptureStoreObjects > this.limits.maximumControllerStoreObjects || this.limits.maximumGenerationsPerCapture > this.limits.maximumControllerGenerations || this.limits.maximumMetadataRecordBytes > this.limits.maximumMetadataAggregateBytes ) throw new Error('Git reversion storage quota hierarchy is invalid.'); privateTreeTraversalLimit(this.limits); } private assertUsable(): void { if (this.closed) throw new Error('The Git reversion engine is closed.'); if (this.sealed) throw this.sealError ?? new Error('The Git reversion engine is closing.'); } private assertMaintenanceUsable(): void { if (this.closed) throw new Error('The Git reversion engine is closed.'); } private requireProject(projectIdArg: string): IRegisteredProject { this.assertUsable(); const project = this.projects.get(projectIdArg); if (!project) throw new Error('The Git reversion project is not registered.'); return project; } private captureKey(identityArg: IGitReversionIdentity): string { return sha256(canonicalJson({ ownerKey: this.ownerKey, projectId: identityArg.projectId, sessionId: identityArg.sessionId, runId: identityArg.runId, captureId: identityArg.captureId, })); } private copyIdentity(identityArg: IGitReversionIdentity): IGitReversionIdentity { return { projectId: identityArg.projectId, sessionId: identityArg.sessionId, runId: identityArg.runId, captureId: identityArg.captureId, }; } private async captureIsDefinitivelyNew(captureKeyArg: string): Promise { if (await this.readLifecycleRecord(captureKeyArg)) return false; if ((await this.listPrivateIntents()).some((intent) => intent.captureKey === captureKeyArg)) { return false; } try { await plugins.fs.promises.lstat(this.captureRoot(captureKeyArg)); return false; } catch (errorArg) { if ((errorArg as NodeJS.ErrnoException).code === 'ENOENT') return true; throw errorArg; } } private assertApplyRequest(requestArg: IGitReversionApplyRequest): void { assertIdentity(requestArg.identity); assertReference(requestArg.reference); assertIdentifier(requestArg.operationId, 'The Git reversion operation ID'); if (!['undo', 'redo'].includes(requestArg.direction)) { throw new Error('The Git reversion direction is invalid.'); } if (requestArg.reference.captureKey !== this.captureKey(requestArg.identity)) { throw new Error('The Git reversion reference does not match its identity.'); } } private assertManifestIdentity( manifestArg: IGitReversionManifest, identityArg: IGitReversionIdentity, ): void { this.assertManifest(manifestArg); if ( manifestArg.captureKey !== this.captureKey(identityArg) || !recordsEqual(manifestArg.identity, this.copyIdentity(identityArg)) ) throw new Error('The Git capture identity does not match its durable manifest.'); } private assertManifest(manifestArg: IGitReversionManifest): void { const requiredKeys = [ 'affectedWorkspaces', 'captureKey', 'identity', 'kind', 'ownerKey', 'phase', 'projectDirectory', 'projectIdentity', 'repositories', 'schemaVersion', ]; if ( !manifestArg || !requiredKeys.every((key) => Object.hasOwn(manifestArg, key)) || Object.keys(manifestArg).some((key) => ( !requiredKeys.includes(key) && key !== 'reasonCode' )) || manifestArg.schemaVersion !== 1 || manifestArg.kind !== 'manifest' || manifestArg.ownerKey !== this.ownerKey || !/^[0-9a-f]{64}$/u.test(manifestArg.captureKey) || !['prepared', 'finalized'].includes(manifestArg.phase) || !Array.isArray(manifestArg.repositories) || manifestArg.repositories.length > this.limits.maximumRepositories || !Array.isArray(manifestArg.affectedWorkspaces) || manifestArg.affectedWorkspaces.length > this.limits.maximumRepositories || (manifestArg.reasonCode !== undefined && ( typeof manifestArg.reasonCode !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(manifestArg.reasonCode) )) || (manifestArg.phase === 'prepared' && manifestArg.reasonCode !== undefined && manifestArg.reasonCode !== discoveryLimitReasonCode) || (manifestArg.reasonCode === discoveryLimitReasonCode && ( manifestArg.repositories.length !== 0 || manifestArg.affectedWorkspaces.length !== 0 )) ) throw new Error('The private Git reversion manifest is invalid.'); assertIdentity(manifestArg.identity); if (manifestArg.captureKey !== this.captureKey(manifestArg.identity)) { throw new Error('The private Git reversion manifest identity is invalid.'); } if ( !plugins.path.isAbsolute(manifestArg.projectDirectory) || Buffer.byteLength(manifestArg.projectDirectory, 'utf8') > 4096 ) throw new Error('The private Git reversion project path is invalid.'); this.assertFilesystemIdentity( manifestArg.projectIdentity, manifestArg.projectDirectory, 'directory', ); const repositoryIds = new Set(); for (const entry of manifestArg.repositories) { this.assertRepositoryIdentity(entry.repository); this.assertSide(entry.before); if (entry.after) this.assertSide(entry.after); if (repositoryIds.has(entry.repository.repositoryId)) { throw new Error('The private Git reversion manifest repeats a repository.'); } repositoryIds.add(entry.repository.repositoryId); } if ( (manifestArg.phase === 'prepared' && manifestArg.repositories.some((entry) => entry.after !== undefined)) || (manifestArg.phase === 'finalized' && manifestArg.repositories.some((entry) => !entry.after)) ) throw new Error('The private Git reversion manifest phase is inconsistent.'); } private async assertDiscoveryLimitCapture( projectArg: IRegisteredProject, recordArg: IDurableCaptureRecord, ): Promise { this.assertDiscoveryLimitCaptureRecord(recordArg); const manifest = recordArg.manifest; if ( manifest.identity.projectId !== projectArg.projectId || manifest.projectDirectory !== projectArg.directory || !filesystemIdentitiesEqual(manifest.projectIdentity, projectArg.identity) ) throw new GitReversionFencedError('The discovery-limit Git capture is not exact.'); const lifecycle = await this.readLifecycleRecord(recordArg.captureKey); if ( !lifecycle || lifecycle.projectId !== projectArg.projectId || lifecycle.phase !== (manifest.phase === 'prepared' ? 'active' : 'finalized') || lifecycle.publication !== null ) throw new GitReversionFencedError( 'The discovery-limit Git capture lifecycle is not exact.', ); await this.assertProjectDirectory(projectArg); if ((await this.privateDirectoryEntries(this.captureStoresRoot(recordArg.captureKey))).length !== 0) { throw new GitReversionFencedError( 'The discovery-limit Git capture unexpectedly contains repository state.', ); } } private assertDiscoveryLimitCaptureRecord(recordArg: IDurableCaptureRecord): void { this.assertDurableCaptureRecord(recordArg); const manifest = recordArg.manifest; if ( manifest.reasonCode !== discoveryLimitReasonCode || manifest.repositories.length !== 0 || manifest.affectedWorkspaces.length !== 0 || Object.keys(recordArg.generationIds).length !== 0 || recordArg.revision !== (manifest.phase === 'prepared' ? 0 : 1) ) throw new GitReversionFencedError('The discovery-limit Git capture is not exact.'); if (manifest.phase === 'finalized') { const manifestId = sha256(canonicalJson(manifest)); const reference: TGitReversionReference = { schemaVersion: 1, captureKey: manifest.captureKey, manifestId, }; const expectedState: IGitReversionCaptureState = { schemaVersion: 1, kind: 'capture-state', captureKey: manifest.captureKey, manifestId, currentSide: 'after', revision: 0, outcome: { disposition: 'nonrevertible', reference, reasonCode: discoveryLimitReasonCode, }, }; if (!recordsEqual(recordArg.state, expectedState)) { throw new GitReversionFencedError( 'The finalized discovery-limit Git capture state is not exact.', ); } } } private assertRepositoryIdentity(repositoryArg: IGitReversionRepositoryIdentity): void { const expectedKeys = [ 'commonDir', 'commonDirIdentity', 'commonId', 'excludedRepositoryPaths', 'gitDir', 'gitDirIdentity', 'gitEntryIdentity', 'gitEntryPath', 'indexParentIdentity', 'indexPath', 'label', 'objectDir', 'objectDirIdentity', 'objectFormat', 'repositoryId', 'root', 'rootIdentity', 'scope', ].sort().join(','); if ( !repositoryArg || Object.keys(repositoryArg).sort().join(',') !== expectedKeys || !/^git-[0-9a-f]{32}$/u.test(repositoryArg.repositoryId) || !/^common-[0-9a-f]{32}$/u.test(repositoryArg.commonId) || !['project', 'owned-worktree'].includes(repositoryArg.scope) || !['sha1', 'sha256'].includes(repositoryArg.objectFormat) || !plugins.path.isAbsolute(repositoryArg.root) || !plugins.path.isAbsolute(repositoryArg.gitEntryPath) || !plugins.path.isAbsolute(repositoryArg.gitDir) || !plugins.path.isAbsolute(repositoryArg.commonDir) || !plugins.path.isAbsolute(repositoryArg.indexPath) || [ repositoryArg.root, repositoryArg.gitEntryPath, repositoryArg.gitDir, repositoryArg.commonDir, repositoryArg.objectDir, repositoryArg.indexPath, ].some((value) => Buffer.byteLength(value, 'utf8') > 4096) || !Array.isArray(repositoryArg.excludedRepositoryPaths) || repositoryArg.excludedRepositoryPaths.length > this.limits.maximumRepositories ) throw new Error('The private Git repository identity is invalid.'); this.assertFilesystemIdentity(repositoryArg.rootIdentity, repositoryArg.root, 'directory'); const gitEntryType = repositoryArg.gitEntryIdentity.ancestry.at(-1)?.type; if (gitEntryType !== 'directory' && gitEntryType !== 'file') { throw new Error('The private Git marker filesystem type is invalid.'); } this.assertFilesystemIdentity( repositoryArg.gitEntryIdentity, repositoryArg.gitEntryPath, gitEntryType, ); this.assertFilesystemIdentity(repositoryArg.gitDirIdentity, repositoryArg.gitDir, 'directory'); this.assertFilesystemIdentity(repositoryArg.commonDirIdentity, repositoryArg.commonDir, 'directory'); this.assertFilesystemIdentity(repositoryArg.objectDirIdentity, repositoryArg.objectDir, 'directory'); this.assertFilesystemIdentity( repositoryArg.indexParentIdentity, plugins.path.dirname(repositoryArg.indexPath), 'directory', ); const expectedCommonId = `common-${sha256(canonicalJson([ repositoryArg.commonDir, repositoryArg.commonDirIdentity, repositoryArg.objectDir, repositoryArg.objectDirIdentity, ])).slice(0, 32)}`; const expectedRepositoryId = `git-${sha256(canonicalJson([ expectedCommonId, repositoryArg.scope, repositoryArg.root, repositoryArg.rootIdentity, repositoryArg.gitEntryPath, repositoryArg.gitEntryIdentity, repositoryArg.gitDir, repositoryArg.gitDirIdentity, repositoryArg.indexPath, repositoryArg.indexParentIdentity, ])).slice(0, 32)}`; if ( repositoryArg.commonId !== expectedCommonId || repositoryArg.repositoryId !== expectedRepositoryId ) throw new Error('The private Git repository stable identity is inconsistent.'); } private assertFilesystemIdentity( identityArg: IControllerFilesystemIdentity, pathArg: string, targetTypeArg: TControllerFilesystemObjectType, ): void { const expectedLength = 1 + pathArg.slice(plugins.path.parse(pathArg).root.length) .split(plugins.path.sep) .filter(Boolean).length; if ( !identityArg || Object.keys(identityArg).sort().join(',') !== 'ancestry' || !Array.isArray(identityArg.ancestry) || identityArg.ancestry.length !== expectedLength || identityArg.ancestry.some((component, index) => ( !component || Object.keys(component).sort().join(',') !== 'device,inode,type' || !/^(?:0|[1-9][0-9]{0,31})$/u.test(component.device) || !/^(?:0|[1-9][0-9]{0,31})$/u.test(component.inode) || component.type !== (index === identityArg.ancestry.length - 1 ? targetTypeArg : 'directory') )) ) throw new Error('A private filesystem identity is incomplete or invalid.'); } private async assertRepositoryBinding( repositoryArg: IGitReversionRepositoryIdentity, ): Promise { this.assertRepositoryIdentity(repositoryArg); try { await assertCanonicalFilesystemObjects([ { path: repositoryArg.root, targetType: 'directory', expectedIdentity: repositoryArg.rootIdentity, }, { path: repositoryArg.gitEntryPath, targetType: repositoryArg.gitEntryIdentity.ancestry.at(-1)!.type, expectedIdentity: repositoryArg.gitEntryIdentity, }, { path: repositoryArg.gitDir, targetType: 'directory', expectedIdentity: repositoryArg.gitDirIdentity, }, { path: repositoryArg.commonDir, targetType: 'directory', expectedIdentity: repositoryArg.commonDirIdentity, }, { path: repositoryArg.objectDir, targetType: 'directory', expectedIdentity: repositoryArg.objectDirIdentity, }, { path: plugins.path.dirname(repositoryArg.indexPath), targetType: 'directory', expectedIdentity: repositoryArg.indexParentIdentity, }, ]); } catch (errorArg) { if (!(errorArg instanceof CanonicalDirectoryError)) throw errorArg; throw new GitReversionFencedError( 'The Git repository filesystem binding no longer matches registration.', { cause: errorArg }, ); } } private assertSide(sideArg: IGitReversionSide): void { if ( (sideArg.objectGenerationId !== undefined && !/^[0-9a-f]{64}$/u.test(sideArg.objectGenerationId)) || !objectIdPattern.test(sideArg.worktreeTree) || !objectIdPattern.test(sideArg.indexTree) || !objectIdPattern.test(sideArg.headOid) || (sideArg.headRef !== null && !sideArg.headRef.startsWith('refs/heads/')) || !/^[0-9a-f]{64}$/u.test(sideArg.topologyHash) || !/^[0-9a-f]{64}$/u.test(sideArg.ignorePolicyHash) || !/^[0-9a-f]{64}$/u.test(sideArg.operationHash) || Object.keys(sideArg.refs).length > this.limits.maximumPaths || Object.entries(sideArg.refs).some(([ref, oid]) => ( !this.refNameHasSafeBytes(ref) || !objectIdPattern.test(oid) )) || Object.keys(sideArg.symbolicRefs).length > this.limits.maximumPaths || Object.entries(sideArg.symbolicRefs).some(([ref, target]) => ( !this.refNameHasSafeBytes(ref) || !this.refNameHasSafeBytes(target) )) ) throw new Error('The private Git side metadata is invalid.'); } private refNameHasSafeBytes(refArg: string): boolean { return refArg.startsWith('refs/') && Buffer.byteLength(refArg, 'utf8') <= 1024 && !/[\0-\x20\x7f]/u.test(refArg); } private async assertGitRefName(refArg: string, signalArg?: AbortSignal): Promise { if (!this.refNameHasSafeBytes(refArg)) throw new Error('A Git ref contains unsafe bytes.'); if (this.validatedRefNames.has(refArg)) return; const result = await this.runGit(['check-ref-format', '--normalize', refArg], { signal: signalArg, acceptedExitCodes: [0, 1], maximumOutputBytes: 2048, }); if (result.exitCode !== 0 || trimLine(result.stdout) !== refArg) { throw new Error(`A Git ref is not valid: ${refArg}`); } if ( this.validatedRefNames.size >= this.limits.maximumPaths + this.limits.maximumPrivateRefsPerCapture ) throw new Error('The validated Git ref cache limit was exceeded.'); this.validatedRefNames.add(refArg); } private parseDirectRefOid( bytesArg: Buffer, objectFormatArg: IGitReversionRepositoryIdentity['objectFormat'], ): string { const value = decodeUtf8(bytesArg, 'Private Git direct ref'); const oidLength = objectFormatArg === 'sha256' ? 64 : 40; const oidPattern = new RegExp(`^[0-9a-f]{${oidLength}}$`, 'u'); if (oidPattern.test(value)) return value; if (value.length === oidLength + 1 && value.endsWith('\n')) { const oid = value.slice(0, -1); if (oidPattern.test(oid)) return oid; } throw new GitReversionFencedError('A private Git ref is not direct and well formed.'); } private async assertPrivateDirectRefName( refArg: string, signalArg?: AbortSignal, ): Promise { try { await this.assertGitRefName(refArg, signalArg); } catch (errorArg) { throw new GitReversionFencedError('A private Git ref name is invalid.', { cause: errorArg, }); } } private async fault(contextArg: IGitReversionFaultContext): Promise { try { await this.faultInjector?.(contextArg); } catch (errorArg) { throw new GitReversionInjectedFaultError(errorArg); } } private indexRoot(): string { return plugins.path.join(this.rootDirectory, 'indexes'); } private worktreeRoot(): string { return plugins.path.join(this.rootDirectory, 'worktrees'); } private worktreeRecordRoot(): string { return plugins.path.join(this.rootDirectory, 'worktree-records'); } private worktreeRecordPathForRef(refArg: string): string { const prefix = `${this.worktreeRefRoot()}/`; const key = refArg.slice(prefix.length); if (!refArg.startsWith(prefix) || !/^[0-9a-f]{64}$/u.test(key)) { throw new Error('The private worktree record ref is invalid.'); } return plugins.path.join(this.worktreeRecordRoot(), `${key}.json`); } private isWorktreeRecordTemporaryName(nameArg: string): boolean { return /^[0-9a-f]{64}\.json\.[1-9][0-9]*\.[0-9a-f]{20}\.tmp$/u.test(nameArg); } private projectCatalogRoot(): string { return plugins.path.join(this.rootDirectory, 'projects'); } private captureRecordRoot(): string { return plugins.path.join(this.rootDirectory, 'captures'); } private lifecycleRoot(): string { return plugins.path.join(this.rootDirectory, 'lifecycle'); } private captureRoot(captureKeyArg: string): string { if (!/^[0-9a-f]{64}$/u.test(captureKeyArg)) throw new Error('The capture key is invalid.'); return plugins.path.join(this.captureRecordRoot(), captureKeyArg); } private captureStoresRoot(captureKeyArg: string): string { return plugins.path.join(this.captureRoot(captureKeyArg), 'stores'); } private captureStoreRoot(captureKeyArg: string, repositoryIdArg: string): string { if (!/^git-[0-9a-f]{32}$/u.test(repositoryIdArg)) { throw new Error('The private Git store repository ID is invalid.'); } return plugins.path.join(this.captureStoresRoot(captureKeyArg), repositoryIdArg); } private captureGitDirectory(captureKeyArg: string, repositoryIdArg: string): string { return plugins.path.join(this.captureStoreRoot(captureKeyArg, repositoryIdArg), 'git'); } private generationRoot(captureKeyArg: string, repositoryIdArg: string): string { return plugins.path.join(this.captureStoreRoot(captureKeyArg, repositoryIdArg), 'generations'); } private generationDirectory( captureKeyArg: string, repositoryIdArg: string, generationIdArg: string, ): string { if (!/^[0-9a-f]{64}$/u.test(generationIdArg)) throw new Error('The object generation ID is invalid.'); return plugins.path.join(this.generationRoot(captureKeyArg, repositoryIdArg), generationIdArg); } private lifecyclePath(captureKeyArg: string): string { if (!/^[0-9a-f]{64}$/u.test(captureKeyArg)) throw new Error('The capture lifecycle key is invalid.'); return plugins.path.join(this.lifecycleRoot(), `${captureKeyArg}.json`); } private privateIntentRoot(): string { return plugins.path.join(this.rootDirectory, 'intents'); } private privateIntentPath(intentIdArg: string): string { if (!/^[0-9a-f]{64}$/u.test(intentIdArg)) throw new Error('The private intent ID is invalid.'); return plugins.path.join(this.privateIntentRoot(), `${intentIdArg}.json`); } private async countDirectoryEntries(directoryArg: string): Promise { const directory = await plugins.fs.promises.opendir(directoryArg); let count = 0; try { for await (const entry of directory) { count += 1; if (count > this.limits.maximumScavengeEntries) { throw new Error('A private Git directory exceeds its scavenging entry limit.'); } if (Buffer.byteLength(entry.name, 'utf8') > 1024) { throw new Error('A private Git directory entry name is too long.'); } } } finally { await directory.close().catch((errorArg: NodeJS.ErrnoException) => { if (errorArg.code !== 'ERR_DIR_CLOSED') throw errorArg; }); } return count; } private async syncDirectory(directoryArg: string): Promise { const directory = await plugins.fs.promises.open(directoryArg, 'r'); try { await directory.sync(); } finally { await directory.close(); } } private async writeFileHandleChunk( handleArg: plugins.fs.promises.FileHandle, chunkArg: Buffer, positionArg: number, ): Promise { let written = 0; while (written < chunkArg.byteLength) { const result = await handleArg.write( chunkArg, written, chunkArg.byteLength - written, positionArg + written, ); if (result.bytesWritten === 0) throw new Error('Writing Git output made no progress.'); written += result.bytesWritten; } } private async syncRegularFile( pathArg: string, descriptionArg: string, requirePrivateModeArg: boolean, ): Promise { const handle = await plugins.fs.promises.open( pathArg, plugins.fs.constants.O_RDONLY | plugins.fs.constants.O_NOFOLLOW, ); try { const before = await handle.stat(); if ( !before.isFile() || before.isSymbolicLink() || (requirePrivateModeArg && (before.mode & 0o077) !== 0) || (requirePrivateModeArg && before.nlink !== 1) || (typeof process.getuid === 'function' && before.uid !== process.getuid()) ) throw new GitReversionFencedError(`${descriptionArg} is not an exact owned regular file.`); await handle.sync(); const after = await handle.stat(); if ( before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || !after.isFile() ) throw new GitReversionFencedError(`${descriptionArg} changed while it was synchronized.`); return after; } finally { await handle.close(); } } private async readBoundedRegularFile( pathArg: string, maximumBytesArg: number, descriptionArg: string, signalArg?: AbortSignal, requirePrivateModeArg = false, detectConcurrentReplacementArg = false, afterOpenArg?: () => Promise, ): Promise { let handle: plugins.fs.promises.FileHandle; try { handle = await plugins.fs.promises.open( pathArg, plugins.fs.constants.O_RDONLY | plugins.fs.constants.O_NOFOLLOW | plugins.fs.constants.O_NONBLOCK, ); } catch (errorArg) { if ((errorArg as NodeJS.ErrnoException).code === 'ENOENT') throw errorArg; throw new Error(`${descriptionArg} could not be opened safely.`, { cause: errorArg }); } try { await afterOpenArg?.(); const before = await handle.stat(); if (detectConcurrentReplacementArg && before.nlink === 0) { throw new GitReversionConcurrentRecordReadError( `${descriptionArg} was replaced while it was opened.`, ); } if ( !before.isFile() || (requirePrivateModeArg && before.nlink !== 1) || (requirePrivateModeArg && (before.mode & 0o077) !== 0) || (typeof process.getuid === 'function' && before.uid !== process.getuid()) ) throw new Error(`${descriptionArg} is not a bounded owned regular file.`); if (before.size > maximumBytesArg) { throw new Error(`${descriptionArg} exceeds its byte limit.`); } const chunks: Buffer[] = []; let total = 0; let position = 0; while (true) { signalArg?.throwIfAborted(); const remaining = maximumBytesArg + 1 - total; if (remaining <= 0) throw new Error(`${descriptionArg} exceeds its byte limit.`); const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, remaining)); const { bytesRead } = await handle.read(chunk, 0, chunk.byteLength, position); if (bytesRead === 0) break; chunks.push(chunk.subarray(0, bytesRead)); total += bytesRead; position += bytesRead; if (total > maximumBytesArg) throw new Error(`${descriptionArg} exceeds its byte limit.`); } const after = await handle.stat(); if (detectConcurrentReplacementArg && after.nlink === 0) { throw new GitReversionConcurrentRecordReadError( `${descriptionArg} was replaced while it was read.`, ); } if ( before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || total !== before.size || !after.isFile() || (requirePrivateModeArg && after.nlink !== 1) || (requirePrivateModeArg && (after.mode & 0o077) !== 0) || (typeof process.getuid === 'function' && after.uid !== process.getuid()) ) throw new Error(`${descriptionArg} changed while it was read.`); if (detectConcurrentReplacementArg) { let pathAfter: plugins.fs.Stats; try { pathAfter = await plugins.fs.promises.lstat(pathArg); } catch (errorArg) { if ((errorArg as NodeJS.ErrnoException).code === 'ENOENT') { throw new GitReversionConcurrentRecordReadError( `${descriptionArg} was removed while it was read.`, ); } throw errorArg; } if ( !pathAfter.isFile() || pathAfter.isSymbolicLink() || pathAfter.nlink !== 1 || (pathAfter.mode & 0o077) !== 0 || (typeof process.getuid === 'function' && pathAfter.uid !== process.getuid()) ) throw new Error(`${descriptionArg} pathname is not an exact owned regular file.`); if (pathAfter.dev !== after.dev || pathAfter.ino !== after.ino) { throw new GitReversionConcurrentRecordReadError( `${descriptionArg} was replaced while its pathname was revalidated.`, ); } } return Buffer.concat(chunks, total); } finally { await handle.close(); } } private assertPrivateIntent(intentArg: TPrivateIntent): void { const expectedBaseKeys = [ 'captureKey', 'entries', 'intentId', 'kind', 'operation', 'ownerKey', 'phase', 'projectDirectory', 'projectId', 'projectIdentity', 'schemaVersion', ].sort().join(','); if ( !intentArg || typeof intentArg !== 'object' || Array.isArray(intentArg) || Object.keys(intentArg).sort().join(',') !== expectedBaseKeys || intentArg.schemaVersion !== 1 || intentArg.ownerKey !== this.ownerKey || !/^[0-9a-f]{64}$/u.test(intentArg.intentId) || !/^[0-9a-f]{64}$/u.test(intentArg.captureKey) || !plugins.path.isAbsolute(intentArg.projectDirectory) || Buffer.byteLength(intentArg.projectDirectory, 'utf8') > 4096 || !Array.isArray(intentArg.entries) || intentArg.entries.length === 0 || intentArg.entries.length > this.limits.maximumIntentEntries ) throw new GitReversionFencedError('A private Git intent is invalid.'); assertIdentifier(intentArg.projectId, 'The private Git intent project ID'); this.assertFilesystemIdentity(intentArg.projectIdentity, intentArg.projectDirectory, 'directory'); if ( (intentArg.kind === 'publication-intent' && (!['prepare', 'participant-extension'].includes(intentArg.operation) || !['prepared', 'committed'].includes(intentArg.phase))) || (intentArg.kind === 'deletion-intent' && (intentArg.operation !== 'release' || intentArg.phase !== 'deleting')) || !['publication-intent', 'deletion-intent'].includes(intentArg.kind) ) throw new GitReversionFencedError('A private Git intent state is invalid.'); const keys = new Set(); for (const entry of intentArg.entries) { this.assertRepositoryIdentity(entry.repository); if (!entry.ref.startsWith(`${privateRefRoot}/`) || Buffer.byteLength(entry.ref, 'utf8') > 1024) { throw new GitReversionFencedError('A private Git intent ref escaped its namespace.'); } const key = this.privateRefChargeKey(entry.repository, entry.ref); if (keys.has(key)) throw new GitReversionFencedError('A private Git intent repeats a ref.'); keys.add(key); if (!objectIdPattern.test(entry.expectedOid ?? '')) { if (intentArg.kind === 'deletion-intent' || entry.expectedOid !== null) { throw new GitReversionFencedError('A private Git intent predecessor is invalid.'); } } if (intentArg.kind === 'publication-intent') { const publicationEntry = entry as IPrivatePublicationIntentEntry; if ( Object.keys(entry).sort().join(',') !== 'expectedOid,intendedOid,intendedValue,ref,repository' || !objectIdPattern.test(publicationEntry.intendedOid) || !publicationEntry.intendedValue || typeof publicationEntry.intendedValue !== 'object' || Array.isArray(publicationEntry.intendedValue) || Buffer.byteLength(canonicalJson(publicationEntry.intendedValue), 'utf8') > this.limits.maximumMetadataBytes ) throw new GitReversionFencedError('A private Git publication entry is invalid.'); } else if (Object.keys(entry).sort().join(',') !== 'expectedOid,ref,repository') { throw new GitReversionFencedError('A private Git deletion entry is invalid.'); } } } private async cleanupPrivateIntentTemporaryFiles(): Promise { await this.countDirectoryEntries(this.privateIntentRoot()); const pattern = /^[0-9a-f]{64}\.json\.[1-9][0-9]*\.[0-9a-f]{20}\.tmp$/u; const directory = await plugins.fs.promises.opendir(this.privateIntentRoot()); let removed = false; try { for await (const entry of directory) { if (!pattern.test(entry.name)) continue; const path = plugins.path.join(this.privateIntentRoot(), entry.name); const stat = await plugins.fs.promises.lstat(path); if ( !entry.isFile() || entry.isSymbolicLink() || !stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0 || (typeof process.getuid === 'function' && stat.uid !== process.getuid()) ) throw new Error('A private Git intent temporary file is invalid.'); await plugins.fs.promises.rm(path); removed = true; } } finally { await directory.close().catch(() => undefined); } if (removed) await this.syncDirectory(this.privateIntentRoot()); } private listPrivateIntents(): Promise { return this.withMetadataMutation(() => this.listPrivateIntentsUnlocked()); } private async listPrivateIntentsUnlocked(): Promise { const entryCount = await this.countDirectoryEntries(this.privateIntentRoot()); if (entryCount > this.limits.maximumIntentFiles) { throw new Error('The private Git intent file limit was exceeded.'); } const paths: Array<{ id: string; path: string; size: number }> = []; let aggregate = 0; const directory = await plugins.fs.promises.opendir(this.privateIntentRoot()); try { for await (const entry of directory) { const match = /^([0-9a-f]{64})\.json$/u.exec(entry.name); if (!match || !entry.isFile() || entry.isSymbolicLink()) { throw new Error('The private Git intent directory contains an unexpected entry.'); } const path = plugins.path.join(this.privateIntentRoot(), entry.name); const stat = await plugins.fs.promises.lstat(path); if (!stat.isFile() || stat.isSymbolicLink() || stat.size > this.limits.maximumIntentFileBytes) { throw new Error('A private Git intent file is invalid.'); } aggregate += stat.size; if (aggregate > this.limits.maximumIntentAggregateBytes) { throw new Error('The private Git intent aggregate byte limit was exceeded.'); } paths.push({ id: match[1]!, path, size: stat.size }); } } finally { await directory.close().catch(() => undefined); } const intents: TPrivateIntent[] = []; this.privateIntentBytes.clear(); this.privateIntentAggregateBytes = aggregate; for (const entry of paths.sort((left, right) => left.id.localeCompare(right.id))) { const bytes = await this.readBoundedRegularFile( entry.path, Math.min(entry.size, this.limits.maximumIntentFileBytes), 'Private Git intent', undefined, true, ); let parsed: unknown; try { parsed = JSON.parse(decodeUtf8(bytes, 'Private Git intent')); } catch (errorArg) { throw new GitReversionFencedError('A private Git intent is malformed.', { cause: errorArg }); } const intent = parsed as TPrivateIntent; this.assertPrivateIntent(intent); if (intent.intentId !== entry.id) { throw new GitReversionFencedError('A private Git intent escaped its owned path.'); } intents.push(intent); this.privateIntentBytes.set(intent.intentId, bytes.byteLength); } return intents; } private async readPrivateIntent(intentIdArg: string): Promise { let bytes: Buffer; try { bytes = await this.readBoundedRegularFile( this.privateIntentPath(intentIdArg), this.limits.maximumIntentFileBytes, 'Private Git intent', undefined, true, ); } catch (errorArg) { if ((errorArg as NodeJS.ErrnoException).code === 'ENOENT') return undefined; throw errorArg; } let parsed: unknown; try { parsed = JSON.parse(decodeUtf8(bytes, 'Private Git intent')); } catch (errorArg) { throw new GitReversionFencedError('A private Git intent is malformed.', { cause: errorArg }); } const intent = parsed as TPrivateIntent; this.assertPrivateIntent(intent); if (intent.intentId !== intentIdArg) { throw new GitReversionFencedError('A private Git intent escaped its owned path.'); } return intent; } private async writePrivateIntent( intentArg: TPrivateIntent, initialArg: boolean, ): Promise { this.assertPrivateIntent(intentArg); const target = this.privateIntentPath(intentArg.intentId); const temporary = `${target}.${process.pid}.${plugins.crypto.randomBytes(10).toString('hex')}.tmp`; const bytes = Buffer.from(`${canonicalJson(intentArg)}\n`, 'utf8'); const previousBytes = this.privateIntentBytes.get(intentArg.intentId) ?? 0; const nextCount = this.privateIntentBytes.has(intentArg.intentId) ? this.privateIntentBytes.size : this.privateIntentBytes.size + 1; const nextAggregate = this.privateIntentAggregateBytes - previousBytes + bytes.byteLength; if (bytes.byteLength > this.limits.maximumIntentFileBytes) { throw new Error('The private Git intent file byte limit was exceeded.'); } if (nextCount > this.limits.maximumIntentFiles) { throw new Error('The private Git intent file count limit was exceeded.'); } if (nextAggregate > this.limits.maximumIntentAggregateBytes) { throw new Error('The private Git intent aggregate byte limit was exceeded.'); } if (initialArg && this.privateIntentBytes.has(intentArg.intentId)) { throw new GitReversionFencedError('The private Git intent ID already exists.'); } let handle: plugins.fs.promises.FileHandle | undefined; try { handle = await plugins.fs.promises.open(temporary, 'wx', 0o600); await handle.writeFile(bytes); await this.fault({ point: intentArg.kind === 'publication-intent' && intentArg.phase === 'committed' ? 'intent.before-commit-fsync' : 'intent.before-fsync', intentId: intentArg.intentId, captureKey: intentArg.captureKey, }); await handle.sync(); await this.fault({ point: intentArg.kind === 'publication-intent' && intentArg.phase === 'committed' ? 'intent.after-commit-fsync' : 'intent.after-fsync', intentId: intentArg.intentId, captureKey: intentArg.captureKey, }); await handle.close(); handle = undefined; await plugins.fs.promises.rename(temporary, target); await this.fault({ point: 'atomic.after-rename-before-directory-fsync', intentId: intentArg.intentId, captureKey: intentArg.captureKey, }); await this.syncDirectory(this.privateIntentRoot()); } finally { await handle?.close().catch(() => undefined); await plugins.fs.promises.rm(temporary, { force: true }).catch(() => undefined); } this.privateIntentBytes.set(intentArg.intentId, bytes.byteLength); this.privateIntentAggregateBytes = nextAggregate; } private async removePrivateIntent(intentArg: TPrivateIntent): Promise { await this.fault({ point: 'intent.before-remove', intentId: intentArg.intentId, captureKey: intentArg.captureKey, }); await plugins.fs.promises.rm(this.privateIntentPath(intentArg.intentId), { force: true }); await this.syncDirectory(this.privateIntentRoot()); const bytes = this.privateIntentBytes.get(intentArg.intentId) ?? 0; this.privateIntentBytes.delete(intentArg.intentId); this.privateIntentAggregateBytes -= bytes; await this.fault({ point: 'intent.after-remove', intentId: intentArg.intentId, captureKey: intentArg.captureKey, }); } private assertLifecycleRecord(recordArg: ICaptureLifecycleRecord): void { if ( !recordArg || typeof recordArg !== 'object' || Array.isArray(recordArg) || Object.keys(recordArg).sort().join(',') !== 'captureKey,kind,ownerKey,phase,projectId,publication,schemaVersion' || recordArg.schemaVersion !== 1 || recordArg.kind !== 'capture-lifecycle' || recordArg.ownerKey !== this.ownerKey || !/^[0-9a-f]{64}$/u.test(recordArg.captureKey) || ![ 'creation', 'prepared-publication', 'active', 'finalized', 'release-intent', 'deleting', 'removed', ].includes(recordArg.phase) || ![null, 'prepare', 'finalize'].includes(recordArg.publication) || (recordArg.phase === 'prepared-publication' && recordArg.publication === null) || (recordArg.phase !== 'prepared-publication' && recordArg.publication !== null) ) throw new GitReversionFencedError('A capture lifecycle record is invalid.'); assertIdentifier(recordArg.projectId, 'The capture lifecycle project ID'); } private listLifecycleRecords(): Promise { return this.withCaptureMutation(() => this.listLifecycleRecordsUnlocked()); } private async listLifecycleRecordsUnlocked(): Promise { const directory = await plugins.fs.promises.opendir(this.lifecycleRoot()); const entries: Array<{ captureKey: string; path: string; size: number }> = []; let aggregate = 0; try { for await (const entry of directory) { if (entries.length >= this.limits.maximumLifecycleEntries) { throw new Error('The capture lifecycle entry limit was exceeded.'); } const match = /^([0-9a-f]{64})\.json$/u.exec(entry.name); if (!match || !entry.isFile() || entry.isSymbolicLink()) { throw new Error('The capture lifecycle directory contains an unexpected entry.'); } const path = plugins.path.join(this.lifecycleRoot(), entry.name); const stat = await plugins.fs.promises.lstat(path); if ( !stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0 || (typeof process.getuid === 'function' && stat.uid !== process.getuid()) || stat.size > this.limits.maximumLifecycleRecordBytes ) throw new Error('A capture lifecycle entry is not a bounded owned regular file.'); aggregate += stat.size; if (aggregate > this.limits.maximumLifecycleAggregateBytes) { throw new Error('The capture lifecycle aggregate byte limit was exceeded.'); } entries.push({ captureKey: match[1]!, path, size: stat.size }); } } finally { await directory.close().catch(() => undefined); } const records: ICaptureLifecycleRecord[] = []; this.lifecycleRecordBytes.clear(); this.lifecycleAggregateBytes = aggregate; for (const entry of entries.sort((left, right) => left.captureKey.localeCompare(right.captureKey))) { const bytes = await this.readBoundedRegularFile( entry.path, this.limits.maximumLifecycleRecordBytes, 'Capture lifecycle record', undefined, true, ); let parsed: unknown; try { parsed = JSON.parse(decodeUtf8(bytes, 'Capture lifecycle record')); } catch (errorArg) { throw new GitReversionFencedError('A capture lifecycle record is malformed.', { cause: errorArg, }); } const record = parsed as ICaptureLifecycleRecord; this.assertLifecycleRecord(record); if (record.captureKey !== entry.captureKey) { throw new GitReversionFencedError('A capture lifecycle record escaped its owned path.'); } this.lifecycleRecordBytes.set(record.captureKey, bytes.byteLength); records.push(record); } return records; } private async cleanupLifecycleTemporaryFiles(): Promise { const pattern = /^[0-9a-f]{64}\.json\.[1-9][0-9]*\.[0-9a-f]{20}\.tmp$/u; const directory = await plugins.fs.promises.opendir(this.lifecycleRoot()); let entries = 0; let removed = false; try { for await (const entry of directory) { entries += 1; if (entries > this.limits.maximumScavengeEntries) { throw new Error('The capture lifecycle scavenging entry limit was exceeded.'); } if (!pattern.test(entry.name)) continue; const path = plugins.path.join(this.lifecycleRoot(), entry.name); const stat = await plugins.fs.promises.lstat(path); if ( !entry.isFile() || entry.isSymbolicLink() || !stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0 || (typeof process.getuid === 'function' && stat.uid !== process.getuid()) ) throw new Error('A capture lifecycle temporary file is invalid.'); await plugins.fs.promises.rm(path); removed = true; } } finally { await directory.close().catch(() => undefined); } if (removed) await this.syncDirectory(this.lifecycleRoot()); } private async loadLifecycleQuota(): Promise { await this.listLifecycleRecords(); } private writeLifecycleRecord(recordArg: ICaptureLifecycleRecord): Promise { return this.withCaptureMutation(() => this.writeLifecycleRecordUnlocked(recordArg)); } private async writeLifecycleRecordUnlocked(recordArg: ICaptureLifecycleRecord): Promise { this.assertLifecycleRecord(recordArg); const bytes = Buffer.from(`${canonicalJson(recordArg)}\n`, 'utf8'); const hadPrevious = this.lifecycleRecordBytes.has(recordArg.captureKey); const previousBytes = this.lifecycleRecordBytes.get(recordArg.captureKey) ?? 0; const previousAggregate = this.lifecycleAggregateBytes; const nextCount = hadPrevious ? this.lifecycleRecordBytes.size : this.lifecycleRecordBytes.size + 1; const nextCaptureCount = new Set([ ...this.captureRecordBytes.keys(), ...this.lifecycleRecordBytes.keys(), recordArg.captureKey, ]).size; const nextAggregate = this.lifecycleAggregateBytes - previousBytes + bytes.byteLength; if (bytes.byteLength > this.limits.maximumLifecycleRecordBytes) { throw new Error('The capture lifecycle record byte limit was exceeded.'); } if (nextCount > this.limits.maximumLifecycleEntries) { throw new Error('The capture lifecycle entry limit was exceeded.'); } if (nextCaptureCount > this.limits.maximumCaptures) { throw new Error('The capture count limit was reached.'); } if (nextAggregate > this.limits.maximumLifecycleAggregateBytes) { throw new Error('The capture lifecycle aggregate byte limit was exceeded.'); } // Reserve quota before I/O so a concurrent capture cannot admit against stale accounting. this.lifecycleRecordBytes.set(recordArg.captureKey, bytes.byteLength); this.lifecycleAggregateBytes = nextAggregate; const target = this.lifecyclePath(recordArg.captureKey); const temporary = `${target}.${process.pid}.${plugins.crypto.randomBytes(10).toString('hex')}.tmp`; let handle: plugins.fs.promises.FileHandle | undefined; let renamed = false; try { handle = await plugins.fs.promises.open(temporary, 'wx', 0o600); await handle.writeFile(bytes); await handle.sync(); await handle.close(); handle = undefined; await plugins.fs.promises.rename(temporary, target); renamed = true; await this.fault({ point: 'atomic.after-rename-before-directory-fsync', captureKey: recordArg.captureKey, }); await this.syncDirectory(this.lifecycleRoot()); } catch (errorArg) { if (!renamed) { if (hadPrevious) this.lifecycleRecordBytes.set(recordArg.captureKey, previousBytes); else this.lifecycleRecordBytes.delete(recordArg.captureKey); this.lifecycleAggregateBytes = previousAggregate; } else { this.invalidateRecovery(); } throw errorArg; } finally { await handle?.close().catch(() => undefined); await plugins.fs.promises.rm(temporary, { force: true }).catch(() => undefined); } } private removeLifecycleRecord(captureKeyArg: string): Promise { return this.withCaptureMutation(() => this.removeLifecycleRecordUnlocked(captureKeyArg)); } private async removeLifecycleRecordUnlocked(captureKeyArg: string): Promise { const bytes = this.lifecycleRecordBytes.get(captureKeyArg) ?? 0; await plugins.fs.promises.rm(this.lifecyclePath(captureKeyArg), { force: true }); await this.syncDirectory(this.lifecycleRoot()); this.lifecycleRecordBytes.delete(captureKeyArg); this.lifecycleAggregateBytes -= bytes; } private lifecycleRecord( captureKeyArg: string, projectIdArg: string, phaseArg: TCaptureLifecyclePhase, publicationArg: ICaptureLifecycleRecord['publication'] = null, ): ICaptureLifecycleRecord { return { schemaVersion: 1, kind: 'capture-lifecycle', ownerKey: this.ownerKey, captureKey: captureKeyArg, projectId: projectIdArg, phase: phaseArg, publication: publicationArg, }; } private captureLifecycleClaim(captureKeyArg: string): TCaptureLifecycleClaimPurpose | undefined { return captureLifecycleClaimsByRoot.get(this.rootDirectory)?.get(captureKeyArg); } private hasCaptureLifecycleClaim(purposeArg: TCaptureLifecycleClaimPurpose): boolean { return [...(captureLifecycleClaimsByRoot.get(this.rootDirectory)?.values() ?? [])] .includes(purposeArg); } private claimCaptureLifecycle( captureKeyArg: string, purposeArg: TCaptureLifecycleClaimPurpose, ): TCaptureLifecycleClaimPurpose | undefined { let claims = captureLifecycleClaimsByRoot.get(this.rootDirectory); if (!claims) { claims = new Map(); captureLifecycleClaimsByRoot.set(this.rootDirectory, claims); } const existing = claims.get(captureKeyArg); if (existing) return existing; claims.set(captureKeyArg, purposeArg); return undefined; } private releaseCaptureLifecycleClaim( captureKeyArg: string, purposeArg: TCaptureLifecycleClaimPurpose, ): void { const claims = captureLifecycleClaimsByRoot.get(this.rootDirectory); if (claims?.get(captureKeyArg) !== purposeArg) { throw new Error('The Git capture lifecycle claim ownership diverged.'); } claims.delete(captureKeyArg); if (claims.size === 0) captureLifecycleClaimsByRoot.delete(this.rootDirectory); } private async beginCaptureCreation( captureKeyArg: string, projectIdArg: string, ): Promise { const lifecycle = this.lifecycleRecord(captureKeyArg, projectIdArg, 'creation'); await this.writeLifecycleRecord(lifecycle); await this.fault({ point: 'capture.before-store-create', captureKey: captureKeyArg }); await this.ensurePrivateDirectory(this.captureRoot(captureKeyArg)); await this.ensurePrivateDirectory(this.captureStoresRoot(captureKeyArg)); await this.syncCaptureRecordRoot(); await this.fault({ point: 'capture.after-store-create', captureKey: captureKeyArg }); } private async publishCaptureLifecycle( captureKeyArg: string, projectIdArg: string, publicationArg: 'prepare' | 'finalize', ): Promise { await this.writeLifecycleRecord(this.lifecycleRecord( captureKeyArg, projectIdArg, 'prepared-publication', publicationArg, )); await this.fault({ point: 'capture.after-publication-fsync', captureKey: captureKeyArg }); await this.writeLifecycleRecord(this.lifecycleRecord( captureKeyArg, projectIdArg, publicationArg === 'prepare' ? 'active' : 'finalized', )); } private async publishFinalizedCaptureRecord( recordArg: IDurableCaptureRecord, projectIdArg: string, ): Promise { await this.assertInitialFinalizedCaptureRecord(recordArg, projectIdArg); await this.writeDurableCaptureRecord(recordArg); await this.fault({ point: 'capture.after-finalize-record-fsync', captureKey: recordArg.captureKey, }); await this.publishCaptureLifecycle(recordArg.captureKey, projectIdArg, 'finalize'); } private async assertPrivatePathAncestry(pathArg: string): Promise { if (!plugins.path.isAbsolute(pathArg) || !isWithin(this.rootDirectory, pathArg)) { throw new GitReversionFencedError('A private Git path escaped its controller root.'); } const filesystemRoot = plugins.path.parse(pathArg).root; let current = filesystemRoot; const paths = [filesystemRoot]; for (const component of pathArg.slice(filesystemRoot.length).split(plugins.path.sep).filter(Boolean)) { current = plugins.path.join(current, component); paths.push(current); } for (const path of paths) { const stat = await plugins.fs.promises.lstat(path); if (!stat.isDirectory() || stat.isSymbolicLink()) { throw new GitReversionFencedError('A private Git directory ancestry is not canonical.'); } if ( isWithin(this.rootDirectory, path) && typeof process.getuid === 'function' && stat.uid !== process.getuid() ) throw new GitReversionFencedError('A private Git directory ancestry has another owner.'); } } private assertPrivateTreeNode(statArg: plugins.fs.Stats): void { if ( statArg.isSymbolicLink() || (typeof process.getuid === 'function' && statArg.uid !== process.getuid()) ) throw new GitReversionFencedError('A private Git store entry has unsafe ownership.'); if (!statArg.isDirectory() && (!statArg.isFile() || statArg.nlink !== 1)) { throw new GitReversionFencedError( statArg.isFile() ? 'A private Git store file has an external hard link.' : 'A private Git store contains a special file instead of a regular file or directory.', ); } } private async walkPrivateTree( pathArg: string, visitorArg: ((nodeArg: { path: string; stat: plugins.fs.Stats }) => Promise) | undefined, stateArg: { entries: number }, depthArg: number, ): Promise { if (depthArg > this.limits.maximumScanDepth + 16) { throw new GitReversionFencedError('A private Git store exceeds its admitted tree depth.'); } const stat = await plugins.fs.promises.lstat(pathArg); this.assertPrivateTreeNode(stat); stateArg.entries += 1; if (stateArg.entries > this.maximumPrivateTreeEntries) { throw new GitReversionFencedError('The private Git store exceeds its cleanup traversal limit.'); } await visitorArg?.({ path: pathArg, stat }); if (!stat.isDirectory()) return; const directory = await plugins.fs.promises.opendir(pathArg); try { for await (const entry of directory) { if (Buffer.byteLength(entry.name, 'utf8') > 1024) { throw new GitReversionFencedError('A private Git store entry name is too long.'); } await this.walkPrivateTree( plugins.path.join(pathArg, entry.name), visitorArg, stateArg, depthArg + 1, ); } } finally { await directory.close().catch(() => undefined); } } private async inspectPrivateTree( pathArg: string, visitorArg?: (nodeArg: { path: string; stat: plugins.fs.Stats }) => Promise, ): Promise { await this.assertPrivatePathAncestry(pathArg); await this.walkPrivateTree(pathArg, visitorArg, { entries: 0 }, 0); } private async chmodPrivateNode( nodeArg: { path: string; stat: plugins.fs.Stats }, ): Promise { const targetMode = nodeArg.stat.isDirectory() ? 0o700 : 0o600; if ((nodeArg.stat.mode & 0o777) === targetMode) return; const flags = plugins.fs.constants.O_RDONLY | plugins.fs.constants.O_NOFOLLOW | plugins.fs.constants.O_NONBLOCK | (nodeArg.stat.isDirectory() ? plugins.fs.constants.O_DIRECTORY : 0); const handle = await plugins.fs.promises.open(nodeArg.path, flags); try { const stat = await handle.stat(); if ( stat.dev !== nodeArg.stat.dev || stat.ino !== nodeArg.stat.ino || stat.isDirectory() !== nodeArg.stat.isDirectory() || (!stat.isDirectory() && (!stat.isFile() || stat.nlink !== 1)) || (typeof process.getuid === 'function' && stat.uid !== process.getuid()) ) throw new GitReversionFencedError('A private Git store entry changed before hardening.'); await handle.chmod(targetMode); } finally { await handle.close(); } } private async removePrivateTree(pathArg: string): Promise { try { await this.inspectPrivateTree(pathArg); } catch (errorArg) { if ((errorArg as NodeJS.ErrnoException).code === 'ENOENT') return; throw errorArg; } const state = { entries: 0 }; const remove = async (path: string, depth: number): Promise => { if (depth > this.limits.maximumScanDepth + 16) { throw new GitReversionFencedError('A private Git store exceeds its admitted tree depth.'); } const stat = await plugins.fs.promises.lstat(path); this.assertPrivateTreeNode(stat); state.entries += 1; if (state.entries > this.maximumPrivateTreeEntries) { throw new GitReversionFencedError('The private Git store exceeds its cleanup traversal limit.'); } if (stat.isDirectory()) { const directory = await plugins.fs.promises.opendir(path); try { for await (const entry of directory) { if (Buffer.byteLength(entry.name, 'utf8') > 1024) { throw new GitReversionFencedError('A private Git store entry name is too long.'); } await remove(plugins.path.join(path, entry.name), depth + 1); } } finally { await directory.close().catch(() => undefined); } } const current = await plugins.fs.promises.lstat(path); this.assertPrivateTreeNode(current); if ( current.dev !== stat.dev || current.ino !== stat.ino || current.isDirectory() !== stat.isDirectory() ) throw new GitReversionFencedError('A private Git store entry changed before deletion.'); if (current.isDirectory()) await plugins.fs.promises.rmdir(path); else await plugins.fs.promises.rm(path); }; await remove(pathArg, 0); } private removePrivateIntentsForCaptures( captureKeysArg: ReadonlySet, signalArg?: AbortSignal, ): Promise { return this.withMetadataMutation(async () => { for (const intent of await this.listPrivateIntentsUnlocked()) { signalArg?.throwIfAborted(); if (captureKeysArg.has(intent.captureKey)) await this.removePrivateIntent(intent); } }); } private async readLifecycleRecord( captureKeyArg: string, ): Promise { let bytes: Buffer; try { bytes = await this.readBoundedRegularFile( this.lifecyclePath(captureKeyArg), this.limits.maximumLifecycleRecordBytes, 'Capture lifecycle record', undefined, true, ); } catch (errorArg) { if ((errorArg as NodeJS.ErrnoException).code === 'ENOENT') return undefined; throw errorArg; } let parsed: unknown; try { parsed = JSON.parse(decodeUtf8(bytes, 'Capture lifecycle record')); } catch (errorArg) { throw new GitReversionFencedError('A capture lifecycle record is malformed.', { cause: errorArg, }); } const record = parsed as ICaptureLifecycleRecord; this.assertLifecycleRecord(record); if (record.captureKey !== captureKeyArg) { throw new GitReversionFencedError('A capture lifecycle record escaped its owned path.'); } return record; } private async requireFinalizedCaptureLifecycle( captureKeyArg: string, projectIdArg: string, ): Promise { const lifecycle = await this.readLifecycleRecord(captureKeyArg); if ( !lifecycle || lifecycle.projectId !== projectIdArg || lifecycle.phase !== 'finalized' || lifecycle.publication !== null ) throw new GitReversionRecoveryRequiredError( 'The Git capture lifecycle is not finalized.', ); return lifecycle; } private async claimLifecycleRecordForRecovery( snapshotArg: ICaptureLifecycleRecord, ): Promise { const existingClaim = this.claimCaptureLifecycle(snapshotArg.captureKey, 'deletion'); if (existingClaim === 'creation' || existingClaim === 'finalization') return undefined; if (existingClaim === 'deletion') throw new GitReversionRecoveryRequiredError( 'The Git capture already has an active deletion.', ); try { const current = await this.withCaptureMutation(() => ( this.readLifecycleRecord(snapshotArg.captureKey) )); if (!current || !recordsEqual(current, snapshotArg)) { this.releaseCaptureLifecycleClaim(snapshotArg.captureKey, 'deletion'); return undefined; } return current; } catch (errorArg) { this.releaseCaptureLifecycleClaim(snapshotArg.captureKey, 'deletion'); throw errorArg; } } private async deleteCaptureRootWithLifecycle( recordArg: ICaptureLifecycleRecord, claimedPurposeArg?: TCaptureLifecycleClaimPurpose, privateIntentsClearedArg = false, ): Promise { let acquiredClaim = false; if (claimedPurposeArg) { if (this.captureLifecycleClaim(recordArg.captureKey) !== claimedPurposeArg) { throw new Error('The Git capture lifecycle deletion claim is missing.'); } } else { const existingClaim = this.claimCaptureLifecycle(recordArg.captureKey, 'deletion'); if (existingClaim) throw new GitReversionRecoveryRequiredError( 'The Git capture already has an active lifecycle operation.', ); acquiredClaim = true; } try { if (!privateIntentsClearedArg) { const hasPendingIntent = await this.withMetadataMutation(async () => ( (await this.listPrivateIntentsUnlocked()).some((intent) => ( intent.captureKey === recordArg.captureKey )) )); if (hasPendingIntent) { throw new GitReversionRecoveryRequiredError( 'A private Git intent must recover before its capture can be deleted.', ); } } const releaseIntent = recordArg.phase === 'release-intent' ? recordArg : this.lifecycleRecord(recordArg.captureKey, recordArg.projectId, 'release-intent'); if (recordArg.phase !== 'release-intent' && recordArg.phase !== 'deleting') { await this.writeLifecycleRecord(releaseIntent); } await this.writeLifecycleRecord(this.lifecycleRecord( recordArg.captureKey, recordArg.projectId, 'deleting', )); await this.fault({ point: 'capture.before-root-delete', captureKey: recordArg.captureKey }); await this.assertPrivateCaptureTree(recordArg.captureKey); await this.assertPrivateDirectory(this.captureRoot(recordArg.captureKey)).then( () => this.hardenPrivateTree(this.captureRoot(recordArg.captureKey)), (errorArg) => { if ((errorArg as NodeJS.ErrnoException).code !== 'ENOENT') throw errorArg; }, ); await this.removePrivateTree(this.captureRoot(recordArg.captureKey)); await this.fault({ point: 'capture.after-root-delete', captureKey: recordArg.captureKey }); await this.syncCaptureRecordRoot(); await this.fault({ point: 'capture.after-root-delete-fsync', captureKey: recordArg.captureKey }); this.captureAggregateBytes -= this.captureRecordBytes.get(recordArg.captureKey) ?? 0; this.captureRecordBytes.delete(recordArg.captureKey); const refPrefix = `\0${this.captureRefRoot(recordArg.captureKey)}/`; for (const [key, charge] of [...this.privateRefCharges]) { if (!key.includes(refPrefix)) continue; this.privateRefCharges.delete(key); this.privateRefAggregateBytes -= charge.bytes; } await this.writeLifecycleRecord(this.lifecycleRecord( recordArg.captureKey, recordArg.projectId, 'removed', )); await this.removeLifecycleRecord(recordArg.captureKey); } finally { if (acquiredClaim) this.releaseCaptureLifecycleClaim(recordArg.captureKey, 'deletion'); } } private async assertPrivateCaptureTree(captureKeyArg: string): Promise { const root = this.captureRoot(captureKeyArg); let rootEntries: plugins.fs.Dirent[]; try { await this.inspectPrivateTree(root); rootEntries = await this.privateDirectoryEntries(root); } catch (errorArg) { if ((errorArg as NodeJS.ErrnoException).code === 'ENOENT') return; throw errorArg; } for (const entry of rootEntries) { if ( entry.name !== 'stores' && entry.name !== 'record.json' && !/^record\.json\.[1-9][0-9]*\.[0-9a-f]{20}\.tmp$/u.test(entry.name) ) throw new GitReversionFencedError('A private capture root has an unexpected entry.'); if (entry.name === 'stores') { if (!entry.isDirectory() || entry.isSymbolicLink()) { throw new GitReversionFencedError('The private capture store root is invalid.'); } } else if (!entry.isFile() || entry.isSymbolicLink()) { throw new GitReversionFencedError('A private capture record entry is invalid.'); } } const durable = await this.readDurableCaptureRecord(captureKeyArg); const repositories = new Map((durable?.manifest.repositories ?? []).map((entry) => [ entry.repository.repositoryId, entry.repository, ])); const storesPath = this.captureStoresRoot(captureKeyArg); let stores: plugins.fs.Dirent[]; try { stores = await this.privateDirectoryEntries(storesPath); } catch (errorArg) { if ((errorArg as NodeJS.ErrnoException).code === 'ENOENT' && !durable) return; throw errorArg; } for (const store of stores) { if (!/^git-[0-9a-f]{32}$/u.test(store.name) || !store.isDirectory() || store.isSymbolicLink()) { throw new GitReversionFencedError('A private capture store entry is invalid.'); } const repository = repositories.get(store.name); if (repository) { await this.assertPrivateGitStore(captureKeyArg, repository); continue; } const storeRoot = this.captureStoreRoot(captureKeyArg, store.name); const entries = await this.privateDirectoryEntries(storeRoot); if (entries.some((entry) => ( !['git', 'generations'].includes(entry.name) || !entry.isDirectory() || entry.isSymbolicLink() ))) throw new GitReversionFencedError('A partial private Git store has an unexpected entry.'); const configPath = plugins.path.join(storeRoot, 'git', 'config'); const config = await this.readBoundedRegularFile( configPath, 4096, 'Private Git config', undefined, true, ).then( (bytes) => decodeUtf8(bytes, 'Private Git config'), (errorArg: NodeJS.ErrnoException) => { if (errorArg.code === 'ENOENT') return undefined; throw errorArg; }, ); if (config !== undefined) { const format = config === this.privateStoreConfig('sha1') ? 'sha1' : config === this.privateStoreConfig('sha256') ? 'sha256' : undefined; if (!format) throw new GitReversionFencedError('A partial private Git config diverged.'); await this.assertPrivateGitStoreByFormat(captureKeyArg, store.name, format); } } } private async reconcileActiveFinalizedCaptureLifecycle( snapshotArg: ICaptureLifecycleRecord, ): Promise { if (snapshotArg.phase !== 'active' || snapshotArg.publication !== null) return; const durable = await this.readDurableCaptureRecord(snapshotArg.captureKey); if (!durable || durable.manifest.phase !== 'finalized') return; await this.assertInitialFinalizedCaptureRecord(durable, snapshotArg.projectId); const existingClaim = this.claimCaptureLifecycle(snapshotArg.captureKey, 'finalization'); if (existingClaim) return; try { const currentLifecycle = await this.readLifecycleRecord(snapshotArg.captureKey); const currentDurable = await this.readDurableCaptureRecord(snapshotArg.captureKey); if (!currentLifecycle || !recordsEqual(currentLifecycle, snapshotArg)) { throw new GitReversionRecoveryRequiredError( 'The Git capture lifecycle changed during finalization recovery.', ); } if (!currentDurable || !recordsEqual(currentDurable, durable)) { throw new GitReversionFencedError( 'The durable finalized Git capture changed during lifecycle recovery.', ); } await this.assertInitialFinalizedCaptureRecord(currentDurable, currentLifecycle.projectId); await this.publishCaptureLifecycle( currentLifecycle.captureKey, currentLifecycle.projectId, 'finalize', ); } finally { this.releaseCaptureLifecycleClaim(snapshotArg.captureKey, 'finalization'); } } private async recoverCaptureLifecycles(signalArg?: AbortSignal): Promise { if (this.hasCaptureLifecycleClaim('deletion')) throw new GitReversionRecoveryRequiredError( 'A Git capture deletion is still active.', ); const claimed: ICaptureLifecycleRecord[] = []; try { for (const snapshot of await this.listLifecycleRecords()) { signalArg?.throwIfAborted(); const activeClaim = this.captureLifecycleClaim(snapshot.captureKey); if (activeClaim === 'deletion') throw new GitReversionRecoveryRequiredError( 'A Git capture deletion is still active.', ); if (activeClaim === 'creation' || activeClaim === 'finalization') continue; if ( snapshot.phase === 'creation' || ['release-intent', 'deleting', 'removed'].includes(snapshot.phase) ) { const lifecycle = await this.claimLifecycleRecordForRecovery(snapshot); if (lifecycle) claimed.push(lifecycle); continue; } if (snapshot.phase === 'prepared-publication') { const record = await this.readDurableCaptureRecord(snapshot.captureKey); if (!record) throw new GitReversionFencedError('An acknowledged capture publication is missing.'); const expected = snapshot.publication === 'finalize' ? 'finalized' : 'prepared'; if ( record.manifest.phase !== expected || record.manifest.identity.projectId !== snapshot.projectId ) { throw new GitReversionFencedError('An acknowledged capture publication phase diverged.'); } if (snapshot.publication === 'finalize') { await this.assertInitialFinalizedCaptureRecord(record, snapshot.projectId); } await this.writeLifecycleRecord(this.lifecycleRecord( snapshot.captureKey, snapshot.projectId, snapshot.publication === 'finalize' ? 'finalized' : 'active', )); continue; } await this.reconcileActiveFinalizedCaptureLifecycle(snapshot); } if (claimed.length > 0) { await this.removePrivateIntentsForCaptures( new Set(claimed.map((entry) => entry.captureKey)), signalArg, ); for (const lifecycle of claimed) { await this.deleteCaptureRootWithLifecycle(lifecycle, 'deletion', true); } } } finally { for (const lifecycle of claimed) { if (this.captureLifecycleClaim(lifecycle.captureKey) === 'deletion') { this.releaseCaptureLifecycleClaim(lifecycle.captureKey, 'deletion'); } } } if (this.hasCaptureLifecycleClaim('deletion')) throw new GitReversionRecoveryRequiredError( 'A Git capture deletion is still active.', ); } private captureRecordPath(captureKeyArg: string): string { if (!/^[0-9a-f]{64}$/u.test(captureKeyArg)) { throw new Error('The repository-free capture key is invalid.'); } return plugins.path.join(this.captureRoot(captureKeyArg), 'record.json'); } private projectCatalogPath(projectIdArg: string): string { return plugins.path.join(this.projectCatalogRoot(), `${sha256(projectIdArg)}.json`); } private assertDurableCaptureRecord(recordArg: IDurableCaptureRecord): void { if (!recordArg || typeof recordArg !== 'object' || Array.isArray(recordArg)) { throw new Error('The private capture record is invalid.'); } const keys = Object.keys(recordArg).sort().join(','); const expectedKeys = recordArg.state === undefined ? 'captureKey,generationIds,kind,manifest,ownerKey,revision,schemaVersion' : 'captureKey,generationIds,kind,manifest,ownerKey,revision,schemaVersion,state'; if ( recordArg.schemaVersion !== 1 || recordArg.kind !== 'capture' || recordArg.ownerKey !== this.ownerKey || !/^[0-9a-f]{64}$/u.test(recordArg.captureKey) || keys !== expectedKeys || !Number.isSafeInteger(recordArg.revision) || recordArg.revision < 0 || !recordArg.generationIds || typeof recordArg.generationIds !== 'object' || Array.isArray(recordArg.generationIds) ) throw new Error('The private capture record is invalid.'); this.assertManifest(recordArg.manifest); if ( recordArg.manifest.captureKey !== recordArg.captureKey || (recordArg.manifest.phase === 'prepared' && recordArg.state !== undefined) || (recordArg.manifest.phase === 'finalized' && recordArg.state === undefined) ) throw new Error('The capture record phase is inconsistent.'); const repositoryIds = new Set(recordArg.manifest.repositories.map((entry) => ( entry.repository.repositoryId ))); let generationCount = 0; for (const [repositoryId, generationIds] of Object.entries(recordArg.generationIds)) { if ( !repositoryIds.has(repositoryId) || !Array.isArray(generationIds) || generationIds.some((generationId) => !/^[0-9a-f]{64}$/u.test(generationId)) || new Set(generationIds).size !== generationIds.length ) throw new Error('The capture object-generation enrollment is invalid.'); generationCount += generationIds.length; } if (generationCount > this.limits.maximumGenerationsPerCapture) { throw new Error('The capture object-generation limit was exceeded.'); } if (recordArg.state) this.assertCaptureState(recordArg.state, recordArg.manifest); } private async assertInitialFinalizedCaptureRecord( recordArg: IDurableCaptureRecord, projectIdArg: string, ): Promise { this.assertDurableCaptureRecord(recordArg); const { manifest, state } = recordArg; if ( manifest.phase !== 'finalized' || !state || manifest.identity.projectId !== projectIdArg || state.currentSide !== 'after' || state.revision !== 0 || recordArg.revision < 1 ) throw new GitReversionFencedError( 'The durable finalized Git capture is not an exact initial finalization.', ); const project = this.projects.get(projectIdArg); if (project && ( manifest.projectDirectory !== project.directory || !filesystemIdentitiesEqual(manifest.projectIdentity, project.identity) )) throw new GitReversionFencedError( 'The durable finalized Git capture project binding diverged.', ); const reference: TGitReversionReference = { schemaVersion: 1, captureKey: recordArg.captureKey, manifestId: state.manifestId, }; const changed = manifest.repositories.some((entry) => ( !this.semanticSidesEqual(entry.before, entry.after!) )); const expectedOutcome: TGitReversionFinalizedOutcome = manifest.reasonCode ? { disposition: 'nonrevertible', reference, reasonCode: manifest.reasonCode, ...(manifest.affectedWorkspaces.length > 0 ? { affectedWorkspaces: manifest.affectedWorkspaces } : {}), } : changed ? { disposition: 'revertible', reference, affectedWorkspaces: manifest.affectedWorkspaces, } : { disposition: 'no-change', reference }; if (!recordsEqual(state.outcome, expectedOutcome)) { throw new GitReversionFencedError( 'The durable finalized Git capture outcome is inconsistent.', ); } for (const entry of manifest.repositories) { const enrolled = new Set(recordArg.generationIds[entry.repository.repositoryId] ?? []); for (const side of [entry.before, entry.after!]) { if (side.objectGenerationId && !enrolled.has(side.objectGenerationId)) { throw new GitReversionFencedError( 'The durable finalized Git capture has an unenrolled object generation.', ); } } } if (manifest.reasonCode === discoveryLimitReasonCode) { this.assertDiscoveryLimitCaptureRecord(recordArg); if ((await this.privateDirectoryEntries(this.captureStoresRoot(recordArg.captureKey))).length !== 0) { throw new GitReversionFencedError( 'The discovery-limit Git capture unexpectedly contains repository state.', ); } } } private async readDurableCaptureRecord( captureKeyArg: string, ): Promise { const path = this.captureRecordPath(captureKeyArg); let bytes: Buffer; try { await this.assertPrivatePathAncestry(this.captureRoot(captureKeyArg)); bytes = await this.readBoundedRegularFile( path, this.limits.maximumCaptureRecordBytes, 'Private capture file', undefined, true, ); } catch (errorArg) { if ((errorArg as NodeJS.ErrnoException).code === 'ENOENT') return undefined; throw errorArg; } let parsed: unknown; try { parsed = JSON.parse(decodeUtf8(bytes, 'Capture metadata')); } catch (errorArg) { throw new Error('The private capture is malformed.', { cause: errorArg }); } const record = parsed as IDurableCaptureRecord; this.assertDurableCaptureRecord(record); if (record.captureKey !== captureKeyArg || path !== this.captureRecordPath(record.captureKey)) { throw new Error('The private capture escaped its owned path.'); } return record; } private async listDurableCaptureRecords(): Promise { const entryCount = await this.countDirectoryEntries(this.captureRecordRoot()); if (entryCount > this.limits.maximumCaptures) { throw new Error('The capture count limit was exceeded.'); } const entries: plugins.fs.Dirent[] = []; const directory = await plugins.fs.promises.opendir(this.captureRecordRoot()); try { for await (const entry of directory) entries.push(entry); } finally { await directory.close().catch(() => undefined); } const records: IDurableCaptureRecord[] = []; for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { const match = /^([0-9a-f]{64})$/u.exec(entry.name); if (!match || !entry.isDirectory() || entry.isSymbolicLink()) { throw new Error('The capture directory contains an unexpected entry.'); } await this.assertPrivateCaptureTree(match[1]!); const record = await this.readDurableCaptureRecord(match[1]!); if (!record) continue; records.push(record); } return records; } private async loadCaptureRecordQuota(): Promise { this.captureRecordBytes.clear(); this.captureAggregateBytes = 0; for (const record of await this.listDurableCaptureRecords()) { const stat = await plugins.fs.promises.lstat(this.captureRecordPath(record.captureKey)); this.captureRecordBytes.set(record.captureKey, stat.size); this.captureAggregateBytes += stat.size; if (this.captureAggregateBytes > this.limits.maximumCaptureAggregateBytes) { throw new Error('The capture metadata aggregate byte limit was exceeded.'); } } } private async cleanupCaptureRecordTemporaryFiles(): Promise { await this.countDirectoryEntries(this.captureRecordRoot()); const temporaryPattern = /^record\.json\.[1-9][0-9]*\.[0-9a-f]{20}\.tmp$/u; let removed = false; const directory = await plugins.fs.promises.opendir(this.captureRecordRoot()); try { for await (const entry of directory) { if (!/^[0-9a-f]{64}$/u.test(entry.name) || !entry.isDirectory() || entry.isSymbolicLink()) continue; const captureRoot = plugins.path.join(this.captureRecordRoot(), entry.name); await this.assertPrivateDirectory(captureRoot); const captureDirectory = await plugins.fs.promises.opendir(captureRoot); try { for await (const child of captureDirectory) { if (!temporaryPattern.test(child.name)) continue; const path = plugins.path.join(captureRoot, child.name); const stat = await plugins.fs.promises.lstat(path); if ( !child.isFile() || child.isSymbolicLink() || !stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0 || (typeof process.getuid === 'function' && stat.uid !== process.getuid()) ) throw new Error('A capture temporary file is invalid.'); await plugins.fs.promises.rm(path); removed = true; } } finally { await captureDirectory.close().catch(() => undefined); } if (removed) await this.syncDirectory(captureRoot); } } finally { await directory.close().catch(() => undefined); } if (removed) await this.syncCaptureRecordRoot(); } private withCaptureMutation( operationArg: () => Promise, allowSealedArg = false, ): Promise { if (allowSealedArg) this.assertMaintenanceUsable(); else this.assertUsable(); const predecessor = this.captureMutationTail; const operation = predecessor.catch(() => undefined).then(() => { if (allowSealedArg) this.assertMaintenanceUsable(); else this.assertUsable(); return operationArg(); }); this.captureMutationTail = operation.then(() => undefined, () => undefined); this.activeCaptureMutations.add(operation); void operation.finally(() => this.activeCaptureMutations.delete(operation)).catch(() => undefined); return operation; } private async syncCaptureRecordRoot(): Promise { const directory = await plugins.fs.promises.open(this.captureRecordRoot(), 'r'); try { await directory.sync(); } finally { await directory.close(); } } private writeDurableCaptureRecord(recordArg: IDurableCaptureRecord): Promise { return this.withCaptureMutation(async () => { this.assertDurableCaptureRecord(recordArg); const target = this.captureRecordPath(recordArg.captureKey); const temporary = `${target}.${process.pid}.${plugins.crypto.randomBytes(10).toString('hex')}.tmp`; const bytes = Buffer.from(`${canonicalJson(recordArg)}\n`, 'utf8'); const previousBytes = this.captureRecordBytes.get(recordArg.captureKey) ?? 0; const nextCount = this.captureRecordBytes.has(recordArg.captureKey) ? this.captureRecordBytes.size : this.captureRecordBytes.size + 1; const nextCaptureCount = new Set([ ...this.captureRecordBytes.keys(), ...this.lifecycleRecordBytes.keys(), recordArg.captureKey, ]).size; const nextAggregate = this.captureAggregateBytes - previousBytes + bytes.byteLength; if ( nextCount > this.limits.maximumCaptureRecords || nextCaptureCount > this.limits.maximumCaptures ) { throw new Error('The capture count limit was reached.'); } if (bytes.byteLength > this.limits.maximumCaptureRecordBytes) { throw new Error('The capture record byte limit was exceeded.'); } if (nextAggregate > this.limits.maximumCaptureAggregateBytes) { throw new Error('The capture metadata aggregate byte limit was exceeded.'); } await this.ensurePrivateDirectory(this.captureRoot(recordArg.captureKey)); let handle: plugins.fs.promises.FileHandle | undefined; try { handle = await plugins.fs.promises.open(temporary, 'wx', 0o600); await handle.writeFile(bytes); await handle.sync(); await handle.close(); handle = undefined; await plugins.fs.promises.rename(temporary, target); await this.fault({ point: 'atomic.after-rename-before-directory-fsync', captureKey: recordArg.captureKey, }); await this.syncDirectory(this.captureRoot(recordArg.captureKey)); await this.syncCaptureRecordRoot(); } catch (errorArg) { this.invalidateRecovery(); throw errorArg; } finally { await handle?.close().catch(() => undefined); await plugins.fs.promises.rm(temporary, { force: true }).catch(() => undefined); } this.captureRecordBytes.set(recordArg.captureKey, bytes.byteLength); this.captureAggregateBytes = nextAggregate; }); } private deleteDurableCaptureRecord(captureKeyArg: string): Promise { return this.withCaptureMutation(async () => { const previousBytes = this.captureRecordBytes.get(captureKeyArg); if (previousBytes === undefined) { const existing = await this.readDurableCaptureRecord(captureKeyArg); if (!existing) return; throw new Error('The repository-free capture quota state is incomplete.'); } await plugins.fs.promises.rm(this.captureRecordPath(captureKeyArg), { force: true }); await this.syncDirectory(this.captureRoot(captureKeyArg)); await this.syncCaptureRecordRoot(); this.captureRecordBytes.delete(captureKeyArg); this.captureAggregateBytes -= previousBytes; }); } private async ensurePrivateDirectory(pathArg: string): Promise { try { const existing = await plugins.fs.promises.lstat(pathArg); if (!existing.isDirectory() || existing.isSymbolicLink()) { throw new Error(`The private Git path is not an owned directory: ${pathArg}`); } if (typeof process.getuid === 'function' && existing.uid !== process.getuid()) { throw new Error(`The private Git directory has another owner: ${pathArg}`); } if ((existing.mode & 0o077) !== 0) { throw new Error(`The private Git directory has unsafe permissions: ${pathArg}`); } } catch (errorArg) { if ((errorArg as NodeJS.ErrnoException).code !== 'ENOENT') throw errorArg; const parent = plugins.path.dirname(pathArg); if (parent !== pathArg && pathArg !== this.rootDirectory) { await this.ensurePrivateDirectory(parent); } await plugins.fs.promises.mkdir(pathArg, { mode: 0o700 }); } await this.assertPrivateDirectory(pathArg); } private async assertPrivateDirectory(pathArg: string): Promise { await this.assertPrivatePathAncestry(pathArg); const stat = await plugins.fs.promises.lstat(pathArg); if ( !stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0 || (typeof process.getuid === 'function' && stat.uid !== process.getuid()) ) throw new Error(`The private Git directory is not exclusive and owned: ${pathArg}`); } private async rememberProjectRepositories( projectArg: IRegisteredProject, repositoriesArg: readonly IGitReversionRepositoryIdentity[], signalArg?: AbortSignal, ): Promise { return this.withProjectCatalogLock(projectArg.projectId, async () => { signalArg?.throwIfAborted(); const existing = await this.readProjectRepositoryCatalog(projectArg); const repositories = new Map(); for (const repository of repositoriesArg) { this.assertRepositoryIdentity(repository); repositories.set(repository.repositoryId, repository); } const remembered = [...repositories.values()].sort(compareRepositories); if (remembered.length > this.limits.maximumRepositories) { throw new Error('The remembered Git repository limit was exceeded.'); } const catalog: IProjectRepositoryCatalog = { schemaVersion: 1, projectId: projectArg.projectId, projectDirectory: projectArg.directory, projectIdentity: structuredClone(projectArg.identity), repositories: remembered, }; if (existing && canonicalJson(existing) === canonicalJson(catalog)) return remembered; await this.writeProjectRepositoryCatalog(catalog, signalArg); return remembered; }); } private async readProjectRepositoryCatalog( projectArg: IRegisteredProject, ): Promise { const path = this.projectCatalogPath(projectArg.projectId); let stat: plugins.fs.Stats; try { stat = await plugins.fs.promises.lstat(path); } catch (errorArg) { if ((errorArg as NodeJS.ErrnoException).code === 'ENOENT') return undefined; throw errorArg; } if ( !stat.isFile() || stat.isSymbolicLink() || stat.size > this.limits.maximumMetadataBytes || (typeof process.getuid === 'function' && stat.uid !== process.getuid()) ) throw new Error('The private Git project repository catalog is invalid.'); const bytes = await this.readBoundedRegularFile( path, this.limits.maximumMetadataBytes, 'Private Git project repository catalog', undefined, true, ); const parsed = JSON.parse(decodeUtf8(bytes, 'Private Git project repository catalog')) as unknown; if ( !parsed || typeof parsed !== 'object' || Array.isArray(parsed) || Object.keys(parsed).sort().join(',') !== 'projectDirectory,projectId,projectIdentity,repositories,schemaVersion' ) throw new Error('The private Git project repository catalog is invalid.'); const catalog = parsed as IProjectRepositoryCatalog; this.assertFilesystemIdentity(catalog.projectIdentity, catalog.projectDirectory, 'directory'); if ( catalog.schemaVersion !== 1 || catalog.projectId !== projectArg.projectId || catalog.projectDirectory !== projectArg.directory || !filesystemIdentitiesEqual(catalog.projectIdentity, projectArg.identity) || !Array.isArray(catalog.repositories) || catalog.repositories.length > this.limits.maximumRepositories ) throw new Error('The private Git project repository catalog is invalid.'); const repositoryIds = new Set(); for (const repository of catalog.repositories) { this.assertRepositoryIdentity(repository); if (repositoryIds.has(repository.repositoryId)) { throw new Error('The private Git project repository catalog repeats a repository.'); } repositoryIds.add(repository.repositoryId); } return catalog; } private async writeProjectRepositoryCatalog( catalogArg: IProjectRepositoryCatalog, signalArg?: AbortSignal, ): Promise { signalArg?.throwIfAborted(); const target = this.projectCatalogPath(catalogArg.projectId); const temporary = `${target}.${process.pid}.${plugins.crypto.randomBytes(10).toString('hex')}.tmp`; const bytes = Buffer.from(`${canonicalJson(catalogArg)}\n`, 'utf8'); if (bytes.byteLength > this.limits.maximumMetadataBytes) { throw new Error('The private Git project repository catalog exceeds its metadata limit.'); } let handle: plugins.fs.promises.FileHandle | undefined; try { handle = await plugins.fs.promises.open(temporary, 'wx', 0o600); await handle.writeFile(bytes); await handle.sync(); await handle.close(); handle = undefined; signalArg?.throwIfAborted(); await plugins.fs.promises.rename(temporary, target); await this.fault({ point: 'atomic.after-rename-before-directory-fsync' }); const directory = await plugins.fs.promises.open(this.projectCatalogRoot(), 'r'); try { await directory.sync(); } finally { await directory.close(); } } catch (errorArg) { this.invalidateRecovery(); throw errorArg; } finally { await handle?.close().catch(() => undefined); await plugins.fs.promises.rm(temporary, { force: true }).catch(() => undefined); } } private async deleteProjectRepositoryCatalog( projectArg: IRegisteredProject, signalArg?: AbortSignal, ): Promise { await this.withProjectCatalogLock(projectArg.projectId, async () => { signalArg?.throwIfAborted(); await plugins.fs.promises.rm(this.projectCatalogPath(projectArg.projectId), { force: true }); const directory = await plugins.fs.promises.open(this.projectCatalogRoot(), 'r'); try { await directory.sync(); } finally { await directory.close(); } }); } private async withProjectCatalogLock( projectIdArg: string, operationArg: () => Promise, ): Promise { this.assertUsable(); const predecessor = this.projectCatalogTails.get(projectIdArg) ?? Promise.resolve(); let release!: () => void; const gate = new Promise((resolve) => { release = resolve; }); const tail = predecessor.catch(() => undefined).then(() => gate); this.projectCatalogTails.set(projectIdArg, tail); const operation = (async () => { await predecessor.catch(() => undefined); this.assertUsable(); try { return await operationArg(); } finally { release(); if (this.projectCatalogTails.get(projectIdArg) === tail) { this.projectCatalogTails.delete(projectIdArg); } } })(); this.activeProjectCatalogOperations.add(operation); try { return await operation; } finally { this.activeProjectCatalogOperations.delete(operation); } } private async cleanupProjectCatalogTemporaryFiles(): Promise { await this.countDirectoryEntries(this.projectCatalogRoot()); const pattern = /^[0-9a-f]{64}\.json\.[1-9][0-9]*\.[0-9a-f]{20}\.tmp$/u; const directory = await plugins.fs.promises.opendir(this.projectCatalogRoot()); let removed = false; try { for await (const entry of directory) { if (!pattern.test(entry.name)) continue; const path = plugins.path.join(this.projectCatalogRoot(), entry.name); const stat = await plugins.fs.promises.lstat(path); if ( !entry.isFile() || entry.isSymbolicLink() || !stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0 || (typeof process.getuid === 'function' && stat.uid !== process.getuid()) ) throw new Error('A private Git project-catalog temporary file is invalid.'); await plugins.fs.promises.rm(path); removed = true; } } finally { await directory.close().catch(() => undefined); } if (removed) await this.syncDirectory(this.projectCatalogRoot()); } private async cleanupOwnedTemporaryIndexDirectory(): Promise { await this.countDirectoryEntries(this.indexRoot()); const pattern = new RegExp( `^${this.ownerKey}-git-[0-9a-f]{32}-[1-9][0-9]*-[0-9a-f]{20}(?:\\.lock)?$`, 'u', ); const directory = await plugins.fs.promises.opendir(this.indexRoot()); let removed = false; try { for await (const entry of directory) { if (!pattern.test(entry.name)) continue; const path = plugins.path.join(this.indexRoot(), entry.name); const stat = await plugins.fs.promises.lstat(path); if ( !entry.isFile() || entry.isSymbolicLink() || !stat.isFile() || stat.isSymbolicLink() || (typeof process.getuid === 'function' && stat.uid !== process.getuid()) ) throw new Error('A private Git temporary index is invalid.'); await plugins.fs.promises.rm(path, { force: true }); removed = true; } } finally { await directory.close().catch(() => undefined); } if (removed) await this.syncDirectory(this.indexRoot()); } private async cleanupRepositoryTemporaryIndexes( repositoryArg: IGitReversionRepositoryIdentity, ): Promise { const directory = plugins.path.dirname(repositoryArg.indexPath); const prefix = `${plugins.path.basename(repositoryArg.indexPath)}.hcon-${this.ownerKey}-`; await this.assertRepositoryBinding(repositoryArg); await this.countDirectoryEntries(directory); const temporaryPattern = /^[1-9][0-9]*-[0-9a-f]{16}(?:\.lock)?$/u; const ownerPattern = /^index-[0-9a-f]{32}\.owner$/u; const temporaryPaths: string[] = []; const ownerPaths: string[] = []; const opened = await plugins.fs.promises.opendir(directory); try { for await (const entry of opened) { if (!entry.name.startsWith(prefix)) continue; const suffix = entry.name.slice(prefix.length); if (!temporaryPattern.test(suffix) && !ownerPattern.test(suffix)) continue; const path = plugins.path.join(directory, entry.name); const stat = await plugins.fs.promises.lstat(path); if ( !entry.isFile() || entry.isSymbolicLink() || !stat.isFile() || stat.isSymbolicLink() || (typeof process.getuid === 'function' && stat.uid !== process.getuid()) ) throw new GitReversionFencedError('An owned Git index sidecar is invalid.'); (ownerPattern.test(suffix) ? ownerPaths : temporaryPaths).push(path); } } finally { await opened.close().catch(() => undefined); } const lockPath = `${repositoryArg.indexPath}.lock`; const lockStat = await plugins.fs.promises.lstat(lockPath).catch((errorArg: NodeJS.ErrnoException) => { if (errorArg.code === 'ENOENT') return undefined; throw errorArg; }); if (lockStat) { const ownerStats = await Promise.all(ownerPaths.map(async (path) => ({ path, stat: await plugins.fs.promises.lstat(path), }))); const exactOwner = ownerStats.find(({ stat }) => ( stat.isFile() && !stat.isSymbolicLink() && lockStat.isFile() && !lockStat.isSymbolicLink() && stat.dev === lockStat.dev && stat.ino === lockStat.ino && stat.uid === lockStat.uid && stat.nlink >= 2 && lockStat.nlink >= 2 && (typeof process.getuid !== 'function' || stat.uid === process.getuid()) )); if (!exactOwner) { throw new GitReversionFencedError('An external Git index lock is present and was preserved.'); } await this.assertRepositoryBinding(repositoryArg); await plugins.fs.promises.rm(lockPath); await this.syncDirectory(directory); } let removed = false; for (const path of [...temporaryPaths, ...ownerPaths]) { await this.assertRepositoryBinding(repositoryArg); await plugins.fs.promises.rm(path, { force: true }); removed = true; } if (removed) await this.syncDirectory(directory); } private async discoverProjectRepositories( projectArg: IRegisteredProject, signalArg?: AbortSignal, ): Promise { const projectDirectory = await this.assertProjectDirectory(projectArg); const candidates = new Set(); const containing = await this.tryRepositoryRoot(projectDirectory, signalArg); if (containing) { candidates.add(containing); if (candidates.size > this.limits.maximumRepositories) { throw new GitReversionDiscoveryLimitError('maximumRepositories'); } } const queue: Array<{ path: string; depth: number }> = [{ path: projectDirectory, depth: 0 }]; let queueIndex = 0; let scanned = 0; while (queueIndex < queue.length) { signalArg?.throwIfAborted(); const next = queue[queueIndex++]!; scanned += 1; if (scanned > this.limits.maximumScannedDirectories) { throw new GitReversionDiscoveryLimitError('maximumScannedDirectories'); } let directory: plugins.fs.Dir; try { directory = await plugins.fs.promises.opendir(next.path); } catch (errorArg) { throw errorArg; } try { for await (const entry of directory) { signalArg?.throwIfAborted(); if ( entry.name === '.git' && !entry.isSymbolicLink() && (entry.isDirectory() || entry.isFile()) ) { candidates.add(next.path); if (candidates.size > this.limits.maximumRepositories) { throw new GitReversionDiscoveryLimitError('maximumRepositories'); } continue; } if ( next.depth >= this.limits.maximumScanDepth || !entry.isDirectory() || entry.isSymbolicLink() ) continue; const child = plugins.path.join(next.path, entry.name); if (Buffer.byteLength(child, 'utf8') > 4096) { throw new Error('A repository discovery path exceeds the path limit.'); } if (queue.length >= this.limits.maximumScannedDirectories) { throw new GitReversionDiscoveryLimitError('maximumScannedDirectories'); } queue.push({ path: child, depth: next.depth + 1 }); } } finally { await directory.close().catch(() => undefined); } } let repositories = new Map(); for (const candidate of [...candidates].sort()) { const repository = await this.describeRepository(candidate, projectDirectory, signalArg); repositories.set(repository.repositoryId, repository); if (repositories.size > this.limits.maximumRepositories) { throw new GitReversionDiscoveryLimitError('maximumRepositories'); } } repositories = new Map((await this.rememberProjectRepositories( projectArg, [...repositories.values()], signalArg, )).map((repository) => [repository.repositoryId, repository])); const freshSources = [...repositories.values()]; const worktreeRefs = await this.listWorktreeRecordRefs(); for (const ref of worktreeRefs) { const loaded = await this.readWorktreeRecordByRef(ref); if (!loaded) continue; const record = loaded.value; this.assertWorktreeRecord(record); if ( record.ownerKey !== this.ownerKey || record.projectId !== projectArg.projectId || !['created', 'registered', 'active', 'removing', 'retainedDirty', 'fenced'].includes( record.state, ) ) continue; if (record.state === 'fenced') continue; const source = freshSources.find((repository) => ( this.repositoryBindingsEqual(repository, record.sourceRepository) )); if (!source) continue; try { await this.assertWorktreePathBinding(record); await this.assertOwnedWorktreeDiscoveryRegistration(source, record, signalArg); const owned = await this.describeRepository( record.path, projectDirectory, signalArg, { ownedWorktreeAuthority: true }, ); if ( owned.commonDir !== source.commonDir || owned.commonId !== source.commonId ) throw new Error('An owned Git worktree changed repository identity.'); repositories.set(owned.repositoryId, owned); } catch { record.state = 'fenced'; await this.writeWorktreeRecord(ref, record, loaded.oid).catch(() => undefined); } if (repositories.size > this.limits.maximumRepositories) { throw new GitReversionDiscoveryLimitError('maximumRepositories'); } } const result = [...repositories.values()].sort(compareRepositories); for (const repository of result) { repository.excludedRepositoryPaths = result.flatMap((nested) => { if (nested.repositoryId === repository.repositoryId || !isWithin(repository.root, nested.root)) { return []; } const relative = plugins.path.relative(repository.root, nested.root).split(plugins.path.sep).join('/'); return relative.length > 0 ? [relative] : []; }).sort(); } return result; } private async tryRepositoryRoot( directoryArg: string, signalArg?: AbortSignal, ): Promise { const result = await this.runGit(['rev-parse', '--show-toplevel'], { cwd: directoryArg, signal: signalArg, acceptedExitCodes: [0, 128], }); if (result.exitCode === 128) return undefined; return plugins.fs.promises.realpath(trimLine(result.stdout)); } private async assertGitEntryTargetsGitDirectory( rootArg: string, gitEntryPathArg: string, gitDirectoryArg: string, signalArg?: AbortSignal, ): Promise { const resolved = await this.runGit([ 'rev-parse', '--path-format=absolute', '--resolve-git-dir', gitEntryPathArg, ], { cwd: rootArg, signal: signalArg, }); const resolvedGitDirectory = await plugins.fs.promises.realpath(trimLine(resolved.stdout)); if (resolvedGitDirectory !== gitDirectoryArg) { throw new GitReversionFencedError( 'The Git repository marker resolves to another Git directory.', ); } } private async describeRepository( directoryArg: string, projectDirectoryArg: string, signalArg?: AbortSignal, optionsArg: { ownedWorktreeAuthority?: boolean } = {}, ): Promise { const [topologyResult, worktreesResult, partialCloneResult, promisorResult] = await Promise.all([ this.runGit([ 'rev-parse', '--show-toplevel', '--path-format=absolute', '--absolute-git-dir', '--git-common-dir', '--git-path', 'objects', '--git-path', 'index', '--show-object-format', '--is-shallow-repository', ], { cwd: directoryArg, signal: signalArg }), this.runGit(['worktree', 'list', '--porcelain', '-z'], { cwd: directoryArg, signal: signalArg, }), this.runGit(['config', '--local', '--get', 'extensions.partialClone'], { cwd: directoryArg, signal: signalArg, acceptedExitCodes: [0, 1], }), this.runGit([ 'config', '--local', '--get-regexp', '^remote\..*\.promisor$', '^(true|yes|on|1)$', ], { cwd: directoryArg, signal: signalArg, acceptedExitCodes: [0, 1], }), ]); const [ rootValue, gitDirValue, commonDirValue, objectDirValue, indexValue, formatValue, shallowValue, ] = splitExactGitLines(topologyResult.stdout, 7, 'Git repository topology'); const root = await plugins.fs.promises.realpath(rootValue!); const directory = await plugins.fs.promises.realpath(directoryArg); const projectDirectory = await plugins.fs.promises.realpath(projectDirectoryArg); if (root !== directory) { throw new GitReversionFencedError('The Git top-level does not match its discovery directory.'); } const projectRelated = isWithin(root, projectDirectory) || isWithin(projectDirectory, root); if (!projectRelated && !optionsArg.ownedWorktreeAuthority) { throw new GitReversionFencedError('The Git repository is unrelated to the registered project.'); } const scope: IGitReversionRepositoryIdentity['scope'] = projectRelated ? 'project' : 'owned-worktree'; const gitEntryPath = plugins.path.join(root, '.git'); const gitEntryStat = await plugins.fs.promises.lstat(gitEntryPath); const gitEntryType = gitEntryStat.isDirectory() ? 'directory' as const : gitEntryStat.isFile() ? 'file' as const : undefined; if (!gitEntryType || gitEntryStat.isSymbolicLink()) { throw new Error('The Git repository marker is not a regular file or directory.'); } const gitDir = await plugins.fs.promises.realpath(gitDirValue!); const commonDir = await plugins.fs.promises.realpath(commonDirValue!); const objectDir = plugins.path.resolve(objectDirValue!); const indexPath = plugins.path.resolve(indexValue!); const objectFormat = formatValue!; if (objectFormat !== 'sha1' && objectFormat !== 'sha256') { throw new Error('The Git repository object format is unsupported.'); } if ( shallowValue !== 'false' || trimLine(partialCloneResult.stdout) !== '' || trimLine(promisorResult.stdout) !== '' ) throw new Error('Shallow and partial Git repositories are unsupported.'); const alternatesPath = plugins.path.join(objectDir, 'info', 'alternates'); try { const stat = await plugins.fs.promises.lstat(alternatesPath); if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 0) { throw new Error('Git object-directory alternates are unsupported.'); } } catch (errorArg) { if ((errorArg as NodeJS.ErrnoException).code !== 'ENOENT') throw errorArg; } const registrations = this.parseWorktreeRegistrations(worktreesResult.stdout); if (!registrations.some((entry) => entry.path === root)) { throw new Error('The Git worktree is not canonically registered.'); } await this.assertGitEntryTargetsGitDirectory(root, gitEntryPath, gitDir, signalArg); const [ rootResolution, gitEntryResolution, gitDirResolution, commonDirResolution, objectDirResolution, indexParentResolution, ] = ( await Promise.all([ resolveCanonicalFilesystemObject(root, 'directory'), resolveCanonicalFilesystemObject(gitEntryPath, gitEntryType), resolveCanonicalFilesystemObject(gitDir, 'directory'), resolveCanonicalFilesystemObject(commonDir, 'directory'), resolveCanonicalFilesystemObject(objectDir, 'directory'), resolveCanonicalFilesystemObject(plugins.path.dirname(indexPath), 'directory'), ]) ); const rootIdentity = { ancestry: rootResolution.ancestry }; const gitEntryIdentity = { ancestry: gitEntryResolution.ancestry }; const gitDirIdentity = { ancestry: gitDirResolution.ancestry }; const commonDirIdentity = { ancestry: commonDirResolution.ancestry }; const objectDirIdentity = { ancestry: objectDirResolution.ancestry }; const indexParentIdentity = { ancestry: indexParentResolution.ancestry }; const commonId = `common-${sha256(canonicalJson([ commonDir, commonDirIdentity, objectDir, objectDirIdentity, ])).slice(0, 32)}`; const repository: IGitReversionRepositoryIdentity = { scope, repositoryId: `git-${sha256(canonicalJson([ commonId, scope, root, rootIdentity, gitEntryPath, gitEntryIdentity, gitDir, gitDirIdentity, indexPath, indexParentIdentity, ])).slice(0, 32)}`, commonId, objectFormat, root, rootIdentity, gitEntryPath, gitEntryIdentity, gitDir, gitDirIdentity, commonDir, commonDirIdentity, objectDir, objectDirIdentity, indexPath, indexParentIdentity, label: isWithin(projectDirectory, root) ? (plugins.path.relative(projectDirectory, root) || plugins.path.basename(root)) : plugins.path.basename(root), excludedRepositoryPaths: [], }; await this.assertRepositoryBinding(repository); const verifiedTopology = await this.git(repository, [ 'rev-parse', '--show-toplevel', '--path-format=absolute', '--absolute-git-dir', '--git-common-dir', '--git-path', 'objects', '--git-path', 'index', '--show-object-format', ], signalArg); const [ verifiedRoot, verifiedGitDir, verifiedCommonDir, verifiedObjectDir, verifiedIndex, verifiedFormat, ] = splitExactGitLines(verifiedTopology.stdout, 6, 'Verified Git repository topology'); if ( verifiedRoot !== root || verifiedGitDir !== gitDir || verifiedCommonDir !== commonDir || plugins.path.resolve(verifiedObjectDir!) !== objectDir || plugins.path.resolve(verifiedIndex!) !== indexPath || verifiedFormat !== objectFormat ) throw new GitReversionFencedError('The Git repository topology changed during binding.'); return repository; } private parseWorktreeRegistrations(valueArg: Buffer): IWorktreeRegistration[] { const fields = splitNul(valueArg, 'Git worktree metadata'); const result: IWorktreeRegistration[] = []; let current: IWorktreeRegistration | undefined; for (const field of fields) { if (field.startsWith('worktree ')) { current = { path: field.slice('worktree '.length), detached: false, locked: false, }; result.push(current); } else if (current && field.startsWith('HEAD ')) { current.headOid = field.slice('HEAD '.length); } else if (current && field.startsWith('branch ')) { current.branchRef = field.slice('branch '.length); } else if (current && field === 'detached') { current.detached = true; } else if (current && field === 'locked') { current.locked = true; } else if (current && field.startsWith('locked ')) { current.locked = true; current.lockReason = field.slice('locked '.length); } } return result; } private worktreeTopologyHash(valueArg: Buffer, currentWorktreePathArg: string): string { return sha256(canonicalJson(this.parseWorktreeRegistrations(valueArg).map((entry) => ({ path: entry.path, ...(entry.path === currentWorktreePathArg ? {} : { detached: entry.detached, ...(entry.branchRef ? { branchRef: entry.branchRef } : {}), }), locked: entry.locked, ...(entry.lockReason ? { lockReason: entry.lockReason } : {}), })))); } private pathspec(repositoryArg: IGitReversionRepositoryIdentity): string[] { return [ '.', ...repositoryArg.excludedRepositoryPaths.flatMap((path) => [ `:(exclude)${path}`, `:(exclude)${path}/**`, ]), ]; } private async estimateGenerationReservation( repositoryArg: IGitReversionRepositoryIdentity, signalArg?: AbortSignal, ): Promise { const result = await this.git(repositoryArg, [ 'ls-files', '--cached', '--others', '--exclude-standard', '-z', '--', ...this.pathspec(repositoryArg), ], signalArg); this.assertPathOutputBound(result.stdout, 'Git generation reservation paths'); const paths = splitNul(result.stdout, 'Git generation reservation paths'); if (paths.length > this.limits.maximumScavengeEntries) { throw new Error('The Git generation reservation entry limit was exceeded.'); } let bytes = result.stdout.byteLength + this.limits.maximumMetadataRecordBytes * 4 + this.limits.maximumIgnorePolicyBytes; for (const path of paths) { signalArg?.throwIfAborted(); const absolute = plugins.path.resolve(repositoryArg.root, path); if (!isWithin(repositoryArg.root, absolute)) { throw new GitReversionFencedError('A generation reservation path escaped its repository.'); } await this.assertRepositoryBinding(repositoryArg); try { const stat = await plugins.fs.promises.lstat(absolute); if (stat.isFile()) bytes += stat.size; else if (stat.isSymbolicLink()) { bytes += Buffer.byteLength(await plugins.fs.promises.readlink(absolute), 'utf8'); } } catch (errorArg) { if ((errorArg as NodeJS.ErrnoException).code !== 'ENOENT') throw errorArg; } bytes += Buffer.byteLength(path, 'utf8') + 1024; if (!Number.isSafeInteger(bytes)) throw new Error('The generation reservation size overflowed.'); } return { bytes, objects: Math.max(64, addSafeLimit(multiplySafeLimit(paths.length, 4), 64)), }; } private async readPackIndexObjectCount( pathArg: string, expectedStatArg: plugins.fs.Stats, ): Promise { const handle = await plugins.fs.promises.open( pathArg, plugins.fs.constants.O_RDONLY | plugins.fs.constants.O_NOFOLLOW | plugins.fs.constants.O_NONBLOCK, ); try { const header = Buffer.allocUnsafe(1032); const { bytesRead } = await handle.read(header, 0, header.byteLength, 0); const versioned = bytesRead >= 8 && header.readUInt32BE(0) === 0xff744f63; if (versioned && (header.readUInt32BE(4) !== 2 || bytesRead < 1032)) { throw new GitReversionFencedError('A private Git pack index version is unsupported.'); } if (!versioned && bytesRead < 1024) { throw new GitReversionFencedError('A private Git pack index is truncated.'); } const count = header.readUInt32BE(versioned ? 1028 : 1020); const current = await handle.stat(); if ( current.dev !== expectedStatArg.dev || current.ino !== expectedStatArg.ino || current.size !== expectedStatArg.size || !current.isFile() || current.nlink !== 1 ) throw new GitReversionFencedError('A private Git pack index changed while it was counted.'); return count; } finally { await handle.close(); } } private async scanObjectDirectory(pathArg: string): Promise { let bytes = 0; let objects = 0; let entries = 0; const scan = async (path: string): Promise => { const stat = await plugins.fs.promises.lstat(path); if ( stat.isSymbolicLink() || (stat.mode & 0o077) !== 0 || (typeof process.getuid === 'function' && stat.uid !== process.getuid()) ) throw new GitReversionFencedError('A private object-store entry is not exclusively owned.'); entries += 1; if (entries > this.maximumPrivateTreeEntries) { throw new Error('The private object-store scan entry limit was exceeded.'); } if (stat.isFile()) { if (stat.nlink !== 1) { throw new GitReversionFencedError('A private object-store file has an external hard link.'); } bytes += stat.size; const parentName = plugins.path.basename(plugins.path.dirname(path)); if (parentName === 'pack' && path.endsWith('.idx')) { objects += await this.readPackIndexObjectCount(path, stat); } else if (parentName !== 'pack' || (!path.endsWith('.pack') && !path.endsWith('.rev'))) { objects += 1; } if (!Number.isSafeInteger(bytes) || !Number.isSafeInteger(objects)) { throw new Error('The private object-store usage overflowed.'); } return; } if (!stat.isDirectory()) throw new GitReversionFencedError('A private object store contains a special file.'); const directory = await plugins.fs.promises.opendir(path); try { for await (const entry of directory) { await scan(plugins.path.join(path, entry.name)); } } finally { await directory.close().catch(() => undefined); } }; await scan(pathArg); return { bytes, objects }; } private generationReservationKey( captureKeyArg: string, repositoryIdArg: string, generationIdArg: string, ): string { return `${captureKeyArg}\0${repositoryIdArg}\0${generationIdArg}`; } private activeGenerationReservationKeys( reservationsArg: ReadonlyMap, ): Set { const result = new Set(); for (const reservation of reservationsArg.values()) { if (!reservation.repositoryId || !reservation.generationId) continue; result.add(this.generationReservationKey( reservation.captureKey, reservation.repositoryId, reservation.generationId, )); } return result; } private async scanCaptureStoreUsage( captureKeyArg: string, excludedGenerationKeysArg: ReadonlySet = new Set(), ): Promise { const root = this.captureStoresRoot(captureKeyArg); let directory: plugins.fs.Dir; try { await this.assertPrivatePathAncestry(root); directory = await plugins.fs.promises.opendir(root); } catch (errorArg) { if ((errorArg as NodeJS.ErrnoException).code === 'ENOENT') { return { bytes: 0, objects: 0, generations: 0 }; } throw errorArg; } let bytes = 0; let objects = 0; let generations = 0; try { for await (const entry of directory) { if (!/^git-[0-9a-f]{32}$/u.test(entry.name) || !entry.isDirectory() || entry.isSymbolicLink()) { throw new GitReversionFencedError('A private Git store entry is invalid.'); } const store = plugins.path.join(root, entry.name); await this.assertPrivateDirectory(store); const generationDirectories = await this.listGenerationObjectDirectories( captureKeyArg, entry.name, ); const committedGenerationDirectories = generationDirectories.filter((objectDirectory) => ( !excludedGenerationKeysArg.has(this.generationReservationKey( captureKeyArg, entry.name, plugins.path.basename(plugins.path.dirname(objectDirectory)), )) )); generations += committedGenerationDirectories.length; const objectDirectories = [ plugins.path.join(store, 'git', 'objects'), ...committedGenerationDirectories, ]; for (const objectDirectory of objectDirectories) { const usage = await this.scanObjectDirectory(objectDirectory); bytes += usage.bytes; objects += usage.objects; } } } finally { await directory.close().catch(() => undefined); } return { bytes, objects, generations }; } private async scanControllerStoreUsage(captureKeyArg?: string): Promise< IGenerationUsage & { generations: number; captureUsage?: IGenerationUsage & { generations: number }; } >; private async scanControllerStoreUsage( captureKeyArg: string | undefined, excludedGenerationKeysArg: ReadonlySet, ): Promise< IGenerationUsage & { generations: number; captureUsage?: IGenerationUsage & { generations: number }; } >; private async scanControllerStoreUsage( captureKeyArg?: string, excludedGenerationKeysArg: ReadonlySet = new Set(), ): Promise< IGenerationUsage & { generations: number; captureUsage?: IGenerationUsage & { generations: number }; } > { let bytes = 0; let objects = 0; let generations = 0; let captureUsage: (IGenerationUsage & { generations: number }) | undefined; const directory = await plugins.fs.promises.opendir(this.captureRecordRoot()); let captures = 0; try { for await (const entry of directory) { if (!/^[0-9a-f]{64}$/u.test(entry.name) || !entry.isDirectory() || entry.isSymbolicLink()) { throw new GitReversionFencedError('The capture root contains an unexpected entry.'); } captures += 1; if (captures > this.limits.maximumCaptures) { throw new Error('The capture count limit was exceeded.'); } const usage = await this.scanCaptureStoreUsage(entry.name, excludedGenerationKeysArg); if (entry.name === captureKeyArg) captureUsage = usage; bytes += usage.bytes; objects += usage.objects; generations += usage.generations; if ( bytes > this.limits.maximumControllerStoreBytes || objects > this.limits.maximumControllerStoreObjects || generations > this.limits.maximumControllerGenerations ) throw new Error('The aggregate controller Git store quota was exceeded.'); } } finally { await directory.close().catch(() => undefined); } return { bytes, objects, generations, ...(captureUsage ? { captureUsage } : {}) }; } private async assertStoreReservationsWithinQuota( reservationsArg: ReadonlyMap, captureKeyArg: string, ): Promise { const controllerUsage = await this.scanControllerStoreUsage( captureKeyArg, this.activeGenerationReservationKeys(reservationsArg), ); const captureUsage = controllerUsage.captureUsage ?? { bytes: 0, objects: 0, generations: 0 }; const controllerReserved = { bytes: 0, objects: 0, generations: 0 }; const captureReserved = { bytes: 0, objects: 0, generations: 0 }; for (const reservation of reservationsArg.values()) { controllerReserved.bytes = addSafeLimit(controllerReserved.bytes, reservation.bytes); controllerReserved.objects = addSafeLimit(controllerReserved.objects, reservation.objects); controllerReserved.generations = addSafeLimit( controllerReserved.generations, reservation.generations, ); if (reservation.captureKey === captureKeyArg) { captureReserved.bytes = addSafeLimit(captureReserved.bytes, reservation.bytes); captureReserved.objects = addSafeLimit(captureReserved.objects, reservation.objects); captureReserved.generations = addSafeLimit( captureReserved.generations, reservation.generations, ); } } if ( captureUsage.bytes + captureReserved.bytes > this.limits.maximumCaptureStoreBytes || captureUsage.objects + captureReserved.objects > this.limits.maximumCaptureStoreObjects || captureUsage.generations + captureReserved.generations > this.limits.maximumGenerationsPerCapture || controllerUsage.bytes + controllerReserved.bytes > this.limits.maximumControllerStoreBytes || controllerUsage.objects + controllerReserved.objects > this.limits.maximumControllerStoreObjects || controllerUsage.generations + controllerReserved.generations > this.limits.maximumControllerGenerations ) throw new Error('The private store reservation exceeds controller storage quota.'); } private reserveStoreCapacity(reservationArg: IStoreReservation): Promise { return this.withCaptureMutation(async () => { if (this.storeReservations.has(reservationArg.reservationId)) { throw new Error('A private store reservation identity was repeated.'); } const next = new Map(this.storeReservations); next.set(reservationArg.reservationId, reservationArg); await this.assertStoreReservationsWithinQuota(next, reservationArg.captureKey); this.storeReservations.set(reservationArg.reservationId, reservationArg); }); } private resizeStoreReservation( reservationIdArg: string, usageArg: IGenerationUsage, ): Promise { return this.withCaptureMutation(async () => { const current = this.storeReservations.get(reservationIdArg); if (!current) throw new Error('A private store reservation is missing.'); if ( !Number.isSafeInteger(usageArg.bytes) || usageArg.bytes < 0 || !Number.isSafeInteger(usageArg.objects) || usageArg.objects < 0 ) throw new Error('A private store reservation size is invalid.'); const replacement = { ...current, ...usageArg }; const next = new Map(this.storeReservations); next.set(reservationIdArg, replacement); await this.assertStoreReservationsWithinQuota(next, current.captureKey); this.storeReservations.set(reservationIdArg, replacement); return replacement; }); } private reconcileStoreReservation(reservationIdArg: string): Promise { return this.withCaptureMutation(async () => { const reservation = this.storeReservations.get(reservationIdArg); if (!reservation) throw new Error('A private store reservation is missing.'); const next = new Map(this.storeReservations); next.delete(reservationIdArg); await this.assertStoreReservationsWithinQuota(next, reservation.captureKey); this.storeReservations.delete(reservationIdArg); }, true); } private releaseStoreReservation( reservationIdArg: string, allowSealedArg = false, ): Promise { return this.withCaptureMutation(async () => { this.storeReservations.delete(reservationIdArg); }, allowSealedArg); } private async createGeneration( captureKeyArg: string, repositoryArg: IGitReversionRepositoryIdentity, signalArg?: AbortSignal, ): Promise<{ context: IGenerationContext; reservation: IGenerationUsage }> { await this.retryPendingMaintenanceCleanups(); this.assertUsable(); // Reservation enumerates non-ignored files, so reject unsafe ignore inputs first. await this.captureIgnorePolicy(repositoryArg, signalArg); const estimate = await this.estimateGenerationReservation(repositoryArg, signalArg); if ( estimate.bytes > this.limits.maximumGenerationBytes || estimate.objects > this.limits.maximumGenerationObjects ) throw new Error('The private object-generation reservation exceeds its quota.'); const reservation = estimate; const generationId = plugins.crypto.randomBytes(32).toString('hex'); const reservationId = `generation:${generationId}`; await this.reserveStoreCapacity({ reservationId, captureKey: captureKeyArg, repositoryId: repositoryArg.repositoryId, generationId, generations: 1, ...reservation, }); const directory = this.generationDirectory( captureKeyArg, repositoryArg.repositoryId, generationId, ); try { await this.ensurePrivateStore(captureKeyArg, repositoryArg); await this.ensurePrivateDirectory(directory); const objectDirectory = plugins.path.join(directory, 'objects'); await this.ensurePrivateDirectory(objectDirectory); const existing = (await this.listGenerationObjectDirectories( captureKeyArg, repositoryArg.repositoryId, )).filter((path) => path !== objectDirectory); return { reservation, context: { captureKey: captureKeyArg, generationId, objectDirectory, environment: { GIT_OBJECT_DIRECTORY: objectDirectory, GIT_ALTERNATE_OBJECT_DIRECTORIES: [repositoryArg.objectDir, ...existing] .join(plugins.path.delimiter), GIT_NO_REPLACE_OBJECTS: '1', }, }, }; } catch (errorArg) { await this.removeGeneration(repositoryArg, { captureKey: captureKeyArg, generationId, objectDirectory: plugins.path.join(directory, 'objects'), environment: {}, }).catch(() => undefined); throw errorArg; } } private closureRoots(sideArg: IGitReversionSide): string[] { const roots = [...new Set([ sideArg.worktreeTree, sideArg.indexTree, sideArg.headOid, ...Object.values(sideArg.refs), ...Object.keys(sideArg.symbolicRefs).map((ref) => sideArg.refs[ref]), ...Object.values(sideArg.symbolicRefs).map((ref) => sideArg.refs[ref]), ].filter((oid): oid is string => oid !== undefined))].sort(compareStrings); if ( roots.length === 0 || roots.length > addSafeLimit(this.limits.maximumPaths, 3) || roots.some((oid) => !objectIdPattern.test(oid)) ) throw new GitReversionFencedError('A captured Git object closure has invalid roots.'); const inputBytes = Buffer.byteLength(`${roots.join('\n')}\n`, 'utf8'); if (inputBytes > this.limits.maximumPathBytes) { throw new Error('The captured Git object closure root list exceeds its byte limit.'); } return roots; } private packIndexMaximumBytes( objectCountArg: number, objectFormatArg: IGitReversionRepositoryIdentity['objectFormat'], ): number { const hashLength = objectFormatArg === 'sha256' ? 32 : 20; return addSafeLimit( 1032, multiplySafeLimit(objectCountArg, hashLength + 16), hashLength * 2, ); } private async growGenerationReservation( contextArg: IGenerationContext, usageArg: IGenerationUsage, ): Promise { if ( usageArg.bytes > this.limits.maximumGenerationBytes || usageArg.objects > this.limits.maximumGenerationObjects ) throw new Error('The private generation exceeded its transient quota.'); const reservationId = `generation:${contextArg.generationId}`; const current = this.storeReservations.get(reservationId); if (!current) throw new Error('The private generation reservation is missing.'); const next = { bytes: Math.max(current.bytes, usageArg.bytes), objects: Math.max(current.objects, usageArg.objects), }; return next.bytes === current.bytes && next.objects === current.objects ? current : this.resizeStoreReservation(reservationId, next); } private async removeGenerationLooseObjects(objectDirectoryArg: string): Promise { await this.hardenPrivateTree(objectDirectoryArg); const directory = await plugins.fs.promises.opendir(objectDirectoryArg); const removals: string[] = []; try { for await (const entry of directory) { if (/^[0-9a-f]{2}$/u.test(entry.name) && entry.isDirectory() && !entry.isSymbolicLink()) { removals.push(plugins.path.join(objectDirectoryArg, entry.name)); continue; } if ( !['info', 'pack'].includes(entry.name) || !entry.isDirectory() || entry.isSymbolicLink() ) throw new GitReversionFencedError('A private generation has an unexpected loose entry.'); } } finally { await directory.close().catch(() => undefined); } for (const path of removals) await this.removePrivateTree(path); if (removals.length > 0) await this.syncDirectory(objectDirectoryArg); } private async writeReachablePack( repositoryArg: IGitReversionRepositoryIdentity, contextArg: IGenerationContext, rootsArg: readonly string[], alternateObjectDirectoriesArg: readonly string[], gitDirectoryArg: string, cwdArg: string, signalArg?: AbortSignal, ): Promise { const packDirectory = plugins.path.join(contextArg.objectDirectory, 'pack'); await this.ensurePrivateDirectory(packDirectory); const token = `${this.ownerKey}-${process.pid}-${plugins.crypto.randomBytes(10).toString('hex')}`; const temporaryPack = plugins.path.join(packDirectory, `incoming-${token}.pack`); const temporaryIndex = plugins.path.join(packDirectory, `incoming-${token}.idx`); const hashLength = repositoryArg.objectFormat === 'sha256' ? 64 : 40; const hashPattern = new RegExp(`^[0-9a-f]{${hashLength}}$`, 'u'); const initialUsage = await this.scanObjectDirectory(contextArg.objectDirectory); let reservation = await this.growGenerationReservation(contextArg, initialUsage); let packHeader = Buffer.alloc(0); let objectCount: number | undefined; let indexMaximumBytes = 0; let handle: plugins.fs.promises.FileHandle | undefined; try { handle = await plugins.fs.promises.open(temporaryPack, 'wx', 0o600); await this.runGit([ '-c', 'pack.packSizeLimit=0', '-c', 'pack.writeReverseIndex=false', 'pack-objects', '--quiet', '--stdout', '--revs', '--non-empty', '--no-thin', '--no-local', '--missing=error', '--no-reuse-delta', '--no-reuse-object', '--window=0', '--depth=0', '--threads=1', '--compression=0', '--no-write-bitmap-index', ], { cwd: cwdArg, signal: signalArg, acceptedExitCodes: [0], input: Buffer.from(`${rootsArg.join('\n')}\n`, 'utf8'), outputFile: { handle, maximumBytes: this.limits.maximumGenerationBytes, beforeWrite: async (nextBytes, chunk) => { if (packHeader.byteLength < 12) { packHeader = Buffer.concat([ packHeader, chunk.subarray(0, Math.min(chunk.byteLength, 12 - packHeader.byteLength)), ]); if (packHeader.byteLength === 12) { if ( packHeader.toString('ascii', 0, 4) !== 'PACK' || ![2, 3].includes(packHeader.readUInt32BE(4)) ) throw new GitReversionFencedError('Git produced an invalid pack header.'); objectCount = packHeader.readUInt32BE(8); if (objectCount < 1) throw new GitReversionFencedError('Git produced an empty closure pack.'); indexMaximumBytes = this.packIndexMaximumBytes(objectCount, repositoryArg.objectFormat); } } const transientBytes = addSafeLimit( initialUsage.bytes, nextBytes, multiplySafeLimit(indexMaximumBytes, 2), ); const transientObjects = addSafeLimit(initialUsage.objects, objectCount ?? 0); reservation = await this.growGenerationReservation(contextArg, { bytes: transientBytes, objects: transientObjects, }); }, }, env: { GIT_DIR: gitDirectoryArg, GIT_OBJECT_DIRECTORY: contextArg.objectDirectory, GIT_ALTERNATE_OBJECT_DIRECTORIES: alternateObjectDirectoriesArg.join(plugins.path.delimiter), GIT_NO_REPLACE_OBJECTS: '1', }, timeoutMs: this.limits.worktreeCommandTimeoutMs, maximumOutputBytes: 64 * 1024, }); await handle.sync(); await handle.close(); handle = undefined; if (objectCount === undefined || indexMaximumBytes === 0) { throw new GitReversionFencedError('Git closure pack output has no complete header.'); } const packStat = await plugins.fs.promises.lstat(temporaryPack); if ( !packStat.isFile() || packStat.isSymbolicLink() || packStat.size < 12 || packStat.size > this.limits.maximumGenerationBytes ) throw new GitReversionFencedError('The private closure pack is invalid.'); reservation = await this.growGenerationReservation(contextArg, { bytes: addSafeLimit( initialUsage.bytes, packStat.size, multiplySafeLimit(indexMaximumBytes, 2), ), objects: addSafeLimit(initialUsage.objects, objectCount), }); const indexed = await this.runGit([ '-c', 'pack.writeReverseIndex=false', 'index-pack', '--strict', '--threads=1', '--no-rev-index', '-o', temporaryIndex, temporaryPack, ], { cwd: cwdArg, signal: signalArg, acceptedExitCodes: [0], env: { GIT_DIR: gitDirectoryArg, GIT_OBJECT_DIRECTORY: contextArg.objectDirectory, GIT_ALTERNATE_OBJECT_DIRECTORIES: '', GIT_NO_REPLACE_OBJECTS: '1', }, timeoutMs: this.limits.worktreeCommandTimeoutMs, maximumOutputBytes: 64 * 1024, }); const packHash = trimLine(indexed.stdout); if (!hashPattern.test(packHash)) { throw new GitReversionFencedError('Git returned an invalid closure pack identity.'); } await plugins.fs.promises.chmod(temporaryIndex, 0o600); const packPath = plugins.path.join(packDirectory, `pack-${packHash}.pack`); const indexPath = plugins.path.join(packDirectory, `pack-${packHash}.idx`); for (const path of [packPath, indexPath]) { const exists = await plugins.fs.promises.lstat(path).then(() => true, (errorArg) => { if ((errorArg as NodeJS.ErrnoException).code === 'ENOENT') return false; throw errorArg; }); if (exists) throw new GitReversionFencedError('A private closure pack identity already exists.'); } await plugins.fs.promises.rename(temporaryPack, packPath); await plugins.fs.promises.rename(temporaryIndex, indexPath); await this.syncDirectory(packDirectory); await this.runGit([ 'verify-pack', '--', indexPath, ], { cwd: cwdArg, signal: signalArg, acceptedExitCodes: [0], env: { GIT_DIR: gitDirectoryArg, GIT_OBJECT_DIRECTORY: contextArg.objectDirectory, GIT_ALTERNATE_OBJECT_DIRECTORIES: '', GIT_NO_REPLACE_OBJECTS: '1', }, timeoutMs: this.limits.worktreeCommandTimeoutMs, maximumOutputBytes: 64 * 1024, }); await this.removeGenerationLooseObjects(contextArg.objectDirectory); await this.verifyReachableClosure( rootsArg, contextArg.objectDirectory, gitDirectoryArg, cwdArg, signalArg, ); const finalPackStat = await this.syncRegularFile(packPath, 'Private Git pack', true); await this.syncRegularFile(indexPath, 'Private Git pack index', true); await this.syncDirectory(packDirectory); await this.syncDirectory(contextArg.objectDirectory); await this.syncDirectory(plugins.path.dirname(contextArg.objectDirectory)); await this.assertRepositoryBinding(repositoryArg); void reservation; return { packHash, packBytes: finalPackStat.size, objectCount, packPath, indexPath, }; } finally { await handle?.close().catch(() => undefined); await plugins.fs.promises.rm(temporaryPack, { force: true }).catch(() => undefined); await plugins.fs.promises.rm(temporaryIndex, { force: true }).catch(() => undefined); } } private async verifyReachableClosure( rootsArg: readonly string[], objectDirectoryArg: string, gitDirectoryArg: string, cwdArg: string, signalArg?: AbortSignal, sourceCommonDirectoryArg?: string, sourceWorktreeArg?: string, ): Promise { try { await this.runGit([ 'rev-list', '--objects', '--quiet', '--missing=error', '--stdin', ], { cwd: cwdArg, signal: signalArg, acceptedExitCodes: [0], input: Buffer.from(`${rootsArg.join('\n')}\n`, 'utf8'), env: { GIT_DIR: gitDirectoryArg, GIT_OBJECT_DIRECTORY: objectDirectoryArg, GIT_ALTERNATE_OBJECT_DIRECTORIES: '', GIT_NO_REPLACE_OBJECTS: '1', ...(sourceCommonDirectoryArg ? { GIT_COMMON_DIR: sourceCommonDirectoryArg } : {}), ...(sourceWorktreeArg ? { GIT_WORK_TREE: sourceWorktreeArg } : {}), }, timeoutMs: this.limits.worktreeCommandTimeoutMs, maximumOutputBytes: 64 * 1024, }); } catch (errorArg) { throw new GitReversionFencedError('A Git object generation is not self-contained.', { cause: errorArg instanceof Error ? errorArg : undefined, }); } } private async materializeGenerationClosure( repositoryArg: IGitReversionRepositoryIdentity, contextArg: IGenerationContext, rootsArg: readonly string[], signalArg?: AbortSignal, ): Promise { await this.assertRepositoryBinding(repositoryArg); await this.hardenPrivateTree(plugins.path.dirname(contextArg.objectDirectory)); const alternates = (contextArg.environment.GIT_ALTERNATE_OBJECT_DIRECTORIES ?? '') .split(plugins.path.delimiter) .filter(Boolean); const privateGitDirectory = this.captureGitDirectory( contextArg.captureKey, repositoryArg.repositoryId, ); return this.writeReachablePack( repositoryArg, contextArg, rootsArg, alternates, privateGitDirectory, this.captureRoot(contextArg.captureKey), signalArg, ); } private async verifyGeneration( repositoryArg: IGitReversionRepositoryIdentity, contextArg: IGenerationContext, ): Promise { await this.withCaptureMutation(async () => { const reservationId = `generation:${contextArg.generationId}`; const reservation = this.storeReservations.get(reservationId); if (!reservation) throw new Error('The private generation reservation is missing.'); await this.hardenPrivateTree(plugins.path.dirname(contextArg.objectDirectory)); const usage = await this.scanObjectDirectory(contextArg.objectDirectory); if ( usage.bytes > reservation.bytes || usage.objects > reservation.objects || usage.bytes > this.limits.maximumGenerationBytes || usage.objects > this.limits.maximumGenerationObjects ) throw new Error('The private object generation exceeded its physical quota.'); const next = new Map(this.storeReservations); next.delete(reservationId); await this.assertStoreReservationsWithinQuota(next, reservation.captureKey); this.storeReservations.delete(reservationId); }); await this.fault({ point: 'capture.before-generation-enrollment', captureKey: contextArg.captureKey, repositoryId: repositoryArg.repositoryId, }); } private async removeGeneration( repositoryArg: IGitReversionRepositoryIdentity, contextArg: IGenerationContext, ): Promise { const cleanup: IGenerationCleanup = { captureKey: contextArg.captureKey, repositoryId: repositoryArg.repositoryId, generationId: contextArg.generationId, }; this.pendingGenerationCleanups.set(contextArg.generationId, cleanup); await this.performGenerationCleanup(cleanup); } private async performGenerationCleanup(cleanupArg: IGenerationCleanup): Promise { const generationDirectory = this.generationDirectory( cleanupArg.captureKey, cleanupArg.repositoryId, cleanupArg.generationId, ); try { await this.hardenPrivateTree(generationDirectory).catch((errorArg) => { if ((errorArg as NodeJS.ErrnoException).code !== 'ENOENT') throw errorArg; }); await this.removePrivateTree(generationDirectory); await this.syncDirectory(this.generationRoot( cleanupArg.captureKey, cleanupArg.repositoryId, )).catch((errorArg) => { if ((errorArg as NodeJS.ErrnoException).code !== 'ENOENT') throw errorArg; }); await this.releaseStoreReservation(`generation:${cleanupArg.generationId}`, true); this.pendingGenerationCleanups.delete(cleanupArg.generationId); } catch (errorArg) { this.invalidateRecovery(); throw errorArg; } } private generationContext( captureKeyArg: string, repositoryIdArg: string, generationIdArg: string, ): IGenerationContext { return { captureKey: captureKeyArg, generationId: generationIdArg, objectDirectory: plugins.path.join( this.generationDirectory(captureKeyArg, repositoryIdArg, generationIdArg), 'objects', ), environment: {}, }; } private async enrollGenerationIds( captureKeyArg: string, generationIdsArg: Record, ): Promise { const record = await this.readDurableCaptureRecord(captureKeyArg); if (!record) throw new GitReversionFencedError('The capture generation manifest is missing.'); const generationIds = structuredClone(record.generationIds); let changed = false; for (const [repositoryId, newGenerationIds] of Object.entries(generationIdsArg)) { if ( !/^git-[0-9a-f]{32}$/u.test(repositoryId) || !Array.isArray(newGenerationIds) || newGenerationIds.some((generationId) => !/^[0-9a-f]{64}$/u.test(generationId)) ) { throw new GitReversionFencedError('An enrolled object generation identity is invalid.'); } const repository = record.manifest.repositories.find((entry) => ( entry.repository.repositoryId === repositoryId ))?.repository; if (!repository) throw new GitReversionFencedError('An object generation has no capture repository.'); const ids = generationIds[repositoryId] ?? []; for (const generationId of newGenerationIds) { await this.assertPrivateDirectory(this.generationDirectory( captureKeyArg, repositoryId, generationId, )); if (!ids.includes(generationId)) { ids.push(generationId); generationIds[repositoryId] = ids; changed = true; } } } if (!changed) return; await this.fault({ point: 'capture.before-generation-enrollment', captureKey: captureKeyArg }); await this.writeDurableCaptureRecord({ ...record, generationIds, revision: record.revision + 1, }); await this.fault({ point: 'capture.after-generation-enrollment', captureKey: captureKeyArg }); } private async deleteGenerationIds( manifestArg: IGitReversionManifest, generationIdsArg: Record, ): Promise { for (const [repositoryId, generationIds] of Object.entries(generationIdsArg)) { const repository = manifestArg.repositories.find((entry) => ( entry.repository.repositoryId === repositoryId ))?.repository; if (!repository) continue; for (const generationId of generationIds) { await this.removeGeneration(repository, this.generationContext( manifestArg.captureKey, repositoryId, generationId, )); } } } private async scavengeUnenrolledGenerations(recordArg: IDurableCaptureRecord): Promise { const referenced = new Map>(); const reference = (repositoryIdArg: string, generationIdArg: string | undefined): void => { if (!generationIdArg) return; const ids = referenced.get(repositoryIdArg) ?? new Set(); ids.add(generationIdArg); referenced.set(repositoryIdArg, ids); }; for (const entry of recordArg.manifest.repositories) { reference(entry.repository.repositoryId, entry.before.objectGenerationId); reference(entry.repository.repositoryId, entry.after?.objectGenerationId); } for (const wal of await this.listLatestApplyWals(recordArg.manifest)) { for (const [repositoryId, generationIds] of Object.entries(wal.generationIds)) { for (const generationId of generationIds) reference(repositoryId, generationId); } } const retainedGenerationIds: Record = {}; let enrollmentChanged = false; for (const entry of recordArg.manifest.repositories) { const enrolled = new Set(recordArg.generationIds[entry.repository.repositoryId] ?? []); const retained = referenced.get(entry.repository.repositoryId) ?? new Set(); const root = this.generationRoot(recordArg.captureKey, entry.repository.repositoryId); let directory: plugins.fs.Dir; try { directory = await plugins.fs.promises.opendir(root); } catch (errorArg) { if ((errorArg as NodeJS.ErrnoException).code === 'ENOENT') continue; throw errorArg; } const removals: string[] = []; try { for await (const generation of directory) { if ( !/^[0-9a-f]{64}$/u.test(generation.name) || !generation.isDirectory() || generation.isSymbolicLink() ) throw new GitReversionFencedError('An object generation is invalid during recovery.'); if (!enrolled.has(generation.name) || !retained.has(generation.name)) { removals.push(generation.name); } } } finally { await directory.close().catch(() => undefined); } for (const generationId of removals) { const generationDirectory = this.generationDirectory( recordArg.captureKey, entry.repository.repositoryId, generationId, ); await this.hardenPrivateTree(generationDirectory); await this.removePrivateTree(generationDirectory); } if (removals.length > 0) await this.syncDirectory(root); const kept = [...enrolled].filter((generationId) => retained.has(generationId)).sort(compareStrings); if (kept.length > 0) retainedGenerationIds[entry.repository.repositoryId] = kept; if (!recordsEqual(kept, [...enrolled].sort(compareStrings))) enrollmentChanged = true; } if (enrollmentChanged) { const current = await this.readDurableCaptureRecord(recordArg.captureKey); if (!current || current.revision !== recordArg.revision) { throw new GitReversionFencedError('The capture generation enrollment changed during scavenging.'); } await this.writeDurableCaptureRecord({ ...current, generationIds: retainedGenerationIds, revision: current.revision + 1, }); } } private async captureStableRepositories( repositoriesArg: readonly IGitReversionRepositoryIdentity[], signalArg?: AbortSignal, optionsArg?: { captureKey: string; persistent: boolean; generationIds?: Record; }, ): Promise { if (!optionsArg) { throw new Error('A private object-generation context is required for Git capture.'); } const generations = new Map(); let latest: IGitReversionSide[] = []; let stable = false; try { for (const repository of repositoriesArg) { const created = await this.createGeneration(optionsArg.captureKey, repository, signalArg); generations.set(repository.repositoryId, { repository, ...created }); } for (let attempt = 0; attempt < stableScanAttempts; attempt += 1) { const first: IGitReversionSide[] = []; const second: IGitReversionSide[] = []; for (const repository of repositoriesArg) { first.push(await this.captureSide( repository, generations.get(repository.repositoryId)!.context.environment, signalArg, )); } for (const generation of generations.values()) { await this.removeGenerationLooseObjects(generation.context.objectDirectory); } for (const repository of repositoriesArg) { second.push(await this.captureSide( repository, generations.get(repository.repositoryId)!.context.environment, signalArg, )); } latest = second; if (first.every((side, index) => this.sidesEqual(side, second[index]!))) { stable = true; break; } if (attempt + 1 < stableScanAttempts) { for (const generation of generations.values()) { await this.removeGenerationLooseObjects(generation.context.objectDirectory); } } } if (!latest.length && repositoriesArg.length > 0) throw new Error('Git capture produced no side.'); for (const generation of generations.values()) { await this.hardenPrivateTree(plugins.path.dirname(generation.context.objectDirectory)); } if (optionsArg.persistent) { for (let index = 0; index < repositoriesArg.length; index += 1) { const repository = repositoriesArg[index]!; const generation = generations.get(repository.repositoryId)!; await this.materializeGenerationClosure( repository, generation.context, this.closureRoots(latest[index]!), signalArg, ); await this.hardenPrivateTree(plugins.path.dirname(generation.context.objectDirectory)); await this.verifyGeneration(generation.repository, generation.context); const ids = optionsArg.generationIds?.[generation.repository.repositoryId] ?? []; ids.push(generation.context.generationId); if (optionsArg.generationIds) { optionsArg.generationIds[generation.repository.repositoryId] = ids; } await this.fault({ point: 'capture.after-generation-enrollment', captureKey: optionsArg.captureKey, repositoryId: generation.repository.repositoryId, }); } } else { for (const generation of generations.values()) { await this.verifyGeneration(generation.repository, generation.context); await this.removeGeneration(generation.repository, generation.context); } } const result = stable ? latest : latest.map((side) => ({ ...side, unsupportedReason: 'git.unstable' })); return result.map((side, index) => { const repository = repositoriesArg[index]!; const generation = generations.get(repository.repositoryId)!; return optionsArg.persistent ? { ...side, objectGenerationId: generation.context.generationId } : side; }); } catch (errorArg) { for (const generation of generations.values()) { await this.removeGeneration(generation.repository, generation.context).catch(() => undefined); } throw errorArg; } } private async captureSide( repositoryArg: IGitReversionRepositoryIdentity, objectEnvironmentArg: Record, signalArg?: AbortSignal, ): Promise { // Validate ignore inputs before any concurrent Git command can consume a special file. const ignorePolicy = await this.captureIgnorePolicy(repositoryArg, signalArg); const [head, symbolicHead, refsResult, topology, operation] = await Promise.all([ this.git(repositoryArg, ['rev-parse', '--verify', 'HEAD^{commit}'], signalArg, [0, 128]), this.git(repositoryArg, ['symbolic-ref', '-q', 'HEAD'], signalArg, [0, 1]), this.git(repositoryArg, [ 'for-each-ref', '--format=%(refname)%09%(objectname)%09%(symref)', 'refs', ], signalArg), this.git(repositoryArg, ['worktree', 'list', '--porcelain', '-z'], signalArg), this.captureOperationState(repositoryArg, signalArg), ]); const headOid = trimLine(head.stdout); const headRef = symbolicHead.exitCode === 0 ? trimLine(symbolicHead.stdout) : null; const parsedRefs = this.parseRefs(refsResult.stdout); if (headRef) await this.assertGitRefName(headRef, signalArg); for (const [ref, target] of Object.entries(parsedRefs.symbolicRefs)) { await this.assertGitRefName(ref, signalArg); await this.assertGitRefName(target, signalArg); } for (const ref of Object.keys(parsedRefs.refs)) await this.assertGitRefName(ref, signalArg); let unsupportedReason: string | undefined; if (!objectIdPattern.test(headOid)) unsupportedReason = 'git.unborn-head'; if (headRef !== null && !headRef.startsWith('refs/heads/')) { unsupportedReason ??= 'git.unsupported-head-ref'; } unsupportedReason ??= parsedRefs.unsupportedReason; unsupportedReason ??= await this.checkIndexSupport(repositoryArg, signalArg); unsupportedReason ??= operation.unsupportedReason; const pathSupport = unsupportedReason ? undefined : await this.checkPathSupport(repositoryArg, signalArg); unsupportedReason ??= pathSupport?.unsupportedReason; const trees = unsupportedReason ? await this.fallbackTrees(repositoryArg, headOid, objectEnvironmentArg, signalArg) : await this.captureTrees( repositoryArg, pathSupport!.paths, objectEnvironmentArg, signalArg, ); return { ...trees, headOid: objectIdPattern.test(headOid) ? headOid : trees.worktreeTree, headRef, refs: parsedRefs.refs, symbolicRefs: parsedRefs.symbolicRefs, topologyHash: this.worktreeTopologyHash(topology.stdout, repositoryArg.root), ignorePolicyHash: ignorePolicy, operationHash: operation.hash, ...(unsupportedReason ? { unsupportedReason } : {}), }; } private async checkIndexSupport( repositoryArg: IGitReversionRepositoryIdentity, signalArg?: AbortSignal, ): Promise { const [sparse, split, stages, flags] = await Promise.all([ this.git(repositoryArg, ['config', '--bool', '--get', 'core.sparseCheckout'], signalArg, [0, 1]), this.git(repositoryArg, ['config', '--bool', '--get', 'core.splitIndex'], signalArg, [0, 1]), this.git(repositoryArg, ['ls-files', '--stage', '-z'], signalArg), this.git(repositoryArg, ['ls-files', '-v', '-z'], signalArg), ]); if (sparse.exitCode === 0 && trimLine(sparse.stdout) === 'true') return 'git.sparse-index'; if (split.exitCode === 0 && trimLine(split.stdout) === 'true') return 'git.split-index'; this.assertPathOutputBound(stages.stdout, 'Git index'); for (const entry of splitNul(stages.stdout, 'Git index metadata')) { const tab = entry.indexOf('\t'); const metadata = entry.slice(0, tab).split(' '); if (tab < 0 || metadata.length !== 3 || metadata[2] !== '0') return 'git.unmerged'; if (/^0+$/u.test(metadata[1] ?? '')) return 'git.intent-to-add'; if (!['100644', '100755', '120000'].includes(metadata[0] ?? '')) { return 'git.unsupported-mode'; } } for (const entry of splitNul(flags.stdout, 'Git index flags')) { if (!entry.startsWith('H ')) return 'git.index-flags'; } try { await this.assertRepositoryBinding(repositoryArg); const stat = await plugins.fs.promises.lstat(repositoryArg.indexPath); if (!stat.isFile() || stat.isSymbolicLink()) return 'git.unsupported-index-file'; if (stat.size > this.limits.maximumGenerationBytes) return 'git.index-size'; } catch (errorArg) { if ((errorArg as NodeJS.ErrnoException).code !== 'ENOENT') throw errorArg; } return undefined; } private async checkPathSupport( repositoryArg: IGitReversionRepositoryIdentity, signalArg?: AbortSignal, ): Promise<{ paths: Buffer; unsupportedReason?: string }> { const pathsResult = await this.git(repositoryArg, [ 'ls-files', '--cached', '--others', '--exclude-standard', '-z', '--', ...this.pathspec(repositoryArg), ], signalArg); this.assertPathOutputBound(pathsResult.stdout, 'Git paths'); const paths = splitNul(pathsResult.stdout, 'Git paths'); if (paths.length > this.limits.maximumPaths) { return { paths: pathsResult.stdout, unsupportedReason: 'git.path-limit' }; } for (const path of paths) { const absolute = plugins.path.resolve(repositoryArg.root, path); if (!isWithin(repositoryArg.root, absolute)) { return { paths: pathsResult.stdout, unsupportedReason: 'git.path-escape' }; } try { await this.assertRepositoryBinding(repositoryArg); const stat = await plugins.fs.promises.lstat(absolute); if (!stat.isFile() && !stat.isSymbolicLink()) { return { paths: pathsResult.stdout, unsupportedReason: 'git.special-path' }; } } catch (errorArg) { if ((errorArg as NodeJS.ErrnoException).code !== 'ENOENT') throw errorArg; } } const autocrlf = await this.git( repositoryArg, ['config', '--get', 'core.autocrlf'], signalArg, [0, 1], ); if (autocrlf.exitCode === 0 && !['false', ''].includes(trimLine(autocrlf.stdout))) { return { paths: pathsResult.stdout, unsupportedReason: 'git.filter-policy' }; } if (paths.length > 0) { const attributes = await this.git(repositoryArg, [ 'check-attr', '-z', '--stdin', 'filter', 'working-tree-encoding', 'ident', 'merge', 'diff', ], signalArg, [0], Buffer.from(`${paths.join('\0')}\0`, 'utf8')); const values = splitNul(attributes.stdout, 'Git attribute metadata'); if (values.length % 3 !== 0) { return { paths: pathsResult.stdout, unsupportedReason: 'git.filter-policy' }; } for (let index = 2; index < values.length; index += 3) { if (!['unspecified', 'unset'].includes(values[index]!)) { return { paths: pathsResult.stdout, unsupportedReason: 'git.filter-policy' }; } } } const executableConfig = await this.git(repositoryArg, [ 'config', '--local', '--name-only', '--get-regexp', '^(filter\..*\.(clean|smudge|process)|merge\..*\.driver|diff\..*\.command)$', ], signalArg, [0, 1]); if (executableConfig.exitCode === 0 && trimLine(executableConfig.stdout) !== '') { return { paths: pathsResult.stdout, unsupportedReason: 'git.executable-config' }; } return { paths: pathsResult.stdout }; } private assertPathOutputBound(valueArg: Buffer, descriptionArg: string): void { if (valueArg.byteLength > this.limits.maximumPathBytes) { throw new Error(`${descriptionArg} exceed the metadata byte limit.`); } } private async captureOperationState( repositoryArg: IGitReversionRepositoryIdentity, signalArg?: AbortSignal, ): Promise<{ hash: string; unsupportedReason?: string }> { const operationPaths = [ 'MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD', 'BISECT_LOG', 'rebase-apply', 'rebase-merge', 'sequencer', ]; const pathResult = await this.git(repositoryArg, [ 'rev-parse', '--path-format=absolute', ...operationPaths.flatMap((operationPath) => ['--git-path', operationPath]), ], signalArg); const paths = splitExactGitLines( pathResult.stdout, operationPaths.length, 'Git operation-state paths', ); const present: string[] = []; for (let index = 0; index < operationPaths.length; index += 1) { const operationPath = operationPaths[index]!; const path = paths[index]!; try { await this.assertRepositoryBinding(repositoryArg); await plugins.fs.promises.lstat(path); present.push(operationPath); } catch (errorArg) { if ((errorArg as NodeJS.ErrnoException).code !== 'ENOENT') throw errorArg; } } return { hash: sha256(canonicalJson(present)), ...(present.length > 0 ? { unsupportedReason: 'git.operation-in-progress' } : {}), }; } private async captureIgnorePolicy( repositoryArg: IGitReversionRepositoryIdentity, signalArg?: AbortSignal, ): Promise { let policyBytes = 0; let fileCount = 0; const policyParts: Buffer[] = []; const charge = (bytesArg: number, descriptionArg: string): void => { policyBytes += bytesArg; if (policyBytes > this.limits.maximumIgnorePolicyBytes) { throw new Error(`${descriptionArg} exceeds the aggregate Git ignore-policy byte limit.`); } }; const config = await this.git(repositoryArg, [ 'config', '--show-origin', '--null', '--get-regexp', '^(core\\.excludesfile|core\\.ignorecase|status\\.showuntrackedfiles)$', ], signalArg, [0, 1], undefined, undefined, this.limits.commandTimeoutMs, this.limits.maximumIgnorePolicyBytes + 1); charge(config.stdout.byteLength, 'Git ignore configuration output'); policyParts.push(config.stdout, Buffer.from([0])); const policyFiles = [plugins.path.join(repositoryArg.commonDir, 'info', 'exclude')]; const globalExclude = await this.git( repositoryArg, ['config', '--path', '--get', 'core.excludesFile'], signalArg, [0, 1], undefined, undefined, this.limits.commandTimeoutMs, Math.max(1, this.limits.maximumIgnorePolicyBytes - policyBytes + 1), ); charge(globalExclude.stdout.byteLength, 'Global Git ignore configuration output'); if (globalExclude.exitCode === 0 && trimLine(globalExclude.stdout)) { const path = trimLine(globalExclude.stdout); if ( !plugins.path.isAbsolute(path) || path.includes('\0') || Buffer.byteLength(path, 'utf8') > 4096 ) throw new Error('The global Git ignore-policy path is invalid.'); policyFiles.push(path); } const readPolicyFile = async ( pathArg: string, displayPathArg: string, requireRepositoryBindingArg: boolean, ): Promise => { signalArg?.throwIfAborted(); fileCount += 1; if (fileCount > this.limits.maximumIgnorePolicyFiles) { throw new Error('The Git ignore-policy file count limit was exceeded.'); } const pathBytes = Buffer.from(displayPathArg, 'utf8'); charge(pathBytes.byteLength, 'Git ignore-policy paths'); try { if (requireRepositoryBindingArg) await this.assertRepositoryBinding(repositoryArg); const value = await this.readBoundedRegularFile( pathArg, Math.min( this.limits.maximumMetadataBytes, this.limits.maximumIgnorePolicyBytes - policyBytes, ), 'Git ignore-policy file', signalArg, ); charge(value.byteLength, 'Git ignore-policy files'); policyParts.push(pathBytes, Buffer.from([0]), value, Buffer.from([0])); } catch (errorArg) { if ((errorArg as NodeJS.ErrnoException).code !== 'ENOENT') throw errorArg; } }; for (const path of policyFiles) { await readPolicyFile( path, path, isWithin(repositoryArg.commonDir, path) || isWithin(repositoryArg.root, path), ); } const ignoreFiles = await this.git(repositoryArg, [ 'ls-files', '--cached', '--others', '-z', '--', ...this.pathspec(repositoryArg).map((entry) => ( entry === '.' ? ':(glob)**/.gitignore' : entry )), ], signalArg); this.assertPathOutputBound(ignoreFiles.stdout, 'Git ignore files'); charge(ignoreFiles.stdout.byteLength, 'Git ignore-file path output'); const ignorePaths = splitNul(ignoreFiles.stdout, 'Git ignore files'); if (ignorePaths.length > this.limits.maximumPaths) { throw new Error('The Git ignore-file path count limit was exceeded.'); } for (let index = 0; index < ignorePaths.length; index += 1) { const path = ignorePaths[index]!; const absolute = plugins.path.resolve(repositoryArg.root, path); if (!isWithin(repositoryArg.root, absolute)) { throw new Error('A Git ignore-policy path escaped its repository.'); } await readPolicyFile(absolute, path, true); if (index % 256 === 255) await new Promise((resolve) => setImmediate(resolve)); } return sha256(Buffer.concat(policyParts)); } private async fallbackTrees( repositoryArg: IGitReversionRepositoryIdentity, headOidArg: string, objectEnvironmentArg: Record, signalArg?: AbortSignal, ): Promise> { const tree = objectIdPattern.test(headOidArg) ? trimLine((await this.git(repositoryArg, ['rev-parse', `${headOidArg}^{tree}`], signalArg)).stdout) : trimLine((await this.git( repositoryArg, ['mktree'], signalArg, [0], Buffer.alloc(0), objectEnvironmentArg, )).stdout); return { worktreeTree: tree, indexTree: tree }; } private async assertTemporaryIndexBound(pathArg: string): Promise { const stat = await plugins.fs.promises.lstat(pathArg); if ( !stat.isFile() || stat.isSymbolicLink() || stat.size > this.limits.maximumGenerationBytes || (typeof process.getuid === 'function' && stat.uid !== process.getuid()) ) throw new Error('A temporary Git index exceeds its admitted boundary.'); } private async captureTrees( repositoryArg: IGitReversionRepositoryIdentity, pathsArg: Buffer, objectEnvironmentArg: Record, signalArg?: AbortSignal, ): Promise> { const temporaryIndex = await this.temporaryIndexPath(repositoryArg.repositoryId); try { try { await this.assertRepositoryBinding(repositoryArg); await plugins.fs.promises.copyFile(repositoryArg.indexPath, temporaryIndex); } catch (errorArg) { if ((errorArg as NodeJS.ErrnoException).code !== 'ENOENT') throw errorArg; await this.git( repositoryArg, ['read-tree', '--empty'], signalArg, [0], undefined, { ...objectEnvironmentArg, GIT_INDEX_FILE: temporaryIndex }, ); } await plugins.fs.promises.chmod(temporaryIndex, 0o600); if (pathsArg.length > 0) { await this.git(repositoryArg, [ '--literal-pathspecs', 'add', '-A', '--pathspec-from-file=-', '--pathspec-file-nul', ], signalArg, [0], pathsArg, { ...objectEnvironmentArg, GIT_INDEX_FILE: temporaryIndex, }); } await this.assertTemporaryIndexBound(temporaryIndex); const worktreeTree = trimLine((await this.git( repositoryArg, ['write-tree'], signalArg, [0], undefined, { ...objectEnvironmentArg, GIT_INDEX_FILE: temporaryIndex }, )).stdout); const indexTree = trimLine((await this.git( repositoryArg, ['write-tree'], signalArg, [0], undefined, objectEnvironmentArg, )).stdout); return { worktreeTree, indexTree }; } finally { await this.removeTemporaryIndexPaths([ temporaryIndex, `${temporaryIndex}.lock`, ]).catch(() => undefined); } } private parseRefs(valueArg: Buffer): { refs: Record; symbolicRefs: Record; unsupportedReason?: string; } { const refs: Record = {}; const symbolicRefs: Record = {}; const text = decodeUtf8(valueArg, 'Git ref metadata'); const lines = text.split('\n').filter(Boolean); if (lines.length > this.limits.maximumPaths) { return { refs, symbolicRefs, unsupportedReason: 'git.ref-limit' }; } for (const line of lines) { const fields = line.split('\t'); const [ref, oid, symbolicTarget] = fields; if ( fields.length !== 3 || !ref?.startsWith('refs/') || !oid || !objectIdPattern.test(oid) || (symbolicTarget !== '' && !symbolicTarget?.startsWith('refs/')) ) { throw new Error('Git returned malformed ref metadata.'); } if (ref.startsWith(`${privateRefRoot}/`)) continue; refs[ref] = oid; if (symbolicTarget) symbolicRefs[ref] = symbolicTarget; } return { refs, symbolicRefs }; } private sidesEqual(leftArg: IGitReversionSide, rightArg: IGitReversionSide): boolean { const withoutGeneration = (sideArg: IGitReversionSide): unknown => { const { objectGenerationId: _generation, ...side } = sideArg; return side; }; return recordsEqual(withoutGeneration(leftArg), withoutGeneration(rightArg)); } private semanticSidesEqual(leftArg: IGitReversionSide, rightArg: IGitReversionSide): boolean { return leftArg.worktreeTree === rightArg.worktreeTree && leftArg.indexTree === rightArg.indexTree && leftArg.headOid === rightArg.headOid && leftArg.headRef === rightArg.headRef && recordsEqual(leftArg.refs, rightArg.refs) && recordsEqual(leftArg.symbolicRefs, rightArg.symbolicRefs); } private captureRepositoryReason( beforeArg: IGitReversionSide, afterArg: IGitReversionSide, ): string | undefined { let reason = beforeArg.unsupportedReason ?? afterArg.unsupportedReason; if (beforeArg.topologyHash !== afterArg.topologyHash) reason ??= 'git.topology-changed'; if (beforeArg.ignorePolicyHash !== afterArg.ignorePolicyHash) reason ??= 'git.ignore-policy-changed'; if (beforeArg.operationHash !== afterArg.operationHash) reason ??= 'git.operation-state-changed'; if (!recordsEqual(beforeArg.symbolicRefs, afterArg.symbolicRefs)) reason ??= 'git.ref-changed'; const allowed = new Set( [beforeArg.headRef, afterArg.headRef].filter((entry): entry is string => entry !== null), ); for (const ref of new Set([...Object.keys(beforeArg.refs), ...Object.keys(afterArg.refs)])) { if (beforeArg.refs[ref] !== afterArg.refs[ref] && !allowed.has(ref)) { reason ??= 'git.ref-changed'; } } return reason; } private async createPreparedRecords( manifestArg: IGitReversionManifest, signalArg?: AbortSignal, ): Promise { const repositories = this.metadataRepositories( manifestArg.repositories.map((entry) => entry.repository), ); const entries: IPrivatePublicationIntentEntry[] = []; let publicationStarted = false; try { for (const repository of repositories) { entries.push(await this.buildPublicationEntry( repository, this.manifestRef(manifestArg.captureKey), manifestArg, null, signalArg, )); entries.push(await this.buildPublicationEntry( repository, this.captureWalRef(manifestArg.captureKey), { schemaVersion: 1, kind: 'capture-wal', captureKey: manifestArg.captureKey, phase: 'prepared', }, null, signalArg, )); } for (const entry of manifestArg.repositories) { entries.push(await this.buildPublicationEntry( entry.repository, this.captureSideRef(manifestArg.captureKey, 'before', entry.repository.repositoryId), { schemaVersion: 1, kind: 'capture-side', captureKey: manifestArg.captureKey, repository: entry.repository, side: entry.before, }, null, signalArg, )); entries.push(await this.buildPublicationEntry( entry.repository, this.repositoryIdentityRef( manifestArg.captureKey, entry.repository.repositoryId, ), { schemaVersion: 1, kind: 'repository-identity', captureKey: manifestArg.captureKey, repository: entry.repository, }, null, signalArg, )); } const intent = this.createPublicationIntent('prepare', manifestArg, entries); publicationStarted = true; await this.executePublicationIntent(intent, signalArg); } catch (errorArg) { if (!publicationStarted) await this.cleanupPublicationEntries(entries); throw errorArg; } } private async finalizeRecords( preparedManifestArg: IGitReversionManifest, manifestArg: IGitReversionManifest, stateArg: IGitReversionCaptureState, signalArg?: AbortSignal, ): Promise { const intent = this.createFinalizationIntent(preparedManifestArg, manifestArg, stateArg); await this.validateFinalizationRecords(intent, false, signalArg); const repositories = this.metadataRepositories( manifestArg.repositories.map((entry) => entry.repository), ); for (const repository of repositories) { await this.putRecord( repository, this.finalizationCandidateRef(intent.captureKey, intent.candidateId), intent, { mode: 'normal' }, signalArg, ); await this.fault({ point: 'capture.after-staging-repository', captureKey: intent.captureKey, repositoryId: repository.repositoryId, }); } await this.assertUniqueFinalizationIntent(repositories, intent.captureKey, intent, signalArg); for (const repository of repositories) { await this.putRecord( repository, this.finalizationIntentRef(intent.captureKey), intent, { mode: 'normal' }, signalArg, ); await this.fault({ point: 'capture.after-intent-repository', captureKey: intent.captureKey, repositoryId: repository.repositoryId, }); } await this.publishFinalization(intent, false, signalArg); } private createFinalizationIntent( preparedManifestArg: IGitReversionManifest, manifestArg: IGitReversionManifest, stateArg: IGitReversionCaptureState, ): IGitReversionFinalizationIntent { const value = { preparedManifest: preparedManifestArg, manifest: manifestArg, state: stateArg, }; return { schemaVersion: 1, kind: 'finalization-intent', captureKey: manifestArg.captureKey, candidateId: sha256(canonicalJson(value)), ...value, }; } private assertFinalizationIntent(intentArg: IGitReversionFinalizationIntent): void { if ( !intentArg || intentArg.schemaVersion !== 1 || intentArg.kind !== 'finalization-intent' || !/^[0-9a-f]{64}$/u.test(intentArg.captureKey) || !/^[0-9a-f]{64}$/u.test(intentArg.candidateId) ) throw new GitReversionFencedError('The Git finalization intent is invalid.'); this.assertManifest(intentArg.preparedManifest); this.assertManifest(intentArg.manifest); if ( intentArg.preparedManifest.phase !== 'prepared' || intentArg.manifest.phase !== 'finalized' || intentArg.captureKey !== intentArg.manifest.captureKey || intentArg.captureKey !== intentArg.preparedManifest.captureKey || intentArg.captureKey !== this.captureKey(intentArg.manifest.identity) || !recordsEqual(intentArg.preparedManifest.identity, intentArg.manifest.identity) || intentArg.preparedManifest.projectDirectory !== intentArg.manifest.projectDirectory || !filesystemIdentitiesEqual( intentArg.preparedManifest.projectIdentity, intentArg.manifest.projectIdentity, ) || intentArg.preparedManifest.ownerKey !== intentArg.manifest.ownerKey || !recordsEqual( intentArg.preparedManifest.repositories, intentArg.manifest.repositories.map((entry) => ({ repository: entry.repository, before: entry.before, })), ) ) throw new GitReversionFencedError('The Git finalization predecessor is invalid.'); const manifestId = sha256(canonicalJson(intentArg.manifest)); const expectedCandidateId = sha256(canonicalJson({ preparedManifest: intentArg.preparedManifest, manifest: intentArg.manifest, state: intentArg.state, })); const reference = intentArg.state.outcome.reference as TGitReversionReference | undefined; if ( intentArg.candidateId !== expectedCandidateId || intentArg.state.schemaVersion !== 1 || intentArg.state.kind !== 'capture-state' || intentArg.state.captureKey !== intentArg.captureKey || intentArg.state.manifestId !== manifestId || intentArg.state.currentSide !== 'after' || intentArg.state.revision !== 0 || !reference || reference.captureKey !== intentArg.captureKey || reference.manifestId !== manifestId ) throw new GitReversionFencedError('The Git finalization state is invalid.'); assertReference(reference); const changed = intentArg.manifest.repositories.some((entry) => ( !this.semanticSidesEqual(entry.before, entry.after!) )); const expectedDisposition = intentArg.manifest.reasonCode ? 'nonrevertible' : changed ? 'revertible' : 'no-change'; const expectedOutcome: TGitReversionFinalizedOutcome = expectedDisposition === 'nonrevertible' ? { disposition: 'nonrevertible', reference, reasonCode: intentArg.manifest.reasonCode!, ...(intentArg.manifest.affectedWorkspaces.length > 0 ? { affectedWorkspaces: intentArg.manifest.affectedWorkspaces } : {}), } : expectedDisposition === 'revertible' ? { disposition: 'revertible', reference, affectedWorkspaces: intentArg.manifest.affectedWorkspaces, } : { disposition: 'no-change', reference }; if (!recordsEqual(intentArg.state.outcome, expectedOutcome)) { throw new GitReversionFencedError('The Git finalization outcome is inconsistent.'); } } private async validateFinalizationRecords( intentArg: IGitReversionFinalizationIntent, recoveryArg: boolean, signalArg?: AbortSignal, ): Promise { this.assertFinalizationIntent(intentArg); const repositories = this.metadataRepositories( intentArg.manifest.repositories.map((entry) => entry.repository), ); for (const repository of repositories) { const manifestRef = this.manifestRef(intentArg.captureKey); const oid = await this.resolveRef(repository, manifestRef, signalArg); if (!oid) { if (!recoveryArg) throw new GitReversionFencedError('A prepared Git manifest is missing.'); } else { const value = await this.readRecord(repository, manifestRef, signalArg); const valid = recordsEqual(value, intentArg.preparedManifest) || (recoveryArg && recordsEqual(value, intentArg.manifest)); if (!valid) throw new GitReversionFencedError('A prepared Git manifest diverged.'); } } for (const entry of intentArg.manifest.repositories) { const identity = await this.readRecord<{ schemaVersion: 1; kind: 'repository-identity'; captureKey: string; repository: IGitReversionRepositoryIdentity; }>(entry.repository, this.repositoryIdentityRef( intentArg.captureKey, entry.repository.repositoryId, ), signalArg); const before = await this.readRecord<{ schemaVersion: 1; kind: 'capture-side'; captureKey: string; repository: IGitReversionRepositoryIdentity; side: IGitReversionSide; }>(entry.repository, this.captureSideRef( intentArg.captureKey, 'before', entry.repository.repositoryId, ), signalArg); if ( identity.schemaVersion !== 1 || identity.kind !== 'repository-identity' || identity.captureKey !== intentArg.captureKey || !recordsEqual(identity.repository, entry.repository) || before.schemaVersion !== 1 || before.kind !== 'capture-side' || before.captureKey !== intentArg.captureKey || !recordsEqual(before.repository, entry.repository) || !recordsEqual(before.side, entry.before) ) throw new GitReversionFencedError('A prepared Git repository record diverged.'); await this.verifyCapturedObjects( entry.repository, intentArg.captureKey, [entry.before, entry.after!], signalArg, ); } } private async publishFinalization( intentArg: IGitReversionFinalizationIntent, recoveryArg: boolean, signalArg?: AbortSignal, ): Promise { const mode = recoveryArg ? 'recovery' : 'normal'; const repositories = this.metadataRepositories( intentArg.manifest.repositories.map((entry) => entry.repository), ); for (const entry of intentArg.manifest.repositories) { await this.putRecord(entry.repository, this.captureSideRef( intentArg.captureKey, 'after', entry.repository.repositoryId, ), { schemaVersion: 1, kind: 'capture-side', captureKey: intentArg.captureKey, repository: entry.repository, side: entry.after, }, { mode }, signalArg); } for (const repository of repositories) { await this.putRecord( repository, this.capturePinsRef(intentArg.captureKey), intentArg.manifest, { mode }, signalArg, ); await this.putRecord( repository, this.captureStateRef(intentArg.captureKey), intentArg.state, { mode }, signalArg, ); await this.fault({ point: 'capture.after-state-repository', captureKey: intentArg.captureKey, repositoryId: repository.repositoryId, }); } for (const repository of repositories) { await this.putRecord( repository, this.manifestRef(intentArg.captureKey), intentArg.manifest, { mode, predecessor: intentArg.preparedManifest }, signalArg, ); await this.fault({ point: 'capture.after-manifest-repository', captureKey: intentArg.captureKey, repositoryId: repository.repositoryId, }); } const preparedWal = { schemaVersion: 1, kind: 'capture-wal', captureKey: intentArg.captureKey, phase: 'prepared', }; const finalizedWal = { schemaVersion: 1, kind: 'capture-wal', captureKey: intentArg.captureKey, phase: 'finalized', manifestId: intentArg.state.manifestId, }; for (const repository of repositories) { await this.putRecord( repository, this.captureWalRef(intentArg.captureKey), finalizedWal, { mode, predecessor: preparedWal }, signalArg, ); } } private async recoverFinalizations( projectArg: IRegisteredProject, repositoriesArg: readonly IGitReversionRepositoryIdentity[], captureKeyArg?: string, signalArg?: AbortSignal, ): Promise { const captureKeys = new Set(); if (captureKeyArg) captureKeys.add(captureKeyArg); for (const capture of await this.listDurableCaptureRecords()) { if ( capture.manifest.identity.projectId === projectArg.projectId && capture.manifest.phase === 'prepared' ) captureKeys.add(capture.captureKey); } for (const captureKey of [...captureKeys].sort()) { await this.recoverFinalization(projectArg, repositoriesArg, captureKey, signalArg); } } private async recoverFinalization( projectArg: IRegisteredProject, repositoriesArg: readonly IGitReversionRepositoryIdentity[], captureKeyArg: string, signalArg?: AbortSignal, ): Promise { const durable = await this.readDurableCaptureRecord(captureKeyArg); let repositories = this.metadataRepositories( durable?.manifest.repositories.map((entry) => entry.repository) ?? repositoriesArg, ); try { let scanned = await this.scanFinalizationRecords(repositories, captureKeyArg, signalArg); if (scanned.fenced) throw new GitReversionFencedError('The Git capture finalization is fenced.'); if (scanned.intents.length === 0) return; for (const value of scanned.intents) this.assertFinalizationIntent(value); const unique = new Map(scanned.intents.map((value) => [canonicalJson(value), value])); if (unique.size !== 1) throw new GitReversionFencedError( 'Conflicting Git finalization candidates were found.', ); const intent = [...unique.values()][0]!; if (intent.captureKey !== captureKeyArg) throw new GitReversionFencedError( 'A Git finalization candidate escaped its capture.', ); repositories = this.metadataRepositories(intent.manifest.repositories.map((entry) => entry.repository)); scanned = await this.scanFinalizationRecords(repositories, captureKeyArg, signalArg); if (scanned.fenced) throw new GitReversionFencedError('The Git capture finalization is fenced.'); const expandedUnique = new Map(scanned.intents.map((value) => [canonicalJson(value), value])); if ( expandedUnique.size !== 1 || !recordsEqual([...expandedUnique.values()][0], intent) ) throw new GitReversionFencedError('Conflicting Git finalization intents were found.'); const projectDirectory = await this.assertProjectDirectory(projectArg); if ( intent.manifest.identity.projectId !== projectArg.projectId || intent.manifest.projectDirectory !== projectDirectory || !filesystemIdentitiesEqual(intent.manifest.projectIdentity, projectArg.identity) ) throw new GitReversionFencedError('The Git finalization project identity diverged.'); await this.validateFinalizationRecords(intent, true, signalArg); for (const repository of repositories) { await this.putRecord( repository, this.finalizationCandidateRef(captureKeyArg, intent.candidateId), intent, { mode: 'recovery' }, signalArg, ); } await this.assertUniqueFinalizationIntent(repositories, captureKeyArg, intent, signalArg); for (const repository of repositories) { await this.putRecord( repository, this.finalizationIntentRef(captureKeyArg), intent, { mode: 'recovery' }, signalArg, ); } const complete = await this.finalizationPublicationComplete(intent, signalArg); if (!complete) { for (const repository of repositories) { const applyRefs = await this.listRefs( repository, `${this.captureRefRoot(captureKeyArg)}/applies/`, signalArg, ); if (applyRefs.length > 0) throw new GitReversionFencedError( 'Git apply metadata exists before capture finalization completed.', ); } await this.publishFinalization(intent, true, signalArg); } await this.validatePublishedFinalization(intent, signalArg); const generationIds = structuredClone(durable?.generationIds ?? {}); for (const entry of intent.manifest.repositories) { const ids = generationIds[entry.repository.repositoryId] ?? []; for (const side of [entry.before, entry.after!]) { if (side.objectGenerationId && !ids.includes(side.objectGenerationId)) { ids.push(side.objectGenerationId); } } generationIds[entry.repository.repositoryId] = ids; } await this.publishFinalizedCaptureRecord({ schemaVersion: 1, kind: 'capture', ownerKey: this.ownerKey, captureKey: captureKeyArg, manifest: intent.manifest, state: intent.state, generationIds, revision: (durable?.revision ?? 0) + 1, }, intent.manifest.identity.projectId); } catch (errorArg) { await this.fenceFinalization(repositories, captureKeyArg, signalArg); if (errorArg instanceof GitReversionFencedError) throw errorArg; throw new GitReversionFencedError('Git capture finalization recovery failed.', { cause: errorArg, }); } } private async scanFinalizationRecords( repositoriesArg: readonly IGitReversionRepositoryIdentity[], captureKeyArg: string, signalArg?: AbortSignal, ): Promise<{ intents: IGitReversionFinalizationIntent[]; fenced: boolean }> { const intents: IGitReversionFinalizationIntent[] = []; let fenced = false; const candidatePrefix = `${this.captureRefRoot(captureKeyArg)}/finalization/candidates/`; for (const repository of repositoriesArg) { const refs = await this.listRefs( repository, `${this.captureRefRoot(captureKeyArg)}/finalization/`, signalArg, ); for (const ref of refs) { if (ref === this.finalizationFenceRef(captureKeyArg)) { fenced = true; continue; } if (ref !== this.finalizationIntentRef(captureKeyArg) && !ref.startsWith(candidatePrefix)) { throw new GitReversionFencedError('An unexpected Git finalization ref was found.'); } const value = await this.readRecord(repository, ref, signalArg); this.assertFinalizationIntent(value); if ( ref.startsWith(candidatePrefix) && ref !== this.finalizationCandidateRef(captureKeyArg, value.candidateId) ) throw new GitReversionFencedError('A Git finalization candidate ref is invalid.'); intents.push(value); } } return { intents, fenced }; } private async assertUniqueFinalizationIntent( repositoriesArg: readonly IGitReversionRepositoryIdentity[], captureKeyArg: string, intentArg: IGitReversionFinalizationIntent, signalArg?: AbortSignal, ): Promise { const scanned = await this.scanFinalizationRecords(repositoriesArg, captureKeyArg, signalArg); if ( scanned.fenced || scanned.intents.length === 0 || scanned.intents.some((value) => !recordsEqual(value, intentArg)) ) { await this.fenceFinalization(repositoriesArg, captureKeyArg, signalArg); throw new GitReversionFencedError('The staged Git finalization candidate diverged.'); } for (const repository of repositoriesArg) { const oid = await this.resolveRef( repository, this.finalizationCandidateRef(captureKeyArg, intentArg.candidateId), signalArg, ); if (!oid) throw new GitReversionFencedError('A staged Git finalization candidate is missing.'); } } private async finalizationPublicationComplete( intentArg: IGitReversionFinalizationIntent, signalArg?: AbortSignal, ): Promise { const finalizedWal = { schemaVersion: 1, kind: 'capture-wal', captureKey: intentArg.captureKey, phase: 'finalized', manifestId: intentArg.state.manifestId, }; for (const repository of this.metadataRepositories( intentArg.manifest.repositories.map((entry) => entry.repository), )) { const manifestOid = await this.resolveRef( repository, this.manifestRef(intentArg.captureKey), signalArg, ); const walOid = await this.resolveRef(repository, this.captureWalRef(intentArg.captureKey), signalArg); if (!manifestOid || !walOid) return false; const manifest = await this.readRecord( repository, this.manifestRef(intentArg.captureKey), signalArg, ); const wal = await this.readRecord(repository, this.captureWalRef(intentArg.captureKey), signalArg); if (!recordsEqual(manifest, intentArg.manifest) || !recordsEqual(wal, finalizedWal)) return false; } return true; } private async validatePublishedFinalization( intentArg: IGitReversionFinalizationIntent, signalArg?: AbortSignal, ): Promise { for (const entry of intentArg.manifest.repositories) { const after = await this.readRecord<{ schemaVersion: 1; kind: 'capture-side'; captureKey: string; repository: IGitReversionRepositoryIdentity; side: IGitReversionSide; }>(entry.repository, this.captureSideRef( intentArg.captureKey, 'after', entry.repository.repositoryId, ), signalArg); if ( after.schemaVersion !== 1 || after.kind !== 'capture-side' || after.captureKey !== intentArg.captureKey || !recordsEqual(after.repository, entry.repository) || !recordsEqual(after.side, entry.after) ) throw new GitReversionFencedError('A finalized Git after-side diverged.'); } for (const repository of this.metadataRepositories( intentArg.manifest.repositories.map((entry) => entry.repository), )) { const pins = await this.readRecord( repository, this.capturePinsRef(intentArg.captureKey), signalArg, ); if (!recordsEqual(pins, intentArg.manifest)) { throw new GitReversionFencedError('A finalized Git pin record diverged.'); } } if (!await this.finalizationPublicationComplete(intentArg, signalArg)) { throw new GitReversionFencedError('Git finalization publication is incomplete.'); } } private async fenceFinalization( repositoriesArg: readonly IGitReversionRepositoryIdentity[], captureKeyArg: string, signalArg?: AbortSignal, ): Promise { const value = { schemaVersion: 1, kind: 'finalization-fence', captureKey: captureKeyArg, }; for (const repository of this.metadataRepositories(repositoriesArg)) { await this.putRecord( repository, this.finalizationFenceRef(captureKeyArg), value, { mode: 'recovery' }, signalArg, ).catch(() => undefined); } } private async findManifest( projectArg: IRegisteredProject, repositoriesArg: readonly IGitReversionRepositoryIdentity[], captureKeyArg: string, signalArg?: AbortSignal, ): Promise { const durable = await this.readDurableCaptureRecord(captureKeyArg); if (durable) return durable.manifest; await this.recoverFinalizations(projectArg, repositoriesArg, captureKeyArg, signalArg); const copies: IRecordCopy[] = []; for (const repository of this.metadataRepositories(repositoriesArg)) { const ref = this.manifestRef(captureKeyArg); const oid = await this.resolveRef(repository, ref, signalArg); if (!oid) continue; copies.push({ repository, ref, oid, value: await this.readRecord(repository, ref, signalArg), }); } if (copies.length === 0) return undefined; const manifest = copies[0]!.value; this.assertManifest(manifest); if (manifest.identity.projectId !== projectArg.projectId) { throw new Error('The Git capture belongs to another project.'); } if ( manifest.projectDirectory !== projectArg.directory || !filesystemIdentitiesEqual(manifest.projectIdentity, projectArg.identity) ) throw new GitReversionFencedError('The Git capture project filesystem identity diverged.'); if (!copies.every((copy) => recordsEqual(copy.value, manifest))) { throw new GitReversionFencedError('The replicated Git capture manifest diverged.'); } for (const repository of this.metadataRepositories( manifest.repositories.map((entry) => entry.repository), )) { const ref = this.manifestRef(captureKeyArg); const oid = await this.resolveRef(repository, ref, signalArg); if (!oid) throw new GitReversionFencedError('A replicated Git capture manifest is missing.'); } return manifest; } private async loadFinalizedCapture( identityArg: IGitReversionIdentity, referenceArg: TGitReversionReference, signalArg?: AbortSignal, ): Promise<{ manifest: IGitReversionManifest; state: IGitReversionCaptureState }> { assertIdentity(identityArg); assertReference(referenceArg); const project = this.requireProject(identityArg.projectId); const repositories = await this.discoverProjectRepositories(project, signalArg); const manifest = await this.findManifest(project, repositories, referenceArg.captureKey, signalArg); if (!manifest) throw new Error('The Git reversion capture is missing.'); this.assertManifestIdentity(manifest, identityArg); if (manifest.phase !== 'finalized') throw new Error('The Git reversion capture is not finalized.'); if (sha256(canonicalJson(manifest)) !== referenceArg.manifestId) { throw new Error('The Git reversion manifest ID does not match.'); } await this.assertFreshRepositoryBindings(project, manifest, repositories, signalArg); await this.recoverPendingApplyWals(manifest, signalArg); return { manifest, state: await this.loadState(manifest, signalArg) }; } private async assertFreshRepositoryBindings( projectArg: IRegisteredProject, manifestArg: IGitReversionManifest, discoveredArg: readonly IGitReversionRepositoryIdentity[], signalArg?: AbortSignal, ): Promise { const discovered = new Map(discoveredArg.map((repository) => [repository.repositoryId, repository])); if ( manifestArg.repositories.length !== discovered.size || manifestArg.repositories.some((entry) => { const fresh = discovered.get(entry.repository.repositoryId); return !fresh || !recordsEqual(fresh, entry.repository); }) ) throw new GitReversionFencedError( 'The persisted Git repository identity does not exactly match fresh discovery.', ); for (const entry of manifestArg.repositories) { const fresh = discovered.get(entry.repository.repositoryId)!; const repeated = await this.describeRepository( fresh.root, projectArg.directory, signalArg, { ownedWorktreeAuthority: fresh.scope === 'owned-worktree' }, ); if (!this.repositoryBindingsEqual(repeated, fresh)) { throw new GitReversionFencedError( 'The fresh Git repository topology changed during repeated verification.', ); } } } private async recoverPendingApplyWals( manifestArg: IGitReversionManifest, signalArg?: AbortSignal, ): Promise { const commonIds = [...new Set( manifestArg.repositories.map((entry) => entry.repository.commonId), )].sort(); const hasCurrentCaptureOwnership = (): boolean => commonIds.some((commonId) => ( this.ownershipByCommonId.get(commonId)?.kind === 'capture' )); const hasQueuedCaptureOwnership = (): boolean => commonIds.some((commonId) => ( [...(this.ownershipWaitersByCommonId.get(commonId) ?? [])].some((waiter) => ( waiter.kind === 'capture' )) )); let wals: IGitReversionApplyWal[]; try { wals = (await this.listLatestApplyWals(manifestArg, signalArg)).sort( (left, right) => left.sourceRevision - right.sourceRevision || left.attempt - right.attempt || left.operationKey.localeCompare(right.operationKey), ); if (wals.some((wal) => wal.phase === 'fenced')) return; const byRevision = new Map(); for (const wal of wals) { const group = byRevision.get(wal.sourceRevision) ?? []; group.push(wal); byRevision.set(wal.sourceRevision, group); } for (const group of byRevision.values()) { const nonCompensated = group.filter((wal) => wal.phase !== 'compensated'); const targetDecisions = nonCompensated.filter((wal) => wal.decision === 'target'); if ( targetDecisions.length > 1 || (targetDecisions.length === 1 && nonCompensated.length > 1) || (targetDecisions.length === 0 && nonCompensated.length > 1) ) throw new GitReversionFencedError( 'Conflicting Git apply operations claim the same source revision.', ); } } catch (errorArg) { const ownershipToken = `apply-recovery:${manifestArg.captureKey}:${plugins.crypto .randomBytes(16).toString('hex')}`; const borrowCaptureOwnership = hasCurrentCaptureOwnership(); if (!borrowCaptureOwnership && hasQueuedCaptureOwnership()) { throw new GitReversionRecoveryRequiredError( 'A Git capture is queued before apply metadata can be fenced.', { cause: errorArg instanceof Error ? errorArg : undefined }, ); } if (!borrowCaptureOwnership) { await this.acquireOwnership( commonIds, ownershipToken, manifestArg.identity.projectId, 'apply', signalArg, ); } try { await this.withLocks(commonIds, async () => { await this.requireFinalizedCaptureLifecycle( manifestArg.captureKey, manifestArg.identity.projectId, ); await this.fenceDiscoveredApplyWals(manifestArg, signalArg); }); } finally { if (!borrowCaptureOwnership) this.releaseOwnership(ownershipToken); } throw new GitReversionFencedError('Replicated Git apply metadata is inconsistent.', { cause: errorArg, }); } for (const wal of wals) { if (wal.phase === 'fenced') continue; if (['committed', 'compensated'].includes(wal.phase)) { await this.withLocks(commonIds, async () => { await this.requireFinalizedCaptureLifecycle( manifestArg.captureKey, manifestArg.identity.projectId, ); const operations = await this.inspectApplyOperations(manifestArg, signalArg); if (operations.some((entry) => ( entry.fenced || !['committed', 'compensated'].includes(entry.wal.phase) ))) throw new GitReversionRecoveryRequiredError( 'The Git apply operation set changed before terminal recovery.', ); const operation = operations.find( (entry) => entry.operationKey === wal.operationKey, ); if ( !operation || !['committed', 'compensated'].includes(operation.wal.phase) ) throw new GitReversionRecoveryRequiredError( 'The terminal Git apply changed before recovery.', ); await this.recoverWal(manifestArg, undefined, operation.wal, signalArg); await this.loadState(manifestArg, signalArg); }); continue; } if (hasCurrentCaptureOwnership() || hasQueuedCaptureOwnership()) { throw new GitReversionRecoveryRequiredError( 'A Git capture excludes nonterminal apply recovery.', ); } const token = `apply:${wal.captureKey}:${wal.operationKey}`; await this.acquireOwnership( commonIds, token, manifestArg.identity.projectId, 'apply', signalArg, ); try { await this.withLocks(commonIds, async () => { await this.requireFinalizedCaptureLifecycle( manifestArg.captureKey, manifestArg.identity.projectId, ); await this.recoverWal(manifestArg, undefined, wal, signalArg); await this.loadState(manifestArg, signalArg); }); } finally { this.releaseOwnership(token); } } } private async fenceDiscoveredApplyWals( manifestArg: IGitReversionManifest, signalArg?: AbortSignal, ): Promise { const found = new Map(); const prefix = `${this.captureRefRoot(manifestArg.captureKey)}/applies/`; for (const repository of this.metadataRepositories( manifestArg.repositories.map((entry) => entry.repository), )) { const refs = await this.listRefs(repository, prefix, signalArg).catch(() => []); for (const ref of refs.filter((entry) => entry.endsWith('/wal') || entry.endsWith('/latest'))) { const wal = await this.readRecord(repository, ref, signalArg) .catch(() => undefined); if (!wal) continue; try { this.assertWal(wal, manifestArg); found.set(`${wal.operationKey}:${wal.attempt}`, wal); } catch { // Invalid records cannot safely supply a fence identity. } } } for (const wal of found.values()) await this.fenceWal(manifestArg, wal, signalArg); } private async createState( manifestArg: IGitReversionManifest, stateArg: IGitReversionCaptureState, signalArg?: AbortSignal, ): Promise { for (const repository of this.metadataRepositories( manifestArg.repositories.map((entry) => entry.repository), )) { await this.writeRecord( repository, this.captureStateRef(manifestArg.captureKey), stateArg, zeroOid(repository), signalArg, ); } } private async replaceState( manifestArg: IGitReversionManifest, stateArg: IGitReversionCaptureState, predecessorArg: IGitReversionCaptureState, modeArg: 'normal' | 'recovery', signalArg?: AbortSignal, ): Promise { if (manifestArg.repositories.length === 0) { const durable = await this.readDurableCaptureRecord(manifestArg.captureKey); if (!durable) throw new Error('The repository-free Git capture is missing.'); await this.writeDurableCaptureRecord({ ...durable, manifest: manifestArg, state: stateArg, revision: durable.revision + 1, }); return; } for (const repository of this.metadataRepositories( manifestArg.repositories.map((entry) => entry.repository), )) { await this.putRecord( repository, this.captureStateRef(manifestArg.captureKey), stateArg, { mode: modeArg, predecessor: predecessorArg }, signalArg, ); await this.fault({ point: 'apply.after-state-repository', captureKey: manifestArg.captureKey, repositoryId: repository.repositoryId, }); } const durable = await this.readDurableCaptureRecord(manifestArg.captureKey); if (!durable) throw new GitReversionFencedError('The durable capture state is missing.'); await this.writeDurableCaptureRecord({ ...durable, manifest: manifestArg, state: stateArg, revision: durable.revision + 1, }); } private async loadState( manifestArg: IGitReversionManifest, signalArg?: AbortSignal, ): Promise { if (manifestArg.repositories.length === 0) { const state = (await this.readDurableCaptureRecord(manifestArg.captureKey))?.state; if (!state) throw new Error('The repository-free Git capture state is missing.'); this.assertCaptureState(state, manifestArg); return state; } const copies: IGitReversionCaptureState[] = []; for (const repository of this.metadataRepositories( manifestArg.repositories.map((entry) => entry.repository), )) { copies.push(await this.readRecord( repository, this.captureStateRef(manifestArg.captureKey), signalArg, )); } const state = copies[0]; if (!state) throw new GitReversionFencedError('The replicated Git capture state is missing.'); for (const copy of copies) this.assertCaptureState(copy, manifestArg); if (!copies.every((copy) => recordsEqual(copy, state))) { throw new GitReversionFencedError('The replicated Git capture state diverged.'); } return state; } private async createApplyPlans( manifestArg: IGitReversionManifest, sideNamesArg: { source: TGitReversionSideName; target: TGitReversionSideName }, operationKeyArg: string, attemptArg: number, signalArg?: AbortSignal, ): Promise<{ plans: IGitReversionApplyPlan[]; generationIds: Record; }> { const repositories = manifestArg.repositories.map((entry) => entry.repository); const generationIds: Record = {}; const initialSides = await this.captureStableRepositories(repositories, signalArg, { captureKey: manifestArg.captureKey, persistent: true, generationIds, }); const plans: IGitReversionApplyPlan[] = []; try { for (let index = 0; index < manifestArg.repositories.length; index += 1) { const entry = manifestArg.repositories[index]!; const initial = initialSides[index]!; const source = sideNamesArg.source === 'before' ? entry.before : entry.after!; const target = sideNamesArg.target === 'before' ? entry.before : entry.after!; const scopedRefs = [...new Set( [entry.before.headRef, entry.after!.headRef] .filter((ref): ref is string => ref !== null), )].sort(); this.assertApplyPreconditions(entry.repository, initial, source, scopedRefs); await this.verifyCapturedObjects( entry.repository, manifestArg.captureKey, [source, target], signalArg, ); const mergeGeneration = await this.createGeneration( manifestArg.captureKey, entry.repository, signalArg, ); let generationVerified = false; try { const desiredWorktreeTree = await this.mergeTrees( entry.repository, mergeGeneration.context.environment, source.worktreeTree, initial.worktreeTree, target.worktreeTree, signalArg, ); const desiredIndexTree = await this.mergeTrees( entry.repository, mergeGeneration.context.environment, source.indexTree, initial.indexTree, target.indexTree, signalArg, ); const syntheticRootOid = await this.createApplySyntheticRoot( entry.repository, mergeGeneration.context.environment, { captureKey: manifestArg.captureKey, operationKey: operationKeyArg, attempt: attemptArg, generationId: mergeGeneration.context.generationId, initial, source, target, desiredWorktreeTree, desiredIndexTree, }, signalArg, ); const roots = [...new Set([ ...this.closureRoots(initial), ...this.closureRoots(source), ...this.closureRoots(target), desiredWorktreeTree, desiredIndexTree, syntheticRootOid, ])].sort(compareStrings); if ( roots.length > addSafeLimit(multiplySafeLimit(this.limits.maximumPaths, 3), 8) || Buffer.byteLength(`${roots.join('\n')}\n`, 'utf8') > this.limits.maximumPathBytes ) throw new Error('The apply source-pack closure root list exceeds its limit.'); const pack = await this.materializeGenerationClosure( entry.repository, mergeGeneration.context, roots, signalArg, ); await this.verifyGeneration(entry.repository, mergeGeneration.context); generationVerified = true; const ids = generationIds[entry.repository.repositoryId] ?? []; ids.push(mergeGeneration.context.generationId); generationIds[entry.repository.repositoryId] = ids; const plan: IGitReversionApplyPlan = { captureKey: manifestArg.captureKey, repository: entry.repository, initial, source, target, desiredWorktreeTree, desiredIndexTree, scopedRefs, sourcePack: { generationId: mergeGeneration.context.generationId, packHash: pack.packHash, packBytes: pack.packBytes, objectCount: pack.objectCount, syntheticRootOid, keepToken: [ 'hcon-v1', this.ownerKey, manifestArg.captureKey, operationKeyArg, String(attemptArg), entry.repository.repositoryId, mergeGeneration.context.generationId, ].join(':'), }, }; await this.preflightPlan(plan, signalArg); plans.push(plan); } finally { if (!generationVerified) { await this.removeGeneration(entry.repository, mergeGeneration.context).catch(() => undefined); } } } return { plans: plans.sort((left, right) => compareRepositories(left.repository, right.repository)), generationIds, }; } catch (errorArg) { for (const [repositoryId, ids] of Object.entries(generationIds)) { const repository = repositories.find((candidate) => candidate.repositoryId === repositoryId); if (!repository) continue; for (const generationId of ids) { await this.removeGeneration(repository, this.generationContext( manifestArg.captureKey, repositoryId, generationId, )).catch(() => undefined); } } throw errorArg; } } private async createApplySyntheticRoot( repositoryArg: IGitReversionRepositoryIdentity, objectEnvironmentArg: Record, inputArg: { captureKey: string; operationKey: string; attempt: number; generationId: string; initial: IGitReversionSide; source: IGitReversionSide; target: IGitReversionSide; desiredWorktreeTree: string; desiredIndexTree: string; }, signalArg?: AbortSignal, ): Promise { const trees = [ ['desired-index', inputArg.desiredIndexTree], ['desired-worktree', inputArg.desiredWorktreeTree], ['initial-index', inputArg.initial.indexTree], ['initial-worktree', inputArg.initial.worktreeTree], ['source-index', inputArg.source.indexTree], ['source-worktree', inputArg.source.worktreeTree], ['target-index', inputArg.target.indexTree], ['target-worktree', inputArg.target.worktreeTree], ] as const; const tree = trimLine((await this.git( repositoryArg, ['mktree'], signalArg, [0], Buffer.from(trees.map(([name, oid]) => `040000 tree ${oid}\t${name}\n`).join(''), 'utf8'), objectEnvironmentArg, )).stdout); if (!objectIdPattern.test(tree)) { throw new GitReversionFencedError('Git returned an invalid apply source-pack tree.'); } const parents = [...new Set([ inputArg.initial.headOid, inputArg.source.headOid, inputArg.target.headOid, ])]; const syntheticRootOid = trimLine((await this.git( repositoryArg, [ 'commit-tree', tree, ...parents.flatMap((oid) => ['-p', oid]), '-m', canonicalJson({ kind: 'harness-controller-source-pack', ownerKey: this.ownerKey, captureKey: inputArg.captureKey, operationKey: inputArg.operationKey, attempt: inputArg.attempt, repositoryId: repositoryArg.repositoryId, generationId: inputArg.generationId, }), ], signalArg, [0], undefined, { ...this.canonicalCommitEnvironment(), ...objectEnvironmentArg }, )).stdout); if (!objectIdPattern.test(syntheticRootOid)) { throw new GitReversionFencedError('Git returned an invalid apply source-pack root.'); } return syntheticRootOid; } private assertApplyPreconditions( repositoryArg: IGitReversionRepositoryIdentity, currentArg: IGitReversionSide, sourceArg: IGitReversionSide, scopedRefsArg: readonly string[], ): void { if (currentArg.unsupportedReason) { throw new GitReversionConflictError(`Current Git state is unsupported: ${currentArg.unsupportedReason}`); } if ( currentArg.topologyHash !== sourceArg.topologyHash || currentArg.ignorePolicyHash !== sourceArg.ignorePolicyHash || currentArg.operationHash !== sourceArg.operationHash || currentArg.headOid !== sourceArg.headOid || currentArg.headRef !== sourceArg.headRef || !recordsEqual(currentArg.symbolicRefs, sourceArg.symbolicRefs) ) throw new GitReversionConflictError('The Git HEAD, topology, or policy no longer matches the source side.'); for (const ref of scopedRefsArg) { if (currentArg.refs[ref] !== sourceArg.refs[ref]) { throw new GitReversionConflictError(`The scoped Git branch changed: ${ref}`); } } this.assertRepositoryIdentity(repositoryArg); } private async verifyCapturedObjects( repositoryArg: IGitReversionRepositoryIdentity, captureKeyArg: string, sidesArg: readonly IGitReversionSide[], signalArg?: AbortSignal, ): Promise { for (const side of sidesArg) { for (const oid of [side.worktreeTree, side.indexTree]) { const result = await this.privateGit( repositoryArg, captureKeyArg, ['cat-file', '-e', `${oid}^{tree}`], signalArg, [0, 1, 128], ); if (result.exitCode !== 0) throw new GitReversionFencedError('A captured Git tree is missing.'); } const head = await this.privateGit( repositoryArg, captureKeyArg, ['cat-file', '-e', `${side.headOid}^{commit}`], signalArg, [0, 1, 128], ); if (head.exitCode !== 0) throw new GitReversionFencedError('A captured Git HEAD is missing.'); } } private async mergeTrees( repositoryArg: IGitReversionRepositoryIdentity, objectEnvironmentArg: Record, baseTreeArg: string, currentTreeArg: string, targetTreeArg: string, signalArg?: AbortSignal, ): Promise { if (currentTreeArg === baseTreeArg) return targetTreeArg; if (targetTreeArg === baseTreeArg) return currentTreeArg; const createCommit = async (treeArg: string, parentArg?: string): Promise => trimLine(( await this.git(repositoryArg, [ 'commit-tree', treeArg, ...(parentArg ? ['-p', parentArg] : []), '-m', 'harness-controller reversion merge input', ], signalArg, [0], undefined, { ...this.canonicalCommitEnvironment(), ...objectEnvironmentArg, }) ).stdout); const baseCommit = await createCommit(baseTreeArg); const currentCommit = await createCommit(currentTreeArg, baseCommit); const targetCommit = await createCommit(targetTreeArg, baseCommit); const result = await this.git(repositoryArg, [ 'merge-tree', '--write-tree', `--merge-base=${baseCommit}`, currentCommit, targetCommit, ], signalArg, [0, 1], undefined, objectEnvironmentArg, this.limits.commandTimeoutMs, 32 * 1024 * 1024); const tree = result.stdout.toString('utf8').split('\n')[0]?.trim() ?? ''; if (result.exitCode !== 0 || !objectIdPattern.test(tree)) throw new GitReversionConflictError(); return tree; } private async preflightPlan( planArg: IGitReversionApplyPlan, signalArg?: AbortSignal, ): Promise { await this.assertNoIgnoredCollision(planArg, signalArg); const objectEnvironment = await this.captureReadEnvironment( planArg.captureKey, planArg.repository, ); const temporaryIndex = await this.temporaryIndexPath(planArg.repository.repositoryId); try { await this.git( planArg.repository, ['read-tree', planArg.initial.worktreeTree], signalArg, [0], undefined, { ...objectEnvironment, GIT_INDEX_FILE: temporaryIndex }, ); await this.git( planArg.repository, ['update-index', '--refresh'], signalArg, [0], undefined, { ...objectEnvironment, GIT_INDEX_FILE: temporaryIndex }, ); await this.assertTemporaryIndexBound(temporaryIndex); await this.git( planArg.repository, ['read-tree', '--dry-run', '-m', '-u', planArg.initial.worktreeTree, planArg.desiredWorktreeTree], signalArg, [0], undefined, { ...objectEnvironment, GIT_INDEX_FILE: temporaryIndex }, ); } catch (errorArg) { throw new GitReversionConflictError('The Git worktree transition failed preflight.', { cause: errorArg, }); } finally { await this.removeTemporaryIndexPaths([ temporaryIndex, `${temporaryIndex}.lock`, ]).catch(() => undefined); } } private async assertNoIgnoredCollision( planArg: IGitReversionApplyPlan, signalArg?: AbortSignal, ): Promise { const objectEnvironment = await this.captureReadEnvironment( planArg.captureKey, planArg.repository, ); const changed = await this.git(planArg.repository, [ 'diff-tree', '-r', '--no-commit-id', '--name-only', '-z', planArg.initial.worktreeTree, planArg.desiredWorktreeTree, ], signalArg, [0], undefined, objectEnvironment); const ignored = await this.git(planArg.repository, [ 'ls-files', '--others', '--ignored', '--exclude-standard', '-z', '--', ...this.pathspec(planArg.repository), ], signalArg, [0]); this.assertPathOutputBound(changed.stdout, 'Changed Git paths'); this.assertPathOutputBound(ignored.stdout, 'Ignored Git paths'); const changedPaths = splitNul(changed.stdout, 'Changed Git paths'); const ignoredPaths = splitNul(ignored.stdout, 'Ignored Git paths'); if ( changedPaths.length > this.limits.maximumPaths || ignoredPaths.length > this.limits.maximumPaths ) throw new Error('The Git collision path count limit was exceeded.'); signalArg?.throwIfAborted(); changedPaths.sort(compareStrings); ignoredPaths.sort(compareStrings); let changedIndex = 0; let ignoredIndex = 0; let comparisons = 0; while (changedIndex < changedPaths.length && ignoredIndex < ignoredPaths.length) { const changedPath = changedPaths[changedIndex]!; const ignoredPath = ignoredPaths[ignoredIndex]!; if ( changedPath === ignoredPath || changedPath.startsWith(`${ignoredPath}/`) || ignoredPath.startsWith(`${changedPath}/`) ) throw new GitReversionConflictError('An ignored path collides with the Git transition.'); if (compareStrings(changedPath, ignoredPath) < 0) changedIndex += 1; else ignoredIndex += 1; comparisons += 1; if (comparisons % 256 === 0) { signalArg?.throwIfAborted(); await new Promise((resolve) => setImmediate(resolve)); } } } private async verifyPlansUnchanged( plansArg: readonly IGitReversionApplyPlan[], signalArg?: AbortSignal, ): Promise { const current = await this.captureStableRepositories( plansArg.map((plan) => plan.repository), signalArg, { captureKey: plansArg[0]?.captureKey ?? '0'.repeat(64), persistent: false, }, ); if (!current.every((side, index) => this.sidesEqual(side, plansArg[index]!.initial))) { throw new GitReversionConflictError('Git state changed during all-repository preflight.'); } } private async applyPlan( planArg: IGitReversionApplyPlan, signalArg?: AbortSignal, ): Promise { await this.materializePlanObjectsInSource(planArg, signalArg); await this.transitionWorktree( planArg.repository, planArg.captureKey, planArg.initial.worktreeTree, planArg.desiredWorktreeTree, signalArg, ); await this.installIndex( planArg.repository, planArg.captureKey, planArg.initial.indexTree, planArg.desiredIndexTree, signalArg, ); await this.applyRefs( planArg.repository, planArg.initial, planArg.target, planArg.scopedRefs, signalArg, ); } private async materializePlanObjectsInSource( planArg: IGitReversionApplyPlan, signalArg?: AbortSignal, ): Promise { await this.assertRepositoryBinding(planArg.repository); const privatePack = plugins.path.join( this.generationDirectory( planArg.captureKey, planArg.repository.repositoryId, planArg.sourcePack.generationId, ), 'objects', 'pack', `pack-${planArg.sourcePack.packHash}.pack`, ); const privateHandle = await plugins.fs.promises.open( privatePack, plugins.fs.constants.O_RDONLY | plugins.fs.constants.O_NOFOLLOW, ); const sourcePackDirectory = plugins.path.join(planArg.repository.objectDir, 'pack'); const sourcePack = plugins.path.join( sourcePackDirectory, `pack-${planArg.sourcePack.packHash}.pack`, ); const sourceIndex = plugins.path.join( sourcePackDirectory, `pack-${planArg.sourcePack.packHash}.idx`, ); const sourceKeep = plugins.path.join( sourcePackDirectory, `pack-${planArg.sourcePack.packHash}.keep`, ); let privateStat: plugins.fs.Stats; try { privateStat = await privateHandle.stat(); if ( !privateStat.isFile() || privateStat.nlink !== 1 || (privateStat.mode & 0o077) !== 0 || privateStat.size !== planArg.sourcePack.packBytes || (typeof process.getuid === 'function' && privateStat.uid !== process.getuid()) ) throw new GitReversionFencedError('The private apply source pack is invalid.'); let sourcePackDirectoryCreated = false; try { const stat = await plugins.fs.promises.lstat(sourcePackDirectory); if (!stat.isDirectory() || stat.isSymbolicLink()) { throw new GitReversionFencedError('The source Git pack directory is invalid.'); } } catch (errorArg) { if ((errorArg as NodeJS.ErrnoException).code !== 'ENOENT') throw errorArg; await this.assertRepositoryBinding(planArg.repository); await plugins.fs.promises.mkdir(sourcePackDirectory, { mode: 0o700 }); sourcePackDirectoryCreated = true; } if (sourcePackDirectoryCreated) await this.syncDirectory(planArg.repository.objectDir); const keepExisted = await this.assertSourceKeep(planArg, true); const imported = await this.runGit([ '-c', 'pack.writeReverseIndex=false', '-c', 'core.fsync=all', '-c', 'core.fsyncMethod=fsync', 'index-pack', '--stdin', '--strict', '--threads=1', '--no-rev-index', `--keep=${planArg.sourcePack.keepToken}`, `--max-input-size=${planArg.sourcePack.packBytes}`, ], { cwd: planArg.repository.root, signal: signalArg, acceptedExitCodes: [0], inputFile: { handle: privateHandle, stat: privateStat }, env: { GIT_DIR: planArg.repository.gitDir, GIT_COMMON_DIR: planArg.repository.commonDir, GIT_WORK_TREE: planArg.repository.root, GIT_OBJECT_DIRECTORY: planArg.repository.objectDir, GIT_ALTERNATE_OBJECT_DIRECTORIES: '', GIT_NO_REPLACE_OBJECTS: '1', }, timeoutMs: this.limits.worktreeCommandTimeoutMs, maximumOutputBytes: 64 * 1024, }); const match = /^(keep|pack)\t([0-9a-f]{40}|[0-9a-f]{64})$/u.exec(trimLine(imported.stdout)); if ( !match || match[2] !== planArg.sourcePack.packHash || (match[1] === 'pack' && !keepExisted) ) throw new GitReversionFencedError('Git returned an invalid source-pack installation result.'); await this.fault({ point: 'apply.after-source-pack-import', captureKey: planArg.captureKey, repositoryId: planArg.repository.repositoryId, }); await this.assertSourceKeep(planArg, false); const [packStat, indexStat] = await Promise.all([ this.syncRegularFile(sourcePack, 'Source Git pack', false), this.syncRegularFile(sourceIndex, 'Source Git pack index', false), this.syncRegularFile(sourceKeep, 'Source Git pack keep', false), ]); if (packStat.size !== planArg.sourcePack.packBytes || indexStat.size < 1024) { throw new GitReversionFencedError('The installed source Git pack is invalid.'); } await this.syncDirectory(sourcePackDirectory); await this.syncDirectory(planArg.repository.objectDir); await this.fault({ point: 'apply.after-source-pack-fsync', captureKey: planArg.captureKey, repositoryId: planArg.repository.repositoryId, }); await this.runGit(['verify-pack', '--', sourceIndex], { cwd: planArg.repository.root, signal: signalArg, acceptedExitCodes: [0], env: { GIT_DIR: planArg.repository.gitDir, GIT_COMMON_DIR: planArg.repository.commonDir, GIT_WORK_TREE: planArg.repository.root, GIT_OBJECT_DIRECTORY: planArg.repository.objectDir, GIT_ALTERNATE_OBJECT_DIRECTORIES: '', GIT_NO_REPLACE_OBJECTS: '1', }, timeoutMs: this.limits.worktreeCommandTimeoutMs, maximumOutputBytes: 64 * 1024, }); } finally { await privateHandle.close(); } await this.assertRepositoryBinding(planArg.repository); const environment = { GIT_DIR: planArg.repository.gitDir, GIT_COMMON_DIR: planArg.repository.commonDir, GIT_WORK_TREE: planArg.repository.root, GIT_OBJECT_DIRECTORY: planArg.repository.objectDir, GIT_ALTERNATE_OBJECT_DIRECTORIES: '', GIT_NO_REPLACE_OBJECTS: '1', }; await this.runGit(['rev-list', '--objects', '--quiet', '--missing=error', '--stdin'], { cwd: planArg.repository.root, signal: signalArg, acceptedExitCodes: [0], input: Buffer.from(`${planArg.sourcePack.syntheticRootOid}\n`, 'utf8'), env: environment, timeoutMs: this.limits.worktreeCommandTimeoutMs, maximumOutputBytes: 64 * 1024, }); } private async assertSourceKeep( planArg: IGitReversionApplyPlan, allowMissingArg: boolean, ): Promise { const path = plugins.path.join( planArg.repository.objectDir, 'pack', `pack-${planArg.sourcePack.packHash}.keep`, ); let handle: plugins.fs.promises.FileHandle; try { handle = await plugins.fs.promises.open( path, plugins.fs.constants.O_RDONLY | plugins.fs.constants.O_NOFOLLOW | plugins.fs.constants.O_NONBLOCK, ); } catch (errorArg) { if (allowMissingArg && (errorArg as NodeJS.ErrnoException).code === 'ENOENT') return false; throw errorArg; } try { const stat = await handle.stat(); const expected = Buffer.from(`${planArg.sourcePack.keepToken}\n`, 'utf8'); if ( !stat.isFile() || stat.nlink !== 1 || stat.size !== expected.byteLength || (typeof process.getuid === 'function' && stat.uid !== process.getuid()) ) throw new GitReversionFencedError('The source Git pack keep is not exclusively owned.'); const value = Buffer.allocUnsafe(stat.size); const { bytesRead } = await handle.read(value, 0, value.byteLength, 0); const after = await handle.stat(); if ( bytesRead !== value.byteLength || !value.equals(expected) || after.dev !== stat.dev || after.ino !== stat.ino || after.size !== stat.size ) throw new GitReversionFencedError('The source Git pack keep ownership diverged.'); return true; } finally { await handle.close(); } } private async removeSourceKeeps( plansArg: readonly IGitReversionApplyPlan[], signalArg?: AbortSignal, allowMissingArg = false, ): Promise { for (const plan of plansArg) { signalArg?.throwIfAborted(); await this.removeSourceKeep(plan, signalArg, allowMissingArg); } } private sourceKeepQuarantinePath( planArg: IGitReversionApplyPlan, packDirectoryArg: string, ): string { const quarantineId = sha256(canonicalJson({ ownerKey: this.ownerKey, captureKey: planArg.captureKey, repositoryId: planArg.repository.repositoryId, packHash: planArg.sourcePack.packHash, keepToken: planArg.sourcePack.keepToken, })); return plugins.path.join( packDirectoryArg, `.hcon-${this.ownerKey}-${quarantineId}.keep-removal`, ); } private async reconcileSourceKeepQuarantine( keepPathArg: string, quarantinePathArg: string, packDirectoryArg: string, expectedArg: Buffer, ): Promise<'absent' | 'removed'> { let handle: plugins.fs.promises.FileHandle; try { handle = await plugins.fs.promises.open( quarantinePathArg, plugins.fs.constants.O_RDONLY | plugins.fs.constants.O_NOFOLLOW | plugins.fs.constants.O_NONBLOCK, ); } catch (errorArg) { if ((errorArg as NodeJS.ErrnoException).code === 'ENOENT') return 'absent'; throw new GitReversionFencedError('The source Git pack keep quarantine is invalid.', { cause: errorArg instanceof Error ? errorArg : undefined, }); } try { const [descriptor, path] = await Promise.all([ handle.stat(), plugins.fs.promises.lstat(quarantinePathArg), ]); const value = Buffer.allocUnsafe(expectedArg.byteLength); const read = await handle.read(value, 0, value.byteLength, 0); const afterRead = await handle.stat(); if ( !descriptor.isFile() || !path.isFile() || path.isSymbolicLink() || descriptor.dev !== path.dev || descriptor.ino !== path.ino || ![1, 2].includes(descriptor.nlink) || path.nlink !== descriptor.nlink || descriptor.size !== expectedArg.byteLength || path.size !== descriptor.size || descriptor.uid !== path.uid || descriptor.mode !== path.mode || (typeof process.getuid === 'function' && descriptor.uid !== process.getuid()) || read.bytesRead !== value.byteLength || !value.equals(expectedArg) || afterRead.dev !== descriptor.dev || afterRead.ino !== descriptor.ino || afterRead.nlink !== descriptor.nlink || afterRead.size !== descriptor.size || afterRead.ctimeMs !== descriptor.ctimeMs || afterRead.mtimeMs !== descriptor.mtimeMs ) throw new GitReversionFencedError('The source Git pack keep quarantine diverged.'); const keep = await plugins.fs.promises.lstat(keepPathArg).then( (stat) => stat, (errorArg: NodeJS.ErrnoException) => { if (errorArg.code === 'ENOENT') return undefined; throw errorArg; }, ); if (keep) { if ( !keep.isFile() || keep.isSymbolicLink() ) throw new GitReversionFencedError( 'The source Git pack keep quarantine conflicts with its live path.', ); if (keep.dev === descriptor.dev && keep.ino === descriptor.ino) { if (keep.nlink !== 2 || descriptor.nlink !== 2) { throw new GitReversionFencedError('The restored source Git pack keep is not exact.'); } await plugins.fs.promises.rm(quarantinePathArg); if ((await handle.stat()).nlink !== 1) { throw new GitReversionFencedError('The restored source Git pack keep is not exact.'); } await this.syncDirectory(packDirectoryArg); return 'absent'; } if (keep.nlink !== 1 || descriptor.nlink !== 1) { throw new GitReversionFencedError( 'The source Git pack keep quarantine conflicts with another link.', ); } const keepHandle = await plugins.fs.promises.open( keepPathArg, plugins.fs.constants.O_RDONLY | plugins.fs.constants.O_NOFOLLOW | plugins.fs.constants.O_NONBLOCK, ); try { const keepDescriptor = await keepHandle.stat(); const keepValue = Buffer.allocUnsafe(expectedArg.byteLength); const keepRead = await keepHandle.read(keepValue, 0, keepValue.byteLength, 0); const keepAfterRead = await keepHandle.stat(); if ( !keepDescriptor.isFile() || keepDescriptor.dev !== keep.dev || keepDescriptor.ino !== keep.ino || keepDescriptor.nlink !== 1 || keepDescriptor.size !== expectedArg.byteLength || (typeof process.getuid === 'function' && keepDescriptor.uid !== process.getuid()) || keepRead.bytesRead !== keepValue.byteLength || !keepValue.equals(expectedArg) || keepAfterRead.dev !== keepDescriptor.dev || keepAfterRead.ino !== keepDescriptor.ino || keepAfterRead.nlink !== keepDescriptor.nlink || keepAfterRead.size !== keepDescriptor.size || keepAfterRead.ctimeMs !== keepDescriptor.ctimeMs || keepAfterRead.mtimeMs !== keepDescriptor.mtimeMs ) throw new GitReversionFencedError( 'The recreated source Git pack keep diverged from its quarantine.', ); } finally { await keepHandle.close(); } await plugins.fs.promises.rm(quarantinePathArg); if ((await handle.stat()).nlink !== 0) { throw new GitReversionFencedError('The prior source Git pack keep was not removed exactly.'); } await this.syncDirectory(packDirectoryArg); return 'absent'; } if (descriptor.nlink !== 1) { throw new GitReversionFencedError('The source Git pack keep quarantine has another link.'); } await plugins.fs.promises.rm(quarantinePathArg); if ((await handle.stat()).nlink !== 0) { throw new GitReversionFencedError('The source Git pack keep quarantine removal was not exact.'); } return 'removed'; } finally { await handle.close(); } } private async removeSourceKeep( planArg: IGitReversionApplyPlan, signalArg?: AbortSignal, allowMissingArg = false, ): Promise { try { await this.removeSourceKeepOwned(planArg, signalArg, allowMissingArg); } catch (errorArg) { if (errorArg instanceof GitReversionFencedError) { throw new GitReversionSourceKeepOwnershipError(errorArg.message, { cause: errorArg }); } throw errorArg; } } private async removeSourceKeepOwned( planArg: IGitReversionApplyPlan, signalArg?: AbortSignal, allowMissingArg = false, ): Promise { await this.assertRepositoryBinding(planArg.repository); const packDirectory = plugins.path.join(planArg.repository.objectDir, 'pack'); const keepPath = plugins.path.join( packDirectory, `pack-${planArg.sourcePack.packHash}.keep`, ); const expected = Buffer.from(`${planArg.sourcePack.keepToken}\n`, 'utf8'); const quarantinePath = this.sourceKeepQuarantinePath(planArg, packDirectory); const quarantineState = await this.reconcileSourceKeepQuarantine( keepPath, quarantinePath, packDirectory, expected, ); if (quarantineState === 'removed') { await this.fault({ point: 'apply.after-source-keep-remove', captureKey: planArg.captureKey, repositoryId: planArg.repository.repositoryId, }); await this.syncDirectory(packDirectory); await this.fault({ point: 'apply.after-source-keep-remove-fsync', captureKey: planArg.captureKey, repositoryId: planArg.repository.repositoryId, }); return; } let handle: plugins.fs.promises.FileHandle; try { handle = await plugins.fs.promises.open( keepPath, plugins.fs.constants.O_RDONLY | plugins.fs.constants.O_NOFOLLOW | plugins.fs.constants.O_NONBLOCK, ); } catch (errorArg) { if (allowMissingArg && (errorArg as NodeJS.ErrnoException).code === 'ENOENT') return; throw errorArg; } try { const before = await handle.stat(); if ( !before.isFile() || before.nlink !== 1 || before.size !== expected.byteLength || (typeof process.getuid === 'function' && before.uid !== process.getuid()) ) throw new GitReversionFencedError('The source Git pack keep is not exclusively owned.'); const initial = Buffer.allocUnsafe(before.size); const initialRead = await handle.read(initial, 0, initial.byteLength, 0); if (initialRead.bytesRead !== initial.byteLength || !initial.equals(expected)) { throw new GitReversionFencedError('The source Git pack keep ownership diverged.'); } const [descriptor, path] = await Promise.all([ handle.stat(), plugins.fs.promises.lstat(keepPath), ]); if ( !descriptor.isFile() || !path.isFile() || path.isSymbolicLink() || descriptor.dev !== before.dev || descriptor.ino !== before.ino || path.dev !== descriptor.dev || path.ino !== descriptor.ino || descriptor.nlink !== 1 || path.nlink !== 1 || descriptor.size !== before.size || path.size !== descriptor.size || descriptor.uid !== before.uid || path.uid !== descriptor.uid || descriptor.mode !== before.mode || path.mode !== descriptor.mode || descriptor.ctimeMs !== before.ctimeMs || path.ctimeMs !== descriptor.ctimeMs || descriptor.mtimeMs !== before.mtimeMs || path.mtimeMs !== descriptor.mtimeMs ) throw new GitReversionFencedError('The source Git pack keep changed before removal.'); const current = Buffer.allocUnsafe(expected.byteLength); const currentRead = await handle.read(current, 0, current.byteLength, 0); const [afterRead, currentPath] = await Promise.all([ handle.stat(), plugins.fs.promises.lstat(keepPath), ]); if ( currentRead.bytesRead !== current.byteLength || !current.equals(expected) || afterRead.dev !== descriptor.dev || afterRead.ino !== descriptor.ino || afterRead.size !== descriptor.size || afterRead.nlink !== descriptor.nlink || afterRead.uid !== descriptor.uid || afterRead.mode !== descriptor.mode || afterRead.ctimeMs !== descriptor.ctimeMs || afterRead.mtimeMs !== descriptor.mtimeMs || currentPath.dev !== afterRead.dev || currentPath.ino !== afterRead.ino || currentPath.size !== afterRead.size || currentPath.nlink !== afterRead.nlink || currentPath.uid !== afterRead.uid || currentPath.mode !== afterRead.mode || currentPath.ctimeMs !== afterRead.ctimeMs || currentPath.mtimeMs !== afterRead.mtimeMs ) throw new GitReversionFencedError('The source Git pack keep changed before removal.'); await this.fault({ point: 'apply.before-source-keep-remove', captureKey: planArg.captureKey, repositoryId: planArg.repository.repositoryId, }); signalArg?.throwIfAborted(); let quarantined = false; const restoreQuarantine = async (): Promise => { try { await plugins.fs.promises.link(quarantinePath, keepPath); await plugins.fs.promises.rm(quarantinePath); quarantined = false; await this.syncDirectory(packDirectory); } catch (errorArg) { throw new GitReversionFencedError( 'A changed source Git pack keep could not be restored after quarantine.', { cause: errorArg instanceof Error ? errorArg : undefined }, ); } }; try { await plugins.fs.promises.rename(keepPath, quarantinePath); quarantined = true; const [quarantinedDescriptor, quarantinedPath] = await Promise.all([ handle.stat(), plugins.fs.promises.lstat(quarantinePath), ]); const quarantinedValue = Buffer.allocUnsafe(expected.byteLength); const quarantinedRead = await handle.read( quarantinedValue, 0, quarantinedValue.byteLength, 0, ); const afterQuarantineRead = await handle.stat(); if ( !quarantinedDescriptor.isFile() || !quarantinedPath.isFile() || quarantinedPath.isSymbolicLink() || quarantinedDescriptor.dev !== afterRead.dev || quarantinedDescriptor.ino !== afterRead.ino || quarantinedPath.dev !== quarantinedDescriptor.dev || quarantinedPath.ino !== quarantinedDescriptor.ino || quarantinedDescriptor.nlink !== 1 || quarantinedPath.nlink !== 1 || quarantinedDescriptor.size !== afterRead.size || quarantinedPath.size !== quarantinedDescriptor.size || quarantinedDescriptor.uid !== afterRead.uid || quarantinedPath.uid !== quarantinedDescriptor.uid || quarantinedDescriptor.mode !== afterRead.mode || quarantinedPath.mode !== quarantinedDescriptor.mode || quarantinedDescriptor.mtimeMs !== afterRead.mtimeMs || quarantinedPath.mtimeMs !== quarantinedDescriptor.mtimeMs || quarantinedRead.bytesRead !== quarantinedValue.byteLength || !quarantinedValue.equals(expected) || afterQuarantineRead.dev !== quarantinedDescriptor.dev || afterQuarantineRead.ino !== quarantinedDescriptor.ino || afterQuarantineRead.size !== quarantinedDescriptor.size || afterQuarantineRead.nlink !== quarantinedDescriptor.nlink || afterQuarantineRead.ctimeMs !== quarantinedDescriptor.ctimeMs || afterQuarantineRead.mtimeMs !== quarantinedDescriptor.mtimeMs ) throw new GitReversionFencedError( 'The source Git pack keep changed while entering removal quarantine.', ); await this.fault({ point: 'apply.after-source-keep-quarantine', captureKey: planArg.captureKey, repositoryId: planArg.repository.repositoryId, }); await plugins.fs.promises.rm(quarantinePath); quarantined = false; } catch (errorArg) { if (quarantined && !(errorArg instanceof GitReversionInjectedFaultError)) { await restoreQuarantine(); } throw errorArg; } if ((await handle.stat()).nlink !== 0) { throw new GitReversionFencedError('The source Git pack keep removal was not exact.'); } await this.fault({ point: 'apply.after-source-keep-remove', captureKey: planArg.captureKey, repositoryId: planArg.repository.repositoryId, }); await this.syncDirectory(packDirectory); await this.fault({ point: 'apply.after-source-keep-remove-fsync', captureKey: planArg.captureKey, repositoryId: planArg.repository.repositoryId, }); } finally { await handle.close(); } } private async transitionWorktree( repositoryArg: IGitReversionRepositoryIdentity, captureKeyArg: string, currentTreeArg: string, targetTreeArg: string, signalArg?: AbortSignal, ): Promise { if (currentTreeArg === targetTreeArg) return; const objectEnvironment = await this.captureReadEnvironment(captureKeyArg, repositoryArg); const temporaryIndex = await this.temporaryIndexPath(repositoryArg.repositoryId); try { await this.git( repositoryArg, ['read-tree', currentTreeArg], signalArg, [0], undefined, { ...objectEnvironment, GIT_INDEX_FILE: temporaryIndex }, ); await this.git( repositoryArg, ['update-index', '--refresh'], signalArg, [0], undefined, { ...objectEnvironment, GIT_INDEX_FILE: temporaryIndex }, ); await this.git( repositoryArg, ['read-tree', '-m', '-u', currentTreeArg, targetTreeArg], signalArg, [0], undefined, { ...objectEnvironment, GIT_INDEX_FILE: temporaryIndex }, ); await this.assertTemporaryIndexBound(temporaryIndex); } finally { await this.removeTemporaryIndexPaths([ temporaryIndex, `${temporaryIndex}.lock`, ]).catch(() => undefined); } } private async installIndex( repositoryArg: IGitReversionRepositoryIdentity, captureKeyArg: string, expectedTreeArg: string, targetTreeArg: string, signalArg?: AbortSignal, ): Promise { const requiresInstall = expectedTreeArg !== targetTreeArg; const temporaryIndex = await this.temporaryIndexPath(repositoryArg.repositoryId); const currentIndex = await this.temporaryIndexPath(repositoryArg.repositoryId); const operationId = plugins.crypto.randomBytes(16).toString('hex'); const ownerSidecar = `${repositoryArg.indexPath}.hcon-${this.ownerKey}-index-${operationId}.owner`; const indexLock = `${repositoryArg.indexPath}.lock`; const indexDirectory = plugins.path.dirname(repositoryArg.indexPath); let linked = false; let renamed = false; let completed = false; const indexGeneration = await this.createGeneration(captureKeyArg, repositoryArg, signalArg); const objectEnvironment = indexGeneration.context.environment; let generationVerified = false; const copyToHandle = async ( sourcePathArg: string, targetHandleArg: plugins.fs.promises.FileHandle, ): Promise => { const sourceHandle = await plugins.fs.promises.open( sourcePathArg, plugins.fs.constants.O_RDONLY | plugins.fs.constants.O_NOFOLLOW, ); try { const buffer = Buffer.allocUnsafe(64 * 1024); let position = 0; while (true) { signalArg?.throwIfAborted(); const { bytesRead } = await sourceHandle.read(buffer, 0, buffer.byteLength, position); if (bytesRead === 0) break; let written = 0; while (written < bytesRead) { const { bytesWritten } = await targetHandleArg.write( buffer, written, bytesRead - written, position + written, ); if (bytesWritten === 0) throw new Error('Writing the Git index made no progress.'); written += bytesWritten; } position += bytesRead; } } finally { await sourceHandle.close(); } }; const assertOwnedLock = async (): Promise => { const [sidecarStat, lockStat] = await Promise.all([ plugins.fs.promises.lstat(ownerSidecar), plugins.fs.promises.lstat(indexLock), ]); if ( !sidecarStat.isFile() || sidecarStat.isSymbolicLink() || !lockStat.isFile() || lockStat.isSymbolicLink() || sidecarStat.dev !== lockStat.dev || sidecarStat.ino !== lockStat.ino || sidecarStat.uid !== lockStat.uid || sidecarStat.nlink < 2 || lockStat.nlink < 2 || (typeof process.getuid === 'function' && sidecarStat.uid !== process.getuid()) ) throw new GitReversionFencedError('The Git index lock ownership proof diverged.'); }; try { await this.git( repositoryArg, ['read-tree', targetTreeArg], signalArg, [0], undefined, { ...objectEnvironment, GIT_INDEX_FILE: temporaryIndex }, ); await plugins.fs.promises.chmod(temporaryIndex, 0o600); await this.assertTemporaryIndexBound(temporaryIndex); let sidecarHandle: plugins.fs.promises.FileHandle | undefined; try { await this.assertRepositoryBinding(repositoryArg); sidecarHandle = await plugins.fs.promises.open(ownerSidecar, 'wx', 0o600); await copyToHandle(temporaryIndex, sidecarHandle); await this.fault({ point: 'index.before-sidecar-fsync', repositoryId: repositoryArg.repositoryId }); await sidecarHandle.sync(); await this.fault({ point: 'index.after-sidecar-fsync', repositoryId: repositoryArg.repositoryId }); await sidecarHandle.close(); sidecarHandle = undefined; } finally { await sidecarHandle?.close().catch(() => undefined); } try { await this.assertRepositoryBinding(repositoryArg); await plugins.fs.promises.link(ownerSidecar, indexLock); linked = true; } catch (errorArg) { if ((errorArg as NodeJS.ErrnoException).code === 'EEXIST') { throw new GitReversionConflictError('The real Git index is locked by another writer.', { cause: errorArg, }); } throw new GitReversionFencedError( 'The filesystem cannot prove Git index ownership with an atomic hard link.', { cause: errorArg }, ); } await assertOwnedLock(); await this.fault({ point: 'index.after-link', repositoryId: repositoryArg.repositoryId }); await this.syncDirectory(indexDirectory); await this.fault({ point: 'index.after-link-fsync', repositoryId: repositoryArg.repositoryId }); let currentIndexHandle: plugins.fs.promises.FileHandle | undefined; try { await this.assertRepositoryBinding(repositoryArg); const indexStat = await plugins.fs.promises.lstat(repositoryArg.indexPath).catch((errorArg) => { if ((errorArg as NodeJS.ErrnoException).code === 'ENOENT') return undefined; throw errorArg; }); if (indexStat && ( !indexStat.isFile() || indexStat.isSymbolicLink() || indexStat.size > this.limits.maximumGenerationBytes )) { throw new GitReversionConflictError('The real Git index changed to an unsupported file.'); } if (indexStat) { await this.assertRepositoryBinding(repositoryArg); currentIndexHandle = await plugins.fs.promises.open(currentIndex, 'wx', 0o600); await this.assertRepositoryBinding(repositoryArg); await copyToHandle(repositoryArg.indexPath, currentIndexHandle); await currentIndexHandle.sync(); await currentIndexHandle.close(); currentIndexHandle = undefined; } else { await this.git( repositoryArg, ['read-tree', '--empty'], signalArg, [0], undefined, { ...objectEnvironment, GIT_INDEX_FILE: currentIndex }, ); } await this.assertTemporaryIndexBound(currentIndex); } finally { await currentIndexHandle?.close().catch(() => undefined); } const lockedTree = trimLine((await this.git( repositoryArg, ['write-tree'], signalArg, [0], undefined, { ...objectEnvironment, GIT_INDEX_FILE: currentIndex }, )).stdout); await this.verifyGeneration( repositoryArg, indexGeneration.context, ); generationVerified = true; if (lockedTree !== expectedTreeArg) { throw new GitReversionConflictError('The real Git index changed during apply.'); } if (requiresInstall) { signalArg?.throwIfAborted(); await assertOwnedLock(); await this.assertRepositoryBinding(repositoryArg); await plugins.fs.promises.rename(indexLock, repositoryArg.indexPath); linked = false; renamed = true; await this.fault({ point: 'index.after-rename', repositoryId: repositoryArg.repositoryId }); await this.syncDirectory(indexDirectory); await this.fault({ point: 'index.after-rename-fsync', repositoryId: repositoryArg.repositoryId }); } else { await assertOwnedLock(); await plugins.fs.promises.rm(indexLock); linked = false; await this.syncDirectory(indexDirectory); } await plugins.fs.promises.rm(ownerSidecar); await this.fault({ point: 'index.after-cleanup', repositoryId: repositoryArg.repositoryId }); await this.syncDirectory(indexDirectory); await this.fault({ point: 'index.after-cleanup-fsync', repositoryId: repositoryArg.repositoryId }); completed = true; } catch (errorArg) { if (linked || renamed) this.invalidateRecovery(); throw errorArg; } finally { const repositoryStillBound = await this.assertRepositoryBinding(repositoryArg) .then(() => true, () => false); if (!completed && !renamed && repositoryStillBound) { if (linked) { await assertOwnedLock(); await plugins.fs.promises.rm(indexLock); await this.syncDirectory(indexDirectory); } await plugins.fs.promises.rm(ownerSidecar, { force: true }); await this.syncDirectory(indexDirectory); } await this.removeTemporaryIndexPaths([ currentIndex, `${currentIndex}.lock`, temporaryIndex, `${temporaryIndex}.lock`, ]).catch(() => undefined); if (generationVerified || !renamed) { await this.removeGeneration(repositoryArg, indexGeneration.context).catch(() => undefined); } } } private async applyRefs( repositoryArg: IGitReversionRepositoryIdentity, currentArg: IGitReversionSide, targetArg: IGitReversionSide, scopedRefsArg: readonly string[], signalArg?: AbortSignal, ): Promise { for (const ref of scopedRefsArg) await this.assertGitRefName(ref, signalArg); if (currentArg.headRef) await this.assertGitRefName(currentArg.headRef, signalArg); if (targetArg.headRef) await this.assertGitRefName(targetArg.headRef, signalArg); for (const side of [currentArg, targetArg]) { for (const [ref, target] of Object.entries(side.symbolicRefs)) { await this.assertGitRefName(ref, signalArg); await this.assertGitRefName(target, signalArg); } } const commands: string[] = ['start']; for (const ref of scopedRefsArg) { const current = currentArg.refs[ref]; const target = targetArg.refs[ref]; if (current === target) { commands.push(`verify ${ref}`, current ?? ''); } else if (current === undefined && target !== undefined) { commands.push(`create ${ref}`, target); } else if (current !== undefined && target === undefined) { commands.push(`delete ${ref}`, current); } else { commands.push(`update ${ref}`, target!, current!); } } commands.push('prepare', 'commit'); if (scopedRefsArg.length > 0) { await this.git( repositoryArg, ['update-ref', '--no-deref', '--stdin', '-z'], signalArg, [0], Buffer.from(`${commands.join('\0')}\0`, 'utf8'), ); } if (currentArg.headRef === targetArg.headRef) { if (targetArg.headRef === null && currentArg.headOid !== targetArg.headOid) { await this.git(repositoryArg, [ 'update-ref', '--no-deref', 'HEAD', targetArg.headOid, currentArg.headOid, ], signalArg); } return; } const currentHead = await this.git(repositoryArg, ['symbolic-ref', '-q', 'HEAD'], signalArg, [0, 1]); const currentHeadRef = currentHead.exitCode === 0 ? trimLine(currentHead.stdout) : null; if (currentHeadRef !== currentArg.headRef) { throw new GitReversionConflictError('Git HEAD changed while applying scoped refs.'); } if (targetArg.headRef) { await this.git(repositoryArg, ['symbolic-ref', 'HEAD', targetArg.headRef], signalArg); } else { const resolved = trimLine((await this.git(repositoryArg, ['rev-parse', 'HEAD'], signalArg)).stdout); await this.git(repositoryArg, [ 'update-ref', '--no-deref', 'HEAD', targetArg.headOid, resolved, ], signalArg); } } private async verifyTargetPlans( plansArg: readonly IGitReversionApplyPlan[], signalArg?: AbortSignal, ): Promise { const current = await this.captureStableRepositories( plansArg.map((plan) => plan.repository), signalArg, { captureKey: plansArg[0]?.captureKey ?? '0'.repeat(64), persistent: false, }, ); for (let index = 0; index < plansArg.length; index += 1) { if (!this.sideMatchesTarget(current[index]!, plansArg[index]!)) { throw new Error('A Git repository did not reach its verified target state.'); } } } private sideMatchesTarget( sideArg: IGitReversionSide, planArg: IGitReversionApplyPlan, ): boolean { if ( sideArg.unsupportedReason || sideArg.worktreeTree !== planArg.desiredWorktreeTree || sideArg.indexTree !== planArg.desiredIndexTree || sideArg.headOid !== planArg.target.headOid || sideArg.headRef !== planArg.target.headRef || sideArg.topologyHash !== planArg.initial.topologyHash || sideArg.ignorePolicyHash !== planArg.initial.ignorePolicyHash || sideArg.operationHash !== planArg.initial.operationHash || !recordsEqual(sideArg.symbolicRefs, planArg.initial.symbolicRefs) ) return false; for (const ref of planArg.scopedRefs) { if (sideArg.refs[ref] !== planArg.target.refs[ref]) return false; } for (const ref of new Set([ ...Object.keys(sideArg.refs), ...Object.keys(planArg.initial.refs), ])) { if (!planArg.scopedRefs.includes(ref) && sideArg.refs[ref] !== planArg.initial.refs[ref]) { return false; } } return true; } private async writeInitialWal( manifestArg: IGitReversionManifest, walArg: IGitReversionApplyWal, priorArg: IGitReversionApplyWal | undefined, signalArg?: AbortSignal, ): Promise { for (const repository of this.metadataRepositories( manifestArg.repositories.map((entry) => entry.repository), )) { const latestRef = this.applyLatestRef(walArg.captureKey, walArg.operationKey); await this.putRecord( repository, latestRef, walArg, { mode: 'normal', ...(priorArg ? { predecessor: priorArg } : {}) }, signalArg, ); await this.putRecord( repository, this.applyAttemptRef(walArg.captureKey, walArg.operationKey, walArg.attempt), walArg, { mode: 'normal' }, signalArg, ); } } private async updateWal( manifestArg: IGitReversionManifest, walArg: IGitReversionApplyWal, predecessorArg: IGitReversionApplyWal, signalArg?: AbortSignal, ): Promise { const repositories = this.metadataRepositories( manifestArg.repositories.map((entry) => entry.repository), ); if (walArg.decision) { const decision: IApplyDecisionRecord = { schemaVersion: 1, kind: 'apply-decision', captureKey: walArg.captureKey, operationKey: walArg.operationKey, attempt: walArg.attempt, decision: walArg.decision, }; for (const repository of repositories) { await this.putRecord( repository, this.applyDecisionRef(walArg.captureKey, walArg.operationKey, walArg.attempt), decision, { mode: 'normal' }, signalArg, ); await this.fault({ point: 'apply.after-decision-repository', captureKey: walArg.captureKey, operationId: walArg.operationId, repositoryId: repository.repositoryId, }); } } for (const repository of repositories) { await this.putRecord( repository, this.applyLatestRef(walArg.captureKey, walArg.operationKey), walArg, { mode: 'normal', predecessor: predecessorArg }, signalArg, ); await this.putRecord( repository, this.applyAttemptRef(walArg.captureKey, walArg.operationKey, walArg.attempt), walArg, { mode: 'normal', predecessor: predecessorArg }, signalArg, ); } } private async loadLatestApplyWal( manifestArg: IGitReversionManifest, operationIdArg: string, signalArg?: AbortSignal, ): Promise { if (manifestArg.repositories.length === 0) return undefined; const operationKey = sha256(operationIdArg); const operation = (await this.inspectApplyOperations(manifestArg, signalArg)).find( (entry) => entry.operationKey === operationKey, ); if (!operation) return undefined; if (operation.wal.operationId !== operationIdArg) { throw new GitReversionFencedError('The Git apply operation identity is invalid.'); } return operation.wal; } private assertWal(walArg: IGitReversionApplyWal, manifestArg: IGitReversionManifest): void { if ( !walArg || walArg.schemaVersion !== 1 || walArg.kind !== 'apply-wal' || walArg.captureKey !== manifestArg.captureKey || walArg.manifestId !== sha256(canonicalJson(manifestArg)) || !/^[0-9a-f]{64}$/u.test(walArg.operationKey) || sha256(walArg.operationId) !== walArg.operationKey || !['undo', 'redo'].includes(walArg.direction) || !Number.isSafeInteger(walArg.attempt) || walArg.attempt < 1 || ![ 'intent', 'preflighted', 'applying', 'targetVerified', 'decision', 'committed', 'compensating', 'compensated', 'fenced', ].includes(walArg.phase) || !Array.isArray(walArg.plans) || walArg.plans.length > this.limits.maximumRepositories || !Array.isArray(walArg.touchedRepositoryIds) || walArg.touchedRepositoryIds.length > this.limits.maximumRepositories || !walArg.generationIds || typeof walArg.generationIds !== 'object' || Array.isArray(walArg.generationIds) ) throw new GitReversionFencedError('The private Git apply WAL is invalid.'); let generationCount = 0; for (const [repositoryId, generationIds] of Object.entries(walArg.generationIds)) { if ( !/^git-[0-9a-f]{32}$/u.test(repositoryId) || !Array.isArray(generationIds) || generationIds.some((generationId) => !/^[0-9a-f]{64}$/u.test(generationId)) ) throw new GitReversionFencedError('The private Git apply generation lineage is invalid.'); generationCount += generationIds.length; } if (generationCount > this.limits.maximumGenerationsPerCapture) { throw new GitReversionFencedError('The private Git apply generation limit was exceeded.'); } const manifestRepositories = new Map(manifestArg.repositories.map((entry) => [ entry.repository.repositoryId, entry.repository, ])); const planRepositories = new Set(); for (const plan of walArg.plans) { const repository = manifestRepositories.get(plan.repository.repositoryId); if ( !repository || plan.captureKey !== manifestArg.captureKey || !recordsEqual(plan.repository, repository) || planRepositories.has(plan.repository.repositoryId) || !Array.isArray(plan.scopedRefs) || plan.scopedRefs.length > this.limits.maximumPaths ) throw new GitReversionFencedError('A private Git apply plan repository identity is invalid.'); this.assertSide(plan.initial); this.assertSide(plan.source); this.assertSide(plan.target); const sourcePack = plan.sourcePack; const hashLength = plan.repository.objectFormat === 'sha256' ? 64 : 40; const expectedKeepToken = [ 'hcon-v1', this.ownerKey, walArg.captureKey, walArg.operationKey, String(walArg.attempt), plan.repository.repositoryId, sourcePack?.generationId, ].join(':'); if ( !objectIdPattern.test(plan.desiredWorktreeTree) || !objectIdPattern.test(plan.desiredIndexTree) || plan.scopedRefs.some((ref) => !this.refNameHasSafeBytes(ref)) || !sourcePack || Object.keys(sourcePack).sort().join(',') !== 'generationId,keepToken,objectCount,packBytes,packHash,syntheticRootOid' || !/^[0-9a-f]{64}$/u.test(sourcePack.generationId) || !new RegExp(`^[0-9a-f]{${hashLength}}$`, 'u').test(sourcePack.packHash) || !new RegExp(`^[0-9a-f]{${hashLength}}$`, 'u').test(sourcePack.syntheticRootOid) || !Number.isSafeInteger(sourcePack.packBytes) || sourcePack.packBytes < 12 || sourcePack.packBytes > this.limits.maximumGenerationBytes || !Number.isSafeInteger(sourcePack.objectCount) || sourcePack.objectCount < 1 || sourcePack.objectCount > this.limits.maximumGenerationObjects || sourcePack.keepToken !== expectedKeepToken || Buffer.byteLength(sourcePack.keepToken, 'utf8') > 1024 || /[\0\r\n]/u.test(sourcePack.keepToken) || !(walArg.generationIds[plan.repository.repositoryId] ?? []) .includes(sourcePack.generationId) ) throw new GitReversionFencedError('A private Git apply plan is invalid.'); planRepositories.add(plan.repository.repositoryId); } for (const repositoryId of Object.keys(walArg.generationIds)) { if (!manifestRepositories.has(repositoryId)) { throw new GitReversionFencedError('A private Git apply generation has no manifest repository.'); } } const sides = sideNameForDirection(walArg.direction); if ( walArg.sourceSide !== sides.source || walArg.targetSide !== sides.target || !Number.isSafeInteger(walArg.sourceRevision) || walArg.sourceRevision < 0 ) throw new GitReversionFencedError('The private Git apply WAL lineage is invalid.'); } private async listLatestApplyWals( manifestArg: IGitReversionManifest, signalArg?: AbortSignal, ): Promise { return (await this.inspectApplyOperations(manifestArg, signalArg)) .map((entry) => entry.wal) .sort((left, right) => left.operationKey.localeCompare(right.operationKey)); } private async inspectApplyOperations( manifestArg: IGitReversionManifest, signalArg?: AbortSignal, ): Promise { const repositories = this.metadataRepositories( manifestArg.repositories.map((entry) => entry.repository), ); if (repositories.length === 0) return []; type TAttemptCopies = { wals: IGitReversionApplyWal[]; decisions: IApplyDecisionRecord[]; fenced: boolean; }; type TOperationCopies = { attempts: Map; latestAttempts: number[]; fenced: boolean; }; const operations = new Map(); const operationFor = (operationKeyArg: string): TOperationCopies => { let operation = operations.get(operationKeyArg); if (!operation) { operation = { attempts: new Map(), latestAttempts: [], fenced: false }; operations.set(operationKeyArg, operation); } return operation; }; const attemptFor = (operationArg: TOperationCopies, attemptArg: number): TAttemptCopies => { let attempt = operationArg.attempts.get(attemptArg); if (!attempt) { attempt = { wals: [], decisions: [], fenced: false }; operationArg.attempts.set(attemptArg, attempt); } return attempt; }; const prefix = `${this.captureRefRoot(manifestArg.captureKey)}/applies/`; const refsByRepository = await Promise.all(repositories.map(async (repository) => ({ repository, refs: await this.listRefs(repository, prefix, signalArg), }))); for (const { refs } of refsByRepository) { for (const ref of refs) { const match = /^([0-9a-f]{64})\/attempts\/([1-9][0-9]*)\/fence$/u.exec( ref.slice(prefix.length), ); if (!match) continue; const operation = operationFor(match[1]!); const attempt = attemptFor(operation, Number(match[2])); attempt.fenced = true; operation.fenced = true; } } for (const { repository, refs } of refsByRepository) { for (const ref of refs) { const relative = ref.slice(prefix.length); const latestMatch = /^([0-9a-f]{64})\/latest$/u.exec(relative); const attemptMatch = /^([0-9a-f]{64})\/attempts\/([1-9][0-9]*)\/(wal|decision|fence)$/u.exec( relative, ); if (!latestMatch && !attemptMatch) { throw new GitReversionFencedError('An unexpected private Git apply ref was found.'); } const operationKey = latestMatch?.[1] ?? attemptMatch?.[1] ?? ''; const operation = operationFor(operationKey); if (latestMatch) { let wal: IGitReversionApplyWal; try { wal = await this.readRecord(repository, ref, signalArg); this.assertWal(wal, manifestArg); } catch (errorArg) { if (operation.fenced) continue; throw errorArg; } if (wal.operationKey !== operationKey && !operation.fenced) { throw new GitReversionFencedError('A Git apply latest ref has another operation identity.'); } if (wal.operationKey !== operationKey) continue; operation.latestAttempts.push(wal.attempt); attemptFor(operation, wal.attempt).wals.push(wal); continue; } const attemptNumber = Number(attemptMatch![2]); if (!Number.isSafeInteger(attemptNumber)) { throw new GitReversionFencedError('A Git apply attempt ref is invalid.'); } const attempt = attemptFor(operation, attemptNumber); if (attemptMatch![3] === 'fence') { attempt.fenced = true; operation.fenced = true; continue; } if (attemptMatch![3] === 'decision') { if (operation.fenced) continue; const decision = await this.readRecord(repository, ref, signalArg); if ( decision.schemaVersion !== 1 || decision.kind !== 'apply-decision' || decision.captureKey !== manifestArg.captureKey || decision.operationKey !== operationKey || decision.attempt !== attemptNumber || !['source', 'target'].includes(decision.decision) ) throw new GitReversionFencedError('A private Git apply decision is invalid.'); attempt.decisions.push(decision); continue; } let wal: IGitReversionApplyWal; try { wal = await this.readRecord(repository, ref, signalArg); this.assertWal(wal, manifestArg); } catch (errorArg) { if (operation.fenced) continue; throw errorArg; } if ( (wal.operationKey !== operationKey || wal.attempt !== attemptNumber) && !operation.fenced ) { throw new GitReversionFencedError('A Git apply attempt ref has another identity.'); } if (wal.operationKey !== operationKey || wal.attempt !== attemptNumber) continue; attempt.wals.push(wal); } } const result: IApplyRecoveryOperation[] = []; for (const [operationKey, operation] of [...operations].sort(([left], [right]) => ( left.localeCompare(right) ))) { const attemptNumbers = [...operation.attempts.keys()].sort((left, right) => left - right); if (operation.fenced) { const copies = attemptNumbers.flatMap((attemptNumber) => ( operation.attempts.get(attemptNumber)!.wals )); if (copies.length === 0) { throw new GitReversionFencedError('A fenced Git apply has no WAL identity.'); } const wal = structuredClone(copies.sort((left, right) => right.attempt - left.attempt)[0]!); wal.phase = 'fenced'; result.push({ operationKey, wal, decision: wal.decision, fenced: true }); continue; } if (attemptNumbers.length === 0 || attemptNumbers.some((value, index) => value !== index + 1)) { throw new GitReversionFencedError('The Git apply attempt lineage is incomplete.'); } const canonical: IGitReversionApplyWal[] = []; let fenced = false; for (const attemptNumber of attemptNumbers) { const attempt = operation.attempts.get(attemptNumber)!; if (attempt.wals.length === 0) { throw new GitReversionFencedError('A Git apply attempt has no WAL record.'); } const wal = this.canonicalizeWalAttempt(manifestArg, attempt.wals, attempt.decisions); fenced ||= attempt.fenced; canonical.push(wal); } for (let index = 1; index < canonical.length; index += 1) { const previous = canonical[index - 1]!; const current = canonical[index]!; const followsCompensation = previous.phase === 'compensated' && previous.decision === 'source' && current.sourceRevision === previous.sourceRevision; const followsCompletedCycle = previous.phase === 'committed' && previous.decision === 'target' && current.sourceRevision > previous.sourceRevision; if ( (!followsCompensation && !followsCompletedCycle) || current.operationId !== previous.operationId || current.direction !== previous.direction ) throw new GitReversionFencedError('A later Git apply attempt bypassed an unresolved attempt.'); } const wal = structuredClone(canonical[canonical.length - 1]!); const allowedLatest = new Set([wal.attempt]); if (wal.attempt > 1) allowedLatest.add(wal.attempt - 1); if (operation.latestAttempts.some((attempt) => !allowedLatest.has(attempt))) { throw new GitReversionFencedError('The Git apply latest-attempt lineage diverged.'); } if (fenced) wal.phase = 'fenced'; result.push({ operationKey, wal, decision: wal.decision, fenced }); } return result; } private canonicalizeWalAttempt( manifestArg: IGitReversionManifest, copiesArg: readonly IGitReversionApplyWal[], decisionsArg: readonly IApplyDecisionRecord[], ): IGitReversionApplyWal { const first = copiesArg[0]!; const immutable = (walArg: IGitReversionApplyWal): unknown => { const { phase: _phase, decision: _decision, touchedRepositoryIds: _touched, plans: _plans, ...rest } = walArg; return rest; }; if (copiesArg.some((copy) => !recordsEqual(immutable(copy), immutable(first)))) { throw new GitReversionFencedError('Immutable Git apply WAL fields diverged.'); } const plans = copiesArg.filter((copy) => copy.plans.length > 0).map((copy) => copy.plans); const canonicalPlans = plans[0] ?? []; if (plans.some((value) => !recordsEqual(value, canonicalPlans))) { throw new GitReversionFencedError('Immutable Git apply plans diverged.'); } const manifestRepositories = new Map(manifestArg.repositories.map((entry) => [ entry.repository.repositoryId, entry.repository, ])); const manifestEntries = new Map(manifestArg.repositories.map((entry) => [ entry.repository.repositoryId, entry, ])); const planIds = canonicalPlans.map((plan) => plan.repository.repositoryId); if ( new Set(planIds).size !== planIds.length || canonicalPlans.some((plan) => !recordsEqual( manifestRepositories.get(plan.repository.repositoryId), plan.repository, )) ) throw new GitReversionFencedError('A Git apply plan repository is invalid.'); for (const plan of canonicalPlans) { this.assertRepositoryIdentity(plan.repository); if (plan.captureKey !== manifestArg.captureKey) { throw new GitReversionFencedError('A Git apply plan escaped its capture.'); } this.assertSide(plan.initial); this.assertSide(plan.source); this.assertSide(plan.target); const entry = manifestEntries.get(plan.repository.repositoryId)!; const expectedSource = first.sourceSide === 'before' ? entry.before : entry.after!; const expectedTarget = first.targetSide === 'before' ? entry.before : entry.after!; const expectedRefs = [...new Set( [entry.before.headRef, entry.after!.headRef].filter((ref): ref is string => ref !== null), )].sort(); if ( !objectIdPattern.test(plan.desiredWorktreeTree) || !objectIdPattern.test(plan.desiredIndexTree) || !recordsEqual(plan.source, expectedSource) || !recordsEqual(plan.target, expectedTarget) || !recordsEqual(plan.scopedRefs, expectedRefs) ) throw new GitReversionFencedError('Immutable Git apply plan content is invalid.'); } let touched: string[] = []; for (const copy of copiesArg) { if ( copy.plans.length !== 0 && !recordsEqual(copy.plans, canonicalPlans) || !recordsEqual(copy.touchedRepositoryIds, planIds.slice(0, copy.touchedRepositoryIds.length)) ) throw new GitReversionFencedError('Touched Git repositories are not a monotonic plan prefix.'); if (copy.touchedRepositoryIds.length > touched.length) touched = copy.touchedRepositoryIds; } const decisions = new Set<'source' | 'target'>(); for (const copy of copiesArg) if (copy.decision) decisions.add(copy.decision); for (const decision of decisionsArg) decisions.add(decision.decision); if (decisions.size > 1) throw new GitReversionFencedError('Conflicting Git apply decisions were found.'); const decision = [...decisions][0]; const phases = new Set(copiesArg.map((copy) => copy.phase)); let phase: IGitReversionApplyWal['phase']; if (phases.has('fenced')) phase = 'fenced'; else if ( (phases.has('committed') && decision !== 'target') || (phases.has('compensated') && decision !== 'source') || (decision === 'target' && (phases.has('compensating') || phases.has('compensated'))) || (decision === 'source' && (phases.has('decision') || phases.has('committed'))) ) throw new GitReversionFencedError('The Git apply phase conflicts with its decision.'); else if (decision === 'target') phase = phases.has('committed') ? 'committed' : 'decision'; else if (decision === 'source') phase = phases.has('compensated') ? 'compensated' : 'compensating'; else { const ordered: IGitReversionApplyWal['phase'][] = [ 'intent', 'preflighted', 'applying', 'targetVerified', ]; phase = ordered.reduce((latest, candidate) => phases.has(candidate) ? candidate : latest, 'intent'); if ([...phases].some((value) => !ordered.includes(value))) { throw new GitReversionFencedError('A Git apply phase requires a durable decision.'); } } if ( phase !== 'intent' && canonicalPlans.length !== manifestArg.repositories.length && !(decision === 'source' && canonicalPlans.length === 0) ) throw new GitReversionFencedError('The Git apply plan set is incomplete.'); return { ...structuredClone(first), phase, ...(decision ? { decision } : { decision: undefined }), touchedRepositoryIds: [...touched], plans: structuredClone(canonicalPlans), }; } private async persistRecoveryWal( manifestArg: IGitReversionManifest, walArg: IGitReversionApplyWal, signalArg?: AbortSignal, ): Promise { const repositories = this.metadataRepositories( manifestArg.repositories.map((entry) => entry.repository), ); if (walArg.decision) { const decision: IApplyDecisionRecord = { schemaVersion: 1, kind: 'apply-decision', captureKey: walArg.captureKey, operationKey: walArg.operationKey, attempt: walArg.attempt, decision: walArg.decision, }; for (const repository of repositories) { await this.putRecord( repository, this.applyDecisionRef(walArg.captureKey, walArg.operationKey, walArg.attempt), decision, { mode: 'recovery' }, signalArg, ); } } for (const repository of repositories) { await this.putRecord( repository, this.applyAttemptRef(walArg.captureKey, walArg.operationKey, walArg.attempt), walArg, { mode: 'recovery' }, signalArg, ); await this.putRecord( repository, this.applyLatestRef(walArg.captureKey, walArg.operationKey), walArg, { mode: 'recovery' }, signalArg, ); } } private assertCaptureState( stateArg: IGitReversionCaptureState, manifestArg: IGitReversionManifest, ): void { const reference = stateArg?.outcome?.reference as TGitReversionReference | undefined; if ( !stateArg || stateArg.schemaVersion !== 1 || stateArg.kind !== 'capture-state' || stateArg.captureKey !== manifestArg.captureKey || stateArg.manifestId !== sha256(canonicalJson(manifestArg)) || !['before', 'after'].includes(stateArg.currentSide) || !Number.isSafeInteger(stateArg.revision) || stateArg.revision < 0 || !reference || reference.captureKey !== manifestArg.captureKey || reference.manifestId !== stateArg.manifestId ) throw new GitReversionFencedError('A replicated Git capture state is invalid.'); assertReference(reference); } private async reconcileStateForWal( manifestArg: IGitReversionManifest, walArg: IGitReversionApplyWal, signalArg?: AbortSignal, ): Promise { const repositories = this.metadataRepositories( manifestArg.repositories.map((entry) => entry.repository), ); const copies: IGitReversionCaptureState[] = []; for (const repository of repositories) { const state = await this.readRecord( repository, this.captureStateRef(manifestArg.captureKey), signalArg, ); this.assertCaptureState(state, manifestArg); copies.push(state); } const immutable = (stateArg: IGitReversionCaptureState): unknown => { const { currentSide: _side, revision: _revision, ...rest } = stateArg; return rest; }; if (copies.some((copy) => !recordsEqual(immutable(copy), immutable(copies[0]!)))) { throw new GitReversionFencedError('Immutable Git capture state fields diverged.'); } const isSource = (stateArg: IGitReversionCaptureState): boolean => ( stateArg.currentSide === walArg.sourceSide && stateArg.revision === walArg.sourceRevision ); const isTarget = (stateArg: IGitReversionCaptureState): boolean => ( stateArg.currentSide === walArg.targetSide && stateArg.revision === walArg.sourceRevision + 1 ); if (walArg.decision !== 'target') { if (!copies.every(isSource)) { throw new GitReversionFencedError('A source Git decision conflicts with replicated state.'); } return copies[0]!; } if (!copies.every((copy) => isSource(copy) || isTarget(copy))) { throw new GitReversionFencedError('A target Git decision conflicts with replicated state.'); } const target: IGitReversionCaptureState = { ...copies[0]!, currentSide: walArg.targetSide, revision: walArg.sourceRevision + 1, }; for (const repository of repositories) { await this.putRecord( repository, this.captureStateRef(manifestArg.captureKey), target, { mode: 'recovery' }, signalArg, ); await this.fault({ point: 'apply.after-state-repository', captureKey: manifestArg.captureKey, operationId: walArg.operationId, repositoryId: repository.repositoryId, }); } return target; } private async recoverWal( manifestArg: IGitReversionManifest, _stateArg: IGitReversionCaptureState | undefined, walArg: IGitReversionApplyWal, signalArg?: AbortSignal, ): Promise { const operation = (await this.inspectApplyOperations(manifestArg, signalArg)).find( (entry) => entry.operationKey === walArg.operationKey, ); if (!operation) throw new GitReversionFencedError('The Git apply WAL disappeared during recovery.'); const canonical = operation.wal; if (operation.fenced || canonical.phase === 'fenced') throw new GitReversionFencedError(); if (Object.keys(canonical.generationIds).length > 0) { await this.enrollGenerationIds(manifestArg.captureKey, canonical.generationIds); } if (canonical.phase === 'committed' || canonical.phase === 'compensated') { await this.persistRecoveryWal(manifestArg, canonical, signalArg); return; } try { canonical.decision ??= 'source'; canonical.phase = canonical.decision === 'target' ? 'decision' : 'compensating'; // Decision refs are replicated before state repair or any worktree mutation. await this.persistRecoveryWal(manifestArg, canonical, signalArg); await this.reconcileStateForWal(manifestArg, canonical, signalArg); const recovered = canonical.decision === 'target' ? await this.rollForward(manifestArg, canonical, signalArg) : await this.compensate(manifestArg, canonical, signalArg); if (!recovered) throw new GitReversionFencedError(); } catch (errorArg) { await this.fenceWal(manifestArg, canonical, signalArg); if (errorArg instanceof GitReversionFencedError) throw errorArg; throw new GitReversionFencedError(undefined, { cause: errorArg }); } } private async compensate( manifestArg: IGitReversionManifest, walArg: IGitReversionApplyWal, signalArg?: AbortSignal, ): Promise { walArg.phase = 'compensating'; walArg.decision = 'source'; await this.persistRecoveryWal(manifestArg, walArg, signalArg); for (const plan of walArg.plans) await this.materializePlanObjectsInSource(plan, signalArg); const touched = new Set(walArg.touchedRepositoryIds); for (const plan of [...walArg.plans].reverse()) { if (!touched.has(plan.repository.repositoryId)) continue; await this.fault({ point: 'apply.during-compensation', captureKey: walArg.captureKey, operationId: walArg.operationId, repositoryId: plan.repository.repositoryId, }); const current = (await this.captureStableRepositories([plan.repository], signalArg, { captureKey: plan.captureKey, persistent: false, }))[0]!; if (this.sidesEqual(current, plan.initial)) continue; if (!await this.sideIsOperationCombination(current, plan, signalArg)) return false; await this.transitionWorktree( plan.repository, plan.captureKey, current.worktreeTree, plan.initial.worktreeTree, signalArg, ); await this.installIndex( plan.repository, plan.captureKey, current.indexTree, plan.initial.indexTree, signalArg, ); const afterFiles = (await this.captureStableRepositories([plan.repository], signalArg, { captureKey: plan.captureKey, persistent: false, }))[0]!; await this.applyRefs( plan.repository, afterFiles, plan.initial, plan.scopedRefs, signalArg, ); const restored = (await this.captureStableRepositories([plan.repository], signalArg, { captureKey: plan.captureKey, persistent: false, }))[0]!; if (!this.sidesEqual(restored, plan.initial)) return false; } const all = await this.captureStableRepositories( walArg.plans.map((plan) => plan.repository), signalArg, { captureKey: manifestArg.captureKey, persistent: false, }, ); if (!all.every((side, index) => this.sidesEqual(side, walArg.plans[index]!.initial))) { return false; } await this.removeSourceKeeps(walArg.plans, signalArg, true); walArg.phase = 'compensated'; await this.persistRecoveryWal(manifestArg, walArg, signalArg); return true; } private async rollForward( manifestArg: IGitReversionManifest, walArg: IGitReversionApplyWal, signalArg?: AbortSignal, ): Promise { if (walArg.decision !== 'target') return false; for (const plan of walArg.plans) await this.materializePlanObjectsInSource(plan, signalArg); for (const plan of walArg.plans) { const current = (await this.captureStableRepositories([plan.repository], signalArg, { captureKey: plan.captureKey, persistent: false, }))[0]!; if (this.sideMatchesTarget(current, plan)) continue; if (!await this.sideIsOperationCombination(current, plan, signalArg)) return false; await this.transitionWorktree( plan.repository, plan.captureKey, current.worktreeTree, plan.desiredWorktreeTree, signalArg, ); await this.installIndex( plan.repository, plan.captureKey, current.indexTree, plan.desiredIndexTree, signalArg, ); const afterFiles = (await this.captureStableRepositories([plan.repository], signalArg, { captureKey: plan.captureKey, persistent: false, }))[0]!; await this.applyRefs( plan.repository, afterFiles, plan.target, plan.scopedRefs, signalArg, ); } try { await this.verifyTargetPlans(walArg.plans, signalArg); } catch { return false; } await this.removeSourceKeeps(walArg.plans, signalArg, true); walArg.phase = 'committed'; await this.persistRecoveryWal(manifestArg, walArg, signalArg); return true; } private async sideIsOperationCombination( sideArg: IGitReversionSide, planArg: IGitReversionApplyPlan, signalArg?: AbortSignal, ): Promise { if ( sideArg.unsupportedReason || sideArg.topologyHash !== planArg.initial.topologyHash || sideArg.ignorePolicyHash !== planArg.initial.ignorePolicyHash || sideArg.operationHash !== planArg.initial.operationHash || !recordsEqual(sideArg.symbolicRefs, planArg.initial.symbolicRefs) ) return false; for (const ref of planArg.scopedRefs) { const value = sideArg.refs[ref]; if (value !== planArg.initial.refs[ref] && value !== planArg.target.refs[ref]) return false; } const headIsInitial = sideArg.headOid === planArg.initial.headOid && sideArg.headRef === planArg.initial.headRef; const headIsTarget = sideArg.headOid === planArg.target.headOid && sideArg.headRef === planArg.target.headRef; if (!headIsInitial && !headIsTarget) return false; return await this.treeIsCombination( planArg.repository, planArg.captureKey, planArg.initial.worktreeTree, planArg.desiredWorktreeTree, sideArg.worktreeTree, signalArg, ) && await this.treeIsCombination( planArg.repository, planArg.captureKey, planArg.initial.indexTree, planArg.desiredIndexTree, sideArg.indexTree, signalArg, ); } private async treeIsCombination( repositoryArg: IGitReversionRepositoryIdentity, captureKeyArg: string, initialTreeArg: string, targetTreeArg: string, currentTreeArg: string, signalArg?: AbortSignal, ): Promise { if (currentTreeArg === initialTreeArg || currentTreeArg === targetTreeArg) return true; const [initial, target, current] = await Promise.all([ this.treeEntries(repositoryArg, captureKeyArg, initialTreeArg, signalArg), this.treeEntries(repositoryArg, captureKeyArg, targetTreeArg, signalArg), this.treeEntries(repositoryArg, captureKeyArg, currentTreeArg, signalArg), ]); for (const path of new Set([...initial.keys(), ...target.keys(), ...current.keys()])) { const value = current.get(path); if (value !== initial.get(path) && value !== target.get(path)) return false; } return true; } private async treeEntries( repositoryArg: IGitReversionRepositoryIdentity, captureKeyArg: string, treeArg: string, signalArg?: AbortSignal, ): Promise> { const objectEnvironment = await this.captureReadEnvironment(captureKeyArg, repositoryArg); const result = await this.git( repositoryArg, ['ls-tree', '-r', '-z', '--full-tree', treeArg], signalArg, [0], undefined, objectEnvironment, ); this.assertPathOutputBound(result.stdout, 'Git tree entries'); const entries = new Map(); for (const entry of splitNul(result.stdout, 'Git tree entries')) { const tab = entry.indexOf('\t'); if (tab < 0) return new Map([['', 'invalid']]); entries.set(entry.slice(tab + 1), entry.slice(0, tab)); if (entries.size > this.limits.maximumPaths) throw new Error('The Git tree path limit was exceeded.'); } return entries; } private async fenceWal( manifestArg: IGitReversionManifest, walArg: IGitReversionApplyWal, signalArg?: AbortSignal, ): Promise { walArg.phase = 'fenced'; const repositories = this.metadataRepositories( manifestArg.repositories.map((entry) => entry.repository), ); let durableMarkers = 0; for (const repository of repositories) { for (const ref of [ this.applyAttemptRef(walArg.captureKey, walArg.operationKey, walArg.attempt), this.applyLatestRef(walArg.captureKey, walArg.operationKey), ]) { await this.putRecord( repository, ref, walArg, { mode: 'recovery' }, signalArg, ).then( () => { durableMarkers += 1; }, () => undefined, ); } } for (const repository of repositories) { const ref = this.applyFenceRef(walArg.captureKey, walArg.operationKey, walArg.attempt); await this.putRecord(repository, ref, { schemaVersion: 1, kind: 'apply-fence', captureKey: walArg.captureKey, operationKey: walArg.operationKey, attempt: walArg.attempt, }, { mode: 'recovery' }, signalArg).then( () => { durableMarkers += 1; }, () => undefined, ); } if (durableMarkers === 0) { throw new GitReversionFencedError('The Git apply fence could not be persisted.'); } } private selectContainingRepository( projectArg: IRegisteredProject, repositoriesArg: readonly IGitReversionRepositoryIdentity[], ): IGitReversionRepositoryIdentity | undefined { return repositoriesArg .filter((repository) => isWithin(repository.root, projectArg.directory)) .sort((left, right) => right.root.length - left.root.length)[0] ?? repositoriesArg[0]; } private assertProjectDirectory(projectArg: IRegisteredProject): Promise { return assertCanonicalDirectory(projectArg.directory, projectArg.identity); } private worktreeDescriptor( recordArg: IGitReversionWorktreeRecord, ): IGitReversionWorktreeDescriptor { const state = ['active', 'retainedDirty', 'fenced'].includes(recordArg.state) ? recordArg.state as 'active' | 'retainedDirty' | 'fenced' : 'fenced'; return { worktreeId: recordArg.worktreeId, projectId: recordArg.projectId, sessionId: recordArg.sessionId, path: recordArg.path, repositoryId: recordArg.repositoryId, createdAt: recordArg.createdAt, state, }; } private async listWorktreeRecords( projectArg: IRegisteredProject, repositoriesArg: readonly IGitReversionRepositoryIdentity[], signalArg?: AbortSignal, ): Promise>> { const records = new Map>(); for (const ref of await this.listWorktreeRecordRefs()) { signalArg?.throwIfAborted(); const loaded = await this.readWorktreeRecordByRef(ref); if (!loaded) continue; const value = loaded.value; this.assertWorktreeRecord(value); if (value.projectId !== projectArg.projectId) continue; const repository = repositoriesArg.find((candidate) => ( this.repositoryBindingsEqual(candidate, value.sourceRepository) )); if (!repository) { throw new GitReversionFencedError( 'An owned Git worktree source no longer exactly matches fresh discovery.', ); } records.set(value.worktreeId, { repository, ref, oid: loaded.oid, value }); } if (records.size > this.limits.maximumWorktrees) { throw new Error('The owned Git worktree record limit was exceeded.'); } return [...records.values()].sort((left, right) => ( left.value.worktreeId.localeCompare(right.value.worktreeId) )); } private assertWorktreeRecord(recordArg: IGitReversionWorktreeRecord): void { if ( !recordArg || recordArg.schemaVersion !== 1 || recordArg.kind !== 'worktree' || recordArg.ownerKey !== this.ownerKey || !/^worktree-[A-Za-z0-9_-]{22}$/u.test(recordArg.worktreeId) || !plugins.path.isAbsolute(recordArg.path) || recordArg.path !== plugins.path.join(this.worktreeRoot(), recordArg.worktreeId) || !isWithin(this.worktreeRoot(), recordArg.path) || recordArg.parentPath !== this.worktreeRoot() || !plugins.path.isAbsolute(recordArg.sourceRoot) || !plugins.path.isAbsolute(recordArg.sourceCommonDir) || typeof recordArg.createdAt !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u.test(recordArg.createdAt) || !Number.isFinite(Date.parse(recordArg.createdAt)) || ![ 'intent', 'created', 'registered', 'active', 'removing', 'retainedDirty', 'removed', 'fenced', ].includes(recordArg.state) ) throw new Error('The private Git worktree record is invalid.'); this.assertFilesystemIdentity(recordArg.parentIdentity, recordArg.parentPath, 'directory'); if (recordArg.pathIdentity) { this.assertFilesystemIdentity(recordArg.pathIdentity, recordArg.path, 'directory'); } if (!['intent', 'fenced'].includes(recordArg.state) && !recordArg.pathIdentity) { throw new Error('The private Git worktree record has no child filesystem binding.'); } this.assertRepositoryIdentity(recordArg.sourceRepository); if ( recordArg.sourceRepository.root !== recordArg.sourceRoot || recordArg.sourceRepository.commonDir !== recordArg.sourceCommonDir || recordArg.sourceRepository.repositoryId !== recordArg.repositoryId ) throw new Error('The private Git worktree source binding is inconsistent.'); assertIdentifier(recordArg.projectId, 'The private Git worktree project ID'); assertIdentifier(recordArg.sessionId, 'The private Git worktree session ID'); } private async assertWorktreeParentBinding( recordArg: IGitReversionWorktreeRecord, ): Promise { await assertCanonicalFilesystemObject( recordArg.parentPath, 'directory', recordArg.parentIdentity, ); } private async assertWorktreePathBinding( recordArg: IGitReversionWorktreeRecord, ): Promise { await this.assertWorktreeParentBinding(recordArg); if (!recordArg.pathIdentity) { throw new GitReversionFencedError('The owned Git worktree has no child filesystem binding.'); } await assertCanonicalFilesystemObject(recordArg.path, 'directory', recordArg.pathIdentity); } private async requireOwnedWorktreeRegistration( repositoryArg: IGitReversionRepositoryIdentity, recordArg: IGitReversionWorktreeRecord, signalArg?: AbortSignal, ): Promise { await this.assertRepositoryBinding(repositoryArg); await this.assertWorktreePathBinding(recordArg); const registrations = this.parseWorktreeRegistrations((await this.git( repositoryArg, ['worktree', 'list', '--porcelain', '-z'], signalArg, )).stdout); const registration = registrations.find((entry) => entry.path === recordArg.path); if ( !registration || !registration.detached || !registration.locked || registration.lockReason !== this.worktreeLockReason(recordArg.worktreeId) ) throw new Error('The Git worktree registration is not exact-owned, detached, and locked.'); return registration; } private async assertOwnedWorktreeDiscoveryRegistration( repositoryArg: IGitReversionRepositoryIdentity, recordArg: IGitReversionWorktreeRecord, signalArg?: AbortSignal, ): Promise { const registrations = this.parseWorktreeRegistrations((await this.git( repositoryArg, ['worktree', 'list', '--porcelain', '-z'], signalArg, )).stdout); const registration = registrations.find((entry) => entry.path === recordArg.path); if (!registration || !registration.detached) { throw new GitReversionFencedError( 'The owned Git worktree discovery registration is missing or attached.', ); } const exactLock = registration.locked && registration.lockReason === this.worktreeLockReason(recordArg.worktreeId); if (recordArg.state === 'removing') { if (registration.locked && !exactLock) { throw new GitReversionFencedError( 'The removing Git worktree has another lock owner.', ); } return; } if (!exactLock) { throw new GitReversionFencedError( 'The owned Git worktree discovery lock is not exact.', ); } } private async restoreOwnedWorktreeLock( repositoryArg: IGitReversionRepositoryIdentity, recordArg: IGitReversionWorktreeRecord, signalArg?: AbortSignal, ): Promise { await this.assertWorktreePathBinding(recordArg); try { await this.requireOwnedWorktreeRegistration(repositoryArg, recordArg, signalArg); return; } catch (errorArg) { const registrations = this.parseWorktreeRegistrations((await this.git( repositoryArg, ['worktree', 'list', '--porcelain', '-z'], signalArg, )).stdout); const registration = registrations.find((entry) => entry.path === recordArg.path); if (!registration || registration.locked) throw errorArg; await this.assertWorktreePathBinding(recordArg); await this.git(repositoryArg, [ 'worktree', 'lock', '--reason', this.worktreeLockReason(recordArg.worktreeId), recordArg.path, ], signalArg); await this.requireOwnedWorktreeRegistration(repositoryArg, recordArg, signalArg); } } private async addWorktreeCaptureParticipant( projectArg: IRegisteredProject, sourceArg: IGitReversionRepositoryIdentity, recordArg: IGitReversionWorktreeRecord, captureKeyArg: string, signalArg?: AbortSignal, ): Promise { const discovered = await this.discoverProjectRepositories(projectArg, signalArg); const manifest = await this.findManifest(projectArg, discovered, captureKeyArg, signalArg); if (!manifest || manifest.phase !== 'prepared') { throw new Error('The active Git capture cannot accept a worktree participant.'); } await this.requireOwnedWorktreeRegistration(sourceArg, recordArg, signalArg); const participant = await this.describeRepository( recordArg.path, manifest.projectDirectory, signalArg, { ownedWorktreeAuthority: true }, ); if (manifest.repositories.some((entry) => ( entry.repository.repositoryId === participant.repositoryId ))) return; const priorManifest = structuredClone(manifest); const topology = (await this.git( sourceArg, ['worktree', 'list', '--porcelain', '-z'], signalArg, )).stdout; const publicationEntries: IPrivatePublicationIntentEntry[] = []; let publicationStarted = false; try { for (const entry of manifest.repositories) { if (entry.repository.commonId !== participant.commonId) continue; entry.before.topologyHash = this.worktreeTopologyHash( topology, entry.repository.root, ); const sideRef = this.captureSideRef(captureKeyArg, 'before', entry.repository.repositoryId); const priorSideOid = await this.resolveRef(entry.repository, sideRef, signalArg); if (!priorSideOid) throw new GitReversionFencedError('A captured participant side is missing.'); const priorSide = await this.readRecord<{ schemaVersion: 1; kind: 'capture-side'; captureKey: string; repository: IGitReversionRepositoryIdentity; side: IGitReversionSide; }>(entry.repository, sideRef, signalArg); publicationEntries.push(await this.buildPublicationEntry( entry.repository, sideRef, { ...priorSide, side: entry.before }, priorSideOid, signalArg, )); } const durable = await this.readDurableCaptureRecord(captureKeyArg); if (!durable) throw new GitReversionFencedError('The active capture record is missing.'); const generationIds = structuredClone(durable.generationIds); const side = await this.captureStableRepositories([participant], signalArg, { captureKey: captureKeyArg, persistent: true, generationIds, }); side[0]!.topologyHash = this.worktreeTopologyHash(topology, participant.root); manifest.repositories.push({ repository: participant, before: side[0]! }); manifest.repositories.sort((left, right) => compareRepositories(left.repository, right.repository)); if (manifest.repositories.length > this.limits.maximumRepositories) { throw new Error('The Git repository limit was exceeded by a captured worktree.'); } const oldMetadataRepositoryIds = new Set(this.metadataRepositories( priorManifest.repositories.map((entry) => entry.repository), ).map((repository) => repository.repositoryId)); for (const repository of this.metadataRepositories( manifest.repositories.map((entry) => entry.repository), )) { const manifestRef = this.manifestRef(captureKeyArg); const existing = await this.resolveRef(repository, manifestRef, signalArg); if (existing) { const value = await this.readRecord(repository, manifestRef, signalArg); if (!recordsEqual(value, priorManifest)) { throw new GitReversionFencedError('A participant-extension manifest predecessor diverged.'); } } else if (oldMetadataRepositoryIds.has(repository.repositoryId)) { throw new GitReversionFencedError('A participant-extension manifest predecessor is missing.'); } publicationEntries.push(await this.buildPublicationEntry( repository, manifestRef, manifest, existing ?? null, signalArg, )); if (!existing) { publicationEntries.push(await this.buildPublicationEntry( repository, this.captureWalRef(captureKeyArg), { schemaVersion: 1, kind: 'capture-wal', captureKey: captureKeyArg, phase: 'prepared', }, null, signalArg, )); } } publicationEntries.push(await this.buildPublicationEntry( participant, this.captureSideRef(captureKeyArg, 'before', participant.repositoryId), { schemaVersion: 1, kind: 'capture-side', captureKey: captureKeyArg, repository: participant, side: side[0], }, null, signalArg, )); publicationEntries.push(await this.buildPublicationEntry( participant, this.repositoryIdentityRef(captureKeyArg, participant.repositoryId), { schemaVersion: 1, kind: 'repository-identity', captureKey: captureKeyArg, repository: participant, }, null, signalArg, )); const intent = this.createPublicationIntent('participant-extension', manifest, publicationEntries); publicationStarted = true; await this.executePublicationIntent(intent, signalArg); await this.writeDurableCaptureRecord({ ...durable, manifest, generationIds, revision: durable.revision + 1, }); } catch (errorArg) { if (!publicationStarted) await this.cleanupPublicationEntries(publicationEntries); throw errorArg; } } private async removeWorktreeRecord( copyArg: IRecordCopy, signalArg?: AbortSignal, ): Promise { const { repository, ref, value: record } = copyArg; if (record.state === 'fenced') { throw new GitReversionFencedError('The owned Git worktree is fenced and retained.'); } try { await this.assertRepositoryBinding(record.sourceRepository); await this.assertWorktreePathBinding(record); if (!this.repositoryBindingsEqual(repository, record.sourceRepository)) { throw new GitReversionFencedError('The owned Git worktree source binding changed.'); } } catch (errorArg) { record.state = 'fenced'; await this.replaceRecord(repository, ref, record, signalArg).catch(() => undefined); throw errorArg instanceof GitReversionFencedError ? errorArg : new GitReversionFencedError('The owned Git worktree binding changed.', { cause: errorArg, }); } if (record.state === 'removing') { await this.restoreOwnedWorktreeLock(repository, record, signalArg); } else { await this.requireOwnedWorktreeRegistration(repository, record, signalArg); } const worktreeRepository = await this.describeRepository( record.path, repository.root, signalArg, { ownedWorktreeAuthority: true }, ); if ( repository.root !== record.sourceRoot || worktreeRepository.commonDir !== record.sourceCommonDir ) throw new Error('The owned Git worktree changed repository identity.'); const status = await this.git(worktreeRepository, [ 'status', '--porcelain=v2', '-z', '--untracked-files=all', '--ignored=matching', ], signalArg); if (status.stdout.byteLength > 0) { record.state = 'retainedDirty'; await this.replaceRecord(repository, ref, record, signalArg); throw new GitReversionDirtyWorktreeError(); } record.state = 'removing'; await this.replaceRecord(repository, ref, record, signalArg); await this.assertWorktreePathBinding(record); await this.git(repository, ['worktree', 'unlock', record.path], signalArg); try { await this.assertWorktreePathBinding(record); await this.git( repository, ['worktree', 'remove', record.path], signalArg, [0], undefined, undefined, this.limits.worktreeCommandTimeoutMs, ); } catch (errorArg) { const relockSignal = AbortSignal.timeout(this.limits.worktreeCommandTimeoutMs); const relocked = await this.assertWorktreePathBinding(record).then( () => this.git(repository, [ 'worktree', 'lock', '--reason', this.worktreeLockReason(record.worktreeId), record.path, ], relockSignal).then(() => true, () => false), () => false, ); if (!relocked) { throw new GitReversionFencedError( 'The owned Git worktree could not be relocked after removal failed.', { cause: errorArg }, ); } throw errorArg; } const registrations = this.parseWorktreeRegistrations((await this.git( repository, ['worktree', 'list', '--porcelain', '-z'], signalArg, )).stdout); if (registrations.some((entry) => entry.path === record.path)) { record.state = 'fenced'; await this.replaceRecord(repository, ref, record, signalArg); throw new GitReversionFencedError('Git still reports the removed worktree as registered.'); } try { await plugins.fs.promises.lstat(record.path); record.state = 'fenced'; await this.replaceRecord(repository, ref, record, signalArg); throw new GitReversionFencedError('The removed Git worktree path still exists.'); } catch (errorArg) { if ((errorArg as NodeJS.ErrnoException).code !== 'ENOENT') throw errorArg; } record.state = 'removed'; const removedOid = await this.replaceRecord(repository, ref, record, signalArg); try { await this.fault({ point: 'worktree.after-removed-record', worktreeId: record.worktreeId }); await this.deleteExpectedRef(repository, ref, removedOid, signalArg); } catch (errorArg) { this.invalidateRecovery(); throw errorArg; } } private async recoverWorktrees( projectArg: IRegisteredProject, repositoriesArg: readonly IGitReversionRepositoryIdentity[], signalArg?: AbortSignal, ): Promise { const records = await this.listWorktreeRecords(projectArg, repositoriesArg, signalArg); for (const copy of records) { signalArg?.throwIfAborted(); await this.withLocks([copy.repository.commonId], async () => { const record = copy.value; try { await this.assertRepositoryBinding(record.sourceRepository); await this.assertWorktreeParentBinding(record); if (!this.repositoryBindingsEqual(copy.repository, record.sourceRepository)) { throw new GitReversionFencedError('The owned Git worktree source binding changed.'); } } catch { record.state = 'fenced'; await this.replaceRecord(copy.repository, copy.ref, record, signalArg).catch(() => undefined); return; } const registrations = this.parseWorktreeRegistrations((await this.git( copy.repository, ['worktree', 'list', '--porcelain', '-z'], signalArg, )).stdout); const registered = registrations.find((entry) => entry.path === record.path); let pathExists = false; try { await plugins.fs.promises.lstat(record.path); pathExists = true; } catch (errorArg) { if ((errorArg as NodeJS.ErrnoException).code !== 'ENOENT') throw errorArg; } if (record.state === 'removed') { await this.deleteExpectedRef(copy.repository, copy.ref, copy.oid, signalArg); return; } if (record.state === 'intent' && !registered && !pathExists) { await this.deleteExactRef(copy.repository, copy.ref, signalArg); return; } if (record.state === 'intent' && registered && pathExists) { if ( !registered.detached || !registered.locked || registered.lockReason !== this.worktreeLockReason(record.worktreeId) ) { record.state = 'fenced'; await this.replaceRecord(copy.repository, copy.ref, record, signalArg); return; } try { const resolved = await resolveCanonicalFilesystemObject(record.path, 'directory'); record.pathIdentity = { ancestry: resolved.ancestry }; await this.assertWorktreePathBinding(record); const worktreeRepository = await this.describeRepository( record.path, copy.repository.root, signalArg, { ownedWorktreeAuthority: true }, ); if (worktreeRepository.commonId !== record.sourceRepository.commonId) { throw new Error('The intent-gap worktree belongs to another repository.'); } record.state = 'created'; await this.replaceRecord(copy.repository, copy.ref, record, signalArg); } catch { delete record.pathIdentity; record.state = 'fenced'; await this.replaceRecord(copy.repository, copy.ref, record, signalArg); return; } } if (['intent', 'created', 'registered'].includes(record.state)) { if (!registered || !pathExists) { record.state = 'fenced'; await this.replaceRecord(copy.repository, copy.ref, record, signalArg); return; } try { await this.requireOwnedWorktreeRegistration(copy.repository, record, signalArg); } catch { record.state = 'fenced'; await this.replaceRecord(copy.repository, copy.ref, record, signalArg); return; } const ownership = this.ownershipByCommonId.get(copy.repository.commonId); if (ownership?.kind === 'capture') { await this.addWorktreeCaptureParticipant( projectArg, copy.repository, record, ownership.token, signalArg, ); } record.state = 'active'; await this.replaceRecord(copy.repository, copy.ref, record, signalArg); return; } if (record.state === 'removing') { if (this.ownershipByCommonId.has(copy.repository.commonId)) { this.queueWorktreeRemovalRecovery(projectArg.projectId); return; } if (!registered && !pathExists) { record.state = 'removed'; const removedOid = await this.replaceRecord(copy.repository, copy.ref, record, signalArg); await this.fault({ point: 'worktree.after-removed-record', worktreeId: record.worktreeId }); await this.deleteExpectedRef(copy.repository, copy.ref, removedOid, signalArg); return; } if (!registered || !pathExists) { record.state = 'fenced'; await this.replaceRecord(copy.repository, copy.ref, record, signalArg); return; } await this.removeWorktreeRecord(copy, signalArg).catch(async (errorArg) => { if (errorArg instanceof GitReversionDirtyWorktreeError) return; throw errorArg; }); return; } if (record.state === 'active' || record.state === 'retainedDirty') { if (!registered || !pathExists) { record.state = 'fenced'; await this.replaceRecord(copy.repository, copy.ref, record, signalArg); } else { try { await this.requireOwnedWorktreeRegistration(copy.repository, record, signalArg); } catch { record.state = 'fenced'; await this.replaceRecord(copy.repository, copy.ref, record, signalArg); } } } }); } } private worktreeLockReason(worktreeIdArg: string): string { return `harness-controller:${this.ownerKey}:${worktreeIdArg}`; } private async listManifestCopies( repositoriesArg: readonly IGitReversionRepositoryIdentity[], signalArg?: AbortSignal, ): Promise>> { const result = new Map>(); for (const repository of this.metadataRepositories(repositoriesArg)) { const refs = await this.listRefs(repository, `${privateRefRoot}/captures/`, signalArg); for (const ref of refs.filter((entry) => entry.endsWith('/manifest'))) { const oid = await this.resolveRef(repository, ref, signalArg); if (!oid) continue; const value = await this.readRecord(repository, ref, signalArg); if (!result.has(value.captureKey)) result.set(value.captureKey, { repository, ref, oid, value }); } } return result; } private metadataRepositories( repositoriesArg: readonly IGitReversionRepositoryIdentity[], ): IGitReversionRepositoryIdentity[] { const result = new Map(); for (const repository of repositoriesArg) result.set(repository.repositoryId, repository); return [...result.values()].sort(compareRepositories); } private repositoryBindingsEqual( leftArg: IGitReversionRepositoryIdentity, rightArg: IGitReversionRepositoryIdentity, ): boolean { const binding = (repositoryArg: IGitReversionRepositoryIdentity) => ({ scope: repositoryArg.scope, repositoryId: repositoryArg.repositoryId, commonId: repositoryArg.commonId, objectFormat: repositoryArg.objectFormat, root: repositoryArg.root, rootIdentity: repositoryArg.rootIdentity, gitEntryPath: repositoryArg.gitEntryPath, gitEntryIdentity: repositoryArg.gitEntryIdentity, gitDir: repositoryArg.gitDir, gitDirIdentity: repositoryArg.gitDirIdentity, commonDir: repositoryArg.commonDir, commonDirIdentity: repositoryArg.commonDirIdentity, objectDir: repositoryArg.objectDir, objectDirIdentity: repositoryArg.objectDirIdentity, indexPath: repositoryArg.indexPath, indexParentIdentity: repositoryArg.indexParentIdentity, }); return recordsEqual(binding(leftArg), binding(rightArg)); } private async acquireOwnership( commonIdsArg: readonly string[], tokenArg: string, projectIdArg: string, kindArg: IOwnership['kind'], signalArg?: AbortSignal, ): Promise { const commonIds = [...new Set(commonIdsArg)].sort(); this.assertUsable(); signalArg?.throwIfAborted(); const reentrant = commonIds.some((commonId) => ( this.ownershipByCommonId.get(commonId)?.token === tokenArg )); if (reentrant) { const queuedExpansion = commonIds.some((commonId) => { if (this.ownershipByCommonId.get(commonId)?.token === tokenArg) return false; return [...this.ownershipWaitersByCommonId.get(commonId) ?? []].some((waiter) => ( !waiter.commonIds.some((waiterCommonId) => ( this.ownershipByCommonId.get(waiterCommonId)?.token === tokenArg )) )); }); if ( !queuedExpansion && this.grantOwnership(commonIds, tokenArg, projectIdArg, kindArg) ) return; throw new Error('Active Git ownership cannot wait while expanding to a busy repository.'); } const hasQueuedClaim = commonIds.some((commonId) => ( (this.ownershipWaitersByCommonId.get(commonId)?.size ?? 0) > 0 )); if (!hasQueuedClaim && this.grantOwnership(commonIds, tokenArg, projectIdArg, kindArg)) return; if (this.ownershipWaiters.size >= this.limits.maximumQueuedGitCommands) { throw new Error('The bounded Git ownership queue is full.'); } await new Promise((resolve, reject) => { let waiter!: IOwnershipWaiter; const settle = (): boolean => { if (waiter.settled) return false; waiter.settled = true; waiter.signal?.removeEventListener('abort', waiter.abort); this.removeOwnershipWaiter(waiter); return true; }; waiter = { commonIds, token: tokenArg, projectId: projectIdArg, kind: kindArg, signal: signalArg, settled: false, abort: () => { waiter.reject(signalArg?.reason instanceof Error ? signalArg.reason : new DOMException('The Git ownership wait was aborted.', 'AbortError')); }, resolve: () => { if (!settle()) return; resolve(); }, reject: (errorArg) => { if (!settle()) return; reject(errorArg); if (!this.sealed && !this.closed) this.drainOwnershipWaiters(commonIds); }, }; this.ownershipWaiters.add(waiter); for (const commonId of commonIds) { const queue = this.ownershipWaitersByCommonId.get(commonId) ?? new Set(); queue.add(waiter); this.ownershipWaitersByCommonId.set(commonId, queue); } signalArg?.addEventListener('abort', waiter.abort, { once: true }); if (signalArg?.aborted) waiter.abort(); else if (this.sealed || this.closed) waiter.reject(new Error('The Git reversion engine is closing.')); else this.drainOwnershipWaiters(commonIds); }); } private releaseOwnership(tokenArg: string): void { const releasedIds: string[] = []; const releasedProjectIds = new Set(); for (const [commonId, ownership] of this.ownershipByCommonId) { if (ownership.token !== tokenArg) continue; this.ownershipByCommonId.delete(commonId); releasedIds.push(commonId); releasedProjectIds.add(ownership.projectId); } this.drainOwnershipWaiters(releasedIds); for (const projectId of releasedProjectIds) { if (this.pendingWorktreeRemovalProjectIds.has(projectId)) { this.scheduleWorktreeRemovalRecovery(); } } } private grantOwnership( commonIdsArg: readonly string[], tokenArg: string, projectIdArg: string, kindArg: IOwnership['kind'], ): boolean { const available = commonIdsArg.every((commonId) => { const current = this.ownershipByCommonId.get(commonId); return current === undefined || current.token === tokenArg; }); if (!available) return false; for (const commonId of commonIdsArg) { this.ownershipByCommonId.set(commonId, { token: tokenArg, projectId: projectIdArg, kind: kindArg, }); } return true; } private removeOwnershipWaiter(waiterArg: IOwnershipWaiter): void { this.ownershipWaiters.delete(waiterArg); for (const commonId of waiterArg.commonIds) { const queue = this.ownershipWaitersByCommonId.get(commonId); if (!queue) continue; queue.delete(waiterArg); if (queue.size === 0) this.ownershipWaitersByCommonId.delete(commonId); } } private drainOwnershipWaiters(commonIdsArg: readonly string[]): void { if (this.sealed || this.closed) return; const candidates: IOwnershipWaiter[] = []; const queuedCandidates = new Set(); const addFront = (commonIdArg: string): void => { const front = this.ownershipWaitersByCommonId.get(commonIdArg)?.values().next().value; if (!front || queuedCandidates.has(front)) return; queuedCandidates.add(front); candidates.push(front); }; for (const commonId of commonIdsArg) addFront(commonId); for (let index = 0; index < candidates.length; index += 1) { const waiter = candidates[index]!; if (waiter.settled) continue; const ownsQueueFront = waiter.commonIds.every((commonId) => ( this.ownershipWaitersByCommonId.get(commonId)?.values().next().value === waiter )); if (!ownsQueueFront || !this.grantOwnership( waiter.commonIds, waiter.token, waiter.projectId, waiter.kind, )) continue; const affectedIds = [...waiter.commonIds]; waiter.resolve(); for (const commonId of affectedIds) addFront(commonId); } } private async withLocks( keysArg: readonly string[], operationArg: () => Promise, ): Promise { this.assertUsable(); let completeCohort!: () => void; const cohort = new Promise((resolve) => { completeCohort = resolve; }); this.activeLockCohorts.add(cohort); const keys = [...new Set(keysArg)].sort(); const releases: Array<() => void> = []; const waits: Promise[] = []; const installed: Array<{ key: string; tail: Promise }> = []; try { for (const key of keys) { const prior = this.lockTails.get(key) ?? Promise.resolve(); waits.push(prior.catch(() => undefined)); let release!: () => void; const gate = new Promise((resolve) => { release = resolve; }); const tail = prior.catch(() => undefined).then(() => gate); this.lockTails.set(key, tail); releases.push(release); installed.push({ key, tail }); } await Promise.all(waits); this.assertUsable(); return await operationArg(); } finally { for (const release of releases) release(); await Promise.all(installed.map((entry) => entry.tail)); for (const entry of installed) { if (this.lockTails.get(entry.key) === entry.tail) this.lockTails.delete(entry.key); } completeCohort(); this.activeLockCohorts.delete(cohort); } } private privateRefChargeKey( repositoryArg: IGitReversionRepositoryIdentity, refArg: string, ): string { return `${repositoryArg.repositoryId}\0${refArg}`; } private captureKeyFromPrivateRef(refArg: string): string { const match = new RegExp( `^${privateRefRoot}/captures/([0-9a-f]{64})(?:/|$)`, 'u', ).exec(refArg); if (!match?.[1]) throw new Error('A private Git ref has no bounded capture identity.'); return match[1]; } private async hardenPrivateTree(pathArg: string): Promise { await this.inspectPrivateTree(pathArg); await this.inspectPrivateTree(pathArg, async (node) => this.chmodPrivateNode(node)); } private async assertPrivateDirectoryNode(pathArg: string): Promise { const stat = await plugins.fs.promises.lstat(pathArg); if ( !stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0 || (typeof process.getuid === 'function' && stat.uid !== process.getuid()) ) throw new GitReversionFencedError('A private Git schema directory is not exclusive and owned.'); } private async listGenerationObjectDirectories( captureKeyArg: string, repositoryIdArg: string, ): Promise { const root = this.generationRoot(captureKeyArg, repositoryIdArg); let directory: plugins.fs.Dir; try { await this.assertPrivatePathAncestry(root); directory = await plugins.fs.promises.opendir(root); } catch (errorArg) { if ((errorArg as NodeJS.ErrnoException).code === 'ENOENT') return []; throw errorArg; } const result: string[] = []; try { for await (const entry of directory) { if (result.length >= this.limits.maximumGenerationsPerCapture) { throw new Error('The private object-generation limit was exceeded.'); } if (!/^[0-9a-f]{64}$/u.test(entry.name) || !entry.isDirectory() || entry.isSymbolicLink()) { throw new GitReversionFencedError('A private object-generation entry is invalid.'); } const generation = plugins.path.join(root, entry.name); await this.assertPrivateDirectoryNode(generation); const objects = plugins.path.join(generation, 'objects'); await this.assertPrivateDirectoryNode(objects); result.push(objects); } } finally { await directory.close().catch(() => undefined); } return result.sort(compareStrings); } private privateStoreConfig(objectFormatArg: IGitReversionRepositoryIdentity['objectFormat']): string { return objectFormatArg === 'sha256' ? '[core]\n\trepositoryformatversion = 1\n\tbare = true\n[extensions]\n\tobjectFormat = sha256\n' : '[core]\n\trepositoryformatversion = 0\n\tbare = true\n'; } private async privateDirectoryEntries(pathArg: string): Promise { await this.assertPrivateDirectoryNode(pathArg); const result: plugins.fs.Dirent[] = []; const directory = await plugins.fs.promises.opendir(pathArg); try { for await (const entry of directory) { if (result.length >= this.maximumPrivateTreeEntries) { throw new Error('A private Git schema directory exceeds its entry limit.'); } if (Buffer.byteLength(entry.name, 'utf8') > 1024) { throw new Error('A private Git schema entry name is too long.'); } result.push(entry); } } finally { await directory.close().catch(() => undefined); } return result.sort((left, right) => left.name.localeCompare(right.name)); } private async forEachPrivateDirectoryEntry( pathArg: string, operationArg: (entryArg: plugins.fs.Dirent) => Promise, ): Promise { await this.assertPrivateDirectoryNode(pathArg); const directory = await plugins.fs.promises.opendir(pathArg); let entries = 0; try { for await (const entry of directory) { entries += 1; if (entries > this.maximumPrivateTreeEntries) { throw new Error('A private Git schema directory exceeds its entry limit.'); } if (Buffer.byteLength(entry.name, 'utf8') > 1024) { throw new Error('A private Git schema entry name is too long.'); } await operationArg(entry); } } finally { await directory.close().catch(() => undefined); } } private async assertPrivateObjectDirectory( pathArg: string, objectFormatArg: IGitReversionRepositoryIdentity['objectFormat'], ): Promise { const suffixLength = objectFormatArg === 'sha256' ? 62 : 38; await this.forEachPrivateDirectoryEntry(pathArg, async (entry) => { const entryPath = plugins.path.join(pathArg, entry.name); if (entry.name === 'info' || entry.name === 'pack') { if (!entry.isDirectory() || entry.isSymbolicLink()) { throw new GitReversionFencedError('A private Git object control entry is invalid.'); } await this.forEachPrivateDirectoryEntry(entryPath, async (child) => { const allowed = entry.name === 'pack' && /^pack-[0-9a-f]{40,64}\.(?:idx|pack|rev)$/u.test(child.name); if (!allowed || !child.isFile() || child.isSymbolicLink()) { throw new GitReversionFencedError('A private Git object control directory diverged.'); } const stat = await plugins.fs.promises.lstat(plugins.path.join(entryPath, child.name)); if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) { throw new GitReversionFencedError('A private packed Git object is not exclusively owned.'); } }); return; } if (!/^[0-9a-f]{2}$/u.test(entry.name) || !entry.isDirectory() || entry.isSymbolicLink()) { throw new GitReversionFencedError('A private Git object directory has an unexpected entry.'); } await this.forEachPrivateDirectoryEntry(entryPath, async (object) => { if ( !new RegExp(`^[0-9a-f]{${suffixLength}}$`, 'u').test(object.name) || !object.isFile() || object.isSymbolicLink() ) throw new GitReversionFencedError('A private loose Git object entry is invalid.'); const stat = await plugins.fs.promises.lstat(plugins.path.join(entryPath, object.name)); if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) { throw new GitReversionFencedError('A private loose Git object is not exclusively owned.'); } }); }); } private async assertPrivateRefsDirectory( pathArg: string, objectFormatArg: IGitReversionRepositoryIdentity['objectFormat'], ): Promise { const inspect = async ( path: string, segments: readonly string[], depth: number, ): Promise => { if (depth > this.limits.maximumScanDepth) { throw new Error('The private Git ref directory depth limit was exceeded.'); } await this.forEachPrivateDirectoryEntry(path, async (entry) => { if (!/^[A-Za-z0-9._-]+$/u.test(entry.name) || entry.name.endsWith('.lock')) { throw new GitReversionFencedError('A private Git ref path is invalid.'); } const entryPath = plugins.path.join(path, entry.name); const entrySegments = [...segments, entry.name]; if (entry.isDirectory() && !entry.isSymbolicLink()) { await inspect(entryPath, entrySegments, depth + 1); return; } if (!entry.isFile() || entry.isSymbolicLink()) { throw new GitReversionFencedError('A private Git ref entry is not a regular file.'); } const bytes = await this.readBoundedRegularFile( entryPath, 128, 'Private Git direct ref', undefined, true, ); await this.assertPrivateDirectRefName(['refs', ...entrySegments].join('/')); this.parseDirectRefOid(bytes, objectFormatArg); }); }; await inspect(pathArg, [], 0); } private async assertPrivateGitStore( captureKeyArg: string, repositoryArg: IGitReversionRepositoryIdentity, ): Promise { this.assertRepositoryIdentity(repositoryArg); await this.assertPrivateGitStoreByFormat( captureKeyArg, repositoryArg.repositoryId, repositoryArg.objectFormat, ); } private async assertPrivateGitStoreByFormat( captureKeyArg: string, repositoryIdArg: string, objectFormatArg: IGitReversionRepositoryIdentity['objectFormat'], ): Promise { const storeRoot = this.captureStoreRoot(captureKeyArg, repositoryIdArg); const gitDirectory = this.captureGitDirectory(captureKeyArg, repositoryIdArg); await this.assertPrivatePathAncestry(storeRoot); const storeEntries = await this.privateDirectoryEntries(storeRoot); if (storeEntries.map((entry) => entry.name).join(',') !== 'generations,git') { throw new GitReversionFencedError('A private Git store has an unexpected control entry.'); } if (storeEntries.some((entry) => !entry.isDirectory() || entry.isSymbolicLink())) { throw new GitReversionFencedError('A private Git store control entry is invalid.'); } const gitEntries = await this.privateDirectoryEntries(gitDirectory); const gitEntryNames = new Set(gitEntries.map((entry) => entry.name)); if ( gitEntryNames.size !== 5 || !['HEAD', 'config', 'description', 'objects', 'refs'].every((name) => gitEntryNames.has(name)) ) { throw new GitReversionFencedError( `A private bare Git directory has unexpected entries: ${gitEntries.map((entry) => entry.name).join(',')}`, ); } const expectedFiles = new Map([ ['HEAD', 'ref: refs/heads/controller-private\n'], ['config', this.privateStoreConfig(objectFormatArg)], ['description', 'Harness Controller private reversion store\n'], ]); for (const [name, value] of expectedFiles) { const bytes = await this.readBoundedRegularFile( plugins.path.join(gitDirectory, name), 4096, `Private Git ${name}`, undefined, true, ); if (decodeUtf8(bytes, `Private Git ${name}`) !== value) { throw new GitReversionFencedError('A private Git store header diverged.'); } } const objects = plugins.path.join(gitDirectory, 'objects'); const refs = plugins.path.join(gitDirectory, 'refs'); await this.assertPrivateObjectDirectory(objects, objectFormatArg); await this.assertPrivateRefsDirectory(refs, objectFormatArg); for (const objectDirectory of await this.listGenerationObjectDirectories( captureKeyArg, repositoryIdArg, )) { const generationDirectory = plugins.path.dirname(objectDirectory); const entries = await this.privateDirectoryEntries(generationDirectory); if ( entries.length !== 1 || entries[0]!.name !== 'objects' || !entries[0]!.isDirectory() || entries[0]!.isSymbolicLink() ) throw new GitReversionFencedError('A private object generation has an unexpected entry.'); await this.assertPrivateObjectDirectory(objectDirectory, objectFormatArg); } } private async ensurePrivateStore( captureKeyArg: string, repositoryArg: IGitReversionRepositoryIdentity, ): Promise { const storeRoot = this.captureStoreRoot(captureKeyArg, repositoryArg.repositoryId); const gitDirectory = this.captureGitDirectory(captureKeyArg, repositoryArg.repositoryId); await this.ensurePrivateDirectory(storeRoot); await this.ensurePrivateDirectory(gitDirectory); await this.ensurePrivateDirectory(plugins.path.join(gitDirectory, 'objects')); await this.ensurePrivateDirectory(plugins.path.join(gitDirectory, 'objects', 'info')); await this.ensurePrivateDirectory(plugins.path.join(gitDirectory, 'objects', 'pack')); await this.ensurePrivateDirectory(plugins.path.join(gitDirectory, 'refs')); await this.ensurePrivateDirectory(plugins.path.join(gitDirectory, 'refs', 'heads')); await this.ensurePrivateDirectory(plugins.path.join(gitDirectory, 'refs', 'tags')); await this.ensurePrivateDirectory(this.generationRoot(captureKeyArg, repositoryArg.repositoryId)); const config = this.privateStoreConfig(repositoryArg.objectFormat); for (const [name, value] of [ ['HEAD', 'ref: refs/heads/controller-private\n'], ['config', config], ['description', 'Harness Controller private reversion store\n'], ] as const) { const path = plugins.path.join(gitDirectory, name); const existing = await this.readBoundedRegularFile( path, 4096, `Private Git ${name}`, undefined, true, ).then( (bytes) => decodeUtf8(bytes, `Private Git ${name}`), (errorArg: NodeJS.ErrnoException) => { if (errorArg.code === 'ENOENT') return undefined; throw errorArg; }, ); if (existing !== undefined && existing !== value) { throw new GitReversionFencedError('A private Git store header diverged.'); } if (existing === undefined) await plugins.fs.promises.writeFile(path, value, { mode: 0o600, flag: 'wx' }); } await this.hardenPrivateTree(storeRoot); await this.assertPrivateGitStore(captureKeyArg, repositoryArg); await this.syncDirectory(gitDirectory); await this.syncDirectory(storeRoot); } private async privateGit( repositoryArg: IGitReversionRepositoryIdentity, captureKeyArg: string, argsArg: readonly string[], signalArg?: AbortSignal, acceptedExitCodesArg: readonly number[] = [0], inputArg?: Buffer | string, envArg?: Record, timeoutMsArg = this.limits.commandTimeoutMs, maximumOutputBytesArg = this.limits.maximumCommandOutputBytes, outputFileArg?: IGitOutputFile, ): Promise { const gitDirectory = this.captureGitDirectory(captureKeyArg, repositoryArg.repositoryId); await this.assertPrivateGitStore(captureKeyArg, repositoryArg); const generations = await this.listGenerationObjectDirectories( captureKeyArg, repositoryArg.repositoryId, ); const result = await this.runGit(argsArg, { cwd: this.captureRoot(captureKeyArg), signal: signalArg, acceptedExitCodes: acceptedExitCodesArg, input: inputArg, outputFile: outputFileArg, env: { ...envArg, GIT_DIR: gitDirectory, GIT_OBJECT_DIRECTORY: plugins.path.join(gitDirectory, 'objects'), GIT_ALTERNATE_OBJECT_DIRECTORIES: generations.join(plugins.path.delimiter), GIT_NO_REPLACE_OBJECTS: '1', }, timeoutMs: timeoutMsArg, maximumOutputBytes: maximumOutputBytesArg, }); if (['hash-object', 'mktree', 'commit-tree', 'update-ref', 'prune'].includes(argsArg[0] ?? '')) { await this.hardenPrivateTree(this.captureStoreRoot(captureKeyArg, repositoryArg.repositoryId)); } return result; } private async captureReadEnvironment( captureKeyArg: string, repositoryArg: IGitReversionRepositoryIdentity, ): Promise> { await this.assertPrivateGitStore(captureKeyArg, repositoryArg); const generations = await this.listGenerationObjectDirectories( captureKeyArg, repositoryArg.repositoryId, ); return { GIT_ALTERNATE_OBJECT_DIRECTORIES: generations.join(plugins.path.delimiter), GIT_NO_REPLACE_OBJECTS: '1', }; } private withMetadataMutation(operationArg: () => Promise): Promise { this.assertUsable(); const predecessor = this.metadataMutationTail; const operation = predecessor.catch(() => undefined).then(async () => { this.assertUsable(); return operationArg(); }); this.metadataMutationTail = operation.then(() => undefined, () => undefined); this.activeMetadataMutations.add(operation); void operation.finally(() => this.activeMetadataMutations.delete(operation)).catch(() => undefined); return operation; } private setPrivateRefCharge( repositoryArg: IGitReversionRepositoryIdentity, refArg: string, chargeArg: IPrivateRefCharge | undefined, ): void { if (!this.metadataQuotaLoaded) { throw new Error('The private Git metadata quota has not completed recovery.'); } const key = this.privateRefChargeKey(repositoryArg, refArg); const previous = this.privateRefCharges.get(key); const nextCount = this.privateRefCharges.size - (previous ? 1 : 0) + (chargeArg ? 1 : 0); const nextAggregate = this.privateRefAggregateBytes - (previous?.bytes ?? 0) + (chargeArg?.bytes ?? 0); if (nextCount > this.limits.maximumPrivateRefs) { throw new Error('The private Git repository-backed record count limit was exceeded.'); } if ( chargeArg && (chargeArg.bytes > this.limits.maximumMetadataBytes || chargeArg.bytes > this.limits.maximumMetadataRecordBytes) ) { throw new Error('The private Git metadata record exceeds its byte limit.'); } if (nextAggregate > this.limits.maximumPrivateMetadataAggregateBytes) { throw new Error('The private Git repository-backed aggregate byte limit was exceeded.'); } const capturePrefix = `${this.captureRefRoot(this.captureKeyFromPrivateRef(refArg))}/`; let captureCount = 0; let captureBytes = 0; for (const [candidateKey, candidateCharge] of this.privateRefCharges) { if (candidateKey === key || !candidateKey.includes(`\0${capturePrefix}`)) continue; captureCount += 1; captureBytes += candidateCharge.bytes; } if (chargeArg) { captureCount += 1; captureBytes += chargeArg.bytes; } if ( captureCount > this.limits.maximumPrivateRefsPerCapture || captureCount > this.limits.maximumMetadataRecordsPerCapture ) throw new Error('The private Git per-capture ref limit was exceeded.'); if (captureBytes > this.limits.maximumMetadataAggregateBytes) { throw new Error('The private Git per-capture metadata byte limit was exceeded.'); } if (chargeArg) this.privateRefCharges.set(key, chargeArg); else this.privateRefCharges.delete(key); this.privateRefAggregateBytes = nextAggregate; } private async reserveMetadataWrite( repositoryArg: IGitReversionRepositoryIdentity, refArg: string, valueArg: unknown, ): Promise { const encoded = Buffer.from(canonicalJson(valueArg), 'utf8'); if ( encoded.byteLength > this.limits.maximumMetadataBytes || encoded.byteLength > this.limits.maximumMetadataRecordBytes ) throw new Error('The private Git metadata record exceeds its byte limit.'); const captureKey = this.captureKeyFromPrivateRef(refArg); const key = this.privateRefChargeKey(repositoryArg, refArg); const reservation: IMetadataWriteReservation = { repositoryId: repositoryArg.repositoryId, ref: refArg, captureKey, bytes: encoded.byteLength, storeReservationId: `metadata:${plugins.crypto.randomBytes(32).toString('hex')}`, }; await this.reserveStoreCapacity({ reservationId: reservation.storeReservationId, captureKey, bytes: encoded.byteLength + 64 * 1024, objects: 3, generations: 0, }); try { await this.withMetadataMutation(async () => { if (this.metadataWriteReservations.has(key)) { throw new Error('A private Git metadata ref already has a pending writer.'); } const projected = new Map(); for (const [chargeKey, charge] of this.privateRefCharges) projected.set(chargeKey, charge.bytes); for (const [chargeKey, pending] of this.metadataWriteReservations) { projected.set(chargeKey, pending.bytes); } projected.set(key, reservation.bytes); if (projected.size > this.limits.maximumPrivateRefs) { throw new Error('The controller-wide private Git ref limit was exceeded.'); } let aggregate = 0; let captureCount = 0; let captureBytes = 0; const capturePrefix = `\0${this.captureRefRoot(captureKey)}/`; for (const [chargeKey, bytes] of projected) { aggregate += bytes; if (chargeKey.includes(capturePrefix)) { captureCount += 1; captureBytes += bytes; } } if (aggregate > this.limits.maximumPrivateMetadataAggregateBytes) { throw new Error('The controller-wide private Git metadata byte limit was exceeded.'); } if ( captureCount > this.limits.maximumPrivateRefsPerCapture || captureCount > this.limits.maximumMetadataRecordsPerCapture ) throw new Error('The private Git per-capture ref limit was exceeded.'); if (captureBytes > this.limits.maximumMetadataAggregateBytes) { throw new Error('The private Git per-capture metadata byte limit was exceeded.'); } this.metadataWriteReservations.set(key, reservation); }); return reservation; } catch (errorArg) { await this.releaseStoreReservation(reservation.storeReservationId, true).catch(() => undefined); throw errorArg; } } private releaseMetadataWrite( repositoryArg: IGitReversionRepositoryIdentity, refArg: string, ): Promise { const key = this.privateRefChargeKey(repositoryArg, refArg); return this.withMetadataMutation(async () => { const reservation = this.metadataWriteReservations.get(key); if (!reservation) return; this.metadataWriteReservations.delete(key); await this.releaseStoreReservation(reservation.storeReservationId, true); }); } private async reconcileMetadataStoreReservation( reservationArg: IMetadataWriteReservation, ): Promise { await this.reconcileStoreReservation(reservationArg.storeReservationId); } private consumeMetadataWriteReservation( repositoryArg: IGitReversionRepositoryIdentity, refArg: string, ): void { const key = this.privateRefChargeKey(repositoryArg, refArg); if (!this.metadataWriteReservations.delete(key)) { throw new Error('The private Git metadata write reservation is missing.'); } } private async prunePrivateMetadataStore( repositoryArg: IGitReversionRepositoryIdentity, captureKeyArg: string, signalArg?: AbortSignal, ): Promise { await this.privateGit( repositoryArg, captureKeyArg, ['prune', '--expire=now'], signalArg, [0], undefined, undefined, this.limits.commandTimeoutMs, this.limits.maximumCommandOutputBytes, ); const usage = await this.scanCaptureStoreUsage(captureKeyArg); if ( usage.bytes > this.limits.maximumCaptureStoreBytes || usage.objects > this.limits.maximumCaptureStoreObjects ) throw new Error('Private Git metadata cleanup did not restore physical quota.'); } private async rebuildPrivateRefQuota(signalArg?: AbortSignal): Promise { await this.withMetadataMutation(async () => { this.metadataQuotaLoaded = false; this.privateRefCharges.clear(); this.privateRefAggregateBytes = 0; const charges = new Map(); let aggregate = 0; for (const capture of await this.listDurableCaptureRecords()) { let captureRefCount = 0; let captureMetadataBytes = 0; for (const repository of this.metadataRepositories( capture.manifest.repositories.map((entry) => entry.repository), )) { signalArg?.throwIfAborted(); const refs = await this.listDirectRefs( repository, `${this.captureRefRoot(capture.captureKey)}/`, signalArg, ); captureRefCount += refs.length; if ( captureRefCount > this.limits.maximumPrivateRefsPerCapture || captureRefCount > this.limits.maximumMetadataRecordsPerCapture ) throw new Error('The private Git per-capture ref limit was exceeded.'); const values = await this.readBatchRecordsAt( repository, capture.captureKey, refs.map((entry) => entry.oid), signalArg, ); for (let index = 0; index < refs.length; index += 1) { const ref = refs[index]!; const value = values[index]!; const bytes = Buffer.byteLength(canonicalJson(value), 'utf8'); if ( bytes > this.limits.maximumMetadataBytes || bytes > this.limits.maximumMetadataRecordBytes ) throw new GitReversionFencedError('A private Git metadata record exceeds its byte limit.'); captureMetadataBytes += bytes; if (captureMetadataBytes > this.limits.maximumMetadataAggregateBytes) { throw new Error('The private Git per-capture metadata byte limit was exceeded.'); } const key = this.privateRefChargeKey(repository, ref.ref); if (charges.has(key)) throw new GitReversionFencedError('A private Git ref was repeated.'); charges.set(key, { oid: ref.oid, bytes }); aggregate += bytes; if (charges.size > this.limits.maximumPrivateRefs) { throw new Error('The private Git repository-backed record count limit was exceeded.'); } if ( aggregate > this.limits.maximumPrivateMetadataAggregateBytes || aggregate > this.limits.maximumMetadataAggregateBytes * this.limits.maximumCaptures ) throw new Error('The private Git repository-backed aggregate byte limit was exceeded.'); } } } this.privateRefCharges.clear(); for (const [key, charge] of charges) this.privateRefCharges.set(key, charge); this.privateRefAggregateBytes = aggregate; this.metadataQuotaLoaded = true; }); } private pinsForRecord( valueArg: unknown, repositoryIdArg: string, ): { trees: string[]; commits: string[] } { const trees = new Set(); const commits = new Set(); const addSide = (sideArg: IGitReversionSide): void => { trees.add(sideArg.worktreeTree); trees.add(sideArg.indexTree); commits.add(sideArg.headOid); }; const record = valueArg as { kind?: string; repositories?: IGitReversionRepositoryCapture[]; manifest?: IGitReversionManifest; repository?: IGitReversionRepositoryIdentity; side?: IGitReversionSide; plans?: IGitReversionApplyPlan[]; }; if (record.kind === 'manifest' || record.kind === 'finalization-intent') { const entries = record.kind === 'manifest' ? record.repositories ?? [] : record.manifest?.repositories ?? []; for (const entry of entries) { if (entry.repository.repositoryId !== repositoryIdArg) continue; addSide(entry.before); if (entry.after) addSide(entry.after); } } else if ( record.kind === 'capture-side' && record.repository?.repositoryId === repositoryIdArg ) { if (record.side) addSide(record.side); } else if (record.kind === 'apply-wal') { for (const plan of record.plans ?? []) { if (plan.repository.repositoryId !== repositoryIdArg) continue; addSide(plan.initial); addSide(plan.source); addSide(plan.target); trees.add(plan.desiredWorktreeTree); trees.add(plan.desiredIndexTree); } } return { trees: [...trees].sort(), commits: [...commits].sort() }; } private async buildRecord( repositoryArg: IGitReversionRepositoryIdentity, captureKeyArg: string, valueArg: unknown, signalArg?: AbortSignal, ): Promise<{ oid: string; value: object; bytes: number }> { if (!valueArg || typeof valueArg !== 'object' || Array.isArray(valueArg)) { throw new Error('Private Git metadata must be an object.'); } const encoded = Buffer.from(canonicalJson(valueArg), 'utf8'); if ( encoded.byteLength > this.limits.maximumMetadataBytes || encoded.byteLength > this.limits.maximumMetadataRecordBytes ) { throw new Error('The private Git metadata record exceeds its byte limit.'); } const blob = trimLine((await this.privateGit( repositoryArg, captureKeyArg, ['hash-object', '-w', '--stdin'], signalArg, [0], encoded, )).stdout); const pins = this.pinsForRecord(valueArg, repositoryArg.repositoryId); const treeEntries = [ { mode: '100644', type: 'blob', oid: blob, name: 'metadata.json' }, ...pins.trees.map((oid, index) => ({ mode: '040000', type: 'tree', oid, name: `tree-${String(index).padStart(4, '0')}`, })), ].sort((left, right) => left.name.localeCompare(right.name)); const treeInput = Buffer.from(treeEntries.map((entry) => ( `${entry.mode} ${entry.type} ${entry.oid}\t${entry.name}\0` )).join(''), 'utf8'); const tree = trimLine((await this.privateGit( repositoryArg, captureKeyArg, ['mktree', '-z'], signalArg, [0], treeInput, )).stdout); const validParents: string[] = []; for (const oid of pins.commits) { const exists = await this.privateGit( repositoryArg, captureKeyArg, ['cat-file', '-e', `${oid}^{commit}`], signalArg, [0, 1, 128], ); if (exists.exitCode === 0) validParents.push(oid); } const commit = trimLine((await this.privateGit(repositoryArg, captureKeyArg, [ 'commit-tree', tree, ...validParents.flatMap((parent) => ['-p', parent]), '-m', 'harness-controller private reversion metadata', ], signalArg, [0], undefined, this.canonicalCommitEnvironment())).stdout); return { oid: commit, value: valueArg as object, bytes: encoded.byteLength }; } private async buildPublicationEntry( repositoryArg: IGitReversionRepositoryIdentity, refArg: string, valueArg: object, expectedOidArg: string | null, signalArg?: AbortSignal, ): Promise { const captureKey = this.captureKeyFromPrivateRef(refArg); const reservation = await this.reserveMetadataWrite(repositoryArg, refArg, valueArg); try { const built = await this.buildRecord(repositoryArg, captureKey, valueArg, signalArg); await this.reconcileMetadataStoreReservation(reservation); return { repository: structuredClone(repositoryArg), ref: refArg, expectedOid: expectedOidArg, intendedOid: built.oid, intendedValue: structuredClone(valueArg), }; } catch (errorArg) { await this.releaseMetadataWrite(repositoryArg, refArg).catch(() => undefined); await this.prunePrivateMetadataStore(repositoryArg, captureKey).catch(() => undefined); throw errorArg; } } private async cleanupPublicationEntries( entriesArg: readonly IPrivatePublicationIntentEntry[], ): Promise { const stores = new Map(); for (const entry of entriesArg) { await this.releaseMetadataWrite(entry.repository, entry.ref).catch(() => undefined); stores.set(entry.repository.repositoryId, entry.repository); } for (const repository of stores.values()) { await this.prunePrivateMetadataStore( repository, entriesArg[0]?.ref ? this.captureKeyFromPrivateRef(entriesArg[0].ref) : '0'.repeat(64), ).catch(() => undefined); } } private createPublicationIntent( operationArg: IPrivatePublicationIntent['operation'], manifestArg: IGitReversionManifest, entriesArg: IPrivatePublicationIntentEntry[], ): IPrivatePublicationIntent { const intent: IPrivatePublicationIntent = { schemaVersion: 1, kind: 'publication-intent', ownerKey: this.ownerKey, intentId: plugins.crypto.randomBytes(32).toString('hex'), projectId: manifestArg.identity.projectId, projectDirectory: manifestArg.projectDirectory, projectIdentity: structuredClone(manifestArg.projectIdentity), captureKey: manifestArg.captureKey, operation: operationArg, phase: 'prepared', entries: entriesArg, }; this.assertPrivateIntent(intent); return intent; } private async executePublicationIntent( intentArg: IPrivatePublicationIntent, signalArg?: AbortSignal, ): Promise { try { await this.withMetadataMutation(async () => { if (this.captureLifecycleClaim(intentArg.captureKey) === 'deletion') { throw new GitReversionRecoveryRequiredError( 'The Git capture is being deleted before private publication.', ); } await this.writePrivateIntent(intentArg, true); const previousCharges = new Map(); try { for (const entry of intentArg.entries) { signalArg?.throwIfAborted(); const current = await this.resolveRef(entry.repository, entry.ref, signalArg); const expected = entry.expectedOid ?? undefined; if (current !== expected) { throw new GitReversionFencedError( `A private Git publication predecessor diverged: ${entry.ref}`, ); } const key = this.privateRefChargeKey(entry.repository, entry.ref); const previous = this.privateRefCharges.get(key); if (current && previous?.oid !== current) { throw new GitReversionFencedError('The private Git publication quota state diverged.'); } previousCharges.set(key, previous); this.setPrivateRefCharge(entry.repository, entry.ref, { oid: entry.intendedOid, bytes: Buffer.byteLength(canonicalJson(entry.intendedValue), 'utf8'), }); this.consumeMetadataWriteReservation(entry.repository, entry.ref); } } catch (errorArg) { for (const entry of [...intentArg.entries].reverse()) { const key = this.privateRefChargeKey(entry.repository, entry.ref); if (previousCharges.has(key)) { this.setPrivateRefCharge(entry.repository, entry.ref, previousCharges.get(key)); } } for (const entry of intentArg.entries) { this.metadataWriteReservations.delete(this.privateRefChargeKey(entry.repository, entry.ref)); } await this.removePrivateIntent(intentArg); throw errorArg; } for (const entry of intentArg.entries) { await this.fault({ point: 'intent.before-ref-cas', intentId: intentArg.intentId, captureKey: intentArg.captureKey, repositoryId: entry.repository.repositoryId, ref: entry.ref, }); await this.privateGit(entry.repository, intentArg.captureKey, [ 'update-ref', '--no-deref', entry.ref, entry.intendedOid, entry.expectedOid ?? zeroOid(entry.repository), ], signalArg); await this.fault({ point: 'intent.after-ref-cas', intentId: intentArg.intentId, captureKey: intentArg.captureKey, repositoryId: entry.repository.repositoryId, ref: entry.ref, }); } const committed: IPrivatePublicationIntent = { ...intentArg, phase: 'committed' }; await this.writePrivateIntent(committed, false); await this.removePrivateIntent(committed); }); } catch (errorArg) { const retainedIntent = await this.listPrivateIntents().then( (intents) => intents.some((intent) => intent.intentId === intentArg.intentId), () => true, ); for (const entry of intentArg.entries) { await this.releaseMetadataWrite(entry.repository, entry.ref).catch(() => undefined); if (!retainedIntent) { await this.prunePrivateMetadataStore(entry.repository, intentArg.captureKey).catch(() => undefined); } } this.invalidateRecovery(); throw errorArg; } } private async chargeForOid( repositoryArg: IGitReversionRepositoryIdentity, refArg: string, oidArg: string, signalArg?: AbortSignal, ): Promise { const value = await this.readRecordAt( repositoryArg, this.captureKeyFromPrivateRef(refArg), oidArg, signalArg, ); this.setPrivateRefCharge(repositoryArg, refArg, { oid: oidArg, bytes: Buffer.byteLength(canonicalJson(value), 'utf8'), }); } private recoverPublicationIntent( intentArg: IPrivatePublicationIntent, signalArg?: AbortSignal, ): Promise { return this.withMetadataMutation(async () => { const current = await this.readPrivateIntent(intentArg.intentId); if (!current) return; if (current.kind !== 'publication-intent' || !recordsEqual(current, intentArg)) { throw new GitReversionFencedError('A private Git publication intent changed during recovery.'); } await this.recoverPublicationIntentUnlocked(current, signalArg); }); } private async recoverPublicationIntentUnlocked( intentArg: IPrivatePublicationIntent, signalArg?: AbortSignal, ): Promise { for (const entry of intentArg.entries) { const value = await this.readRecordAt( entry.repository, intentArg.captureKey, entry.intendedOid, signalArg, ); if (!recordsEqual(value, entry.intendedValue)) { throw new GitReversionFencedError('A private Git publication object diverged from its intent.'); } } const entries = intentArg.phase === 'prepared' ? [...intentArg.entries].reverse() : intentArg.entries; if (intentArg.phase === 'committed') { const previousCharges = new Map(); try { for (const entry of entries) { const current = await this.resolveRef(entry.repository, entry.ref, signalArg); const expected = entry.expectedOid ?? undefined; if (current !== expected && current !== entry.intendedOid) { throw new GitReversionFencedError( `A committed private Git publication encountered a third ref value: ${entry.ref}`, ); } const key = this.privateRefChargeKey(entry.repository, entry.ref); previousCharges.set(key, this.privateRefCharges.get(key)); this.setPrivateRefCharge(entry.repository, entry.ref, { oid: entry.intendedOid, bytes: Buffer.byteLength(canonicalJson(entry.intendedValue), 'utf8'), }); } } catch (errorArg) { for (const entry of [...entries].reverse()) { const key = this.privateRefChargeKey(entry.repository, entry.ref); if (previousCharges.has(key)) { this.setPrivateRefCharge(entry.repository, entry.ref, previousCharges.get(key)); } } throw errorArg; } } for (const entry of entries) { signalArg?.throwIfAborted(); const current = await this.resolveRef(entry.repository, entry.ref, signalArg); const expected = entry.expectedOid ?? undefined; if (intentArg.phase === 'prepared') { if (current === entry.intendedOid) { await this.fault({ point: 'intent.before-ref-cas', intentId: intentArg.intentId, captureKey: intentArg.captureKey, repositoryId: entry.repository.repositoryId, ref: entry.ref, }); if (expected) { await this.privateGit(entry.repository, intentArg.captureKey, [ 'update-ref', '--no-deref', entry.ref, expected, entry.intendedOid, ], signalArg); await this.chargeForOid(entry.repository, entry.ref, expected, signalArg); } else { await this.privateGit(entry.repository, intentArg.captureKey, [ 'update-ref', '--no-deref', '-d', entry.ref, entry.intendedOid, ], signalArg); this.setPrivateRefCharge(entry.repository, entry.ref, undefined); } await this.fault({ point: 'intent.after-ref-cas', intentId: intentArg.intentId, captureKey: intentArg.captureKey, repositoryId: entry.repository.repositoryId, ref: entry.ref, }); } else if (current === expected) { if (expected) await this.chargeForOid(entry.repository, entry.ref, expected, signalArg); else this.setPrivateRefCharge(entry.repository, entry.ref, undefined); } else { throw new GitReversionFencedError( `A private Git publication rollback encountered a third ref value: ${entry.ref}`, ); } continue; } if (current === expected) { await this.fault({ point: 'intent.before-ref-cas', intentId: intentArg.intentId, captureKey: intentArg.captureKey, repositoryId: entry.repository.repositoryId, ref: entry.ref, }); await this.privateGit(entry.repository, intentArg.captureKey, [ 'update-ref', '--no-deref', entry.ref, entry.intendedOid, entry.expectedOid ?? zeroOid(entry.repository), ], signalArg); await this.fault({ point: 'intent.after-ref-cas', intentId: intentArg.intentId, captureKey: intentArg.captureKey, repositoryId: entry.repository.repositoryId, ref: entry.ref, }); } else if (current !== entry.intendedOid) { throw new GitReversionFencedError( `A committed private Git publication encountered a third ref value: ${entry.ref}`, ); } this.setPrivateRefCharge(entry.repository, entry.ref, { oid: entry.intendedOid, bytes: Buffer.byteLength(canonicalJson(entry.intendedValue), 'utf8'), }); } await this.removePrivateIntent(intentArg); } private recoverDeletionIntent( intentArg: IPrivateDeletionIntent, signalArg?: AbortSignal, ): Promise { return this.withMetadataMutation(async () => { const current = await this.readPrivateIntent(intentArg.intentId); if (!current) return; if (current.kind !== 'deletion-intent' || !recordsEqual(current, intentArg)) { throw new GitReversionFencedError('A private Git deletion intent changed during recovery.'); } await this.recoverDeletionIntentUnlocked(current, signalArg); }); } private async recoverDeletionIntentUnlocked( intentArg: IPrivateDeletionIntent, signalArg?: AbortSignal, ): Promise { for (const entry of intentArg.entries) { signalArg?.throwIfAborted(); const current = await this.resolveRef(entry.repository, entry.ref, signalArg); if (!current) { this.setPrivateRefCharge(entry.repository, entry.ref, undefined); continue; } if (current !== entry.expectedOid) { throw new GitReversionFencedError( `A private Git release encountered a replacement ref: ${entry.ref}`, ); } await this.fault({ point: 'intent.before-ref-cas', intentId: intentArg.intentId, captureKey: intentArg.captureKey, repositoryId: entry.repository.repositoryId, ref: entry.ref, }); await this.privateGit(entry.repository, intentArg.captureKey, [ 'update-ref', '--no-deref', '-d', entry.ref, entry.expectedOid, ], signalArg); this.setPrivateRefCharge(entry.repository, entry.ref, undefined); await this.fault({ point: 'intent.after-ref-cas', intentId: intentArg.intentId, captureKey: intentArg.captureKey, repositoryId: entry.repository.repositoryId, ref: entry.ref, }); } await this.removePrivateIntent(intentArg); } private async executeDeletionIntent( intentArg: IPrivateDeletionIntent, signalArg?: AbortSignal, ): Promise { try { await this.withMetadataMutation(async () => { await this.writePrivateIntent(intentArg, true); for (const entry of intentArg.entries) { signalArg?.throwIfAborted(); const current = await this.resolveRef(entry.repository, entry.ref, signalArg); if (current !== entry.expectedOid) { throw new GitReversionFencedError( `A private Git release predecessor diverged: ${entry.ref}`, ); } await this.fault({ point: 'intent.before-ref-cas', intentId: intentArg.intentId, captureKey: intentArg.captureKey, repositoryId: entry.repository.repositoryId, ref: entry.ref, }); await this.privateGit(entry.repository, intentArg.captureKey, [ 'update-ref', '--no-deref', '-d', entry.ref, entry.expectedOid, ], signalArg); this.setPrivateRefCharge(entry.repository, entry.ref, undefined); await this.fault({ point: 'intent.after-ref-cas', intentId: intentArg.intentId, captureKey: intentArg.captureKey, repositoryId: entry.repository.repositoryId, ref: entry.ref, }); } await this.removePrivateIntent(intentArg); }); } catch (errorArg) { this.invalidateRecovery(); throw errorArg; } } private async releaseRepositoryCapture( projectArg: IRegisteredProject, repositoriesArg: readonly IGitReversionRepositoryIdentity[], manifestArg: IGitReversionManifest, signalArg?: AbortSignal, ): Promise { const repositories = this.metadataRepositories([ ...repositoriesArg, ...manifestArg.repositories.map((entry) => entry.repository), ]); const entries: IPrivateDeletionIntentEntry[] = []; const prefix = `${this.captureRefRoot(manifestArg.captureKey)}/`; for (const repository of repositories) { for (const { ref, oid } of (await this.listDirectRefs(repository, prefix, signalArg)).reverse()) { entries.push({ repository: structuredClone(repository), ref, expectedOid: oid }); if (entries.length > this.limits.maximumIntentEntries) { throw new Error('The private Git release intent entry limit was exceeded.'); } } } if (entries.length === 0) return; const intent: IPrivateDeletionIntent = { schemaVersion: 1, kind: 'deletion-intent', ownerKey: this.ownerKey, intentId: plugins.crypto.randomBytes(32).toString('hex'), projectId: projectArg.projectId, projectDirectory: projectArg.directory, projectIdentity: structuredClone(projectArg.identity), captureKey: manifestArg.captureKey, operation: 'release', phase: 'deleting', entries, }; this.assertPrivateIntent(intent); await this.executeDeletionIntent(intent, signalArg); } private async recoverPrivateIntents( projectIdArg: string | undefined, signalArg?: AbortSignal, ): Promise { let processed = false; for (const intent of await this.listPrivateIntents()) { if (projectIdArg && intent.projectId !== projectIdArg) continue; const project = this.projects.get(intent.projectId); if ( !project || project.directory !== intent.projectDirectory || !filesystemIdentitiesEqual(project.identity, intent.projectIdentity) || await this.assertProjectDirectory(project) !== intent.projectDirectory ) throw new GitReversionFencedError('A private Git intent project binding diverged.'); for (const entry of intent.entries) { await this.assertRepositoryBinding(entry.repository); } if (intent.kind === 'publication-intent') { await this.recoverPublicationIntent(intent, signalArg); } else { await this.recoverDeletionIntent(intent, signalArg); } processed = true; } return processed; } private async readWorktreeRecordByRef(refArg: string): Promise<{ oid: string; value: T } | undefined> { const path = this.worktreeRecordPathForRef(refArg); let bytes: Buffer | undefined; let concurrentReadError: GitReversionConcurrentRecordReadError | undefined; for (let attempt = 0; attempt < 4; attempt += 1) { try { bytes = await this.readBoundedRegularFile( path, this.limits.maximumMetadataRecordBytes, 'Private worktree record', undefined, true, true, () => this.fault({ point: 'worktree.record-read.after-open', ref: refArg }), ); break; } catch (errorArg) { if ((errorArg as NodeJS.ErrnoException).code === 'ENOENT') return undefined; if (errorArg instanceof GitReversionConcurrentRecordReadError) { concurrentReadError = errorArg; continue; } throw errorArg; } } if (!bytes) { throw new GitReversionFencedError( 'The private worktree record changed repeatedly while it was read.', { cause: concurrentReadError }, ); } let value: unknown; try { value = JSON.parse(decodeUtf8(bytes, 'Private worktree record')); } catch (errorArg) { throw new GitReversionFencedError('A private worktree record is malformed.', { cause: errorArg, }); } if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new GitReversionFencedError('A private worktree record is invalid.'); } return { oid: sha256(bytes), value: value as T }; } private async cleanupWorktreeRecordTemporaryFiles(): Promise { const directory = await plugins.fs.promises.opendir(this.worktreeRecordRoot()); let inspected = 0; let removed = false; try { for await (const entry of directory) { inspected += 1; if (inspected > this.limits.maximumScavengeEntries) { throw new Error('The worktree record scavenging entry limit was exceeded.'); } if (!this.isWorktreeRecordTemporaryName(entry.name)) continue; const path = plugins.path.join(this.worktreeRecordRoot(), entry.name); const stat = await plugins.fs.promises.lstat(path); if ( !entry.isFile() || entry.isSymbolicLink() || !stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || (stat.mode & 0o077) !== 0 || (typeof process.getuid === 'function' && stat.uid !== process.getuid()) ) throw new GitReversionFencedError('A worktree record temporary file is invalid.'); await plugins.fs.promises.rm(path); removed = true; } } finally { await directory.close().catch(() => undefined); } if (removed) await this.syncDirectory(this.worktreeRecordRoot()); } private async writeWorktreeRecord( refArg: string, valueArg: unknown, expectedOidArg: string | undefined, ): Promise { if (!valueArg || typeof valueArg !== 'object' || Array.isArray(valueArg)) { throw new Error('Private worktree metadata must be an object.'); } const target = this.worktreeRecordPathForRef(refArg); const current = await this.readWorktreeRecordByRef(refArg); if (current?.oid !== expectedOidArg) { throw new GitReversionFencedError('The private worktree record CAS predecessor diverged.'); } const bytes = Buffer.from(`${canonicalJson(valueArg)}\n`, 'utf8'); if (bytes.byteLength > this.limits.maximumMetadataRecordBytes) { throw new Error('The private worktree record exceeds its byte limit.'); } const temporary = `${target}.${process.pid}.${plugins.crypto.randomBytes(10).toString('hex')}.tmp`; let handle: plugins.fs.promises.FileHandle | undefined; try { handle = await plugins.fs.promises.open(temporary, 'wx', 0o600); await handle.writeFile(bytes); await handle.sync(); await handle.close(); handle = undefined; const rechecked = await this.readWorktreeRecordByRef(refArg); if (rechecked?.oid !== expectedOidArg) { throw new GitReversionFencedError('The private worktree record changed before publication.'); } await plugins.fs.promises.rename(temporary, target); await this.syncDirectory(this.worktreeRecordRoot()); } finally { await handle?.close().catch(() => undefined); await plugins.fs.promises.rm(temporary, { force: true }).catch(() => undefined); } return sha256(bytes); } private async listWorktreeRecordRefs(): Promise { const refs: string[] = []; const directory = await plugins.fs.promises.opendir(this.worktreeRecordRoot()); try { for await (const entry of directory) { if (this.isWorktreeRecordTemporaryName(entry.name)) continue; if (refs.length >= this.limits.maximumWorktrees) { throw new Error('The owned Git worktree record limit was exceeded.'); } const match = /^([0-9a-f]{64})\.json$/u.exec(entry.name); if (!match || !entry.isFile() || entry.isSymbolicLink()) { throw new Error('The worktree record directory contains an unexpected entry.'); } refs.push(`${this.worktreeRefRoot()}/${match[1]}`); } } finally { await directory.close().catch(() => undefined); } return refs.sort(compareStrings); } private async writeRecord( repositoryArg: IGitReversionRepositoryIdentity, refArg: string, valueArg: unknown, expectedOidArg: string, signalArg?: AbortSignal, ): Promise { if (refArg.startsWith(`${this.worktreeRefRoot()}/`)) { const expected = expectedOidArg === zeroOid(repositoryArg) ? undefined : expectedOidArg; return this.writeWorktreeRecord(refArg, valueArg, expected); } if (!refArg.startsWith(`${privateRefRoot}/`)) throw new Error('A private Git ref escaped its namespace.'); await this.assertGitRefName(refArg, signalArg); const captureKey = this.captureKeyFromPrivateRef(refArg); const reservation = await this.reserveMetadataWrite(repositoryArg, refArg, valueArg); let built: { oid: string; value: object; bytes: number }; try { built = await this.buildRecord(repositoryArg, captureKey, valueArg, signalArg); await this.reconcileMetadataStoreReservation(reservation); } catch (errorArg) { await this.releaseMetadataWrite(repositoryArg, refArg).catch(() => undefined); await this.prunePrivateMetadataStore(repositoryArg, captureKey).catch(() => undefined); throw errorArg; } try { return await this.withMetadataMutation(async () => { const current = await this.resolveRef(repositoryArg, refArg, signalArg); const expected = expectedOidArg === zeroOid(repositoryArg) ? undefined : expectedOidArg; if (current !== expected) { throw new GitReversionFencedError(`The private Git ref CAS predecessor diverged: ${refArg}`); } const key = this.privateRefChargeKey(repositoryArg, refArg); const previous = this.privateRefCharges.get(key); if (current && previous?.oid !== current) { throw new GitReversionFencedError('The private Git metadata quota state diverged from its ref.'); } this.setPrivateRefCharge(repositoryArg, refArg, { oid: built.oid, bytes: built.bytes }); this.consumeMetadataWriteReservation(repositoryArg, refArg); try { await this.privateGit( repositoryArg, captureKey, ['update-ref', '--no-deref', refArg, built.oid, expectedOidArg], signalArg, ); } catch (errorArg) { const actual = await this.resolveRef(repositoryArg, refArg, signalArg).catch(() => undefined); if (actual === built.oid) return built.oid; this.setPrivateRefCharge(repositoryArg, refArg, previous); throw new GitReversionFencedError(`The private Git ref CAS failed: ${refArg}`, { cause: errorArg, }); } return built.oid; }); } catch (errorArg) { await this.releaseMetadataWrite(repositoryArg, refArg).catch(() => undefined); await this.prunePrivateMetadataStore(repositoryArg, captureKey).catch(() => undefined); throw errorArg; } } private async replaceRecord( repositoryArg: IGitReversionRepositoryIdentity, refArg: string, valueArg: unknown, signalArg?: AbortSignal, ): Promise { const current = await this.resolveRef(repositoryArg, refArg, signalArg); if (!current) throw new GitReversionFencedError(`The private Git ref is missing: ${refArg}`); return this.writeRecord(repositoryArg, refArg, valueArg, current, signalArg); } private async putRecord( repositoryArg: IGitReversionRepositoryIdentity, refArg: string, valueArg: unknown, optionsArg: { mode: 'normal' | 'recovery'; predecessor?: unknown }, signalArg?: AbortSignal, ): Promise { let current = await this.resolveRef(repositoryArg, refArg, signalArg); if (current) { const currentValue = await this.readRecord(repositoryArg, refArg, signalArg); if (recordsEqual(currentValue, valueArg)) return current; if ( optionsArg.mode === 'normal' && (optionsArg.predecessor === undefined || !recordsEqual(currentValue, optionsArg.predecessor)) ) throw new GitReversionFencedError(`The private Git ref has an unexpected predecessor: ${refArg}`); } else if (optionsArg.mode === 'normal' && optionsArg.predecessor !== undefined) { throw new GitReversionFencedError(`The private Git ref predecessor is missing: ${refArg}`); } try { return await this.writeRecord( repositoryArg, refArg, valueArg, current ?? zeroOid(repositoryArg), signalArg, ); } catch (errorArg) { current = await this.resolveRef(repositoryArg, refArg, signalArg); if (current) { const currentValue = await this.readRecord(repositoryArg, refArg, signalArg); if (recordsEqual(currentValue, valueArg)) return current; } throw new GitReversionFencedError(`The private Git ref CAS failed: ${refArg}`, { cause: errorArg, }); } } private async readRecord( repositoryArg: IGitReversionRepositoryIdentity, refArg: string, signalArg?: AbortSignal, ): Promise { if (refArg.startsWith(`${this.worktreeRefRoot()}/`)) { const record = await this.readWorktreeRecordByRef(refArg); if (!record) throw new GitReversionFencedError('The private worktree record is missing.'); return record.value; } return this.readRecordAt( repositoryArg, this.captureKeyFromPrivateRef(refArg), refArg, signalArg, ); } private async readNulTerminatedBatchField( handleArg: plugins.fs.promises.FileHandle, positionArg: number, maximumBytesArg: number, descriptionArg: string, ): Promise<{ value: Buffer; nextPosition: number }> { const buffer = Buffer.allocUnsafe(maximumBytesArg + 1); const { bytesRead } = await handleArg.read(buffer, 0, buffer.byteLength, positionArg); if (bytesRead === 0) throw new GitReversionFencedError(`${descriptionArg} ended early.`); const terminator = buffer.subarray(0, bytesRead).indexOf(0); if (terminator < 0) throw new GitReversionFencedError(`${descriptionArg} exceeds its framing limit.`); return { value: Buffer.from(buffer.subarray(0, terminator)), nextPosition: positionArg + terminator + 1, }; } private async readExactBatchBytes( handleArg: plugins.fs.promises.FileHandle, positionArg: number, lengthArg: number, descriptionArg: string, ): Promise<{ value: Buffer; nextPosition: number }> { const value = Buffer.allocUnsafe(lengthArg); let offset = 0; while (offset < lengthArg) { const { bytesRead } = await handleArg.read( value, offset, lengthArg - offset, positionArg + offset, ); if (bytesRead === 0) throw new GitReversionFencedError(`${descriptionArg} ended early.`); offset += bytesRead; } return { value, nextPosition: positionArg + lengthArg }; } private async readBatchRecordsAt( repositoryArg: IGitReversionRepositoryIdentity, captureKeyArg: string, revisionsArg: readonly string[], signalArg?: AbortSignal, ): Promise { if (revisionsArg.length === 0) return []; if (revisionsArg.length > this.limits.maximumPrivateRefs) { throw new Error('The private Git batch record count limit was exceeded.'); } const oidLength = repositoryArg.objectFormat === 'sha256' ? 64 : 40; const oidPattern = new RegExp(`^[0-9a-f]{${oidLength}}$`, 'u'); if (revisionsArg.some((revision) => !oidPattern.test(revision))) { throw new GitReversionFencedError('A private Git batch revision is malformed.'); } const framingMaximumBytes = multiplySafeLimit(revisionsArg.length, 256); const outputMaximumBytes = addSafeLimit( this.limits.maximumMetadataAggregateBytes, framingMaximumBytes, ); const outputPath = await this.temporaryIndexPath(repositoryArg.repositoryId); let outputHandle: plugins.fs.promises.FileHandle | undefined; let operationError: unknown; try { outputHandle = await plugins.fs.promises.open(outputPath, 'wx+', 0o600); await this.privateGit( repositoryArg, captureKeyArg, ['cat-file', '--batch', '-Z'], signalArg, [0], Buffer.from(revisionsArg.map((revision) => `${revision}:metadata.json\0`).join(''), 'utf8'), undefined, this.limits.commandTimeoutMs, this.limits.maximumCommandOutputBytes, { handle: outputHandle, maximumBytes: outputMaximumBytes, }, ); const stat = await outputHandle.stat(); if ( !stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > outputMaximumBytes || (stat.mode & 0o077) !== 0 || (typeof process.getuid === 'function' && stat.uid !== process.getuid()) ) throw new GitReversionFencedError('The private Git batch output is invalid.'); const values: object[] = []; let position = 0; let aggregateBytes = 0; for (let index = 0; index < revisionsArg.length; index += 1) { signalArg?.throwIfAborted(); const header = await this.readNulTerminatedBatchField( outputHandle, position, 256, 'Private Git batch header', ); position = header.nextPosition; const match = new RegExp(`^([0-9a-f]{${oidLength}}) blob (0|[1-9][0-9]*)$`, 'u') .exec(decodeUtf8(header.value, 'Private Git batch header')); if (!match) throw new GitReversionFencedError('A private Git batch object is missing or malformed.'); const bytes = Number(match[2]); if ( !Number.isSafeInteger(bytes) || bytes < 1 || bytes > this.limits.maximumMetadataBytes || bytes > this.limits.maximumMetadataRecordBytes ) throw new GitReversionFencedError('A private Git batch record exceeds its byte limit.'); aggregateBytes = addSafeLimit(aggregateBytes, bytes); if (aggregateBytes > this.limits.maximumMetadataAggregateBytes) { throw new GitReversionFencedError('Private Git batch metadata exceeds its aggregate limit.'); } const record = await this.readExactBatchBytes( outputHandle, position, bytes, 'Private Git batch record', ); position = record.nextPosition; const terminator = await this.readExactBatchBytes( outputHandle, position, 1, 'Private Git batch record terminator', ); position = terminator.nextPosition; if (terminator.value[0] !== 0) { throw new GitReversionFencedError('A private Git batch record has invalid framing.'); } let value: unknown; try { value = JSON.parse(decodeUtf8(record.value, 'Private Git batch record')) as unknown; } catch (errorArg) { throw new GitReversionFencedError('Private Git batch metadata is malformed.', { cause: errorArg, }); } if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new GitReversionFencedError('Private Git batch metadata is invalid.'); } values.push(value as object); } if (position !== stat.size) { throw new GitReversionFencedError('Private Git batch output has trailing data.'); } return values; } catch (errorArg) { operationError = errorArg; throw errorArg; } finally { const cleanupErrors: unknown[] = []; if (outputHandle) { try { await outputHandle.close(); } catch (errorArg) { cleanupErrors.push(errorArg); } } try { await this.removeTemporaryIndexPaths([outputPath]); } catch (errorArg) { cleanupErrors.push(errorArg); } if (cleanupErrors.length > 0) { throw new AggregateError( operationError === undefined ? cleanupErrors : [operationError, ...cleanupErrors], 'Private Git batch output cleanup failed.', ); } } } private async readRecordAt( repositoryArg: IGitReversionRepositoryIdentity, captureKeyArg: string, revisionArg: string, signalArg?: AbortSignal, ): Promise { const result = await this.privateGit( repositoryArg, captureKeyArg, ['show', `${revisionArg}:metadata.json`], signalArg, [0], undefined, undefined, this.limits.commandTimeoutMs, this.limits.maximumMetadataBytes + 1, ); let value: unknown; try { value = JSON.parse(decodeUtf8(result.stdout, 'Private Git metadata')); } catch (errorArg) { throw new GitReversionFencedError('Private Git metadata is malformed.', { cause: errorArg }); } if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new GitReversionFencedError('Private Git metadata is invalid.'); } return value as T; } private async resolveRef( repositoryArg: IGitReversionRepositoryIdentity, refArg: string, signalArg?: AbortSignal, ): Promise { if (refArg.startsWith(`${this.worktreeRefRoot()}/`)) { return (await this.readWorktreeRecordByRef(refArg))?.oid; } await this.assertGitRefName(refArg, signalArg); const captureKey = this.captureKeyFromPrivateRef(refArg); const gitDirectory = this.captureGitDirectory(captureKey, repositoryArg.repositoryId); try { await this.assertPrivateDirectory(gitDirectory); } catch (errorArg) { if ((errorArg as NodeJS.ErrnoException).code === 'ENOENT') return undefined; throw errorArg; } await this.assertPrivateGitStore(captureKey, repositoryArg); const refPath = plugins.path.join( gitDirectory, ...refArg.split('/'), ); let bytes: Buffer; try { await this.assertPrivatePathAncestry(plugins.path.dirname(refPath)); bytes = await this.readBoundedRegularFile( refPath, 128, 'Private Git direct ref', signalArg, true, ); } catch (errorArg) { if ((errorArg as NodeJS.ErrnoException).code === 'ENOENT') return undefined; throw errorArg; } return this.parseDirectRefOid(bytes, repositoryArg.objectFormat); } private async listDirectRefs( repositoryArg: IGitReversionRepositoryIdentity, prefixArg: string, signalArg?: AbortSignal, ): Promise { const captureKey = this.captureKeyFromPrivateRef(prefixArg); try { await this.assertPrivateDirectory(this.captureGitDirectory( captureKey, repositoryArg.repositoryId, )); } catch (errorArg) { if ((errorArg as NodeJS.ErrnoException).code === 'ENOENT') return []; throw errorArg; } await this.assertPrivateGitStore(captureKey, repositoryArg); const seen = new Set(); const refs: IPrivateDirectRef[] = []; const refsRoot = plugins.path.join( this.captureGitDirectory(captureKey, repositoryArg.repositoryId), 'refs', ); const walk = async (pathArg: string, segmentsArg: readonly string[], depthArg: number): Promise => { if (depthArg > this.limits.maximumScanDepth) { throw new Error('The private Git ref directory depth limit was exceeded.'); } await this.forEachPrivateDirectoryEntry(pathArg, async (entry) => { signalArg?.throwIfAborted(); const path = plugins.path.join(pathArg, entry.name); const segments = [...segmentsArg, entry.name]; if (entry.isDirectory() && !entry.isSymbolicLink()) { await walk(path, segments, depthArg + 1); return; } if (!entry.isFile() || entry.isSymbolicLink()) { throw new GitReversionFencedError('A private Git ref entry is not a regular file.'); } const ref = ['refs', ...segments].join('/'); await this.assertPrivateDirectRefName(ref, signalArg); const bytes = await this.readBoundedRegularFile( path, 128, 'Private Git direct ref', signalArg, true, ); const oid = this.parseDirectRefOid(bytes, repositoryArg.objectFormat); if (!ref.startsWith(prefixArg)) return; if ( seen.has(ref) || refs.length >= this.limits.maximumPrivateRefs ) throw new GitReversionFencedError('A listed private Git ref is malformed or repeated.'); seen.add(ref); refs.push({ ref, oid }); }); }; await walk(refsRoot, [], 0); return refs.sort((left, right) => compareStrings(left.ref, right.ref)); } private async listRefs( repositoryArg: IGitReversionRepositoryIdentity, prefixArg: string, signalArg?: AbortSignal, ): Promise { if (prefixArg === `${this.worktreeRefRoot()}/`) return this.listWorktreeRecordRefs(); return (await this.listDirectRefs(repositoryArg, prefixArg, signalArg)) .map((entry) => entry.ref); } private async deleteExactRef( repositoryArg: IGitReversionRepositoryIdentity, refArg: string, signalArg?: AbortSignal, ): Promise { const current = await this.resolveRef(repositoryArg, refArg, signalArg); if (current) await this.deleteExpectedRef(repositoryArg, refArg, current, signalArg); } private async deleteExpectedRef( repositoryArg: IGitReversionRepositoryIdentity, refArg: string, expectedOidArg: string, signalArg?: AbortSignal, ): Promise { if (refArg.startsWith(`${this.worktreeRefRoot()}/`)) { const current = await this.readWorktreeRecordByRef(refArg); if (!current) return; if (current.oid !== expectedOidArg) { throw new GitReversionFencedError('The private worktree record changed before deletion.'); } await plugins.fs.promises.rm(this.worktreeRecordPathForRef(refArg)); await this.syncDirectory(this.worktreeRecordRoot()); return; } await this.withMetadataMutation(async () => { const current = await this.resolveRef(repositoryArg, refArg, signalArg); if (!current) { this.setPrivateRefCharge(repositoryArg, refArg, undefined); return; } if (current !== expectedOidArg) { throw new GitReversionFencedError(`The private Git ref changed before deletion: ${refArg}`); } const charge = this.privateRefCharges.get(this.privateRefChargeKey(repositoryArg, refArg)); if (charge?.oid !== current) { throw new GitReversionFencedError('The private Git deletion quota state is incomplete.'); } try { await this.privateGit(repositoryArg, this.captureKeyFromPrivateRef(refArg), [ 'update-ref', '--no-deref', '-d', refArg, expectedOidArg, ], signalArg); } catch (errorArg) { const actual = await this.resolveRef(repositoryArg, refArg, signalArg).catch(() => undefined); if (actual) throw new GitReversionFencedError(`The private Git ref deletion CAS failed: ${refArg}`, { cause: errorArg, }); } this.setPrivateRefCharge(repositoryArg, refArg, undefined); }); } private captureRefRoot(captureKeyArg: string): string { return `${privateRefRoot}/captures/${captureKeyArg}`; } private manifestRef(captureKeyArg: string): string { return `${this.captureRefRoot(captureKeyArg)}/manifest`; } private captureWalRef(captureKeyArg: string): string { return `${this.captureRefRoot(captureKeyArg)}/wal`; } private captureStateRef(captureKeyArg: string): string { return `${this.captureRefRoot(captureKeyArg)}/state`; } private capturePinsRef(captureKeyArg: string): string { return `${this.captureRefRoot(captureKeyArg)}/pins`; } private finalizationCandidateRef(captureKeyArg: string, candidateIdArg: string): string { return `${this.captureRefRoot(captureKeyArg)}/finalization/candidates/${candidateIdArg}`; } private finalizationIntentRef(captureKeyArg: string): string { return `${this.captureRefRoot(captureKeyArg)}/finalization/intent`; } private finalizationFenceRef(captureKeyArg: string): string { return `${this.captureRefRoot(captureKeyArg)}/finalization/fence`; } private captureSideRef( captureKeyArg: string, sideArg: TGitReversionSideName, repositoryIdArg: string, ): string { return `${this.captureRefRoot(captureKeyArg)}/${sideArg}/${repositoryIdArg}`; } private repositoryIdentityRef(captureKeyArg: string, repositoryIdArg: string): string { return `${this.captureRefRoot(captureKeyArg)}/repositories/${repositoryIdArg}`; } private applyLatestRef(captureKeyArg: string, operationKeyArg: string): string { return `${this.captureRefRoot(captureKeyArg)}/applies/${operationKeyArg}/latest`; } private applyAttemptRef( captureKeyArg: string, operationKeyArg: string, attemptArg: number, ): string { return `${this.captureRefRoot(captureKeyArg)}/applies/${operationKeyArg}/attempts/${attemptArg}/wal`; } private applyDecisionRef( captureKeyArg: string, operationKeyArg: string, attemptArg: number, ): string { return `${this.captureRefRoot(captureKeyArg)}/applies/${operationKeyArg}/attempts/${attemptArg}/decision`; } private applyFenceRef( captureKeyArg: string, operationKeyArg: string, attemptArg: number, ): string { return `${this.captureRefRoot(captureKeyArg)}/applies/${operationKeyArg}/attempts/${attemptArg}/fence`; } private worktreeRef(worktreeIdArg: string): string { return `${this.worktreeRefRoot()}/${sha256(worktreeIdArg)}`; } private worktreeRefRoot(): string { return `${privateRefRoot}/worktrees/${this.ownerKey}`; } private async temporaryIndexPath(repositoryIdArg: string): Promise { await this.retryPendingMaintenanceCleanups(); this.assertUsable(); return plugins.path.join( this.indexRoot(), `${this.ownerKey}-${repositoryIdArg}-${process.pid}-${plugins.crypto.randomBytes(10).toString('hex')}`, ); } private async removeTemporaryIndexPaths(pathsArg: readonly string[]): Promise { const paths = [...new Set(pathsArg)]; const pattern = new RegExp( `^${this.ownerKey}-git-[0-9a-f]{32}-[1-9][0-9]*-[0-9a-f]{20}(?:\\.lock)?$`, 'u', ); for (const path of paths) { if ( plugins.path.dirname(path) !== this.indexRoot() || !pattern.test(plugins.path.basename(path)) ) throw new GitReversionFencedError('A temporary Git index cleanup path is invalid.'); this.pendingTemporaryIndexCleanups.add(path); } const completed: string[] = []; const errors: unknown[] = []; for (const path of paths) { try { const before = await plugins.fs.promises.lstat(path).catch((errorArg: NodeJS.ErrnoException) => { if (errorArg.code === 'ENOENT') return undefined; throw errorArg; }); if (before) { if ( !before.isFile() || before.isSymbolicLink() || before.nlink !== 1 || (typeof process.getuid === 'function' && before.uid !== process.getuid()) ) throw new GitReversionFencedError('A temporary Git index cleanup target is unsafe.'); const current = await plugins.fs.promises.lstat(path); if ( !current.isFile() || current.isSymbolicLink() || current.nlink !== 1 || current.dev !== before.dev || current.ino !== before.ino || current.size !== before.size ) throw new GitReversionFencedError('A temporary Git index changed before cleanup.'); await plugins.fs.promises.rm(path); } completed.push(path); } catch (errorArg) { errors.push(errorArg); } } try { await this.syncDirectory(this.indexRoot()); } catch (errorArg) { errors.push(errorArg); } if (errors.length === 0) { for (const path of completed) this.pendingTemporaryIndexCleanups.delete(path); return; } this.invalidateRecovery(); throw new AggregateError(errors, 'Temporary Git index cleanup was incomplete.'); } private canonicalCommitEnvironment(): Record { return { GIT_AUTHOR_NAME: 'Harness Controller', GIT_AUTHOR_EMAIL: 'reversion@harness-controller.invalid', GIT_AUTHOR_DATE: '2000-01-01T00:00:00Z', GIT_COMMITTER_NAME: 'Harness Controller', GIT_COMMITTER_EMAIL: 'reversion@harness-controller.invalid', GIT_COMMITTER_DATE: '2000-01-01T00:00:00Z', }; } private acquireGitCommandPermit(signalArg?: AbortSignal): Promise<() => void> { this.assertUsable(); signalArg?.throwIfAborted(); const createRelease = (): (() => void) => { let released = false; return () => { if (released) return; released = true; this.activeGitCommandPermits -= 1; while (this.gitCommandWaiters.length > 0 && !this.sealed && !this.closed) { const waiter = this.gitCommandWaiters.shift()!; waiter.signal?.removeEventListener('abort', waiter.abort); if (waiter.signal?.aborted) { waiter.reject(waiter.signal.reason instanceof Error ? waiter.signal.reason : new DOMException('The Git command wait was aborted.', 'AbortError')); continue; } this.activeGitCommandPermits += 1; waiter.resolve(createRelease()); break; } }; }; if (this.activeGitCommandPermits < this.limits.maximumConcurrentGitCommands) { this.activeGitCommandPermits += 1; const release = createRelease(); try { signalArg?.throwIfAborted(); this.assertUsable(); } catch (errorArg) { release(); throw errorArg; } return Promise.resolve(release); } if (this.gitCommandWaiters.length >= this.limits.maximumQueuedGitCommands) { throw new Error('The bounded Git command queue is full.'); } return new Promise<() => void>((resolve, reject) => { const waiter: IGitCommandWaiter = { resolve, reject: (errorArg) => { signalArg?.removeEventListener('abort', waiter.abort); const index = this.gitCommandWaiters.indexOf(waiter); if (index >= 0) this.gitCommandWaiters.splice(index, 1); reject(errorArg); }, signal: signalArg, abort: () => { waiter.reject(signalArg?.reason instanceof Error ? signalArg.reason : new DOMException('The Git command wait was aborted.', 'AbortError')); }, }; this.gitCommandWaiters.push(waiter); signalArg?.addEventListener('abort', waiter.abort, { once: true }); if (signalArg?.aborted) waiter.abort(); else if (this.sealed || this.closed) waiter.reject(new Error('The Git reversion engine is closing.')); }); } private async git( repositoryArg: IGitReversionRepositoryIdentity, argsArg: readonly string[], signalArg?: AbortSignal, acceptedExitCodesArg: readonly number[] = [0], inputArg?: Buffer | string, envArg?: Record, timeoutMsArg = this.limits.commandTimeoutMs, maximumOutputBytesArg = this.limits.maximumCommandOutputBytes, ): Promise { await this.assertRepositoryBinding(repositoryArg); return this.runGit(argsArg, { cwd: repositoryArg.root, signal: signalArg, acceptedExitCodes: acceptedExitCodesArg, input: inputArg, env: { ...envArg, GIT_DIR: repositoryArg.gitDir, GIT_COMMON_DIR: repositoryArg.commonDir, GIT_WORK_TREE: repositoryArg.root, }, timeoutMs: timeoutMsArg, maximumOutputBytes: maximumOutputBytesArg, }); } private async runGit( argsArg: readonly string[], optionsArg: { cwd?: string; signal?: AbortSignal; acceptedExitCodes?: readonly number[]; input?: Buffer | string; inputFile?: IGitInputFile; outputFile?: IGitOutputFile; env?: Record; timeoutMs?: number; maximumOutputBytes?: number; } = {}, ): Promise { this.assertUsable(); optionsArg.signal?.throwIfAborted(); if (argsArg.some((argument) => argument.includes('\0'))) { throw new Error('A Git command argument contains a NUL byte.'); } if (optionsArg.input !== undefined && optionsArg.inputFile) { throw new Error('A Git command cannot have buffered and file input together.'); } if ( optionsArg.outputFile && (!Number.isSafeInteger(optionsArg.outputFile.maximumBytes) || optionsArg.outputFile.maximumBytes < 1) ) throw new Error('A Git command file-output limit is invalid.'); const timeoutMs = optionsArg.timeoutMs ?? this.limits.commandTimeoutMs; const maximumOutputBytes = optionsArg.maximumOutputBytes ?? this.limits.maximumCommandOutputBytes; const releasePermit = await this.acquireGitCommandPermit(optionsArg.signal); try { optionsArg.signal?.throwIfAborted(); this.assertUsable(); } catch (errorArg) { releasePermit(); throw errorArg; } const environment = { ...process.env }; for (const key of Object.keys(environment)) { if (key.startsWith('GIT_')) delete environment[key]; } let child: plugins.childProcess.ChildProcessWithoutNullStreams; try { child = plugins.childProcess.spawn('git', [ ...safeGitConfigurationArguments, ...argsArg, ], { cwd: optionsArg.cwd, detached: process.platform !== 'win32', shell: false, windowsHide: true, env: { ...environment, ...optionsArg.env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: plugins.os.devNull, GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'Never', GIT_NO_LAZY_FETCH: '1', GIT_PAGER: 'cat', GIT_EDITOR: 'true', GIT_SEQUENCE_EDITOR: 'true', LC_ALL: 'C', LANG: 'C', }, stdio: ['pipe', 'pipe', 'pipe'], }); } catch (errorArg) { releasePermit(); throw errorArg; } this.unconfirmedGitChildren.add(child); const processGroupId = process.platform !== 'win32' && child.pid ? child.pid : undefined; if (processGroupId !== undefined) this.retainedGitProcessGroups.add(processGroupId); const stdout: Buffer[] = []; const stderr: Buffer[] = []; let outputBytes = 0; let outputFileBytes = 0; let terminalError: Error | undefined; let processError: Error | undefined; let pumpError: Error | undefined; let inputPump = Promise.resolve(); let outputPump = Promise.resolve(); let settled = false; let closeConfirmed = false; let killTimer: NodeJS.Timeout | undefined; let rejectCommand: ((error: Error) => void) | undefined; const currentResult = (exitCodeArg = 1): IGitResult => ({ stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr), exitCode: exitCodeArg, }); const commandPromise = new Promise((resolve, reject) => { rejectCommand = (errorArg) => { if (settled) return; settled = true; reject(new GitProcessError(errorArg.message, currentResult())); }; const terminate = (errorArg: Error): void => { if (settled || terminalError) return; terminalError = errorArg; if (processGroupId !== undefined) signalProcessGroup(processGroupId, 'SIGTERM'); else child.kill('SIGTERM'); killTimer = setTimeout(() => { if (processGroupId !== undefined) signalProcessGroup(processGroupId, 'SIGKILL'); else child.kill('SIGKILL'); void this.confirmTerminatedGitChildClose(child, () => closeConfirmed) .then((outcome) => { if (outcome === 'closed') return; if (outcome === 'exited') { // The process is gone but its close event is still pending, for // example because a descendant inherited the stdio pipes or the // event loop was busy. The child stays tracked until close confirms // and the engine remains usable. rejectCommand?.(errorArg); return; } const cleanupError = new Error( `${errorArg.message} Git child close remained unconfirmed after SIGKILL.`, ); this.sealForGitProcessCleanup(cleanupError); rejectCommand?.(cleanupError); }) .catch((confirmationErrorArg: unknown) => { // Liveness probing failed; treat the child as unconfirmed rather // than leaving the command pending or the rejection unhandled. const message = confirmationErrorArg instanceof Error ? confirmationErrorArg.message : String(confirmationErrorArg); const cleanupError = new Error( `${errorArg.message} Git child close confirmation failed: ${message}`, ); this.sealForGitProcessCleanup(cleanupError); rejectCommand?.(cleanupError); }); }, commandTerminationGraceMs); killTimer.unref(); }; const collect = (targetArg: Buffer[], chunkArg: Buffer | string): void => { if (terminalError) return; const chunk = Buffer.isBuffer(chunkArg) ? chunkArg : Buffer.from(chunkArg); outputBytes += chunk.byteLength; if (outputBytes > maximumOutputBytes) { terminate(new Error('The Git command exceeded its output limit.')); } else { targetArg.push(chunk); } }; const failPump = (errorArg: unknown): void => { const error = errorArg instanceof Error ? errorArg : new Error(String(errorArg)); pumpError ??= error; terminate(error); }; if (optionsArg.outputFile) { outputPump = (async () => { for await (const value of child.stdout) { const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value); const nextBytes = addSafeLimit(outputFileBytes, chunk.byteLength); if (nextBytes > optionsArg.outputFile!.maximumBytes) { throw new Error('The Git command exceeded its file-output limit.'); } await optionsArg.outputFile!.beforeWrite?.(nextBytes, chunk); await this.writeFileHandleChunk( optionsArg.outputFile!.handle, chunk, outputFileBytes, ); outputFileBytes = nextBytes; } })().catch(failPump); } else { child.stdout.on('data', (chunk) => collect(stdout, chunk)); } child.stderr.on('data', (chunk) => collect(stderr, chunk)); child.stdin.on('error', () => undefined); const abort = () => terminate( optionsArg.signal?.reason instanceof Error ? optionsArg.signal.reason : new DOMException('The Git command was aborted.', 'AbortError'), ); optionsArg.signal?.addEventListener('abort', abort, { once: true }); if (optionsArg.signal?.aborted) abort(); const timeout = setTimeout( () => terminate(new DOMException('The Git command timed out.', 'TimeoutError')), timeoutMs, ); timeout.unref(); const cleanupAdmission = (): void => { clearTimeout(timeout); if (killTimer) clearTimeout(killTimer); optionsArg.signal?.removeEventListener('abort', abort); }; child.once('error', (errorArg) => { processError = errorArg; terminate(errorArg); }); child.once('close', (exitCode, signal) => { closeConfirmed = true; this.unconfirmedGitChildren.delete(child); cleanupAdmission(); void (async () => { let groupError: Error | undefined; if (processGroupId !== undefined) { await this.reapGitProcessGroup(processGroupId).catch((errorArg) => { groupError = errorArg instanceof Error ? errorArg : new Error(String(errorArg)); this.sealForGitProcessCleanup(groupError); }); } await Promise.all([inputPump, outputPump]); if (settled) return; settled = true; const result = currentResult(exitCode ?? (signal ? 128 : 1)); const failure = terminalError ?? processError ?? pumpError ?? groupError; if (failure) { reject(new GitProcessError(failure.message, result)); return; } if (!(optionsArg.acceptedExitCodes ?? [0]).includes(result.exitCode)) { reject(new GitProcessError( `Git exited with code ${result.exitCode}: ${result.stderr.toString('utf8').trim()}`, result, )); return; } resolve(result); })().catch((errorArg) => { if (settled) return; settled = true; const error = errorArg instanceof Error ? errorArg : new Error(String(errorArg)); reject(new GitProcessError(error.message, currentResult())); }); }); if (optionsArg.inputFile) { inputPump = (async () => { const expected = optionsArg.inputFile!.stat; let position = 0; const buffer = Buffer.allocUnsafe(64 * 1024); while (position < expected.size) { optionsArg.signal?.throwIfAborted(); const { bytesRead } = await optionsArg.inputFile!.handle.read( buffer, 0, Math.min(buffer.byteLength, expected.size - position), position, ); if (bytesRead === 0) throw new Error('Git file input ended before its expected size.'); const chunk = buffer.subarray(0, bytesRead); await new Promise((resolve, reject) => { child.stdin.write(chunk, (errorArg) => { if (errorArg) reject(errorArg); else resolve(); }); }); position += bytesRead; } const after = await optionsArg.inputFile!.handle.stat(); if ( position !== expected.size || after.dev !== expected.dev || after.ino !== expected.ino || after.size !== expected.size || !after.isFile() ) throw new Error('Git file input changed while it was streamed.'); await new Promise((resolve, reject) => { const onError = (errorArg: Error): void => { child.stdin.off('error', onError); reject(errorArg); }; child.stdin.once('error', onError); child.stdin.end(() => { child.stdin.off('error', onError); resolve(); }); }); })().catch(failPump); } else if (optionsArg.input === undefined) child.stdin.end(); else child.stdin.end(optionsArg.input); }); this.activeGitCommands.add(commandPromise); try { return await commandPromise; } finally { this.activeGitCommands.delete(commandPromise); releasePermit(); } } /** * Waits a bounded time for a SIGKILLed Git child to confirm close, then * judges liveness. Pending exit and close events are given a chance to run * first because a busy event loop delivers expired timers before the child's * I/O callbacks. Only a child that is still alive after SIGKILL is a cleanup * failure that must seal the engine. */ private async confirmTerminatedGitChildClose( childArg: plugins.childProcess.ChildProcess, closeConfirmedArg: () => boolean, ): Promise<'closed' | 'exited' | 'alive'> { if (await waitFor(() => !closeConfirmedArg(), this.limits.commandCloseConfirmationMs)) { return 'closed'; } for (let attempt = 0; attempt < 2; attempt += 1) { await new Promise((resolve) => setImmediate(resolve)); if (closeConfirmedArg()) return 'closed'; } if (childArg.exitCode !== null || childArg.signalCode !== null) return 'exited'; if (childArg.pid === undefined || !isProcessAlive(childArg.pid)) return 'exited'; return 'alive'; } private async reapGitProcessGroup(processGroupIdArg: number): Promise { if (process.platform === 'win32') return; if (!isProcessGroupAlive(processGroupIdArg)) { this.retainedGitProcessGroups.delete(processGroupIdArg); return; } signalProcessGroup(processGroupIdArg, 'SIGTERM'); let exited = await waitFor( () => isProcessGroupAlive(processGroupIdArg), commandTerminationGraceMs, ); if (!exited) { signalProcessGroup(processGroupIdArg, 'SIGKILL'); exited = await waitFor( () => isProcessGroupAlive(processGroupIdArg), this.limits.commandCloseConfirmationMs, ); } if (!exited) throw new Error('An owned Git process-group member survived SIGKILL.'); this.retainedGitProcessGroups.delete(processGroupIdArg); } private async reapRetainedGitProcessGroups(): Promise { const results = await Promise.allSettled( [...this.retainedGitProcessGroups].map((processGroupId) => ( this.reapGitProcessGroup(processGroupId) )), ); const errors = results .filter((result): result is PromiseRejectedResult => result.status === 'rejected') .map((result) => result.reason); if (errors.length > 0) { throw new AggregateError(errors, 'Owned Git process-group cleanup was incomplete.'); } } } export class ControllerGitReversionManager extends ControllerGitReversion {}