OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / handlers / repos.js

1153 lines · javascript ref:
/**
 * Repo browser + git smart HTTP handlers.
 *
 * Delegates to hyper-sync's browser methods (listFiles, readFile,
 * getCommits, getDiff, etc.) which shell out to git commands.
 */

import { spawn } from '#node:child_process'
import fs from '#node:fs'
import { join } from '#node:path'

import b4a from 'b4a'

import { requireWriter, requireAdmin, extractIdentity } from '../auth/check.js'
import { canPushGit, resolveGitReader } from '../auth/git-acl.js'
import { PUBLIC_MARKER, ensureExportMarker } from '../repo-access.js'
import { resolveToken } from '../auth/access-token.js'
import { recordPush } from '../services/push-index.js'
import { encodeB64, formatUrl } from '../services/follow-payload.js'
import { OP_CREATED, OP_RETIRED } from '../services/retired-repos.js'
import { repoNameError } from '../repo-name.js'
import { assertValidRepoName } from '../repo-name.js'

/**
 * Extract a personal access token from an HTTP Basic header. Git's credential
 * helper sends `Authorization: Basic base64("<user>:<token>")` — the token is
 * normally the password, but some clients put it in the username field, so we
 * accept either. Returns the candidate string (validated downstream by
 * resolveToken) or null when no Basic header is present.
 */
export function parseBasicToken (req) {
  const auth = req && req.headers && req.headers.authorization
  if (!auth || !auth.startsWith('Basic ')) return null
  let decoded
  try {
    decoded = b4a.toString(b4a.from(auth.slice(6).trim(), 'base64'), 'utf8')
  } catch {
    return null
  }
  const idx = decoded.indexOf(':')
  const user = idx === -1 ? decoded : decoded.slice(0, idx)
  const pass = idx === -1 ? '' : decoded.slice(idx + 1)
  if (pass && pass.indexOf('pgv3_') === 0) return pass
  if (user && user.indexOf('pgv3_') === 0) return user
  return pass || user || null
}

/**
 * Resolve the pusher's canonical userId from the SAME credentials allowPush
 * accepts (session, then PAT). Used to attribute a successful push in the
 * push-index for the public profile "repos pushed" list. Returns null when
 * unattributable — attribution is best-effort and never gates the push.
 *
 * The two credential paths resolve to DIFFERENT shapes, and conflating them
 * silently broke PAT attribution: getUserByIdentity returns a User (has
 * .userId), but resolveToken returns { user, scope, identityId } — reading
 * .userId off that wrapper is always undefined, so every token-authenticated
 * push went unrecorded (push-index empty, profile "Repos pushed (0)") even
 * though the push itself succeeded. Exported as a factory so the mapping is
 * unit-testable without standing up an HTTP server.
 */
export function makeResolvePusher (svc) {
  return async function resolvePusher (req) {
    const session = svc.sessions ? extractIdentity(req, svc.sessions) : null
    if (session && session.publicKey) {
      const user = await svc.identityRegistry.getUserByIdentity(session.publicKey).catch(() => null)
      if (user && user.userId) return user.userId
    }
    const token = parseBasicToken(req)
    if (token) {
      const resolved = await resolveToken(svc.identityRegistry, token).catch(() => null)
      if (resolved && resolved.user && resolved.user.userId) return resolved.user.userId
    }
    return null
  }
}

