A markdown file in the repository, rendered.

barerepo / book / BOOK.md
rendered
log files threads runs releases config jump to file t

The barerepo book

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.


Contents

Part I. The argument

  1. What a forge is actually for
  2. The shape GitHub imposed
  3. The inversion
  4. The no-interstitial principle
  5. The rules

Part II. The substrate

  1. Refs are just files
  2. Notes
  3. Hooks
  4. What git already does

Part III. Mechanics

  1. Identity
  2. Repositories and ownership
  3. Proposals
  4. Threads
  5. Configuration
  6. Runners
  7. Runs
  8. Search
  9. Access control
  10. Feeds and the inbox
  11. Large files
  12. Copying, renaming, archiving
  13. Releases and artifacts
  14. Webhooks

Part IV. The pages

  1. Page by page

Part V. Operating it

  1. Performance
  2. Storage and garbage
  3. Abuse
  4. Backup, restore, and leaving

Part VI. Decisions

  1. Roads not taken
  2. Non-goals

Part VII. Using barerepo

  1. Keys and signing
  2. Accounts
  3. Repositories
  4. Reading code
  5. Threads
  6. Proposals
  7. Configuration and access
  8. Runners and builds
  9. Following what happens
  10. Leaving

Part VIII. Building and running it

  1. Installing barerepo
  2. Rendering untrusted content
  3. Anchoring comments to code
  4. Transferring, renaming and deleting
  5. Testing

Appendices A. Ref layout B. Config schema C. Route table D. Hook pseudocode E. CLI reference F. Task index G. Diagrams


Part I. The argument

1. What a forge is actually for

Strip away the product and a forge does five things:

  1. Stores repositories durably and serves them over the network.
  2. Lets people read code in a browser without cloning.
  3. Accepts changes from people who do not have write access.
  4. Holds discussion attached to that code.
  5. Runs builds when the code changes.

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.

2. The shape GitHub imposed

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.

3. The inversion

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."

4. The no-interstitial principle

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.

5. The rules

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.


Part II. The substrate

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.

6. Refs are just files

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.

7. Notes

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.

8. Hooks

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.

9. What git already does

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.


Part III. Mechanics

10. Identity

An account is a name plus one or more ssh public keys. There is no email address, no password, and no verification link.

Why

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.

Signup

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.

Sign in

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.

Git authentication

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.

The server-owned items

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.

  1. Account name to public keys. Cannot live in a repository, because it is what authorizes repository access in the first place.
  2. Namespace ownership. john/johnbot belongs to john. Set at creation. A config file cannot grant its own authority. See chapter 11.
  3. Tokens. Runner tokens, job tokens, feed tokens, sessions, and the one-use code br auth trades for a browser session. Secrets, and therefore not committable.
  4. Namespace redirects. Where a renamed or transferred repository used to live. Cannot be in the repository, because the repository is what moved, and the redirect must answer for a path that no longer holds one.
  5. Release artifacts. Binaries attached to a tag. Not in git because build output is derived, large and numerous, and putting it in git makes every clone download every binary of every version forever. See chapter 22.3 for the honest cost of this.
  6. Caches, indexes and derived state. Search index, rendered diff cache, blame cache, the inbox event log, the last_visited timestamp. All discardable. All rebuildable from git alone.
  7. Work in flight, and who is doing it. Attached runners, the job queue, and a webhook's run of failures. None of it is in git because none of it is about the repository's contents; it is about machines talking to this server right now. It is discardable, and losing it costs a reattach, a rebuild on the next push, and a stopped hook starting again. It is not item 6, because it is not rebuildable from git: nothing in a repository records that a laptop dialed in this morning.

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:

  • The proposal counter is stored as a ref inside the repository, not on the server. git update-ref with an expected old value gives atomic allocation for free, and the number then travels with the repository. See chapter 12.
  • The inbox event log is derived. Every event it contains is reconstructable from reflogs, note timestamps and proposal refs. It sits under item 6.

