# 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** 6. Refs are just files 7. Notes 8. Hooks 9. What git already does **Part III. Mechanics** 10. Identity 11. Repositories and ownership 12. Proposals 13. Threads 14. Configuration 15. Runners 16. Runs 17. Search 18. Access control 19. Feeds and the inbox 20. Large files 21. Copying, renaming, archiving 22. Releases and artifacts 23. Webhooks **Part IV. The pages** 24. Page by page **Part V. Operating it** 25. Performance 26. Storage and garbage 27. Abuse 28. Backup, restore, and leaving **Part VI. Decisions** 29. Roads not taken 30. Non-goals **Part VII. Using barerepo** 31. Keys and signing 32. Accounts 33. Repositories 34. Reading code 35. Threads 36. Proposals 37. Configuration and access 38. Runners and builds 39. Following what happens 40. Leaving **Part VIII. Building and running it** 41. Installing barerepo 42. Rendering untrusted content 43. Anchoring comments to code 44. Transferring, renaming and deleting 45. 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 ` ` 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/` since 2008, storing patchsets under `refs/changes/`. GitHub itself stores every pull request at `refs/pull//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 `/.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 `/` 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/`. 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 `. 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/ ``` 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 ..` 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/` 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/ ``` **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 ``` A note is one blob at the path ``, 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, `--`, 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. ```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_` 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/` 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/` 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. ```json { "runner": "uproar.local", "labels": ["build", "test"], "ref": "refs/proposals/47", "started": 1787074650, "duration": 18, "exit": 0, "log": "" } ``` `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/` | 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: ```toml [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.** ```toml [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= 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 `` 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. ```toml [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 ```toml [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 ` 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 ```toml [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: ```toml [[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 `/.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 `/.keys` and `signing keys: 1` to `/.gpg`. `/.keys` and `/.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/`, 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//`. 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' '' | 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' '' | 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`: ```toml [repo] default_branch = "main" ``` Commit and push. The server reads the file on push. ### 33.7 Make a repository private Edit `.barerepo/config`: ```toml [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`: ```toml [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 ``` 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 ```toml [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 ```toml [proposals] require_runs = ["build", "test"] ``` ### 37.5 Restrict who can propose ```toml [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: ```toml [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 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 ```toml [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 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 /.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: ``` .git/hooks/pre-receive .git/hooks/post-receive .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. ```toml [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 ```toml [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 ``. - A comment containing `[x](javascript:alert(1))`. - A comment containing ``. - 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/ a proposal refs/revisions// a retained earlier tip of proposal n refs/notes/threads/ 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 ```toml [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. ```toml [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/ POST /tokens, DELETE /tokens/ 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 / profile GET // log, or empty page GET ///files// GET ///file// GET ///raw// see chapter 42.3 GET ///commit/ GET ///compare/... GET ///threads POST ///threads GET ///thread/ POST ///thread//reply GET ///runs GET ///run/ GET ///runners POST ///runners/token GET ///config POST ///transfer POST ///rename POST ///delete POST ///copy GET ///releases GET ///release/ GET /inbox GET /inbox.atom?token= GET /.keys public ssh keys GET /.gpg public signing keys GET /.atom GET //.atom GET ///threads.atom GET //.git/info/refs?service=git-upload-pack GET //.git/info/refs?service=git-receive-pack POST //.git/git-upload-pack POST //.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/": 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 [server] sign in, prints a link to open br propose [remote] push HEAD to refs/proposals/new br fetch fetch proposal n to a local branch br threads list the threads you have fetched br thread print thread n br reply [-m msg] append a comment and push the note br notes fetch all note refs On a build machine: barerepo-runner [--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 --key make an account from the shell barerepo token create make a token for git over https barerepo ssh --account ssh entry point, called by authorized_keys barerepo hook hook entry point, called by git barerepo doctor reinstall hooks in every repository barerepo doctor --reindex rebuild the search index from git barerepo copy 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. ```