export function repoHandlers (svc) {
  const sync = () => svc._sync
  const registry = () => svc.identityRegistry

  function ensureSync () {
    const s = sync()
    if (!s) throw new Error('sync service not running')
    return s
  }

  /**
   * Name check for the RETIREMENT path, which is cleanup — not creation.
   *
   * assertValidRepoName exists to stop malformed names being CREATED. Applying
   * it unchanged to retire meant a malformed repo could never be REMOVED: the
   * fleet carries real repos called `--help` (rejected: starts with "-") and
   * `undefined` (rejected: stringified sentinel), and both were untombstonable
   * — the guard against making them also protected them.
   *
   * So: a malformed name is accepted ONLY if that repo actually exists here.
   * That cleans up real junk without letting anyone append tombstones for
   * names that were never repos, which would just pollute the event log.
   *
   * Path separators, traversal and control characters are refused regardless —
   * the name reaches the filesystem, and "it already exists" is not a reason to
   * relax containment.
   */
  const assertRetirableName = (name) => {
    if (typeof name !== 'string' || !name) {
      throw new Error('retire requires { name }')
    }
    if (name.includes('/') || name.includes('\\') || name.includes('..') ||
        // eslint-disable-next-line no-control-regex
        /[\u0000-\u001f\u007f]/.test(name)) {
      throw new Error('unsafe repo name: ' + JSON.stringify(name))
    }
    if (!repoNameError(name)) return name // well-formed — nothing to argue about

    const reposDir = svc._reposDir
    const exists = !!reposDir && fs.existsSync(join(reposDir, name + '.git'))
    if (!exists) {
      throw new Error('invalid repo name: ' + JSON.stringify(name) +
        ' — malformed names may only be retired when the repo actually exists here')
    }
    return name
  }

  return [
    // list-repos and repo-info already registered by syncHandlers — browser adds these:

    {
      // get-repo: name → { punchUrl, info }. The pgh CLI's `clone`
      // command relies on this RPC; before 2026-05-18 the call was
      // unimplemented and clone.js fell through to a plain `git clone`
      // with no daemon coordination. The daemon is the authority on its
      // own punch:// URL — serverKey + repo name → URL.
      name: 'get-repo',
      fn: async (ctx, params = {}) => {
        if (!params.name) throw new Error('get-repo requires { name }')
        const s = ensureSync()
        const info = s.info(params.name)
        if (!info) throw new Error('repo not found: ' + params.name)

        // READ GATE. This RPC hands out the punch:// URL and the on-disk path,
        // which is enough to clone — so it is a read surface, not metadata.
        // UDS callers are the local operator and pass through repo-access's
        // local-transport allowance.
        if (svc.repoAccess) {
          let d = null
          try {
            d = await svc.repoAccess.async(params.name, {
              transport: ctx && ctx.transport ? ctx.transport : 'http',
              identity: ctx && ctx.identity ? ctx.identity : null
            })
          } catch { d = null }
          if (d && d.wouldDeny) {
            if (!d.enforced) {
              // eslint-disable-next-line no-console
              console.log('[read-gate] OBSERVE would-deny get-repo:' + params.name + ' (' + d.reason + ')')
            } else {
              // Same wording as the not-found throw above — do not confirm that
              // a private repo exists.
              throw new Error('repo not found: ' + params.name)
            }
          }
        }
        const serverKeyHex = svc.identity && svc.identity.publicKeyHex
        return {
          name: params.name,
          punchUrl: serverKeyHex ? ('punch://' + serverKeyHex + '/' + params.name) : null,
          info
        }
      }
    },

    {
      // repo-follow-payload: emits the follow-payload + URL for a
      // local repo, so a non-mirror peer can read-only replicate it
      // via `pgh repo follow`. Auth model mirrors dump-keys — UDS +
      // swarm allowed, HTTP blocked. The payload itself ships no
      // secrets (only public keys and bootstrap roots) but we still
      // gate on transport so the web layer doesn't accidentally leak
      // them without explicit caller intent.
      name: 'repo-follow-payload',
      fn: async (ctx, params = {}) => {
        if (ctx.transport === 'http') {
          throw Object.assign(new Error('repo-follow-payload not available over HTTP'), {
            code: 'EAUTH', status: 403
          })
        }
        if (!params.name) throw new Error('repo-follow-payload requires { name }')
        const s = ensureSync()
        const info = s.info(params.name)
        if (!info) throw new Error('repo not found: ' + params.name)
        if (!svc.identity || !svc.identity.publicKeyHex) {
          throw new Error('daemon identity not initialized')
        }
        const sourceServerKey = svc.identity.publicKeyHex

        // Tracker key: open (or get cached) the per-repo HyperIssues to
        // emit its bootstrap key. Same path repo-info / issues UIs take —
        // safe to call on demand.
        let issueBootstrapKey = null
        if (svc.issueRegistry) {
          try {
            const tracker = await svc.issueRegistry.getForRepo(params.name)
            issueBootstrapKey = tracker.keyHex
          } catch (err) {
            // ETRACKER_NOT_READY on mirrors before bootstrap delivery is
            // not fatal — the payload still works without it, the
            // follower discovers the key via RepoEventLog entries.
            if (err && err.code !== 'ETRACKER_NOT_READY') throw err
          }
        }
        const repoEventLogKey = svc.bootstrapKeys && svc.bootstrapKeys.repoEventLog
          ? svc.bootstrapKeys.repoEventLog
          : null

        const payloadFields = {
          kind: 'repo',
          sourceServerKey,
          name: params.name,
          issueBootstrapKey,
          repoEventLogKey
        }
        return {
          payload: encodeB64(payloadFields),
          url: formatUrl(payloadFields),
          sourceServerKey,
          name: params.name,
          issueBootstrapKey,
          repoEventLogKey
        }
      }
    },

    {
      name: 'repo-files',
      fn: async (ctx, params = {}) => {
        if (!params.name) throw new Error('repo-files requires { name }')
        const s = ensureSync()
        return s.listFiles(params.name, params.ref, params.path)
      }
    },

    {
      name: 'repo-file',
      fn: async (ctx, params = {}) => {
        if (!params.name || !params.path) throw new Error('repo-file requires { name, path }')
        const s = ensureSync()
        const content = await s.readFile(params.name, params.path, params.ref)
        const binary = await s.isBinary(params.name, params.path, params.ref)
        return { path: params.path, content: binary ? null : content, binary }
      }
    },

    {
      name: 'repo-commits',
      fn: async (ctx, params = {}) => {
        if (!params.name) throw new Error('repo-commits requires { name }')
        const s = ensureSync()
        return s.getCommits(params.name, params.ref, params.limit)
      }
    },

    {
      name: 'repo-commit',
      fn: async (ctx, params = {}) => {
        if (!params.name || !params.hash) throw new Error('repo-commit requires { name, hash }')
        const s = ensureSync()
        const commit = await s.getCommit(params.name, params.hash)
        if (!commit) throw new Error('commit not found')
        const diff = await s.getCommitDiff(params.name, params.hash)
        return { ...commit, diff }
      }
    },

    {
      name: 'repo-branches',
      fn: async (ctx, params = {}) => {
        if (!params.name) throw new Error('repo-branches requires { name }')
        const s = ensureSync()
        return s.getBranches(params.name)
      }
    },

    {
      name: 'repo-tags',
      fn: async (ctx, params = {}) => {
        if (!params.name) throw new Error('repo-tags requires { name }')
        const s = ensureSync()
        return s.getTags(params.name)
      }
    },

    {
      name: 'repo-readme',
      fn: async (ctx, params = {}) => {
        if (!params.name) throw new Error('repo-readme requires { name }')
        const s = ensureSync()
        return s.getReadme(params.name, params.ref)
      }
    },

    {
      name: 'repo-diff',
      fn: async (ctx, params = {}) => {
        if (!params.name || !params.from) throw new Error('repo-diff requires { name, from }')
        const s = ensureSync()
        return s.getDiff(params.name, params.from, params.to)
      }
    },

    {
      name: 'repo-search',
      fn: async (ctx, params = {}) => {
        if (!params.name || !params.query) throw new Error('repo-search requires { name, query }')
        const s = ensureSync()
        return s.searchFiles(params.name, params.query, params.ref)
      }
    },

    {
      // Autocomplete: case-insensitive substring match on repo name +
      // description. Returns up to `limit` results (default 8), sorted
      // with name matches ranked higher than description matches.
      name: 'search-all-repos',
      fn: async (ctx, params = {}) => {
        const q = String(params.query || '').toLowerCase().trim()
        const limit = Math.min(parseInt(params.limit, 10) || 8, 50)
        if (q.length < 2) return { repos: [], query: q, total: 0 }

        const s = sync()
        if (!s) return { repos: [], query: q, total: 0 }

        // Rank on the CHEAP fields first. This fires on a 150ms debounce from
        // the nav search box, and the full enrichRepos() it used to call walks
        // git over every repo — the same full-fleet pass as the /repos landing
        // page, on a keystroke path. Name and branch count are already in
        // status(); description is a small file read (free when cached).
        const { describeRepos, enrichRepos } = await import('../services/repo-activity.js')
        const candidates = await describeRepos(s.status(), s.repos)

        const scored = []
        for (const r of candidates) {
          const name = String(r.name || '').toLowerCase()
          const desc = String(r.description || '').toLowerCase()
          const nameHit = name.includes(q)
          const descHit = desc.includes(q)
          if (!nameHit && !descHit) continue
          scored.push({ base: r, relevance: nameHit ? 2 : 1 })
        }

        scored.sort((a, b) => {
          if (b.relevance !== a.relevance) return b.relevance - a.relevance
          return a.base.name.localeCompare(b.base.name)
        })

        // Cap BEFORE enriching: `commits` is the only field that costs a git
        // walk, and only the rows actually returned need it. Ranking is
        // unaffected — it never depended on commit count.
        const capped = scored.slice(0, limit)
        if (capped.length === 0) return { repos: [], query: q, total: 0 }

        const enriched = await enrichRepos({ repos: capped.map((m) => m.base) }, s.repos)

        // enrichRepos preserves input order, so index i lines up with capped[i].
        const repos = enriched.map((r, i) => ({
          name: r.name,
          description: r.description || null,
          commits: r.commits || 0,
          branches: r.branches || 0,
          relevance: capped[i].relevance
        }))

        return { repos, query: q, total: repos.length }
      }
    },

    {
      // Code search via `git grep` per repo. Limits: maxRepos (default 10),
      // limit = total matches cap (default 100). Returns matches grouped
      // by repo for the autocomplete dropdown.
      name: 'search-code-all',
      fn: async (ctx, params = {}) => {
        const q = String(params.query || '').trim()
        const limit = Math.min(parseInt(params.limit, 10) || 100, 500)
        const maxRepos = Math.min(parseInt(params.maxRepos, 10) || 10, 50)
        if (q.length < 2) return { results: [], query: q, totalMatches: 0, reposSearched: 0 }

        const s = sync()
        if (!s) return { results: [], query: q, totalMatches: 0, reposSearched: 0 }

        const repos = [...s.repos.entries()].slice(0, maxRepos)
        const results = []
        let totalMatches = 0

        for (const [name, info] of repos) {
          if (totalMatches >= limit) break
          const out = await execGit(info.path, [
            'grep', '-n', '-I', '-i', '--heading', '--break', '-e', q, 'HEAD'
          ]).catch(() => '')
          if (!out || !out.trim()) continue
          const matches = parseGrepOutput(out, limit - totalMatches)
          if (matches.length === 0) continue
          results.push({ repo: name, matches })
          totalMatches += matches.length
        }

        return {
          results,
          query: q,
          totalMatches,
          reposSearched: repos.length
        }
      }
    },

    // ── Retire / un-retire (tombstones, issue #135) ────────────
    //
    // ⚠ RETIRE DOES NOT DELETE ANYTHING, AND MUST NEVER LEARN HOW.
    // It appends an op:2 tombstone to the replicated RepoEventLog. Every peer
    // folds that into a retired-set and stops listing / serving / pulling /
    // re-creating the repo. Removing the bare repo's BYTES is a manual
    // operator step on each peer — `rm -rf ~/git-repos/<name>.git` by hand.
    // pear-git-v3 has no capability to delete a repo and must not gain one.
    //
    // The tombstone is what makes that manual delete STICK: without it a peer
    // that still announces the repo re-clones it onto the peer that deleted it.
    //
    // ⚠ ROLLOUT: a peer on older code IGNORES op:2 (it does not crash) and
    // keeps announcing. Every peer must run 0.1.178+ BEFORE removing bytes.

    {
      // repo-backfill-visibility — mark every existing repo public, here.
      //
      // Same job as scripts/backfill-export-ok.js, reachable on an INSTALLED
      // daemon. The script only works where the source tree exists, and
      // `pgh.stage.ignore` lists `scripts/`, so it never ships to a peer
      // installed via `pgh app install` — i.e. it is missing on exactly the
      // machines that must run it before enforcement (#138 R1). An operator
      // needs a daemon-side verb, not a file they do not have.
      //
      // Local + per-peer by design (#135), so this must be run on each peer.
      // Idempotent.
      name: 'repo-backfill-visibility',
      fn: async (ctx, params = {}) => {
        await requireAdmin(ctx, registry())
        const reposDir = svc._reposDir
        if (!reposDir) throw new Error('repos dir not configured')

        let entries = []
        try { entries = fs.readdirSync(reposDir) } catch (err) {
          throw new Error('cannot read repos dir: ' + err.message)
        }

        const dryRun = params.dryRun === true
        let marked = 0
        let already = 0
        const names = []

        for (const d of entries) {
          if (!d.endsWith('.git')) continue
          const repoDir = join(reposDir, d)
          try { if (!fs.statSync(repoDir).isDirectory()) continue } catch { continue }

          const name = d.replace(/\.git$/, '')
          if (fs.existsSync(join(repoDir, PUBLIC_MARKER))) { already++; continue }
          if (dryRun) { marked++; names.push(name); continue }
          if (ensureExportMarker(reposDir, name)) { marked++; names.push(name) }
        }

        return {
          reposDir,
          dryRun,
          bareRepos: entries.filter((d) => d.endsWith('.git')).length,
          marked,
          alreadyMarked: already,
          names,
          note: 'The marker is LOCAL — run this on EVERY peer before enabling PEAR_GIT_READ_GATE=enforce.'
        }
      }
    },

    {
      // repo-visibility — read or set whether this peer SERVES a repo.
      //
      // Backed by the pear-git-export-ok marker (forge #138), the same state
      // the read gate consults. LOCAL and not replicated: "do I serve this?"
      // is a per-peer property, so this must be run on each peer — consistent
      // with #135 making byte removal a per-peer operator step.
      //
      // ⚠ NOT SYMMETRIC. public→private is BEST-EFFORT, not a retraction: it
      // stops future serving here, but cannot pull back bundles already in the
      // overlay (#135 — additive, no GC) nor un-tell peers a discoveryKey they
      // already hold. Reported in the response so an operator does not act on
      // a false belief, the same hazard class as #135's "retire is not delete".
      name: 'repo-visibility',
      fn: async (ctx, params = {}) => {
        const reposDir = svc._reposDir
        if (!reposDir) throw new Error('repos dir not configured')
        assertValidRepoName(params.name)

        const repoDir = join(reposDir, params.name + '.git')
        if (!fs.existsSync(repoDir)) throw new Error('repo not found: ' + params.name)
        const marker = join(repoDir, PUBLIC_MARKER)
        const current = () => (fs.existsSync(marker) ? 'public' : 'private')

        // Query is read-only — no admin needed to ask about a repo you can
        // already see. Changing it is an admin action.
        if (params.visibility === undefined || params.visibility === null) {
          return { name: params.name, visibility: current() }
        }

        await requireAdmin(ctx, registry())

        if (params.visibility !== 'public' && params.visibility !== 'private') {
          throw new Error("visibility must be 'public' or 'private'")
        }

        const before = current()
        if (params.visibility === 'public') {
          if (before !== 'public') fs.writeFileSync(marker, '')
          return { name: params.name, visibility: 'public', changed: before !== 'public' }
        }

        if (before === 'public') fs.unlinkSync(marker)
        return {
          name: params.name,
          visibility: 'private',
          changed: before !== 'private',
          warning: 'Best-effort. This peer stops serving ' + params.name +
            ', but bundles already published remain in the overlay (no true deletion, #135) ' +
            'and peers already holding the discoveryKey keep it. Run this on every peer.'
        }
      }
    },

    {
      name: 'retire-repo',
      fn: async (ctx, params = {}) => {
        await requireAdmin(ctx, registry())
        assertRetirableName(params.name)
        const log = svc._repoEventLog
        if (!log) throw new Error('repo-event-log not available')
        // Single-writer by construction: mirrors open the log by key and are
        // read-only replicas, so only the primary can author a tombstone.
        if (!log.writable) {
          const err = new Error('retire-repo must run on the primary — this daemon holds a read-only replica of the repo-event-log')
          err.code = 'ENOT_PRIMARY'
          throw err
        }
        await log.append({ op: OP_RETIRED, name: params.name, head: '' })
        if (svc._retiredRepos) svc._retiredRepos.applyEntry({ op: OP_RETIRED, name: params.name }, log.length - 1)
        return {
          retired: params.name,
          note: 'Tombstone appended. Bytes were NOT removed. Verify every peer shows it retired, then remove ~/git-repos/' + params.name + '.git by hand on each peer.'
        }
      }
    },

    {
      // Reversal: a later op:0 wins by sequence number, so a mistaken retire
      // is undone by re-announcing the repo as created.
      name: 'unretire-repo',
      fn: async (ctx, params = {}) => {
        await requireAdmin(ctx, registry())
        assertRetirableName(params.name)
        const log = svc._repoEventLog
        if (!log) throw new Error('repo-event-log not available')
        if (!log.writable) {
          const err = new Error('unretire-repo must run on the primary')
          err.code = 'ENOT_PRIMARY'
          throw err
        }
        await log.append({ op: OP_CREATED, name: params.name, head: '' })
        if (svc._retiredRepos) svc._retiredRepos.applyEntry({ op: OP_CREATED, name: params.name }, log.length - 1)
        return { unretired: params.name }
      }
    },

    {
      // The propagation check. This is the gate an operator must pass on
      // EVERY peer before removing any bytes.
      name: 'list-retired-repos',
      fn: async () => {
        const set = svc._retiredRepos
        return { retired: set ? set.names() : [], folded: !!set }
      }
    },

    // ── Per-repo-autobase invites / join / enroll (issue #35) ──
    //
    // A repo tracker is already its own autobase, so joining one repo
    // replicates ONLY that repo. Mirrors the org invite handlers
    // (create-org-invite / join-org / enroll-writer / list-org-keys)
    // — peer mode = these handlers; mirror mode = list-repo-keys.

    {
      // Owner: mint a blind-pairing invite for one repo (P2P). A joiner
      // pairs over the swarm and, for contributor+, is addWritered DURING
      // pairing — no HTTP, no separate enroll step.
      name: 'create-repo-invite',
      fn: async (ctx, params = {}) => {
        await requireWriter(ctx, registry())
        if (!params.name) throw new Error('create-repo-invite requires { name }')
        if (!svc.issueRegistry) throw new Error('issue registry not available')
        if (!svc.swarm || !svc.swarm.swarm) throw new Error('swarm not available for pairing')
        const { invitation, descriptor } = await svc.issueRegistry.createRepoInvite(params.name, {
          role: params.role || 'reader',
          swarm: svc.swarm.swarm
        })
        // Persist so the pairing member is re-registered on daemon restart.
        if (svc._pendingPairings && descriptor) svc._pendingPairings.add('repos', params.name, descriptor)
        // Announce this repo room on the shared swarm so a joiner can
        // rendezvous for it by its discoveryKey (Keet activateRoom).
        const tracker = await svc.issueRegistry.getForRepo(params.name)
        if (tracker && typeof svc.activateRoom === 'function') {
          svc.activateRoom(tracker.discoveryKey)
        }
        return { invitation }
      }
    },

    {
      // Joiner: pair into ONLY that repo over the swarm; become a writer
      // (contributor+) DURING pairing. One step, P2P — no enroll-repo-writer.
      name: 'join-repo',
      fn: async (ctx, params = {}) => {
        await requireWriter(ctx, registry())
        if (!params.invitation) throw new Error('join-repo requires { invitation }')
        if (!svc.issueRegistry) throw new Error('issue registry not available')
        if (!svc.swarm || !svc.swarm.swarm) throw new Error('swarm not available for pairing')
        const joined = await svc.issueRegistry.joinRepo(params.invitation, { swarm: svc.swarm.swarm })
        // Join THIS repo room's topic on the shared swarm to rendezvous
        // with the owner for it — keeps peer-mode scoped to just this repo.
        const tracker = await svc.issueRegistry.getForRepo(joined.name)
        if (tracker && typeof svc.activateRoom === 'function') {
          svc.activateRoom(tracker.discoveryKey)
        }
        // gip-overlay: materialize this repo's bare repo from the autobase as
        // refs replicate in (objects fetched via the overlay bundle chain).
        if (svc._refReconcilers) svc._refReconcilers.ensureFor(joined.name).catch(() => {})
        return joined
      }
    },

    {
      // Mirror mode: every repo's tracker bootstrap key, for a sister
      // daemon to mount each → full replica. Admin-gated (full-forge
      // replication capability); peers never call this.
      name: 'list-repo-keys',
      fn: async (ctx, params = {}) => {
        await requireAdmin(ctx, registry())
        if (!svc.issueRegistry) throw new Error('issue registry not available')
        return svc.issueRegistry.listRepoKeys()
      }
    },

    {
      // List the push-authorized writers of a repo (gip authority = the
      // per-repo autobase writer set). Writer-gated visibility.
      name: 'repo-list-writers',
      fn: async (ctx, params = {}) => {
        await requireWriter(ctx, registry())
        if (!params.name) throw new Error('repo-list-writers requires { name }')
        if (!svc.issueRegistry) throw new Error('issue registry not available')
        const tracker = await svc.issueRegistry.getForRepo(params.name)
        return { name: params.name, writers: await tracker.listWriters() }
      }
    },

    {
      // Revoke a writer's push authority on a repo. Admin-gated. After
      // removal the node's future ref/bundle ops are no longer applied; its
      // past applied ops stand; read access is unaffected (reads are open).
      name: 'repo-revoke-writer',
      fn: async (ctx, params = {}) => {
        await requireAdmin(ctx, registry())
        if (!params.name) throw new Error('repo-revoke-writer requires { name }')
        if (!params.userId) throw new Error('repo-revoke-writer requires { userId }')
        if (!svc.issueRegistry) throw new Error('issue registry not available')
        const tracker = await svc.issueRegistry.getForRepo(params.name)
        await tracker.removeWriter(params.userId)
        return { name: params.name, userId: params.userId, revoked: true }
      }
    },

    {
      // READ the per-repo autobase's ref-VIEW — the P2P source of truth for a
      // repo's refs, as distinct from the bare repo on disk.
      //
      // Nothing exposed this before, and its absence caused a real mistake on
      // 2026-08-08: `pgh repo branches` was cited as evidence about the view when
      // it actually reads DISK (syncServer.getBranches), and a ref deletion was
      // then believed to have propagated when for 3 of 5 repos it silently had
      // not. Ref replication cannot be reasoned about without seeing BOTH sides,
      // so this returns both and marks where they disagree.
      name: 'repo-refs',
      fn: async (ctx, params = {}) => {
        if (!params.name) throw new Error('repo-refs requires { name }')
        if (!svc.issueRegistry) throw new Error('issue registry not available')
        const tracker = await svc.issueRegistry.getForRepo(params.name)

        const view = new Map()
        for (const r of await tracker.listRefs()) {
          if (r && r.ref) view.set(r.ref, r.hash || null)
        }

        const disk = new Map()
        if (svc._reposDir) {
          const { listDiskRefs } = await import('../services/ref-publish-reconciler.js')
          for (const [ref, oid] of await listDiskRefs(join(svc._reposDir, params.name + '.git'))) {
            disk.set(ref, oid)
          }
        }

        // Classification lives in services/ref-diff.js so the rule that decides
        // "is this a resurrection hazard" is pure and directly testable.
        const { classifyRefs, refDisagreements } = await import('../services/ref-diff.js')
        const refs = classifyRefs(view, disk)

        return {
          name: params.name,
          // Whether THIS daemon can publish ref changes for this repo at all.
          // gipPublishRef returns SILENTLY when false, so a delete issued here
          // would be a no-op that still answers ok:true — the 2026-08-08 trap.
          writable: tracker.writable,
          trackerKey: tracker.keyHex,
          refs,
          // Pre-filtered for the common operator question: what disagrees?
          disagreements: refDisagreements(refs)
        }
      }
    },

    {
      // Surface THIS daemon's own tracker local key for a repo — the key an
      // owner needs to addWriter this daemon onto the shared per-repo autobase.
      // Read-only (it's just a public key). This is the missing primitive that
      // lets an admin learn a peer's writer key WITHOUT the peer tearing down an
      // existing (e.g. org-grouped, read-only) mount: the admin pulls this key,
      // then calls repo-add-writer. `writable` tells the caller whether this
      // daemon is already a writer on the shared autobase.
      name: 'repo-local-key',
      fn: async (ctx, params = {}) => {
        if (!params.name) throw new Error('repo-local-key requires { name }')
        if (!svc.issueRegistry) throw new Error('issue registry not available')
        const tracker = await svc.issueRegistry.getForRepo(params.name)
        return {
          name: params.name,
          localKey: tracker.localKeyHex,
          trackerKey: tracker.keyHex,
          writable: tracker.writable,
          identity: svc.identity ? svc.identity.publicKeyHex : null
        }
      }
    },

    {
      // Grant a peer push authority on a repo by adding its tracker LOCAL key
      // to the per-repo autobase writer set (gip authority). Admin-gated; the
      // symmetric counterpart to repo-revoke-writer.
      //
      // This is the path to make an ALREADY-mounted tracker writable on a peer
      // (e.g. one mounted read-only via the org-resource path): the peer never
      // re-joins (which would no-op — issue-registry joinRepo short-circuits if
      // the repo is already open). Instead the OWNER addWriters the peer's local
      // key here; once the add-writer op replicates, the peer's own local key is
      // in the writer set and its autobase becomes `writable`, so its ref/bundle
      // ops linearize into the shared view (symmetric P2P writes).
      //
      // role 'owner' = indexer (offline-tolerant co-equal writer),
      // 'writer' = non-indexer writer (default), 'mirror' = read-only metadata.
      name: 'repo-add-writer',
      fn: async (ctx, params = {}) => {
        await requireAdmin(ctx, registry())
        if (!params.name) throw new Error('repo-add-writer requires { name }')
        if (!params.userId) throw new Error('repo-add-writer requires { userId }')
        if (!params.writerKey) {
          throw new Error('repo-add-writer requires { writerKey } (peer tracker local key hex)')
        }
        const role = params.role || 'writer'
        if (!['owner', 'writer', 'mirror'].includes(role)) {
          throw new Error('role must be owner, writer, or mirror')
        }
        if (!svc.issueRegistry) throw new Error('issue registry not available')
        const addedBy = (ctx && ctx.identity && ctx.identity.publicKey) ||
          (ctx && ctx.transport === 'uds' ? 'uds' : null)
        const tracker = await svc.issueRegistry.getForRepo(params.name)
        await tracker.addWriter(params.userId, { writerKey: params.writerKey, role, addedBy })
        return { name: params.name, userId: params.userId, role, added: true }
      }
    },

    {
      // AUTO-ADMISSION (forceResync companion, #3). A peer that re-joined a
      // tracker under a fresh corestore (`repo-resync`) holds a NEW, read-only
      // local key. Instead of an admin manually running repo-add-writer, the peer
      // REQUESTS admission of its own key over the swarm; the owner auto-approves
      // IFF the requester ALREADY holds write authority for the repo
      // (`requireWriter` — the same gate as a git push). This grants NO new trust:
      // it re-admits an already-authorized member's rotated key. Role is FORCED to
      // non-indexer 'writer' — indexer/owner promotion stays a deliberate admin
      // act (no auto-escalation via a self-request). The swarm call is
      // envelope-signed, so ctx.identity is the verified requester.
      name: 'repo-request-writer',
      fn: async (ctx, params = {}) => {
        if (!params.name) throw new Error('repo-request-writer requires { name }')
        if (!params.writerKey) {
          throw new Error('repo-request-writer requires { writerKey } (your tracker local key hex)')
        }
        // Gate: requester must already have write authority (throws otherwise).
        await requireWriter(ctx, registry())
        if (!svc.issueRegistry) throw new Error('issue registry not available')
        const requester = (ctx && ctx.identity && ctx.identity.publicKey) ||
          (ctx && ctx.transport === 'uds' ? 'uds' : null)
        if (!requester) throw new Error('repo-request-writer requires a verified identity')
        const userId = params.userId || requester
        const tracker = await svc.issueRegistry.getForRepo(params.name)
        // Self-request NEVER grants indexer/owner — plain writer only.
        await tracker.addWriter(userId, { writerKey: params.writerKey, role: 'writer', addedBy: requester })
        return { name: params.name, userId, role: 'writer', admitted: true }
      }
    }
  ]
}

