a73x

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

issueopen, list, show, comment, edit, edit-comment, delete-comment, label, assign, close, claim, unclaim, renew, claims
patchcreate, list, show, diff, comment, review, revise, edit-comment, delete-comment, edit-revision, log, checkout, merge, close
syncfetch, reconcile and push collab refs
hooksinstall and inspect the commit-msg trailer hook
statusproject overview
dashboardinteractive TUI
searchfull-text across issues and patches
lograw event stream, chronological
refslist this repository's collab refs and what each one is
keymanage trusted signing keys
whoami, identityyour identity and its aliases
releasepublish, 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, 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.
$ 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 namecadir/ 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:

QuestionGovernedUngoverned
What may an authenticated principal do?conf/access.confserver.toml's [access]
Which keys authenticate at all?keydir/the authorized_keys file
What may an anonymous HTTP request see?server.tomlserver.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