a73x

README.md

Ref:   Size: 28.1 KiB   History

# git-collab

Distributed issues and code review over Git.

Issues, patches, reviews and comments are stored as signed events in the
repository itself, under `refs/collab/*`. They travel with `git fetch` and
`git push`, so collaboration works between any two clones — and there is no
account to create, no API to call, and nothing to migrate if you move host.

An optional server adds a read-only web UI, SSH remotes and release artifacts,
but nothing depends on it. Two people with a shared remote have everything.

## Why

Review lives in the wrong place. It is the record of *why* code looks the way
it does, and it usually ends up in a database belonging to whoever hosts the
repository. Issues, comments and reviews are as much a part of a project's
history as its commits, and they should be as portable.

What that buys, concretely:

- **Offline.** File an issue, review a patch and reply to a comment on a plane.
  Reconcile on the ground.
- **Attributable.** Every event is signed with Ed25519 and verified on sync.
  You choose which keys you trust.
- **Yours.** Clone the repository and you have the whole conversation.

## Revision-aware review

The part that is deliberately unlike a pull request.

A patch has numbered revisions. Comments anchor to the revision they were
written on, so they never drift onto lines that have since moved. When an author
addresses review, `patch revise` records the next revision, and you can diff
revision against revision — an interdiff — to see exactly what the author
changed in response to review, rather than re-reading the whole patch.

```console
$ git-collab patch log a1b2c3d4       # every revision, with a file-change summary
$ git-collab patch diff a1b2c3d4 --between 1 2   # interdiff: what changed between rounds
$ git-collab patch diff a1b2c3d4 --revision 2    # revision 2 against the base
```

`patch log --timeline` puts the whole story in one sequence — revisions,
comments, reviews, corrections and the merge — each entry anchored to the
revision it was made against, so the causal chain is legible without
interleaving two commands' output by eye:

```console
$ git-collab patch log a1b2c3d4 --timeline
2026-08-11T10:01:03Z  r1        Alice  edd93bf7  (initial)
2026-08-11T10:01:08Z  comment   Bob    @r1  needs a test for the empty case
2026-08-11T10:01:12Z  review    Bob    @r1  request-changes  holding off until that lands
2026-08-11T10:01:31Z  r2        Alice  37a3675a  added the empty-case test
2026-08-11T10:01:36Z  review    Bob    @r2  approve  that covers it
2026-08-11T10:01:44Z  merged    Alice  66f21e82
```

Reading it top to bottom: the request for changes was made against r1, and r2 is
what answered it. Corrections appear as their own entries rather than being
folded silently into the text they changed — the DAG is append-only so that the
record stays an audit trail, and the timeline is where that is visible. Add
`--json` for the same sequence as an array. Without `--timeline`, `patch log`
prints the revision list exactly as before.

Each revision is pinned by a ref in the patch's own namespace, named after the
commit it points at (`refs/collab/patches/<id>/rev/<oid>`), so `git-collab sync`
carries the patch and every revision it ever had. Contributing needs no `git push` of a branch and no
write access to `refs/heads/*`, and rebasing your branch cannot strip an earlier
revision of the objects a reviewer is looking at.

Merge however you already merge — merging stays plain git. That a patch merged
is *recorded*, though, rather than guessed at on every read: a merged branch is
routinely deleted, and no amount of reachability can see a squash, whose commit
shares nothing with the patch's own.

Put `Patch: <id>` in the commit that lands the patch and `git-collab sync` finds
it on the patch's base branch and records the merge — which works retroactively,
on any machine, and survives a squash, because `git merge --squash` carries the
message along. Or record it by hand:

```console
$ git-collab patch merge a1b2c3d4
```

`git-collab init` installs a `commit-msg` hook that writes the trailer for you,
on commits made while you are on a branch that exactly one open patch was
created from. It is a convenience only — `.git/hooks` is not cloned, so it helps
the machine it was installed on and only for commits made afterwards, which is
why scanning at sync time stays the mechanism that actually records merges.

The hook never fails a commit and never touches a message it is unsure about:
no matching patch, more than one, a detached HEAD, or no `git-collab` on `PATH`
all leave the message exactly as you wrote it. If a `commit-msg` hook of your
own is already there, `init` will not touch it and tells you the one line to add
if you want the trailer:

```console
$ git-collab hooks status                    # is it installed, what would it stamp
$ git-collab hooks install                   # install it on its own
```

