run402 4.44.0 → 4.45.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -200,6 +200,146 @@ export function evolveRetentionRoots(previous, options) {
200
200
  fail("REF_STATE_LIMIT_EXCEEDED", `${roots.length} retention roots exceed the ${GITVAULT_MAX_RETENTION_ROOT_ENTRIES} bound`, "evolving retention roots", { roots: roots.length });
201
201
  return roots;
202
202
  }
203
+ // ─── clone-installs-retained-refs (D1-D5): local refs/r402/retain/* bookkeeping ──
204
+ /**
205
+ * The client-local, protocol-owned namespace (design D4). Distinct from the
206
+ * VAULT-side `refs/run402/*` (e.g. `GITVAULT_DEPLOY_REF`) that rides the wire
207
+ * as part of `ref_state` — `refs/r402/*` never rides the wire at all; it is
208
+ * written directly into the local `.git` by the materializer and reconciled
209
+ * on every later fetch/fsck. A push naming any ref under it is refused by the
210
+ * remote helper before a transaction is ever built (see
211
+ * `git-remote-run402.mjs`'s `partitionProtectedRefPushes`) — this constant is
212
+ * the ONE place the namespace string is spelled, shared by both sides.
213
+ */
214
+ export const GITVAULT_R402_REF_NAMESPACE = "refs/r402/";
215
+ /** Where a retained (branch-unreachable) deploy-capture tip gets its local ref (design D1/D2). */
216
+ export const GITVAULT_RETAIN_REF_PREFIX = `${GITVAULT_R402_REF_NAMESPACE}retain/`;
217
+ /**
218
+ * D1's ref-identity choice, recorded here because the design doc's own
219
+ * assumption did not hold: `GitvaultRetentionRoot` carries no per-capture
220
+ * stable id (only `{ref, oid, dropped_at_generation}`) — the capture id that
221
+ * DOES exist (`GitvaultHead.capture_binding.capture_id`) lives on the head
222
+ * that INTRODUCED a tip onto a canonical ref, not on the retention-root entry
223
+ * recording its later displacement, and correlating the two would require
224
+ * walking the chain further back than materialization already reads (D2
225
+ * forbids new reads here). The commit oid itself is already in hand, is
226
+ * content-addressed (so it is exactly as stable as a capture id — neither
227
+ * ever changes for the same history), and needs no correlation at all — the
228
+ * ref name IS the tip's own identity.
229
+ */
230
+ export function gitvaultRetainedRefName(oid) {
231
+ return `${GITVAULT_RETAIN_REF_PREFIX}${oid}`;
232
+ }
233
+ /** `git for-each-ref --format='%(objectname) %(refname)' <prefix>` → `Map<refname, oid>`. Namespace-scoped by construction (the prefix argv). */
234
+ async function listRefsUnderPrefix(repoDir, prefix) {
235
+ const r = await hardenedGit(repoDir, ["for-each-ref", "--format=%(objectname) %(refname)", prefix]);
236
+ const out = new Map();
237
+ for (const raw of r.lines()) {
238
+ const line = raw.trim();
239
+ if (!line)
240
+ continue;
241
+ const sp = line.indexOf(" ");
242
+ if (sp === -1)
243
+ continue;
244
+ out.set(line.slice(sp + 1), line.slice(0, sp));
245
+ }
246
+ return out;
247
+ }
248
+ /**
249
+ * D2: install/remove local `refs/r402/retain/<oid>` refs so every retained
250
+ * (branch-unreachable) tip the vault's materialized retention roots name is
251
+ * locally referenced — the git-ecosystem `refs/pull/*` precedent, so a fresh
252
+ * `git fsck` is silent and `git for-each-ref refs/r402/` names what is
253
+ * retained and why (D6).
254
+ *
255
+ * Skips a root tip already reachable from a canonical ref (`state.refs`) or
256
+ * the HEAD target when detached — no redundant refs (D2). Reconciliation is
257
+ * namespace-scoped: only `refs/r402/retain/*` is ever read, written, or
258
+ * deleted; nothing else is touched, even when other bookkeeping under
259
+ * `refs/r402/*` exists.
260
+ *
261
+ * D3 — warn, never fail: driven from a SINGLE try/catch around the whole
262
+ * operation (list existing → compute the desired set → one atomic
263
+ * `update-ref --stdin` transaction for every create/update/delete). Any
264
+ * failure anywhere in that sequence returns a `warning` string and touches
265
+ * nothing further; it never throws, so a clone/fetch/fsck calling this can
266
+ * never fail on it. Called only when `repoDir` is an actual git repository —
267
+ * `repos fsck` addresses a vault by `repo_id`/`project_id` alone as often as
268
+ * by a local checkout, and "no local repo here" is a normal, silent no-op,
269
+ * never a warning.
270
+ */
271
+ export async function reconcileRetainedTipRefs(repoDir, state) {
272
+ const empty = (warning = null) => ({ written: [], deleted: [], retained_count: 0, warning });
273
+ let isRepo;
274
+ try {
275
+ const probe = await hardenedGit(repoDir, ["rev-parse", "--git-dir"], { okStatuses: [128] });
276
+ isRepo = probe.status === 0;
277
+ }
278
+ catch {
279
+ return empty(); // no repository here (or it vanished) — nothing to reconcile, not a failure
280
+ }
281
+ if (!isRepo)
282
+ return empty();
283
+ try {
284
+ // Reachability basis = refs git actually WRITES locally on clone/fetch
285
+ // (refs/heads/*, refs/tags/*, plus a detached HEAD). A vault-canonical
286
+ // protocol ref (refs/run402/*) exists only in the vault's ref map — git's
287
+ // clone refspec never materializes it as a local ref, so its tip would
288
+ // dangle locally exactly like a displaced retention root. Its tip
289
+ // therefore joins the candidate set instead of the reachability basis
290
+ // (live-acceptance catch: the current deploy-capture tip dangled).
291
+ const locallyWritten = [];
292
+ const protocolTips = [];
293
+ for (const [ref, oid] of Object.entries(state.refs)) {
294
+ if (ref.startsWith("refs/heads/") || ref.startsWith("refs/tags/"))
295
+ locallyWritten.push(oid);
296
+ else
297
+ protocolTips.push(oid);
298
+ }
299
+ const reachableTips = [...new Set(locallyWritten)];
300
+ if (state.head_target.kind === "detached")
301
+ reachableTips.push(state.head_target.oid);
302
+ const candidateOids = [...new Set([...state.roots.map((r) => r.oid), ...protocolTips])].sort();
303
+ const retainedOids = [];
304
+ for (const oid of candidateOids) {
305
+ if (!(await hasObject(repoDir, oid)))
306
+ continue; // not present locally — nothing to reference, not a failure
307
+ let reachable = false;
308
+ for (const tip of reachableTips) {
309
+ if ((await hasObject(repoDir, tip)) && (await isAncestor(repoDir, oid, tip))) {
310
+ reachable = true;
311
+ break;
312
+ }
313
+ }
314
+ if (!reachable)
315
+ retainedOids.push(oid);
316
+ }
317
+ const existing = await listRefsUnderPrefix(repoDir, GITVAULT_RETAIN_REF_PREFIX);
318
+ const desired = new Map(retainedOids.map((oid) => [gitvaultRetainedRefName(oid), oid]));
319
+ const toWrite = [];
320
+ for (const [ref, oid] of desired)
321
+ if (existing.get(ref) !== oid)
322
+ toWrite.push([ref, oid]);
323
+ const toDelete = [...existing.keys()].filter((ref) => !desired.has(ref));
324
+ if (toWrite.length === 0 && toDelete.length === 0) {
325
+ return { written: [], deleted: [], retained_count: retainedOids.length, warning: null };
326
+ }
327
+ // ONE `update-ref --stdin` transaction for every create/update/delete —
328
+ // git applies the whole batch atomically, so a mid-batch failure leaves
329
+ // the namespace exactly as it was before this call (D3's "degrades to
330
+ // exactly today's behavior", not a half-reconciled namespace).
331
+ const lines = [];
332
+ for (const [ref, oid] of toWrite)
333
+ lines.push(`update ${ref} ${oid}\n`);
334
+ for (const ref of toDelete)
335
+ lines.push(`delete ${ref}\n`);
336
+ await hardenedGit(repoDir, ["update-ref", "--stdin"], { input: lines.join("") });
337
+ return { written: toWrite.map(([ref]) => ref), deleted: toDelete, retained_count: retainedOids.length, warning: null };
338
+ }
339
+ catch (e) {
340
+ return empty(`refs/r402/retain bookkeeping failed: ${e instanceof Error ? e.message : String(e)}`);
341
+ }
342
+ }
203
343
  /** Validate a listing request before it is sent (the request schema, D186). */
