A complete description of how barerepo works and why it works that way.
This book assumes you have never seen the site. It should be sufficient to build a functionally identical system from scratch. It says almost nothing about visual design, because the visual design is a consequence of the mechanics rather than a cause of anything.
Part I. The argument
Part II. The substrate
Part III. Mechanics
Part IV. The pages
Part V. Operating it
Part VI. Decisions
Part VII. Using barerepo
Part VIII. Building and running it
Appendices A. Ref layout B. Config schema C. Route table D. Hook pseudocode E. CLI reference F. Task index G. Diagrams
Strip away the product and a forge does five things:
Everything else on a modern barerepo is either a social network, a project management tool, or a compliance artifact. Those are real products, but they are not forges, and bundling them is what makes forges slow, complicated, and sticky in the bad sense.
A useful test for any proposed feature: remove it and ask whether the five things above still work. If they do, the feature is optional. Most of GitHub is optional.
GitHub's information architecture solved a specific problem in 2008: how do you let a stranger propose a change to code they do not own, when the stranger cannot be trusted with write access and the maintainer does not want to read a mailing list?
Their answer was the fork plus the pull request. You copy the entire repository into your own namespace, push a branch there, and then ask the original repository to pull from your copy. The web UI makes this feel like one action. Underneath it is repository duplication as a permission workaround.
That answer worked, and every forge since has copied it: Gitea, GitLab, Bitbucket, Forgejo. The copying went deeper than the pull request. It took the whole information architecture with it.
Three consequences follow, and they are the reason barerepo exists.
The repository became the atom. Every view is scoped to one repository. But a person with sixty repositories on one disk mostly wants cross-repository questions answered: where have I used this library, what did I touch last month, which of my projects are failing to build. No barerepo answers these, because the architecture has no place to put the answer.
Discussion left git. Issues and pull request comments live in the forge's database. They do not clone. They do not survive a migration except through a lossy importer. They cannot be read offline. If the host disappears, the code survives and the reasoning behind the code does not. This is the single largest piece of unnecessary lock-in in the industry, and it is unnecessary because git has had a mechanism for attaching data to commits since 2010.
Configuration left the repository. Branch protection, merge settings, collaborator lists, CI permissions, all of it lives in a web form. You cannot diff it, cannot review a change to it, cannot see who changed it or when, cannot revert it, and cannot move it. A team can have a rigorous review process for a one-line code change and no process at all for the setting that governs whether review is required.
None of these are bugs. They are what you get when the coordination layer is the product.
barerepo inverts the ownership question. The server owns almost nothing.
| Thing | GitHub | barerepo |
|---|---|---|
| Identity | email plus password on their server | ssh key you hold |
| Discussion | their database | git notes in your clone |
| Settings | their web form | a file in your tree |
| Proposed changes | a fork in their namespace | a ref in the target repo |
| Merging | their button | git merge on your machine |
| Builds | their compute, metered | your hardware, unmetered |
| Code | git | git |
The consequence is stated plainly and is the entire pitch: if barerepo disappears tomorrow, you lose a web UI and nothing else. Your clone contains the code, the history, the discussion, the configuration, and the build results. You can point it at another host, or at no host, and keep working.
GitHub cannot match this. Their business model requires that leaving is expensive. Ours requires that leaving is free, which is a strange thing to build a business on until you notice that it is the only durable answer to "why should I trust you with my work."
The second thesis is about the distance between the user and the machine.
GitHub inserts itself at every step. A settings page instead of a config file. A
merge button instead of git merge. A runner setup flow spread across four pages
instead of one command. A web editor instead of your editor. A protected branch
rule builder instead of a hook.
Each individual insertion is small and defensible. Together they produce developers who have used git daily for five years and cannot explain what a ref is, because the interface has never required them to know and has actively hidden it.
barerepo shows the command. Every action the UI can perform is displayed as the git or shell invocation that performs it. This is not a power-user affordance in a collapsed panel. It is the primary interface, and the web UI is a viewer over it.
Two things fall out of this that are worth stating explicitly.
Beginners learn. A person who copies git push origin HEAD:refs/proposals/new
twenty times will eventually notice what a ref is. A person who clicks "Create pull
request" twenty times will not.
One question decides a new feature. Does this let the user do something they could not do from a terminal, or does it merely hide the terminal? Build the first. Refuse the second. Almost every request to make barerepo "easier" is a request of the second kind, and almost every one should be declined.
Everything else follows from these. Changing one is a design review, not a ticket.
Rule 1. The server never merges. There is no merge button. A proposal closes when its tip becomes reachable from the default branch. The server observes this; it does not cause it.
Rule 2. Every mutation has a visible command. If the UI can do it, the UI shows how to do it without the UI.
The command shown must be one the user can already run. git, ssh-keygen and
curl are on their machine; barerepo's CLI is not, and telling someone to run a
program they have not installed is an interstitial with extra steps. So wherever
a page names a barerepo command, it names the plain one first and offers barerepo
second, as the shortcut it is. A page that shows only the barerepo form is a bug,
and Part VII's promise that no task needs the CLI is the test for it.
Rule 3. Nothing is stored that is not a git object, except a closed list in chapter 10. Each item on that list carries a written reason it cannot live in git. Adding to it is a design review.
Rule 4. No JavaScript on any page that could render without it. Pages are HTML over the wire. Keyboard shortcuts are the only exception, and they are measured in hundreds of bytes. The budget in chapter 25 is enforced in the test suite, not advertised in the interface.
Rule 5. Anyone authenticated can propose. Write access to refs/proposals/*
is the default for every signed-in user on every public repository. Permission is
only ever about writing to refs/heads/*.
Rule 6. The default branch is master, configurable per repository and per
user with one line and no friction in either direction. Never hardcode either name
anywhere in the codebase. Always read the repository's actual HEAD.
Rule 7. No discovery surface until there is something to discover. No explore, no trending, no stars. A sparse discovery page advertises emptiness, and emptiness is the default state of a new platform for a long time.
You cannot build this system without understanding four features of git that most forges use only incidentally. This part is a working explanation rather than a tutorial.
A ref is a file containing a commit hash. That is the entire concept.
.git/refs/heads/master -> a3f9c2...
.git/refs/tags/v1.0 -> 8b1d44...
.git/refs/remotes/origin/master -> a3f9c2...
A branch is a ref under refs/heads/. A tag is a ref under refs/tags/. They are
the same mechanism distinguished only by directory. Git treats them differently
because of where they live, not because of what they are.
The important consequence: refs/heads/ and refs/tags/ are conventions, not
rules. You can create refs/proposals/47, or refs/anything/you/want, and git
will store it, transfer it over the wire, let you fetch it, and let you check it
out. It simply will not appear in git branch, because that command only reads
refs/heads/.
This is what makes proposals possible without repository duplication. A proposal is a real ref in the real repository, transferred by ordinary git, invisible to anyone's branch list, and subject to whatever access rule you write for its namespace.
Refs may be packed into .git/packed-refs rather than existing as loose files.
Any implementation must use git for-each-ref or the equivalent library call
rather than reading the filesystem directly.
Git notes attach arbitrary data to an existing object without changing that
object's hash. A note is stored as a blob in a tree, in a commit, on a ref under
refs/notes/. The tree is keyed by the hash of the object being annotated.
refs/notes/commits the default namespace
refs/notes/threads/47 a namespace barerepo uses for discussion
refs/notes/runs a namespace barerepo uses for build results
Because a note lives on a ref, it is an ordinary git object: it transfers over the wire, it clones, it survives a mirror, and it can be read offline.
git log --show-notes=threads/47
Notes are not fetched by default. A client must ask for them:
git fetch origin "refs/notes/*:refs/notes/*"
barerepo's CLI does this automatically, and the web UI tells the user the command. This is the one place where the honest answer is slightly inconvenient and we say so rather than hiding it.
The hard part. Two people writing notes concurrently will conflict on the ref,
exactly as two people pushing to the same branch would. Git provides merge
strategies for notes, including cat_sort_uniq and union, which concatenate
rather than failing. For an append-only stream of comments this is correct
behavior. For edits and deletions it is not, and chapter 13 addresses that
directly. Do not defer this problem; it is the most likely thing to make threads
look broken the first time a second person uses them.
Git runs server-side hooks around a push. Three matter.
pre-receive runs once, receives every proposed ref update on stdin as
<old-sha> <new-sha> <refname> lines, and can reject the entire push by exiting
non-zero. Anything printed to stderr appears in the pusher's terminal. This is
where access control lives and where proposal allocation happens.
update runs once per ref and can reject individual refs. barerepo does not use
it; an all-or-nothing push is easier to reason about.
post-receive runs after refs have been updated, receives the same stdin
format, and cannot reject anything. This is where barerepo detects merged proposals,
queues builds, and updates indexes.
Two properties matter for the design. Hooks can print to the user's terminal, which is why barerepo can explain a rejection where the user is actually looking. And hooks run with the repository available locally, which means reachability checks and diff computation are local operations rather than API calls.
A recurring theme in this book: several things forges implement as product features already exist in git, and reimplementing them is what makes forges heavy.
Proposed changes. Gerrit has pushed changes to refs/for/<branch> since 2008,
storing patchsets under refs/changes/. GitHub itself stores every pull request at
refs/pull/<n>/head; you can fetch any pull request on any repository that way
right now. Gitea does the same. The ref mechanism is not novel and not risky. It is
already how every forge works internally, hidden behind a fork abstraction that
exists for permission reasons rather than technical ones.
Attached discussion. Notes, since 2010. Essentially unused by forges.
Access control. Hooks, since forever. Forges reimplement this as database rows consulted by application code.
Merging. git merge, obviously. A merge button is a remote procedure call to
run a command you could have run locally, with the side effect that the server must
now understand merge strategies, conflict resolution, and rebasing.
Configuration distribution. Files in the tree. CI systems already learned this
lesson; .github/workflows is config in the repository. Forges applied the lesson
to build definitions and then stopped.
barerepo's contribution is not inventing a mechanism. It is refusing to build a second, worse mechanism on top of the one that already exists.
An account is a name plus one or more ssh public keys. There is no email address, no password, and no verification link.
An email address on a forge is a hostage. It is the recovery channel, so it is the account, so losing access to it or having it disputed loses the account. It is also the vector for the notification firehose that every forge eventually builds.
An ssh key is different in kind. The user already generates one to push. The server only ever holds the public half. Possession is the credential, and possession is not something the server can revoke, transfer, or lose on the user's behalf.
The form collects a name and one public key, and then asks you to sign a nonce with that key.
1. POST /signup { name, pubkey } -> nonce
2. POST /signup { nonce, signature } -> account, session
Validate that the name is unused and matches ^[a-z0-9][a-z0-9-]{0,38}$. Validate
that the key parses as a supported type, and that no other account holds it.
Then hand back a nonce and wait for a signature over it.
Why signing, when the key alone would do. A public key is public. GitHub
publishes everyone's at /<user>.keys, and people paste theirs into issues. A
key is also unique to one account here, so without proof of possession anyone
can register a key they found and the person holding the private half can never
use it. There is no email to appeal to, per this chapter, so that is permanent.
Signing costs one command and closes it.
It also stops a slower mistake. Paste the wrong .pub, or one whose private
half is long gone, and without a signature you find out weeks later when you
try to sign in and cannot.
The namespace is barerepo-signup, not barerepo-auth. With one namespace a
signature captured from a sign-in could be replayed to claim an account with
somebody else's key, which is the attack this step exists to stop.
There is still nothing to verify in the email sense. No message is sent, no link is clicked, nothing is waited for. The signature is checked in the same second it arrives, and the account exists immediately after.
The signup page must state the tradeoff plainly rather than burying it: losing every key loses the account, because there is no out-of-band recovery channel. Push the user toward adding a second key immediately. This honesty is the cost of not holding an email hostage, and hiding it would be dishonest in a way the rest of the design is not.
Challenge and response against the stored public key.
1. client: POST /auth/challenge { name }
2. server: generate 32 random bytes, store with 10 minute expiry, return it
3. client: sign nonce with private key
4. client: POST /auth/verify { name, nonce, signature }
5. server: verify against every stored key for that name
6. server: on success, issue a session cookie
The CLI does steps 1 through 4 in one command:
br auth john
It prints a line to paste into the browser. The web page shows this command rather than a password field, because there is no password.
A name with no account is told so. An account name is public: it is a URL
namespace, and anyone can look at /<name> and see whether it is taken. Holding
that back at sign-in protects nothing, and it costs the user a dead end. They
sign a nonce the server never stored, and the failure arrives several steps
later wearing the wrong message. Say it at the first step, and offer signup.
Ten minutes, not one. Signing the nonce means leaving the browser, finding a terminal, running a command and coming back. A minute is enough time for a script and not enough for a person, and the person is who this flow is for.
The window is not what makes the exchange safe. The nonce is public: it is printed on the page. What proves possession is the signature, which needs the private key. Replay is stopped by deleting the nonce when it is used, whether or not the signature was any good, so a spent challenge cannot be tried twice.
Session cookies are opaque random tokens in a server table, HttpOnly, Secure,
SameSite=Lax. Not JWTs. There is no scaling problem here that a table lookup does
not solve, and revocation matters more than statelessness.
Over ssh, the ssh daemon authenticates by key before barerepo sees the connection.
Map the key fingerprint to an account and hand off to git-upload-pack or
git-receive-pack with the account in the environment.
Over https, use a token in the password field. Same tokens as the runner tokens in chapter 15, different scope.
Rule 3 says nothing is stored outside git except a closed list. Here it is, complete. Each entry states why it cannot be a git object.
john/johnbot belongs to john. Set at creation.
A config file cannot grant its own authority. See chapter 11.br auth trades for a browser session. Secrets, and therefore not
committable.last_visited timestamp. All discardable. All
rebuildable from git alone.This list was four items in an earlier draft and the count was wrong twice. Redirects and artifacts were added to the design without being added here, which made rule 3 false while it was still being cited. Then runners, the job queue and webhook failures were built and the list stayed at six, which made it false again. The count is stated plainly because a closed list that grows without saying so is worse than an open one, and this one has grown twice.
Two things that look like they belong here and do not:
git update-ref with an expected old value gives atomic allocation for
free, and the number then travels with the repository. See chapter 12.Item 6 is the only one that may grow, and only with things that can be deleted without loss.
A repository is a bare git repository on disk plus a row recording who owns the namespace.
/var/lib/barerepo/repos/john/johnbot.git
Configuration lives in .barerepo/config inside the repository, including the list of
who may push. This creates a circularity: if the file grants push access, and push
access lets you edit the file, then anyone who can push can grant themselves
anything, and there is nothing to bootstrap the first grant.
The resolution is that ownership comes from the URL namespace and is not editable
from inside the repository. john/johnbot is owned by john because it is under
john, which was decided at creation. The owner may always push, always edit the
config, and always transfer or delete the repository. Everyone else derives their
access from the config file, which the owner controls.
This is why namespace ownership is item 2 on the closed list. It is the one grant that cannot be self-referential.
There are two ways to create a repository. Both produce an identical result.
From the web form.
POST /new { name, description, default_branch, visibility }
Create the bare repository. Run git symbolic-ref HEAD refs/heads/<default_branch>.
Install the hooks. Record ownership. Do not create an initial commit, a README, or
a license file. An empty repository is empty, and chapter 24 describes the page
that makes that useful.
By pushing to a name that does not exist.
git remote add origin git@barerepo.example:john/johnbot
git push -u origin master
The repository does not exist. The server creates it, then accepts the push.
This exists because the web form is an interstitial. Without it the sequence is: leave the terminal, open a browser, fill a form, copy a remote URL, return to the terminal. The push already contains every fact the form was asking for.
The form is kept because it is discoverable, because it is how someone creates an empty repository to push to later, and because a person who has not yet pushed anything needs somewhere to start. The form tells the user the shortcut exists.
Where creation happens. Not in a hook. There is no repository yet, so there
are no hooks to run. Creation happens in barerepo ssh for ssh, and in the http
handler for https, after authentication has identified the account and before
git-receive-pack is invoked.
A pushed .barerepo/config decides at once. The default below applies when
the push carries no config file. If it carries one that says
visibility = "public", the repository is public the moment it lands, because
the file is the setting. Cloning a public repository and pushing it to a new
name therefore produces a public repository, which is the file being obeyed and
not the default being lost.
Defaults on push-to-create.
post-receive points it at the branch that actually came..barerepo/config.Where visibility actually lives, and why the form cannot store it.
Visibility is [repo] visibility in .barerepo/config, which is a file in the
tree. A repository with no commits has no tree, so there is nowhere to put it,
and the rule above says do not create an initial commit. The server will not
keep a copy either, because that is rule 3.
So an empty repository is private, whichever way it was made. The web form's visibility field does not write anything at creation. If the user picks public, the empty-repository page adds two lines to the block it tells them to paste:
mkdir -p .forge && printf '[repo]\nvisibility = "public"\n' > .barerepo/config
git add .barerepo/config
This is rule 2 doing real work rather than decoration. The setting is a commit with an author and a date from the first moment it exists, and the user has seen the file that holds it before they ever go looking for a settings page.
Rules that must hold.
lisa/thing by john is rejected, not created.refs/proposals/new never creates a repository. A proposal pushed to a
nonexistent repository is a typo, not a contribution.The cost, stated plainly. Typos create repositories. A push to jonhbot
creates jonhbot. Gitea has this exact behavior and this exact problem. Two
mitigations: print the created repository's URL prominently in the push output so
the mistake is visible immediately, and keep deletion easy.
Make this behavior a server option, allow_push_to_create, default on. An
administrator running a locked-down instance will want it off.
Set at creation, defaulting to master, changeable to anything with one field.
Stored as HEAD in the repository itself, which is where git already keeps it, and
mirrored into .barerepo/config for visibility once a first commit exists.
Never hardcode either name. Read HEAD. This is stated three times in this book because it is the single easiest way to accidentally ship a bug that only affects half your users.
The core mechanism. Read chapter 6 first if refs are unfamiliar.
contributor server maintainer
git push HEAD:
refs/proposals/new ------> pre-receive
allocate 47
rewrite ref
<------ print URL
refs/proposals/47
state: open
<----- git fetch
refs/proposals/47
git merge prop-47
post-receive <----- git push
is-ancestor? yes
state: merged
the server never
ran git merge
A contributor clones, commits, and pushes to a magic ref:
git clone https://barerepo.example/john/johnbot
cd johnbot
git commit -am "fix panic on empty config"
git push origin HEAD:refs/proposals/new
No fork. No permission grant. No repository duplication.
pre-receive sees an update to refs/proposals/new, which is reserved and never
actually created. It allocates the next integer for this repository, rewrites the
destination, and prints the resulting URL to the pusher's terminal.
How a push to one ref lands on another. pre-receive cannot do this: it can
only accept or reject. The rewrite is done by git's proc-receive hook, which
exists for exactly this and which Gerrit-style workflows are the reason for. It
speaks pkt-line on stdin and stdout, performs the ref update itself, and reports
back the ref that was really written, so the pusher's own terminal prints
refs/proposals/47 rather than the magic name.
It runs only for refs that match receive.procReceiveRefs, which barerepo sets per
repository when it installs the hooks:
git config --replace-all receive.procReceiveRefs refs/proposals
The value is a prefix, not a glob. refs/proposals/* matches nothing, the
hook is then never called at all, and and the push creates a ref literally
named refs/proposals/new. There is no warning, and the only symptom is the
wrong ref appearing.
The maintainer reviews locally:
git fetch origin refs/proposals/47:prop-47
git merge prop-47
git push
post-receive then observes that refs/proposals/47 is now an ancestor of HEAD
and marks the associated thread merged.
The server did not merge anything. It watched a push happen and drew a conclusion. This is rule 1, and it eliminates every line of server-side merge strategy, conflict resolution, and rebase logic that other forges carry.
Proposal numbers and thread numbers share one counter per repository, because a proposal is a thread with a ref attached.
The counter is a ref, not a server table.
refs/meta/counter
It points at a blob containing an integer. Allocation reads it, then writes the
new value with git update-ref refs/meta/counter <new> <expected-old>. The
expected-old argument makes the write fail if another push moved it first, so
concurrent allocation is atomic without a lock or a transaction.
This keeps the counter out of the server-owned list in chapter 10, and it means the number travels with the repository through a mirror, a transfer or a copy.
On every push to refs/heads/*, for each open proposal in the repository:
git merge-base --is-ancestor refs/proposals/<n> <new-head>
Exit 0 means merged. Close the thread and record the merge commit.
This is O(open proposals) per push, and each check is fast, but on a repository
with hundreds of open proposals it is worth checking only proposals whose tip is
in the pushed commit range. Compute the range once with
git rev-list <old>..<new> and test membership.
Push again to the same ref:
git push -f origin HEAD:refs/proposals/47
Force is required because the history may have been rewritten. barerepo records each
push as a new revision in the thread, in the pusher's own name, saying which
revision it is and whether it rewrote the last one. Without that line a reviewer
reads comments about code that is no longer there and cannot tell where the change
happened. The previous tip is retained in refs/revisions/47/<k> so review comments
anchored to old commits do not dangle. Old revisions expire per chapter 26.
Why revisions are not under refs/proposals/47/. They cannot be. A ref is a
file, so refs/proposals/47 and refs/proposals/47/rev/1 ask git for a file
and a directory of the same name, and git refuses:
cannot lock ref 'refs/proposals/47/rev/1':
'refs/proposals/47' exists; cannot create 'refs/proposals/47/rev/1'
refs/proposals/47 has to stay exactly where it is, because chapter 36.5 tells
every reviewer to fetch it by that name. So the revisions move instead.
Only the proposal's original author and users with push access may update a given proposal ref.
The author is the account that pushed, recorded in the thread's meta blob.
Never the commit author. A commit's author is whatever the pusher typed into
git config user.name, so deciding access from it lets anyone take over a
proposal by picking the right name. The two differ constantly in ordinary use
as well: applying somebody's patch and pushing it is normal.
Gerrit pioneered this mechanism and is widely disliked. The dislike is almost entirely about policies layered on top of the mechanism rather than the mechanism itself. barerepo takes the mechanism and refuses the policies:
Change-Id trailer, and therefore no commit-msg hook to install.If a future version wants enforced linear history or gated review, it can add them, and it will inherit the same complaints. The default is loose.
open ref exists, not reachable from HEAD
merged ref is an ancestor of HEAD
closed explicitly closed by owner or author, ref retained
abandoned no activity for the expiry window, ref eligible for GC
There is no "draft" state. Do not push it if it is not ready.
A thread is a discussion. If a proposal ref is attached, it is what another forge would call a pull request. If not, it is what another forge would call an issue.
There is no type field and there are no separate lists. This is not a simplification for its own sake. On every other forge, the moment an issue needs code, someone opens a second object and links the two by hand, and the discussion splits across both. Unifying them removes an entire category of bookkeeping.
refs/notes/threads/<n>
The tree is keyed by the object each note annotates, because that is what git notes is, and it is what makes the commands in 35.4 work:
git log --show-notes=threads/47
git notes --ref=threads/47 show <sha>
A note is one blob at the path <sha>, holding the comments on that object in
order. Each comment is a record:
author: lisa
time: 1787074650
anchor: config.go:43 (optional, for line comments)
revision: 2 (optional, which proposal revision)
fresh install, empty config.toml, immediate nil deref on line 44.
Headers, blank line, markdown body. The shape is an email message, because that
format has survived fifty years of adversarial use. Records are
separated by a line containing only --, and are written in time order.
Comments are records inside one note, not one blob each. An earlier draft
named a blob per comment, <unix-timestamp>-<author>-<short-hash>, so that
lexical sort was chronological. That layout cannot be read by git: git notes
looks up a note by the annotated object's hash, so a tree keyed by anything
else is invisible to every command in 35.4, and the offline promise in chapter 3
becomes false. Appending records is also exactly the shape union merge
resolves correctly, which is what chapter 7 needs.
Thread metadata lives in a blob named meta at the root of the same tree.
title: panic when config file is empty
state: open open | merged | closed | abandoned
ref: refs/proposals/47 (optional, present if a proposal is attached)
opened: 1787074650
author: lisa
meta is not a valid object hash, so git notes ignores it and every command in
35.4 keeps working. barerepo reads it. This is verified rather than assumed: a
meta blob alongside real notes does not disturb git log --show-notes.
A comment may carry an anchor header such as config.go:43. The file will
change. The anchor must not silently point at the wrong line. Chapter 37
specifies the resolution rule in full.
Two people commenting at the same time both push to refs/notes/threads/47. The
second push fails as non-fast-forward, exactly as it would on a branch.
For an append-only comment stream, git's union merge strategy for notes resolves
this correctly, because two comments are two separate blobs and the union of the
trees is the desired result. Configure it:
git config notes.rewriteMode ignore
git config notes.mergeStrategy union
Server-side, on conflict, fetch, merge with union, retry the write. Bound the retries and fail loudly rather than silently dropping a comment.
Edits and deletions are not append-only and union merge does not handle them. Pick one of two answers before shipping:
barerepo chooses tombstones, because a discussion that can be silently rewritten is worth less than one that cannot, and because the whole premise is that the clone contains the truth.
git fetch origin "refs/notes/*:refs/notes/*"
git log --show-notes=threads/47
Every thread page shows this. It is the proof of the claim that the discussion is yours.
.barerepo/config at the tip of the default branch, TOML.
[repo]
default_branch = "master"
visibility = "public"
description = "irc bot that refuses to leave"
[access]
push = ["john", "lisa"]
[proposals]
accept_from = "anyone" # anyone | authenticated | push
require_runs = ["build", "test"]
expire_days = 180
[runners]
"uproar.local" = ["build", "test"]
"lisa-mbp" = ["build"]
[build]
command = "make ci"
image = "golang:1.26"
[[webhook]]
url = "https://example.com/hook"
events = ["push"]
secret_env = "DEPLOY_HOOK_SECRET"
On every push, from the tip of the default branch, before the access check. Cache by tree hash; the file changes rarely and the parse is on the hot path.
A malformed config file must not lock anyone out. On parse failure, fall back to the last known good version and print a warning to the pusher's terminal. The owner can always push regardless, because owner access comes from the namespace and not from the file.
Because changing it is a commit. It has an author, a timestamp, a diff, a revert, and a review path. A team can require review for a change to who may push, using the same mechanism they use for code, which no settings page can offer.
It also means configuration clones. Moving a repository to a new host moves its policy with it.
Builds run on machines the user owns. barerepo dispatches and records; it does not execute.
Hosted CI is the expensive part of running a forge and the metered part of using one. Removing it removes the largest operating cost and the most common reason to hit a paywall. It also means a build has access to whatever the user's machine has, which is frequently the actual requirement.
The whole flow is one line, and this is the most visible proof of the no-interstitial principle:
curl -sL barerepo.sh | sh -s rt_live_7Kq2mXe
The token is in the copied line. There is no subsequent step, no config file to create, no web form to fill in after the install, no OS selector to click. One copy, one paste, and the page the user copied from updates the moment the runner attaches.
Three platforms are displayed simultaneously rather than behind tabs. Tabs hide two thirds of the answer to save nine lines of vertical space.
runner (your laptop) server
POST /runner/attach ------------> verify token
<------------ runner_id
GET /runner/poll ------------> hold up to 30s
<------------ 204, or a job
clone at sha
run command
POST /runner/log ------------> append to buffer
POST /runner/log ------------>
POST /runner/done ------------> write refs/notes/runs
GET /runner/poll ------------> loop forever
every arrow points right first.
the server never opens a connection to the runner.
no inbound port. no public address. NAT is fine.
The runner dials out and long-polls. It never needs an inbound port, a public address, or a hole in a firewall, which is what makes "paste this on your laptop" actually work.
1. runner -> POST /runner/attach { token, hostname, os, arch, labels }
2. server -> { runner_id, poll_interval }
3. runner -> GET /runner/poll?id=... (long poll, 30s timeout)
4. server -> 204 no content, or a job:
{ job_id, repo, ref, sha, command, image, clone_url, job_token }
5. runner -> clone at sha, run command, stream output
6. runner -> POST /runner/log { job_id, seq, chunk } (repeatedly)
7. runner -> POST /runner/done { job_id, exit_code, duration }
8. goto 3
job_token is scoped to one job and one repository, expires when the job ends, and
is what the runner uses to clone. The long-lived runner token never leaves the
runner.
A runner that stops polling for three intervals is marked offline. A job whose runner disappears mid-run is requeued once, then marked failed with a distinct status, because an infinite requeue loop on a poison job is the classic failure mode here.
On push to any ref, if [build] command is set, create a job for the new tip.
Otherwise read .github/workflows and create a job per workflow job, per chapter
15A. Queue per repository, first in first out.
A job carries the labels it asked for. A runner takes the first queued job it satisfies, rather than the first queued job, so a build waiting for a machine nobody has attached does not hold up the builds that could run now.
Tokens are issued per repository, displayed once at creation inside the command, and revocable from the keys page. Store a hash, not the token. Rotating a token requires re-pasting the line, which is one command, which is the point.
A repository arriving from GitHub already says how it builds, in
.github/workflows. barerepo reads that file rather than asking for it again.
Why this is not a contradiction. Chapter 3 names configuration distribution
as something git already solved, and credits .github/workflows with being the
place that learned it: config is a file in the tree. The objection in this book
has never been to that file. It is to a forge that can only be configured
through its own web forms. Reading a workflow is honouring the same rule that
.barerepo/config honours.
The aim is a workflow that needs no edit. A repository should be able to arrive with the file it already has and build. Every decision below follows from that, and where barerepo cannot do something it says so rather than asking for the file to be changed.
What barerepo does with it. Every run: step becomes a line in one shell
script, in order, beginning with set -e, because GitHub fails a job at its
first failing step and a script without it would run on and report the last
command. env: becomes exports, working-directory: a subshell, and
container: the image.
The environment GitHub sets is set: CI, GITHUB_ACTIONS,
GITHUB_REPOSITORY, GITHUB_REF, GITHUB_REF_NAME, GITHUB_SHA,
GITHUB_WORKFLOW, GITHUB_JOB, GITHUB_EVENT_NAME, and the runner's own
RUNNER_OS, RUNNER_ARCH and RUNNER_TEMP. A workflow that reads those needs
no change.
The expressions naming that same context are filled in: github.sha,
github.ref, github.ref_name, github.repository, github.event_name,
github.workspace, github.workflow, github.job, runner.os, runner.arch
and runner.temp. This is not an expression evaluator and will not become one.
It is a substitution, and it exists because ${{ reaches sh as a bad
substitution and fails the step outright.
actions/checkout is answered rather than run, because barerepo cloned the
repository at the commit already. actions/cache is answered as a statement
that barerepo does not cache. The setup- actions become a check that the tool is
on the machine, and print the version the workflow asked for beside the version
the machine has.
Why a setup action checks rather than installs. The runner is a machine the user owns. A build that installs a toolchain onto somebody's laptop without saying so does something the person who pasted one line did not agree to. Chapter 15 says a build has access to whatever the machine has; it does not say a build may change what the machine has.
A matrix builds a job several times, so barerepo queues it several times. The
axes are multiplied, exclude removes what it names, and each combination
becomes its own job with its own runs-on. The combination is substituted into
the command, the image and the machine, and exported as MATRIX_<AXIS> so a
step reading the environment works too.
Each job carries its combination in its name, test (go 1.26, os ubuntu-latest),
because a commit built four times is unreadable otherwise. The name reaches the
run, so the runs page says which combination failed.
include is not applied. It can add keys to a combination and whole
combinations that no axis names, and a wrong guess there runs a build the
workflow did not ask for. barerepo says it did not apply it and builds the axes.
What barerepo declines, out loud. A step using any other action. A step or job
behind an if:, because an expression is a program and barerepo has no evaluator.
A step asking for a shell barerepo cannot start. Any expression outside the lists
above, secrets first among them.
Each of these is printed in the push output, naming the step and the reason. This is the rule the whole feature rests on: a skipped step is never silent. A build that reports success after running half of what was asked is worse than no build at all, and it is the failure mode every partial implementation of somebody else's format tends toward.
runs-on and the machines you actually have. barerepo cannot conjure a
machine. ubuntu-latest is matched against the operating system a runner
reported when it attached, as are the other hosted images; self-hosted is
always true, because every runner here is. Anything else must be a label the
runner declared.
When no attached machine satisfies a job, barerepo does not queue it and does not fail without saying so. The push says which machine the job wanted and prints the link to the add-a-runner page:
unit wants a ubuntu-latest machine and none is attached.
attach one: https://barerepo.example/john/johnbot/runners/new
Precedence. [build] command wins. A repository that has told barerepo how to
build in barerepo's own file is not second-guessed, and the workflow is not read.
This keeps chapter 14's file the answer for repositories that want one command,
and makes the workflow the answer for repositories that arrived with one.
What this is for. It removes the largest cost of moving a repository here, which is rewriting CI before anything builds. It is not an implementation of GitHub Actions, it will not become one, and the list of declines above is the honest boundary rather than a roadmap.
Build results are notes on the built commit:
refs/notes/runs
One ref, with the note tree keyed by the commit that was built. Not one ref per
commit: refs/notes/runs/<sha> would give a busy repository a ref for every
commit it ever built, and git log --show-notes=runs would show nothing,
because git looks a note up by the annotated object's hash and not by the ref's
name. The claim below, that build history clones and is readable, holds only
with a single ref.
The two layouts also cannot coexist. refs/notes/runs and
refs/notes/runs/<sha> are a file and a directory of the same name, so a
repository that used per-commit refs cannot move to this one without deleting
all of them first.
{
"runner": "uproar.local",
"labels": ["build", "test"],
"ref": "refs/proposals/47",
"started": 1787074650,
"duration": 18,
"exit": 0,
"log": "<blob-sha>"
}
ref is what was being built. A commit can arrive on a branch and on a
proposal, and the runs page in chapter 24 shows which, so the record has to
carry it: the commit alone does not say.
A commit can be built more than once, by a rerun or by two runners with
different labels. Each run is one record, and records are separated by a line
containing only --, exactly as thread comments are in chapter 13.
Log output under 64kb goes inline in an output field. Larger output is written
as a blob and named by log instead, so a long build does not make every reader
of the notes ref pay for it. Both are git objects, so build history clones with
the repository, which no other forge offers.
Raw text, in order, on one page. No collapsible step sections, no per-step timing theater, no live-updating spinner. If the log is 18kb, all 18kb are on the page.
The reason is not minimalism. A failing build is read by someone who wants to find
an error message, and the fastest way to find an error message is ctrl-F on a
page that contains all of the text. Collapsible steps defeat browser search, which
is the single most useful tool the reader has.
One index, one result set, spanning code, threads, and repositories. No tabs, no scope selector, no query syntax to learn before the first useful result.
Index on push, incrementally, from the pushed range rather than a full rescan. Index thread comments on note write. The index is item 6 on the closed list: derived, discardable, rebuildable from git alone with a full rescan.
Index everything. Filter at query time.
Every indexed document carries its repository id. Every query is filtered against the set of repositories the requesting user may read, per chapter 18.
This is a security requirement and is not a configuration option. Get it wrong and private code leaks through search results, which is a cross-tenant data breach of exactly the kind that has hit other forges.
Do not solve it by indexing only public repositories. That makes search useless to the person who most needs it, which is the owner searching their own private work.
Filter in the query, not after it. Filtering the result set after ranking leaks the existence and count of private matches.
Rank code matches above threads above repositories, on the theory that someone searching a forge is usually looking for a symbol. Show the matching line with the match highlighted, plus enough surrounding context to recognize it.
Cross-repository search is the default. This is the one place where barerepo answers a question no other forge answers well, because it is the one place where the architecture is not repository-scoped by default. Chapter 2 identified "the repository became the atom" as a core mistake; search is where fixing it is cheapest.
The complete matrix. There is nothing else.
| Namespace | Who may write |
|---|---|
refs/heads/* |
namespace owner, plus [access] push |
refs/tags/* |
same |
refs/proposals/new |
per [proposals] accept_from, default anyone authenticated |
refs/proposals/<n> |
that proposal's author, plus anyone with push |
refs/notes/threads/* |
anyone who may read the repository |
refs/notes/runs |
the server only, via job completion |
| everything else | nobody |
The namespace owner may write any ref in their own repository, including
refs/meta/* and refs/notes/runs. Everyone else follows the table exactly.
This is what makes chapter 40.3 true. git push --mirror carries the counter,
the run notes and every proposal ref, so a matrix that refuses them leaves a
repository that can be taken and never put back, which is chapter 3's claim
with the second half missing. It grants nothing that was withheld: an owner who
wanted to fake a build result could already push whatever content they liked.
The force-push and deletion rules below still apply to the owner's branches, because those protect the owner from themselves rather than from anyone else.
Read access is binary per repository: public or private, from [repo] visibility.
Private repositories are readable by the owner and by [access] push.
Absent means private, and only the exact word public opens a repository.
This matters more than it looks. A repository created by a push has no
.barerepo/config at all, because the server does not commit one, so "no file"
has to resolve to something. It resolves to private. So does an empty value, an
unknown value, and a typo. Nothing but public publishes code.
The rule follows from chapter 11: accidentally publishing code is not recoverable, and accidentally hiding it is one line in a file. A default that can be reached by misspelling a word must be the recoverable one.
Write access says who may push. It does not say what they may do.
Default: force-push and deletion are allowed on every branch except the default branch.
This is a correctness default rather than a policy preference. A force-push to the default branch destroys other people's work with no undo and no record. A force-push to a topic branch destroys only the pusher's own work.
Override per repository:
[access]
allow_force_push = ["master"]
allow_delete = []
Proposal refs are exempt. Force-pushing your own proposal is how you update it, per chapter 12.
Log every force-push and every ref deletion with the old hash. The commits stay reachable until garbage collection, so a logged old hash is a recovery path.
There are no teams, no organizations, no roles, and no per-path rules. If a repository needs those, it has outgrown barerepo, and saying so is more honest than building a permissions engine nobody can reason about.
A repository that distributes what it holds can refuse a commit that carries no signature.
Default: off. Every repository takes unsigned commits.
[access]
require_signed_commits = true
With it on, pre-receive walks the commits the push adds to a branch or a tag, and
refuses the whole push unless every one of them carries a signature that verifies
against a key the server holds. The keys are the ssh keys accounts published for
authentication, written into an allowed_signers file whenever a key is added or
retired. A signature made with a key barerepo has never seen is refused the same as
no signature at all.
The namespace matters. Each entry is written namespaces="git", so a signature
made to sign in cannot be replayed as a commit signature.
A retired key keeps vouching for what it already signed. Removing a key does
not delete it; it records the moment it stopped granting access and writes
valid-before on its entry. git checks a signature against the commit's own
timestamp, so work signed while the key was live still verifies, and anything
signed after the key retired does not. Rotating a key therefore costs nothing and
rewrites no history. The retired key opens no door: it leaves authorized_keys in
the same write.
The owner is not exempt. A rule that the owner can walk past protects the repository from everybody except the person most able to break it.
Comment refs are exempt. Chapter 13 writes those from a client on a reply, and no one signs a comment.
If the allowed_signers file is missing, the push is refused and says so. It lives
in the cache, which may be deleted at any time, so the server writes it at every
start and barerepo doctor puts it back. A repository that asked for signatures
must not quietly stop checking them.
On GitHub, adding a collaborator is how you let someone contribute at all. Here,
anyone can already push proposals and open threads without permission. Adding
someone to push only means they may write to master directly and skip their own
proposal. Most repositories never need to add anyone, and that is a feature.
Chapter 30 rejects a notification inbox of the GitHub kind and rejects email as a primary channel. That rejection left a hole: a proposal arrives and the owner finds out by chance. A forge nobody hears from is broken.
The answer is a chronological event log, readable as a page or as Atom.
An event is written when something happens that another person would want to know about:
proposal.opened proposal.updated proposal.merged
thread.opened thread.replied thread.closed
push run.failed repo.transferred
run.succeeded is not an event. A green build is not news.
There is no watch button and no subscribe button. Participation is subscription. If you have not touched a thread, you do not hear about it.
/inbox. Newest first. One line per event: what happened, where, who, when.
Events older than 90 days are dropped. This is a feed, not an archive. The archive is the repository.
Read and unread flags would be per-user, per-event state that is not derivable from git, so it would have to be added to the closed list in chapter 10 as a new category rather than folded into item 6.
So there are no unread flags. There is one last_visited timestamp per account.
The page draws a horizontal rule at that point, so you can see what is new since
you last looked.
That timestamp is item 6 on the closed list: discardable. If it is lost you lose a horizontal rule.
Every feed is also Atom. This is the entire notification system.
/inbox.atom?token=<feed-token> your inbox
/john/johnbot.atom one repository, public only
/john/johnbot/threads.atom threads in one repository
/john.atom one user's public activity
Atom suits this design exactly. It is static XML, needs no JavaScript, needs no account for public feeds, and works in software the user already chose.
Atom and not RSS, for three reasons that bite this payload in particular. RSS
2.0's <description> has no defined content model: plain text, HTML and escaped
HTML are all in use and readers guess. barerepo emits commit subjects and thread
titles holding <, & and backticks, which is exactly where the guess goes wrong.
Atom gives every text construct a defined content model, text unless the element
says otherwise, so there is nothing to guess. barerepo sends plain text and says so by
saying nothing. RSS
dates are RFC 822, with two digit years and named timezones that readers parse
differently; Atom mandates RFC 3339. And Atom requires a unique id per entry,
where RSS's guid is optional with muddled permalink semantics, so a reader that
dedupes by URL shows the same event twice when a URL changes.
For a title, a link, a date and a line of text, either format would work. The reasons above are edges, not the middle. Both is the wrong answer: two formats is two code paths and two sets of escaping bugs, for a notification system this chapter keeps small. One correct feed beats two adequate ones.
The link says feed, not atom. The format is a fact about the bytes and the
word is what a person hunts for. Somebody looking for a feed does not know they are
looking for Atom, and the URL still ends in .atom for anything that cares.
The feed token is separate from the session and read-only. Revoke it on the keys page. A feed reader stores URLs in plain text, so a URL that grants write access is a bad idea.
barerepo sends no email and runs no mail service. There is no SMTP configuration, no bounce handling, no deliverability problem, no unsubscribe flow and no address to leak in a breach.
If you want email, point a feed-to-email service at your Atom URL. That is somebody else's job and they are better at it.
LFS is off. [behavior] allow_lfs = false.
A forge for source code is not a file host. LFS adds a second storage system, a second transfer protocol, a second authentication path, and a second place for data to be lost or leaked.
Turning LFS off is not enough on its own. Without a limit, a user commits a 4 GB video directly into git. That is worse than LFS, because it is in the history permanently and every clone pays for it forever.
[limits]
max_blob_mb = 100
max_push_mb = 2048
pre-receive rejects any push containing a blob over max_blob_mb. The message
must name the file and its size. A rejection that says only "push too large" sends
the user hunting.
Check blob sizes with git cat-file --batch-all-objects --batch-check over the
pushed range, not over the whole repository.
Two requirements, both non-negotiable, both drawn from a real 2026 vulnerability in Gogs where neither was met:
Large files belong in object storage, with a URL or a checksum in the repository. The build fetches them. Say this in the rejection message.
Chapter 12 removed the fork as the contribution mechanism. That is correct, and it created a different problem: the word "fork" also means taking a project somewhere its maintainer will not go, and that is a legitimate and important thing to do.
Without an answer, "no forks" reads as "you cannot leave this project", which is the opposite of what barerepo is for.
So copying is supported and is called copying.
barerepo copy john/johnbot lisa/johnbot
Server-side, using git alternates, so a 4 GB repository is not pulled down a home connection and pushed back up.
What is copied. Branches, tags, all history, threads and notes.
What is not copied. Proposal refs. They belong to the original conversation.
What alternates cost. A copy made this way borrows the original's objects
through objects/info/alternates, which is what makes it instant. It also means
the copy is not standalone: delete the original and the copy loses the history
it never had its own copy of.
So a delete detaches its dependents first. git repack -a -d writes every
borrowed object into the copy, the alternates file goes, and the copy stands on
its own. This runs before the original is moved to trash, and a delete that
cannot detach a dependent fails rather than proceeding.
Without that step, copying is a way to lose somebody else's work by deleting your own.
What does not exist. No "forked from" badge. No fork network graph. No upstream tracking. No automatic pull request back to the original. It is a fast copy, not a relationship.
If the copier wants to contribute back, they push a proposal to the original like anyone else. That path was never blocked.
A repository or an account can be renamed. The old name is reserved permanently and redirects.
The old name is never freed. This contradicts the instinct to recycle unused names, and it is meant to. A recycled account name inherits the old identity's comments, proposals and mentions in every thread that references it. That is identity confusion, and reserving a string is cheap.
Renaming an account rewrites authorized_keys and every repository path in the
namespace. Rate limit it. Once a year is generous.
[repo]
archived = true
An archived repository is read-only. Pushes are rejected with a message saying so. Threads accept no new comments. Builds do not run.
The owner is exempt, and has to be. Unarchiving is one line in
.barerepo/config, and that line arrives by push. If archiving rejected every
push, nothing could ever be unarchived and the flag would be a one-way door.
The owner's push always runs, for the same reason the owner can always push a
broken config in chapter 14: owner access comes from the namespace, not from the
file, so the file can never lock the owner out of the file.
It stays visible, clonable and searchable. Archiving says "this is finished", not "this is gone".
Unarchive by setting the flag back and pushing. It is one line in a file, like everything else.
A release is a tag, a body and some files.
The body is markdown, stored in refs/notes/releases as a note on the tag
object, so it clones with the repository and git notes --ref=releases show <tag> prints it. One ref keyed by object, for the reason in chapter 16.
Attached files go in the blob store, not in git.
Why not in git. Build outputs are derived, large and numerous. Putting them in git means every clone downloads every binary of every version forever. That is how repositories become unusable.
This dents the portability claim in chapter 3, and the book will not pretend otherwise.
git clone --mirror takes the code, the history, the threads, the config, the
release notes and the build logs. It does not take attached binaries.
That is acceptable, because artifacts are derived from source that you do have. Losing them is not losing data. Say this in the release UI rather than letting a user discover it during a migration.
[limits]
artifact_retain_days = 90
Build artifacts expire. Release artifacts do not, because a published download that disappears breaks other people's installers.
A run can attach its output to a release. The job token from chapter 15 carries the permission, scoped to one repository and one job.
A webhook is the escape hatch. It is what makes it acceptable to refuse every integration request forever. There is no marketplace, no apps, no plugin system. There is an HTTP POST and whatever the user builds behind it.
In .barerepo/config, so it versions and reviews like everything else:
[[webhook]]
url = "https://example.com/hook"
events = ["push", "proposal.opened"]
secret_env = "DEPLOY_HOOK_SECRET"
The secret is named, not written. The value lives in the server's secret store. Committing a secret to a public repository must be impossible by construction.
A webhook URL is user-controlled and the server fetches it. This is the classic server-side request forgery hole.
Deny by default these ranges: 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12,
192.168.0.0/16, 169.254.0.0/16, 100.64.0.0/10, ::1, fc00::/7,
fe80::/10.
Resolve the hostname and check the resolved address, not the string. Re-check after every redirect. Do not follow redirects to a denied address.
The allowlist is a server setting, never a repository setting, or a repository could grant itself access to the internal network.
POST JSON. Sign the body with HMAC-SHA256 in a header. Retry three times with backoff. Disable a hook after 20 consecutive failures and show that on the config page.
Three short decisions that would otherwise be silent.
Deploy keys do not exist. A bot is an account with a key. Add it to
[access] push like a person. One mechanism instead of two, and the audit trail
looks the same for both.
Commit signatures are verified and displayed, and required only where a
repository asks. barerepo already holds ssh public keys for authentication, so
ssh commit signing costs almost nothing. The default is that an unsigned push is
taken, because whether a project wants signatures is the project's decision. A
repository that has made that decision sets require_signed_commits under
[access], and chapter 18 says what the hook then refuses.
Two places, two amounts of detail. The log page draws one mark before the hash on a signed commit, in a slot every row carries, so the hashes stay in one column and the rows keep one shape. It says whether, not what. The commit page says what: signed by whom, or that the key is expired or revoked, or that the signature does not match, which is the only one drawn in the danger colour.
Whom means the account that published the key, and it is a link to that account.
A key carries a name typed by whoever made it, which proves nothing. Which account
published it is a thing barerepo knows and can stand behind. A signature on a key
nobody published says signed and names nobody.
The public halves are served at /<user>.gpg. A badge on a page is the server's
claim about a signature. A reader who cares checks it in their own clone, which is
where every other claim barerepo makes is checked, so barerepo hands out the key rather
than asking to be believed:
curl https://barerepo.example/john.gpg | gpg --import
git verify-commit a3f9c2
The commit page prints those two lines, the way the thread page prints the two
that read a discussion offline. A reader who has never verified a signature has no
reason to know the commands exist, and a feature nobody can find is not built. The
profile links the keys too: ssh keys: 2 goes to /<user>.keys and
signing keys: 1 to /<user>.gpg.
/<user>.keys and /<user>.gpg are plain text, not a download: a person who clicks
the link reads the keys, and curl pipes them just the same. An account with more
than one key gets all of them, one after another, which is what gpg --import and
authorized_keys both take.
barerepo does not show the signature itself. It is in the commit object and a reader cannot check it by looking at it. Drawing an armoured block on a page would look like proof and be none.
A signing key is published on the keys page. Paste the armoured public key and
barerepo holds it, the way it already holds an ssh key. It is only ever read: barerepo
names you on a commit you signed and can do nothing else with it. Every published
key goes into one keyring, rebuilt whenever a key is added or revoked, and that ring
is the only one git is given. A commit signed with a key nobody published says
signed and claims nothing about who signed it.
Presence is read from the commit object, not from a verification. The signature
is a gpgsig header, so the log page knows a commit is signed without running gpg
once, let alone twenty times. Verification is one more process and only the commit
page, for one commit, pays it. A server with no gpg installed still marks the log
correctly and says on the commit page that it cannot check the key.
Submodules work and get no special support. They are ordinary git. Document one trap: a private submodule needs credentials the runner may not have, and the failure looks like a broken build rather than a permissions error.
Every page in the system, what it shows, what it reads, and what it writes. A page not listed here does not exist, and the absence is usually a decision.
Shows. Name field, public key field, one button, and a plain statement that losing every key loses the account.
Reads. Nothing.
Writes. Account, first key.
Why it looks like this. There is no email field because there is no email. The
key field hints at cat ~/.ssh/id_ed25519.pub because that is where the value comes
from, and a user who learns that command has learned something durable.
Shows. Name field and a button that starts a challenge. Then the nonce, and
the one ssh-keygen line that signs it, and a box to paste the signature into.
Never a password field, because there is no password.
The command shown is OpenSSH's, not a program of ours. br auth john does the
same thing in one step and is offered second. Showing the CLI first would say that
signing in needs a program the user has not installed, which is false and is the
opposite of what rule 2 is for.
Reads. Account keys.
Writes. Challenge nonce, then a session.
Shows. Every ssh key with fingerprint, label, and last-used time. Every runner token with its labels, attached machine, and last-seen time. Every feed token with the feed it opens. Revoke on each.
Reads. Server state items 1 and 3.
Writes. Key additions and revocations, token issue and revoke.
The three kinds of credential, and they are not interchangeable. An ssh key signs you in and pushes. A runner token attaches one machine to one repository, per chapter 15. A feed token reads one Atom feed and can do nothing else, per chapter 19.5. The page names what each one can do, because a user about to paste a token into a feed reader deserves to know it cannot write.
Why it exists. This is the only settings page in the entire product, because
keys and tokens are the only things that cannot live in a repository. Everything
a user might look for here that is repository-scoped is in .barerepo/config
instead, and the page should say so.
Shows. Name, description, default branch, visibility, create. Below the form, the two commands that create a repository without using the form at all.
Writes. Bare repository, HEAD, hooks, ownership row.
Why the shortcut is on this page. A user who has found this form is about to spend four steps on something one push would have done. Telling them here is the only place the message reaches them at the moment it is useful. This is rule 2 applied to a page whose whole purpose the rule undermines, which is the correct outcome rather than an awkward one.
Note. Default branch defaults to master and is a plain text field accepting
any branch name, not a toggle with a recommended option.
Note. Visibility is not stored at creation, because there is no tree to store it in yet. Choosing public adds the line that sets it to the block on the empty-repository page. See chapter 11.
Shows. One block of shell commands to paste, and a second block for pointing an
existing repository here. If the creator chose public, the first block also
writes .barerepo/config, because that is the only place visibility can live.
Why it exists. This is a new user's first contact with the system after signup, and it is the most important page in the product. It contains no onboarding tour, no sample project, and no "learn git" link. It contains the commands, in order, ready to paste.
The landing page for every repository. Not the file tree.
Shows. Commits newest first, each with message, author, time, and how many files changed and by how much. Every row is the same row. The hash is the link to the commit; nothing else on the row is a control. No diff is drawn here and none is offered here. Merged proposals appear as entries.
Why no diff on this page. An earlier draft drew every diff already expanded, which is what the mockup shows. Twenty open diffs on a real repository is a 219kb page against a 30kb budget in chapter 25. Every way of paying that back left some rows with a diff and some without, which is a list that changes shape as the reader scrolls it. One shape for every row is worth more than a diff the reader did not ask for.
Reads. git log --numstat.
Why this is the landing page. Nobody navigates code by clicking folders. People arrive at a repository to find out what changed, or to find a specific symbol. The log answers the first directly and the file jump answers the second in one keystroke. GitHub's landing page answers neither and spends its space on a directory listing and a rendered README.
Shows. Directories and files at a path, each with the last commit that touched it and that commit's message.
Reads. git ls-tree, plus one git log -1 per entry.
Performance note. The per-entry log is the expensive part and is the reason this page is not the landing page. Cache by tree hash; the result is immutable for a given tree.
Shows. File contents with blame in the left gutter on every line, always, alongside line numbers.
Why not a separate blame page. Blame is not a separate question. "What is this
line" and "why is this line" are asked at the same moment, and splitting them into
two pages doubles the work of the most common investigation a person performs in a
code viewer. It costs one git blame call, which caches by blob hash forever
because the answer cannot change.
Shows. One commit, full message, metadata, complete diff, and whether it is reachable from the default branch. If it closed a thread, that is stated.
Shows. Any ref against any ref, both as free text fields. Combined stats, per-file diffs, conflict status.
Why free text. Because master...refs/proposals/47 is a legitimate thing to
type, and a dropdown of branches cannot express it. The proposal review path goes
through this page.
Shows. Issues and proposals in one list. Each row shows whether a ref is attached, diff stats if so, build status if any, and reply count. Filters are open, merged, closed, all.
Why unified. See chapter 13. The split is bookkeeping that serves the database schema rather than the reader.
Shows. Title, metadata, attached ref if any, comments in order, inline diff excerpts for line-anchored comments, build results inline in the timeline, a reply box, and the two commands to read the thread offline.
Reads. refs/notes/threads/<n>, the proposal ref, run notes.
Writes. A comment blob on note write.
The important detail. Build results appear as events in the comment timeline rather than in a separate status panel, because a build result is a thing that happened at a time, which is what a timeline is for.
Shows. Title, body, and an optional field for a ref.
The mechanic. A thread with no ref is an issue. Paste a ref and it is a proposal. One form, one object, no type selector.
Shows. Three commands, one per platform, all visible at once, each with the token already inside it. Below them, three facts: the runner dials out, the token is repository-scoped, the page updates when a runner attaches.
Why this page matters disproportionately. It is the clearest demonstration of the entire thesis. GitHub's equivalent is four pages and a downloaded archive. If this page ever grows a second step, something has gone wrong.
This is the one place a program really is needed, because a runner is an agent that keeps polling, which a one-line shell command cannot be. Rule 2 still applies: the page says what the program does, and says that the protocol it speaks is the plain HTTP in chapter 15, so anyone who would rather write their own has everything they need to. That is the difference between a required tool and a hidden one.
Shows. Attached machines, platform, labels, run count, status, last seen. Offline machines can be forgotten.
Shows. Runs newest first with commit, status, duration, ref, and which machine ran it.
Shows. Status, exit code, what triggered it, and the complete log as plain text.
Why plain text. Covered in chapter 16: a failing build is read by someone hunting an error message, and collapsible steps defeat browser search.
Shows. Events newest first, one line each, with a rule marking the last visit.
Reads. The event log, filtered to repositories you own and threads you touched.
Writes. The last_visited timestamp.
Note. No unread counts, no badges, no mark-all-read. See chapter 19.4 for why.
Shows. Tags with notes and attached files, newest first.
Reads. Tags, refs/notes/releases, the blob store.
Note. The page states that notes clone and attached files do not, per chapter 22.3. A user should learn this here, not during a migration.
Shows. Code matches, threads, and repositories in one ranked list. Code matches show the matching line with context.
Reachable from every page with /, because the alternative is navigating to a
search page, which is an interstitial.
Shows. A user, their key count, and their repositories sorted by last push, each with language, size, default branch, and open proposal count.
Note. The default branch is shown per repository, so a user with a mix of
master and main sees the mix.
Shows. .barerepo/config rendered as a file, with the commit that last changed
it, and history, blame, and raw links.
There is no settings page. Edit the file, commit, push, and it applies. This page is a file view with a specific path, and the fact that it is barely a special case is the point.
Shows. Exactly which rule refused the push, the relevant line from
.barerepo/config, and the command that would have worked.
How it is reached. The hook prints a URL alongside the rejection message. Without that line the page is unreachable, because a rejected push happens in a terminal and no browser is involved.
Why it exists at all. The hook already printed the reason. The page exists because terminals scroll, and because a rejected push is where a new contributor decides whether to keep going.
Says what does exist near the requested path.
explore, trending, stars, followers, notification inbox, wiki, project
boards, insight graphs, marketplace, gists, organization management, onboarding
tour, in-browser editor, protected branch rule builder, merge queue, code owners.
The discovery pages are absent per rule 7: a new platform is empty for a long time, and a page that displays emptiness to every visitor actively harms adoption. The rest are absent per chapter 1: remove them and the five things a forge does still work.
These are build-failing thresholds, asserted in the test suite. They are not displayed to users. A product that reports its own speed in its interface is advertising, and the number stops being checked the moment it becomes a slogan.
| View | Time | Payload |
|---|---|---|
| Any page with no diff | under 10ms | under 15kb |
| Log | under 20ms | under 30kb |
| File view with blame | under 20ms | under 40kb |
| Any page | under 2kb JS |
The JavaScript budget is not zero. Two keyboard shortcuts need a listener: / for
search and t for the file jump. Everything else renders without script. Two
kilobytes is generous for that and leaves no room for a framework, which is the
point of stating a number rather than a slogan.
What makes this achievable. No client framework, no bundler, no hydration, no round trip for data after the HTML. Server-rendered HTML from local git operations is fast by default; the work is in not making it slow.
What one process costs. These numbers are only reachable if git operations
are cheap, and shelling out makes each one cost a process. Measured on an
ordinary laptop, git rev-parse HEAD on a small repository takes 8ms of which
almost all is spawn. A page that runs four git commands has spent 32ms before
it has rendered anything, and no amount of caching inside barerepo changes that.
So the budget is a budget on git invocations as much as on time. A page
gets one or two. Reaching that means git cat-file --batch for several objects
in one process, one git log --patch for the whole log page rather than one
per commit, and caching whatever is a function of an immutable object. Where
that is not enough, the answer is libgit2 in-process rather than a looser
number: docs/BUILD.md says to start by shelling out and to optimise when
profiling says so, and this is profiling saying so.
An earlier draft of this chapter gave the times without the process cost, which made the budget read as achievable by writing careful Go. It is not: it is achievable by not starting processes.
What makes it hard. Diff rendering is the one expensive operation and the one that cannot be trivially optimized away. Cache rendered diffs keyed by the pair of blob hashes. Blobs are immutable, so the cache never needs invalidation and can be evicted purely by size.
Blame caches by blob hash on the same reasoning. The per-entry log on the file tree caches by tree hash.
Repository size grows in three places that other forges do not have.
Proposal refs. Anyone may create them, so they accumulate. Expire proposals
with no activity for [proposals] expire_days, default 180. Delete the ref, retain
the thread. The thread is small; the ref pins commits.
Proposal revisions. Each force-push retains the old tip under
refs/revisions/<n>/<k>. Keep the most recent five and the ones with anchored
comments. Delete the rest on the same expiry schedule.
Note trees. One blob per comment, plus tombstones, forever. This is small in absolute terms and should not be pruned, because the durability of discussion is the product.
Run git gc per repository on a schedule, not on push. Repack after bulk ref
deletion, or the pack files retain everything you just deleted.
Rule 5 is an open door and must be defended without closing it.
Proposal spam. Rate limit by account and by repository. Cap open proposals per account per repository at a small number, ten is plenty. Reject pushes above a size threshold from accounts with no accepted proposals.
Account spam. Signup requires a key and a signature over a nonce. Both are
cheap to produce, so neither slows a squatter down: ssh-keygen makes a fresh
key in milliseconds and signs with it just as fast. The signature is there to
stop somebody claiming a key that is not theirs, per chapter 10, and it is not
an anti-spam measure.
Rate limit signup by source address, signup_per_hour_per_ip. Accept that a
determined actor gets accounts and that the proposal caps are the actual
defense.
Note spam. Comments are cheap. Rate limit per account per thread.
Resource abuse via runners. Not a concern, because runners are the user's own hardware. This is a quiet benefit of the design: the most expensive abuse vector on every other forge does not exist here.
Private key exposure. Users will paste private keys into the public key field. Detect the header and refuse with a clear message telling them which half to paste.
Backup is the repository directory plus the small server database. The repositories are self-describing; the database holds only the closed list in chapter 10 and nothing else.
On SQLite the database is one file, so the backup is a file copy alongside the
repository directory. On PostgreSQL it is a pg_dump. Nothing else differs.
Restore is putting the directory back and reinstalling hooks.
Leaving is the part that matters, and the design should be tested against it regularly. A user who clones with
git clone --mirror https://barerepo.example/john/johnbot
has the code, the full history, every proposal ref, every thread, every comment,
the configuration, and the build results. They can push that to any other host, or
serve it themselves with git daemon, and lose only the web viewer.
Test this. Periodically mirror a repository, delete the original, restore from the mirror, and confirm nothing is missing. A claim about portability that is never exercised is a claim that will turn out to be false at the worst moment.
Forks and pull requests. The familiar model. Rejected because forking is repository duplication used as a permission workaround, and because it puts the contributor's work in a place the maintainer must go and fetch from. Proposal refs put the work in the target repository immediately, which is where everyone interested in it already is.
Gerrit as-is. The ref mechanism is taken directly from Gerrit and credited in
chapter 9. The policies are refused: Change-Id trailers, one commit per change,
forced rebase, numeric scoring. Those policies exist because Google needed enforced
linear history and gated review at enormous scale. Most projects need neither, and
inheriting them is inheriting the complaints.
Email patches. git send-email and the sourcehut model. Elegant, zero
server state, and the correct answer for kernel-scale projects with strong mailing
list culture. Rejected because configuring SMTP is a harder first contact than
git push, and first contact is where contributors are won or lost.
Federation. ActivityPub between instances, as Forgejo is pursuing. Deferred rather than refused. The technology is unsettled, the specification is moving, and a small forge that does one thing well is more useful than a small forge that does two things partially. Revisit when ForgeFed stabilizes.
A merge button. Requested constantly. Refused permanently. Adding it means the server must understand merge strategies, conflict resolution, rebase semantics, and partial failure, which is a large surface for a convenience that replaces two commands. Rule 1 exists to make this a settled question rather than a recurring argument.
Hosted CI. The largest operating cost of running a forge and the most common paywall on using one. Removing it removes both, at the cost of requiring the user to have a machine, which developers do.
Storing discussion in a database. Faster to query, easier to edit, trivially consistent. Rejected because it is precisely the thing that makes leaving a forge lossy, and not being lossy to leave is the whole argument.
Not built, and not to be added without revisiting Part I:
explore, trending, and discovery surfaces; stars and social graph; wiki; project boards; insight and contribution graphs; marketplace; gists; organization and team management; onboarding tours; any outbound email at all; in-browser IDE; code owners; required reviewers; protected branch rule builders; squash and rebase merge buttons; draft proposals; issue templates; saved replies; fork networks and "forked from" relationships; deploy keys as a separate concept from accounts.
Two entries moved off this list during writing. A notification inbox is now built, because rejecting it left users with no way to learn that anything happened; the version in chapter 19 has no read state and no email, which is what made it acceptable. Copying a project is now supported, because refusing forks as a contribution mechanism accidentally read as refusing the right to diverge.
Most exist to serve a social network or a compliance department. barerepo is neither. Every one of them can be added later by someone who wants a different product; none of them can be removed later from a product that shipped with them.
Part VII is written in Simplified Technical English. Each task shows the exact
commands. No task needs the web interface. No task needs br.
Parts I to VI are written in normal prose, because they contain argument and not procedure.
Your key is your account. Read this chapter first.
ssh-keygen -t ed25519 -C "laptop"
Press enter to accept the default path. Type a passphrase. The command writes two files:
~/.ssh/id_ed25519 the private half. Never send this to anyone.
~/.ssh/id_ed25519.pub the public half. This is safe to publish.
cat ~/.ssh/id_ed25519.pub
The output is one line. It starts with ssh-ed25519. Copy the whole line.
barerepo uses this to authenticate you. This is stock OpenSSH 8.0 and later. You do not need barerepo's CLI, or any other program, to sign in.
printf '%s' '<nonce>' | ssh-keygen -Y sign -f ~/.ssh/id_ed25519 -n barerepo-auth -
The final - means "read the message from standard input", and the signature
is written to standard output. Copy all of it, starting at
-----BEGIN SSH SIGNATURE-----.
One command, and nothing is left on disk. An earlier draft wrote the nonce to
/tmp/nonce, signed the file, and printed /tmp/nonce.sig: three commands, two
files left behind, and echo -n which is not portable between shells.
-n barerepo-auth sets the namespace. The namespace stops a signature from one
service from working on a different service. Always use barerepo-auth.
The server writes your public key to a file:
john ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...
The server then runs:
ssh-keygen -Y verify -f allowed_signers -I john -n barerepo-auth -s /tmp/nonce.sig < /tmp/nonce
Exit code 0 means the signature is correct. Any other exit code means it is wrong.
You can run the same command yourself. Nothing is hidden.
There is no recovery. There is no email. There is no reset link.
Add a second key on the day you sign up. Keep it on a different machine.
/signup.cat ~/.ssh/id_ed25519.pub.The account exists at once. Nothing is sent to you.
/signin.Step 4 is one line and needs nothing but the ssh you already have:
printf '%s' '<nonce>' | ssh-keygen -Y sign -f ~/.ssh/id_ed25519 -n barerepo-auth -
br does the same work without the browser. It asks for its own nonce, signs it
with the key in ~/.ssh, and prints a link. Open the link and that browser is
signed in. The link works once and expires in ten minutes.
br auth john
Part VII means what it says: no task here needs the CLI. The sign-in page shows
the ssh command first for that reason, and mentions br auth second.
/keys.Do this immediately after signup.
Open /keys. Press revoke beside the key.
You cannot remove your last key. The server refuses.
/keys shows the last used time for each key. Check this if you think a key is
lost.
/new.master. Type any other name if you
want a different one.You do not have to use the form. Push to a name that does not exist:
git init
git add -A
git commit -m "first"
git remote add origin git@barerepo.example:john/johnbot
git push -u origin master
The server creates john/johnbot and accepts the push. The push output contains
the URL.
The default branch is the branch you pushed. The repository is private. Change
either one later in .barerepo/config.
You can only do this inside your own namespace.
Check the push output. A typo in the name creates a repository with that typo. Delete it per 33.8 if that happens.
git init
git remote add origin git@barerepo.example:john/johnbot
git add -A
git commit -m "first"
git push -u origin master
git remote set-url origin git@barerepo.example:john/johnbot
git push --all
git push --tags
git clone git@barerepo.example:john/johnbot
Or over https:
git clone https://barerepo.example/john/johnbot
Edit .barerepo/config:
[repo]
default_branch = "main"
Commit and push. The server reads the file on push.
Edit .barerepo/config:
[repo]
visibility = "private"
Commit and push.
To take a project in your own direction:
barerepo copy john/johnbot lisa/johnbot
The server copies it. Nothing is downloaded to your machine.
You get all branches, all tags, all history and all threads. You do not get their proposal refs.
There is no link back to the original. It is a copy, not a relationship.
To contribute to the original instead, push a proposal. See 36.1.
Open the config page. Press rename.
The old name redirects forever. Existing clones keep working.
The old name is never released to anyone else.
Edit .barerepo/config:
[repo]
archived = true
Commit and push. The repository becomes read-only. It stays visible and clonable.
Set it back to false to undo.
The server rejects a single file over the limit. The default is 100 MB.
The message names the file. Remove it from your history and push again.
Put large files in object storage. Put a URL or a checksum in the repository.
LFS is off unless your administrator turned it on.
Open the repository config page. Press delete. Type the repository name to confirm.
The repository disappears from the site at once. It stops serving, and the name is held rather than freed.
The data is kept for 30 days and then erased. Inside that window an administrator can put it back. After it, nothing can.
Mirror it first if you want your own copy. See 40.1. Do not treat the 30 days as a backup; it is a window to notice a mistake, not a place to keep anything.
Open the repository. The log is the first page. Each commit shows what it touched and how much it changed.
Press the hash to read the diff.
Press t on any repository page. Type part of the file name.
Open the file. The blame is in the left column. There is no separate blame page.
Press the commit hash in the log.
Open /john/johnbot/compare. Type any ref in each field. Examples:
master...refs/proposals/47
v1.0...v1.1
master...8b1d44
Press / on any page. Type your search. The results contain code, threads and
repositories in one list.
A thread is an issue. A thread with a ref attached is a proposal. Both use the same pages.
/john/johnbot/threads.Type in the reply box at the end of the thread. Press reply.
Open the proposal diff. Press the line number. Type your comment.
Fetch the notes:
git fetch origin "refs/notes/*:refs/notes/*"
Read one thread:
git log --show-notes=threads/47
Read all notes on a commit:
git notes --ref=threads/47 show <sha>
This works with no network. This works if barerepo stops.
Add a note and push it:
git notes --ref=threads/47 append -m "I see the same problem."
git push origin refs/notes/threads/47
If the push fails, someone else replied first. Take what the server holds, write your reply after it, and push again:
git fetch origin refs/notes/threads/47
git update-ref refs/notes/threads/47 FETCH_HEAD
git notes --ref=threads/47 append -m "I see the same problem."
git push origin refs/notes/threads/47
Not git notes merge -s union. It is the obvious answer and the wrong one. A
union merge joins the two notes with no -- between them, so the last comment on
your side is welded onto the first comment on theirs, and the server refuses the
push for dropping a reply that is no longer a whole record. Resetting to the
server's copy and appending again cannot do that. br reply does exactly this,
and parks anything your copy held that the server has not seen under
refs/notes/before-reply/threads/47 rather than resetting it away.
The author and the repository owner can close a thread. Press close. The same control opens it again, and says so.
A merged proposal is not closed by a person, so neither of them is offered the control on one. See 36.6.
A proposal closes by itself when it is merged. See 36.6.
You do not need permission. You do not fork.
git clone https://barerepo.example/john/johnbot
cd johnbot
git checkout -b my-fix
git commit -am "fix panic on empty config"
git push origin HEAD:refs/proposals/new
The server prints the URL of your proposal. The push output contains the number.
refs/proposals/newnew is a magic name. The server never makes a ref with that name. The server
allocates the next number and uses that name instead.
Commit more work. Push again to the same number:
git push -f origin HEAD:refs/proposals/47
-f is needed if you changed your history. The server keeps your old version.
You can push to a proposal if you started it. The repository owner can also push to it.
Read the push output. Or list the refs:
git ls-remote origin "refs/proposals/*"
Fetch it to a local branch:
git fetch origin refs/proposals/47:prop-47
git log master..prop-47
git diff master...prop-47
Build it, run it, read it. It is a normal branch on your machine.
There is no merge button. Merge on your machine:
git checkout master
git merge prop-47
git push
The server sees that the proposal is now part of master. The server closes the
thread. You do nothing else.
Use any method you want. The server only checks reachability.
git merge --squash prop-47 && git commit
A squash changes the hashes. The server will not detect the merge. Close the thread by hand in this case.
Open the thread. Press close. Say why in a reply.
The ref stays. The author keeps their work.
Read the message in your terminal. It says which rule stopped you, and prints a URL if you want the same explanation in a browser.
The most common cause is a push to master without access. The answer is:
git push origin HEAD:refs/proposals/new
There is no settings page. Settings are a file.
git pull
$EDITOR .barerepo/config
git commit -am "allow lisa to push"
git push
The change applies on push.
[access]
push = ["john", "lisa"]
The owner is always allowed. You do not list the owner.
Delete the name. Commit and push.
[proposals]
require_runs = ["build", "test"]
[proposals]
accept_from = "authenticated"
Values are anyone, authenticated and push.
Force-push is allowed on every branch except the default branch.
To allow it on the default branch:
[access]
allow_force_push = ["master"]
Think first. A force-push to the default branch destroys other people's work.
git log -p .barerepo/config
Every change has an author, a date and a diff. A settings page cannot do this.
git revert <sha>
git push
The server keeps the last good version. The server prints a warning on push.
The owner can always push. Fix the file and push again.
Builds run on your machines. barerepo does not run them.
/john/johnbot/runners.Linux and macOS:
curl -sL barerepo.sh | sh -s rt_live_7Kq2mXe
Windows:
irm barerepo.sh/ps | iex; barerepo-runner rt_live_7Kq2mXe
The token is in the line. There is no second step.
barerepo-runner rt_live_7Kq2mXe --labels build,test
[build]
command = "make ci"
image = "golang:1.26"
Leave image empty to build on the host.
Push. Every push runs the build command.
Open the run. The log is on the page. Use ctrl-F to find the error.
A build that says more than the page can carry shows its last 12kb, because that is where a failure is, and links the whole log as plain text. Chapter 25 budgets the page and a long build must not be the thing that breaks it.
git checkout <sha>
make ci
The runner does the same thing. Nothing else happens.
Open /john/johnbot/runners. Press forget.
Open /keys. Press revoke beside the token. The machine stops at its next poll.
The runner calls the server. The server never calls the runner. A laptop behind a firewall works.
barerepo sends no email. You read a feed instead.
Open /inbox. Events are newest first.
A line marks where you were when you last visited.
There is no watch button. Reply to a thread and you will hear about it.
Open /keys. Press new feed token. Copy the URL:
https://barerepo.example/inbox.atom?token=ft_live_9Xk2m
Add that URL to any feed reader.
The token is read-only. Revoke it on /keys.
Public repositories need no token:
https://barerepo.example/john/johnbot.atom
https://barerepo.example/john/johnbot/threads.atom
barerepo will not send it. Point a feed-to-email service at your Atom URL.
Test this before you need it.
git clone --mirror https://barerepo.example/john/johnbot
This copies the code, all history, every branch, every tag, every proposal ref, every thread, every comment, the config file and every build result.
cd johnbot.git
git for-each-ref
You will see refs/heads/*, refs/proposals/* and refs/notes/*.
git remote set-url origin git@otherhost:john/johnbot
git push --mirror
git daemon --base-path=/srv/git --export-all
You lose the web pages. You lose search. You lose build dispatch.
You lose no data.
Part VIII is written in Simplified Technical English, because it contains procedure. It covers the things you must know to deploy barerepo and to know that your implementation is correct.
/usr/local/bin/barerepo the server.
/usr/local/bin/barerepo-runner the build agent. the server hands this out.
/etc/barerepo/barerepo.toml server config. not repository config.
/var/lib/barerepo/repos/ bare repositories, as <user>/<repo>.git
/var/lib/barerepo/forge.db the server-owned items, chapter 10. sqlite by
default. postgres instead, see 41.7.1.
/var/lib/barerepo/artifacts/ release artifacts. item 5. not in git.
/var/lib/barerepo/cache/ rendered diffs, blame, search index.
/var/lib/barerepo/.ssh/ the git user's authorized_keys file.
The cache directory can be deleted at any time. The server rebuilds it.
Create one system user. All repositories belong to it.
useradd -m -d /var/lib/barerepo -s /bin/bash git
Every ssh push arrives as this user. The account name comes from the key, not from the unix user.
barerepo does not run its own ssh daemon in the simple setup. It uses OpenSSH and an
authorized_keys file.
For each stored public key, write one line:
command="/usr/local/bin/barerepo ssh --account john",no-port-forwarding,no-x11-forwarding,no-agent-forwarding,no-pty ssh-ed25519 AAAAC3Nza...
The command= prefix forces every connection into barerepo ssh. The user cannot
get a shell. The --account flag tells barerepo who is connecting.
Rewrite the file whenever a key is added or revoked. Write to a temporary file and rename, so a partial write never locks everyone out.
barerepo ssh reads SSH_ORIGINAL_COMMAND, which contains git-upload-pack 'john/johnbot.git' or git-receive-pack '...'. Parse it, check access, then
execute the real git binary.
Reject anything else. SSH_ORIGINAL_COMMAND is attacker-controlled. Allow
exactly git-upload-pack, git-receive-pack and git-upload-archive. Reject all
other input. Do not pass the string to a shell.
Put a reverse proxy in front. Terminate TLS there.
proxy_pass http://127.0.0.1:3000;
proxy_set_header X-Real-IP $remote_addr;
Do not turn on header-based authentication. Gitea shipped a critical vulnerability in 2026 where a reverse-proxy auth header let anyone become admin by sending one header. If barerepo ever adds such a feature, it must be off by default and must require an explicit trusted-proxy address, never a wildcard.
Git over https needs two routes. See appendix C. Authenticate with a token in the HTTP basic password field. The username is ignored.
Ask for the credential on git-receive-pack before answering, even when the
repository is public. A git client sends no credential until it is challenged.
If the ref advertisement for a push succeeds anonymously, the client never sends
a token, and the push that follows is refused for the wrong reason: the user is
told they lack access when what actually happened is that they were never asked
who they were. Return 401 on both halves of a push when there is no credential.
Reads are the opposite: answer a public repository anonymously and never challenge, because a clone that demands a token from a stranger is a clone that does not happen.
Install three hook files in every repository at creation:
<repo>.git/hooks/pre-receive
<repo>.git/hooks/post-receive
<repo>.git/hooks/update (not used, remove it)
Each hook is a two-line shell script that calls the barerepo binary:
#!/bin/sh
exec /usr/local/bin/barerepo hook pre-receive
Keep the logic in the binary, not in the hook file. Then an upgrade of barerepo upgrades every repository at once, and you never have to rewrite hook files across thousands of directories.
Add a barerepo doctor command that reinstalls hooks everywhere. You
will need it after a restore.
barerepo init
This creates the database and the directory tree. It creates no accounts.
Create the first account from the command line, because the web signup may be closed:
barerepo account create john --key "$(cat john.pub)" --admin
An admin can delete any repository and any account. Nothing else. There is no admin dashboard, because there is almost nothing to administer.
/etc/barerepo/barerepo.toml is not repository config. It holds only deployment
facts.
[server]
listen = "127.0.0.1:3000"
external_url = "https://barerepo.example"
raw_url = "" # a separate host. see chapter 42.3
ssh_host = "barerepo.example"
ssh_port = 22
[database]
url = "sqlite:///var/lib/barerepo/forge.db"
[paths]
repos = "/var/lib/barerepo/repos"
cache = "/var/lib/barerepo/cache"
artifacts = "/var/lib/barerepo/artifacts"
[limits]
max_blob_mb = 100
max_push_mb = 2048
max_open_proposals = 10
signup_per_hour_per_ip = 5
artifact_retain_days = 90
[behavior]
allow_push_to_create = true
allow_lfs = false
Two sections, one rule each. [limits] holds every number that bounds
something. [behavior] holds every switch that turns something on or off. A
setting that is a number goes in the first and a setting that is a boolean goes
in the second, so nobody has to remember which section a key lives in.
allow_push_to_create lets a user create a repository by pushing to a name that
does not exist. See chapter 11. Turn it off on a locked-down instance.
max_push_mb bounds a whole push and max_blob_mb bounds one file inside it.
The default whole-push limit is loose, because the push most likely
to hit it is somebody's first import of an existing repository, and rejecting
that is the worst possible first contact. The per-file limit is the one that
does the real work; see chapter 20.2.
[database]
url = "sqlite:///var/lib/barerepo/forge.db"
barerepo runs on SQLite or PostgreSQL. The scheme in the URL picks one.
sqlite:///var/lib/barerepo/forge.db the default. one file, no service.
postgres://barerepo@localhost/barerepo a server you already run.
There is nothing else to set. Migrations run on first start against either one, and no feature exists on one and not the other.
Use SQLite unless you have a reason not to. The server-owned items in chapter 10 are small, they are read far more than they are written, and every write is serialised by one process. SQLite in WAL mode is the correct tool for that shape, it needs no service to run, no user to create, and no password to store, and it makes the backup in chapter 28 a file copy.
Use PostgreSQL if you already run one and want one backup story, or you want the database on different hardware from the repositories, or your host's disk makes SQLite's locking unreliable, which network filesystems do.
Postgres does not make barerepo faster. The hot path is git, not the database.
The test suite runs on SQLite. It needs no service, so every test gets a fresh empty database and the suite stays fast enough to run on every commit. The Postgres schema is held to the SQLite one by a test that compares the two definitions column by column, which needs no server either. Run the whole suite against a real Postgres before a release; do not make every commit wait for it.
The URL contains a password when Postgres wants one, so /etc/barerepo/barerepo.toml
is 0640 and owned by the git user. This file is deployment configuration and
never enters a repository; see chapter 14 for the file that does.
Database migrations run on first start and cannot be reversed.
barerepo serve; see appendix E.Do not automate this without backups in the same script. An unattended migration with no backup is how you lose everything.
Everything in this chapter is a security requirement. None of it is optional.
Thread bodies and comments are markdown written by anyone with an account. Rule 5 means that is anyone at all.
Render markdown to HTML, then sanitize the HTML with an allowlist. Never use a blocklist. Never trust the markdown renderer to be safe.
Allow these elements only:
p br strong em del code pre blockquote
h1 h2 h3 h4 h5 h6
ul ol li
a img
table thead tbody tr th td
hr
Allow these attributes only:
a: href title
img: src alt title
code: class (language-* only, for highlighting)
Strip every other element and attribute. Strip all on* handlers. Strip style.
Strip <script>, <iframe>, <object>, <embed>, <form>, <svg>.
Allow http, https and mailto only.
Reject javascript:, data:, vbscript: and file:. Reject them after
decoding, because javascript: is the same string.
Add rel="nofollow noopener noreferrer" to every external link.
Proxy remote images through the server or block them. A remote image in a comment leaks the reader's IP address to whoever posted it. Blocking is simpler and honest; say so in the UI.
A repository can contain a file named evil.html containing a script. If you
serve raw file content from your own domain, you have given an attacker your
cookies.
Serve raw content from a separate domain, not a subdomain that shares cookies. Send these headers on every raw response:
Content-Type: text/plain; charset=utf-8
Content-Disposition: attachment
X-Content-Type-Options: nosniff
Content-Security-Policy: default-src 'none'; sandbox
Never send a Content-Type derived from the file extension.
The route.
GET /<user>/<repo>/raw/<ref>/<path>
The separate host is a server setting, [server] raw_url in chapter 41.7,
because a repository must never be able to choose where its own content is
served from.
[server]
raw_url = "https://raw.barerepo.example"
When raw_url is set, the main host answers this route with a 302 to the same
path on the raw host, and the raw host serves the bytes with the four headers
above.
When raw_url is empty, barerepo serves raw content from the main host with
those same four headers, and the raw handler reads no session cookie at all. It
therefore answers for public repositories only, and returns 404 for a private
one whether or not the reader could see it in the web interface.
This fallback exists because most people install barerepo on one hostname, and a
raw link that only works for operators who own a second domain is a link most
users would never get. The headers are what actually stop the file from running:
attachment means the browser downloads it instead of rendering it, and the
sandbox policy means nothing runs even if it does render. The second host is
defence in depth, not the whole defence.
Say which mode is active on the repository config page, so a user who cannot fetch a private file raw learns why on the page rather than from a 404.
Escape every byte of file content before it reaches HTML. This includes the blame column, which contains commit messages, which are also untrusted.
Detect binary files by looking for a null byte in the first 8000 bytes. Do not render binary content. Show the size and offer download.
Cap rendered file size. Files above 1 MB show a notice and a download link.
Validate account names against ^[a-z0-9][a-z0-9-]{0,38}$. Reserve every name
that a top-level route already uses:
new signup signin signout auth keys
gpgkeys tokens search inbox runner raw
static api admin about
The first twelve are routes in appendix C today. The last four are held back because they are the names a future route would want, and freeing a name later is easy while taking one back is not.
Repository names are not reserved. The list guards the top level, where an
account named runner would shadow /runner/attach. A repository is the second
path segment, so john/runner shadows nothing and is somebody's project. Applying
the list to both refuses names for no reason, and the pattern above is what keeps
a path safe: it permits no dot and no slash.
Derive this list from the route table in code rather than copying it. A route added without a matching reservation is a route an account can shadow, and that is a bug the test suite should catch rather than a list a person must remember.
Validate repository names the same way.
Never build a filesystem path by joining user input. Resolve the path and confirm
it is inside the repository root. ../ is the oldest attack there is.
Ref names come from a push, and a push is untrusted.
Validate against git check-ref-format. Reject names containing .., a leading
-, control characters, or a leading dot in any component.
Never pass a ref name to a shell. Use argument arrays. A ref named --upload-pack=evil
is an argument injection if you build a command string.
Because rule 4 forbids JavaScript that a page depends on, the policy can be strict:
Content-Security-Policy: default-src 'none'; img-src 'self'; style-src 'self'; script-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'
script-src 'self' is there for the two keyboard shortcuts chapter 25 budgets
2kb for. Without it default-src 'none' blocks them, and the budget describes
something that cannot run. An earlier draft omitted it and said that any feature
needing script-src is wrong, which contradicted chapter 25 outright.
What the policy still refuses is everything that made the rule worth having:
no inline script, no unsafe-eval, and no script from anywhere but this server.
A framework cannot get in through 2kb served from /static. If a feature needs
more than that, chapter 25's budget is the thing that catches it, and the test
suite asserts the budget.
A comment on config.go:43 is useful. The file will change. This chapter says
what happens then.
The anchor header stores four things, not one:
anchor: config.go:43
blob: 7c1e08a... the blob hash of the file at comment time
revision: 2 which proposal revision was displayed
side: new old | new, which side of the diff
The blob hash is the important field. A blob is immutable. The exact content the commenter was looking at can always be recovered.
Exact. The blob hash matches. Show the comment at line 43.
Compute a diff from the stored blob to the current blob. Map line 43 forward through that diff.
Three outcomes:
Never delete an outdated comment. Never hide it silently.
Show it in the thread timeline in order, with the original code excerpt from the stored blob, and the label "outdated". The reader can see what was said and what it referred to.
This is why the blob hash is stored. Without it, an outdated comment is a reference to content nobody can retrieve.
Chapter 12 retains old proposal tips under refs/revisions/<n>/<k>. That keeps
the commits reachable, which keeps the blobs reachable.
The retained revision keeps the object alive. The stored blob hash finds the right object. You need both. Revisions expire per chapter 26; the anchor must degrade gracefully to "outdated, original content unavailable" when they do.
Chapter 11 states that ownership comes from the namespace and cannot be changed from inside the repository. Transfer is therefore a server operation, not a git operation.
The owner opens the repository config page and presses transfer. The owner types the new owner's account name and the repository name to confirm.
The server:
repos/john/johnbot.git to repos/lisa/johnbot.git.authorized_keys if access changes.Keep the redirect forever. A moved repository whose old URL returns 404 breaks every clone, every bookmark and every link in every thread that mentions it.
Git clients follow redirects on both transports. An existing clone keeps working.
Free the old name only if the new owner explicitly releases it.
Nothing inside the repository changes. Commits, proposals, threads, notes and
.barerepo/config are untouched.
[access] push still lists the same names. The new owner should edit it. The
server does not edit repository content on transfer, because that would rewrite
history nobody asked to rewrite.
The owner presses delete and types the repository name.
The server moves the directory to a trash area with a timestamp. A cron job erases trash older than 30 days.
Say the 30 days in the UI. A delete that is instantly irreversible produces a support request barerepo has no support channel to answer.
Deleting an account deletes or transfers every repository in the namespace. Force the user to choose per repository. Do not delete data as a side effect of an account action.
Free the account name only after the trash window ends. A recycled name that inherits an old identity's proposals and comments is a security problem, not a convenience.
You do not have a correct implementation until these pass.
This is the most important test in the system, because portability is the entire argument.
1. Create a repository. Push code.
2. Open a thread. Reply to it.
3. Push a proposal from a second account. Comment on a line.
4. Merge the proposal.
5. Run a build.
6. git clone --mirror the repository.
7. Delete the original from the server.
8. Push the mirror to a second barerepo instance.
9. Confirm: code, history, threads, comments, proposals, config and run
results are all present.
Run this in CI on every commit. A claim about portability that is never exercised becomes false without anyone noticing.
refs/heads/master without access. Expect rejection, and expect the
message to name the proposal command.refs/proposals/new. Expect allocation, and expect the URL in the push
output.refs/proposals/new twice at the same moment. Expect two different
numbers.Write two comments to one thread at the same moment from two clients. Expect both to survive. Repeat one thousand times. Expect zero losses.
This test finds the bug that makes threads look broken to the second user, which is the failure most likely to reach production.
<script>alert(1)</script>.[x](javascript:alert(1)).<img src=x onerror=alert(1)>.evil.html containing a script, fetched raw.../../etc.admin, new, signin.--upload-pack=/bin/sh.SSH_ORIGINAL_COMMAND of rm -rf /.Every one must fail safely. Add each to the suite as a permanent regression test.
Assert the budget in chapter 25 as build-failing thresholds, not as goals.
Test against a large repository, not a toy one. Use the git source tree or the linux kernel. A log page that is fast on ten commits proves nothing.
Assert the JavaScript budget from chapter 25. A page over 2kb of script has grown something, and the test should say which page.
Back up. Destroy the server. Restore. Run barerepo doctor. Confirm push
and pull still work.
Do this on a schedule. A backup that has never been restored is not a backup.
refs/heads/* branches
refs/tags/* tags
refs/proposals/new reserved, never created, triggers allocation
refs/proposals/<n> a proposal
refs/revisions/<n>/<k> a retained earlier tip of proposal n
refs/notes/threads/<n> discussion for thread n
refs/notes/runs build results, keyed by built commit
refs/notes/releases release notes, keyed by tag object
refs/meta/counter next proposal or thread number
[repo]
default_branch = "master" # string, mirrors HEAD
visibility = "private" # public | private. absent means private
description = "" # string
archived = false # true makes the repository read-only
[access]
push = [] # list of account names, owner always implied
allow_force_push = [] # branches where force-push is permitted
allow_delete = [] # branches that may be deleted
require_signed_commits = false # refuse a commit not signed by a key this server holds
[proposals]
accept_from = "anyone" # anyone | authenticated | push
require_runs = [] # list of label names
expire_days = 180 # integer
[runners]
# "hostname" = ["label", ...]
[build]
command = "" # shell command, empty disables builds
image = "" # container image, empty means host
[[webhook]] # repeatable
url = "" # https, public addresses only
events = [] # see chapter 19.1
secret_env = "" # name of a secret, never the value
Server config, /etc/barerepo/barerepo.toml, is a different file. See chapter 41.7.
[limits]
max_blob_mb = 100 # single file, rejected in pre-receive
max_push_mb = 2048 # whole push
max_open_proposals = 10 # per account per repository
signup_per_hour_per_ip = 5
artifact_retain_days = 90 # build artifacts. releases do not expire
[behavior]
allow_push_to_create = true # push to a name that does not exist
allow_lfs = false # off. see chapter 20
Numbers live in [limits] and switches live in [behavior]. The full server
file, including [server], [database] and [paths], is in chapter 41.7.
GET / landing or profile if signed in
GET /signup POST /signup
GET /signin POST /auth/challenge, POST /auth/verify
GET /auth/claim?c= spends the one-use code br auth printed
GET /keys POST /keys, DELETE /keys/<id>
POST /tokens, DELETE /tokens/<id>
GET /new POST /new
a push to a nonexistent repo also creates one,
handled in the transport, not on a route
GET /search?q=
GET /<user> profile
GET /<user>/<repo> log, or empty page
GET /<user>/<repo>/files/<ref>/<path>
GET /<user>/<repo>/file/<ref>/<path>
GET /<user>/<repo>/raw/<ref>/<path> see chapter 42.3
GET /<user>/<repo>/commit/<sha>
GET /<user>/<repo>/compare/<a>...<b>
GET /<user>/<repo>/threads POST /<user>/<repo>/threads
GET /<user>/<repo>/thread/<n> POST /<user>/<repo>/thread/<n>/reply
GET /<user>/<repo>/runs
GET /<user>/<repo>/run/<id>
GET /<user>/<repo>/runners POST /<user>/<repo>/runners/token
GET /<user>/<repo>/config POST /<user>/<repo>/transfer
POST /<user>/<repo>/rename
POST /<user>/<repo>/delete
POST /<user>/<repo>/copy
GET /<user>/<repo>/releases
GET /<user>/<repo>/release/<tag>
GET /inbox
GET /inbox.atom?token=
GET /<user>.keys public ssh keys
GET /<user>.gpg public signing keys
GET /<user>.atom
GET /<user>/<repo>.atom
GET /<user>/<repo>/threads.atom
GET /<user>/<repo>.git/info/refs?service=git-upload-pack
GET /<user>/<repo>.git/info/refs?service=git-receive-pack
POST /<user>/<repo>.git/git-upload-pack
POST /<user>/<repo>.git/git-receive-pack
POST /runner/attach
GET /runner/poll
POST /runner/log
POST /runner/done
Repository creation happens before this point, in barerepo ssh or the http
handler. A repository that does not exist has no hooks to run. See chapter 11.
before git-receive-pack runs:
if repo does not exist:
if not allow_push_to_create: reject
if namespace != authenticated user: reject
if name in trash window: reject
if name has a transfer redirect: follow it, do not create
if name fails validation: reject
create repo, set HEAD from the pushed ref, visibility private
install hooks
print the new repository URL
pre-receive:
config = parse(read_blob(HEAD, ".barerepo/config")) or last_good
# archiving is repository-wide, so it is judged once and not per ref.
# the owner is exempt or the flag could never be turned off. chapter 21.3.
if config.repo.archived and user != owner:
reject("repository is archived. the owner can unarchive it in .barerepo/config")
# every ref is judged against the access matrix in chapter 18. the chain is
# exhaustive: a ref that matches no namespace is refused by the last branch.
for (old, new, ref) in stdin:
if ref == "refs/proposals/new":
if not may_propose(user, config): reject("...")
if open_proposals(user, repo) >= limits.max_open_proposals: reject("...")
n = allocate(repo)
rewrite(ref -> "refs/proposals/" + n)
print(url_for(repo, n))
elif ref matches "refs/proposals/<n>":
if user != author(n) and not may_push(user, config): reject("...")
retain_revision(n, old)
elif ref matches "refs/heads/*" or "refs/tags/*":
if config.access.require_signed_commits and new != zero:
unsigned = [c for c in commits_added(new) if not has_signature(c)]
if unsigned: reject(unsigned_and_how_to_sign(unsigned))
if not may_push(user, config):
print(proposal_command_hint())
print(url_for_rejection(repo, attempt_id))
reject()
if is_force(old, new) and ref == default_branch
and ref not in config.access.allow_force_push:
reject("force-push to the default branch")
if new == zero and ref == default_branch
and ref not in config.access.allow_delete:
reject("cannot delete the default branch")
elif ref matches "refs/notes/threads/*":
if not may_read(user, config): reject("...")
else:
reject("namespace not writable")
# size is judged once, over the objects this push actually adds, and only
# after the refs are judged. a push that was going to be refused anyway
# should not first spend time weighing its objects.
if total_size(new_objects) > limits.max_push_mb:
reject(size_and_limit())
for blob in new_blobs(all pushed ranges):
if size(blob) > limits.max_blob_mb:
reject(name_and_size(blob))
post-receive:
for (old, new, ref) in stdin:
if ref matches "refs/heads/*":
range = rev_list(old..new)
for n in open_proposals(repo):
if tip(n) in range or is_ancestor(tip(n), new):
close_as_merged(n, new)
if ref == default_branch: reload_config_cache()
if config.build.command:
enqueue_job(repo, ref, new)
index(repo, ref, old, new)
Three programs, because a contributor should not download a server to sign in.
br auth <name> [server] sign in, prints a link to open
br propose [remote] push HEAD to refs/proposals/new
br fetch <n> fetch proposal n to a local branch
br threads list the threads you have fetched
br thread <n> print thread n
br reply <n> [-m msg] append a comment and push the note
br notes fetch all note refs
On a build machine:
barerepo-runner <token> [--labels a,b] attach this machine as a runner
Server side, run as root or the git user:
barerepo init create db and directory tree
barerepo serve run the server. http, git http, runners
barerepo account create <name> --key <k> make an account from the shell
barerepo token create <name> make a token for git over https
barerepo ssh --account <name> ssh entry point, called by authorized_keys
barerepo hook <pre-receive|post-receive> hook entry point, called by git
barerepo doctor reinstall hooks in every repository
barerepo doctor --reindex rebuild the search index from git
barerepo copy <src> <dst> server-side copy, uses git alternates
barerepo serve is the one an operator types and the one a service unit runs. It
reads /etc/barerepo/barerepo.toml, or whatever BAREREPO_CONFIG names, and needs
nothing else. barerepo ssh and barerepo hook are entry points that sshd and
git invoke; a person never types either of them.
br is a separate download and an optional one. Install barerepo-runner beside
barerepo on the server, because the add runner page hands out the copy sitting
next to it, and then the two can never be of different versions.
Every one of these prints the underlying git command it runs, per rule 2. The CLI is a convenience over git, never a replacement for it, and a user who reads its output learns how to stop needing it.
Every task in Part VII. Use this to find a procedure.
| Task | Section |
|---|---|
| Make an ssh key | 31.1 |
| Read your public key | 31.2 |
| Sign a nonce by hand | 31.3 |
| Verify a signature by hand | 31.4 |
| Lose your key | 31.5 |
| Sign up | 32.1 |
| Sign in | 32.2 |
| Add a second key | 32.3 |
| Remove a key | 32.4 |
| Make a repository from the form | 33.1 |
| Make a repository by pushing | 33.2 |
| Push new code | 33.3 |
| Push code that already exists | 33.4 |
| Clone | 33.5 |
| Change the default branch | 33.6 |
| Make a repository private | 33.7 |
| Copy someone else's project | 33.8 |
| Rename a repository | 33.9 |
| Archive a repository | 33.10 |
| Push a large file | 33.11 |
| Delete a repository | 33.12 |
| See what changed | 34.1 |
| Find a file | 34.2 |
| See who wrote a line | 34.3 |
| Compare two refs | 34.5 |
| Search | 34.6 |
| Open an issue | 35.1 |
| Reply | 35.2 |
| Comment on one line | 35.3 |
| Read threads offline | 35.4 |
| Reply offline | 35.5 |
| Close a thread | 35.6 |
| Propose a change | 36.1 |
| Change your proposal | 36.3 |
| Find your proposal number | 36.4 |
| Review a proposal | 36.5 |
| Merge a proposal | 36.6 |
| Reject a proposal | 36.8 |
| Fix a rejected push | 36.9 |
| Edit the config | 37.1 |
| Add a collaborator | 37.2 |
| Remove a collaborator | 37.3 |
| Require builds to pass | 37.4 |
| Restrict who can propose | 37.5 |
| Allow force-push on a branch | 37.6 |
| See who changed a setting | 37.7 |
| Undo a setting change | 37.8 |
| Attach a build machine | 38.1 |
| Turn on builds | 38.3 |
| Read a failed build | 38.5 |
| Remove a machine | 38.7 |
| Revoke a token | 38.8 |
| See what happened | 39.1 |
| Use a feed reader | 39.3 |
| Follow one repository | 39.4 |
| Take everything and leave | 40.1 |
| Move to another host | 40.3 |
browser ssh client runner
| | |
| https | ssh | https, outbound only
v v v
+----------------------------------------------------------+
| barerepo binary |
| |
| web handlers barerepo ssh hook runner api |
| | | | | |
+--------|--------------|-----------|------------|---------+
| | | |
v v v v
+----------+ +---------------------+ +---------+
| sqlite | | bare repositories | | cache |
| or | | | | |
| postgres | | refs/heads | | diffs |
| | | refs/proposals | | blame |
| keys | | refs/notes | | index |
| owners | | .barerepo/config | | |
| tokens | | | | |
| redirects| | | | |
+----------+ +---------------------+ +---------+
see chapter 10 everything else all derived,
lives here deletable
in git on the server
code x
history x
branches, tags x
proposals x
threads, comments x
build results x
repository config x
release notes x
proposal counter x
account -> keys x
namespace ownership x
tokens and sessions x
namespace redirects x
release artifacts x
cache, index, event log x
git clone --mirror takes column one.
column two is listed in chapter 10, with a reason for each.
push to refs/proposals/new
|
v
+--------+
force-push | open |
+---------> | |
| +--------+
| | | |
+------------+ | +-----------------+
| |
merged into | | owner or author
default branch | | presses close
v v
+--------+ +--------+
| merged | | closed |
+--------+ +--------+
|
no activity for |
expire_days v
+-----------+
| abandoned |
+-----------+
ref eligible
for deletion,
thread retained
git push origin HEAD:refs/proposals/new
|
v
sshd authenticates the key
|
v
authorized_keys forces: barerepo ssh --account john
|
v
barerepo parses SSH_ORIGINAL_COMMAND, allowlist only
|
v
git-receive-pack runs
|
v
pre-receive
| read .barerepo/config from default branch tip
| check namespace rules (chapter 18)
| allocate number, rewrite ref
| print URL to the user's terminal
v
refs updated on disk
|
v
post-receive
| check open proposals for reachability
| close any that merged
| enqueue a build job
| update the search index
v
done. the user sees the URL in their terminal.
revision 1 revision 2 displayed as
line 43 <-- comment made here
line 43 unchanged at line 43
line 45 (moved) at line 45, "moved"
line deleted "outdated", with the
original excerpt shown
from the stored blob
the stored blob hash is what makes the last case possible.