Either way, a patch created with `--fixes` closes the issue it fixes at the same
moment. `git-collab issue reopen` then stays reopened: the fix landing and
turning out to be wrong is ordinary, and nothing re-closes an issue over a
decision you made after it.

A patch whose commits are simply reachable from its base tip is shown as
`merged?` — a hint, and one that can only ever be a hint, so it decides nothing
and writes nothing. `--json` reports it as a separate `looks_merged` field,
never inside `status`.

A merge recorded in error is undone by `git-collab patch reopen`, which is also
how a patch closed by mistake comes back. It clears the recorded merge commit as
it goes — an open patch that still named the commit that landed it would be two
answers to one question — and prints the commit it dropped, so recording the
merge again is a copy and paste. Reopening is an ordinary event: a close on
another clone and a reopen on this one are resolved by the same total order as
every other status change, so the two converge instead of racing.

## Scripting it

Every command that creates, changes, closes or records something takes `--json`
and prints exactly one object on stdout:

```console
$ git-collab issue open -t "The bug" --json
{"action":"issue.open","issue":"7c1e...(full id)"}
$ git-collab patch merge a1b2c3d4 --json
{"action":"patch.merge","patch":"...","commit":"...","already_recorded":false,"closed_issue":"..."}
```

Ids there are always full. Abbreviation is sized to the repository and can be
overridden with `collab.abbrev`, which makes it a display policy and nothing a
script should ever have to parse — so it appears in the prose and never in the
JSON. A failure prints `{"error": "..."}`, on stdout, and exits 1: a caller that
asked for JSON never has to read stderr. And stderr is where the push that a
write triggers narrates itself, under an `auto-sync:` prefix, so stdout stays
one parseable value whether or not the network was involved.

## Install

Requires Rust 1.88 or newer.

```console
$ make install               # git-collab and git-collab-server into ~/.local/bin, plus man pages
```

That installs under `$HOME/.local`, so it needs no root and `~/.local/bin` is
already on most PATHs. For a system-wide install:

```console
$ sudo make install PREFIX=/usr/local
```

`make uninstall` removes what `make install` wrote, and takes the same `PREFIX`.
If the man directory is not writable the binaries still install and a warning
says the man pages did not.

## Getting started

```console
$ cd your-repo
$ git-collab init-key                        # generate an Ed25519 signing key
$ git-collab init                            # collab refspecs on your remotes, plus the commit-msg hook

$ git-collab issue open -t "Parser drops trailing newline"
$ git-collab issue list

$ git checkout -b fix-parser
$ ...work, commit...
$ git-collab patch create -t "Fix trailing newline in parser"

$ git-collab sync                            # fetch, reconcile, push

$ ...address review, commit...
$ git-collab patch revise a1b2c3d4 -b "addressed review"   # record revision 2
$ git-collab sync
```

Reviewing someone else's patch:

```console
$ git-collab sync
$ git-collab patch list
$ git-collab patch checkout a1b2c3d4         # local branch at the latest revision
$ git-collab patch comment a1b2c3d4 --file src/parse.rs --line 42 -b "off by one?"
$ git-collab patch review a1b2c3d4 --verdict request-changes -b "see inline"
```

`git-collab dashboard` opens a TUI over the same data if you would rather browse
than type — and it is a review surface, not only a browser. Open a patch and
the review loop is under your hands: `c` comments on the diff line the cursor
is on (or on the patch, anywhere else), `R` records a verdict, `x` marks a
comment answered or withdraws the claim, `a` shows the change that answered it,
and `o` checks the latest revision out. Every body is composed in `$EDITOR`,
seeded with the hunk you are writing about; as with `git commit`, `#` lines are
dropped and an empty message aborts.

The dashboard never reloads under you. A header says `as of HH:MM`, and when an
agent lands something while you are reading, a banner says how many events
arrived — you reload with `r` when you are ready.

## Writing and correcting prose

Review prose is long, multi-line, and full of things a shell wants to
interpret. Every command that takes `--body` also reads one from a file, or
from stdin with `-`, using git's own `-F` convention — so a body never has to
be written around the shell:

```console
$ git-collab patch review a1b2c3d4 -v approve -F review.md
$ some-tool | git-collab patch comment a1b2c3d4 -F -
$ git-collab patch comment a1b2c3d4          # no body: opens $EDITOR
```

