/** pgh repo [name] — list/show/info/branches/tags + invite/join + follower mode */
import { PghPeerClient } from '../peer-client.js'
export const usage = `pgh repo — browse repos served by the daemon
pgh repo List repos
pgh repo list List repos (detailed)
pgh repo <name> Show one repo
pgh repo info <name>
pgh repo branches <name>
pgh repo tags <name>
pgh repo create <name> [--branch <b>] Host a new bare repo (default: main)
pgh repo scan Rescan the repos dir
Per-repo P2P pairing (issue #35):
pgh repo invite <name> [--role reader|contributor|maintainer|admin]
pgh repo join <invitation>
Follower mode:
pgh repo follow-payload <name>
pgh repo follow <invitation>
pgh repo unfollow <sourceServerKey> <name>
pgh repo follows
Per-repo autobase = push authority (gip):
pgh repo writers <name> List writers
pgh repo grant-writer <name> --peer <url> [--role owner|writer]
Admin: grant a peer push authority
pgh repo backfill-visibility [--dry-run] Mark EVERY existing repo public on this
peer. Run once per peer BEFORE enabling
read-gate enforcement, or every repo
predating the marker goes unserved.
pgh repo visibility <name> Show whether THIS peer serves the repo
pgh repo visibility <name> public Serve it (writes the export-ok marker)
pgh repo visibility <name> private Stop serving it here. Admin.
BEST-EFFORT, not a retraction: bundles
already published stay in the overlay
and peers holding the discoveryKey keep
it. The marker is LOCAL — run on each
peer.
pgh repo retire <name> Tombstone a repo: stop listing/serving/
re-creating it fleet-wide. Bytes are
NOT removed — remove ~/git-repos/
<name>.git by hand on each peer AFTER
the tombstone has replicated. Admin,
primary only.
pgh repo unretire <name> Undo a retire (a later op wins).
pgh repo list-retired Show the replicated tombstone set.
pgh repo set-head <name> <branch> Set the repo's DEFAULT BRANCH fleet-wide
(replicated; admin; run on the tracker
owner). Accepts main or
refs/heads/main.
pgh repo local-key <name> Print this daemon's tracker local key
pgh repo revoke <name> <userId> Revoke a writer
pgh repo resync <name> Heal a stuck tracker: clean re-join in
a fresh corestore + P2P doorbell
auto-admission (no url)`
// A repo name must start alphanumeric and stay within git-safe punctuation —
// same shape pear-git-v3 enforces in lib/repo-name.js.
const VALID_REPO_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
// Values that mean "a variable was stringified", never a repo someone meant.
// `undefined` is not hypothetical: there is one on the live fleet.
const SENTINELS = new Set(['undefined', 'null', 'nan', 'false', 'true', '[object object]'])
export async function run (client, args) {
const sub = args[0]
// No args → list all repos
if (!sub) {
const repos = await client.call('list-repos', {})
return Array.isArray(repos) ? repos : repos.repos || repos
}
// Explicit subcommands
if (sub === 'list' || sub === 'ls') {
const repos = await client.call('list-repos-detailed', {})
return Array.isArray(repos) ? repos : repos.repos || repos
}
if (sub === 'info') {
const name = args[1]
if (!name) throw new Error('usage: pgh repo info <name>')
return client.call('repo-info', { name })
}
if (sub === 'branches') {
const name = args[1]
if (!name) throw new Error('usage: pgh repo branches <name>')
return client.call('repo-branches', { name })
}
if (sub === 'tags') {
const name = args[1]
if (!name) throw new Error('usage: pgh repo tags <name>')
return client.call('repo-tags', { name })
}
// Default branch. HEAD used to be per-backend, so nodes disagreed and the
// public saw whichever the load balancer hit; it is replicated now, but a
// repo whose nodes already disagree gets seeded by whichever writer
// reconciles first. This makes that a deliberate choice rather than a race.
// Retirement. Deleting a bare repo by hand does NOT stick: four paths
// resurrect it (cross-peer re-pull, boot rescan, directory watcher, residual
// references in issue-registry-index.json / tracker autobases). The tombstone
// must replicate to every peer BEFORE anyone removes bytes. See
// pear-git-v3/lib/services/retired-repos.js, which never deletes anything and
// is explicit that removing bytes is a manual per-peer operator action.
// visibility — read or set whether THIS peer serves a repo.
//
// Deliberately not `--public/--private` flags: the two directions are not
// symmetric. public→private stops future serving but cannot retract bundles
// already in the overlay, nor un-tell peers a discoveryKey. The daemon
// returns a warning saying so; print it rather than swallowing it, or an
// operator will believe a repo was made secret when it was only unlisted.
if (sub === 'visibility') {
const name = args[1]
const value = args[2]
if (!name) throw new Error('usage: pgh repo visibility <name> [public|private]')
if (value && value !== 'public' && value !== 'private') {
throw new Error("usage: pgh repo visibility <name> [public|private] — got '" + value + "'")
}
// No value → query. The marker is LOCAL, so this answers for THIS peer
// only; run it on each peer to see the whole picture.
return client.call('repo-visibility', value ? { name, visibility: value } : { name })
}
// backfill-visibility — mark every existing repo public on THIS peer.
//
// Runs daemon-side, not from a script: `pgh.stage.ignore` lists `scripts/`,
// so pear-git-v3's backfill script never ships to a peer installed via
// `pgh app install` — it would be missing on exactly the machines that must
// run it before enforcement. Idempotent; --dry-run reports without writing.
if (sub === 'backfill-visibility') {
const dryRun = args.includes('--dry-run') || args.includes('-n')
return client.call('repo-backfill-visibility', { dryRun })
}
if (sub === 'retire') {
const name = args[1]
if (!name) throw new Error('usage: pgh repo retire <name>')
return client.call('retire-repo', { name })
}
if (sub === 'unretire') {
const name = args[1]
if (!name) throw new Error('usage: pgh repo unretire <name>')
return client.call('unretire-repo', { name })
}
if (sub === 'list-retired' || sub === 'retired') {
return client.call('list-retired-repos', {})
}
if (sub === 'set-head') {
const name = args[1]
const ref = args[2]
if (!name || !ref) throw new Error('usage: pgh repo set-head <name> <branch>')
return client.call('repo-set-head', { name, ref })
}
// Host a new bare repo in one call: init --bare + symbolic-ref HEAD + scan +
// serve, all daemon-side. Must be matched BEFORE the bare-name fallthrough at
// the bottom, or `create` would be read as a repo name to show.
if (sub === 'create') {
const name = args[1]
if (!name) throw new Error('usage: pgh repo create <name> [--branch <branch>]')
const bi = args.indexOf('--branch')
const branch = (bi !== -1 && args[bi + 1]) || 'main'
return client.call('create-repo', { name, branch })
}
if (sub === 'scan') {
return client.call('scan-repos', {})
}
// ── Per-repo-autobase invite / join (issue #35) ──
//
// A repo tracker is its own autobase, so a peer that joins ONE repo
// replicates only that repo. Mirrors `pgh org invite|join`.
if (sub === 'invite') {
const name = args[1]
if (!name) throw new Error('usage: pgh repo invite <name> [--role reader|contributor|maintainer|admin]')
const ri = args.indexOf('--role')
const role = (ri !== -1 && args[ri + 1]) || 'reader'
// → { invitation } — a blind-pairing invite code (the whole UX).
return client.call('create-repo-invite', { name, role })
}
if (sub === 'join') {
const invitation = args[1]
if (!invitation) throw new Error('usage: pgh repo join <invitation>')
// One-step P2P join: the daemon pairs over the swarm and, for
// contributor+, enrolls us as a writer DURING pairing. No owner URL,
// no HTTP enroll call — the invite code is the whole UX.
return client.call('join-repo', { invitation })
}
// ── Follower-mode subcommands ───────────────────────────────────
//
// pgh repo follow-payload <name> emit follow-payload for local repo
// (source side; operator pastes the
// URL/payload elsewhere)
// pgh repo follow <token> start following a remote repo
// pgh repo unfollow <srvKey> <name> stop following
// pgh repo follows list active follows
if (sub === 'follow-payload') {
const name = args[1]
if (!name) throw new Error('usage: pgh repo follow-payload <name>')
return client.call('repo-follow-payload', { name })
}
if (sub === 'follow') {
const token = args[1]
if (!token) {
throw new Error(
'usage: pgh repo follow <payload-b64 | punch+follow://srvKey/repo/name>'
)
}
if (token.startsWith('punch+follow://')) {
return client.call('follow-repo', { url: token })
}
return client.call('follow-repo', { payload: token })
}
if (sub === 'unfollow') {
const sourceServerKey = args[1]
const name = args[2]
if (!sourceServerKey || !name) {
throw new Error('usage: pgh repo unfollow <sourceServerKey> <name>')
}
return client.call('unfollow-repo', { sourceServerKey, name })
}
if (sub === 'follows') {
return client.call('list-repo-follows', {})
}
// gip-overlay authority (P4/P6): the per-repo autobase writer set IS the
// push authority. Grant = invite/join (above); these expose list + revoke.
// pgh repo writers <name> list push-authorized writers
// pgh repo revoke <name> <userId> admin: remove a writer (future ops dropped)
if (sub === 'writers') {
const name = args[1]
if (!name) throw new Error('usage: pgh repo writers <name>')
return client.call('repo-list-writers', { name })
}
// Surface this daemon's own tracker local key for a repo — the key an
// owner needs to grant it write authority (the other side of grant-writer).
if (sub === 'local-key') {
const name = args[1]
if (!name) throw new Error('usage: pgh repo local-key <name>')
return client.call('repo-local-key', { name })
}
// Admin: grant a peer push authority on a repo by adding its tracker local
// key to the per-repo autobase writer set. Pulls the peer's local key from
// its daemon (repo-local-key over the swarm), then addWriters it here. This
// is what flips a peer that mounted the repo read-only (org-resource path)
// into a real writer — no re-join. role 'owner' = indexer (offline-tolerant
// co-equal writer), 'writer' = non-indexer (default).
if (sub === 'grant-writer') {
const name = args[1]
if (!name) throw new Error('usage: pgh repo grant-writer <name> --peer <url> [--role owner|writer]')
const pi = args.indexOf('--peer')
const peer = pi !== -1 ? args[pi + 1] : null
if (!peer) throw new Error('grant-writer requires --peer <url> (the peer daemon to grant)')
const ri = args.indexOf('--role')
const role = (ri !== -1 && args[ri + 1]) || 'writer'
const ui = args.indexOf('--user')
const userOverride = ui !== -1 ? args[ui + 1] : null
// 1. Pull the peer's tracker local key (+ its identity) from its daemon.
const pc = await PghPeerClient.connect({ url: peer })
let peerKey
try {
peerKey = await pc.call('repo-local-key', { name })
} finally {
await pc.close()
}
if (!peerKey || !peerKey.localKey) {
throw new Error('peer did not return a tracker local key for "' + name + '" (is the repo mounted there?)')
}
const userId = userOverride || peerKey.identity || peerKey.localKey
// 2. addWriter the peer's local key on THIS (owner) daemon.
return client.call('repo-add-writer', { name, userId, writerKey: peerKey.localKey, role })
}
// Clean RE-JOIN of a stuck/frozen per-repo tracker into a FRESH corestore
// (repo-resync RPC → forceResync). Recovers a standalone peer whose tracker
// can't reconcile a group manifest migration (crash-loops or silently freezes)
// — a fresh namespace does NOT heal it (same primaryKey-derived local writer),
// only a fresh corestore does. The daemon AUTO-ADMITS the re-joined key via the
// P2P doorbell (an optimistic request-writer op vouched by this peer's old
// writer key; the owner admits on apply) — no url, no HTTP, fully over
// hyperswarm. One command = heal + participate.
if (sub === 'resync') {
const name = args[1]
if (!name) throw new Error('usage: pgh repo resync <name>')
return client.call('repo-resync', { repo: name })
}
if (sub === 'revoke') {
const name = args[1]
const userId = args[2]
if (!name || !userId) throw new Error('usage: pgh repo revoke <name> <userId>')
return client.call('repo-revoke-writer', { name, userId })
}
// Default: treat the first arg as a repo NAME → show it.
//
// GUARDED, because this path has a side effect it does not look like it has:
// repo-info resolves through getForRepo(), which mints a per-repo tracker on
// demand, and RefReconcilerManager.ensureFor() runs `git init --bare` when
// the path is missing. So a READ materializes a repo. Typing
// `pgh repo --help` created a bare repo called `--help`; 16 empty repos on
// the live fleet were produced this way, named after flags (`--help`),
// subcommand words (`list`, `create`, `new`, `help`, `comment`), orgs and
// projects that were never repos, and one literal `undefined`.
//
// The daemon should not create on read either — that is the deeper fix, in
// repo-info — but the CLI has no business forwarding these in the first place.
if (sub.startsWith('-')) {
// A flag reaching here means an unknown option, not a repo called "--help".
throw new Error(usage)
}
if (SENTINELS.has(sub.toLowerCase())) {
throw new Error(`refusing to treat "${sub}" as a repo name — it looks like a ` +
'stringified value, not something you meant to type.\n\n' + usage)
}
if (!VALID_REPO_NAME.test(sub)) {
throw new Error(`invalid repo name: ${JSON.stringify(sub)}\n\n` + usage)
}
return client.call('repo-info', { name: sub })
}