Item 6 is the only one that may grow, and only with things that can be deleted without loss.

11. Repositories and ownership

A repository is a bare git repository on disk plus a row recording who owns the namespace.

/var/lib/barerepo/repos/john/johnbot.git

The bootstrap problem

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.

Creation

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.

  • The default branch is the branch you pushed. HEAD is set from it. This is more accurate than a form field, because it matches what the user already has. The repository is created before the push arrives, so its HEAD at that moment is a placeholder; post-receive points it at the branch that actually came.
  • Visibility is private. Accidentally publishing code is not recoverable. Accidentally hiding it is one line in .barerepo/config.
  • Description is empty.

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.

  • Creation is allowed only in the pusher's own namespace. A push to 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.
  • A name inside the deletion trash window fails rather than creating. See chapter 44.4.
  • A name that was transferred away follows the redirect rather than creating. See chapter 44.2.
  • The name must pass the validation in chapter 42.5.

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.

The default branch

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.

12. Proposals

The core mechanism. Read chapter 6 first if refs are unfamiliar.

The lifecycle

  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.

Allocation

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.

Reachability detection

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.

Updating a proposal

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.

What barerepo does not require

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:

  • No Change-Id trailer, and therefore no commit-msg hook to install.
  • No one-commit-per-proposal. Push whatever history you have. Messy is fine.
  • No forced rebase when the target branch moves.
  • No numeric scoring or approval categories.

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.

States

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.

13. Threads

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.

Storage

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.

Anchoring a comment to a line

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.

The conflict problem

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:

  • Tombstones. An edit writes a new blob referencing the old one's name; a deletion writes a tombstone blob. Rendering resolves the chain. History is preserved, which is honest, and the tree grows.
  • Last write wins. Simpler, loses concurrent edits silently.

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.

Reading offline

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.

14. Configuration

.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"

When it is read

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.

Why this instead of a settings page

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.

15. Runners

Builds run on machines the user owns. barerepo dispatches and records; it does not execute.

Why

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.

Attaching a runner

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.

The protocol

  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.

Job dispatch

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.

Token lifecycle

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.

15A. GitHub workflows

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.

16. Runs

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.

Displaying a run

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.

17. Search

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.

Access filtering

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.

18. Access control

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.

Force-push and deletion

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.

Signed commits

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.

Why "collaborator" barely matters here

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.

19. Feeds and the inbox

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.

19.1 Events

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.

19.2 What lands in your inbox

  • Anything in a repository you own.
  • Anything in a thread you opened or replied to.
  • Anything on a proposal you opened.

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.

19.3 The inbox page

/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.

19.4 Read state

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.

19.5 Atom

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.

19.6 No email

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.

20. Large files

20.1 The default

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.

20.2 The blob size limit

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.

20.3 If an administrator enables LFS

Two requirements, both non-negotiable, both drawn from a real 2026 vulnerability in Gogs where neither was met:

  • Per-repository object isolation. Never a shared object directory. Shared storage means one repository can overwrite another repository's objects.
  • Content hash verification on upload. Verify that the uploaded bytes hash to the OID the client claims. Without this an attacker replaces a legitimate object with malicious content and no integrity warning is ever shown.

20.4 What to tell the user instead

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.

21. Copying, renaming, archiving

21.1 Copying a project

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.

21.2 Renaming

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.

21.3 Archiving