/**
 * Pick a best-effort HEAD SHA from a `for-each-ref` snapshot map.
 * Prefers refs/heads/main, then refs/heads/master, then any refs/heads/*.
 * Returns '' if the repo has no branches yet (e.g. bare/empty).
 */
function pickHead (refs) {
  if (!refs || refs.size === 0) return ''
  if (refs.has('refs/heads/main')) return refs.get('refs/heads/main')
  if (refs.has('refs/heads/master')) return refs.get('refs/heads/master')
  for (const [ref, sha] of refs) {
    if (ref.startsWith('refs/heads/')) return sha
  }
  return ''
}

function execGit (repoPath, args) {
  return new Promise((resolve) => {
    const proc = spawn('git', ['-C', repoPath, ...args], {
      stdio: ['ignore', 'pipe', 'ignore']
    })
    const chunks = []
    proc.stdout.on('data', (c) => chunks.push(c))
    proc.on('error', () => resolve(''))
    proc.on('close', () => resolve(Buffer.concat(chunks).toString('utf8')))
  })
}

/**
 * Parse `git grep -n --heading --break` output into {file, line, content}
 * records. The output format is:
 *   HEAD:path/to/file.js
 *   12:matching line content
 *   15:another match
 *   <blank>
 *   HEAD:other/file.js
 *   ...
 */