Whatever you supply is stored byte for byte — trailing newlines, non-ASCII and
leading `#` included. Nothing is stripped, because a body is markdown, not a
commit message.

Typos are fixable. Editing appends a new event that supersedes the old body;
the original stays in the DAG, so the log remains an audit trail:

```console
$ git-collab patch show a1b2c3d4             # comment IDs are printed in [brackets]
$ git-collab patch edit-comment a1b2c3d4 f39bf43b -b "what I meant to say"
$ git-collab patch edit-revision a1b2c3d4 3 -F -   # fix a revision's description
$ git-collab patch delete-comment a1b2c3d4 f39bf43b
```

Deleting leaves a tombstone rather than a hole: the comment keeps its position,
author and timestamp, and only its text is dropped. A review is a conversation,
and a comment that silently vanished would leave every reply to it pointing at
nothing.

Only a comment's own author can change it. An edit signed by anyone else is
ignored when state is derived, not merely refused at the command line — anyone
holding a copy of the DAG can append to it, so the rule has to hold where every
reader folds it.

## Commands

| | |
|---|---|
| `issue` | open, list, show, comment, edit, edit-comment, delete-comment, label, assign, close, claim, unclaim, renew, claims |
| `patch` | create, list, show, diff, comment, review, revise, edit-comment, delete-comment, edit-revision, log, checkout, merge, close |
| `sync` | fetch, reconcile and push collab refs |
| `hooks` | install and inspect the `commit-msg` trailer hook |
| `status` | project overview |
| `dashboard` | interactive TUI |
| `search` | full-text across issues and patches |
| `log` | raw event stream, chronological |
| `refs` | list this repository's collab refs and what each one is |
| `key` | manage trusted signing keys |
| `whoami`, `identity` | your identity and its aliases |
| `release` | publish, list and delete artifacts on a server |

Every command takes `--help`, and `man git-collab` covers the same ground.

Both binaries take `--version`, which reports the crate version and the git
commit the binary was built from, with a `-dirty` marker for a build made from
a modified tree:

```console
$ git-collab --version
git-collab 0.1.0 (29768e2c6fd3e1eed89f095b057365b353ce9652)
```

The commit is the full object name so that checking an installed binary against
a checkout is one string comparison against `git rev-parse HEAD`. `git-collab
status` leads with the same line. A build from a source tree with no `.git`, or
on a machine with no git, prints the crate version alone.

Common synonyms for the verbs above are accepted but not advertised — `issue
create` and `patch open` both work, as do `ls`, `rm` and `keys`. They exist so a
wrong guess in a script succeeds rather than writing usage text into a pipeline.

## Ids

Issues and patches are named by 40-character ids, and every command that takes
one accepts any unambiguous prefix — `issue show 9ae` is fine. A prefix that
matches more than one object is refused, never resolved:

```console
$ git-collab issue show 9a
error: ambiguous issue prefix '9a': 2 matches
```

Lists, headers and confirmations print a uniform abbreviation: 8 characters by
default, widening as the repository grows, on the same birthday-bound rule git
uses for `core.abbrev`. An id that would still be ambiguous at that width is
printed wider, so anything the tool prints is unique at the moment it is
printed and can be pasted straight back into a command or a commit trailer.

Set `collab.abbrev` to override the width — a number (clamped to 4–40), or `no`
for whole ids:

```console
$ git config collab.abbrev 12    # print 12 characters
$ git config collab.abbrev no    # print ids in full
```

Note that displayed width and accepted width are deliberately different. What
you can type is as short as stays unambiguous; what gets printed carries a
collision margin, because printed ids end up in commit messages and scripts
where they have to keep working as the repository grows.

## Sync

`git-collab init` adds collab refspecs to every remote of the repo (and installs
the `commit-msg` hook). It is idempotent: running it again configures nothing
twice and reports what was already in place. `git-collab
sync` (no arguments) syncs all of them — fetch, reconcile, push — and only
reports success once every one of them has actually succeeded. A remote that
fails does not stop the others: failures are reported together, alongside
which remotes did succeed, and the command exits non-zero. Pass `--remote
<name>` to sync just one remote instead.

Write commands (`issue`/`patch`) auto-sync afterwards. Two git config keys
control that:

- `collab.autoSync` (bool, default `true`) — set to `false` to disable
  auto-sync entirely.