204
344
  export function validateHeadsListingRequest(request) {
205
345
  if (!GITVAULT_HEX16_RE.test(request.after_generation))
@@ -2186,8 +2326,10 @@ export class GitvaultVault {
2186
2326
  */
2187
2327
  async restoreObjectsInto(targetRepoDir) {
2188
2328
  const newest = await this.materialize();
2189
- if (!newest.head)
2190
- return { refs: {}, head_target: newest.head_target, generation: newest.generation };
2329
+ if (!newest.head) {
2330
+ const retained_refs = await reconcileRetainedTipRefs(targetRepoDir, { refs: {}, roots: [], head_target: newest.head_target });
2331
+ return { refs: {}, head_target: newest.head_target, generation: newest.generation, retained_refs };
2332
+ }
2191
2333
  const writerKey = newest.genesis.creator_signing_pubkey;
2192
2334
  // walk back to the newest checkpoint-bearing head
2193
2335
  const heads = [];
@@ -2238,7 +2380,12 @@ export class GitvaultVault {
2238
2380
  if (!(await hasObject(targetRepoDir, t)))
2239
2381
  fail("CHAIN_UNUSABLE", `covered tip ${t} does not resolve after restore`, "restoring gitvault objects", { oid: t });
2240
2382
  }
2241
- return { refs: newest.refs, head_target: newest.head_target, generation: newest.generation };
2383
+ // clone-installs-retained-refs (D2): every retained tip just restored
2384
+ // above is now present locally — install/reconcile its refs/r402/retain/*
2385
+ // ref so `git fsck` is silent. Runs AFTER coverage verification so a
2386
+ // reconcile never references an object the restore itself failed to land.
2387
+ const retained_refs = await reconcileRetainedTipRefs(targetRepoDir, { refs: newest.refs, roots: newest.roots, head_target: newest.head_target });
2388
+ return { refs: newest.refs, head_target: newest.head_target, generation: newest.generation, retained_refs };
2242
2389
  }
2243
2390
  }
2244
2391
  /** §4.7 cross-field equality: covers_through agree; the claim set's ordered pack ids/hashes/sizes/total equal the manifest's (shared stored fields only). */