README
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.
$ 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:
$ 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:
$ 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:
$ 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:
$ 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.
$ 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:
$ 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
$ 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:
$ 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:
$ 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:
$ 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:
$ 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:
$ 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:
$ 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, defaulttrue) — set tofalseto 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 plainsync.
$ 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:
$ 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:
$ 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:
$ 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:
$ 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:
$ 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.
$ 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.
$ git-collab-server --config /etc/git-collab/server.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:
$ 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.
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":
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:
$ 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:
$ 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.
@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
$ 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 **:
$ 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:
$ 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.
Open Patches
No open patches.
Open Issues
Recent Commits
| Commit | Summary | Author | Date |
|---|---|---|---|
| d2e0ddcd | Close the lease branch follow-up actions | a73x | 2026-09-06 |
| ea15d816 | Add a safe baseline test target | a73x | 2026-09-06 |
| 4d802a02 | Exercise lease commands with abbreviated issue ids | a73x | 2026-09-06 |
| fe3e3c17 | Make ambiguous lease prefix coverage deterministic | a73x | 2026-09-06 |
| c8c2ea7e | Record the loop proven on real VMs, and the bug it found | a73x | 2026-09-06 |
| fef0420c | Resolve an issue prefix when renewing or releasing a lease | a73x | 2026-09-06 |
| 4a60f080 | Record that a tightening fix needs its regression test verified | a73x | 2026-09-06 |
| e0e6ae94 | Record the green baseline and what it cost to get there | a73x | 2026-09-06 |
| 2e8fe285 | Cover the control-socket overflow that broke a sync outright | a73x | 2026-09-06 |
| c461e69f | Load repo trust policy without the machine's global file in tests | a73x | 2026-09-06 |