- `collab.autoSyncRemote` (string, unset by default) — pins auto-sync to a
  single named remote. Left unset, auto-sync covers every configured remote,
  same as a plain `sync`.

```console
$ git config collab.autoSync false            # disable auto-sync
$ git config collab.autoSyncRemote origin     # auto-sync only 'origin'
```

A write command does two separable things, and says so separately. Its own
result goes to stdout; the auto-sync that follows narrates itself on stderr,
every line under an `auto-sync:` prefix, naming the remotes *before* it reaches
them:

```console
$ git-collab issue open -t "Something broke"
Opened issue 27eff033
auto-sync: publishing to 'origin'...
auto-sync: Pushing to 'origin'...
auto-sync: published to 'origin'.
```

The split is the point. Recording the event locally and publishing it are
different operations with different failure modes, so a failed push never fails
the command — the event is already recorded, and reporting otherwise would send
a script off to retry a write that already happened:

```console
$ git-collab issue open -t "Something broke"    # exit status 0
Opened issue 27eff033
auto-sync: publishing to 'origin'...
auto-sync: failed: git fetch exited with status exit status: 128
auto-sync: your changes are recorded locally; run 'git-collab sync' to publish them.
```

Because the narration stays on stderr, stdout carries the command's result and
nothing else, whether or not a sync happened.

## Claiming work

Two people — or twenty agents — working the same backlog need to know who has
picked up what before they start. That is the one thing a repository cannot
answer: git's only atomic operation is a ref update on push, so a claim made
in the repository is a race whose loser finds out after the fact. Claims
therefore live on the server, and the commands need one round trip each:

```console
$ git-collab issue claim a1b2c3d4                # yours until you say otherwise
Claimed issue a1b2c3d4
$ git-collab issue claims                        # who holds what
a1b2c3d4  key:SHA256:…  assigned
$ git-collab issue unclaim a1b2c3d4
Released the claim on a1b2c3d4
```

Ids are abbreviated here as everywhere else, and `--json` carries the full
forty characters — the identifier a script should hold on to.

With no `--ttl` a claim is an *assignment*: open-ended, released when you say
so. With one it is a *lease* that lapses unless renewed, which is what an
unattended worker wants — if the machine dies, the work returns to the pool on
its own rather than staying claimed by a process that no longer exists:

```console
$ git-collab issue claim a1b2c3d4 --ttl 900      # 15 minutes
$ git-collab issue renew a1b2c3d4 --ttl 900      # before it lapses
```

Losing a race exits **4**, distinct from the 1 any other failure exits with,
so a script can tell "someone else got it" from "the command was wrong"
without reading prose:

```console
$ git-collab issue claim a1b2c3d4 --json; echo "exit $?"
{"status":"held","holder":"key:SHA256:…","expires_at":"2026-09-05T12:15:00Z",…}
exit 4
```

The server is the arbiter, so these commands need a reachable SSH remote and
authenticate with the same key that clones the repository — there is no token
to issue and nothing to configure. They write no events and never sync, so a
claim leaves no trace in `refs/collab/*`; the web UI shows the holder on the
issue list and its detail page. A claim is a coordination hint with a clock on
it, not part of the record.

## Trust

Sync verifies every signature it fetches. Until you add a trusted key, valid
signatures are accepted with a warning — enough to get started, not enough to
rely on.

```console
$ git-collab key add --self                  # trust your own key
$ git-collab key add <base64-pubkey> --label alice
$ git-collab key list
```

Events signed by an untrusted key are reported rather than silently applied.
Trusted keys live in `.git/collab/trusted-keys`, with an optional global list.

## Conflict resolution

Two people can edit the same issue while disconnected. Events carry a Lamport
clock, and reconciliation orders them by `(clock, oid)` — deterministic on every
machine, with no dependence on wall-clock time or on who synced first. Both
edits survive; the ordering is just agreed.

## Server

`git-collab-server` serves repositories over SSH and gives them a read-only web
UI: repository list, commits, tree and blob browser, diffs, and the issues and
patches from `refs/collab/*` rendered as pages.

```console
$ git-collab-server --config /etc/git-collab/server.toml
```

```toml
repos_dir = "/srv/git"
http_bind = "0.0.0.0:8080"
ssh_bind = "0.0.0.0:2222"
authorized_keys = "/etc/git-collab/authorized_keys"
site_title = "my repos"
```