[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.

22. Releases and artifacts

22.1 A release

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.

22.2 Artifacts

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.

22.3 The honest cost

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.

22.4 Retention

[limits]
artifact_retain_days = 90

Build artifacts expire. Release artifacts do not, because a published download that disappears breaks other people's installers.

22.5 Publishing from a build

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.

23. Webhooks

23.1 Why they exist

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.

23.2 Configuration

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.

23.3 SSRF

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.

23.4 Delivery

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.

23A. Deploy keys, bots and signatures

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.


Part IV. The pages

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.

24. Page by page

signup

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.

sign in

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.

keys and tokens

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.

new repository

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.

empty repository

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.

repository log

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.

file tree

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.

file view

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.

commit

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.

compare

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.

thread list

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.

thread

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.

new thread

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.

add a runner

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.

runners

Shows. Attached machines, platform, labels, run count, status, last seen. Offline machines can be forgotten.

runs

Shows. Runs newest first with commit, status, duration, ref, and which machine ran it.

run detail

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.

inbox

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.

releases

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.

search

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.

profile

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.

repository config

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.

push rejected

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.

404

Says what does exist near the requested path.

Pages that do not exist

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.


Part V. Operating it

25. Performance

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.

26. Storage and garbage

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.

27. Abuse

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.

28. Backup, restore, and leaving

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.


Part VI. Decisions

29. Roads not taken

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.

30. Non-goals

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. Using barerepo

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.

31. Keys and signing

Your key is your account. Read this chapter first.

31.1 Make a key

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.

31.2 Read your public key

cat ~/.ssh/id_ed25519.pub

The output is one line. It starts with ssh-ed25519. Copy the whole line.

31.3 Sign a message with your key

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.

31.4 How the server checks the signature

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.

31.5 Lose your key

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.

32. Accounts

32.1 Sign up

  1. Open /signup.
  2. Type your name.
  3. Paste the output of cat ~/.ssh/id_ed25519.pub.
  4. Press create.

The account exists at once. Nothing is sent to you.

32.2 Sign in

  1. Open /signin.
  2. Type your name.
  3. Press send challenge. The page shows a nonce and the command to sign it.
  4. Sign the nonce with OpenSSH. See 31.3.
  5. Paste the signature.

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.

32.3 Add a second key

  1. Open /keys.
  2. Press new key.
  3. Paste the public key from the other machine.

Do this immediately after signup.

32.4 Remove a key

Open /keys. Press revoke beside the key.

You cannot remove your last key. The server refuses.

32.5 See where your keys are used

/keys shows the last used time for each key. Check this if you think a key is lost.

33. Repositories

33.1 Make a repository from the form

  1. Open /new.
  2. Type a name.
  3. Set the default branch. The default is master. Type any other name if you want a different one.
  4. Set public or private.
  5. Press create.

33.2 Make a repository by pushing

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.

33.3 Push new code to it

git init
git remote add origin git@barerepo.example:john/johnbot
git add -A
git commit -m "first"
git push -u origin master

33.4 Push code that already exists

git remote set-url origin git@barerepo.example:john/johnbot
git push --all
git push --tags

33.5 Clone

git clone git@barerepo.example:john/johnbot

Or over https:

git clone https://barerepo.example/john/johnbot

33.6 Change the default branch

Edit .barerepo/config:

[repo]
default_branch = "main"

Commit and push. The server reads the file on push.

33.7 Make a repository private

Edit .barerepo/config:

[repo]
visibility = "private"

Commit and push.

33.8 Copy someone else's project

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.

33.9 Rename a repository

Open the config page. Press rename.

The old name redirects forever. Existing clones keep working.

The old name is never released to anyone else.

33.10 Archive a repository

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.

33.11 Push a large file

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.

33.12 Delete a repository

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.

34. Reading code

34.1 See what changed

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.

34.2 Find a file

Press t on any repository page. Type part of the file name.

34.3 See who wrote a line

Open the file. The blame is in the left column. There is no separate blame page.

34.4 See one commit

Press the commit hash in the log.

34.5 Compare two refs

Open /john/johnbot/compare. Type any ref in each field. Examples:

master...refs/proposals/47
v1.0...v1.1
master...8b1d44

34.6 Search

Press / on any page. Type your search. The results contain code, threads and repositories in one list.

35. Threads

A thread is an issue. A thread with a ref attached is a proposal. Both use the same pages.

35.1 Open a thread

  1. Open /john/johnbot/threads.
  2. Press new thread.
  3. Type a title and a body.
  4. Leave the ref field empty for an issue.
  5. Press open.

35.2 Reply

Type in the reply box at the end of the thread. Press reply.

35.3 Comment on one line

Open the proposal diff. Press the line number. Type your comment.

35.4 Read threads offline

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.

35.5 Reply offline

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.

35.6 Close a thread

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.

36. Proposals

You do not need permission. You do not fork.

36.1 Propose a change

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.

36.2 Understand refs/proposals/new

new is a magic name. The server never makes a ref with that name. The server allocates the next number and uses that name instead.

36.3 Change your proposal

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.

36.4 Find your proposal number

Read the push output. Or list the refs:

git ls-remote origin "refs/proposals/*"

36.5 Review a proposal

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.

36.6 Merge a proposal

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.

36.7 Merge without a merge commit

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.

36.8 Reject a proposal

Open the thread. Press close. Say why in a reply.

The ref stays. The author keeps their work.

36.9 Your push was rejected

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

37. Configuration and access

There is no settings page. Settings are a file.

37.1 Edit the config

git pull
$EDITOR .barerepo/config
git commit -am "allow lisa to push"
git push

The change applies on push.

37.2 Add a collaborator

[access]
push = ["john", "lisa"]

The owner is always allowed. You do not list the owner.

37.3 Remove a collaborator

Delete the name. Commit and push.

37.4 Require builds to pass

[proposals]
require_runs = ["build", "test"]

37.5 Restrict who can propose

[proposals]
accept_from = "authenticated"

Values are anyone, authenticated and push.

37.6 Allow force-push on a branch

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.

37.7 See who changed a setting

git log -p .barerepo/config

Every change has an author, a date and a diff. A settings page cannot do this.

37.8 Undo a setting change

git revert <sha>
git push

37.9 You broke the config file

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.

38. Runners and builds

Builds run on your machines. barerepo does not run them.

38.1 Attach a machine

  1. Open /john/johnbot/runners.
  2. Press add a runner.
  3. Copy the line for your operating system.
  4. Paste it on the machine.

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.

38.2 Set what a machine can build

barerepo-runner rt_live_7Kq2mXe --labels build,test

38.3 Turn on builds

[build]
command = "make ci"
image   = "golang:1.26"

Leave image empty to build on the host.

38.4 Run a build

Push. Every push runs the build command.

38.5 Read a failed build

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.

38.6 Run the build yourself

git checkout <sha>
make ci

The runner does the same thing. Nothing else happens.

38.7 Remove a machine

Open /john/johnbot/runners. Press forget.

38.8 Revoke a token

Open /keys. Press revoke beside the token. The machine stops at its next poll.

38.9 A runner needs no open port

The runner calls the server. The server never calls the runner. A laptop behind a firewall works.

39. Following what happens

barerepo sends no email. You read a feed instead.

39.1 See what happened

Open /inbox. Events are newest first.

A line marks where you were when you last visited.

39.2 What appears there

  • Anything in a repository you own.
  • Anything in a thread you opened or replied to.
  • Anything on a proposal you opened.

There is no watch button. Reply to a thread and you will hear about it.

39.3 Use a feed reader

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.

39.4 Follow one repository

Public repositories need no token:

https://barerepo.example/john/johnbot.atom
https://barerepo.example/john/johnbot/threads.atom

39.5 Get email anyway

barerepo will not send it. Point a feed-to-email service at your Atom URL.

40. Leaving

Test this before you need it.

40.1 Take everything

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.

40.2 Check what you took

cd johnbot.git
git for-each-ref

You will see refs/heads/*, refs/proposals/* and refs/notes/*.

40.3 Move to another host

git remote set-url origin git@otherhost:john/johnbot
git push --mirror

40.4 Serve it yourself

git daemon --base-path=/srv/git --export-all

40.5 What you lose

You lose the web pages. You lose search. You lose build dispatch.

You lose no data.


Part VIII. Building and running it

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.

41. Installing barerepo

41.1 Disk layout

/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.

41.2 The git user

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.

41.3 SSH access

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.

41.4 HTTPS access

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.

41.5 Hooks

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.

41.6 First run

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.

41.7 Server config

/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.

41.7.1 The database

[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.

41.8 Upgrading

Database migrations run on first start and cannot be reversed.

  1. Stop the service. It runs barerepo serve; see appendix E.
  2. Back up the database and the repository directory.
  3. Replace the binary.
  4. Start the service.
  5. Read the logs through the migration.

Do not automate this without backups in the same script. An unattended migration with no backup is how you lose everything.

42. Rendering untrusted content

Everything in this chapter is a security requirement. None of it is optional.

42.1 Markdown

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>.

42.2 Link and image schemes

Allow http, https and mailto only.

Reject javascript:, data:, vbscript: and file:. Reject them after decoding, because java&#115;cript: 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.

42.3 File content

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.

42.4 File rendering in the file view

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.

42.5 Names and paths

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.

42.6 Ref names

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.

42.7 The content security policy

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.

43. Anchoring comments to code

A comment on config.go:43 is useful. The file will change. This chapter says what happens then.

43.1 What is stored

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.

43.2 Displaying a comment on the revision it was made on

Exact. The blob hash matches. Show the comment at line 43.

43.3 Displaying a comment on a later revision

Compute a diff from the stored blob to the current blob. Map line 43 forward through that diff.

Three outcomes:

  • The line is unchanged. Show the comment at its new line number.
  • The line moved. Show the comment at the moved position. Mark it "moved".
  • The line was deleted or heavily rewritten. The comment is outdated.

43.4 Outdated comments

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.

43.5 Why not rely on retained revisions alone

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.

44. Transferring, renaming and deleting

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.

44.1 Transfer a repository

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:

  1. Confirms the target account exists.
  2. Confirms the target namespace has no repository with that name.
  3. Moves the directory from repos/john/johnbot.git to repos/lisa/johnbot.git.
  4. Updates the ownership row.
  5. Writes a permanent redirect from the old path to the new path.
  6. Rewrites authorized_keys if access changes.

44.2 Redirects

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.

44.3 What transfer does not change

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.

44.4 Delete a repository

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.

44.5 Delete an account

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.

45. Testing

You do not have a correct implementation until these pass.

45.1 The portability test

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.

45.2 Hook tests

  • Push to refs/heads/master without access. Expect rejection, and expect the message to name the proposal command.
  • Push to refs/proposals/new. Expect allocation, and expect the URL in the push output.
  • Push to refs/proposals/new twice at the same moment. Expect two different numbers.
  • Push to another user's proposal ref. Expect rejection.
  • Merge a proposal. Expect the thread to close by itself.
  • Squash-merge a proposal. Expect the thread to stay open, per chapter 36.7.

45.3 Notes concurrency

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.

45.4 Security tests

  • A comment containing <script>alert(1)</script>.
  • A comment containing [x](javascript:alert(1)).
  • A comment containing <img src=x onerror=alert(1)>.
  • A file named evil.html containing a script, fetched raw.
  • A repository named ../../etc.
  • An account named admin, new, signin.
  • A branch named --upload-pack=/bin/sh.
  • An SSH_ORIGINAL_COMMAND of rm -rf /.
  • A private key pasted into the public key field.

Every one must fail safely. Add each to the suite as a permanent regression test.

45.5 Performance tests

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.

45.6 The restore test

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.


Appendices

Appendix A. Ref layout

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

Appendix B. Config schema

[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.

Appendix C. Route table

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

Appendix D. Hook pseudocode

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)

Appendix E. CLI reference

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.

Appendix F. Task index

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

Appendix G. Diagrams

G.1 The whole system

   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

G.2 Where each thing lives

                        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.

G.3 Proposal states

                 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

G.4 A push, end to end

  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.

G.5 Comment anchoring over revisions

  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.
source · rawbarerepo 0.1.0