function parseGrepOutput (output, limit) {
  const matches = []
  let currentFile = null
  for (const line of output.split('\n')) {
    if (matches.length >= limit) break
    if (!line.trim() || line === '--') continue
    const fileMatch = line.match(/^HEAD:(.+)$/)
    if (fileMatch) {
      currentFile = fileMatch[1]
      continue
    }
    const lineMatch = line.match(/^(\d+)([:-])(.*)$/)
    if (lineMatch && currentFile) {
      const [, lineNum, sep, content] = lineMatch
      matches.push({
        file: currentFile,
        line: parseInt(lineNum, 10),
        content: content.trim(),
        isMatch: sep === ':'
      })
    }
  }
  return matches
}

/**
 * Git smart HTTP routes — register directly on the HTTP server.
 * Handles:
 *   GET  /:repo.git/info/refs?service=git-upload-pack
 *   POST /:repo.git/git-upload-pack
 *   POST /:repo.git/git-receive-pack
 */
export function registerGitRoutes (svc) {
  const sync = () => svc._sync
  if (!svc.http || !svc.http.server) return

  const server = svc.http.server

  // Push gate (issue #37, item 2; classic-git-auth Phase 1): git-receive-pack
  // requires write access; reads (git-upload-pack) stay open. Three credential
  // paths, all resolving through the SAME IdentityRegistry user-perm model:
  //   1. session bearer  (browser / signed-envelope) — Bearer header / cookie
  //   2. HTTP Basic      (stock `git push https://…`) — password is a PAT
  //   3. none            → 401 WWW-Authenticate so git prompts for a token
  // Returns true if allowed; otherwise writes the response + returns false.
  async function allowPush (req, res) {
    // 1. Existing session path (browser dashboard).
    const session = svc.sessions ? extractIdentity(req, svc.sessions) : null
    if (session && session.publicKey) {
      const ok = await canPushGit(svc.identityRegistry, {
        transport: 'http',
        identityId: session.publicKey
      })
      if (ok) return true
    }

    // 2. HTTP Basic → personal access token.
    const token = parseBasicToken(req)
    if (token) {
      const ok = await canPushGit(svc.identityRegistry, { transport: 'http', token })
      if (ok) return true
      // Credentials WERE presented but aren't authorized — 403, don't 401-loop.
      res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' })
      res.end('push denied: token invalid, expired, or lacks write access\n')
      return false
    }

    // 3. No usable credentials — challenge so stock git prompts for a token.
    res.writeHead(401, {
      'Content-Type': 'text/plain; charset=utf-8',
      'WWW-Authenticate': 'Basic realm="pear-git", charset="UTF-8"'
    })
    res.end('authentication required: push with a personal access token as the password\n')
    return false
  }

  // Resolve the pusher's canonical userId — see makeResolvePusher above.
  const resolvePusher = makeResolvePusher(svc)

  /**
   * Read gate for the classic-git transports. Consults the shared choke point
   * (svc.repoAccess, lib/repo-access.js) rather than deciding locally, so that
   * one visibility change moves every surface at once.
   *
   * In OBSERVE mode (the default) this logs what it WOULD deny and allows the
   * request — enforcing before a fleet-wide marker backfill would un-serve
   * every existing repo. Returns true if the request may proceed.
   */
  async function allowRead (repoName, res, where, req) {
    const access = svc.repoAccess
    if (!access) return true // gate not wired (tests / partial boot) — fail open, as today

    // Identity extraction mirrors allowPush's credential paths — session
    // bearer/cookie first, then HTTP Basic where the password is a PAT — but
    // resolves them for READ: resolveGitReader accepts a read-scoped token,
    // which canPushGit rightly refuses. Anonymous stays anonymous; a public
    // repo needs no identity at all.
    let userId = null
    if (req) {
      try {
        const session = svc.sessions ? extractIdentity(req, svc.sessions) : null
        const caller = session && session.publicKey
          ? { transport: 'http', identityId: session.publicKey }
          : (() => { const t = parseBasicToken(req); return t ? { transport: 'http', token: t } : null })()
        if (caller) {
          const resolved = await resolveGitReader(svc.identityRegistry, caller)
          if (resolved && resolved.user) userId = resolved.user.userId
        }
      } catch {
        userId = null // unresolvable credential reads as anonymous, never as authorized
      }
    }

    // transport 'http': deliberately NOT treated as local. The daemon's own
    // tooling reaches it over UDS; anything arriving on the HTTP server is a
    // remote client even when it dials 127.0.0.1.
    const ctx = { transport: 'http', userId }
    // .async only consults the identity hook for a repo that is not public, so
    // public clones still cost no registry or tracker lookup.
    const decision = await access.async(repoName, ctx)
    if (!decision.wouldDeny) return true

    if (!decision.enforced) {
      // eslint-disable-next-line no-console
      console.log('[read-gate] OBSERVE would-deny ' + where + ' ' + repoName + ' (' + decision.reason + ')')
      return true
    }

    // eslint-disable-next-line no-console
    console.log('[read-gate] deny ' + where + ' ' + repoName + ' (' + decision.reason + ')')
    // 404, not 403: a private repo should not confirm its own existence to an
    // unauthorized caller. Matches the "Repository not found" body above so the
    // two are indistinguishable.
    res.writeHead(404)
    res.end('Repository not found')
    return false
  }

  server.on('request', async (req, res) => {
    const s = sync()
    if (!s) return // not handling this request

    // Match /:repo.git/info/refs
    const infoMatch = req.url.match(/^\/([^/]+)\.git\/info\/refs/)
    if (infoMatch && req.method === 'GET') {
      const repoName = infoMatch[1]
      const url = new URL(req.url, 'http://localhost')
      const service = url.searchParams.get('service')

      if (!s.repos.has(repoName)) {
        res.writeHead(404)
        res.end('Repository not found')
        return
      }

      // Gate the push advertisement so an unauthorized client is rejected
      // at discovery, before it sends a pack.
      if (service === 'git-receive-pack' && !(await allowPush(req, res))) return

      // Read gate. Historically reads advertised freely on every transport —
      // that default is what makes "private repo" unimplementable. Denying at
      // info/refs stops `git clone` before any pack is generated.
      if (service !== 'git-receive-pack' && !(await allowRead(repoName, res, 'info/refs', req))) return

      const repoPath = s._repoPath(repoName)
      s.handleInfoRefs(repoPath, service).then(body => {
        res.writeHead(200, {
          'Content-Type': 'application/x-' + service + '-advertisement',
          'Cache-Control': 'no-cache'
        })
        res.end(body)
      }).catch(err => {
        res.writeHead(500)
        res.end(err.message)
      })
      return
    }

    // Match /:repo.git/git-upload-pack or git-receive-pack
    const packMatch = req.url.match(/^\/([^/]+)\.git\/(git-upload-pack|git-receive-pack)$/)
    if (packMatch && req.method === 'POST') {
      const repoName = packMatch[1]
      const service = packMatch[2]

      if (!s.repos.has(repoName)) {
        res.writeHead(404)
        res.end('Repository not found')
        return
      }

      // Push gate: git-receive-pack requires write access.
      if (service === 'git-receive-pack' && !(await allowPush(req, res))) return

      // Read gate — defence in depth. A client that skipped info/refs (or was
      // told to advertise by a cached response) still cannot fetch a pack.
      if (service === 'git-upload-pack' && !(await allowRead(repoName, res, 'upload-pack', req))) return

      // Attribute the push to its author for the public profile activity list.
      // Resolved now (headers intact); recorded on successful close below.
      let pusher = null
      if (service === 'git-receive-pack') pusher = await resolvePusher(req).catch(() => null)

      const repoPath = s._repoPath(repoName)

      // For a PUSH (git-receive-pack), snapshot refs BEFORE spawning so
      // we can diff afterwards and fire auto-builds for any ref whose SHA
      // changed. Fire-and-forget — build runs in the background, HTTP
      // response returns as soon as git-receive-pack finishes.
      let preRefs = null
      if (service === 'git-receive-pack' && svc._releases && svc._sync) {
        try {
          const { snapshotRefs } = await import('../ci-orchestrator.js')
          preRefs = await snapshotRefs(repoPath)
        } catch {}
      }

      const proc = s.spawnGitService(repoPath, service)

      res.writeHead(200, {
        'Content-Type': 'application/x-' + service + '-result',
        'Cache-Control': 'no-cache'
      })

      req.pipe(proc.stdin)
      proc.stdout.pipe(res)
      proc.stderr.on('data', () => {}) // swallow stderr
      proc.on('close', () => {
        res.end()
        // Attribute the push (best-effort) once git-receive-pack has finished.
        if (service === 'git-receive-pack' && pusher) recordPush(pusher, repoName)
        // Auto-build hook: diff refs + kick off background builds
        if (service === 'git-receive-pack' && preRefs) {
          import('../ci-orchestrator.js').then(async ({ snapshotRefs, autoBuildFromPush }) => {
            try {
              const postRefs = await snapshotRefs(repoPath)
              await autoBuildFromPush({
                vdriveSeeder: svc._vdriveSeeder,
                sync: s,
                releaseManager: svc._releases,
                pendingBuilds: svc._pendingBuilds,
                repo: repoName,
                before: preRefs,
                after: postRefs
              })
              // Drive the gip producer for each ref this push changed, so an
              // HTTP push reaches the P2P layer DIRECTLY — not only via the
              // post-receive hook's loopback to ref-update-observed. Closes the
              // asymmetry where this in-process observer drove the legacy
              // repoEventLog but not gip. Idempotent with a hook fire for the
              // same push (the autobase FF guard no-ops the duplicate);
              // fire-and-forget; self-guards on tracker.writable.
              if (svc._gipPublishRef) {
                const ZERO = '0000000000000000000000000000000000000000'
                for (const [ref, newSha] of postRefs) {
                  if (preRefs.get(ref) === newSha) continue
                  svc._gipPublishRef({ name: repoName, ref, oldSha: preRefs.get(ref) || null, newSha })
                    .catch((e) => console.error('[post-receive] gip publish failed for', ref, '-', e.message))
                }
                for (const [ref, oldSha] of preRefs) {
                  if (postRefs.has(ref)) continue
                  svc._gipPublishRef({ name: repoName, ref, oldSha, newSha: ZERO })
                    .catch((e) => console.error('[post-receive] gip delete failed for', ref, '-', e.message))
                }
              }
              // Append an 'updated' event to the repo-event log so
              // mirror daemons get a live notification of the push. Best-
              // effort — failures here must not affect the git push.
              if (svc._repoEventLog && svc._repoEventLog.writable) {
                try {
                  await svc._repoEventLog.append({
                    op: 1, // updated
                    name: repoName,
                    head: pickHead(postRefs)
                  })
                } catch (err) {
                  // eslint-disable-next-line no-console
                  console.error('[post-receive] repo-event-log append failed:', err.message)
                }
              }
            } catch (err) {
              // eslint-disable-next-line no-console
              console.error('[post-receive] auto-build failed:', err.message)
            }
          }).catch(() => {})
        }
      })
      return
    }
  })
}