Single commit page showing message, metadata and full diff.

barerepo / book / f10e013
barerepo · 1mo · 5 files · +4058 -0 · signed
barerepo
@@ -0,0 +1,6 @@
1+ [repo]
2+ visibility = "public"
3+ description = "how barerepo works and why it works that way"
4+
5+ [access]
6+ allow_force_push = ["master"]
@@ -0,0 +1 @@
1+ .DS_Store
@@ -0,0 +1,4005 @@
1+ # The barerepo book
2+
3+ A complete description of how barerepo works and why it works that way.
4+
5+ This book assumes you have never seen the site. It should be sufficient to build
6+ a functionally identical system from scratch. It says almost nothing
7+ about visual design, because the visual design is a consequence of the mechanics
8+ rather than a cause of anything.
9+
10+ ---
11+
12+ ## Contents
13+
14+ **Part I. The argument**
15+
16+ 1. What a forge is actually for
17+ 2. The shape GitHub imposed
18+ 3. The inversion
19+ 4. The no-interstitial principle
20+ 5. The rules
21+
22+ **Part II. The substrate**
23+
24+ 6. Refs are just files
25+ 7. Notes
26+ 8. Hooks
27+ 9. What git already does
28+
29+ **Part III. Mechanics**
30+
31+ 10. Identity
32+ 11. Repositories and ownership
33+ 12. Proposals
34+ 13. Threads
35+ 14. Configuration
36+ 15. Runners
37+ 16. Runs
38+ 17. Search
39+ 18. Access control
40+ 19. Feeds and the inbox
41+ 20. Large files
42+ 21. Copying, renaming, archiving
43+ 22. Releases and artifacts
44+ 23. Webhooks
45+
46+ **Part IV. The pages**
47+
48+ 24. Page by page
49+
50+ **Part V. Operating it**
51+
52+ 25. Performance
53+ 26. Storage and garbage
54+ 27. Abuse
55+ 28. Backup, restore, and leaving
56+
57+ **Part VI. Decisions**
58+
59+ 29. Roads not taken
60+ 30. Non-goals
61+
62+ **Part VII. Using barerepo**
63+
64+ 31. Keys and signing
65+ 32. Accounts
66+ 33. Repositories
67+ 34. Reading code
68+ 35. Threads
69+ 36. Proposals
70+ 37. Configuration and access
71+ 38. Runners and builds
72+ 39. Following what happens
73+ 40. Leaving
74+
75+ **Part VIII. Building and running it**
76+
77+ 41. Installing barerepo
78+ 42. Rendering untrusted content
79+ 43. Anchoring comments to code
80+ 44. Transferring, renaming and deleting
81+ 45. Testing
82+
83+ **Appendices**
84+ A. Ref layout
85+ B. Config schema
86+ C. Route table
87+ D. Hook pseudocode
88+ E. CLI reference
89+ F. Task index
90+ G. Diagrams
91+
92+ ---
93+
94+ # Part I. The argument
95+
96+ ## 1. What a forge is actually for
97+
98+ Strip away the product and a forge does five things:
99+
100+ 1. Stores repositories durably and serves them over the network.
101+ 2. Lets people read code in a browser without cloning.
102+ 3. Accepts changes from people who do not have write access.
103+ 4. Holds discussion attached to that code.
104+ 5. Runs builds when the code changes.
105+
106+ Everything else on a modern barerepo is either a social network, a project management
107+ tool, or a compliance artifact. Those are real products, but they are not forges,
108+ and bundling them is what makes forges slow, complicated, and sticky in the bad
109+ sense.
110+
111+ A useful test for any proposed feature: remove it and ask whether the five things
112+ above still work. If they do, the feature is optional. Most of GitHub is optional.
113+
114+ ## 2. The shape GitHub imposed
115+
116+ GitHub's information architecture solved a specific problem in 2008: how do you
117+ let a stranger propose a change to code they do not own, when the stranger cannot
118+ be trusted with write access and the maintainer does not want to read a mailing
119+ list?
120+
121+ Their answer was the fork plus the pull request. You copy the entire repository
122+ into your own namespace, push a branch there, and then ask the original repository
123+ to pull from your copy. The web UI makes this feel like one action. Underneath it
124+ is repository duplication as a permission workaround.
125+
126+ That answer worked, and every forge since has copied it: Gitea, GitLab, Bitbucket,
127+ Forgejo. The copying went deeper than the pull request. It took the whole
128+ information architecture with it.
129+
130+ Three consequences follow, and they are the reason barerepo exists.
131+
132+ **The repository became the atom.** Every view is scoped to one repository. But a
133+ person with sixty repositories on one disk mostly wants cross-repository questions
134+ answered: where have I used this library, what did I touch last month, which of my
135+ projects are failing to build. No barerepo answers these, because the architecture has
136+ no place to put the answer.
137+
138+ **Discussion left git.** Issues and pull request comments live in the forge's
139+ database. They do not clone. They do not survive a migration except through a
140+ lossy importer. They cannot be read offline. If the host disappears, the code
141+ survives and the reasoning behind the code does not. This is the single largest
142+ piece of unnecessary lock-in in the industry, and it is unnecessary because git
143+ has had a mechanism for attaching data to commits since 2010.
144+
145+ **Configuration left the repository.** Branch protection, merge settings,
146+ collaborator lists, CI permissions, all of it lives in a web form. You cannot diff
147+ it, cannot review a change to it, cannot see who changed it or when, cannot revert
148+ it, and cannot move it. A team can have a rigorous review process for a one-line
149+ code change and no process at all for the setting that governs whether review is
150+ required.
151+
152+ None of these are bugs. They are what you get when the coordination layer is the
153+ product.
154+
155+ ## 3. The inversion
156+
157+ barerepo inverts the ownership question. The server owns almost nothing.
158+
159+ | Thing | GitHub | barerepo |
160+ |---|---|---|
161+ | Identity | email plus password on their server | ssh key you hold |
162+ | Discussion | their database | git notes in your clone |
163+ | Settings | their web form | a file in your tree |
164+ | Proposed changes | a fork in their namespace | a ref in the target repo |
165+ | Merging | their button | `git merge` on your machine |
166+ | Builds | their compute, metered | your hardware, unmetered |
167+ | Code | git | git |
168+
169+ The consequence is stated plainly and is the entire pitch: **if barerepo disappears
170+ tomorrow, you lose a web UI and nothing else.** Your clone contains the code, the
171+ history, the discussion, the configuration, and the build results. You can point
172+ it at another host, or at no host, and keep working.
173+
174+ GitHub cannot match this. Their business model requires that leaving is expensive.
175+ Ours requires that leaving is free, which is a strange thing to build a business
176+ on until you notice that it is the only durable answer to "why should I trust you
177+ with my work."
178+
179+ ## 4. The no-interstitial principle
180+
181+ The second thesis is about the distance between the user and the machine.
182+
183+ GitHub inserts itself at every step. A settings page instead of a config file. A
184+ merge button instead of `git merge`. A runner setup flow spread across four pages
185+ instead of one command. A web editor instead of your editor. A protected branch
186+ rule builder instead of a hook.
187+
188+ Each individual insertion is small and defensible. Together they produce
189+ developers who have used git daily for five years and cannot explain what a ref
190+ is, because the interface has never required them to know and has actively hidden
191+ it.
192+
193+ barerepo shows the command. Every action the UI can perform is displayed as the git
194+ or shell invocation that performs it. This is not a power-user affordance in a
195+ collapsed panel. It is the primary interface, and the web UI is a viewer over it.
196+
197+ Two things fall out of this that are worth stating explicitly.
198+
199+ **Beginners learn.** A person who copies `git push origin HEAD:refs/proposals/new`
200+ twenty times will eventually notice what a ref is. A person who clicks "Create pull
201+ request" twenty times will not.
202+
203+ **One question decides a new feature.** Does this let the user do something they
204+ could not do from a terminal, or does it merely hide the terminal? Build the first.
205+ Refuse the second. Almost every request to make barerepo "easier" is a request of the
206+ second kind, and almost every one should be declined.
207+
208+ ## 5. The rules
209+
210+ Everything else follows from these. Changing one is a design review, not a ticket.
211+
212+ **Rule 1. The server never merges.** There is no merge button. A proposal closes
213+ when its tip becomes reachable from the default branch. The server observes this;
214+ it does not cause it.
215+
216+ **Rule 2. Every mutation has a visible command.** If the UI can do it, the UI shows
217+ how to do it without the UI.
218+
219+ The command shown must be one the user can already run. `git`, `ssh-keygen` and
220+ `curl` are on their machine; barerepo's CLI is not, and telling someone to run a
221+ program they have not installed is an interstitial with extra steps. So wherever
222+ a page names a `barerepo` command, it names the plain one first and offers `barerepo`
223+ second, as the shortcut it is. A page that shows only the `barerepo` form is a bug,
224+ and Part VII's promise that no task needs the CLI is the test for it.
225+
226+ **Rule 3. Nothing is stored that is not a git object,** except a closed list in
227+ chapter 10. Each item on that list carries a written reason it cannot live in git.
228+ Adding to it is a design review.
229+
230+ **Rule 4. No JavaScript on any page that could render without it.** Pages are HTML
231+ over the wire. Keyboard shortcuts are the only exception, and they are measured in
232+ hundreds of bytes. The budget in chapter 25 is enforced in the test suite, not
233+ advertised in the interface.
234+
235+ **Rule 5. Anyone authenticated can propose.** Write access to `refs/proposals/*`
236+ is the default for every signed-in user on every public repository. Permission is
237+ only ever about writing to `refs/heads/*`.
238+
239+ **Rule 6. The default branch is `master`,** configurable per repository and per
240+ user with one line and no friction in either direction. Never hardcode either name
241+ anywhere in the codebase. Always read the repository's actual HEAD.
242+
243+ **Rule 7. No discovery surface until there is something to discover.** No explore,
244+ no trending, no stars. A sparse discovery page advertises emptiness, and emptiness
245+ is the default state of a new platform for a long time.
246+
247+ ---
248+
249+ # Part II. The substrate
250+
251+ You cannot build this system without understanding four features of git that most
252+ forges use only incidentally. This part is a working explanation rather than a
253+ tutorial.
254+
255+ ## 6. Refs are just files
256+
257+ A ref is a file containing a commit hash. That is the entire concept.
258+
259+ ```
260+ .git/refs/heads/master -> a3f9c2...
261+ .git/refs/tags/v1.0 -> 8b1d44...
262+ .git/refs/remotes/origin/master -> a3f9c2...
263+ ```
264+
265+ A branch is a ref under `refs/heads/`. A tag is a ref under `refs/tags/`. They are
266+ the same mechanism distinguished only by directory. Git treats them differently
267+ because of where they live, not because of what they are.
268+
269+ The important consequence: **`refs/heads/` and `refs/tags/` are conventions, not
270+ rules.** You can create `refs/proposals/47`, or `refs/anything/you/want`, and git
271+ will store it, transfer it over the wire, let you fetch it, and let you check it
272+ out. It simply will not appear in `git branch`, because that command only reads
273+ `refs/heads/`.
274+
275+ This is what makes proposals possible without repository duplication. A proposal
276+ is a real ref in the real repository, transferred by ordinary git, invisible to
277+ anyone's branch list, and subject to whatever access rule you write for its
278+ namespace.
279+
280+ Refs may be packed into `.git/packed-refs` rather than existing as loose files.
281+ Any implementation must use `git for-each-ref` or the equivalent library call
282+ rather than reading the filesystem directly.
283+
284+ ## 7. Notes
285+
286+ Git notes attach arbitrary data to an existing object without changing that
287+ object's hash. A note is stored as a blob in a tree, in a commit, on a ref under
288+ `refs/notes/`. The tree is keyed by the hash of the object being annotated.
289+
290+ ```
291+ refs/notes/commits the default namespace
292+ refs/notes/threads/47 a namespace barerepo uses for discussion
293+ refs/notes/runs a namespace barerepo uses for build results
294+ ```
295+
296+ Because a note lives on a ref, it is an ordinary git object: it transfers over the
297+ wire, it clones, it survives a mirror, and it can be read offline.
298+
299+ ```
300+ git log --show-notes=threads/47
301+ ```
302+
303+ Notes are not fetched by default. A client must ask for them:
304+
305+ ```
306+ git fetch origin "refs/notes/*:refs/notes/*"
307+ ```
308+
309+ barerepo's CLI does this automatically, and the web UI tells the user the command.
310+ This is the one place where the honest answer is slightly inconvenient and we say
311+ so rather than hiding it.
312+
313+ **The hard part.** Two people writing notes concurrently will conflict on the ref,
314+ exactly as two people pushing to the same branch would. Git provides merge
315+ strategies for notes, including `cat_sort_uniq` and `union`, which concatenate
316+ rather than failing. For an append-only stream of comments this is correct
317+ behavior. For edits and deletions it is not, and chapter 13 addresses that
318+ directly. Do not defer this problem; it is the most likely thing to make threads
319+ look broken the first time a second person uses them.
320+
321+ ## 8. Hooks
322+
323+ Git runs server-side hooks around a push. Three matter.
324+
325+ **`pre-receive`** runs once, receives every proposed ref update on stdin as
326+ `<old-sha> <new-sha> <refname>` lines, and can reject the entire push by exiting
327+ non-zero. Anything printed to stderr appears in the pusher's terminal. This is
328+ where access control lives and where proposal allocation happens.
329+
330+ **`update`** runs once per ref and can reject individual refs. barerepo does not use
331+ it; an all-or-nothing push is easier to reason about.
332+
333+ **`post-receive`** runs after refs have been updated, receives the same stdin
334+ format, and cannot reject anything. This is where barerepo detects merged proposals,
335+ queues builds, and updates indexes.
336+
337+ Two properties matter for the design. Hooks can print to the user's terminal,
338+ which is why barerepo can explain a rejection where the user is actually looking.
339+ And hooks run with the repository available locally, which means reachability
340+ checks and diff computation are local operations rather than API calls.
341+
342+ ## 9. What git already does
343+
344+ A recurring theme in this book: several things forges implement as product
345+ features already exist in git, and reimplementing them is what makes forges heavy.
346+
347+ **Proposed changes.** Gerrit has pushed changes to `refs/for/<branch>` since 2008,
348+ storing patchsets under `refs/changes/`. GitHub itself stores every pull request at
349+ `refs/pull/<n>/head`; you can fetch any pull request on any repository that way
350+ right now. Gitea does the same. The ref mechanism is not novel and not risky. It is
351+ already how every forge works internally, hidden behind a fork abstraction that
352+ exists for permission reasons rather than technical ones.
353+
354+ **Attached discussion.** Notes, since 2010. Essentially unused by forges.
355+
356+ **Access control.** Hooks, since forever. Forges reimplement this as database rows
357+ consulted by application code.
358+
359+ **Merging.** `git merge`, obviously. A merge button is a remote procedure call to
360+ run a command you could have run locally, with the side effect that the server must
361+ now understand merge strategies, conflict resolution, and rebasing.
362+
363+ **Configuration distribution.** Files in the tree. CI systems already learned this
364+ lesson; `.github/workflows` is config in the repository. Forges applied the lesson
365+ to build definitions and then stopped.
366+
367+ barerepo's contribution is not inventing a mechanism. It is refusing to build a
368+ second, worse mechanism on top of the one that already exists.
369+
370+ ---
371+
372+ # Part III. Mechanics
373+
374+ ## 10. Identity
375+
376+ An account is a name plus one or more ssh public keys. There is no email address,
377+ no password, and no verification link.
378+
379+ ### Why
380+
381+ An email address on a forge is a hostage. It is the recovery channel, so it is the
382+ account, so losing access to it or having it disputed loses the account. It is also
383+ the vector for the notification firehose that every forge eventually builds.
384+
385+ An ssh key is different in kind. The user already generates one to push. The
386+ server only ever holds the public half. Possession is the credential, and
387+ possession is not something the server can revoke, transfer, or lose on the user's
388+ behalf.
389+
390+ ### Signup
391+
392+ The form collects a name and one public key, and then asks you to sign a nonce
393+ with that key.
394+
395+ ```
396+ 1. POST /signup { name, pubkey } -> nonce
397+ 2. POST /signup { nonce, signature } -> account, session
398+ ```
399+
400+ Validate that the name is unused and matches `^[a-z0-9][a-z0-9-]{0,38}$`. Validate
401+ that the key parses as a supported type, and that no other account holds it.
402+ Then hand back a nonce and wait for a signature over it.
403+
404+ **Why signing, when the key alone would do.** A public key is public. GitHub
405+ publishes everyone's at `/<user>.keys`, and people paste theirs into issues. A
406+ key is also unique to one account here, so without proof of possession anyone
407+ can register a key they found and the person holding the private half can never
408+ use it. There is no email to appeal to, per this chapter, so that is permanent.
409+ Signing costs one command and closes it.
410+
411+ It also stops a slower mistake. Paste the wrong `.pub`, or one whose private
412+ half is long gone, and without a signature you find out weeks later when you
413+ try to sign in and cannot.
414+
415+ **The namespace is `barerepo-signup`, not `barerepo-auth`.** With one namespace a
416+ signature captured from a sign-in could be replayed to claim an account with
417+ somebody else's key, which is the attack this step exists to stop.
418+
419+ There is still nothing to verify in the email sense. No message is sent, no link
420+ is clicked, nothing is waited for. The signature is checked in the same second
421+ it arrives, and the account exists immediately after.
422+
423+ The signup page must state the tradeoff plainly rather than burying it: **losing
424+ every key loses the account, because there is no out-of-band recovery channel.**
425+ Push the user toward adding a second key immediately. This honesty is the cost of
426+ not holding an email hostage, and hiding it would be dishonest in a way the rest of
427+ the design is not.
428+
429+ ### Sign in
430+
431+ Challenge and response against the stored public key.
432+
433+ ```
434+ 1. client: POST /auth/challenge { name }
435+ 2. server: generate 32 random bytes, store with 10 minute expiry, return it
436+ 3. client: sign nonce with private key
437+ 4. client: POST /auth/verify { name, nonce, signature }
438+ 5. server: verify against every stored key for that name
439+ 6. server: on success, issue a session cookie
440+ ```
441+
442+ The CLI does steps 1 through 4 in one command:
443+
444+ ```
445+ br auth john
446+ ```
447+
448+ It prints a line to paste into the browser. The web page shows this command rather
449+ than a password field, because there is no password.
450+
451+ **A name with no account is told so.** An account name is public: it is a URL
452+ namespace, and anyone can look at `/<name>` and see whether it is taken. Holding
453+ that back at sign-in protects nothing, and it costs the user a dead end. They
454+ sign a nonce the server never stored, and the failure arrives several steps
455+ later wearing the wrong message. Say it at the first step, and offer signup.
456+
457+ **Ten minutes, not one.** Signing the nonce means leaving the browser, finding a
458+ terminal, running a command and coming back. A minute is enough time for a
459+ script and not enough for a person, and the person is who this flow is for.
460+
461+ The window is not what makes the exchange safe. The nonce is public: it is
462+ printed on the page. What proves possession is the signature, which needs the
463+ private key. Replay is stopped by deleting the nonce when it is used, whether or
464+ not the signature was any good, so a spent challenge cannot be tried twice.
465+
466+ Session cookies are opaque random tokens in a server table, `HttpOnly`, `Secure`,
467+ `SameSite=Lax`. Not JWTs. There is no scaling problem here that a table lookup does
468+ not solve, and revocation matters more than statelessness.
469+
470+ ### Git authentication
471+
472+ Over ssh, the ssh daemon authenticates by key before barerepo sees the connection.
473+ Map the key fingerprint to an account and hand off to `git-upload-pack` or
474+ `git-receive-pack` with the account in the environment.
475+
476+ Over https, use a token in the password field. Same tokens as the runner tokens in
477+ chapter 15, different scope.
478+
479+ ### The server-owned items
480+
481+ Rule 3 says nothing is stored outside git except a closed list. Here it is,
482+ complete. Each entry states why it cannot be a git object.
483+
484+ 1. **Account name to public keys.** Cannot live in a repository, because it is
485+ what authorizes repository access in the first place.
486+ 2. **Namespace ownership.** `john/johnbot` belongs to `john`. Set at creation.
487+ A config file cannot grant its own authority. See chapter 11.
488+ 3. **Tokens.** Runner tokens, job tokens, feed tokens, sessions, and the one-use
489+ code `br auth` trades for a browser session. Secrets, and therefore not
490+ committable.
491+ 4. **Namespace redirects.** Where a renamed or transferred repository used to
492+ live. Cannot be in the repository, because the repository is what moved, and
493+ the redirect must answer for a path that no longer holds one.
494+ 5. **Release artifacts.** Binaries attached to a tag. Not in git because build
495+ output is derived, large and numerous, and putting it in git makes every clone
496+ download every binary of every version forever. See chapter 22.3 for the honest
497+ cost of this.
498+ 6. **Caches, indexes and derived state.** Search index, rendered diff cache, blame
499+ cache, the inbox event log, the `last_visited` timestamp. All discardable. All
500+ rebuildable from git alone.
501+ 7. **Work in flight, and who is doing it.** Attached runners, the job queue, and a
502+ webhook's run of failures. None of it is in git because none of it is about the
503+ repository's contents; it is about machines talking to this server right now. It
504+ is discardable, and losing it costs a reattach, a rebuild on the next push, and
505+ a stopped hook starting again. It is not item 6, because it is not rebuildable
506+ from git: nothing in a repository records that a laptop dialed in this morning.
507+
508+ **This list was four items in an earlier draft and the count was wrong twice.**
509+ Redirects and artifacts were added to the design without being added here, which
510+ made rule 3 false while it was still being cited. Then runners, the job queue and
511+ webhook failures were built and the list stayed at six, which made it false again.
512+ The count is stated plainly because a closed list that grows without saying so is
513+ worse than an open one, and this one has grown twice.
514+
515+ Two things that look like they belong here and do not:
516+
517+ - **The proposal counter** is stored as a ref inside the repository, not on the
518+ server. `git update-ref` with an expected old value gives atomic allocation for
519+ free, and the number then travels with the repository. See chapter 12.
520+ - **The inbox event log** is derived. Every event it contains is reconstructable
521+ from reflogs, note timestamps and proposal refs. It sits under item 6.
522+
523+ Item 6 is the only one that may grow, and only with things that can be deleted
524+ without loss.
525+
526+ ## 11. Repositories and ownership
527+
528+ A repository is a bare git repository on disk plus a row recording who owns the
529+ namespace.
530+
531+ ```
532+ /var/lib/barerepo/repos/john/johnbot.git
533+ ```
534+
535+ ### The bootstrap problem
536+
537+ Configuration lives in `.barerepo/config` inside the repository, including the list of
538+ who may push. This creates a circularity: if the file grants push access, and push
539+ access lets you edit the file, then anyone who can push can grant themselves
540+ anything, and there is nothing to bootstrap the first grant.
541+
542+ The resolution is that **ownership comes from the URL namespace and is not editable
543+ from inside the repository.** `john/johnbot` is owned by `john` because it is under
544+ `john`, which was decided at creation. The owner may always push, always edit the
545+ config, and always transfer or delete the repository. Everyone else derives their
546+ access from the config file, which the owner controls.
547+
548+ This is why namespace ownership is item 2 on the closed list. It is the one grant
549+ that cannot be self-referential.
550+
551+ ### Creation
552+
553+ There are two ways to create a repository. Both produce an identical result.
554+
555+ **From the web form.**
556+
557+ ```
558+ POST /new { name, description, default_branch, visibility }
559+ ```
560+
561+ Create the bare repository. Run `git symbolic-ref HEAD refs/heads/<default_branch>`.
562+ Install the hooks. Record ownership. Do not create an initial commit, a README, or
563+ a license file. An empty repository is empty, and chapter 24 describes the page
564+ that makes that useful.
565+
566+ **By pushing to a name that does not exist.**
567+
568+ ```
569+ git remote add origin git@barerepo.example:john/johnbot
570+ git push -u origin master
571+ ```
572+
573+ The repository does not exist. The server creates it, then accepts the push.
574+
575+ This exists because the web form is an interstitial. Without it the sequence is:
576+ leave the terminal, open a browser, fill a form, copy a remote URL, return to the
577+ terminal. The push already contains every fact the form was asking for.
578+
579+ The form is kept because it is discoverable, because it is how someone creates an
580+ empty repository to push to later, and because a person who has not yet pushed
581+ anything needs somewhere to start. The form tells the user the shortcut exists.
582+
583+ **Where creation happens.** Not in a hook. There is no repository yet, so there
584+ are no hooks to run. Creation happens in `barerepo ssh` for ssh, and in the http
585+ handler for https, after authentication has identified the account and before
586+ `git-receive-pack` is invoked.
587+
588+ **A pushed `.barerepo/config` decides at once.** The default below applies when
589+ the push carries no config file. If it carries one that says
590+ `visibility = "public"`, the repository is public the moment it lands, because
591+ the file is the setting. Cloning a public repository and pushing it to a new
592+ name therefore produces a public repository, which is the file being obeyed and
593+ not the default being lost.
594+
595+ **Defaults on push-to-create.**
596+
597+ - The default branch is the branch you pushed. HEAD is set from it. This is more
598+ accurate than a form field, because it matches what the user already has.
599+ The repository is created before the push arrives, so its HEAD at that moment
600+ is a placeholder; `post-receive` points it at the branch that actually came.
601+ - Visibility is private. Accidentally publishing code is not recoverable.
602+ Accidentally hiding it is one line in `.barerepo/config`.
603+ - Description is empty.
604+
605+ **Where visibility actually lives, and why the form cannot store it.**
606+
607+ Visibility is `[repo] visibility` in `.barerepo/config`, which is a file in the
608+ tree. A repository with no commits has no tree, so there is nowhere to put it,
609+ and the rule above says do not create an initial commit. The server will not
610+ keep a copy either, because that is rule 3.
611+
612+ So an empty repository is private, whichever way it was made. The web form's
613+ visibility field does not write anything at creation. If the user picks public,
614+ the empty-repository page adds two lines to the block it tells them to paste:
615+
616+ ```
617+ mkdir -p .forge && printf '[repo]\nvisibility = "public"\n' > .barerepo/config
618+ git add .barerepo/config
619+ ```
620+
621+ This is rule 2 doing real work rather than decoration. The setting is a commit
622+ with an author and a date from the first moment it exists, and the user has
623+ seen the file that holds it before they ever go looking for a settings page.
624+
625+ **Rules that must hold.**
626+
627+ - Creation is allowed only in the pusher's own namespace. A push to
628+ `lisa/thing` by `john` is rejected, not created.
629+ - `refs/proposals/new` never creates a repository. A proposal pushed to a
630+ nonexistent repository is a typo, not a contribution.
631+ - A name inside the deletion trash window fails rather than creating. See
632+ chapter 44.4.
633+ - A name that was transferred away follows the redirect rather than creating.
634+ See chapter 44.2.
635+ - The name must pass the validation in chapter 42.5.
636+
637+ **The cost, stated plainly.** Typos create repositories. A push to `jonhbot`
638+ creates `jonhbot`. Gitea has this exact behavior and this exact problem. Two
639+ mitigations: print the created repository's URL prominently in the push output so
640+ the mistake is visible immediately, and keep deletion easy.
641+
642+ Make this behavior a server option, `allow_push_to_create`, default on. An
643+ administrator running a locked-down instance will want it off.
644+
645+ ### The default branch
646+
647+ Set at creation, defaulting to `master`, changeable to anything with one field.
648+ Stored as HEAD in the repository itself, which is where git already keeps it, and
649+ mirrored into `.barerepo/config` for visibility once a first commit exists.
650+
651+ **Never hardcode either name.** Read HEAD. This is stated three times in this book
652+ because it is the single easiest way to accidentally ship a bug that only affects
653+ half your users.
654+
655+ ## 12. Proposals
656+
657+ The core mechanism. Read chapter 6 first if refs are unfamiliar.
658+
659+ ### The lifecycle
660+
661+ ```
662+ contributor server maintainer
663+
664+ git push HEAD:
665+ refs/proposals/new ------> pre-receive
666+ allocate 47
667+ rewrite ref
668+ <------ print URL
669+
670+ refs/proposals/47
671+ state: open
672+ <----- git fetch
673+ refs/proposals/47
674+
675+ git merge prop-47
676+ post-receive <----- git push
677+ is-ancestor? yes
678+ state: merged
679+
680+ the server never
681+ ran git merge
682+ ```
683+
684+ A contributor clones, commits, and pushes to a magic ref:
685+
686+ ```
687+ git clone https://barerepo.example/john/johnbot
688+ cd johnbot
689+ git commit -am "fix panic on empty config"
690+ git push origin HEAD:refs/proposals/new
691+ ```
692+
693+ No fork. No permission grant. No repository duplication.
694+
695+ `pre-receive` sees an update to `refs/proposals/new`, which is reserved and never
696+ actually created. It allocates the next integer for this repository, rewrites the
697+ destination, and prints the resulting URL to the pusher's terminal.
698+
699+ **How a push to one ref lands on another.** `pre-receive` cannot do this: it can
700+ only accept or reject. The rewrite is done by git's `proc-receive` hook, which
701+ exists for exactly this and which Gerrit-style workflows are the reason for. It
702+ speaks pkt-line on stdin and stdout, performs the ref update itself, and reports
703+ back the ref that was really written, so the pusher's own terminal prints
704+ `refs/proposals/47` rather than the magic name.
705+
706+ It runs only for refs that match `receive.procReceiveRefs`, which barerepo sets per
707+ repository when it installs the hooks:
708+
709+ ```
710+ git config --replace-all receive.procReceiveRefs refs/proposals
711+ ```
712+
713+ The value is a **prefix, not a glob**. `refs/proposals/*` matches nothing, the
714+ hook is then never called at all, and and the push creates a ref literally
715+ named `refs/proposals/new`. There is no warning, and the only symptom is the
716+ wrong ref appearing.
717+
718+ The maintainer reviews locally:
719+
720+ ```
721+ git fetch origin refs/proposals/47:prop-47
722+ git merge prop-47
723+ git push
724+ ```
725+
726+ `post-receive` then observes that `refs/proposals/47` is now an ancestor of HEAD
727+ and marks the associated thread merged.
728+
729+ **The server did not merge anything.** It watched a push happen and drew a
730+ conclusion. This is rule 1, and it eliminates every line of server-side merge
731+ strategy, conflict resolution, and rebase logic that other forges carry.
732+
733+ ### Allocation
734+
735+ Proposal numbers and thread numbers share one counter per repository, because a
736+ proposal is a thread with a ref attached.
737+
738+ **The counter is a ref, not a server table.**
739+
740+ ```
741+ refs/meta/counter
742+ ```
743+
744+ It points at a blob containing an integer. Allocation reads it, then writes the
745+ new value with `git update-ref refs/meta/counter <new> <expected-old>`. The
746+ expected-old argument makes the write fail if another push moved it first, so
747+ concurrent allocation is atomic without a lock or a transaction.
748+
749+ This keeps the counter out of the server-owned list in chapter 10, and it means
750+ the number travels with the repository through a mirror, a transfer or a copy.
751+
752+ ### Reachability detection
753+
754+ On every push to `refs/heads/*`, for each open proposal in the repository:
755+
756+ ```
757+ git merge-base --is-ancestor refs/proposals/<n> <new-head>
758+ ```
759+
760+ Exit 0 means merged. Close the thread and record the merge commit.
761+
762+ This is O(open proposals) per push, and each check is fast, but on a repository
763+ with hundreds of open proposals it is worth checking only proposals whose tip is
764+ in the pushed commit range. Compute the range once with
765+ `git rev-list <old>..<new>` and test membership.
766+
767+ ### Updating a proposal
768+
769+ Push again to the same ref:
770+
771+ ```
772+ git push -f origin HEAD:refs/proposals/47
773+ ```
774+
775+ Force is required because the history may have been rewritten. barerepo records each
776+ push as a new revision in the thread, in the pusher's own name, saying which
777+ revision it is and whether it rewrote the last one. Without that line a reviewer
778+ reads comments about code that is no longer there and cannot tell where the change
779+ happened. The previous tip is retained in `refs/revisions/47/<k>` so review comments
780+ anchored to old commits do not dangle. Old revisions expire per chapter 26.
781+
782+ **Why revisions are not under `refs/proposals/47/`.** They cannot be. A ref is a
783+ file, so `refs/proposals/47` and `refs/proposals/47/rev/1` ask git for a file
784+ and a directory of the same name, and git refuses:
785+
786+ ```
787+ cannot lock ref 'refs/proposals/47/rev/1':
788+ 'refs/proposals/47' exists; cannot create 'refs/proposals/47/rev/1'
789+ ```
790+
791+ `refs/proposals/47` has to stay exactly where it is, because chapter 36.5 tells
792+ every reviewer to fetch it by that name. So the revisions move instead.
793+
794+ Only the proposal's original author and users with push access may update a given
795+ proposal ref.
796+
797+ **The author is the account that pushed, recorded in the thread's `meta` blob.**
798+ Never the commit author. A commit's author is whatever the pusher typed into
799+ `git config user.name`, so deciding access from it lets anyone take over a
800+ proposal by picking the right name. The two differ constantly in ordinary use
801+ as well: applying somebody's patch and pushing it is normal.
802+
803+ ### What barerepo does not require
804+
805+ Gerrit pioneered this mechanism and is widely disliked. The dislike is almost
806+ entirely about policies layered on top of the mechanism rather than the mechanism
807+ itself. barerepo takes the mechanism and refuses the policies:
808+
809+ - **No `Change-Id` trailer**, and therefore no commit-msg hook to install.
810+ - **No one-commit-per-proposal.** Push whatever history you have. Messy is fine.
811+ - **No forced rebase** when the target branch moves.
812+ - **No numeric scoring** or approval categories.
813+
814+ If a future version wants enforced linear history or gated review, it can add
815+ them, and it will inherit the same complaints. The default is loose.
816+
817+ ### States
818+
819+ ```
820+ open ref exists, not reachable from HEAD
821+ merged ref is an ancestor of HEAD
822+ closed explicitly closed by owner or author, ref retained
823+ abandoned no activity for the expiry window, ref eligible for GC
824+ ```
825+
826+ There is no "draft" state. Do not push it if it is not ready.
827+
828+ ## 13. Threads
829+
830+ A thread is a discussion. If a proposal ref is attached, it is what another forge
831+ would call a pull request. If not, it is what another forge would call an issue.
832+
833+ **There is no type field and there are no separate lists.** This is not a
834+ simplification for its own sake. On every other forge, the moment an issue needs
835+ code, someone opens a second object and links the two by hand, and the discussion
836+ splits across both. Unifying them removes an entire category of bookkeeping.
837+
838+ ### Storage
839+
840+ ```
841+ refs/notes/threads/<n>
842+ ```
843+
844+ **The tree is keyed by the object each note annotates**, because that is what
845+ git notes is, and it is what makes the commands in 35.4 work:
846+
847+ ```
848+ git log --show-notes=threads/47
849+ git notes --ref=threads/47 show <sha>
850+ ```
851+
852+ A note is one blob at the path `<sha>`, holding the comments on that object in
853+ order. Each comment is a record:
854+
855+ ```
856+ author: lisa
857+ time: 1787074650
858+ anchor: config.go:43 (optional, for line comments)
859+ revision: 2 (optional, which proposal revision)
860+
861+ fresh install, empty config.toml, immediate nil deref on line 44.
862+ ```
863+
864+ Headers, blank line, markdown body. The shape is an email message, because that
865+ format has survived fifty years of adversarial use. Records are
866+ separated by a line containing only `--`, and are written in time order.
867+
868+ **Comments are records inside one note, not one blob each.** An earlier draft
869+ named a blob per comment, `<unix-timestamp>-<author>-<short-hash>`, so that
870+ lexical sort was chronological. That layout cannot be read by git: `git notes`
871+ looks up a note by the annotated object's hash, so a tree keyed by anything
872+ else is invisible to every command in 35.4, and the offline promise in chapter 3
873+ becomes false. Appending records is also exactly the shape `union` merge
874+ resolves correctly, which is what chapter 7 needs.
875+
876+ **Thread metadata lives in a blob named `meta` at the root of the same tree.**
877+
878+ ```
879+ title: panic when config file is empty
880+ state: open open | merged | closed | abandoned
881+ ref: refs/proposals/47 (optional, present if a proposal is attached)
882+ opened: 1787074650
883+ author: lisa
884+ ```
885+
886+ `meta` is not a valid object hash, so git notes ignores it and every command in
887+ 35.4 keeps working. barerepo reads it. This is verified rather than assumed: a
888+ `meta` blob alongside real notes does not disturb `git log --show-notes`.
889+
890+ ### Anchoring a comment to a line
891+
892+ A comment may carry an `anchor` header such as `config.go:43`. The file will
893+ change. The anchor must not silently point at the wrong line. Chapter 37
894+ specifies the resolution rule in full.
895+
896+ ### The conflict problem
897+
898+ Two people commenting at the same time both push to `refs/notes/threads/47`. The
899+ second push fails as non-fast-forward, exactly as it would on a branch.
900+
901+ For an append-only comment stream, git's `union` merge strategy for notes resolves
902+ this correctly, because two comments are two separate blobs and the union of the
903+ trees is the desired result. Configure it:
904+
905+ ```
906+ git config notes.rewriteMode ignore
907+ git config notes.mergeStrategy union
908+ ```
909+
910+ Server-side, on conflict, fetch, merge with union, retry the write. Bound the
911+ retries and fail loudly rather than silently dropping a comment.
912+
913+ **Edits and deletions are not append-only and union merge does not handle them.**
914+ Pick one of two answers before shipping:
915+
916+ - **Tombstones.** An edit writes a new blob referencing the old one's name; a
917+ deletion writes a tombstone blob. Rendering resolves the chain. History is
918+ preserved, which is honest, and the tree grows.
919+ - **Last write wins.** Simpler, loses concurrent edits silently.
920+
921+ barerepo chooses tombstones, because a discussion that can be silently rewritten is
922+ worth less than one that cannot, and because the whole premise is that the clone
923+ contains the truth.
924+
925+ ### Reading offline
926+
927+ ```
928+ git fetch origin "refs/notes/*:refs/notes/*"
929+ git log --show-notes=threads/47
930+ ```
931+
932+ Every thread page shows this. It is the proof of the claim that the discussion is
933+ yours.
934+
935+ ## 14. Configuration
936+
937+ `.barerepo/config` at the tip of the default branch, TOML.
938+
939+ ```toml
940+ [repo]
941+ default_branch = "master"
942+ visibility = "public"
943+ description = "irc bot that refuses to leave"
944+
945+ [access]
946+ push = ["john", "lisa"]
947+
948+ [proposals]
949+ accept_from = "anyone" # anyone | authenticated | push
950+ require_runs = ["build", "test"]
951+ expire_days = 180
952+
953+ [runners]
954+ "uproar.local" = ["build", "test"]
955+ "lisa-mbp" = ["build"]
956+
957+ [build]
958+ command = "make ci"
959+ image = "golang:1.26"
960+
961+ [[webhook]]
962+ url = "https://example.com/hook"
963+ events = ["push"]
964+ secret_env = "DEPLOY_HOOK_SECRET"
965+ ```
966+
967+ ### When it is read
968+
969+ On every push, from the tip of the default branch, before the access check. Cache
970+ by tree hash; the file changes rarely and the parse is on the hot path.
971+
972+ A malformed config file must not lock anyone out. On parse failure, fall back to
973+ the last known good version and print a warning to the pusher's terminal. The owner
974+ can always push regardless, because owner access comes from the namespace and not
975+ from the file.
976+
977+ ### Why this instead of a settings page
978+
979+ Because changing it is a commit. It has an author, a timestamp, a diff, a revert,
980+ and a review path. A team can require review for a change to who may push, using
981+ the same mechanism they use for code, which no settings page can offer.
982+
983+ It also means configuration clones. Moving a repository to a new host moves its
984+ policy with it.
985+
986+ ## 15. Runners
987+
988+ Builds run on machines the user owns. barerepo dispatches and records; it does not
989+ execute.
990+
991+ ### Why
992+
993+ Hosted CI is the expensive part of running a forge and the metered part of using
994+ one. Removing it removes the largest operating cost and the most common reason to
995+ hit a paywall. It also means a build has access to whatever the user's machine has,
996+ which is frequently the actual requirement.
997+
998+ ### Attaching a runner
999+
1000+ The whole flow is one line, and this is the most visible proof of the
1001+ no-interstitial principle:
1002+
1003+ ```
1004+ curl -sL barerepo.sh | sh -s rt_live_7Kq2mXe
1005+ ```
1006+
1007+ **The token is in the copied line.** There is no subsequent step, no config file to
1008+ create, no web form to fill in after the install, no OS selector to click. One
1009+ copy, one paste, and the page the user copied from updates the moment the runner
1010+ attaches.
1011+
1012+ Three platforms are displayed simultaneously rather than behind tabs. Tabs hide two
1013+ thirds of the answer to save nine lines of vertical space.
1014+
1015+ ### The protocol
1016+
1017+ ```
1018+ runner (your laptop) server
1019+
1020+ POST /runner/attach ------------> verify token
1021+ <------------ runner_id
1022+
1023+ GET /runner/poll ------------> hold up to 30s
1024+ <------------ 204, or a job
1025+
1026+ clone at sha
1027+ run command
1028+ POST /runner/log ------------> append to buffer
1029+ POST /runner/log ------------>
1030+ POST /runner/done ------------> write refs/notes/runs
1031+
1032+ GET /runner/poll ------------> loop forever
1033+
1034+ every arrow points right first.
1035+ the server never opens a connection to the runner.
1036+ no inbound port. no public address. NAT is fine.
1037+ ```
1038+
1039+ The runner dials out and long-polls. It never needs an inbound port, a public
1040+ address, or a hole in a firewall, which is what makes "paste this on your laptop"
1041+ actually work.
1042+
1043+ ```
1044+ 1. runner -> POST /runner/attach { token, hostname, os, arch, labels }
1045+ 2. server -> { runner_id, poll_interval }
1046+ 3. runner -> GET /runner/poll?id=... (long poll, 30s timeout)
1047+ 4. server -> 204 no content, or a job:
1048+ { job_id, repo, ref, sha, command, image, clone_url, job_token }
1049+ 5. runner -> clone at sha, run command, stream output
1050+ 6. runner -> POST /runner/log { job_id, seq, chunk } (repeatedly)
1051+ 7. runner -> POST /runner/done { job_id, exit_code, duration }
1052+ 8. goto 3
1053+ ```
1054+
1055+ `job_token` is scoped to one job and one repository, expires when the job ends, and
1056+ is what the runner uses to clone. The long-lived runner token never leaves the
1057+ runner.
1058+
1059+ A runner that stops polling for three intervals is marked offline. A job whose
1060+ runner disappears mid-run is requeued once, then marked failed with a distinct
1061+ status, because an infinite requeue loop on a poison job is the classic failure
1062+ mode here.
1063+
1064+ ### Job dispatch
1065+
1066+ On push to any ref, if `[build] command` is set, create a job for the new tip.
1067+ Otherwise read `.github/workflows` and create a job per workflow job, per chapter
1068+ 15A. Queue per repository, first in first out.
1069+
1070+ A job carries the labels it asked for. A runner takes the first queued job it
1071+ satisfies, rather than the first queued job, so a build waiting for a machine
1072+ nobody has attached does not hold up the builds that could run now.
1073+
1074+ ### Token lifecycle
1075+
1076+ Tokens are issued per repository, displayed once at creation inside the command,
1077+ and revocable from the keys page. Store a hash, not the token. Rotating a token
1078+ requires re-pasting the line, which is one command, which is the point.
1079+
1080+ ## 15A. GitHub workflows
1081+
1082+ A repository arriving from GitHub already says how it builds, in
1083+ `.github/workflows`. barerepo reads that file rather than asking for it again.
1084+
1085+ **Why this is not a contradiction.** Chapter 3 names configuration distribution
1086+ as something git already solved, and credits `.github/workflows` with being the
1087+ place that learned it: config is a file in the tree. The objection in this book
1088+ has never been to that file. It is to a forge that can only be configured
1089+ through its own web forms. Reading a workflow is honouring the same rule that
1090+ `.barerepo/config` honours.
1091+
1092+ **The aim is a workflow that needs no edit.** A repository should be able to
1093+ arrive with the file it already has and build. Every decision below follows from
1094+ that, and where barerepo cannot do something it says so rather than asking for the
1095+ file to be changed.
1096+
1097+ **What barerepo does with it.** Every `run:` step becomes a line in one shell
1098+ script, in order, beginning with `set -e`, because GitHub fails a job at its
1099+ first failing step and a script without it would run on and report the last
1100+ command. `env:` becomes exports, `working-directory:` a subshell, and
1101+ `container:` the image.
1102+
1103+ The environment GitHub sets is set: `CI`, `GITHUB_ACTIONS`,
1104+ `GITHUB_REPOSITORY`, `GITHUB_REF`, `GITHUB_REF_NAME`, `GITHUB_SHA`,
1105+ `GITHUB_WORKFLOW`, `GITHUB_JOB`, `GITHUB_EVENT_NAME`, and the runner's own
1106+ `RUNNER_OS`, `RUNNER_ARCH` and `RUNNER_TEMP`. A workflow that reads those needs
1107+ no change.
1108+
1109+ The expressions naming that same context are filled in: `github.sha`,
1110+ `github.ref`, `github.ref_name`, `github.repository`, `github.event_name`,
1111+ `github.workspace`, `github.workflow`, `github.job`, `runner.os`, `runner.arch`
1112+ and `runner.temp`. This is not an expression evaluator and will not become one.
1113+ It is a substitution, and it exists because `${{` reaches `sh` as a bad
1114+ substitution and fails the step outright.
1115+
1116+ `actions/checkout` is answered rather than run, because barerepo cloned the
1117+ repository at the commit already. `actions/cache` is answered as a statement
1118+ that barerepo does not cache. The `setup-` actions become a check that the tool is
1119+ on the machine, and print the version the workflow asked for beside the version
1120+ the machine has.
1121+
1122+ **Why a setup action checks rather than installs.** The runner is a machine the
1123+ user owns. A build that installs a toolchain onto somebody's laptop without
1124+ saying so does something the person who pasted one line did not agree to. Chapter 15 says
1125+ a build has access to whatever the machine has; it does not say a build may
1126+ change what the machine has.
1127+
1128+ **A matrix builds a job several times, so barerepo queues it several times.** The
1129+ axes are multiplied, `exclude` removes what it names, and each combination
1130+ becomes its own job with its own `runs-on`. The combination is substituted into
1131+ the command, the image and the machine, and exported as `MATRIX_<AXIS>` so a
1132+ step reading the environment works too.
1133+
1134+ Each job carries its combination in its name, `test (go 1.26, os ubuntu-latest)`,
1135+ because a commit built four times is unreadable otherwise. The name reaches the
1136+ run, so the runs page says which combination failed.
1137+
1138+ `include` is not applied. It can add keys to a combination and whole
1139+ combinations that no axis names, and a wrong guess there runs a build the
1140+ workflow did not ask for. barerepo says it did not apply it and builds the axes.
1141+
1142+ **What barerepo declines, out loud.** A step using any other action. A step or job
1143+ behind an `if:`, because an expression is a program and barerepo has no evaluator.
1144+ A step asking for a shell barerepo cannot start. Any expression outside the lists
1145+ above, `secrets` first among them.
1146+
1147+ Each of these is printed in the push output, naming the step and the reason.
1148+ This is the rule the whole feature rests on: **a skipped step is never silent.**
1149+ A build that reports success after running half of what was asked is worse than
1150+ no build at all, and it is the failure mode every partial
1151+ implementation of somebody else's format tends toward.
1152+
1153+ **`runs-on` and the machines you actually have.** barerepo cannot conjure a
1154+ machine. `ubuntu-latest` is matched against the operating system a runner
1155+ reported when it attached, as are the other hosted images; `self-hosted` is
1156+ always true, because every runner here is. Anything else must be a label the
1157+ runner declared.
1158+
1159+ When no attached machine satisfies a job, barerepo does not queue it and does not
1160+ fail without saying so. The push says which machine the job wanted and prints the link to
1161+ the add-a-runner page:
1162+
1163+ ```
1164+ unit wants a ubuntu-latest machine and none is attached.
1165+ attach one: https://barerepo.example/john/johnbot/runners/new
1166+ ```
1167+
1168+ **Precedence.** `[build] command` wins. A repository that has told barerepo how to
1169+ build in barerepo's own file is not second-guessed, and the workflow is not read.
1170+ This keeps chapter 14's file the answer for repositories that want one command,
1171+ and makes the workflow the answer for repositories that arrived with one.
1172+
1173+ **What this is for.** It removes the largest cost of moving a repository here,
1174+ which is rewriting CI before anything builds. It is not an implementation of
1175+ GitHub Actions, it will not become one, and the list of declines above is the
1176+ honest boundary rather than a roadmap.
1177+
1178+ ## 16. Runs
1179+
1180+ Build results are notes on the built commit:
1181+
1182+ ```
1183+ refs/notes/runs
1184+ ```
1185+
1186+ One ref, with the note tree keyed by the commit that was built. Not one ref per
1187+ commit: `refs/notes/runs/<sha>` would give a busy repository a ref for every
1188+ commit it ever built, and `git log --show-notes=runs` would show nothing,
1189+ because git looks a note up by the annotated object's hash and not by the ref's
1190+ name. The claim below, that build history clones and is readable, holds only
1191+ with a single ref.
1192+
1193+ The two layouts also cannot coexist. `refs/notes/runs` and
1194+ `refs/notes/runs/<sha>` are a file and a directory of the same name, so a
1195+ repository that used per-commit refs cannot move to this one without deleting
1196+ all of them first.
1197+
1198+ ```json
1199+ {
1200+ "runner": "uproar.local",
1201+ "labels": ["build", "test"],
1202+ "ref": "refs/proposals/47",
1203+ "started": 1787074650,
1204+ "duration": 18,
1205+ "exit": 0,
1206+ "log": "<blob-sha>"
1207+ }
1208+ ```
1209+
1210+ `ref` is what was being built. A commit can arrive on a branch and on a
1211+ proposal, and the runs page in chapter 24 shows which, so the record has to
1212+ carry it: the commit alone does not say.
1213+
1214+ A commit can be built more than once, by a rerun or by two runners with
1215+ different labels. Each run is one record, and records are separated by a line
1216+ containing only `--`, exactly as thread comments are in chapter 13.
1217+
1218+ Log output under 64kb goes inline in an `output` field. Larger output is written
1219+ as a blob and named by `log` instead, so a long build does not make every reader
1220+ of the notes ref pay for it. Both are git objects, so build history clones with
1221+ the repository, which no other forge offers.
1222+
1223+ ### Displaying a run
1224+
1225+ Raw text, in order, on one page. No collapsible step sections, no per-step timing
1226+ theater, no live-updating spinner. If the log is 18kb, all 18kb are on the page.
1227+
1228+ The reason is not minimalism. A failing build is read by someone who wants to find
1229+ an error message, and the fastest way to find an error message is `ctrl-F` on a
1230+ page that contains all of the text. Collapsible steps defeat browser search, which
1231+ is the single most useful tool the reader has.
1232+
1233+ ## 17. Search
1234+
1235+ One index, one result set, spanning code, threads, and repositories. No tabs, no
1236+ scope selector, no query syntax to learn before the first useful result.
1237+
1238+ Index on push, incrementally, from the pushed range rather than a full rescan.
1239+ Index thread comments on note write. The index is item 6 on the closed list:
1240+ derived, discardable, rebuildable from git alone with a full rescan.
1241+
1242+ ### Access filtering
1243+
1244+ **Index everything. Filter at query time.**
1245+
1246+ Every indexed document carries its repository id. Every query is filtered against
1247+ the set of repositories the requesting user may read, per chapter 18.
1248+
1249+ This is a security requirement and is not a configuration option. Get it wrong and
1250+ private code leaks through search results, which is a cross-tenant data breach of
1251+ exactly the kind that has hit other forges.
1252+
1253+ Do not solve it by indexing only public repositories. That makes search useless to
1254+ the person who most needs it, which is the owner searching their own private work.
1255+
1256+ Filter in the query, not after it. Filtering the result set after ranking leaks
1257+ the existence and count of private matches.
1258+
1259+ Rank code matches above threads above repositories, on the theory that someone
1260+ searching a forge is usually looking for a symbol. Show the matching line with the
1261+ match highlighted, plus enough surrounding context to recognize it.
1262+
1263+ Cross-repository search is the default. This is the one place where barerepo answers a
1264+ question no other forge answers well, because it is the one place where the
1265+ architecture is not repository-scoped by default. Chapter 2 identified "the
1266+ repository became the atom" as a core mistake; search is where fixing it is
1267+ cheapest.
1268+
1269+ ## 18. Access control
1270+
1271+ The complete matrix. There is nothing else.
1272+
1273+ | Namespace | Who may write |
1274+ |---|---|
1275+ | `refs/heads/*` | namespace owner, plus `[access] push` |
1276+ | `refs/tags/*` | same |
1277+ | `refs/proposals/new` | per `[proposals] accept_from`, default anyone authenticated |
1278+ | `refs/proposals/<n>` | that proposal's author, plus anyone with push |
1279+ | `refs/notes/threads/*` | anyone who may read the repository |
1280+ | `refs/notes/runs` | the server only, via job completion |
1281+ | everything else | nobody |
1282+
1283+ **The namespace owner may write any ref in their own repository**, including
1284+ `refs/meta/*` and `refs/notes/runs`. Everyone else follows the table exactly.
1285+
1286+ This is what makes chapter 40.3 true. `git push --mirror` carries the counter,
1287+ the run notes and every proposal ref, so a matrix that refuses them leaves a
1288+ repository that can be taken and never put back, which is chapter 3's claim
1289+ with the second half missing. It grants nothing that was withheld: an owner who
1290+ wanted to fake a build result could already push whatever content they liked.
1291+
1292+ The force-push and deletion rules below still apply to the owner's branches,
1293+ because those protect the owner from themselves rather than from anyone else.
1294+
1295+ Read access is binary per repository: public or private, from `[repo] visibility`.
1296+ Private repositories are readable by the owner and by `[access] push`.
1297+
1298+ **Absent means private, and only the exact word `public` opens a repository.**
1299+
1300+ This matters more than it looks. A repository created by a push has no
1301+ `.barerepo/config` at all, because the server does not commit one, so "no file"
1302+ has to resolve to something. It resolves to private. So does an empty value, an
1303+ unknown value, and a typo. Nothing but `public` publishes code.
1304+
1305+ The rule follows from chapter 11: accidentally publishing code is not
1306+ recoverable, and accidentally hiding it is one line in a file. A default that
1307+ can be reached by misspelling a word must be the recoverable one.
1308+
1309+ ### Force-push and deletion
1310+
1311+ Write access says who may push. It does not say what they may do.
1312+
1313+ **Default: force-push and deletion are allowed on every branch except the default
1314+ branch.**
1315+
1316+ This is a correctness default rather than a policy preference. A force-push to the
1317+ default branch destroys other people's work with no undo and no record. A
1318+ force-push to a topic branch destroys only the pusher's own work.
1319+
1320+ Override per repository:
1321+
1322+ ```toml
1323+ [access]
1324+ allow_force_push = ["master"]
1325+ allow_delete = []
1326+ ```
1327+
1328+ Proposal refs are exempt. Force-pushing your own proposal is how you update it,
1329+ per chapter 12.
1330+
1331+ Log every force-push and every ref deletion with the old hash. The commits stay
1332+ reachable until garbage collection, so a logged old hash is a recovery path.
1333+
1334+ There are no teams, no organizations, no roles, and no per-path rules. If a
1335+ repository needs those, it has outgrown barerepo, and saying so is more honest than
1336+ building a permissions engine nobody can reason about.
1337+
1338+ ### Signed commits
1339+
1340+ A repository that distributes what it holds can refuse a commit that carries no
1341+ signature.
1342+
1343+ **Default: off. Every repository takes unsigned commits.**
1344+
1345+ ```toml
1346+ [access]
1347+ require_signed_commits = true
1348+ ```
1349+
1350+ With it on, pre-receive walks the commits the push adds to a branch or a tag, and
1351+ refuses the whole push unless every one of them carries a signature that verifies
1352+ against a key the server holds. The keys are the ssh keys accounts published for
1353+ authentication, written into an `allowed_signers` file whenever a key is added or
1354+ retired. A signature made with a key barerepo has never seen is refused the same as
1355+ no signature at all.
1356+
1357+ The namespace matters. Each entry is written `namespaces="git"`, so a signature
1358+ made to sign in cannot be replayed as a commit signature.
1359+
1360+ **A retired key keeps vouching for what it already signed.** Removing a key does
1361+ not delete it; it records the moment it stopped granting access and writes
1362+ `valid-before` on its entry. git checks a signature against the commit's own
1363+ timestamp, so work signed while the key was live still verifies, and anything
1364+ signed after the key retired does not. Rotating a key therefore costs nothing and
1365+ rewrites no history. The retired key opens no door: it leaves `authorized_keys` in
1366+ the same write.
1367+
1368+ The owner is not exempt. A rule that the owner can walk past protects the
1369+ repository from everybody except the person most able to break it.
1370+
1371+ Comment refs are exempt. Chapter 13 writes those from a client on a reply, and no
1372+ one signs a comment.
1373+
1374+ If the `allowed_signers` file is missing, the push is refused and says so. It lives
1375+ in the cache, which may be deleted at any time, so the server writes it at every
1376+ start and `barerepo doctor` puts it back. A repository that asked for signatures
1377+ must not quietly stop checking them.
1378+
1379+ ### Why "collaborator" barely matters here
1380+
1381+ On GitHub, adding a collaborator is how you let someone contribute at all. Here,
1382+ anyone can already push proposals and open threads without permission. Adding
1383+ someone to `push` only means they may write to `master` directly and skip their own
1384+ proposal. Most repositories never need to add anyone, and that is a feature.
1385+
1386+ ## 19. Feeds and the inbox
1387+
1388+ Chapter 30 rejects a notification inbox of the GitHub kind and rejects email as a
1389+ primary channel. That rejection left a hole: a proposal arrives and the owner
1390+ finds out by chance. A forge nobody hears from is broken.
1391+
1392+ The answer is a chronological event log, readable as a page or as Atom.
1393+
1394+ ### 19.1 Events
1395+
1396+ An event is written when something happens that another person would want to know
1397+ about:
1398+
1399+ ```
1400+ proposal.opened proposal.updated proposal.merged
1401+ thread.opened thread.replied thread.closed
1402+ push run.failed repo.transferred
1403+ ```
1404+
1405+ `run.succeeded` is not an event. A green build is not news.
1406+
1407+ ### 19.2 What lands in your inbox
1408+
1409+ - Anything in a repository you own.
1410+ - Anything in a thread you opened or replied to.
1411+ - Anything on a proposal you opened.
1412+
1413+ There is no watch button and no subscribe button. Participation is subscription.
1414+ If you have not touched a thread, you do not hear about it.
1415+
1416+ ### 19.3 The inbox page
1417+
1418+ `/inbox`. Newest first. One line per event: what happened, where, who, when.
1419+
1420+ Events older than 90 days are dropped. This is a feed, not an archive. The
1421+ archive is the repository.
1422+
1423+ ### 19.4 Read state
1424+
1425+ Read and unread flags would be per-user, per-event state that is not derivable
1426+ from git, so it would have to be added to the closed list in chapter 10 as a new
1427+ category rather than folded into item 6.
1428+
1429+ So there are no unread flags. There is one `last_visited` timestamp per account.
1430+ The page draws a horizontal rule at that point, so you can see what is new since
1431+ you last looked.
1432+
1433+ That timestamp is item 6 on the closed list: discardable. If it is lost you lose a
1434+ horizontal rule.
1435+
1436+ ### 19.5 Atom
1437+
1438+ Every feed is also Atom. This is the entire notification system.
1439+
1440+ ```
1441+ /inbox.atom?token=<feed-token> your inbox
1442+ /john/johnbot.atom one repository, public only
1443+ /john/johnbot/threads.atom threads in one repository
1444+ /john.atom one user's public activity
1445+ ```
1446+
1447+ Atom suits this design exactly. It is static XML, needs no JavaScript, needs no
1448+ account for public feeds, and works in software the user already chose.
1449+
1450+ **Atom and not RSS**, for three reasons that bite this payload in particular. RSS
1451+ 2.0's `<description>` has no defined content model: plain text, HTML and escaped
1452+ HTML are all in use and readers guess. barerepo emits commit subjects and thread
1453+ titles holding `<`, `&` and backticks, which is exactly where the guess goes wrong.
1454+ Atom gives every text construct a defined content model, `text` unless the element
1455+ says otherwise, so there is nothing to guess. barerepo sends plain text and says so by
1456+ saying nothing. RSS
1457+ dates are RFC 822, with two digit years and named timezones that readers parse
1458+ differently; Atom mandates RFC 3339. And Atom requires a unique `id` per entry,
1459+ where RSS's `guid` is optional with muddled permalink semantics, so a reader that
1460+ dedupes by URL shows the same event twice when a URL changes.
1461+
1462+ For a title, a link, a date and a line of text, either format would work. The
1463+ reasons above are edges, not the middle. **Both is the wrong answer**: two formats
1464+ is two code paths and two sets of escaping bugs, for a notification system this
1465+ chapter keeps small. One correct feed beats two adequate ones.
1466+
1467+ **The link says `feed`, not `atom`.** The format is a fact about the bytes and the
1468+ word is what a person hunts for. Somebody looking for a feed does not know they are
1469+ looking for Atom, and the URL still ends in `.atom` for anything that cares.
1470+
1471+ The feed token is separate from the session and read-only. Revoke it on the keys
1472+ page. A feed reader stores URLs in plain text, so a URL that grants write access
1473+ is a bad idea.
1474+
1475+ ### 19.6 No email
1476+
1477+ barerepo sends no email and runs no mail service. There is no SMTP configuration, no
1478+ bounce handling, no deliverability problem, no unsubscribe flow and no address to
1479+ leak in a breach.
1480+
1481+ If you want email, point a feed-to-email service at your Atom URL. That is
1482+ somebody else's job and they are better at it.
1483+
1484+ ## 20. Large files
1485+
1486+ ### 20.1 The default
1487+
1488+ LFS is off. `[behavior] allow_lfs = false`.
1489+
1490+ A forge for source code is not a file host. LFS adds a second storage system, a
1491+ second transfer protocol, a second authentication path, and a second place for
1492+ data to be lost or leaked.
1493+
1494+ ### 20.2 The blob size limit
1495+
1496+ Turning LFS off is not enough on its own. Without a limit, a user commits a 4 GB
1497+ video directly into git. That is worse than LFS, because it is in the history
1498+ permanently and every clone pays for it forever.
1499+
1500+ ```toml
1501+ [limits]
1502+ max_blob_mb = 100
1503+ max_push_mb = 2048
1504+ ```
1505+
1506+ `pre-receive` rejects any push containing a blob over `max_blob_mb`. The message
1507+ must name the file and its size. A rejection that says only "push too large" sends
1508+ the user hunting.
1509+
1510+ Check blob sizes with `git cat-file --batch-all-objects --batch-check` over the
1511+ pushed range, not over the whole repository.
1512+
1513+ ### 20.3 If an administrator enables LFS
1514+
1515+ Two requirements, both non-negotiable, both drawn from a real 2026 vulnerability
1516+ in Gogs where neither was met:
1517+
1518+ - **Per-repository object isolation.** Never a shared object directory. Shared
1519+ storage means one repository can overwrite another repository's objects.
1520+ - **Content hash verification on upload.** Verify that the uploaded bytes hash to
1521+ the OID the client claims. Without this an attacker replaces a legitimate object
1522+ with malicious content and no integrity warning is ever shown.
1523+
1524+ ### 20.4 What to tell the user instead
1525+
1526+ Large files belong in object storage, with a URL or a checksum in the repository.
1527+ The build fetches them. Say this in the rejection message.
1528+
1529+ ## 21. Copying, renaming, archiving
1530+
1531+ ### 21.1 Copying a project
1532+
1533+ Chapter 12 removed the fork as the contribution mechanism. That is correct, and it
1534+ created a different problem: the word "fork" also means taking a project somewhere
1535+ its maintainer will not go, and that is a legitimate and important thing to do.
1536+
1537+ Without an answer, "no forks" reads as "you cannot leave this project", which
1538+ is the opposite of what barerepo is for.
1539+
1540+ So copying is supported and is called copying.
1541+
1542+ ```
1543+ barerepo copy john/johnbot lisa/johnbot
1544+ ```
1545+
1546+ Server-side, using git alternates, so a 4 GB repository is not pulled down a home
1547+ connection and pushed back up.
1548+
1549+ **What is copied.** Branches, tags, all history, threads and notes.
1550+
1551+ **What is not copied.** Proposal refs. They belong to the original conversation.
1552+
1553+ **What alternates cost.** A copy made this way borrows the original's objects
1554+ through `objects/info/alternates`, which is what makes it instant. It also means
1555+ the copy is not standalone: delete the original and the copy loses the history
1556+ it never had its own copy of.
1557+
1558+ So a delete detaches its dependents first. `git repack -a -d` writes every
1559+ borrowed object into the copy, the alternates file goes, and the copy stands on
1560+ its own. This runs before the original is moved to trash, and a delete that
1561+ cannot detach a dependent fails rather than proceeding.
1562+
1563+ Without that step, copying is a way to lose somebody else's work by deleting
1564+ your own.
1565+
1566+ **What does not exist.** No "forked from" badge. No fork network graph. No
1567+ upstream tracking. No automatic pull request back to the original. It is a fast
1568+ copy, not a relationship.
1569+
1570+ If the copier wants to contribute back, they push a proposal to the original like
1571+ anyone else. That path was never blocked.
1572+
1573+ ### 21.2 Renaming
1574+
1575+ A repository or an account can be renamed. The old name is reserved permanently
1576+ and redirects.
1577+
1578+ **The old name is never freed.** This contradicts the instinct to recycle unused
1579+ names, and it is meant to. A recycled account name inherits the old identity's
1580+ comments, proposals and mentions in every thread that references it. That is
1581+ identity confusion, and reserving a string is cheap.
1582+
1583+ Renaming an account rewrites `authorized_keys` and every repository path in the
1584+ namespace. Rate limit it. Once a year is generous.
1585+
1586+ ### 21.3 Archiving
1587+
1588+ ```toml
1589+ [repo]
1590+ archived = true
1591+ ```
1592+
1593+ An archived repository is read-only. Pushes are rejected with a message saying
1594+ so. Threads accept no new comments. Builds do not run.
1595+
1596+ **The owner is exempt, and has to be.** Unarchiving is one line in
1597+ `.barerepo/config`, and that line arrives by push. If archiving rejected every
1598+ push, nothing could ever be unarchived and the flag would be a one-way door.
1599+ The owner's push always runs, for the same reason the owner can always push a
1600+ broken config in chapter 14: owner access comes from the namespace, not from the
1601+ file, so the file can never lock the owner out of the file.
1602+
1603+ It stays visible, clonable and searchable. Archiving says "this is finished", not
1604+ "this is gone".
1605+
1606+ Unarchive by setting the flag back and pushing. It is one line in a file, like
1607+ everything else.
1608+
1609+ ## 22. Releases and artifacts
1610+
1611+ ### 22.1 A release
1612+
1613+ A release is a tag, a body and some files.
1614+
1615+ The body is markdown, stored in `refs/notes/releases` as a note on the tag
1616+ object, so it clones with the repository and `git notes --ref=releases show
1617+ <tag>` prints it. One ref keyed by object, for the reason in chapter 16.
1618+
1619+ ### 22.2 Artifacts
1620+
1621+ Attached files go in the blob store, not in git.
1622+
1623+ **Why not in git.** Build outputs are derived, large and numerous. Putting them in
1624+ git means every clone downloads every binary of every version forever. That is how
1625+ repositories become unusable.
1626+
1627+ ### 22.3 The honest cost
1628+
1629+ This dents the portability claim in chapter 3, and the book will not pretend
1630+ otherwise.
1631+
1632+ `git clone --mirror` takes the code, the history, the threads, the config, the
1633+ release notes and the build logs. It does not take attached binaries.
1634+
1635+ That is acceptable, because artifacts are derived from source that you do have.
1636+ Losing them is not losing data. Say this in the release UI rather than letting a
1637+ user discover it during a migration.
1638+
1639+ ### 22.4 Retention
1640+
1641+ ```toml
1642+ [limits]
1643+ artifact_retain_days = 90
1644+ ```
1645+
1646+ Build artifacts expire. Release artifacts do not, because a published download
1647+ that disappears breaks other people's installers.
1648+
1649+ ### 22.5 Publishing from a build
1650+
1651+ A run can attach its output to a release. The job token from chapter 15 carries
1652+ the permission, scoped to one repository and one job.
1653+
1654+ ## 23. Webhooks
1655+
1656+ ### 23.1 Why they exist
1657+
1658+ A webhook is the escape hatch. It is what makes it acceptable to refuse every
1659+ integration request forever. There is no marketplace, no apps, no plugin system.
1660+ There is an HTTP POST and whatever the user builds behind it.
1661+
1662+ ### 23.2 Configuration
1663+
1664+ In `.barerepo/config`, so it versions and reviews like everything else:
1665+
1666+ ```toml
1667+ [[webhook]]
1668+ url = "https://example.com/hook"
1669+ events = ["push", "proposal.opened"]
1670+ secret_env = "DEPLOY_HOOK_SECRET"
1671+ ```
1672+
1673+ The secret is named, not written. The value lives in the server's secret store.
1674+ Committing a secret to a public repository must be impossible by construction.
1675+
1676+ ### 23.3 SSRF
1677+
1678+ A webhook URL is user-controlled and the server fetches it. This is the classic
1679+ server-side request forgery hole.
1680+
1681+ **Deny by default** these ranges: `127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`,
1682+ `192.168.0.0/16`, `169.254.0.0/16`, `100.64.0.0/10`, `::1`, `fc00::/7`,
1683+ `fe80::/10`.
1684+
1685+ Resolve the hostname and check the resolved address, not the string. Re-check
1686+ after every redirect. Do not follow redirects to a denied address.
1687+
1688+ The allowlist is a server setting, never a repository setting, or a repository
1689+ could grant itself access to the internal network.
1690+
1691+ ### 23.4 Delivery
1692+
1693+ POST JSON. Sign the body with HMAC-SHA256 in a header. Retry three times with
1694+ backoff. Disable a hook after 20 consecutive failures and show that on the config
1695+ page.
1696+
1697+ ## 23A. Deploy keys, bots and signatures
1698+
1699+ Three short decisions that would otherwise be silent.
1700+
1701+ **Deploy keys do not exist.** A bot is an account with a key. Add it to
1702+ `[access] push` like a person. One mechanism instead of two, and the audit trail
1703+ looks the same for both.
1704+
1705+ **Commit signatures are verified and displayed, and required only where a
1706+ repository asks.** barerepo already holds ssh public keys for authentication, so
1707+ ssh commit signing costs almost nothing. The default is that an unsigned push is
1708+ taken, because whether a project wants signatures is the project's decision. A
1709+ repository that has made that decision sets `require_signed_commits` under
1710+ `[access]`, and chapter 18 says what the hook then refuses.
1711+
1712+ **Two places, two amounts of detail.** The log page draws one mark before the hash
1713+ on a signed commit, in a slot every row carries, so the hashes stay in one column
1714+ and the rows keep one shape. It says whether, not what. The commit page says what:
1715+ signed by whom, or that the key is expired or revoked, or that the signature does
1716+ not match, which is the only one drawn in the danger colour.
1717+
1718+ **Whom means the account that published the key**, and it is a link to that account.
1719+ A key carries a name typed by whoever made it, which proves nothing. Which account
1720+ published it is a thing barerepo knows and can stand behind. A signature on a key
1721+ nobody published says `signed` and names nobody.
1722+
1723+ **The public halves are served at `/<user>.gpg`.** A badge on a page is the server's
1724+ claim about a signature. A reader who cares checks it in their own clone, which is
1725+ where every other claim barerepo makes is checked, so barerepo hands out the key rather
1726+ than asking to be believed:
1727+
1728+ ```
1729+ curl https://barerepo.example/john.gpg | gpg --import
1730+ git verify-commit a3f9c2
1731+ ```
1732+
1733+ **The commit page prints those two lines**, the way the thread page prints the two
1734+ that read a discussion offline. A reader who has never verified a signature has no
1735+ reason to know the commands exist, and a feature nobody can find is not built. The
1736+ profile links the keys too: `ssh keys: 2` goes to `/<user>.keys` and
1737+ `signing keys: 1` to `/<user>.gpg`.
1738+
1739+ `/<user>.keys` and `/<user>.gpg` are plain text, not a download: a person who clicks
1740+ the link reads the keys, and `curl` pipes them just the same. An account with more
1741+ than one key gets all of them, one after another, which is what `gpg --import` and
1742+ `authorized_keys` both take.
1743+
1744+ **barerepo does not show the signature itself.** It is in the commit object and a
1745+ reader cannot check it by looking at it. Drawing an armoured block on a page would
1746+ look like proof and be none.
1747+
1748+ **A signing key is published on the keys page.** Paste the armoured public key and
1749+ barerepo holds it, the way it already holds an ssh key. It is only ever read: barerepo
1750+ names you on a commit you signed and can do nothing else with it. Every published
1751+ key goes into one keyring, rebuilt whenever a key is added or revoked, and that ring
1752+ is the only one git is given. A commit signed with a key nobody published says
1753+ `signed` and claims nothing about who signed it.
1754+
1755+ **Presence is read from the commit object, not from a verification.** The signature
1756+ is a `gpgsig` header, so the log page knows a commit is signed without running gpg
1757+ once, let alone twenty times. Verification is one more process and only the commit
1758+ page, for one commit, pays it. A server with no gpg installed still marks the log
1759+ correctly and says on the commit page that it cannot check the key.
1760+
1761+ **Submodules work and get no special support.** They are ordinary git. Document
1762+ one trap: a private submodule needs credentials the runner may not have, and the
1763+ failure looks like a broken build rather than a permissions error.
1764+
1765+
1766+ ---
1767+
1768+ # Part IV. The pages
1769+
1770+ Every page in the system, what it shows, what it reads, and what it writes. A page
1771+ not listed here does not exist, and the absence is usually a decision.
1772+
1773+ ## 24. Page by page
1774+
1775+ ### signup
1776+
1777+ **Shows.** Name field, public key field, one button, and a plain statement that
1778+ losing every key loses the account.
1779+
1780+ **Reads.** Nothing.
1781+
1782+ **Writes.** Account, first key.
1783+
1784+ **Why it looks like this.** There is no email field because there is no email. The
1785+ key field hints at `cat ~/.ssh/id_ed25519.pub` because that is where the value comes
1786+ from, and a user who learns that command has learned something durable.
1787+
1788+ ### sign in
1789+
1790+ **Shows.** Name field and a button that starts a challenge. Then the nonce, and
1791+ the one `ssh-keygen` line that signs it, and a box to paste the signature into.
1792+ Never a password field, because there is no password.
1793+
1794+ **The command shown is OpenSSH's, not a program of ours.** `br auth john` does the
1795+ same thing in one step and is offered second. Showing the CLI first would say that
1796+ signing in needs a program the user has not installed, which is false and is the
1797+ opposite of what rule 2 is for.
1798+
1799+ **Reads.** Account keys.
1800+
1801+ **Writes.** Challenge nonce, then a session.
1802+
1803+ ### keys and tokens
1804+
1805+ **Shows.** Every ssh key with fingerprint, label, and last-used time. Every
1806+ runner token with its labels, attached machine, and last-seen time. Every feed
1807+ token with the feed it opens. Revoke on each.
1808+
1809+ **Reads.** Server state items 1 and 3.
1810+
1811+ **Writes.** Key additions and revocations, token issue and revoke.
1812+
1813+ **The three kinds of credential, and they are not interchangeable.** An ssh key
1814+ signs you in and pushes. A runner token attaches one machine to one repository,
1815+ per chapter 15. A feed token reads one Atom feed and can do nothing else, per
1816+ chapter 19.5. The page names what each one can do, because a user about to paste
1817+ a token into a feed reader deserves to know it cannot write.
1818+
1819+ **Why it exists.** This is the only settings page in the entire product, because
1820+ keys and tokens are the only things that cannot live in a repository. Everything
1821+ a user might look for here that is repository-scoped is in `.barerepo/config`
1822+ instead, and the page should say so.
1823+
1824+ ### new repository
1825+
1826+ **Shows.** Name, description, default branch, visibility, create. Below the form,
1827+ the two commands that create a repository without using the form at all.
1828+
1829+ **Writes.** Bare repository, HEAD, hooks, ownership row.
1830+
1831+ **Why the shortcut is on this page.** A user who has found this form is about to
1832+ spend four steps on something one push would have done. Telling them here is the
1833+ only place the message reaches them at the moment it is useful. This is rule 2
1834+ applied to a page whose whole purpose the rule undermines, which is the correct
1835+ outcome rather than an awkward one.
1836+
1837+ **Note.** Default branch defaults to `master` and is a plain text field accepting
1838+ any branch name, not a toggle with a recommended option.
1839+
1840+ **Note.** Visibility is not stored at creation, because there is no tree to
1841+ store it in yet. Choosing public adds the line that sets it to the block on the
1842+ empty-repository page. See chapter 11.
1843+
1844+ ### empty repository
1845+
1846+ **Shows.** One block of shell commands to paste, and a second block for pointing an
1847+ existing repository here. If the creator chose public, the first block also
1848+ writes `.barerepo/config`, because that is the only place visibility can live.
1849+
1850+ **Why it exists.** This is a new user's first contact with the system after signup,
1851+ and it is the most important page in the product. It contains no
1852+ onboarding tour, no sample project, and no "learn git" link. It contains the
1853+ commands, in order, ready to paste.
1854+
1855+ ### repository log
1856+
1857+ **The landing page for every repository.** Not the file tree.
1858+
1859+ **Shows.** Commits newest first, each with message, author, time, and how many files
1860+ changed and by how much. **Every row is the same row.** The hash is the link to the
1861+ commit; nothing else on the row is a control. No diff is drawn here and none is
1862+ offered here. Merged proposals appear as entries.
1863+
1864+ **Why no diff on this page.** An earlier draft drew every diff already expanded,
1865+ which is what the mockup shows. Twenty open diffs on a real repository is a 219kb
1866+ page against a 30kb budget in chapter 25. Every way of paying that back left some
1867+ rows with a diff and some without, which is a list that changes shape as the reader
1868+ scrolls it. One shape for every row is worth more than a diff the reader did not
1869+ ask for.
1870+
1871+ **Reads.** `git log --numstat`.
1872+
1873+ **Why this is the landing page.** Nobody navigates code by clicking folders. People
1874+ arrive at a repository to find out what changed, or to find a specific symbol. The
1875+ log answers the first directly and the file jump answers the second in one
1876+ keystroke. GitHub's landing page answers neither and spends its space on a
1877+ directory listing and a rendered README.
1878+
1879+ ### file tree
1880+
1881+ **Shows.** Directories and files at a path, each with the last commit that touched
1882+ it and that commit's message.
1883+
1884+ **Reads.** `git ls-tree`, plus one `git log -1` per entry.
1885+
1886+ **Performance note.** The per-entry log is the expensive part and is the reason
1887+ this page is not the landing page. Cache by tree hash; the result is immutable for
1888+ a given tree.
1889+
1890+ ### file view
1891+
1892+ **Shows.** File contents with **blame in the left gutter on every line, always**,
1893+ alongside line numbers.
1894+
1895+ **Why not a separate blame page.** Blame is not a separate question. "What is this
1896+ line" and "why is this line" are asked at the same moment, and splitting them into
1897+ two pages doubles the work of the most common investigation a person performs in a
1898+ code viewer. It costs one `git blame` call, which caches by blob hash forever
1899+ because the answer cannot change.
1900+
1901+ ### commit
1902+
1903+ **Shows.** One commit, full message, metadata, complete diff, and whether it is
1904+ reachable from the default branch. If it closed a thread, that is stated.
1905+
1906+ ### compare
1907+
1908+ **Shows.** Any ref against any ref, both as free text fields. Combined stats,
1909+ per-file diffs, conflict status.
1910+
1911+ **Why free text.** Because `master...refs/proposals/47` is a legitimate thing to
1912+ type, and a dropdown of branches cannot express it. The proposal review path goes
1913+ through this page.
1914+
1915+ ### thread list
1916+
1917+ **Shows.** Issues and proposals in one list. Each row shows whether a ref is
1918+ attached, diff stats if so, build status if any, and reply count. Filters are open,
1919+ merged, closed, all.
1920+
1921+ **Why unified.** See chapter 13. The split is bookkeeping that serves the database
1922+ schema rather than the reader.
1923+
1924+ ### thread
1925+
1926+ **Shows.** Title, metadata, attached ref if any, comments in order, inline diff
1927+ excerpts for line-anchored comments, build results inline in the timeline, a reply
1928+ box, and the two commands to read the thread offline.
1929+
1930+ **Reads.** `refs/notes/threads/<n>`, the proposal ref, run notes.
1931+
1932+ **Writes.** A comment blob on note write.
1933+
1934+ **The important detail.** Build results appear as events in the comment timeline
1935+ rather than in a separate status panel, because a build result is a thing that
1936+ happened at a time, which is what a timeline is for.
1937+
1938+ ### new thread
1939+
1940+ **Shows.** Title, body, and an optional field for a ref.
1941+
1942+ **The mechanic.** A thread with no ref is an issue. Paste a ref and it is a
1943+ proposal. One form, one object, no type selector.
1944+
1945+ ### add a runner
1946+
1947+ **Shows.** Three commands, one per platform, all visible at once, each with the
1948+ token already inside it. Below them, three facts: the runner dials out, the token
1949+ is repository-scoped, the page updates when a runner attaches.
1950+
1951+ **Why this page matters disproportionately.** It is the clearest demonstration of
1952+ the entire thesis. GitHub's equivalent is four pages and a downloaded archive. If
1953+ this page ever grows a second step, something has gone wrong.
1954+
1955+ **This is the one place a program really is needed**, because a runner is an
1956+ agent that keeps polling, which a one-line shell command cannot be. Rule 2 still
1957+ applies: the page says what the program does, and says that the protocol it
1958+ speaks is the plain HTTP in chapter 15, so anyone who would rather write their
1959+ own has everything they need to. That is the difference between a required tool
1960+ and a hidden one.
1961+
1962+ ### runners
1963+
1964+ **Shows.** Attached machines, platform, labels, run count, status, last seen.
1965+ Offline machines can be forgotten.
1966+
1967+ ### runs
1968+
1969+ **Shows.** Runs newest first with commit, status, duration, ref, and which machine
1970+ ran it.
1971+
1972+ ### run detail
1973+
1974+ **Shows.** Status, exit code, what triggered it, and the complete log as plain
1975+ text.
1976+
1977+ **Why plain text.** Covered in chapter 16: a failing build is read by someone
1978+ hunting an error message, and collapsible steps defeat browser search.
1979+
1980+ ### inbox
1981+
1982+ **Shows.** Events newest first, one line each, with a rule marking the last visit.
1983+
1984+ **Reads.** The event log, filtered to repositories you own and threads you touched.
1985+
1986+ **Writes.** The `last_visited` timestamp.
1987+
1988+ **Note.** No unread counts, no badges, no mark-all-read. See chapter 19.4 for why.
1989+
1990+ ### releases
1991+
1992+ **Shows.** Tags with notes and attached files, newest first.
1993+
1994+ **Reads.** Tags, `refs/notes/releases`, the blob store.
1995+
1996+ **Note.** The page states that notes clone and attached files do not, per chapter
1997+ 22.3. A user should learn this here, not during a migration.
1998+
1999+ ### search
2000+
2001+ **Shows.** Code matches, threads, and repositories in one ranked list. Code matches
2002+ show the matching line with context.
2003+
2004+ **Reachable from every page** with `/`, because the alternative is navigating to a
2005+ search page, which is an interstitial.
2006+
2007+ ### profile
2008+
2009+ **Shows.** A user, their key count, and their repositories sorted by last push,
2010+ each with language, size, default branch, and open proposal count.
2011+
2012+ **Note.** The default branch is shown per repository, so a user with a mix of
2013+ `master` and `main` sees the mix.
2014+
2015+ ### repository config
2016+
2017+ **Shows.** `.barerepo/config` rendered as a file, with the commit that last changed
2018+ it, and history, blame, and raw links.
2019+
2020+ **There is no settings page.** Edit the file, commit, push, and it applies. This
2021+ page is a file view with a specific path, and the fact that it is barely a special
2022+ case is the point.
2023+
2024+ ### push rejected
2025+
2026+ **Shows.** Exactly which rule refused the push, the relevant line from
2027+ `.barerepo/config`, and the command that would have worked.
2028+
2029+ **How it is reached.** The hook prints a URL alongside the rejection message.
2030+ Without that line the page is unreachable, because a rejected push happens in a
2031+ terminal and no browser is involved.
2032+
2033+ **Why it exists at all.** The hook already printed the reason. The page exists
2034+ because terminals scroll, and because a rejected push is where a new contributor
2035+ decides whether to keep going.
2036+
2037+ ### 404
2038+
2039+ Says what does exist near the requested path.
2040+
2041+ ### Pages that do not exist
2042+
2043+ `explore`, `trending`, `stars`, `followers`, notification inbox, wiki, project
2044+ boards, insight graphs, marketplace, gists, organization management, onboarding
2045+ tour, in-browser editor, protected branch rule builder, merge queue, code owners.
2046+
2047+ The discovery pages are absent per rule 7: a new platform is empty for a long time,
2048+ and a page that displays emptiness to every visitor actively harms adoption. The
2049+ rest are absent per chapter 1: remove them and the five things a forge does still
2050+ work.
2051+
2052+ ---
2053+
2054+ # Part V. Operating it
2055+
2056+ ## 25. Performance
2057+
2058+ These are build-failing thresholds, asserted in the test suite. They are not
2059+ displayed to users. A product that reports its own speed in its interface is
2060+ advertising, and the number stops being checked the moment it becomes a slogan.
2061+
2062+ | View | Time | Payload |
2063+ |---|---|---|
2064+ | Any page with no diff | under 10ms | under 15kb |
2065+ | Log | under 20ms | under 30kb |
2066+ | File view with blame | under 20ms | under 40kb |
2067+ | Any page | under 2kb JS | |
2068+
2069+ The JavaScript budget is not zero. Two keyboard shortcuts need a listener: `/` for
2070+ search and `t` for the file jump. Everything else renders without script. Two
2071+ kilobytes is generous for that and leaves no room for a framework, which is the
2072+ point of stating a number rather than a slogan.
2073+
2074+ **What makes this achievable.** No client framework, no bundler, no hydration, no
2075+ round trip for data after the HTML. Server-rendered HTML from local git operations
2076+ is fast by default; the work is in not making it slow.
2077+
2078+ **What one process costs.** These numbers are only reachable if git operations
2079+ are cheap, and shelling out makes each one cost a process. Measured on an
2080+ ordinary laptop, `git rev-parse HEAD` on a small repository takes 8ms of which
2081+ almost all is spawn. A page that runs four git commands has spent 32ms before
2082+ it has rendered anything, and no amount of caching inside barerepo changes that.
2083+
2084+ So the budget is a budget on **git invocations** as much as on time. A page
2085+ gets one or two. Reaching that means `git cat-file --batch` for several objects
2086+ in one process, one `git log --patch` for the whole log page rather than one
2087+ per commit, and caching whatever is a function of an immutable object. Where
2088+ that is not enough, the answer is libgit2 in-process rather than a looser
2089+ number: docs/BUILD.md says to start by shelling out and to optimise when
2090+ profiling says so, and this is profiling saying so.
2091+
2092+ An earlier draft of this chapter gave the times without the process cost, which
2093+ made the budget read as achievable by writing careful Go. It is not: it is
2094+ achievable by not starting processes.
2095+
2096+ **What makes it hard.** Diff rendering is the one expensive operation and
2097+ the one that cannot be trivially optimized away. Cache rendered diffs keyed by the
2098+ pair of blob hashes. Blobs are immutable, so the cache never needs invalidation and
2099+ can be evicted purely by size.
2100+
2101+ Blame caches by blob hash on the same reasoning. The per-entry log on the file tree
2102+ caches by tree hash.
2103+
2104+ ## 26. Storage and garbage
2105+
2106+ Repository size grows in three places that other forges do not have.
2107+
2108+ **Proposal refs.** Anyone may create them, so they accumulate. Expire proposals
2109+ with no activity for `[proposals] expire_days`, default 180. Delete the ref, retain
2110+ the thread. The thread is small; the ref pins commits.
2111+
2112+ **Proposal revisions.** Each force-push retains the old tip under
2113+ `refs/revisions/<n>/<k>`. Keep the most recent five and the ones with anchored
2114+ comments. Delete the rest on the same expiry schedule.
2115+
2116+ **Note trees.** One blob per comment, plus tombstones, forever. This is small in
2117+ absolute terms and should not be pruned, because the durability of discussion is
2118+ the product.
2119+
2120+ Run `git gc` per repository on a schedule, not on push. Repack after bulk ref
2121+ deletion, or the pack files retain everything you just deleted.
2122+
2123+ ## 27. Abuse
2124+
2125+ Rule 5 is an open door and must be defended without closing it.
2126+
2127+ **Proposal spam.** Rate limit by account and by repository. Cap open proposals per
2128+ account per repository at a small number, ten is plenty. Reject pushes above a size
2129+ threshold from accounts with no accepted proposals.
2130+
2131+ **Account spam.** Signup requires a key and a signature over a nonce. Both are
2132+ cheap to produce, so neither slows a squatter down: `ssh-keygen` makes a fresh
2133+ key in milliseconds and signs with it just as fast. The signature is there to
2134+ stop somebody claiming a key that is not theirs, per chapter 10, and it is not
2135+ an anti-spam measure.
2136+
2137+ Rate limit signup by source address, `signup_per_hour_per_ip`. Accept that a
2138+ determined actor gets accounts and that the proposal caps are the actual
2139+ defense.
2140+
2141+ **Note spam.** Comments are cheap. Rate limit per account per thread.
2142+
2143+ **Resource abuse via runners.** Not a concern, because runners are the user's own
2144+ hardware. This is a quiet benefit of the design: the most expensive abuse vector on
2145+ every other forge does not exist here.
2146+
2147+ **Private key exposure.** Users will paste private keys into the public key field.
2148+ Detect the header and refuse with a clear message telling them which half to paste.
2149+
2150+ ## 28. Backup, restore, and leaving
2151+
2152+ **Backup** is the repository directory plus the small server database. The
2153+ repositories are self-describing; the database holds only the closed list in
2154+ chapter 10 and nothing else.
2155+
2156+ On SQLite the database is one file, so the backup is a file copy alongside the
2157+ repository directory. On PostgreSQL it is a `pg_dump`. Nothing else differs.
2158+
2159+ **Restore** is putting the directory back and reinstalling hooks.
2160+
2161+ **Leaving** is the part that matters, and the design should be tested against it
2162+ regularly. A user who clones with
2163+
2164+ ```
2165+ git clone --mirror https://barerepo.example/john/johnbot
2166+ ```
2167+
2168+ has the code, the full history, every proposal ref, every thread, every comment,
2169+ the configuration, and the build results. They can push that to any other host, or
2170+ serve it themselves with `git daemon`, and lose only the web viewer.
2171+
2172+ **Test this.** Periodically mirror a repository, delete the original, restore from
2173+ the mirror, and confirm nothing is missing. A claim about portability that is never
2174+ exercised is a claim that will turn out to be false at the worst moment.
2175+
2176+ ---
2177+
2178+ # Part VI. Decisions
2179+
2180+ ## 29. Roads not taken
2181+
2182+ **Forks and pull requests.** The familiar model. Rejected because forking is
2183+ repository duplication used as a permission workaround, and because it puts the
2184+ contributor's work in a place the maintainer must go and fetch from. Proposal refs
2185+ put the work in the target repository immediately, which is where everyone
2186+ interested in it already is.
2187+
2188+ **Gerrit as-is.** The ref mechanism is taken directly from Gerrit and credited in
2189+ chapter 9. The policies are refused: `Change-Id` trailers, one commit per change,
2190+ forced rebase, numeric scoring. Those policies exist because Google needed enforced
2191+ linear history and gated review at enormous scale. Most projects need neither, and
2192+ inheriting them is inheriting the complaints.
2193+
2194+ **Email patches.** `git send-email` and the sourcehut model. Elegant, zero
2195+ server state, and the correct answer for kernel-scale projects with strong mailing
2196+ list culture. Rejected because configuring SMTP is a harder first contact than
2197+ `git push`, and first contact is where contributors are won or lost.
2198+
2199+ **Federation.** ActivityPub between instances, as Forgejo is pursuing. Deferred
2200+ rather than refused. The technology is unsettled, the specification is moving, and
2201+ a small forge that does one thing well is more useful than a small forge that does
2202+ two things partially. Revisit when ForgeFed stabilizes.
2203+
2204+ **A merge button.** Requested constantly. Refused permanently. Adding it means the
2205+ server must understand merge strategies, conflict resolution, rebase semantics, and
2206+ partial failure, which is a large surface for a convenience that replaces two
2207+ commands. Rule 1 exists to make this a settled question rather than a recurring
2208+ argument.
2209+
2210+ **Hosted CI.** The largest operating cost of running a forge and the most common
2211+ paywall on using one. Removing it removes both, at the cost of requiring the user
2212+ to have a machine, which developers do.
2213+
2214+ **Storing discussion in a database.** Faster to query, easier to edit, trivially
2215+ consistent. Rejected because it is precisely the thing that makes leaving a forge
2216+ lossy, and not being lossy to leave is the whole argument.
2217+
2218+ ## 30. Non-goals
2219+
2220+ Not built, and not to be added without revisiting Part I:
2221+
2222+ explore, trending, and discovery surfaces; stars and social graph; wiki; project
2223+ boards; insight and contribution graphs; marketplace; gists; organization and team
2224+ management; onboarding tours; any outbound email at all; in-browser IDE; code
2225+ owners; required reviewers; protected branch rule builders; squash and rebase merge
2226+ buttons; draft proposals; issue templates; saved replies; fork networks and
2227+ "forked from" relationships; deploy keys as a separate concept from accounts.
2228+
2229+ Two entries moved off this list during writing. A notification inbox is now built,
2230+ because rejecting it left users with no way to learn that anything happened; the
2231+ version in chapter 19 has no read state and no email, which is what made it
2232+ acceptable. Copying a project is now supported, because refusing forks as a
2233+ contribution mechanism accidentally read as refusing the right to diverge.
2234+
2235+ Most exist to serve a social network or a compliance department. barerepo is neither.
2236+ Every one of them can be added later by someone who wants a different product; none
2237+ of them can be removed later from a product that shipped with them.
2238+
2239+ ---
2240+
2241+ # Part VII. Using barerepo
2242+
2243+ Part VII is written in Simplified Technical English. Each task shows the exact
2244+ commands. No task needs the web interface. No task needs `br`.
2245+
2246+ Parts I to VI are written in normal prose, because they contain argument and not
2247+ procedure.
2248+
2249+ ## 31. Keys and signing
2250+
2251+ Your key is your account. Read this chapter first.
2252+
2253+ ### 31.1 Make a key
2254+
2255+ ```
2256+ ssh-keygen -t ed25519 -C "laptop"
2257+ ```
2258+
2259+ Press enter to accept the default path. Type a passphrase. The command writes two
2260+ files:
2261+
2262+ ```
2263+ ~/.ssh/id_ed25519 the private half. Never send this to anyone.
2264+ ~/.ssh/id_ed25519.pub the public half. This is safe to publish.
2265+ ```
2266+
2267+ ### 31.2 Read your public key
2268+
2269+ ```
2270+ cat ~/.ssh/id_ed25519.pub
2271+ ```
2272+
2273+ The output is one line. It starts with `ssh-ed25519`. Copy the whole line.
2274+
2275+ ### 31.3 Sign a message with your key
2276+
2277+ barerepo uses this to authenticate you. This is stock OpenSSH 8.0 and later. You
2278+ do not need barerepo's CLI, or any other program, to sign in.
2279+
2280+ ```
2281+ printf '%s' '<nonce>' | ssh-keygen -Y sign -f ~/.ssh/id_ed25519 -n barerepo-auth -
2282+ ```
2283+
2284+ The final `-` means "read the message from standard input", and the signature
2285+ is written to standard output. Copy all of it, starting at
2286+ `-----BEGIN SSH SIGNATURE-----`.
2287+
2288+ One command, and nothing is left on disk. An earlier draft wrote the nonce to
2289+ `/tmp/nonce`, signed the file, and printed `/tmp/nonce.sig`: three commands, two
2290+ files left behind, and `echo -n` which is not portable between shells.
2291+
2292+ `-n barerepo-auth` sets the namespace. The namespace stops a signature from one
2293+ service from working on a different service. Always use `barerepo-auth`.
2294+
2295+ ### 31.4 How the server checks the signature
2296+
2297+ The server writes your public key to a file:
2298+
2299+ ```
2300+ john ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...
2301+ ```
2302+
2303+ The server then runs:
2304+
2305+ ```
2306+ ssh-keygen -Y verify -f allowed_signers -I john -n barerepo-auth -s /tmp/nonce.sig < /tmp/nonce
2307+ ```
2308+
2309+ Exit code 0 means the signature is correct. Any other exit code means it is wrong.
2310+
2311+ You can run the same command yourself. Nothing is hidden.
2312+
2313+ ### 31.5 Lose your key
2314+
2315+ There is no recovery. There is no email. There is no reset link.
2316+
2317+ Add a second key on the day you sign up. Keep it on a different machine.
2318+
2319+ ## 32. Accounts
2320+
2321+ ### 32.1 Sign up
2322+
2323+ 1. Open `/signup`.
2324+ 2. Type your name.
2325+ 3. Paste the output of `cat ~/.ssh/id_ed25519.pub`.
2326+ 4. Press create.
2327+
2328+ The account exists at once. Nothing is sent to you.
2329+
2330+ ### 32.2 Sign in
2331+
2332+ 1. Open `/signin`.
2333+ 2. Type your name.
2334+ 3. Press send challenge. The page shows a nonce and the command to sign it.
2335+ 4. Sign the nonce with OpenSSH. See 31.3.
2336+ 5. Paste the signature.
2337+
2338+ Step 4 is one line and needs nothing but the ssh you already have:
2339+
2340+ ```
2341+ printf '%s' '<nonce>' | ssh-keygen -Y sign -f ~/.ssh/id_ed25519 -n barerepo-auth -
2342+ ```
2343+
2344+ `br` does the same work without the browser. It asks for its own nonce, signs it
2345+ with the key in `~/.ssh`, and prints a link. Open the link and that browser is
2346+ signed in. The link works once and expires in ten minutes.
2347+
2348+ ```
2349+ br auth john
2350+ ```
2351+
2352+ Part VII means what it says: no task here needs the CLI. The sign-in page shows
2353+ the ssh command first for that reason, and mentions `br auth` second.
2354+
2355+ ### 32.3 Add a second key
2356+
2357+ 1. Open `/keys`.
2358+ 2. Press new key.
2359+ 3. Paste the public key from the other machine.
2360+
2361+ Do this immediately after signup.
2362+
2363+ ### 32.4 Remove a key
2364+
2365+ Open `/keys`. Press revoke beside the key.
2366+
2367+ You cannot remove your last key. The server refuses.
2368+
2369+ ### 32.5 See where your keys are used
2370+
2371+ `/keys` shows the last used time for each key. Check this if you think a key is
2372+ lost.
2373+
2374+ ## 33. Repositories
2375+
2376+ ### 33.1 Make a repository from the form
2377+
2378+ 1. Open `/new`.
2379+ 2. Type a name.
2380+ 3. Set the default branch. The default is `master`. Type any other name if you
2381+ want a different one.
2382+ 4. Set public or private.
2383+ 5. Press create.
2384+
2385+ ### 33.2 Make a repository by pushing
2386+
2387+ You do not have to use the form. Push to a name that does not exist:
2388+
2389+ ```
2390+ git init
2391+ git add -A
2392+ git commit -m "first"
2393+ git remote add origin git@barerepo.example:john/johnbot
2394+ git push -u origin master
2395+ ```
2396+
2397+ The server creates `john/johnbot` and accepts the push. The push output contains
2398+ the URL.
2399+
2400+ The default branch is the branch you pushed. The repository is private. Change
2401+ either one later in `.barerepo/config`.
2402+
2403+ You can only do this inside your own namespace.
2404+
2405+ Check the push output. A typo in the name creates a repository with that typo.
2406+ Delete it per 33.8 if that happens.
2407+
2408+ ### 33.3 Push new code to it
2409+
2410+ ```
2411+ git init
2412+ git remote add origin git@barerepo.example:john/johnbot
2413+ git add -A
2414+ git commit -m "first"
2415+ git push -u origin master
2416+ ```
2417+
2418+ ### 33.4 Push code that already exists
2419+
2420+ ```
2421+ git remote set-url origin git@barerepo.example:john/johnbot
2422+ git push --all
2423+ git push --tags
2424+ ```
2425+
2426+ ### 33.5 Clone
2427+
2428+ ```
2429+ git clone git@barerepo.example:john/johnbot
2430+ ```
2431+
2432+ Or over https:
2433+
2434+ ```
2435+ git clone https://barerepo.example/john/johnbot
2436+ ```
2437+
2438+ ### 33.6 Change the default branch
2439+
2440+ Edit `.barerepo/config`:
2441+
2442+ ```toml
2443+ [repo]
2444+ default_branch = "main"
2445+ ```
2446+
2447+ Commit and push. The server reads the file on push.
2448+
2449+ ### 33.7 Make a repository private
2450+
2451+ Edit `.barerepo/config`:
2452+
2453+ ```toml
2454+ [repo]
2455+ visibility = "private"
2456+ ```
2457+
2458+ Commit and push.
2459+
2460+ ### 33.8 Copy someone else's project
2461+
2462+ To take a project in your own direction:
2463+
2464+ ```
2465+ barerepo copy john/johnbot lisa/johnbot
2466+ ```
2467+
2468+ The server copies it. Nothing is downloaded to your machine.
2469+
2470+ You get all branches, all tags, all history and all threads. You do not get their
2471+ proposal refs.
2472+
2473+ There is no link back to the original. It is a copy, not a relationship.
2474+
2475+ To contribute to the original instead, push a proposal. See 36.1.
2476+
2477+ ### 33.9 Rename a repository
2478+
2479+ Open the config page. Press rename.
2480+
2481+ The old name redirects forever. Existing clones keep working.
2482+
2483+ The old name is never released to anyone else.
2484+
2485+ ### 33.10 Archive a repository
2486+
2487+ Edit `.barerepo/config`:
2488+
2489+ ```toml
2490+ [repo]
2491+ archived = true
2492+ ```
2493+
2494+ Commit and push. The repository becomes read-only. It stays visible and clonable.
2495+
2496+ Set it back to `false` to undo.
2497+
2498+ ### 33.11 Push a large file
2499+
2500+ The server rejects a single file over the limit. The default is 100 MB.
2501+
2502+ The message names the file. Remove it from your history and push again.
2503+
2504+ Put large files in object storage. Put a URL or a checksum in the repository.
2505+
2506+ LFS is off unless your administrator turned it on.
2507+
2508+ ### 33.12 Delete a repository
2509+
2510+ Open the repository config page. Press delete. Type the repository name to
2511+ confirm.
2512+
2513+ The repository disappears from the site at once. It stops serving, and the name
2514+ is held rather than freed.
2515+
2516+ The data is kept for 30 days and then erased. Inside that window an
2517+ administrator can put it back. After it, nothing can.
2518+
2519+ Mirror it first if you want your own copy. See 40.1. Do not treat the 30 days as
2520+ a backup; it is a window to notice a mistake, not a place to keep anything.
2521+
2522+ ## 34. Reading code
2523+
2524+ ### 34.1 See what changed
2525+
2526+ Open the repository. The log is the first page. Each commit shows what it touched
2527+ and how much it changed.
2528+
2529+ Press the hash to read the diff.
2530+
2531+ ### 34.2 Find a file
2532+
2533+ Press `t` on any repository page. Type part of the file name.
2534+
2535+ ### 34.3 See who wrote a line
2536+
2537+ Open the file. The blame is in the left column. There is no separate blame page.
2538+
2539+ ### 34.4 See one commit
2540+
2541+ Press the commit hash in the log.
2542+
2543+ ### 34.5 Compare two refs
2544+
2545+ Open `/john/johnbot/compare`. Type any ref in each field. Examples:
2546+
2547+ ```
2548+ master...refs/proposals/47
2549+ v1.0...v1.1
2550+ master...8b1d44
2551+ ```
2552+
2553+ ### 34.6 Search
2554+
2555+ Press `/` on any page. Type your search. The results contain code, threads and
2556+ repositories in one list.
2557+
2558+ ## 35. Threads
2559+
2560+ A thread is an issue. A thread with a ref attached is a proposal. Both use the
2561+ same pages.
2562+
2563+ ### 35.1 Open a thread
2564+
2565+ 1. Open `/john/johnbot/threads`.
2566+ 2. Press new thread.
2567+ 3. Type a title and a body.
2568+ 4. Leave the ref field empty for an issue.
2569+ 5. Press open.
2570+
2571+ ### 35.2 Reply
2572+
2573+ Type in the reply box at the end of the thread. Press reply.
2574+
2575+ ### 35.3 Comment on one line
2576+
2577+ Open the proposal diff. Press the line number. Type your comment.
2578+
2579+ ### 35.4 Read threads offline
2580+
2581+ Fetch the notes:
2582+
2583+ ```
2584+ git fetch origin "refs/notes/*:refs/notes/*"
2585+ ```
2586+
2587+ Read one thread:
2588+
2589+ ```
2590+ git log --show-notes=threads/47
2591+ ```
2592+
2593+ Read all notes on a commit:
2594+
2595+ ```
2596+ git notes --ref=threads/47 show <sha>
2597+ ```
2598+
2599+ This works with no network. This works if barerepo stops.
2600+
2601+ ### 35.5 Reply offline
2602+
2603+ Add a note and push it:
2604+
2605+ ```
2606+ git notes --ref=threads/47 append -m "I see the same problem."
2607+ git push origin refs/notes/threads/47
2608+ ```
2609+
2610+ If the push fails, someone else replied first. Take what the server holds, write
2611+ your reply after it, and push again:
2612+
2613+ ```
2614+ git fetch origin refs/notes/threads/47
2615+ git update-ref refs/notes/threads/47 FETCH_HEAD
2616+ git notes --ref=threads/47 append -m "I see the same problem."
2617+ git push origin refs/notes/threads/47
2618+ ```
2619+
2620+ **Not `git notes merge -s union`.** It is the obvious answer and the wrong one. A
2621+ union merge joins the two notes with no `--` between them, so the last comment on
2622+ your side is welded onto the first comment on theirs, and the server refuses the
2623+ push for dropping a reply that is no longer a whole record. Resetting to the
2624+ server's copy and appending again cannot do that. `br reply` does exactly this,
2625+ and parks anything your copy held that the server has not seen under
2626+ `refs/notes/before-reply/threads/47` rather than resetting it away.
2627+
2628+ ### 35.6 Close a thread
2629+
2630+ The author and the repository owner can close a thread. Press close. The same
2631+ control opens it again, and says so.
2632+
2633+ A merged proposal is not closed by a person, so neither of them is offered the
2634+ control on one. See 36.6.
2635+
2636+ A proposal closes by itself when it is merged. See 36.6.
2637+
2638+ ## 36. Proposals
2639+
2640+ You do not need permission. You do not fork.
2641+
2642+ ### 36.1 Propose a change
2643+
2644+ ```
2645+ git clone https://barerepo.example/john/johnbot
2646+ cd johnbot
2647+ git checkout -b my-fix
2648+ git commit -am "fix panic on empty config"
2649+ git push origin HEAD:refs/proposals/new
2650+ ```
2651+
2652+ The server prints the URL of your proposal. The push output contains the number.
2653+
2654+ ### 36.2 Understand `refs/proposals/new`
2655+
2656+ `new` is a magic name. The server never makes a ref with that name. The server
2657+ allocates the next number and uses that name instead.
2658+
2659+ ### 36.3 Change your proposal
2660+
2661+ Commit more work. Push again to the same number:
2662+
2663+ ```
2664+ git push -f origin HEAD:refs/proposals/47
2665+ ```
2666+
2667+ `-f` is needed if you changed your history. The server keeps your old version.
2668+
2669+ You can push to a proposal if you started it. The repository owner can also push
2670+ to it.
2671+
2672+ ### 36.4 Find your proposal number
2673+
2674+ Read the push output. Or list the refs:
2675+
2676+ ```
2677+ git ls-remote origin "refs/proposals/*"
2678+ ```
2679+
2680+ ### 36.5 Review a proposal
2681+
2682+ Fetch it to a local branch:
2683+
2684+ ```
2685+ git fetch origin refs/proposals/47:prop-47
2686+ git log master..prop-47
2687+ git diff master...prop-47
2688+ ```
2689+
2690+ Build it, run it, read it. It is a normal branch on your machine.
2691+
2692+ ### 36.6 Merge a proposal
2693+
2694+ There is no merge button. Merge on your machine:
2695+
2696+ ```
2697+ git checkout master
2698+ git merge prop-47
2699+ git push
2700+ ```
2701+
2702+ The server sees that the proposal is now part of `master`. The server closes the
2703+ thread. You do nothing else.
2704+
2705+ ### 36.7 Merge without a merge commit
2706+
2707+ Use any method you want. The server only checks reachability.
2708+
2709+ ```
2710+ git merge --squash prop-47 && git commit
2711+ ```
2712+
2713+ A squash changes the hashes. The server will not detect the merge. Close the
2714+ thread by hand in this case.
2715+
2716+ ### 36.8 Reject a proposal
2717+
2718+ Open the thread. Press close. Say why in a reply.
2719+
2720+ The ref stays. The author keeps their work.
2721+
2722+ ### 36.9 Your push was rejected
2723+
2724+ Read the message in your terminal. It says which rule stopped you, and prints a
2725+ URL if you want the same explanation in a browser.
2726+
2727+ The most common cause is a push to `master` without access. The answer is:
2728+
2729+ ```
2730+ git push origin HEAD:refs/proposals/new
2731+ ```
2732+
2733+ ## 37. Configuration and access
2734+
2735+ There is no settings page. Settings are a file.
2736+
2737+ ### 37.1 Edit the config
2738+
2739+ ```
2740+ git pull
2741+ $EDITOR .barerepo/config
2742+ git commit -am "allow lisa to push"
2743+ git push
2744+ ```
2745+
2746+ The change applies on push.
2747+
2748+ ### 37.2 Add a collaborator
2749+
2750+ ```toml
2751+ [access]
2752+ push = ["john", "lisa"]
2753+ ```
2754+
2755+ The owner is always allowed. You do not list the owner.
2756+
2757+ ### 37.3 Remove a collaborator
2758+
2759+ Delete the name. Commit and push.
2760+
2761+ ### 37.4 Require builds to pass
2762+
2763+ ```toml
2764+ [proposals]
2765+ require_runs = ["build", "test"]
2766+ ```
2767+
2768+ ### 37.5 Restrict who can propose
2769+
2770+ ```toml
2771+ [proposals]
2772+ accept_from = "authenticated"
2773+ ```
2774+
2775+ Values are `anyone`, `authenticated` and `push`.
2776+
2777+ ### 37.6 Allow force-push on a branch
2778+
2779+ Force-push is allowed on every branch except the default branch.
2780+
2781+ To allow it on the default branch:
2782+
2783+ ```toml
2784+ [access]
2785+ allow_force_push = ["master"]
2786+ ```
2787+
2788+ Think first. A force-push to the default branch destroys other people's work.
2789+
2790+ ### 37.7 See who changed a setting
2791+
2792+ ```
2793+ git log -p .barerepo/config
2794+ ```
2795+
2796+ Every change has an author, a date and a diff. A settings page cannot do this.
2797+
2798+ ### 37.8 Undo a setting change
2799+
2800+ ```
2801+ git revert <sha>
2802+ git push
2803+ ```
2804+
2805+ ### 37.9 You broke the config file
2806+
2807+ The server keeps the last good version. The server prints a warning on push.
2808+
2809+ The owner can always push. Fix the file and push again.
2810+
2811+ ## 38. Runners and builds
2812+
2813+ Builds run on your machines. barerepo does not run them.
2814+
2815+ ### 38.1 Attach a machine
2816+
2817+ 1. Open `/john/johnbot/runners`.
2818+ 2. Press add a runner.
2819+ 3. Copy the line for your operating system.
2820+ 4. Paste it on the machine.
2821+
2822+ Linux and macOS:
2823+
2824+ ```
2825+ curl -sL barerepo.sh | sh -s rt_live_7Kq2mXe
2826+ ```
2827+
2828+ Windows:
2829+
2830+ ```
2831+ irm barerepo.sh/ps | iex; barerepo-runner rt_live_7Kq2mXe
2832+ ```
2833+
2834+ The token is in the line. There is no second step.
2835+
2836+ ### 38.2 Set what a machine can build
2837+
2838+ ```
2839+ barerepo-runner rt_live_7Kq2mXe --labels build,test
2840+ ```
2841+
2842+ ### 38.3 Turn on builds
2843+
2844+ ```toml
2845+ [build]
2846+ command = "make ci"
2847+ image = "golang:1.26"
2848+ ```
2849+
2850+ Leave `image` empty to build on the host.
2851+
2852+ ### 38.4 Run a build
2853+
2854+ Push. Every push runs the build command.
2855+
2856+ ### 38.5 Read a failed build
2857+
2858+ Open the run. The log is on the page. Use `ctrl-F` to find the error.
2859+
2860+ A build that says more than the page can carry shows its **last** 12kb, because that
2861+ is where a failure is, and links the whole log as plain text. Chapter 25 budgets the
2862+ page and a long build must not be the thing that breaks it.
2863+
2864+ ### 38.6 Run the build yourself
2865+
2866+ ```
2867+ git checkout <sha>
2868+ make ci
2869+ ```
2870+
2871+ The runner does the same thing. Nothing else happens.
2872+
2873+ ### 38.7 Remove a machine
2874+
2875+ Open `/john/johnbot/runners`. Press forget.
2876+
2877+ ### 38.8 Revoke a token
2878+
2879+ Open `/keys`. Press revoke beside the token. The machine stops at its next poll.
2880+
2881+ ### 38.9 A runner needs no open port
2882+
2883+ The runner calls the server. The server never calls the runner. A laptop behind a
2884+ firewall works.
2885+
2886+ ## 39. Following what happens
2887+
2888+ barerepo sends no email. You read a feed instead.
2889+
2890+ ### 39.1 See what happened
2891+
2892+ Open `/inbox`. Events are newest first.
2893+
2894+ A line marks where you were when you last visited.
2895+
2896+ ### 39.2 What appears there
2897+
2898+ - Anything in a repository you own.
2899+ - Anything in a thread you opened or replied to.
2900+ - Anything on a proposal you opened.
2901+
2902+ There is no watch button. Reply to a thread and you will hear about it.
2903+
2904+ ### 39.3 Use a feed reader
2905+
2906+ Open `/keys`. Press new feed token. Copy the URL:
2907+
2908+ ```
2909+ https://barerepo.example/inbox.atom?token=ft_live_9Xk2m
2910+ ```
2911+
2912+ Add that URL to any feed reader.
2913+
2914+ The token is read-only. Revoke it on `/keys`.
2915+
2916+ ### 39.4 Follow one repository
2917+
2918+ Public repositories need no token:
2919+
2920+ ```
2921+ https://barerepo.example/john/johnbot.atom
2922+ https://barerepo.example/john/johnbot/threads.atom
2923+ ```
2924+
2925+ ### 39.5 Get email anyway
2926+
2927+ barerepo will not send it. Point a feed-to-email service at your Atom URL.
2928+
2929+ ## 40. Leaving
2930+
2931+ Test this before you need it.
2932+
2933+ ### 40.1 Take everything
2934+
2935+ ```
2936+ git clone --mirror https://barerepo.example/john/johnbot
2937+ ```
2938+
2939+ This copies the code, all history, every branch, every tag, every proposal ref,
2940+ every thread, every comment, the config file and every build result.
2941+
2942+ ### 40.2 Check what you took
2943+
2944+ ```
2945+ cd johnbot.git
2946+ git for-each-ref
2947+ ```
2948+
2949+ You will see `refs/heads/*`, `refs/proposals/*` and `refs/notes/*`.
2950+
2951+ ### 40.3 Move to another host
2952+
2953+ ```
2954+ git remote set-url origin git@otherhost:john/johnbot
2955+ git push --mirror
2956+ ```
2957+
2958+ ### 40.4 Serve it yourself
2959+
2960+ ```
2961+ git daemon --base-path=/srv/git --export-all
2962+ ```
2963+
2964+ ### 40.5 What you lose
2965+
2966+ You lose the web pages. You lose search. You lose build dispatch.
2967+
2968+ You lose no data.
2969+
2970+
2971+ ---
2972+
2973+ # Part VIII. Building and running it
2974+
2975+ Part VIII is written in Simplified Technical English, because it contains
2976+ procedure. It covers the things you must know to deploy barerepo and to know that
2977+ your implementation is correct.
2978+
2979+ ## 41. Installing barerepo
2980+
2981+ ### 41.1 Disk layout
2982+
2983+ ```
2984+ /usr/local/bin/barerepo the server.
2985+ /usr/local/bin/barerepo-runner the build agent. the server hands this out.
2986+ /etc/barerepo/barerepo.toml server config. not repository config.
2987+ /var/lib/barerepo/repos/ bare repositories, as <user>/<repo>.git
2988+ /var/lib/barerepo/forge.db the server-owned items, chapter 10. sqlite by
2989+ default. postgres instead, see 41.7.1.
2990+ /var/lib/barerepo/artifacts/ release artifacts. item 5. not in git.
2991+ /var/lib/barerepo/cache/ rendered diffs, blame, search index.
2992+ /var/lib/barerepo/.ssh/ the git user's authorized_keys file.
2993+ ```
2994+
2995+ The cache directory can be deleted at any time. The server rebuilds it.
2996+
2997+ ### 41.2 The git user
2998+
2999+ Create one system user. All repositories belong to it.
3000+
3001+ ```
3002+ useradd -m -d /var/lib/barerepo -s /bin/bash git
3003+ ```
3004+
3005+ Every ssh push arrives as this user. The account name comes from the key, not
3006+ from the unix user.
3007+
3008+ ### 41.3 SSH access
3009+
3010+ barerepo does not run its own ssh daemon in the simple setup. It uses OpenSSH and an
3011+ `authorized_keys` file.
3012+
3013+ For each stored public key, write one line:
3014+
3015+ ```
3016+ command="/usr/local/bin/barerepo ssh --account john",no-port-forwarding,no-x11-forwarding,no-agent-forwarding,no-pty ssh-ed25519 AAAAC3Nza...
3017+ ```
3018+
3019+ The `command=` prefix forces every connection into `barerepo ssh`. The user cannot
3020+ get a shell. The `--account` flag tells barerepo who is connecting.
3021+
3022+ Rewrite the file whenever a key is added or revoked. Write to a temporary file and
3023+ rename, so a partial write never locks everyone out.
3024+
3025+ `barerepo ssh` reads `SSH_ORIGINAL_COMMAND`, which contains `git-upload-pack
3026+ 'john/johnbot.git'` or `git-receive-pack '...'`. Parse it, check access, then
3027+ execute the real git binary.
3028+
3029+ **Reject anything else.** `SSH_ORIGINAL_COMMAND` is attacker-controlled. Allow
3030+ exactly `git-upload-pack`, `git-receive-pack` and `git-upload-archive`. Reject all
3031+ other input. Do not pass the string to a shell.
3032+
3033+ ### 41.4 HTTPS access
3034+
3035+ Put a reverse proxy in front. Terminate TLS there.
3036+
3037+ ```
3038+ proxy_pass http://127.0.0.1:3000;
3039+ proxy_set_header X-Real-IP $remote_addr;
3040+ ```
3041+
3042+ **Do not turn on header-based authentication.** Gitea shipped a critical
3043+ vulnerability in 2026 where a reverse-proxy auth header let anyone become admin by
3044+ sending one header. If barerepo ever adds such a feature, it must be off by default
3045+ and must require an explicit trusted-proxy address, never a wildcard.
3046+
3047+ Git over https needs two routes. See appendix C. Authenticate with a token in the
3048+ HTTP basic password field. The username is ignored.
3049+
3050+ **Ask for the credential on `git-receive-pack` before answering, even when the
3051+ repository is public.** A git client sends no credential until it is challenged.
3052+ If the ref advertisement for a push succeeds anonymously, the client never sends
3053+ a token, and the push that follows is refused for the wrong reason: the user is
3054+ told they lack access when what actually happened is that they were never asked
3055+ who they were. Return 401 on both halves of a push when there is no credential.
3056+
3057+ Reads are the opposite: answer a public repository anonymously and never
3058+ challenge, because a clone that demands a token from a stranger is a clone that
3059+ does not happen.
3060+
3061+ ### 41.5 Hooks
3062+
3063+ Install three hook files in every repository at creation:
3064+
3065+ ```
3066+ <repo>.git/hooks/pre-receive
3067+ <repo>.git/hooks/post-receive
3068+ <repo>.git/hooks/update (not used, remove it)
3069+ ```
3070+
3071+ Each hook is a two-line shell script that calls the barerepo binary:
3072+
3073+ ```
3074+ #!/bin/sh
3075+ exec /usr/local/bin/barerepo hook pre-receive
3076+ ```
3077+
3078+ Keep the logic in the binary, not in the hook file. Then an upgrade of barerepo
3079+ upgrades every repository at once, and you never have to rewrite hook files
3080+ across thousands of directories.
3081+
3082+ Add a `barerepo doctor` command that reinstalls hooks everywhere. You
3083+ will need it after a restore.
3084+
3085+ ### 41.6 First run
3086+
3087+ ```
3088+ barerepo init
3089+ ```
3090+
3091+ This creates the database and the directory tree. It creates no accounts.
3092+
3093+ Create the first account from the command line, because the web signup may be
3094+ closed:
3095+
3096+ ```
3097+ barerepo account create john --key "$(cat john.pub)" --admin
3098+ ```
3099+
3100+ An admin can delete any repository and any account. Nothing else. There is no
3101+ admin dashboard, because there is almost nothing to administer.
3102+
3103+ ### 41.7 Server config
3104+
3105+ `/etc/barerepo/barerepo.toml` is not repository config. It holds only deployment
3106+ facts.
3107+
3108+ ```toml
3109+ [server]
3110+ listen = "127.0.0.1:3000"
3111+ external_url = "https://barerepo.example"
3112+ raw_url = "" # a separate host. see chapter 42.3
3113+ ssh_host = "barerepo.example"
3114+ ssh_port = 22
3115+
3116+ [database]
3117+ url = "sqlite:///var/lib/barerepo/forge.db"
3118+
3119+ [paths]
3120+ repos = "/var/lib/barerepo/repos"
3121+ cache = "/var/lib/barerepo/cache"
3122+ artifacts = "/var/lib/barerepo/artifacts"
3123+
3124+ [limits]
3125+ max_blob_mb = 100
3126+ max_push_mb = 2048
3127+ max_open_proposals = 10
3128+ signup_per_hour_per_ip = 5
3129+ artifact_retain_days = 90
3130+
3131+ [behavior]
3132+ allow_push_to_create = true
3133+ allow_lfs = false
3134+ ```
3135+
3136+ **Two sections, one rule each.** `[limits]` holds every number that bounds
3137+ something. `[behavior]` holds every switch that turns something on or off. A
3138+ setting that is a number goes in the first and a setting that is a boolean goes
3139+ in the second, so nobody has to remember which section a key lives in.
3140+
3141+ `allow_push_to_create` lets a user create a repository by pushing to a name that
3142+ does not exist. See chapter 11. Turn it off on a locked-down instance.
3143+
3144+ `max_push_mb` bounds a whole push and `max_blob_mb` bounds one file inside it.
3145+ The default whole-push limit is loose, because the push most likely
3146+ to hit it is somebody's first import of an existing repository, and rejecting
3147+ that is the worst possible first contact. The per-file limit is the one that
3148+ does the real work; see chapter 20.2.
3149+
3150+ ### 41.7.1 The database
3151+
3152+ ```toml
3153+ [database]
3154+ url = "sqlite:///var/lib/barerepo/forge.db"
3155+ ```
3156+
3157+ barerepo runs on **SQLite or PostgreSQL**. The scheme in the URL picks one.
3158+
3159+ ```
3160+ sqlite:///var/lib/barerepo/forge.db the default. one file, no service.
3161+ postgres://barerepo@localhost/barerepo a server you already run.
3162+ ```
3163+
3164+ There is nothing else to set. Migrations run on first start against either one,
3165+ and no feature exists on one and not the other.
3166+
3167+ **Use SQLite unless you have a reason not to.** The server-owned items in
3168+ chapter 10 are small, they are read far more than they are written, and every
3169+ write is serialised by one process. SQLite in WAL mode is the correct tool for
3170+ that shape, it needs no service to run, no user to create, and no password to
3171+ store, and it makes the backup in chapter 28 a file copy.
3172+
3173+ **Use PostgreSQL if** you already run one and want one backup story, or you want
3174+ the database on different hardware from the repositories, or your host's disk
3175+ makes SQLite's locking unreliable, which network filesystems do.
3176+
3177+ Postgres does not make barerepo faster. The hot path is git, not the database.
3178+
3179+ **The test suite runs on SQLite.** It needs no service, so every test gets a
3180+ fresh empty database and the suite stays fast enough to run on every commit. The
3181+ Postgres schema is held to the SQLite one by a test that compares the two
3182+ definitions column by column, which needs no server either. Run the whole suite
3183+ against a real Postgres before a release; do not make every commit wait for it.
3184+
3185+ The URL contains a password when Postgres wants one, so `/etc/barerepo/barerepo.toml`
3186+ is `0640` and owned by the git user. This file is deployment configuration and
3187+ never enters a repository; see chapter 14 for the file that does.
3188+
3189+ ### 41.8 Upgrading
3190+
3191+ Database migrations run on first start and cannot be reversed.
3192+
3193+ 1. Stop the service. It runs `barerepo serve`; see appendix E.
3194+ 2. Back up the database and the repository directory.
3195+ 3. Replace the binary.
3196+ 4. Start the service.
3197+ 5. Read the logs through the migration.
3198+
3199+ Do not automate this without backups in the same script. An unattended migration
3200+ with no backup is how you lose everything.
3201+
3202+ ## 42. Rendering untrusted content
3203+
3204+ Everything in this chapter is a security requirement. None of it is optional.
3205+
3206+ ### 42.1 Markdown
3207+
3208+ Thread bodies and comments are markdown written by anyone with an account. Rule 5
3209+ means that is anyone at all.
3210+
3211+ Render markdown to HTML, then **sanitize the HTML with an allowlist**. Never use a
3212+ blocklist. Never trust the markdown renderer to be safe.
3213+
3214+ Allow these elements only:
3215+
3216+ ```
3217+ p br strong em del code pre blockquote
3218+ h1 h2 h3 h4 h5 h6
3219+ ul ol li
3220+ a img
3221+ table thead tbody tr th td
3222+ hr
3223+ ```
3224+
3225+ Allow these attributes only:
3226+
3227+ ```
3228+ a: href title
3229+ img: src alt title
3230+ code: class (language-* only, for highlighting)
3231+ ```
3232+
3233+ Strip every other element and attribute. Strip all `on*` handlers. Strip `style`.
3234+ Strip `<script>`, `<iframe>`, `<object>`, `<embed>`, `<form>`, `<svg>`.
3235+
3236+ ### 42.2 Link and image schemes
3237+
3238+ Allow `http`, `https` and `mailto` only.
3239+
3240+ Reject `javascript:`, `data:`, `vbscript:` and `file:`. Reject them after
3241+ decoding, because `java&#115;cript:` is the same string.
3242+
3243+ Add `rel="nofollow noopener noreferrer"` to every external link.
3244+
3245+ Proxy remote images through the server or block them. A remote image in a comment
3246+ leaks the reader's IP address to whoever posted it. Blocking is simpler and
3247+ honest; say so in the UI.
3248+
3249+ ### 42.3 File content
3250+
3251+ A repository can contain a file named `evil.html` containing a script. If you
3252+ serve raw file content from your own domain, you have given an attacker your
3253+ cookies.
3254+
3255+ Serve raw content from a **separate domain**, not a subdomain that shares cookies.
3256+ Send these headers on every raw response:
3257+
3258+ ```
3259+ Content-Type: text/plain; charset=utf-8
3260+ Content-Disposition: attachment
3261+ X-Content-Type-Options: nosniff
3262+ Content-Security-Policy: default-src 'none'; sandbox
3263+ ```
3264+
3265+ Never send a `Content-Type` derived from the file extension.
3266+
3267+ **The route.**
3268+
3269+ ```
3270+ GET /<user>/<repo>/raw/<ref>/<path>
3271+ ```
3272+
3273+ **The separate host is a server setting**, `[server] raw_url` in chapter 41.7,
3274+ because a repository must never be able to choose where its own content is
3275+ served from.
3276+
3277+ ```toml
3278+ [server]
3279+ raw_url = "https://raw.barerepo.example"
3280+ ```
3281+
3282+ When `raw_url` is set, the main host answers this route with a 302 to the same
3283+ path on the raw host, and the raw host serves the bytes with the four headers
3284+ above.
3285+
3286+ **When `raw_url` is empty**, barerepo serves raw content from the main host with
3287+ those same four headers, and the raw handler reads no session cookie at all. It
3288+ therefore answers for public repositories only, and returns 404 for a private
3289+ one whether or not the reader could see it in the web interface.
3290+
3291+ This fallback exists because most people install barerepo on one hostname, and a
3292+ `raw` link that only works for operators who own a second domain is a link most
3293+ users would never get. The headers are what actually stop the file from running:
3294+ `attachment` means the browser downloads it instead of rendering it, and the
3295+ sandbox policy means nothing runs even if it does render. The second host is
3296+ defence in depth, not the whole defence.
3297+
3298+ Say which mode is active on the repository config page, so a user who cannot
3299+ fetch a private file raw learns why on the page rather than from a 404.
3300+
3301+ ### 42.4 File rendering in the file view
3302+
3303+ Escape every byte of file content before it reaches HTML. This includes the blame
3304+ column, which contains commit messages, which are also untrusted.
3305+
3306+ Detect binary files by looking for a null byte in the first 8000 bytes. Do not
3307+ render binary content. Show the size and offer download.
3308+
3309+ Cap rendered file size. Files above 1 MB show a notice and a download link.
3310+
3311+ ### 42.5 Names and paths
3312+
3313+ Validate account names against `^[a-z0-9][a-z0-9-]{0,38}$`. Reserve every name
3314+ that a top-level route already uses:
3315+
3316+ ```
3317+ new signup signin signout auth keys
3318+ gpgkeys tokens search inbox runner raw
3319+ static api admin about
3320+ ```
3321+
3322+ The first twelve are routes in appendix C today. The last four are held back
3323+ because they are the names a future route would want, and freeing a name later
3324+ is easy while taking one back is not.
3325+
3326+ **Repository names are not reserved.** The list guards the top level, where an
3327+ account named `runner` would shadow `/runner/attach`. A repository is the second
3328+ path segment, so `john/runner` shadows nothing and is somebody's project. Applying
3329+ the list to both refuses names for no reason, and the pattern above is what keeps
3330+ a path safe: it permits no dot and no slash.
3331+
3332+ Derive this list from the route table in code rather than copying it. A route
3333+ added without a matching reservation is a route an account can shadow, and that
3334+ is a bug the test suite should catch rather than a list a person must remember.
3335+
3336+ Validate repository names the same way.
3337+
3338+ Never build a filesystem path by joining user input. Resolve the path and confirm
3339+ it is inside the repository root. `../` is the oldest attack there is.
3340+
3341+ ### 42.6 Ref names
3342+
3343+ Ref names come from a push, and a push is untrusted.
3344+
3345+ Validate against `git check-ref-format`. Reject names containing `..`, a leading
3346+ `-`, control characters, or a leading dot in any component.
3347+
3348+ Never pass a ref name to a shell. Use argument arrays. A ref named `--upload-pack=evil`
3349+ is an argument injection if you build a command string.
3350+
3351+ ### 42.7 The content security policy
3352+
3353+ Because rule 4 forbids JavaScript that a page depends on, the policy can be
3354+ strict:
3355+
3356+ ```
3357+ Content-Security-Policy: default-src 'none'; img-src 'self'; style-src 'self'; script-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'
3358+ ```
3359+
3360+ `script-src 'self'` is there for the two keyboard shortcuts chapter 25 budgets
3361+ 2kb for. Without it `default-src 'none'` blocks them, and the budget describes
3362+ something that cannot run. An earlier draft omitted it and said that any feature
3363+ needing `script-src` is wrong, which contradicted chapter 25 outright.
3364+
3365+ What the policy still refuses is everything that made the rule worth having:
3366+ no inline script, no `unsafe-eval`, and no script from anywhere but this server.
3367+ A framework cannot get in through 2kb served from `/static`. If a feature needs
3368+ more than that, chapter 25's budget is the thing that catches it, and the test
3369+ suite asserts the budget.
3370+
3371+ ## 43. Anchoring comments to code
3372+
3373+ A comment on `config.go:43` is useful. The file will change. This chapter says
3374+ what happens then.
3375+
3376+ ### 43.1 What is stored
3377+
3378+ The anchor header stores four things, not one:
3379+
3380+ ```
3381+ anchor: config.go:43
3382+ blob: 7c1e08a... the blob hash of the file at comment time
3383+ revision: 2 which proposal revision was displayed
3384+ side: new old | new, which side of the diff
3385+ ```
3386+
3387+ The blob hash is the important field. A blob is immutable. The exact content the
3388+ commenter was looking at can always be recovered.
3389+
3390+ ### 43.2 Displaying a comment on the revision it was made on
3391+
3392+ Exact. The blob hash matches. Show the comment at line 43.
3393+
3394+ ### 43.3 Displaying a comment on a later revision
3395+
3396+ Compute a diff from the stored blob to the current blob. Map line 43 forward
3397+ through that diff.
3398+
3399+ Three outcomes:
3400+
3401+ - **The line is unchanged.** Show the comment at its new line number.
3402+ - **The line moved.** Show the comment at the moved position. Mark it "moved".
3403+ - **The line was deleted or heavily rewritten.** The comment is **outdated**.
3404+
3405+ ### 43.4 Outdated comments
3406+
3407+ Never delete an outdated comment. Never hide it silently.
3408+
3409+ Show it in the thread timeline in order, with the original code excerpt from the
3410+ stored blob, and the label "outdated". The reader can see what was said and what
3411+ it referred to.
3412+
3413+ This is why the blob hash is stored. Without it, an outdated comment is a
3414+ reference to content nobody can retrieve.
3415+
3416+ ### 43.5 Why not rely on retained revisions alone
3417+
3418+ Chapter 12 retains old proposal tips under `refs/revisions/<n>/<k>`. That keeps
3419+ the commits reachable, which keeps the blobs reachable.
3420+
3421+ The retained revision keeps the object alive. The stored blob hash finds the right
3422+ object. You need both. Revisions expire per chapter 26; the anchor must degrade
3423+ gracefully to "outdated, original content unavailable" when they do.
3424+
3425+ ## 44. Transferring, renaming and deleting
3426+
3427+ Chapter 11 states that ownership comes from the namespace and cannot be changed
3428+ from inside the repository. Transfer is therefore a server operation, not a git
3429+ operation.
3430+
3431+ ### 44.1 Transfer a repository
3432+
3433+ The owner opens the repository config page and presses transfer. The owner types
3434+ the new owner's account name and the repository name to confirm.
3435+
3436+ The server:
3437+
3438+ 1. Confirms the target account exists.
3439+ 2. Confirms the target namespace has no repository with that name.
3440+ 3. Moves the directory from `repos/john/johnbot.git` to `repos/lisa/johnbot.git`.
3441+ 4. Updates the ownership row.
3442+ 5. Writes a permanent redirect from the old path to the new path.
3443+ 6. Rewrites `authorized_keys` if access changes.
3444+
3445+ ### 44.2 Redirects
3446+
3447+ Keep the redirect forever. A moved repository whose old URL returns 404 breaks
3448+ every clone, every bookmark and every link in every thread that mentions it.
3449+
3450+ Git clients follow redirects on both transports. An existing clone keeps working.
3451+
3452+ Free the old name only if the new owner explicitly releases it.
3453+
3454+ ### 44.3 What transfer does not change
3455+
3456+ Nothing inside the repository changes. Commits, proposals, threads, notes and
3457+ `.barerepo/config` are untouched.
3458+
3459+ `[access] push` still lists the same names. The new owner should edit it. The
3460+ server does not edit repository content on transfer, because that would rewrite
3461+ history nobody asked to rewrite.
3462+
3463+ ### 44.4 Delete a repository
3464+
3465+ The owner presses delete and types the repository name.
3466+
3467+ The server moves the directory to a trash area with a timestamp. A cron job erases
3468+ trash older than 30 days.
3469+
3470+ Say the 30 days in the UI. A delete that is instantly irreversible produces a
3471+ support request barerepo has no support channel to answer.
3472+
3473+ ### 44.5 Delete an account
3474+
3475+ Deleting an account deletes or transfers every repository in the namespace. Force
3476+ the user to choose per repository. Do not delete data as a side effect of an
3477+ account action.
3478+
3479+ Free the account name only after the trash window ends. A recycled name that
3480+ inherits an old identity's proposals and comments is a security problem, not a
3481+ convenience.
3482+
3483+ ## 45. Testing
3484+
3485+ You do not have a correct implementation until these pass.
3486+
3487+ ### 45.1 The portability test
3488+
3489+ This is the most important test in the system, because portability is the entire
3490+ argument.
3491+
3492+ ```
3493+ 1. Create a repository. Push code.
3494+ 2. Open a thread. Reply to it.
3495+ 3. Push a proposal from a second account. Comment on a line.
3496+ 4. Merge the proposal.
3497+ 5. Run a build.
3498+ 6. git clone --mirror the repository.
3499+ 7. Delete the original from the server.
3500+ 8. Push the mirror to a second barerepo instance.
3501+ 9. Confirm: code, history, threads, comments, proposals, config and run
3502+ results are all present.
3503+ ```
3504+
3505+ Run this in CI on every commit. A claim about portability that is never exercised
3506+ becomes false without anyone noticing.
3507+
3508+ ### 45.2 Hook tests
3509+
3510+ - Push to `refs/heads/master` without access. Expect rejection, and expect the
3511+ message to name the proposal command.
3512+ - Push to `refs/proposals/new`. Expect allocation, and expect the URL in the push
3513+ output.
3514+ - Push to `refs/proposals/new` twice at the same moment. Expect two different
3515+ numbers.
3516+ - Push to another user's proposal ref. Expect rejection.
3517+ - Merge a proposal. Expect the thread to close by itself.
3518+ - Squash-merge a proposal. Expect the thread to stay open, per chapter 36.7.
3519+
3520+ ### 45.3 Notes concurrency
3521+
3522+ Write two comments to one thread at the same moment from two clients. Expect both
3523+ to survive. Repeat one thousand times. Expect zero losses.
3524+
3525+ This test finds the bug that makes threads look broken to the second user, which
3526+ is the failure most likely to reach production.
3527+
3528+ ### 45.4 Security tests
3529+
3530+ - A comment containing `<script>alert(1)</script>`.
3531+ - A comment containing `[x](javascript:alert(1))`.
3532+ - A comment containing `<img src=x onerror=alert(1)>`.
3533+ - A file named `evil.html` containing a script, fetched raw.
3534+ - A repository named `../../etc`.
3535+ - An account named `admin`, `new`, `signin`.
3536+ - A branch named `--upload-pack=/bin/sh`.
3537+ - An `SSH_ORIGINAL_COMMAND` of `rm -rf /`.
3538+ - A private key pasted into the public key field.
3539+
3540+ Every one must fail safely. Add each to the suite as a permanent regression test.
3541+
3542+ ### 45.5 Performance tests
3543+
3544+ Assert the budget in chapter 25 as build-failing thresholds, not as goals.
3545+
3546+ Test against a large repository, not a toy one. Use the git source tree or the
3547+ linux kernel. A log page that is fast on ten commits proves nothing.
3548+
3549+ Assert the JavaScript budget from chapter 25. A page over 2kb of script has grown
3550+ something, and the test should say which page.
3551+
3552+ ### 45.6 The restore test
3553+
3554+ Back up. Destroy the server. Restore. Run `barerepo doctor`. Confirm push
3555+ and pull still work.
3556+
3557+ Do this on a schedule. A backup that has never been restored is not a backup.
3558+
3559+
3560+ ---
3561+
3562+ # Appendices
3563+
3564+ ## Appendix A. Ref layout
3565+
3566+ ```
3567+ refs/heads/* branches
3568+ refs/tags/* tags
3569+ refs/proposals/new reserved, never created, triggers allocation
3570+ refs/proposals/<n> a proposal
3571+ refs/revisions/<n>/<k> a retained earlier tip of proposal n
3572+ refs/notes/threads/<n> discussion for thread n
3573+ refs/notes/runs build results, keyed by built commit
3574+ refs/notes/releases release notes, keyed by tag object
3575+ refs/meta/counter next proposal or thread number
3576+ ```
3577+
3578+ ## Appendix B. Config schema
3579+
3580+ ```toml
3581+ [repo]
3582+ default_branch = "master" # string, mirrors HEAD
3583+ visibility = "private" # public | private. absent means private
3584+ description = "" # string
3585+ archived = false # true makes the repository read-only
3586+
3587+ [access]
3588+ push = [] # list of account names, owner always implied
3589+ allow_force_push = [] # branches where force-push is permitted
3590+ allow_delete = [] # branches that may be deleted
3591+ require_signed_commits = false # refuse a commit not signed by a key this server holds
3592+
3593+ [proposals]
3594+ accept_from = "anyone" # anyone | authenticated | push
3595+ require_runs = [] # list of label names
3596+ expire_days = 180 # integer
3597+
3598+ [runners]
3599+ # "hostname" = ["label", ...]
3600+
3601+ [build]
3602+ command = "" # shell command, empty disables builds
3603+ image = "" # container image, empty means host
3604+
3605+ [[webhook]] # repeatable
3606+ url = "" # https, public addresses only
3607+ events = [] # see chapter 19.1
3608+ secret_env = "" # name of a secret, never the value
3609+ ```
3610+
3611+ Server config, `/etc/barerepo/barerepo.toml`, is a different file. See chapter 41.7.
3612+
3613+ ```toml
3614+ [limits]
3615+ max_blob_mb = 100 # single file, rejected in pre-receive
3616+ max_push_mb = 2048 # whole push
3617+ max_open_proposals = 10 # per account per repository
3618+ signup_per_hour_per_ip = 5
3619+ artifact_retain_days = 90 # build artifacts. releases do not expire
3620+
3621+ [behavior]
3622+ allow_push_to_create = true # push to a name that does not exist
3623+ allow_lfs = false # off. see chapter 20
3624+ ```
3625+
3626+ Numbers live in `[limits]` and switches live in `[behavior]`. The full server
3627+ file, including `[server]`, `[database]` and `[paths]`, is in chapter 41.7.
3628+
3629+ ## Appendix C. Route table
3630+
3631+ ```
3632+ GET / landing or profile if signed in
3633+ GET /signup POST /signup
3634+ GET /signin POST /auth/challenge, POST /auth/verify
3635+ GET /auth/claim?c= spends the one-use code br auth printed
3636+ GET /keys POST /keys, DELETE /keys/<id>
3637+ POST /tokens, DELETE /tokens/<id>
3638+ GET /new POST /new
3639+ a push to a nonexistent repo also creates one,
3640+ handled in the transport, not on a route
3641+ GET /search?q=
3642+
3643+ GET /<user> profile
3644+ GET /<user>/<repo> log, or empty page
3645+ GET /<user>/<repo>/files/<ref>/<path>
3646+ GET /<user>/<repo>/file/<ref>/<path>
3647+ GET /<user>/<repo>/raw/<ref>/<path> see chapter 42.3
3648+ GET /<user>/<repo>/commit/<sha>
3649+ GET /<user>/<repo>/compare/<a>...<b>
3650+ GET /<user>/<repo>/threads POST /<user>/<repo>/threads
3651+ GET /<user>/<repo>/thread/<n> POST /<user>/<repo>/thread/<n>/reply
3652+ GET /<user>/<repo>/runs
3653+ GET /<user>/<repo>/run/<id>
3654+ GET /<user>/<repo>/runners POST /<user>/<repo>/runners/token
3655+ GET /<user>/<repo>/config POST /<user>/<repo>/transfer
3656+ POST /<user>/<repo>/rename
3657+ POST /<user>/<repo>/delete
3658+ POST /<user>/<repo>/copy
3659+ GET /<user>/<repo>/releases
3660+ GET /<user>/<repo>/release/<tag>
3661+
3662+ GET /inbox
3663+ GET /inbox.atom?token=
3664+ GET /<user>.keys public ssh keys
3665+ GET /<user>.gpg public signing keys
3666+ GET /<user>.atom
3667+ GET /<user>/<repo>.atom
3668+ GET /<user>/<repo>/threads.atom
3669+
3670+ GET /<user>/<repo>.git/info/refs?service=git-upload-pack
3671+ GET /<user>/<repo>.git/info/refs?service=git-receive-pack
3672+ POST /<user>/<repo>.git/git-upload-pack
3673+ POST /<user>/<repo>.git/git-receive-pack
3674+
3675+ POST /runner/attach
3676+ GET /runner/poll
3677+ POST /runner/log
3678+ POST /runner/done
3679+ ```
3680+
3681+ ## Appendix D. Hook pseudocode
3682+
3683+ Repository creation happens before this point, in `barerepo ssh` or the http
3684+ handler. A repository that does not exist has no hooks to run. See chapter 11.
3685+
3686+ ```
3687+ before git-receive-pack runs:
3688+ if repo does not exist:
3689+ if not allow_push_to_create: reject
3690+ if namespace != authenticated user: reject
3691+ if name in trash window: reject
3692+ if name has a transfer redirect: follow it, do not create
3693+ if name fails validation: reject
3694+ create repo, set HEAD from the pushed ref, visibility private
3695+ install hooks
3696+ print the new repository URL
3697+
3698+ pre-receive:
3699+ config = parse(read_blob(HEAD, ".barerepo/config")) or last_good
3700+
3701+ # archiving is repository-wide, so it is judged once and not per ref.
3702+ # the owner is exempt or the flag could never be turned off. chapter 21.3.
3703+ if config.repo.archived and user != owner:
3704+ reject("repository is archived. the owner can unarchive it in .barerepo/config")
3705+
3706+ # every ref is judged against the access matrix in chapter 18. the chain is
3707+ # exhaustive: a ref that matches no namespace is refused by the last branch.
3708+ for (old, new, ref) in stdin:
3709+ if ref == "refs/proposals/new":
3710+ if not may_propose(user, config): reject("...")
3711+ if open_proposals(user, repo) >= limits.max_open_proposals: reject("...")
3712+ n = allocate(repo)
3713+ rewrite(ref -> "refs/proposals/" + n)
3714+ print(url_for(repo, n))
3715+
3716+ elif ref matches "refs/proposals/<n>":
3717+ if user != author(n) and not may_push(user, config): reject("...")
3718+ retain_revision(n, old)
3719+
3720+ elif ref matches "refs/heads/*" or "refs/tags/*":
3721+ if config.access.require_signed_commits and new != zero:
3722+ unsigned = [c for c in commits_added(new) if not has_signature(c)]
3723+ if unsigned: reject(unsigned_and_how_to_sign(unsigned))
3724+ if not may_push(user, config):
3725+ print(proposal_command_hint())
3726+ print(url_for_rejection(repo, attempt_id))
3727+ reject()
3728+ if is_force(old, new) and ref == default_branch
3729+ and ref not in config.access.allow_force_push:
3730+ reject("force-push to the default branch")
3731+ if new == zero and ref == default_branch
3732+ and ref not in config.access.allow_delete:
3733+ reject("cannot delete the default branch")
3734+
3735+ elif ref matches "refs/notes/threads/*":
3736+ if not may_read(user, config): reject("...")
3737+
3738+ else:
3739+ reject("namespace not writable")
3740+
3741+ # size is judged once, over the objects this push actually adds, and only
3742+ # after the refs are judged. a push that was going to be refused anyway
3743+ # should not first spend time weighing its objects.
3744+ if total_size(new_objects) > limits.max_push_mb:
3745+ reject(size_and_limit())
3746+ for blob in new_blobs(all pushed ranges):
3747+ if size(blob) > limits.max_blob_mb:
3748+ reject(name_and_size(blob))
3749+
3750+ post-receive:
3751+ for (old, new, ref) in stdin:
3752+ if ref matches "refs/heads/*":
3753+ range = rev_list(old..new)
3754+ for n in open_proposals(repo):
3755+ if tip(n) in range or is_ancestor(tip(n), new):
3756+ close_as_merged(n, new)
3757+ if ref == default_branch: reload_config_cache()
3758+ if config.build.command:
3759+ enqueue_job(repo, ref, new)
3760+ index(repo, ref, old, new)
3761+ ```
3762+
3763+ ## Appendix E. CLI reference
3764+
3765+ Three programs, because a contributor should not download a server to sign in.
3766+
3767+ ```
3768+ br auth <name> [server] sign in, prints a link to open
3769+ br propose [remote] push HEAD to refs/proposals/new
3770+ br fetch <n> fetch proposal n to a local branch
3771+ br threads list the threads you have fetched
3772+ br thread <n> print thread n
3773+ br reply <n> [-m msg] append a comment and push the note
3774+ br notes fetch all note refs
3775+
3776+ On a build machine:
3777+
3778+ barerepo-runner <token> [--labels a,b] attach this machine as a runner
3779+
3780+ Server side, run as root or the git user:
3781+
3782+ barerepo init create db and directory tree
3783+ barerepo serve run the server. http, git http, runners
3784+ barerepo account create <name> --key <k> make an account from the shell
3785+ barerepo token create <name> make a token for git over https
3786+ barerepo ssh --account <name> ssh entry point, called by authorized_keys
3787+ barerepo hook <pre-receive|post-receive> hook entry point, called by git
3788+ barerepo doctor reinstall hooks in every repository
3789+ barerepo doctor --reindex rebuild the search index from git
3790+ barerepo copy <src> <dst> server-side copy, uses git alternates
3791+ ```
3792+
3793+ `barerepo serve` is the one an operator types and the one a service unit runs. It
3794+ reads `/etc/barerepo/barerepo.toml`, or whatever `BAREREPO_CONFIG` names, and needs
3795+ nothing else. `barerepo ssh` and `barerepo hook` are entry points that sshd and
3796+ git invoke; a person never types either of them.
3797+
3798+ `br` is a separate download and an optional one. Install `barerepo-runner` beside
3799+ `barerepo` on the server, because the add runner page hands out the copy sitting
3800+ next to it, and then the two can never be of different versions.
3801+
3802+ Every one of these prints the underlying git command it runs, per rule 2. The CLI
3803+ is a convenience over git, never a replacement for it, and a user who reads its
3804+ output learns how to stop needing it.
3805+
3806+ ## Appendix F. Task index
3807+
3808+ Every task in Part VII. Use this to find a procedure.
3809+
3810+ | Task | Section |
3811+ |---|---|
3812+ | Make an ssh key | 31.1 |
3813+ | Read your public key | 31.2 |
3814+ | Sign a nonce by hand | 31.3 |
3815+ | Verify a signature by hand | 31.4 |
3816+ | Lose your key | 31.5 |
3817+ | Sign up | 32.1 |
3818+ | Sign in | 32.2 |
3819+ | Add a second key | 32.3 |
3820+ | Remove a key | 32.4 |
3821+ | Make a repository from the form | 33.1 |
3822+ | Make a repository by pushing | 33.2 |
3823+ | Push new code | 33.3 |
3824+ | Push code that already exists | 33.4 |
3825+ | Clone | 33.5 |
3826+ | Change the default branch | 33.6 |
3827+ | Make a repository private | 33.7 |
3828+ | Copy someone else's project | 33.8 |
3829+ | Rename a repository | 33.9 |
3830+ | Archive a repository | 33.10 |
3831+ | Push a large file | 33.11 |
3832+ | Delete a repository | 33.12 |
3833+ | See what changed | 34.1 |
3834+ | Find a file | 34.2 |
3835+ | See who wrote a line | 34.3 |
3836+ | Compare two refs | 34.5 |
3837+ | Search | 34.6 |
3838+ | Open an issue | 35.1 |
3839+ | Reply | 35.2 |
3840+ | Comment on one line | 35.3 |
3841+ | Read threads offline | 35.4 |
3842+ | Reply offline | 35.5 |
3843+ | Close a thread | 35.6 |
3844+ | Propose a change | 36.1 |
3845+ | Change your proposal | 36.3 |
3846+ | Find your proposal number | 36.4 |
3847+ | Review a proposal | 36.5 |
3848+ | Merge a proposal | 36.6 |
3849+ | Reject a proposal | 36.8 |
3850+ | Fix a rejected push | 36.9 |
3851+ | Edit the config | 37.1 |
3852+ | Add a collaborator | 37.2 |
3853+ | Remove a collaborator | 37.3 |
3854+ | Require builds to pass | 37.4 |
3855+ | Restrict who can propose | 37.5 |
3856+ | Allow force-push on a branch | 37.6 |
3857+ | See who changed a setting | 37.7 |
3858+ | Undo a setting change | 37.8 |
3859+ | Attach a build machine | 38.1 |
3860+ | Turn on builds | 38.3 |
3861+ | Read a failed build | 38.5 |
3862+ | Remove a machine | 38.7 |
3863+ | Revoke a token | 38.8 |
3864+ | See what happened | 39.1 |
3865+ | Use a feed reader | 39.3 |
3866+ | Follow one repository | 39.4 |
3867+ | Take everything and leave | 40.1 |
3868+ | Move to another host | 40.3 |
3869+
3870+ ## Appendix G. Diagrams
3871+
3872+ ### G.1 The whole system
3873+
3874+ ```
3875+ browser ssh client runner
3876+ | | |
3877+ | https | ssh | https, outbound only
3878+ v v v
3879+ +----------------------------------------------------------+
3880+ | barerepo binary |
3881+ | |
3882+ | web handlers barerepo ssh hook runner api |
3883+ | | | | | |
3884+ +--------|--------------|-----------|------------|---------+
3885+ | | | |
3886+ v v v v
3887+ +----------+ +---------------------+ +---------+
3888+ | sqlite | | bare repositories | | cache |
3889+ | or | | | | |
3890+ | postgres | | refs/heads | | diffs |
3891+ | | | refs/proposals | | blame |
3892+ | keys | | refs/notes | | index |
3893+ | owners | | .barerepo/config | | |
3894+ | tokens | | | | |
3895+ | redirects| | | | |
3896+ +----------+ +---------------------+ +---------+
3897+ see chapter 10 everything else all derived,
3898+ lives here deletable
3899+ ```
3900+
3901+ ### G.2 Where each thing lives
3902+
3903+ ```
3904+ in git on the server
3905+ code x
3906+ history x
3907+ branches, tags x
3908+ proposals x
3909+ threads, comments x
3910+ build results x
3911+ repository config x
3912+ release notes x
3913+ proposal counter x
3914+
3915+ account -> keys x
3916+ namespace ownership x
3917+ tokens and sessions x
3918+ namespace redirects x
3919+ release artifacts x
3920+ cache, index, event log x
3921+
3922+ git clone --mirror takes column one.
3923+ column two is listed in chapter 10, with a reason for each.
3924+ ```
3925+
3926+ ### G.3 Proposal states
3927+
3928+ ```
3929+ push to refs/proposals/new
3930+ |
3931+ v
3932+ +--------+
3933+ force-push | open |
3934+ +---------> | |
3935+ | +--------+
3936+ | | | |
3937+ +------------+ | +-----------------+
3938+ | |
3939+ merged into | | owner or author
3940+ default branch | | presses close
3941+ v v
3942+ +--------+ +--------+
3943+ | merged | | closed |
3944+ +--------+ +--------+
3945+ |
3946+ no activity for |
3947+ expire_days v
3948+ +-----------+
3949+ | abandoned |
3950+ +-----------+
3951+ ref eligible
3952+ for deletion,
3953+ thread retained
3954+ ```
3955+
3956+ ### G.4 A push, end to end
3957+
3958+ ```
3959+ git push origin HEAD:refs/proposals/new
3960+ |
3961+ v
3962+ sshd authenticates the key
3963+ |
3964+ v
3965+ authorized_keys forces: barerepo ssh --account john
3966+ |
3967+ v
3968+ barerepo parses SSH_ORIGINAL_COMMAND, allowlist only
3969+ |
3970+ v
3971+ git-receive-pack runs
3972+ |
3973+ v
3974+ pre-receive
3975+ | read .barerepo/config from default branch tip
3976+ | check namespace rules (chapter 18)
3977+ | allocate number, rewrite ref
3978+ | print URL to the user's terminal
3979+ v
3980+ refs updated on disk
3981+ |
3982+ v
3983+ post-receive
3984+ | check open proposals for reachability
3985+ | close any that merged
3986+ | enqueue a build job
3987+ | update the search index
3988+ v
3989+ done. the user sees the URL in their terminal.
3990+ ```
3991+
3992+ ### G.5 Comment anchoring over revisions
3993+
3994+ ```
3995+ revision 1 revision 2 displayed as
3996+
3997+ line 43 <-- comment made here
3998+ line 43 unchanged at line 43
3999+ line 45 (moved) at line 45, "moved"
4000+ line deleted "outdated", with the
4001+ original excerpt shown
4002+ from the stored blob
4003+
4004+ the stored blob hash is what makes the last case possible.
4005+ ```
@@ -0,0 +1,21 @@
1+ MIT License
2+
3+ Copyright (c) 2026 BareRepo
4+
5+ Permission is hereby granted, free of charge, to any person obtaining a copy
6+ of this software and associated documentation files (the "Software"), to deal
7+ in the Software without restriction, including without limitation the rights
8+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+ copies of the Software, and to permit persons to whom the Software is
10+ furnished to do so, subject to the following conditions:
11+
12+ The above copyright notice and this permission notice shall be included in all
13+ copies or substantial portions of the Software.
14+
15+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+ SOFTWARE.
@@ -0,0 +1,25 @@
1+ # book
2+
3+ The book is [BOOK.md](BOOK.md). One file, about 3,950 lines.
4+
5+ It describes how the system works and why it works that way, in enough detail
6+ to build a functionally identical one from scratch. It says almost nothing
7+ about visual design, because the visual design follows from the mechanics.
8+
9+ Part I argues the design. Parts II and III are the mechanics. Part IV is every
10+ page. Part V is operating it. Part VI records what was refused and why. Part VII
11+ is written in Simplified Technical English and shows the exact commands for
12+ every task. Part VIII is building and running it. The appendices hold the ref
13+ layout, the config schema, the route table, the hook pseudocode and the CLI
14+ reference.
15+
16+ The three programs the book describes:
17+
18+ | Repo | Binary | What it is |
19+ |---|---|---|
20+ | [server](https://barerepo.com/barerepo/server) | `barerepo` | the forge you install |
21+ | [cli](https://barerepo.com/barerepo/cli) | `br` | an optional shortcut for git commands |
22+ | [runner](https://barerepo.com/barerepo/runner) | `barerepo-runner` | the build machine agent |
23+
24+ The code is the source of truth. Where this book and the code disagree, the code
25+ is right and the book is a bug.
reachable from master
barerepo / bookbarerepo 0.1.0