Per-repository policy lives in `<repo>.git/.collab/server.toml` and controls
visibility, anonymous clone and per-key read/write access. A repository with no
policy file is public and world-writable to any key in `authorized_keys` — set a
policy before serving anything you care about.

`make docker` builds a container image.

### Governance

A repository called `settings` under `repos_dir` takes over as the server's
rules. Pushing to it reconfigures the server; there is no reload and no restart.

Create it with `setup`, which is deliberately a command rather than something
the server does on its own:

```console
$ git-collab-server setup --config /etc/git-collab/server.toml \
      --admin-key ~/.ssh/alex.pub
```

The key file's basename becomes the identity — `alex.pub` is the principal
`alex` — and `--admin-name` overrides it. That name is enrolled in `keydir/` and
granted `RW+` on `settings`, so it can push the rules from then on.

The seed reproduces the policy already in force: it reads each repository's
`server.toml` and writes the rule that means the same thing, including
`R = @anonymous` and `option listed = yes` for a repository that is public
today. So enabling governance changes nothing about what the server exposes,
which is what makes it safe to run on a live server — `--dry-run` prints the
rules first. Setup refuses if `settings.git` already exists.

It changes nothing about who can *connect*, either. Because `keydir/`
supersedes `authorized_keys` the moment `settings.git` exists, setup copies
every key in that file into `keydir/` — leaving the file itself untouched — and
translates the `server.toml` rosters that named those keys by fingerprint. An
entry's trailing comment names it where the comment can be a principal name,
used verbatim and never cut down to `alex@laptop`'s first component; otherwise
it gets an obviously provisional `key1`, `key2`, reported with the key's
fingerprint so you can rename it. Every derived name is printed, by `--dry-run`
too. Pass `--no-enrol-existing` for a clean single-administrator bootstrap,
which says how many keys that locks out.

```text
settings.git
├── conf/access.conf
└── keydir/
    ├── laptop/alex.pub
    ├── desktop/alex.pub
    └── claude-a.pub
```

A principal's name is the **basename** of its key file, directories ignored — so
`laptop/alex.pub` and `desktop/alex.pub` are both `alex`, which is how one person
adds a second machine.

**Delegates.** A key in `keydir/` is a person. A certificate is a
**delegate** of the person it names, and may write `refs/collab/*` and
nothing else.

`cadir/` mirrors `keydir/`, but answers the other question — not "which keys
are this person" but "which CAs may mint delegates of them":

```text
settings.git
├── keydir/xps14/alex.pub        who you are
└── cadir/mint/alex.pub          who may act as you
```

Any OpenSSH CA works. Mint a short-lived credential and hand it to an agent:

```console
$ ssh-keygen -s mint -I claude-a -n alex -V +10m agent_key.pub
```

Nothing on the agent's side is git-collab-specific. `ssh-keygen -s` leaves
`agent_key-cert.pub` beside the key, ssh loads a `-cert.pub` sitting next to
an identity automatically, and git only needs pointing at the key:

```console
$ GIT_SSH_COMMAND='ssh -i agent_key -o IdentitiesOnly=yes' \
      git clone ssh://git.example.com/tools.git
```

A `Host` block with `IdentityFile` and `CertificateFile` in `~/.ssh/config`
does the same when the certificate lives elsewhere.

The cert's principal must name an enrolled person and its CA must be enrolled
*for that name* — `cadir/` lends identity, it never creates it. `access.conf`
never mentions delegates — the person holds the grants and the certificate
borrows them, clipped to `refs/collab/*`. No rule can widen that clip: none
grants a certificate a branch, a release, or a repository creation. The clip
is namespace-scoped, not operation-scoped, though: inside `refs/collab/*` a
delegate inherits whatever the person holds there, rewind and delete
included, so `RW+` on a repo lends every delegate of that person the power to
rewind or delete collab refs — destroying issue and patch history — where
`RW` keeps that out of reach. The same CA key enrolled under two names is
allowed (unlike `keydir/`, where one key
under two names is an authorization coin-flip): a certificate names its
principal, so the lookup runs the other way, and a shared CA is two explicit
opt-ins.

The clip is write-only: a delegate reads whatever the person it acts for
reads, with no narrowing — it could not otherwise prepare a patch against
anything the person can see — so a leaked certificate exposes the person's
whole read surface until it expires.

