OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / services / ref-diff.js

68 lines · javascript ref:
/**
 * ref-diff — compare a repo's ref-VIEW (the replicated autobase source of
 * truth) against its bare repo on DISK, and name the disagreements.
 *
 * Pure: no I/O, no network. The `repo-refs` handler supplies both maps.
 *
 * WHY THIS EXISTS. Until 2026-08-08 nothing in pear-git-v3 could show the view's
 * refs at all — `pgh repo branches` goes to `syncServer.getBranches()`, which
 * reads DISK. That invisibility produced a concrete failure: a `release` ref was
 * deleted on the origin and believed to have propagated fleet-wide, when for 3 of
 * 5 repos the deletion silently never entered the view (`gipPublishRef` returns
 * without a word when the tracker is not writable, or when the view never carried
 * the ref) — while `ref-update-observed` still answered `ok: true`.
 *
 * The four states are the whole point; each one means something operationally:
 *
 *   in-sync    view and disk agree. Nothing to do.
 *   divergent  both have it, at different oids. The view wins: RefReconciler
 *              rolls disk BACK to the view's oid (that is how a locally-accepted
 *              but globally-non-FF push gets undone).
 *   viewOnly   the view carries it, disk does not. RefReconciler will materialize
 *              it — so deleting it from disk alone does NOT stick.
 *   diskOnly   disk carries it, the view does not. THE RESURRECTION HAZARD:
 *              ref-publish-reconciler reads this as "disk is ahead" and
 *              re-announces, which undoes a deletion made elsewhere. It is also
 *              what an out-of-band ref (never propagated) looks like, so the two
 *              cases are indistinguishable from this side alone — which is
 *              exactly the ambiguity that follows from deleting a key rather than
 *              tombstoning it.
 */

/** @typedef {{ ref: string, view: string|null, disk: string|null, state: string }} RefRow */

export const IN_SYNC = 'in-sync'
export const DIVERGENT = 'divergent'
export const VIEW_ONLY = 'viewOnly'
export const DISK_ONLY = 'diskOnly'

/**
 * @param {Map<string,string>|Iterable<[string,string]>} viewRefs  ref -> oid
 * @param {Map<string,string>|Iterable<[string,string]>} diskRefs  ref -> oid
 * @returns {RefRow[]} sorted by ref name
 */
export function classifyRefs (viewRefs, diskRefs) {
  const view = viewRefs instanceof Map ? viewRefs : new Map(viewRefs || [])
  const disk = diskRefs instanceof Map ? diskRefs : new Map(diskRefs || [])

  const rows = []
  for (const ref of new Set([...view.keys(), ...disk.keys()])) {
    const v = view.get(ref) || null
    const d = disk.get(ref) || null
    let state
    if (v && d) state = v === d ? IN_SYNC : DIVERGENT
    else if (v) state = VIEW_ONLY
    else state = DISK_ONLY
    rows.push({ ref, view: v, disk: d, state })
  }
  return rows.sort((a, b) => (a.ref < b.ref ? -1 : a.ref > b.ref ? 1 : 0))
}

/**
 * Rows worth an operator's attention — everything except in-sync.
 * @param {RefRow[]} rows
 */
export function refDisagreements (rows) {
  return rows.filter((r) => r.state !== IN_SYNC)
}