Revocation is the roster: remove `cadir/mint/alex.pub` and the delegates it
minted die on their next command; remove the person's keys and their
delegates die with them. The cert's own expiry does the rest — there is no
revocation list to maintain.

```text
@admins = alex
@agents = claude-a claude-b

repo settings
    RW+                    =   @admins

repo tools
    RW+                    =   @admins
    RW      refs/collab/   =   @agents
    R                      =   @all

repo agents/[a-z-]+
    C                      =   @agents
    RW+                    =   CREATOR
```

Rules are ordered and **first-match-wins**, over refexes, with `R` / `RW` / `RW+`
to allow and `-` to deny. A refex is a regex anchored at the start; omitted it
means every ref, and one that does not begin with `refs/` is read as being under
`refs/heads/`. `C` grants creating a repository, which is what makes the wild
pattern above work: an agent allocates its own namespace and owns it, with no
central allocator.

Note what the `@agents` line does *not* say. A contributor needs write access to
`refs/collab/*` and to nothing else — patches travel as collab refs, so no
setting anywhere grants an agent credential the ability to move a branch.

Every push to `settings` is validated before it is accepted: the rules must
parse, the keys must be well-formed, and somebody must still be able to push the
config afterwards. A push failing any of those is rejected and the previous
config keeps governing, so the live config is only ever one that works.

**How this interacts with `server.toml`.** The two are split by axis, and the
split is total:

| Question | Governed | Ungoverned |
|---|---|---|
| What may an authenticated principal do? | `conf/access.conf` | `server.toml`'s `[access]` |
| Which keys authenticate at all? | `keydir/` | the `authorized_keys` file |
| What may an anonymous HTTP request see? | `server.toml` | `server.toml` |

When `settings.git` exists, `access.conf` **supersedes** `server.toml`'s
`[access]` outright — those lists are not consulted, not intersected, not
unioned — and `keydir/` supersedes `authorized_keys`. Superseding rather than
layering means exactly one file answers each question, so the two can never
disagree in a way that is invisible in the file you are reading.

`visibility`, `[ui] anonymous` and `[http] anonymous_clone` stay in
`server.toml`, because an anonymous request has no principal for a rule to
match: `@all` means every enrolled key, not the public.

With no `settings` repository, none of this is on and the server behaves exactly
as it did before.

### Releases

```console
$ git-collab release publish v1.2.0 dist/app-x86_64.tar.gz
$ git-collab release list
```

Artifacts upload over SSH and download over HTTP, with server-computed SHA-256
checksums, range requests and resumable downloads.

## Storage

Everything lives in ordinary git objects.

```
refs/collab/issues/<id>              one commit per event, DAG-ordered
refs/collab/patches/<id>/events      the same, for a patch
refs/collab/patches/<id>/rev/<oid>   pins a revision's commit so it survives a rebase
refs/collab/archive/...              closed items, kept out of the working set
```

Each event commit's tree holds the event as canonical JSON alongside its
detached signature and public key. Nothing is stored outside the object
database, so `git gc`, `git fsck` and every other git tool work unchanged.

### Looking at them

The patch refs nest, and that trips up the obvious way to inspect them:
`git for-each-ref` globs do **not** cross `/`, so `refs/collab/*` matches
nothing below the first level and reports it as an empty result rather than an
error. Use a prefix with no glob, or `**`:

```console
$ git for-each-ref 'refs/collab/*'      # nothing, and no error
$ git for-each-ref refs/collab          # everything
$ git for-each-ref 'refs/collab/**'     # everything
```

Better, ask the tool, which knows what each ref is:

```console
$ git-collab refs
patch events    a1b2c3d4e5f6  refs/collab/patches/<id>/events
patch revision  9f8e7d6c5b4a  refs/collab/patches/<id>/rev/9f8e7d6c5b4a...

2 collab refs: 1 patch, 1 revision.
```

It takes `--json`. Nothing reads the ref layouts written by pre-release
versions any more: a command that meets one refuses and names it, and this is
the command it points at — the one thing that still classifies those shapes, so
you can see what a repository holds before deciding whether to re-fetch it or
delete the stale refs. `git-collab-server refs --config server.toml` answers the
same question across a server's repositories.

## Status

Early. The format has changed before and may change again; there is no
compatibility guarantee yet.