Single commit page showing message, metadata and full diff.

barerepo / server / 133728e
barerepo · 1mo · 205 files · +34886 -0 · signed
barerepo
@@ -0,0 +1,7 @@
1+ [repo]
2+ visibility = "public"
3+ description = "the forge you install"
4+
5+ [access]
6+ allow_force_push = ["master"]
7+ require_signed_commits = true
@@ -0,0 +1,11 @@
1+ /cmd/barerepo/barerepo
2+ .playwright-mcp
3+ .claude
4+ .DS_Store
5+ *.png
6+ !internal/httpd/static/og.png
7+ *.jpg
8+ *.jpeg
9+ *.gif
10+ go.work
11+ go.work.sum
@@ -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,87 @@
1+ # Punchlist
2+
3+ Work remaining before this deploys and goes public. One line per item. An item
4+ is done when the check in its last column passes.
5+
6+ Sizes are S under an hour, M a few hours, L most of a day.
7+
8+ ## A. Blocks open sourcing
9+
10+ | | Item | Where | Done when | Size |
11+ |---|---|---|---|---|
12+ | [x] | A1 | Pick a license | you | the name of a license, written down | S |
13+ | [x] | A2 | Add LICENSE to all four repos | server, cli, runner, book | `ls LICENSE` succeeds in each | S |
14+ | [x] | A3 | Name the copyright holder | the four LICENSE files | the holder line is filled in, not a placeholder | S |
15+
16+ MIT, copyright BareRepo, in all four repositories.
17+
18+ ## B. Rename the product in the product
19+
20+ The book and the binaries say barerepo. The running program still says forge in
21+ 62 places, and one of them names a command that no longer exists.
22+
23+ | | Item | Where | Done when | Size |
24+ |---|---|---|---|---|
25+ | [x] | B1 | Fix the wrong command name | `internal/httpd/accounts.go:241` says "run forge auth again" | it says `br auth` | S |
26+ | [x] | B2 | Page titles and brand | `web.go` 12, `accounts.go` 5, `atom.go` 1 | no `forge · ` prefix remains | M |
27+ | [x] | B3 | Templates | 14 files under `internal/httpd/templates/`, 35 hits | `grep -ric forge templates/` returns only `.forge/` paths | M |
28+ | [x] | B4 | Workflow messages | `internal/workflow/workflow.go` 15, `matrix.go` 1 | decline and skip messages say barerepo | M |
29+ | [x] | B5 | Startup and hook errors | `cmd/barerepo/commands.go` 3, `hook.go` 2, `blobs.go` 1, `sshx/command.go` 1 | "forge is ready" says barerepo | S |
30+ | [x] | B6 | Update the tests that assert the old word | 13 files, listed at the bottom | full suite green | M |
31+
32+ Do not touch these while renaming. The code depends on the exact strings:
33+ `barerepo-auth`, `barerepo-signup`, `.barerepo/config`, `/etc/forge/barerepo.toml`,
34+ `/var/lib/barerepo`, `barerepo_session`.
35+
36+ ## C. Blocks deploying
37+
38+ barerepo.com is live on v4 and v6. C4 is the only one left here.
39+
40+ | | Item | Where | Done when | Size |
41+ |---|---|---|---|---|
42+ | [x] | C1 | Change the git identity the server commits under | `internal/gitx/gitx.go:86-89`, currently `forge@localhost` | a note written by the server is authored by barerepo | S |
43+ | [x] | C2 | Handle SIGTERM | `cmd/barerepo/commands.go`, the serve command | a push in flight finishes, or is refused cleanly, when the process is asked to stop | M |
44+ | [x] | C3 | Ship a systemd unit | new `contrib/barerepo.service` | `systemctl start barerepo` works from a fresh install | S |
45+ | [ ] | C4 | Write the install steps | server README or `docs/INSTALL.md` | someone who has never seen this can go from tarball to running | M |
46+
47+ C2 is the one with teeth. Nothing catches SIGTERM today, so a deploy during a
48+ push kills it after the objects are written and before the ref moves.
49+
50+ ## D. Cleanup before either
51+
52+ | | Item | Where | Done when | Size |
53+ |---|---|---|---|---|
54+ | [ ] | D1 | Decide `allow_lfs`: build 20.3 or delete the key | `internal/config/config.go:61` | the key has a reader, or no longer exists | S or L |
55+ | [ ] | D2 | Decide `artifact_retain_days` the same way | `config.go`, retention | same | S |
56+ | [x] | D3 | One go version across the three modules | all three say 1.24, which is what the cross repo instructions tell people to write | all three agree | S |
57+ | [ ] | D4 | Add CI | all four repos | a push runs the suite | M |
58+ | [ ] | D5 | Fix or quarantine `TestBudget` | `e2e/budget_test.go` | the suite is green twice in a row under load | M |
59+
60+ ## E. Written down but never built
61+
62+ | | Item | Where | Done when | Size |
63+ |---|---|---|---|---|
64+ | [ ] | E1 | LFS, or say it is refused | book 20.3 | built, or the chapter says not built and `allow_lfs` is gone | L |
65+ | [ ] | E2 | Account rename, or cut it | book 21.2, no route and no CLI exist | built, or the chapter is cut | M |
66+
67+ ## Checked and fine
68+
69+ No real secrets are committed. `rt_live_7Kq2mXe` in the mockups and the book is
70+ an eight character illustration, not a token. Git is always invoked with an
71+ argument array and never a shell. All three `panic` calls are startup time on
72+ constants. It cross compiles for linux/arm64. `barerepo init` works from nothing.
73+
74+ ## Suggested order
75+
76+ A1 and A2 first, because they are cheap and nothing can be public without them.
77+ Then C1, since every commit the server makes carries the wrong identity into
78+ history forever and that cannot be fixed later. Then B, in one pass, so the
79+ tests move once. C2 before you put it on a machine you restart.
80+
81+ ## Tests that assert the old word
82+
83+ `internal/httpd/install_test.go`, `internal/httpd/view_test.go`,
84+ `internal/workflow/workflow_test.go`, `internal/store/store_test.go`,
85+ `e2e/security_test.go`, `e2e/runner_setup_test.go`, `e2e/signin_test.go`,
86+ `e2e/search_test.go`, `e2e/crawl_test.go`, `e2e/portability_test.go`,
87+ `e2e/feeds_test.go`, `e2e/notespush_test.go`, `e2e/copy_test.go`
@@ -0,0 +1,22 @@
1+ # barerepo
2+
3+ go build ./cmd/barerepo
4+ barerepo init
5+ barerepo serve
6+
7+ Reads `/etc/barerepo/barerepo.toml`, or whatever `BAREREPO_CONFIG` names.
8+
9+ ## The three programs
10+
11+ | Repo | Binary | What it is |
12+ |---|---|---|
13+ | [barerepo/server](https://barerepo.com/barerepo/server) | `barerepo` | this one, the forge you install |
14+ | [barerepo/cli](https://barerepo.com/barerepo/cli) | `br` | an optional shortcut for the git commands |
15+ | [barerepo/runner](https://barerepo.com/barerepo/runner) | `barerepo-runner` | the build machine agent |
16+
17+ Install `barerepo-runner` beside `barerepo`. The add runner page hands it
18+ out from there, so the pair can never be out of step and there is nothing
19+ to host or version.
20+
21+ Nothing needs `br`. Every task is a plain git command, and the pages show
22+ that command first.
@@ -0,0 +1,3364 @@
1+ # Book amendments
2+
3+ Thirteen contradictions were found reading the book against
4+ `plans/docs/BUILD.md`, `plans/build.py` and the 24 mockups. All are resolved.
5+ The book is the source of truth, so the book was changed; this file records
6+ what changed and why, and is not a second specification.
7+
8+ Every resolution took the side that costs the user least.
9+
10+ ## Resolved in the book
11+
12+ **1. The security chapter reference.** `README.md` sent readers to chapter 36,
13+ which is Proposals. Now chapter 42, which is the security chapter.
14+
15+ **2. `build.py` now generates every page.** Five pages were hand-written and
16+ outside the generator: `signup`, `profile`, `repo-log`, `repo-config`,
17+ `thread`. They are in `PAGES` now, so `python3 build.py` writes all 25 files
18+ from one shared chrome, which is what the README always claimed. Their drift
19+ went with them: the card height was wrong by 24px and the separator glyph
20+ differed.
21+
22+ **3. Deleting a repository.** 33.12 said "gone at once", 44.4 said a 30-day
23+ trash window. The window wins, because an instant irreversible delete produces
24+ a support request forge has no channel to answer. 33.12 now states the window,
25+ and states that it is a window to notice a mistake rather than a backup.
26+
27+ **4. One name for the push limit.** `[limits] max_push_size_mb = 512` and
28+ `[behavior] max_push_mb = 2048` were the same setting. Now `[limits]
29+ max_push_mb = 2048`, with a rule that removes the ambiguity for every future
30+ key: **numbers live in `[limits]`, switches live in `[behavior]`.**
31+ 2048 wins over 512 because the push most likely to hit this limit is somebody's
32+ first import of an existing repository, and rejecting that is the worst
33+ possible first contact.
34+
35+ **5. The pre-receive pseudocode.** Appendix D nested the notes-namespace check
36+ and the catch-all rejection inside the blob-size loop, so a push to
37+ `refs/notes/threads/*` was never authorised and the catch-all never ran on a
38+ push that added no blobs. The ref chain is now exhaustive and size is judged
39+ once, after the refs, over the objects the push actually adds.
40+
41+ **6. The reserved-name list.** 42.5 listed eleven names but missed `inbox`,
42+ `tokens` and `auth`, all of which are routes an account name could shadow. The
43+ list is now every routed name plus four held for later, and the book says to
44+ derive it from the route table in code rather than copy it.
45+
46+ **7. Raw file content has a route.** 42.3 required a separate domain but no
47+ route existed and two mockups linked to one. Added
48+ `GET /<user>/<repo>/raw/<ref>/<path>` and `[server] raw_url`. When `raw_url` is
49+ empty, raw is served from the main host as a download with the four hardening
50+ headers, and the handler reads no session cookie, so it answers for public
51+ repositories only. Most people install on one hostname; a link that only works
52+ for operators who own a second domain is a link most users never get.
53+
54+ **8. The server has a command.** Appendix E named the ssh and hook entry points
55+ but never the daemon. `forge serve` now runs the server, and the ssh entry point
56+ that only `authorized_keys` invokes is `forge ssh`. The command a person types
57+ got the obvious name.
58+
59+ **9. `repo-config.html` showed invalid TOML.** `uproar.local = [...]` unquoted
60+ is a dotted key, meaning table `uproar`, key `local`. Now quoted, matching
61+ chapter 14. It matters on the one page whose entire point is that the config is
62+ a file you edit by hand.
63+
64+ **10. The thread page shows the offline commands.** Chapters 13 and 24 both
65+ require them and the mockup had none. They are the proof of the portability
66+ claim, on the page making the claim.
67+
68+ **11. The keys page has feed tokens.** Chapters 19.5 and 39.3 issue and revoke
69+ them there. The page now shows all three credentials and says plainly what each
70+ one can do, because a user pasting a token into a feed reader should know it
71+ cannot write.
72+
73+ **12. One separator glyph.** `·` throughout. These pages are full of diff stats
74+ where a hyphen is already a minus sign.
75+
76+ **13. Archiving was a one-way door.** Found while rewriting the hook pseudocode.
77+ 21.3 says unarchive by editing one line and pushing, but archiving rejected
78+ every push, so nothing could ever be unarchived. The owner is now exempt, for
79+ the same reason the owner can always push a broken config: owner access comes
80+ from the namespace, not from the file.
81+
82+ ## Found while building stage 1
83+
84+ **14. An empty repository is private, and nothing else can be.** Chapter 11 says
85+ push-to-create sets visibility private, but visibility is `[repo] visibility` in
86+ `.barerepo/config`, and a repository created by a push has no tree to hold that
87+ file. Appendix B's schema default was `public`, so a pushed-to-create repository
88+ was world-readable. A live clone confirmed the leak.
89+
90+ Resolved: **absent means private, and only the exact word `public` opens a
91+ repository.** An empty value, an unknown value and a typo all stay closed. The
92+ web form's visibility field stores nothing at creation either; choosing public
93+ adds the line that sets it to the block the empty-repository page tells you to
94+ paste. Chapters 11, 18, 24 and appendix B all say so now.
95+
96+ **15. A push must be challenged before it is answered.** A git client sends no
97+ credential until it gets a 401. On a public repository the ref advertisement for
98+ a push succeeded anonymously, so the client never sent its token and the push was
99+ then refused for the wrong reason: "you cannot push here" when the truth was
100+ "you were never asked who you are". Chapter 41.4 now says to return 401 on both
101+ halves of a push, and to never challenge a read.
102+
103+ ## Found while building stage 2
104+
105+ **16. The file view links to a history page that has no route.** `repo-file.html`
106+ and `repo-config.html` both show `history · raw` in the footer. Chapter 24 says
107+ the config page has "history, blame, and raw links". Appendix C routes neither
108+ `history` nor, until amendment 7, `raw`.
109+
110+ History would be the log filtered to one path, which git answers with
111+ `git log -- <path>` and which nothing else in the book describes. Left unbuilt
112+ and rendered as plain muted text rather than a link, so the footer keeps its
113+ shape without offering a page that is not there. It needs a route in appendix C
114+ before it can be built.
115+
116+ **17. The compare mockup has no submit button.** Chapter 24 says both sides are
117+ free text fields, and every other form mockup shows a button. `repo-compare.html`
118+ shows the two refs as boxes with nothing to press. Built with a `compare` button
119+ in the same style as the other forms, because a form a keyboard user can submit
120+ and a mouse user cannot is not finished.
121+
122+ ## Found while building stage 3
123+
124+ **18. The mockups are built from inline styles, which the product's own policy
125+ forbids.** Chapter 42.7 sets `style-src 'self'` with no `'unsafe-inline'`, so a
126+ `style="..."` attribute is dropped by the browser. Every mockup in `plans/` is
127+ made almost entirely of them.
128+
129+ That is fine for the mockups: they are opened as files, with no policy, and
130+ inline style is a reasonable way to write a static reference. It is not fine for
131+ the real pages, and it fails silently, which is the dangerous part. A template
132+ that carries a style attribute renders with that spacing missing and nothing
133+ says so.
134+
135+ Caught by measuring a live page against its mockup: the gap under the sign-in
136+ button was 0px where the mockup has 18px, because the attribute holding it was
137+ being dropped.
138+
139+ Every template now uses classes only, and `TestNoInlineStyles` fails the build
140+ if a style attribute or a script tag reappears. Anyone porting a mockup to a
141+ template has to move its spacing into `barerepo.css` on the way.
142+
143+ **19. The keys mockup shows token values it cannot possibly know.**
144+ `keys.html` listed `rt_live_7Kq2mXe` and `ft_live_9Xk2m` as the headline of each
145+ token row. Chapter 15 says a token is displayed once at creation, inside the
146+ command that uses it, and that the server stores a hash. So the keys page can
147+ never render either string.
148+
149+ The mockup now names what the row actually is: the machine a runner token is
150+ attached to, or the feed a feed token opens. The token value appears exactly
151+ once, on the page that issued it, with a line saying it will not be shown again.
152+
153+ **20. Signing in did not record which key was used.** Chapter 32.5 says the keys
154+ page shows a last-used time per key, "check this if you think a key is lost".
155+ `ssh-keygen -Y verify` answers only yes or no, so an allowed_signers file
156+ holding every key cannot say which one matched. The keys are now tried one at a
157+ time and the matching one is stamped.
158+
159+ Still missing: a push over ssh does not stamp the key either, because
160+ `authorized_keys` passes only `--account`. Adding the fingerprint to that forced
161+ command would fix it, and chapter 41.3 would need to say so.
162+
163+ ## Found while building stage 4
164+
165+ **21. Appendix A's revision refs cannot exist.** The layout said:
166+
167+ ```
168+ refs/proposals/<n> a proposal
169+ refs/proposals/<n>/rev/<k> a retained earlier revision
170+ ```
171+
172+ A ref is a file, so those two ask git for a file and a directory of the same
173+ name. git refuses outright:
174+
175+ ```
176+ cannot lock ref 'refs/proposals/47/rev/1':
177+ 'refs/proposals/47' exists; cannot create 'refs/proposals/47/rev/1'
178+ ```
179+
180+ `refs/proposals/47` cannot move, because chapter 36.5 tells every reviewer to
181+ fetch it by that name. So the revisions moved: **`refs/revisions/<n>/<k>`**.
182+ Appendix A, chapter 12, chapter 26 and chapter 43.5 all say so now, and a test
183+ creates both refs so the layout cannot drift back.
184+
185+ **22. Appendix D says `rewrite(ref -> ...)` without saying how.** A pre-receive
186+ hook cannot redirect a push; it can only accept or reject. The mechanism is
187+ git's `proc-receive` hook, and the config that enables it is
188+ `receive.procReceiveRefs` with a **prefix** value, not a glob.
189+
190+ Getting that wrong fails silently: with `refs/proposals/*` the hook is never
191+ called, no error appears anywhere, and the push creates a ref literally named
192+ `refs/proposals/new`, the one thing appendix A says never exists. Chapter 12
193+ now documents the mechanism and the trap.
194+
195+ **23. Chapter 13's note layout could not be read by git.** It said the note tree
196+ holds one blob per comment, named `<unix-timestamp>-<author>-<short-hash>`.
197+
198+ `git notes` looks a note up by the hash of the object it annotates, so the tree
199+ must be keyed by object hash. A tree keyed by anything else is invisible to
200+ every command chapter 35.4 tells the user to run, and the offline promise in
201+ chapter 3 stops being true.
202+
203+ Checked both halves against real git rather than assumed:
204+
205+ - `git notes --ref=threads/47 add` writes the blob at the path `<sha>`.
206+ - A `meta` blob alongside it does not disturb `git log --show-notes=threads/47`,
207+ because `meta` is not a valid hash and git ignores it.
208+
209+ Chapter 13 now says: one note per annotated object, comments as records inside
210+ it separated by `--`, and `meta` at the tree root for title, state and attached
211+ ref. Records are also the shape `union` merge resolves correctly, which is what
212+ chapter 7 already asked for.
213+
214+ **24. The sign-in page told users to run a program they do not have.** The
215+ mockup's only instruction was `forge auth john`. Signing a nonce needs nothing
216+ but OpenSSH, which is already installed, and Part VII opens by promising that no
217+ task needs the forge CLI.
218+
219+ Chapter 31.3 also made it harder than it is: three commands, writing the nonce
220+ to `/tmp/nonce`, leaving `/tmp/nonce.sig` behind, and using `echo -n`, which is
221+ not portable between shells. `ssh-keygen -Y sign` reads standard input and
222+ writes to standard output, so it is one line and leaves nothing:
223+
224+ ```
225+ printf '%s' '<nonce>' | ssh-keygen -Y sign -f ~/.ssh/id_ed25519 -n barerepo-auth -
226+ ```
227+
228+ Verified by copying the command the live page prints, verbatim, and signing in
229+ with it.
230+
231+ Rule 2 in chapter 5 now says this outright: the command a page shows must be one
232+ the user can already run, so the plain form comes first and `forge` is offered
233+ second as the shortcut it is. `TestTheCliIsNeverTheOnlyWay` fails the build if
234+ a template names a forge subcommand without its plain equivalent.
235+
236+ The runner page is the one honest exception, because a runner long-polls and a
237+ shell one-liner cannot. It now says so, and gives the four HTTP endpoints so
238+ anyone can write their own.
239+
240+ **25. The diff mockups have no line numbers, but chapter 35.3 says to press
241+ one.** "Open the proposal diff. Press the line number. Type your comment." The
242+ diff rows in `repo-commit.html`, `repo-compare.html` and `repo-log.html` render
243+ the code and nothing else, so there was no number to press, and an anchor of
244+ `config.go:43` pointed at a 43 the reader could not see.
245+
246+ The diff helper in `build.py` now numbers each line on the new side. A removed
247+ line gets a blank gutter, because it has no number on that side. The real diff
248+ parser carries the same number, and the number is a link wherever there is a
249+ thread to attach a comment to.
250+
251+ **26. Build results were unreadable by git, and the layout was a dead end.**
252+ Appendix A put them at `refs/notes/runs/<sha>`, one notes ref per built commit.
253+ Chapter 16 claims build history clones and is readable. Both halves fail:
254+
255+ - `git log --show-notes=runs` prints nothing, because git looks a note up by
256+ the hash of the object it annotates, not by the ref's name. Tested.
257+ - `refs/notes/runs` and `refs/notes/runs/<sha>` cannot both exist, so a
258+ repository that used per-commit refs can never move to the working layout
259+ without deleting every one of them first. Tested, and git says so plainly:
260+ `'refs/notes/runs/965790c...' exists; cannot create 'refs/notes/runs'`.
261+
262+ A busy repository would also carry one ref per commit it ever built.
263+
264+ Now `refs/notes/runs`, one ref, tree keyed by the built commit. Several runs of
265+ one commit are separate records, split by `--`, the same shape chapter 13 uses
266+ for comments. `refs/notes/releases/<tag>` moved to `refs/notes/releases` for the
267+ same reason, keyed by the tag object.
268+
269+ Chapter 16 also said a log under 64kb "is inlined" without saying where. It is
270+ an `output` field now, and `log` names a blob when the output is larger.
271+
272+ **27. A run record could not say what was built.** Chapter 24 says the runs
273+ page shows "commit, status, duration, ref, and which machine ran it", and
274+ `runs.html` puts the ref on every row. Chapter 16's record had no ref field.
275+
276+ A commit arrives on a branch and on a proposal, so the commit alone does not
277+ answer it. The record carries `ref` now.
278+
279+ **28. The content security policy blocked the JavaScript the book budgets
280+ for.** Chapter 42.7 set `default-src 'none'` with no `script-src`, which blocks
281+ every script, and closed with "if a future feature needs `script-src`, that
282+ feature is wrong". Chapter 25 budgets 2kb of script for two keyboard shortcuts,
283+ `/` for search and `t` for the file jump, and chapter 34 tells users to press
284+ them.
285+
286+ Both cannot hold. The policy now includes `script-src 'self'`. Inline script is
287+ still blocked, `eval` is still blocked, and nothing loads from another host, so
288+ the rule the policy existed to enforce is intact: `keys.js` is 814 bytes and a
289+ framework cannot arrive through it. `TestScriptBudget` fails the build if it
290+ passes 2kb or if a second script file appears.
291+
292+ **29. Search has no index, on purpose, for now.** Chapter 17 wants an index
293+ built on push and kept in the cache directory. This runs `git grep` over the
294+ repositories the asker may read, and no others.
295+
296+ Chapter 17's warning is that filtering a shared index after ranking leaks the
297+ existence and count of private matches, and calls that the highest-severity
298+ mistake available in the codebase. Not looking at all is the same rule applied
299+ one step earlier, so that class of bug cannot occur here.
300+
301+ The cost is that a query is O(readable repositories). An index has to keep this
302+ property when it arrives.
303+
304+ **30. Copying with alternates makes a copy that a delete can destroy.** Chapter
305+ 21.1 says to copy server-side using git alternates, which is right: it keeps a
306+ 4 GB repository off a home connection. It did not say what happens next.
307+
308+ A copy made with `--shared` borrows the original's objects. Delete the original
309+ and the copy loses the history it never had its own copy of, which turns
310+ "delete my repository" into "delete somebody else's work".
311+
312+ A delete now detaches its dependents first: `git repack -a -d` writes every
313+ borrowed object into the copy and the alternates file goes. A delete that
314+ cannot detach a dependent fails instead of proceeding. Chapter 21.1 says so
315+ now, and `TestCopyAndDetach` deletes the source and checks the copy still has
316+ its history.
317+
318+ **31. A repository could be taken and never put back.** Chapter 40.3 tells a
319+ user to move hosts with `git push --mirror`. Writing the test in chapter 45.1
320+ showed that push being refused, ref by ref:
321+
322+ ```
323+ ! [remote rejected] refs/meta/counter (pre-receive hook declined)
324+ ! [remote rejected] refs/notes/runs (pre-receive hook declined)
325+ ! [remote rejected] refs/proposals/2 (pre-receive hook declined)
326+ ```
327+
328+ The access matrix in chapter 18 says those namespaces are the server's or
329+ nobody's, which is right for a contributor and wrong for the owner restoring
330+ their own repository. Chapter 3's promise is that you can point your clone at
331+ another host and keep working, and half of it was missing.
332+
333+ The matrix now says the namespace owner may write any ref in their own
334+ repository. It grants nothing that was withheld: an owner who wanted to forge a
335+ build result could already push any content they liked.
336+
337+ **32. Allocation could hand out a number already in use.** Chapter 35.5 tells a
338+ user they can open a thread by pushing `refs/notes/threads/<n>` from their
339+ clone. That leaves `refs/meta/counter` behind, and the next proposal took the
340+ same number, putting two conversations in one place. Allocation steps over any
341+ number that already names a thread or a proposal.
342+
343+ Both were found by writing chapter 45.1's test, on its first two runs.
344+
345+ **33. Anyone could take over anyone's proposal.** Chapter 12 says only the
346+ proposal's author and accounts with push access may update its ref. The check
347+ read the commit author, `git log --format=%an`.
348+
349+ A commit's author is whatever the pusher typed into `git config user.name`.
350+ Setting it to `lisa` was enough to force-push over lisa's proposal. Writing
351+ chapter 45.2's hook tests surfaced it: the rejection said "belongs to tester",
352+ which is the name the test harness commits under, not the account that pushed.
353+
354+ The author is now read from the thread's `meta` blob, which records the account
355+ that pushed. Chapter 12 says so, and `TestCommitAuthorIsNotIdentity` performs
356+ the takeover and expects it to fail.
357+
358+ The two also differ in ordinary use: applying somebody's patch and pushing it
359+ is normal, and it should not hand them your proposal.
360+
361+ **34. The chapter 25 budget is not met, and the reason is process spawn.**
362+ Chapter 45.5 says to assert the budget against a large repository rather than a
363+ toy. Against 1000 files and 200 commits, on an ordinary laptop:
364+
365+ | page | before caching | after caching | budget |
366+ |---|---|---|---|
367+ | file tree | 82ms | 38ms | 10ms |
368+ | log with diffs | 56ms | 56ms | 20ms |
369+ | file view with blame | 167ms | 47ms | 20ms |
370+
371+ Payloads are all inside budget: 1kb, 18kb, 1kb against 15kb, 30kb, 40kb.
372+
373+ Caching blame by blob hash and the per-entry log by tree hash, both of which
374+ chapter 25 specifies, took the file view from 167ms to 47ms and the file tree
375+ from 82ms to 38ms. Neither reaches the number.
376+
377+ The floor is process spawn. `git rev-parse HEAD` on a small repository measures
378+ 8ms, almost all of it spawn. A page that runs four git commands has spent 32ms
379+ before rendering anything. Chapter 25 now states this and says the budget is a
380+ budget on git invocations as much as on milliseconds: one or two per page,
381+ reached with `git cat-file --batch` and one `git log --patch` for a whole page,
382+ and libgit2 in-process where that is not enough.
383+
384+ Cutting invocations closed most of it. `git cat-file --batch` answers the hash,
385+ the size and the content in one process instead of three. HEAD and refs are
386+ plain files, so reading them costs nothing where asking git costs 8ms each.
387+ Parsed configuration caches by the commit it came from, which chapter 14 asks
388+ for. The per-entry tree log keys on commit and path rather than tree hash,
389+ because the commit is a file read and resolving the tree is a process.
390+
391+ Each commit's diff is also cached by its own hash. A commit cannot change, so
392+ a push invalidates one entry rather than the page. The commit list comes from
393+ one cheap `git log` with no patch; on a miss the diffs come from a single
394+ `git log --patch` for the range rather than one call per commit.
395+
396+ | page | first measured | now | budget |
397+ |---|---|---|---|
398+ | file tree | 82ms | 10.3ms | 10ms |
399+ | log with diffs | 56ms | 24ms | 20ms |
400+ | file view with blame | 167ms | 10.5ms | 20ms |
401+ | threads list | 104ms at 6 threads | 55ms at 50 | 10ms |
402+ | one thread | not measured | 22ms | 10ms |
403+
404+ **Three pages read git once per row, and none of them were measured.** The
405+ threads list read each thread's meta with a `cat-file`, listed each note tree
406+ with an `ls-tree`, and read every comment blob with another `cat-file`. Fifty
407+ threads with three replies each is roughly three hundred processes. The runs
408+ page did the same over the runs ref and then spent a `git log` per row for the
409+ commit subject. A single thread page did it over one thread's notes.
410+
411+ All three now use `gitx.Batch`, which drives one `cat-file --batch` process for
412+ many objects, and `gitx.TreeEntries`, which reads the raw tree git answers
413+ with. The threads list costs three processes whatever the thread count, the
414+ runs page three, and one thread two. Measured on the live server: threads list
415+ 104ms to 32ms at six threads, runs page 78ms to 32ms at four runs, one thread
416+ 32ms to 21ms.
417+
418+ **The remaining cost is git working, not forge spawning.** `gitx.Run` for a
419+ `rev-parse` measures 7.8ms on this machine and a bare `exec.Command` measures
420+ 9.1ms, so the wrapper adds nothing and the book's 8ms figure holds. Three
421+ processes is 24ms of that 55ms; the rest is git reading fifty trees and a
422+ hundred blobs, which is work no amount of batching removes. Chapter 25's 10ms
423+ is not reachable from a process, and the chapter already names the answer:
424+ libgit2 in-process rather than a looser number.
425+
426+ Getting the threads list to the chapter's stated two processes means dropping
427+ the `for-each-ref` and reading the ref files directly, the way HEAD is read.
428+ That is left alone deliberately: a direct read is wrong inside a worktree,
429+ which is a bug this codebase has already shipped once, and it saves 8ms
430+ against a page that misses by 45.
431+
432+ `TestBudget` is left failing rather than adjusted, because chapter 45.5 asks
433+ for build-failing thresholds and a threshold quietly raised to match the code
434+ measures nothing.
435+
436+ ## Also changed
437+
438+ **SQLite or PostgreSQL.** One setting picks it:
439+
440+ ```toml
441+ [database]
442+ url = "sqlite:///var/lib/barerepo/forge.db"
443+ ```
444+
445+ SQLite is the default and is the right answer for almost every install: the
446+ server-owned data is small, writes are serialised by one process, and the backup
447+ becomes a file copy. Postgres exists for people who already run one. It does not
448+ make forge faster; the hot path is git.
449+
450+ Chapter 41.7.1 covers it. `[paths] db` is gone, replaced by `[database] url`.
451+
452+ **Tests run on SQLite.** It needs no service, so every test gets a fresh empty
453+ database. The Postgres schema is held to the SQLite one by a test that compares
454+ the two definitions column by column and needs no server. Run the full suite
455+ against a real Postgres before a release, not on every commit.
456+
457+ ## The write path was the last one starting a process per row
458+
459+ Reading a thread was batched; writing one was not. `Write` is a compare-and-swap
460+ on the notes ref, and the loser reads again rather than dropping a comment. That
461+ is correct, and under load it was quadratic: twenty writers meant the twentieth
462+ lost nineteen times, and each attempt cost a `ls-tree` plus a `cat-file` per
463+ note. Chapter 45.3's thousand comments took 323 seconds and came within one
464+ retry of the twenty the loop allows, which is where a comment starts being lost
465+ for real rather than in theory.
466+
467+ Two changes. Writers in one process now queue on a striped mutex, so the swap is
468+ contention between processes and not inside one. The read inside the loop uses
469+ `gitx.Batch`, so a thread costs two processes whatever the note count. The same
470+ test now takes 71 seconds, and nothing is dropped.
471+
472+ Serialising in one process leaves the swap untested by the tests named for it,
473+ because they can no longer collide. `TestConcurrentRepliesAcrossProcesses` runs
474+ eight real processes at one thread, which is the case that actually happens: a
475+ hook and the web server writing at once. Deleting the swap's old value makes it
476+ keep one comment of eight, so it is testing what it says it is.
477+
478+ `countComments` was dead and is gone.
479+
480+ The whole suite went from 232 seconds to 66, and three of the five chapter 25
481+ budgets now pass that did not. `TestBudget` still fails on the threads list, one
482+ thread, and the log with diffs, and is still left failing rather than adjusted.
483+
484+ ## Three pages put the wrong thing at the right of the tab row
485+
486+ `repotabs` ended every page with "jump to file `t`". The mockups end four pages
487+ differently: threads with `open · merged · closed · all`, runs with `runners ·
488+ add a runner`, runners with `add a runner`, releases with `newest first`. Only
489+ the code pages get the file jump, which is the only place the `t` shortcut goes
490+ anywhere.
491+
492+ The threads one was not a missing decoration. Chapter 24's thread list says
493+ "Filters are open, merged, closed, all" and forge had no filter at all, so a
494+ repository with three open threads and forty-one closed showed forty-four rows
495+ and no way to narrow them. The filter is a query parameter, the default is all,
496+ and an unknown value shows everything rather than an empty page. Abandoned
497+ counts as closed, because the row names four states and the model has five.
498+
499+ The counts in the header stay counts of every thread. A number that moves with
500+ the filter is the filter read back, and says nothing.
501+
502+ ## Still missing on the thread list row
503+
504+ Chapter 24 says each row shows "whether a ref is attached, diff stats if so,
505+ build status if any, and reply count". Forge shows the ref and the count. The
506+ mockup's `has proposal · +81 -12 · build ok · 2 replies` is two thirds there.
507+
508+ A diffstat per row is a git process per row, which is the thing chapter 25
509+ forbids, so it wants the same treatment the commit diffs got: cache by the pair
510+ of end hashes, which are immutable, and pay only for proposals not seen before.
511+ The build status is already in the runs notes the runs page batches.
512+
513+ ## The log page now opens one diff at a time, against what the book says
514+
515+ Chapter 24's repository log says "the diff **already expanded**", chapter 34.1
516+ says "Each commit shows its diff. Large diffs are collapsed. Press expand", and
517+ docs/BUILD.md repeats it. The page now ships every diff shut, and opening one
518+ shuts the last.
519+
520+ This is the author's call and it overrides the three places above, which should
521+ be amended. The reason it was asked for is visible in what the page actually
522+ did: twenty commits, thirteen of them over the inline threshold, so thirteen
523+ rows said "large diff collapsed" and seven showed a wall of diff. Which state a
524+ row was in depended on a size threshold the reader cannot see, there was no way
525+ to shut a diff once open, and "expand" was not an expand at all, it navigated to
526+ the commit page. Every complaint in that sentence is true whichever default is
527+ chosen.
528+
529+ **No JavaScript was added.** `<details name="log">` is an exclusive group in
530+ HTML: opening one closes the others, with no script at all. Chapter 25 budgets
531+ 2kb of JavaScript for two keyboard shortcuts and this spends none of it. Where a
532+ browser does not know the `name` attribute it ignores it, and the diffs are
533+ still collapsible, just not exclusive, which is the right way for it to fail.
534+
535+ A large diff is still not inlined, because the payload budget is on what is
536+ sent, not on what is displayed, and a shut `<details>` has still sent its
537+ contents. Those rows say "too large to inline" and link to the commit, and they
538+ carry no disclosure triangle, because there is nothing behind them to disclose.
539+
540+ ## Things that should have been links and were not
541+
542+ The month on the keys and profile pages was formatted with `"jan 2006"`. Go's
543+ reference month is `Jan`, so a lowercase one is not a token and was copied out
544+ literally: every key ever added read "added jan 2026" whatever month it was.
545+ Now formatted with the reference layout and lowercased afterwards, which is what
546+ the mockups show.
547+
548+ Then a sweep of every view for values that name something forge can open:
549+
550+ | view | was plain | now opens |
551+ |---|---|---|
552+ | log | the commit hash, the author | the commit, the account |
553+ | commit | the parent hash, the author, each path, the branch | the commit, the account, the file, the log |
554+ | thread | the merged commit, the proposal ref, each author, a line anchor | the commit, a compare, the account, the file at the line |
555+ | thread list | the merged commit, the author | the commit, the account |
556+ | runs, run | the ref, the runner, the subject, the run's hash | a compare, the runner list, the commit |
557+ | releases | the tag, the tagger | the files at the tag, the account |
558+ | compare | each path | the file on the b side |
559+
560+ **A name in a commit or a pushed note is not an account.** It is whatever the
561+ writer set in their local git config, so linking it blindly makes a page full of
562+ 404s. Every name is checked against the accounts table first, in one query per
563+ page rather than one per row, and a name that is not an account stays plain
564+ text. On the live server this is visible: `lisa`, `dave` and `rock` link, and
565+ `donuts-are-good` does not, because only the first three are accounts.
566+
567+ The same rule applies to hashes and paths. A deleted file gets no link, because
568+ the file does not exist at that commit; `parsePatch` now records `Gone` from
569+ git's "deleted file mode" line. A thread with nothing merged gets no merged
570+ link. An anchor whose original is lost gets no file link.
571+
572+ Every link on the log, thread list, thread, runs, releases, commit and file tree
573+ pages was then fetched. None of them 404s.
574+
575+ ## A readme link, and refs you can see before you type them
576+
577+ Two gaps the book does not name, both asked for directly.
578+
579+ **The readme.** Chapter 24 is against a *rendered* readme on the landing page,
580+ because that is the space GitHub spends instead of answering what changed. It is
581+ not against a repository saying it has one. The log bar now carries a `readme`
582+ link when the root tree holds one, under any of the usual names, and it opens
583+ the file view, which shows source with a blame gutter like every other file.
584+ Nothing is rendered on the log page and nothing moved off it.
585+
586+ Finding it costs one tree read, cached by the commit, so it is free after the
587+ first hit. The log page measures 16ms against chapter 25's 20ms with it in.
588+
589+ **The refs.** Chapter 24 says the compare fields are free text and gives the
590+ reason: `master...refs/proposals/47` is a legitimate thing to type and a
591+ dropdown of branches cannot express it. That reasoning holds, and it left a
592+ repository's branches, tags and proposals invisible everywhere in the web
593+ interface. A field whose valid values cannot be discovered is a field only its
594+ author can use.
595+
596+ So the compare page lists what exists, under the form, as links that keep the a
597+ side and swap the b side. It is not a picker: the fields stay free text and the
598+ list is what is there to type. The branch name in the log bar links to that
599+ page, which is the only place a branch name meant anything before.
600+
601+ Notes refs are left out. They are storage, not somewhere to compare against.
602+
603+ ## The thread list row is now the whole row chapter 24 asks for
604+
605+ Chapter 24 says each row shows "whether a ref is attached, diff stats if so,
606+ build status if any, and reply count". Forge showed the ref and the count. Now
607+ it shows all four, in the mockup's order: `has proposal · +2 -0 · build ok ·
608+ 3 replies`, with a failed build in the one colour the mockups use for it.
609+
610+ Neither number costs a process per row. Every run in the repository comes back
611+ in the two processes `run.Recent` already used for the runs page. The diffstat
612+ is keyed on the two end hashes, which are objects and cannot change, so a
613+ proposal is measured once and never again. Resolving a proposal ref to a hash is
614+ a file read.
615+
616+ The threads list measures 41ms against chapter 25's 10ms, up from 35ms, on a
617+ cold cache in a repository built by the test. It was already failing that
618+ threshold for the reason SPEC-NOTES gives above: git reading fifty trees is work
619+ no batching removes.
620+
621+ ## Two bugs from one wrong assumption about HEAD
622+
623+ `gitx.ResolveRef` read a ref file and returned its contents. For every ref under
624+ `refs/` that is a hash. For `HEAD` it is the line `ref: refs/heads/master`,
625+ because HEAD is symbolic.
626+
627+ So `ResolveRef(dir, "HEAD")` returned a ref name where every caller expected a
628+ hash, and returned it with no error, which is the worst shape a wrong answer can
629+ have. It broke two things in one afternoon: the readme page 404'd, because the
630+ name is not a tree, and every proposal's diffstat came back `+0 -0`, because the
631+ name is not a rev.
632+
633+ Fixed in `gitx` rather than at either call site, since the next caller would
634+ have hit it too. `ResolveRef` now follows `ref: ` up to five times and errors on
635+ a ref that points at itself, which is a file somebody wrote by hand.
636+
637+ ## Seventeen templates were never rendered by the test that claims to render them
638+
639+ `TestTemplatesRender` opens "Every template runs here, because a typo otherwise
640+ fails when a user asks for the page." It ran ten of twenty-seven. The other
641+ seventeen, including every account page, the runner setup, search and the inbox,
642+ had nothing rendering them at all.
643+
644+ The test now walks `pages` and fails on any template with no case, so the next
645+ one cannot ship untested. The seventeen have cases.
646+
647+ ## Also
648+
649+ The log rows are uniform: every commit carries a `view commit` link, and the
650+ "too large to inline" wording is gone. A link inside `<summary>` navigates
651+ without toggling the disclosure, which was checked in a browser rather than
652+ assumed.
653+
654+ The rendered readme keeps each control once: `rendered` in the bar, `[source]`
655+ under the prose, `raw` in the footer.
656+
657+ ## The breadcrumb named two places and linked neither
658+
659+ `rock / forge / 26ddf1d` sat at the top of seventeen pages with the account and
660+ the repository as plain text. Both are places, and the reader is one click from
661+ each on every page except the one that made them type the URL.
662+
663+ There is now a `crumbs` template, so a page cannot write the pair by hand and
664+ forget, and a test fails on any template that goes back to doing so. The last
665+ segment stays plain, because it names the page you are already on.
666+
667+ The file tree is the one page where the middle segment moves: at the root the
668+ repository *is* the page, and under a path it is somewhere to go back to, so it
669+ links only in the second case.
670+
671+ ## The one line on the runner page pointed at a 404
672+
673+ BUILD.md calls the runner command "the piece to get exactly right, because it is
674+ the most visible proof of the no-interstitial thesis". The page rendered it
675+ correctly and both halves of it, `/runner.sh` and `/runner.ps1`, returned 404.
676+ So the page that exists to prove one paste is enough handed out a paste that
677+ failed.
678+
679+ The protocol and the `forge runner` subcommand were already built. Only the
680+ script was missing.
681+
682+ **Where the binary comes from.** BUILD.md says "same single binary as the
683+ server, different subcommand", so the server hands out its own executable at
684+ `/runner/binary`, guarded by the runner token, which the script already has and
685+ a stranger does not. No release hosting, no second artifact to keep in step.
686+
687+ **What it does when it cannot help.** A server can only serve the platform it
688+ was built for. The script compares `uname` against the server's and, on a
689+ mismatch, prints the two commands that build and run it instead. A wrong
690+ architecture installed silently is worse than a refusal that says what to do.
691+
692+ Measured on the live server, the real line: downloads 27mb, attaches, and the
693+ runners page shows the machine as idle. That is chapter 15's "the page the user
694+ copied from updates the moment the runner attaches", working.
695+
696+ The book writes the url as the hosted `barerepo.sh`. Serving it from each install's
697+ own `external_url` is what a self-hosted forge can actually do, and it is what
698+ the template already rendered.
699+
700+ ## Reading GitHub workflows, and the book grew a chapter for it
701+
702+ The book had nothing on this. It has chapter 15A now, and BUILD.md has a stage
703+ 6A, because the author asked for the feature and a feature the book does not
704+ describe is a feature nobody can check.
705+
706+ **It is not a contradiction.** Chapter 3 already credits `.github/workflows`
707+ with learning the lesson forges forgot: configuration is a file in the tree. The
708+ objection in this book was never to that file. It was to a forge configurable
709+ only through its own web forms. Reading a workflow honours the same rule
710+ `.barerepo/config` honours.
711+
712+ **What it translates.** Every `run:` step into one shell script, in order, with
713+ `env:` turned into exports and `working-directory` into a subshell so a step's
714+ directory does not leak into the next. `actions/checkout` is answered rather
715+ than run, because forge cloned the repository already. `container:` becomes the
716+ image.
717+
718+ **What it declines, and the rule the feature rests on.** Any other action, any
719+ `if:`, any matrix, any shell forge cannot start, and any `${{ }}` left in a
720+ command. Each one is printed in the push output, naming the step and the reason.
721+ A skipped step is never silent. A build reporting success while quietly running
722+ half of what was asked is worse than no build, and it is the failure every
723+ partial implementation of somebody else's format drifts toward.
724+
725+ **runs-on, against machines that actually exist.** A job now carries the labels
726+ it asked for, and a runner takes the first queued job it *satisfies* rather than
727+ the first queued job, so a build waiting for a machine nobody attached does not
728+ block the ones that could run now. `ubuntu-latest` and its siblings match on the
729+ operating system the runner reported when it attached, `self-hosted` is always
730+ true because every forge runner is, and anything else must be a label the runner
731+ declared.
732+
733+ When nothing fits, the push declines and prints the page that fixes it, which is
734+ chapter 11's rule about typos applied to machines:
735+
736+ ```
737+ unit wants a ubuntu-latest machine and none is attached.
738+ attach one: https://barerepo.example/john/johnbot/runners/new
739+ ```
740+
741+ **Precedence.** `[build] command` wins and the workflow is not read at all. A
742+ repository that answered in forge's own file is not second-guessed, and a
743+ repository with both does not build twice. Both directions are tested.
744+
745+ **The one dependency.** `gopkg.in/yaml.v3`. Hand-rolling a YAML subset for a
746+ format other people control is how a parser becomes a bug farm, and the book's
747+ minimalism is about architecture, not about refusing a parser.
748+
749+ The `jobs` table grew a `labels` column in both dialects, which the portability
750+ test compares column by column and passed.
751+
752+ ## Making a workflow work without editing it, and a silent pass it turned up
753+
754+ The aim the author set is that a repository arrives with the file it already has
755+ and builds. Two things stood between that and the first implementation.
756+
757+ **`${{ }}` was not a decline, it was a break.** The first version left an
758+ expression in the command and reported it. `sh` reads `${{` as a bad
759+ substitution and fails the step outright, so any workflow naming
760+ `${{ github.sha }}` failed at the first step that used one. That is the most
761+ common thing in a real workflow after `run:` itself.
762+
763+ Forge now sets the environment GitHub sets, including `GITHUB_SHA`,
764+ `GITHUB_REF_NAME`, `GITHUB_REPOSITORY`, `GITHUB_WORKFLOW`, `GITHUB_JOB`, `CI`,
765+ and the runner's own `RUNNER_OS`, `RUNNER_ARCH` and `RUNNER_TEMP`, and fills the
766+ expressions that name the same things. A workflow reading either form now works
767+ untouched. This is a substitution and not an evaluator: `secrets`, and anything
768+ else outside the list, is still left alone and still reported.
769+
770+ **A failing step did not fail the build.** The runner passes the command to
771+ `sh -c` with no `set -e`. For chapter 15's one command that is correct, and its
772+ exit code is the command's. For a translated workflow of many steps it is not:
773+ the first step could fail, every later step still ran, and the build reported
774+ the exit code of the last one. Measured: a script whose first line is `false`
775+ exits 0.
776+
777+ That is precisely the green build that means nothing, which is the failure the
778+ whole chapter is written against, and the feature shipped with it. The generated
779+ script now begins with `set -e`, which is also what GitHub does. `[build]
780+ command` is untouched, being one command by definition.
781+
782+ **The setup actions check, they do not install.** `setup-go`, `setup-node`,
783+ `setup-python`, `setup-java` and `setup-dotnet` become a test that the tool is
784+ on the machine, failing with a sentence if it is not, and printing the version
785+ the workflow asked for beside the version the machine has. The version is
786+ printed rather than enforced, since a patch digit should not fail a build.
787+
788+ Not installing is the point. The runner is a machine the user owns, and a build
789+ that quietly puts a toolchain on somebody's laptop is doing something the person
790+ who pasted one line did not agree to. Chapter 15 says a build has access to
791+ whatever the machine has. It does not say a build may change what the machine
792+ has. A test asserts no generated line runs `apt-get`, `brew install`, `npm
793+ install`, `pip install` or a piped shell script.
794+
795+ `actions/cache` is answered as a statement that forge does not cache, so a build
796+ starting from the clone is a stated fact rather than a surprise.
797+
798+ ## A matrix builds a job several times, so forge queues it several times
799+
800+ The first version declined a matrix whole, which meant a repository whose CI is
801+ a matrix got no build at all. That is most repositories that test more than one
802+ version of anything.
803+
804+ The axes are multiplied, `exclude` removes what it names, and each combination
805+ becomes its own job with its own `runs-on`. The combination is substituted into
806+ the command, the image and the machine, and exported as `MATRIX_<AXIS>` so a
807+ step reading the environment works as well as a step reading the expression.
808+
809+ `include` is not applied. It can add keys to a combination and whole
810+ combinations no axis names, and a wrong guess there runs a build the workflow
811+ did not ask for. Forge says so and builds the axes.
812+
813+ **The part that made it usable rather than merely present.** A matrix queues one
814+ commit several times, and `run.Record` had no way to say which build was which:
815+ three combinations produced three records distinguishable only by the machine,
816+ and not at all when two ran on one machine. A matrix build where you cannot see
817+ which combination failed is not worth having.
818+
819+ The job now carries a name, `test (go 1.26, os ubuntu-latest)`, with the axes
820+ named and sorted so the same matrix always produces the same names. The server
821+ already holds the job when the run finishes, so the name reaches the record
822+ without the runner protocol changing at all. The runs page and the run page both
823+ show it.
824+
825+ The `jobs` table gained `name` beside `labels`, in both dialects, which the
826+ portability test compares column by column and passed.
827+
828+ **What this looks like when the machines are not all there.** A matrix over
829+ ubuntu and macos, on a server with only a linux runner attached, queues the
830+ ubuntu half and declines the mac half by name, with the link to attach one. That
831+ is tested end to end, along with each machine taking only its own combination.
832+
833+ ## The runs page said builds were off while builds were running
834+
835+ `BuildOn` was `[build] command != ""`, which was the whole truth until chapter
836+ 15A gave a repository a second way to build. A repository building from
837+ `.github/workflows` showed "builds are off. set [build] command to turn them
838+ on" underneath its own build results.
839+
840+ The page now says what the repository actually builds from, naming the files,
841+ and the off state names both routes rather than only forge's own. Finding out
842+ costs one process and only when `[build] command` is absent, since a repository
843+ that answered in forge's file is not asked a second question.
844+
845+ This is the same shape as the seeded runs that read "builds are off" earlier in
846+ this file: a page holding two facts that contradict each other, where one of
847+ them was written before the other existed.
848+
849+ ## I added two columns without a migration, and every existing install would have broken
850+
851+ The `jobs` table gained `labels` and then `name` for chapter 15A. I added them to
852+ the `CREATE TABLE` in migration 5, which is the one migration this codebase
853+ already says must never be edited: `migrations` is append-only and
854+ `schema_migrations` records how far an install has got.
855+
856+ A fresh database therefore had the columns and every existing one did not. The
857+ first `/runner/poll` after the upgrade answered `no such column: labels`, 500,
858+ forever. Builds would have stopped on every server that had ever run forge
859+ before, and only on those.
860+
861+ **Nothing in the suite could have caught it.** BUILD.md says tests run on SQLite
862+ because it needs no service, so every test gets a fresh empty database. That is
863+ the right call and it makes the whole suite structurally blind to an upgrade. It
864+ was found by pasting the runner line at the development server, whose database
865+ was three days old.
866+
867+ Fixed by putting migration 5 back as it was and adding migration 6, which is
868+ what the mechanism was always for.
869+
870+ Two tests now cover the class:
871+
872+ `TestAnExistingDatabaseUpgradesToEveryLaterVersion` opens a database as an older
873+ forge that knows only migrations 1..n, closes it, reopens with the full list the
874+ way a replaced binary does, and then runs the real query. It does this for every
875+ n, so a column added without a migration fails at whichever version predates it.
876+
877+ `TestMigrationsAreAppendOnly` holds the count. Adding a migration means raising
878+ one number and is meant to be easy; editing an earlier one changes the count in
879+ the other direction and fails with the reason spelled out. Falsified by putting
880+ the original mistake back, which it catches.
881+
882+ ## The runner needed a flag the book says it does not
883+
884+ Appendix E gives `forge runner <token> [--labels a,b]`, chapter 38.2 gives
885+ `forge runner rt_live_7Kq2mXe --labels build,test`, and the runner-setup mockup
886+ gives the same. Forge's own page printed `--server https://...` in the middle of
887+ it, and the binary refused to start without it.
888+
889+ A token that cannot say where it came from forces a second parameter, which is
890+ the interstitial chapter 15 exists to remove.
891+
892+ The runner now records where a token attached, after the attach succeeded so a
893+ wrong address is never the one kept, in `~/.config/forge/servers`. The file maps
894+ a hash of the token to a url and holds no token, at mode 0600. The pasted line
895+ supplies the address the first time and the documented line needs none after.
896+
897+ Measured on the development server: the one-liner attaches, and then
898+ `forge runner <token> --labels build,test` attaches with no address at all.
899+
900+ ## A matrix could show a green thread row over a failed build
901+
902+ The thread list reads the runs for a proposal and stopped at the first one that
903+ named the right ref. That was correct while a proposal had one build. A matrix
904+ gives it several, `run.Recent` returns them newest first, and the newest is not
905+ the one that matters.
906+
907+ So a proposal whose linux build failed at 10:00 and whose mac build passed at
908+ 10:01 said `build ok`. Chapter 19.1 says green is not news; a green that is
909+ hiding a red is worse than not news.
910+
911+ The row now reduces every run for the ref to one status, and any failure is the
912+ status: `1 of 3 builds failed`, in the colour the mockups keep for it. One run
913+ still reads exactly as the mockup does, `build ok · test ok`, because that is
914+ what the build reported and there is nothing to summarise.
915+
916+ The thread page did not have the bug, since chapter 24 puts each build in the
917+ timeline as its own event and it never stopped early. It did drop the job name,
918+ so three builds of one proposal read as three identical rows. They now say which
919+ combination each was.
920+
921+ ## The escape hatch was written and never wired
922+
923+ `internal/webhook` held the deny list, the HMAC signature and the retry, and
924+ nothing in the tree called any of it. A repository could put `[[webhook]]` in
925+ `.barerepo/config` and forge would do nothing with it, without saying so.
926+
927+ Chapter 23.1 calls a webhook the escape hatch, the thing that makes it
928+ acceptable to refuse every integration request forever. An escape hatch that is
929+ present in the source and absent at run time is worse than one that was never
930+ started, because the config file accepts the lines.
931+
932+ **Where it fires from.** The event log, after a cursor, read by the server
933+ process. Both writers of events keep working the way they did: the hook process
934+ records a push and returns, the web process records a comment and answers. A
935+ receiver that takes thirty seconds cannot slow a push, because nothing on the
936+ push path is waiting for it.
937+
938+ A first start puts the cursor at the newest event. A new hook is not a request
939+ for ninety days of history.
940+
941+ **The secret is a name.** `secret_env = "DEPLOY_HOOK_SECRET"` names a value in
942+ the server's environment. A name whose value is not set is a recorded failure
943+ that says which name, not an unsigned delivery to a receiver expecting a
944+ signature. Committing a secret to a public repository stays impossible by
945+ construction, which is the whole reason 23.2 names it rather than holding it.
946+
947+ **Twenty failures in a row stops a hook**, per 23.4, and the config page is the
948+ only place that could report it, so it does: the url, the events it asked for,
949+ and either when it last delivered or the reason it is failing. One delivery
950+ clears the count, or a hook that fails once a week eventually stops for nothing.
951+
952+ Proven on the development server rather than only in tests: a pushed config
953+ naming a hook with an unset secret produced
954+
955+ 1 failure since the last delivery · DEPLOY_HOOK_SECRET is not set on this
956+ server, so nothing would sign the body
957+
958+ ## One dead receiver held up every other repository
959+
960+ The first sender walked one event at a time and each hook in turn, three
961+ attempts with backoff on every one. A receiver that is down costs about six
962+ seconds per event and twenty events before it disables, and nothing else in the
963+ queue moves for those two minutes.
964+
965+ A hook that has already failed now gets one attempt. The three attempts are for
966+ a receiver that is briefly down, and the first failure is the test of that. One
967+ event's hooks go at once, since they are independent of each other.
968+
969+ ## The budget was failing and the fork was most of it
970+
971+ Chapter 25's thresholds are asserted, and two rows had been red: the threads
972+ list at 39ms against 10, and one thread at 12ms.
973+
974+ Measured rather than guessed. Every git process this machine starts costs 6.5 to
975+ 8ms before git does any work, which the file tree page shows exactly: one
976+ `ls-tree`, 6.4ms, and 6.7ms on the clock. The threads list started four
977+ processes.
978+
979+ `git cat-file --batch` is now kept open per repository and pooled, so reading
980+ objects starts nothing. One thread went from 12ms to 400 microseconds.
981+
982+ **I guarded against something that does not happen.** A long-lived reader cannot
983+ see an object that arrives in a new pack, I assumed, so I stat'd every pack
984+ directory and restarted the process when it changed. It was wrong: git rereads
985+ the pack directory every time it answers `missing`, so the kept reader does see
986+ it. The guard is deleted. The test that made me delete it stays, because that
987+ reread is the assumption the entire pool rests on, and a future git that stops
988+ doing it must fail here rather than serve a page with a hole in it.
989+
990+ **And a deadlock that does happen.** The batch wrote every object id before
991+ reading any answer. Past the pipe buffer that is a deadlock: git stops reading
992+ input while it is blocked writing output, and forge stops writing while it is
993+ blocked writing input. Four thousand ids reproduces it. The write moved to its
994+ own goroutine. Both tests were falsified before being kept.
995+
996+ **The threads list itself.** It read every comment blob of every thread to count
997+ replies, and forked `for-each-ref` to find them. Refs are files, which is
998+ chapter 6, so the ref list costs no process now either. A row is cached by the
999+ note commit that wrote it, and a commit is immutable, so the entry never needs
1000+ invalidating. The page reads nothing it has read before.
1001+
1002+ 39ms to 7.4ms. The whole budget passes.
1003+
1004+ ## A url the book hands out, that answered 404
1005+
1006+ Chapter 19.5 lists four feeds, chapter 39.4 tells a reader to paste two of them
1007+ into a feed reader, and appendix C lists all four as routes. Three were served.
1008+ `/<user>/<repo>/threads.atom` fell through the route table to the 404 page.
1009+
1010+ The book prints that url twice, so the failure is not a missing feature, it is a
1011+ promise the running server does not keep. A reader who follows 39.4 gets a feed
1012+ reader with a dead entry in it and no reason given.
1013+
1014+ It now answers, with the same events the threads page shows and nothing else:
1015+ the six thread and proposal kinds from 19.1. A push is not discussion, so the
1016+ repository feed carries it and the threads feed does not. Both are asserted
1017+ against a live server rather than against the query, because the route was the
1018+ part that was missing.
1019+
1020+ A private repository answers 404 on both feeds, since 19.5 says public only and
1021+ existence leaks.
1022+
1023+ **The profile page had the same shape of gap.** `profile.html` puts `atom` in
1024+ the sidebar under the key count, and `/<user>.atom` has worked since the feeds
1025+ went in, but the template never linked it. The link is back. A feed nobody can
1026+ find from the page is a feed that needs the book open next to it.
1027+
1028+ ## The older link was drawn and never given a value
1029+
1030+ `repo-log.html` puts `older` in the footer, and the template has carried
1031+ `{{if .Older}}<a href="{{.Older}}">older</a>{{end}}` since the log was built.
1032+ Nothing ever set `Older`. The condition was false on every page forge has served.
1033+
1034+ So the log showed twenty commits and the twenty-first was unreachable. A
1035+ repository with two hundred commits published a hundred and eighty of them over
1036+ git and none of them over http.
1037+
1038+ The page now starts where `?from=<sha>` says, asks for twenty-one, and links the
1039+ twenty-first as `older`. There is no offset and no cursor to keep, because a
1040+ commit already names its own position in the history. A `from` that is not a
1041+ commit here answers 404 rather than quietly showing the newest page, since a
1042+ stale link that looks like it worked is worse than one that says it did not.
1043+
1044+ ## A tag cost a process, and a hundred tags cost a hundred
1045+
1046+ The releases page called `git notes show` once per tag, inside the loop over
1047+ `for-each-ref`. Measured against chapter 25's ten milliseconds, with a hundred
1048+ tags: **1.785 seconds**, and 29kb over a 15kb payload budget.
1049+
1050+ Three things were wrong and each is worth naming.
1051+
1052+ **The notes.** They are now read the way chapter 16's other note refs are: walk
1053+ the notes tree through the object pool, one level of fanout at a time, skipping
1054+ any subtree that holds no note this page asked for, then read the bodies in one
1055+ batch. No process at all, and only the twenty bodies the page draws.
1056+
1057+ **`for-each-ref`.** Eleven milliseconds on its own for a hundred tags, which is
1058+ the whole budget. Refs are files, per chapter 6, so the tag names and their
1059+ object ids come from the ref files, and the tag objects come from the pool.
1060+
1061+ Batching by ref name still cost 11ms because git resolves each name; batching by
1062+ the object id the ref file already gave costs 7.7ms. Then the sorted list is
1063+ cached under a digest of the ref state, so a page that follows a page with no tag
1064+ pushed between them reads no objects at all. The key is the content, so nothing
1065+ invalidates it.
1066+
1067+ **No end to the list.** Twenty rows, then `older`, the same word in the same
1068+ corner as the log. The releases mockup has two releases and so shows no such
1069+ link; the log mockup does, and one convention for "this list continues" beats
1070+ inventing a second.
1071+
1072+ 1.785s to 7.4ms, 29kb to 6kb. That row of the budget passes.
1073+
1074+ **Still failing, and it was failing before this pass.** The log with diffs takes
1075+ 26ms against 20. Measured at the previous commit, with none of this pass's
1076+ changes and no tags in the repository, it took 24ms. The file tree sits on 10ms
1077+ against 10 and crosses in either direction between runs. Neither is caused by
1078+ anything here, and neither is fixed by anything here.
1079+
1080+ ## The landing page spent nine milliseconds asking whether the repository was empty
1081+
1082+ Chapter 25 gives the log with diffs twenty milliseconds. It took twenty-six, and
1083+ the last pass could not say why. This pass measured the parts instead of reading
1084+ the code, which is the method that worked on the releases page.
1085+
1086+ page 22.9ms
1087+ gitread.Log 12.3ms, of which one fork is 11.5ms
1088+ transport.Open 0.2ms
1089+ readme, config ~0ms
1090+ git --version 8.2ms
1091+
1092+ That last line is the important one. A bare `git --version` costs 8.2ms on this
1093+ machine, so a process is 8ms before git does anything. Two processes is 16ms of
1094+ a 20ms budget.
1095+
1096+ The log page started two. The second was `repo.IsEmpty`, which ran
1097+ `git for-each-ref --count=1` on every repository landing page to answer "does
1098+ this repository have a ref". Refs are files, per chapter 6. `gitx.AnyRef` walks
1099+ `refs/` and stops at the first one, then falls back to `packed-refs`, which is
1100+ where `git gc` moves them. No process.
1101+
1102+ 26ms to 13ms. What remains is the one `git log`, and that one has to be a
1103+ process, because the order of a log is a revision walk and forge is not going to
1104+ reimplement one.
1105+
1106+ ## The file tree ran ls-tree for a listing the object pool already had
1107+
1108+ The same measurement put the file tree on 10.2ms against a 10ms budget, which
1109+ means it failed about half the runs. One `ls-tree --long` fork was the whole of
1110+ it.
1111+
1112+ `--long` is there for blob sizes. Nothing displays them: not the template, not
1113+ the mockup, and `Entry.Size` had one writer and no reader. So the size was the
1114+ only reason to ask git rather than read the tree object, and the size was never
1115+ used.
1116+
1117+ `gitx.TreeRows` reads a raw tree object, keeping the mode, since the mode is the
1118+ only field that says tree or blob. Entry order is git's own tree order, which is
1119+ what ls-tree was printing anyway, so the directories-on-top pass below it still
1120+ sees exactly what it saw.
1121+
1122+ 10.2ms to 0.5ms.
1123+
1124+ ## Rendered diffs are cached now, which the build guide asked for
1125+
1126+ `BUILD.md` says to cache rendered diffs because they are immutable. Forge cached
1127+ the patch text and re-parsed and re-rendered it on every request.
1128+
1129+ The hunk markup was written three times, identically, in `repo-log.html`,
1130+ `repo-commit.html` and `repo-compare.html`. It is one `hunks` block in the layout
1131+ now, and the log renders its commits through it once and keeps the html under the
1132+ commit hash.
1133+
1134+ Worth saying plainly: this was worth 0.7ms of the 10ms I thought it would fix. I
1135+ had assumed the render was the cost and it was not. The measurement above is what
1136+ found the two forks. The cache stays because the guide asks for it by name and
1137+ because it took triplicated markup down to one copy, not because it was the fix.
1138+
1139+ **The whole budget passes**, with margin on every row:
1140+
1141+ file tree 0.5ms 1kb (budget 10ms, 15kb)
1142+ log with diffs 13.1ms 22kb (budget 20ms, 30kb)
1143+ file view with blame 10.3ms 1kb (budget 20ms, 40kb)
1144+ threads list 2.1ms 13kb (budget 10ms, 15kb)
1145+ one thread 0.6ms 1kb (budget 10ms, 15kb)
1146+ releases 7.5ms 6kb (budget 10ms, 15kb)
1147+
1148+ ## Copying was written, and had no way to reach it from the site
1149+
1150+ `repo.Copy` clones with `--shared`, fetches notes and the counter, drops proposal
1151+ refs and installs hooks. `repo.Detach` un-borrows. `repo.Dependents` finds who
1152+ borrows. The delete path already calls Detach before it trashes anything, which
1153+ is the requirement in chapter 21.1 that stops a copy losing its history.
1154+
1155+ `forge copy <src> <dst>` uses all of it. Appendix C lists
1156+ `POST /<user>/<repo>/copy` and nothing answered it. The escape hatch shape again:
1157+ the machinery was complete and one route was missing.
1158+
1159+ **Who gets the button.** Not the owner. Chapter 21.1 exists because chapter 12
1160+ removed the fork, and the thing being restored is taking a project somewhere its
1161+ maintainer will not go. So the control is on the config page for any signed in
1162+ reader who can read the repository. Read access is the whole permission, because
1163+ chapter 12 already removed asking as a step.
1164+
1165+ **Where it goes.** `<you>/<the same name>`, with no field to fill in. The book's
1166+ own example is `forge copy john/johnbot lisa/johnbot`. If you already have a
1167+ repository by that name the page says so and links it, rather than offering a
1168+ button that will fail.
1169+
1170+ **What it says.** The same four sentences the CLI prints, because the terminal
1171+ and the page must not explain the same operation differently: branches, tags,
1172+ history, threads and notes come across, proposal refs do not, there is no link
1173+ back and no badge, and contributing means pushing a proposal.
1174+
1175+ **The plain commands come first**, per chapter 5 rule 2. `git clone --mirror`,
1176+ then a push naming heads, tags and notes, which is exactly the ref set the server
1177+ side copy moves. Forge creates the destination on push, per chapter 11, so the
1178+ plain path needs no visit to `/new`.
1179+
1180+ Proven end to end against a running server: lisa copies john's repository, the
1181+ copy has master and does not have the proposal ref that was pushed to the
1182+ original first, and john deleting the original leaves lisa's history intact. The
1183+ test asserts the original had a proposal ref before the copy, so the assertion
1184+ that none came across cannot pass by accident.
1185+
1186+ ## The last url in appendix C that answered 404
1187+
1188+ `GET /<user>/<repo>/release/<tag>` is in the route table. Chapter 24 has one
1189+ entry for releases and it describes the list. There is no mockup for a single
1190+ release and no paragraph describing one.
1191+
1192+ Building a page the plans do not describe would be inventing product voice, which
1193+ is the mistake that produced a landing page full of made up copy earlier in this
1194+ work. Answering 404 to a url the book prints is the mistake fixed two passes ago.
1195+
1196+ So it opens the list at that tag, using the `?from=` the releases page already
1197+ takes. A release is a tag, a body and some files, and all three are on that row.
1198+ A tag that is not in the repository is a 404, not the newest release wearing the
1199+ wrong name.
1200+
1201+ Every url in appendix C now answers.
1202+
1203+ ## A repository search result printed its own name twice
1204+
1205+ `search.html` gives one thing a box: the matched source line. A code row is the
1206+ kind, the file and line, then the line itself in a box with the match marked.
1207+
1208+ A thread row in the mockup is `johnbot 44 · does this work behind a socks proxy?`
1209+ on one line, with the excerpt underneath in muted text. A repository row is
1210+ `john / johnbot` and its description underneath. Neither has a box.
1211+
1212+ Forge gave every row a box, filled with `Result.Text`. For a thread that put the
1213+ title in a monospace code box. For a repository it put `rock / forge` in a box
1214+ directly under the link that already said `rock / forge`.
1215+
1216+ The box is now the code row's alone. A thread carries its title beside its number
1217+ where the mockup puts it, and a repository carries only its description. The query
1218+ is marked in the heading line as well, since that is now where a thread title
1219+ lives.
1220+
1221+ `TestOnlyACodeSearchResultDrawsABox` renders one of each kind and counts the
1222+ boxes. A `want` list of strings cannot say "and not this".
1223+
1224+ ## The rule about comments was not being checked where comments also live
1225+
1226+ Every comment in this tree is one line. I had been checking `.go` and `.css` with
1227+ an awk one liner and had never looked at `.js`, and the one script in the product
1228+ opened with a two line block.
1229+
1230+ `TestEveryCommentIsOneLine` walks the tree and fails on any run of consecutive
1231+ `//` lines in a `.go`, `.js` or `.css` file, and on a `/* */` that does not close
1232+ on the line it opened. It was falsified before it was kept: a temporary file with
1233+ a two line comment fails it by name and line.
1234+
1235+ ## Search has no index, and chapter 17 opens by asking for one
1236+
1237+ Recorded rather than fixed, because it is the largest thing left and half of it
1238+ would be worse than none.
1239+
1240+ Chapter 17: "One index, one result set". "Index on push, incrementally, from the
1241+ pushed range rather than a full rescan. Index thread comments on note write."
1242+ Appendix E lists `forge doctor --reindex` to rebuild it.
1243+
1244+ There is no index. `readable` opens every repository on the server for every
1245+ query, and `search.Search` then runs `git grep` in each one, plus a thread read.
1246+ Measured on the development server with 29 repositories: **64ms**, against
1247+ chapter 25's ten milliseconds for a page with no diff. It is O(repositories) in
1248+ processes, so it gets worse with exactly the growth a forge wants.
1249+
1250+ What is already right, and must stay right when the index arrives: the read
1251+ filter is applied **before** the search, not after. `readable` builds the target
1252+ set from what the asker may open, so a private match is never ranked and then
1253+ dropped. Chapter 17 calls filtering after ranking a leak of the count of private
1254+ matches, and BUILD.md calls it the highest severity mistake available here.
1255+
1256+ The constraint that decides the design: BUILD.md requires a schema both SQLite
1257+ and PostgreSQL accept, so FTS5 and tsvector are both out. One document table with
1258+ a repository column, filtered in the query, is portable and is one query instead
1259+ of N processes.
1260+
1261+ ## An empty package
1262+
1263+ `internal/web` was an empty directory imported by nothing. Deleted.
1264+
1265+ ## Search has an index now, and the first thing it indexed was the trash
1266+
1267+ Chapter 17 opens with "one index, one result set". Forge had no index. Every
1268+ query opened every repository on the server and ran `git grep` in each one, plus
1269+ a thread read. Measured on the development server with 29 repositories: 64ms,
1270+ against chapter 25's ten for a page with no diff, and O(repositories) in
1271+ processes, so it got worse with exactly the growth a forge wants.
1272+
1273+ **The read filter is the whole design.** Chapter 17 says filter in the query, and
1274+ BUILD.md calls filtering after ranking the highest severity mistake available
1275+ here. So every document carries `public` and a `readers` column holding the
1276+ owner and `[access] push` as `|john|lisa|`, and the query is
1277+
1278+ WHERE (LOWER(body) LIKE ? OR LOWER(title) LIKE ?)
1279+ AND (public = 1 OR readers LIKE ?)
1280+
1281+ The pipes matter: `|john|` never matches inside `|johnson|`. Chapter 18 makes read
1282+ binary, public or the owner or `[access] push`, so the whole rule fits in two
1283+ columns and needs no join.
1284+
1285+ That test was falsified before it was kept. Changing `public = 1` to `1 = 1`
1286+ makes it fail by name, for both an anonymous reader and a signed in stranger.
1287+
1288+ **Why not FTS5 or tsvector.** BUILD.md requires a schema both SQLite and
1289+ PostgreSQL accept, so neither becomes the only one actually tested. Neither full
1290+ text extension is portable. One document table with a LIKE scan is, and a scan of
1291+ one table beats N processes by a wide margin, which is the whole problem being
1292+ solved.
1293+
1294+ **What is indexed.** One row per file at the tip of the default branch, one per
1295+ thread with its comments, one per repository for its name and description. Blobs
1296+ over 512kb and anything holding a NUL byte are skipped: chapter 17 indexes source,
1297+ and one generated file should not become the index.
1298+
1299+ **Incremental, as the chapter asks.** A push takes `git diff --name-only old..new`
1300+ and updates only those paths, deleting the rows for paths the push removed. A
1301+ first push, or a push whose old side is unknown, walks the tree once. Code lives
1302+ at the tip of the default branch, so no other ref changes what a code search
1303+ finds, but every push rewrites the read set and the discussion, because
1304+ visibility arrives in the tree and a proposal push carries notes.
1305+
1306+ **A comment written on the web is a note write and not a push**, so the three
1307+ places httpd writes a note reindex the discussion.
1308+
1309+ **`forge doctor --reindex`** is appendix E's line, and it empties the index first.
1310+ A rebuild that only adds cannot remove a repository that has gone.
1311+
1312+ **Which is how the bug was found.** The first reindex on the development server
1313+ said "indexed 7 repositories" and one of them was
1314+ `trash/1787104067-mark-renamed`. Chapter 44.4 keeps a deleted repository for 30
1315+ days, and the walk was matching every `*.git` directory under the repository root,
1316+ including the ones waiting to be erased. A deleted private repository would have
1317+ had its contents searchable under an account named `trash`.
1318+
1319+ The delete path already dropped its documents, so this was reindex alone. The
1320+ walk skips the trash directory now and the test that proves it was falsified
1321+ first: without the skip it fails with the actual leaked link,
1322+ `trash / 1787130049-john-johnbot / retry.go:3`.
1323+
1324+ **64ms to 7.8ms on the development server.** In the budget, over a thousand
1325+ indexed files, fifty threads and a hundred tags:
1326+
1327+ search 900µs 1kb (budget 10ms, 15kb)
1328+
1329+ Every row of chapter 25 still passes.
1330+
1331+ ## Giving a repository away left the old owner able to search it
1332+
1333+ A transfer moves the directory, the database row and the index rows. It did not
1334+ move the read set. Chapter 18 makes read binary and the owner is half of it, so
1335+ the `readers` column still said `|john|` after john gave the repository to lisa.
1336+
1337+ Two things followed, and both are wrong in opposite directions. Lisa could not
1338+ search the private repository she now owned, which is exactly the failure chapter
1339+ 17 names when it refuses to solve access by indexing only public work. And john
1340+ could still find its contents, in a repository he no longer owned.
1341+
1342+ A rename does not have the problem, because a rename does not change the owner.
1343+ The read set is rewritten from the tree after any move, since a transfer is the
1344+ one change to who may read that no push announces.
1345+
1346+ The test fails on both halves without the fix.
1347+
1348+ ## A query is a string, and LIKE thinks some of it is syntax
1349+
1350+ `%` and `_` are LIKE's wildcards. A search for `100%` was reaching the database as
1351+ `%100%%`, and a search for `read_all` matched `readXall`.
1352+
1353+ They are escaped now, with `ESCAPE '\'`, which both dialects read the same way.
1354+
1355+ The first version of this test did not actually test it. `%100%%` still needs the
1356+ literal `100`, so it matched the one document it should have matched and the test
1357+ passed with the escaping removed. It asks for `c%e` now, a string that appears in
1358+ neither document and which unescaped reaches both through the word "coverage",
1359+ and for `read_all` against a document holding `readXall`. Both halves fail
1360+ without the escaping.
1361+
1362+ Worth writing down as a rule rather than an incident: a test that passes when the
1363+ thing it tests is deleted is not a test. Take the code out and watch it go red.
1364+
1365+ ## One row per file hid the rest of the matches
1366+
1367+ `git grep` returned every matching line, so a symbol used four times in a file was
1368+ four rows. The index holds one row per file, and the first version of the query
1369+ turned that into one row per file on the page as well.
1370+
1371+ That is a loss for the one thing chapter 17 says a forge is usually searched for.
1372+ A file now contributes up to three lines, each with its own number and link, and
1373+ the whole set is still bounded by the page's limit, so a common word cannot fill
1374+ the page from one file.
1375+
1376+ ## The tab strip carried a count that only one page filled in
1377+
1378+ Every mockup draws the tab row as `log · files · threads 3 · runs · config`. The
1379+ number is the point of putting it there: a reader learns there is discussion
1380+ without spending a page load to find out.
1381+
1382+ `openRepoFor` builds that row for every repository page and passed `0`. The
1383+ threads page overwrote it afterwards with the real number, so the count appeared
1384+ on exactly the one page where a reader already had the list in front of them.
1385+
1386+ It is read once in `openRepoFor` now. `thread.OpenCount` goes through the same
1387+ cached row list the threads page uses, so with fifty threads the cost is the file
1388+ tree at 0.5ms to 2ms and the log at 12.9 to 14.5, both well inside chapter 25.
1389+
1390+ The thread page's footer had the same gap. `thread.html` in the mockups says
1391+ `threads · 3 open` and the template said `threads`.
1392+
1393+ ## A profile row was missing the number chapter 24 asks it for
1394+
1395+ Chapter 24, profile: "each with language, size, default branch, and open proposal
1396+ count". `repoMeta` built the first three. The mockup line is
1397+ `go · 4.1mb · master · 3 proposals` and forge drew `go · 4.5mb · master`.
1398+
1399+ An open proposal is an open thread that carries a ref, which is what the threads
1400+ list already means by the word, so the count comes from the same place. A
1401+ repository with none says nothing rather than "0 proposals".
1402+
1403+ ## The bio in profile.html has nowhere to live, and that is correct
1404+
1405+ The mockup puts `writes bots. mostly go.` under the account name. There is no
1406+ such field and there should not be one.
1407+
1408+ Chapter 10's closed list is the complete set of what the server stores outside
1409+ git. It has six items and none of them is a profile. The chapter says outright
1410+ that the list was four items in an earlier draft and grew without being updated,
1411+ and that "a closed list that quietly grows is worse than an open one".
1412+
1413+ A repository description lives in `.barerepo/config`, in the tree, which is why that
1414+ one is drawn. An account has no tree to put a bio in. Left unbuilt on purpose.
1415+
1416+ ## A note to myself about git checkout
1417+
1418+ Falsifying the tab count test meant editing `web.go`, running the test, and
1419+ putting the line back. I put it back with `git checkout internal/httpd/web.go`,
1420+ which threw away every other uncommitted change in that file from the same pass:
1421+ the struct field, the profile count and the `repoMeta` signature. The test went
1422+ red for the wrong reason and I had to write them again from the transcript.
1423+
1424+ Falsify by reversing the exact edit, never by checking the file out.
1425+
1426+ ## The keys page named one credential kind out of four
1427+
1428+ Chapter 24 is explicit about why the closing line exists: "The page names what
1429+ each one can do, because a user about to paste a token into a feed reader
1430+ deserves to know it cannot write."
1431+
1432+ Forge said "a key signs you in and pushes. a git token clones and pushes over
1433+ https." and stopped. The two sentences it dropped are the two the chapter gives a
1434+ reason for. `keys.html` has them both: "a runner token attaches one machine to one
1435+ repository. a feed token reads one feed and can write nothing."
1436+
1437+ All four are named now. The runner sentence also says where a runner token comes
1438+ from, because forge has no `new runner token` button and the mockup does.
1439+
1440+ **That missing button is deliberate.** Chapter 15 puts the token inside the copied
1441+ command, per repository. A button on `/keys` has no repository to scope to, so it
1442+ would have to ask for one, which is the interstitial the whole chapter exists to
1443+ remove. The runners page of a repository is where the token is made.
1444+
1445+ ## A runner token said nothing about the runner
1446+
1447+ Chapter 24 asks a runner token row for "its labels, attached machine, and
1448+ last-seen time". Forge showed the scope and the last-used time.
1449+
1450+ The runners table already carries `token_id`, so the machine that attached with a
1451+ token is one join away. A row now reads
1452+ `runner · rock/forge · m2 · labels build, test · last used 2h`, which is the
1453+ mockup's `john/johnbot · labels build, test · last seen 40m` with the machine
1454+ named as well.
1455+
1456+ ## Runners were never busy and had never run anything
1457+
1458+ Chapter 24, runners: "Attached machines, platform, labels, **run count**, status,
1459+ last seen". The run count was absent and the status had two values where the
1460+ mockup has three.
1461+
1462+ Both come from the jobs table, which already carries `runner_id`, in one grouped
1463+ query: how many jobs each machine has taken, and whether any of them is running
1464+ now. A machine holding a job reads `busy`, which is what `runners.html` shows for
1465+ `lisa-mbp`, and a machine that has run nothing says nothing rather than "0 runs".
1466+
1467+ ## Pages checked this pass and found correct
1468+
1469+ The sweep is worth recording in both directions, or the next pass repeats it.
1470+
1471+ - **inbox** matches `inbox.html` completely, including the horizontal rule at
1472+ `last_visited` from chapter 19.4 and the ninety day line.
1473+ - **commit** correctly has no tab strip, because `repo-commit.html` has none.
1474+ - **compare** matches, including the ref shortcuts under the form.
1475+ - **file tree**, **runs**, **thread**, **releases**, **threads** match.
1476+
1477+ ## Adding a runner had grown the second step chapter 24 forbids
1478+
1479+ Chapter 24 on the add-a-runner page: "Three commands, one per platform, all
1480+ visible at once, each with the token already inside it." Then, in bold: "If this
1481+ page ever grows a second step, something has gone wrong."
1482+
1483+ The page showed one button, `new runner token`. You clicked it and then got the
1484+ commands. That is a second step, on the page the chapter picks out as the
1485+ clearest demonstration of the whole thesis.
1486+
1487+ The token is minted on arrival now. The reason the button existed is real: a
1488+ token is shown once, so a page that mints on every view leaves a dead token per
1489+ view. So a view first revokes this account's runner tokens for this repository
1490+ that are over an hour old and that no machine ever attached with. A reload cannot
1491+ pile them up, and cannot revoke the line the reader copied a moment ago either,
1492+ which is the case the test names.
1493+
1494+ **What I did not build: the auto-refresh.** The mockup's third fact is "this page
1495+ refreshes the moment a runner attaches", and chapter 24 lists it. It cannot be
1496+ done here without breaking something else. A meta refresh on a page that mints a
1497+ token either mints one per tick or revokes the line the reader is in the middle of
1498+ pasting. Forge's page does not claim to refresh, so nothing on it is untrue, and
1499+ the honest fix needs the page to know a runner attached without reloading, which
1500+ is polling, which is javascript this design does not want. Left out on purpose.
1501+
1502+ ## A new repository could not be given a description
1503+
1504+ Chapter 24, new repository: "Shows. Name, description, default branch, visibility,
1505+ create." The form had name, default branch and visibility.
1506+
1507+ The reason it was missing is real: chapter 11 says the server commits nothing, so
1508+ a description has nowhere to go. It lives in `.barerepo/config`, in a tree that does
1509+ not exist yet. Visibility has the same problem and was already solved, by carrying
1510+ the answer to the empty repository page and putting it in the block the reader
1511+ pastes.
1512+
1513+ The description now goes the same way.
1514+
1515+ **The paste block changes shape when there is one.** The mockup writes the config
1516+ with `printf '[repo]\nvisibility = "public"\n'`, and printf reads backslashes, and
1517+ the whole argument is inside single quotes. A description holding a quote or a
1518+ backslash would break the command the reader pastes, silently, on their machine.
1519+
1520+ So a described repository gets a quoted heredoc instead, which passes every
1521+ character through untouched:
1522+
1523+ mkdir -p .forge && cat > .barerepo/config <<'EOF'
1524+ [repo]
1525+ visibility = "public"
1526+ description = "irc bot that refuses to leave"
1527+ EOF
1528+
1529+ A repository with no description keeps the mockup's printf line exactly. The only
1530+ character a quoted heredoc cannot carry is a newline, and a form input cannot hold
1531+ one, but it is stripped anyway rather than trusted.
1532+
1533+ ## The push rejected page was never built, and it is the one that matters most
1534+
1535+ `push-rejected.html` is a mockup, chapter 24 has an entry for it, and BUILD.md
1536+ stage 4 says "this page matters more than it looks, because rejection is where new
1537+ contributors get stuck". There was no template, no route, and no url.
1538+
1539+ The wording had been done. An earlier pass made the hook print the mockup's exact
1540+ sentences so the terminal and the page could not differ. The page they were copied
1541+ from did not exist.
1542+
1543+ Chapter 24 says why it has to: "The hook already printed the reason. The page
1544+ exists because terminals scroll, and because a rejected push is where a new
1545+ contributor decides whether to keep going." And: "The hook prints a URL alongside
1546+ the rejection message. Without that line the page is unreachable, because a
1547+ rejected push happens in a terminal and no browser is involved."
1548+
1549+ **It holds no state.** A rejection is not on chapter 10's closed list and must not
1550+ be, so nothing is written down. The url carries the one fact the server cannot
1551+ recompute, which is the ref you pushed to:
1552+
1553+ http://barerepo.example/john/johnbot/rejected?ref=refs/heads/master
1554+
1555+ Everything else the page reads live: the `[access] push` line out of the tree, and
1556+ who you are out of your session. That means it also stays correct later. Sign in,
1557+ or get added to the list, and the page stops telling you to open a proposal and
1558+ says you may push now.
1559+
1560+ The mockup's `e91b7d · 2m ago` line is not drawn. A time carried in a url is a
1561+ number the reader supplied to themselves, and they already know when they pushed.
1562+
1563+ `repocfg.List` and `repocfg.Who` were moved out of the hook so both the terminal
1564+ and the page render the config line from one function. That was the point of
1565+ copying the wording in the first place.
1566+
1567+ ## A run's footer named the wrong ref, and a rev that is not here drew a page
1568+
1569+ `run.html` puts the triggering ref in the footer, `refs/proposals/46`. Forge put
1570+ the default branch there, which for a proposal build is the one ref the run had
1571+ nothing to do with.
1572+
1573+ Separately, `/john/johnbot/run/8` answered 200 with "nothing has been built at
1574+ this commit". `8` is a valid rev *name* and not a commit in the repository, so the
1575+ page was drawn for something that does not exist. A rev that does not resolve is a
1576+ 404 now. A real commit with no runs still gets the page, because that is a true
1577+ state and a reader may have come looking for it.
1578+
1579+ ## A collapsed diff had no expand link, only a way off the page
1580+
1581+ Chapter 24 on the repository log: "Diffs over a threshold collapse with a size
1582+ label and an expand link." The size label was there. The link said `view commit`
1583+ and went to the commit page.
1584+
1585+ `repo-log.html` reads `3d · large diff collapsed · expand`. Forge read
1586+ `14 files · +302 -288 · view commit`. Both the word and the destination were
1587+ wrong: expand means show it here, and the sha at the front of the row is already
1588+ the link to the commit page.
1589+
1590+ The link is `?expand=<sha>#<short>` now. It reopens the log with that one commit
1591+ open and jumps to it, keeps `?from=` so a reader on the second page stays there,
1592+ and every other row stays collapsed, which is the point of collapsing.
1593+
1594+ The diff itself comes from the cache the log already wrote. **The first version
1595+ read only the cache**, which meant the link silently did nothing whenever the
1596+ cache was cold. The end to end test caught it, because the test harness does not
1597+ set the caches. It falls back to reading the one commit now.
1598+
1599+ ## The releases page crossed its budget again, and the fix was one commit hash
1600+
1601+ Adding the open thread count to every repository page cost about 1.5ms, which put
1602+ releases at 10.8ms against 10. Measured rather than guessed:
1603+
1604+ Releases 2.7ms ReadNotes 4.4ms OpenCount 1.4ms
1605+
1606+ `ReadNotes` was walking the notes tree through the object pool on every view,
1607+ reading about forty objects to draw twenty bodies. Notes live under one ref, and
1608+ that ref is a commit, and a commit is immutable. So the whole object-to-body map
1609+ is read once and cached under the notes commit.
1610+
1611+ The cold pass costs more, because it now reads every note rather than the twenty
1612+ on screen: 11.5ms once, then 1.4ms for every view until somebody pushes a note.
1613+
1614+ Releases 2.6ms ReadNotes 1.4ms OpenCount 1.4ms
1615+
1616+ The releases row is 5.9ms. Every row of chapter 25 passes.
1617+
1618+ The tree walk also lost the filter that kept it to wanted notes, and gained the
1619+ rule it should have had from the start: a tree entry whose accumulated path is a
1620+ full object id is a note, and a shorter one is a fanout directory.
1621+
1622+ ## Every page now has to render the same with no cache at all
1623+
1624+ Last pass the expand link silently did nothing on a cold cache, and the only
1625+ reason that was caught is that the end to end harness happened not to set the
1626+ caches. That is luck, not a test.
1627+
1628+ So the harness sets them now, and every existing test runs the warm path, which
1629+ is the one production takes and the only one where a wrong cached answer can
1630+ appear. Then one test takes them away and fetches thirteen pages twice: the
1631+ profile, the log, the log with a diff expanded, the file tree at two paths, a
1632+ file, a commit, a compare, the threads list, runs, releases, config and a search.
1633+ Every pair must render identically, relative times aside.
1634+
1635+ **An empty cache is not a cold cache.** The first version deleted the cache
1636+ directory, which proved nothing: `cache.Disk` keeps a hot map in memory, and even
1637+ without it the log writes every diff it reads before anything asks for one back.
1638+ The honest test is a cache that retains nothing, which is what a full disk is, so
1639+ the second pass sets them to nil.
1640+
1641+ **And a test needs something to find.** The second version still passed with the
1642+ fix removed, because the repository had two small commits and nothing to collapse,
1643+ so the expand url changed nothing either way. The repository has a seven file
1644+ commit now. With the fallback taken out the test fails by name and by url.
1645+
1646+ That is three versions of one test, two of which proved nothing. Writing the test
1647+ is the easy half.
1648+
1649+ The invariant it holds is the one chapter 10 item 6 states: caches are derived,
1650+ discardable, and rebuildable. If deleting them changes what a reader sees, one of
1651+ them is not a cache.
1652+
1653+ ## Four of the five limits were settings that did nothing
1654+
1655+ `[limits]` in the server config has five keys. One of them, `signup_per_hour_per_ip`,
1656+ is enforced. The other four are values an operator can set and forge never reads:
1657+ `max_blob_mb`, `max_push_mb`, `max_open_proposals`, `artifact_retain_days`.
1658+
1659+ That is worse than not having them. A config key the file accepts and the server
1660+ ignores is a promise the operator has no way to check.
1661+
1662+ **`max_blob_mb` is enforced now**, because chapter 20.2 is the one that cannot
1663+ wait: "Turning LFS off is not enough on its own. Without a limit, a user commits a
1664+ 4 GB video directly into git. That is worse than LFS, because it is in the history
1665+ permanently and every clone pays for it forever."
1666+
1667+ The check runs in pre-receive over the pushed range and not the whole repository,
1668+ which inside a hook is exactly `rev-list --objects <new> --not --all`, since the
1669+ ref has not moved yet so `--all` still holds the old tips. Two processes, and only
1670+ when a limit is set. The ids go through one `cat-file --batch-check`.
1671+
1672+ `rev-list --objects` prints the path beside each blob, which is the whole point:
1673+ chapter 20.2 says "The message must name the file and its size. A rejection that
1674+ says only 'push too large' sends the user hunting." So the rejection reads
1675+
1676+ demo.mov is 2.1mb. the limit is 1mb.
1677+
1678+ large files belong in object storage, with a url or a checksum in the
1679+ repository. the build fetches them.
1680+
1681+ The second half is chapter 20.4, which says to put it in the rejection rather than
1682+ leave the user with a refusal and no direction. Blobs over the limit are sorted
1683+ largest first, and a push with several says how many, so a reader fixes the worst
1684+ one first instead of pushing five more times.
1685+
1686+ The test pushes a two megabyte file against a one megabyte limit and asserts the
1687+ name, the limit, the object storage line, and that the small file in the same
1688+ commit is not blamed. Removing the check fails it.
1689+
1690+ **Still unenforced, and recorded rather than half done:** `max_push_mb`,
1691+ `max_open_proposals` and `artifact_retain_days`. `max_open_proposals` is BUILD.md's
1692+ "rate limit proposal refs per key per repo, rule 5 is an open door", and it belongs
1693+ with the other trap on the same list, expiring unreferenced proposal refs.
1694+
1695+ ## The open door had no doorstop
1696+
1697+ Rule 5 says anyone authenticated can propose. Chapter 27 opens with "Rule 5 is an
1698+ open door and must be defended without closing it", and names the defense: "Cap
1699+ open proposals per account per repository at a small number, ten is plenty."
1700+
1701+ Appendix D's pre-receive pseudocode has the check on the line after the one forge
1702+ already had:
1703+
1704+ if not may_propose(user, config): reject("...")
1705+ if open_proposals(user, repo) >= limits.max_open_proposals: reject("...")
1706+
1707+ Forge had the first and not the second. `max_open_proposals` was one of the four
1708+ `[limits]` keys nothing read.
1709+
1710+ An open proposal is an open thread that carries a ref, which is what the threads
1711+ list already means by the word, so the count comes from the same place. Three
1712+ things the test pins down, because each is a way to get this wrong:
1713+
1714+ - **Per account.** Mark at the cap does not stop john proposing.
1715+ - **Only on opening.** A force-push to `refs/proposals/2` is revising something
1716+ already open, not opening another, and chapter 12 makes that the normal way to
1717+ update a proposal. Capping it would break the mechanism it is protecting.
1718+ - **The message says what to do.** "you have 2 proposals open on john/johnbot. the
1719+ limit is 2. land or close one, then push this again."
1720+
1721+ Removing the check fails the test.
1722+
1723+ ## Still open on the same list
1724+
1725+ Chapter 26 names two more, both about a repository growing without bound, and both
1726+ are config keys that already exist and do nothing:
1727+
1728+ - **Proposal refs.** "Expire proposals with no activity for `[proposals]
1729+ expire_days`, default 180. Delete the ref, retain the thread. The thread is
1730+ small; the ref pins commits." `repocfg.Proposals.ExpireDays` is parsed and
1731+ defaulted and never read.
1732+ - **Proposal revisions.** "Keep the most recent five and the ones with anchored
1733+ comments. Delete the rest on the same expiry schedule."
1734+
1735+ They belong together, in `Server.Sweep`, which already runs on a schedule and
1736+ already drops expired tokens and old events. The anchored comment rule is the
1737+ part that needs care: chapter 43.4 keeps a comment's line visible after the code
1738+ moves, and it does that through the blob hash recorded beside it, so a revision
1739+ holding one of those blobs cannot be deleted.
1740+
1741+ ## A proposal ref pins commits forever, and expire_days did nothing
1742+
1743+ Chapter 26 names three places a repository grows that other forges do not have.
1744+ The first: "Proposal refs. Anyone may create them, so they accumulate. Expire
1745+ proposals with no activity for `[proposals] expire_days`, default 180. Delete the
1746+ ref, retain the thread. The thread is small; the ref pins commits."
1747+
1748+ `repocfg.Proposals.ExpireDays` was parsed, defaulted to 180, and never read.
1749+
1750+ The sweep already runs on a schedule and already drops expired tokens, old events
1751+ and trash past its window, so this went beside them. Per repository, per proposal
1752+ ref: the thread's own last activity decides, since a thread and its proposal are
1753+ one object and the thread is where activity lands. A ref with no thread behind it
1754+ is dated by the commit it points at, read through the object pool rather than a
1755+ process.
1756+
1757+ **A window of zero is expiry switched off, not expiry of everything.** That is
1758+ the kind of default that deletes a whole forge on a config typo, so the test says
1759+ it out loud.
1760+
1761+ **The commits are not gone**, and the test asserts that too. `update-ref -d`
1762+ unpins them and `git gc` collects them later, which is the same order chapter 26
1763+ puts them in: "Run `git gc` per repository on a schedule, not on push. Repack
1764+ after bulk ref deletion, or the pack files retain everything you just deleted."
1765+
1766+ `repo.Walk` came out of this. The reindex command had its own copy of the walk
1767+ that skips the trash directory, and that copy is where the bug two passes ago
1768+ lived, so there is one of them now and both callers use it.
1769+
1770+ **Still open, the other two thirds of chapter 26.** Revisions retain the old tip
1771+ on every force-push under `refs/revisions/<n>/<k>`, and the rule is to keep the
1772+ most recent five and the ones with anchored comments. The retention half is easy;
1773+ the anchored half is not, because chapter 43.4 keeps a comment's line visible
1774+ through the blob hash recorded beside it, so a revision that holds the only copy
1775+ of an anchored blob cannot be deleted without breaking a comment that is still on
1776+ the page. `git gc` per repository on a schedule is also not run.
1777+
1778+ ## The field that says which revision a comment belongs to was never filled in
1779+
1780+ Chapter 26's second growth point: "Proposal revisions. Each force-push retains the
1781+ old tip under `refs/revisions/<n>/<k>`. Keep the most recent five and the ones with
1782+ anchored comments."
1783+
1784+ The first half is arithmetic. The second half needs to know which revision a
1785+ comment is anchored to, and `thread.Comment` has had a `Revision` field, written
1786+ into the note format and parsed back out of it, since threads were built. Nothing
1787+ ever set it. Every comment in every repository says revision 0.
1788+
1789+ So the retention rule had no input, which is presumably why the pruning was never
1790+ written.
1791+
1792+ **A comment now records the revision it was written against.** That number is the
1793+ one the content on screen will take when the next force-push retains it, which is
1794+ the count of existing revision refs plus one. `proposal.CurrentRevision` reads it
1795+ from the ref files, so a comment costs no process to number.
1796+
1797+ `nextRevision` in the hook was forking `for-each-ref` for the same count. It calls
1798+ `CurrentRevision` too now, so a proposal update starts one process fewer.
1799+
1800+ **A comment that will not say pins everything.** Every comment written before this
1801+ pass says revision 0, and chapter 43.4 keeps a comment's line visible through the
1802+ blob recorded beside it, so deleting the revision that holds that blob breaks a
1803+ comment still on the page. When an anchored comment cannot name its revision,
1804+ nothing is pruned for that proposal at all. It is the conservative answer and it
1805+ un-sticks itself as comments are written.
1806+
1807+ **An expired proposal keeps none.** The sweep prunes with a keep of five normally
1808+ and zero for a proposal whose ref it just expired, because the reason the ref went
1809+ is the reason the revisions should go with it. Anchored comments still hold what
1810+ they need, since the thread outlives both.
1811+
1812+ Eight revisions, five newest kept, one comment anchored to revision two: one and
1813+ three are deleted and two survives. Removing the anchored check prunes it and the
1814+ test says so by number.
1815+
1816+ ## The last third of chapter 26, and the sentence that made it urgent
1817+
1818+ "Run `git gc` per repository on a schedule, not on push. Repack after bulk ref
1819+ deletion, or the pack files retain everything you just deleted."
1820+
1821+ The second sentence is about what the last two passes built. Expiring a proposal
1822+ ref and pruning retained revisions frees nothing on their own: the objects stay in
1823+ the pack, so a repository that grew without bound still grows without bound, only
1824+ with a shorter ref list.
1825+
1826+ The sweep collects each repository now, and which form it runs is decided by
1827+ whether it deleted anything there:
1828+
1829+ - **It deleted refs**: a full `git gc`, which repacks. That is chapter 26's
1830+ "repack after bulk ref deletion", run the moment the deletion happens rather
1831+ than left to a threshold that may never trip.
1832+ - **It deleted nothing**: `git gc --auto`, which is git's own scheduled form. It
1833+ costs one process and returns immediately unless git thinks there is work.
1834+
1835+ **No `--prune=now`, deliberately.** Git's default two week grace exists because a
1836+ push in flight writes objects before it writes the ref that reaches them, and
1837+ pruning aggressively deletes them out from under it. So a proposal ref expired an
1838+ hour ago is unpinned now and collected a fortnight later, which is the same order
1839+ chapter 26 states and the reason the expiry test asserts the commit is still
1840+ readable straight afterwards.
1841+
1842+ The test packs a loose repository, checks a pack file appears where there was
1843+ none, and checks the commit a ref still reaches survived it. A repack that loses
1844+ reachable objects is the one way this can be badly wrong, so it is asserted rather
1845+ than assumed. Taking the gc out fails it.
1846+
1847+ **Chapter 26 is complete.** All three growth points it names are handled: proposal
1848+ refs expire, revisions are pruned to five plus the anchored ones, and note trees
1849+ are left alone on purpose, because 26 says the durability of discussion is the
1850+ product.
1851+
1852+ ## The whole push has a limit too, and it costs nothing extra to check
1853+
1854+ `max_push_mb` was the third of the four `[limits]` keys nothing read. Chapter
1855+ 20.2 sets it beside `max_blob_mb`, and `config.go` already carried the reason it
1856+ is loose: "the push most likely to hit it is somebody's first import."
1857+
1858+ Both limits ask about the same objects, so they are one pass of git. The pre
1859+ receive check reads the pushed range once, sums every new object, and picks the
1860+ largest blobs out of the same output. Two processes for both limits, and none at
1861+ all when neither is set.
1862+
1863+ The two rejections are deliberately different, because the fixes are:
1864+
1865+ demo.mov is 2.1mb. the limit is 1mb.
1866+ large files belong in object storage...
1867+
1868+ this push adds 1.4mb of objects. the limit is 1mb.
1869+ push it in parts, or ask whoever runs this forge to raise [limits] max_push_mb.
1870+
1871+ One is the user's mistake and chapter 20.4 says where the file belongs. The other
1872+ may be a perfectly good repository meeting a policy, so it names the setting.
1873+
1874+ **It says "adds ... of objects", not "is".** The number is the uncompressed size
1875+ of what the push adds, which is what forge can measure at pre-receive and what
1876+ decides how much disk it takes. Saying "this push is 1.4mb" of a transfer that
1877+ was four hundred kilobytes on the wire would be a number that does not match
1878+ anything the user can see.
1879+
1880+ The test pushes three files, each under the blob limit and together over the push
1881+ limit, and asserts the rejection does not name a file, because naming one would
1882+ mean the wrong limit fired.
1883+
1884+ ## What is left of the four
1885+
1886+ `artifact_retain_days` is the last one, and it cannot be enforced because there
1887+ is nothing to retain. `[paths] artifacts` is configured and the directory is
1888+ created, and no code writes to it or reads from it. Chapter 22 is unbuilt: a
1889+ release has a body in `refs/notes/releases`, which works, and attached files,
1890+ which do not exist.
1891+
1892+ That is the honest state. Chapter 22.4's rule is already written down for when
1893+ they do: build artifacts expire, release artifacts do not, because a published
1894+ download that disappears breaks other people's installers.
1895+
1896+ ## Release artifacts exist now, and chapter 22 is built
1897+
1898+ `[paths] artifacts` was configured, the directory was created at init, and no code
1899+ ever wrote to it or read from it. A release had a body and no files.
1900+
1901+ **The upload is chapter 22.5 exactly.** "A run can attach its output to a release.
1902+ The job token from chapter 15 carries the permission, scoped to one repository and
1903+ one job." `POST /runner/artifact` takes that token, and it is the job's own token
1904+ and not the runner's long-lived one, which is the difference that makes "one job"
1905+ true. The poll already issued it and labelled it `job <id>`; that label is what
1906+ binds the token to the job, and the test proves a plain git token for the same
1907+ repository is refused.
1908+
1909+ The tag has to be a tag in the repository. Without that check an upload creates a
1910+ directory nobody can ever reach, which is a disk leak with no page to show it.
1911+
1912+ **The build gets what it needs to speak the protocol.** `BAREREPO_URL`, `BAREREPO_REPO`,
1913+ `BAREREPO_JOB` and `BAREREPO_JOB_TOKEN` are in the environment of `[build] command`, so a
1914+ build attaches a file with curl and forge invents no new syntax to describe
1915+ artifacts. The runner setup page lists the endpoint beside the other four and
1916+ shows the command, per chapter 24's rule that the page says what the protocol is
1917+ so anyone can write their own runner.
1918+
1919+ **The write is a rename.** A half finished upload is a dotfile ending in `.part`,
1920+ and `List` skips it, so a reader never sees a truncated binary. A rerun replaces a
1921+ file rather than appending to a list.
1922+
1923+ **The download is an attachment and never a page.** `application/octet-stream`,
1924+ `Content-Disposition: attachment`, `nosniff` and a sandbox policy, which is what
1925+ chapter 42.3 asks of any bytes a stranger uploaded. Read access is checked, so a
1926+ private repository's binaries are not public.
1927+
1928+ **A deleted repository takes its files.** They are not in git, so nothing else
1929+ would have.
1930+
1931+ ## artifact_retain_days stays a setting that does nothing, correctly
1932+
1933+ Chapter 22.4: "Build artifacts expire. Release artifacts do not, because a
1934+ published download that disappears breaks other people's installers."
1935+
1936+ Everything built here is a release artifact, so nothing expires and the key has
1937+ nothing to act on. Build artifacts, the kind that expire, are files a run keeps
1938+ without attaching them to a release, and no mockup shows them: chapter 24's run
1939+ detail entry is "status, exit code, what triggered it, and the complete log as
1940+ plain text", with no artifact row. Inventing that surface to give the key a job
1941+ would be building a page the plans do not describe.
1942+
1943+ So it stays unused on purpose, and this is the note that says why rather than
1944+ leaving the next reader to find a dead key and guess.
1945+
1946+ ## The same bug as last time, in the new feature
1947+
1948+ Auditing the artifact work found the mistake the search index made two passes ago,
1949+ in the same shape: **what changes this state without going through the path I
1950+ built?**
1951+
1952+ Attached files live at `<artifacts>/<owner>/<name>/<tag>/`. A rename changes the
1953+ name and a transfer changes the owner, and `serveRepoMove` moved the directory,
1954+ the database row and the search index, and left every attached file behind. The
1955+ releases page would show none, and the bytes would sit on disk with no page to
1956+ reach them and no delete to collect them, because a delete only removes the path
1957+ the repository has now.
1958+
1959+ `artifact.Move` runs beside `MoveDocs` now. The test renames, checks the file is
1960+ still listed and still downloads, then transfers to another account and checks
1961+ again, because the two halves of the path move separately.
1962+
1963+ **A copy does not take them, and that is right.** Chapter 21.1 lists what copying
1964+ carries: branches, tags, all history, threads and notes. Attached binaries are not
1965+ on that list, and 22.3 already says a mirror does not take them either.
1966+
1967+ ## Two uploads of one name could write into each other
1968+
1969+ `Put` wrote to `.<name>.part` and renamed. Two jobs attaching the same file name
1970+ to the same tag at the same time share that path, so one truncates the other and
1971+ the rename publishes a mixture. `os.CreateTemp` gives each upload its own part
1972+ file now.
1973+
1974+ ## The releases page did twenty directory reads to draw nothing
1975+
1976+ Listing files per release meant a `ReadDir` per row, twenty of them, on
1977+ directories that do not exist for a repository with nothing attached, which is
1978+ almost all of them. The row sat between 5.9 and 12.2ms against a 10ms budget and
1979+ crossed depending on what else the machine was doing.
1980+
1981+ The whole blob store for a repository is read in one pass now: one `ReadDir` of
1982+ the repository's directory, and one more only for a tag that actually has files.
1983+ A repository with nothing attached costs one failed stat.
1984+
1985+ That row has been near its limit for several passes and every small addition
1986+ tipped it. This is the difference between nudging it under and giving it room.
1987+
1988+ ## A rename detached every runner, and six other things
1989+
1990+ The checklist from the last pass turned into a test, and the test found six more
1991+ holes in the same wall.
1992+
1993+ `DB.Move` updated two tables, `repos` and `redirects`. Seven others key on the
1994+ repository path and none of them moved:
1995+
1996+ - **tokens.scope.** A runner token is scoped to `john/johnbot`. After a rename the
1997+ scope still says the old path and every job is queued under the new one, so the
1998+ check in `jobRunner` never matches again. The machine polls forever and builds
1999+ nothing, and the runners page is empty. Renaming a repository silently detached
2000+ every runner attached to it.
2001+ - **runners.repo**, so the page could not list them either.
2002+ - **jobs.repo**, so anything already queued was orphaned.
2003+ - **events.repo** and **participation.repo**, so a transfer left the inbox
2004+ entries with the old owner and gave the new one nothing.
2005+ - **webhooks.repo**, so a failing hook's count reset to zero and the config page
2006+ said it had never delivered.
2007+ - **search_docs.repo**, which was moved separately by the handler, one more place
2008+ to forget.
2009+
2010+ All seven move inside the same transaction now, and the handler's separate call
2011+ is gone. The test writes one row into every table, renames and transfers in one
2012+ move, and asserts each one followed. Before the fix it fails seven times.
2013+
2014+ ## The budget was measuring a repository forge does not keep
2015+
2016+ The releases row had been flaking between 5.9ms and 12.2ms against its ten, and
2017+ the last pass's fix was not the reason it passed.
2018+
2019+ The cause was reading a hundred loose tag ref files. Under any disk load that
2020+ doubles. The search row, one SQLite query, barely moved in the same runs, which
2021+ is what said it was I/O and not the machine.
2022+
2023+ Forge gcs every repository in the sweep now, and gc packs refs. So a repository
2024+ forge has been hosting for an hour reads its refs out of one file, and the
2025+ benchmark was measuring one that had never been swept.
2026+
2027+ The test packs refs before measuring, because that is the state forge maintains,
2028+ and the whole table changed:
2029+
2030+ file tree 0.6ms was 1.9
2031+ threads list 1.0ms was 3.5
2032+ one thread 0.7ms was 1.9
2033+ releases 0.9ms was 5.9
2034+
2035+ Nothing is near its limit now. The honest caveat: a repository between a large tag
2036+ push and the next sweep does have loose refs and is slower, for up to an hour.
2037+ That is a real state and it is bounded by the sweep, which is the reason the sweep
2038+ exists.
2039+
2040+ Worth noticing that garbage collection turned out to be load bearing for page
2041+ speed and not only for disk.
2042+
2043+ ## The delete had the same six holes as the rename
2044+
2045+ The move test made the shape obvious, so the same test was written for the other
2046+ end of a repository's life. `DB.Forget` dropped one row, the ownership row in
2047+ `repos`, and left six tables pointing at a repository that no longer exists.
2048+
2049+ What that looks like to a user:
2050+
2051+ - **The keys page lists a runner token for a repository that is gone.** Its detail
2052+ line names `john/johnbot`, which 404s. There is no way to tell from the page
2053+ that the token is now worthless.
2054+ - **A machine stays attached** to a repository with no page, polling forever.
2055+ - **A queued job** waits for a build that can never run.
2056+ - **The inbox keeps its lines**, each linking to a 404, which is the one thing
2057+ chapter 24's own rule about names says not to do.
2058+ - **A failing webhook's counter** survives, so a repository created later with the
2059+ same name inherits somebody else's failure count. That one is not just untidy,
2060+ it is wrong.
2061+ - **Search still finds it.** The handler was dropping the index separately, so this
2062+ one was covered by accident rather than by the store.
2063+
2064+ All seven deletes are one transaction in `Forget` now, and the handler's separate
2065+ index call is gone, the same consolidation the move got. `ForgetDocs` and
2066+ `MoveDocs` are both deleted: a caller that has to remember a second call is a
2067+ caller that will forget it.
2068+
2069+ **A delete is safe to be this total because there is no restore.** Chapter 44.4
2070+ keeps the git data in trash for thirty days, and that is the recovery path. None
2071+ of these rows are recoverable state: a token is a secret nobody can read back, a
2072+ runner reattaches with one line, a queued job reruns on the next push.
2073+
2074+ The test is the delete half of the move test, sharing the fixture that fills every
2075+ table. Both fail loudly when the code is taken out.
2076+
2077+ ## The old name was freed by the one door that does not go through the transport
2078+
2079+ Chapter 21.2 states it in bold: "**The old name is never freed.** This contradicts
2080+ the instinct to recycle unused names, and it is deliberate."
2081+
2082+ A push to a renamed repository already followed the redirect, because the
2083+ transport looks the redirect up before it considers creating anything. The form at
2084+ `/new` does not go through the transport. It called `repo.Create` directly, and
2085+ `repo.Create` only asks whether a directory is there. The directory moved, so the
2086+ name looked free.
2087+
2088+ Take `john/johnbot` to `john/ircbot`, then make `john/johnbot` again on the form,
2089+ and there are now two truths: a real repository at the old path, and a redirect
2090+ row saying that path is somewhere else. Everything that points at the old name is
2091+ wrong, and chapter 21.2's reason for the rule is the worse half of it, that the
2092+ new repository inherits every mention of the old one.
2093+
2094+ **And `repo.InTrash` had never been called.** Its own comment says "reports a name
2095+ still in its window, where a push fails rather than creates. Appendix D." Nothing
2096+ called it, from either door. So a repository deleted a minute ago handed its name
2097+ straight back out while thirty days of its data sat in the trash under that name.
2098+
2099+ `transport.Claimed` answers both questions in one place and says which it is:
2100+
2101+ john/johnbot is now john/ircbot. the old name is kept forever, so nothing
2102+ that points at it breaks.
2103+
2104+ john/johnbot was deleted. its name is held for 30 days, then it is free.
2105+
2106+ Both doors ask it now, `mayCreate` for a push and `serveNewRepo` for the form.
2107+ The test takes both doors for both cases, and fails on both without it.
2108+
2109+ **The shape, again.** Two ways in, one of them checked. It is the same mistake as
2110+ the delete that dropped one row of seven and the rename that moved two tables of
2111+ nine. The question that keeps finding it: what is the other way this happens?
2112+
2113+ ## A reply pushed from a clone told nobody
2114+
2115+ The two doors again, and this time the one that was silent is the one the whole
2116+ design is about.
2117+
2118+ Chapter 3's claim is that discussion is git notes in your clone. Chapter 19.2 says
2119+ participation is subscription: "Anything in a thread you opened or replied to.
2120+ There is no watch button and no subscribe button."
2121+
2122+ `recordPushes` skipped `refs/notes/` entirely. So a reply written on the web
2123+ recorded `thread.replied` and subscribed its author, and the same reply pushed
2124+ from a clone recorded nothing and subscribed nobody. The owner of the repository
2125+ never heard it. The person who wrote it never heard the answer.
2126+
2127+ Every event kind chapter 19.1 lists for threads, `thread.opened`,
2128+ `thread.replied` and `thread.closed`, could only ever be produced by the web.
2129+ The door the book calls the point produced none of them.
2130+
2131+ Post-receive reads the thread's meta at the old commit and at the new one, which
2132+ is two pooled object reads and no process, and decides from the pair:
2133+
2134+ - no meta before it, so the ref is new: **opened**
2135+ - open before and not open after: **closed**
2136+ - otherwise: **replied**
2137+
2138+ Reading both sides is what stops a closed thread reporting itself closed again on
2139+ every later push.
2140+
2141+ The test opens a thread on the web and replies to it by pushing a note, then reads
2142+ two inboxes: the owner's, which must have the reply, and the replier's, which must
2143+ now carry the thread he joined by pushing to it. Without the fix both are empty.
2144+
2145+ ## A failed build told nobody either
2146+
2147+ Chapter 19.1 lists nine event kinds. Eight of them had a writer. `run.failed` had
2148+ a constant, a line in `eventLine` to render it, and nothing anywhere that recorded
2149+ one.
2150+
2151+ The chapter is not ambiguous about whether it should exist. It names
2152+ `run.failed` in the list and then says, on the next line, "`run.succeeded` is not
2153+ an event. A green build is not news." The whole sentence is there to draw the line
2154+ on one side of which a red build sits.
2155+
2156+ And chapter 19 opens by saying why any of this exists: "a proposal arrives and the
2157+ owner finds out by chance. A forge nobody hears from is broken."
2158+
2159+ So `serveRunnerDone` records one when the exit code is not zero, and records
2160+ nothing when it is zero, which the second test asserts, because a feed that
2161+ reports success is a feed people stop reading.
2162+
2163+ **The number is what makes it reach the right person.** The event carries the
2164+ proposal number when the ref is a proposal ref, so it lands in the inbox of
2165+ whoever opened that proposal, per chapter 19.2's "anything on a proposal you
2166+ opened". Without it a failure would only reach the repository's owner, and the
2167+ person whose change broke would be the last to know.
2168+
2169+ The actor is the machine that ran it. A build has no human author, and the runner
2170+ name is the true answer to who is reporting.
2171+
2172+ ## The sweep that found it
2173+
2174+ Listing every event kind against the code that writes it took one command and
2175+ found the one gap:
2176+
2177+ for k in ProposalOpened ... ; do grep -rn "Kind: *store.$k" ...; done
2178+
2179+ Two passes ago the same shape found the thread events, which only the web
2180+ produced. It is worth doing for any set the book enumerates: the book lists nine,
2181+ the code should write nine, and anything with a name and no writer is a promise
2182+ nobody keeps.
2183+
2184+ ## Chapter 42, swept section by section
2185+
2186+ "Everything in this chapter is a security requirement. None of it is optional."
2187+ So each section was checked against the code rather than assumed.
2188+
2189+ **42.1 markdown** and **42.4 file rendering** are done and were already right: an
2190+ allowlist of elements and attributes, `on*` and `style` stripped explicitly,
2191+ script and its siblings dropped with their contents, a null byte in the first 8000
2192+ bytes marks a file binary, a megabyte caps rendering, and both cases draw a notice
2193+ and a download link.
2194+
2195+ **42.3 file content** and **42.6 ref names** likewise: attachment headers with
2196+ nosniff and a sandbox policy, `ValidRef` before any ref reaches an argument, and
2197+ argument arrays everywhere so a ref named `--upload-pack=evil` is a name.
2198+
2199+ Two sections were not done.
2200+
2201+ ## A blocked image was blocked silently
2202+
2203+ 42.2 ends: "Proxy remote images through the server or block them. A remote image
2204+ in a comment leaks the reader's IP address to whoever posted it. Blocking is
2205+ simpler and honest; **say so in the UI**."
2206+
2207+ Forge blocked it and said nothing. The source attribute was dropped and the `<img>`
2208+ was written anyway, so the reader got a broken image icon and no reason. Worse,
2209+ the comment above the code read "it is blocked and the page says so", which was
2210+ not true, and a comment that describes behaviour the code does not have is worse
2211+ than no comment.
2212+
2213+ A blocked image is now replaced, not emptied:
2214+
2215+ remote image blocked, it would tell its host who read this
2216+
2217+ The reason is in the sentence because the reader is the person it protects, and a
2218+ notice that only says "blocked" reads like a bug in forge rather than a choice
2219+ made for them.
2220+
2221+ ## The test chapter 42.5 asks for by name did not exist
2222+
2223+ 42.5: "Derive this list from the route table in code rather than copying it. A
2224+ route added without a matching reservation is a route an account can shadow, and
2225+ that is a bug **the test suite should catch** rather than a list a person must
2226+ remember."
2227+
2228+ `names.go` said, in a comment, that `TestReservedCoversRoutes` kept the list in
2229+ step. There is no such test and there never was. A comment naming a test that does
2230+ not exist is the same failure as the image comment on the same day.
2231+
2232+ The test now parses `web.go`, finds every comparison against `r.URL.Path`, takes
2233+ the first path segment of each literal, and requires it to be reserved. That is
2234+ derived from the route table, because the switch is the route table.
2235+
2236+ It found `/signout` unreserved on its first run. An account named `signout` could
2237+ be registered, and `GET /signout` would then draw that account's profile while
2238+ `POST /signout` ended your session. Reserved now.
2239+
2240+ Two other things the sweep is worth repeating for: it reads the switch, so a route
2241+ added tomorrow is checked tomorrow, and it fails loudly if it finds fewer than
2242+ eight routes, which is how it says it has stopped reading the right thing.
2243+
2244+ ## A webhook could ask for an event that does not exist
2245+
2246+ Chapter 23.1 calls a webhook the escape hatch, "the thing that makes it acceptable
2247+ to refuse every integration request forever". An earlier pass wrote down what that
2248+ means: an escape hatch present in the source and absent at run time is worse than
2249+ one never started, because the config file accepts the lines.
2250+
2251+ A misspelt event name is the same failure in a smaller box. Write
2252+
2253+ events = ["push", "proposal.open"]
2254+
2255+ and the file parses, the hook is stored, and the second name never matches
2256+ anything. The config page said "delivered 2m ago" because the first name worked,
2257+ and nothing anywhere said the second one was a typo.
2258+
2259+ `run.succeeded` is the sharp case. It is a name a person will reach for, and
2260+ chapter 19.1 mentions it exactly once, to say it is not an event. A hook asking
2261+ for green builds waits forever and looks healthy while it does.
2262+
2263+ The config page names them now, and it is the same page that already reports a
2264+ failing hook, because it is the only report a hook has:
2265+
2266+ run.succeeded is not an event forge sends, so it never fires
2267+
2268+ Chapter 19.1's nine kinds are a list in the store now, with the check that reads
2269+ it, and a test asserts every kind in the list is known by the check. A list and a
2270+ lookup that can disagree is a bug waiting for a tenth event.
2271+
2272+ **Why report rather than reject the push.** A pre-receive rejection over a typo in
2273+ a hook would refuse code because of a line about notifications, and chapter 14 is
2274+ clear that a bad config file must not lock anyone out. The page is where a
2275+ webhook's state already lives.
2276+
2277+ ## The env block was the one place a skipped step was silent
2278+
2279+ Chapter 15A rests on one rule, and states it in bold: "a skipped step is never
2280+ silent. A build that reports success while having quietly run half of what was
2281+ asked is worse than no build at all."
2282+
2283+ Every decline the chapter lists was implemented and reported: a step using an
2284+ action forge does not run, a step or job behind an `if:`, a shell forge cannot
2285+ start, an expression outside the substitution list. Both enumerated lists were
2286+ complete too, the twelve environment variables GitHub sets and the eleven
2287+ expressions forge fills.
2288+
2289+ `env:` was the hole. It was written before any of that and never revisited.
2290+
2291+ **An expression in an env value was neither filled nor reported.** The chapter
2292+ explains why substitution exists at all: "`${{` reaches `sh` as a bad substitution
2293+ and fails the step outright." In a `run:` line that is true and loud. In an
2294+ `env:` value it is not, because the value is shell quoted, so
2295+
2296+ env:
2297+ TOKEN: ${{ secrets.NPM_TOKEN }}
2298+
2299+ exported `TOKEN` holding the literal text `${{ secrets.NPM_TOKEN }}` and the
2300+ build carried on. Nothing failed and nothing was said. `secrets` is the case
2301+ chapter 15A names first among the expressions forge declines, and it is where
2302+ every real workflow puts one.
2303+
2304+ **A value that is not a scalar was exported empty.** `scalar` returns "" for a
2305+ list or a map, so an env block forge could not read became `export LIST=''`. An
2306+ empty string is a value, and exporting one is a claim about what the workflow
2307+ asked for. It is left unset and named now.
2308+
2309+ Both go through the same substitute-and-report path a `run:` step uses, so the
2310+ three env scopes, workflow, job and step, all report against their own name.
2311+
2312+ **Why this one hid.** Every decline the chapter enumerates was there, so a sweep
2313+ of the list came back clean. The hole was not a missing item on the list, it was
2314+ one code path that never asked the list anything.
2315+
2316+ ## The paste block told a private repository to make itself public
2317+
2318+ My own bug, from the pass that added a description to the new repository form.
2319+
2320+ The empty page's block already had a `Public` flag, and I reused it to choose the
2321+ visibility line in the new heredoc. That flag does not mean what its name says. It
2322+ is `res.Config.Public() && !wantsPublic`, which exists to decide whether to offer
2323+ the make-it-public line at all, and for an empty repository it is always false,
2324+ because an empty repository has no commits and therefore no `.barerepo/config` to be
2325+ public in.
2326+
2327+ So the heredoc always wrote `visibility = "public"`. Choose private on the form,
2328+ give it a description, and the block forge hands you publishes it. Chapter 11 is
2329+ the reason that is the worst possible direction for the mistake: "accidentally
2330+ publishing code is not recoverable, and accidentally hiding it is one line in a
2331+ file."
2332+
2333+ The page carries the word the form was told now, rather than inferring it from a
2334+ flag that means something else.
2335+
2336+ **The test suite could not have caught it, and now can.** `TestTemplatesRender`
2337+ gives each template one set of data, so a branch that data does not reach is never
2338+ rendered. The `repo-empty` case has no description, so the heredoc arm was never
2339+ executed and the missing `Visibility` field never errored. A second end to end
2340+ case covers the described private repository, which is the arm that was wrong.
2341+
2342+ Worth writing down as a shape: a template with a branch has states, and rendering
2343+ one state is not rendering the template.
2344+
2345+ ## The sweep that found it
2346+
2347+ Every command forge prints in a `<pre class="box">`, read against what it would
2348+ actually do. The rest hold up: the clone-and-push copy commands name the same ref
2349+ set the server side copy moves, the runner lines carry the token, the tag commands
2350+ are stock git, and the config printf is the mockup's own line.
2351+
2352+ ## A test for the class of bug, not the bug
2353+
2354+ Last pass a template branch nobody rendered hid a field nobody supplied, and a
2355+ private repository was told to publish itself. The fix was one line. This is the
2356+ guard.
2357+
2358+ `TestTemplatesRender` gives each template one set of data. Go templates resolve a
2359+ field when they reach it, so a field inside a branch that data does not take is
2360+ never looked up and never errors. Twenty five templates carry about a hundred and
2361+ thirty branches between them, so rendering one state each leaves most of them
2362+ unread.
2363+
2364+ `TestEveryTemplateFieldExistsOnItsData` reads the parse tree instead of executing
2365+ it. It starts at `layout`, because a page file on its own is only its define
2366+ blocks, follows `{{template "x" .}}` wherever the dot is unchanged, and collects
2367+ every field read against the page's own dot. Range and with bodies are left alone,
2368+ since their dot is a different type. Each field is then looked up on the fixture's
2369+ struct by reflection.
2370+
2371+ **It failed on its first honest run.** `repo-empty` asks for `.Visibility`, added
2372+ to the handler last pass, and the fixture never gained it. So the very fixture I
2373+ had just fixed the bug in was still out of step with the page, and the render test
2374+ was still happy.
2375+
2376+ Falsified twice: once by the real gap it found, and once by renaming `.CopyTo` to
2377+ `.CopyToo` in the config page, which it names by template and field.
2378+
2379+ **What it does not do.** It does not prove a branch produces the right words, only
2380+ that the words it asks for can be found. The private repository case still needs
2381+ its own end to end test, and has one. This catches the cheaper half of the problem
2382+ everywhere rather than the whole problem in one place.
2383+
2384+ ## The front door had never been opened by a test
2385+
2386+ Every test in this tree that needed a signed in reader made a session by calling
2387+ `db.NewSession` directly. That is the right shortcut for a test about the keys
2388+ page or the inbox, and it meant the thing those shortcuts stand in for, signing up
2389+ and signing in, was the one flow nothing exercised. If it broke, every test would
2390+ still pass and nobody could use the site.
2391+
2392+ There is one now, with a real key and real signatures:
2393+
2394+ - `ssh-keygen -t ed25519` makes a key.
2395+ - The signup form takes the name and the public half and answers with a nonce.
2396+ - `printf '%s' '<nonce>' | ssh-keygen -Y sign -f <key> -n barerepo-signup -` signs it,
2397+ which is chapter 31.3's line: one command, stdin to stdout, nothing on disk.
2398+ - The account exists and the reader is signed in.
2399+ - `/auth/challenge` then `/auth/verify` take the other door, the one a returning
2400+ reader uses, with `-n barerepo-auth`.
2401+ - The session opens `/keys`, since a session that opens nothing is not a session.
2402+
2403+ **The namespace separation is asserted, because it is the reason it exists.**
2404+ Chapter 10: "The namespace is `barerepo-signup`, not `barerepo-auth`. With one namespace
2405+ a signature captured from a sign-in could be replayed to claim an account with
2406+ somebody else's key." The test signs a signup nonce with `barerepo-auth` and requires
2407+ it to be refused. Setting the two constants equal fails it by name.
2408+
2409+ **One thing the test taught me about the code rather than the other way round.** A
2410+ wrong signature spends the nonce. My first version treated that as a bug and it is
2411+ not: it stops a signature being guessed at against one challenge, the form comes
2412+ back with the name and key already filled, and the page says "the nonce is spent,
2413+ so press create for a new one". The test asserts that sentence now, because
2414+ without it the next attempt looks like forge is broken.
2415+
2416+ ## The runner protocol was documented and never driven
2417+
2418+ Same question as last pass: what does every test work around? Every test needing a
2419+ runner called `AttachRunner` and `TakeJob` on the database directly. So
2420+ `/runner/attach` and `/runner/poll` were named on the add-a-runner page, listed in
2421+ appendix C, and exercised by nothing.
2422+
2423+ That page makes a specific promise, and chapter 24 explains why it matters: "the
2424+ page says what the program does, and says that the protocol it speaks is the plain
2425+ HTTP in chapter 15, so anyone who would rather write their own has everything they
2426+ need to. That is the difference between a required tool and a hidden one."
2427+
2428+ The test is that anyone. It attaches with a token, checks the runners page lists
2429+ the machine, pushes a `[build] command`, polls and receives the job, posts a log
2430+ chunk, posts the result, and reads the run page for the machine name, the log and
2431+ the status. Nothing in it imports forge's own runner.
2432+
2433+ It passed first time, which is the honest outcome and still worth having: the
2434+ claim on that page is checked now rather than asserted. Breaking the log endpoint
2435+ fails it on the run page, which is where a reader would notice.
2436+
2437+ **What is still worked around.** Every push in this suite goes over https with a
2438+ token in the url, because that is what a test can do without an sshd. So
2439+ `sshx.Serve` and `forge ssh`, the entry point `authorized_keys` forces and the one
2440+ chapter 41.3 calls attacker-controlled, are covered only by unit tests of
2441+ `Parse` and `Write`. That is the next hole of this kind, and it is a bigger one,
2442+ since ssh is the transport the design is built around.
2443+
2444+ ## ssh, the transport the design is built on, had never carried a byte in a test
2445+
2446+ Every push in this suite goes over https with a token in the url, because that is
2447+ what a test can do without an sshd. So `sshx.Serve`, the function that takes
2448+ `SSH_ORIGINAL_COMMAND` and hands git the connection, and `forge ssh`, the entry
2449+ point `authorized_keys` forces, were covered by unit tests of `Parse` and `Write`
2450+ and nothing else. Chapter 10 makes an ssh key the identity and every page prints
2451+ an ssh clone url.
2452+
2453+ A test needs no daemon. `GIT_SSH_COMMAND` points git at a five line script that
2454+ does what `authorized_keys` does: put the command in `SSH_ORIGINAL_COMMAND` and
2455+ exec `forge ssh --account john`. Then a real `git clone` and a real `git push` go
2456+ through the real path, and the log page is read to prove the push landed.
2457+
2458+ **The script taught me something about the shape of the connection.** My first one
2459+ took the first argument as the host and the rest as the command, and git refused
2460+ it: git sends `-o SendEnv=GIT_PROTOCOL` ahead of the host for protocol v2. sshd
2461+ puts only the last argument in `SSH_ORIGINAL_COMMAND`, so the script does too.
2462+
2463+ **Chapter 41.3 is asserted directly.** Six commands are pushed at the entry point:
2464+ nothing, `sh`, a git verb with `; touch` after it, one with `&& whoami`, `scp -t`,
2465+ and a path escaping the root. Each must be refused, none may crash, and each must
2466+ say who refused it. `/tmp/forge-owned` is checked afterwards, since the honest
2467+ question is not whether an error was printed but whether a shell ran.
2468+
2469+ ## Three of my falsifications this pass did nothing at all
2470+
2471+ Worth writing down because it is a failure of method, not of code.
2472+
2473+ To check that a test can fail I edit the code, run the test, and put the code
2474+ back. Three times this pass the edit silently matched nothing, because the pattern
2475+ I searched for had a leading tab the source did not, and `str.replace` reports
2476+ nothing when it replaces nothing. The test passed, I read that as "the test does
2477+ not discriminate", and I was reading an unmodified binary.
2478+
2479+ I caught it by running the command by hand and seeing the refusal message that the
2480+ edit should have removed.
2481+
2482+ **Every falsification asserts the edit applied now.** `assert s.count(old) == 1`
2483+ before writing the file, so a pattern that does not match stops the check instead
2484+ of quietly passing it. A falsification that cannot fail is worth less than no
2485+ falsification, because it is believed.
2486+
2487+ ## Going back over the guards, with an assertion this time
2488+
2489+ Last pass three falsifications silently edited nothing and I read their passes as
2490+ information. So this pass went back over the guards whose falsification had never
2491+ been proven, with a helper that refuses to run unless its edit matched, and that
2492+ says plainly whether the test caught the break.
2493+
2494+ Twelve guards checked. Ten caught their break. Two did not, for different reasons,
2495+ and the difference is the interesting part.
2496+
2497+ **One was a bad test.** `TestOnlyACodeSearchResultDrawsABox` builds `searchRow`
2498+ values by hand and renders the template, so it proves the template honours
2499+ `HasText` and nothing at all about the handler that sets it. Handing a thread the
2500+ box search.html keeps for a source line is a handler decision, and no test touched
2501+ it. There is an end to end one now: a query that matches a thread and no file, and
2502+ the page must hold no `<pre>` at all. It catches the break.
2503+
2504+ **One was a bad mutation.** The cold cache test compares a page rendered with the
2505+ caches on against the same page with them gone. Deleting a cache *write* cannot
2506+ change that, since output with no cache is the property under test. The mutation
2507+ did not violate the property, so the pass told me nothing about the test. The same
2508+ test does catch a real break, which is a cache read with no read behind it, and
2509+ that was falsified when it was written.
2510+
2511+ So a falsification says something only when the mutation actually violates the
2512+ property. A mutation that makes the code slower, or uglier, or differently spelled
2513+ is not a falsification, and reading its pass as reassurance is the same error as
2514+ reading an unapplied edit.
2515+
2516+ ## And one real gap, found by falsifying rather than by reading
2517+
2518+ Dropping `refs/notes/*` from the copy's fetch refspec did not fail the copy test.
2519+ Chapter 21.1 says what a copy carries: "Branches, tags, all history, threads and
2520+ notes." The test asserted the branches came and the proposal refs did not, and
2521+ never looked for the discussion. A copy that silently lost every thread would have
2522+ passed.
2523+
2524+ It now checks the notes ref arrived and that the copy's thread list is not empty,
2525+ and the mutation fails it.
2526+
2527+ ## Chapter 40.1 lists nine things a mirror takes, and the test checked seven
2528+
2529+ Same lens as the copy gap: a sentence that enumerates is a checklist, and a test
2530+ for it must read every clause.
2531+
2532+ "This copies the code, all history, every branch, every tag, every proposal ref,
2533+ every thread, every comment, the config file and every build result."
2534+
2535+ `TestPortability` builds a repository, opens a thread, pushes a proposal, comments
2536+ on a line, merges, records a build, mirrors it, deletes the original, pushes the
2537+ mirror to a second forge, and reads all of it back. It covered code, history,
2538+ proposal refs, threads, comments, the config and build results.
2539+
2540+ **Every branch and every tag were the two it did not.** The repository it built had
2541+ one branch and no tags at all, so the two plural clauses were unread. It now
2542+ pushes a `topic` branch and an annotated `v1.0.0`, and reads both back off the
2543+ second instance, along with the release body, which chapter 22.1 says clones with
2544+ the repository and which nothing had checked either.
2545+
2546+ **And two mutations, one of which taught me something.** Removing `refs/tags/`
2547+ from pre-receive's access switch did not fail it, and that is correct: the owner
2548+ of the destination is restoring, and chapter 18 gives the namespace owner every
2549+ ref in their own repository, so the switch is never reached for a mirror push.
2550+ Bad mutation, not a bad test.
2551+
2552+ The mutation that does violate the property is turning that exemption off. Then
2553+ the mirror push is judged ref by ref and `refs/notes/runs` is refused with "build
2554+ results are written by the server", and the test fails. Chapter 18 says this
2555+ outright, that the exemption is what makes chapter 40.3 true, and now something
2556+ holds it: taking the exemption away breaks taking your repository somewhere else.
2557+
2558+ ## Chapter 18 calls itself the complete matrix, so now it is a table
2559+
2560+ "The complete matrix. There is nothing else." Seven rows, and until now the only
2561+ thing holding them was reading the pre-receive switch and agreeing with it.
2562+
2563+ Seventeen cells, pushed for real by four accounts against one public repository
2564+ where john owns it and lisa has `[access] push`:
2565+
2566+ refs/heads/* owner yes, push list yes, stranger no
2567+ refs/tags/* the same three
2568+ refs/proposals/new a stranger yes, which is rule 5
2569+ refs/proposals/<n> its author yes, push list yes, another stranger no
2570+ refs/notes/threads/* anyone who may read yes
2571+ refs/notes/runs stranger no, owner yes
2572+ refs/meta/* stranger no, owner yes
2573+ everything else stranger no, owner yes
2574+
2575+ The last two rows are the owner exemption chapter 18 states beside the table, and
2576+ they are in the same table because they are the same rule.
2577+
2578+ **The first version of this test passed two cells for the wrong reason.** Every
2579+ case pushed the same commit, so once an allowed case had written a ref, the
2580+ refused case that followed it got "Everything up-to-date" from git and the hook
2581+ never ran. Two cells were vacuous and green.
2582+
2583+ Each case pushes its own commit now, and the loop fails outright on "up-to-date",
2584+ because a push that sends nothing has judged nothing. That guard matters more than
2585+ the cells: a test that quietly stops exercising the thing it names is the failure
2586+ mode this whole run keeps finding.
2587+
2588+ Falsified three ways, each catching it: dropping `[access] push` from the read of
2589+ who may push, letting the final `default` accept instead of reject, and removing
2590+ the proposal author check.
2591+
2592+ ## The tool I built to check my tests corrupted the code it was checking
2593+
2594+ The falsification helper edits a file, runs a test, and puts the file back. It put
2595+ it back by replacing the mutation string with the original string. That is only
2596+ safe when the mutation string is unique in the file.
2597+
2598+ One mutation replaced a `reject(...)` call with `return nil`. `return nil` appears
2599+ in that file many times, so the restore rewrote the first one, which lives in an
2600+ unrelated function, into a call with variables that do not exist there. The
2601+ package stopped compiling, and I committed it before the suite told me.
2602+
2603+ Both the corruption and the deletion were repaired in the next commit, and the
2604+ suite is green again.
2605+
2606+ **The helper keeps the whole file now and writes it back byte for byte**, then
2607+ asserts the file on disk equals what it read. A restore that pattern matches is
2608+ the same class of mistake as an edit that pattern matches without checking, which
2609+ is what this helper existed to prevent. It made both errors on the same day.
2610+
2611+ Two rules out of it, both cheap:
2612+
2613+ - Restore by content, never by pattern. Keep the original bytes.
2614+ - Run the whole suite before committing, not the one test the pass was about. The
2615+ build failure was in a package the matrix test never touches, and `go test ./e2e/`
2616+ was perfectly happy.
2617+
2618+ ## Rule 3 was false again, in the way chapter 10 warned it would be
2619+
2620+ Rule 3: nothing is stored that is not a git object, except a closed list in
2621+ chapter 10. The chapter prints that list and then says, of itself:
2622+
2623+ "**This list was four items in an earlier draft and the count was wrong.**
2624+ Redirects and artifacts were added to the design without being added here, which
2625+ made rule 3 false while it was still being cited. The count is stated plainly
2626+ because a closed list that quietly grows is worse than an open one."
2627+
2628+ It has happened again. Forge stores fourteen tables. Six of them map cleanly onto
2629+ items 1 to 4 and 6. Four do not:
2630+
2631+ - **runners**, a machine that dialed in and what it can build
2632+ - **jobs**, the queue of work waiting for one
2633+ - **webhooks** and **webhook_cursor**, a hook's run of failures and where the
2634+ sender got to
2635+
2636+ None of that is derivable from git, so item 6 cannot hold it: item 6 says
2637+ "rebuildable from git alone", and nothing in a repository records that a laptop
2638+ attached this morning. Chapter 19.4 makes the same distinction for read state and
2639+ concludes it "would have to be added to the closed list in chapter 10 as a new
2640+ category rather than folded into item 6". That is the reasoning followed here.
2641+
2642+ **The book now has a seventh item**, work in flight and who is doing it, with the
2643+ reason it is not item 6 written into it, and the paragraph about the count now
2644+ says the list has grown quietly twice.
2645+
2646+ **Chapter 28 and BUILD.md both said five tables.** Chapter 28 uses that number to
2647+ describe a backup, so a reader counting tables would have found nine more than the
2648+ book admitted to. Both now describe the database by what it holds rather than by a
2649+ number that goes stale on the next migration.
2650+
2651+ **And a test, because the chapter's own complaint is that this keeps happening
2652+ quietly.** `TestEveryTableIsOnTheClosedList` reads every `CREATE TABLE` out of the
2653+ migrations and requires each to name the list item that owns it. A new table with
2654+ no item fails the build. It checks the other direction too, so a claim about
2655+ storage that no migration makes is also a failure. Both directions falsified.
2656+
2657+ ## Two thresholds the book states and nothing measured
2658+
2659+ **Chapter 25 budgets a page 2kb of javascript.** It is in BUILD.md's table beside
2660+ the timings, which the notes call "build-failing thresholds, not aspirations", and
2661+ every row of that table was asserted except this one.
2662+
2663+ The answer is 786 bytes, one file, one script tag, which is rule 4 honoured with
2664+ room to spare. But nothing said so, and the next person to reach for a helper
2665+ library would have found out from nobody. The test reads every `<script src>` out
2666+ of the templates, adds up what they pull from the embedded static files, and fails
2667+ over 2kb. Padding `keys.js` past the line fails it.
2668+
2669+ **Chapter 24 ends with the pages that do not exist.** Sixteen of them, and the
2670+ chapter gives two different reasons: the discovery pages are absent per rule 7,
2671+ because "a page that displays emptiness to every visitor actively harms adoption",
2672+ and the rest per chapter 1, because "remove them and the five things a forge does
2673+ still work".
2674+
2675+ A list of things that must not exist is as checkable as a list of things that must.
2676+ Nineteen paths are requested and every one has to answer 404, and the same test
2677+ reads the thread list for a merge button, which is BUILD.md's own trap: "Do not add
2678+ a merge button. Every request for one is a request to become GitHub."
2679+
2680+ Both halves falsified. Wiring `/explore` to the search handler fails the first,
2681+ putting a merge button on the thread list fails the second.
2682+
2683+ That second one is worth keeping precisely because it will never fail by accident.
2684+ It fails the day somebody decides one small button would be convenient.
2685+
2686+ ## The one link on the site that could only fail
2687+
2688+ Crawling every link on fifteen signed-in pages, a hundred distinct urls, found one
2689+ that did not answer: `/inbox.atom`, linked from the inbox page itself, returned
2690+ 401 to the person looking at their own inbox.
2691+
2692+ The 401 was correct and the message was helpful, "this feed needs a feed token.
2693+ make one on your keys page." But `inbox.html` draws `atom · feed token` as two
2694+ links, and the first one could never work for anybody. A link whose only outcome
2695+ is an error is a link that should not be there, or a handler that should answer.
2696+
2697+ The handler answers now. A request carrying a feed token is served as before. A
2698+ request carrying no token at all, from a browser that already has a session, is
2699+ served to that session's account.
2700+
2701+ **This does not weaken chapter 19.5.** The token exists for a reason the chapter
2702+ states: "A feed reader stores URLs in plain text, so a URL that grants write
2703+ access is a bad idea." That is about what goes in a url a feed reader keeps. A
2704+ session cookie is not sent by a feed reader and grants strictly more than the feed
2705+ already, so refusing it bought nothing and cost the link on the page.
2706+
2707+ Both halves are asserted: an anonymous request and a wrong token are still 401,
2708+ and only the session case is new.
2709+
2710+ The crawl is worth repeating after any template change. Ninety nine of a hundred
2711+ links were fine, which is the ratio that makes reading them by hand a bad use of a
2712+ pass and a script a good one.
2713+
2714+ ## The crawl is a test now, and it found the bug the hand run had missed
2715+
2716+ Last pass's link crawl was a shell loop over fifteen pages I chose. It found one
2717+ broken link. Written as a test that follows links rather than visiting a list, and
2718+ run against a repository with a proposal on it, it found another straight away.
2719+
2720+ **Every file on a proposal's compare page linked to a 404.** The compare page
2721+ builds a file link as `/file/<ref>/<path>`, and for `master...refs/proposals/1`
2722+ the ref is `refs/proposals/1`. The route reads one path element as the ref, so
2723+ `refs` became the ref and `proposals/1/config.go` the path, and nothing was there.
2724+
2725+ A ref holding a slash cannot be one path element. Rather than teach the route
2726+ where a ref ends, the link resolves the ref to a commit when it holds a slash,
2727+ which is unambiguous and also survives the force-push that chapter 12 makes the
2728+ normal way to update a proposal. A plain branch name still reads as itself.
2729+
2730+ **And the crawler taught me one thing about my own tooling.** Its first run
2731+ reported three comment links as 404 that were fine: a page writes `&` as `&amp;`,
2732+ and a crawler that does not undo that asks for a url nobody wrote. Three of the
2733+ four failures were mine.
2734+
2735+ The test crawls from seven roots, follows every internal href it finds, stops at
2736+ three hundred pages, and fails if it reaches fewer than twenty five, because a
2737+ crawl that stops early passes for the wrong reason. Falsified twice: reverting the
2738+ file link and reverting yesterday's inbox feed fix each fail it.
2739+
2740+ That is the shape worth keeping. A list of pages checks the pages somebody thought
2741+ of. A crawl checks the ones they did not.
2742+
2743+ ## A ref with a slash in it, everywhere it is spent on one path element
2744+
2745+ The compare page's file link was the first of three. Grepping for every url built
2746+ from a ref found the rest:
2747+
2748+ - **the releases page**, linking each tag to its tree. A tag may hold a slash, and
2749+ `release/1.0` is a spelling plenty of projects use.
2750+ - **the config page**, linking `.barerepo/config` to its raw bytes through the
2751+ default branch. `feature/x` is a legal branch name and a common one.
2752+
2753+ Both go through the same resolve now. And `fileRef` had to grow: `release/1.0` is
2754+ not a ref path, so reading the ref files cannot find it, and git is asked when the
2755+ files cannot say. The first version resolved a full ref name only and quietly left
2756+ the broken url alone, which the crawl caught the moment a slashed tag existed.
2757+
2758+ **The fixture is the reason it was caught.** The crawl passed before, because the
2759+ repository it built had one tag named `v1.0.0` and one branch named `master`. A
2760+ slash is legal in a ref and it is the thing that breaks a url spending one path
2761+ element on one, so the fixture has both a `release/1.0` tag and a `feature/login`
2762+ branch now. Same lesson as the mirror test that had one branch and no tags: a
2763+ clause about a hard case is unread until the fixture contains one.
2764+
2765+ ## Nothing linked to the releases page
2766+
2767+ The crawl reached the releases page for the first time only after a tab was added
2768+ for it, which is how the missing tab was found: the page answered when asked, and
2769+ nothing ever asked.
2770+
2771+ `releases.html` draws the tab row as `log · files · threads 3 · runs · config`,
2772+ and so does every other mockup. None of the twenty four links to releases. Only
2773+ `index.html`, the contact sheet, does, and that is a page of the mockups rather
2774+ than a page of forge.
2775+
2776+ So chapter 24 describes a view, appendix C routes it, a mockup draws it, and a
2777+ reader could only reach it by typing the url. **This is a sixth tab, and it is a
2778+ visible deviation from five mockups**, taken deliberately: a page nobody can find
2779+ is worse than a tab row one item longer, and the word traces to chapter 24 and to
2780+ the mockup's own title.
2781+
2782+ **The crawl now asserts reachability as well as answers.** Those are two
2783+ properties and the second one hid: removing the tab makes nothing 404, it makes a
2784+ page disappear. Nine pages must be reached from the front door, and removing the
2785+ tab fails it by name.
2786+
2787+ ## A file name with a space in it broke its own diff
2788+
2789+ Following the lesson that a hard case is unread until the fixture contains one,
2790+ the crawl's repository gained a file called `a note.md` and one called `c++.md`.
2791+
2792+ The plus turned out to be my crawler again: html/template writes `+` as `&#43;` in
2793+ a url attribute, and a browser reads it back as `+`. The crawler now unescapes
2794+ html entities generally rather than the one entity I had noticed, which is the
2795+ second time that same shortcut has produced a false failure.
2796+
2797+ The space was real. Every link to `a note.md` on the log and the commit page ended
2798+ in `%09`, a tab, and answered 404.
2799+
2800+ The unified diff format is where it comes from. A `+++ b/` line normally ends at
2801+ the name, but when the name holds a space git writes a tab after it, because that
2802+ tab is the format saying where the name ends. Forge took the whole rest of the
2803+ line, tab included, as the path.
2804+
2805+ So a repository with one space in one file name had a broken link on its landing
2806+ page. The parse cuts at the tab now.
2807+
2808+ **Two of the last three bugs have been the same shape**: a value that is usually a
2809+ plain token, spent somewhere that assumes it is one. A ref with a slash in a path
2810+ element, and a path with a space in a diff header. Both were invisible until a
2811+ fixture held the awkward case, and both were on the pages a reader sees first.
2812+
2813+ ## git has two spellings for a path, and forge only knew one
2814+
2815+ `café.md` went into the crawl's repository and produced this link on the log page:
2816+
2817+ /john/johnbot/file/<sha>/"a/caf\303\251.md" "b/caf\303\251.md"
2818+
2819+ Git quotes any path outside ascii in its own output, as a C string with octal
2820+ escapes. So `diff --git "a/café.md" "b/café.md"` has no ` b/` in it to split on,
2821+ and no `+++ b/` prefix to correct it either, since that line reads `+++ "b/…`.
2822+ Both parses missed and the whole rest of the line became the path.
2823+
2824+ **The fix is one setting, not one parser.** `core.quotePath=false` makes git write
2825+ the raw bytes, and forge sets it for every git process through the environment it
2826+ already builds. That fixes every place forge reads a path at once: the diff
2827+ header, the file list, `ls-tree --name-only` and `diff --name-only` in the search
2828+ indexer, and `rev-list --objects` in the blob size check.
2829+
2830+ Writing an unquoter instead would have fixed the one call site I was looking at
2831+ and left the other four to be found later, one crawl at a time.
2832+
2833+ **The search index had the same bug and no way to notice.** A repository with an
2834+ accented file name indexed it under git's octal spelling, so the search page
2835+ offered a link to a path that does not exist. There is a test for that now
2836+ alongside the crawl, because the crawl only reads links and the index is not one.
2837+
2838+ Both falsified by flipping the setting back to true.
2839+
2840+ ## The awkward names live in one fixture
2841+
2842+ `release/1.0`, `feature/login`, `a note.md`, `c++.md`, `café.md`. Every one of
2843+ them found something, and each one was cheaper to add than the bug it found was to
2844+ find any other way. The next awkward name goes there rather than into a test of
2845+ its own.
2846+
2847+ ## The two branches that draw a notice instead of the file
2848+
2849+ The awkward fixture grew a directory, a nested `docs/a note.md`, a file with a
2850+ null byte in it, a file over a megabyte, and a file deleted in the commit after it
2851+ appeared. The crawl went green, which says every link those states produce
2852+ answers. It does not say the pages are right, because the crawl reads links and
2853+ these two branches draw prose.
2854+
2855+ Chapter 42.4 asks for two refusals: "Detect binary files by looking for a null
2856+ byte in the first 8000 bytes. Do not render binary content. Show the size and
2857+ offer download." and "Cap rendered file size. Files above 1 MB show a notice and a
2858+ download link."
2859+
2860+ Both were implemented and neither was ever rendered by a test, because no fixture
2861+ had ever contained such a file. They are asserted now, in both directions: the
2862+ binary page says `binary file` and offers a download and does not contain the
2863+ bytes, the large page says `too large to render` and does not contain a line of
2864+ it, and an ordinary file still renders and says neither. That last case matters,
2865+ since two rules that refuse everything would pass the first two checks.
2866+
2867+ Setting the sniff length to zero fails it, and raising the cap to a terabyte fails
2868+ it.
2869+
2870+ **The deletion, the directory and the nested awkward name found nothing.** Worth
2871+ saying: most awkward cases do not find a bug, and they are still cheap enough that
2872+ adding them is the right call. Five of nine have found something so far.
2873+
2874+ ## The landing page kept its diffs shut
2875+
2876+ Every mockup carries one `sr-only` sentence saying what its page is for. Forge has
2877+ the same mechanism, `{{.Summary}}` in the layout, and every page fills it in. So
2878+ the two sets of sentences can be read side by side, and one pair disagreed.
2879+
2880+ The mock says: "Repository log with every commit diff expanded inline. This is the
2881+ landing page." Forge said: "Repository log, newest first, each commit's diff one
2882+ click away."
2883+
2884+ Forge's sentence was the accurate one. Each diff sat in `<details name="log">`,
2885+ which is shut until clicked, and the `name` makes the whole page an accordion, so
2886+ opening a second diff closes the first. Its own stylesheet said so out loud:
2887+ "a commit's diff on the log page, shut until asked for. one open at a time".
2888+
2889+ Chapter 24 says the opposite: "Commits newest first, each with message, author,
2890+ time, changed files, and **the diff already expanded**. Diffs over a threshold
2891+ collapse with a size label and an expand link." The mockup draws it that way too:
2892+ no `<details>` anywhere in the file, two diffs open, and only the third commit, a
2893+ fourteen-file merge, collapsed with `large diff collapsed · expand`.
2894+
2895+ The threshold is the answer to the size problem, and it was already built. The
2896+ disclosure was a second answer to a problem that had one, and it cost the page its
2897+ reason for existing: "People arrive at a repository to find out what changed... The
2898+ log answers the first directly." A page of shut drawers does not answer directly.
2899+
2900+ Now the diff renders inline and the collapsed branch is untouched. The stats line
2901+ lost its `· view commit`, which the mock does not draw and which the linked sha
2902+ beside it already does.
2903+
2904+ The page got **smaller**: 19kb against 22kb, because the disclosure markup was pure
2905+ overhead. It was never a page-weight measure. The bytes were always being sent.
2906+
2907+ Putting the `<details>` back fails the test.
2908+
2909+ **The method here is worth keeping.** A screen reader sentence is a claim about
2910+ what a page does, written twice by two different people. Where the two spellings
2911+ disagree, one of them is a bug. The crawl now also fails any page that renders that
2912+ heading empty, so the pairs stay comparable.
2913+
2914+ ## A typo in the config took everything away
2915+
2916+ Chapter 14 states it in one sentence: "A malformed config file must not lock anyone
2917+ out. On parse failure, fall back to the last known good version and print a warning
2918+ to the pusher's terminal."
2919+
2920+ Forge did the warning and not the fallback. `Load` returned `Default()`, and the
2921+ defaults are not neutral, they are the safest possible answer to every question:
2922+
2923+ - `visibility` empty reads as private, so a public repository went dark
2924+ - `[access] push` empty means owner only, so everyone else lost push
2925+ - `require_runs` empty means nothing is required any more
2926+ - `[runners]` empty means no labelled runner matches
2927+ - `[[webhook]]` empty means the hooks stop firing
2928+
2929+ One unclosed bracket did all of that at once. The warning it printed was honest
2930+ about it: "its settings are being ignored". The code and the book disagreed and the
2931+ code said so out loud.
2932+
2933+ **The fallback now walks the file's own history.** `git log -n 25 -- .barerepo/config`
2934+ newest first, and the first version that parses is the one in force. The walk is
2935+ bounded so a file that has never parsed cannot cost a walk of the whole history, and
2936+ the result is cached under the same commit key as the file itself, so a repository
2937+ with a broken config pays for the walk once.
2938+
2939+ **Two warnings, because there are two moments.** The config is read from the tip of
2940+ the default branch, which during a push is still the old version. So the push that
2941+ introduces the typo used to be the one push that said nothing. It now parses the
2942+ incoming file too and says the file will not parse. Every later push names the older
2943+ commit whose settings are deciding.
2944+
2945+ **And the config page said nothing at all.** A reader opened it, saw the broken
2946+ file rendered as if it were law, and had no way to know. It carries the same
2947+ sentence now.
2948+
2949+ Three falsifications: returning `Default()` again, dropping the incoming-file check,
2950+ and dropping the page's error each fail the test.
2951+
2952+ ### Found on the way, not built
2953+
2954+ The book's config page shows "history, blame, and raw links" and the mock draws
2955+ `history · blame · raw`. Forge draws `raw`. The file view has the same gap. Next
2956+ pass.
2957+
2958+ ## The history link that was a grey word
2959+
2960+ Chapter 24 gives the config page "history, blame, and raw links", and the mock
2961+ draws all three underlined. Forge drew `raw`. The file view drew
2962+ `<span class="muted">history</span>`, which is a word styled to look like a control
2963+ and wired to nothing. That is worse than leaving it out: it promises and refuses.
2964+
2965+ **There is no history route, and there does not need to be one.** Appendix C has no
2966+ `/history` and no `/blame`, and chapter 24 argues against a blame page directly:
2967+ "Blame is not a separate question." So the two links resolve to pages that already
2968+ exist:
2969+
2970+ - `blame` on the config page is the file view of `.barerepo/config`, which draws blame
2971+ in the gutter on every line, always. The config page renders the file as a `pre`,
2972+ so this is the only way to see who wrote which line of the policy.
2973+ - `history` is the log page restricted to one path: `/<owner>/<name>?path=<file>`.
2974+ A query parameter on a route that already exists, not a new route.
2975+
2976+ The log page was already the right page for this. It draws every diff open, so one
2977+ file's history is that file's changes, each with its diff, newest first. It says
2978+ what it is restricted to and links back to the whole log.
2979+
2980+ **The diff cache had to be told.** It is keyed by commit sha and holds the whole
2981+ commit's patch. A path-restricted log produces a different patch under the same sha,
2982+ so the filtered path skips the cache in both directions. An unfiltered log is
2983+ unchanged and still reads from it: 14.2ms, 19kb, well inside chapter 25.
2984+
2985+ ### The feature found its own bug, in the crawl
2986+
2987+ `doomed.md` is deleted by the awkward fixture. Its history page linked the file name
2988+ back to the file view at the branch tip, where the file is not, and the crawl caught
2989+ the 404 within a minute of the feature existing.
2990+
2991+ A file with a history and no present tense is a real state, so the page says so:
2992+ the name is plain text and reads `which is not in master any more`. Removing that
2993+ check fails the crawl.
2994+
2995+ Four falsifications, and three of them were fixture failures first: the render cases
2996+ in `view_test.go` had to grow the new fields before `TestEveryTemplateFieldExistsOnItsData`
2997+ would go green. That guard has now paid for itself twice.
2998+
2999+ ## Three settings the config parsed and nothing read
3000+
3001+ `.barerepo/config` is the whole settings surface, so a key that parses and does nothing
3002+ is worse than a missing feature: the page shows the file as though it were law.
3003+ Counting reads of every field in the struct found three at zero.
3004+
3005+ **`[repo] default_branch`.** Chapter 33.6 is a recipe: edit it, commit, push, "the
3006+ server reads the file on push". HEAD was set once, on the first push into an empty
3007+ repository, and never looked at the file again. It follows the config now, and says
3008+ so in the terminal. A branch named but not pushed gets a sentence rather than a HEAD
3009+ pointing at nothing.
3010+
3011+ **`[proposals] require_runs`.** Chapter 37.4 is a section called "Require builds to
3012+ pass" and it did nothing at all. A team could read that section, write the line,
3013+ push it, and believe the default branch was protected.
3014+
3015+ Forge has no merge button by design, so there is only one place this rule can live:
3016+ the push that puts a commit on the default branch. The hook reads the run notes for
3017+ that commit and refuses it if a required name has not passed, naming the ones that
3018+ have not and pointing at the runs page.
3019+
3020+ **The owner is exempt**, on chapter 21.3's precedent for archived. Without that,
3021+ `require_runs = ["build"]` with no runner attached locks everyone out of the
3022+ repository including the person who has to edit the file to undo it, and the file
3023+ lives on the branch they can no longer push to.
3024+
3025+ `[runners]`, the third, is still unread. It maps a hostname to the labels that
3026+ machine will take, and a runner already advertises its own labels when it attaches,
3027+ so the config side is a second opinion with no stated precedence. Left alone
3028+ deliberately rather than guessed at.
3029+
3030+ ### The feature found a silent bug behind it
3031+
3032+ The first version of the test failed with the build passing. The run note held
3033+ `{"runner":"uproar.local","exit":0}` and no `name`, because the job lookup behind
3034+ `/runner/done` selected every column except `name`. Every run forge has ever
3035+ recorded from a finished job has had an empty name, and chapter 15A's matrix is
3036+ grouped by exactly that field.
3037+
3038+ Nothing noticed, because until today nothing read the name back.
3039+
3040+ ## The flash of unstyled content
3041+
3042+ Reported while the above was being written, and real. `/static/barerepo.css` answered
3043+ with no `Cache-Control`, no `ETag` and no `Last-Modified`, because an embedded file
3044+ has a zero modtime and `http.FileServerFS` sends no validator without one. With
3045+ nothing to revalidate against, a browser refetches the stylesheet on every
3046+ navigation, and the page paints before it lands.
3047+
3048+ The url carries the version now, which makes the body under it immutable, so it is
3049+ served with a year and `immutable`. An unversioned url is somebody's bookmark and
3050+ gets a minute. Chapter 25's own principle: cache whatever is a function of an
3051+ immutable thing.
3052+
3053+ The 2kb script budget test caught this within a minute, because it read
3054+ `keys.js?v={{.Version}}` as a file name. It reads the path now.
3055+
3056+ ## The front door answered 404 to anything that asked politely
3057+
3058+ Found by running `curl -sI` against the sign-in page while looking at cache headers.
3059+
3060+ ```
3061+ GET /signin -> 200
3062+ HEAD /signin -> 404
3063+ HEAD /signup -> 400
3064+ ```
3065+
3066+ `curl -I` sends HEAD. The router matched `/signin` on `r.Method == http.MethodGet`,
3067+ so a HEAD fell past every named route into the generic one-path-element case and
3068+ was answered as a profile for an account called `signin`. `/signup` has no method
3069+ guard at all, so a HEAD reached the *form* branch and was answered as a submission
3070+ with no form in it.
3071+
3072+ HEAD is a GET that stops at the headers. Go's own server discards the body for a
3073+ HEAD response, so routing it like a GET is all that is needed. Link checkers,
3074+ uptime probes, and the unfurler in every chat client use HEAD. Every one of them
3075+ was being told the sign-in page does not exist.
3076+
3077+ The two pages that were wrong are the two a stranger sees first.
3078+
3079+ ### One GET that a HEAD must not reach
3080+
3081+ `/runner/poll` is left on `MethodGet` alone, deliberately. It does not read a
3082+ queue, it **takes** from one: the handler removes a job and answers with it. A HEAD
3083+ routed there would take a build and throw it away, and no runner would ever see it.
3084+ A link checker walking the site would empty the queue.
3085+
3086+ So the inconsistency is the correct state, and it now has a test that says so. The
3087+ test asserts a queued job survives a HEAD to the poll, which fails the moment
3088+ somebody tidies the last `r.Method == http.MethodGet` away.
3089+
3090+ That is the point worth keeping: a rule with one exception needs the exception
3091+ written down as a test, or the next person removes it for consistency.
3092+
3093+ ## The cache key was the release number, which never moves
3094+
3095+ The flash of unstyled content was reported again after the fix, and the report was
3096+ right: the server on 3999 was a binary built at 08:58, before any of today's work.
3097+ Its html still asked for `/static/barerepo.css` with no version and no caching. Nothing
3098+ was wrong with the fix; nothing was running it. Rebuilt, restarted, and one
3099+ navigation to a second page now issues no second request for the stylesheet.
3100+
3101+ **But the fix had a trap in it.** The url carried `?v={{.Version}}`, and `Version`
3102+ is a const, `0.1.0`. It does not move between builds. So a browser that took the
3103+ stylesheet under `?v=0.1.0` with `max-age=31536000, immutable` would keep it for a
3104+ year, and the next edit to `barerepo.css` would reach nobody who already had it. That
3105+ is a worse bug than the one being fixed: the flash is a nuisance, a stylesheet
3106+ frozen for a year is a broken page nobody can clear.
3107+
3108+ The url carries a **hash of the files** now, computed once from the embedded
3109+ directory at startup. A changed stylesheet is a url no browser has ever seen, so
3110+ `immutable` is true rather than hopeful, and a release number nobody remembered to
3111+ bump cannot pin an old file.
3112+
3113+ The test asserts the tag is not the version and that every page hands out the same
3114+ one, since a page with a stale tag pins a stale stylesheet for whoever lands there
3115+ first.
3116+
3117+ **The lesson is about `immutable` itself.** It is a promise, and a promise keyed on
3118+ something that does not change is a lie with a one year expiry. Cache on the hash of
3119+ the thing, which is the same rule chapter 25 already applies to every git object
3120+ forge caches.
3121+
3122+ ## Anyone could write a comment in anyone else's name
3123+
3124+ The thread page prints these two lines and invites a reader to use them:
3125+
3126+ ```
3127+ git notes --ref=threads/1 append -m "your reply"
3128+ git push origin refs/notes/threads/1
3129+ ```
3130+
3131+ Chapter 35.5 prints the same pair. Following them put the reply on the page **inside
3132+ the previous person's comment**, over that person's name, because `git notes append`
3133+ joins with a blank line and forge separates records with a line of two dashes.
3134+
3135+ So forge printed instructions that misattributed the words of whoever followed them.
3136+
3137+ **The repair uses the difference, not a guess.** In post-receive the old commit is
3138+ still there, so what a push added is exactly the suffix of each note that was not
3139+ there before. If that suffix is not already a well-formed record it is wrapped as
3140+ one, authored by the account the push authenticated as. A record that names its own
3141+ author is left exactly as pushed, because chapter 40.3 restores a repository by
3142+ pushing its notes and a restore that renames every author is not a restore.
3143+
3144+ ### Pulling that thread found something much worse
3145+
3146+ Testing the exemption above turned up the real bug. A comment body is written into
3147+ the note as-is, and the record separator is a line of two dashes. So this, typed
3148+ into the reply box on the web page by any signed-in user:
3149+
3150+ ```
3151+ looks fine to me
3152+
3153+ --
3154+ author: john
3155+ time: 1755000000
3156+
3157+ I approve this change.
3158+ ```
3159+
3160+ renders as **two** comments, and the second one is signed *john* with a timestamp
3161+ the writer chose. Anyone could put words in anyone's mouth, including the owner
3162+ approving a proposal. Two comments were written and the page drew three.
3163+
3164+ A body is content and a separator is framing, and content that can become framing is
3165+ the same bug as SQL injection with the same shape. A line of only dashes now gets one
3166+ more dash on the way in and loses it on the way out. Two dashes is the separator and
3167+ writing one always produces at least three, so a body can no longer end its own
3168+ record. A reader who types `---` still sees `---`.
3169+
3170+ Falsified: dropping the escape lets the forged comment through, and the test counts
3171+ the comments rather than looking for a name, so it fails on the third comment
3172+ existing at all.
3173+
3174+ ## The same bug again, twice, through the front door
3175+
3176+ Yesterday's separator escape closed one half of the record format. The other half is
3177+ the header block, and it had the same shape of hole in two places.
3178+
3179+ **A hidden form field chose the name over a comment.** The line comment form carries
3180+ `blob`, the hash of the file the comment is anchored to, and the handler read it with
3181+ `strings.TrimSpace` and nothing else. TrimSpace does not touch a newline in the
3182+ middle. So `blob=abc\nauthor: john` wrote:
3183+
3184+ ```
3185+ author: mark
3186+ time: 1755...
3187+ anchor: README.md:1
3188+ blob: abc
3189+ author: john
3190+ side: new
3191+ ```
3192+
3193+ and the parser takes the last `author:` it sees. Posted as mark, signed john, from
3194+ the ordinary comment form on the ordinary page.
3195+
3196+ **A thread title could claim a header of its own.** The title is the first line of
3197+ the meta blob, so `title: x\nmerged: <sha>` made a thread claim it had been merged.
3198+ `state:` happened to be safe only because `Render` writes it after the title and the
3199+ last one wins. Safe by accident is not safe.
3200+
3201+ Both are fixed at the one place that writes a record, not at the handlers. Every
3202+ header value goes through `oneLine` on the way out, so a newline in any field becomes
3203+ a space and a value can never start a line. Fixing this at the call sites would have
3204+ meant finding all of them, and the next field added would have to be found again.
3205+
3206+ **The rule this makes explicit.** A record is lines of `key: value` and then a body.
3207+ Nothing that comes from a person may contain the two things that structure it: a
3208+ newline in a header, or a line of dashes in a body. Both are now escaped where the
3209+ record is written. That is one place, and it is the only place either rule needs to
3210+ live.
3211+
3212+ ## Expanded diffs, and the budget nobody was measuring
3213+
3214+ Reported: the log page is a wall of open diffs. It was, and the report found a
3215+ second thing behind it. The landing page of this repository weighed **219kb**.
3216+ Chapter 25 budgets it at **30kb**, and calls the table "build-failing thresholds,
3217+ not aspirations".
3218+
3219+ Chapter 24 asks for both: "the diff already expanded", and "Diffs over a threshold
3220+ collapse". The threshold that existed was per commit, six files or 160 lines. Twenty
3221+ commits can each sit under it and still add up to seven times the page budget. Two
3222+ rules that are each satisfied and together are not.
3223+
3224+ So the page has a budget of its own now. Diffs open from the newest down until the
3225+ inline diff content reaches 18kb, and the rest collapse with the same expand link,
3226+ saying `collapsed to keep this page small` rather than `large diff collapsed`,
3227+ because a reader deserves to know which rule shut it. The commit a reader asked to
3228+ expand is never shut by this, whatever it costs.
3229+
3230+ This repository's landing page is **27kb** now, three diffs open, fifteen collapsed
3231+ for the page and four for their own size.
3232+
3233+ ### The budget test was measuring a repository nobody has
3234+
3235+ It builds a thousand files and two hundred commits, and every commit changed **one
3236+ line of one file**. Twenty of those are 19kb of page, so the test passed while the
3237+ real thing was seven times over. A fixture can be large and still be nothing like
3238+ the thing it stands for.
3239+
3240+ Each commit now changes three files by ten lines each, which is under the per-commit
3241+ rule and over the page's. Getting there took two wrong fixtures: the first rewrote
3242+ whole files, so every commit collapsed on its own; the second picked different files
3243+ each commit without carrying the earlier ones forward, so every commit reverted the
3244+ one before it and changed six files instead of three. **The fixture has to be right
3245+ before the measurement means anything**, and both wrong versions passed.
3246+
3247+ Removing the page budget now fails the test by 3kb, and so does collapsing
3248+ everything, because the same test asserts at least one diff is open. Chapter 24 and
3249+ chapter 25 hold each other in place.
3250+
3251+ ## Collapsed by default, and the book says so now
3252+
3253+ The expanded log page is reverted. Diffs are shut until asked for, one open at a
3254+ time, which is what `<details name="log">` does and what was there before.
3255+
3256+ Chapter 24 is amended rather than worked around, because the code and the book must
3257+ not disagree: it asked for "the diff already expanded", and that is a 219kb page on
3258+ a real repository against chapter 25's 30kb budget. The mockup draws three commits.
3259+
3260+ **A shut disclosure still sends its bytes**, so the page budget is still needed. Past
3261+ 18kb of diff the page stops sending diffs at all, and those commits get the same
3262+ `expand` link the large ones already had, which reloads the page with that one diff
3263+ in it. Every commit opens; only the first few open without a round trip. No new
3264+ words on the page.
3265+
3266+ "collapsed to keep this page small" is removed. It explained forge's own budget to
3267+ somebody who did not ask, which is a note for whoever wrote it and not for whoever
3268+ is reading. Nothing in the interface should explain the implementation.
3269+
3270+ ## Two more budgets measured against a repository nobody has
3271+
3272+ The same fixture problem as the log page, in two more rows.
3273+
3274+ **The file tree** measured `/john/big/files`, the root, which holds two entries. The
3275+ thousand files are in `src/`. Measured there it is **234kb** against a 15kb budget.
3276+ A directory now draws fifty entries and offers `more`, which carries on from the
3277+ last name, since a tree is in name order and stays in it.
3278+
3279+ **The file view** measured a six line file. Chapter 24 wants blame on every line,
3280+ always, and chapter 25 gives the page 40kb, so the two together bound the page at
3281+ about two hundred lines of source. Measured on an eight hundred line file it is
3282+ **155kb**. The page now fits what it can afford, counting each line rather than
3283+ capping a count, because one long line costs more than one short one. Below the
3284+ last line it says `200 of 800 lines` and links to the whole file.
3285+
3286+ Both numbers are visible product decisions that the book's own budget forces. Flagged
3287+ rather than hidden.
3288+
3289+ ## Three pages measured for the first time, and three are over
3290+
3291+ The budget table names seven paths. Every page not on it has never been measured,
3292+ which is how the log page reached 219kb and the run page reached 190kb. Five more
3293+ were pointed at the big fixture, with a build that says a great deal recorded on
3294+ its tip.
3295+
3296+ | page | time | budget | payload |
3297+ |---|---|---|---|
3298+ | runs | 1ms | 10ms | 1kb |
3299+ | one run | 27ms | 10ms | 13kb |
3300+ | the profile | 32ms | 10ms | 1kb |
3301+ | one commit | 23ms | 20ms | |
3302+
3303+ **The payloads are fine and the times are not**, which is a different disease from
3304+ the four pages before it. Those sent too much. These do too much.
3305+
3306+ **Two costs came off the profile already.** `git symbolic-ref` is a file read now,
3307+ and `git count-objects -v` is a walk of the object directory: both were a process
3308+ each, per repository listed, and a profile lists as many as the account owns. The
3309+ language guess is cached under the tip it was read from, which cannot change under
3310+ that name, so a thousand-file `ls-tree` happens once. 44ms to 32ms.
3311+
3312+ **What is left is structural.** A profile row costs a config load, a ref read, an
3313+ object walk and a scan of every thread to count open proposals. The scan is cached
3314+ per thread, so a repository with fifty threads is fifty small disk reads before the
3315+ row can say `3 proposals`. Chapter 25 says a page gets one or two git invocations;
3316+ this page gets a handful *per repository*, and the mockup draws fourteen.
3317+
3318+ The honest fix is a per-repository summary cached and invalidated on push, so the
3319+ profile reads one record per repository. That is real work and it is not this pass.
3320+
3321+ **The rows are not in the table yet, deliberately.** A row that fails on purpose
3322+ turns a green suite red forever and stops it telling anybody anything. The
3323+ measurements are here instead, so the number is written down and the work is
3324+ visible rather than forgotten.
3325+
3326+ ## Signed commits, and what chapter 23A used to say
3327+
3328+ **23A said signatures are never required, and now three repositories require them.**
3329+ The old sentence was "Never reject a push for being unsigned; that is a policy for
3330+ the project, not for barerepo to enforce." The second half of that is still right,
3331+ so the rule is a per-repository key rather than a server setting:
3332+ `[access] require_signed_commits`, off unless a repository asks. What changed is
3333+ that the project can now hold barerepo to its own policy instead of asking people
3334+ to remember. `barerepo/server`, `barerepo/cli` and `barerepo/runner` set it,
3335+ because those three distribute the program itself. Nothing else on the server is
3336+ affected.
3337+
3338+ **It checks the maths, not just the header.** The first version only looked for a
3339+ `gpgsig` header, which stops somebody forgetting and stops nobody who is trying. It
3340+ verifies now, against an `allowed_signers` file written from the ssh keys accounts
3341+ already published for authentication. Each entry carries `namespaces="git"`, so a
3342+ sign in signature cannot be replayed as a commit signature. The principal is a
3343+ wildcard, because a commit names an address barerepo never issued and has no way to
3344+ tie to an account.
3345+
3346+ **Retiring a key does not delete it.** `DeleteKey` used to remove the row, which
3347+ would have invalidated every commit that key had ever signed the moment somebody
3348+ rotated. It sets `retired_at` now. Live keys go to `authorized_keys`, every key ever
3349+ published goes to `allowed_signers`, and a retired one carries `valid-before`. git
3350+ checks a signature against the commit's own timestamp, so old work still verifies
3351+ and new work signed by the retired key does not.
3352+
3353+ **Two things about that timestamp cost an hour each.** `valid-before` is exclusive,
3354+ so it is written one second after the moment of retirement, or a commit made in the
3355+ same second as the rotation is refused. And it is written in local time with no
3356+ suffix: ssh-keygen reads a bare timestamp as local, and the `Z` form this OpenSSH
3357+ build was given did not parse at all, which silently turned every constraint into a
3358+ refusal. Both are covered by tests that fail if either is undone.
3359+
3360+ **The range is what the ref gains, not what the repository gains.** The first
3361+ version walked `rev-list <new> --not --all`, which skips any commit already in the
3362+ repository. An unsigned commit pushed to `refs/proposals/N` is already in the
3363+ repository, so landing it on master would have passed unread. It walks
3364+ `<new> --not <old>` now, and a whole history on a branch that did not exist before.
@@ -0,0 +1,401 @@
1+ package main
2+
3+ import (
4+ "context"
5+ "errors"
6+ "flag"
7+ "fmt"
8+ "net/http"
9+ "os"
10+ "os/signal"
11+ "path/filepath"
12+ "strings"
13+ "syscall"
14+ "time"
15+
16+ "github.com/barerepo/server/internal/cache"
17+ "github.com/barerepo/server/internal/config"
18+ "github.com/barerepo/server/internal/gitread"
19+ "github.com/barerepo/server/internal/hook"
20+ "github.com/barerepo/server/internal/httpd"
21+ "github.com/barerepo/server/internal/repo"
22+ "github.com/barerepo/server/internal/repocfg"
23+ "github.com/barerepo/server/internal/search"
24+ "github.com/barerepo/server/internal/sshx"
25+ "github.com/barerepo/server/internal/store"
26+ "github.com/barerepo/server/internal/thread"
27+ "github.com/barerepo/server/internal/token"
28+ "github.com/barerepo/server/internal/transport"
29+ )
30+
31+ func cmdInit(ctx context.Context, args []string) error {
32+ fs := flag.NewFlagSet("init", flag.ContinueOnError)
33+ path := configFlag(fs)
34+ if err := fs.Parse(args); err != nil {
35+ return err
36+ }
37+ cfg, err := loadConfig(*path)
38+ if err != nil {
39+ return err
40+ }
41+ for _, d := range []string{cfg.Paths.Repos, cfg.Paths.Cache, cfg.Paths.Artifacts} {
42+ if err := os.MkdirAll(d, 0o750); err != nil {
43+ return err
44+ }
45+ }
46+ db, err := store.Open(ctx, cfg.Database.URL)
47+ if err != nil {
48+ return err
49+ }
50+ defer db.Close()
51+
52+ kind, _ := cfg.DatabaseKind()
53+ fmt.Printf("barerepo is ready.\n\n config %s\n database %s\n repos %s\n\n",
54+ cfg.Path, kind, cfg.Paths.Repos)
55+ fmt.Printf("no accounts exist. make the first one:\n\n" +
56+ " barerepo account create <name> --key \"$(cat ~/.ssh/id_ed25519.pub)\" --admin\n\n")
57+ return nil
58+ }
59+
60+ func cmdServe(ctx context.Context, args []string) error {
61+ fs := flag.NewFlagSet("serve", flag.ContinueOnError)
62+ path := configFlag(fs)
63+ if err := fs.Parse(args); err != nil {
64+ return err
65+ }
66+ cfg, err := loadConfig(*path)
67+ if err != nil {
68+ return err
69+ }
70+ db, err := store.Open(ctx, cfg.Database.URL)
71+ if err != nil {
72+ return err
73+ }
74+ defer db.Close()
75+
76+ // Written at every start, because it lives in the cache and the cache may be deleted at any time.
77+ if err := db.SyncAuthorizedKeys(ctx); err != nil {
78+ logger().Error("writing the key files", "err", err)
79+ }
80+
81+ // The cache is a function of immutable git objects, so it can be deleted at any time.
82+ shared := cache.New(cfg.Paths.Cache)
83+ gitread.Cache = shared
84+ repocfg.Cache = shared
85+ thread.Cache = shared
86+ httpd.Cache = shared
87+
88+ httpd.Site = strings.TrimRight(cfg.Server.ExternalURL, "/")
89+ log := logger()
90+ self, _ := os.Executable()
91+ srv := &httpd.Server{
92+ Cfg: cfg,
93+ DB: db,
94+ Transport: &transport.Server{Cfg: cfg, DB: db, Bin: self},
95+ Log: log,
96+ }
97+ http := &http.Server{
98+ Addr: cfg.Server.Listen,
99+ Handler: srv.Handler(),
100+ ReadHeaderTimeout: 10 * time.Second,
101+ }
102+ // The sweeper only touches discardable things, so it never blocks a start.
103+ go srv.SweepEvery(ctx, time.Hour)
104+ // Webhooks are the escape hatch of chapter 23, and a slow receiver must not slow a push.
105+ go srv.DeliverEvery(ctx, httpd.HookTick)
106+
107+ // A push is objects on disk and then a ref, so a stop between the two is a torn push.
108+ stopping, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
109+ defer stop()
110+ done := make(chan error, 1)
111+ go func() { done <- http.ListenAndServe() }()
112+
113+ log.Info("barerepo listening", "addr", cfg.Server.Listen, "external", cfg.Server.ExternalURL)
114+ select {
115+ case err := <-done:
116+ return err
117+ case <-stopping.Done():
118+ }
119+ log.Info("stopping, letting the pushes in flight finish")
120+ // Long enough for a receive-pack to land, short enough that a deploy is not a wait.
121+ grace, cancel := context.WithTimeout(context.Background(), 30*time.Second)
122+ defer cancel()
123+ if err := http.Shutdown(grace); err != nil {
124+ log.Error("some connections were cut", "err", err)
125+ return err
126+ }
127+ log.Info("stopped cleanly")
128+ return nil
129+ }
130+
131+ func cmdAccount(ctx context.Context, args []string) error {
132+ if len(args) < 2 || args[0] != "create" {
133+ return errors.New("usage: barerepo account create <name> --key <public key>")
134+ }
135+ name := args[1]
136+ fs := flag.NewFlagSet("account create", flag.ContinueOnError)
137+ path := configFlag(fs)
138+ key := fs.String("key", "", "an ssh public key, or a path to one")
139+ admin := fs.Bool("admin", false, "may delete any repository or account")
140+ if err := fs.Parse(args[2:]); err != nil {
141+ return err
142+ }
143+ if *key == "" {
144+ return errors.New("--key is required. it is the whole credential; there is no password")
145+ }
146+ raw := *key
147+ // A path is what a person types first, so accept one instead of demanding contents.
148+ if !strings.Contains(raw, " ") {
149+ if b, err := os.ReadFile(raw); err == nil {
150+ raw = string(b)
151+ }
152+ }
153+ cfg, err := loadConfig(*path)
154+ if err != nil {
155+ return err
156+ }
157+ db, err := store.Open(ctx, cfg.Database.URL)
158+ if err != nil {
159+ return err
160+ }
161+ defer db.Close()
162+
163+ if _, err := db.CreateAccount(ctx, name, raw, *admin); err != nil {
164+ return err
165+ }
166+ fmt.Printf("created %s\n\nadd a second key from another machine today. "+
167+ "losing every key loses the account.\n", name)
168+ return nil
169+ }
170+
171+ func cmdToken(ctx context.Context, args []string) error {
172+ if len(args) < 2 || args[0] != "create" {
173+ return errors.New("usage: barerepo token create <account>")
174+ }
175+ name := args[1]
176+ fs := flag.NewFlagSet("token create", flag.ContinueOnError)
177+ path := configFlag(fs)
178+ label := fs.String("label", "", "what this token is for")
179+ if err := fs.Parse(args[2:]); err != nil {
180+ return err
181+ }
182+ cfg, err := loadConfig(*path)
183+ if err != nil {
184+ return err
185+ }
186+ db, err := store.Open(ctx, cfg.Database.URL)
187+ if err != nil {
188+ return err
189+ }
190+ defer db.Close()
191+
192+ if _, err := db.Account(ctx, name); err != nil {
193+ return fmt.Errorf("no account named %s", name)
194+ }
195+ tok, _, err := db.CreateToken(ctx, token.Git, name, "", *label)
196+ if err != nil {
197+ return err
198+ }
199+ fmt.Printf("%s\n\nit goes in the password field. the username is ignored:\n\n"+
200+ " git clone %s/%s/<repo>\n\n"+
201+ "this is the only time it is shown. revoke it on your keys page.\n",
202+ tok, cfg.Server.ExternalURL, name)
203+ return nil
204+ }
205+
206+ func cmdDoctor(ctx context.Context, args []string) error {
207+ fs := flag.NewFlagSet("doctor", flag.ContinueOnError)
208+ path := configFlag(fs)
209+ fixHooks := fs.Bool("fix-hooks", true, "reinstall hooks in every repository")
210+ reindex := fs.Bool("reindex", false, "rebuild the search index from git")
211+ if err := fs.Parse(args); err != nil {
212+ return err
213+ }
214+ cfg, err := loadConfig(*path)
215+ if err != nil {
216+ return err
217+ }
218+ // The file is a copy of the pubkeys table, so doctor puts it back when it has drifted.
219+ db, err := store.Open(ctx, cfg.Database.URL)
220+ if err != nil {
221+ return err
222+ }
223+ defer db.Close()
224+ if err := db.SyncAuthorizedKeys(ctx); err != nil {
225+ return err
226+ }
227+ if *reindex {
228+ return rebuildIndex(ctx, cfg)
229+ }
230+ if !*fixHooks {
231+ return nil
232+ }
233+ self, err := os.Executable()
234+ if err != nil {
235+ return err
236+ }
237+ n := 0
238+ err = filepath.WalkDir(cfg.Paths.Repos, func(p string, d os.DirEntry, err error) error {
239+ if err != nil || !d.IsDir() || !strings.HasSuffix(p, ".git") {
240+ return err
241+ }
242+ if err := repo.InstallHooks(p, self); err != nil {
243+ return err
244+ }
245+ n++
246+ return filepath.SkipDir
247+ })
248+ if err != nil {
249+ return err
250+ }
251+ fmt.Printf("reinstalled hooks in %d repositories\n", n)
252+ return nil
253+ }
254+
255+ // rebuildIndex is appendix E's reindex: the index is derived, so git is the only thing it needs.
256+ func rebuildIndex(ctx context.Context, cfg config.Config) error {
257+ db, err := store.Open(ctx, cfg.Database.URL)
258+ if err != nil {
259+ return err
260+ }
261+ defer db.Close()
262+
263+ shared := cache.New(cfg.Paths.Cache)
264+ gitread.Cache = shared
265+ repocfg.Cache = shared
266+ thread.Cache = shared
267+
268+ // A rebuild starts empty, so a repository that has gone leaves the index with it.
269+ if err := db.EmptyIndex(ctx); err != nil {
270+ return err
271+ }
272+ n := 0
273+ err = repo.Walk(cfg.Paths.Repos, func(owner, name, dir string) error {
274+ branch, err := repo.HeadBranch(ctx, dir)
275+ if err != nil {
276+ return nil
277+ }
278+ // Load hands back the fallback with the error, and chapter 14 will not let a bad file drop the repository out of search until it parses.
279+ rc, _ := repocfg.Load(ctx, dir)
280+ t := search.Target{Owner: owner, Name: name, Dir: dir, Ref: branch, Config: rc}
281+ if err := search.IndexAll(ctx, db, t); err != nil {
282+ return err
283+ }
284+ n++
285+ return nil
286+ })
287+ if err != nil {
288+ return err
289+ }
290+ fmt.Printf("indexed %d repositories\n\n", n)
291+ fmt.Printf("the index is derived from git, so this can be run at any time.\n")
292+ return nil
293+ }
294+
295+ // cmdSSH reads the attacker-controlled SSH_ORIGINAL_COMMAND and never gives it a shell. 41.3.
296+ func cmdSSH(ctx context.Context, args []string) error {
297+ fs := flag.NewFlagSet("ssh", flag.ContinueOnError)
298+ path := configFlag(fs)
299+ account := fs.String("account", "", "the account this key belongs to")
300+ if err := fs.Parse(args); err != nil {
301+ return err
302+ }
303+ if *account == "" {
304+ return errors.New("no account. authorized_keys must force this command with --account")
305+ }
306+ cfg, err := loadConfig(*path)
307+ if err != nil {
308+ return err
309+ }
310+ db, err := store.Open(ctx, cfg.Database.URL)
311+ if err != nil {
312+ return err
313+ }
314+ defer db.Close()
315+
316+ self, _ := os.Executable()
317+ t := &transport.Server{Cfg: cfg, DB: db, Bin: self}
318+ return sshx.Serve(ctx, t, *account, os.Getenv("SSH_ORIGINAL_COMMAND"))
319+ }
320+
321+ func cmdHook(ctx context.Context, args []string) error {
322+ if len(args) == 0 {
323+ return errors.New("usage: barerepo hook <pre-receive|post-receive>")
324+ }
325+ e := hook.EnvFromOS()
326+
327+ // A hook that cannot reach the database still runs, because a push is not an inbox line.
328+ if cfg, err := config.Load(e.Config); err == nil {
329+ hook.Limits = cfg.Limits
330+ hook.AllowedSigners = filepath.Join(cfg.Paths.Cache, "allowed_signers")
331+ hook.Keyring = filepath.Join(cfg.Paths.Cache, "keyring")
332+ if args[0] == "proc-receive" || args[0] == "post-receive" {
333+ if db, err := store.Open(ctx, cfg.Database.URL); err == nil {
334+ defer db.Close()
335+ hook.Queue = db
336+ hook.Docs = db
337+ }
338+ }
339+ }
340+ // proc-receive speaks pkt-line, so it reads its own input and not the others' ref lines.
341+ if args[0] == "proc-receive" {
342+ return hook.ProcReceive(ctx, e, os.Stdin, os.Stdout, os.Stderr)
343+ }
344+ ups, err := hook.ReadUpdates(os.Stdin)
345+ if err != nil {
346+ return err
347+ }
348+ switch args[0] {
349+ case "pre-receive":
350+ return hook.PreReceive(ctx, e, ups, os.Stderr)
351+ case "post-receive":
352+ return hook.PostReceive(ctx, e, ups, os.Stderr)
353+ default:
354+ return fmt.Errorf("%q is not a hook barerepo installs", args[0])
355+ }
356+ }
357+
358+ // cmdCopy is chapter 21.1: your own direction, without pulling the whole thing down first.
359+ func cmdCopy(ctx context.Context, args []string) error {
360+ fs := flag.NewFlagSet("copy", flag.ContinueOnError)
361+ path := configFlag(fs)
362+ if err := fs.Parse(args); err != nil {
363+ return err
364+ }
365+ rest := fs.Args()
366+ if len(rest) != 2 {
367+ return errors.New("usage: barerepo copy <account>/<repo> <account>/<repo>")
368+ }
369+ srcOwner, srcName, ok1 := strings.Cut(rest[0], "/")
370+ dstOwner, dstName, ok2 := strings.Cut(rest[1], "/")
371+ if !ok1 || !ok2 {
372+ return errors.New("both names are <account>/<repo>")
373+ }
374+ cfg, err := loadConfig(*path)
375+ if err != nil {
376+ return err
377+ }
378+ db, err := store.Open(ctx, cfg.Database.URL)
379+ if err != nil {
380+ return err
381+ }
382+ defer db.Close()
383+
384+ if _, err := db.Account(ctx, dstOwner); err != nil {
385+ return fmt.Errorf("there is no account named %s", dstOwner)
386+ }
387+ self, _ := os.Executable()
388+ if _, err := repo.Copy(ctx, cfg.Paths.Repos, srcOwner, srcName, dstOwner, dstName, self); err != nil {
389+ return err
390+ }
391+ if _, err := db.ExecContext(ctx,
392+ `INSERT INTO repos (owner, name, created_at) VALUES (?, ?, ?)`,
393+ dstOwner, dstName, time.Now().Unix()); err != nil {
394+ return err
395+ }
396+ fmt.Printf("copied %s/%s to %s/%s\n\n", srcOwner, srcName, dstOwner, dstName)
397+ fmt.Printf("branches, tags, history, threads and notes came across.\n")
398+ fmt.Printf("proposal refs did not: they belong to the original conversation.\n")
399+ fmt.Printf("there is no link back. to contribute to the original, push a proposal.\n")
400+ return nil
401+ }
@@ -0,0 +1,111 @@
1+ // Command barerepo is the server, the ssh and hook entry points, and the install commands.
2+ package main
3+
4+ import (
5+ "context"
6+ "errors"
7+ "flag"
8+ "fmt"
9+ "log/slog"
10+ "os"
11+ "path/filepath"
12+
13+ "github.com/barerepo/server/internal/config"
14+ "github.com/barerepo/server/internal/gitx"
15+ "github.com/barerepo/server/internal/hook"
16+ "github.com/barerepo/server/internal/store"
17+ )
18+
19+ const usage = `barerepo
20+
21+ barerepo serve run the server
22+ barerepo init create the database and directory tree
23+ barerepo account create <name> make an account
24+ barerepo token create <name> make a token for git over https
25+ barerepo doctor reinstall hooks in every repository
26+ barerepo doctor --reindex rebuild the search index from git
27+ barerepo copy <src> <dst> copy a repository, server-side
28+
29+ barerepo ssh ssh entry point, called by authorized_keys
30+ barerepo hook <pre-receive|post-receive>
31+ hook entry point, called by git
32+
33+ The client half is br, from github.com/barerepo/cli. Nothing here needs it.
34+
35+ Every server-side command reads ` + config.DefaultPath + `.
36+ `
37+
38+ func main() {
39+ err := run(os.Args[1:])
40+ switch {
41+ case err == nil:
42+ case errors.Is(err, hook.ErrRejected):
43+ // The hook already explained itself in the pusher's terminal.
44+ os.Exit(1)
45+ default:
46+ fmt.Fprintln(os.Stderr, "barerepo: "+err.Error())
47+ os.Exit(1)
48+ }
49+ }
50+
51+ func run(args []string) error {
52+ if len(args) == 0 {
53+ fmt.Print(usage)
54+ return nil
55+ }
56+ ctx := context.Background()
57+ switch args[0] {
58+ case "serve":
59+ return cmdServe(ctx, args[1:])
60+ case "init":
61+ return cmdInit(ctx, args[1:])
62+ case "account":
63+ return cmdAccount(ctx, args[1:])
64+ case "token":
65+ return cmdToken(ctx, args[1:])
66+ case "doctor":
67+ return cmdDoctor(ctx, args[1:])
68+ case "copy":
69+ return cmdCopy(ctx, args[1:])
70+ case "ssh":
71+ return cmdSSH(ctx, args[1:])
72+ case "hook":
73+ return cmdHook(ctx, args[1:])
74+ case "help", "-h", "--help":
75+ fmt.Print(usage)
76+ return nil
77+ default:
78+ return fmt.Errorf("%q is not a barerepo command. run barerepo with no arguments to see them", args[0])
79+ }
80+ }
81+
82+ // configFlag has BAREREPO_CONFIG behind it, so a path is written once and never typed again.
83+ func configFlag(fs *flag.FlagSet) *string {
84+ def := config.DefaultPath
85+ if p := os.Getenv("BAREREPO_CONFIG"); p != "" {
86+ def = p
87+ }
88+ return fs.String("config", def, "path to barerepo.toml, or set BAREREPO_CONFIG")
89+ }
90+
91+ func loadConfig(path string) (config.Config, error) {
92+ cfg, err := config.Load(path)
93+ if err != nil {
94+ return cfg, err
95+ }
96+ if _, err := gitx.Version(context.Background()); err != nil {
97+ return cfg, err
98+ }
99+ // Every entry point that touches git comes through here, including the hook subprocess.
100+ gitx.Identity = cfg.Identity()
101+ store.SSHDir = cfg.Paths.SSHDir
102+ store.SignersPath = filepath.Join(cfg.Paths.Cache, "allowed_signers")
103+ if self, err := os.Executable(); err == nil {
104+ store.SSHBin = self
105+ }
106+ return cfg, nil
107+ }
108+
109+ func logger() *slog.Logger {
110+ return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
111+ }
@@ -0,0 +1,30 @@
1+ [Unit]
2+ Description=barerepo
3+ Documentation=https://github.com/barerepo/book
4+ After=network-online.target
5+ Wants=network-online.target
6+
7+ [Service]
8+ Type=simple
9+ User=git
10+ Group=git
11+ Environment=BAREREPO_CONFIG=/etc/barerepo/barerepo.toml
12+ ExecStart=/usr/local/bin/barerepo serve
13+ Restart=on-failure
14+ RestartSec=2
15+
16+ KillSignal=SIGTERM
17+ TimeoutStopSec=45
18+
19+ NoNewPrivileges=yes
20+ PrivateTmp=yes
21+ ProtectSystem=full
22+ ProtectHome=yes
23+ ReadWritePaths=/var/lib/barerepo
24+ ProtectKernelTunables=yes
25+ ProtectControlGroups=yes
26+ RestrictSUIDSGID=yes
27+ LockPersonality=yes
28+
29+ [Install]
30+ WantedBy=multi-user.target
@@ -0,0 +1,44 @@
1+ package e2e
2+
3+ import (
4+ "net/http"
5+ "os/exec"
6+ "strings"
7+ "testing"
8+ )
9+
10+ // Chapter 24 lists the pages that do not exist, and the whole argument is that they never appear.
11+ func TestThePagesChapter24RefusesDoNotExist(t *testing.T) {
12+ if _, err := exec.LookPath("git"); err != nil {
13+ t.Skip("git is not installed")
14+ }
15+ in := newInstance(t)
16+ john := in.account("john")
17+ seed(t, in, john, "john", "johnbot")
18+
19+ // The discovery pages are absent per rule 7, and the rest per chapter 1.
20+ absent := []string{
21+ "/explore", "/trending", "/stars", "/followers", "/notifications",
22+ "/wiki", "/boards", "/insights", "/marketplace", "/gists",
23+ "/organizations", "/tour", "/edit",
24+ "/john/johnbot/wiki", "/john/johnbot/insights", "/john/johnbot/stars",
25+ "/john/johnbot/settings", "/john/johnbot/branches/protect", "/john/johnbot/merge-queue",
26+ }
27+ for _, path := range absent {
28+ code, _, _ := get(t, in.http.URL+path)
29+ if code != http.StatusNotFound {
30+ t.Errorf("%s answered %d, and chapter 24 says it does not exist", path, code)
31+ }
32+ }
33+
34+ // And the one BUILD.md names as a trap, because every request for it is a request to be GitHub.
35+ code, _, page := get(t, in.http.URL+"/john/johnbot/threads")
36+ if code != http.StatusOK {
37+ t.Fatalf("the threads page answered %d", code)
38+ }
39+ for _, button := range []string{">merge<", "merge pull", "squash and merge", "rebase and merge"} {
40+ if strings.Contains(strings.ToLower(page), button) {
41+ t.Errorf("the thread list offers %q, and BUILD.md says the answer is a fetch command", button)
42+ }
43+ }
44+ }
@@ -0,0 +1,183 @@
1+ package e2e
2+
3+ import (
4+ "bytes"
5+ "context"
6+ "net/http"
7+ "net/url"
8+ "os/exec"
9+ "strconv"
10+ "strings"
11+ "testing"
12+
13+ "github.com/barerepo/server/internal/token"
14+ )
15+
16+ // Chapter 22.5: a run attaches its output to a release, and the job token carries the permission.
17+ func TestABuildAttachesAFileToARelease(t *testing.T) {
18+ if _, err := exec.LookPath("git"); err != nil {
19+ t.Skip("git is not installed")
20+ }
21+ in := newInstance(t)
22+ john := in.account("john")
23+ work := seed(t, in, john, "john", "johnbot")
24+ run(t, work, "git", "tag", "-a", "v1.0.0", "-m", "first release")
25+ run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "v1.0.0")
26+
27+ jobID, jobToken := takeJob(t, in, "john", "john/johnbot")
28+
29+ body := bytes.Repeat([]byte("a compiled binary, more or less\n"), 100)
30+ if code := upload(t, in, jobToken, jobID, "v1.0.0", "johnbot-linux-amd64", body); code != http.StatusNoContent {
31+ t.Fatalf("the upload answered %d", code)
32+ }
33+
34+ // The releases page names the file and its size, as releases.html has it.
35+ code, _, page := get(t, in.http.URL+"/john/johnbot/releases")
36+ if code != http.StatusOK {
37+ t.Fatalf("the releases page answered %d", code)
38+ }
39+ if !strings.Contains(page, "johnbot-linux-amd64") {
40+ t.Errorf("the releases page does not name the attached file:\n%s", page)
41+ }
42+ if !strings.Contains(page, "3.1kb") {
43+ t.Errorf("the releases page does not give the file's size:\n%s", page)
44+ }
45+
46+ // And it downloads, byte for byte, as an attachment and never as a page.
47+ url := in.http.URL + "/john/johnbot/release/v1.0.0/johnbot-linux-amd64"
48+ resp, err := http.Get(url)
49+ if err != nil {
50+ t.Fatal(err)
51+ }
52+ defer resp.Body.Close()
53+ got := readAll(t, resp)
54+ if got != string(body) {
55+ t.Errorf("the download is %d bytes, wanted %d", len(got), len(body))
56+ }
57+ if d := resp.Header.Get("Content-Disposition"); !strings.HasPrefix(d, "attachment") {
58+ t.Errorf("an attached file is served as %q, which a browser may render", d)
59+ }
60+ if resp.Header.Get("X-Content-Type-Options") != "nosniff" {
61+ t.Error("the download does not say nosniff, so a browser may guess its type")
62+ }
63+ }
64+
65+ // A tag that is not in the repository has no release to attach to.
66+ func TestAnArtifactNeedsARealTag(t *testing.T) {
67+ if _, err := exec.LookPath("git"); err != nil {
68+ t.Skip("git is not installed")
69+ }
70+ ctx := context.Background()
71+ in := newInstance(t)
72+ john := in.account("john")
73+ seed(t, in, john, "john", "johnbot")
74+
75+ jobID, jobToken := takeJob(t, in, "john", "john/johnbot")
76+
77+ if code := upload(t, in, jobToken, jobID, "v9.9.9", "x", []byte("hi")); code != http.StatusNotFound {
78+ t.Errorf("attaching to a tag that is not here answered %d", code)
79+ }
80+ // A name that would leave the directory is not a file name.
81+ if code := upload(t, in, jobToken, jobID, "v1.0.0", "../escape", []byte("hi")); code == http.StatusNoContent {
82+ t.Error("a file name with a path in it was accepted")
83+ }
84+ // A token for the repository that is not this job's token is not enough. 22.5.
85+ other, _, err := in.db.CreateToken(ctx, token.Git, "john", "john/johnbot", "a plain git token")
86+ if err != nil {
87+ t.Fatal(err)
88+ }
89+ if code := upload(t, in, other, jobID, "v1.0.0", "x", []byte("hi")); code != http.StatusUnauthorized {
90+ t.Errorf("a token that is not this job's uploaded anyway: %d", code)
91+ }
92+ }
93+
94+ func upload(t *testing.T, in *instance, token string, jobID int64, tag, file string, body []byte) int {
95+ t.Helper()
96+ req, err := http.NewRequest(http.MethodPost, in.http.URL+"/runner/artifact", bytes.NewReader(body))
97+ if err != nil {
98+ t.Fatal(err)
99+ }
100+ req.Header.Set("Barerepo-Token", token)
101+ req.Header.Set("Barerepo-Job", strconv.FormatInt(jobID, 10))
102+ req.Header.Set("Barerepo-Tag", tag)
103+ req.Header.Set("Barerepo-File", file)
104+ resp, err := http.DefaultClient.Do(req)
105+ if err != nil {
106+ t.Fatal(err)
107+ }
108+ defer resp.Body.Close()
109+ return resp.StatusCode
110+ }
111+
112+ // takeJob makes a runner, queues a job, takes it, and issues the job token the poll would have.
113+ func takeJob(t *testing.T, in *instance, account, repo string) (int64, string) {
114+ id, jobToken, _ := takeJobAs(t, in, account, repo)
115+ return id, jobToken
116+ }
117+
118+ // takeJobAs also hands back the runner's own token, which is what the log and done calls carry.
119+ func takeJobAs(t *testing.T, in *instance, account, repo string) (int64, string, string) {
120+ t.Helper()
121+ ctx := context.Background()
122+ runnerToken, tok, err := in.db.CreateToken(ctx, token.Runner, account, repo, "a runner")
123+ if err != nil {
124+ t.Fatal(err)
125+ }
126+ runner, err := in.db.AttachRunner(ctx, tok.ID, repo, "uproar.local", "linux", "amd64", nil)
127+ if err != nil {
128+ t.Fatal(err)
129+ }
130+ if _, err := in.db.QueueJob(ctx, repo, "refs/heads/master", "abc", "go build", ""); err != nil {
131+ t.Fatal(err)
132+ }
133+ job, err := in.db.TakeJob(ctx, repo, *runner)
134+ if err != nil || job == nil {
135+ t.Fatalf("the runner could not take a job: %v", err)
136+ }
137+ jobToken, _, err := in.db.CreateToken(ctx, token.Git, account, repo,
138+ "job "+strconv.FormatInt(job.ID, 10))
139+ if err != nil {
140+ t.Fatal(err)
141+ }
142+ return job.ID, jobToken, runnerToken
143+ }
144+
145+ // A repository that moves takes its attached files, which are not in git and move with nothing else.
146+ func TestARenameCarriesTheAttachedFiles(t *testing.T) {
147+ if _, err := exec.LookPath("git"); err != nil {
148+ t.Skip("git is not installed")
149+ }
150+ in := newInstance(t)
151+ john := in.account("john")
152+ in.account("lisa")
153+ work := seed(t, in, john, "john", "johnbot")
154+ run(t, work, "git", "tag", "-a", "v1.0.0", "-m", "first release")
155+ run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "v1.0.0")
156+
157+ jobID, jobToken := takeJob(t, in, "john", "john/johnbot")
158+ if code := upload(t, in, jobToken, jobID, "v1.0.0", "bot-linux", []byte("binary")); code != http.StatusNoContent {
159+ t.Fatalf("the upload answered %d", code)
160+ }
161+
162+ resp := post(t, in, "john", "/john/johnbot/rename", url.Values{"name": {"ircbot"}})
163+ if resp.StatusCode != http.StatusFound {
164+ t.Fatalf("the rename answered %d", resp.StatusCode)
165+ }
166+ if _, _, page := get(t, in.http.URL+"/john/ircbot/releases"); !strings.Contains(page, "bot-linux") {
167+ t.Errorf("the rename left the attached file behind:\n%s", page)
168+ }
169+ code, _, body := get(t, in.http.URL+"/john/ircbot/release/v1.0.0/bot-linux")
170+ if code != http.StatusOK || body != "binary" {
171+ t.Errorf("the file does not download at the new name: %d %q", code, body)
172+ }
173+
174+ // A transfer moves the owner, which is the other half of the path the files live under.
175+ resp = post(t, in, "john", "/john/ircbot/transfer",
176+ url.Values{"owner": {"lisa"}, "confirm": {"ircbot"}})
177+ if resp.StatusCode != http.StatusFound {
178+ t.Fatalf("the transfer answered %d", resp.StatusCode)
179+ }
180+ if _, _, page := get(t, in.http.URL+"/lisa/ircbot/releases"); !strings.Contains(page, "bot-linux") {
181+ t.Errorf("the transfer left the attached file behind:\n%s", page)
182+ }
183+ }
@@ -0,0 +1,73 @@
1+ package e2e
2+
3+ import (
4+ "net/http"
5+ "os/exec"
6+ "strings"
7+ "testing"
8+ )
9+
10+ // Chapter 14: a malformed config falls back to the last version that parsed, not to the defaults.
11+ func TestABrokenConfigKeepsTheSettingsThatWorked(t *testing.T) {
12+ if _, err := exec.LookPath("git"); err != nil {
13+ t.Skip("git is not installed")
14+ }
15+ in := newInstance(t)
16+ john := in.account("john")
17+ lisa := in.account("lisa")
18+ work := seed(t, in, john, "john", "johnbot")
19+
20+ write(t, work, ".barerepo/config", "[repo]\nvisibility = \"public\"\n\n[access]\npush = [\"john\", \"lisa\"]\n")
21+ run(t, work, "git", "add", "-A")
22+ run(t, work, "git", "commit", "-qm", "open it up and let lisa push")
23+ run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "master")
24+
25+ // A stranger can read it and lisa can push, which is what the good file bought.
26+ if code, _, _ := get(t, in.http.URL+"/john/johnbot"); code != http.StatusOK {
27+ t.Fatalf("the repository is not public yet, so the test proves nothing later")
28+ }
29+
30+ // One unclosed bracket, which is the shape of every real config typo.
31+ write(t, work, ".barerepo/config", "[repo\nvisibility = \"public\"\n")
32+ run(t, work, "git", "add", "-A")
33+ run(t, work, "git", "commit", "-qm", "a typo in the config")
34+ out := run(t, work, "git", "push", in.url(john, "/john/johnbot"), "master")
35+
36+ // The push that makes the typo is the one that reports it, not whoever pushes next.
37+ if !strings.Contains(out, "does not parse") {
38+ t.Errorf("the push that broke the file said nothing about it:\n%s", out)
39+ }
40+
41+ // And every later push says which older version is doing the deciding.
42+ write(t, work, "conn.go", "package irc\n")
43+ run(t, work, "git", "add", "-A")
44+ run(t, work, "git", "commit", "-qm", "an unrelated change")
45+ later := run(t, work, "git", "push", in.url(john, "/john/johnbot"), "master")
46+ if !strings.Contains(later, "does not parse") {
47+ t.Errorf("a later push said nothing about the broken file:\n%s", later)
48+ }
49+ if !strings.Contains(later, "still in force") {
50+ t.Errorf("a later push did not name the settings that are deciding:\n%s", later)
51+ }
52+
53+ // The defaults are private, so a public repository is proof the old file is still being read.
54+ if code, _, _ := get(t, in.http.URL+"/john/johnbot"); code != http.StatusOK {
55+ t.Errorf("a typo in the config took the repository private, and chapter 14 forbids that")
56+ }
57+
58+ // And the access list survived, which is the clause about not locking anyone out.
59+ mineDir := clone(t, in, lisa, "/john/johnbot")
60+ commit(t, mineDir, "package main\n\nfunc main() {}\n", "lisa can still push")
61+ if _, err := try(t, mineDir, "git", "push", in.url(lisa, "/john/johnbot"), "master"); err != nil {
62+ t.Errorf("lisa lost her push access to a typo: %v", err)
63+ }
64+
65+ // A reader of the config page is reading the broken file and must be told it is not in force.
66+ code, _, body := get(t, in.http.URL+"/john/johnbot/config")
67+ if code != http.StatusOK {
68+ t.Fatalf("the config page answered %d", code)
69+ }
70+ if !strings.Contains(body, "does not parse") {
71+ t.Errorf("the config page shows the broken file as though it were in force:\n%s", body)
72+ }
73+ }
@@ -0,0 +1,102 @@
1+ package e2e
2+
3+ import (
4+ "fmt"
5+ "os/exec"
6+ "strings"
7+ "testing"
8+ )
9+
10+ // Chapter 20.2: LFS off is not enough without a size limit, and the message must name the file.
11+ func TestAnOversizedBlobIsRejectedByName(t *testing.T) {
12+ if _, err := exec.LookPath("git"); err != nil {
13+ t.Skip("git is not installed")
14+ }
15+ in := newInstance(t)
16+ john := in.account("john")
17+ work := seed(t, in, john, "john", "johnbot")
18+
19+ setLimits(t, in, "\n[limits]\nmax_blob_mb = 1\n")
20+
21+ big := strings.Repeat("video frame padding, not source code\n", 60000)
22+ write(t, work, "demo.mov", big)
23+ write(t, work, "notes.txt", "small and fine\n")
24+ run(t, work, "git", "add", "-A")
25+ run(t, work, "git", "commit", "-qm", "add a video")
26+
27+ out, err := try(t, work, "git", "push", in.url(john, "/john/johnbot"), "master")
28+ if err == nil {
29+ t.Fatalf("a 2mb blob was accepted against a 1mb limit:\n%s", out)
30+ }
31+ if !strings.Contains(out, "demo.mov") {
32+ t.Errorf("the rejection does not name the file, so the user has to hunt:\n%s", out)
33+ }
34+ if !strings.Contains(out, "the limit is 1mb") {
35+ t.Errorf("the rejection does not say what the limit is:\n%s", out)
36+ }
37+ if !strings.Contains(out, "object storage") {
38+ t.Errorf("chapter 20.4 says to tell the user where large files belong:\n%s", out)
39+ }
40+ if strings.Contains(out, "notes.txt") {
41+ t.Errorf("the rejection blames a file that is under the limit:\n%s", out)
42+ }
43+
44+ // The push is all or nothing, so nothing landed.
45+ if _, _, body := get(t, in.http.URL+"/john/johnbot"); strings.Contains(body, "add a video") {
46+ t.Error("the rejected commit is on the log anyway")
47+ }
48+ }
49+
50+ // A push under the limit must not pay for the check with a rejection.
51+ func TestASmallPushIsNotAffectedByTheBlobLimit(t *testing.T) {
52+ if _, err := exec.LookPath("git"); err != nil {
53+ t.Skip("git is not installed")
54+ }
55+ in := newInstance(t)
56+ john := in.account("john")
57+ work := seed(t, in, john, "john", "johnbot")
58+
59+ setLimits(t, in, "\n[limits]\nmax_blob_mb = 1\n")
60+
61+ write(t, work, "small.go", "package main\n\nfunc main() {}\n")
62+ run(t, work, "git", "add", "-A")
63+ run(t, work, "git", "commit", "-qm", "a small change")
64+ run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "master")
65+
66+ if _, _, body := get(t, in.http.URL+"/john/johnbot"); !strings.Contains(body, "a small change") {
67+ t.Error("a push under the limit did not land")
68+ }
69+ }
70+
71+ // Chapter 20.2 bounds the whole push as well as one file in it, and the two are one pass of git.
72+ func TestAPushOverTheWholeLimitIsRejected(t *testing.T) {
73+ if _, err := exec.LookPath("git"); err != nil {
74+ t.Skip("git is not installed")
75+ }
76+ in := newInstance(t)
77+ john := in.account("john")
78+ work := seed(t, in, john, "john", "johnbot")
79+ setLimits(t, in, "\n[limits]\nmax_blob_mb = 4\nmax_push_mb = 1\n")
80+
81+ // Three files, each under the blob limit, together over the push limit.
82+ for i := 0; i < 3; i++ {
83+ write(t, work, fmt.Sprintf("part%d.bin", i),
84+ strings.Repeat(fmt.Sprintf("chunk %d padding padding padding\n", i), 15000))
85+ }
86+ run(t, work, "git", "add", "-A")
87+ run(t, work, "git", "commit", "-qm", "a first import")
88+
89+ out, err := try(t, work, "git", "push", in.url(john, "/john/johnbot"), "master")
90+ if err == nil {
91+ t.Fatalf("a push over the whole limit was accepted:\n%s", out)
92+ }
93+ if strings.Contains(out, "part0.bin") {
94+ t.Errorf("the whole push was refused by naming one file, which is the other limit:\n%s", out)
95+ }
96+ if !strings.Contains(out, "the limit is 1mb") {
97+ t.Errorf("the rejection does not say what the limit is:\n%s", out)
98+ }
99+ if !strings.Contains(out, "max_push_mb") {
100+ t.Errorf("the rejection does not say which setting to raise:\n%s", out)
101+ }
102+ }
@@ -0,0 +1,260 @@
1+ package e2e
2+
3+ import (
4+ "context"
5+ "fmt"
6+ "net/http"
7+ "net/http/httptest"
8+ "os/exec"
9+ "sort"
10+ "strings"
11+ "testing"
12+ "time"
13+
14+ "github.com/barerepo/server/internal/cache"
15+ "github.com/barerepo/server/internal/gitread"
16+ "github.com/barerepo/server/internal/httpd"
17+ "github.com/barerepo/server/internal/repocfg"
18+ runpkg "github.com/barerepo/server/internal/run"
19+ "github.com/barerepo/server/internal/search"
20+ "github.com/barerepo/server/internal/thread"
21+ "github.com/barerepo/server/internal/transport"
22+ )
23+
24+ // Chapter 25's budget as build-failing thresholds, in server-side render time only.
25+ var budget = []struct {
26+ page string
27+ path string
28+ time time.Duration
29+ payload int
30+ }{
31+ {"file tree", "/john/big/files/master/src", 10 * time.Millisecond, 15 << 10},
32+ {"log", "/john/big", 20 * time.Millisecond, 30 << 10},
33+ {"file view with blame", "/john/big/file/master/src/large.go", 20 * time.Millisecond, 40 << 10},
34+ {"threads list", "/john/big/threads", 10 * time.Millisecond, 15 << 10},
35+ {"one thread", "/john/big/thread/1", 10 * time.Millisecond, 15 << 10},
36+ {"releases", "/john/big/releases", 10 * time.Millisecond, 15 << 10},
37+ {"search", "/search?q=file0500", 10 * time.Millisecond, 15 << 10},
38+ {"runs", "/john/big/runs", 10 * time.Millisecond, 15 << 10},
39+ // The run, commit and profile pages are over in SPEC-NOTES and are not listed yet, because a row that fails on purpose tells the suite nothing.
40+ }
41+
42+ // TestBudget fails on purpose, because a threshold raised to match the code measures nothing.
43+ func TestBudget(t *testing.T) {
44+ if testing.Short() {
45+ t.Skip("building a large repository takes a while")
46+ }
47+ if _, err := exec.LookPath("git"); err != nil {
48+ t.Skip("git is not installed")
49+ }
50+ in := newInstance(t)
51+ in.account("john")
52+ dir := buildBig(t, in, "john", "big", 1000, 200)
53+ buildThreads(t, dir, 50)
54+ buildTags(t, dir, 100)
55+ // The sweep gcs every repository, which packs its refs, so that is the state to measure in.
56+ run(t, dir, "git", "pack-refs", "--all")
57+ indexBig(t, in, dir, "john", "big")
58+ // A build that said a great deal, because a quiet one measures the chrome and not the page.
59+ head := strings.TrimSpace(run(t, dir, "git", "rev-parse", "master"))
60+ buildRuns(t, in, dir, "john/big", head)
61+ for i := range budget {
62+ budget[i].path = strings.ReplaceAll(budget[i].path, "HEADSHA", head)
63+ }
64+
65+ shared := cache.New(in.cfg.Paths.Cache)
66+ gitread.Cache = shared
67+ repocfg.Cache = shared
68+ thread.Cache = shared
69+ httpd.Cache = shared
70+ srv := &httpd.Server{Cfg: in.cfg, DB: in.db,
71+ Transport: &transport.Server{Cfg: in.cfg, DB: in.db, Bin: binary}}
72+ handler := srv.Handler()
73+
74+ for _, b := range budget {
75+ // Warm once, because the budget is what a reader waits for, not a cold start.
76+ record(handler, b.path)
77+
78+ var best time.Duration
79+ var size int
80+ for i := 0; i < 5; i++ {
81+ start := time.Now()
82+ rec := record(handler, b.path)
83+ took := time.Since(start)
84+ if i == 0 || took < best {
85+ best = took
86+ }
87+ size = rec.Body.Len()
88+ if rec.Code != http.StatusOK {
89+ t.Fatalf("%s: %s answered %d", b.page, b.path, rec.Code)
90+ }
91+ }
92+ if best > b.time {
93+ t.Errorf("%s took %s, over the %s budget in chapter 25", b.page, best.Round(time.Millisecond), b.time)
94+ }
95+ if size > b.payload {
96+ t.Errorf("%s sent %dkb, over the %dkb budget", b.page, size>>10, b.payload>>10)
97+ }
98+ t.Logf("%-22s %6s %5dkb (budget %s, %dkb)",
99+ b.page, best.Round(100*time.Microsecond), size>>10, b.time, b.payload>>10)
100+ }
101+ }
102+
103+ func record(h http.Handler, path string) *httptest.ResponseRecorder {
104+ req := httptest.NewRequest(http.MethodGet, path, nil)
105+ rec := httptest.NewRecorder()
106+ h.ServeHTTP(rec, req)
107+ return rec
108+ }
109+
110+ // buildBig uses plumbing, because a working tree would measure git's index rather than barerepo.
111+ func buildBig(t *testing.T, in *instance, owner, name string, files, commits int) string {
112+ t.Helper()
113+ work := t.TempDir()
114+ run(t, work, "git", "init", "-q", "--bare", "-b", "master")
115+
116+ // One blob reused, so the setup is about tree and commit count, not a thousand files.
117+ var entries strings.Builder
118+ for i := 0; i < files; i++ {
119+ body := fmt.Sprintf("package src\n\n// file %d\nfunc F%d() int {\n\treturn %d\n}\n", i, i, i)
120+ entries.WriteString("100644 blob " + hashObject(t, work, body) + "\t" + fmt.Sprintf("file%04d.go", i) + "\n")
121+ }
122+ // One file long enough to measure blame on, since a page of six lines measures the chrome.
123+ var large strings.Builder
124+ large.WriteString("package src\n\n")
125+ for i := 0; i < 800; i++ {
126+ fmt.Fprintf(&large, "func Large%d() string { return \"line %d of a file somebody reads\" }\n", i, i)
127+ }
128+ entries.WriteString("100644 blob " + hashObject(t, work, large.String()) + "\tlarge.go\n")
129+ srcTree := mktree(t, work, entries.String())
130+ config := hashObject(t, work, "[repo]\nvisibility = \"public\"\n")
131+
132+ // current carries every blob forward, or a commit reverts what the one before it wrote.
133+ current := map[string]string{}
134+ for _, line := range strings.Split(strings.TrimRight(entriesOf(t, work, srcTree), "\n"), "\n") {
135+ if line == "" {
136+ continue
137+ }
138+ current[line[strings.LastIndex(line, "\t")+1:]] = strings.Fields(line)[2]
139+ }
140+
141+ parent := ""
142+ for c := 0; c < commits; c++ {
143+ // Three files change by ten lines each, which is under the per-commit collapse rule.
144+ for k := 0; k < 3; k++ {
145+ name := fmt.Sprintf("file%04d.go", (c*3+k)%files)
146+ var body strings.Builder
147+ fmt.Fprintf(&body, "package src\n\n// %s\n", name)
148+ for line := 0; line < 40; line++ {
149+ value := line
150+ if line >= 10 && line < 20 {
151+ value = line * (c + 1)
152+ }
153+ fmt.Fprintf(&body, "func F%d() int { return %d }\n", line, value)
154+ }
155+ current[name] = hashObject(t, work, body.String())
156+ }
157+ names := make([]string, 0, len(current))
158+ for name := range current {
159+ names = append(names, name)
160+ }
161+ sort.Strings(names)
162+ var e strings.Builder
163+ for _, name := range names {
164+ e.WriteString("100644 blob " + current[name] + "\t" + name + "\n")
165+ }
166+ tree := mktree(t, work,
167+ "040000 tree "+mktree(t, work, e.String())+"\tsrc\n"+
168+ "040000 tree "+mktree(t, work, "100644 blob "+config+"\tconfig\n")+"\t.barerepo\n")
169+ args := []string{"commit-tree", tree, "-m", fmt.Sprintf("change %d", c)}
170+ if parent != "" {
171+ args = append(args, "-p", parent)
172+ }
173+ parent = run(t, work, "git", args...)
174+ }
175+ run(t, work, "git", "update-ref", "refs/heads/master", parent)
176+
177+ dst := repoDir(t, in, owner, name)
178+ run(t, "", "git", "clone", "-q", "--bare", work, dst)
179+ run(t, dst, "git", "symbolic-ref", "HEAD", "refs/heads/master")
180+ if _, err := in.db.ExecContext(t.Context(),
181+ `INSERT INTO repos (owner, name, created_at) VALUES (?, ?, ?)`, owner, name, 0); err != nil {
182+ t.Fatal(err)
183+ }
184+ return dst
185+ }
186+
187+ func entriesOf(t *testing.T, dir, tree string) string {
188+ t.Helper()
189+ return run(t, dir, "git", "ls-tree", tree)
190+ }
191+
192+ // buildThreads writes enough discussion to measure, since the old cost was per thread.
193+ func buildThreads(t *testing.T, dir string, count int) {
194+ t.Helper()
195+ ctx := context.Background()
196+ tip := strings.TrimSpace(run(t, dir, "git", "rev-parse", "master"))
197+ for n := 1; n <= count; n++ {
198+ meta := thread.Meta{
199+ Title: fmt.Sprintf("thread number %d", n), State: thread.Open,
200+ Author: "lisa", Opened: time.Now(),
201+ }
202+ if err := thread.Write(ctx, dir, n, "open", func(tr *thread.Tree) { tr.Meta = meta }); err != nil {
203+ t.Fatal(err)
204+ }
205+ // The first thread is the one the budget reads, so it carries a discussion and not a stub.
206+ replies := 3
207+ if n == 1 {
208+ replies = 40
209+ }
210+ for i := 0; i < replies; i++ {
211+ c := thread.Comment{Author: "mark", Time: time.Now(), Body: fmt.Sprintf(
212+ "reply %d. the reconnect loop spins because the backoff is computed from a "+
213+ "counter that is reset in the same branch that increments it, so it never "+
214+ "reaches the second delay and the socket is hammered.", i)}
215+ if err := thread.Reply(ctx, dir, n, tip, c); err != nil {
216+ t.Fatal(err)
217+ }
218+ }
219+ }
220+ }
221+
222+ // buildTags gives the releases page something to read, because one tag measures nothing.
223+ func buildTags(t *testing.T, dir string, count int) {
224+ t.Helper()
225+ tip := strings.TrimSpace(run(t, dir, "git", "rev-parse", "master"))
226+ for n := 1; n <= count; n++ {
227+ tag := fmt.Sprintf("v1.0.%d", n)
228+ run(t, dir, "git", "tag", "-a", tag, "-m", "release "+tag, tip)
229+ obj := strings.TrimSpace(run(t, dir, "git", "rev-parse", tag))
230+ run(t, dir, "git", "notes", "--ref=refs/notes/releases", "add", "-m", "what changed in "+tag, obj)
231+ }
232+ }
233+
234+ // indexBig fills chapter 17's index the way a push would, since this repository was built by plumbing.
235+ func indexBig(t *testing.T, in *instance, dir, owner, name string) {
236+ t.Helper()
237+ ctx := t.Context()
238+ cfg, err := repocfg.Load(ctx, dir)
239+ if err != nil {
240+ t.Fatal(err)
241+ }
242+ target := search.Target{Owner: owner, Name: name, Dir: dir, Ref: "master", Config: cfg}
243+ if err := search.IndexAll(ctx, in.db, target); err != nil {
244+ t.Fatal(err)
245+ }
246+ }
247+
248+ // buildRuns records one loud build on a commit, since a page is only measured by what it carries.
249+ func buildRuns(t *testing.T, in *instance, dir, repo, sha string) {
250+ t.Helper()
251+ var log strings.Builder
252+ for i := 0; i < 4000; i++ {
253+ log.WriteString("go: downloading example.com/some/module v1.2.3\n")
254+ }
255+ rec := runpkg.Record{Runner: "uproar.local", Name: "build", Ref: "refs/heads/master",
256+ Started: time.Now().Add(-time.Minute).Unix(), Duration: 18, Exit: 1}
257+ if err := runpkg.Append(context.Background(), dir, sha, rec, log.String()); err != nil {
258+ t.Fatal(err)
259+ }
260+ }
@@ -0,0 +1,76 @@
1+ package e2e
2+
3+ import (
4+ "net/http"
5+ "os/exec"
6+ "strings"
7+ "testing"
8+ )
9+
10+ // A stylesheet a browser cannot keep is refetched on every click, and the page flashes unstyled.
11+ func TestTheStylesheetIsKeptBetweenPages(t *testing.T) {
12+ if _, err := exec.LookPath("git"); err != nil {
13+ t.Skip("git is not installed")
14+ }
15+ in := newInstance(t)
16+ john := in.account("john")
17+ seed(t, in, john, "john", "johnbot")
18+
19+ // Every page asks for the stylesheet under the running version, which is what makes it keepable.
20+ _, _, body := get(t, in.http.URL+"/john/johnbot")
21+ i := strings.Index(body, "/static/barerepo.css")
22+ if i < 0 {
23+ t.Fatalf("no stylesheet on the page:\n%s", body)
24+ }
25+ href := body[i : strings.Index(body[i:], `"`)+i]
26+ if !strings.Contains(href, "?v=") {
27+ t.Errorf("the stylesheet url carries no version, so it can only be cached by guessing: %s", href)
28+ }
29+
30+ resp, err := http.Get(in.http.URL + href)
31+ if err != nil {
32+ t.Fatal(err)
33+ }
34+ defer resp.Body.Close()
35+ cc := resp.Header.Get("Cache-Control")
36+ if !strings.Contains(cc, "immutable") || !strings.Contains(cc, "max-age=") {
37+ t.Errorf("the versioned stylesheet answered Cache-Control %q, so a browser refetches it", cc)
38+ }
39+
40+ // And a bookmarked url with no version still says something, or that request flashes too.
41+ plain, err := http.Get(in.http.URL + "/static/barerepo.css")
42+ if err != nil {
43+ t.Fatal(err)
44+ }
45+ defer plain.Body.Close()
46+ if plain.Header.Get("Cache-Control") == "" {
47+ t.Error("an unversioned stylesheet answers with no caching policy at all")
48+ }
49+ }
50+
51+ // A url cached for a year must change when the file does, or an edit reaches nobody who has it.
52+ func TestTheStylesheetUrlIsNotTheReleaseNumber(t *testing.T) {
53+ if _, err := exec.LookPath("git"); err != nil {
54+ t.Skip("git is not installed")
55+ }
56+ in := newInstance(t)
57+ john := in.account("john")
58+ seed(t, in, john, "john", "johnbot")
59+
60+ _, _, body := get(t, in.http.URL+"/john/johnbot")
61+ i := strings.Index(body, "/static/barerepo.css")
62+ href := body[i : strings.Index(body[i:], `"`)+i]
63+ _, tag, _ := strings.Cut(href, "?v=")
64+
65+ // The version does not move between builds, so a stylesheet keyed on it is kept after an edit.
66+ if tag == "" || strings.Contains(body, "?v=0.1.0") {
67+ t.Errorf("the stylesheet is cached for a year under %q, which the next edit will not change", tag)
68+ }
69+ // Every page agrees, or one of them hands out a url that pins an old stylesheet.
70+ for _, path := range []string{"/john/johnbot/files", "/john/johnbot/config", "/signin"} {
71+ _, _, page := get(t, in.http.URL+path)
72+ if !strings.Contains(page, "?v="+tag) {
73+ t.Errorf("%s asks for a different stylesheet url than the log page does", path)
74+ }
75+ }
76+ }
@@ -0,0 +1,98 @@
1+ package e2e
2+
3+ import (
4+ "fmt"
5+ "net/http"
6+ "os"
7+ "os/exec"
8+ "regexp"
9+ "strings"
10+ "testing"
11+
12+ "github.com/barerepo/server/internal/gitread"
13+ "github.com/barerepo/server/internal/httpd"
14+ "github.com/barerepo/server/internal/repocfg"
15+ "github.com/barerepo/server/internal/thread"
16+ )
17+
18+ // elapsed matches the relative times a page prints, which move between two fetches and mean nothing here.
19+ var elapsed = regexp.MustCompile(`\b\d+[smhd]\b|\b\d+ (?:second|minute|hour|day|week|month|year)s? ago`)
20+
21+ // A cache may make a page fast. It must never be the only place an answer lives. Chapter 10 item 6.
22+ func TestEveryPageRendersTheSameWithAnEmptyCache(t *testing.T) {
23+ if _, err := exec.LookPath("git"); err != nil {
24+ t.Skip("git is not installed")
25+ }
26+ in := newInstance(t)
27+ john := in.account("john")
28+ work := seed(t, in, john, "john", "johnbot")
29+
30+ write(t, work, "retry.go", "package main\n\nfunc backoff(n int) int { return n * 2 }\n")
31+ // Seven files, so the log collapses this one and the expand link has something to open.
32+ for i := 0; i < 7; i++ {
33+ write(t, work, fmt.Sprintf("file%d.go", i), fmt.Sprintf("package main\n\nvar v%d = %d\n", i, i))
34+ }
35+ run(t, work, "git", "add", "-A")
36+ run(t, work, "git", "commit", "-qm", "add a backoff")
37+ run(t, work, "git", "tag", "-a", "v1.0.0", "-m", "first release")
38+ run(t, work, "git", "notes", "--ref=refs/notes/releases", "add", "-m", "what changed", "v1.0.0")
39+ run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "master")
40+ run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "v1.0.0")
41+ run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"),
42+ "refs/notes/releases:refs/notes/releases")
43+
44+ head := strings.TrimSpace(run(t, work, "git", "rev-parse", "HEAD"))
45+ paths := []string{
46+ "/john",
47+ "/john/johnbot",
48+ "/john/johnbot?expand=" + head,
49+ "/john/johnbot/files",
50+ "/john/johnbot/files/master",
51+ "/john/johnbot/file/master/retry.go",
52+ "/john/johnbot/commit/" + head,
53+ "/john/johnbot/compare/master...master",
54+ "/john/johnbot/threads",
55+ "/john/johnbot/runs",
56+ "/john/johnbot/releases",
57+ "/john/johnbot/config",
58+ "/search?q=backoff",
59+ }
60+
61+ warm := map[string]string{}
62+ for _, p := range paths {
63+ code, _, body := get(t, in.http.URL+p)
64+ if code != http.StatusOK {
65+ t.Fatalf("%s answered %d before the cache was emptied", p, code)
66+ }
67+ warm[p] = body
68+ }
69+
70+ // A cache that retains nothing is the honest test, since an empty one is warm again by the second read.
71+ if err := os.RemoveAll(in.cfg.Paths.Cache); err != nil {
72+ t.Fatal(err)
73+ }
74+ warmAgain := gitread.Cache
75+ t.Cleanup(func() {
76+ gitread.Cache, repocfg.Cache, thread.Cache, httpd.Cache = warmAgain, warmAgain, warmAgain, warmAgain
77+ })
78+ gitread.Cache = nil
79+ repocfg.Cache = nil
80+ thread.Cache = nil
81+ httpd.Cache = nil
82+
83+ for _, p := range paths {
84+ code, _, body := get(t, in.http.URL+p)
85+ if code != http.StatusOK {
86+ t.Errorf("%s answered %d with an empty cache", p, code)
87+ continue
88+ }
89+ if normal(body) != normal(warm[p]) {
90+ t.Errorf("%s renders differently with an empty cache, so the cache holds the only copy\n"+
91+ "cold:\n%s\nwarm:\n%s", p, body, warm[p])
92+ }
93+ }
94+ }
95+
96+ func normal(body string) string {
97+ return elapsed.ReplaceAllString(body, "<ago>")
98+ }
@@ -0,0 +1,67 @@
1+ package e2e
2+
3+ import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+ )
9+
10+ // Every comment in this tree is one line, which is a standing instruction, so a test holds it.
11+ func TestEveryCommentIsOneLine(t *testing.T) {
12+ root, err := filepath.Abs("..")
13+ if err != nil {
14+ t.Fatal(err)
15+ }
16+ skip := map[string]bool{".git": true, "plans": true, "misc": true, ".playwright-mcp": true}
17+ err = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
18+ if err != nil {
19+ return nil
20+ }
21+ if d.IsDir() {
22+ if skip[d.Name()] {
23+ return filepath.SkipDir
24+ }
25+ return nil
26+ }
27+ switch filepath.Ext(path) {
28+ case ".go", ".js", ".css":
29+ default:
30+ return nil
31+ }
32+ body, err := os.ReadFile(path)
33+ if err != nil {
34+ return nil
35+ }
36+ where := strings.TrimPrefix(path, root+string(filepath.Separator))
37+ run, start := 0, 0
38+ for i, line := range strings.Split(string(body), "\n") {
39+ if strings.HasPrefix(strings.TrimSpace(line), "//") {
40+ if run == 0 {
41+ start = i + 1
42+ }
43+ run++
44+ continue
45+ }
46+ if run > 1 {
47+ t.Errorf("%s:%d has a %d line comment block, and every comment here is one line", where, start, run)
48+ }
49+ run = 0
50+ }
51+ if run > 1 {
52+ t.Errorf("%s:%d has a %d line comment block, and every comment here is one line", where, start, run)
53+ }
54+ if strings.Contains(string(body), "/*") && filepath.Ext(path) == ".css" {
55+ for i, line := range strings.Split(string(body), "\n") {
56+ open := strings.Contains(line, "/*")
57+ if open && !strings.Contains(line[strings.Index(line, "/*"):], "*/") {
58+ t.Errorf("%s:%d opens a css comment it does not close on the same line", where, i+1)
59+ }
60+ }
61+ }
62+ return nil
63+ })
64+ if err != nil {
65+ t.Fatal(err)
66+ }
67+ }
@@ -0,0 +1,95 @@
1+ package e2e
2+
3+ import (
4+ "net/url"
5+ "os/exec"
6+ "regexp"
7+ "sort"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "github.com/barerepo/server/internal/token"
13+ )
14+
15+ var formIn = regexp.MustCompile(`<form[^>]*action="([^"]+)"`)
16+
17+ // A form is a control, and a reader must only be shown the ones they may use. Chapter 24.
18+ func TestNobodyIsShownAControlTheyMayNotUse(t *testing.T) {
19+ if _, err := exec.LookPath("git"); err != nil {
20+ t.Skip("git is not installed")
21+ }
22+ in := newInstance(t)
23+ john := in.account("john")
24+ lisa := in.account("lisa")
25+ seed(t, in, john, "john", "johnbot")
26+ post(t, in, "john", "/john/johnbot/threads", url.Values{
27+ "title": {"a thread"}, "body": {"with a body"}})
28+
29+ owner, err := in.db.NewSession(t.Context(), "john")
30+ if err != nil {
31+ t.Fatal(err)
32+ }
33+ stranger, err := in.db.NewSession(t.Context(), "lisa")
34+ if err != nil {
35+ t.Fatal(err)
36+ }
37+ _ = lisa
38+
39+ // An attached machine, last seen long enough ago that the page offers to forget it.
40+ _, tok, err := in.db.CreateToken(t.Context(), token.Runner, "john", "john/johnbot", "a runner")
41+ if err != nil {
42+ t.Fatal(err)
43+ }
44+ if _, err := in.db.AttachRunner(t.Context(), tok.ID, "john/johnbot",
45+ "uproar.local", "linux", "amd64", nil); err != nil {
46+ t.Fatal(err)
47+ }
48+ if _, err := in.db.ExecContext(t.Context(),
49+ `UPDATE runners SET last_seen = ?`, time.Now().Add(-time.Hour).Unix()); err != nil {
50+ t.Fatal(err)
51+ }
52+
53+ // Every page a reader can reach on somebody else's repository, and the controls on it.
54+ pages := []string{
55+ "/john/johnbot", "/john/johnbot/files", "/john/johnbot/threads",
56+ "/john/johnbot/thread/1", "/john/johnbot/runs", "/john/johnbot/runners",
57+ "/john/johnbot/releases", "/john/johnbot/config",
58+ }
59+ want := map[string][]string{
60+ // The owner runs the repository, and is not offered a copy of what is already theirs.
61+ "the owner": {
62+ "/john/johnbot/delete", "/john/johnbot/rename",
63+ "/john/johnbot/runners/forget", "/john/johnbot/thread/1/close",
64+ "/john/johnbot/thread/1/reply", "/john/johnbot/transfer",
65+ },
66+ // A stranger may talk and may copy. Closing is the author's and the owner's, and she is neither.
67+ "a stranger": {
68+ "/john/johnbot/copy", "/john/johnbot/thread/1/reply",
69+ },
70+ // Signed out there is nothing to press at all, because every control needs an account.
71+ "nobody": {},
72+ }
73+ for _, who := range []struct{ name, session string }{
74+ {"the owner", owner}, {"a stranger", stranger}, {"nobody", ""},
75+ } {
76+ found := map[string]bool{}
77+ for _, path := range pages {
78+ _, _, body := fetch(t, in, who.session, path)
79+ for _, m := range formIn.FindAllStringSubmatch(body, -1) {
80+ // A search box is a get form and asks nobody for permission.
81+ if m[1] != "/search" {
82+ found[m[1]] = true
83+ }
84+ }
85+ }
86+ got := make([]string, 0, len(found))
87+ for a := range found {
88+ got = append(got, a)
89+ }
90+ sort.Strings(got)
91+ if strings.Join(got, " ") != strings.Join(want[who.name], " ") {
92+ t.Errorf("%s is shown\n %v\nand should be shown\n %v", who.name, got, want[who.name])
93+ }
94+ }
95+ }
@@ -0,0 +1,111 @@
1+ package e2e
2+
3+ import (
4+ "context"
5+ "net/http"
6+ "net/url"
7+ "os/exec"
8+ "strings"
9+ "testing"
10+ )
11+
12+ // post sends a form as one account, without following the redirect, so the answer can be read.
13+ func post(t *testing.T, in *instance, account, path string, form url.Values) *http.Response {
14+ t.Helper()
15+ body := strings.NewReader(form.Encode())
16+ req, err := http.NewRequest(http.MethodPost, in.http.URL+path, body)
17+ if err != nil {
18+ t.Fatal(err)
19+ }
20+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
21+ if account != "" {
22+ token, err := in.db.NewSession(context.Background(), account)
23+ if err != nil {
24+ t.Fatal(err)
25+ }
26+ req.AddCookie(&http.Cookie{Name: "barerepo_session", Value: token})
27+ }
28+ client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
29+ return http.ErrUseLastResponse
30+ }}
31+ resp, err := client.Do(req)
32+ if err != nil {
33+ t.Fatal(err)
34+ }
35+ resp.Body.Close()
36+ return resp
37+ }
38+
39+ // Chapter 21.1. Copying is the answer to "no forks", so a reader who is not the owner performs it.
40+ func TestAReaderCopiesARepositoryFromTheConfigPage(t *testing.T) {
41+ if _, err := exec.LookPath("git"); err != nil {
42+ t.Skip("git is not installed")
43+ }
44+ in := newInstance(t)
45+ john := in.account("john")
46+ in.account("lisa")
47+ seed(t, in, john, "john", "johnbot")
48+
49+ // A proposal ref exists on the original, and chapter 21.1 says it must not come across.
50+ work := clone(t, in, john, "/john/johnbot")
51+ commit(t, work, "package main\n\nfunc main() {}\n", "a proposal")
52+ run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "HEAD:refs/proposals/new")
53+
54+ // Guard the guard: if the original has no proposal ref, the assertion below proves nothing.
55+ if refs := run(t, repoDir(t, in, "john", "johnbot"), "git", "for-each-ref",
56+ "--format=%(refname)"); !strings.Contains(refs, "refs/proposals/") {
57+ t.Fatalf("the push made no proposal ref, so this test cannot show one is left behind:\n%s", refs)
58+ }
59+
60+ resp := post(t, in, "lisa", "/john/johnbot/copy", nil)
61+ if resp.StatusCode != http.StatusFound {
62+ t.Fatalf("the copy answered %d, wanted a redirect to the new repository", resp.StatusCode)
63+ }
64+ if got := resp.Header.Get("Location"); got != "/lisa/johnbot" {
65+ t.Errorf("the copy sent the reader to %q", got)
66+ }
67+
68+ if code, _, _ := get(t, in.http.URL+"/lisa/johnbot"); code != http.StatusOK {
69+ t.Fatalf("the copy is not being served: %d", code)
70+ }
71+
72+ dir := repoDir(t, in, "lisa", "johnbot")
73+ refs := run(t, dir, "git", "for-each-ref", "--format=%(refname)")
74+ if !strings.Contains(refs, "refs/heads/master") {
75+ t.Errorf("the copy has no branches:\n%s", refs)
76+ }
77+ if strings.Contains(refs, "refs/proposals/") {
78+ t.Errorf("proposal refs came across, and chapter 21.1 says they belong to the original:\n%s", refs)
79+ }
80+ // Chapter 21.1 copies threads and notes, which is the half a fast object copy does not bring.
81+ if !strings.Contains(refs, "refs/notes/threads/") {
82+ t.Errorf("the discussion did not come across, so the copy lost the conversation:\n%s", refs)
83+ }
84+ if _, _, page := get(t, in.http.URL+"/lisa/johnbot/threads"); !strings.Contains(page, "1") {
85+ t.Errorf("the copy's thread list is empty:\n%s", page)
86+ }
87+
88+ // A copy borrows the original's objects, so deleting the original must give them to the copy first.
89+ post(t, in, "john", "/john/johnbot/delete", url.Values{"confirm": {"johnbot"}})
90+ if code, _, _ := get(t, in.http.URL+"/lisa/johnbot"); code != http.StatusOK {
91+ t.Fatalf("deleting the original took the copy's history with it: %d", code)
92+ }
93+ if out := run(t, dir, "git", "log", "--format=%s", "master"); !strings.Contains(out, "first") {
94+ t.Errorf("the copy lost its history when the original went:\n%s", out)
95+ }
96+ }
97+
98+ // A signed-out reader is sent to sign in, because a copy needs an account to own it.
99+ func TestCopyNeedsAnAccount(t *testing.T) {
100+ if _, err := exec.LookPath("git"); err != nil {
101+ t.Skip("git is not installed")
102+ }
103+ in := newInstance(t)
104+ john := in.account("john")
105+ seed(t, in, john, "john", "johnbot")
106+
107+ resp := post(t, in, "", "/john/johnbot/copy", nil)
108+ if got := resp.Header.Get("Location"); got != "/signin" {
109+ t.Errorf("an anonymous copy went to %q, wanted /signin", got)
110+ }
111+ }
@@ -0,0 +1,176 @@
1+ package e2e
2+
3+ import (
4+ "html"
5+ "net/http"
6+ "net/url"
7+ "os/exec"
8+ "regexp"
9+ "sort"
10+ "strings"
11+ "testing"
12+ )
13+
14+ var hrefIn = regexp.MustCompile(`href="([^"]+)"`)
15+
16+ var saidIn = regexp.MustCompile(`<h2 class="sr-only">([^<]*)</h2>`)
17+
18+ // Nothing barerepo draws may lead nowhere, so every link on every page is followed and has to answer.
19+ func TestNoPageLinksToSomethingThatDoesNotAnswer(t *testing.T) {
20+ if _, err := exec.LookPath("git"); err != nil {
21+ t.Skip("git is not installed")
22+ }
23+ in := newInstance(t)
24+ john := in.account("john")
25+ mark := in.account("mark")
26+ work := seed(t, in, john, "john", "johnbot")
27+
28+ // A repository with something on every page, or the crawl walks past empty states.
29+ write(t, work, "retry.go", "package main\n\nfunc backoff(n int) int { return n * 2 }\n")
30+ // A space and a plus are legal in a path and are what break a url nobody escaped.
31+ write(t, work, "a note.md", "# a file with a space in its name\n")
32+ write(t, work, "c++.md", "# a file with a plus in its name\n")
33+ // git quotes a path outside ascii in its own output, which is a second spelling to get wrong.
34+ write(t, work, "café.md", "# a file with an accent in its name\n")
35+ // A directory, so the tree has depth and an up link, and an awkward name inside it.
36+ write(t, work, "docs/a note.md", "# a note in a directory\n")
37+ // A binary and a large file, which are the two branches the file view draws instead of lines.
38+ write(t, work, "logo.bin", "\x00\x01\x02binary\x00bytes\n")
39+ write(t, work, "huge.txt", strings.Repeat("a line of a very large file\n", 40000))
40+ write(t, work, "doomed.md", "# this file is deleted in the next commit\n")
41+ run(t, work, "git", "add", "-A")
42+ run(t, work, "git", "commit", "-qm", "add a backoff")
43+ // A deleted file has no +++ b/ line, so its path comes from the other half of the header.
44+ run(t, work, "git", "rm", "-q", "doomed.md")
45+ run(t, work, "git", "commit", "-qm", "delete a file")
46+ run(t, work, "git", "tag", "-a", "v1.0.0", "-m", "the first release")
47+ // A slash is legal in a ref and it is what breaks a url that spends one path element on one.
48+ run(t, work, "git", "tag", "-a", "release/1.0", "-m", "a tag with a slash in it")
49+ run(t, work, "git", "branch", "feature/login")
50+ run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "master")
51+ run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "feature/login")
52+ run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "v1.0.0")
53+ run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "release/1.0")
54+
55+ mine := clone(t, in, mark, "/john/johnbot")
56+ commit(t, mine, "package main\n\nfunc main() {}\n", "a proposal")
57+ run(t, mine, "git", "push", "-q", in.url(mark, "/john/johnbot"), "HEAD:refs/proposals/new")
58+
59+ post(t, in, "john", "/john/johnbot/threads", url.Values{"title": {"a thread"}, "body": {"with a body"}})
60+
61+ // A page has as many versions as it has kinds of reader, and each draws its own links.
62+ owner, err := in.db.NewSession(t.Context(), "john")
63+ if err != nil {
64+ t.Fatal(err)
65+ }
66+ stranger, err := in.db.NewSession(t.Context(), "mark")
67+ if err != nil {
68+ t.Fatal(err)
69+ }
70+ for _, who := range []struct {
71+ name string
72+ session string
73+ least int
74+ }{{"the owner", owner, 25}, {"a stranger", stranger, 15}, {"nobody", "", 10}} {
75+ crawl(t, in, who.name, who.session, who.least, who.name == "the owner")
76+ }
77+ }
78+
79+ // crawl follows every link one reader is shown, because a link that refuses them is not a link.
80+ func crawl(t *testing.T, in *instance, who, session string, least int, isOwner bool) {
81+ t.Helper()
82+ queue := []string{"/", "/john", "/john/johnbot", "/keys", "/inbox", "/search?q=backoff", "/signup"}
83+ // A seed is typed, so being sent to sign in is a fair answer. A drawn link is a promise.
84+ seed := map[string]bool{}
85+ for _, p := range queue {
86+ seed[p] = true
87+ }
88+ seen := map[string]bool{}
89+ var broken []string
90+ for len(queue) > 0 && len(seen) < 300 {
91+ path := queue[0]
92+ queue = queue[1:]
93+ if seen[path] {
94+ continue
95+ }
96+ seen[path] = true
97+
98+ code, where, body := fetch(t, in, session, path)
99+ if code != http.StatusOK && code != http.StatusFound {
100+ broken = append(broken, path+" answered "+itoaCode(code))
101+ continue
102+ }
103+ // A link that can only send this reader to the sign-in page is a link they should not see.
104+ if !seed[path] && strings.HasPrefix(where, "/signin") {
105+ broken = append(broken, path+", which only sends them to sign in")
106+ continue
107+ }
108+ // Every mock carries one sr-only sentence saying what its page is for, and so must every page.
109+ if strings.HasPrefix(body, "<!doctype html>") {
110+ said := saidIn.FindStringSubmatch(body)
111+ if said == nil || strings.TrimSpace(said[1]) == "" {
112+ broken = append(broken, path+" says nothing about itself to a screen reader")
113+ }
114+ }
115+ for _, m := range hrefIn.FindAllStringSubmatch(body, -1) {
116+ if next, ok := internal(m[1]); ok {
117+ queue = append(queue, next)
118+ }
119+ }
120+ }
121+ if len(seen) < least {
122+ t.Fatalf("%s reached only %d pages, so the crawl is not reading links", who, len(seen))
123+ }
124+ // A page nothing links to is a page nobody finds, whatever it answers when asked directly.
125+ if isOwner {
126+ for _, must := range []string{
127+ "/john/johnbot", "/john/johnbot/files", "/john/johnbot/threads",
128+ "/john/johnbot/runs", "/john/johnbot/releases", "/john/johnbot/config",
129+ "/john/johnbot/thread/2", "/keys", "/inbox",
130+ } {
131+ if !seen[must] {
132+ t.Errorf("nothing links to %s, so a reader can only reach it by typing", must)
133+ }
134+ }
135+ }
136+ sort.Strings(broken)
137+ for _, b := range broken {
138+ t.Errorf("%s is shown a link to %s", who, b)
139+ }
140+ }
141+
142+ // internal keeps the links that stay on this barerepo, since an outside url is not barerepo's to answer.
143+ func internal(href string) (string, bool) {
144+ if !strings.HasPrefix(href, "/") || strings.HasPrefix(href, "//") {
145+ return "", false
146+ }
147+ if strings.HasPrefix(href, "/static/") {
148+ return "", false
149+ }
150+ // A page writes & as &amp; and + as &#43;, and a crawler must read what a browser would.
151+ return html.UnescapeString(href), true
152+ }
153+
154+ func fetch(t *testing.T, in *instance, session, path string) (int, string, string) {
155+ t.Helper()
156+ req, err := http.NewRequest(http.MethodGet, in.http.URL+path, nil)
157+ if err != nil {
158+ t.Fatal(err)
159+ }
160+ if session != "" {
161+ req.AddCookie(&http.Cookie{Name: "barerepo_session", Value: session})
162+ }
163+ client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
164+ return http.ErrUseLastResponse
165+ }}
166+ resp, err := client.Do(req)
167+ if err != nil {
168+ t.Fatal(err)
169+ }
170+ defer resp.Body.Close()
171+ return resp.StatusCode, resp.Header.Get("Location"), readAll(t, resp)
172+ }
173+
174+ func itoaCode(n int) string {
175+ return string(rune('0'+n/100)) + string(rune('0'+n/10%10)) + string(rune('0'+n%10))
176+ }
@@ -0,0 +1,148 @@
1+ package e2e
2+
3+ import (
4+ "context"
5+ "io"
6+ "net/http"
7+ "os/exec"
8+ "strings"
9+ "testing"
10+
11+ "github.com/barerepo/server/internal/store"
12+ )
13+
14+ // get reads a page and returns its status, content type and body.
15+ func get(t *testing.T, url string) (int, string, string) {
16+ t.Helper()
17+ resp, err := http.Get(url)
18+ if err != nil {
19+ t.Fatal(err)
20+ }
21+ defer resp.Body.Close()
22+ body, _ := io.ReadAll(resp.Body)
23+ return resp.StatusCode, resp.Header.Get("Content-Type"), string(body)
24+ }
25+
26+ // Chapter 39.4 tells a reader to paste these urls into a feed reader, so all three must answer.
27+ func TestEveryPublicFeedInChapter39Answers(t *testing.T) {
28+ if _, err := exec.LookPath("git"); err != nil {
29+ t.Skip("git is not installed")
30+ }
31+ ctx := context.Background()
32+ in := newInstance(t)
33+ john := in.account("john")
34+ seed(t, in, john, "john", "johnbot")
35+
36+ if err := in.db.Record(ctx, store.Event{Kind: store.ThreadOpened, Actor: "lisa",
37+ Repo: "john/johnbot", Number: 47, Title: "the log page is slow"}); err != nil {
38+ t.Fatal(err)
39+ }
40+
41+ for _, path := range []string{"/john.atom", "/john/johnbot.atom", "/john/johnbot/threads.atom"} {
42+ code, ctype, body := get(t, in.http.URL+path)
43+ if code != http.StatusOK {
44+ t.Errorf("%s answered %d, and chapter 39.4 hands this url out", path, code)
45+ continue
46+ }
47+ if !strings.HasPrefix(ctype, "application/atom+xml") {
48+ t.Errorf("%s came back as %q, which no feed reader will take", path, ctype)
49+ }
50+ if !strings.Contains(body, "<feed") {
51+ t.Errorf("%s is not a feed:\n%s", path, body)
52+ }
53+ }
54+ }
55+
56+ // The threads feed is the discussion alone, so a push must not appear in it. 19.5.
57+ func TestTheThreadsFeedCarriesThreadsAndNotPushes(t *testing.T) {
58+ if _, err := exec.LookPath("git"); err != nil {
59+ t.Skip("git is not installed")
60+ }
61+ ctx := context.Background()
62+ in := newInstance(t)
63+ john := in.account("john")
64+ seed(t, in, john, "john", "johnbot")
65+
66+ if err := in.db.Record(ctx, store.Event{Kind: store.ThreadOpened, Actor: "lisa",
67+ Repo: "john/johnbot", Number: 47, Title: "the log page is slow"}); err != nil {
68+ t.Fatal(err)
69+ }
70+
71+ _, _, repoFeed := get(t, in.http.URL+"/john/johnbot.atom")
72+ if !strings.Contains(repoFeed, "pushed") {
73+ t.Errorf("the repository feed lost the push:\n%s", repoFeed)
74+ }
75+ if !strings.Contains(repoFeed, "opened thread 47") {
76+ t.Errorf("the repository feed lost the thread:\n%s", repoFeed)
77+ }
78+
79+ _, _, threadFeed := get(t, in.http.URL+"/john/johnbot/threads.atom")
80+ if !strings.Contains(threadFeed, "opened thread 47") {
81+ t.Errorf("the threads feed lost the thread:\n%s", threadFeed)
82+ }
83+ if strings.Contains(threadFeed, "pushed") {
84+ t.Errorf("the threads feed carried a push:\n%s", threadFeed)
85+ }
86+ }
87+
88+ // A private repository has no public feed, and a 404 is what a stranger gets. 19.5.
89+ func TestAPrivateRepositoryHasNoPublicFeed(t *testing.T) {
90+ if _, err := exec.LookPath("git"); err != nil {
91+ t.Skip("git is not installed")
92+ }
93+ in := newInstance(t)
94+ john := in.account("john")
95+ work := t.TempDir()
96+ run(t, work, "git", "init", "-q", "-b", "master")
97+ write(t, work, "config.go", "package main\n")
98+ write(t, work, ".barerepo/config", "[repo]\nvisibility = \"private\"\n")
99+ run(t, work, "git", "add", "-A")
100+ run(t, work, "git", "commit", "-qm", "first")
101+ run(t, work, "git", "push", "-q", in.url(john, "/john/secret"), "master")
102+
103+ for _, path := range []string{"/john/secret.atom", "/john/secret/threads.atom"} {
104+ if code, _, _ := get(t, in.http.URL+path); code != http.StatusNotFound {
105+ t.Errorf("%s answered %d to a stranger, wanted 404", path, code)
106+ }
107+ }
108+ }
109+
110+ // The inbox page links to its own feed, and a link that can only fail is not a link. Chapter 19.5.
111+ func TestTheInboxFeedOpensForTheReaderLookingAtIt(t *testing.T) {
112+ if _, err := exec.LookPath("git"); err != nil {
113+ t.Skip("git is not installed")
114+ }
115+ in := newInstance(t)
116+ john := in.account("john")
117+ seed(t, in, john, "john", "johnbot")
118+
119+ // A feed reader has no session and must still be refused without a token.
120+ if code, _, _ := get(t, in.http.URL+"/inbox.atom"); code != http.StatusUnauthorized {
121+ t.Errorf("an anonymous request for the inbox feed answered %d", code)
122+ }
123+ if code, _, _ := get(t, in.http.URL+"/inbox.atom?token=ft_live_nonsense"); code != http.StatusUnauthorized {
124+ t.Errorf("a wrong token answered %d", code)
125+ }
126+
127+ session, err := in.db.NewSession(t.Context(), "john")
128+ if err != nil {
129+ t.Fatal(err)
130+ }
131+ req, err := http.NewRequest(http.MethodGet, in.http.URL+"/inbox.atom", nil)
132+ if err != nil {
133+ t.Fatal(err)
134+ }
135+ req.AddCookie(&http.Cookie{Name: "barerepo_session", Value: session})
136+ resp, err := http.DefaultClient.Do(req)
137+ if err != nil {
138+ t.Fatal(err)
139+ }
140+ defer resp.Body.Close()
141+ body := readAll(t, resp)
142+ if resp.StatusCode != http.StatusOK {
143+ t.Fatalf("the reader's own inbox feed answered %d:\n%s", resp.StatusCode, body)
144+ }
145+ if !strings.Contains(body, "barerepo inbox for john") {
146+ t.Errorf("the feed is not john's:\n%s", body)
147+ }
148+ }
@@ -0,0 +1,63 @@
1+ package e2e
2+
3+ import (
4+ "net/http"
5+ "os/exec"
6+ "strings"
7+ "testing"
8+ )
9+
10+ // Chapter 42.4: binary content is not rendered, and a file over a megabyte is not either.
11+ func TestTheFileViewRefusesToRenderWhatItShouldNot(t *testing.T) {
12+ if _, err := exec.LookPath("git"); err != nil {
13+ t.Skip("git is not installed")
14+ }
15+ in := newInstance(t)
16+ john := in.account("john")
17+ work := seed(t, in, john, "john", "johnbot")
18+
19+ write(t, work, "logo.bin", "\x00\x01\x02binary\x00bytes\n")
20+ write(t, work, "huge.txt", strings.Repeat("a line of a very large file\n", 40000))
21+ write(t, work, "small.txt", "one line\n")
22+ run(t, work, "git", "add", "-A")
23+ run(t, work, "git", "commit", "-qm", "add the awkward ones")
24+ run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "master")
25+
26+ cases := []struct {
27+ path string
28+ want []string
29+ not []string
30+ }{
31+ // A null byte in the first 8000 marks it binary, and the page offers the bytes instead.
32+ {"/john/johnbot/file/master/logo.bin",
33+ []string{"binary file", "download"}, []string{"binary\x00bytes"}},
34+ // Over a megabyte the page says so rather than sending it, per the same section.
35+ {"/john/johnbot/file/master/huge.txt",
36+ []string{"too large to render", "download"}, []string{"a line of a very large file"}},
37+ // And an ordinary file still renders, or the two rules above have eaten everything.
38+ {"/john/johnbot/file/master/small.txt", []string{"one line"}, []string{"too large", "binary file"}},
39+ }
40+ for _, c := range cases {
41+ code, _, body := get(t, in.http.URL+c.path)
42+ if code != http.StatusOK {
43+ t.Errorf("%s answered %d", c.path, code)
44+ continue
45+ }
46+ for _, want := range c.want {
47+ if !strings.Contains(body, want) {
48+ t.Errorf("%s does not say %q", c.path, want)
49+ }
50+ }
51+ for _, not := range c.not {
52+ if strings.Contains(body, not) {
53+ t.Errorf("%s put %q on the page", c.path, not)
54+ }
55+ }
56+ }
57+
58+ // The download itself is an attachment and never a page, whatever the bytes are. 42.3.
59+ code, _, _ := get(t, in.http.URL+"/john/johnbot/raw/master/logo.bin")
60+ if code != http.StatusOK {
61+ t.Errorf("the raw bytes answered %d", code)
62+ }
63+ }
@@ -0,0 +1,91 @@
1+ package e2e
2+
3+ import (
4+ "context"
5+ "net/http"
6+ "os/exec"
7+ "strconv"
8+ "testing"
9+
10+ "github.com/barerepo/server/internal/token"
11+ )
12+
13+ // A HEAD is a GET that stops at the headers, so a page that answers one must answer the other.
14+ func TestEveryPageAnswersHeadTheWayItAnswersGet(t *testing.T) {
15+ if _, err := exec.LookPath("git"); err != nil {
16+ t.Skip("git is not installed")
17+ }
18+ in := newInstance(t)
19+ john := in.account("john")
20+ seed(t, in, john, "john", "johnbot")
21+
22+ // The two a stranger sees first are the two that were wrong, so they lead the list.
23+ paths := []string{
24+ "/signin", "/signup", "/", "/john", "/john/johnbot",
25+ "/john/johnbot/files", "/john/johnbot/threads", "/john/johnbot/runs",
26+ "/john/johnbot/releases", "/john/johnbot/config", "/john/johnbot/runners",
27+ "/search?q=irc", "/john.atom", "/john/johnbot.atom", "/nobody/nothing",
28+ }
29+ for _, path := range paths {
30+ want := status(t, in, http.MethodGet, path)
31+ got := status(t, in, http.MethodHead, path)
32+ if got != want {
33+ t.Errorf("%s answers %d to a reader and %d to a link checker", path, want, got)
34+ }
35+ }
36+ }
37+
38+ // status asks once and follows nothing, because a redirect answered is still an answer.
39+ func status(t *testing.T, in *instance, method, path string) int {
40+ t.Helper()
41+ req, err := http.NewRequest(method, in.http.URL+path, nil)
42+ if err != nil {
43+ t.Fatal(err)
44+ }
45+ client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
46+ return http.ErrUseLastResponse
47+ }}
48+ resp, err := client.Do(req)
49+ if err != nil {
50+ t.Fatal(err)
51+ }
52+ resp.Body.Close()
53+ return resp.StatusCode
54+ }
55+
56+ // The runner poll takes a job off the queue, so it is the one GET that a HEAD must not reach.
57+ func TestAHeadDoesNotSwallowAQueuedBuild(t *testing.T) {
58+ if _, err := exec.LookPath("git"); err != nil {
59+ t.Skip("git is not installed")
60+ }
61+ ctx := context.Background()
62+ in := newInstance(t)
63+ john := in.account("john")
64+ seed(t, in, john, "john", "johnbot")
65+
66+ secret, tok, err := in.db.CreateToken(ctx, token.Runner, "john", "john/johnbot", "a runner")
67+ if err != nil {
68+ t.Fatal(err)
69+ }
70+ runner, err := in.db.AttachRunner(ctx, tok.ID, "john/johnbot", "uproar.local", "linux", "amd64", nil)
71+ if err != nil {
72+ t.Fatal(err)
73+ }
74+ if _, err := in.db.QueueJob(ctx, "john/johnbot", "refs/heads/master", "abc", "go build", ""); err != nil {
75+ t.Fatal(err)
76+ }
77+
78+ poll := "/runner/poll?token=" + secret + "&id=" + strconv.FormatInt(runner.ID, 10)
79+ if code := status(t, in, http.MethodHead, poll); code == http.StatusOK {
80+ t.Errorf("a HEAD to the poll answered %d, and the job it answered with is gone", code)
81+ }
82+
83+ // The job has to still be there, or a link checker walking the site emptied the queue.
84+ job, err := in.db.TakeJob(ctx, "john/johnbot", *runner)
85+ if err != nil {
86+ t.Fatal(err)
87+ }
88+ if job == nil {
89+ t.Error("a HEAD to the poll took the build, and no runner will ever see it")
90+ }
91+ }
@@ -0,0 +1,79 @@
1+ package e2e
2+
3+ import (
4+ "net/http"
5+ "os/exec"
6+ "strings"
7+ "testing"
8+ )
9+
10+ // Chapter 24 gives the file view a history link and the config page history, blame and raw.
11+ func TestOneFileHasItsOwnHistory(t *testing.T) {
12+ if _, err := exec.LookPath("git"); err != nil {
13+ t.Skip("git is not installed")
14+ }
15+ in := newInstance(t)
16+ john := in.account("john")
17+ work := seed(t, in, john, "john", "johnbot")
18+
19+ write(t, work, "conn.go", "package irc\n\nfunc dial() error { return nil }\n")
20+ run(t, work, "git", "add", "-A")
21+ run(t, work, "git", "commit", "-qm", "add the dialer")
22+ write(t, work, "other.go", "package irc\n\nfunc unrelated() {}\n")
23+ run(t, work, "git", "add", "-A")
24+ run(t, work, "git", "commit", "-qm", "a commit that never touches the dialer")
25+ write(t, work, "conn.go", "package irc\n\nfunc dial() error { return errBusy }\n")
26+ run(t, work, "git", "add", "-A")
27+ run(t, work, "git", "commit", "-qm", "the dialer returns busy now")
28+ run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "master")
29+
30+ // The file view's history link is the only way to ask this question from a page.
31+ code, _, file := get(t, in.http.URL+"/john/johnbot/file/master/conn.go")
32+ if code != http.StatusOK {
33+ t.Fatalf("the file view answered %d", code)
34+ }
35+ if !strings.Contains(file, `href="/john/johnbot?path=conn.go"`) {
36+ t.Errorf("the file view does not link to its own history:\n%s", footOf(file))
37+ }
38+
39+ code, _, hist := get(t, in.http.URL+"/john/johnbot?path=conn.go")
40+ if code != http.StatusOK {
41+ t.Fatalf("the history answered %d", code)
42+ }
43+ for _, want := range []string{"add the dialer", "the dialer returns busy now"} {
44+ if !strings.Contains(hist, want) {
45+ t.Errorf("the history of conn.go lost %q", want)
46+ }
47+ }
48+ // A commit that never touched the file is the whole point of asking for one file.
49+ if strings.Contains(hist, "a commit that never touches the dialer") {
50+ t.Error("the history of conn.go carries a commit that does not touch conn.go")
51+ }
52+ // Every row counts only this file, since that is what asking for one file means.
53+ if !strings.Contains(hist, "conn.go · &#43;1 -1") {
54+ t.Errorf("a row does not count conn.go alone:\n%s", hist)
55+ }
56+ // And it says what it is restricted to, or a reader cannot tell it from the whole log.
57+ if !strings.Contains(hist, "history of") || !strings.Contains(hist, "the whole log") {
58+ t.Error("the history page does not say it is one file, nor offer the way back")
59+ }
60+
61+ // The config page is a file view with a fixed path, so chapter 24 gives it all three links.
62+ code, _, cfg := get(t, in.http.URL+"/john/johnbot/config")
63+ if code != http.StatusOK {
64+ t.Fatalf("the config page answered %d", code)
65+ }
66+ for _, want := range []string{">history</a>", ">blame</a>", ">raw</a>"} {
67+ if !strings.Contains(cfg, want) {
68+ t.Errorf("the config page is missing its %s link", strings.Trim(want, ">/a<"))
69+ }
70+ }
71+ }
72+
73+ // footOf is the last of a page, which is where every one of these links lives.
74+ func footOf(body string) string {
75+ if i := strings.LastIndex(body, `class="foot"`); i >= 0 {
76+ return body[i:]
77+ }
78+ return body
79+ }
@@ -0,0 +1,254 @@
1+ package e2e
2+
3+ import (
4+ "net/http"
5+ "os/exec"
6+ "path/filepath"
7+ "strings"
8+ "sync"
9+ "testing"
10+ )
11+
12+ // seed makes a repository with one commit and returns the working copy.
13+ func seed(t *testing.T, in *instance, token, owner, name string) string {
14+ t.Helper()
15+ work := t.TempDir()
16+ run(t, work, "git", "init", "-q", "-b", "master")
17+ write(t, work, "config.go", "package main\n")
18+ write(t, work, ".barerepo/config", "[repo]\nvisibility = \"public\"\n")
19+ run(t, work, "git", "add", "-A")
20+ run(t, work, "git", "commit", "-qm", "first")
21+ run(t, work, "git", "push", "-q", in.url(token, "/"+owner+"/"+name), "master")
22+ return work
23+ }
24+
25+ // clone checks out a repository as somebody else.
26+ func clone(t *testing.T, in *instance, token, path string) string {
27+ t.Helper()
28+ dir := filepath.Join(t.TempDir(), "c")
29+ run(t, "", "git", "clone", "-q", in.url(token, path), dir)
30+ return dir
31+ }
32+
33+ func commit(t *testing.T, dir, body, message string) {
34+ t.Helper()
35+ write(t, dir, "config.go", body)
36+ run(t, dir, "git", "commit", "-qam", message)
37+ }
38+
39+ // Chapter 45.2. Each case is one of the hook tests it lists.
40+ func TestHooks(t *testing.T) {
41+ if _, err := exec.LookPath("git"); err != nil {
42+ t.Skip("git is not installed")
43+ }
44+ in := newInstance(t)
45+ john := in.account("john")
46+ lisa := in.account("lisa")
47+ mark := in.account("mark")
48+ seed(t, in, john, "john", "johnbot")
49+
50+ t.Run("push to master without access", func(t *testing.T) {
51+ c := clone(t, in, lisa, "/john/johnbot")
52+ commit(t, c, "package main // lisa\n", "lisa edits")
53+ out, err := try(t, c, "git", "push", in.url(lisa, "/john/johnbot"), "master")
54+ if err == nil {
55+ t.Fatal("the push was accepted")
56+ }
57+ // Name the way forward, because a rejected push is where a contributor decides to stop.
58+ if !strings.Contains(out, "refs/proposals/new") {
59+ t.Errorf("the rejection does not name the proposal command:\n%s", out)
60+ }
61+ if !strings.Contains(out, "[access] push") {
62+ t.Errorf("the rejection does not name the rule that refused it:\n%s", out)
63+ }
64+ })
65+
66+ t.Run("push to refs/proposals/new", func(t *testing.T) {
67+ c := clone(t, in, lisa, "/john/johnbot")
68+ commit(t, c, "package main // proposal\n", "a proposal")
69+ out := run(t, c, "git", "push", in.url(lisa, "/john/johnbot"), "HEAD:refs/proposals/new")
70+ if !strings.Contains(out, "refs/proposals/") {
71+ t.Fatalf("no number was allocated:\n%s", out)
72+ }
73+ if !strings.Contains(out, in.http.URL) {
74+ t.Errorf("the push output has no url to open:\n%s", out)
75+ }
76+ if strings.Contains(out, "refs/proposals/new\n") {
77+ t.Error("refs/proposals/new was created; it is reserved")
78+ }
79+ })
80+
81+ t.Run("two pushes at the same moment", func(t *testing.T) {
82+ // Two different numbers, which is the whole reason the counter is a compare-and-swap.
83+ var wg sync.WaitGroup
84+ outs := make([]string, 2)
85+ start := make(chan struct{})
86+ for i := range outs {
87+ c := clone(t, in, mark, "/john/johnbot")
88+ commit(t, c, "package main // race "+string(rune('a'+i))+"\n", "race")
89+ wg.Add(1)
90+ go func(i int, dir string) {
91+ defer wg.Done()
92+ <-start
93+ out, _ := try(t, dir, "git", "push", in.url(mark, "/john/johnbot"), "HEAD:refs/proposals/new")
94+ outs[i] = out
95+ }(i, c)
96+ }
97+ close(start)
98+ wg.Wait()
99+
100+ got := map[string]bool{}
101+ for i, out := range outs {
102+ ref := proposalRef(out)
103+ if ref == "" {
104+ t.Fatalf("push %d allocated nothing:\n%s", i, out)
105+ }
106+ if got[ref] {
107+ t.Fatalf("both pushes got %s", ref)
108+ }
109+ got[ref] = true
110+ }
111+ })
112+
113+ t.Run("push to another account's proposal", func(t *testing.T) {
114+ c := clone(t, in, lisa, "/john/johnbot")
115+ commit(t, c, "package main // mine\n", "mine")
116+ out := run(t, c, "git", "push", in.url(lisa, "/john/johnbot"), "HEAD:refs/proposals/new")
117+ ref := proposalRef(out)
118+
119+ other := clone(t, in, mark, "/john/johnbot")
120+ commit(t, other, "package main // yours\n", "taking it over")
121+ out, err := try(t, other, "git", "push", "-f", in.url(mark, "/john/johnbot"), "HEAD:"+ref)
122+ if err == nil {
123+ t.Fatal("somebody else's proposal was overwritten")
124+ }
125+ if !strings.Contains(out, "belongs to lisa") {
126+ t.Errorf("the rejection does not say whose it is:\n%s", out)
127+ }
128+ })
129+
130+ t.Run("merging closes the thread", func(t *testing.T) {
131+ c := clone(t, in, lisa, "/john/johnbot")
132+ commit(t, c, "package main // to merge\n", "worth merging")
133+ out := run(t, c, "git", "push", in.url(lisa, "/john/johnbot"), "HEAD:refs/proposals/new")
134+ ref := proposalRef(out)
135+ n := strings.TrimPrefix(ref, "refs/proposals/")
136+
137+ owner := clone(t, in, john, "/john/johnbot")
138+ run(t, owner, "git", "fetch", "-q", in.url(john, "/john/johnbot"), ref+":prop")
139+ run(t, owner, "git", "merge", "-q", "--no-edit", "prop")
140+ out = run(t, owner, "git", "push", in.url(john, "/john/johnbot"), "master")
141+ if !strings.Contains(out, "closed as merged") {
142+ t.Errorf("the thread did not close itself:\n%s", out)
143+ }
144+ dir := repoDir(t, in, "john", "johnbot")
145+ meta := run(t, dir, "git", "cat-file", "blob", "refs/notes/threads/"+n+":meta")
146+ if !strings.Contains(meta, "state: merged") {
147+ t.Errorf("thread %s is not merged:\n%s", n, meta)
148+ }
149+ })
150+
151+ t.Run("squash merging leaves it open", func(t *testing.T) {
152+ // A squash changes the hashes, so the server says so rather than guessing. 36.7.
153+ c := clone(t, in, lisa, "/john/johnbot")
154+ commit(t, c, "package main // squash me\n", "squash me")
155+ out := run(t, c, "git", "push", in.url(lisa, "/john/johnbot"), "HEAD:refs/proposals/new")
156+ n := strings.TrimPrefix(proposalRef(out), "refs/proposals/")
157+
158+ owner := clone(t, in, john, "/john/johnbot")
159+ run(t, owner, "git", "fetch", "-q", in.url(john, "/john/johnbot"), "refs/proposals/"+n+":prop")
160+ run(t, owner, "git", "merge", "-q", "--squash", "prop")
161+ run(t, owner, "git", "commit", "-qm", "squashed")
162+ run(t, owner, "git", "push", in.url(john, "/john/johnbot"), "master")
163+
164+ dir := repoDir(t, in, "john", "johnbot")
165+ meta := run(t, dir, "git", "cat-file", "blob", "refs/notes/threads/"+n+":meta")
166+ if !strings.Contains(meta, "state: open") {
167+ t.Errorf("a squash merge closed thread %s, which the server cannot know:\n%s", n, meta)
168+ }
169+ })
170+ }
171+
172+ // proposalRef reads the allocated ref out of git's push output.
173+ func proposalRef(out string) string {
174+ for _, line := range strings.Split(out, "\n") {
175+ i := strings.Index(line, "refs/proposals/")
176+ if i < 0 {
177+ continue
178+ }
179+ ref := strings.Fields(line[i:])[0]
180+ if ref != "refs/proposals/new" {
181+ return ref
182+ }
183+ }
184+ return ""
185+ }
186+
187+ // A commit author is whatever the pusher typed, so it never decides who writes a proposal ref.
188+ func TestCommitAuthorIsNotIdentity(t *testing.T) {
189+ if _, err := exec.LookPath("git"); err != nil {
190+ t.Skip("git is not installed")
191+ }
192+ in := newInstance(t)
193+ john := in.account("john")
194+ lisa := in.account("lisa")
195+ mark := in.account("mark")
196+ seed(t, in, john, "john", "johnbot")
197+
198+ c := clone(t, in, lisa, "/john/johnbot")
199+ commit(t, c, "package main // lisa's work\n", "lisa's work")
200+ ref := proposalRef(run(t, c, "git", "push", in.url(lisa, "/john/johnbot"), "HEAD:refs/proposals/new"))
201+
202+ // mark claims to be lisa in the only place a commit records a name.
203+ thief := clone(t, in, mark, "/john/johnbot")
204+ run(t, thief, "git", "config", "user.name", "lisa")
205+ run(t, thief, "git", "config", "user.email", "lisa@localhost")
206+ write(t, thief, "config.go", "package main // taken\n")
207+ run(t, thief, "git", "commit", "-qam", "taken")
208+
209+ out, err := try(t, thief, "git", "push", "-f", in.url(mark, "/john/johnbot"), "HEAD:"+ref)
210+ if err == nil {
211+ t.Fatal("a commit signed with somebody else's name took over their proposal")
212+ }
213+ if !strings.Contains(out, "belongs to lisa") {
214+ t.Errorf("unexpected rejection:\n%s", out)
215+ }
216+ }
217+
218+ // Chapter 24: terminals scroll, so a rejected push must print a url to a page that explains it.
219+ func TestARejectedPushPrintsAUrlToAPageThatWorks(t *testing.T) {
220+ if _, err := exec.LookPath("git"); err != nil {
221+ t.Skip("git is not installed")
222+ }
223+ in := newInstance(t)
224+ john := in.account("john")
225+ mark := in.account("mark")
226+ work := seed(t, in, john, "john", "johnbot")
227+ _ = work
228+
229+ mine := clone(t, in, mark, "/john/johnbot")
230+ commit(t, mine, "package main\n\nfunc main() {}\n", "not mine to push")
231+ out, err := try(t, mine, "git", "push", in.url(mark, "/john/johnbot"), "master")
232+ if err == nil {
233+ t.Fatal("a stranger pushed to master, so there is no rejection to explain")
234+ }
235+ if !strings.Contains(out, "/rejected?ref=") {
236+ t.Fatalf("the rejection printed no url, so the page is unreachable:\n%s", out)
237+ }
238+
239+ i := strings.Index(out, in.http.URL+"/john/johnbot/rejected?ref=")
240+ if i < 0 {
241+ t.Fatalf("the url is not this server's:\n%s", out)
242+ }
243+ line := strings.Fields(out[i:])[0]
244+ code, _, body := get(t, line)
245+ if code != http.StatusOK {
246+ t.Fatalf("%s answered %d", line, code)
247+ }
248+ for _, want := range []string{"you pushed to refs/heads/master", "[access] push",
249+ "git push origin HEAD:refs/proposals/new", "printed in your terminal"} {
250+ if !strings.Contains(body, want) {
251+ t.Errorf("the page does not say %q:\n%s", want, body)
252+ }
253+ }
254+ }
@@ -0,0 +1,85 @@
1+ package e2e
2+
3+ import (
4+ "net/url"
5+ "os/exec"
6+ "strings"
7+ "testing"
8+ )
9+
10+ // A comment body is text, and no text a reader types may become another reader's name.
11+ func TestNobodyCanWriteAReplyInSomebodyElsesName(t *testing.T) {
12+ if _, err := exec.LookPath("git"); err != nil {
13+ t.Skip("git is not installed")
14+ }
15+ in := newInstance(t)
16+ john := in.account("john")
17+ in.account("mark")
18+ seed(t, in, john, "john", "johnbot")
19+
20+ post(t, in, "john", "/john/johnbot/threads", url.Values{
21+ "title": {"a thread"}, "body": {"the first word"}})
22+ // The record separator is a line of two dashes, so mark writes one and a header after it.
23+ post(t, in, "mark", "/john/johnbot/thread/1/reply", url.Values{
24+ "body": {"looks fine to me\n\n--\nauthor: john\ntime: 1755000000\n\nI approve this change."}})
25+
26+ _, _, body := get(t, in.http.URL+"/john/johnbot/thread/1")
27+
28+ // Two comments were written, so three would mean a third was conjured out of the second.
29+ if n := strings.Count(body, `class="body"`); n != 2 {
30+ t.Errorf("two people wrote and the page shows %d comments", n)
31+ }
32+ // Every word mark typed stays inside mark's comment, whatever it looks like.
33+ i := strings.Index(body, "looks fine to me")
34+ j := strings.Index(body, "I approve this change.")
35+ if i < 0 || j < 0 {
36+ t.Fatalf("the reply is not on the page whole:\n%s", body)
37+ }
38+ if k := strings.Index(body[i:j], `class="body"`); k >= 0 {
39+ t.Error("mark's reply was split, and its second half was given to somebody else")
40+ }
41+ // And the dashes the reader typed are the dashes the reader sees.
42+ if !strings.Contains(body, "<hr") && !strings.Contains(body, "--") {
43+ t.Error("the line of dashes vanished from the reply that contained it")
44+ }
45+ }
46+
47+ // A header value comes from a form too, and a newline in one is a second header nobody typed.
48+ func TestAFormFieldCannotBecomeAHeaderLine(t *testing.T) {
49+ if _, err := exec.LookPath("git"); err != nil {
50+ t.Skip("git is not installed")
51+ }
52+ in := newInstance(t)
53+ john := in.account("john")
54+ in.account("mark")
55+ seed(t, in, john, "john", "johnbot")
56+
57+ post(t, in, "john", "/john/johnbot/threads", url.Values{
58+ "title": {"a thread"}, "body": {"the first word"}})
59+ // blob is a hidden field on the line comment form, so it is the one nobody would think to check.
60+ post(t, in, "mark", "/john/johnbot/thread/1/comment", url.Values{
61+ "path": {"README.md"}, "line": {"1"},
62+ "blob": {"abc\nauthor: john"},
63+ "body": {"a line comment from mark"},
64+ })
65+
66+ _, _, body := get(t, in.http.URL+"/john/johnbot/thread/1")
67+ i := strings.Index(body, "a line comment from mark")
68+ if i < 0 {
69+ t.Fatalf("the line comment is not on the page:\n%s", body)
70+ }
71+ // The name above the comment is the one the session proved, whatever the hidden field said.
72+ above := body[max(0, i-500):i]
73+ if !strings.Contains(above, ">mark</a>") {
74+ t.Errorf("a hidden form field chose the name over the comment:\n%s", above)
75+ }
76+ // And a title cannot claim a header of its own, since it is the first line of the same blob.
77+ post(t, in, "mark", "/john/johnbot/threads", url.Values{
78+ "title": {"a title\nmerged: 0123456789012345678901234567890123456789"},
79+ "body": {"and a body"}})
80+ // The whole title stays one title, so the words after the newline are still part of it.
81+ _, _, two := get(t, in.http.URL+"/john/johnbot/thread/2")
82+ if !strings.Contains(two, "a title merged: 0123456789") {
83+ t.Errorf("a thread title became a header of its own:\n%s", two)
84+ }
85+ }
@@ -0,0 +1,45 @@
1+ package e2e
2+
3+ import (
4+ "os/exec"
5+ "strings"
6+ "testing"
7+ )
8+
9+ // Chapter 40.1 lists what a mirror clone takes, and a promise about leaving must be exact.
10+ func TestAMirrorCloneTakesEverythingChapter40Lists(t *testing.T) {
11+ if _, err := exec.LookPath("git"); err != nil {
12+ t.Skip("git is not installed")
13+ }
14+ in := newInstance(t)
15+ john := in.account("john")
16+ mark := in.account("mark")
17+ work := seed(t, in, john, "john", "johnbot")
18+ run(t, work, "git", "tag", "-a", "v1.0.0", "-m", "the first release")
19+ run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "v1.0.0")
20+
21+ // A proposal, then a rewrite of it, so there is a retained revision to take as well.
22+ mine := clone(t, in, mark, "/john/johnbot")
23+ commit(t, mine, "package main\n\nfunc main() {}\n", "a first attempt")
24+ run(t, mine, "git", "push", "-q", in.url(mark, "/john/johnbot"), "HEAD:refs/proposals/new")
25+ commit(t, mine, "package main\n\nfunc main() { println(1) }\n", "a second attempt")
26+ run(t, mine, "git", "push", "-q", "-f", in.url(mark, "/john/johnbot"), "HEAD:refs/proposals/1")
27+
28+ // A build result, because chapter 40.1 promises those come too and they live in a note.
29+ sha := strings.TrimSpace(run(t, work, "git", "rev-parse", "HEAD"))
30+ passRun(t, in, "john", "john/johnbot", sha, "build")
31+
32+ away := t.TempDir()
33+ run(t, away, "git", "clone", "--mirror", "-q", in.url(john, "/john/johnbot"), "johnbot.git")
34+ took := run(t, away+"/johnbot.git", "git", "for-each-ref", "--format=%(refname)")
35+
36+ // Every kind chapter 40.1 names, and the revisions comments are anchored to. 43.5.
37+ for _, want := range []string{
38+ "refs/heads/master", "refs/tags/v1.0.0", "refs/proposals/1",
39+ "refs/notes/threads/1", "refs/revisions/1/", "refs/notes/runs",
40+ } {
41+ if !strings.Contains(took, want) {
42+ t.Errorf("a mirror clone left %s behind:\n%s", want, took)
43+ }
44+ }
45+ }
@@ -0,0 +1,124 @@
1+ package e2e
2+
3+ import (
4+ "fmt"
5+ "os/exec"
6+ "strings"
7+ "testing"
8+ )
9+
10+ // Chapter 18 calls itself the complete matrix, so every cell of it is a row here.
11+ func TestTheAccessMatrixIsTheWholeMatrix(t *testing.T) {
12+ if _, err := exec.LookPath("git"); err != nil {
13+ t.Skip("git is not installed")
14+ }
15+ in := newInstance(t)
16+ john := in.account("john")
17+ lisa := in.account("lisa")
18+ mark := in.account("mark")
19+ dave := in.account("dave")
20+
21+ // john owns it, lisa has push, mark and dave have neither.
22+ work := t.TempDir()
23+ run(t, work, "git", "init", "-q", "-b", "master", work)
24+ write(t, work, "config.go", "package main\n")
25+ write(t, work, ".barerepo/config", "[repo]\nvisibility = \"public\"\n\n[access]\npush = [\"lisa\"]\n")
26+ run(t, work, "git", "add", "-A")
27+ run(t, work, "git", "commit", "-qm", "first")
28+ run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "master")
29+
30+ // mark opens a proposal, which makes him its author for the row that says so.
31+ mine := clone(t, in, mark, "/john/johnbot")
32+ commit(t, mine, "package main\n\nfunc main() {}\n", "mark proposes")
33+ run(t, mine, "git", "push", "-q", in.url(mark, "/john/johnbot"), "HEAD:refs/proposals/new")
34+
35+ cases := []struct {
36+ row string
37+ actor string
38+ token string
39+ ref string
40+ allowed bool
41+ }{
42+ {"refs/heads/*", "the owner", john, "refs/heads/topic", true},
43+ {"refs/heads/*", "someone with push", lisa, "refs/heads/lisa-topic", true},
44+ {"refs/heads/*", "a stranger", mark, "refs/heads/mark-topic", false},
45+ {"refs/tags/*", "the owner", john, "refs/tags/v1.0.0", true},
46+ {"refs/tags/*", "someone with push", lisa, "refs/tags/v1.0.1", true},
47+ {"refs/tags/*", "a stranger", mark, "refs/tags/v9.9.9", false},
48+ {"refs/proposals/new", "a stranger", dave, "refs/proposals/new", true},
49+ {"refs/proposals/<n>", "its author", mark, "refs/proposals/1", true},
50+ {"refs/proposals/<n>", "someone with push", lisa, "refs/proposals/1", true},
51+ {"refs/proposals/<n>", "another stranger", dave, "refs/proposals/1", false},
52+ {"refs/notes/runs", "a stranger", mark, "refs/notes/runs", false},
53+ {"refs/notes/runs", "the owner restoring", john, "refs/notes/runs", true},
54+ {"refs/meta/*", "a stranger", mark, "refs/meta/counter", false},
55+ {"refs/meta/*", "the owner restoring", john, "refs/meta/counter", true},
56+ {"everything else", "the owner restoring", john, "refs/wat/anything", true},
57+ {"everything else", "a stranger", mark, "refs/wat/anything", false},
58+ }
59+
60+ // Each case pushes its own commit, or git answers "everything up-to-date" and the hook never runs.
61+ shas := make([]string, len(cases))
62+ for i := range cases {
63+ commit(t, mine, fmt.Sprintf("package main\n\nvar n = %d\n", i), fmt.Sprintf("case %d", i))
64+ shas[i] = strings.TrimSpace(run(t, mine, "git", "rev-parse", "HEAD"))
65+ }
66+
67+ for i, c := range cases {
68+ out, err := try(t, mine, "git", "push", "-f", in.url(c.token, "/john/johnbot"),
69+ shas[i]+":"+c.ref)
70+ if strings.Contains(out, "up-to-date") {
71+ t.Errorf("%s: %s pushed nothing, so the hook never judged it", c.row, c.actor)
72+ continue
73+ }
74+ switch {
75+ case c.allowed && err != nil:
76+ t.Errorf("%s: %s was refused and chapter 18 allows it:\n%s", c.row, c.actor, out)
77+ case !c.allowed && err == nil:
78+ t.Errorf("%s: %s wrote it and chapter 18 does not allow that:\n%s", c.row, c.actor, out)
79+ }
80+ }
81+ }
82+
83+ // Chapter 18 lets anyone who may read write refs/notes/threads/*, and chapter 13 makes that a reply.
84+ func TestAnyoneWhoMayReadMayReplyAndNobodyMayWipeAThread(t *testing.T) {
85+ if _, err := exec.LookPath("git"); err != nil {
86+ t.Skip("git is not installed")
87+ }
88+ in := newInstance(t)
89+ john := in.account("john")
90+ dave := in.account("dave")
91+ seed(t, in, john, "john", "johnbot")
92+
93+ mine := clone(t, in, john, "/john/johnbot")
94+ commit(t, mine, "package main\n\nfunc main() {}\n", "john proposes")
95+ run(t, mine, "git", "push", "-q", in.url(john, "/john/johnbot"), "HEAD:refs/proposals/new")
96+
97+ daves := clone(t, in, dave, "/john/johnbot")
98+ ref := "refs/notes/threads/1"
99+ run(t, daves, "git", "fetch", "-q", in.url(dave, "/john/johnbot"), ref+":"+ref)
100+ run(t, daves, "git", "fetch", "-q", in.url(dave, "/john/johnbot"), "refs/proposals/1:refs/proposals/1")
101+ run(t, daves, "git", "notes", "--ref=threads/1", "append", "-m", "dave may read, so dave may reply")
102+ if out, err := try(t, daves, "git", "push", in.url(dave, "/john/johnbot"), ref+":"+ref); err != nil {
103+ t.Errorf("a reader was refused a reply and chapter 18 allows it:\n%s", out)
104+ }
105+
106+ // The same ref, the same reader, and a push that keeps nothing. Chapter 13 refuses this one.
107+ wipe := strings.TrimSpace(run(t, daves, "git", "rev-parse", "HEAD"))
108+ if out, err := try(t, daves, "git", "push", "-f", in.url(dave, "/john/johnbot"), wipe+":"+ref); err == nil {
109+ t.Errorf("a reader wiped a thread and chapter 13 forbids that:\n%s", out)
110+ }
111+
112+ // The meta blob survives this one, so only the guard that counts records can refuse it.
113+ run(t, daves, "git", "fetch", "-q", "-f", in.url(dave, "/john/johnbot"), ref+":"+ref)
114+ listed := run(t, daves, "git", "notes", "--ref=threads/1", "list")
115+ fields := strings.Fields(listed)
116+ if len(fields) < 2 {
117+ t.Fatalf("thread 1 holds no note to overwrite: %s", listed)
118+ }
119+ on := fields[1]
120+ run(t, daves, "git", "notes", "--ref=threads/1", "add", "-f", "-m", "mine alone now", on)
121+ if out, err := try(t, daves, "git", "push", "-f", in.url(dave, "/john/johnbot"), ref+":"+ref); err == nil {
122+ t.Errorf("a reader overwrote a comment and chapter 13 forbids that:\n%s", out)
123+ }
124+ }
@@ -0,0 +1,67 @@
1+ package e2e
2+
3+ import (
4+ "net/http"
5+ "net/url"
6+ "os/exec"
7+ "strings"
8+ "testing"
9+ )
10+
11+ // Chapter 19.2: participation is subscription, and a reply pushed from a clone is a reply.
12+ func TestAReplyPushedFromACloneReachesTheInbox(t *testing.T) {
13+ if _, err := exec.LookPath("git"); err != nil {
14+ t.Skip("git is not installed")
15+ }
16+ in := newInstance(t)
17+ john := in.account("john")
18+ mark := in.account("mark")
19+ seed(t, in, john, "john", "johnbot")
20+
21+ // John opens a thread here, which is the door that already recorded an event.
22+ resp := post(t, in, "john", "/john/johnbot/threads", url.Values{
23+ "title": {"the reconnect loop spins"},
24+ "body": {"it retries with no delay"},
25+ })
26+ if resp.StatusCode != http.StatusFound {
27+ t.Fatalf("opening a thread answered %d", resp.StatusCode)
28+ }
29+
30+ // Mark replies the other way, by pushing a note from his clone, which chapter 3 calls the point.
31+ work := clone(t, in, mark, "/john/johnbot")
32+ run(t, work, "git", "fetch", "-q", in.url(mark, "/john/johnbot"),
33+ "refs/notes/threads/1:refs/notes/threads/1")
34+ run(t, work, "git", "notes", "--ref=threads/1", "append", "-m",
35+ "mark\ntime: 2026-08-19T00:00:00Z\n\nseen it on a flaky link too")
36+ run(t, work, "git", "push", "-q", in.url(mark, "/john/johnbot"),
37+ "refs/notes/threads/1:refs/notes/threads/1")
38+
39+ // John owns the repository, so the reply must be in his inbox.
40+ body := inboxOf(t, in, "john")
41+ if !strings.Contains(body, "mark replied") {
42+ t.Errorf("a reply pushed from a clone is not in the owner's inbox:\n%s", body)
43+ }
44+ // And mark took part, so he hears about what comes next without pressing anything.
45+ if body := inboxOf(t, in, "mark"); !strings.Contains(body, "reconnect loop") {
46+ t.Errorf("pushing a reply did not subscribe the author to the thread:\n%s", body)
47+ }
48+ }
49+
50+ func inboxOf(t *testing.T, in *instance, account string) string {
51+ t.Helper()
52+ token, err := in.db.NewSession(t.Context(), account)
53+ if err != nil {
54+ t.Fatal(err)
55+ }
56+ req, err := http.NewRequest(http.MethodGet, in.http.URL+"/inbox", nil)
57+ if err != nil {
58+ t.Fatal(err)
59+ }
60+ req.AddCookie(&http.Cookie{Name: "barerepo_session", Value: token})
61+ resp, err := http.DefaultClient.Do(req)
62+ if err != nil {
63+ t.Fatal(err)
64+ }
65+ defer resp.Body.Close()
66+ return readAll(t, resp)
67+ }
@@ -0,0 +1,112 @@
1+ package e2e
2+
3+ import (
4+ "net/http"
5+ "net/url"
6+ "os/exec"
7+ "strings"
8+ "testing"
9+ )
10+
11+ // Chapter 35.5 is two commands, and what they produce has to appear on the thread page.
12+ func TestAReplyWrittenTheWayTheBookSaysAppearsOnThePage(t *testing.T) {
13+ if _, err := exec.LookPath("git"); err != nil {
14+ t.Skip("git is not installed")
15+ }
16+ in := newInstance(t)
17+ john := in.account("john")
18+ mark := in.account("mark")
19+ seed(t, in, john, "john", "johnbot")
20+
21+ resp := post(t, in, "john", "/john/johnbot/threads", url.Values{
22+ "title": {"the reconnect loop spins"},
23+ "body": {"it retries with no delay"},
24+ })
25+ if resp.StatusCode != http.StatusFound {
26+ t.Fatalf("opening a thread answered %d", resp.StatusCode)
27+ }
28+
29+ work := clone(t, in, mark, "/john/johnbot")
30+ // 35.4's fetch is the wildcard one, because that is the line the book hands out.
31+ run(t, work, "git", "fetch", "-q", in.url(mark, "/john/johnbot"), "refs/notes/*:refs/notes/*")
32+ // 35.5 verbatim: a message and nothing else, with no header a reader would have to know about.
33+ run(t, work, "git", "notes", "--ref=threads/1", "append", "-m", "I see the same problem.")
34+ run(t, work, "git", "push", "-q", in.url(mark, "/john/johnbot"), "refs/notes/threads/1")
35+
36+ code, _, body := get(t, in.http.URL+"/john/johnbot/thread/1")
37+ if code != http.StatusOK {
38+ t.Fatalf("the thread answered %d", code)
39+ }
40+ if !strings.Contains(body, "I see the same problem.") {
41+ t.Error("a reply pushed the way chapter 35.5 documents is not on the thread page")
42+ }
43+ // And it is attributed to mark, or john is shown saying words mark wrote.
44+ if !strings.Contains(body, "mark") {
45+ t.Error("the reply is on the page with no author, and the push knew who pushed it")
46+ }
47+ // The two must be separate comments, since one of them belongs to somebody else.
48+ if strings.Count(body, `class="body"`) < 2 {
49+ t.Error("the reply was merged into the comment above it, under that person's name")
50+ }
51+ if i, j := strings.Index(body, "it retries with no delay"), strings.Index(body, "mark"); i > j {
52+ t.Error("the reply is above the comment it answers")
53+ }
54+ }
55+
56+ // A reply from the web is already a proper record, so the repair must leave it exactly alone.
57+ func TestARepairDoesNotTouchAWellFormedReply(t *testing.T) {
58+ if _, err := exec.LookPath("git"); err != nil {
59+ t.Skip("git is not installed")
60+ }
61+ in := newInstance(t)
62+ john := in.account("john")
63+ mark := in.account("mark")
64+ seed(t, in, john, "john", "johnbot")
65+
66+ post(t, in, "john", "/john/johnbot/threads", url.Values{
67+ "title": {"a thread"}, "body": {"the first word"}})
68+ post(t, in, "mark", "/john/johnbot/thread/1/reply", url.Values{"body": {"the second word"}})
69+
70+ // A mirror push of the whole ref changes nothing, so nothing may be re-attributed by it.
71+ work := clone(t, in, mark, "/john/johnbot")
72+ run(t, work, "git", "fetch", "-q", in.url(mark, "/john/johnbot"), "refs/notes/*:refs/notes/*")
73+ run(t, work, "git", "push", "-q", in.url(mark, "/john/johnbot"), "refs/notes/threads/1")
74+
75+ _, _, body := get(t, in.http.URL+"/john/johnbot/thread/1")
76+ if strings.Count(body, "the second word") != 1 {
77+ t.Error("a push that changed nothing rewrote a comment")
78+ }
79+ if !strings.Contains(body, ">john</a>") || !strings.Contains(body, ">mark</a>") {
80+ t.Errorf("a push that changed nothing lost an author:\n%s", body)
81+ }
82+ }
83+
84+ // A record that already names its author is kept as written, or chapter 40.3 cannot restore a repository.
85+ func TestARestoredNoteKeepsTheAuthorItCarries(t *testing.T) {
86+ if _, err := exec.LookPath("git"); err != nil {
87+ t.Skip("git is not installed")
88+ }
89+ in := newInstance(t)
90+ john := in.account("john")
91+ mark := in.account("mark")
92+ in.account("lisa")
93+ seed(t, in, john, "john", "johnbot")
94+
95+ post(t, in, "john", "/john/johnbot/threads", url.Values{
96+ "title": {"a thread"}, "body": {"the first word"}})
97+
98+ // Mark pushes a record lisa wrote, which is what restoring somebody else's repository is.
99+ work := clone(t, in, mark, "/john/johnbot")
100+ run(t, work, "git", "fetch", "-q", in.url(mark, "/john/johnbot"), "refs/notes/*:refs/notes/*")
101+ run(t, work, "git", "notes", "--ref=threads/1", "append", "-m",
102+ "--\nauthor: lisa\ntime: 1755000000\n\nlisa wrote this line")
103+ run(t, work, "git", "push", "-q", in.url(mark, "/john/johnbot"), "refs/notes/threads/1")
104+
105+ _, _, body := get(t, in.http.URL+"/john/johnbot/thread/1")
106+ if !strings.Contains(body, "lisa wrote this line") {
107+ t.Fatalf("the pushed record is not on the page:\n%s", body)
108+ }
109+ if !strings.Contains(body, ">lisa</a>") {
110+ t.Error("a record naming its own author was re-attributed to whoever pushed it")
111+ }
112+ }
@@ -0,0 +1,65 @@
1+ package e2e
2+
3+ import (
4+ "net/http"
5+ "net/url"
6+ "os/exec"
7+ "strings"
8+ "testing"
9+ )
10+
11+ // Chapter 21.2: the old name is never freed, or it inherits the old identity's threads and mentions.
12+ func TestARenamedRepositoryNeverFreesItsOldName(t *testing.T) {
13+ if _, err := exec.LookPath("git"); err != nil {
14+ t.Skip("git is not installed")
15+ }
16+ in := newInstance(t)
17+ john := in.account("john")
18+ seed(t, in, john, "john", "johnbot")
19+
20+ resp := post(t, in, "john", "/john/johnbot/rename", url.Values{"name": {"ircbot"}})
21+ if resp.StatusCode != http.StatusFound {
22+ t.Fatalf("the rename answered %d", resp.StatusCode)
23+ }
24+ // The old path redirects, which is what keeps every clone working. 44.2.
25+ code, _, _ := get(t, in.http.URL+"/john/johnbot")
26+ if code != http.StatusOK {
27+ t.Errorf("the old path answered %d instead of following the redirect", code)
28+ }
29+
30+ // The form must not hand the old name to anybody, including the person who moved it.
31+ resp = post(t, in, "john", "/new", url.Values{"name": {"johnbot"}, "default_branch": {"master"}})
32+ if resp.StatusCode == http.StatusFound {
33+ t.Error("the form gave the old name back out, so the redirect now lies")
34+ }
35+
36+ // Nor may a push take it, which is the other way a repository comes into being. Chapter 11.
37+ work := t.TempDir()
38+ run(t, work, "git", "init", "-q", "-b", "master", work)
39+ write(t, work, "f", "hi\n")
40+ run(t, work, "git", "add", "-A")
41+ run(t, work, "git", "commit", "-qm", "first")
42+ out, err := try(t, work, "git", "push", in.url(john, "/john/johnbot"), "master")
43+ if err == nil && !strings.Contains(out, "ircbot") {
44+ t.Errorf("a push claimed the old name instead of following the redirect:\n%s", out)
45+ }
46+ }
47+
48+ // Chapter 44.4: a deleted repository keeps its name claimed while it waits out its window.
49+ func TestADeletedNameIsNotHandedOutWhileItWaits(t *testing.T) {
50+ if _, err := exec.LookPath("git"); err != nil {
51+ t.Skip("git is not installed")
52+ }
53+ in := newInstance(t)
54+ john := in.account("john")
55+ seed(t, in, john, "john", "johnbot")
56+
57+ resp := post(t, in, "john", "/john/johnbot/delete", url.Values{"confirm": {"johnbot"}})
58+ if resp.StatusCode != http.StatusFound {
59+ t.Fatalf("the delete answered %d", resp.StatusCode)
60+ }
61+ resp = post(t, in, "john", "/new", url.Values{"name": {"johnbot"}, "default_branch": {"master"}})
62+ if resp.StatusCode == http.StatusFound {
63+ t.Error("a name still in the trash window was handed straight back out")
64+ }
65+ }
@@ -0,0 +1,363 @@
1+ // Package e2e runs chapter 45.1's test, because an unexercised portability claim goes false.
2+ package e2e
3+
4+ import (
5+ "context"
6+ "fmt"
7+ "net/http/httptest"
8+ "os"
9+ "os/exec"
10+ "path/filepath"
11+ "strings"
12+ "testing"
13+
14+ "github.com/barerepo/server/internal/cache"
15+ "github.com/barerepo/server/internal/config"
16+ "github.com/barerepo/server/internal/gitread"
17+ "github.com/barerepo/server/internal/httpd"
18+ "github.com/barerepo/server/internal/repocfg"
19+ "github.com/barerepo/server/internal/store"
20+ "github.com/barerepo/server/internal/thread"
21+ "github.com/barerepo/server/internal/transport"
22+ )
23+
24+ var binary string
25+
26+ func TestMain(m *testing.M) {
27+ dir, err := os.MkdirTemp("", "barerepo-e2e-")
28+ if err != nil {
29+ panic(err)
30+ }
31+ binary = filepath.Join(dir, "barerepo")
32+ build := exec.Command("go", "build", "-o", binary, "../cmd/barerepo")
33+ if out, err := build.CombinedOutput(); err != nil {
34+ fmt.Fprintf(os.Stderr, "building barerepo: %v\n%s", err, out)
35+ os.Exit(1)
36+ }
37+ code := m.Run()
38+ os.RemoveAll(dir)
39+ os.Exit(code)
40+ }
41+
42+ // instance is one barerepo, so the test can push a mirror to a second one.
43+ type instance struct {
44+ t *testing.T
45+ cfg config.Config
46+ db *store.DB
47+ srv *httpd.Server
48+ http *httptest.Server
49+ // keys remembers each account's key, so a test can sign as that account.
50+ keys map[string]string
51+ }
52+
53+ // keyOf is the private half of the key the account was made with.
54+ func (in *instance) keyOf(name string) string {
55+ in.t.Helper()
56+ key, ok := in.keys[name]
57+ if !ok {
58+ in.t.Fatalf("no key was recorded for %s", name)
59+ }
60+ return key
61+ }
62+
63+ func newInstance(t *testing.T) *instance {
64+ t.Helper()
65+ root := t.TempDir()
66+ cfg := config.Default()
67+ cfg.Paths.Repos = filepath.Join(root, "repos")
68+ cfg.Paths.Cache = filepath.Join(root, "cache")
69+ cfg.Paths.Artifacts = filepath.Join(root, "artifacts")
70+ cfg.Database.URL = "sqlite://" + filepath.Join(root, "barerepo.db")
71+ cfg.Path = filepath.Join(root, "barerepo.toml")
72+
73+ body := fmt.Sprintf("[paths]\nrepos = %q\ncache = %q\nartifacts = %q\n\n[database]\nurl = %q\n",
74+ cfg.Paths.Repos, cfg.Paths.Cache, cfg.Paths.Artifacts, cfg.Database.URL)
75+ if err := os.WriteFile(cfg.Path, []byte(body), 0o600); err != nil {
76+ t.Fatal(err)
77+ }
78+ for _, d := range []string{cfg.Paths.Repos, cfg.Paths.Cache, cfg.Paths.Artifacts} {
79+ if err := os.MkdirAll(d, 0o750); err != nil {
80+ t.Fatal(err)
81+ }
82+ }
83+ // The server writes this on every key change, and the hook checks signatures against it.
84+ store.SignersPath = filepath.Join(cfg.Paths.Cache, "allowed_signers")
85+ db, err := store.Open(context.Background(), cfg.Database.URL)
86+ if err != nil {
87+ t.Fatal(err)
88+ }
89+ t.Cleanup(func() { db.Close() })
90+
91+ // Every test runs warm, because a cache that returns the wrong answer only does so on a hit.
92+ shared := cache.New(cfg.Paths.Cache)
93+ gitread.Cache = shared
94+ repocfg.Cache = shared
95+ thread.Cache = shared
96+ httpd.Cache = shared
97+
98+ srv := &httpd.Server{Cfg: cfg, DB: db,
99+ Transport: &transport.Server{Cfg: cfg, DB: db, Bin: binary}}
100+ ts := httptest.NewServer(srv.Handler())
101+ t.Cleanup(ts.Close)
102+
103+ // The external url must be the one the server actually got, so clone urls point at it.
104+ cfg.Server.ExternalURL = ts.URL
105+ srv.Cfg = cfg
106+ srv.Transport.Cfg = cfg
107+ body += fmt.Sprintf("\n[server]\nexternal_url = %q\n", ts.URL)
108+ if err := os.WriteFile(cfg.Path, []byte(body), 0o600); err != nil {
109+ t.Fatal(err)
110+ }
111+ return &instance{t: t, cfg: cfg, db: db, srv: srv, http: ts}
112+ }
113+
114+ // account makes an account with a fresh key and returns a push token.
115+ func (in *instance) account(name string) string {
116+ in.t.Helper()
117+ dir := in.t.TempDir()
118+ key := filepath.Join(dir, "id")
119+ run(in.t, "", "ssh-keygen", "-t", "ed25519", "-N", "", "-C", name, "-f", key, "-q")
120+ pub, err := os.ReadFile(key + ".pub")
121+ if err != nil {
122+ in.t.Fatal(err)
123+ }
124+ if _, err := in.db.CreateAccount(context.Background(), name, string(pub), false); err != nil {
125+ in.t.Fatal(err)
126+ }
127+ if in.keys == nil {
128+ in.keys = map[string]string{}
129+ }
130+ in.keys[name] = key
131+ tok, _, err := in.db.CreateToken(context.Background(), "gt", name, "", "test")
132+ if err != nil {
133+ in.t.Fatal(err)
134+ }
135+ return tok
136+ }
137+
138+ func (in *instance) url(token, path string) string {
139+ return strings.Replace(in.http.URL, "://", "://x:"+token+"@", 1) + path
140+ }
141+
142+ func run(t *testing.T, dir string, name string, args ...string) string {
143+ t.Helper()
144+ cmd := exec.Command(name, args...)
145+ cmd.Dir = dir
146+ cmd.Env = append(os.Environ(),
147+ "GIT_AUTHOR_NAME=tester", "GIT_AUTHOR_EMAIL=t@x",
148+ "GIT_COMMITTER_NAME=tester", "GIT_COMMITTER_EMAIL=t@x",
149+ "GIT_TERMINAL_PROMPT=0", "GIT_CONFIG_COUNT=1",
150+ "GIT_CONFIG_KEY_0=credential.helper", "GIT_CONFIG_VALUE_0=")
151+ out, err := cmd.CombinedOutput()
152+ if err != nil {
153+ t.Fatalf("%s %s: %v\n%s", name, strings.Join(args, " "), err, out)
154+ }
155+ return strings.TrimSpace(string(out))
156+ }
157+
158+ func try(t *testing.T, dir string, name string, args ...string) (string, error) {
159+ t.Helper()
160+ cmd := exec.Command(name, args...)
161+ cmd.Dir = dir
162+ cmd.Env = append(os.Environ(),
163+ "GIT_AUTHOR_NAME=tester", "GIT_AUTHOR_EMAIL=t@x",
164+ "GIT_COMMITTER_NAME=tester", "GIT_COMMITTER_EMAIL=t@x",
165+ "GIT_TERMINAL_PROMPT=0", "GIT_CONFIG_COUNT=1",
166+ "GIT_CONFIG_KEY_0=credential.helper", "GIT_CONFIG_VALUE_0=")
167+ out, err := cmd.CombinedOutput()
168+ return string(out), err
169+ }
170+
171+ // TestPortability is chapter 45.1, step for step.
172+ func TestPortability(t *testing.T) {
173+ if _, err := exec.LookPath("git"); err != nil {
174+ t.Skip("git is not installed")
175+ }
176+ origin := newInstance(t)
177+ john := origin.account("john")
178+ lisa := origin.account("lisa")
179+
180+ // 1. Create a repository. Push code.
181+ work := t.TempDir()
182+ run(t, work, "git", "init", "-q", "-b", "master")
183+ write(t, work, "config.go", "package main\n\nfunc Load() error {\n\treturn nil\n}\n")
184+ write(t, work, ".barerepo/config", "[repo]\nvisibility = \"public\"\n\n[build]\ncommand = \"true\"\n")
185+ run(t, work, "git", "add", "-A")
186+ run(t, work, "git", "commit", "-qm", "first")
187+ run(t, work, "git", "push", "-q", origin.url(john, "/john/johnbot"), "master")
188+
189+ // 2. Open a thread. Reply to it.
190+ dir := repoDir(t, origin, "john", "johnbot")
191+ head := run(t, dir, "git", "rev-parse", "HEAD")
192+ note(t, dir, 1, "title: does this work behind a socks proxy?\nstate: open\nauthor: lisa\n",
193+ head, "author: lisa\ntime: 1\n\nasking about proxies.\n")
194+ appendNote(t, dir, 1, head, "author: john\ntime: 2\n\nit should.\n")
195+
196+ // 3. Push a proposal from a second account. Comment on a line.
197+ other := t.TempDir()
198+ run(t, other, "git", "clone", "-q", origin.url(lisa, "/john/johnbot"), other+"/c")
199+ clone := other + "/c"
200+ write(t, clone, "config.go", "package main\n\nfunc Load() error {\n\treturn fmt.Errorf(\"no\")\n}\n")
201+ run(t, clone, "git", "commit", "-qam", "return a real error")
202+ // Thread 1 was pushed straight in, as chapter 35.5 allows, so allocation steps over it.
203+ out := run(t, clone, "git", "push", origin.url(lisa, "/john/johnbot"), "HEAD:refs/proposals/new")
204+ if !strings.Contains(out, "refs/proposals/2") {
205+ t.Fatalf("the proposal did not step over the thread that already had 1:\n%s", out)
206+ }
207+ blob := run(t, dir, "git", "rev-parse", "refs/proposals/2:config.go")
208+ tip := run(t, dir, "git", "rev-parse", "refs/proposals/2")
209+ appendNote(t, dir, 1, tip,
210+ "author: john\ntime: 3\nanchor: config.go:4\nblob: "+blob+"\nside: new\n\nis fmt imported?\n")
211+
212+ // 4. Merge the proposal.
213+ run(t, clone, "git", "fetch", "-q", origin.url(lisa, "/john/johnbot"), "refs/proposals/2:prop-2")
214+ run(t, clone, "git", "checkout", "-q", "master")
215+ run(t, clone, "git", "merge", "-q", "--no-edit", "prop-2")
216+ run(t, clone, "git", "push", "-q", origin.url(john, "/john/johnbot"), "master")
217+
218+ // 5. A second branch and a tag, because chapter 40.1 says every branch and every tag.
219+ run(t, clone, "git", "checkout", "-q", "-b", "topic")
220+ write(t, clone, "topic.go", "package main\n")
221+ run(t, clone, "git", "add", "-A")
222+ run(t, clone, "git", "commit", "-qm", "a topic branch")
223+ run(t, clone, "git", "push", "-q", origin.url(john, "/john/johnbot"), "topic")
224+ run(t, clone, "git", "checkout", "-q", "master")
225+ run(t, clone, "git", "tag", "-a", "v1.0.0", "-m", "the first release")
226+ run(t, clone, "git", "push", "-q", origin.url(john, "/john/johnbot"), "v1.0.0")
227+ tag := run(t, dir, "git", "rev-parse", "v1.0.0")
228+ // A release body is a note on the tag, and chapter 22.1 says it clones with the repository.
229+ run(t, dir, "git", "notes", "--ref=refs/notes/releases", "add", "-m", "what changed here", tag)
230+
231+ // 6. Run a build, recorded where a mirror will find it, since the protocol is tested elsewhere.
232+ merged := run(t, dir, "git", "rev-parse", "refs/heads/master")
233+ runNote(t, dir, merged)
234+
235+ // 7. git clone --mirror the repository.
236+ mirror := filepath.Join(t.TempDir(), "johnbot.git")
237+ run(t, "", "git", "clone", "-q", "--mirror", origin.url(john, "/john/johnbot"), mirror)
238+
239+ // 8. Delete the original from the server.
240+ if err := os.RemoveAll(dir); err != nil {
241+ t.Fatal(err)
242+ }
243+ if _, err := try(t, "", "git", "ls-remote", origin.url(john, "/john/johnbot")); err == nil {
244+ t.Fatal("the original still answers after being deleted")
245+ }
246+
247+ // 9. Push the mirror to a second barerepo instance.
248+ second := newInstance(t)
249+ dave := second.account("dave")
250+ run(t, mirror, "git", "push", "--mirror", second.url(dave, "/dave/johnbot"))
251+
252+ // 10. Confirm everything is present on the second instance.
253+ moved := repoDir(t, second, "dave", "johnbot")
254+ checks := []struct{ what, got, want string }{
255+ {"code", run(t, moved, "git", "show", "master:config.go"), "fmt.Errorf"},
256+ {"history", run(t, moved, "git", "log", "--format=%s", "master"), "first"},
257+ {"the merge", run(t, moved, "git", "log", "--format=%s", "master"), "return a real error"},
258+ {"config", run(t, moved, "git", "show", "master:.barerepo/config"), "visibility"},
259+ {"the thread", run(t, moved, "git", "cat-file", "blob", "refs/notes/threads/1:meta"), "socks proxy"},
260+ {"comments", run(t, moved, "git", "notes", "--ref=threads/1", "show", head), "asking about proxies"},
261+ {"the reply", run(t, moved, "git", "notes", "--ref=threads/1", "show", head), "it should"},
262+ {"the line comment", run(t, moved, "git", "notes", "--ref=threads/1", "show", tip), "anchor: config.go:4"},
263+ {"the anchored blob", run(t, moved, "git", "notes", "--ref=threads/1", "show", tip), blob},
264+ {"run results", run(t, moved, "git", "notes", "--ref=runs", "show", merged), "uproar.local"},
265+ {"every branch", run(t, moved, "git", "show", "topic:topic.go"), "package main"},
266+ {"every tag", run(t, moved, "git", "cat-file", "-p", "v1.0.0"), "the first release"},
267+ {"the release body", run(t, moved, "git", "notes", "--ref=refs/notes/releases", "show", tag),
268+ "what changed here"},
269+ }
270+ for _, c := range checks {
271+ if !strings.Contains(c.got, c.want) {
272+ t.Errorf("%s did not survive the move: wanted %q in\n%s", c.what, c.want, c.got)
273+ }
274+ }
275+ // The proposal ref travels too, per chapter 40.1.
276+ if _, err := try(t, moved, "git", "rev-parse", "--verify", "refs/proposals/2"); err != nil {
277+ t.Error("the proposal ref did not survive the move")
278+ }
279+ // The commented content is still fetchable, which chapter 43.4 needs to show it.
280+ if _, err := try(t, moved, "git", "cat-file", "-e", blob); err != nil {
281+ t.Error("the blob a comment was anchored to is gone")
282+ }
283+ }
284+
285+ func write(t *testing.T, dir, name, body string) {
286+ t.Helper()
287+ path := filepath.Join(dir, name)
288+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
289+ t.Fatal(err)
290+ }
291+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
292+ t.Fatal(err)
293+ }
294+ }
295+
296+ func repoDir(t *testing.T, in *instance, owner, name string) string {
297+ t.Helper()
298+ return filepath.Join(in.cfg.Paths.Repos, owner, name+".git")
299+ }
300+
301+ // note writes a thread the way the server does: a meta blob and one note.
302+ func note(t *testing.T, dir string, n int, meta, object, comment string) {
303+ t.Helper()
304+ metaBlob := hashObject(t, dir, meta)
305+ noteBlob := hashObject(t, dir, comment)
306+ tree := mktree(t, dir, "100644 blob "+metaBlob+"\tmeta\n100644 blob "+noteBlob+"\t"+object+"\n")
307+ commit := run(t, dir, "git", "commit-tree", tree, "-m", "open thread")
308+ run(t, dir, "git", "update-ref", fmt.Sprintf("refs/notes/threads/%d", n), commit)
309+ }
310+
311+ func appendNote(t *testing.T, dir string, n int, object, record string) {
312+ t.Helper()
313+ ref := fmt.Sprintf("refs/notes/threads/%d", n)
314+ existing, err := try(t, dir, "git", "cat-file", "blob", ref+":"+object)
315+ body := record
316+ if err == nil {
317+ body = strings.TrimRight(existing, "\n") + "\n--\n" + record
318+ }
319+ listing := run(t, dir, "git", "ls-tree", ref)
320+ var entries strings.Builder
321+ for _, line := range strings.Split(listing, "\n") {
322+ if line == "" || strings.HasSuffix(line, "\t"+object) {
323+ continue
324+ }
325+ entries.WriteString(line + "\n")
326+ }
327+ entries.WriteString("100644 blob " + hashObject(t, dir, body) + "\t" + object + "\n")
328+ tree := mktree(t, dir, entries.String())
329+ commit := run(t, dir, "git", "commit-tree", tree, "-p", ref, "-m", "reply")
330+ run(t, dir, "git", "update-ref", ref, commit)
331+ }
332+
333+ func runNote(t *testing.T, dir, sha string) {
334+ t.Helper()
335+ body := `{"runner":"uproar.local","labels":["build"],"ref":"refs/heads/master","started":1,"duration":18,"exit":0,"output":"ok\n"}`
336+ tree := mktree(t, dir, "100644 blob "+hashObject(t, dir, body+"\n")+"\t"+sha+"\n")
337+ commit := run(t, dir, "git", "commit-tree", tree, "-m", "run")
338+ run(t, dir, "git", "update-ref", "refs/notes/runs", commit)
339+ }
340+
341+ func hashObject(t *testing.T, dir, body string) string {
342+ t.Helper()
343+ cmd := exec.Command("git", "hash-object", "-w", "--stdin")
344+ cmd.Dir = dir
345+ cmd.Stdin = strings.NewReader(body)
346+ out, err := cmd.Output()
347+ if err != nil {
348+ t.Fatal(err)
349+ }
350+ return strings.TrimSpace(string(out))
351+ }
352+
353+ func mktree(t *testing.T, dir, entries string) string {
354+ t.Helper()
355+ cmd := exec.Command("git", "mktree")
356+ cmd.Dir = dir
357+ cmd.Stdin = strings.NewReader(entries)
358+ out, err := cmd.Output()
359+ if err != nil {
360+ t.Fatalf("mktree: %v", err)
361+ }
362+ return strings.TrimSpace(string(out))
363+ }
@@ -0,0 +1,63 @@
1+ package e2e
2+
3+ import (
4+ "fmt"
5+ "os"
6+ "os/exec"
7+ "strings"
8+ "testing"
9+ )
10+
11+ // setLimits appends a limits block to an instance's config, which the hooks read at push time.
12+ func setLimits(t *testing.T, in *instance, block string) {
13+ t.Helper()
14+ body, err := os.ReadFile(in.cfg.Path)
15+ if err != nil {
16+ t.Fatal(err)
17+ }
18+ if err := os.WriteFile(in.cfg.Path, append(body, []byte(block)...), 0o600); err != nil {
19+ t.Fatal(err)
20+ }
21+ }
22+
23+ // Chapter 27: rule 5 is an open door, and a cap on open proposals is how it is defended.
24+ func TestOpenProposalsAreCappedPerAccountPerRepository(t *testing.T) {
25+ if _, err := exec.LookPath("git"); err != nil {
26+ t.Skip("git is not installed")
27+ }
28+ in := newInstance(t)
29+ john := in.account("john")
30+ mark := in.account("mark")
31+ seed(t, in, john, "john", "johnbot")
32+ setLimits(t, in, "\n[limits]\nmax_open_proposals = 2\n")
33+
34+ mine := clone(t, in, mark, "/john/johnbot")
35+ for i := 0; i < 2; i++ {
36+ commit(t, mine, fmt.Sprintf("package main\n\nvar v = %d\n", i), fmt.Sprintf("proposal %d", i))
37+ run(t, mine, "git", "push", "-q", in.url(mark, "/john/johnbot"), "HEAD:refs/proposals/new")
38+ }
39+
40+ commit(t, mine, "package main\n\nvar v = 99\n", "one too many")
41+ out, err := try(t, mine, "git", "push", in.url(mark, "/john/johnbot"), "HEAD:refs/proposals/new")
42+ if err == nil {
43+ t.Fatalf("a third proposal opened against a cap of two:\n%s", out)
44+ }
45+ if !strings.Contains(out, "you have 2 proposals open") {
46+ t.Errorf("the rejection does not say how many are open:\n%s", out)
47+ }
48+ if !strings.Contains(out, "the limit is 2") {
49+ t.Errorf("the rejection does not say what the limit is:\n%s", out)
50+ }
51+ if !strings.Contains(out, "land or close one") {
52+ t.Errorf("the rejection does not say what to do about it:\n%s", out)
53+ }
54+
55+ // The cap is per account, so somebody else is not held back by mark's two.
56+ theirs := clone(t, in, john, "/john/johnbot")
57+ commit(t, theirs, "package main\n\nvar w = 1\n", "john proposes")
58+ run(t, theirs, "git", "push", "-q", in.url(john, "/john/johnbot"), "HEAD:refs/proposals/new")
59+
60+ // Updating a proposal already open is not opening a new one.
61+ commit(t, mine, "package main\n\nvar v = 100\n", "revise the second")
62+ run(t, mine, "git", "push", "-q", "-f", in.url(mark, "/john/johnbot"), "HEAD:refs/proposals/2")
63+ }
@@ -0,0 +1,50 @@
1+ package e2e
2+
3+ import (
4+ "net/http"
5+ "os/exec"
6+ "strings"
7+ "testing"
8+ )
9+
10+ // Appendix C lists a url for one release, and a url the book prints must not answer 404.
11+ func TestOneReleaseOpensTheListAtThatTag(t *testing.T) {
12+ if _, err := exec.LookPath("git"); err != nil {
13+ t.Skip("git is not installed")
14+ }
15+ in := newInstance(t)
16+ john := in.account("john")
17+ work := seed(t, in, john, "john", "johnbot")
18+ run(t, work, "git", "tag", "-a", "v1.2.0", "-m", "fixes the empty config panic")
19+ run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "v1.2.0")
20+
21+ client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
22+ return http.ErrUseLastResponse
23+ }}
24+ resp, err := client.Get(in.http.URL + "/john/johnbot/release/v1.2.0")
25+ if err != nil {
26+ t.Fatal(err)
27+ }
28+ resp.Body.Close()
29+ if resp.StatusCode != http.StatusFound {
30+ t.Fatalf("the release url answered %d", resp.StatusCode)
31+ }
32+ if got := resp.Header.Get("Location"); got != "/john/johnbot/releases?from=v1.2.0" {
33+ t.Errorf("it went to %q", got)
34+ }
35+
36+ code, _, body := get(t, in.http.URL+"/john/johnbot/releases?from=v1.2.0")
37+ if code != http.StatusOK || !strings.Contains(body, "v1.2.0") {
38+ t.Errorf("the list does not show the tag it was opened at: %d\n%s", code, body)
39+ }
40+
41+ // A tag that is not here is a 404, not the newest release wearing the wrong name.
42+ resp, err = client.Get(in.http.URL + "/john/johnbot/release/v9.9.9")
43+ if err != nil {
44+ t.Fatal(err)
45+ }
46+ resp.Body.Close()
47+ if resp.StatusCode != http.StatusNotFound {
48+ t.Errorf("an unknown tag answered %d", resp.StatusCode)
49+ }
50+ }
@@ -0,0 +1,126 @@
1+ package e2e
2+
3+ import (
4+ "context"
5+ "os/exec"
6+ "strings"
7+ "testing"
8+
9+ "github.com/barerepo/server/internal/token"
10+ )
11+
12+ // Chapter 37.4: require_runs names the runs the default branch waits for, and it was never read.
13+ func TestTheDefaultBranchWaitsForTheRunsTheConfigNames(t *testing.T) {
14+ if _, err := exec.LookPath("git"); err != nil {
15+ t.Skip("git is not installed")
16+ }
17+ in := newInstance(t)
18+ john := in.account("john")
19+ lisa := in.account("lisa")
20+ work := seed(t, in, john, "john", "johnbot")
21+
22+ write(t, work, ".barerepo/config", "[repo]\nvisibility = \"public\"\n\n"+
23+ "[access]\npush = [\"lisa\"]\n\n[proposals]\nrequire_runs = [\"build\"]\n")
24+ run(t, work, "git", "add", "-A")
25+ run(t, work, "git", "commit", "-qm", "builds must pass before master takes a commit")
26+ run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "master")
27+
28+ hers := clone(t, in, lisa, "/john/johnbot")
29+ commit(t, hers, "package main\n\nfunc main() {}\n", "a change nobody built")
30+ out, err := try(t, hers, "git", "push", in.url(lisa, "/john/johnbot"), "master")
31+ if err == nil {
32+ t.Fatalf("master took a commit with no passing build:\n%s", out)
33+ }
34+ for _, want := range []string{"require_runs", "build", "refs/proposals/new"} {
35+ if !strings.Contains(out, want) {
36+ t.Errorf("the rejection does not mention %q:\n%s", want, out)
37+ }
38+ }
39+
40+ // The same commit, once a run named build has passed on it, is the push that must go through.
41+ sha := strings.TrimSpace(run(t, hers, "git", "rev-parse", "HEAD"))
42+ passRun(t, in, "john", "john/johnbot", sha, "build")
43+ if code, _, body := get(t, in.http.URL+"/john/johnbot/run/"+sha); code != 200 {
44+ t.Fatalf("the run page answered %d", code)
45+ } else {
46+ t.Logf("RUNPAGE %.900s", body[strings.Index(body, "class=\"card\""):])
47+ }
48+ if out, err := try(t, hers, "git", "push", in.url(lisa, "/john/johnbot"), "master"); err != nil {
49+ t.Errorf("master refused a commit whose build passed (sha %s): %v\n%s", sha, err, out)
50+ }
51+ }
52+
53+ // The owner is exempt, or a repository whose runner is gone has nobody who can fix the config.
54+ func TestTheOwnerIsNotLockedOutByARunThatCannotHappen(t *testing.T) {
55+ if _, err := exec.LookPath("git"); err != nil {
56+ t.Skip("git is not installed")
57+ }
58+ in := newInstance(t)
59+ john := in.account("john")
60+ work := seed(t, in, john, "john", "johnbot")
61+
62+ write(t, work, ".barerepo/config", "[proposals]\nrequire_runs = [\"build\"]\n")
63+ run(t, work, "git", "add", "-A")
64+ run(t, work, "git", "commit", "-qm", "require a run no runner will ever take")
65+ run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "master")
66+
67+ write(t, work, "conn.go", "package irc\n")
68+ run(t, work, "git", "add", "-A")
69+ run(t, work, "git", "commit", "-qm", "the owner undoing their own rule")
70+ if out, err := try(t, work, "git", "push", in.url(john, "/john/johnbot"), "master"); err != nil {
71+ t.Errorf("the owner cannot reach their own repository to fix it: %v\n%s", err, out)
72+ }
73+ }
74+
75+ // passRun records one passing run under a name, the way a runner finishing a job does.
76+ func passRun(t *testing.T, in *instance, account, repo, sha, name string) {
77+ t.Helper()
78+ ctx := context.Background()
79+ runnerToken, tok, err := in.db.CreateToken(ctx, token.Runner, account, repo, "a runner")
80+ if err != nil {
81+ t.Fatal(err)
82+ }
83+ runner, err := in.db.AttachRunner(ctx, tok.ID, repo, "uproar.local", "linux", "amd64", nil)
84+ if err != nil {
85+ t.Fatal(err)
86+ }
87+ if _, err := in.db.QueueJobFor(ctx, repo, "refs/heads/master", sha,
88+ "go build", "", nil, name); err != nil {
89+ t.Fatal(err)
90+ }
91+ job, err := in.db.TakeJob(ctx, repo, *runner)
92+ if err != nil || job == nil {
93+ t.Fatalf("the runner could not take the job: %v", err)
94+ }
95+ finish(t, in, runnerToken, job.ID, 0)
96+ }
97+
98+ // Chapter 33.6 is one recipe: edit default_branch, commit, push. It was parsed and read by nothing.
99+ func TestChangingTheDefaultBranchInTheConfigMovesIt(t *testing.T) {
100+ if _, err := exec.LookPath("git"); err != nil {
101+ t.Skip("git is not installed")
102+ }
103+ in := newInstance(t)
104+ john := in.account("john")
105+ work := seed(t, in, john, "john", "johnbot")
106+
107+ run(t, work, "git", "branch", "main")
108+ run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "main")
109+ write(t, work, ".barerepo/config", "[repo]\ndefault_branch = \"main\"\nvisibility = \"public\"\n")
110+ run(t, work, "git", "add", "-A")
111+ run(t, work, "git", "commit", "-qm", "move to main")
112+ run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "master")
113+ // The config arrives on master, so main has to catch up before it can hold the file.
114+ run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "master:main")
115+
116+ // A fresh clone takes its branch from HEAD, which is the only place this setting can show.
117+ fresh := clone(t, in, john, "/john/johnbot")
118+ if got := strings.TrimSpace(run(t, fresh, "git", "rev-parse", "--abbrev-ref", "HEAD")); got != "main" {
119+ t.Errorf("a clone landed on %q, and the config says main", got)
120+ }
121+ // And the pages agree, or a reader is looking at a branch nobody else is on.
122+ _, _, body := get(t, in.http.URL+"/john/johnbot")
123+ if !strings.Contains(body, ">main</a>") {
124+ t.Errorf("the landing page does not show main as the branch:\n%s", body[:min(len(body), 1200)])
125+ }
126+ }
@@ -0,0 +1,43 @@
1+ package e2e
2+
3+ import (
4+ "net/http"
5+ "os/exec"
6+ "strings"
7+ "testing"
8+ )
9+
10+ // Chapter 12: barerepo records each push as a revision in the thread, or review reads the wrong code.
11+ func TestAProposalPushIsRecordedInItsThread(t *testing.T) {
12+ if _, err := exec.LookPath("git"); err != nil {
13+ t.Skip("git is not installed")
14+ }
15+ in := newInstance(t)
16+ john := in.account("john")
17+ mark := in.account("mark")
18+ seed(t, in, john, "john", "johnbot")
19+
20+ work := clone(t, in, mark, "/john/johnbot")
21+ commit(t, work, "package main\n\nfunc main() {}\n", "a first attempt")
22+ run(t, work, "git", "push", "-q", in.url(mark, "/john/johnbot"), "HEAD:refs/proposals/new")
23+
24+ // The second push is the one a reviewer needs to be told about.
25+ commit(t, work, "package main\n\nfunc main() { println(1) }\n", "a second attempt")
26+ out := run(t, work, "git", "push", in.url(mark, "/john/johnbot"), "HEAD:refs/proposals/1")
27+ if !strings.Contains(out, "updated") {
28+ t.Fatalf("the second push did not update the proposal:\n%s", out)
29+ }
30+
31+ code, _, body := get(t, in.http.URL+"/john/johnbot/thread/1")
32+ if code != http.StatusOK {
33+ t.Fatalf("the thread answered %d", code)
34+ }
35+ if !strings.Contains(body, "pushed revision 2") {
36+ t.Errorf("the thread does not say the proposal moved:\n%s", body)
37+ }
38+ // The comment is mark's, because mark pushed it, and the page says who did everything else.
39+ i := strings.Index(body, "pushed revision 2")
40+ if !strings.Contains(body[max(0, i-400):i], "mark") {
41+ t.Error("the revision was recorded with nobody's name on it")
42+ }
43+ }
@@ -0,0 +1,66 @@
1+ package e2e
2+
3+ import (
4+ "bytes"
5+ "encoding/json"
6+ "net/http"
7+ "os/exec"
8+ "strings"
9+ "testing"
10+ )
11+
12+ // Chapter 19.1 has run.failed and not run.succeeded, so a red build has to reach the inbox.
13+ func TestAFailedBuildReachesTheInbox(t *testing.T) {
14+ if _, err := exec.LookPath("git"); err != nil {
15+ t.Skip("git is not installed")
16+ }
17+ in := newInstance(t)
18+ john := in.account("john")
19+ seed(t, in, john, "john", "johnbot")
20+
21+ jobID, _, runnerToken := takeJobAs(t, in, "john", "john/johnbot")
22+ finish(t, in, runnerToken, jobID, 2)
23+
24+ body := inboxOf(t, in, "john")
25+ if !strings.Contains(body, "build failed") {
26+ t.Errorf("a failed build is not in the inbox:\n%s", body)
27+ }
28+ if !strings.Contains(body, "refs/heads/master") {
29+ t.Errorf("the inbox line does not say what was being built:\n%s", body)
30+ }
31+ }
32+
33+ // A green build is not news, and putting it in the inbox is how a feed becomes noise. 19.1.
34+ func TestAPassingBuildIsNotAnEvent(t *testing.T) {
35+ if _, err := exec.LookPath("git"); err != nil {
36+ t.Skip("git is not installed")
37+ }
38+ in := newInstance(t)
39+ john := in.account("john")
40+ seed(t, in, john, "john", "johnbot")
41+
42+ jobID, _, runnerToken := takeJobAs(t, in, "john", "john/johnbot")
43+ finish(t, in, runnerToken, jobID, 0)
44+
45+ if body := inboxOf(t, in, "john"); strings.Contains(body, "build failed") {
46+ t.Errorf("a build that passed produced an event:\n%s", body)
47+ }
48+ }
49+
50+ func finish(t *testing.T, in *instance, runnerToken string, jobID int64, exit int) {
51+ t.Helper()
52+ body, err := json.Marshal(map[string]any{
53+ "token": runnerToken, "job_id": jobID, "exit_code": exit, "duration": 3,
54+ })
55+ if err != nil {
56+ t.Fatal(err)
57+ }
58+ resp, err := http.Post(in.http.URL+"/runner/done", "application/json", bytes.NewReader(body))
59+ if err != nil {
60+ t.Fatal(err)
61+ }
62+ defer resp.Body.Close()
63+ if resp.StatusCode != http.StatusNoContent {
64+ t.Fatalf("finishing the job answered %d", resp.StatusCode)
65+ }
66+ }
@@ -0,0 +1,81 @@
1+ package e2e
2+
3+ import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "net/http"
8+ "os/exec"
9+ "strings"
10+ "testing"
11+
12+ "github.com/barerepo/server/internal/token"
13+ )
14+
15+ // Chapter 25 budgets any page with no diff at 15kb, and a build log is the thing that can break it.
16+ func TestALongBuildLogDoesNotBreakTheRunPage(t *testing.T) {
17+ if _, err := exec.LookPath("git"); err != nil {
18+ t.Skip("git is not installed")
19+ }
20+ in := newInstance(t)
21+ john := in.account("john")
22+ work := seed(t, in, john, "john", "johnbot")
23+
24+ sha := strings.TrimSpace(run(t, work, "git", "rev-parse", "HEAD"))
25+ jobID, runnerToken := jobOn(t, in, "john", "john/johnbot", sha)
26+ // A build that says a lot, which is what a real one does when it fails.
27+ var log strings.Builder
28+ for i := 0; i < 4000; i++ {
29+ log.WriteString("go: downloading example.com/some/module v1.2.3\n")
30+ }
31+ log.WriteString("store/db.go:14:2: cannot find module providing package\n")
32+ body, err := json.Marshal(map[string]any{
33+ "token": runnerToken, "job_id": jobID, "chunk": log.String(),
34+ })
35+ if err != nil {
36+ t.Fatal(err)
37+ }
38+ resp, err := http.Post(in.http.URL+"/runner/log", "application/json", bytes.NewReader(body))
39+ if err != nil {
40+ t.Fatal(err)
41+ }
42+ resp.Body.Close()
43+ finish(t, in, runnerToken, jobID, 1)
44+
45+ code, _, page := get(t, in.http.URL+"/john/johnbot/run/"+sha)
46+ if code != http.StatusOK {
47+ t.Fatalf("the run page answered %d", code)
48+ }
49+ if len(page) > 15<<10 {
50+ t.Errorf("the run page sent %dkb, over chapter 25's 15kb", len(page)>>10)
51+ }
52+ // The end of a log is where the failure is, so that is the end that stays.
53+ if !strings.Contains(page, "cannot find module providing package") {
54+ t.Error("the page cut off the line the reader came for")
55+ }
56+ if !strings.Contains(page, "the whole log") {
57+ t.Error("the page does not offer the rest of the log it cut")
58+ }
59+ }
60+
61+ // jobOn queues and takes a job for one real commit, which is what the run page can then find.
62+ func jobOn(t *testing.T, in *instance, account, repo, sha string) (int64, string) {
63+ t.Helper()
64+ ctx := context.Background()
65+ runnerToken, tok, err := in.db.CreateToken(ctx, token.Runner, account, repo, "a runner")
66+ if err != nil {
67+ t.Fatal(err)
68+ }
69+ runner, err := in.db.AttachRunner(ctx, tok.ID, repo, "uproar.local", "linux", "amd64", nil)
70+ if err != nil {
71+ t.Fatal(err)
72+ }
73+ if _, err := in.db.QueueJob(ctx, repo, "refs/heads/master", sha, "go build", ""); err != nil {
74+ t.Fatal(err)
75+ }
76+ job, err := in.db.TakeJob(ctx, repo, *runner)
77+ if err != nil || job == nil {
78+ t.Fatalf("the runner could not take the job: %v", err)
79+ }
80+ return job.ID, runnerToken
81+ }
@@ -0,0 +1,126 @@
1+ package e2e
2+
3+ import (
4+ "bytes"
5+ "encoding/json"
6+ "net/http"
7+ "net/url"
8+ "os/exec"
9+ "strconv"
10+ "strings"
11+ "testing"
12+
13+ "github.com/barerepo/server/internal/token"
14+ )
15+
16+ // Chapter 24 says the protocol is plain http so anyone can write their own runner. This is anyone.
17+ func TestARunnerCanBeWrittenFromTheFourEndpoints(t *testing.T) {
18+ if _, err := exec.LookPath("git"); err != nil {
19+ t.Skip("git is not installed")
20+ }
21+ in := newInstance(t)
22+ john := in.account("john")
23+ work := seed(t, in, john, "john", "johnbot")
24+
25+ tok, _, err := in.db.CreateToken(t.Context(), token.Runner, "john", "john/johnbot", "a runner")
26+ if err != nil {
27+ t.Fatal(err)
28+ }
29+
30+ // POST /runner/attach. The machine says what it is and gets an id back.
31+ var attached struct {
32+ RunnerID int64 `json:"runner_id"`
33+ PollInterval int `json:"poll_interval"`
34+ }
35+ post := postJSON(t, in, "/runner/attach", map[string]any{
36+ "token": tok, "hostname": "uproar.local", "os": "linux", "arch": "amd64",
37+ "labels": []string{"build", "test"},
38+ })
39+ if post.StatusCode != http.StatusOK {
40+ t.Fatalf("attach answered %d", post.StatusCode)
41+ }
42+ if err := json.Unmarshal([]byte(post.body), &attached); err != nil {
43+ t.Fatal(err)
44+ }
45+ if attached.RunnerID == 0 || attached.PollInterval == 0 {
46+ t.Fatalf("attach said %+v, which tells a runner nothing", attached)
47+ }
48+
49+ // The page updates the moment a runner attaches, which is what the runners page is for.
50+ if _, _, page := get(t, in.http.URL+"/john/johnbot/runners"); !strings.Contains(page, "uproar.local") {
51+ t.Errorf("the runners page does not list a machine that just attached:\n%s", page)
52+ }
53+
54+ // A build to take. The workflow-free path is [build] command in the repository's own file.
55+ write(t, work, ".barerepo/config", "[repo]\nvisibility = \"public\"\n\n[build]\ncommand = \"echo hi\"\n")
56+ run(t, work, "git", "commit", "-qam", "turn builds on")
57+ run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "master")
58+
59+ // GET /runner/poll. It holds the request open, and answers with one job.
60+ var job struct {
61+ JobID int64 `json:"job_id"`
62+ Repo string `json:"repo"`
63+ SHA string `json:"sha"`
64+ Command string `json:"command"`
65+ CloneURL string `json:"clone_url"`
66+ JobToken string `json:"job_token"`
67+ }
68+ poll := getBody(t, in, "/runner/poll?token="+url.QueryEscape(tok)+
69+ "&id="+strconv.FormatInt(attached.RunnerID, 10))
70+ if poll.StatusCode != http.StatusOK {
71+ t.Fatalf("poll answered %d, so the push queued nothing: %s", poll.StatusCode, poll.body)
72+ }
73+ if err := json.Unmarshal([]byte(poll.body), &job); err != nil {
74+ t.Fatal(err)
75+ }
76+ if job.Command != "echo hi" || job.Repo != "john/johnbot" || job.JobToken == "" {
77+ t.Fatalf("the job is not what the repository asked for: %+v", job)
78+ }
79+
80+ // POST /runner/log, then POST /runner/done. The log is the whole page, per chapter 16.
81+ if r := postJSON(t, in, "/runner/log", map[string]any{
82+ "token": tok, "job_id": job.JobID, "seq": 0, "chunk": "$ echo hi\nhi\n",
83+ }); r.StatusCode != http.StatusNoContent {
84+ t.Fatalf("log answered %d", r.StatusCode)
85+ }
86+ if r := postJSON(t, in, "/runner/done", map[string]any{
87+ "token": tok, "job_id": job.JobID, "exit_code": 0, "duration": 1,
88+ }); r.StatusCode != http.StatusNoContent {
89+ t.Fatalf("done answered %d", r.StatusCode)
90+ }
91+
92+ // And the run is on the page, with the log, which is the point of all four calls.
93+ code, _, page := get(t, in.http.URL+"/john/johnbot/run/"+job.SHA)
94+ if code != http.StatusOK {
95+ t.Fatalf("the run page answered %d", code)
96+ }
97+ for _, want := range []string{"uproar.local", "$ echo hi", "build ok"} {
98+ if !strings.Contains(page, want) {
99+ t.Errorf("the run page is missing %q:\n%s", want, page)
100+ }
101+ }
102+ }
103+
104+ func postJSON(t *testing.T, in *instance, path string, body map[string]any) formResult {
105+ t.Helper()
106+ raw, err := json.Marshal(body)
107+ if err != nil {
108+ t.Fatal(err)
109+ }
110+ resp, err := http.Post(in.http.URL+path, "application/json", bytes.NewReader(raw))
111+ if err != nil {
112+ t.Fatal(err)
113+ }
114+ defer resp.Body.Close()
115+ return formResult{resp, readAll(t, resp)}
116+ }
117+
118+ func getBody(t *testing.T, in *instance, path string) formResult {
119+ t.Helper()
120+ resp, err := http.Get(in.http.URL + path)
121+ if err != nil {
122+ t.Fatal(err)
123+ }
124+ defer resp.Body.Close()
125+ return formResult{resp, readAll(t, resp)}
126+ }
@@ -0,0 +1,197 @@
1+ package e2e
2+
3+ import (
4+ "io"
5+ "net/http"
6+ "os/exec"
7+ "strings"
8+ "testing"
9+
10+ bartoken "github.com/barerepo/server/internal/token"
11+ )
12+
13+ // readAll drains a response body and returns it as a string.
14+ func readAll(t *testing.T, resp *http.Response) string {
15+ t.Helper()
16+ body, err := io.ReadAll(resp.Body)
17+ if err != nil {
18+ t.Fatal(err)
19+ }
20+ return string(body)
21+ }
22+
23+ // Chapter 24: three commands, all visible at once, token inside. A button to reveal them is a step.
24+ func TestAddingARunnerIsOnePageAndNoClicks(t *testing.T) {
25+ if _, err := exec.LookPath("git"); err != nil {
26+ t.Skip("git is not installed")
27+ }
28+ in := newInstance(t)
29+ john := in.account("john")
30+ seed(t, in, john, "john", "johnbot")
31+
32+ token, err := in.db.NewSession(t.Context(), "john")
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ req, err := http.NewRequest(http.MethodGet, in.http.URL+"/john/johnbot/runners/new", nil)
37+ if err != nil {
38+ t.Fatal(err)
39+ }
40+ req.AddCookie(&http.Cookie{Name: "barerepo_session", Value: token})
41+ resp, err := http.DefaultClient.Do(req)
42+ if err != nil {
43+ t.Fatal(err)
44+ }
45+ defer resp.Body.Close()
46+ body := readAll(t, resp)
47+
48+ if strings.Contains(body, "new runner token") {
49+ t.Error("the page asks for a click before it shows the commands, which chapter 24 forbids")
50+ }
51+ for _, want := range []string{"runner.sh", "runner.ps1", "barerepo-runner ", "rt_live_"} {
52+ if !strings.Contains(body, want) {
53+ t.Errorf("the page is missing %q:\n%s", want, body)
54+ }
55+ }
56+ // The three facts chapter 24 asks for, in the mockup's words.
57+ for _, want := range []string{"dials out", "scopes to this repo", "shown once"} {
58+ if !strings.Contains(body, want) {
59+ t.Errorf("the page does not say %q", want)
60+ }
61+ }
62+
63+ // A reload must not revoke the line the reader just copied.
64+ first := tokenIn(t, body)
65+ req2, _ := http.NewRequest(http.MethodGet, in.http.URL+"/john/johnbot/runners/new", nil)
66+ req2.AddCookie(&http.Cookie{Name: "barerepo_session", Value: token})
67+ resp2, err := http.DefaultClient.Do(req2)
68+ if err != nil {
69+ t.Fatal(err)
70+ }
71+ defer resp2.Body.Close()
72+ readAll(t, resp2)
73+
74+ if _, err := in.db.AccountForToken(t.Context(), bartoken.Runner, first); err != nil {
75+ t.Errorf("reloading the page revoked the token the reader had already copied: %v", err)
76+ }
77+ }
78+
79+ func tokenIn(t *testing.T, body string) string {
80+ t.Helper()
81+ i := strings.Index(body, "rt_live_")
82+ if i < 0 {
83+ t.Fatal("no runner token on the page")
84+ }
85+ rest := body[i:]
86+ end := strings.IndexAny(rest, "< \n")
87+ if end < 0 {
88+ t.Fatal("the token does not end")
89+ }
90+ return rest[:end]
91+ }
92+
93+ // Chapter 24 puts a description on the new repository form, and only the pasted block can store it.
94+ func TestTheNewRepositoryDescriptionReachesThePasteBlock(t *testing.T) {
95+ if _, err := exec.LookPath("git"); err != nil {
96+ t.Skip("git is not installed")
97+ }
98+ in := newInstance(t)
99+ in.account("john")
100+
101+ session, err := in.db.NewSession(t.Context(), "john")
102+ if err != nil {
103+ t.Fatal(err)
104+ }
105+ form := "name=johnbot&description=irc+bot+that+refuses+to+leave&visibility=public&default_branch=master"
106+ req, err := http.NewRequest(http.MethodPost, in.http.URL+"/new", strings.NewReader(form))
107+ if err != nil {
108+ t.Fatal(err)
109+ }
110+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
111+ req.AddCookie(&http.Cookie{Name: "barerepo_session", Value: session})
112+ client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
113+ return http.ErrUseLastResponse
114+ }}
115+ resp, err := client.Do(req)
116+ if err != nil {
117+ t.Fatal(err)
118+ }
119+ resp.Body.Close()
120+ if resp.StatusCode != http.StatusFound {
121+ t.Fatalf("creating answered %d", resp.StatusCode)
122+ }
123+
124+ // The repository is private until the config is pushed, so the empty page needs the session.
125+ page, err := http.NewRequest(http.MethodGet, in.http.URL+resp.Header.Get("Location"), nil)
126+ if err != nil {
127+ t.Fatal(err)
128+ }
129+ page.AddCookie(&http.Cookie{Name: "barerepo_session", Value: session})
130+ shown, err := http.DefaultClient.Do(page)
131+ if err != nil {
132+ t.Fatal(err)
133+ }
134+ defer shown.Body.Close()
135+ if shown.StatusCode != http.StatusOK {
136+ t.Fatalf("the empty page answered %d", shown.StatusCode)
137+ }
138+ body := readAll(t, shown)
139+ if !strings.Contains(body, `description = "irc bot that refuses to leave"`) {
140+ t.Errorf("the description is not in the block the reader pastes:\n%s", body)
141+ }
142+ // A quoted heredoc passes every character through, which printf with escapes does not.
143+ if !strings.Contains(body, "&lt;&lt;'EOF'") {
144+ t.Errorf("the block is not a quoted heredoc, so a quote in the description would break it")
145+ }
146+ if !strings.Contains(body, `visibility = "public"`) {
147+ t.Errorf("the block does not carry the visibility the form was told:\n%s", body)
148+ }
149+ }
150+
151+ // The block writes the file, so it has to write the visibility that was asked for and not a default.
152+ func TestADescribedPrivateRepositoryStaysPrivateInThePasteBlock(t *testing.T) {
153+ if _, err := exec.LookPath("git"); err != nil {
154+ t.Skip("git is not installed")
155+ }
156+ in := newInstance(t)
157+ in.account("john")
158+
159+ session, err := in.db.NewSession(t.Context(), "john")
160+ if err != nil {
161+ t.Fatal(err)
162+ }
163+ form := "name=johnbot&description=irc+bot&visibility=private&default_branch=master"
164+ req, err := http.NewRequest(http.MethodPost, in.http.URL+"/new", strings.NewReader(form))
165+ if err != nil {
166+ t.Fatal(err)
167+ }
168+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
169+ req.AddCookie(&http.Cookie{Name: "barerepo_session", Value: session})
170+ client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
171+ return http.ErrUseLastResponse
172+ }}
173+ resp, err := client.Do(req)
174+ if err != nil {
175+ t.Fatal(err)
176+ }
177+ resp.Body.Close()
178+
179+ page, err := http.NewRequest(http.MethodGet, in.http.URL+resp.Header.Get("Location"), nil)
180+ if err != nil {
181+ t.Fatal(err)
182+ }
183+ page.AddCookie(&http.Cookie{Name: "barerepo_session", Value: session})
184+ shown, err := http.DefaultClient.Do(page)
185+ if err != nil {
186+ t.Fatal(err)
187+ }
188+ defer shown.Body.Close()
189+ body := readAll(t, shown)
190+
191+ if !strings.Contains(body, `visibility = "private"`) {
192+ t.Errorf("a repository asked to be private is told to write public:\n%s", body)
193+ }
194+ if strings.Contains(body, `visibility = "public"`) {
195+ t.Errorf("the block writes public for a repository that asked to be private:\n%s", body)
196+ }
197+ }
@@ -0,0 +1,303 @@
1+ package e2e
2+
3+ import (
4+ "io"
5+ "net/http"
6+ "net/url"
7+ "os/exec"
8+ "strings"
9+ "testing"
10+ )
11+
12+ // find runs a search as one account, or as nobody when account is empty.
13+ func find(t *testing.T, in *instance, account, query string) string {
14+ t.Helper()
15+ req, err := http.NewRequest(http.MethodGet,
16+ in.http.URL+"/search?q="+url.QueryEscape(query), nil)
17+ if err != nil {
18+ t.Fatal(err)
19+ }
20+ if account != "" {
21+ token, err := in.db.NewSession(t.Context(), account)
22+ if err != nil {
23+ t.Fatal(err)
24+ }
25+ req.AddCookie(&http.Cookie{Name: "barerepo_session", Value: token})
26+ }
27+ resp, err := http.DefaultClient.Do(req)
28+ if err != nil {
29+ t.Fatal(err)
30+ }
31+ defer resp.Body.Close()
32+ body, err := io.ReadAll(resp.Body)
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ return string(body)
37+ }
38+
39+ // The highest severity mistake available here is a private match reaching somebody else. 17, BUILD.md.
40+ func TestSearchNeverShowsAPrivateRepositoryToAStranger(t *testing.T) {
41+ if _, err := exec.LookPath("git"); err != nil {
42+ t.Skip("git is not installed")
43+ }
44+ in := newInstance(t)
45+ john := in.account("john")
46+ in.account("lisa")
47+
48+ work := t.TempDir()
49+ run(t, work, "git", "init", "-q", "-b", "master", work)
50+ write(t, work, "secret.go", "package main\n\nconst launchCodeZebra = 1\n")
51+ write(t, work, ".barerepo/config", "[repo]\nvisibility = \"private\"\n")
52+ run(t, work, "git", "add", "-A")
53+ run(t, work, "git", "commit", "-qm", "first")
54+ run(t, work, "git", "push", "-q", in.url(john, "/john/vault"), "master")
55+
56+ if body := find(t, in, "john", "launchCodeZebra"); !strings.Contains(body, "secret.go") {
57+ t.Error("the owner cannot find their own private code, which makes search useless to them")
58+ }
59+ for _, who := range []string{"", "lisa"} {
60+ // The query itself is echoed into the search box, so the file name is what proves a leak.
61+ body := find(t, in, who, "launchCodeZebra")
62+ if strings.Contains(body, "secret.go") {
63+ t.Errorf("a private match leaked to %q:\n%s", who, body)
64+ }
65+ if !strings.Contains(body, "nothing matched") {
66+ t.Errorf("%q was not told the search found nothing:\n%s", who, body)
67+ }
68+ }
69+
70+ // [access] push grants read, per chapter 18, so lisa sees it once she is listed.
71+ write(t, work, ".barerepo/config", "[repo]\nvisibility = \"private\"\n\n[access]\npush = [\"lisa\"]\n")
72+ run(t, work, "git", "commit", "-qam", "let lisa in")
73+ run(t, work, "git", "push", "-q", in.url(john, "/john/vault"), "master")
74+ if body := find(t, in, "lisa", "launchCodeZebra"); !strings.Contains(body, "secret.go") {
75+ t.Error("a named reader still cannot find the code they may read")
76+ }
77+ if body := find(t, in, "", "launchCodeZebra"); strings.Contains(body, "secret.go") {
78+ t.Error("granting one reader published the repository")
79+ }
80+ }
81+
82+ // A path outside ascii has two spellings in git's output, and the index must hold the real one.
83+ func TestTheIndexHoldsANameOutsideAscii(t *testing.T) {
84+ if _, err := exec.LookPath("git"); err != nil {
85+ t.Skip("git is not installed")
86+ }
87+ in := newInstance(t)
88+ john := in.account("john")
89+ work := seed(t, in, john, "john", "johnbot")
90+
91+ write(t, work, "café.md", "# une note\n\nzircon is in here\n")
92+ run(t, work, "git", "add", "-A")
93+ run(t, work, "git", "commit", "-qm", "add a note with an accent")
94+ run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "master")
95+
96+ body := find(t, in, "", "zircon")
97+ if !strings.Contains(body, "café.md") {
98+ t.Errorf("the index spelled the path some other way:\n%s", body)
99+ }
100+ if strings.Contains(body, `\303`) {
101+ t.Errorf("the index kept git's octal spelling of the name:\n%s", body)
102+ }
103+ }
104+
105+ // Chapter 17 indexes on push, so what a push changed is what a search finds afterwards.
106+ func TestTheIndexFollowsThePush(t *testing.T) {
107+ if _, err := exec.LookPath("git"); err != nil {
108+ t.Skip("git is not installed")
109+ }
110+ in := newInstance(t)
111+ john := in.account("john")
112+ work := seed(t, in, john, "john", "johnbot")
113+
114+ write(t, work, "retry.go", "package main\n\nfunc backoff(n int) int { return n * 2 }\n")
115+ run(t, work, "git", "add", "-A")
116+ run(t, work, "git", "commit", "-qm", "add a backoff")
117+ run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "master")
118+
119+ if body := find(t, in, "", "backoff"); !strings.Contains(body, "retry.go") {
120+ t.Fatalf("a pushed file is not in the index:\n%s", body)
121+ }
122+
123+ // An edit must remove what was there, or search answers with a line that no longer exists.
124+ write(t, work, "retry.go", "package main\n\nfunc linger(n int) int { return n * 3 }\n")
125+ run(t, work, "git", "commit", "-qam", "rename it")
126+ run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "master")
127+ if body := find(t, in, "", "backoff"); strings.Contains(body, "retry.go") {
128+ t.Errorf("the index still holds a line the push replaced:\n%s", body)
129+ }
130+ if body := find(t, in, "", "linger"); !strings.Contains(body, "retry.go") {
131+ t.Errorf("the index did not take the new line:\n%s", body)
132+ }
133+
134+ // A deleted file must leave, or every search result is a link to a 404.
135+ run(t, work, "git", "rm", "-q", "retry.go")
136+ run(t, work, "git", "commit", "-qm", "drop it")
137+ run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "master")
138+ if body := find(t, in, "", "linger"); strings.Contains(body, "retry.go") {
139+ t.Errorf("a deleted file is still in the index:\n%s", body)
140+ }
141+ }
142+
143+ // Chapter 17 indexes thread comments on note write, and a comment written on the web is a note write.
144+ func TestACommentWrittenOnTheWebIsSearchable(t *testing.T) {
145+ if _, err := exec.LookPath("git"); err != nil {
146+ t.Skip("git is not installed")
147+ }
148+ in := newInstance(t)
149+ john := in.account("john")
150+ seed(t, in, john, "john", "johnbot")
151+
152+ resp := post(t, in, "john", "/john/johnbot/threads", url.Values{
153+ "title": {"the reconnect loop spins"},
154+ "body": {"it retries with no delay at all, which pins a core"},
155+ })
156+ if resp.StatusCode != http.StatusFound {
157+ t.Fatalf("opening a thread answered %d", resp.StatusCode)
158+ }
159+ body := find(t, in, "", "pins a core")
160+ if !strings.Contains(body, "the reconnect loop spins") {
161+ t.Errorf("a comment written on the web is not in the index:\n%s", body)
162+ }
163+ if !strings.Contains(body, "thread") {
164+ t.Errorf("the match did not come back as a thread:\n%s", body)
165+ }
166+ }
167+
168+ // The index is derived from git, so appendix E's reindex must rebuild it from nothing.
169+ func TestReindexRebuildsTheWholeIndex(t *testing.T) {
170+ if _, err := exec.LookPath("git"); err != nil {
171+ t.Skip("git is not installed")
172+ }
173+ in := newInstance(t)
174+ john := in.account("john")
175+ work := seed(t, in, john, "john", "johnbot")
176+ write(t, work, "retry.go", "package main\n\nfunc backoff(n int) int { return n * 2 }\n")
177+ run(t, work, "git", "add", "-A")
178+ run(t, work, "git", "commit", "-qm", "add a backoff")
179+ run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "master")
180+
181+ if _, err := in.db.ExecContext(t.Context(), `DELETE FROM search_docs`); err != nil {
182+ t.Fatal(err)
183+ }
184+ if body := find(t, in, "", "backoff"); strings.Contains(body, "retry.go") {
185+ t.Fatal("the index was not actually emptied, so this test proves nothing")
186+ }
187+
188+ out := run(t, "", binary, "doctor", "--config", in.cfg.Path, "--reindex")
189+ if !strings.Contains(out, "indexed 1 repositories") {
190+ t.Errorf("reindex said %q", out)
191+ }
192+ if body := find(t, in, "", "backoff"); !strings.Contains(body, "retry.go") {
193+ t.Errorf("reindex did not rebuild the code index:\n%s", body)
194+ }
195+ }
196+
197+ // Chapter 44.4 keeps a deleted repository for 30 days, and it must not be searchable while it waits.
198+ func TestADeletedRepositoryLeavesTheIndex(t *testing.T) {
199+ if _, err := exec.LookPath("git"); err != nil {
200+ t.Skip("git is not installed")
201+ }
202+ in := newInstance(t)
203+ john := in.account("john")
204+ work := seed(t, in, john, "john", "johnbot")
205+ write(t, work, "retry.go", "package main\n\nfunc backoff(n int) int { return n * 2 }\n")
206+ run(t, work, "git", "add", "-A")
207+ run(t, work, "git", "commit", "-qm", "add a backoff")
208+ run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "master")
209+ if body := find(t, in, "", "backoff"); !strings.Contains(body, "retry.go") {
210+ t.Fatal("the push did not index, so this test proves nothing")
211+ }
212+
213+ post(t, in, "john", "/john/johnbot/delete", url.Values{"confirm": {"johnbot"}})
214+ if body := find(t, in, "", "backoff"); strings.Contains(body, "retry.go") {
215+ t.Errorf("a deleted repository is still searchable:\n%s", body)
216+ }
217+
218+ // A rebuild must not put it back, because the directory is still on disk under trash.
219+ run(t, "", binary, "doctor", "--config", in.cfg.Path, "--reindex")
220+ if body := find(t, in, "", "backoff"); strings.Contains(body, "retry.go") {
221+ t.Errorf("reindex walked the trash directory and put a deleted repository back:\n%s", body)
222+ }
223+ }
224+
225+ // A transfer changes who may read a private repository, so the index has to change with it. 44.3.
226+ func TestATransferMovesWhoMaySearchIt(t *testing.T) {
227+ if _, err := exec.LookPath("git"); err != nil {
228+ t.Skip("git is not installed")
229+ }
230+ in := newInstance(t)
231+ john := in.account("john")
232+ in.account("lisa")
233+
234+ work := t.TempDir()
235+ run(t, work, "git", "init", "-q", "-b", "master", work)
236+ write(t, work, "secret.go", "package main\n\nconst launchCodeZebra = 1\n")
237+ write(t, work, ".barerepo/config", "[repo]\nvisibility = \"private\"\n")
238+ run(t, work, "git", "add", "-A")
239+ run(t, work, "git", "commit", "-qm", "first")
240+ run(t, work, "git", "push", "-q", in.url(john, "/john/vault"), "master")
241+
242+ resp := post(t, in, "john", "/john/vault/transfer",
243+ url.Values{"owner": {"lisa"}, "confirm": {"vault"}})
244+ if resp.StatusCode != http.StatusFound {
245+ t.Fatalf("the transfer answered %d", resp.StatusCode)
246+ }
247+
248+ if body := find(t, in, "lisa", "launchCodeZebra"); !strings.Contains(body, "secret.go") {
249+ t.Errorf("the new owner cannot search the repository she now owns:\n%s", body)
250+ }
251+ if body := find(t, in, "john", "launchCodeZebra"); strings.Contains(body, "secret.go") {
252+ t.Errorf("the old owner can still search a private repository he gave away:\n%s", body)
253+ }
254+ }
255+
256+ // A file holds a symbol more than once, and one row per file would hide where the rest of them are.
257+ func TestASearchShowsMoreThanOneLineOfAFile(t *testing.T) {
258+ if _, err := exec.LookPath("git"); err != nil {
259+ t.Skip("git is not installed")
260+ }
261+ in := newInstance(t)
262+ john := in.account("john")
263+ work := seed(t, in, john, "john", "johnbot")
264+ write(t, work, "retry.go", "package main\n\nfunc backoff(n int) int {\n\treturn n\n}\n\nvar b = backoff(2)\n")
265+ run(t, work, "git", "add", "-A")
266+ run(t, work, "git", "commit", "-qm", "two mentions")
267+ run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "master")
268+
269+ body := find(t, in, "", "backoff")
270+ if n := strings.Count(body, "retry.go:"); n < 2 {
271+ t.Errorf("retry.go appeared %d times for a word it holds twice:\n%s", n, body)
272+ }
273+ if !strings.Contains(body, "retry.go:3") || !strings.Contains(body, "retry.go:7") {
274+ t.Errorf("the two lines are not both named:\n%s", body)
275+ }
276+ }
277+
278+ // search.html gives the box to the matched source line, so the handler must not hand one to a thread.
279+ func TestOnlyCodeGetsABoxOnTheSearchPage(t *testing.T) {
280+ if _, err := exec.LookPath("git"); err != nil {
281+ t.Skip("git is not installed")
282+ }
283+ in := newInstance(t)
284+ john := in.account("john")
285+ seed(t, in, john, "john", "johnbot")
286+
287+ // A phrase that exists in a thread and in no file, so only the thread row can answer.
288+ resp := post(t, in, "john", "/john/johnbot/threads", url.Values{
289+ "title": {"the reconnect loop spins"},
290+ "body": {"it retries with a zircon delay"},
291+ })
292+ if resp.StatusCode != http.StatusFound {
293+ t.Fatalf("opening a thread answered %d", resp.StatusCode)
294+ }
295+
296+ body := find(t, in, "", "zircon")
297+ if !strings.Contains(body, "reconnect loop spins") {
298+ t.Fatalf("the thread was not found, so this proves nothing:\n%s", body)
299+ }
300+ if strings.Contains(body, "<pre") {
301+ t.Errorf("a thread result was given the box search.html keeps for a source line:\n%s", body)
302+ }
303+ }
@@ -0,0 +1,114 @@
1+ package e2e
2+
3+ import (
4+ "io"
5+ "net/http"
6+ "os/exec"
7+ "strings"
8+ "testing"
9+ )
10+
11+ // Chapter 45.4's attacks, kept permanently, and these need a real push or request.
12+ func TestSecurityList(t *testing.T) {
13+ if _, err := exec.LookPath("git"); err != nil {
14+ t.Skip("git is not installed")
15+ }
16+ in := newInstance(t)
17+ john := in.account("john")
18+ seed(t, in, john, "john", "johnbot")
19+
20+ t.Run("a branch that is really an argument", func(t *testing.T) {
21+ c := clone(t, in, john, "/john/johnbot")
22+ for _, ref := range []string{
23+ "refs/heads/--upload-pack=/bin/sh",
24+ "refs/heads/-x",
25+ "refs/heads/..",
26+ } {
27+ out, err := try(t, c, "git", "push", in.url(john, "/john/johnbot"), "HEAD:"+ref)
28+ if err == nil {
29+ t.Errorf("%s was accepted:\n%s", ref, out)
30+ }
31+ }
32+ })
33+
34+ t.Run("a repository named ../../etc", func(t *testing.T) {
35+ c := clone(t, in, john, "/john/johnbot")
36+ for _, path := range []string{"/john/..%2f..%2fetc", "/john/../../etc", "/../../etc/passwd"} {
37+ out, err := try(t, c, "git", "push", in.url(john, path), "master")
38+ if err == nil {
39+ t.Errorf("a push to %s was accepted:\n%s", path, out)
40+ }
41+ }
42+ })
43+
44+ t.Run("evil.html fetched raw", func(t *testing.T) {
45+ // A file with a script in it, served from this origin, is the reader's session. 42.3.
46+ c := clone(t, in, john, "/john/johnbot")
47+ write(t, c, "evil.html", "<script>alert(document.cookie)</script>\n")
48+ run(t, c, "git", "add", "-A")
49+ run(t, c, "git", "commit", "-qm", "add evil.html")
50+ run(t, c, "git", "push", "-q", in.url(john, "/john/johnbot"), "master")
51+
52+ resp, err := http.Get(in.http.URL + "/john/johnbot/raw/master/evil.html")
53+ if err != nil {
54+ t.Fatal(err)
55+ }
56+ defer resp.Body.Close()
57+ body, _ := io.ReadAll(resp.Body)
58+ if !strings.Contains(string(body), "alert(document.cookie)") {
59+ t.Fatalf("the file did not come back at all: %s", body)
60+ }
61+ want := map[string]string{
62+ "Content-Type": "text/plain; charset=utf-8",
63+ "Content-Disposition": "attachment",
64+ "X-Content-Type-Options": "nosniff",
65+ "Content-Security-Policy": "default-src 'none'; sandbox",
66+ }
67+ for header, value := range want {
68+ if got := resp.Header.Get(header); got != value {
69+ t.Errorf("%s = %q, want %q: the browser would run this file", header, got, value)
70+ }
71+ }
72+ })
73+
74+ t.Run("a private repository over git", func(t *testing.T) {
75+ // No .barerepo/config in the content, or the file decides and the default never applies.
76+ c := t.TempDir()
77+ run(t, c, "git", "init", "-q", "-b", "master")
78+ write(t, c, "secret.txt", "do not read\n")
79+ run(t, c, "git", "add", "-A")
80+ run(t, c, "git", "commit", "-qm", "private things")
81+ run(t, c, "git", "push", "-q", in.url(john, "/john/hidden"), "master")
82+
83+ lisa := in.account("lisa")
84+ if out, err := try(t, "", "git", "ls-remote", in.url(lisa, "/john/hidden")); err == nil {
85+ t.Errorf("another account listed a private repository:\n%s", out)
86+ }
87+ if out, err := try(t, "", "git", "ls-remote", in.http.URL+"/john/hidden"); err == nil {
88+ t.Errorf("an anonymous client listed a private repository:\n%s", out)
89+ }
90+ })
91+
92+ t.Run("a config that says public makes it public", func(t *testing.T) {
93+ // A pushed visibility = "public" is chapter 14 working, not the default failing.
94+ c := clone(t, in, john, "/john/johnbot")
95+ run(t, c, "git", "push", "-q", in.url(john, "/john/open"), "master")
96+ if _, err := try(t, "", "git", "ls-remote", in.http.URL+"/john/open"); err != nil {
97+ t.Error("a repository whose config says public was not readable")
98+ }
99+ })
100+
101+ t.Run("a token is not a session", func(t *testing.T) {
102+ // A git token must not open the interface, or one in a build script is a login.
103+ req, _ := http.NewRequest(http.MethodGet, in.http.URL+"/keys", nil)
104+ req.AddCookie(&http.Cookie{Name: "barerepo_session", Value: john})
105+ resp, err := http.DefaultTransport.RoundTrip(req)
106+ if err != nil {
107+ t.Fatal(err)
108+ }
109+ defer resp.Body.Close()
110+ if resp.StatusCode == http.StatusOK {
111+ t.Error("a git token worked as a session cookie")
112+ }
113+ })
114+ }
@@ -0,0 +1,73 @@
1+ package e2e
2+
3+ import (
4+ "os/exec"
5+ "strings"
6+ "testing"
7+ )
8+
9+ // The sr-only summary describes the page to a screen reader, and reads as a spec anywhere else.
10+ func TestALinkPreviewSaysWhatTheThingIsNotWhatThePageIs(t *testing.T) {
11+ if _, err := exec.LookPath("git"); err != nil {
12+ t.Skip("git is not installed")
13+ }
14+ in := newInstance(t)
15+ john := in.account("john")
16+ work := seed(t, in, john, "john", "johnbot")
17+
18+ write(t, work, ".barerepo/config", "[repo]\nvisibility = \"public\"\n"+
19+ "description = \"a bot that answers\"\n")
20+ run(t, work, "git", "add", "-A")
21+ run(t, work, "git", "commit", "-qm", "describe the repository")
22+ run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "master")
23+
24+ for _, c := range []struct {
25+ what, path string
26+ want []string
27+ }{
28+ {"the repository log", "/john/johnbot", []string{"john/johnbot", "a bot that answers"}},
29+ {"the profile", "/john", []string{"john", "repositor"}},
30+ } {
31+ code, _, body := get(t, in.http.URL+c.path)
32+ if code != 200 {
33+ t.Fatalf("%s answered %d", c.what, code)
34+ }
35+ og := metaContent(body, "og:description")
36+ if og == "" {
37+ t.Errorf("%s has no og:description", c.what)
38+ continue
39+ }
40+ if strings.Contains(og, "This is the landing page") ||
41+ strings.Contains(og, "listing repositories sorted by last push") {
42+ t.Errorf("%s shares the screen reader summary: %q", c.what, og)
43+ }
44+ for _, w := range c.want {
45+ if !strings.Contains(og, w) {
46+ t.Errorf("%s og:description %q does not say %q", c.what, og, w)
47+ }
48+ }
49+ // The screen reader sentence must still be on the page, which chapter 25 requires.
50+ if !strings.Contains(body, "sr-only") {
51+ t.Errorf("%s lost its screen reader summary", c.what)
52+ }
53+ }
54+ }
55+
56+ // metaContent pulls one meta tag's content out of a page.
57+ func metaContent(body, prop string) string {
58+ i := strings.Index(body, `property="`+prop+`"`)
59+ if i < 0 {
60+ return ""
61+ }
62+ rest := body[i:]
63+ j := strings.Index(rest, `content="`)
64+ if j < 0 {
65+ return ""
66+ }
67+ rest = rest[j+len(`content="`):]
68+ k := strings.Index(rest, `"`)
69+ if k < 0 {
70+ return ""
71+ }
72+ return rest[:k]
73+ }
@@ -0,0 +1,280 @@
1+ package e2e
2+
3+ import (
4+ "os"
5+ "os/exec"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+ "time"
10+ )
11+
12+ // signWith makes a key and points a working copy at it, which is all git needs to sign with ssh.
13+ func signWith(t *testing.T, dir string) {
14+ t.Helper()
15+ signWithKey(t, dir, newKey(t, "signer"))
16+ }
17+
18+ // newKey makes a key pair and hands back the private half's path.
19+ func newKey(t *testing.T, comment string) string {
20+ t.Helper()
21+ key := filepath.Join(t.TempDir(), "id")
22+ if out, err := exec.Command("ssh-keygen", "-t", "ed25519", "-N", "", "-C", comment,
23+ "-f", key, "-q").CombinedOutput(); err != nil {
24+ t.Fatalf("ssh-keygen: %v\n%s", err, out)
25+ }
26+ return key
27+ }
28+
29+ func signWithKey(t *testing.T, dir, key string) {
30+ t.Helper()
31+ run(t, dir, "git", "config", "gpg.format", "ssh")
32+ run(t, dir, "git", "config", "user.signingkey", key+".pub")
33+ run(t, dir, "git", "config", "commit.gpgsign", "true")
34+ }
35+
36+ // An official repository distributes what it holds, so require_signed_commits refuses an unsigned commit.
37+ func TestARepositoryThatAsksForSignaturesRefusesAnUnsignedCommit(t *testing.T) {
38+ if _, err := exec.LookPath("git"); err != nil {
39+ t.Skip("git is not installed")
40+ }
41+ if _, err := exec.LookPath("ssh-keygen"); err != nil {
42+ t.Skip("ssh-keygen is not installed")
43+ }
44+ in := newInstance(t)
45+ john := in.account("john")
46+ work := seed(t, in, john, "john", "johnbot")
47+
48+ signWithKey(t, work, in.keyOf("john"))
49+ write(t, work, ".barerepo/config", "[repo]\nvisibility = \"public\"\n\n"+
50+ "[access]\nrequire_signed_commits = true\n")
51+ run(t, work, "git", "add", "-A")
52+ run(t, work, "git", "commit", "-qm", "this repository takes only signed commits")
53+ run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "master")
54+
55+ // The owner is not exempt, or the rule protects the repository from everyone except its owner.
56+ run(t, work, "git", "config", "commit.gpgsign", "false")
57+ commit(t, work, "package main\n\nfunc main() {}\n", "written without a signature")
58+ out, err := try(t, work, "git", "push", in.url(john, "/john/johnbot"), "master")
59+ if err == nil {
60+ t.Fatalf("an unsigned commit landed on a repository that asks for signatures:\n%s", out)
61+ }
62+ for _, want := range []string{"only signed commits", "require_signed_commits", "commit.gpgsign"} {
63+ if !strings.Contains(out, want) {
64+ t.Errorf("the rejection does not mention %q:\n%s", want, out)
65+ }
66+ }
67+
68+ // The same change, signed, is the push that must go through.
69+ run(t, work, "git", "config", "commit.gpgsign", "true")
70+ run(t, work, "git", "commit", "-q", "--amend", "--no-edit")
71+ if out, err := try(t, work, "git", "push", in.url(john, "/john/johnbot"), "master"); err != nil {
72+ t.Errorf("a signed commit was refused: %v\n%s", err, out)
73+ }
74+ }
75+
76+ // The key is off unless a repository asks for it, so nobody else's repository changes.
77+ func TestEveryOtherRepositoryStillTakesUnsignedCommits(t *testing.T) {
78+ if _, err := exec.LookPath("git"); err != nil {
79+ t.Skip("git is not installed")
80+ }
81+ in := newInstance(t)
82+ john := in.account("john")
83+ work := seed(t, in, john, "john", "johnbot")
84+
85+ commit(t, work, "package main\n\nfunc main() {}\n", "no signature, no config, no objection")
86+ if out, err := try(t, work, "git", "push", in.url(john, "/john/johnbot"), "master"); err != nil {
87+ t.Errorf("a plain repository refused an unsigned commit: %v\n%s", err, out)
88+ }
89+ if os.Getenv("CI") != "" {
90+ t.Log("ran under CI")
91+ }
92+ }
93+
94+ // A commit parked on a proposal ref is still unsigned when a branch reaches for it.
95+ func TestAnUnsignedCommitCannotEnterThroughAProposal(t *testing.T) {
96+ if _, err := exec.LookPath("git"); err != nil {
97+ t.Skip("git is not installed")
98+ }
99+ if _, err := exec.LookPath("ssh-keygen"); err != nil {
100+ t.Skip("ssh-keygen is not installed")
101+ }
102+ in := newInstance(t)
103+ john := in.account("john")
104+ work := seed(t, in, john, "john", "johnbot")
105+
106+ signWithKey(t, work, in.keyOf("john"))
107+ write(t, work, ".barerepo/config", "[repo]\nvisibility = \"public\"\n\n"+
108+ "[access]\nrequire_signed_commits = true\n")
109+ run(t, work, "git", "add", "-A")
110+ run(t, work, "git", "commit", "-qm", "this repository takes only signed commits")
111+ run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "master")
112+
113+ // The proposal namespace takes it, because a proposal is a request and not a landing.
114+ run(t, work, "git", "config", "commit.gpgsign", "false")
115+ commit(t, work, "package main\n\nfunc main() {}\n", "unsigned, offered as a proposal")
116+ if out, err := try(t, work, "git", "push", in.url(john, "/john/johnbot"),
117+ "HEAD:refs/proposals/new"); err != nil {
118+ t.Fatalf("the proposal was refused: %v\n%s", err, out)
119+ }
120+
121+ // The commit is now in the repository, so a walk of what is new to the repository would miss it.
122+ out, err := try(t, work, "git", "push", in.url(john, "/john/johnbot"), "master")
123+ if err == nil {
124+ t.Fatalf("an unsigned commit reached master through a proposal:\n%s", out)
125+ }
126+ if !strings.Contains(out, "only signed commits") {
127+ t.Errorf("the rejection does not say why:\n%s", out)
128+ }
129+ }
130+
131+ // A signature is only worth the key behind it, so a key the server never saw does not count.
132+ func TestASignatureFromAKeyTheServerDoesNotHoldIsRefused(t *testing.T) {
133+ if _, err := exec.LookPath("git"); err != nil {
134+ t.Skip("git is not installed")
135+ }
136+ if _, err := exec.LookPath("ssh-keygen"); err != nil {
137+ t.Skip("ssh-keygen is not installed")
138+ }
139+ in := newInstance(t)
140+ john := in.account("john")
141+ work := seed(t, in, john, "john", "johnbot")
142+
143+ // The account's own key, which the server wrote into the signers file when the account was made.
144+ signWithKey(t, work, in.keyOf("john"))
145+ write(t, work, ".barerepo/config", "[repo]\nvisibility = \"public\"\n\n"+
146+ "[access]\nrequire_signed_commits = true\n")
147+ run(t, work, "git", "add", "-A")
148+ run(t, work, "git", "commit", "-qm", "this repository takes only signed commits")
149+ run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "master")
150+
151+ // A real signature, made by a key nobody published here.
152+ signWithKey(t, work, newKey(t, "a stranger's key"))
153+ commit(t, work, "package main\n\nfunc main() {}\n", "signed by a key the server never saw")
154+ out, err := try(t, work, "git", "push", in.url(john, "/john/johnbot"), "master")
155+ if err == nil {
156+ t.Fatalf("a signature from an unknown key was taken as proof:\n%s", out)
157+ }
158+ if !strings.Contains(out, "only signed commits") {
159+ t.Errorf("the rejection does not say why:\n%s", out)
160+ }
161+
162+ // The account's own key still works, so the rule is about the key and not about signing at all.
163+ signWithKey(t, work, in.keyOf("john"))
164+ run(t, work, "git", "commit", "-q", "--amend", "--no-edit")
165+ if out, err := try(t, work, "git", "push", in.url(john, "/john/johnbot"), "master"); err != nil {
166+ t.Errorf("a signature from a published key was refused: %v\n%s", err, out)
167+ }
168+ }
169+
170+ // A retired key must keep vouching for what it signed, or rotating a key rewrites the past.
171+ func TestARetiredKeyStillVouchesForWhatItAlreadySigned(t *testing.T) {
172+ if _, err := exec.LookPath("git"); err != nil {
173+ t.Skip("git is not installed")
174+ }
175+ if _, err := exec.LookPath("ssh-keygen"); err != nil {
176+ t.Skip("ssh-keygen is not installed")
177+ }
178+ in := newInstance(t)
179+ john := in.account("john")
180+ work := seed(t, in, john, "john", "johnbot")
181+
182+ signWithKey(t, work, in.keyOf("john"))
183+ write(t, work, ".barerepo/config", "[repo]\nvisibility = \"public\"\n\n"+
184+ "[access]\nrequire_signed_commits = true\n")
185+ run(t, work, "git", "add", "-A")
186+ run(t, work, "git", "commit", "-qm", "this repository takes only signed commits")
187+ run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "master")
188+
189+ // Written and signed while the old key is still john's, which is the ordinary case.
190+ commit(t, work, "package main\n\nfunc main() {}\n", "signed before the rotation")
191+
192+ // Then john rotates, which is what happens between writing a commit and pushing it.
193+ keys, err := in.db.Keys(t.Context(), "john")
194+ if err != nil || len(keys) != 1 {
195+ t.Fatalf("john has %d keys, err %v", len(keys), err)
196+ }
197+ next := newKey(t, "john's new key")
198+ pub, err := os.ReadFile(next + ".pub")
199+ if err != nil {
200+ t.Fatal(err)
201+ }
202+ if _, err := in.db.AddKey(t.Context(), "john", string(pub)); err != nil {
203+ t.Fatal(err)
204+ }
205+ if err := in.db.DeleteKey(t.Context(), "john", keys[0].ID); err != nil {
206+ t.Fatal(err)
207+ }
208+
209+ // The retired key opens no door any more.
210+ live, err := in.db.Keys(t.Context(), "john")
211+ if err != nil {
212+ t.Fatal(err)
213+ }
214+ for _, k := range live {
215+ if k.ID == keys[0].ID {
216+ t.Fatal("the retired key is still listed as one of john's keys")
217+ }
218+ }
219+
220+ // The commit it signed while it was live still holds, which is the whole point of keeping it.
221+ if out, err := try(t, work, "git", "push", in.url(john, "/john/johnbot"), "master"); err != nil {
222+ t.Errorf("a commit signed by a key that has since retired was refused: %v\n%s", err, out)
223+ }
224+
225+ // Dated an hour on, because ssh records validity to the second and this test runs inside one.
226+ signWithKey(t, work, in.keyOf("john"))
227+ write(t, work, "config.go", "package main\n\nfunc main() { _ = 1 }\n")
228+ later := time.Now().Add(time.Hour).Format(time.RFC3339)
229+ cmd := exec.Command("git", "commit", "-qam", "signed after the rotation")
230+ cmd.Dir = work
231+ cmd.Env = append(os.Environ(),
232+ "GIT_AUTHOR_NAME=tester", "GIT_AUTHOR_EMAIL=t@x",
233+ "GIT_COMMITTER_NAME=tester", "GIT_COMMITTER_EMAIL=t@x",
234+ "GIT_AUTHOR_DATE="+later, "GIT_COMMITTER_DATE="+later,
235+ "GIT_TERMINAL_PROMPT=0", "GIT_CONFIG_COUNT=1",
236+ "GIT_CONFIG_KEY_0=credential.helper", "GIT_CONFIG_VALUE_0=")
237+ if out, err := cmd.CombinedOutput(); err != nil {
238+ t.Fatalf("git commit: %v\n%s", err, out)
239+ }
240+ if out, err := try(t, work, "git", "push", in.url(john, "/john/johnbot"), "master"); err == nil {
241+ t.Errorf("a retired key signed something new and it was taken:\n%s", out)
242+ }
243+ }
244+
245+ // A server that cannot check a signature must say so, not quietly accept whatever it is given.
246+ func TestASignerFileThatIsGoneRefusesRatherThanStopsChecking(t *testing.T) {
247+ if _, err := exec.LookPath("git"); err != nil {
248+ t.Skip("git is not installed")
249+ }
250+ if _, err := exec.LookPath("ssh-keygen"); err != nil {
251+ t.Skip("ssh-keygen is not installed")
252+ }
253+ in := newInstance(t)
254+ john := in.account("john")
255+ work := seed(t, in, john, "john", "johnbot")
256+
257+ signWithKey(t, work, in.keyOf("john"))
258+ write(t, work, ".barerepo/config", "[repo]\nvisibility = \"public\"\n\n"+
259+ "[access]\nrequire_signed_commits = true\n")
260+ run(t, work, "git", "add", "-A")
261+ run(t, work, "git", "commit", "-qm", "this repository takes only signed commits")
262+ run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "master")
263+
264+ // The cache may be deleted at any time, and this file lives there.
265+ if err := os.Remove(filepath.Join(in.cfg.Paths.Cache, "allowed_signers")); err != nil {
266+ t.Fatal(err)
267+ }
268+
269+ commit(t, work, "package main\n\nfunc main() {}\n", "properly signed, but nothing can check it")
270+ out, err := try(t, work, "git", "push", in.url(john, "/john/johnbot"), "master")
271+ if err == nil {
272+ t.Fatalf("the push was taken by a server that could not check it:\n%s", out)
273+ }
274+ if !strings.Contains(out, "cannot check a signature") {
275+ t.Errorf("the rejection does not say the server cannot check:\n%s", out)
276+ }
277+ if !strings.Contains(out, "barerepo doctor") {
278+ t.Errorf("the rejection does not say how to fix it:\n%s", out)
279+ }
280+ }
@@ -0,0 +1,148 @@
1+ package e2e
2+
3+ import (
4+ "net/http"
5+ "net/url"
6+ "os"
7+ "os/exec"
8+ "path/filepath"
9+ "regexp"
10+ "strings"
11+ "testing"
12+ )
13+
14+ // nonceIn pulls the nonce a page is showing, which is the value the next command signs.
15+ var nonceIn = regexp.MustCompile(`printf '%s' '([^']+)'`)
16+
17+ // Chapters 10 and 31: an account is a name and a key, and signing in is one ssh-keygen line.
18+ func TestSignupAndSignInWithARealKey(t *testing.T) {
19+ if _, err := exec.LookPath("ssh-keygen"); err != nil {
20+ t.Skip("ssh-keygen is not installed")
21+ }
22+ in := newInstance(t)
23+ dir := t.TempDir()
24+ key := filepath.Join(dir, "id")
25+ run(t, "", "ssh-keygen", "-t", "ed25519", "-N", "", "-C", "john", "-f", key, "-q")
26+ pub, err := os.ReadFile(key + ".pub")
27+ if err != nil {
28+ t.Fatal(err)
29+ }
30+
31+ // The form collects a name and a key, and answers with a nonce to sign. Chapter 10.
32+ body := form(t, in, "/signup", url.Values{"name": {"john"}, "pubkey": {string(pub)}})
33+ nonce := nonceIn.FindStringSubmatch(body)
34+ if nonce == nil {
35+ t.Fatalf("signup did not answer with a nonce to sign:\n%s", body)
36+ }
37+
38+ // A signature under the sign-in namespace must not claim an account. Chapter 10.
39+ wrong := sign(t, key, nonce[1], "barerepo-auth")
40+ body = form(t, in, "/signup", url.Values{"nonce": {nonce[1]}, "signature": {wrong}})
41+ if !strings.Contains(body, "barerepo-signup") {
42+ t.Errorf("a barerepo-auth signature was accepted for signup, so one can be replayed:\n%s", body)
43+ }
44+ // A wrong signature spends the nonce, so the page says so and asks for the form again.
45+ if !strings.Contains(body, "the nonce is spent") {
46+ t.Errorf("the page does not say the nonce is gone, so the next try looks broken:\n%s", body)
47+ }
48+
49+ body = form(t, in, "/signup", url.Values{"name": {"john"}, "pubkey": {string(pub)}})
50+ nonce = nonceIn.FindStringSubmatch(body)
51+ if nonce == nil {
52+ t.Fatalf("signup did not answer with a second nonce:\n%s", body)
53+ }
54+ right := sign(t, key, nonce[1], "barerepo-signup")
55+ resp := formResponse(t, in, "/signup", url.Values{"nonce": {nonce[1]}, "signature": {right}})
56+ if resp.StatusCode != http.StatusFound {
57+ t.Fatalf("finishing signup answered %d:\n%s", resp.StatusCode, resp.body)
58+ }
59+ if sessionCookie(resp) == "" {
60+ t.Error("signup did not sign the new account in")
61+ }
62+
63+ // Now the other door, which is the one a returning reader takes. Chapter 31.3.
64+ body = form(t, in, "/auth/challenge", url.Values{"name": {"john"}})
65+ nonce = nonceIn.FindStringSubmatch(body)
66+ if nonce == nil {
67+ t.Fatalf("the challenge did not answer with a nonce:\n%s", body)
68+ }
69+ // One line, reading stdin and writing stdout, so nothing is left on disk. Chapter 31.3.
70+ if !strings.Contains(body, "ssh-keygen -Y sign") || !strings.Contains(body, "-n barerepo-auth") {
71+ t.Errorf("the page does not show the command that signs the nonce:\n%s", body)
72+ }
73+ resp = formResponse(t, in, "/auth/verify",
74+ url.Values{"nonce": {nonce[1]}, "signature": {sign(t, key, nonce[1], "barerepo-auth")}})
75+ if resp.StatusCode != http.StatusFound {
76+ t.Fatalf("verifying answered %d", resp.StatusCode)
77+ }
78+ cookie := sessionCookie(resp)
79+ if cookie == "" {
80+ t.Fatal("signing in handed back no session")
81+ }
82+
83+ // The session is the whole point, so it has to open the one page that needs one.
84+ req, err := http.NewRequest(http.MethodGet, in.http.URL+"/keys", nil)
85+ if err != nil {
86+ t.Fatal(err)
87+ }
88+ req.AddCookie(&http.Cookie{Name: "barerepo_session", Value: cookie})
89+ keys, err := http.DefaultClient.Do(req)
90+ if err != nil {
91+ t.Fatal(err)
92+ }
93+ defer keys.Body.Close()
94+ page := readAll(t, keys)
95+ if keys.StatusCode != http.StatusOK || !strings.Contains(page, "ssh keys") {
96+ t.Errorf("the session does not open the keys page: %d\n%s", keys.StatusCode, page)
97+ }
98+ }
99+
100+ // sign is chapter 31.3's line: one command, stdin to stdout, nothing left behind.
101+ func sign(t *testing.T, key, nonce, namespace string) string {
102+ t.Helper()
103+ cmd := exec.Command("ssh-keygen", "-Y", "sign", "-f", key, "-n", namespace, "-")
104+ cmd.Stdin = strings.NewReader(nonce)
105+ out, err := cmd.Output()
106+ if err != nil {
107+ t.Fatalf("ssh-keygen -Y sign: %v", err)
108+ }
109+ return string(out)
110+ }
111+
112+ func form(t *testing.T, in *instance, path string, values url.Values) string {
113+ t.Helper()
114+ resp := formResponse(t, in, path, values)
115+ return resp.body
116+ }
117+
118+ type formResult struct {
119+ *http.Response
120+ body string
121+ }
122+
123+ func formResponse(t *testing.T, in *instance, path string, values url.Values) formResult {
124+ t.Helper()
125+ req, err := http.NewRequest(http.MethodPost, in.http.URL+path, strings.NewReader(values.Encode()))
126+ if err != nil {
127+ t.Fatal(err)
128+ }
129+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
130+ client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
131+ return http.ErrUseLastResponse
132+ }}
133+ resp, err := client.Do(req)
134+ if err != nil {
135+ t.Fatal(err)
136+ }
137+ defer resp.Body.Close()
138+ return formResult{resp, readAll(t, resp)}
139+ }
140+
141+ func sessionCookie(r formResult) string {
142+ for _, c := range r.Cookies() {
143+ if c.Name == "barerepo_session" {
144+ return c.Value
145+ }
146+ }
147+ return ""
148+ }
@@ -0,0 +1,110 @@
1+ package e2e
2+
3+ import (
4+ "fmt"
5+ "os"
6+ "os/exec"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+ )
11+
12+ // sshWrapper is what authorized_keys does, without an sshd: force one command with the account named.
13+ func sshWrapper(t *testing.T, in *instance, account string) string {
14+ t.Helper()
15+ path := filepath.Join(t.TempDir(), "ssh")
16+ // git sends its own options and the host before the command, so the command is the last argument.
17+ script := fmt.Sprintf("#!/bin/sh\nfor a in \"$@\"; do cmd=\"$a\"; done\n"+
18+ "SSH_ORIGINAL_COMMAND=\"$cmd\" exec %q ssh --config %q --account %q\n",
19+ binary, in.cfg.Path, account)
20+ if err := os.WriteFile(path, []byte(script), 0o700); err != nil {
21+ t.Fatal(err)
22+ }
23+ return path
24+ }
25+
26+ // Chapter 10 makes an ssh key the identity, so ssh is the transport, and it has to carry a push.
27+ func TestGitOverSSHClonesAndPushes(t *testing.T) {
28+ if _, err := exec.LookPath("git"); err != nil {
29+ t.Skip("git is not installed")
30+ }
31+ in := newInstance(t)
32+ john := in.account("john")
33+ seed(t, in, john, "john", "johnbot")
34+
35+ wrapper := sshWrapper(t, in, "john")
36+ dir := filepath.Join(t.TempDir(), "clone")
37+ sshRun(t, "", wrapper, "git", "clone", "-q", "ssh://barerepo/john/johnbot", dir)
38+ if _, err := os.Stat(filepath.Join(dir, "config.go")); err != nil {
39+ t.Fatalf("the clone brought nothing: %v", err)
40+ }
41+
42+ write(t, dir, "config.go", "package main\n\nvar over = \"ssh\"\n")
43+ run(t, dir, "git", "-c", "user.email=t@x", "-c", "user.name=t", "commit", "-qam", "pushed over ssh")
44+ sshRun(t, dir, wrapper, "git", "push", "-q", "origin", "master")
45+
46+ if _, _, page := get(t, in.http.URL+"/john/johnbot"); !strings.Contains(page, "pushed over ssh") {
47+ t.Errorf("a push over ssh did not land:\n%s", page)
48+ }
49+ }
50+
51+ // Chapter 41.3: SSH_ORIGINAL_COMMAND is attacker controlled and must never reach a shell.
52+ func TestSSHRefusesAnythingThatIsNotGit(t *testing.T) {
53+ if _, err := exec.LookPath("git"); err != nil {
54+ t.Skip("git is not installed")
55+ }
56+ in := newInstance(t)
57+ john := in.account("john")
58+ seed(t, in, john, "john", "johnbot")
59+
60+ for _, cmd := range []string{
61+ "",
62+ "sh",
63+ "git-upload-pack 'john/johnbot'; touch /tmp/barerepo-owned",
64+ "git-upload-pack 'john/johnbot' && whoami",
65+ "scp -t /tmp",
66+ // Well formed apart from the verb, so nothing but the list of three can refuse it.
67+ "scp 'john/johnbot'",
68+ "git-upload-pack '../../etc'",
69+ } {
70+ out, err := barerepoSSH(t, in, "john", cmd)
71+ if err == nil {
72+ t.Errorf("%q was accepted over ssh:\n%s", cmd, out)
73+ continue
74+ }
75+ if strings.Contains(out, "panic") || strings.Contains(out, "goroutine ") {
76+ t.Errorf("%q crashed rather than being refused:\n%s", cmd, out)
77+ }
78+ if !strings.Contains(out, "barerepo") {
79+ t.Errorf("%q was refused without saying who refused it:\n%s", cmd, out)
80+ }
81+ }
82+ if _, err := os.Stat("/tmp/barerepo-owned"); err == nil {
83+ os.Remove("/tmp/barerepo-owned")
84+ t.Fatal("a shell ran, so SSH_ORIGINAL_COMMAND reached one")
85+ }
86+ }
87+
88+ // barerepoSSH runs the entry point authorized_keys forces, with one command in the environment.
89+ func barerepoSSH(t *testing.T, in *instance, account, cmd string) (string, error) {
90+ t.Helper()
91+ c := exec.Command(binary, "ssh", "--config", in.cfg.Path, "--account", account)
92+ c.Env = append(os.Environ(), "SSH_ORIGINAL_COMMAND="+cmd)
93+ out, err := c.CombinedOutput()
94+ return string(out), err
95+ }
96+
97+ // sshRun runs a git command with barerepo's ssh entry point standing in for the daemon.
98+ func sshRun(t *testing.T, dir, wrapper, name string, args ...string) {
99+ t.Helper()
100+ cmd := exec.Command(name, args...)
101+ cmd.Dir = dir
102+ cmd.Env = append(os.Environ(),
103+ "GIT_SSH_COMMAND="+wrapper,
104+ "GIT_AUTHOR_NAME=tester", "GIT_AUTHOR_EMAIL=t@x",
105+ "GIT_COMMITTER_NAME=tester", "GIT_COMMITTER_EMAIL=t@x",
106+ "GIT_TERMINAL_PROMPT=0")
107+ if out, err := cmd.CombinedOutput(); err != nil {
108+ t.Fatalf("%s %s: %v\n%s", name, strings.Join(args, " "), err, out)
109+ }
110+ }
@@ -0,0 +1,68 @@
1+ package e2e
2+
3+ import (
4+ "net/http"
5+ "net/url"
6+ "os/exec"
7+ "strings"
8+ "testing"
9+ )
10+
11+ // Every mockup carries the open thread count in the tab strip, not only the threads page itself.
12+ func TestTheOpenThreadCountIsOnEveryRepositoryPage(t *testing.T) {
13+ if _, err := exec.LookPath("git"); err != nil {
14+ t.Skip("git is not installed")
15+ }
16+ in := newInstance(t)
17+ john := in.account("john")
18+ seed(t, in, john, "john", "johnbot")
19+
20+ for i := 0; i < 2; i++ {
21+ resp := post(t, in, "john", "/john/johnbot/threads", url.Values{
22+ "title": {"something is wrong"},
23+ "body": {"it does the thing"},
24+ })
25+ if resp.StatusCode != http.StatusFound {
26+ t.Fatalf("opening a thread answered %d", resp.StatusCode)
27+ }
28+ }
29+
30+ for _, path := range []string{"", "/files", "/runs", "/config", "/releases", "/threads"} {
31+ code, _, body := get(t, in.http.URL+"/john/johnbot"+path)
32+ if code != http.StatusOK {
33+ t.Errorf("%s answered %d", path, code)
34+ continue
35+ }
36+ if !strings.Contains(body, "threads 2") {
37+ t.Errorf("the tab strip on %q does not carry the open count", path)
38+ }
39+ }
40+
41+ // The thread page footer says it too, per thread.html.
42+ if _, _, body := get(t, in.http.URL+"/john/johnbot/thread/1"); !strings.Contains(body, "2 open") {
43+ t.Errorf("the thread page footer does not say how many are open:\n%s", body)
44+ }
45+ }
46+
47+ // Chapter 24 puts the open proposal count on each repository row of a profile.
48+ func TestAProfileRowNamesTheOpenProposalCount(t *testing.T) {
49+ if _, err := exec.LookPath("git"); err != nil {
50+ t.Skip("git is not installed")
51+ }
52+ in := newInstance(t)
53+ john := in.account("john")
54+ seed(t, in, john, "john", "johnbot")
55+
56+ if _, _, body := get(t, in.http.URL+"/john"); strings.Contains(body, "proposal") {
57+ t.Fatalf("a repository with no proposals counts them anyway:\n%s", body)
58+ }
59+
60+ work := clone(t, in, john, "/john/johnbot")
61+ commit(t, work, "package main\n\nfunc main() {}\n", "a proposal")
62+ run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "HEAD:refs/proposals/new")
63+
64+ _, _, body := get(t, in.http.URL+"/john")
65+ if !strings.Contains(body, "1 proposal") {
66+ t.Errorf("the profile row does not name the open proposal:\n%s", body)
67+ }
68+ }
@@ -0,0 +1,117 @@
1+ package e2e
2+
3+ import (
4+ "context"
5+ "io"
6+ "net/http"
7+ "os/exec"
8+ "strings"
9+ "testing"
10+ )
11+
12+ // A webhook aimed at the server's own network is the classic SSRF, and it must fail loudly. 23.3.
13+ func TestAWebhookCannotReachThePrivateNetwork(t *testing.T) {
14+ if _, err := exec.LookPath("git"); err != nil {
15+ t.Skip("git is not installed")
16+ }
17+ ctx := context.Background()
18+ in := newInstance(t)
19+ john := in.account("john")
20+ work := seed(t, in, john, "john", "johnbot")
21+
22+ // The cursor starts at the newest event, so only what happens next is delivered.
23+ if err := in.db.StartWebhooksHere(ctx); err != nil {
24+ t.Fatal(err)
25+ }
26+ const hook = "https://localhost/deploy"
27+ write(t, work, ".barerepo/config", "[repo]\nvisibility = \"public\"\n\n"+
28+ "[[webhook]]\nurl = \""+hook+"\"\nevents = [\"push\"]\n")
29+ run(t, work, "git", "commit", "-qam", "add a webhook")
30+ run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "master")
31+
32+ in.srv.DeliverHooks(ctx)
33+
34+ state, err := in.db.HooksOf(ctx, "john/johnbot")
35+ if err != nil {
36+ t.Fatal(err)
37+ }
38+ got, ok := state[hook]
39+ if !ok {
40+ t.Fatal("the push delivered nothing and recorded nothing, so a broken hook is silent")
41+ }
42+ if got.Failures != 1 {
43+ t.Errorf("the hook counted %d failures, wanted 1", got.Failures)
44+ }
45+ if !strings.Contains(got.LastError, "not a public address") &&
46+ !strings.Contains(got.LastError, "which webhooks may not reach") {
47+ t.Errorf("the reason was %q, which does not say the address was denied", got.LastError)
48+ }
49+
50+ // The config page is the only report a webhook has, so the failure must be on it. 23.4.
51+ resp, err := http.Get(in.http.URL + "/john/johnbot/config")
52+ if err != nil {
53+ t.Fatal(err)
54+ }
55+ defer resp.Body.Close()
56+ body, _ := io.ReadAll(resp.Body)
57+ if !strings.Contains(string(body), hook) {
58+ t.Errorf("the config page does not name the webhook:\n%s", body)
59+ }
60+ if !strings.Contains(string(body), "since the last delivery") {
61+ t.Errorf("the config page does not say the hook is failing:\n%s", body)
62+ }
63+ }
64+
65+ // An event no hook named must not be delivered, or the events list means nothing. 23.2.
66+ func TestAnUnnamedEventIsNotDelivered(t *testing.T) {
67+ if _, err := exec.LookPath("git"); err != nil {
68+ t.Skip("git is not installed")
69+ }
70+ ctx := context.Background()
71+ in := newInstance(t)
72+ john := in.account("john")
73+ work := seed(t, in, john, "john", "johnbot")
74+
75+ if err := in.db.StartWebhooksHere(ctx); err != nil {
76+ t.Fatal(err)
77+ }
78+ write(t, work, ".barerepo/config", "[repo]\nvisibility = \"public\"\n\n"+
79+ "[[webhook]]\nurl = \"https://localhost/deploy\"\nevents = [\"thread.opened\"]\n")
80+ run(t, work, "git", "commit", "-qam", "a hook that only wants threads")
81+ run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "master")
82+
83+ in.srv.DeliverHooks(ctx)
84+
85+ state, err := in.db.HooksOf(ctx, "john/johnbot")
86+ if err != nil {
87+ t.Fatal(err)
88+ }
89+ if len(state) != 0 {
90+ t.Errorf("a push reached a hook that only asked for thread.opened: %v", state)
91+ }
92+ }
93+
94+ // The config page is a hook's only report, so it has to name an event that will never fire. 23.4.
95+ func TestTheConfigPageNamesAnEventBarerepoNeverSends(t *testing.T) {
96+ if _, err := exec.LookPath("git"); err != nil {
97+ t.Skip("git is not installed")
98+ }
99+ in := newInstance(t)
100+ john := in.account("john")
101+ work := seed(t, in, john, "john", "johnbot")
102+
103+ write(t, work, ".barerepo/config", "[repo]\nvisibility = \"public\"\n\n"+
104+ "[[webhook]]\nurl = \"https://deploy.example/hook\"\nevents = [\"push\", \"run.succeeded\"]\n")
105+ run(t, work, "git", "commit", "-qam", "a hook that asks for a green build")
106+ run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "master")
107+
108+ resp, err := http.Get(in.http.URL + "/john/johnbot/config")
109+ if err != nil {
110+ t.Fatal(err)
111+ }
112+ defer resp.Body.Close()
113+ body, _ := io.ReadAll(resp.Body)
114+ if !strings.Contains(string(body), "run.succeeded is not an event barerepo sends") {
115+ t.Errorf("the config page does not say the hook asked for something that never fires:\n%s", body)
116+ }
117+ }
@@ -0,0 +1,241 @@
1+ package e2e
2+
3+ import (
4+ "context"
5+ "os/exec"
6+ "strings"
7+ "testing"
8+
9+ "github.com/barerepo/server/internal/store"
10+ "github.com/barerepo/server/internal/token"
11+ )
12+
13+ // Chapter 15A end to end: a repository whose CI is a GitHub workflow and nothing else.
14+ func TestWorkflowBuildsWithoutABarerepoConfig(t *testing.T) {
15+ if _, err := exec.LookPath("git"); err != nil {
16+ t.Skip("git is not installed")
17+ }
18+ in := newInstance(t)
19+ tok := in.account("john")
20+ ctx := context.Background()
21+
22+ work := t.TempDir()
23+ run(t, work, "git", "init", "-q", "-b", "master")
24+ write(t, work, "config.go", "package main\n")
25+ // No [build] command anywhere, which is the whole point: the workflow is the only build.
26+ write(t, work, ".barerepo/config", "[repo]\nvisibility = \"public\"\n")
27+ write(t, work, ".github/workflows/ci.yml", `
28+ name: ci
29+ on: [push]
30+ jobs:
31+ test:
32+ runs-on: ubuntu-latest
33+ steps:
34+ - uses: actions/checkout@v4
35+ - name: unit
36+ run: go test ./...
37+ - name: deploy
38+ uses: some/deploy-action@v1
39+ `)
40+ run(t, work, "git", "add", "-A")
41+ run(t, work, "git", "commit", "-qm", "first")
42+
43+ // With no machine attached, the push declines and says where to attach one.
44+ out := run(t, work, "git", "push", in.url(tok, "/john/bot"), "master")
45+ for _, want := range []string{
46+ "skipped deploy",
47+ "some/deploy-action@v1",
48+ "wants a ubuntu-latest machine and none is attached",
49+ "/john/bot/runners/new",
50+ } {
51+ if !strings.Contains(out, want) {
52+ t.Errorf("the push does not say %q\n%s", want, out)
53+ }
54+ }
55+ if n := queued(t, in, "john/bot"); n != 0 {
56+ t.Fatalf("%d jobs were queued with no machine to run them", n)
57+ }
58+
59+ // A mac cannot take it either, and the job must not be handed to the wrong machine.
60+ attach(t, in, "john/bot", "macbook", "darwin", nil)
61+ run(t, work, "git", "commit", "-qm", "second", "--allow-empty")
62+ out = run(t, work, "git", "push", in.url(tok, "/john/bot"), "master")
63+ if !strings.Contains(out, "none is attached") {
64+ t.Errorf("a mac was treated as an ubuntu machine\n%s", out)
65+ }
66+
67+ // Attach linux and the same push queues the job.
68+ attach(t, in, "john/bot", "builder", "linux", nil)
69+ run(t, work, "git", "commit", "-qm", "third", "--allow-empty")
70+ out = run(t, work, "git", "push", in.url(tok, "/john/bot"), "master")
71+ if !strings.Contains(out, "queued test from .github/workflows/ci.yml") {
72+ t.Errorf("the push did not queue the workflow job\n%s", out)
73+ }
74+
75+ jobs, err := in.db.QueuedJobs(ctx, "john/bot")
76+ if err != nil {
77+ t.Fatal(err)
78+ }
79+ if len(jobs) != 1 {
80+ t.Fatalf("got %d queued jobs, want 1", len(jobs))
81+ }
82+ j := jobs[0]
83+ if !strings.Contains(j.Command, "go test ./...") {
84+ t.Errorf("the run step did not reach the job: %q", j.Command)
85+ }
86+ if len(j.Labels) != 1 || j.Labels[0] != "ubuntu-latest" {
87+ t.Errorf("the job did not keep what it asked for: %v", j.Labels)
88+ }
89+
90+ // The mac must not be able to take it, and the linux machine must.
91+ mac := runnerNamed(t, in, "john/bot", "macbook")
92+ if got, err := in.db.TakeJob(ctx, "john/bot", mac); err != nil || got != nil {
93+ t.Errorf("a mac took an ubuntu job: %+v %v", got, err)
94+ }
95+ builder := runnerNamed(t, in, "john/bot", "builder")
96+ got, err := in.db.TakeJob(ctx, "john/bot", builder)
97+ if err != nil || got == nil {
98+ t.Fatalf("the linux machine could not take its own job: %+v %v", got, err)
99+ }
100+ }
101+
102+ // A repository with both keeps barerepo's own answer, because [build] command is one command.
103+ func TestABarerepoConfigWinsOverAWorkflow(t *testing.T) {
104+ if _, err := exec.LookPath("git"); err != nil {
105+ t.Skip("git is not installed")
106+ }
107+ in := newInstance(t)
108+ tok := in.account("john")
109+
110+ work := t.TempDir()
111+ run(t, work, "git", "init", "-q", "-b", "master")
112+ write(t, work, ".barerepo/config", "[repo]\nvisibility = \"public\"\n\n[build]\ncommand = \"make ci\"\n")
113+ write(t, work, ".github/workflows/ci.yml",
114+ "jobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: go test ./...\n")
115+ run(t, work, "git", "add", "-A")
116+ run(t, work, "git", "commit", "-qm", "first")
117+ out := run(t, work, "git", "push", in.url(tok, "/john/bot"), "master")
118+
119+ if !strings.Contains(out, "queued a build for") {
120+ t.Errorf("the repository's own build did not run\n%s", out)
121+ }
122+ if strings.Contains(out, "workflows/ci.yml") {
123+ t.Errorf("the workflow ran as well, so the build happened twice\n%s", out)
124+ }
125+ jobs, err := in.db.QueuedJobs(context.Background(), "john/bot")
126+ if err != nil {
127+ t.Fatal(err)
128+ }
129+ if len(jobs) != 1 || jobs[0].Command != "make ci" {
130+ t.Errorf("queued %d jobs, first %q", len(jobs), jobs[0].Command)
131+ }
132+ }
133+
134+ func queued(t *testing.T, in *instance, repo string) int {
135+ t.Helper()
136+ jobs, err := in.db.QueuedJobs(context.Background(), repo)
137+ if err != nil {
138+ t.Fatal(err)
139+ }
140+ return len(jobs)
141+ }
142+
143+ // attach registers a machine the way a real runner does, so the matching is the real matching.
144+ func attach(t *testing.T, in *instance, repo, host, os string, labels []string) {
145+ t.Helper()
146+ ctx := context.Background()
147+ owner, _, _ := strings.Cut(repo, "/")
148+ tok, _, err := in.db.CreateToken(ctx, token.Runner, owner, repo, "runner for "+host)
149+ if err != nil {
150+ t.Fatal(err)
151+ }
152+ rec, err := in.db.AccountForToken(ctx, token.Runner, tok)
153+ if err != nil {
154+ t.Fatal(err)
155+ }
156+ if _, err := in.db.AttachRunner(ctx, rec.ID, repo, host, os, "amd64", labels); err != nil {
157+ t.Fatal(err)
158+ }
159+ }
160+
161+ func runnerNamed(t *testing.T, in *instance, repo, host string) store.Runner {
162+ t.Helper()
163+ runners, err := in.db.RunnersOf(context.Background(), repo)
164+ if err != nil {
165+ t.Fatal(err)
166+ }
167+ for _, r := range runners {
168+ if r.Hostname == host {
169+ return r
170+ }
171+ }
172+ t.Fatalf("no runner named %s", host)
173+ return store.Runner{}
174+ }
175+
176+ // A matrix builds one commit several times, and each build has to be told apart. Chapter 15A.
177+ func TestMatrixQueuesOneNamedJobPerCombination(t *testing.T) {
178+ if _, err := exec.LookPath("git"); err != nil {
179+ t.Skip("git is not installed")
180+ }
181+ in := newInstance(t)
182+ tok := in.account("john")
183+ ctx := context.Background()
184+
185+ work := t.TempDir()
186+ run(t, work, "git", "init", "-q", "-b", "master")
187+ write(t, work, ".barerepo/config", "[repo]\nvisibility = \"public\"\n")
188+ write(t, work, ".github/workflows/ci.yml", `
189+ name: ci
190+ jobs:
191+ test:
192+ runs-on: ${{ matrix.os }}
193+ strategy:
194+ matrix:
195+ os: [ubuntu-latest, macos-latest]
196+ steps:
197+ - run: go test ./...
198+ `)
199+ run(t, work, "git", "add", "-A")
200+ run(t, work, "git", "commit", "-qm", "first")
201+
202+ // Only linux is attached, so the mac half is declined and the linux half is queued.
203+ attach(t, in, "john/bot", "builder", "linux", nil)
204+ out := run(t, work, "git", "push", in.url(tok, "/john/bot"), "master")
205+ if !strings.Contains(out, "wants a macos-latest machine and none is attached") {
206+ t.Errorf("the mac combination was not declined\n%s", out)
207+ }
208+ if !strings.Contains(out, "queued test (os ubuntu-latest)") {
209+ t.Errorf("the linux combination was not queued\n%s", out)
210+ }
211+
212+ jobs, err := in.db.QueuedJobs(ctx, "john/bot")
213+ if err != nil {
214+ t.Fatal(err)
215+ }
216+ if len(jobs) != 1 {
217+ t.Fatalf("got %d queued jobs, want 1: %+v", len(jobs), jobs)
218+ }
219+ // The name has to survive to the job, or two builds of one commit are indistinguishable.
220+ if jobs[0].Name != "test (os ubuntu-latest)" {
221+ t.Errorf("the job is named %q", jobs[0].Name)
222+ }
223+
224+ // Attach a mac and the other half queues on the next push.
225+ attach(t, in, "john/bot", "macbook", "darwin", nil)
226+ run(t, work, "git", "commit", "-qm", "second", "--allow-empty")
227+ out = run(t, work, "git", "push", in.url(tok, "/john/bot"), "master")
228+ if !strings.Contains(out, "queued test (os macos-latest)") {
229+ t.Errorf("the mac combination did not queue once a mac was attached\n%s", out)
230+ }
231+
232+ // Each machine takes only its own combination.
233+ mac := runnerNamed(t, in, "john/bot", "macbook")
234+ got, err := in.db.TakeJob(ctx, "john/bot", mac)
235+ if err != nil || got == nil {
236+ t.Fatalf("the mac could not take its own combination: %+v %v", got, err)
237+ }
238+ if !strings.Contains(got.Name, "macos-latest") {
239+ t.Errorf("the mac took %q", got.Name)
240+ }
241+ }
@@ -0,0 +1,33 @@
1+ module github.com/barerepo/server
2+
3+ go 1.24
4+
5+ require (
6+ github.com/BurntSushi/toml v1.6.0
7+ github.com/jackc/pgx/v5 v5.10.0
8+ github.com/yuin/goldmark v1.8.5
9+ golang.org/x/crypto v0.55.0
10+ golang.org/x/net v0.58.0
11+ gopkg.in/yaml.v3 v3.0.1
12+ modernc.org/sqlite v1.56.0
13+ )
14+
15+ require (
16+ github.com/dustin/go-humanize v1.0.1 // indirect
17+ github.com/google/uuid v1.6.0 // indirect
18+ github.com/jackc/pgpassfile v1.0.0 // indirect
19+ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
20+ github.com/jackc/puddle/v2 v2.2.2 // indirect
21+ github.com/kr/text v0.2.0 // indirect
22+ github.com/mattn/go-isatty v0.0.24 // indirect
23+ github.com/ncruces/go-strftime v1.0.0 // indirect
24+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
25+ github.com/rogpeppe/go-internal v1.6.1 // indirect
26+ golang.org/x/image v0.45.0 // indirect
27+ golang.org/x/sync v0.22.0 // indirect
28+ golang.org/x/sys v0.47.0 // indirect
29+ golang.org/x/text v0.41.0 // indirect
30+ modernc.org/libc v1.74.4 // indirect
31+ modernc.org/mathutil v1.7.1 // indirect
32+ modernc.org/memory v1.11.0 // indirect
33+ )
@@ -0,0 +1,81 @@
1+ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
2+ github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
3+ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
4+ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
5+ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
6+ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
7+ github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
8+ github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
9+ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
10+ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
11+ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
12+ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
13+ github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
14+ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
15+ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
16+ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
17+ github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
18+ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
19+ github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
20+ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
21+ github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
22+ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
23+ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
24+ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
25+ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
26+ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
27+ github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
28+ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
29+ github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
30+ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
31+ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
32+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
33+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
34+ github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k=
35+ github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
36+ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
37+ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
38+ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
39+ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
40+ github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
41+ github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
42+ golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
43+ golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
44+ golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
45+ golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
46+ golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
47+ golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
48+ golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
49+ golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
50+ golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
51+ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
52+ golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
53+ golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
54+ golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
55+ golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
56+ golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
57+ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
58+ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
59+ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
60+ gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
61+ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
62+ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
63+ gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
64+ modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
65+ modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
66+ modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
67+ modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
68+ modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
69+ modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
70+ modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
71+ modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
72+ modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
73+ modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
74+ modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
75+ modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
76+ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
77+ modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
78+ modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0=
79+ modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
80+ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
81+ modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
@@ -0,0 +1,242 @@
1+ // Package artifact keeps the files attached to a release, outside git. Chapter 22.2.
2+ package artifact
3+
4+ import (
5+ "fmt"
6+ "io"
7+ "io/fs"
8+ "os"
9+ "path/filepath"
10+ "sort"
11+ "strings"
12+ "time"
13+
14+ "github.com/barerepo/server/internal/gitx"
15+ )
16+
17+ // File is one attached file, named and measured, which is what the releases page draws.
18+ type File struct {
19+ Name string
20+ Size int64
21+ }
22+
23+ // ValidName reports a file name safe to join to a path, which is the only check that matters here.
24+ func ValidName(name string) bool {
25+ if name == "" || len(name) > 128 || name == "." || name == ".." {
26+ return false
27+ }
28+ if strings.ContainsAny(name, "/\\\x00") || strings.HasPrefix(name, ".") {
29+ return false
30+ }
31+ for _, c := range name {
32+ switch {
33+ case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9':
34+ case c == '.' || c == '-' || c == '_' || c == '+':
35+ default:
36+ return false
37+ }
38+ }
39+ return true
40+ }
41+
42+ // dir resolves where one tag's files live, refusing anything that would leave the root.
43+ func dir(root, owner, name, tag string) (string, error) {
44+ if !gitx.ValidName(owner) || !gitx.ValidRepoName(name) || !gitx.ValidRef("refs/tags/"+tag) {
45+ return "", fmt.Errorf("no such release")
46+ }
47+ absRoot, err := filepath.Abs(root)
48+ if err != nil {
49+ return "", err
50+ }
51+ // A tag may hold a slash, so it is one path element with the slash spelled out.
52+ safe := strings.ReplaceAll(tag, "/", "%2F")
53+ if safe == "." || safe == ".." || strings.HasPrefix(safe, ".") {
54+ return "", fmt.Errorf("no such release")
55+ }
56+ out := filepath.Join(absRoot, owner, name, safe)
57+ if !strings.HasPrefix(out, absRoot+string(os.PathSeparator)) {
58+ return "", fmt.Errorf("no such release")
59+ }
60+ return out, nil
61+ }
62+
63+ // Put writes one attached file, replacing what was there, so a rerun does not double the list.
64+ func Put(root, owner, name, tag, file string, body io.Reader) (int64, error) {
65+ if !ValidName(file) {
66+ return 0, fmt.Errorf("%q is not a usable file name", file)
67+ }
68+ d, err := dir(root, owner, name, tag)
69+ if err != nil {
70+ return 0, err
71+ }
72+ if err := os.MkdirAll(d, 0o750); err != nil {
73+ return 0, err
74+ }
75+ // A unique part file, so two uploads of one name cannot write into each other.
76+ f, err := os.CreateTemp(d, "."+file+".*.part")
77+ if err != nil {
78+ return 0, err
79+ }
80+ tmp := f.Name()
81+ n, err := io.Copy(f, body)
82+ if cerr := f.Close(); err == nil {
83+ err = cerr
84+ }
85+ if err != nil {
86+ os.Remove(tmp)
87+ return 0, err
88+ }
89+ // The rename is what makes a half written upload invisible to a reader.
90+ if err := os.Rename(tmp, filepath.Join(d, file)); err != nil {
91+ os.Remove(tmp)
92+ return 0, err
93+ }
94+ return n, nil
95+ }
96+
97+ // ListAll reads one repository's releases in a single pass, so a page with no files costs one stat.
98+ func ListAll(root, owner, name string) (map[string][]File, error) {
99+ d, err := repoDir(root, owner, name)
100+ if err != nil {
101+ return nil, err
102+ }
103+ tags, err := os.ReadDir(d)
104+ if err != nil {
105+ return nil, nil
106+ }
107+ out := make(map[string][]File, len(tags))
108+ for _, t := range tags {
109+ if !t.IsDir() {
110+ continue
111+ }
112+ files, err := readFiles(filepath.Join(d, t.Name()))
113+ if err != nil || len(files) == 0 {
114+ continue
115+ }
116+ out[unescapeTag(t.Name())] = files
117+ }
118+ return out, nil
119+ }
120+
121+ // readFiles names what is in one release's directory, skipping the part files an upload leaves.
122+ func readFiles(d string) ([]File, error) {
123+ entries, err := os.ReadDir(d)
124+ if err != nil {
125+ return nil, err
126+ }
127+ out := make([]File, 0, len(entries))
128+ for _, e := range entries {
129+ if e.IsDir() || !ValidName(e.Name()) {
130+ continue
131+ }
132+ info, err := e.Info()
133+ if err != nil {
134+ continue
135+ }
136+ out = append(out, File{Name: e.Name(), Size: info.Size()})
137+ }
138+ sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
139+ return out, nil
140+ }
141+
142+ func unescapeTag(dirName string) string { return strings.ReplaceAll(dirName, "%2F", "/") }
143+
144+ // Open reads one attached file back, and the caller closes it.
145+ func Open(root, owner, name, tag, file string) (*os.File, os.FileInfo, error) {
146+ if !ValidName(file) {
147+ return nil, nil, fmt.Errorf("no such file")
148+ }
149+ d, err := dir(root, owner, name, tag)
150+ if err != nil {
151+ return nil, nil, err
152+ }
153+ f, err := os.Open(filepath.Join(d, file))
154+ if err != nil {
155+ return nil, nil, err
156+ }
157+ info, err := f.Stat()
158+ if err != nil || info.IsDir() {
159+ f.Close()
160+ return nil, nil, fmt.Errorf("no such file")
161+ }
162+ return f, info, nil
163+ }
164+
165+ // Move carries a repository's files to its new name, since nothing in git holds them. 22.2.
166+ func Move(root, oldOwner, oldName, newOwner, newName string) error {
167+ from, err := repoDir(root, oldOwner, oldName)
168+ if err != nil {
169+ return err
170+ }
171+ to, err := repoDir(root, newOwner, newName)
172+ if err != nil {
173+ return err
174+ }
175+ if _, err := os.Stat(from); err != nil {
176+ // A repository with nothing attached has nothing to move, which is most of them.
177+ return nil
178+ }
179+ if err := os.MkdirAll(filepath.Dir(to), 0o750); err != nil {
180+ return err
181+ }
182+ return os.Rename(from, to)
183+ }
184+
185+ // repoDir is where one repository's releases live, refusing anything that would leave the root.
186+ func repoDir(root, owner, name string) (string, error) {
187+ if !gitx.ValidName(owner) || !gitx.ValidRepoName(name) {
188+ return "", fmt.Errorf("no such repository")
189+ }
190+ absRoot, err := filepath.Abs(root)
191+ if err != nil {
192+ return "", err
193+ }
194+ out := filepath.Join(absRoot, owner, name)
195+ if !strings.HasPrefix(out, absRoot+string(os.PathSeparator)) {
196+ return "", fmt.Errorf("no such repository")
197+ }
198+ return out, nil
199+ }
200+
201+ // Forget drops every file a repository has, for a delete, since the blob store is not in git.
202+ func Forget(root, owner, name string) error {
203+ out, err := repoDir(root, owner, name)
204+ if err != nil {
205+ return err
206+ }
207+ return os.RemoveAll(out)
208+ }
209+
210+ // PartWindow is how long one upload may be in flight before its temporary counts as abandoned.
211+ const PartWindow = 24 * time.Hour
212+
213+ // SweepParts removes what a killed process left mid-copy, which no page lists and nothing else frees.
214+ func SweepParts(root string, window time.Duration, now time.Time) (int, error) {
215+ if window <= 0 {
216+ return 0, nil
217+ }
218+ gone := 0
219+ err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
220+ if err != nil || d.IsDir() {
221+ return nil
222+ }
223+ // Put writes ".<file>.<random>.part", and ValidName refuses a leading dot, so nothing else is one.
224+ name := d.Name()
225+ if !strings.HasPrefix(name, ".") || !strings.HasSuffix(name, ".part") {
226+ return nil
227+ }
228+ // Old enough that no upload is still writing it, since a live one is a file in use.
229+ info, err := d.Info()
230+ if err != nil || now.Sub(info.ModTime()) <= window {
231+ return nil
232+ }
233+ if os.Remove(p) == nil {
234+ gone++
235+ }
236+ return nil
237+ })
238+ if err != nil && !os.IsNotExist(err) {
239+ return gone, err
240+ }
241+ return gone, nil
242+ }
@@ -0,0 +1,79 @@
1+ // Package auth also reads gpg keys, because a signature is only useful against a key barerepo holds.
2+ package auth
3+
4+ import (
5+ "context"
6+ "errors"
7+ "os"
8+ "os/exec"
9+ "path/filepath"
10+ "strings"
11+ )
12+
13+ // GPGKey is what one pasted armor says about itself.
14+ type GPGKey struct {
15+ Fingerprint string
16+ UID string
17+ }
18+
19+ // ReadGPGKey asks gpg what the armor holds without keeping any of it. It never imports.
20+ func ReadGPGKey(ctx context.Context, armor string) (GPGKey, error) {
21+ if !strings.Contains(armor, "BEGIN PGP PUBLIC KEY BLOCK") {
22+ return GPGKey{}, errors.New("that is not a public key block. paste the output of gpg --armor --export")
23+ }
24+ if strings.Contains(armor, "PRIVATE KEY BLOCK") {
25+ return GPGKey{}, errors.New("that is a private key. export the public half instead, and treat this one as compromised")
26+ }
27+ cmd := exec.CommandContext(ctx, "gpg", "--batch", "--with-colons",
28+ "--import-options", "show-only", "--import")
29+ cmd.Stdin = strings.NewReader(armor)
30+ out, err := cmd.Output()
31+ if err != nil {
32+ return GPGKey{}, errors.New("that key does not read as a public key")
33+ }
34+ return parseColons(string(out))
35+ }
36+
37+ // parseColons reads the fingerprint and the first user id out of gpg's machine format.
38+ func parseColons(out string) (GPGKey, error) {
39+ var k GPGKey
40+ for _, line := range strings.Split(out, "\n") {
41+ f := strings.Split(line, ":")
42+ switch {
43+ case len(f) > 9 && f[0] == "fpr" && k.Fingerprint == "":
44+ k.Fingerprint = f[9]
45+ case len(f) > 9 && f[0] == "uid" && k.UID == "":
46+ k.UID = strings.ReplaceAll(f[9], `\x3a`, ":")
47+ }
48+ }
49+ if k.Fingerprint == "" {
50+ return k, errors.New("that key has no fingerprint, so it is not a key")
51+ }
52+ return k, nil
53+ }
54+
55+ // Keyring writes every held key into one directory, which is what gpg needs to check a signature.
56+ func Keyring(ctx context.Context, dir string, armors []string) (string, error) {
57+ if len(armors) == 0 {
58+ return "", errors.New("no keys")
59+ }
60+ if err := os.MkdirAll(dir, 0o700); err != nil {
61+ return "", err
62+ }
63+ // No agent, because a ring of public keys never needs one and its socket path can be too long.
64+ if err := os.WriteFile(filepath.Join(dir, "gpg.conf"), []byte("no-autostart\n"), 0o600); err != nil {
65+ return "", err
66+ }
67+ cmd := exec.CommandContext(ctx, "gpg", "--batch", "--quiet", "--no-autostart", "--import")
68+ cmd.Env = append(os.Environ(), "GNUPGHOME="+dir)
69+ cmd.Stdin = strings.NewReader(strings.Join(armors, "\n"))
70+ // The exit code is not the answer: gpg fails on a missing agent it did not need. Ask the ring.
71+ _ = cmd.Run()
72+ list := exec.CommandContext(ctx, "gpg", "--batch", "--no-autostart", "--list-keys", "--with-colons")
73+ list.Env = append(os.Environ(), "GNUPGHOME="+dir)
74+ out, err := list.Output()
75+ if err != nil || !strings.Contains(string(out), "fpr:") {
76+ return "", errors.New("no key could be read into the keyring")
77+ }
78+ return filepath.Clean(dir), nil
79+ }
@@ -0,0 +1,97 @@
1+ // Package auth runs the same `ssh-keygen -Y verify` the user can run, hiding nothing.
2+ package auth
3+
4+ import (
5+ "context"
6+ "crypto/rand"
7+ "encoding/base64"
8+ "errors"
9+ "os"
10+ "os/exec"
11+ "path/filepath"
12+ "strings"
13+
14+ "github.com/barerepo/server/internal/gitx"
15+ "github.com/barerepo/server/internal/store"
16+ )
17+
18+ // Namespace keeps a signature made here from working elsewhere, and the reverse. 31.3.
19+ const Namespace = "barerepo-auth"
20+
21+ // SignupNamespace is separate, so a captured sign-in cannot claim an account.
22+ const SignupNamespace = "barerepo-signup"
23+
24+ // NewNonce is 32 random bytes, encoded so a person can paste it. Chapter 10 step 2.
25+ func NewNonce() (string, error) {
26+ buf := make([]byte, 32)
27+ if _, err := rand.Read(buf); err != nil {
28+ return "", err
29+ }
30+ return base64.RawURLEncoding.EncodeToString(buf), nil
31+ }
32+
33+ var errBadSignature = errors.New("that signature does not match any key on this account")
34+
35+ // Verify tries every key one at a time, because chapter 32.5 needs to know which one answered.
36+ func Verify(ctx context.Context, account, nonce, signature string, keys []store.PubKey) (*store.PubKey, error) {
37+ return verify(ctx, Namespace, account, nonce, signature, keys)
38+ }
39+
40+ // VerifySignup stops one person claiming an account with another's published public key.
41+ func VerifySignup(ctx context.Context, name, nonce, signature, pubkey string) error {
42+ algo, blob, comment, fp, err := store.ParsePubKey(pubkey)
43+ if err != nil {
44+ return err
45+ }
46+ key := store.PubKey{Algo: algo, Blob: blob, Comment: comment, Fingerprint: fp}
47+ if _, err := verify(ctx, SignupNamespace, name, nonce, signature, []store.PubKey{key}); err != nil {
48+ if errors.Is(err, errBadSignature) {
49+ return errors.New("that signature was not made by the key you pasted, with -n barerepo-signup")
50+ }
51+ return err
52+ }
53+ return nil
54+ }
55+
56+ func verify(ctx context.Context, namespace, account, nonce, signature string, keys []store.PubKey) (*store.PubKey, error) {
57+ if len(keys) == 0 || !gitx.ValidName(account) {
58+ return nil, errBadSignature
59+ }
60+ if !strings.Contains(signature, "BEGIN SSH SIGNATURE") {
61+ return nil, errors.New("that is not an ssh signature. it starts with -----BEGIN SSH SIGNATURE-----")
62+ }
63+
64+ dir, err := os.MkdirTemp("", "barerepo-auth-")
65+ if err != nil {
66+ return nil, err
67+ }
68+ defer os.RemoveAll(dir)
69+
70+ sigPath := filepath.Join(dir, "nonce.sig")
71+ if err := os.WriteFile(sigPath, []byte(signature), 0o600); err != nil {
72+ return nil, err
73+ }
74+ signersPath := filepath.Join(dir, "allowed_signers")
75+
76+ for i := range keys {
77+ k := keys[i]
78+ line := account + " " + k.Algo + " " + k.Blob + "\n"
79+ if err := os.WriteFile(signersPath, []byte(line), 0o600); err != nil {
80+ return nil, err
81+ }
82+ cmd := exec.CommandContext(ctx, "ssh-keygen",
83+ "-Y", "verify",
84+ "-f", signersPath,
85+ "-I", account,
86+ "-n", namespace,
87+ "-s", sigPath)
88+ cmd.Stdin = strings.NewReader(nonce)
89+ cmd.Env = []string{"PATH=/usr/local/bin:/usr/bin:/bin", "HOME=" + dir, "LC_ALL=C"}
90+ if out, err := cmd.CombinedOutput(); err == nil {
91+ return &k, nil
92+ } else {
93+ _ = out // ssh-keygen's wording is not ours to show
94+ }
95+ }
96+ return nil, errBadSignature
97+ }
@@ -0,0 +1,152 @@
1+ package auth
2+
3+ import (
4+ "context"
5+ "os"
6+ "os/exec"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+
11+ "github.com/barerepo/server/internal/store"
12+ )
13+
14+ // makeKey generates a real pair and returns the private path and the stored PubKey.
15+ func makeKey(t *testing.T, comment string) (string, store.PubKey) {
16+ t.Helper()
17+ if _, err := exec.LookPath("ssh-keygen"); err != nil {
18+ t.Skip("ssh-keygen is not installed")
19+ }
20+ dir := t.TempDir()
21+ priv := filepath.Join(dir, "id")
22+ cmd := exec.Command("ssh-keygen", "-t", "ed25519", "-N", "", "-C", comment, "-f", priv, "-q")
23+ if out, err := cmd.CombinedOutput(); err != nil {
24+ t.Fatalf("ssh-keygen: %v\n%s", err, out)
25+ }
26+ raw, err := os.ReadFile(priv + ".pub")
27+ if err != nil {
28+ t.Fatal(err)
29+ }
30+ algo, blob, cmt, fp, err := store.ParsePubKey(string(raw))
31+ if err != nil {
32+ t.Fatal(err)
33+ }
34+ return priv, store.PubKey{Algo: algo, Blob: blob, Comment: cmt, Fingerprint: fp}
35+ }
36+
37+ // sign does what chapter 31.3 tells the user to do, by hand.
38+ func sign(t *testing.T, priv, nonce, namespace string) string {
39+ t.Helper()
40+ dir := t.TempDir()
41+ msg := filepath.Join(dir, "nonce")
42+ if err := os.WriteFile(msg, []byte(nonce), 0o600); err != nil {
43+ t.Fatal(err)
44+ }
45+ cmd := exec.Command("ssh-keygen", "-Y", "sign", "-f", priv, "-n", namespace, msg)
46+ if out, err := cmd.CombinedOutput(); err != nil {
47+ t.Fatalf("sign: %v\n%s", err, out)
48+ }
49+ sig, err := os.ReadFile(msg + ".sig")
50+ if err != nil {
51+ t.Fatal(err)
52+ }
53+ return string(sig)
54+ }
55+
56+ func TestVerify(t *testing.T) {
57+ ctx := context.Background()
58+ privA, keyA := makeKey(t, "laptop")
59+ privB, keyB := makeKey(t, "uproar")
60+ _, keyC := makeKey(t, "somebody else")
61+
62+ nonce, err := NewNonce()
63+ if err != nil {
64+ t.Fatal(err)
65+ }
66+
67+ // The key that signed is on the account.
68+ if k, err := Verify(ctx, "john", nonce, sign(t, privA, nonce, Namespace), []store.PubKey{keyA}); err != nil || k == nil {
69+ t.Errorf("a good signature was rejected: %v", err)
70+ }
71+ // Chapter 10 step 5: every key is tried, not just the first.
72+ k, err := Verify(ctx, "john", nonce, sign(t, privB, nonce, Namespace), []store.PubKey{keyA, keyB})
73+ if err != nil {
74+ t.Errorf("the second key on the account was not tried: %v", err)
75+ } else if k.Fingerprint != keyB.Fingerprint {
76+ // Chapter 32.5 times each key, so the right key has to come back, not just a yes.
77+ t.Errorf("Verify named the wrong key: got %s, want %s", k.Fingerprint, keyB.Fingerprint)
78+ }
79+ // A key that is not on the account.
80+ if _, err := Verify(ctx, "john", nonce, sign(t, privA, nonce, Namespace), []store.PubKey{keyC}); err == nil {
81+ t.Error("a signature from an unknown key was accepted")
82+ }
83+ // A signature over a different nonce. This is the replay case.
84+ other, _ := NewNonce()
85+ if _, err := Verify(ctx, "john", nonce, sign(t, privA, other, Namespace), []store.PubKey{keyA}); err == nil {
86+ t.Error("a signature over a different nonce was accepted")
87+ }
88+ // A signature made for another service. This is what the namespace is for.
89+ if _, err := Verify(ctx, "john", nonce, sign(t, privA, nonce, "git"), []store.PubKey{keyA}); err == nil {
90+ t.Error("a signature from another namespace was accepted")
91+ }
92+ // Nothing that is not a signature gets as far as ssh-keygen.
93+ for _, junk := range []string{"", "hello", "-----BEGIN RSA PRIVATE KEY-----"} {
94+ if _, err := Verify(ctx, "john", nonce, junk, []store.PubKey{keyA}); err == nil {
95+ t.Errorf("Verify accepted %q", junk)
96+ }
97+ }
98+ // An account with no keys can never sign in.
99+ if _, err := Verify(ctx, "john", nonce, sign(t, privA, nonce, Namespace), nil); err == nil {
100+ t.Error("an account with no keys was signed in")
101+ }
102+ }
103+
104+ func TestNonceIsUnique(t *testing.T) {
105+ seen := map[string]bool{}
106+ for i := 0; i < 100; i++ {
107+ n, err := NewNonce()
108+ if err != nil {
109+ t.Fatal(err)
110+ }
111+ if len(n) < 40 || seen[n] {
112+ t.Fatalf("nonce %q is short or repeated", n)
113+ }
114+ seen[n] = true
115+ }
116+ if strings.Contains(strings.Join(keysOf(seen), ""), " ") {
117+ t.Error("a nonce contains a space, which breaks the echo -n in chapter 31.3")
118+ }
119+ }
120+
121+ func keysOf(m map[string]bool) []string {
122+ out := make([]string, 0, len(m))
123+ for k := range m {
124+ out = append(out, k)
125+ }
126+ return out
127+ }
128+
129+ // Signup proves the private half, in its own namespace, so no sign-in can be replayed.
130+ func TestVerifySignup(t *testing.T) {
131+ ctx := context.Background()
132+ priv, key := makeKey(t, "laptop")
133+ pub := key.Algo + " " + key.Blob + " laptop"
134+ nonce, _ := NewNonce()
135+
136+ if err := VerifySignup(ctx, "rock", nonce, sign(t, priv, nonce, SignupNamespace), pub); err != nil {
137+ t.Errorf("a good signup signature was rejected: %v", err)
138+ }
139+ // The sign-in namespace must not work here.
140+ if err := VerifySignup(ctx, "rock", nonce, sign(t, priv, nonce, Namespace), pub); err == nil {
141+ t.Error("a barerepo-auth signature was accepted at signup")
142+ }
143+ // Nor should a signature from a different key than the one being claimed.
144+ _, other := makeKey(t, "other")
145+ otherPub := other.Algo + " " + other.Blob + " other"
146+ if err := VerifySignup(ctx, "rock", nonce, sign(t, priv, nonce, SignupNamespace), otherPub); err == nil {
147+ t.Error("a signature from a different key was accepted")
148+ }
149+ if err := VerifySignup(ctx, "rock", nonce, "not a signature", pub); err == nil {
150+ t.Error("junk was accepted as a signature")
151+ }
152+ }
@@ -0,0 +1,79 @@
1+ // Package cache keys answers by immutable object hash, so it never needs invalidation.
2+ package cache
3+
4+ import (
5+ "crypto/sha256"
6+ "encoding/hex"
7+ "os"
8+ "path/filepath"
9+ "sync"
10+ )
11+
12+ // Disk is content-addressed, and deleting the directory is safe because the server rebuilds.
13+ type Disk struct {
14+ root string
15+ mu sync.Mutex
16+ hot map[string][]byte
17+ }
18+
19+ // hotLimit bounds the memory half, where a page reads the same few entries in a row.
20+ const hotLimit = 512
21+
22+ func New(root string) *Disk {
23+ return &Disk{root: root, hot: map[string][]byte{}}
24+ }
25+
26+ // Get returns a cached value, or false.
27+ func (d *Disk) Get(kind, key string) ([]byte, bool) {
28+ if d == nil {
29+ return nil, false
30+ }
31+ k := kind + ":" + key
32+ d.mu.Lock()
33+ if v, ok := d.hot[k]; ok {
34+ d.mu.Unlock()
35+ return v, true
36+ }
37+ d.mu.Unlock()
38+
39+ body, err := os.ReadFile(d.path(kind, key))
40+ if err != nil {
41+ return nil, false
42+ }
43+ d.remember(k, body)
44+ return body, true
45+ }
46+
47+ // Put ignores a write failure, because the next reader recomputes, which is the point.
48+ func (d *Disk) Put(kind, key string, body []byte) {
49+ if d == nil {
50+ return
51+ }
52+ d.remember(kind+":"+key, body)
53+ path := d.path(kind, key)
54+ if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
55+ return
56+ }
57+ tmp := path + ".tmp"
58+ if err := os.WriteFile(tmp, body, 0o640); err != nil {
59+ return
60+ }
61+ // Rename, so a reader never sees half an answer.
62+ _ = os.Rename(tmp, path)
63+ }
64+
65+ func (d *Disk) remember(k string, body []byte) {
66+ d.mu.Lock()
67+ defer d.mu.Unlock()
68+ if len(d.hot) >= hotLimit {
69+ d.hot = map[string][]byte{}
70+ }
71+ d.hot[k] = body
72+ }
73+
74+ // path spreads entries over two levels, so no directory holds a million files.
75+ func (d *Disk) path(kind, key string) string {
76+ sum := sha256.Sum256([]byte(key))
77+ name := hex.EncodeToString(sum[:])
78+ return filepath.Join(d.root, kind, name[:2], name[2:])
79+ }
@@ -0,0 +1,211 @@
1+ // Package config reads barerepo.toml, which holds deployment facts only. Chapter 14.
2+ package config
3+
4+ import (
5+ "fmt"
6+ "net/url"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+
11+ "github.com/BurntSushi/toml"
12+ )
13+
14+ const DefaultPath = "/etc/barerepo/barerepo.toml"
15+
16+ type Config struct {
17+ Server Server `toml:"server"`
18+ Database Database `toml:"database"`
19+ Paths Paths `toml:"paths"`
20+ Limits Limits `toml:"limits"`
21+ Behavior Behavior `toml:"behavior"`
22+
23+ // Path is where this configuration was read from. Not a config key.
24+ Path string `toml:"-"`
25+ }
26+
27+ type Server struct {
28+ Listen string `toml:"listen"`
29+ ExternalURL string `toml:"external_url"`
30+ // RawURL is a separate host for raw content, and empty means a download from the main one.
31+ RawURL string `toml:"raw_url"`
32+ SSHHost string `toml:"ssh_host"`
33+ SSHPort int `toml:"ssh_port"`
34+ // SSHUser is the account in a clone url, which another barerepo on the same host may already own.
35+ SSHUser string `toml:"ssh_user"`
36+ // GitIdentity signs commits the server makes, and empty derives it from the external url.
37+ GitIdentity string `toml:"git_identity"`
38+ }
39+
40+ // Database picks SQLite or PostgreSQL by URL scheme, and no feature exists on only one.
41+ type Database struct {
42+ URL string `toml:"url"`
43+ }
44+
45+ type Paths struct {
46+ Repos string `toml:"repos"`
47+ Cache string `toml:"cache"`
48+ Artifacts string `toml:"artifacts"`
49+ // SSHDir holds the authorized_keys the server writes, and empty means it writes none.
50+ SSHDir string `toml:"ssh_dir"`
51+ }
52+
53+ type Limits struct {
54+ // MaxBlobMB bounds one file, because turning LFS off without it does not hold.
55+ MaxBlobMB int `toml:"max_blob_mb"`
56+ // MaxPushMB is loose, because the push most likely to hit it is somebody's first import.
57+ MaxPushMB int `toml:"max_push_mb"`
58+ MaxOpenProposals int `toml:"max_open_proposals"`
59+ SignupPerHourPerIP int `toml:"signup_per_hour_per_ip"`
60+ // CommentPerHourPerThread is per account as well, since chapter 27 says comments are cheap.
61+ CommentPerHourPerThread int `toml:"comment_per_hour_per_thread"`
62+ ArtifactRetainDays int `toml:"artifact_retain_days"`
63+ }
64+
65+ type Behavior struct {
66+ AllowPushToCreate bool `toml:"allow_push_to_create"`
67+ AllowLFS bool `toml:"allow_lfs"`
68+ }
69+
70+ // Default is a working single-host install on SQLite with no config file. Chapter 41.7.
71+ func Default() Config {
72+ return Config{
73+ Server: Server{
74+ Listen: "127.0.0.1:3000",
75+ ExternalURL: "http://127.0.0.1:3000",
76+ RawURL: "",
77+ SSHHost: "localhost",
78+ SSHPort: 22,
79+ SSHUser: "git",
80+ },
81+ Database: Database{URL: "sqlite:///var/lib/barerepo/forge.db"},
82+ Paths: Paths{
83+ Repos: "/var/lib/barerepo/repos",
84+ Cache: "/var/lib/barerepo/cache",
85+ Artifacts: "/var/lib/barerepo/artifacts",
86+ },
87+ Limits: Limits{
88+ MaxBlobMB: 100,
89+ MaxPushMB: 2048,
90+ MaxOpenProposals: 10,
91+ SignupPerHourPerIP: 5,
92+ // Generous, because a reviewer working through a diff leaves a lot of comments at once.
93+ CommentPerHourPerThread: 30,
94+ ArtifactRetainDays: 90,
95+ },
96+ Behavior: Behavior{
97+ AllowPushToCreate: true,
98+ AllowLFS: false,
99+ },
100+ }
101+ }
102+
103+ // Identity is who the server commits as, from config or from the host it answers on.
104+ func (c Config) Identity() string {
105+ if c.Server.GitIdentity != "" {
106+ return c.Server.GitIdentity
107+ }
108+ user := c.Server.SSHUser
109+ if user == "" {
110+ user = "git"
111+ }
112+ host := c.Server.SSHHost
113+ if host == "" {
114+ if u, err := url.Parse(c.Server.ExternalURL); err == nil && u.Hostname() != "" {
115+ host = u.Hostname()
116+ }
117+ }
118+ if host == "" {
119+ host = "localhost"
120+ }
121+ return user + "@" + host
122+ }
123+
124+ // Load reads over the defaults, and a missing file is an operator who has decided nothing yet.
125+ func Load(path string) (Config, error) {
126+ if path == "" {
127+ path = DefaultPath
128+ }
129+ cfg := Default()
130+ cfg.Path = path
131+
132+ raw, err := os.ReadFile(path)
133+ if os.IsNotExist(err) {
134+ return cfg, cfg.check()
135+ }
136+ if err != nil {
137+ return cfg, err
138+ }
139+ var md toml.MetaData
140+ if md, err = toml.Decode(string(raw), &cfg); err != nil {
141+ return cfg, fmt.Errorf("%s: %w", path, err)
142+ }
143+ // A silently ignored misspelling is how a limit turns out never to have applied. Say so.
144+ if un := md.Undecoded(); len(un) > 0 {
145+ keys := make([]string, len(un))
146+ for i, k := range un {
147+ keys[i] = k.String()
148+ }
149+ return cfg, fmt.Errorf("%s: unknown setting %s", path, strings.Join(keys, ", "))
150+ }
151+ return cfg, cfg.check()
152+ }
153+
154+ // check refuses a configuration that would fail later in a confusing place.
155+ func (c Config) check() error {
156+ for name, p := range map[string]string{
157+ "paths.repos": c.Paths.Repos,
158+ "paths.cache": c.Paths.Cache,
159+ "paths.artifacts": c.Paths.Artifacts,
160+ } {
161+ if p == "" {
162+ return fmt.Errorf("%s: %s is empty", c.Path, name)
163+ }
164+ if !filepath.IsAbs(p) {
165+ return fmt.Errorf("%s: %s must be an absolute path, got %q", c.Path, name, p)
166+ }
167+ }
168+ if c.Server.Listen == "" {
169+ return fmt.Errorf("%s: server.listen is empty", c.Path)
170+ }
171+ if c.Behavior.AllowLFS {
172+ return fmt.Errorf("%s: behavior.allow_lfs is on and chapter 20.3 is not built, "+
173+ "so nothing would serve it. leave it off", c.Path)
174+ }
175+ if _, err := c.DatabaseKind(); err != nil {
176+ return fmt.Errorf("%s: %w", c.Path, err)
177+ }
178+ if c.Server.RawURL != "" {
179+ u, err := url.Parse(c.Server.RawURL)
180+ if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
181+ return fmt.Errorf("%s: server.raw_url must be a full http or https url", c.Path)
182+ }
183+ // A shared domain shares cookies, which is the whole thing chapter 42.3 avoids.
184+ if ext, err := url.Parse(c.Server.ExternalURL); err == nil && ext.Host == u.Host {
185+ return fmt.Errorf("%s: server.raw_url must be a different host from server.external_url", c.Path)
186+ }
187+ }
188+ return nil
189+ }
190+
191+ // Kind names a supported database.
192+ type Kind string
193+
194+ const (
195+ SQLite Kind = "sqlite"
196+ Postgres Kind = "postgres"
197+ )
198+
199+ // DatabaseKind reports which database database.url selects.
200+ func (c Config) DatabaseKind() (Kind, error) {
201+ switch {
202+ case c.Database.URL == "":
203+ return "", fmt.Errorf("database.url is empty")
204+ case strings.HasPrefix(c.Database.URL, "sqlite:"):
205+ return SQLite, nil
206+ case strings.HasPrefix(c.Database.URL, "postgres:"), strings.HasPrefix(c.Database.URL, "postgresql:"):
207+ return Postgres, nil
208+ default:
209+ return "", fmt.Errorf("database.url must start with sqlite: or postgres:, got %q", c.Database.URL)
210+ }
211+ }
@@ -0,0 +1,24 @@
1+ package config
2+
3+ import "testing"
4+
5+ func TestIdentityFollowsTheHostTheServerAnswersOn(t *testing.T) {
6+ cases := []struct{ user, sshHost, external, want string }{
7+ {"", "", "https://barerepo.com", "git@barerepo.com"},
8+ {"barerepo", "", "https://barerepo.com", "barerepo@barerepo.com"},
9+ {"", "barerepo.com", "https://other.example", "git@barerepo.com"},
10+ {"", "", "", "git@localhost"},
11+ }
12+ for _, c := range cases {
13+ cfg := Default()
14+ cfg.Server.SSHUser, cfg.Server.SSHHost, cfg.Server.ExternalURL = c.user, c.sshHost, c.external
15+ if got := cfg.Identity(); got != c.want {
16+ t.Errorf("Identity(user=%q host=%q ext=%q) = %q, want %q", c.user, c.sshHost, c.external, got, c.want)
17+ }
18+ }
19+ cfg := Default()
20+ cfg.Server.GitIdentity = "someone@else"
21+ if got := cfg.Identity(); got != "someone@else" {
22+ t.Errorf("a configured identity was ignored, got %q", got)
23+ }
24+ }
@@ -0,0 +1,23 @@
1+ package config
2+
3+ import (
4+ "strings"
5+ "testing"
6+ )
7+
8+ func TestSwitchingLargeFilesOnRefusesToStart(t *testing.T) {
9+ cfg := Default()
10+ cfg.Paths.Repos, cfg.Paths.Cache, cfg.Paths.Artifacts = "/a", "/b", "/c"
11+ cfg.Behavior.AllowLFS = true
12+ err := cfg.check()
13+ if err == nil {
14+ t.Fatal("allow_lfs came on and the config passed, which is the trap it was")
15+ }
16+ if !strings.Contains(err.Error(), "not built") {
17+ t.Errorf("the error does not say why: %v", err)
18+ }
19+ cfg.Behavior.AllowLFS = false
20+ if err := cfg.check(); err != nil {
21+ t.Errorf("a config with large files off did not pass: %v", err)
22+ }
23+ }
@@ -0,0 +1,127 @@
1+ package gitread
2+
3+ import (
4+ "context"
5+ "strconv"
6+ "strings"
7+
8+ "github.com/barerepo/server/internal/gitx"
9+ )
10+
11+ // Comparison takes free text both sides, because a dropdown cannot say refs/proposals/47.
12+ type Comparison struct {
13+ A, B string
14+ Commits int
15+ Files []FileDiff
16+ Add int
17+ Del int
18+ Conflict bool
19+ // Missing names the side that did not resolve, so the page says which one was wrong.
20+ Missing string
21+ }
22+
23+ // Compare uses three dots, from the merge base, which is what a person means by a comparison.
24+ func Compare(ctx context.Context, dir, a, b string) (*Comparison, error) {
25+ c := &Comparison{A: a, B: b}
26+ for _, side := range []struct{ name, rev string }{{"a", a}, {"b", b}} {
27+ if !gitx.ValidRev(side.rev) {
28+ c.Missing = side.rev
29+ return c, nil
30+ }
31+ if _, err := gitx.Run(ctx, dir, "rev-parse", "--verify", "--quiet", side.rev+"^{commit}"); err != nil {
32+ c.Missing = side.rev
33+ return c, nil
34+ }
35+ }
36+
37+ if out, err := gitx.Run(ctx, dir, "rev-list", "--count", a+".."+b); err == nil {
38+ c.Commits, _ = strconv.Atoi(strings.TrimSpace(out))
39+ }
40+
41+ patch, err := gitx.Run(ctx, dir, "diff", "--unified=3", "--no-color", "--find-renames", a+"..."+b, "--")
42+ if err != nil {
43+ return nil, err
44+ }
45+ c.Files = parsePatch(patch)
46+ for _, f := range c.Files {
47+ c.Add += f.Add
48+ c.Del += f.Del
49+ }
50+
51+ // Answered without merging or a working tree, because rule 1 says the server only reports.
52+ if _, err := gitx.Run(ctx, dir, "merge-tree", "--write-tree", "--name-only", a, b); err != nil {
53+ c.Conflict = true
54+ }
55+ return c, nil
56+ }
57+
58+ // DiffStat is the mockup's +81 -12 in one process, where a full Compare costs five.
59+ func DiffStat(ctx context.Context, dir, a, b string) (add, del int) {
60+ add, del, _ = diffStat(ctx, dir, a, b)
61+ return add, del
62+ }
63+
64+ // diffStat reports whether git answered, because a failed run counts +0 -0 and so does an empty one, and only one is worth keeping.
65+ func diffStat(ctx context.Context, dir, a, b string) (add, del int, ok bool) {
66+ if !gitx.ValidRev(a) || !gitx.ValidRev(b) {
67+ return 0, 0, false
68+ }
69+ out, err := gitx.Run(ctx, dir, "diff", "--numstat", "--no-color", a+"..."+b, "--")
70+ if err != nil {
71+ return 0, 0, false
72+ }
73+ for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
74+ fields := strings.Fields(line)
75+ if len(fields) < 3 {
76+ continue
77+ }
78+ // A binary file is reported as "-", which counts as no lines either way.
79+ if n, err := strconv.Atoi(fields[0]); err == nil {
80+ add += n
81+ }
82+ if n, err := strconv.Atoi(fields[1]); err == nil {
83+ del += n
84+ }
85+ }
86+ return add, del, true
87+ }
88+
89+ // DiffStatBetween is DiffStat keyed by two object hashes, which cannot change, so it never invalidates.
90+ func DiffStatBetween(ctx context.Context, dir, aSHA, bSHA string) (add, del int) {
91+ if aSHA == "" || bSHA == "" {
92+ return 0, 0
93+ }
94+ if aSHA == bSHA {
95+ return 0, 0
96+ }
97+ key := aSHA + "-" + bSHA
98+ if body, ok := Cache.Get("diffstat", key); ok {
99+ if a, d, ok := parseStat(string(body)); ok {
100+ return a, d
101+ }
102+ }
103+ add, del, ok := diffStat(ctx, dir, aSHA, bSHA)
104+ if !ok {
105+ // This key never expires, so a reader who left mid-render must not pin +0 -0 on it forever.
106+ return 0, 0
107+ }
108+ Cache.Put("diffstat", key, []byte(strconv.Itoa(add)+" "+strconv.Itoa(del)))
109+ return add, del
110+ }
111+
112+ // parseStat reads back what DiffStatBetween wrote.
113+ func parseStat(body string) (add, del int, ok bool) {
114+ a, d, found := strings.Cut(strings.TrimSpace(body), " ")
115+ if !found {
116+ return 0, 0, false
117+ }
118+ add, err := strconv.Atoi(a)
119+ if err != nil {
120+ return 0, 0, false
121+ }
122+ del, err = strconv.Atoi(d)
123+ if err != nil {
124+ return 0, 0, false
125+ }
126+ return add, del, true
127+ }
@@ -0,0 +1,174 @@
1+ package gitread
2+
3+ import (
4+ "context"
5+ "strconv"
6+ "strings"
7+ "time"
8+
9+ "github.com/barerepo/server/internal/cache"
10+ "github.com/barerepo/server/internal/gitx"
11+ )
12+
13+ // File carries blame already, because "what is this line" and "why" are one question. 24.
14+ type File struct {
15+ Path string
16+ Blob string
17+ Size int64
18+ Binary bool
19+ // TooBig is chapter 42.4's cap, rather than sending a browser what it will choke on.
20+ TooBig bool
21+ Lines []FileLine
22+ }
23+
24+ type FileLine struct {
25+ Number int
26+ Text string
27+ SHA string // the commit that last touched this line
28+ Short string
29+ Author string
30+ When time.Time
31+ }
32+
33+ // renderCap is the largest file that is rendered whole. Chapter 42.4.
34+ const renderCap = 1 << 20
35+
36+ // binarySniff is how many bytes are checked for a null byte. Chapter 42.4.
37+ const binarySniff = 8000
38+
39+ // Open reads a file at a revision.
40+ func Open(ctx context.Context, dir, rev, path string) (*File, error) {
41+ if !gitx.ValidRev(rev) || !gitx.ValidPath(path) {
42+ return nil, nil
43+ }
44+ // One process for hash, size and content, because three is what chapter 25 cannot afford.
45+ obj, err := gitx.CatFile(ctx, dir, rev+":"+path)
46+ if err != nil || obj.Type != "blob" {
47+ return nil, nil // no such file at that revision
48+ }
49+ f := &File{Path: path, Size: obj.Size, Blob: obj.SHA}
50+ body := obj.Body
51+ head := body
52+ if len(head) > binarySniff {
53+ head = head[:binarySniff]
54+ }
55+ if strings.IndexByte(head, 0) >= 0 {
56+ f.Binary = true
57+ return f, nil
58+ }
59+ if obj.Size > renderCap {
60+ f.TooBig = true
61+ return f, nil
62+ }
63+
64+ text := strings.Split(strings.TrimSuffix(body, "\n"), "\n")
65+ blame, err := blameAt(ctx, dir, rev, path)
66+ if err != nil {
67+ blame = nil // a file with no history still renders
68+ }
69+ f.Lines = make([]FileLine, len(text))
70+ for i, line := range text {
71+ f.Lines[i] = FileLine{Number: i + 1, Text: line}
72+ if i < len(blame) {
73+ b := blame[i]
74+ f.Lines[i].SHA = b.SHA
75+ f.Lines[i].Short = b.Short
76+ f.Lines[i].Author = b.Author
77+ f.Lines[i].When = b.When
78+ }
79+ }
80+ return f, nil
81+ }
82+
83+ // BlameLine is who last touched one line.
84+ type BlameLine struct {
85+ SHA string
86+ Short string
87+ Author string
88+ When time.Time
89+ }
90+
91+ // Cache holds answers that cannot change. Nil means compute every time.
92+ var Cache *cache.Disk
93+
94+ // Blame reads porcelain output, cached by commit and path, because that is the question it answers.
95+ func Blame(ctx context.Context, dir, rev, path string) ([]BlameLine, error) {
96+ if !gitx.ValidRev(rev) || !gitx.ValidPath(path) {
97+ return nil, nil
98+ }
99+ return blameAt(ctx, dir, rev, path)
100+ }
101+
102+ // blameAt keys on commit and path, not blob: two paths can share a blob and a revert reuses one, and either serves the wrong author and commit on every line.
103+ func blameAt(ctx context.Context, dir, rev, path string) ([]BlameLine, error) {
104+ key := ""
105+ if sha := revObject(dir, rev); sha != "" {
106+ key = sha + ":" + path
107+ }
108+ if key != "" {
109+ if body, ok := Cache.Get("blame", key); ok {
110+ return parseBlame(string(body)), nil
111+ }
112+ }
113+ out, err := gitx.Run(ctx, dir, "blame", "--porcelain", rev, "--", path)
114+ if err != nil {
115+ return nil, err
116+ }
117+ if key != "" {
118+ Cache.Put("blame", key, []byte(out))
119+ }
120+ return parseBlame(out), nil
121+ }
122+
123+ // parseBlame remembers each commit's details as they pass, since the format states them once.
124+ func parseBlame(out string) []BlameLine {
125+ type meta struct {
126+ author string
127+ when time.Time
128+ }
129+ seen := map[string]meta{}
130+
131+ var lines []BlameLine
132+ var cur BlameLine
133+ var curMeta meta
134+ inEntry := false
135+
136+ for _, line := range strings.Split(out, "\n") {
137+ switch {
138+ case strings.HasPrefix(line, "\t"):
139+ // The content line ends an entry.
140+ if inEntry {
141+ if curMeta.author == "" {
142+ curMeta = seen[cur.SHA]
143+ } else {
144+ seen[cur.SHA] = curMeta
145+ }
146+ cur.Author = curMeta.author
147+ cur.When = curMeta.when
148+ lines = append(lines, cur)
149+ inEntry = false
150+ curMeta = meta{}
151+ }
152+ case strings.HasPrefix(line, "author "):
153+ curMeta.author = strings.TrimPrefix(line, "author ")
154+ case strings.HasPrefix(line, "author-time "):
155+ if secs, err := strconv.ParseInt(strings.TrimPrefix(line, "author-time "), 10, 64); err == nil {
156+ curMeta.when = time.Unix(secs, 0)
157+ }
158+ case len(line) >= 40 && isHex(line[:40]):
159+ cur = BlameLine{SHA: line[:40], Short: line[:7]}
160+ inEntry = true
161+ }
162+ }
163+ return lines
164+ }
165+
166+ func isHex(s string) bool {
167+ for i := 0; i < len(s); i++ {
168+ c := s[i]
169+ if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') {
170+ return false
171+ }
172+ }
173+ return true
174+ }
@@ -0,0 +1,306 @@
1+ // Package gitread turns git output into template values, and only ever reads.
2+ package gitread
3+
4+ import (
5+ "context"
6+ "fmt"
7+ "strconv"
8+ "strings"
9+ "time"
10+
11+ "github.com/barerepo/server/internal/gitx"
12+ )
13+
14+ type Commit struct {
15+ SHA string
16+ Short string
17+ Author string
18+ When time.Time
19+ Subject string
20+ Body string
21+ Files []FileDiff
22+ Add int
23+ Del int
24+ // Signed is read from the commit object, so a server with no gpg still knows a signature is there.
25+ Signed bool
26+ // SigState is what git says verification found, and only the commit page asks for it.
27+ SigState string
28+ Signer string
29+ // SigKey is the fingerprint, which is the part of a signature a reader can actually compare.
30+ SigKey string
31+ }
32+
33+ type FileDiff struct {
34+ Path string
35+ OldPath string // set on a rename
36+ Add int
37+ Del int
38+ Binary bool
39+ // Gone marks a deletion, so a link to the file at this commit is not offered where it would 404.
40+ Gone bool
41+ Hunks []Hunk
42+ }
43+
44+ type Hunk struct {
45+ Header string
46+ Lines []Line
47+ }
48+
49+ // Line.Kind is ' ', '+' or '-', and New is the new-side number chapter 35.3 comments on.
50+ type Line struct {
51+ Kind byte
52+ Text string
53+ New int
54+ }
55+
56+ // A commit message holds anything but NUL, so NUL separates records and \x1e separates fields.
57+ const logFormat = "%x00%H\x1e%h\x1e%an\x1e%at\x1e%s\x1e%b\x1e"
58+
59+ // Log reads n commits and what each touched, since the log page draws no diff. Chapter 24.
60+ func Log(ctx context.Context, dir, ref, path string, n int) ([]Commit, error) {
61+ if !gitx.ValidRev(ref) {
62+ return nil, nil
63+ }
64+ args := append([]string{"log", "--max-count=" + strconv.Itoa(n), "--format=" + logFormat,
65+ "--numstat", "--no-renames", ref, "--"}, only(path)...)
66+ // The metadata walk alone, because a warm cache answers the rest without a tree diff.
67+ meta, err := gitx.Run(ctx, dir, append(append([]string{}, args[:3]...),
68+ append([]string{ref, "--"}, only(path)...)...)...)
69+ if err != nil {
70+ return nil, err
71+ }
72+ commits := parseLog(meta, func(string) []FileDiff { return nil })
73+ if path == "" && fromCache(commits) {
74+ return commits, nil
75+ }
76+
77+ out, err := gitx.Run(ctx, dir, args...)
78+ if err != nil {
79+ return nil, err
80+ }
81+ commits = parseLog(out, parseNumstat)
82+ markSigned(ctx, dir, commits)
83+ if path == "" {
84+ toCache(commits)
85+ }
86+ return commits, nil
87+ }
88+
89+ // only is the pathspec, kept in one place because two commands take it.
90+ func only(path string) []string {
91+ if path == "" {
92+ return nil
93+ }
94+ return []string{path}
95+ }
96+
97+ // fromCache fills every commit's stats and signature, or reports that one of them was missing.
98+ func fromCache(commits []Commit) bool {
99+ if Cache == nil {
100+ return false
101+ }
102+ for i := range commits {
103+ body, ok := Cache.Get("stat", commits[i].SHA)
104+ if !ok {
105+ return false
106+ }
107+ signed, rest, _ := strings.Cut(string(body), "\n")
108+ commits[i].Signed = signed == "1"
109+ commits[i].Files = parseNumstat(rest)
110+ for _, f := range commits[i].Files {
111+ commits[i].Add += f.Add
112+ commits[i].Del += f.Del
113+ }
114+ }
115+ return true
116+ }
117+
118+ // toCache keeps what a log row needs, which is a commit's counts and whether it carries a signature.
119+ func toCache(commits []Commit) {
120+ if Cache == nil {
121+ return
122+ }
123+ for _, c := range commits {
124+ var b strings.Builder
125+ if c.Signed {
126+ b.WriteString("1\n")
127+ } else {
128+ b.WriteString("0\n")
129+ }
130+ for _, f := range c.Files {
131+ fmt.Fprintf(&b, "%d\t%d\t%s\n", f.Add, f.Del, f.Path)
132+ }
133+ Cache.Put("stat", c.SHA, []byte(b.String()))
134+ }
135+ }
136+
137+ // markSigned reads the commit objects in one process, because a signature is a header and not a check.
138+ func markSigned(ctx context.Context, dir string, commits []Commit) {
139+ specs := make([]string, 0, len(commits))
140+ for _, c := range commits {
141+ specs = append(specs, c.SHA)
142+ }
143+ objs, err := gitx.Batch(ctx, dir, specs)
144+ if err != nil {
145+ return
146+ }
147+ for i := range commits {
148+ if obj := objs[commits[i].SHA]; obj != nil {
149+ commits[i].Signed = hasSignature(obj.Body)
150+ }
151+ }
152+ }
153+
154+ // hasSignature looks for the header git writes for both gpg and ssh signatures.
155+ func hasSignature(body string) bool {
156+ for _, line := range strings.Split(body, "\n") {
157+ if line == "" {
158+ // The headers end at the first blank line, and the message can say anything it likes.
159+ return false
160+ }
161+ if strings.HasPrefix(line, "gpgsig") {
162+ return true
163+ }
164+ }
165+ return false
166+ }
167+
168+ // parseNumstat reads the counts git prints per file, which is all a log row says about a commit.
169+ func parseNumstat(body string) []FileDiff {
170+ var files []FileDiff
171+ for _, line := range strings.Split(body, "\n") {
172+ parts := strings.SplitN(strings.TrimSpace(line), "\t", 3)
173+ if len(parts) != 3 || parts[2] == "" {
174+ continue
175+ }
176+ f := FileDiff{Path: parts[2]}
177+ // git writes a dash for each count of a binary file, which has no lines to count.
178+ f.Binary = parts[0] == "-" || parts[1] == "-"
179+ f.Add, _ = strconv.Atoi(parts[0])
180+ f.Del, _ = strconv.Atoi(parts[1])
181+ // A file with only removals is one this commit deleted, and it has no page at this ref.
182+ f.Gone = !f.Binary && f.Add == 0 && f.Del > 0
183+ files = append(files, f)
184+ }
185+ return files
186+ }
187+
188+ func parseLog(out string, files func(string) []FileDiff) []Commit {
189+ var commits []Commit
190+ for _, record := range strings.Split(out, "\x00") {
191+ if strings.TrimSpace(record) == "" {
192+ continue
193+ }
194+ fields := strings.SplitN(record, "\x1e", 7)
195+ if len(fields) < 7 {
196+ continue
197+ }
198+ c := Commit{
199+ SHA: fields[0],
200+ Short: fields[1],
201+ Author: fields[2],
202+ Subject: fields[4],
203+ Body: strings.TrimSpace(fields[5]),
204+ }
205+ if secs, err := strconv.ParseInt(fields[3], 10, 64); err == nil {
206+ c.When = time.Unix(secs, 0)
207+ }
208+ c.Files = files(fields[6])
209+ for _, f := range c.Files {
210+ c.Add += f.Add
211+ c.Del += f.Del
212+ }
213+ commits = append(commits, c)
214+ }
215+ return commits
216+ }
217+
218+ // parsePatch reads unified diff output into files and hunks.
219+ func parsePatch(patch string) []FileDiff {
220+ var files []FileDiff
221+ var cur *FileDiff
222+ var hunk *Hunk
223+ newLine := 0
224+
225+ flushHunk := func() {
226+ if cur != nil && hunk != nil {
227+ cur.Hunks = append(cur.Hunks, *hunk)
228+ hunk = nil
229+ }
230+ }
231+ flushFile := func() {
232+ flushHunk()
233+ if cur != nil {
234+ files = append(files, *cur)
235+ cur = nil
236+ }
237+ }
238+
239+ for _, line := range strings.Split(patch, "\n") {
240+ switch {
241+ case strings.HasPrefix(line, "diff --git "):
242+ flushFile()
243+ cur = &FileDiff{Path: pathFromDiffLine(line)}
244+ case cur == nil:
245+ // The blank lines and indented subject git prints before the first file.
246+ case strings.HasPrefix(line, "rename from "):
247+ cur.OldPath = strings.TrimPrefix(line, "rename from ")
248+ case strings.HasPrefix(line, "rename to "):
249+ cur.Path = strings.TrimPrefix(line, "rename to ")
250+ case strings.HasPrefix(line, "Binary files "):
251+ cur.Binary = true
252+ case strings.HasPrefix(line, "deleted file mode "):
253+ cur.Gone = true
254+ case hunk == nil && strings.HasPrefix(line, "+++ b/"):
255+ // Only before the first hunk, because an added line reading "++ b/x" arrives as this one, and a name holding a space ends at the tab.
256+ name := strings.TrimPrefix(line, "+++ b/")
257+ cur.Path, _, _ = strings.Cut(name, "\t")
258+ case strings.HasPrefix(line, "@@"):
259+ flushHunk()
260+ hunk = &Hunk{Header: line}
261+ newLine = newStartOf(line)
262+ case hunk == nil:
263+ // index, mode and --- lines, between the header and the hunks.
264+ case strings.HasPrefix(line, "+"):
265+ cur.Add++
266+ hunk.Lines = append(hunk.Lines, Line{Kind: '+', Text: line[1:], New: newLine})
267+ newLine++
268+ case strings.HasPrefix(line, "-"):
269+ cur.Del++
270+ hunk.Lines = append(hunk.Lines, Line{Kind: '-', Text: line[1:]})
271+ case strings.HasPrefix(line, " "):
272+ hunk.Lines = append(hunk.Lines, Line{Kind: ' ', Text: line[1:], New: newLine})
273+ newLine++
274+ case line == `\ No newline at end of file`:
275+ // Real, and not worth a row in the diff.
276+ }
277+ }
278+ flushFile()
279+ return files
280+ }
281+
282+ // pathFromDiffLine takes the b-side, falling back to the tail when a path holds a space.
283+ func pathFromDiffLine(line string) string {
284+ rest := strings.TrimPrefix(line, "diff --git ")
285+ if i := strings.Index(rest, " b/"); i >= 0 {
286+ return rest[i+3:]
287+ }
288+ return rest
289+ }
290+
291+ // newStartOf reads the new-side start line out of an "@@ -a,b +c,d @@" header.
292+ func newStartOf(header string) int {
293+ _, rest, ok := strings.Cut(header, "+")
294+ if !ok {
295+ return 0
296+ }
297+ end := strings.IndexAny(rest, ", ")
298+ if end < 0 {
299+ end = len(rest)
300+ }
301+ n, err := strconv.Atoi(rest[:end])
302+ if err != nil {
303+ return 0
304+ }
305+ return n
306+ }
@@ -0,0 +1,384 @@
1+ package gitread
2+
3+ import (
4+ "context"
5+ "os"
6+ "os/exec"
7+ "path/filepath"
8+ "testing"
9+
10+ "github.com/barerepo/server/internal/gitx"
11+ "strings"
12+ )
13+
14+ // buildRepo makes a real repository, because only git's actual output exercises the parser.
15+ func buildRepo(t *testing.T) string {
16+ t.Helper()
17+ if _, err := gitx.Version(context.Background()); err != nil {
18+ t.Skip("git is not installed")
19+ }
20+ dir := t.TempDir()
21+ run := func(args ...string) {
22+ t.Helper()
23+ cmd := exec.Command(gitx.Bin, args...)
24+ cmd.Dir = dir
25+ cmd.Env = append(os.Environ(),
26+ "GIT_AUTHOR_NAME=lisa", "GIT_AUTHOR_EMAIL=m@x",
27+ "GIT_COMMITTER_NAME=lisa", "GIT_COMMITTER_EMAIL=m@x")
28+ if out, err := cmd.CombinedOutput(); err != nil {
29+ t.Fatalf("git %v: %v\n%s", args, err, out)
30+ }
31+ }
32+ write := func(name, body string) {
33+ t.Helper()
34+ if err := os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0o755); err != nil {
35+ t.Fatal(err)
36+ }
37+ if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
38+ t.Fatal(err)
39+ }
40+ }
41+ run("init", "-q", "-b", "master")
42+ write("config.go", "package main\n\nfunc Load() {}\n")
43+ run("add", "-A")
44+ run("commit", "-qm", "first")
45+ write("config.go", "package main\n\nfunc Load() error {\n\treturn nil\n}\n")
46+ write("irc/conn.go", "package irc\n")
47+ run("add", "-A")
48+ run("commit", "-qm", "second commit\n\nwith a body that explains it.")
49+ return dir
50+ }
51+
52+ func TestLog(t *testing.T) {
53+ dir := buildRepo(t)
54+ commits, err := Log(context.Background(), dir, "HEAD", "", 10)
55+ if err != nil {
56+ t.Fatal(err)
57+ }
58+ if len(commits) != 2 {
59+ t.Fatalf("got %d commits, want 2", len(commits))
60+ }
61+ // The log page draws no diff, so the hunks below come from the commit page's read.
62+ shown, err := Show(context.Background(), dir, commits[0].SHA)
63+ if err != nil || shown == nil {
64+ t.Fatalf("Show(%s) = %v, %v", commits[0].SHA, shown, err)
65+ }
66+
67+ // Newest first, per chapter 24.
68+ c := commits[0]
69+ if c.Subject != "second commit" {
70+ t.Errorf("Subject = %q", c.Subject)
71+ }
72+ if c.Body != "with a body that explains it." {
73+ t.Errorf("Body = %q", c.Body)
74+ }
75+ if c.Author != "lisa" {
76+ t.Errorf("Author = %q", c.Author)
77+ }
78+ if c.When.IsZero() {
79+ t.Error("When is zero")
80+ }
81+ if len(c.Short) == 0 || len(c.SHA) != 40 {
82+ t.Errorf("SHA = %q, Short = %q", c.SHA, c.Short)
83+ }
84+ if len(c.Files) != 2 {
85+ t.Fatalf("got %d files, want 2: %+v", len(c.Files), c.Files)
86+ }
87+ // git orders the diff by path, so config.go comes before irc/conn.go.
88+ if c.Files[0].Path != "config.go" || c.Files[1].Path != "irc/conn.go" {
89+ t.Errorf("paths = %q, %q", c.Files[0].Path, c.Files[1].Path)
90+ }
91+ if c.Add == 0 || c.Del == 0 {
92+ t.Errorf("Add = %d, Del = %d, want both non-zero", c.Add, c.Del)
93+ }
94+
95+ // The hunk must survive with its header and its line kinds intact.
96+ f := shown.Files[0]
97+ if len(f.Hunks) == 0 {
98+ t.Fatal("config.go has no hunks")
99+ }
100+ h := f.Hunks[0]
101+ if h.Header[:2] != "@@" {
102+ t.Errorf("hunk header = %q", h.Header)
103+ }
104+ var kinds string
105+ for _, l := range h.Lines {
106+ kinds += string(l.Kind)
107+ }
108+ for _, want := range []byte{' ', '+', '-'} {
109+ if !contains(kinds, want) {
110+ t.Errorf("no %q lines in %q", want, kinds)
111+ }
112+ }
113+ // The first commit added a file, so it has no deletions.
114+ if commits[1].Del != 0 {
115+ t.Errorf("the first commit reports %d deletions", commits[1].Del)
116+ }
117+ }
118+
119+ // A revision that is really an argument must never reach git.
120+ func TestLogRefusesFlags(t *testing.T) {
121+ dir := buildRepo(t)
122+ for _, rev := range []string{"--upload-pack=/bin/sh", "-x", "master; id", "HEAD@{0}"} {
123+ got, err := Log(context.Background(), dir, rev, "", 10)
124+ if err != nil || got != nil {
125+ t.Errorf("Log(%q) = %v, %v; want nil, nil", rev, got, err)
126+ }
127+ }
128+ }
129+
130+ func contains(s string, b byte) bool {
131+ for i := 0; i < len(s); i++ {
132+ if s[i] == b {
133+ return true
134+ }
135+ }
136+ return false
137+ }
138+
139+ func TestOpenAndBlame(t *testing.T) {
140+ dir := buildRepo(t)
141+ f, err := Open(context.Background(), dir, "HEAD", "config.go")
142+ if err != nil || f == nil {
143+ t.Fatalf("Open: %v %v", f, err)
144+ }
145+ if len(f.Lines) != 5 {
146+ t.Fatalf("got %d lines, want 5: %+v", len(f.Lines), f.Lines)
147+ }
148+ if f.Binary || f.TooBig {
149+ t.Errorf("Binary = %v, TooBig = %v", f.Binary, f.TooBig)
150+ }
151+ if f.Lines[0].Text != "package main" {
152+ t.Errorf("line 1 = %q", f.Lines[0].Text)
153+ }
154+ if f.Lines[0].Number != 1 || f.Lines[4].Number != 5 {
155+ t.Error("line numbers are wrong")
156+ }
157+ // Blame in the gutter on every line, always. Chapter 24.
158+ for _, l := range f.Lines {
159+ if l.Short == "" || l.Author != "lisa" || l.When.IsZero() {
160+ t.Errorf("line %d has no blame: %+v", l.Number, l)
161+ break
162+ }
163+ }
164+ if f, err := Open(context.Background(), dir, "HEAD", "nope.go"); err != nil || f != nil {
165+ t.Errorf("a missing file returned %v, %v", f, err)
166+ }
167+ for _, bad := range []string{"../../etc/passwd", "-rf", "a/../../b"} {
168+ if f, _ := Open(context.Background(), dir, "HEAD", bad); f != nil {
169+ t.Errorf("Open(%q) returned a file", bad)
170+ }
171+ }
172+ }
173+
174+ func TestOpenDetectsBinary(t *testing.T) {
175+ dir := buildRepo(t)
176+ if err := os.WriteFile(filepath.Join(dir, "blob.bin"),
177+ append([]byte("gif89a"), 0, 1, 2, 3), 0o644); err != nil {
178+ t.Fatal(err)
179+ }
180+ cmd := exec.Command(gitx.Bin, "add", "-A")
181+ cmd.Dir = dir
182+ cmd.Run()
183+ cmd = exec.Command(gitx.Bin, "-c", "user.email=m@x", "-c", "user.name=lisa", "commit", "-qm", "binary")
184+ cmd.Dir = dir
185+ if out, err := cmd.CombinedOutput(); err != nil {
186+ t.Fatalf("commit: %v\n%s", err, out)
187+ }
188+ f, err := Open(context.Background(), dir, "HEAD", "blob.bin")
189+ if err != nil || f == nil {
190+ t.Fatalf("Open: %v %v", f, err)
191+ }
192+ if !f.Binary {
193+ t.Error("a file with a null byte was not detected as binary")
194+ }
195+ if len(f.Lines) != 0 {
196+ t.Error("binary content was rendered into lines")
197+ }
198+ }
199+
200+ func TestCompare(t *testing.T) {
201+ dir := buildRepo(t)
202+ c, err := Compare(context.Background(), dir, "HEAD~1", "HEAD")
203+ if err != nil {
204+ t.Fatal(err)
205+ }
206+ if c.Missing != "" {
207+ t.Fatalf("Missing = %q", c.Missing)
208+ }
209+ if c.Commits != 1 {
210+ t.Errorf("Commits = %d, want 1", c.Commits)
211+ }
212+ if len(c.Files) != 2 {
213+ t.Errorf("got %d files, want 2", len(c.Files))
214+ }
215+ if c.Add == 0 {
216+ t.Error("Add = 0")
217+ }
218+ if c.Conflict {
219+ t.Error("a fast-forward comparison reported conflicts")
220+ }
221+ // A side that does not exist is named, not swallowed.
222+ c, err = Compare(context.Background(), dir, "HEAD", "nosuchref")
223+ if err != nil {
224+ t.Fatal(err)
225+ }
226+ if c.Missing != "nosuchref" {
227+ t.Errorf("Missing = %q, want nosuchref", c.Missing)
228+ }
229+ // And a side that is really an argument never reaches git.
230+ c, _ = Compare(context.Background(), dir, "HEAD", "--upload-pack=/bin/sh")
231+ if c.Missing == "" {
232+ t.Error("a flag was accepted as a revision")
233+ }
234+ }
235+
236+ // Chapter 35.3 comments by line number, so it has to survive the parser as the new-side one.
237+ func TestLineNumbers(t *testing.T) {
238+ dir := buildRepo(t)
239+ commits, err := Log(context.Background(), dir, "HEAD", "", 1)
240+ if err != nil {
241+ t.Fatal(err)
242+ }
243+ // Line numbers live in hunks, and hunks are what the commit page reads.
244+ shown, err := Show(context.Background(), dir, commits[0].SHA)
245+ if err != nil || shown == nil {
246+ t.Fatalf("Show(%s) = %v, %v", commits[0].SHA, shown, err)
247+ }
248+ var f FileDiff
249+ for _, c := range shown.Files {
250+ if c.Path == "config.go" {
251+ f = c
252+ }
253+ }
254+ if len(f.Hunks) == 0 {
255+ t.Fatal("config.go has no hunks")
256+ }
257+ seen := map[int]bool{}
258+ for _, h := range f.Hunks {
259+ start := newStartOf(h.Header)
260+ if start <= 0 {
261+ t.Fatalf("hunk header %q has no new-side start", h.Header)
262+ }
263+ want := start
264+ for _, l := range h.Lines {
265+ switch l.Kind {
266+ case '-':
267+ if l.New != 0 {
268+ t.Errorf("a removed line carries new-side number %d", l.New)
269+ }
270+ default:
271+ if l.New != want {
272+ t.Errorf("line %q numbered %d, want %d", l.Text, l.New, want)
273+ }
274+ if seen[l.New] {
275+ t.Errorf("line number %d appears twice", l.New)
276+ }
277+ seen[l.New] = true
278+ want++
279+ }
280+ }
281+ }
282+ }
283+
284+ func TestNewStartOf(t *testing.T) {
285+ for header, want := range map[string]int{
286+ "@@ -41,7 +41,10 @@ func Load": 41,
287+ "@@ -1 +1,2 @@": 1,
288+ "@@ -0,0 +1,5 @@": 1,
289+ "@@ -88,23 +88,0 @@": 88,
290+ "not a header": 0,
291+ } {
292+ if got := newStartOf(header); got != want {
293+ t.Errorf("newStartOf(%q) = %d, want %d", header, got, want)
294+ }
295+ }
296+ }
297+
298+ // A readme is not the landing page, but a repository that has one should be able to say so.
299+ func TestReadmeFindsTheRootOneAndNothingElse(t *testing.T) {
300+ ctx := context.Background()
301+ dir := buildRepo(t)
302+ head := headOf(t, dir)
303+
304+ // buildRepo writes no readme, so this repository has none to find.
305+ if got := Readme(ctx, dir, head); got != "" {
306+ t.Errorf("found a readme %q in a repository with none", got)
307+ }
308+
309+ for _, name := range []string{"README.md", "readme.txt", "README"} {
310+ dir := buildRepo(t)
311+ commitFile(t, dir, name, "hello")
312+ if got := Readme(ctx, dir, headOf(t, dir)); got != name {
313+ t.Errorf("Readme with %s = %q", name, got)
314+ }
315+ }
316+
317+ // A readme below the root is somebody's documentation folder, not the repository's readme.
318+ deep := buildRepo(t)
319+ commitFile(t, deep, "docs/README.md", "hello")
320+ if got := Readme(ctx, deep, headOf(t, deep)); got != "" {
321+ t.Errorf("a nested readme was taken as the repository's: %q", got)
322+ }
323+ }
324+
325+ // The compare fields are free text per chapter 24, so this is what exists to type, not a dropdown.
326+ func TestRefsListsBranchesTagsAndProposals(t *testing.T) {
327+ dir := buildRepo(t)
328+ gitRun(t, dir, "branch", "topic")
329+ gitRun(t, dir, "tag", "v0.1.0")
330+ gitRun(t, dir, "update-ref", "refs/proposals/47", "HEAD")
331+
332+ kinds := map[string]string{}
333+ for _, ref := range Refs(context.Background(), dir) {
334+ kinds[ref.Name] = ref.Kind
335+ }
336+ want := map[string]string{
337+ "master": "branch", "topic": "branch", "v0.1.0": "tag",
338+ // A proposal keeps its full name, because that is what the compare page asks you to type.
339+ "refs/proposals/47": "proposal",
340+ }
341+ for name, kind := range want {
342+ if kinds[name] != kind {
343+ t.Errorf("%s came back as %q, want %q", name, kinds[name], kind)
344+ }
345+ }
346+ // Notes are storage and not somewhere to compare against.
347+ for name := range kinds {
348+ if strings.HasPrefix(name, "refs/notes/") {
349+ t.Errorf("a notes ref was offered to compare against: %s", name)
350+ }
351+ }
352+ }
353+
354+ func gitRun(t *testing.T, dir string, args ...string) {
355+ t.Helper()
356+ cmd := exec.Command(gitx.Bin, args...)
357+ cmd.Dir = dir
358+ cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=lisa", "GIT_AUTHOR_EMAIL=m@x",
359+ "GIT_COMMITTER_NAME=lisa", "GIT_COMMITTER_EMAIL=m@x")
360+ if out, err := cmd.CombinedOutput(); err != nil {
361+ t.Fatalf("git %v: %v\n%s", args, err, out)
362+ }
363+ }
364+
365+ func headOf(t *testing.T, dir string) string {
366+ t.Helper()
367+ out, err := gitx.Run(context.Background(), dir, "rev-parse", "HEAD")
368+ if err != nil {
369+ t.Fatal(err)
370+ }
371+ return strings.TrimSpace(out)
372+ }
373+
374+ func commitFile(t *testing.T, dir, name, body string) {
375+ t.Helper()
376+ if err := os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0o755); err != nil {
377+ t.Fatal(err)
378+ }
379+ if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
380+ t.Fatal(err)
381+ }
382+ gitRun(t, dir, "add", name)
383+ gitRun(t, dir, "commit", "-qm", "add "+name)
384+ }
@@ -0,0 +1,623 @@
1+ package gitread
2+
3+ import (
4+ "bytes"
5+ "context"
6+ "crypto/sha256"
7+ "encoding/hex"
8+ "fmt"
9+ "io"
10+ "io/fs"
11+ "path/filepath"
12+ "sort"
13+ "strconv"
14+ "strings"
15+ "time"
16+
17+ "github.com/barerepo/server/internal/gitx"
18+ )
19+
20+ // Show reads one commit whole, because the commit page is where a person went to see it.
21+ func Show(ctx context.Context, dir, rev string) (*Commit, error) {
22+ if !gitx.ValidRev(rev) {
23+ return nil, nil
24+ }
25+ out, err := gitx.Run(ctx, dir,
26+ "log", "--max-count=1", "--format="+logFormat,
27+ "--patch", "--unified=3", "--no-color", "--find-renames", rev, "--")
28+ if err != nil {
29+ return nil, err
30+ }
31+ commits := parseLog(out, parsePatch)
32+ if len(commits) == 0 {
33+ return nil, nil
34+ }
35+ c := commits[0]
36+ markSigned(ctx, dir, commits[:1])
37+ c.Signed = commits[0].Signed
38+ if c.Signed && Keyring != "" {
39+ // One more process, and only for a commit that carries a signature to check.
40+ var buf bytes.Buffer
41+ err := gitx.Pipe(ctx, dir, nil, &buf, io.Discard, []string{"GNUPGHOME=" + Keyring},
42+ "log", "--max-count=1", "--format=%G?\x1e%GS\x1e%GF", rev, "--")
43+ if err == nil {
44+ f := strings.SplitN(strings.TrimSpace(buf.String()), "\x1e", 3)
45+ c.SigState, c.Signer = f[0], f[1]
46+ if len(f) == 3 {
47+ c.SigKey = f[2]
48+ }
49+ }
50+ }
51+ return &c, nil
52+ }
53+
54+ func patchOf(logOutput string) string {
55+ if i := strings.Index(logOutput, "\x1e"); i >= 0 {
56+ fields := strings.SplitN(strings.TrimPrefix(logOutput, "\x00"), "\x1e", 7)
57+ if len(fields) == 7 {
58+ return fields[6]
59+ }
60+ }
61+ return ""
62+ }
63+
64+ // Reachable answers "is this on the default branch", which is what a link's reader wants.
65+ func Reachable(ctx context.Context, dir, rev, ref string) bool {
66+ if !gitx.ValidRev(rev) || !gitx.ValidRev(ref) {
67+ return false
68+ }
69+ _, err := gitx.Run(ctx, dir, "merge-base", "--is-ancestor", rev, ref)
70+ return err == nil
71+ }
72+
73+ // Entry is one row of the file tree.
74+ type Entry struct {
75+ Name string
76+ Path string
77+ Dir bool
78+ Size int64
79+ Subject string // the last commit that touched it
80+ Author string
81+ When time.Time
82+ }
83+
84+ // Tree lists one directory, and its per-entry log is why this is not the landing page.
85+ func Tree(ctx context.Context, dir, rev, path string) ([]Entry, error) {
86+ if !gitx.ValidRev(rev) {
87+ return nil, nil
88+ }
89+ if path != "" && !gitx.ValidPath(path) {
90+ return nil, nil
91+ }
92+ // The tree comes from the object pool, because ls-tree is a process and this page is a listing.
93+ spec := rev + ":" + path
94+ objs, err := gitx.Batch(ctx, dir, []string{spec})
95+ if err != nil {
96+ return nil, err
97+ }
98+ obj := objs[spec]
99+ if obj == nil || obj.Type != "tree" {
100+ return nil, fmt.Errorf("%s is not a directory here", spec)
101+ }
102+
103+ var entries []Entry
104+ for _, row := range gitx.TreeRows(obj.Body) {
105+ e := Entry{Name: row.Name, Dir: row.Mode == "40000"}
106+ if path != "" {
107+ e.Path = path + "/" + row.Name
108+ } else {
109+ e.Path = row.Name
110+ }
111+ entries = append(entries, e)
112+ }
113+
114+ // git already sorts alphabetically, so this only puts the folders on top, as the mockup shows.
115+ dirs := entries[:0:0]
116+ files := entries[:0:0]
117+ for _, e := range entries {
118+ if e.Dir {
119+ dirs = append(dirs, e)
120+ } else {
121+ files = append(files, e)
122+ }
123+ }
124+ entries = append(dirs, files...)
125+
126+ // Keyed by this rev's commit and path, not the tree, because resolving a tree costs the process this saves and the branch tip filed old rows under the branch's name.
127+ treeHash := ""
128+ if sha := revObject(dir, rev); sha != "" {
129+ treeHash = sha + ":" + path
130+ }
131+ if treeHash != "" {
132+ if body, ok := Cache.Get("tree", treeHash); ok {
133+ if cached := parseTreeLog(string(body), entries); cached != nil {
134+ return cached, nil
135+ }
136+ }
137+ }
138+
139+ var record strings.Builder
140+ for i := range entries {
141+ e := &entries[i]
142+ out, err := gitx.Run(ctx, dir, "log", "--max-count=1",
143+ "--format=%an\x1e%at\x1e%s", rev, "--", e.Path)
144+ if err != nil {
145+ continue
146+ }
147+ f := strings.SplitN(strings.TrimRight(out, "\n"), "\x1e", 3)
148+ if len(f) != 3 {
149+ continue
150+ }
151+ e.Author = f[0]
152+ if secs, err := strconv.ParseInt(f[1], 10, 64); err == nil {
153+ e.When = time.Unix(secs, 0)
154+ }
155+ e.Subject = f[2]
156+ fmt.Fprintf(&record, "%s\x1e%s\x1e%d\x1e%s\n", e.Path, e.Author, e.When.Unix(), e.Subject)
157+ }
158+ if treeHash != "" {
159+ Cache.Put("tree", treeHash, []byte(record.String()))
160+ }
161+ return entries, nil
162+ }
163+
164+ // revObject is the immutable id a rev names, read from the ref files alone because chapter 25 will not spend a process on a cache key, and empty means do not cache.
165+ func revObject(dir, rev string) string {
166+ if rev == "" || rev == "HEAD" {
167+ branch, err := gitx.HeadBranch(dir)
168+ if err != nil {
169+ return ""
170+ }
171+ rev = "refs/heads/" + branch
172+ }
173+ if len(rev) == objectIDLen && isHex(rev) {
174+ return rev
175+ }
176+ // A proposal is typed in full, so refs/heads/refs/proposals/47 is a file nobody wrote, and git's own order settles a name that is both a tag and a branch.
177+ tries := []string{"refs/" + rev, "refs/tags/" + rev, "refs/heads/" + rev}
178+ if strings.HasPrefix(rev, "refs/") {
179+ tries = []string{rev}
180+ }
181+ for _, ref := range tries {
182+ if sha, err := gitx.ResolveRef(dir, ref); err == nil {
183+ return sha
184+ }
185+ }
186+ return ""
187+ }
188+
189+ // ParentShort is the first parent's short id, or empty at a root commit.
190+ func ParentShort(ctx context.Context, dir, rev string) string {
191+ if !gitx.ValidRev(rev) {
192+ return ""
193+ }
194+ out, err := gitx.Run(ctx, dir, "rev-parse", "--short", "--verify", "--quiet", rev+"^")
195+ if err != nil {
196+ return ""
197+ }
198+ return strings.TrimSpace(out)
199+ }
200+
201+ // RepoStat is the summary of one repository on a profile page.
202+ type RepoStat struct {
203+ Size int64
204+ Language string
205+ Branch string
206+ Pushed time.Time
207+ }
208+
209+ // Stat measures a repository for the profile listing.
210+ func Stat(ctx context.Context, dir string) RepoStat {
211+ var st RepoStat
212+ // A file read, because a profile lists many repositories and chapter 25 counts processes.
213+ if branch, err := gitx.HeadBranch(dir); err == nil {
214+ st.Branch = branch
215+ }
216+ if out, err := gitx.Run(ctx, dir, "for-each-ref", "--sort=-committerdate", "--count=1",
217+ "--format=%(committerdate:unix)", "refs/heads"); err == nil {
218+ if secs, err := strconv.ParseInt(strings.TrimSpace(out), 10, 64); err == nil {
219+ st.Pushed = time.Unix(secs, 0)
220+ }
221+ }
222+ // A walk of the object store, because a profile lists many repositories and each process costs.
223+ st.Size = objectBytes(dir)
224+ st.Language = languageOf(ctx, dir, st.Branch)
225+ return st
226+ }
227+
228+ // objectBytes adds up what a repository keeps on disk, loose and packed, with no process at all.
229+ func objectBytes(dir string) int64 {
230+ var total int64
231+ filepath.WalkDir(filepath.Join(dir, "objects"), func(_ string, d fs.DirEntry, err error) error {
232+ if err != nil || d.IsDir() {
233+ return nil
234+ }
235+ if info, err := d.Info(); err == nil {
236+ total += info.Size()
237+ }
238+ return nil
239+ })
240+ return total
241+ }
242+
243+ // languageOf caches the guess under the tip it was made from, which cannot change under that name.
244+ func languageOf(ctx context.Context, dir, branch string) string {
245+ sha, err := gitx.ResolveRef(dir, "refs/heads/"+branch)
246+ if err != nil || Cache == nil {
247+ return language(ctx, dir, branch)
248+ }
249+ if body, ok := Cache.Get("lang", sha); ok {
250+ return string(body)
251+ }
252+ lang := language(ctx, dir, branch)
253+ Cache.Put("lang", sha, []byte(lang))
254+ return lang
255+ }
256+
257+ // language guesses from the commonest extension, and is a label on a listing, not a fact.
258+ func language(ctx context.Context, dir, branch string) string {
259+ if branch == "" {
260+ branch = "HEAD"
261+ }
262+ out, err := gitx.Run(ctx, dir, "ls-tree", "-r", "--name-only", branch)
263+ if err != nil {
264+ return ""
265+ }
266+ byExt := map[string]int{}
267+ for _, name := range strings.Split(out, "\n") {
268+ i := strings.LastIndex(name, ".")
269+ if i < 0 || i == len(name)-1 {
270+ continue
271+ }
272+ if lang, ok := languages[strings.ToLower(name[i+1:])]; ok {
273+ byExt[lang]++
274+ }
275+ }
276+ best, bestN := "", 0
277+ for lang, n := range byExt {
278+ if n > bestN || (n == bestN && lang < best) {
279+ best, bestN = lang, n
280+ }
281+ }
282+ return best
283+ }
284+
285+ // languages is short on purpose, because an unlisted extension beats a wrong guess.
286+ var languages = map[string]string{
287+ "go": "go", "c": "c", "h": "c", "cc": "c++", "cpp": "c++", "hpp": "c++",
288+ "rs": "rust", "py": "python", "rb": "ruby", "js": "javascript",
289+ "ts": "typescript", "java": "java", "kt": "kotlin", "swift": "swift",
290+ "sh": "shell", "bash": "shell", "zsh": "shell", "nix": "nix",
291+ "lua": "lua", "php": "php", "cs": "c#", "ex": "elixir", "exs": "elixir",
292+ "hs": "haskell", "ml": "ocaml", "zig": "zig", "css": "css", "html": "html",
293+ "sql": "sql", "md": "markdown",
294+ }
295+
296+ // Release is a tag, a body and some files. Chapter 22.
297+ type Release struct {
298+ Tag string
299+ Tagger string
300+ When time.Time
301+ Subject string // the tag's own message, for an annotated tag
302+ Notes string // the body, from refs/notes/releases
303+ Object string
304+ }
305+
306+ // ReleasesRef holds release bodies keyed by tag object, in one ref, for chapter 16's reason.
307+ const ReleasesRef = "refs/notes/releases"
308+
309+ // Releases lists tags newest first, from the ref files and the object pool, so it starts no process.
310+ func Releases(ctx context.Context, dir string) ([]Release, error) {
311+ refs, err := gitx.ListRefs(dir, "refs/tags")
312+ if err != nil || len(refs) == 0 {
313+ return nil, err
314+ }
315+ names := make([]string, 0, len(refs))
316+ for ref := range refs {
317+ names = append(names, ref)
318+ }
319+ sort.Strings(names)
320+
321+ // The key is the ref state itself, so a tag pushed or deleted writes a different entry.
322+ sum := sha256.New()
323+ for _, ref := range names {
324+ io.WriteString(sum, ref+" "+refs[ref]+"\n")
325+ }
326+ key := hex.EncodeToString(sum.Sum(nil))
327+ if body, ok := Cache.Get("tags", key); ok {
328+ return parseReleases(string(body)), nil
329+ }
330+
331+ specs := make([]string, 0, len(names))
332+ for _, ref := range names {
333+ specs = append(specs, refs[ref])
334+ }
335+ objs, err := gitx.Batch(ctx, dir, specs)
336+ if err != nil {
337+ return nil, err
338+ }
339+ list := make([]Release, 0, len(names))
340+ for _, ref := range names {
341+ obj := objs[refs[ref]]
342+ if obj == nil {
343+ continue
344+ }
345+ r := Release{Tag: strings.TrimPrefix(ref, "refs/tags/"), Object: obj.SHA}
346+ r.Tagger, r.When, r.Subject = describeTag(obj.Body)
347+ list = append(list, r)
348+ }
349+ sort.Slice(list, func(i, j int) bool {
350+ if list[i].When.Equal(list[j].When) {
351+ return list[i].Tag > list[j].Tag
352+ }
353+ return list[i].When.After(list[j].When)
354+ })
355+ Cache.Put("tags", key, []byte(formatReleases(list)))
356+ return list, nil
357+ }
358+
359+ // formatReleases writes the list the cache keeps, without the notes, which are read per page.
360+ func formatReleases(list []Release) string {
361+ var b strings.Builder
362+ for _, r := range list {
363+ b.WriteString(r.Tag + "\x1e" + r.Object + "\x1e" + strconv.FormatInt(r.When.Unix(), 10) +
364+ "\x1e" + r.Tagger + "\x1e" + r.Subject + "\n")
365+ }
366+ return b.String()
367+ }
368+
369+ func parseReleases(body string) []Release {
370+ var list []Release
371+ for _, line := range strings.Split(strings.TrimRight(body, "\n"), "\n") {
372+ f := strings.SplitN(line, "\x1e", 5)
373+ if len(f) < 5 {
374+ continue
375+ }
376+ r := Release{Tag: f[0], Object: f[1], Tagger: f[3], Subject: f[4]}
377+ if secs, err := strconv.ParseInt(f[2], 10, 64); err == nil {
378+ r.When = time.Unix(secs, 0)
379+ }
380+ list = append(list, r)
381+ }
382+ return list
383+ }
384+
385+ // describeTag reads who made a tag, when, and its first line, from a tag object or from the commit.
386+ func describeTag(body string) (who string, when time.Time, subject string) {
387+ head, msg, _ := strings.Cut(body, "\n\n")
388+ for _, line := range strings.Split(head, "\n") {
389+ kind, rest, ok := strings.Cut(line, " ")
390+ if !ok || (kind != "tagger" && kind != "author") {
391+ continue
392+ }
393+ who, when = ident(rest)
394+ if kind == "tagger" {
395+ break
396+ }
397+ }
398+ subject, _, _ = strings.Cut(strings.TrimSpace(msg), "\n")
399+ return who, when, subject
400+ }
401+
402+ // ident splits a git identity line into the name and the moment it carries.
403+ func ident(s string) (string, time.Time) {
404+ name := s
405+ if i := strings.LastIndex(s, " <"); i >= 0 {
406+ name = s[:i]
407+ }
408+ rest := s
409+ if i := strings.LastIndex(s, "> "); i >= 0 {
410+ rest = s[i+2:]
411+ }
412+ stamp, _, _ := strings.Cut(rest, " ")
413+ secs, err := strconv.ParseInt(stamp, 10, 64)
414+ if err != nil {
415+ return name, time.Time{}
416+ }
417+ return name, time.Unix(secs, 0)
418+ }
419+
420+ // ReadNotes attaches the bodies for the releases a page is about to draw.
421+ func ReadNotes(ctx context.Context, dir string, list []Release) {
422+ want := make(map[string]int, len(list))
423+ for i := range list {
424+ want[list[i].Object] = i
425+ }
426+ for object, body := range releaseNotes(ctx, dir) {
427+ if i, ok := want[object]; ok {
428+ list[i].Notes = strings.TrimRight(body, "\n")
429+ }
430+ }
431+ }
432+
433+ // crumb is one tree in the notes fanout, with the object id its path has spelled so far.
434+ type crumb struct{ spec, prefix string }
435+
436+ // releaseNotes reads every body once per notes commit, and a commit is immutable, so a repeat is free.
437+ func releaseNotes(ctx context.Context, dir string) map[string]string {
438+ refs, err := gitx.ListRefs(dir, "refs/notes")
439+ if err != nil || refs[ReleasesRef] == "" {
440+ return nil
441+ }
442+ if body, ok := Cache.Get("relnotes", refs[ReleasesRef]); ok {
443+ return parseNotes(string(body))
444+ }
445+ all := walkNotes(ctx, dir, refs[ReleasesRef])
446+ if all == nil {
447+ return nil
448+ }
449+ Cache.Put("relnotes", refs[ReleasesRef], []byte(formatNotes(all)))
450+ return all
451+ }
452+
453+ // formatNotes writes the map the cache keeps, length prefixed because a body holds newlines.
454+ func formatNotes(notes map[string]string) string {
455+ var b strings.Builder
456+ for object, body := range notes {
457+ b.WriteString(object + " " + strconv.Itoa(len(body)) + "\n" + body)
458+ }
459+ return b.String()
460+ }
461+
462+ func parseNotes(record string) map[string]string {
463+ out := map[string]string{}
464+ for len(record) > 0 {
465+ head, rest, ok := strings.Cut(record, "\n")
466+ if !ok {
467+ return out
468+ }
469+ object, size, ok := strings.Cut(head, " ")
470+ if !ok {
471+ return out
472+ }
473+ n, err := strconv.Atoi(size)
474+ if err != nil || n > len(rest) {
475+ return out
476+ }
477+ out[object] = rest[:n]
478+ record = rest[n:]
479+ }
480+ return out
481+ }
482+
483+ // walkNotes reads the whole notes tree through the object pool, one level of fanout at a time.
484+ func walkNotes(ctx context.Context, dir, head string) map[string]string {
485+ blobOf := map[string]string{}
486+ level := []crumb{{head + "^{tree}", ""}}
487+ for len(level) > 0 {
488+ specs := make([]string, 0, len(level))
489+ for _, c := range level {
490+ specs = append(specs, c.spec)
491+ }
492+ objs, err := gitx.Batch(ctx, dir, specs)
493+ if err != nil {
494+ return nil
495+ }
496+ var next []crumb
497+ for _, c := range level {
498+ obj := objs[c.spec]
499+ if obj == nil {
500+ continue
501+ }
502+ for name, sha := range gitx.TreeEntries(obj.Body) {
503+ id := c.prefix + name
504+ // A tree entry that is not a full object id is a fanout directory, not a note.
505+ if len(id) >= objectIDLen {
506+ blobOf[id] = sha
507+ } else {
508+ next = append(next, crumb{sha, id})
509+ }
510+ }
511+ }
512+ level = next
513+ }
514+ if len(blobOf) == 0 {
515+ return nil
516+ }
517+ specs := make([]string, 0, len(blobOf))
518+ for _, sha := range blobOf {
519+ specs = append(specs, sha)
520+ }
521+ blobs, err := gitx.Batch(ctx, dir, specs)
522+ if err != nil {
523+ return nil
524+ }
525+ out := make(map[string]string, len(blobOf))
526+ for object, sha := range blobOf {
527+ if b := blobs[sha]; b != nil {
528+ out[object] = b.Body
529+ }
530+ }
531+ return out
532+ }
533+
534+ // objectIDLen is a sha1 hex object id, which is the path a note takes once the fanout is spelled out.
535+ const objectIDLen = 40
536+
537+ // parseTreeLog fills entries from a cached record, or nil when the record does not match.
538+ func parseTreeLog(body string, entries []Entry) []Entry {
539+ byPath := map[string][3]string{}
540+ for _, line := range strings.Split(strings.TrimRight(body, "\n"), "\n") {
541+ f := strings.SplitN(line, "\x1e", 4)
542+ if len(f) != 4 {
543+ return nil
544+ }
545+ byPath[f[0]] = [3]string{f[1], f[2], f[3]}
546+ }
547+ for i := range entries {
548+ got, ok := byPath[entries[i].Path]
549+ if !ok {
550+ return nil
551+ }
552+ entries[i].Author = got[0]
553+ if secs, err := strconv.ParseInt(got[1], 10, 64); err == nil {
554+ entries[i].When = time.Unix(secs, 0)
555+ }
556+ entries[i].Subject = got[2]
557+ }
558+ return entries
559+ }
560+
561+ // readmeNames is what the root is checked for, in order, because a repository picks one and keeps it.
562+ var readmeNames = []string{
563+ "README.md", "README", "README.txt", "README.markdown", "README.rst",
564+ "readme.md", "readme", "readme.txt", "readme.markdown", "readme.rst",
565+ "Readme.md", "ReadMe.md", "Readme", "ReadMe",
566+ }
567+
568+ // Readme finds a readme in the root tree, or reports none, keyed by the commit so it costs one read.
569+ func Readme(ctx context.Context, dir, commit string) string {
570+ if commit == "" {
571+ return ""
572+ }
573+ if body, ok := Cache.Get("readme", commit); ok {
574+ return string(body)
575+ }
576+ found := ""
577+ // One process for the whole root, rather than a cat-file per candidate name.
578+ if root, err := gitx.Batch(ctx, dir, []string{commit + "^{tree}"}); err == nil {
579+ if t := root[commit+"^{tree}"]; t != nil {
580+ entries := gitx.TreeEntries(t.Body)
581+ for _, want := range readmeNames {
582+ if _, ok := entries[want]; ok {
583+ found = want
584+ break
585+ }
586+ }
587+ }
588+ }
589+ Cache.Put("readme", commit, []byte(found))
590+ return found
591+ }
592+
593+ // Ref is one name a person can type into the compare form. Chapter 24 keeps that field free text.
594+ type Ref struct {
595+ Name string
596+ Kind string
597+ }
598+
599+ // Refs lists what exists to compare against, in one process, because a field cannot be typed blind.
600+ func Refs(ctx context.Context, dir string) []Ref {
601+ out, err := gitx.Run(ctx, dir, "for-each-ref", "--sort=-committerdate",
602+ "--format=%(refname)", "refs/heads", "refs/tags", "refs/proposals")
603+ if err != nil {
604+ return nil
605+ }
606+ var refs []Ref
607+ for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
608+ switch {
609+ case line == "":
610+ case strings.HasPrefix(line, "refs/heads/"):
611+ refs = append(refs, Ref{Name: strings.TrimPrefix(line, "refs/heads/"), Kind: "branch"})
612+ case strings.HasPrefix(line, "refs/tags/"):
613+ refs = append(refs, Ref{Name: strings.TrimPrefix(line, "refs/tags/"), Kind: "tag"})
614+ case strings.HasPrefix(line, "refs/proposals/"):
615+ // A proposal is typed in full, because that is what the compare page's example shows.
616+ refs = append(refs, Ref{Name: line, Kind: "proposal"})
617+ }
618+ }
619+ return refs
620+ }
621+
622+ // Keyring is where the keys accounts published live, and empty means no signature can be checked.
623+ var Keyring string
@@ -0,0 +1,216 @@
1+ package gitx
2+
3+ import (
4+ "bufio"
5+ "container/list"
6+ "context"
7+ "fmt"
8+ "io"
9+ "os/exec"
10+ "strconv"
11+ "strings"
12+ "sync"
13+ "time"
14+ )
15+
16+ // idlePerDir bounds how many readers one repository keeps, so a hot repo does not serialise.
17+ const idlePerDir = 4
18+
19+ // idleTotal bounds the whole server, because a barerepo holds more repositories than processes.
20+ const idleTotal = 64
21+
22+ // IdleLife is how long an unused reader is kept, and Sweep closes what is older.
23+ const IdleLife = 10 * time.Minute
24+
25+ // reader is one cat-file --batch kept open, because forking git costs more than the read does.
26+ type reader struct {
27+ dir string
28+ cmd *exec.Cmd
29+ in io.WriteCloser
30+ out *bufio.Reader
31+ idle time.Time
32+ spot *list.Element
33+ }
34+
35+ var (
36+ poolMu sync.Mutex
37+ // free holds readers by directory, and order holds the same readers oldest first.
38+ free = map[string][]*reader{}
39+ order = list.New()
40+ )
41+
42+ // take returns an idle reader for dir, or nothing if none is waiting.
43+ func take(dir string) *reader {
44+ poolMu.Lock()
45+ defer poolMu.Unlock()
46+ have := free[dir]
47+ if len(have) == 0 {
48+ return nil
49+ }
50+ r := have[len(have)-1]
51+ free[dir] = have[:len(have)-1]
52+ if len(free[dir]) == 0 {
53+ delete(free, dir)
54+ }
55+ order.Remove(r.spot)
56+ r.spot = nil
57+ return r
58+ }
59+
60+ // put keeps a reader for the next request, or closes it when the pool is full.
61+ func put(r *reader) {
62+ poolMu.Lock()
63+ if len(free[r.dir]) >= idlePerDir {
64+ poolMu.Unlock()
65+ r.close()
66+ return
67+ }
68+ r.idle = time.Now()
69+ free[r.dir] = append(free[r.dir], r)
70+ r.spot = order.PushBack(r)
71+ var evict *reader
72+ if order.Len() > idleTotal {
73+ evict = drop(order.Front())
74+ }
75+ poolMu.Unlock()
76+ if evict != nil {
77+ evict.close()
78+ }
79+ }
80+
81+ // drop removes one reader from both structures, and the caller closes it outside the lock.
82+ func drop(e *list.Element) *reader {
83+ if e == nil {
84+ return nil
85+ }
86+ r := e.Value.(*reader)
87+ order.Remove(e)
88+ r.spot = nil
89+ have := free[r.dir]
90+ for i, other := range have {
91+ if other == r {
92+ free[r.dir] = append(have[:i], have[i+1:]...)
93+ break
94+ }
95+ }
96+ if len(free[r.dir]) == 0 {
97+ delete(free, r.dir)
98+ }
99+ return r
100+ }
101+
102+ // CloseIdleReaders closes every reader unused for longer than age, and returns how many.
103+ func CloseIdleReaders(age time.Duration) int {
104+ cutoff := time.Now().Add(-age)
105+ var stale []*reader
106+ poolMu.Lock()
107+ for e := order.Front(); e != nil; {
108+ next := e.Next()
109+ if e.Value.(*reader).idle.After(cutoff) {
110+ break
111+ }
112+ stale = append(stale, drop(e))
113+ e = next
114+ }
115+ poolMu.Unlock()
116+ for _, r := range stale {
117+ r.close()
118+ }
119+ return len(stale)
120+ }
121+
122+ // start opens a new reader, which is the only place a cat-file process is created.
123+ func start(dir string) (*reader, error) {
124+ cmd := exec.Command(Bin, "cat-file", "--batch")
125+ cmd.Dir = dir
126+ cmd.Env = env()
127+ in, err := cmd.StdinPipe()
128+ if err != nil {
129+ return nil, err
130+ }
131+ out, err := cmd.StdoutPipe()
132+ if err != nil {
133+ in.Close()
134+ return nil, err
135+ }
136+ if err := cmd.Start(); err != nil {
137+ in.Close()
138+ return nil, err
139+ }
140+ return &reader{dir: dir, cmd: cmd, in: in, out: bufio.NewReaderSize(out, 64<<10)}, nil
141+ }
142+
143+ func (r *reader) close() {
144+ r.in.Close()
145+ if r.cmd.Process != nil {
146+ r.cmd.Process.Kill()
147+ }
148+ r.cmd.Wait()
149+ }
150+
151+ // ask writes every spec and reads every answer, and any surprise means the stream is out of step.
152+ func (r *reader) ask(specs []string) (map[string]*Object, error) {
153+ // Written from another goroutine, because a big batch fills the pipe before git has answered.
154+ sent := make(chan error, 1)
155+ go func() {
156+ _, err := io.WriteString(r.in, strings.Join(specs, "\n")+"\n")
157+ sent <- err
158+ }()
159+ out := make(map[string]*Object, len(specs))
160+ for _, spec := range specs {
161+ header, err := r.out.ReadString('\n')
162+ if err != nil {
163+ <-sent
164+ return nil, err
165+ }
166+ fields := strings.Fields(header)
167+ // "<spec> missing" is git's answer, and it rereads the pack directory before saying it.
168+ if len(fields) < 3 {
169+ continue
170+ }
171+ size, err := strconv.ParseInt(fields[2], 10, 64)
172+ if err != nil || size < 0 {
173+ <-sent
174+ return nil, fmt.Errorf("cat-file said %q", strings.TrimSpace(header))
175+ }
176+ body := make([]byte, size+1)
177+ if _, err := io.ReadFull(r.out, body); err != nil {
178+ <-sent
179+ return nil, err
180+ }
181+ out[spec] = &Object{SHA: fields[0], Type: fields[1], Size: size, Body: string(body[:size])}
182+ }
183+ if err := <-sent; err != nil {
184+ return nil, err
185+ }
186+ return out, nil
187+ }
188+
189+ // Batch reads many objects without starting a process, which is what chapter 25's budget needs.
190+ func Batch(ctx context.Context, dir string, specs []string) (map[string]*Object, error) {
191+ if len(specs) == 0 {
192+ return map[string]*Object{}, nil
193+ }
194+ if err := ctx.Err(); err != nil {
195+ return nil, err
196+ }
197+ if r := take(dir); r != nil {
198+ out, err := r.ask(specs)
199+ if err == nil {
200+ put(r)
201+ return out, nil
202+ }
203+ r.close()
204+ }
205+ r, err := start(dir)
206+ if err != nil {
207+ return nil, err
208+ }
209+ out, err := r.ask(specs)
210+ if err != nil {
211+ r.close()
212+ return nil, err
213+ }
214+ put(r)
215+ return out, nil
216+ }
@@ -0,0 +1,221 @@
1+ package gitx
2+
3+ import (
4+ "context"
5+ "os"
6+ "os/exec"
7+ "path/filepath"
8+ "strconv"
9+ "strings"
10+ "testing"
11+ "time"
12+ )
13+
14+ // Keeping cat-file open rests on git rereading the pack directory whenever it answers "missing".
15+ func TestAKeptReaderSeesAnObjectThatArrivedInAPack(t *testing.T) {
16+ if _, err := exec.LookPath("git"); err != nil {
17+ t.Skip("git is not installed")
18+ }
19+ ctx := context.Background()
20+ dir := t.TempDir()
21+ mustGit(t, dir, "init", "-q", "--bare", "-b", "master")
22+
23+ // A referenced object, then gc, is how an object comes to exist only inside a pack.
24+ sha := writeObject(t, dir, "only in a pack\n")
25+ mustGit(t, dir, "prune-packed")
26+ if err := os.Remove(filepath.Join(dir, "objects", sha[:2], sha[2:])); err != nil {
27+ t.Fatal(err)
28+ }
29+ // Asked once while it is absent, so the reader has already answered "missing" for this id.
30+ if got, err := Batch(ctx, dir, []string{sha}); err != nil || got[sha] != nil {
31+ t.Fatalf("the object was readable before it was written: %v %v", got[sha], err)
32+ }
33+ sha = writeObject(t, dir, "only in a pack\n")
34+ tree := mktree(t, dir, "100644 blob "+sha+"\tfile\n")
35+ commit := mustGit(t, dir, "commit-tree", tree, "-m", "one")
36+ mustGit(t, dir, "update-ref", "refs/heads/master", commit)
37+ mustGit(t, dir, "gc", "--prune=now", "-q")
38+ if _, err := os.Stat(filepath.Join(dir, "objects", sha[:2], sha[2:])); err == nil {
39+ t.Skip("git kept the loose copy, so this repository proves nothing")
40+ }
41+
42+ got, err := Batch(ctx, dir, []string{sha})
43+ if err != nil {
44+ t.Fatal(err)
45+ }
46+ if got[sha] == nil {
47+ t.Fatal("a kept reader could not see an object that git packed after it started")
48+ }
49+ if got[sha].Body != "only in a pack\n" {
50+ t.Errorf("the object read as %q", got[sha].Body)
51+ }
52+ }
53+
54+ // An object that is genuinely absent must stay absent, and must not cost a process every time.
55+ func TestAMissingObjectIsMissing(t *testing.T) {
56+ if _, err := exec.LookPath("git"); err != nil {
57+ t.Skip("git is not installed")
58+ }
59+ ctx := context.Background()
60+ dir := t.TempDir()
61+ mustGit(t, dir, "init", "-q", "--bare", "-b", "master")
62+
63+ absent := strings.Repeat("0", 40)
64+ for range 3 {
65+ got, err := Batch(ctx, dir, []string{absent})
66+ if err != nil {
67+ t.Fatal(err)
68+ }
69+ if got[absent] != nil {
70+ t.Fatal("an object that was never written came back")
71+ }
72+ }
73+ }
74+
75+ // The pool answers in the right order, because one stream out of step corrupts every later read.
76+ func TestManyObjectsComeBackInOrder(t *testing.T) {
77+ if _, err := exec.LookPath("git"); err != nil {
78+ t.Skip("git is not installed")
79+ }
80+ ctx := context.Background()
81+ dir := t.TempDir()
82+ mustGit(t, dir, "init", "-q", "--bare", "-b", "master")
83+
84+ bodies := map[string]string{}
85+ var specs []string
86+ for i := range 200 {
87+ body := strings.Repeat("line\n", i+1)
88+ sha := writeObject(t, dir, body)
89+ bodies[sha] = body
90+ specs = append(specs, sha)
91+ }
92+ // A missing spec in the middle must not shift the answers after it.
93+ specs = append(specs[:100], append([]string{strings.Repeat("0", 40)}, specs[100:]...)...)
94+
95+ got, err := Batch(ctx, dir, specs)
96+ if err != nil {
97+ t.Fatal(err)
98+ }
99+ for sha, want := range bodies {
100+ if got[sha] == nil {
101+ t.Fatalf("%s did not come back", sha)
102+ }
103+ if got[sha].Body != want {
104+ t.Fatalf("%s came back as %d bytes, wanted %d", sha, len(got[sha].Body), len(want))
105+ }
106+ }
107+ }
108+
109+ // Idle readers are closed, or a barerepo with many repositories holds a process for each one.
110+ func TestIdleReadersAreClosed(t *testing.T) {
111+ if _, err := exec.LookPath("git"); err != nil {
112+ t.Skip("git is not installed")
113+ }
114+ // The pool is shared with every other test in this package, so start from none.
115+ CloseIdleReaders(0)
116+ ctx := context.Background()
117+ dir := t.TempDir()
118+ mustGit(t, dir, "init", "-q", "--bare", "-b", "master")
119+ if _, err := Batch(ctx, dir, []string{"HEAD"}); err != nil {
120+ t.Fatal(err)
121+ }
122+ if n := CloseIdleReaders(time.Hour); n != 0 {
123+ t.Errorf("a reader idle for no time was closed")
124+ }
125+ if n := CloseIdleReaders(0); n != 1 {
126+ t.Errorf("closed %d readers, wanted the one in the pool", n)
127+ }
128+ }
129+
130+ func mustGit(t *testing.T, dir string, args ...string) string {
131+ t.Helper()
132+ cmd := exec.Command("git", args...)
133+ cmd.Dir = dir
134+ out, err := cmd.CombinedOutput()
135+ if err != nil {
136+ t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
137+ }
138+ return strings.TrimSpace(string(out))
139+ }
140+
141+ func writeObject(t *testing.T, dir, body string) string {
142+ t.Helper()
143+ cmd := exec.Command("git", "hash-object", "-w", "--stdin")
144+ cmd.Dir = dir
145+ cmd.Stdin = strings.NewReader(body)
146+ out, err := cmd.Output()
147+ if err != nil {
148+ t.Fatal(err)
149+ }
150+ return strings.TrimSpace(string(out))
151+ }
152+
153+ func mktree(t *testing.T, dir, entries string) string {
154+ t.Helper()
155+ cmd := exec.Command("git", "mktree")
156+ cmd.Dir = dir
157+ cmd.Stdin = strings.NewReader(entries)
158+ out, err := cmd.Output()
159+ if err != nil {
160+ t.Fatal(err)
161+ }
162+ return strings.TrimSpace(string(out))
163+ }
164+
165+ // A batch larger than the pipe deadlocks if the specs are written before the answers are read.
166+ func TestABatchLargerThanThePipe(t *testing.T) {
167+ if _, err := exec.LookPath("git"); err != nil {
168+ t.Skip("git is not installed")
169+ }
170+ ctx := context.Background()
171+ dir := t.TempDir()
172+ mustGit(t, dir, "init", "-q", "--bare", "-b", "master")
173+
174+ // Four thousand ids is 164kb of input and megabytes of answer, both well past a 64kb pipe.
175+ bodies := make([]string, 4000)
176+ for i := range bodies {
177+ bodies[i] = strings.Repeat("x", 2048) + strconv.Itoa(i) + "\n"
178+ }
179+ specs := writeObjects(t, dir, bodies)
180+
181+ done := make(chan map[string]*Object, 1)
182+ go func() {
183+ got, err := Batch(ctx, dir, specs)
184+ if err != nil {
185+ t.Error(err)
186+ }
187+ done <- got
188+ }()
189+ select {
190+ case got := <-done:
191+ for i, sha := range specs {
192+ if got[sha] == nil || got[sha].Body != bodies[i] {
193+ t.Fatalf("%s came back wrong", sha)
194+ }
195+ }
196+ case <-time.After(30 * time.Second):
197+ t.Fatal("a batch of 4000 objects never finished, which is the write and read deadlock")
198+ }
199+ }
200+
201+ // writeObjects writes many blobs in one git process, so the setup does not dominate the test.
202+ func writeObjects(t *testing.T, dir string, bodies []string) []string {
203+ t.Helper()
204+ files := t.TempDir()
205+ var paths strings.Builder
206+ for i, body := range bodies {
207+ p := filepath.Join(files, strconv.Itoa(i))
208+ if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
209+ t.Fatal(err)
210+ }
211+ paths.WriteString(p + "\n")
212+ }
213+ cmd := exec.Command("git", "hash-object", "-w", "--stdin-paths")
214+ cmd.Dir = dir
215+ cmd.Stdin = strings.NewReader(paths.String())
216+ out, err := cmd.Output()
217+ if err != nil {
218+ t.Fatal(err)
219+ }
220+ return strings.Fields(string(out))
221+ }
@@ -0,0 +1,352 @@
1+ // Package gitx runs git with argument arrays and never a shell, because ref names are untrusted.
2+ package gitx
3+
4+ import (
5+ "bytes"
6+ "context"
7+ "encoding/hex"
8+ "fmt"
9+ "io"
10+ "io/fs"
11+ "os"
12+ "os/exec"
13+ "path/filepath"
14+ "strconv"
15+ "strings"
16+ )
17+
18+ // Bin is the git executable. Resolved once at start.
19+ var Bin = "git"
20+
21+ // Identity signs the commits the server makes itself, and every install wants its own host in it.
22+ var Identity = "barerepo@localhost"
23+
24+ // Run executes git in dir and returns stdout. Args are passed as an array.
25+ func Run(ctx context.Context, dir string, args ...string) (string, error) {
26+ var out, errb bytes.Buffer
27+ cmd := exec.CommandContext(ctx, Bin, args...)
28+ cmd.Dir = dir
29+ cmd.Stdout = &out
30+ cmd.Stderr = &errb
31+ cmd.Env = env()
32+ if err := cmd.Run(); err != nil {
33+ msg := strings.TrimSpace(errb.String())
34+ if msg == "" {
35+ msg = err.Error()
36+ }
37+ return out.String(), fmt.Errorf("git %s: %s", args[0], msg)
38+ }
39+ return out.String(), nil
40+ }
41+
42+ // RunStdin executes git with input on stdin, which is how a blob is written without a file.
43+ func RunStdin(ctx context.Context, dir, stdin string, args ...string) (string, error) {
44+ var out, errb bytes.Buffer
45+ cmd := exec.CommandContext(ctx, Bin, args...)
46+ cmd.Dir = dir
47+ cmd.Stdin = strings.NewReader(stdin)
48+ cmd.Stdout = &out
49+ cmd.Stderr = &errb
50+ cmd.Env = env()
51+ if err := cmd.Run(); err != nil {
52+ msg := strings.TrimSpace(errb.String())
53+ if msg == "" {
54+ msg = err.Error()
55+ }
56+ return out.String(), fmt.Errorf("git %s: %s", args[0], msg)
57+ }
58+ return out.String(), nil
59+ }
60+
61+ // Pipe wires stdin and stdout to the caller, which is how upload-pack and receive-pack are served.
62+ func Pipe(ctx context.Context, dir string, stdin io.Reader, stdout, stderr io.Writer, extraEnv []string, args ...string) error {
63+ cmd := exec.CommandContext(ctx, Bin, args...)
64+ cmd.Dir = dir
65+ cmd.Stdin = stdin
66+ cmd.Stdout = stdout
67+ cmd.Stderr = stderr
68+ cmd.Env = append(env(), extraEnv...)
69+ return cmd.Run()
70+ }
71+
72+ // env is built from nothing, so a user's own git configuration cannot change what the server reads.
73+ func env() []string {
74+ e := []string{
75+ "GIT_CONFIG_NOSYSTEM=1",
76+ "HOME=/nonexistent",
77+ "GIT_TERMINAL_PROMPT=0",
78+ "PATH=/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin",
79+ "LC_ALL=C",
80+ // git spells a path outside ascii as octal escapes unless told not to, and barerepo parses paths.
81+ "GIT_CONFIG_COUNT=3",
82+ "GIT_CONFIG_KEY_0=core.quotePath",
83+ "GIT_CONFIG_VALUE_0=false",
84+ "GIT_CONFIG_KEY_1=core.fsync",
85+ "GIT_CONFIG_VALUE_1=objects,derived-metadata,reference",
86+ "GIT_CONFIG_KEY_2=core.fsyncMethod",
87+ "GIT_CONFIG_VALUE_2=batch",
88+ // git needs an identity, and chapter 10 gives an account no address to borrow.
89+ "GIT_AUTHOR_NAME=barerepo",
90+ "GIT_AUTHOR_EMAIL=" + Identity,
91+ "GIT_COMMITTER_NAME=barerepo",
92+ "GIT_COMMITTER_EMAIL=" + Identity,
93+ }
94+ for _, k := range passthrough {
95+ if v, ok := os.LookupEnv(k); ok {
96+ e = append(e, k+"="+v)
97+ }
98+ }
99+ return e
100+ }
101+
102+ // passthrough is what lets a hook see the quarantined objects the push is still delivering.
103+ var passthrough = []string{
104+ "GIT_DIR",
105+ "GIT_OBJECT_DIRECTORY",
106+ "GIT_ALTERNATE_OBJECT_DIRECTORIES",
107+ "GIT_QUARANTINE_PATH",
108+ }
109+
110+ // Version fails if git is missing, at start, rather than during a user's first clone.
111+ func Version(ctx context.Context) (string, error) {
112+ path, err := exec.LookPath(Bin)
113+ if err != nil {
114+ return "", fmt.Errorf("git not found in PATH")
115+ }
116+ Bin = path
117+ out, err := Run(ctx, "", "version")
118+ return strings.TrimSpace(out), err
119+ }
120+
121+ // Object is one object read by CatFile.
122+ type Object struct {
123+ SHA string
124+ Type string
125+ Size int64
126+ Body string
127+ }
128+
129+ // CatFile gets the hash, type, size and content in one process, because chapter 25 counts processes.
130+ func CatFile(ctx context.Context, dir, spec string) (*Object, error) {
131+ out, err := RunStdin(ctx, dir, spec+"\n", "cat-file", "--batch")
132+ if err != nil {
133+ return nil, err
134+ }
135+ header, body, found := strings.Cut(out, "\n")
136+ if !found {
137+ return nil, fmt.Errorf("cat-file gave no answer for %q", spec)
138+ }
139+ fields := strings.Fields(header)
140+ if len(fields) < 3 {
141+ // "<spec> missing" is what git says for something that is not there.
142+ return nil, fmt.Errorf("no object at %q", spec)
143+ }
144+ size, err := strconv.ParseInt(fields[2], 10, 64)
145+ if err != nil {
146+ return nil, fmt.Errorf("cat-file gave a size of %q", fields[2])
147+ }
148+ if int64(len(body)) > size {
149+ body = body[:size]
150+ }
151+ return &Object{SHA: fields[0], Type: fields[1], Size: size, Body: body}, nil
152+ }
153+
154+ // HeadBranch reads the HEAD file, because rule 6 says ask the repository, not start a process.
155+ func HeadBranch(dir string) (string, error) {
156+ body, err := os.ReadFile(filepath.Join(dir, "HEAD"))
157+ if err != nil {
158+ return "", err
159+ }
160+ ref, ok := strings.CutPrefix(strings.TrimSpace(string(body)), "ref: refs/heads/")
161+ if !ok {
162+ // An object id means a detached HEAD, so this repository was not made by barerepo.
163+ return "", fmt.Errorf("HEAD is not on a branch")
164+ }
165+ return ref, nil
166+ }
167+
168+ // symbolicDepth bounds the chase, because a ref file pointing at itself is a file somebody wrote.
169+ const symbolicDepth = 5
170+
171+ // ResolveRef reads the ref file or packed-refs, saving the process chapter 25 counts.
172+ func ResolveRef(dir, ref string) (string, error) {
173+ for i := 0; i < symbolicDepth; i++ {
174+ sha, err := resolveOnce(dir, ref)
175+ if err != nil {
176+ return "", err
177+ }
178+ // HEAD holds "ref: refs/heads/master", so returning the file is returning a name, not a hash.
179+ next, symbolic := strings.CutPrefix(sha, "ref: ")
180+ if !symbolic {
181+ return sha, nil
182+ }
183+ ref = strings.TrimSpace(next)
184+ }
185+ return "", fmt.Errorf("%s points through more than %d refs", ref, symbolicDepth)
186+ }
187+
188+ // resolveOnce reads one ref file, following nothing.
189+ func resolveOnce(dir, ref string) (string, error) {
190+ if !ValidRef(ref) {
191+ return "", fmt.Errorf("%q is not a ref name", ref)
192+ }
193+ if body, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(ref))); err == nil {
194+ return strings.TrimSpace(string(body)), nil
195+ }
196+ packed, err := os.ReadFile(filepath.Join(dir, "packed-refs"))
197+ if err != nil {
198+ return "", err
199+ }
200+ for _, line := range strings.Split(string(packed), "\n") {
201+ if line == "" || line[0] == '#' || line[0] == '^' {
202+ continue
203+ }
204+ sha, name, ok := strings.Cut(line, " ")
205+ if ok && name == ref {
206+ return sha, nil
207+ }
208+ }
209+ return "", fmt.Errorf("no ref named %s", ref)
210+ }
211+
212+ // ResolveRefOrAsk falls back to git, because reading the file is wrong inside a worktree.
213+ func ResolveRefOrAsk(ctx context.Context, dir, ref string) (string, error) {
214+ if sha, err := ResolveRef(dir, ref); err == nil {
215+ return sha, nil
216+ }
217+ out, err := Run(ctx, dir, "rev-parse", "--verify", "--quiet", ref)
218+ if err != nil {
219+ return "", err
220+ }
221+ sha := strings.TrimSpace(out)
222+ if sha == "" {
223+ return "", fmt.Errorf("no ref named %s", ref)
224+ }
225+ return sha, nil
226+ }
227+
228+ // TreeEntries reads the blob names and hashes out of a raw tree object.
229+ func TreeEntries(body string) map[string]string {
230+ out := map[string]string{}
231+ for i := 0; i < len(body); {
232+ sp := strings.IndexByte(body[i:], ' ')
233+ nul := strings.IndexByte(body[i:], 0)
234+ if sp < 0 || nul < 0 || nul < sp || i+nul+21 > len(body) {
235+ break
236+ }
237+ name := body[i+sp+1 : i+nul]
238+ out[name] = hex.EncodeToString([]byte(body[i+nul+1 : i+nul+21]))
239+ i += nul + 21
240+ }
241+ return out
242+ }
243+
244+ // AnyRef reports whether one ref exists, from the ref files, because asking git costs a process.
245+ func AnyRef(dir string) bool {
246+ found := false
247+ filepath.WalkDir(filepath.Join(dir, "refs"), func(p string, d fs.DirEntry, err error) error {
248+ if err != nil || d.IsDir() {
249+ return nil
250+ }
251+ body, err := os.ReadFile(p)
252+ if err != nil {
253+ return nil
254+ }
255+ sha := strings.TrimSpace(string(body))
256+ if sha == "" || strings.HasPrefix(sha, "ref: ") {
257+ return nil
258+ }
259+ found = true
260+ return fs.SkipAll
261+ })
262+ if found {
263+ return true
264+ }
265+ body, err := os.ReadFile(filepath.Join(dir, "packed-refs"))
266+ if err != nil {
267+ return false
268+ }
269+ for _, line := range strings.Split(string(body), "\n") {
270+ line = strings.TrimSpace(line)
271+ if line != "" && !strings.HasPrefix(line, "#") && !strings.HasPrefix(line, "^") {
272+ return true
273+ }
274+ }
275+ return false
276+ }
277+
278+ // TreeRow is one entry of a tree object, with the mode, which is the only thing that says tree or blob.
279+ type TreeRow struct {
280+ Mode string
281+ Name string
282+ SHA string
283+ }
284+
285+ // TreeRows reads a raw tree object in order, which is the order git wrote and ls-tree prints.
286+ func TreeRows(body string) []TreeRow {
287+ var out []TreeRow
288+ for i := 0; i < len(body); {
289+ sp := strings.IndexByte(body[i:], ' ')
290+ nul := strings.IndexByte(body[i:], 0)
291+ if sp < 0 || nul < 0 || nul < sp || i+nul+21 > len(body) {
292+ break
293+ }
294+ out = append(out, TreeRow{
295+ Mode: body[i : i+sp],
296+ Name: body[i+sp+1 : i+nul],
297+ SHA: hex.EncodeToString([]byte(body[i+nul+1 : i+nul+21])),
298+ })
299+ i += nul + 21
300+ }
301+ return out
302+ }
303+
304+ // ListRefs reads the refs under a prefix from the filesystem, because refs are files. Chapter 6.
305+ func ListRefs(dir, prefix string) (map[string]string, error) {
306+ if !strings.HasSuffix(prefix, "/") {
307+ prefix += "/"
308+ }
309+ if !ValidRef(strings.TrimSuffix(prefix, "/")) {
310+ return nil, fmt.Errorf("%q is not a ref prefix", prefix)
311+ }
312+ out := map[string]string{}
313+ root := filepath.Join(dir, filepath.FromSlash(strings.TrimSuffix(prefix, "/")))
314+ err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
315+ if err != nil || d.IsDir() {
316+ return nil
317+ }
318+ body, err := os.ReadFile(p)
319+ if err != nil {
320+ return nil
321+ }
322+ sha := strings.TrimSpace(string(body))
323+ // A symbolic ref under a notes prefix is not something barerepo writes, so it is skipped.
324+ if strings.HasPrefix(sha, "ref: ") {
325+ return nil
326+ }
327+ name := prefix + filepath.ToSlash(strings.TrimPrefix(p, root+string(filepath.Separator)))
328+ out[name] = sha
329+ return nil
330+ })
331+ if err != nil && !os.IsNotExist(err) {
332+ return nil, err
333+ }
334+ // packed-refs holds what git gc moved out of the tree, and a loose file wins over it.
335+ packed, err := os.ReadFile(filepath.Join(dir, "packed-refs"))
336+ if err != nil {
337+ return out, nil
338+ }
339+ for _, line := range strings.Split(string(packed), "\n") {
340+ if line == "" || line[0] == '#' || line[0] == '^' {
341+ continue
342+ }
343+ sha, name, ok := strings.Cut(line, " ")
344+ if !ok || !strings.HasPrefix(name, prefix) {
345+ continue
346+ }
347+ if _, loose := out[name]; !loose {
348+ out[name] = sha
349+ }
350+ }
351+ return out, nil
352+ }
@@ -0,0 +1,109 @@
1+ package gitx
2+
3+ import (
4+ "regexp"
5+ "strings"
6+ )
7+
8+ // namePattern is the one rule for account and repository names alike. Chapter 42.5.
9+ var namePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,38}$`)
10+
11+ // reserved holds every top-level route name, which TestEveryTopLevelRouteIsAReservedName derives.
12+ var reserved = map[string]bool{
13+ // routed today, per appendix C
14+ "new": true, "signup": true, "signin": true, "signout": true, "auth": true,
15+ "keys": true, "gpgkeys": true, "tokens": true, "search": true, "inbox": true,
16+ "runner": true, "raw": true,
17+ // held for later
18+ "static": true, "api": true, "admin": true, "about": true, "card": true,
19+ }
20+
21+ // ValidName reports whether s is a usable account name, which a top-level route can shadow.
22+ func ValidName(s string) bool {
23+ return namePattern.MatchString(s) && !reserved[s]
24+ }
25+
26+ // ValidRepoName drops the reserved list, because a repository is a second path segment and every reserved name is a first one.
27+ func ValidRepoName(s string) bool {
28+ return namePattern.MatchString(s)
29+ }
30+
31+ // Reserved reports a name refused only because a route owns it, which signup says out loud.
32+ func Reserved(s string) bool { return reserved[s] }
33+
34+ // ValidRef mirrors check-ref-format and runs first, so nothing malformed reaches an argument.
35+ func ValidRef(r string) bool {
36+ if r == "" || len(r) > 255 {
37+ return false
38+ }
39+ if strings.HasSuffix(r, "/") || strings.HasSuffix(r, ".lock") {
40+ return false
41+ }
42+ if strings.Contains(r, "..") || strings.Contains(r, "@{") || strings.Contains(r, "//") {
43+ return false
44+ }
45+ if strings.ContainsAny(r, " ~^:?*[\\\x7f") {
46+ return false
47+ }
48+ for _, c := range r {
49+ if c < 0x20 {
50+ return false
51+ }
52+ }
53+ for _, part := range strings.Split(r, "/") {
54+ if part == "" || strings.HasPrefix(part, ".") || strings.HasSuffix(part, ".") {
55+ return false
56+ }
57+ // Any component, because git is handed the component alone and a dash makes it a flag.
58+ if strings.HasPrefix(part, "-") {
59+ return false
60+ }
61+ }
62+ return true
63+ }
64+
65+ // ValidRev guards a revision from a URL, and above all stops a leading dash becoming a flag.
66+ func ValidRev(r string) bool {
67+ if r == "" || len(r) > 255 {
68+ return false
69+ }
70+ if strings.HasPrefix(r, "-") || strings.Contains(r, "..") && !strings.Contains(r, "...") {
71+ // Callers build their own ranges, and the compare view splits `...` before it gets here.
72+ return false
73+ }
74+ if strings.Contains(r, "...") {
75+ return false
76+ }
77+ for _, c := range r {
78+ switch {
79+ case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9':
80+ case c == '/' || c == '.' || c == '-' || c == '_' || c == '~' || c == '^' || c == '@':
81+ default:
82+ return false
83+ }
84+ }
85+ for _, part := range strings.Split(r, "/") {
86+ if part == "" || strings.HasPrefix(part, ".") || strings.HasPrefix(part, "-") {
87+ return false
88+ }
89+ }
90+ return true
91+ }
92+
93+ // ValidPath guards a path in a git tree against the same two mistakes: escaping up, and flags.
94+ func ValidPath(p string) bool {
95+ if p == "" || len(p) > 4096 || strings.HasPrefix(p, "-") || strings.HasPrefix(p, "/") {
96+ return false
97+ }
98+ for _, part := range strings.Split(p, "/") {
99+ if part == "" || part == "." || part == ".." {
100+ return false
101+ }
102+ }
103+ for _, c := range p {
104+ if c < 0x20 || c == 0x7f {
105+ return false
106+ }
107+ }
108+ return true
109+ }
@@ -0,0 +1,267 @@
1+ package gitx
2+
3+ import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "os/exec"
8+ "path/filepath"
9+ "strings"
10+ "testing"
11+ )
12+
13+ func TestValidName(t *testing.T) {
14+ good := []string{"john", "j", "lisa-2", "a0", "x" + string(make([]byte, 0))}
15+ for _, s := range good {
16+ if !ValidName(s) {
17+ t.Errorf("ValidName(%q) = false, want true", s)
18+ }
19+ }
20+ bad := []string{
21+ "", "-john", "John", "jo_hn", "j.hn", "../../etc", "j/n",
22+ "admin", "new", "signin", "signup", "keys", "tokens", "auth",
23+ "inbox", "runner", "raw", "search",
24+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", // 41 chars
25+ }
26+ for _, s := range bad {
27+ if ValidName(s) {
28+ t.Errorf("ValidName(%q) = true, want false", s)
29+ }
30+ }
31+
32+ // A repository is a second path segment, so a name a route owns at the top shadows nothing.
33+ for _, s := range []string{"admin", "new", "signin", "runner", "raw", "api", "about"} {
34+ if !ValidRepoName(s) {
35+ t.Errorf("ValidRepoName(%q) = false, want a repository to be allowed that name", s)
36+ }
37+ }
38+ for _, s := range []string{"", "-john", "John", "jo_hn", "j.hn", "../../etc", "j/n",
39+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} {
40+ if ValidRepoName(s) {
41+ t.Errorf("ValidRepoName(%q) = true, want false", s)
42+ }
43+ }
44+ }
45+
46+ func TestValidRef(t *testing.T) {
47+ good := []string{
48+ "refs/heads/master", "refs/heads/main", "refs/proposals/new",
49+ "refs/proposals/7/rev/2", "refs/notes/threads/3", "refs/tags/v1.0.0",
50+ }
51+ for _, s := range good {
52+ if !ValidRef(s) {
53+ t.Errorf("ValidRef(%q) = false, want true", s)
54+ }
55+ }
56+ bad := []string{
57+ "", "--upload-pack=/bin/sh", "refs/heads/--upload-pack=/bin/sh",
58+ "refs/heads/-x", "refs/-/x", "refs/heads/..", "refs/heads/a..b",
59+ "refs/heads/.hidden", "refs/heads/x.lock", "refs/heads/a b",
60+ "refs/heads/a\x00b", "refs/heads/a\nb", "refs/heads//x",
61+ "refs/heads/a~1", "refs/heads/a^", "refs/heads/a:b", "refs/heads/",
62+ }
63+ for _, s := range bad {
64+ if ValidRef(s) {
65+ t.Errorf("ValidRef(%q) = true, want false", s)
66+ }
67+ }
68+ }
69+
70+ func TestValidRev(t *testing.T) {
71+ good := []string{
72+ "master", "main", "trunk", "refs/heads/master", "refs/proposals/47",
73+ "v1.0.0", "a3f9c2", "8b1d44e", "HEAD", "HEAD~2", "master^",
74+ "feature/thing",
75+ }
76+ for _, s := range good {
77+ if !ValidRev(s) {
78+ t.Errorf("ValidRev(%q) = false, want true", s)
79+ }
80+ }
81+ bad := []string{
82+ "", "-master", "--upload-pack=/bin/sh", "master..main", "master...main",
83+ "refs/heads/-x", "master; rm -rf /", "master rm", "a\x00b",
84+ "$(rm -rf /)", "`id`", "refs//x", "refs/.hidden/x",
85+ // reflog syntax reaches unreferenced objects, and has no business arriving from a url.
86+ "HEAD@{0}", "master@{yesterday}",
87+ }
88+ for _, s := range bad {
89+ if ValidRev(s) {
90+ t.Errorf("ValidRev(%q) = true, want false", s)
91+ }
92+ }
93+ }
94+
95+ func TestValidPath(t *testing.T) {
96+ good := []string{"config.go", "irc/conn.go", ".barerepo/config", "a/b/c/d.txt", "README"}
97+ for _, s := range good {
98+ if !ValidPath(s) {
99+ t.Errorf("ValidPath(%q) = false, want true", s)
100+ }
101+ }
102+ bad := []string{"", "/etc/passwd", "../../etc/passwd", "a/../../b", "a//b", "./x", "-rf", "a\x00b"}
103+ for _, s := range bad {
104+ if ValidPath(s) {
105+ t.Errorf("ValidPath(%q) = true, want false", s)
106+ }
107+ }
108+ }
109+
110+ // Batch must skip an object that is not there without losing its place in the stream.
111+ func TestBatchReadsManyObjectsAndAMissingOne(t *testing.T) {
112+ dir := t.TempDir()
113+ for _, args := range [][]string{
114+ {"init", "-q", "--bare", "-b", "master", dir},
115+ } {
116+ if out, err := exec.Command("git", args...).CombinedOutput(); err != nil {
117+ t.Fatalf("git %v: %v: %s", args, err, out)
118+ }
119+ }
120+ ctx := context.Background()
121+ want := map[string]string{}
122+ var specs []string
123+ for _, body := range []string{"one", "two\nlines", "three\x00with a nul"} {
124+ sha, err := RunStdin(ctx, dir, body, "hash-object", "-w", "--stdin")
125+ if err != nil {
126+ t.Fatal(err)
127+ }
128+ sha = strings.TrimSpace(sha)
129+ specs = append(specs, sha)
130+ want[sha] = body
131+ }
132+ // A spec git cannot resolve must not shift the answers that follow it.
133+ specs = append([]string{"0000000000000000000000000000000000000000"}, specs...)
134+
135+ got, err := Batch(ctx, dir, specs)
136+ if err != nil {
137+ t.Fatal(err)
138+ }
139+ if len(got) != len(want) {
140+ t.Fatalf("asked for %d objects and one miss, got %d back", len(want), len(got))
141+ }
142+ for sha, body := range want {
143+ obj := got[sha]
144+ if obj == nil {
145+ t.Fatalf("%s is missing from the batch", sha)
146+ }
147+ if obj.Body != body {
148+ t.Errorf("%s: body is %q, want %q", sha, obj.Body, body)
149+ }
150+ if obj.Type != "blob" {
151+ t.Errorf("%s: type is %q, want blob", sha, obj.Type)
152+ }
153+ }
154+ }
155+
156+ // TreeEntries reads twenty binary bytes per entry, so a drifting read loses all the rest.
157+ func TestTreeEntriesReadsARealTree(t *testing.T) {
158+ dir := t.TempDir()
159+ if out, err := exec.Command("git", "init", "-q", "--bare", "-b", "master", dir).CombinedOutput(); err != nil {
160+ t.Fatalf("git init: %v: %s", err, out)
161+ }
162+ ctx := context.Background()
163+ var mktree strings.Builder
164+ want := map[string]string{}
165+ for _, name := range []string{"meta", "0a1b2c3d", "zz-last"} {
166+ sha, err := RunStdin(ctx, dir, "body of "+name, "hash-object", "-w", "--stdin")
167+ if err != nil {
168+ t.Fatal(err)
169+ }
170+ sha = strings.TrimSpace(sha)
171+ want[name] = sha
172+ fmt.Fprintf(&mktree, "100644 blob %s\t%s\n", sha, name)
173+ }
174+ tree, err := RunStdin(ctx, dir, mktree.String(), "mktree")
175+ if err != nil {
176+ t.Fatal(err)
177+ }
178+ obj, err := Batch(ctx, dir, []string{strings.TrimSpace(tree)})
179+ if err != nil {
180+ t.Fatal(err)
181+ }
182+ got := TreeEntries(obj[strings.TrimSpace(tree)].Body)
183+ if len(got) != len(want) {
184+ t.Fatalf("tree has %d entries, want %d: %v", len(got), len(want), got)
185+ }
186+ for name, sha := range want {
187+ if got[name] != sha {
188+ t.Errorf("%s points at %q, want %q", name, got[name], sha)
189+ }
190+ }
191+ }
192+
193+ // HEAD holds "ref: refs/heads/master", so a resolver that returns the file returns a name, not a hash.
194+ func TestResolveRefFollowsSymbolicRefs(t *testing.T) {
195+ dir := t.TempDir()
196+ sha := "a3f9c2db1e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b"
197+ write := func(path, body string) {
198+ t.Helper()
199+ full := filepath.Join(dir, filepath.FromSlash(path))
200+ if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
201+ t.Fatal(err)
202+ }
203+ if err := os.WriteFile(full, []byte(body), 0o644); err != nil {
204+ t.Fatal(err)
205+ }
206+ }
207+ write("refs/heads/master", sha+"\n")
208+ write("HEAD", "ref: refs/heads/master\n")
209+
210+ for _, ref := range []string{"HEAD", "refs/heads/master"} {
211+ got, err := ResolveRef(dir, ref)
212+ if err != nil {
213+ t.Fatalf("ResolveRef(%s): %v", ref, err)
214+ }
215+ if got != sha {
216+ t.Errorf("ResolveRef(%s) = %q, want the hash %q", ref, got, sha)
217+ }
218+ }
219+
220+ // A ref file pointing at itself is a file somebody wrote, and it must not spin.
221+ write("refs/heads/loop", "ref: refs/heads/loop\n")
222+ if _, err := ResolveRef(dir, "refs/heads/loop"); err == nil {
223+ t.Error("a self-referential ref resolved instead of erroring")
224+ }
225+ }
226+
227+ // A repository with no refs is the empty page, and asking git that question cost a whole process.
228+ func TestAnyRefSeesLooseAndPackedRefs(t *testing.T) {
229+ if _, err := exec.LookPath("git"); err != nil {
230+ t.Skip("git is not installed")
231+ }
232+ dir := t.TempDir()
233+ mustRun(t, "", "git", "init", "-q", "--bare", "-b", "master", dir)
234+ if AnyRef(dir) {
235+ t.Fatal("a fresh bare repository reported a ref")
236+ }
237+
238+ work := t.TempDir()
239+ mustRun(t, "", "git", "init", "-q", "-b", "master", work)
240+ if err := os.WriteFile(filepath.Join(work, "f"), []byte("hi\n"), 0o644); err != nil {
241+ t.Fatal(err)
242+ }
243+ mustRun(t, work, "git", "add", "-A")
244+ mustRun(t, work, "git", "-c", "user.email=t@x", "-c", "user.name=t", "commit", "-qm", "first")
245+ mustRun(t, work, "git", "push", "-q", dir, "master")
246+ if !AnyRef(dir) {
247+ t.Fatal("a pushed branch was not seen as a ref")
248+ }
249+
250+ // git gc moves loose refs into packed-refs, and a repository is not empty because of that.
251+ mustRun(t, dir, "git", "pack-refs", "--all")
252+ if _, err := os.Stat(filepath.Join(dir, "refs", "heads", "master")); err == nil {
253+ t.Skip("this git kept the loose ref, so the packed path is untested here")
254+ }
255+ if !AnyRef(dir) {
256+ t.Fatal("a packed ref was not seen, so a full repository draws the empty page")
257+ }
258+ }
259+
260+ func mustRun(t *testing.T, dir, name string, args ...string) {
261+ t.Helper()
262+ cmd := exec.Command(name, args...)
263+ cmd.Dir = dir
264+ if out, err := cmd.CombinedOutput(); err != nil {
265+ t.Fatalf("%s %v: %v\n%s", name, args, err, out)
266+ }
267+ }
@@ -0,0 +1,120 @@
1+ package hook
2+
3+ import (
4+ "context"
5+ "fmt"
6+ "sort"
7+ "strconv"
8+ "strings"
9+
10+ "github.com/barerepo/server/internal/gitx"
11+ )
12+
13+ // blob is one object a push adds, with the path it takes in the tree.
14+ type blob struct {
15+ Path string
16+ Size int64
17+ }
18+
19+ // pushed is what one update adds, which both limits in chapter 20.2 are measured against.
20+ type pushed struct {
21+ Over []blob
22+ Bytes int64
23+ }
24+
25+ // inspect reads the pushed range once since both limits ask the same objects, and counted holds what earlier refs added, because 20.2's push limit is one number for the whole push.
26+ func inspect(ctx context.Context, dir string, u Update, blobLimitMB int, counted map[string]bool) pushed {
27+ if u.Deleting() || !gitx.ValidRev(u.New) {
28+ return pushed{}
29+ }
30+ limit := int64(blobLimitMB) << 20
31+
32+ // The pushed range, not the whole repository: --all still holds the old tips inside a hook.
33+ listed, err := gitx.Run(ctx, dir, "rev-list", "--objects", u.New, "--not", "--all")
34+ if err != nil {
35+ return pushed{}
36+ }
37+ path := map[string]string{}
38+ ids := make([]string, 0, 64)
39+ for _, line := range strings.Split(strings.TrimRight(listed, "\n"), "\n") {
40+ id, name, ok := strings.Cut(line, " ")
41+ if !ok || len(id) < 40 {
42+ continue
43+ }
44+ if counted[id] {
45+ continue
46+ }
47+ counted[id] = true
48+ path[id] = name
49+ ids = append(ids, id)
50+ }
51+ if len(ids) == 0 {
52+ return pushed{}
53+ }
54+
55+ checked, err := gitx.RunStdin(ctx, dir, strings.Join(ids, "\n")+"\n", "cat-file", "--batch-check")
56+ if err != nil {
57+ return pushed{}
58+ }
59+ var out pushed
60+ for _, line := range strings.Split(strings.TrimRight(checked, "\n"), "\n") {
61+ f := strings.Fields(line)
62+ if len(f) != 3 {
63+ continue
64+ }
65+ n, err := strconv.ParseInt(f[2], 10, 64)
66+ if err != nil {
67+ continue
68+ }
69+ out.Bytes += n
70+ if f[1] == "blob" && blobLimitMB > 0 && n > limit {
71+ out.Over = append(out.Over, blob{Path: path[f[0]], Size: n})
72+ }
73+ }
74+ // The path settles a tie, or two files of one size take turns being named, and chapter 20.2 wants the rejection to name the file.
75+ sort.Slice(out.Over, func(i, j int) bool {
76+ if out.Over[i].Size == out.Over[j].Size {
77+ return out.Over[i].Path < out.Over[j].Path
78+ }
79+ return out.Over[i].Size > out.Over[j].Size
80+ })
81+ return out
82+ }
83+
84+ // tooMuch is the whole push, which config calls loose because a first import is what meets it.
85+ func tooMuch(bytes int64, limitMB int) string {
86+ return fmt.Sprintf("this push adds %s of objects. the limit is %dmb.\n"+
87+ "push it in parts, or ask whoever runs this barerepo to raise [limits] max_push_mb.",
88+ blobSize(bytes), limitMB)
89+ }
90+
91+ // tooBig is chapter 20.2's message, which names the file, because "push too large" sends a user hunting.
92+ func tooBig(over []blob, limitMB int) string {
93+ var b strings.Builder
94+ fmt.Fprintf(&b, "%s is %s. the limit is %dmb.\n", over[0].Path, blobSize(over[0].Size), limitMB)
95+ if len(over) > 1 {
96+ fmt.Fprintf(&b, "%d more files in this push are over it too.\n", len(over)-1)
97+ }
98+ b.WriteString("\nlarge files belong in object storage, with a url or a checksum in the\n")
99+ b.WriteString("repository. the build fetches them.")
100+ return b.String()
101+ }
102+
103+ func blobSize(n int64) string {
104+ switch {
105+ case n < 1024:
106+ return fmt.Sprintf("%db", n)
107+ case n < 1024*1024:
108+ return blobUnit(float64(n)/1024, "kb")
109+ default:
110+ return blobUnit(float64(n)/(1024*1024), "mb")
111+ }
112+ }
113+
114+ // blobUnit writes a size the way the pages do, so 88kb is not 88.0kb here, and it is written twice because the hook binary cannot reach httpd.
115+ func blobUnit(v float64, suffix string) string {
116+ if v < 10 {
117+ return fmt.Sprintf("%.1f%s", v, suffix)
118+ }
119+ return fmt.Sprintf("%.0f%s", v, suffix)
120+ }
@@ -0,0 +1,854 @@
1+ // Package hook is what git runs around a push, and its stderr is the pusher's terminal.
2+ package hook
3+
4+ import (
5+ "bufio"
6+ "bytes"
7+ "context"
8+ "errors"
9+ "fmt"
10+ "io"
11+ "net/url"
12+ "os"
13+ "strings"
14+ "time"
15+
16+ "github.com/barerepo/server/internal/config"
17+ "github.com/barerepo/server/internal/gitx"
18+ "github.com/barerepo/server/internal/proposal"
19+ "github.com/barerepo/server/internal/repo"
20+ "github.com/barerepo/server/internal/repocfg"
21+ "github.com/barerepo/server/internal/run"
22+ "github.com/barerepo/server/internal/search"
23+ "github.com/barerepo/server/internal/store"
24+ "github.com/barerepo/server/internal/thread"
25+ "github.com/barerepo/server/internal/workflow"
26+ )
27+
28+ // ErrRejected means the reason is already in the pusher's terminal, so the caller adds nothing.
29+ var ErrRejected = errors.New("rejected")
30+
31+ // reject explains and refuses, in that order.
32+ func reject(out io.Writer, format string, args ...any) error {
33+ fmt.Fprintf(out, format+"\n", args...)
34+ return ErrRejected
35+ }
36+
37+ // Update is one line of a hook's stdin: <old-sha> <new-sha> <refname>.
38+ type Update struct {
39+ Old, New, Ref string
40+ }
41+
42+ // Zero is the all-zero object id git uses for "did not exist" and "deleted".
43+ const Zero = "0000000000000000000000000000000000000000"
44+
45+ func (u Update) Creating() bool { return strings.Trim(u.Old, "0") == "" }
46+ func (u Update) Deleting() bool { return strings.Trim(u.New, "0") == "" }
47+
48+ // Env is what ssh and http set before git-receive-pack, and git passes it to the hooks.
49+ type Env struct {
50+ Account string // who is pushing. empty is impossible on a write.
51+ Owner string
52+ Name string
53+ Dir string
54+ URL string // where this repository is on the web
55+ Created bool // this push created the repository
56+ // Config is where the server read its configuration, which a stripped hook cannot find alone.
57+ Config string
58+ }
59+
60+ func EnvFromOS() Env {
61+ dir, _ := os.Getwd()
62+ return Env{
63+ Account: os.Getenv("BAREREPO_ACCOUNT"),
64+ Owner: os.Getenv("BAREREPO_OWNER"),
65+ Name: os.Getenv("BAREREPO_NAME"),
66+ URL: os.Getenv("BAREREPO_URL"),
67+ Created: os.Getenv("BAREREPO_CREATED") == "1",
68+ Config: os.Getenv("BAREREPO_CONFIG"),
69+ Dir: dir,
70+ }
71+ }
72+
73+ // Vars renders an Env for exec.Cmd. git passes these through to the hooks.
74+ func (e Env) Vars() []string {
75+ created := "0"
76+ if e.Created {
77+ created = "1"
78+ }
79+ return []string{
80+ "BAREREPO_ACCOUNT=" + e.Account,
81+ "BAREREPO_OWNER=" + e.Owner,
82+ "BAREREPO_NAME=" + e.Name,
83+ "BAREREPO_URL=" + e.URL,
84+ "BAREREPO_CREATED=" + created,
85+ "BAREREPO_CONFIG=" + e.Config,
86+ }
87+ }
88+
89+ // ReadUpdates parses a hook's stdin.
90+ func ReadUpdates(r io.Reader) ([]Update, error) {
91+ var out []Update
92+ sc := bufio.NewScanner(r)
93+ for sc.Scan() {
94+ f := strings.Fields(sc.Text())
95+ if len(f) != 3 {
96+ return nil, fmt.Errorf("malformed hook input")
97+ }
98+ out = append(out, Update{Old: f[0], New: f[1], Ref: f[2]})
99+ }
100+ return out, sc.Err()
101+ }
102+
103+ // PreReceive decides the whole push, all or nothing, because a half push is harder to reason about.
104+ func PreReceive(ctx context.Context, e Env, ups []Update, out io.Writer) error {
105+ // Chapter 11: typos create repositories, so print the URL where the mistake is visible.
106+ if e.Created {
107+ fmt.Fprintf(out, "\ncreated %s/%s. it is private.\n", e.Owner, e.Name)
108+ if e.URL != "" {
109+ fmt.Fprintf(out, " %s\n", e.URL)
110+ }
111+ fmt.Fprintf(out, " if that name is a typo, delete it on the config page.\n\n")
112+ }
113+
114+ cfg, cfgErr := repocfg.Load(ctx, e.Dir)
115+ if cfgErr != nil {
116+ // A malformed file must not lock anyone out. Chapter 14.
117+ fmt.Fprintf(out, "warning: %s does not parse\n", repocfg.Path)
118+ fmt.Fprintf(out, "warning: %v\n", cfgErr)
119+ if cfg.FellBackTo != "" {
120+ fmt.Fprintf(out, "warning: the settings from %s are still in force\n", short(cfg.FellBackTo))
121+ } else {
122+ fmt.Fprintf(out, "warning: no earlier version parses either, so the defaults are in force\n")
123+ }
124+ }
125+
126+ // Judged once, and the owner is exempt because unarchiving arrives by push. Chapter 21.3.
127+ if cfg.Repo.Archived && e.Account != e.Owner {
128+ return reject(out, "this repository is archived.\nthe owner can unarchive it in %s", repocfg.Path)
129+ }
130+
131+ head, err := repo.HeadBranch(ctx, e.Dir)
132+ if err != nil {
133+ // An empty repository's HEAD points nowhere until the first push gives it something.
134+ head = ""
135+ }
136+
137+ // A typo is best reported by the push that makes it, not by whoever pushes next. Chapter 14.
138+ for _, u := range ups {
139+ if head == "" || u.Ref != "refs/heads/"+head || u.New == Zero {
140+ continue
141+ }
142+ if err := repocfg.ParseAt(ctx, e.Dir, u.New); err != nil {
143+ fmt.Fprintf(out, "warning: this push leaves %s so it does not parse\n", repocfg.Path)
144+ fmt.Fprintf(out, "warning: %v\n", err)
145+ fmt.Fprintf(out, "warning: the settings in force do not change until it parses again\n")
146+ }
147+ }
148+
149+ // The owner may write any ref they own, or chapter 40.3's `git push --mirror` cannot restore.
150+ restoring := e.Account != "" && e.Account == e.Owner
151+
152+ // Chapter 20.2's push limit is one number for the whole push, so every ref's objects are summed and counted once, or a tag beside its branch is charged twice.
153+ counted := map[string]bool{}
154+ // A commit reachable from two refs in one push is read once, the same as the byte count above.
155+ signedSeen := map[string]bool{}
156+ // A first commit has no parent to rebase onto, and the printed command has to be the one that works.
157+ signedRoot := map[string]bool{}
158+ var adding int64
159+
160+ for _, u := range ups {
161+ if !gitx.ValidRef(u.Ref) {
162+ return reject(out, "%s is not a usable ref name", u.Ref)
163+ }
164+ // Chapter 20.2: turning LFS off does not hold without a limit, and the limit names the file.
165+ if Limits.MaxBlobMB > 0 || Limits.MaxPushMB > 0 {
166+ adds := inspect(ctx, e.Dir, u, Limits.MaxBlobMB, counted)
167+ if len(adds.Over) > 0 {
168+ return reject(out, "%s", tooBig(adds.Over, Limits.MaxBlobMB))
169+ }
170+ adding += adds.Bytes
171+ if Limits.MaxPushMB > 0 && adding > int64(Limits.MaxPushMB)<<20 {
172+ return reject(out, "%s", tooMuch(adding, Limits.MaxPushMB))
173+ }
174+ }
175+
176+ if restoring && !strings.HasPrefix(u.Ref, "refs/heads/") {
177+ // Branches still meet the rules below, which protect the owner from themselves.
178+ continue
179+ }
180+ switch {
181+ case u.Ref == proposal.NewRef || proposal.Number(u.Ref) > 0:
182+ // proc-receive owns this namespace, so only ask whether the account may propose.
183+ if !cfg.MayPropose(e.Owner, e.Account) {
184+ return reject(out, "this repository does not accept proposals from you")
185+ }
186+ // Chapter 27: rule 5 is an open door, and the cap is how it is defended without closing.
187+ if u.Ref == proposal.NewRef && Limits.MaxOpenProposals > 0 {
188+ open := thread.OpenProposalsBy(ctx, e.Dir, e.Account)
189+ if open >= Limits.MaxOpenProposals {
190+ return reject(out,
191+ "you have %d proposals open on %s/%s. the limit is %d.\n"+
192+ "land or close one, then push this again.",
193+ open, e.Owner, e.Name, Limits.MaxOpenProposals)
194+ }
195+ }
196+
197+ case strings.HasPrefix(u.Ref, "refs/heads/"), strings.HasPrefix(u.Ref, "refs/tags/"):
198+ // An official repository distributes what it holds, so every commit that lands in one is signed. Nothing else on the server is affected.
199+ if cfg.Access.RequireSigned && !u.Deleting() {
200+ if bad := unsignedIn(ctx, e.Dir, u, signedSeen, signedRoot); len(bad) > 0 {
201+ return refuseUnsigned(out, u.Ref, bad, signedRoot)
202+ }
203+ }
204+ if !cfg.MayPush(e.Owner, e.Account) {
205+ // The push-rejected page's wording, so the terminal and the page never differ.
206+ fmt.Fprintf(out, "you pushed to %s\n", u.Ref)
207+ fmt.Fprintf(out, "%s [access] push = %s · you are %s\n",
208+ repocfg.Path, repocfg.List(cfg.Access.Push), repocfg.Who(e.Account))
209+ fmt.Fprintf(out, "\npush here instead. it needs no permission.\n")
210+ fmt.Fprintf(out, "\n git push origin HEAD:refs/proposals/new\n")
211+ // Chapter 24: terminals scroll, so the page is only reachable if the hook prints it.
212+ if e.URL != "" {
213+ // The commit and the moment travel with the link, because push-rejected.html heads the page with them and the request knows neither.
214+ fmt.Fprintf(out, "\n %s/rejected?ref=%s&sha=%s&at=%d\n",
215+ e.URL, url.QueryEscape(u.Ref), u.New, time.Now().Unix())
216+ }
217+ return ErrRejected
218+ }
219+ branch := strings.TrimPrefix(u.Ref, "refs/heads/")
220+ if strings.HasPrefix(u.Ref, "refs/heads/") && head != "" {
221+ if u.Deleting() && !cfg.DeleteAllowed(branch, head) {
222+ return reject(out, "%s is the default branch and cannot be deleted", branch)
223+ }
224+ if !u.Deleting() && !u.Creating() {
225+ forced, err := isForce(ctx, e.Dir, u)
226+ if err != nil {
227+ return err
228+ }
229+ if forced && !cfg.ForcePushAllowed(branch, head) {
230+ return reject(out,
231+ "force-push to %s, the default branch, would destroy other people's work.\n"+
232+ "allow it in %s under [access] allow_force_push if you mean it.",
233+ branch, repocfg.Path)
234+ }
235+ }
236+ // Chapter 37.4: the default branch takes a commit only after the named runs pass.
237+ if !u.Deleting() && branch == head && e.Account != e.Owner {
238+ missing := runsMissing(ctx, e.Dir, u.New, cfg.Proposals.RequireRuns)
239+ if len(missing) > 0 {
240+ fmt.Fprintf(out, "%s takes a commit only after %s passes\n",
241+ branch, repocfg.List(cfg.Proposals.RequireRuns))
242+ fmt.Fprintf(out, "%s [proposals] require_runs · %s has not passed on %s\n",
243+ repocfg.Path, strings.Join(missing, ", "), short(u.New))
244+ fmt.Fprintf(out, "\npush the commit as a proposal and it builds there first.\n")
245+ fmt.Fprintf(out, "\n git push origin HEAD:refs/proposals/new\n")
246+ // The runs page is the only place that says why a build has not passed.
247+ if e.URL != "" {
248+ fmt.Fprintf(out, "\n %s/runs\n", e.URL)
249+ }
250+ return ErrRejected
251+ }
252+ }
253+ }
254+
255+ case strings.HasPrefix(u.Ref, "refs/notes/threads/"):
256+ if !cfg.MayRead(e.Owner, e.Account) {
257+ return reject(out, "you cannot read %s/%s", e.Owner, e.Name)
258+ }
259+ // Chapter 13 only ever appends comment records, so a push missing one is dropping it, and a fast-forward proves nothing because a commit can keep the parent and hand back an emptier tree.
260+ if !u.Creating() && !cfg.MayPush(e.Owner, e.Account) {
261+ kept := thread.RecordsAt(ctx, e.Dir, u.New)
262+ added := thread.OnlyAppends(ctx, e.Dir, u.Old, u.New)
263+ for record := range thread.RecordsAt(ctx, e.Dir, u.Old) {
264+ if kept[record] || added {
265+ continue
266+ }
267+ n := thread.NumberOf(u.Ref)
268+ // Chapter 35.5 already answers this, so the answer is what it prints.
269+ return reject(out,
270+ "thread %d holds replies this push does not, and landing it would drop them.\n"+
271+ "take them first, then write yours after them.\n\n"+
272+ " git fetch origin %s\n"+
273+ " git update-ref %s FETCH_HEAD\n"+
274+ " git notes --ref=threads/%d append -m \"...\"\n"+
275+ " git push origin %s",
276+ n, u.Ref, u.Ref, n, u.Ref)
277+ }
278+ }
279+ // Chapter 12 reads a proposal's author out of the meta blob, so a reader who may comment must not be able to rewrite whose thread it is.
280+ if before, had := thread.MetaAt(ctx, e.Dir, u.Old); had {
281+ after, _ := thread.MetaAt(ctx, e.Dir, u.New)
282+ if after.Render() != before.Render() &&
283+ e.Account != before.Author && !cfg.MayPush(e.Owner, e.Account) {
284+ // Chapter 35.5 pushes comments from a clone, and nothing there touches meta.
285+ return reject(out,
286+ "the meta blob records who opened thread %d and where it stands.\n"+
287+ "a push here adds comments; the thread page changes the rest.",
288+ thread.NumberOf(u.Ref))
289+ }
290+ } else if after, made := thread.MetaAt(ctx, e.Dir, u.New); made {
291+ // The first meta names whoever pushed it, and the owner restoring a mirror carries everyone's threads, which chapter 40.3 needs.
292+ if after.Author != "" && after.Author != e.Account &&
293+ !cfg.MayPush(e.Owner, e.Account) {
294+ return reject(out,
295+ "thread %d would open as %s, and this push is %s.\n"+
296+ "open it as yourself, or ask somebody who may push here.",
297+ thread.NumberOf(u.Ref), after.Author, e.Account)
298+ }
299+ }
300+
301+ case strings.HasPrefix(u.Ref, "refs/notes/runs"):
302+ // Chapter 18: the server writes these, through job completion.
303+ return reject(out, "build results are written by the server")
304+
305+ default:
306+ return reject(out, "%s is not a namespace you can write", u.Ref)
307+ }
308+ }
309+ return nil
310+ }
311+
312+ // unsignedIn names the commits this push adds that carry no signature, newest first.
313+ func unsignedIn(ctx context.Context, dir string, u Update, seen, rooted map[string]bool) []string {
314+ // What the ref gains, not what the repository gains, or a commit parked on a proposal ref lands here unread.
315+ args := []string{"rev-list", u.New, "--not", u.Old}
316+ if u.Creating() {
317+ args = []string{"rev-list", u.New}
318+ }
319+ out, err := gitx.Run(ctx, dir, args...)
320+ if err != nil {
321+ return nil
322+ }
323+ var specs []string
324+ for _, sha := range strings.Fields(out) {
325+ if seen[sha] {
326+ continue
327+ }
328+ seen[sha] = true
329+ specs = append(specs, sha)
330+ }
331+ if len(specs) == 0 {
332+ return nil
333+ }
334+ // One process for the whole push, because a signature is a header on the object and not a check.
335+ objs, err := gitx.Batch(ctx, dir, specs)
336+ if err != nil {
337+ return nil
338+ }
339+ good := verified(ctx, dir, specs)
340+ var bad []string
341+ for _, sha := range specs {
342+ obj := objs[sha]
343+ if obj == nil || !hasSignature(obj.Body) || (good != nil && !good[sha]) {
344+ bad = append(bad, sha)
345+ rooted[sha] = obj != nil && !hasParent(obj.Body)
346+ }
347+ }
348+ return bad
349+ }
350+
351+ // AllowedSigners names the keys a commit signature is checked against, and empty means the header is all that is read.
352+ var AllowedSigners string
353+
354+ // Keyring is where published gpg keys live, so a gpg signature is checked the same way an ssh one is.
355+ var Keyring string
356+
357+ // verified asks git which of these signatures hold, and answers nil when nothing can check them.
358+ func verified(ctx context.Context, dir string, specs []string) map[string]bool {
359+ if AllowedSigners == "" {
360+ return nil
361+ }
362+ if _, err := os.Stat(AllowedSigners); err != nil {
363+ // Every commit fails, because a repository that asked for signatures must not quietly stop checking them.
364+ return map[string]bool{}
365+ }
366+ // The revisions go in on stdin, because a push of ten thousand commits is a command line too long to run.
367+ args := []string{"-c", "gpg.ssh.allowedSignersFile=" + AllowedSigners,
368+ "log", "--no-walk", "--format=%H %G?", "--stdin"}
369+ var out, errb bytes.Buffer
370+ extra := []string{}
371+ if Keyring != "" {
372+ if _, err := os.Stat(Keyring); err == nil {
373+ extra = append(extra, "GNUPGHOME="+Keyring)
374+ }
375+ }
376+ in := strings.NewReader(strings.Join(specs, "\n") + "\n")
377+ if err := gitx.Pipe(ctx, dir, in, &out, &errb, extra, args...); err != nil {
378+ // Refusing, because a check that failed to run has not said the signatures are good.
379+ return map[string]bool{}
380+ }
381+ good := map[string]bool{}
382+ for _, line := range strings.Split(out.String(), "\n") {
383+ sha, flag, ok := strings.Cut(strings.TrimSpace(line), " ")
384+ if !ok {
385+ continue
386+ }
387+ // G is a good signature by a key on the list, and every other letter is a reason to refuse.
388+ good[sha] = flag == "G"
389+ }
390+ return good
391+ }
392+
393+ // hasParent tells a first commit from the rest, because a rebase onto a first commit needs --root.
394+ func hasParent(body string) bool {
395+ for _, line := range strings.Split(body, "\n") {
396+ if line == "" {
397+ return false
398+ }
399+ if strings.HasPrefix(line, "parent ") {
400+ return true
401+ }
402+ }
403+ return false
404+ }
405+
406+ // hasSignature looks for the header git writes for both gpg and ssh signatures.
407+ func hasSignature(body string) bool {
408+ for _, line := range strings.Split(body, "\n") {
409+ if line == "" {
410+ // The headers end at the first blank line, and the message can say anything it likes.
411+ return false
412+ }
413+ if strings.HasPrefix(line, "gpgsig") {
414+ return true
415+ }
416+ }
417+ return false
418+ }
419+
420+ // refuseUnsigned prints what is unsigned and the commands that fix it.
421+ func refuseUnsigned(out io.Writer, ref string, bad []string, rooted map[string]bool) error {
422+ fmt.Fprintf(out, "\n%s takes only signed commits.\n", ref)
423+ fmt.Fprintf(out, "%s [access] require_signed_commits = true\n\n", repocfg.Path)
424+ if _, err := os.Stat(AllowedSigners); AllowedSigners != "" && err != nil {
425+ fmt.Fprintf(out, "\n%s takes only signed commits, and this server cannot check a signature.\n", ref)
426+ fmt.Fprintf(out, "the keys it checks against are missing. an operator fixes it with:\n\n")
427+ fmt.Fprintf(out, " barerepo doctor\n")
428+ return ErrRejected
429+ }
430+ if len(bad) == 1 {
431+ fmt.Fprintf(out, "one commit in this push is not signed by a key barerepo holds:\n")
432+ } else {
433+ fmt.Fprintf(out, "%d commits in this push are not signed by a key barerepo holds:\n", len(bad))
434+ }
435+ for i, sha := range bad {
436+ if i == 5 {
437+ fmt.Fprintf(out, " and %d more\n", len(bad)-5)
438+ break
439+ }
440+ fmt.Fprintf(out, " %s\n", short(sha))
441+ }
442+ fmt.Fprintf(out, "\nsign what you push from now on:\n\n")
443+ fmt.Fprintf(out, " git config gpg.format ssh\n")
444+ fmt.Fprintf(out, " git config user.signingkey ~/.ssh/id_ed25519.pub\n")
445+ fmt.Fprintf(out, " git config commit.gpgsign true\n")
446+ fmt.Fprintf(out, "\nthen sign the ones you already wrote:\n\n")
447+ oldest := bad[len(bad)-1]
448+ onto := short(oldest) + "~1"
449+ if rooted[oldest] {
450+ onto = "--root"
451+ }
452+ fmt.Fprintf(out, " git rebase --exec \"git commit --amend --no-edit -S\" %s\n", onto)
453+ return ErrRejected
454+ }
455+
456+ // runsMissing names the required runs that have not passed on one commit, in the configured order.
457+ func runsMissing(ctx context.Context, dir, sha string, want []string) []string {
458+ if len(want) == 0 {
459+ return nil
460+ }
461+ recs, err := run.For(ctx, dir, sha)
462+ if err != nil {
463+ // A commit with no notes has no runs, which is exactly the case this rule exists for.
464+ return want
465+ }
466+ passed := make(map[string]bool, len(recs))
467+ for _, rec := range recs {
468+ if !rec.Failed() {
469+ passed[rec.Name] = true
470+ }
471+ }
472+ var missing []string
473+ for _, name := range want {
474+ if !passed[name] {
475+ missing = append(missing, name)
476+ }
477+ }
478+ return missing
479+ }
480+
481+ // isForce reports whether this update drops commits, which a fast-forward never does.
482+ func isForce(ctx context.Context, dir string, u Update) (bool, error) {
483+ _, err := gitx.Run(ctx, dir, "merge-base", "--is-ancestor", u.Old, u.New)
484+ if err == nil {
485+ return false, nil
486+ }
487+ // merge-base exits 1 for "not an ancestor", and a missing object is the empty repository.
488+ if _, e := gitx.Run(ctx, dir, "cat-file", "-e", u.Old+"^{commit}"); e != nil {
489+ return false, nil
490+ }
491+ return true, nil
492+ }
493+
494+ // PostReceive runs after the refs moved and can refuse nothing.
495+ func PostReceive(ctx context.Context, e Env, ups []Update, out io.Writer) error {
496+ if err := adoptHead(ctx, e, ups); err != nil {
497+ return err
498+ }
499+ followDefaultBranch(ctx, e, out)
500+ if err := closeMergedProposals(ctx, e, ups, out); err != nil {
501+ return err
502+ }
503+ recordPushes(ctx, e, ups)
504+ repairNotes(ctx, e, ups)
505+ recordNotes(ctx, e, ups)
506+ indexPush(ctx, e, ups)
507+ return queueBuilds(ctx, e, ups, out)
508+ }
509+
510+ // Docs is the search index, set once the database is open, and no index means search finds nothing.
511+ var Docs search.Index
512+
513+ // indexPush keeps chapter 17's index current, and a failure here must never fail the push.
514+ func indexPush(ctx context.Context, e Env, ups []Update) {
515+ if Docs == nil {
516+ return
517+ }
518+ branch, err := repo.HeadBranch(ctx, e.Dir)
519+ if err != nil {
520+ return
521+ }
522+ // A file that will not parse still has a fallback, and chapter 14 will not let a typo take the repository out of search until somebody notices.
523+ cfg, _ := repocfg.Load(ctx, e.Dir)
524+ t := search.Target{Owner: e.Owner, Name: e.Name, Dir: e.Dir, Ref: branch, Config: cfg}
525+ if err := search.IndexMeta(ctx, Docs, t); err != nil {
526+ fmt.Fprintf(os.Stderr, "barerepo: index: %v\n", err)
527+ }
528+ // Code lives at the tip of the default branch, so no other ref changes what a code search finds.
529+ head := "refs/heads/" + branch
530+ for _, u := range ups {
531+ if u.Ref == head && !u.Deleting() {
532+ if err := search.IndexPush(ctx, Docs, t, u.Old, u.New); err != nil {
533+ fmt.Fprintf(os.Stderr, "barerepo: index: %v\n", err)
534+ }
535+ return
536+ }
537+ }
538+ }
539+
540+ // Queuer is what post-receive needs from the database, passed in because the hook is its own process.
541+ type Queuer interface {
542+ QueueJob(ctx context.Context, repo, ref, sha, command, image string) (int64, error)
543+ QueueJobFor(ctx context.Context, repo, ref, sha, command, image string, labels []string, name string) (int64, error)
544+ RunnersOf(ctx context.Context, repo string) ([]store.Runner, error)
545+ Record(ctx context.Context, e store.Event) error
546+ TookPart(ctx context.Context, account, repo string, number int) error
547+ }
548+
549+ // Queue is set once the database is open, and no queue means builds are switched off.
550+ var Queue Queuer
551+
552+ // Limits is the server's limits, set once its config is read, because a hook is its own process.
553+ var Limits config.Limits
554+
555+ // queueBuilds makes a job per new tip, from [build] command or from a workflow. Chapters 15 and 15A.
556+ func queueBuilds(ctx context.Context, e Env, ups []Update, out io.Writer) error {
557+ if Queue == nil {
558+ return nil
559+ }
560+ repo := e.Owner + "/" + e.Name
561+ cfg, _ := repocfg.Load(ctx, e.Dir)
562+ command := strings.TrimSpace(cfg.Build.Command)
563+
564+ for _, u := range ups {
565+ if u.Deleting() || strings.HasPrefix(u.Ref, "refs/notes/") {
566+ continue
567+ }
568+ // The repository's own file wins, because it is barerepo's own answer and it is one command.
569+ if command != "" {
570+ if _, err := Queue.QueueJob(ctx, repo, u.Ref, u.New, command, cfg.Build.Image); err != nil {
571+ fmt.Fprintf(out, "could not queue a build for %s: %v\n", u.Ref, err)
572+ continue
573+ }
574+ fmt.Fprintf(out, "queued a build for %s\n", u.Ref)
575+ continue
576+ }
577+ queueWorkflows(ctx, e, repo, u, out)
578+ }
579+ return nil
580+ }
581+
582+ // queueWorkflows runs what it can of .github/workflows, and says plainly what it cannot. Chapter 15A.
583+ func queueWorkflows(ctx context.Context, e Env, repo string, u Update, out io.Writer) {
584+ jobs, err := workflow.Load(ctx, e.Dir, u.New, workflow.Context{Repo: repo, Ref: u.Ref, SHA: u.New})
585+ if err != nil || len(jobs) == 0 {
586+ return
587+ }
588+ runners, err := Queue.RunnersOf(ctx, repo)
589+ if err != nil {
590+ runners = nil
591+ }
592+ for _, j := range jobs {
593+ for _, s := range j.Skipped {
594+ // Never a quiet omission, because a green build has to mean what it says.
595+ fmt.Fprintf(out, "%s: skipped %s, which %s\n", j.Path, s.Step, s.Reason)
596+ }
597+ if !j.Runnable() {
598+ continue
599+ }
600+ if !anyRunnerFor(runners, j.RunsOn) {
601+ declineJob(e, j, out)
602+ continue
603+ }
604+ if _, err := Queue.QueueJobFor(ctx, repo, u.Ref, u.New, j.Script, j.Image, j.RunsOn, j.Name); err != nil {
605+ fmt.Fprintf(out, "could not queue %s: %v\n", j.Name, err)
606+ continue
607+ }
608+ fmt.Fprintf(out, "queued %s from %s for %s\n", j.Name, j.Path, u.Ref)
609+ }
610+ }
611+
612+ // anyRunnerFor reports whether a machine is attached that could take this job.
613+ func anyRunnerFor(runners []store.Runner, want []string) bool {
614+ for _, r := range runners {
615+ if r.Satisfies(want) {
616+ return true
617+ }
618+ }
619+ return false
620+ }
621+
622+ // declineJob says which machine is missing and where to attach one, rather than queueing forever.
623+ func declineJob(e Env, j workflow.Job, out io.Writer) {
624+ asked := strings.Join(j.RunsOn, ", ")
625+ if asked == "" {
626+ asked = "any"
627+ }
628+ fmt.Fprintf(out, "%s wants a %s machine and none is attached.\n", j.Name, asked)
629+ // Chapter 11's rule about typos applies here too: print the page that fixes it.
630+ if e.URL != "" {
631+ fmt.Fprintf(out, " attach one: %s/runners/new\n", e.URL)
632+ }
633+ }
634+
635+ // adoptHead corrects a push-created repository's guessed HEAD to the branch actually pushed.
636+ func adoptHead(ctx context.Context, e Env, ups []Update) error {
637+ head, err := gitx.Run(ctx, e.Dir, "symbolic-ref", "HEAD")
638+ if err != nil {
639+ return nil
640+ }
641+ head = strings.TrimSpace(head)
642+ // If HEAD already resolves to a commit, it is not a guess any more.
643+ if _, err := gitx.Run(ctx, e.Dir, "rev-parse", "--verify", "--quiet", head); err == nil {
644+ return nil
645+ }
646+ for _, u := range ups {
647+ if !u.Deleting() && strings.HasPrefix(u.Ref, "refs/heads/") {
648+ _, err := gitx.Run(ctx, e.Dir, "symbolic-ref", "HEAD", u.Ref)
649+ return err
650+ }
651+ }
652+ return nil
653+ }
654+
655+ // list renders as .barerepo/config spells it, so the printed line is the line the reader finds.
656+
657+ // followDefaultBranch moves HEAD to what the pushed config names, which is all chapter 33.6 asks.
658+ func followDefaultBranch(ctx context.Context, e Env, out io.Writer) {
659+ // The fallback names the branch, so a config that will not parse does not also stop a proposal being seen as merged. Chapter 14.
660+ cfg, _ := repocfg.Load(ctx, e.Dir)
661+ if cfg.Repo.DefaultBranch == "" {
662+ return
663+ }
664+ want := "refs/heads/" + cfg.Repo.DefaultBranch
665+ if !gitx.ValidRef(want) {
666+ return
667+ }
668+ now, err := gitx.Run(ctx, e.Dir, "symbolic-ref", "HEAD")
669+ if err != nil || strings.TrimSpace(now) == want {
670+ return
671+ }
672+ if _, err := gitx.Run(ctx, e.Dir, "rev-parse", "--verify", "--quiet", want); err != nil {
673+ // The branch is named and not pushed yet, so saying so beats moving HEAD to nothing.
674+ fmt.Fprintf(out, "%s names %s as the default branch and it does not exist here yet\n",
675+ repocfg.Path, cfg.Repo.DefaultBranch)
676+ return
677+ }
678+ if _, err := gitx.Run(ctx, e.Dir, "symbolic-ref", "HEAD", want); err != nil {
679+ return
680+ }
681+ fmt.Fprintf(out, "the default branch is %s now, as %s says\n", cfg.Repo.DefaultBranch, repocfg.Path)
682+ }
683+
684+ // closeMergedProposals concludes, from a push, that a reachable proposal tip was merged.
685+ func closeMergedProposals(ctx context.Context, e Env, ups []Update, out io.Writer) error {
686+ head, err := repo.HeadBranch(ctx, e.Dir)
687+ if err != nil {
688+ return nil
689+ }
690+ for _, u := range ups {
691+ if u.Ref != "refs/heads/"+head || u.Deleting() {
692+ continue
693+ }
694+ open, err := openProposals(ctx, e.Dir)
695+ if err != nil {
696+ return err
697+ }
698+ if len(open) == 0 {
699+ return nil
700+ }
701+ // One rev-list and a membership test, not an is-ancestor call per proposal. Chapter 12.
702+ arrived, err := commitsIn(ctx, e.Dir, u)
703+ if err != nil {
704+ return err
705+ }
706+ for _, n := range open {
707+ tip, err := gitx.Run(ctx, e.Dir, "rev-parse", "--verify", "--quiet", proposal.Ref(n))
708+ if err != nil {
709+ continue
710+ }
711+ tip = strings.TrimSpace(tip)
712+ if !arrived[tip] {
713+ // Outside this push's range, but the next check settles an earlier one.
714+ if _, err := gitx.Run(ctx, e.Dir, "merge-base", "--is-ancestor", tip, u.New); err != nil {
715+ continue
716+ }
717+ }
718+ if err := thread.SetState(ctx, e.Dir, n, thread.Merged, u.New); err != nil {
719+ fmt.Fprintf(out, "could not close thread %d: %v\n", n, err)
720+ continue
721+ }
722+ record(ctx, store.Event{
723+ Kind: store.ProposalMerged, Actor: e.Account,
724+ Repo: e.Owner + "/" + e.Name, Number: n, Ref: proposal.Ref(n),
725+ })
726+ fmt.Fprintf(out, "proposal %d is now reachable from %s. thread %d closed as merged.\n", n, head, n)
727+ }
728+ }
729+ return nil
730+ }
731+
732+ // openProposals lists the numbers of proposals whose thread is still open.
733+ func openProposals(ctx context.Context, dir string) ([]int, error) {
734+ out, err := gitx.Run(ctx, dir, "for-each-ref", "--format=%(refname)", "refs/proposals/")
735+ if err != nil {
736+ return nil, err
737+ }
738+ var open []int
739+ for _, ref := range strings.Split(strings.TrimSpace(out), "\n") {
740+ n := proposal.Number(ref)
741+ if n == 0 {
742+ continue
743+ }
744+ m, exists, err := thread.ReadMeta(ctx, dir, n)
745+ if err != nil {
746+ return nil, err
747+ }
748+ if !exists || m.State == thread.Open {
749+ open = append(open, n)
750+ }
751+ }
752+ return open, nil
753+ }
754+
755+ // commitsIn is the set of commits this ref update brought.
756+ func commitsIn(ctx context.Context, dir string, u Update) (map[string]bool, error) {
757+ spec := u.New
758+ if !u.Creating() {
759+ spec = u.Old + ".." + u.New
760+ }
761+ out, err := gitx.Run(ctx, dir, "rev-list", spec)
762+ if err != nil {
763+ return map[string]bool{}, nil
764+ }
765+ set := map[string]bool{}
766+ for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
767+ if line != "" {
768+ set[line] = true
769+ }
770+ }
771+ return set, nil
772+ }
773+
774+ // repairNotes gives a hand-pushed reply its author, since the thread page tells people to push one.
775+ func repairNotes(ctx context.Context, e Env, ups []Update) {
776+ for _, u := range ups {
777+ n := thread.NumberOf(u.Ref)
778+ if n == 0 || u.Deleting() {
779+ continue
780+ }
781+ // The old commit is still here, so what this push added is the difference and not a guess.
782+ was := thread.NotesAt(ctx, e.Dir, u.Old)
783+ // A note that cannot be repaired is still a note, and the push already happened.
784+ _ = thread.Repair(ctx, e.Dir, n, was, e.Account, time.Now())
785+ }
786+ }
787+
788+ // recordNotes gives a comment pushed from a clone the inbox line a comment written here gets. 19.2.
789+ func recordNotes(ctx context.Context, e Env, ups []Update) {
790+ if Queue == nil {
791+ return
792+ }
793+ repo := e.Owner + "/" + e.Name
794+ for _, u := range ups {
795+ n := thread.NumberOf(u.Ref)
796+ if n == 0 || u.Deleting() {
797+ continue
798+ }
799+ meta, ok := thread.MetaAt(ctx, e.Dir, u.New)
800+ if !ok {
801+ continue
802+ }
803+ before, had := thread.MetaAt(ctx, e.Dir, u.Old)
804+ kind := store.ThreadReplied
805+ switch {
806+ case !had:
807+ kind = store.ThreadOpened
808+ case meta.State != thread.Open && before.State == thread.Open:
809+ kind = store.ThreadClosed
810+ }
811+ record(ctx, store.Event{Kind: kind, Actor: e.Account, Repo: repo,
812+ Number: n, Title: meta.Title})
813+ // Participation is subscription, so pushing a reply is how you start hearing about one. 19.2.
814+ if Queue != nil {
815+ _ = Queue.TookPart(ctx, e.Account, repo, n)
816+ }
817+ }
818+ }
819+
820+ // record writes an event and never fails a push, because an inbox line is worth less.
821+ func record(ctx context.Context, ev store.Event) {
822+ if Queue == nil {
823+ return
824+ }
825+ _ = Queue.Record(ctx, ev)
826+ }
827+
828+ // recordPushes notes what arrived, so the people who care can see it.
829+ func recordPushes(ctx context.Context, e Env, ups []Update) {
830+ if Queue == nil {
831+ return
832+ }
833+ repo := e.Owner + "/" + e.Name
834+ for _, u := range ups {
835+ if u.Deleting() || strings.HasPrefix(u.Ref, "refs/notes/") {
836+ continue
837+ }
838+ // A proposal push has its own event, and two inbox lines for one action is one too many.
839+ if proposal.Number(u.Ref) > 0 {
840+ continue
841+ }
842+ record(ctx, store.Event{
843+ Kind: store.Pushed, Actor: e.Account, Repo: repo, Ref: u.Ref,
844+ })
845+ }
846+ }
847+
848+ // short is the seven characters a person reads a sha by, the same length the pages use.
849+ func short(sha string) string {
850+ if len(sha) > 7 {
851+ return sha[:7]
852+ }
853+ return sha
854+ }
@@ -0,0 +1,298 @@
1+ package hook
2+
3+ import (
4+ "context"
5+ "fmt"
6+ "io"
7+ "strings"
8+ "time"
9+
10+ "github.com/barerepo/server/internal/gitx"
11+ "github.com/barerepo/server/internal/pktline"
12+ "github.com/barerepo/server/internal/proposal"
13+ "github.com/barerepo/server/internal/repocfg"
14+ "github.com/barerepo/server/internal/store"
15+ "github.com/barerepo/server/internal/thread"
16+ )
17+
18+ // ProcReceive turns a push to refs/proposals/new into refs/proposals/47, over pkt-line.
19+ func ProcReceive(ctx context.Context, e Env, stdin io.Reader, stdout, out io.Writer) error {
20+ r := pktline.NewReader(stdin)
21+ w := pktline.NewWriter(stdout)
22+
23+ if err := negotiate(r, w); err != nil {
24+ return err
25+ }
26+ commands, err := readCommands(r)
27+ if err != nil {
28+ return err
29+ }
30+
31+ cfg, _ := repocfg.Load(ctx, e.Dir)
32+
33+ for _, c := range commands {
34+ res := s.handle(ctx, e, cfg, c, out)
35+ if res.err != "" {
36+ if err := w.Write("ng " + c.Ref + " " + res.err); err != nil {
37+ return err
38+ }
39+ continue
40+ }
41+ if err := w.Write("ok " + c.Ref); err != nil {
42+ return err
43+ }
44+ // Say where the push landed, so the client names the proposal and not the magic ref.
45+ if res.ref != "" && res.ref != c.Ref {
46+ if err := w.Write("option refname " + res.ref); err != nil {
47+ return err
48+ }
49+ }
50+ if res.old != "" {
51+ if err := w.Write("option old-oid " + res.old); err != nil {
52+ return err
53+ }
54+ }
55+ if res.forced {
56+ if err := w.Write("option forced-update"); err != nil {
57+ return err
58+ }
59+ }
60+ }
61+ return w.Flush()
62+ }
63+
64+ // now is the one clock this file reads, so a test can hold it still.
65+ var now = time.Now
66+
67+ // s exists only to group the handling; proc-receive has no state of its own.
68+ var s procReceive
69+
70+ type procReceive struct{}
71+
72+ type command struct{ Old, New, Ref string }
73+
74+ func (c command) Creating() bool { return strings.Trim(c.Old, "0") == "" }
75+ func (c command) Deleting() bool { return strings.Trim(c.New, "0") == "" }
76+
77+ type result struct {
78+ ref string
79+ old string
80+ forced bool
81+ err string
82+ }
83+
84+ // negotiate agrees on protocol version 1.
85+ func negotiate(r *pktline.Reader, w *pktline.Writer) error {
86+ for {
87+ line, err := r.Read()
88+ if err == pktline.ErrFlush {
89+ break
90+ }
91+ if err != nil {
92+ return err
93+ }
94+ // The line is "version=1" and may carry capabilities after a NUL.
95+ if v, _, _ := strings.Cut(line, "\x00"); v != "version=1" {
96+ return fmt.Errorf("unsupported proc-receive version %q", v)
97+ }
98+ }
99+ if err := w.Write("version=1"); err != nil {
100+ return err
101+ }
102+ return w.Flush()
103+ }
104+
105+ func readCommands(r *pktline.Reader) ([]command, error) {
106+ var out []command
107+ for {
108+ line, err := r.Read()
109+ if err == pktline.ErrFlush {
110+ return out, nil
111+ }
112+ if err != nil {
113+ return nil, err
114+ }
115+ f := strings.Fields(line)
116+ if len(f) < 3 {
117+ return nil, fmt.Errorf("malformed proc-receive command %q", line)
118+ }
119+ out = append(out, command{Old: f[0], New: f[1], Ref: f[2]})
120+ }
121+ }
122+
123+ // handle does the work for one command and says what to report.
124+ func (procReceive) handle(ctx context.Context, e Env, cfg repocfg.Config, c command, out io.Writer) result {
125+ switch {
126+ case c.Ref == proposal.NewRef:
127+ return openProposal(ctx, e, cfg, c, out)
128+ case proposal.Number(c.Ref) > 0:
129+ return updateProposal(ctx, e, cfg, c, out)
130+ default:
131+ return result{err: c.Ref + " is not a proposal ref"}
132+ }
133+ }
134+
135+ // openProposal allocates a number and creates the ref.
136+ func openProposal(ctx context.Context, e Env, cfg repocfg.Config, c command, out io.Writer) result {
137+ if c.Deleting() {
138+ return result{err: "refs/proposals/new is not a ref, so it cannot be deleted"}
139+ }
140+ if !cfg.MayPropose(e.Owner, e.Account) {
141+ return result{err: "this repository does not accept proposals from you"}
142+ }
143+ n, err := proposal.Allocate(ctx, e.Dir)
144+ if err != nil {
145+ return result{err: err.Error()}
146+ }
147+ ref := proposal.Ref(n)
148+ if _, err := gitx.Run(ctx, e.Dir, "update-ref", ref, c.New, ""); err != nil {
149+ return result{err: err.Error()}
150+ }
151+
152+ // A proposal is a thread with a ref, so opening one opens the thread. Chapter 13.
153+ subject, err := gitx.Run(ctx, e.Dir, "log", "--max-count=1", "--format=%s", c.New)
154+ if err != nil {
155+ subject = ""
156+ }
157+ meta := thread.Meta{
158+ Title: strings.TrimSpace(subject),
159+ State: thread.Open,
160+ Ref: ref,
161+ Author: e.Account,
162+ Opened: now(),
163+ }
164+ if err := thread.Create(ctx, e.Dir, n, meta, "", thread.Comment{}); err != nil {
165+ return result{err: err.Error()}
166+ }
167+
168+ record(ctx, store.Event{
169+ Kind: store.ProposalOpened, Actor: e.Account, Repo: e.Owner + "/" + e.Name,
170+ Number: n, Ref: ref, Title: meta.Title,
171+ })
172+ took(ctx, e.Account, e.Owner+"/"+e.Name, n)
173+
174+ fmt.Fprintf(out, "\nproposal %d opened.\n", n)
175+ if e.URL != "" {
176+ fmt.Fprintf(out, " %s/thread/%d\n", e.URL, n)
177+ }
178+ fmt.Fprintf(out, " update it later with: git push -f origin HEAD:%s\n\n", ref)
179+ return result{ref: ref, old: c.Old}
180+ }
181+
182+ // updateProposal expects force, because chapter 12 updates a proposal by rewriting it.
183+ func updateProposal(ctx context.Context, e Env, cfg repocfg.Config, c command, out io.Writer) result {
184+ n := proposal.Number(c.Ref)
185+ if c.Ref != proposal.Ref(n) {
186+ return result{err: "a revision ref is written by the server, not by a push"}
187+ }
188+ // git hands proc-receive the client's old-oid and checks it against nothing, so read the ref: chapter 12's retained revision and the thread's record both hang off what was really replaced.
189+ old := tipOf(ctx, e.Dir, c.Ref)
190+ creating := strings.Trim(old, "0") == ""
191+
192+ // An owner pushing a mirror is restoring, and chapter 40.3 depends on it working.
193+ if e.Account != "" && e.Account == e.Owner && creating {
194+ if _, err := gitx.Run(ctx, e.Dir, "update-ref", c.Ref, c.New, ""); err != nil {
195+ return result{err: err.Error()}
196+ }
197+ return result{ref: c.Ref, old: old}
198+ }
199+ author, err := authorOf(ctx, e.Dir, n)
200+ if err != nil {
201+ return result{err: err.Error()}
202+ }
203+ if author == "" {
204+ // Nothing records who opened this ref, so only push access may touch it.
205+ if !cfg.MayPush(e.Owner, e.Account) {
206+ return result{err: fmt.Sprintf("proposal %d has no recorded author, so only somebody with push access may update it", n)}
207+ }
208+ author = e.Account
209+ }
210+ // Chapter 12: the author, plus anyone with push access.
211+ if e.Account != author && !cfg.MayPush(e.Owner, e.Account) {
212+ return result{err: fmt.Sprintf("proposal %d belongs to %s", n, author)}
213+ }
214+
215+ // Retain the replaced tip, so comments anchored to it do not dangle. Chapters 12 and 43.5.
216+ forced := false
217+ expect := old
218+ if creating {
219+ // An empty old value means the ref must not exist, which is what a create asks for.
220+ expect = ""
221+ } else {
222+ if _, err := gitx.Run(ctx, e.Dir, "merge-base", "--is-ancestor", old, c.New); err != nil {
223+ forced = true
224+ }
225+ k, err := nextRevision(ctx, e.Dir, n)
226+ if err == nil {
227+ if _, err := gitx.Run(ctx, e.Dir, "update-ref", proposal.RevisionRef(n, k), old, ""); err == nil {
228+ fmt.Fprintf(out, "kept the previous version as %s\n", proposal.RevisionRef(n, k))
229+ }
230+ }
231+ }
232+ // The tip the revision was kept from rides in the write, so a push landing in between is refused rather than replacing a version nothing retained.
233+ if _, err := gitx.Run(ctx, e.Dir, "update-ref", c.Ref, c.New, expect); err != nil {
234+ return result{err: err.Error()}
235+ }
236+ // Chapter 12 records each push as a revision in the thread, or a reviewer cannot see it moved.
237+ if !creating {
238+ note := thread.Comment{Author: e.Account, Time: time.Now(),
239+ Body: fmt.Sprintf("pushed revision %d.", proposal.CurrentRevision(e.Dir, n))}
240+ if forced {
241+ note.Body = fmt.Sprintf("pushed revision %d, rewriting the last one.",
242+ proposal.CurrentRevision(e.Dir, n))
243+ }
244+ if err := thread.Reply(ctx, e.Dir, n, c.New, note); err != nil {
245+ fmt.Fprintf(out, "could not record the revision in thread %d: %v\n", n, err)
246+ }
247+ }
248+ record(ctx, store.Event{
249+ Kind: store.ProposalUpdated, Actor: e.Account, Repo: e.Owner + "/" + e.Name,
250+ Number: n, Ref: c.Ref,
251+ })
252+ took(ctx, e.Account, e.Owner+"/"+e.Name, n)
253+ fmt.Fprintf(out, "proposal %d updated.\n", n)
254+ return result{ref: c.Ref, old: old, forced: forced}
255+ }
256+
257+ // tipOf is what a ref actually holds, and the all-zero id is git's word for "it does not".
258+ func tipOf(ctx context.Context, dir, ref string) string {
259+ out, err := gitx.Run(ctx, dir, "rev-parse", "--verify", "--quiet", ref)
260+ if err != nil {
261+ return Zero
262+ }
263+ return strings.TrimSpace(out)
264+ }
265+
266+ // authorOf reads the thread's meta blob, never the commit author, which any pusher can type.
267+ func authorOf(ctx context.Context, dir string, n int) (string, error) {
268+ out, err := gitx.Run(ctx, dir, "for-each-ref", "--format=%(refname)", proposal.Ref(n))
269+ if err != nil {
270+ return "", err
271+ }
272+ if strings.TrimSpace(out) == "" {
273+ return "", nil
274+ }
275+ meta, exists, err := thread.ReadMeta(ctx, dir, n)
276+ if err != nil {
277+ return "", err
278+ }
279+ if !exists || meta.Author == "" {
280+ // A proposal ref with no thread came from a mirror restore, which only the owner writes.
281+ return "", nil
282+ }
283+ return meta.Author, nil
284+ }
285+
286+ // nextRevision is the lowest unused revision slot for proposal n.
287+ func nextRevision(ctx context.Context, dir string, n int) (int, error) {
288+ // Refs are files, per chapter 6, so counting them costs no process on the push path.
289+ return proposal.CurrentRevision(dir, n), nil
290+ }
291+
292+ // took records participation, which is the subscription, so they hear about it later.
293+ func took(ctx context.Context, account, repo string, n int) {
294+ if Queue == nil {
295+ return
296+ }
297+ _ = Queue.TookPart(ctx, account, repo, n)
298+ }
@@ -0,0 +1,669 @@
1+ package httpd
2+
3+ import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "net"
8+ "net/http"
9+ "os"
10+ "path/filepath"
11+ "strconv"
12+ "strings"
13+ "time"
14+
15+ "github.com/barerepo/server/internal/auth"
16+ "github.com/barerepo/server/internal/gitread"
17+ "github.com/barerepo/server/internal/store"
18+ "github.com/barerepo/server/internal/token"
19+ )
20+
21+ // cookieName holds an opaque token in a server table, not a JWT, because revocation matters. 10.
22+ const cookieName = "barerepo_session"
23+
24+ // viewer is who is asking, or "" for nobody, and every private page goes through here.
25+ func (s *Server) viewer(r *http.Request) string {
26+ c, err := r.Cookie(cookieName)
27+ if err != nil || c.Value == "" {
28+ return ""
29+ }
30+ account, err := s.DB.SessionAccount(r.Context(), c.Value)
31+ if err != nil {
32+ return ""
33+ }
34+ return account
35+ }
36+
37+ func (s *Server) setSession(w http.ResponseWriter, r *http.Request, value string) {
38+ http.SetCookie(w, &http.Cookie{
39+ Name: cookieName,
40+ Value: value,
41+ Path: "/",
42+ HttpOnly: true,
43+ // Secure over https, because a plain-http install on localhost still has to sign in.
44+ Secure: strings.HasPrefix(s.Cfg.Server.ExternalURL, "https://"),
45+ SameSite: http.SameSiteLaxMode,
46+ MaxAge: int(store.SessionLife.Seconds()),
47+ })
48+ }
49+
50+ func (s *Server) clearSession(w http.ResponseWriter) {
51+ http.SetCookie(w, &http.Cookie{
52+ Name: cookieName, Value: "", Path: "/", HttpOnly: true, MaxAge: -1,
53+ })
54+ }
55+
56+ // signupPage is the form and its errors, with Nonce set once a signature is what is missing.
57+ type signupPage struct {
58+ chrome
59+ Name string
60+ PubKey string
61+ Nonce string
62+ Error string
63+ }
64+
65+ func (s *Server) serveSignup(w http.ResponseWriter, r *http.Request) {
66+ page := signupPage{chrome: newChrome("barerepo · signup",
67+ "Signup page where an ssh public key is the only credential collected.")}
68+
69+ if isGet(r) {
70+ s.render(w, r, "signup", page)
71+ return
72+ }
73+ if err := r.ParseForm(); err != nil {
74+ http.Error(w, "malformed form", http.StatusBadRequest)
75+ return
76+ }
77+ page.Name = strings.TrimSpace(r.FormValue("name"))
78+ page.PubKey = strings.TrimSpace(r.FormValue("pubkey"))
79+ fail := func(msg string) {
80+ // A bad key is not echoed back, so a private key pasted by mistake leaves the screen.
81+ if strings.Contains(msg, "private key") {
82+ page.PubKey = ""
83+ }
84+ page.Error = msg
85+ w.WriteHeader(http.StatusBadRequest)
86+ s.render(w, r, "signup", page)
87+ }
88+
89+ // Second step: the signature over the nonce the first step handed out.
90+ if nonce := strings.TrimSpace(r.FormValue("nonce")); nonce != "" {
91+ s.finishSignup(w, r, nonce, strings.TrimSpace(r.FormValue("signature")))
92+ return
93+ }
94+
95+ if !s.signupAllowed(r) {
96+ page.Error = "too many signups from this address in the last hour. try later."
97+ w.WriteHeader(http.StatusTooManyRequests)
98+ s.render(w, r, "signup", page)
99+ return
100+ }
101+ if err := s.DB.CheckNewAccount(r.Context(), page.Name, page.PubKey); err != nil {
102+ fail(err.Error())
103+ return
104+ }
105+
106+ // Proof of possession, or anyone can claim an account with a public key they found.
107+ nonce, err := auth.NewNonce()
108+ if err != nil {
109+ s.oops(w, r, err)
110+ return
111+ }
112+ if err := s.DB.NewSignupChallenge(r.Context(), nonce, page.Name, page.PubKey); err != nil {
113+ s.oops(w, r, err)
114+ return
115+ }
116+ page.Nonce = nonce
117+ s.render(w, r, "signup", page)
118+ }
119+
120+ // signinPage carries either the name form or the nonce form.
121+ type signinPage struct {
122+ chrome
123+ Name string
124+ Nonce string
125+ Error string
126+ // Unknown means no such account, so the page points at signup instead of leaving a guess.
127+ Unknown bool
128+ }
129+
130+ func (s *Server) serveSignin(w http.ResponseWriter, r *http.Request) {
131+ s.render(w, r, "signin", signinPage{chrome: signinChrome()})
132+ }
133+
134+ func signinChrome() chrome {
135+ return newChrome("barerepo · sign in",
136+ "Sign in page where the server challenges an ssh key instead of asking for a password.")
137+ }
138+
139+ // serveChallenge is step 2 of chapter 10: generate a nonce and show it.
140+ func (s *Server) serveChallenge(w http.ResponseWriter, r *http.Request) {
141+ name := strings.TrimSpace(r.FormValue("name"))
142+ page := signinPage{chrome: signinChrome(), Name: name}
143+
144+ // A name is a public URL namespace, so hiding it here only wastes the user's signature.
145+ if _, err := s.DB.Account(r.Context(), name); err != nil {
146+ page.Error = "there is no account named " + name + "."
147+ page.Unknown = true
148+ w.WriteHeader(http.StatusNotFound)
149+ s.render(w, r, "signin", page)
150+ return
151+ }
152+ nonce, err := auth.NewNonce()
153+ if err != nil {
154+ s.oops(w, r, err)
155+ return
156+ }
157+ if err := s.DB.NewChallenge(r.Context(), name, nonce); err != nil {
158+ s.oops(w, r, err)
159+ return
160+ }
161+ if wantsText(r) {
162+ plainText(w, nonce)
163+ return
164+ }
165+ page.Nonce = nonce
166+ s.render(w, r, "signin", page)
167+ }
168+
169+ // wantsText marks the caller as a terminal, which reads a nonce and never a page.
170+ func wantsText(r *http.Request) bool {
171+ return strings.Contains(r.Header.Get("Accept"), "text/plain")
172+ }
173+
174+ func plainText(w http.ResponseWriter, lines ...string) {
175+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
176+ for _, line := range lines {
177+ fmt.Fprintln(w, line)
178+ }
179+ }
180+
181+ // serveVerify is steps 4 to 6: check the signature, issue a session.
182+ func (s *Server) serveVerify(w http.ResponseWriter, r *http.Request) {
183+ nonce := strings.TrimSpace(r.FormValue("nonce"))
184+ signature := strings.TrimSpace(r.FormValue("signature"))
185+
186+ // The name rides along, because a spent nonce is not the user's mistake to retype for.
187+ name := strings.TrimSpace(r.FormValue("name"))
188+ fail := func(msg string) {
189+ w.WriteHeader(http.StatusUnauthorized)
190+ if wantsText(r) {
191+ plainText(w, msg)
192+ return
193+ }
194+ s.render(w, r, "signin", signinPage{chrome: signinChrome(), Name: name, Error: msg})
195+ }
196+
197+ // Spent whether or not the signature is good, so no answer is retried on one challenge.
198+ c, err := s.DB.TakeChallenge(r.Context(), nonce)
199+ if err != nil {
200+ fail("that challenge is used up. press send challenge for a new one.")
201+ return
202+ }
203+ keys, err := s.DB.Keys(r.Context(), c.Account)
204+ if err != nil {
205+ s.oops(w, r, err)
206+ return
207+ }
208+ used, err := auth.Verify(r.Context(), c.Account, nonce, signature, keys)
209+ if err != nil {
210+ fail(err.Error())
211+ return
212+ }
213+ if err := s.DB.TouchKey(r.Context(), used.ID); err != nil {
214+ s.log(r, err)
215+ }
216+
217+ if wantsText(r) {
218+ code, err := s.DB.NewClaim(r.Context(), c.Account)
219+ if err != nil {
220+ s.oops(w, r, err)
221+ return
222+ }
223+ plainText(w, c.Account, s.Cfg.Server.ExternalURL+"/auth/claim?c="+code)
224+ return
225+ }
226+ cookie, err := s.DB.NewSession(r.Context(), c.Account)
227+ if err != nil {
228+ s.oops(w, r, err)
229+ return
230+ }
231+ s.setSession(w, r, cookie)
232+ http.Redirect(w, r, "/"+c.Account, http.StatusFound)
233+ }
234+
235+ // serveClaim is the other end of br auth: one link, spent once, and the browser is signed in.
236+ func (s *Server) serveClaim(w http.ResponseWriter, r *http.Request) {
237+ account, err := s.DB.TakeClaim(r.Context(), r.URL.Query().Get("c"))
238+ if err != nil {
239+ w.WriteHeader(http.StatusUnauthorized)
240+ s.render(w, r, "signin", signinPage{chrome: signinChrome(),
241+ Error: "that sign in link is used up or expired. run br auth again."})
242+ return
243+ }
244+ cookie, err := s.DB.NewSession(r.Context(), account)
245+ if err != nil {
246+ s.oops(w, r, err)
247+ return
248+ }
249+ s.setSession(w, r, cookie)
250+ http.Redirect(w, r, "/"+account, http.StatusFound)
251+ }
252+
253+ func (s *Server) serveSignout(w http.ResponseWriter, r *http.Request) {
254+ if c, err := r.Cookie(cookieName); err == nil {
255+ if err := s.DB.EndSession(r.Context(), c.Value); err != nil && !errors.Is(err, store.ErrNotFound) {
256+ s.log(r, err)
257+ }
258+ }
259+ s.clearSession(w)
260+ http.Redirect(w, r, "/signin", http.StatusFound)
261+ }
262+
263+ // keysPage is the only settings page, because keys and tokens cannot live in a repository. 24.
264+ type keysPage struct {
265+ chrome
266+ Account string
267+ Keys []keyView
268+ GPGKeys []gpgKeyView
269+ Tokens []tokenView
270+ Error string
271+ NewToken string
272+ NewTokenLabel string
273+ }
274+
275+ // gpgKeyView is one signing key, named the way its owner named it when they made it.
276+ type gpgKeyView struct {
277+ ID int64
278+ Fingerprint string
279+ UID string
280+ Added string
281+ }
282+
283+ type keyView struct {
284+ ID int64
285+ Algo string
286+ Fingerprint string
287+ Comment string
288+ Added string
289+ LastUsed string
290+ }
291+
292+ type tokenView struct {
293+ ID int64
294+ Name string
295+ Detail string
296+ Created string
297+ }
298+
299+ // requireViewer sends anyone who is not signed in to the sign-in page.
300+ func (s *Server) requireViewer(w http.ResponseWriter, r *http.Request) (string, bool) {
301+ who := s.viewer(r)
302+ if who == "" {
303+ http.Redirect(w, r, "/signin", http.StatusFound)
304+ return "", false
305+ }
306+ return who, true
307+ }
308+
309+ func (s *Server) serveKeys(w http.ResponseWriter, r *http.Request) {
310+ who, ok := s.requireViewer(w, r)
311+ if !ok {
312+ return
313+ }
314+ s.renderKeys(w, r, who, "", "", "")
315+ }
316+
317+ func (s *Server) renderKeys(w http.ResponseWriter, r *http.Request, who, errMsg, newToken, newTokenLabel string) {
318+ ctx := r.Context()
319+ page := keysPage{
320+ chrome: newChrome("barerepo · keys",
321+ "Page listing ssh keys, signing keys, runner tokens and feed tokens, each with a revoke control."),
322+ Account: who,
323+ Error: errMsg,
324+ NewToken: newToken,
325+ NewTokenLabel: newTokenLabel,
326+ }
327+ keys, err := s.DB.Keys(ctx, who)
328+ if err != nil {
329+ s.oops(w, r, err)
330+ return
331+ }
332+ for _, k := range keys {
333+ page.Keys = append(page.Keys, keyView{
334+ ID: k.ID,
335+ Algo: strings.TrimPrefix(k.Algo, "ssh-"),
336+ Fingerprint: k.Fingerprint,
337+ Comment: k.Comment,
338+ Added: month(k.Created),
339+ LastUsed: lastUsed(k.LastUsed),
340+ })
341+ }
342+ gpg, err := s.DB.GPGKeys(ctx, who)
343+ if err != nil {
344+ s.oops(w, r, err)
345+ return
346+ }
347+ for _, k := range gpg {
348+ page.GPGKeys = append(page.GPGKeys, gpgKeyView{
349+ ID: k.ID, Fingerprint: k.Fingerprint, UID: k.UID, Added: month(k.Created)})
350+ }
351+
352+ tokens, err := s.DB.TokensOf(ctx, who)
353+ if err != nil {
354+ s.oops(w, r, err)
355+ return
356+ }
357+ // Chapter 24 asks a runner token for its labels and the machine that attached with it.
358+ attached, err := s.DB.RunnersByToken(ctx, who)
359+ if err != nil {
360+ s.log(r, err)
361+ }
362+ for _, t := range tokens {
363+ page.Tokens = append(page.Tokens, tokenView{
364+ ID: t.ID,
365+ Name: tokenName(t),
366+ Detail: tokenDetail(t, attached[t.ID]),
367+ Created: ago(t.Created),
368+ })
369+ }
370+ s.render(w, r, "keys", page)
371+ }
372+
373+ // tokenName names the row and never holds the token, which was shown once inside its command.
374+ func tokenName(t store.Token) string {
375+ if t.Label != "" {
376+ return t.Label
377+ }
378+ switch t.Kind {
379+ case token.Runner:
380+ return "an unattached runner"
381+ case token.Feed:
382+ return "inbox"
383+ default:
384+ return "git over https"
385+ }
386+ }
387+
388+ func tokenDetail(t store.Token, runners []store.Runner) string {
389+ parts := []string{}
390+ switch t.Kind {
391+ case token.Runner:
392+ parts = append(parts, "runner")
393+ case token.Feed:
394+ parts = append(parts, "feed", "read only")
395+ default:
396+ parts = append(parts, "clone and push over https")
397+ }
398+ if t.Scope != "" {
399+ parts = append(parts, t.Scope)
400+ }
401+ for _, r := range runners {
402+ parts = append(parts, r.Hostname)
403+ if len(r.Labels) > 0 {
404+ parts = append(parts, "labels "+strings.Join(r.Labels, ", "))
405+ }
406+ }
407+ parts = append(parts, lastTouched(t.Kind, t.LastUsed))
408+ return strings.Join(parts, " · ")
409+ }
410+
411+ // lastTouched is the keys mockup's wording for each row: a machine is seen, a feed is read, and everything else is used.
412+ func lastTouched(k token.Kind, t time.Time) string {
413+ verb := "used"
414+ switch k {
415+ case token.Runner:
416+ verb = "seen"
417+ case token.Feed:
418+ verb = "read"
419+ }
420+ if t.IsZero() {
421+ return "never " + verb
422+ }
423+ return "last " + verb + " " + ago(t)
424+ }
425+
426+ func lastUsed(t time.Time) string {
427+ if t.IsZero() {
428+ return "never used"
429+ }
430+ return "last used " + ago(t)
431+ }
432+
433+ func (s *Server) serveAddKey(w http.ResponseWriter, r *http.Request) {
434+ who, ok := s.requireViewer(w, r)
435+ if !ok {
436+ return
437+ }
438+ if _, err := s.DB.AddKey(r.Context(), who, strings.TrimSpace(r.FormValue("pubkey"))); err != nil {
439+ w.WriteHeader(http.StatusBadRequest)
440+ s.renderKeys(w, r, who, err.Error(), "", "")
441+ return
442+ }
443+ http.Redirect(w, r, "/keys", http.StatusFound)
444+ }
445+
446+ func (s *Server) serveDeleteKey(w http.ResponseWriter, r *http.Request) {
447+ who, ok := s.requireViewer(w, r)
448+ if !ok {
449+ return
450+ }
451+ id, _ := strconv.ParseInt(r.FormValue("id"), 10, 64)
452+ if err := s.DB.DeleteKey(r.Context(), who, id); err != nil {
453+ w.WriteHeader(http.StatusBadRequest)
454+ s.renderKeys(w, r, who, err.Error(), "", "")
455+ return
456+ }
457+ http.Redirect(w, r, "/keys", http.StatusFound)
458+ }
459+
460+ func (s *Server) serveNewToken(w http.ResponseWriter, r *http.Request) {
461+ who, ok := s.requireViewer(w, r)
462+ if !ok {
463+ return
464+ }
465+ // Runner tokens belong to one repository, so chapter 15 issues them from its runner page.
466+ kind, label := token.Git, "git over https"
467+ if r.FormValue("kind") == "feed" {
468+ kind, label = token.Feed, "inbox"
469+ }
470+ tok, _, err := s.DB.CreateToken(r.Context(), kind, who, "", label)
471+ if err != nil {
472+ s.oops(w, r, err)
473+ return
474+ }
475+ // Shown once, as the URL chapter 39.3 asks for, because a redirect would lose it.
476+ shown := tok
477+ if kind == token.Feed {
478+ shown = s.Cfg.Server.ExternalURL + "/inbox.atom?token=" + tok
479+ }
480+ s.renderKeys(w, r, who, "", shown, "your new "+label+" token")
481+ }
482+
483+ func (s *Server) serveDeleteToken(w http.ResponseWriter, r *http.Request) {
484+ who, ok := s.requireViewer(w, r)
485+ if !ok {
486+ return
487+ }
488+ id, _ := strconv.ParseInt(r.FormValue("id"), 10, 64)
489+ if err := s.DB.DeleteToken(r.Context(), who, id); err != nil {
490+ w.WriteHeader(http.StatusBadRequest)
491+ s.renderKeys(w, r, who, err.Error(), "", "")
492+ return
493+ }
494+ http.Redirect(w, r, "/keys", http.StatusFound)
495+ }
496+
497+ // finishSignup creates the account once the signature checks out.
498+ func (s *Server) finishSignup(w http.ResponseWriter, r *http.Request, nonce, signature string) {
499+ page := signupPage{chrome: newChrome("barerepo · signup",
500+ "Signup page where an ssh public key is the only credential collected.")}
501+
502+ c, err := s.DB.TakeSignupChallenge(r.Context(), nonce)
503+ if err != nil {
504+ page.Error = "that challenge is used up. start again."
505+ w.WriteHeader(http.StatusBadRequest)
506+ s.render(w, r, "signup", page)
507+ return
508+ }
509+ page.Name, page.PubKey = c.Name, c.PubKey
510+
511+ if err := auth.VerifySignup(r.Context(), c.Name, c.Nonce, signature, c.PubKey); err != nil {
512+ page.Nonce = ""
513+ page.Error = err.Error() + " the nonce is spent, so press create for a new one."
514+ w.WriteHeader(http.StatusUnauthorized)
515+ s.render(w, r, "signup", page)
516+ return
517+ }
518+
519+ account, err := s.DB.CreateAccount(r.Context(), c.Name, c.PubKey, false)
520+ if err != nil {
521+ page.Nonce = ""
522+ page.Error = err.Error()
523+ w.WriteHeader(http.StatusBadRequest)
524+ s.render(w, r, "signup", page)
525+ return
526+ }
527+ cookie, err := s.DB.NewSession(r.Context(), account.Name)
528+ if err != nil {
529+ s.oops(w, r, err)
530+ return
531+ }
532+ s.setSession(w, r, cookie)
533+ http.Redirect(w, r, "/"+account.Name, http.StatusFound)
534+ }
535+
536+ // signupAllowed is the barrier that costs a squatter something, since a key is cheap. 27.
537+ func (s *Server) signupAllowed(r *http.Request) bool {
538+ limit := s.Cfg.Limits.SignupPerHourPerIP
539+ if limit <= 0 {
540+ return true
541+ }
542+ addr := clientAddr(r)
543+ cutoff := time.Now().Add(-time.Hour)
544+
545+ s.signupsMu.Lock()
546+ defer s.signupsMu.Unlock()
547+ if s.signups == nil {
548+ s.signups = map[string][]time.Time{}
549+ }
550+ kept := s.signups[addr][:0]
551+ for _, t := range s.signups[addr] {
552+ if t.After(cutoff) {
553+ kept = append(kept, t)
554+ }
555+ }
556+ if len(kept) >= limit {
557+ s.signups[addr] = kept
558+ return false
559+ }
560+ s.signups[addr] = append(kept, time.Now())
561+ return true
562+ }
563+
564+ // clientAddr names the bucket chapter 27 counts a signup in, which the counted party must not pick.
565+ func clientAddr(r *http.Request) string {
566+ host, _, err := net.SplitHostPort(r.RemoteAddr)
567+ if err != nil {
568+ host = r.RemoteAddr
569+ }
570+ // Chapter 41.4 puts the reverse proxy on this machine, so only from here is X-Real-IP the proxy's and not the caller's own writing.
571+ if ip := r.Header.Get("X-Real-IP"); ip != "" && loopback(host) {
572+ return ip
573+ }
574+ return host
575+ }
576+
577+ // loopback reports the address a proxy on this machine dials from, which is the one 41.4 describes.
578+ func loopback(host string) bool {
579+ ip := net.ParseIP(host)
580+ return ip != nil && ip.IsLoopback()
581+ }
582+
583+ // serveAddGPGKey takes a pasted public key, so a signature on a commit can be checked against it.
584+ func (s *Server) serveAddGPGKey(w http.ResponseWriter, r *http.Request) {
585+ who, ok := s.requireViewer(w, r)
586+ if !ok {
587+ return
588+ }
589+ armor := strings.TrimSpace(r.FormValue("armor"))
590+ key, err := auth.ReadGPGKey(r.Context(), armor)
591+ if err != nil {
592+ s.renderKeys(w, r, who, err.Error(), "", "")
593+ return
594+ }
595+ if err := s.DB.AddGPGKey(r.Context(), who, key.Fingerprint, key.UID, armor); err != nil {
596+ s.renderKeys(w, r, who, "that key is already here, on this account or another one", "", "")
597+ return
598+ }
599+ s.rebuildKeyring(r.Context())
600+ http.Redirect(w, r, "/keys", http.StatusFound)
601+ }
602+
603+ // serveDeleteGPGKey drops a key, which stops barerepo naming its owner on a commit it signed.
604+ func (s *Server) serveDeleteGPGKey(w http.ResponseWriter, r *http.Request) {
605+ who, ok := s.requireViewer(w, r)
606+ if !ok {
607+ return
608+ }
609+ id, _ := strconv.ParseInt(r.FormValue("id"), 10, 64)
610+ if err := s.DB.DeleteGPGKey(r.Context(), who, id); err != nil {
611+ s.renderKeys(w, r, who, err.Error(), "", "")
612+ return
613+ }
614+ s.rebuildKeyring(r.Context())
615+ http.Redirect(w, r, "/keys", http.StatusFound)
616+ }
617+
618+ // rebuildKeyring writes every published key into one ring, which is the only one git is given.
619+ func (s *Server) rebuildKeyring(ctx context.Context) {
620+ keys, err := s.DB.AllGPGKeys(ctx)
621+ if err != nil {
622+ s.Log.Error("keyring", "err", err)
623+ return
624+ }
625+ armors := make([]string, 0, len(keys))
626+ for _, k := range keys {
627+ armors = append(armors, k.Armor)
628+ }
629+ dir := filepath.Join(s.Cfg.Paths.Cache, "keyring")
630+ // A fresh ring every time, since a revoked key must stop naming anybody at once.
631+ os.RemoveAll(dir)
632+ if len(armors) == 0 {
633+ gitread.Keyring = ""
634+ return
635+ }
636+ ring, err := auth.Keyring(ctx, dir, armors)
637+ if err != nil {
638+ s.Log.Error("keyring", "err", err)
639+ return
640+ }
641+ gitread.Keyring = ring
642+ }
643+
644+ // serveUserKeys hands out the public halves, so a signature is checked against a key and not a badge.
645+ func (s *Server) serveUserKeys(w http.ResponseWriter, r *http.Request, who string) {
646+ keys, err := s.DB.GPGKeys(r.Context(), who)
647+ if err != nil || len(keys) == 0 {
648+ s.notFound(w, r)
649+ return
650+ }
651+ // Plain text, so a browser shows the keys rather than downloading a file nobody asked for.
652+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
653+ for _, k := range keys {
654+ fmt.Fprintln(w, strings.TrimSpace(k.Armor))
655+ }
656+ }
657+
658+ // serveUserSSHKeys is the same idea for the keys that push, which is what an authorized_keys wants.
659+ func (s *Server) serveUserSSHKeys(w http.ResponseWriter, r *http.Request, who string) {
660+ keys, err := s.DB.Keys(r.Context(), who)
661+ if err != nil || len(keys) == 0 {
662+ s.notFound(w, r)
663+ return
664+ }
665+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
666+ for _, k := range keys {
667+ fmt.Fprintf(w, "%s %s\n", k.Algo, k.Blob)
668+ }
669+ }
@@ -0,0 +1,164 @@
1+ package httpd
2+
3+ import (
4+ "encoding/xml"
5+ "net/http"
6+ "time"
7+
8+ "github.com/barerepo/server/internal/store"
9+ "github.com/barerepo/server/internal/token"
10+ "github.com/barerepo/server/internal/transport"
11+ )
12+
13+ // Atom is the whole notification system: static XML, in software the reader already chose.
14+ type atomFeed struct {
15+ XMLName xml.Name `xml:"http://www.w3.org/2005/Atom feed"`
16+ Title string `xml:"title"`
17+ ID string `xml:"id"`
18+ Updated string `xml:"updated"`
19+ Link []atomLink `xml:"link"`
20+ Entries []atomEntry `xml:"entry"`
21+ }
22+
23+ type atomLink struct {
24+ Href string `xml:"href,attr"`
25+ Rel string `xml:"rel,attr,omitempty"`
26+ }
27+
28+ type atomEntry struct {
29+ Title string `xml:"title"`
30+ ID string `xml:"id"`
31+ Updated string `xml:"updated"`
32+ Link atomLink `xml:"link"`
33+ Author atomAuthor `xml:"author"`
34+ Summary string `xml:"summary,omitempty"`
35+ }
36+
37+ type atomAuthor struct {
38+ Name string `xml:"name"`
39+ }
40+
41+ func (s *Server) writeFeed(w http.ResponseWriter, r *http.Request, title, self string, events []store.Event) {
42+ base := s.Cfg.Server.ExternalURL
43+ feed := atomFeed{
44+ Title: title,
45+ ID: base + self,
46+ Updated: time.Now().UTC().Format(time.RFC3339),
47+ Link: []atomLink{{Href: base + self, Rel: "self"}},
48+ }
49+ if len(events) > 0 {
50+ feed.Updated = events[0].Created.UTC().Format(time.RFC3339)
51+ }
52+ for _, e := range events {
53+ feed.Entries = append(feed.Entries, atomEntry{
54+ Title: eventLine(e),
55+ ID: base + eventHref(e) + "#" + itoa(int(e.ID)),
56+ Updated: e.Created.UTC().Format(time.RFC3339),
57+ Link: atomLink{Href: base + eventHref(e)},
58+ Author: atomAuthor{Name: e.Actor},
59+ Summary: eventDetail(e),
60+ })
61+ }
62+ w.Header().Set("Content-Type", "application/atom+xml; charset=utf-8")
63+ w.Write([]byte(xml.Header))
64+ enc := xml.NewEncoder(w)
65+ enc.Indent("", " ")
66+ if err := enc.Encode(feed); err != nil {
67+ s.log(r, err)
68+ }
69+ }
70+
71+ // serveInboxFeed takes a read-only token, because a feed reader keeps URLs in plain text.
72+ func (s *Server) serveInboxFeed(w http.ResponseWriter, r *http.Request) {
73+ // A feed reader has no session, so it carries a token. A browser already has one. 19.5.
74+ who := s.viewer(r)
75+ if tok := r.URL.Query().Get("token"); tok != "" || who == "" {
76+ t, err := s.DB.AccountForToken(r.Context(), token.Feed, tok)
77+ if err != nil {
78+ http.Error(w, "this feed needs a feed token. make one on your keys page.",
79+ http.StatusUnauthorized)
80+ return
81+ }
82+ who = t.Account
83+ }
84+ events, err := s.DB.Inbox(r.Context(), who, 50)
85+ if err != nil {
86+ s.oops(w, r, err)
87+ return
88+ }
89+ s.writeFeed(w, r, "barerepo inbox for "+who, "/inbox.atom", events)
90+ }
91+
92+ // serveRepoFeed is public repositories only and needs no token, per chapter 19.5.
93+ func (s *Server) serveRepoFeed(w http.ResponseWriter, r *http.Request, owner, name string) {
94+ res, _, ok := s.openRepoFor(w, r, owner, name, "")
95+ if !ok {
96+ return
97+ }
98+ if !res.Config.Public() {
99+ http.NotFound(w, r)
100+ return
101+ }
102+ events, err := s.DB.EventsFor(r.Context(), owner+"/"+name, 50)
103+ if err != nil {
104+ s.oops(w, r, err)
105+ return
106+ }
107+ s.writeFeed(w, r, owner+"/"+name, "/"+owner+"/"+name+".atom", events)
108+ }
109+
110+ // serveUserFeed answers /<user>.atom, one account's public activity.
111+ func (s *Server) serveUserFeed(w http.ResponseWriter, r *http.Request, account string) {
112+ if _, err := s.DB.Account(r.Context(), account); err != nil {
113+ http.NotFound(w, r)
114+ return
115+ }
116+ events, err := s.DB.EventsBy(r.Context(), account, 50)
117+ if err != nil {
118+ s.oops(w, r, err)
119+ return
120+ }
121+ // An account's feed carries only what happened somewhere anyone can see, asked once per repository because fifty events are usually a handful of them.
122+ public := make([]store.Event, 0, len(events))
123+ seen := map[string]bool{}
124+ for _, e := range events {
125+ visible, known := seen[e.Repo]
126+ if !known {
127+ owner, repoName, _ := cutRepo(e.Repo)
128+ res, err := s.Transport.Open(r.Context(), owner, repoName, "", transport.Read)
129+ visible = err == nil && res.Config.Public()
130+ seen[e.Repo] = visible
131+ }
132+ if visible {
133+ public = append(public, e)
134+ }
135+ }
136+ s.writeFeed(w, r, account, "/"+account+".atom", public)
137+ }
138+
139+ func cutRepo(repo string) (owner, name string, ok bool) {
140+ for i := 0; i < len(repo); i++ {
141+ if repo[i] == '/' {
142+ return repo[:i], repo[i+1:], true
143+ }
144+ }
145+ return repo, "", false
146+ }
147+
148+ // serveThreadsFeed answers /<user>/<repo>/threads.atom, the discussion alone, per chapter 19.5.
149+ func (s *Server) serveThreadsFeed(w http.ResponseWriter, r *http.Request, owner, name string) {
150+ res, _, ok := s.openRepoFor(w, r, owner, name, "")
151+ if !ok {
152+ return
153+ }
154+ if !res.Config.Public() {
155+ http.NotFound(w, r)
156+ return
157+ }
158+ events, err := s.DB.ThreadEventsFor(r.Context(), owner+"/"+name, 50)
159+ if err != nil {
160+ s.oops(w, r, err)
161+ return
162+ }
163+ s.writeFeed(w, r, owner+"/"+name+" threads", "/"+owner+"/"+name+"/threads.atom", events)
164+ }
@@ -0,0 +1,30 @@
1+ package httpd
2+
3+ import (
4+ "io/fs"
5+ "regexp"
6+ "strings"
7+ "testing"
8+ )
9+
10+ // A reader has no copy of the book, so nothing that cites it belongs on a page they can open.
11+ var bookTalk = regexp.MustCompile(`(?i)\b(chapter|appendix|rule) \d|BOOK\.md|BUILD\.md|SPEC-NOTES`)
12+
13+ func TestNoPageTalksToWhoeverWroteIt(t *testing.T) {
14+ names, err := fs.Glob(templateFS, "templates/*.html")
15+ if err != nil || len(names) == 0 {
16+ t.Fatalf("found no templates to read: %v", err)
17+ }
18+ for _, name := range names {
19+ body, err := fs.ReadFile(templateFS, name)
20+ if err != nil {
21+ t.Fatal(err)
22+ }
23+ for i, line := range strings.Split(string(body), "\n") {
24+ // A go comment is for whoever writes barerepo, and a template has no comments to skip.
25+ if found := bookTalk.FindString(line); found != "" {
26+ t.Errorf("%s:%d shows a reader %q, and a reader has no book", name, i+1, found)
27+ }
28+ }
29+ }
30+ }
@@ -0,0 +1,197 @@
1+ package httpd
2+
3+ import (
4+ _ "embed"
5+ "image"
6+ "image/color"
7+ "image/draw"
8+ "image/png"
9+ "net/http"
10+ "strconv"
11+ "strings"
12+ "sync"
13+
14+ "github.com/barerepo/server/internal/gitx"
15+ "github.com/barerepo/server/internal/transport"
16+
17+ "golang.org/x/image/font"
18+ "golang.org/x/image/font/opentype"
19+ "golang.org/x/image/math/fixed"
20+ )
21+
22+ //go:embed static/fonts/SpaceMono-Regular.ttf
23+ var cardRegular []byte
24+
25+ //go:embed static/fonts/SpaceMono-Bold.ttf
26+ var cardBold []byte
27+
28+ const (
29+ cardW, cardH = 1200, 630
30+ cardInset = 48
31+ cardLeft = 104
32+ )
33+
34+ var (
35+ cardOnce sync.Once
36+ cardFaces map[int]font.Face
37+ cardFaceErr error
38+ )
39+
40+ func cardFace(size int, bold bool) font.Face {
41+ cardOnce.Do(func() {
42+ cardFaces = map[int]font.Face{}
43+ for _, spec := range []struct {
44+ key int
45+ size float64
46+ data []byte
47+ }{
48+ {112, 112, cardBold}, {76, 76, cardBold}, {26, 26, cardRegular}, {28, 28, cardRegular},
49+ } {
50+ f, err := opentype.Parse(spec.data)
51+ if err != nil {
52+ cardFaceErr = err
53+ return
54+ }
55+ face, err := opentype.NewFace(f, &opentype.FaceOptions{Size: spec.size, DPI: 72, Hinting: font.HintingFull})
56+ if err != nil {
57+ cardFaceErr = err
58+ return
59+ }
60+ cardFaces[spec.key] = face
61+ }
62+ })
63+ return cardFaces[size]
64+ }
65+
66+ // cardText draws one line and reports where the next one starts.
67+ func cardText(dst *image.RGBA, x, y, size int, s string, c color.Color) {
68+ face := cardFace(size, false)
69+ if face == nil {
70+ return
71+ }
72+ d := font.Drawer{Dst: dst, Src: image.NewUniform(c), Face: face,
73+ Dot: fixed.P(x, y)}
74+ d.DrawString(s)
75+ }
76+
77+ // cardFits shortens a line that would run past the frame, because a card cannot scroll.
78+ func cardFits(s string, size, width int) string {
79+ face := cardFace(size, false)
80+ if face == nil {
81+ return s
82+ }
83+ if font.MeasureString(face, s).Ceil() <= width {
84+ return s
85+ }
86+ for len(s) > 1 {
87+ s = s[:len(s)-1]
88+ if font.MeasureString(face, s+"...").Ceil() <= width {
89+ return s + "..."
90+ }
91+ }
92+ return s
93+ }
94+
95+ // drawCard is the share image for one page, in the same ink as the pages themselves.
96+ func drawCard(title, subtitle, foot string) (*image.RGBA, error) {
97+ if cardFace(112, true); cardFaceErr != nil {
98+ return nil, cardFaceErr
99+ }
100+ img := image.NewRGBA(image.Rect(0, 0, cardW, cardH))
101+ draw.Draw(img, img.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src)
102+
103+ border := color.RGBA{0xdd, 0xdd, 0xdd, 0xff}
104+ frame := image.Rect(cardInset, cardInset, cardW-cardInset, cardH-cardInset)
105+ for x := frame.Min.X; x < frame.Max.X; x++ {
106+ img.Set(x, frame.Min.Y, border)
107+ img.Set(x, frame.Min.Y+1, border)
108+ img.Set(x, frame.Max.Y-1, border)
109+ img.Set(x, frame.Max.Y-2, border)
110+ }
111+ for y := frame.Min.Y; y < frame.Max.Y; y++ {
112+ img.Set(frame.Min.X, y, border)
113+ img.Set(frame.Min.X+1, y, border)
114+ img.Set(frame.Max.X-1, y, border)
115+ img.Set(frame.Max.X-2, y, border)
116+ }
117+
118+ width := cardW - cardLeft - cardInset - 56
119+ size := 112
120+ if len(title) > 12 {
121+ size = 76
122+ }
123+ cardText(img, cardLeft, 232, size, cardFits(title, size, width), color.Black)
124+ if subtitle != "" {
125+ cardText(img, cardLeft+2, 296, 28, cardFits(subtitle, 28, width), color.RGBA{0x55, 0x55, 0x55, 0xff})
126+ }
127+ for x := cardLeft; x < cardW-cardInset-56; x++ {
128+ img.Set(x, 360, border)
129+ img.Set(x, 361, border)
130+ }
131+ cardText(img, cardLeft+2, 530, 26, cardFits(foot, 26, width), color.Black)
132+ return img, nil
133+ }
134+
135+ // serveCard draws the share image for a page, so a link to it says whose it is.
136+ func (s *Server) serveCard(w http.ResponseWriter, r *http.Request, title, subtitle, foot string) {
137+ img, err := drawCard(title, subtitle, foot)
138+ if err != nil {
139+ s.oops(w, r, err)
140+ return
141+ }
142+ w.Header().Set("Content-Type", "image/png")
143+ w.Header().Set("Cache-Control", "public, max-age=300")
144+ png.Encode(w, img)
145+ }
146+
147+ // cardHost is the domain a card signs itself with, without the scheme.
148+ func cardHost(site string) string {
149+ host := strings.TrimPrefix(strings.TrimPrefix(site, "https://"), "http://")
150+ return strings.TrimSuffix(host, "/")
151+ }
152+
153+ // serveNamedCard draws the share image for an account or a repository, and refuses anything else.
154+ func (s *Server) serveNamedCard(w http.ResponseWriter, r *http.Request, parts []string) {
155+ foot := cardHost(s.Cfg.Server.ExternalURL)
156+ switch len(parts) {
157+ case 1:
158+ owner := parts[0]
159+ if !gitx.ValidName(owner) {
160+ s.notFound(w, r)
161+ return
162+ }
163+ if _, err := s.DB.Account(r.Context(), owner); err != nil {
164+ s.notFound(w, r)
165+ return
166+ }
167+ // Only what this reader may see, so a card cannot count somebody's private work.
168+ names, _ := s.DB.ReposOf(r.Context(), owner)
169+ n := 0
170+ for _, repo := range names {
171+ if _, err := s.Transport.Open(r.Context(), owner, repo, s.viewer(r), transport.Read); err == nil {
172+ n++
173+ }
174+ }
175+ s.serveCard(w, r, owner, plural(n, "repository", "repositories"), foot+"/"+owner)
176+ case 2:
177+ owner, name := parts[0], parts[1]
178+ if !gitx.ValidName(owner) || !gitx.ValidRepoName(name) {
179+ s.notFound(w, r)
180+ return
181+ }
182+ // A card is public, so it must not say a private repository exists.
183+ res, err := s.Transport.Open(r.Context(), owner, name, s.viewer(r), transport.Read)
184+ if err != nil || res == nil {
185+ s.notFound(w, r)
186+ return
187+ }
188+ // The count, not the description, so the line is the same shape as the profile card's.
189+ n := 0
190+ if out, err := gitx.Run(r.Context(), res.Dir, "rev-list", "--count", "HEAD"); err == nil {
191+ n, _ = strconv.Atoi(strings.TrimSpace(out))
192+ }
193+ s.serveCard(w, r, owner+"/"+name, plural(n, "commit", "commits"), foot+"/"+owner+"/"+name)
194+ default:
195+ s.notFound(w, r)
196+ }
197+ }
@@ -0,0 +1,109 @@
1+ package httpd
2+
3+ import (
4+ "html/template"
5+ "reflect"
6+ "testing"
7+ "text/template/parse"
8+ )
9+
10+ // A branch nobody renders hides a field nobody supplies, which published a private repository once.
11+ func TestEveryTemplateFieldExistsOnItsData(t *testing.T) {
12+ for name, c := range renderCases() {
13+ page, ok := pages[name]
14+ if !ok {
15+ continue
16+ }
17+ // The walk starts at layout, because a page file is only its define blocks.
18+ layout := page.Lookup("layout")
19+ if layout == nil {
20+ t.Errorf("%s has no layout", name)
21+ continue
22+ }
23+ typ := reflect.TypeOf(c.data)
24+ for _, field := range topLevelFields(layout.Tree, page) {
25+ if !hasField(typ, field) {
26+ t.Errorf("%s asks for .%s and its data has no such field, so a branch that "+
27+ "reaches it renders nothing and says nothing", name, field)
28+ }
29+ }
30+ }
31+ }
32+
33+ // topLevelFields names every field read against the page's own dot, so range bodies are left alone.
34+ func topLevelFields(tree *parse.Tree, page *template.Template) []string {
35+ if tree == nil {
36+ return nil
37+ }
38+ var out []string
39+ var walk func(parse.Node)
40+ walk = func(n parse.Node) {
41+ switch t := n.(type) {
42+ case nil:
43+ return
44+ case *parse.ListNode:
45+ if t == nil {
46+ return
47+ }
48+ for _, c := range t.Nodes {
49+ walk(c)
50+ }
51+ case *parse.ActionNode:
52+ walk(t.Pipe)
53+ case *parse.IfNode:
54+ walk(t.Pipe)
55+ walk(t.List)
56+ walk(t.ElseList)
57+ case *parse.PipeNode:
58+ if t == nil {
59+ return
60+ }
61+ for _, c := range t.Cmds {
62+ walk(c)
63+ }
64+ case *parse.CommandNode:
65+ for _, a := range t.Args {
66+ walk(a)
67+ }
68+ case *parse.FieldNode:
69+ if len(t.Ident) > 0 {
70+ out = append(out, t.Ident[0])
71+ }
72+ case *parse.TemplateNode:
73+ // Only follow a template invoked with the same dot, since another argument is another type.
74+ if isDot(t.Pipe) {
75+ if inner := page.Lookup(t.Name); inner != nil {
76+ out = append(out, topLevelFields(inner.Tree, page)...)
77+ }
78+ }
79+ }
80+ }
81+ walk(tree.Root)
82+ return out
83+ }
84+
85+ // isDot reports whether a template was invoked with the dot it was already looking at.
86+ func isDot(p *parse.PipeNode) bool {
87+ if p == nil || len(p.Cmds) != 1 || len(p.Cmds[0].Args) != 1 {
88+ return false
89+ }
90+ _, ok := p.Cmds[0].Args[0].(*parse.DotNode)
91+ return ok
92+ }
93+
94+ // hasField looks through embedded structs, which is how every page carries its chrome.
95+ func hasField(typ reflect.Type, name string) bool {
96+ if typ == nil {
97+ return false
98+ }
99+ for typ.Kind() == reflect.Pointer {
100+ typ = typ.Elem()
101+ }
102+ if typ.Kind() != reflect.Struct {
103+ return false
104+ }
105+ if _, ok := typ.FieldByName(name); ok {
106+ return true
107+ }
108+ return false
109+ }
@@ -0,0 +1,228 @@
1+ // Package httpd serves the interface and git over http, per appendix C and chapter 41.4.
2+ package httpd
3+
4+ import (
5+ "compress/gzip"
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "net/http"
11+ "strings"
12+
13+ "github.com/barerepo/server/internal/gitx"
14+ "github.com/barerepo/server/internal/hook"
15+ "github.com/barerepo/server/internal/transport"
16+ )
17+
18+ // gitRoute is a parsed git-over-http request.
19+ type gitRoute struct {
20+ Owner, Name string
21+ Service string // git-upload-pack or git-receive-pack
22+ Advertise bool // the GET /info/refs half
23+ // LFS marks the large file endpoints, which this server answers only to refuse.
24+ LFS bool
25+ }
26+
27+ // parseGitPath accepts the path with and without `.git`, because both reach production.
28+ func parseGitPath(p string) (gitRoute, bool) {
29+ p = strings.TrimPrefix(p, "/")
30+ parts := strings.Split(p, "/")
31+ if len(parts) < 3 {
32+ return gitRoute{}, false
33+ }
34+ r := gitRoute{Owner: parts[0], Name: strings.TrimSuffix(parts[1], ".git")}
35+ rest := strings.Join(parts[2:], "/")
36+ switch rest {
37+ case "info/refs":
38+ r.Advertise = true
39+ case "git-upload-pack", "git-receive-pack":
40+ r.Service = rest
41+ default:
42+ // A git-lfs client asks here, and a 404 tells it the repository is missing, which is a lie.
43+ if rest == "info/lfs" || strings.HasPrefix(rest, "info/lfs/") {
44+ r.LFS = true
45+ break
46+ }
47+ return gitRoute{}, false
48+ }
49+ if !gitx.ValidName(r.Owner) || !gitx.ValidRepoName(r.Name) {
50+ return gitRoute{}, false
51+ }
52+ return r, true
53+ }
54+
55+ // refuseLFS answers in the shape git-lfs reads, so the client prints the reason instead of a 404.
56+ func (s *Server) refuseLFS(w http.ResponseWriter) {
57+ w.Header().Set("Content-Type", "application/vnd.git-lfs+json")
58+ w.WriteHeader(http.StatusNotImplemented)
59+ msg := "large file storage is off on this server. keep binaries out of git, " +
60+ "or run your own barerepo where you decide. chapter 20."
61+ if s.Cfg.Behavior.AllowLFS {
62+ msg = "large file storage is switched on in the config and is not built yet, " +
63+ "so nothing here can serve it. chapter 20.3."
64+ }
65+ body, _ := json.Marshal(struct {
66+ Message string `json:"message"`
67+ }{msg})
68+ w.Write(body)
69+ }
70+
71+ // serveGit handles both halves of smart http.
72+ func (s *Server) serveGit(w http.ResponseWriter, req *http.Request, r gitRoute) {
73+ ctx := req.Context()
74+
75+ if r.LFS {
76+ s.refuseLFS(w)
77+ return
78+ }
79+ if r.Advertise {
80+ r.Service = req.URL.Query().Get("service")
81+ if r.Service != "git-upload-pack" && r.Service != "git-receive-pack" {
82+ // Dumb http asks with no service, and barerepo does not serve it to anyone.
83+ http.Error(w, "this server speaks the smart http protocol only", http.StatusForbidden)
84+ return
85+ }
86+ }
87+ // One push is two requests. Only the second one may create anything.
88+ intent := transport.Read
89+ switch {
90+ case r.Service == "git-receive-pack" && r.Advertise:
91+ intent = transport.Announce
92+ case r.Service == "git-receive-pack":
93+ intent = transport.Write
94+ }
95+
96+ user, scope, err := s.authenticate(ctx, req)
97+ if err != nil {
98+ s.askForCredentials(w)
99+ return
100+ }
101+ // A scoped token is a credential for its own repository and nothing else, so anywhere else the reader is whoever an anonymous one would be. 15.
102+ if scope != "" && scope != r.Owner+"/"+r.Name {
103+ user = ""
104+ }
105+ // Ask for the credential before advertising, or the push is refused later for the wrong reason.
106+ if intent != transport.Read && user == "" {
107+ s.askForCredentials(w)
108+ return
109+ }
110+
111+ res, err := s.Transport.Open(ctx, r.Owner, r.Name, user, intent)
112+ var moved transport.Redirect
113+ switch {
114+ case errors.As(err, &moved):
115+ // A 301, which git follows on both transports, so an existing clone keeps working. 44.2.
116+ http.Redirect(w, req, s.movedPath(req, moved), http.StatusMovedPermanently)
117+ return
118+ case errors.Is(err, transport.ErrNotFound):
119+ if user == "" {
120+ // It may be private, so ask, rather than hand back a 404 nobody can act on.
121+ s.askForCredentials(w)
122+ return
123+ }
124+ http.NotFound(w, req)
125+ return
126+ case errors.Is(err, transport.ErrDenied):
127+ http.Error(w, err.Error(), http.StatusForbidden)
128+ return
129+ case err != nil:
130+ s.oops(w, req, err)
131+ return
132+ }
133+
134+ if r.Advertise {
135+ s.advertise(w, req, res.Dir, r.Service)
136+ return
137+ }
138+ s.pack(w, req, res, user, r.Service)
139+ }
140+
141+ // advertise answers GET /info/refs?service=...
142+ func (s *Server) advertise(w http.ResponseWriter, req *http.Request, dir, service string) {
143+ w.Header().Set("Content-Type", "application/x-"+service+"-advertisement")
144+ noCache(w)
145+
146+ // A pkt-line banner first, except under v2, where git writes the whole body itself.
147+ proto := gitProtocol(req)
148+ if proto == "" {
149+ if err := writePktLine(w, "# service="+service+"\n"); err != nil {
150+ return
151+ }
152+ if _, err := w.Write([]byte("0000")); err != nil {
153+ return
154+ }
155+ }
156+ verb := strings.TrimPrefix(service, "git-")
157+ err := gitx.Pipe(req.Context(), dir, nil, w, io.Discard, protoEnv(proto),
158+ verb, "--stateless-rpc", "--advertise-refs", ".")
159+ if err != nil {
160+ s.log(req, fmt.Errorf("%s advertise: %w", service, err))
161+ }
162+ }
163+
164+ // pack answers POST /git-upload-pack and POST /git-receive-pack.
165+ func (s *Server) pack(w http.ResponseWriter, req *http.Request, res *transport.Result, user, service string) {
166+ if ct := req.Header.Get("Content-Type"); ct != "application/x-"+service+"-request" {
167+ http.Error(w, "unexpected content type", http.StatusBadRequest)
168+ return
169+ }
170+ body := io.Reader(req.Body)
171+ if strings.Contains(req.Header.Get("Content-Encoding"), "gzip") {
172+ gz, err := gzip.NewReader(req.Body)
173+ if err != nil {
174+ http.Error(w, "malformed request body", http.StatusBadRequest)
175+ return
176+ }
177+ defer gz.Close()
178+ body = gz
179+ }
180+
181+ w.Header().Set("Content-Type", "application/x-"+service+"-result")
182+ noCache(w)
183+
184+ // git passes the environment through untouched, which is how pre-receive knows who is asking.
185+ env := protoEnv(gitProtocol(req))
186+ env = append(env, hook.Env{
187+ Account: user, Owner: res.Owner, Name: res.Name, Dir: res.Dir,
188+ Created: res.Created,
189+ URL: s.Cfg.Server.ExternalURL + "/" + res.Owner + "/" + res.Name,
190+ Config: s.Cfg.Path,
191+ }.Vars()...)
192+
193+ verb := strings.TrimPrefix(service, "git-")
194+ // Hook output rides this same connection, which is how a rejection reaches the terminal.
195+ err := gitx.Pipe(req.Context(), res.Dir, body, w, io.Discard, env,
196+ verb, "--stateless-rpc", ".")
197+ if err != nil {
198+ s.log(req, fmt.Errorf("%s: %w", service, err))
199+ }
200+ }
201+
202+ // gitProtocol checks the request is one of git's two shapes, since it reaches an environment variable.
203+ func gitProtocol(req *http.Request) string {
204+ v := req.Header.Get("Git-Protocol")
205+ if v == "version=2" || v == "version=1" {
206+ return v
207+ }
208+ return ""
209+ }
210+
211+ func protoEnv(proto string) []string {
212+ if proto == "" {
213+ return nil
214+ }
215+ return []string{"GIT_PROTOCOL=" + proto}
216+ }
217+
218+ // writePktLine writes one git pkt-line: four hex length bytes, then the body.
219+ func writePktLine(w io.Writer, s string) error {
220+ _, err := fmt.Fprintf(w, "%04x%s", len(s)+4, s)
221+ return err
222+ }
223+
224+ func noCache(w http.ResponseWriter) {
225+ w.Header().Set("Expires", "Fri, 01 Jan 1980 00:00:00 GMT")
226+ w.Header().Set("Pragma", "no-cache")
227+ w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate")
228+ }
@@ -0,0 +1,145 @@
1+ package httpd
2+
3+ import (
4+ "net/http"
5+ "os"
6+ "path/filepath"
7+ "runtime"
8+ "strings"
9+ )
10+
11+ // runnerBinary is github.com/barerepo/runner, which ships beside the server and is handed out by it.
12+ const runnerBinary = "barerepo-runner"
13+
14+ // platform is what this binary was built for, which is the only one the server can hand out.
15+ func platform() string { return runtime.GOOS + "/" + runtime.GOARCH }
16+
17+ // serveRunnerScript is the other half of the line on the setup page, which 404'd until now.
18+ func (s *Server) serveRunnerScript(w http.ResponseWriter, r *http.Request) {
19+ w.Header().Set("Content-Type", "text/x-shellscript; charset=utf-8")
20+ // A script is not a page, and a stale copy of an install script is a support ticket.
21+ w.Header().Set("Cache-Control", "no-store")
22+ w.Write([]byte(runnerScript(s.Cfg.Server.ExternalURL)))
23+ }
24+
25+ // serveRunnerPS1 is the same script for the platform that has no sh. Chapter 15 shows all three.
26+ func (s *Server) serveRunnerPS1(w http.ResponseWriter, r *http.Request) {
27+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
28+ w.Header().Set("Cache-Control", "no-store")
29+ w.Write([]byte(runnerPS1(s.Cfg.Server.ExternalURL)))
30+ }
31+
32+ // serveRunnerBinary hands out the runner installed beside this server, so the pair cannot skew.
33+ func (s *Server) serveRunnerBinary(w http.ResponseWriter, r *http.Request) {
34+ // The token is the whole authorisation, because the script has one and a stranger does not.
35+ if _, err := s.runnerAuth(r, r.URL.Query().Get("token")); err != nil {
36+ http.Error(w, "that token is not valid", http.StatusUnauthorized)
37+ return
38+ }
39+ self, err := os.Executable()
40+ if err != nil {
41+ http.Error(w, "this server cannot find its own binary", http.StatusInternalServerError)
42+ return
43+ }
44+ // The runner is its own program now, installed beside this one, so no path has to be configured.
45+ f, err := os.Open(filepath.Join(filepath.Dir(self), runnerBinary))
46+ if err != nil {
47+ http.Error(w, "no "+runnerBinary+" is installed beside this server", http.StatusNotFound)
48+ return
49+ }
50+ defer f.Close()
51+ info, err := f.Stat()
52+ if err != nil {
53+ http.Error(w, "this server cannot stat its own binary", http.StatusInternalServerError)
54+ return
55+ }
56+ w.Header().Set("Content-Type", "application/octet-stream")
57+ w.Header().Set("Barerepo-Platform", platform())
58+ http.ServeContent(w, r, runnerBinary, info.ModTime(), f)
59+ }
60+
61+ // runnerScript builds the posix half. The token arrives as $1, from sh -s <token>.
62+ func runnerScript(external string) string {
63+ base := strings.TrimRight(external, "/")
64+ return `#!/bin/sh
65+ # barerepo runner install. one paste, one machine attached. chapter 15.
66+ set -eu
67+
68+ TOKEN="${1:-}"
69+ if [ -z "$TOKEN" ]; then
70+ echo "usage: curl -sL ` + base + `/runner.sh | sh -s <token>" >&2
71+ exit 1
72+ fi
73+
74+ SERVER="` + base + `"
75+ HAVE="` + platform() + `"
76+
77+ OS=$(uname -s | tr '[:upper:]' '[:lower:]')
78+ ARCH=$(uname -m)
79+ case "$ARCH" in
80+ x86_64 | amd64) ARCH=amd64 ;;
81+ aarch64 | arm64) ARCH=arm64 ;;
82+ esac
83+ WANT="$OS/$ARCH"
84+
85+ # The server can only hand out the binary it is running, so say so rather than install the wrong one.
86+ if [ "$WANT" != "$HAVE" ]; then
87+ echo "this barerepo runs $HAVE and cannot hand you a $WANT binary." >&2
88+ echo "build one and run it yourself:" >&2
89+ echo " GOOS=$OS GOARCH=$ARCH go build -o barerepo-runner ./cmd/barerepo-runner" >&2
90+ echo " ./barerepo-runner $TOKEN --server $SERVER" >&2
91+ exit 1
92+ fi
93+
94+ DIR="${BAREREPO_BIN_DIR:-$HOME/.local/bin}"
95+ mkdir -p "$DIR"
96+
97+ # Downloaded beside the target and moved, so an interrupted install leaves no half a binary.
98+ TMP="$DIR/.barerepo-runner.$$"
99+ trap 'rm -f "$TMP"' EXIT INT TERM
100+ if command -v curl >/dev/null 2>&1; then
101+ curl -fsSL "$SERVER/runner/binary?token=$TOKEN" -o "$TMP"
102+ elif command -v wget >/dev/null 2>&1; then
103+ wget -qO "$TMP" "$SERVER/runner/binary?token=$TOKEN"
104+ else
105+ echo "neither curl nor wget is installed." >&2
106+ exit 1
107+ fi
108+ chmod +x "$TMP"
109+ mv -f "$TMP" "$DIR/barerepo-runner"
110+ trap - EXIT INT TERM
111+
112+ echo "the runner is at $DIR/barerepo-runner. attaching."
113+ exec "$DIR/barerepo-runner" "$TOKEN" --server "$SERVER"
114+ `
115+ }
116+
117+ // runnerPS1 is the windows half, which the mockup shows beside the others rather than behind a tab.
118+ func runnerPS1(external string) string {
119+ base := strings.TrimRight(external, "/")
120+ return `# barerepo runner install. one paste, one machine attached. chapter 15.
121+ $ErrorActionPreference = "Stop"
122+
123+ function barerepo-runner {
124+ param([Parameter(Mandatory=$true)][string]$Token)
125+
126+ $server = "` + base + `"
127+ $have = "` + platform() + `"
128+
129+ $arch = if ([Environment]::Is64BitOperatingSystem) { "amd64" } else { "386" }
130+ $want = "windows/$arch"
131+ if ($want -ne $have) {
132+ Write-Error "this barerepo runs $have and cannot hand you a $want binary. build one: GOOS=windows GOARCH=$arch go build -o barerepo-runner.exe ./cmd/barerepo-runner"
133+ return
134+ }
135+
136+ $dir = Join-Path $env:LOCALAPPDATA "barerepo"
137+ New-Item -ItemType Directory -Force -Path $dir | Out-Null
138+ $exe = Join-Path $dir "barerepo-runner.exe"
139+ Invoke-WebRequest -Uri "$server/runner/binary?token=$Token" -OutFile $exe
140+
141+ Write-Host "the runner is at $exe. attaching."
142+ & $exe $Token --server $server
143+ }
144+ `
145+ }
@@ -0,0 +1,79 @@
1+ package httpd
2+
3+ import (
4+ "os/exec"
5+ "strings"
6+ "testing"
7+ )
8+
9+ // Chapter 15's whole flow is one line, and the line pointed at a 404 until this existed.
10+ func TestRunnerScriptIsRealShellAndCarriesTheToken(t *testing.T) {
11+ script := runnerScript("https://barerepo.example/")
12+
13+ // The trailing slash must not survive into the urls the script builds.
14+ if strings.Contains(script, "barerepo.example//") {
15+ t.Error("the external url's trailing slash reached a request path")
16+ }
17+ for _, want := range []string{
18+ // The token arrives as $1, because the setup page says sh -s <token>.
19+ `TOKEN="${1:-}"`,
20+ `SERVER="https://barerepo.example"`,
21+ "$SERVER/runner/binary?token=$TOKEN",
22+ `exec "$DIR/barerepo-runner" "$TOKEN" --server "$SERVER"`,
23+ // Chapter 15: no second step, so the script attaches rather than telling you to.
24+ "\"$TOKEN\"",
25+ } {
26+ if !strings.Contains(script, want) {
27+ t.Errorf("the install script is missing %q", want)
28+ }
29+ }
30+
31+ // A script served to be piped into sh has to parse in sh.
32+ cmd := exec.Command("sh", "-n")
33+ cmd.Stdin = strings.NewReader(script)
34+ if out, err := cmd.CombinedOutput(); err != nil {
35+ t.Fatalf("sh -n rejected the install script: %v\n%s", err, out)
36+ }
37+
38+ // With no token it must say the line to paste, not download anything.
39+ cmd = exec.Command("sh", "-s")
40+ cmd.Stdin = strings.NewReader(script)
41+ out, err := cmd.CombinedOutput()
42+ if err == nil {
43+ t.Error("the script ran with no token instead of refusing")
44+ }
45+ if !strings.Contains(string(out), "usage: curl -sL https://barerepo.example/runner.sh") {
46+ t.Errorf("the refusal does not show the line to paste: %s", out)
47+ }
48+ }
49+
50+ // The server can only hand out the binary it is running, and a wrong-arch binary is worse than none.
51+ func TestRunnerScriptRefusesAnotherPlatform(t *testing.T) {
52+ script := runnerScript("https://barerepo.example")
53+ if !strings.Contains(script, `HAVE="`+platform()+`"`) {
54+ t.Error("the script does not name the platform this server can serve")
55+ }
56+ if !strings.Contains(script, `if [ "$WANT" != "$HAVE" ]`) {
57+ t.Error("the script does not compare the asking platform with the served one")
58+ }
59+ // It has to leave the reader a way through rather than only an error.
60+ for _, want := range []string{"go build -o barerepo-runner ./cmd/barerepo-runner", "$TOKEN --server $SERVER"} {
61+ if !strings.Contains(script, want) {
62+ t.Errorf("the mismatch message does not tell the reader what to run instead: %q", want)
63+ }
64+ }
65+ }
66+
67+ // Chapter 15 shows three platforms at once, so the windows half is not allowed to be a stub.
68+ func TestRunnerPS1CarriesTheTokenAndTheServer(t *testing.T) {
69+ ps := runnerPS1("https://barerepo.example/")
70+ for _, want := range []string{
71+ `$server = "https://barerepo.example"`,
72+ "$server/runner/binary?token=$Token",
73+ "$Token --server $server",
74+ } {
75+ if !strings.Contains(ps, want) {
76+ t.Errorf("the powershell install is missing %q", want)
77+ }
78+ }
79+ }
@@ -0,0 +1,45 @@
1+ package httpd
2+
3+ import (
4+ "encoding/json"
5+ "net/http/httptest"
6+ "strings"
7+ "testing"
8+
9+ "github.com/barerepo/server/internal/config"
10+ )
11+
12+ func TestALargeFileClientIsToldWhyRatherThanGivenA404(t *testing.T) {
13+ for _, path := range []string{
14+ "/john/johnbot/info/lfs/objects/batch",
15+ "/john/johnbot.git/info/lfs/objects/batch",
16+ "/john/johnbot/info/lfs",
17+ } {
18+ r, ok := parseGitPath(path)
19+ if !ok || !r.LFS {
20+ t.Fatalf("parseGitPath(%q) did not recognise a large file request", path)
21+ }
22+ if r.Owner != "john" || r.Name != "johnbot" {
23+ t.Errorf("parseGitPath(%q) read %s/%s", path, r.Owner, r.Name)
24+ }
25+ }
26+
27+ s := &Server{Cfg: config.Default()}
28+ w := httptest.NewRecorder()
29+ s.refuseLFS(w)
30+ if w.Code != 501 {
31+ t.Errorf("answered %d, want 501", w.Code)
32+ }
33+ if got := w.Header().Get("Content-Type"); got != "application/vnd.git-lfs+json" {
34+ t.Errorf("content type is %q, which git-lfs will not read as its own", got)
35+ }
36+ var body struct {
37+ Message string `json:"message"`
38+ }
39+ if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
40+ t.Fatalf("the body is not json git-lfs can read: %v", err)
41+ }
42+ if !strings.Contains(body.Message, "off on this server") {
43+ t.Errorf("the message does not say why: %q", body.Message)
44+ }
45+ }
@@ -0,0 +1,65 @@
1+ package httpd
2+
3+ import (
4+ "go/ast"
5+ "go/parser"
6+ "go/token"
7+ "strconv"
8+ "strings"
9+ "testing"
10+
11+ "github.com/barerepo/server/internal/gitx"
12+ )
13+
14+ // Chapter 42.5: a route with no reservation is one an account can shadow, and it says a test must catch it.
15+ func TestEveryTopLevelRouteIsAReservedName(t *testing.T) {
16+ fset := token.NewFileSet()
17+ file, err := parser.ParseFile(fset, "web.go", nil, 0)
18+ if err != nil {
19+ t.Fatal(err)
20+ }
21+
22+ seen := map[string]bool{}
23+ ast.Inspect(file, func(n ast.Node) bool {
24+ be, ok := n.(*ast.BinaryExpr)
25+ if !ok || be.Op != token.EQL {
26+ return true
27+ }
28+ lit, ok := be.Y.(*ast.BasicLit)
29+ if !ok || lit.Kind != token.STRING || !isRequestPath(be.X) {
30+ return true
31+ }
32+ path, err := strconv.Unquote(lit.Value)
33+ if err != nil || !strings.HasPrefix(path, "/") {
34+ return true
35+ }
36+ first, _, _ := strings.Cut(strings.TrimPrefix(path, "/"), "/")
37+ if first != "" {
38+ seen[first] = true
39+ }
40+ return true
41+ })
42+
43+ if len(seen) < 8 {
44+ t.Fatalf("only found %d top level routes, so this test is reading the wrong thing", len(seen))
45+ }
46+ for name := range seen {
47+ // A name holding a dot is a file and not something an account could ever be called.
48+ if strings.Contains(name, ".") {
49+ continue
50+ }
51+ if !gitx.Reserved(name) {
52+ t.Errorf("/%s is a route and not a reserved name, so an account can shadow it", name)
53+ }
54+ }
55+ }
56+
57+ // isRequestPath reports whether an expression is r.URL.Path, which is what the route switch compares.
58+ func isRequestPath(e ast.Expr) bool {
59+ sel, ok := e.(*ast.SelectorExpr)
60+ if !ok || sel.Sel.Name != "Path" {
61+ return false
62+ }
63+ inner, ok := sel.X.(*ast.SelectorExpr)
64+ return ok && inner.Sel.Name == "URL"
65+ }
@@ -0,0 +1,349 @@
1+ package httpd
2+
3+ import (
4+ "encoding/json"
5+ "errors"
6+ "net/http"
7+ "strconv"
8+ "strings"
9+ "time"
10+
11+ "github.com/barerepo/server/internal/artifact"
12+ "github.com/barerepo/server/internal/gitx"
13+ "github.com/barerepo/server/internal/proposal"
14+ "github.com/barerepo/server/internal/repo"
15+ "github.com/barerepo/server/internal/run"
16+ "github.com/barerepo/server/internal/store"
17+ "github.com/barerepo/server/internal/token"
18+ )
19+
20+ // pollWait holds a poll open, since the runner dials out and needs no inbound port. 15.
21+ const pollWait = 30 * time.Second
22+
23+ // pollTick is how often a held poll looks for work.
24+ const pollTick = time.Second
25+
26+ // runnerAuth reads the token and names its repository, since chapter 15 scopes one to each.
27+ func (s *Server) runnerAuth(r *http.Request, tok string) (*store.Token, error) {
28+ if tok == "" {
29+ return nil, errors.New("no token")
30+ }
31+ return s.DB.AccountForToken(r.Context(), token.Runner, tok)
32+ }
33+
34+ type attachRequest struct {
35+ Token string `json:"token"`
36+ Hostname string `json:"hostname"`
37+ OS string `json:"os"`
38+ Arch string `json:"arch"`
39+ Labels []string `json:"labels"`
40+ }
41+
42+ type attachResponse struct {
43+ RunnerID int64 `json:"runner_id"`
44+ PollInterval int `json:"poll_interval"`
45+ }
46+
47+ func (s *Server) serveRunnerAttach(w http.ResponseWriter, r *http.Request) {
48+ var req attachRequest
49+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&req); err != nil {
50+ http.Error(w, "malformed request", http.StatusBadRequest)
51+ return
52+ }
53+ t, err := s.runnerAuth(r, req.Token)
54+ if err != nil {
55+ http.Error(w, "that token is not valid", http.StatusUnauthorized)
56+ return
57+ }
58+ if req.Hostname == "" {
59+ http.Error(w, "a runner needs a hostname", http.StatusBadRequest)
60+ return
61+ }
62+ runner, err := s.DB.AttachRunner(r.Context(), t.ID, t.Scope,
63+ req.Hostname, req.OS, req.Arch, req.Labels)
64+ if err != nil {
65+ s.oops(w, r, err)
66+ return
67+ }
68+ writeJSON(w, attachResponse{RunnerID: runner.ID, PollInterval: int(pollWait.Seconds())})
69+ }
70+
71+ type jobResponse struct {
72+ JobID int64 `json:"job_id"`
73+ Repo string `json:"repo"`
74+ Ref string `json:"ref"`
75+ SHA string `json:"sha"`
76+ Command string `json:"command"`
77+ Image string `json:"image,omitempty"`
78+ CloneURL string `json:"clone_url"`
79+ JobToken string `json:"job_token"`
80+ }
81+
82+ // serveRunnerPoll holds the request open until there is work or the wait ends.
83+ func (s *Server) serveRunnerPoll(w http.ResponseWriter, r *http.Request) {
84+ t, err := s.runnerAuth(r, r.URL.Query().Get("token"))
85+ if err != nil {
86+ http.Error(w, "that token is not valid", http.StatusUnauthorized)
87+ return
88+ }
89+ id, _ := strconv.ParseInt(r.URL.Query().Get("id"), 10, 64)
90+ // The id is the caller's to say, so it has to name a machine this token attached. 15.
91+ runner, err := s.runnerOf(r, t, id)
92+ if err != nil || runner.Repo != t.Scope {
93+ http.Error(w, "attach first", http.StatusNotFound)
94+ return
95+ }
96+ if err := s.DB.SeeRunner(r.Context(), runner.ID); err != nil {
97+ s.oops(w, r, err)
98+ return
99+ }
100+
101+ deadline := time.After(pollWait)
102+ tick := time.NewTicker(pollTick)
103+ defer tick.Stop()
104+ for {
105+ job, err := s.DB.TakeJob(r.Context(), runner.Repo, *runner)
106+ if err != nil {
107+ s.oops(w, r, err)
108+ return
109+ }
110+ if job != nil {
111+ s.handOut(w, r, t, job)
112+ return
113+ }
114+ select {
115+ case <-r.Context().Done():
116+ return
117+ case <-deadline:
118+ w.WriteHeader(http.StatusNoContent)
119+ return
120+ case <-tick.C:
121+ }
122+ }
123+ }
124+
125+ // handOut gives a job to a runner, with a token scoped to that one job.
126+ func (s *Server) handOut(w http.ResponseWriter, r *http.Request, t *store.Token, job *store.Job) {
127+ // The long-lived token stays on the runner, and a job carries one that dies with it.
128+ jobToken, _, err := s.DB.CreateToken(r.Context(), token.Git, t.Account, job.Repo, jobLabel(job.ID))
129+ if err != nil {
130+ s.oops(w, r, err)
131+ return
132+ }
133+ owner, name, _ := strings.Cut(job.Repo, "/")
134+ writeJSON(w, jobResponse{
135+ JobID: job.ID,
136+ Repo: job.Repo,
137+ Ref: job.Ref,
138+ SHA: job.SHA,
139+ Command: job.Command,
140+ Image: job.Image,
141+ CloneURL: s.Cfg.Server.ExternalURL + "/" + owner + "/" + name,
142+ JobToken: jobToken,
143+ })
144+ }
145+
146+ type logRequest struct {
147+ Token string `json:"token"`
148+ JobID int64 `json:"job_id"`
149+ Seq int `json:"seq"`
150+ Chunk string `json:"chunk"`
151+ }
152+
153+ func (s *Server) serveRunnerLog(w http.ResponseWriter, r *http.Request) {
154+ var req logRequest
155+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
156+ http.Error(w, "malformed request", http.StatusBadRequest)
157+ return
158+ }
159+ _, runner, ok := s.jobRunner(w, r, req.Token, req.JobID)
160+ if !ok {
161+ return
162+ }
163+ if err := s.DB.AppendLog(r.Context(), req.JobID, runner.ID, req.Chunk); err != nil {
164+ http.Error(w, "that job is not running", http.StatusConflict)
165+ return
166+ }
167+ w.WriteHeader(http.StatusNoContent)
168+ }
169+
170+ type doneRequest struct {
171+ Token string `json:"token"`
172+ JobID int64 `json:"job_id"`
173+ ExitCode int `json:"exit_code"`
174+ Duration int `json:"duration"`
175+ }
176+
177+ // serveRunnerArtifact attaches one file to a release, which is chapter 22.5's job token permission.
178+ func (s *Server) serveRunnerArtifact(w http.ResponseWriter, r *http.Request) {
179+ tok := r.Header.Get("Barerepo-Token")
180+ jobID, _ := strconv.ParseInt(r.Header.Get("Barerepo-Job"), 10, 64)
181+ tag := r.Header.Get("Barerepo-Tag")
182+ file := r.Header.Get("Barerepo-File")
183+ job, ok := s.jobBearer(r, tok, jobID)
184+ if !ok {
185+ http.Error(w, "that token is not this job's", http.StatusUnauthorized)
186+ return
187+ }
188+ owner, name, ok := strings.Cut(job.Repo, "/")
189+ if !ok {
190+ http.Error(w, "that job has no repository", http.StatusConflict)
191+ return
192+ }
193+ // The token is scoped to one job and the job to one repository, so the tag must be in it. 22.5.
194+ dir, err := repo.Dir(s.Cfg.Paths.Repos, owner, name)
195+ if err != nil {
196+ s.oops(w, r, err)
197+ return
198+ }
199+ if _, err := gitx.ResolveRef(dir, "refs/tags/"+tag); err != nil {
200+ http.Error(w, "there is no release tagged "+tag, http.StatusNotFound)
201+ return
202+ }
203+ n, err := artifact.Put(s.Cfg.Paths.Artifacts, owner, name, tag, file, r.Body)
204+ if err != nil {
205+ http.Error(w, err.Error(), http.StatusBadRequest)
206+ return
207+ }
208+ if s.Log != nil {
209+ s.Log.Info("attached a release artifact",
210+ "repo", job.Repo, "tag", tag, "file", file, "bytes", n)
211+ }
212+ w.WriteHeader(http.StatusNoContent)
213+ }
214+
215+ // jobBearer authorizes a job's own token, which chapter 22.5 scopes to one repository and one job.
216+ func (s *Server) jobBearer(r *http.Request, tok string, jobID int64) (*store.Job, bool) {
217+ if tok == "" || jobID == 0 {
218+ return nil, false
219+ }
220+ t, err := s.DB.AccountForToken(r.Context(), token.Git, tok)
221+ if err != nil {
222+ return nil, false
223+ }
224+ job, err := s.DB.Job(r.Context(), jobID)
225+ if err != nil || job == nil || job.Repo != t.Scope {
226+ return nil, false
227+ }
228+ // A requeued or finished job is over, and chapter 15 ends the token with it.
229+ if job.State != store.JobRunning {
230+ return nil, false
231+ }
232+ // The label is what the poll wrote, and it is what binds this token to this one job.
233+ if t.Label != jobLabel(jobID) {
234+ return nil, false
235+ }
236+ return job, true
237+ }
238+
239+ // jobLabel names a job's token, in one place, so the poll and the check cannot drift apart.
240+ func jobLabel(id int64) string { return "job " + strconv.FormatInt(id, 10) }
241+
242+ // dropJobToken revokes what the poll issued, since a job token outliving its job is a git credential nobody asked for, cloning forever. 15.
243+ func (s *Server) dropJobToken(r *http.Request, account string, jobID int64) {
244+ tokens, err := s.DB.TokensOf(r.Context(), account)
245+ if err != nil {
246+ s.log(r, err)
247+ return
248+ }
249+ label := jobLabel(jobID)
250+ for _, t := range tokens {
251+ if t.Kind != token.Git || t.Label != label {
252+ continue
253+ }
254+ if err := s.DB.DeleteToken(r.Context(), account, t.ID); err != nil {
255+ s.log(r, err)
256+ }
257+ }
258+ }
259+
260+ // serveRunnerDone closes a job out and writes the result into the repository.
261+ func (s *Server) serveRunnerDone(w http.ResponseWriter, r *http.Request) {
262+ var req doneRequest
263+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&req); err != nil {
264+ http.Error(w, "malformed request", http.StatusBadRequest)
265+ return
266+ }
267+ t, runner, ok := s.jobRunner(w, r, req.Token, req.JobID)
268+ if !ok {
269+ return
270+ }
271+ job, err := s.DB.FinishJob(r.Context(), req.JobID, runner.ID)
272+ if err != nil {
273+ http.Error(w, "that job is not running", http.StatusConflict)
274+ return
275+ }
276+ // Chapter 15: the job token expires when the job ends, and this is where a job ends.
277+ s.dropJobToken(r, t.Account, req.JobID)
278+
279+ owner, name, _ := strings.Cut(job.Repo, "/")
280+ dir, err := repo.Dir(s.Cfg.Paths.Repos, owner, name)
281+ if err != nil {
282+ s.oops(w, r, err)
283+ return
284+ }
285+ rec := run.Record{
286+ Runner: runner.Hostname,
287+ Labels: runner.Labels,
288+ Name: job.Name,
289+ Ref: job.Ref,
290+ Started: job.Started.Unix(),
291+ Duration: req.Duration,
292+ Exit: req.ExitCode,
293+ }
294+ if err := run.Append(r.Context(), dir, job.SHA, rec, job.Log); err != nil {
295+ s.oops(w, r, err)
296+ return
297+ }
298+ // Chapter 19.1 has run.failed and not run.succeeded, because a green build is not news.
299+ if req.ExitCode != 0 {
300+ s.note(r, store.Event{Kind: store.RunFailed, Actor: runner.Hostname, Repo: job.Repo,
301+ Ref: job.Ref, Number: proposal.Number(job.Ref), Title: job.Name,
302+ Detail: runner.Hostname + " · exit " + strconv.Itoa(req.ExitCode)}, 0)
303+ }
304+ w.WriteHeader(http.StatusNoContent)
305+ }
306+
307+ // jobRunner checks that this token owns this job.
308+ func (s *Server) jobRunner(w http.ResponseWriter, r *http.Request, tok string, jobID int64) (*store.Token, *store.Runner, bool) {
309+ t, err := s.runnerAuth(r, tok)
310+ if err != nil {
311+ http.Error(w, "that token is not valid", http.StatusUnauthorized)
312+ return nil, nil, false
313+ }
314+ job, err := s.DB.Job(r.Context(), jobID)
315+ if err != nil || job.Repo != t.Scope {
316+ http.NotFound(w, r)
317+ return nil, nil, false
318+ }
319+ // The machine holding the job has to be this token's, or the repository's other runners could write into a build they are not running. 15.
320+ runner, err := s.runnerOf(r, t, job.RunnerID)
321+ if err != nil {
322+ http.NotFound(w, r)
323+ return nil, nil, false
324+ }
325+ if err := s.DB.SeeRunner(r.Context(), runner.ID); err != nil {
326+ s.log(r, err)
327+ }
328+ return t, runner, true
329+ }
330+
331+ // runnerOf finds one machine among those that attached with this token, and no others.
332+ func (s *Server) runnerOf(r *http.Request, t *store.Token, id int64) (*store.Runner, error) {
333+ attached, err := s.DB.RunnersByToken(r.Context(), t.Account)
334+ if err != nil {
335+ return nil, err
336+ }
337+ mine := attached[t.ID]
338+ for i := range mine {
339+ if mine[i].ID == id {
340+ return &mine[i], nil
341+ }
342+ }
343+ return nil, store.ErrNotFound
344+ }
345+
346+ func writeJSON(w http.ResponseWriter, v any) {
347+ w.Header().Set("Content-Type", "application/json")
348+ json.NewEncoder(w).Encode(v)
349+ }
@@ -0,0 +1,296 @@
1+ package httpd
2+
3+ import (
4+ "context"
5+ "crypto/sha256"
6+ "embed"
7+ "encoding/hex"
8+ "errors"
9+ "io/fs"
10+ "log/slog"
11+ "net/http"
12+ "sort"
13+ "strings"
14+ "sync"
15+ "time"
16+
17+ "github.com/barerepo/server/internal/artifact"
18+ "github.com/barerepo/server/internal/config"
19+ "github.com/barerepo/server/internal/gitx"
20+ "github.com/barerepo/server/internal/proposal"
21+ "github.com/barerepo/server/internal/repo"
22+ "github.com/barerepo/server/internal/repocfg"
23+ "github.com/barerepo/server/internal/store"
24+ "github.com/barerepo/server/internal/token"
25+ "github.com/barerepo/server/internal/transport"
26+ )
27+
28+ //go:embed static
29+ var static embed.FS
30+
31+ type Server struct {
32+ Cfg config.Config
33+ DB *store.DB
34+ Transport *transport.Server
35+ Log *slog.Logger
36+
37+ // signups counts per address for chapter 27, and losing it on restart is not worth a table.
38+ signupsMu sync.Mutex
39+ signups map[string][]time.Time
40+
41+ // comments counts per account per thread, the other half of chapter 27, on the same terms.
42+ commentsMu sync.Mutex
43+ comments map[string][]time.Time
44+ }
45+
46+ // assetTag is a hash of everything under static, so a changed stylesheet gets a url nobody has cached.
47+ var assetTag = hashAssets()
48+
49+ // hashAssets reads the embedded files once, in name order, because a map's order is not one.
50+ func hashAssets() string {
51+ names, err := fs.Glob(static, "static/*")
52+ if err != nil {
53+ return Version
54+ }
55+ sort.Strings(names)
56+ sum := sha256.New()
57+ for _, name := range names {
58+ body, err := static.ReadFile(name)
59+ if err != nil {
60+ continue
61+ }
62+ sum.Write([]byte(name))
63+ sum.Write(body)
64+ }
65+ return hex.EncodeToString(sum.Sum(nil))[:12]
66+ }
67+
68+ // keepable lets a browser keep the stylesheet, because a page that refetches it flashes unstyled.
69+ func keepable(next http.Handler) http.Handler {
70+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
71+ if r.URL.Query().Get("v") == assetTag {
72+ // The url carries the hash of the body, so what is under it can never change. Chapter 25.
73+ w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
74+ } else {
75+ // An unversioned url is somebody's bookmark, and a minute is enough to stop a flash.
76+ w.Header().Set("Cache-Control", "public, max-age=60")
77+ }
78+ next.ServeHTTP(w, r)
79+ })
80+ }
81+
82+ // Handler is the whole http surface. Routes are in appendix C.
83+ func (s *Server) Handler() http.Handler {
84+ // The ring is built once here, so a restart still knows whose keys signed what.
85+ s.rebuildKeyring(context.Background())
86+
87+ mux := http.NewServeMux()
88+
89+ sub, err := fs.Sub(static, "static")
90+ if err != nil {
91+ panic(err)
92+ }
93+ mux.Handle("GET /static/", webPolicy(keepable(http.StripPrefix("/static/", http.FileServerFS(sub)))))
94+
95+ // Matched by shape, not prefix, because the same prefix carries every web route too.
96+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
97+ if route, ok := parseGitPath(r.URL.Path); ok {
98+ switch {
99+ case r.Method == http.MethodGet && route.Advertise,
100+ r.Method == http.MethodPost && route.Service != "":
101+ s.serveGit(w, r, route)
102+ return
103+ }
104+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
105+ return
106+ }
107+ webPolicy(http.HandlerFunc(s.serveWeb)).ServeHTTP(w, r)
108+ })
109+ return baseHeaders(mux)
110+ }
111+
112+ // baseHeaders go on everything, including the git routes.
113+ func baseHeaders(next http.Handler) http.Handler {
114+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
115+ w.Header().Set("X-Content-Type-Options", "nosniff")
116+ w.Header().Set("Referrer-Policy", "no-referrer")
117+ next.ServeHTTP(w, r)
118+ })
119+ }
120+
121+ // webPolicy wraps the handlers, not the path, because a repository named `git-anything` is legal.
122+ func webPolicy(next http.Handler) http.Handler {
123+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
124+ // 'self' is for the two shortcuts only, and inline, eval and other hosts stay blocked.
125+ w.Header().Set("Content-Security-Policy",
126+ "default-src 'none'; img-src 'self'; style-src 'self'; script-src 'self'; font-src 'self'; "+
127+ "form-action 'self'; frame-ancestors 'none'; base-uri 'none'")
128+ next.ServeHTTP(w, r)
129+ })
130+ }
131+
132+ // authenticate reads a token from the basic password field, no credential is anonymous, and the scope rides along because chapter 15's job token names one repository and no more.
133+ func (s *Server) authenticate(ctx context.Context, r *http.Request) (account, scope string, err error) {
134+ _, pass, ok := r.BasicAuth()
135+ if !ok || pass == "" {
136+ return "", "", nil
137+ }
138+ t, err := s.DB.AccountForToken(ctx, token.Git, pass)
139+ if err != nil {
140+ return "", "", errors.New("that token is not valid")
141+ }
142+ return t.Account, t.Scope, nil
143+ }
144+
145+ // askForCredentials is the one 401, and a private repository gets it, because existence leaks.
146+ func (s *Server) askForCredentials(w http.ResponseWriter) {
147+ w.Header().Set("WWW-Authenticate", `Basic realm="barerepo"`)
148+ http.Error(w, "a token goes in the password field. the username is ignored.", http.StatusUnauthorized)
149+ }
150+
151+ // movedPath rewrites the path for a moved repository, keeping whatever git appended.
152+ func (s *Server) movedPath(r *http.Request, to transport.Redirect) string {
153+ parts := strings.SplitN(strings.TrimPrefix(r.URL.Path, "/"), "/", 3)
154+ rest := ""
155+ if len(parts) == 3 {
156+ rest = "/" + parts[2]
157+ }
158+ u := "/" + to.Owner + "/" + to.Name + rest
159+ if r.URL.RawQuery != "" {
160+ u += "?" + r.URL.RawQuery
161+ }
162+ return u
163+ }
164+
165+ func (s *Server) oops(w http.ResponseWriter, r *http.Request, err error) {
166+ s.log(r, err)
167+ http.Error(w, "something went wrong on the server", http.StatusInternalServerError)
168+ }
169+
170+ func (s *Server) log(r *http.Request, err error) {
171+ if s.Log == nil {
172+ return
173+ }
174+ s.Log.Error("request failed", "method", r.Method, "path", r.URL.Path, "err", err)
175+ }
176+
177+ // TrashWindow is chapter 44.4's 30 days, and the config page states the number.
178+ const TrashWindow = 30 * 24 * time.Hour
179+
180+ // Sweep discards what has expired, and a failure is logged for the next pass to retry.
181+ func (s *Server) Sweep(ctx context.Context) {
182+ if err := s.DB.SweepExpired(ctx); err != nil {
183+ s.Log.Error("sweep: expired tokens", "err", err)
184+ }
185+ if err := s.DB.DropOldEvents(ctx); err != nil {
186+ s.Log.Error("sweep: old events", "err", err)
187+ }
188+ if n, err := s.DB.RequeueLostJobs(ctx, 30*time.Minute); err != nil {
189+ s.Log.Error("sweep: lost jobs", "err", err)
190+ } else if n > 0 {
191+ s.Log.Info("requeued jobs whose runner stopped", "count", n)
192+ }
193+ if n := gitx.CloseIdleReaders(gitx.IdleLife); n > 0 {
194+ s.Log.Info("closed idle object readers", "count", n)
195+ }
196+ if n, err := repo.EmptyTrash(s.Cfg.Paths.Repos, TrashWindow, time.Now()); err != nil {
197+ s.Log.Error("sweep: trash", "err", err)
198+ } else if n > 0 {
199+ s.Log.Info("erased trash past its window", "count", n)
200+ }
201+ s.sweepRateBuckets()
202+ if n, err := artifact.SweepParts(s.Cfg.Paths.Artifacts, artifact.PartWindow, time.Now()); err != nil {
203+ s.Log.Error("sweep: unfinished uploads", "err", err)
204+ } else if n > 0 {
205+ s.Log.Info("removed uploads no process finished", "count", n)
206+ }
207+ s.expireProposals(ctx)
208+ }
209+
210+ // sweepRateBuckets drops the counters nobody is spending, since a key per thread is a key forever.
211+ func (s *Server) sweepRateBuckets() {
212+ cutoff := time.Now().Add(-time.Hour)
213+ prune := func(mu *sync.Mutex, buckets map[string][]time.Time) {
214+ mu.Lock()
215+ defer mu.Unlock()
216+ for key, at := range buckets {
217+ live := at[:0]
218+ for _, t := range at {
219+ if t.After(cutoff) {
220+ live = append(live, t)
221+ }
222+ }
223+ // An hour with nothing in it is the same as never having been counted.
224+ if len(live) == 0 {
225+ delete(buckets, key)
226+ continue
227+ }
228+ buckets[key] = live
229+ }
230+ }
231+ prune(&s.signupsMu, s.signups)
232+ prune(&s.commentsMu, s.comments)
233+ }
234+
235+ // expireProposals drops the refs that pin commits nobody has touched, keeping every thread. 26.
236+ func (s *Server) expireProposals(ctx context.Context) {
237+ err := repo.Walk(s.Cfg.Paths.Repos, func(owner, name, dir string) error {
238+ // The fallback carries the window, so a file that will not parse does not also stop the sweep it configures. Chapter 14.
239+ cfg, _ := repocfg.Load(ctx, dir)
240+ window := time.Duration(cfg.Proposals.ExpireDays) * 24 * time.Hour
241+ gone, err := proposal.Expire(ctx, dir, window, time.Now())
242+ if err != nil {
243+ s.Log.Error("sweep: proposals", "repo", owner+"/"+name, "err", err)
244+ return nil
245+ }
246+ if len(gone) > 0 {
247+ s.Log.Info("expired proposal refs, threads kept",
248+ "repo", owner+"/"+name, "proposals", gone)
249+ }
250+ // An expired proposal keeps no revisions either, since its ref went for the same reason.
251+ deleted := len(gone) > 0
252+ expired := map[int]bool{}
253+ for _, n := range gone {
254+ expired[n] = true
255+ }
256+ for _, n := range proposal.Numbers(dir) {
257+ keep := proposal.RevisionsKept
258+ if expired[n] {
259+ keep = 0
260+ }
261+ dropped, err := proposal.PruneRevisions(ctx, dir, n, keep)
262+ if err != nil {
263+ s.Log.Error("sweep: revisions", "repo", owner+"/"+name, "err", err)
264+ continue
265+ }
266+ if len(dropped) > 0 {
267+ s.Log.Info("pruned retained revisions",
268+ "repo", owner+"/"+name, "proposal", n, "revisions", dropped)
269+ deleted = true
270+ }
271+ }
272+ // Chapter 26: repack after bulk ref deletion, or the packs retain what was just deleted.
273+ if err := repo.Collect(ctx, dir, deleted); err != nil {
274+ s.Log.Error("sweep: gc", "repo", owner+"/"+name, "err", err)
275+ }
276+ return nil
277+ })
278+ if err != nil {
279+ s.Log.Error("sweep: proposals", "err", err)
280+ }
281+ }
282+
283+ // SweepEvery runs Sweep until the context ends.
284+ func (s *Server) SweepEvery(ctx context.Context, every time.Duration) {
285+ t := time.NewTicker(every)
286+ defer t.Stop()
287+ s.Sweep(ctx)
288+ for {
289+ select {
290+ case <-ctx.Done():
291+ return
292+ case <-t.C:
293+ s.Sweep(ctx)
294+ }
295+ }
296+ }
@@ -0,0 +1,432 @@
1+ /* barerepo. one stylesheet, no build step, every value lifted from the mockups in plans/. */
2+
3+ @font-face {
4+ font-family: "Space Mono";
5+ src: url("/static/fonts/SpaceMono-Regular.woff2") format("woff2");
6+ font-weight: 400;
7+ font-style: normal;
8+ font-display: swap;
9+ }
10+
11+ @font-face {
12+ font-family: "Space Mono";
13+ src: url("/static/fonts/SpaceMono-Bold.woff2") format("woff2");
14+ font-weight: 700;
15+ font-style: normal;
16+ font-display: swap;
17+ }
18+
19+ :root {
20+ --font-mono: "Space Mono", monospace;
21+ --surface-2: #ffffff;
22+ --surface-1: #f7f7f7;
23+ --text-primary: #000000;
24+ --text-secondary: #555555;
25+ --text-muted: #888888;
26+ --border: #dddddd;
27+ --border-strong: #999999;
28+ --radius: 0;
29+ --text-danger: #a32d2d;
30+ --text-success: #2f6b0f;
31+ --bg-danger: #fbe3e3;
32+ --bg-success: #e6f2d9;
33+ }
34+
35+ html { height: 100%; }
36+ body {
37+ margin: 0;
38+ padding: 12px;
39+ box-sizing: border-box;
40+ min-height: 100vh;
41+ background: var(--surface-2);
42+ color: var(--text-primary);
43+ }
44+
45+ .sr-only {
46+ position: absolute;
47+ width: 1px;
48+ height: 1px;
49+ overflow: hidden;
50+ clip: rect(0 0 0 0);
51+ }
52+
53+ /* the card. every page is one of these, and nothing sits outside it. */
54+ .card {
55+ font-family: var(--font-mono);
56+ font-size: 16px;
57+ line-height: 1.6;
58+ color: var(--text-primary);
59+ background: var(--surface-2);
60+ border: 0.5px solid var(--border);
61+ border-radius: 12px;
62+ min-height: calc(100vh - 26px);
63+ display: flex;
64+ flex-direction: column;
65+ }
66+
67+ /* links are underlined and keep the colour around them. there is no link blue in this design. */
68+ a {
69+ color: inherit;
70+ text-decoration: underline;
71+ /* measured off a Space Mono H: a regular stem is 0.085em, a bold one 0.15em */
72+ text-decoration-thickness: 0.085em;
73+ text-underline-offset: 3px;
74+ text-decoration-skip-ink: auto;
75+ }
76+
77+ /* bold only happens in rendered markdown, and the rule under it follows the stem it belongs to */
78+ strong a, a strong, b a, a b,
79+ .body h1 a, .body h2 a, .body h3 a, .body h4 a {
80+ text-decoration-thickness: 0.15em;
81+ }
82+ a:hover { color: var(--text-primary); }
83+
84+ /* bars: the top bar, the breadcrumb, the tab strip. all 9px 18px. */
85+ .bar {
86+ display: flex;
87+ align-items: center;
88+ justify-content: space-between;
89+ gap: 16px;
90+ padding: 9px 18px;
91+ border-bottom: 0.5px solid var(--border);
92+ }
93+ .bar .left, .bar .right { display: flex; gap: 20px; align-items: center; }
94+ .bar .right { gap: 16px; color: var(--text-secondary); }
95+ .brand { letter-spacing: -0.5px; }
96+ .searchbox {
97+ border: 0.5px solid var(--border);
98+ border-radius: 8px;
99+ padding: 3px 10px;
100+ color: var(--text-muted);
101+ }
102+
103+ .tabs {
104+ display: flex;
105+ gap: 22px;
106+ padding: 8px 18px;
107+ border-bottom: 0.5px solid var(--border);
108+ color: var(--text-secondary);
109+ font-size: 16px;
110+ }
111+ .tabs .on {
112+ color: var(--text-primary);
113+ border-bottom: 1.5px solid var(--text-primary);
114+ padding-bottom: 4px;
115+ text-decoration: none;
116+ }
117+ .tabs .end { margin-left: auto; color: var(--text-muted); }
118+
119+ /* rows: one record each, separated by a hairline. */
120+ .row { padding: 12px 18px; border-bottom: 0.5px solid var(--border); }
121+ .row.tall { padding: 13px 18px; }
122+ .row.dim { color: var(--text-muted); }
123+ .between { display: flex; justify-content: space-between; gap: 16px; }
124+ .sub { color: var(--text-muted); font-size: 16px; }
125+ .stack { line-height: 1.9; }
126+
127+ .muted { color: var(--text-muted); }
128+ .secondary { color: var(--text-secondary); }
129+ .danger { color: var(--text-danger); }
130+ .success { color: var(--text-success); }
131+ .small { font-size: 16px; }
132+ .strike { text-decoration: line-through; }
133+
134+ /* a command. everything the interface can do is shown as one of these. */
135+ .box {
136+ background: var(--surface-1);
137+ border: 0.5px solid var(--border);
138+ border-radius: 8px;
139+ padding: 9px 12px;
140+ color: var(--text-secondary);
141+ font-size: 16px;
142+ word-break: break-all;
143+ white-space: pre-wrap;
144+ margin: 0;
145+ font-family: inherit;
146+ }
147+ .box.tall { padding: 11px 13px; line-height: 1.9; }
148+
149+ /* forms. one border weight up from the page, so a field reads as a field. */
150+ .field { margin-bottom: 16px; }
151+ .label { color: var(--text-muted); font-size: 16px; margin-bottom: 5px; }
152+ .hint { color: var(--text-muted); font-size: 16px; margin-top: 4px; }
153+ /* one webhook and what became of it, on the config page. */
154+ .hook { margin-top: 9px; line-height: 1.7; }
155+ input.text, textarea.text, select.text {
156+ display: block;
157+ width: 100%;
158+ box-sizing: border-box;
159+ font: inherit;
160+ color: var(--text-primary);
161+ background: var(--surface-2);
162+ border: 0.5px solid var(--border-strong);
163+ border-radius: 8px;
164+ padding: 7px 11px;
165+ word-break: break-all;
166+ }
167+ textarea.text { min-height: 80px; resize: vertical; word-break: normal; }
168+ input.text::placeholder, textarea.text::placeholder { color: var(--text-muted); }
169+ input.text:focus, textarea.text:focus, select.text:focus {
170+ outline: 1px solid var(--text-primary);
171+ outline-offset: -1px;
172+ }
173+
174+ /* The browser draws the arrow, or the control reads as a text box that refuses text. */
175+ select.text { appearance: auto; padding: 6px 11px; }
176+
177+ button.btn, .btn {
178+ font: inherit;
179+ color: var(--text-primary);
180+ background: var(--surface-2);
181+ border: 0.5px solid var(--text-primary);
182+ border-radius: 8px;
183+ padding: 7px 16px;
184+ display: inline-block;
185+ text-decoration: none;
186+ cursor: pointer;
187+ }
188+
189+ /* diffs. the log page is mostly this. */
190+ .diff {
191+ border: 0.5px solid var(--border);
192+ border-radius: 8px;
193+ overflow: hidden;
194+ font-size: 16px;
195+ margin-top: 8px;
196+ }
197+ .diff div { padding: 2px 11px; white-space: pre-wrap; word-break: break-word; }
198+ .diff .hunk {
199+ padding: 3px 11px;
200+ color: var(--text-muted);
201+ background: var(--surface-1);
202+ border-bottom: 0.5px solid var(--border);
203+ }
204+ .diff .ctx { color: var(--text-secondary); }
205+ .diff .add { background: var(--bg-success); color: var(--text-success); }
206+ .diff .del { background: var(--bg-danger); color: var(--text-danger); }
207+ .diff .more { color: var(--text-muted); text-align: center; }
208+
209+ /* the signature mark sits after the hash, so an unsigned row is not indented to make room. */
210+ .sig {
211+ display: inline-block;
212+ width: 16px;
213+ margin-left: 5px;
214+ text-align: center;
215+ color: var(--text-muted);
216+ }
217+
218+
219+
220+ /* file view. blame in the gutter on every line, always, per chapter 24. */
221+ .filelines { padding: 12px 0; font-size: 16px; }
222+ .fileline { display: flex; }
223+ .fileline .blame {
224+ width: 150px;
225+ flex-shrink: 0;
226+ color: var(--text-muted);
227+ padding: 1px 10px 1px 18px;
228+ border-right: 0.5px solid var(--border);
229+ white-space: nowrap;
230+ overflow: hidden;
231+ }
232+ .fileline .num {
233+ width: 34px;
234+ flex-shrink: 0;
235+ color: var(--text-muted);
236+ text-align: right;
237+ padding: 1px 8px;
238+ }
239+ .fileline .code { padding: 1px 10px; color: var(--text-primary); white-space: pre-wrap; }
240+
241+ /* the build log. plain text, all of it, so ctrl-F works. chapter 16. */
242+ .log {
243+ padding: 12px 18px;
244+ font-size: 16px;
245+ line-height: 1.75;
246+ color: var(--text-secondary);
247+ white-space: pre-wrap;
248+ word-break: break-word;
249+ margin: 0;
250+ font-family: inherit;
251+ }
252+
253+ /* the pane a form or a single statement sits in. */
254+ .pane { padding: 22px 18px; max-width: 430px; }
255+ .pane.wide { max-width: 560px; }
256+ .pane.loose { padding: 26px 18px; }
257+ .panetitle { margin-bottom: 20px; }
258+
259+ /* the mark in the inbox for where you were when you last looked. */
260+ .lastvisit {
261+ padding: 5px 18px;
262+ border-bottom: 0.5px solid var(--border);
263+ border-top: 0.5px solid var(--border-strong);
264+ color: var(--text-muted);
265+ font-size: 16px;
266+ }
267+
268+ /* profile. */
269+ .profile { display: flex; gap: 28px; padding: 20px 18px; }
270+ .profile .side { width: 150px; flex-shrink: 0; }
271+ .profile .main { flex: 1; min-width: 0; }
272+ .avatar {
273+ width: 76px;
274+ height: 76px;
275+ border: 0.5px solid var(--border-strong);
276+ border-radius: 8px;
277+ display: flex;
278+ align-items: center;
279+ justify-content: center;
280+ font-size: 32px;
281+ color: var(--text-secondary);
282+ margin-bottom: 10px;
283+ }
284+ .tag {
285+ color: var(--text-muted);
286+ font-size: 16px;
287+ border: 0.5px solid var(--border);
288+ border-radius: 4px;
289+ padding: 0 5px;
290+ }
291+
292+ .foot {
293+ position: sticky;
294+ bottom: 0;
295+ margin-top: auto;
296+ background: var(--surface-2);
297+ border-top: 0.5px solid var(--border);
298+ border-radius: 0 0 12px 12px;
299+ padding: 9px 18px;
300+ display: flex;
301+ justify-content: space-between;
302+ gap: 16px;
303+ color: var(--text-muted);
304+ font-size: 16px;
305+ }
306+
307+ @media (max-width: 620px) {
308+ .profile { display: block; }
309+ .profile .side { width: auto; margin-bottom: 20px; }
310+ .fileline .blame { width: 96px; padding-left: 10px; }
311+ }
312+
313+ .here { color: var(--text-primary); }
314+
315+ /* compare. two refs, free text, because no dropdown expresses `master...refs/proposals/47`. */
316+ .compare { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
317+ .compare input.rev {
318+ flex: 1;
319+ min-width: 150px;
320+ font: inherit;
321+ font-size: 16px;
322+ color: var(--text-secondary);
323+ background: var(--surface-1);
324+ border: 0.5px solid var(--border);
325+ border-radius: 8px;
326+ padding: 9px 12px;
327+ }
328+ .compare input.rev:focus { outline: 1px solid var(--text-primary); outline-offset: -1px; }
329+
330+ /* The mockups' inline spacing, moved here, because chapter 42.7 makes a style attribute inert. */
331+ .actions { margin-bottom: 18px; } /* the row a form's button sits in */
332+ .formerror { margin-bottom: 16px; }
333+ .note { margin-top: 8px; }
334+ .note-wide { margin-top: 14px; }
335+ .title { margin-bottom: 6px; }
336+ .name { margin-bottom: 2px; }
337+ textarea.text.short { min-height: 60px; }
338+ textarea.text.tall { min-height: 90px; }
339+
340+ /* profile: one repository per row */
341+ .repo-row { padding: 11px 0; border-bottom: 0.5px solid var(--border); }
342+ .repo-head { padding-bottom: 8px; border-bottom: 0.5px solid var(--border); }
343+ .repo-meta { margin-top: 3px; }
344+ /* the line under the last repository, which says how many of them the page held. */
345+ .repo-more { padding-top: 11px; }
346+
347+ /* A control that reads as a link but is a form, because it changes something and links must not. */
348+ form.inline { display: inline; }
349+ button.linkbtn {
350+ font: inherit;
351+ color: var(--text-secondary);
352+ background: none;
353+ border: 0;
354+ padding: 0;
355+ text-decoration: underline;
356+ cursor: pointer;
357+ }
358+ button.linkbtn:hover { color: var(--text-primary); }
359+
360+ /* the block under a form that shows how to do the same thing without it */
361+ .pane-top { padding: 16px 18px; border-top: 0.5px solid var(--border); }
362+ .note-under { margin-bottom: 8px; }
363+
364+ /* A comment body, where markdown supplies the structure and this only stops doubled margins. */
365+ .body { word-break: break-word; }
366+ .body > :first-child { margin-top: 0; }
367+ .body > :last-child { margin-bottom: 0; }
368+ .body p { margin: 0 0 8px; }
369+ .body ul, .body ol { margin: 0 0 8px; padding-left: 22px; }
370+ /* a heading owns the text under it, so the space above each one is larger than the space below */
371+ .body h1, .body h2, .body h3, .body h4, .body h5, .body h6 {
372+ font-weight: 700;
373+ line-height: 1.3;
374+ }
375+ .body h1 { font-size: 30px; margin: 40px 0 12px; }
376+ .body h2 { font-size: 22px; margin: 36px 0 10px; }
377+ .body h3 { font-size: 18px; margin: 28px 0 8px; }
378+ .body h4, .body h5, .body h6 { font-size: 16px; margin: 20px 0 6px; }
379+ .body h1 { padding-bottom: 8px; border-bottom: 0.5px solid var(--border); }
380+ .body blockquote {
381+ margin: 0 0 8px;
382+ padding-left: 11px;
383+ border-left: 1.5px solid var(--border);
384+ color: var(--text-secondary);
385+ }
386+ .body pre {
387+ background: var(--surface-1);
388+ border: 0.5px solid var(--border);
389+ border-radius: 8px;
390+ padding: 9px 12px;
391+ font-size: 16px;
392+ overflow-x: auto;
393+ margin: 0 0 8px;
394+ }
395+ .body code { font-size: 16px; }
396+ .body pre code { font-size: inherit; }
397+ .body table { border-collapse: collapse; margin: 0 0 8px; }
398+ .body th, .body td {
399+ border: 0.5px solid var(--border);
400+ padding: 3px 9px;
401+ text-align: left;
402+ }
403+ .body th { background: var(--surface-1); font-weight: normal; }
404+ .body hr { border: 0; border-top: 0.5px solid var(--border); margin: 12px 0; }
405+ .body img { max-width: 100%; }
406+
407+ /* The line a comment was written against, kept visible even once it is outdated. 43.4. */
408+ .num-inline {
409+ display: inline-block;
410+ width: 34px;
411+ margin-right: 8px;
412+ color: var(--text-muted);
413+ text-align: right;
414+ }
415+
416+ /* The line number in a diff, a link wherever a thread can hold the comment. Chapter 35.3. */
417+ .dnum {
418+ display: inline-block;
419+ width: 30px;
420+ margin-right: 9px;
421+ text-align: right;
422+ color: var(--text-muted);
423+ }
424+ a.dnum:hover { color: var(--text-primary); }
425+
426+ .note-small { margin-top: 4px; }
427+
428+ /* a remote image a comment asked for, refused, and said so about. chapter 42.2. */
429+ .blocked { color: var(--text-muted); font-size: 16px; }
430+
431+ /* the matched part of a search result */
432+ .hit { background: var(--bg-success); color: var(--text-success); }
@@ -0,0 +1,93 @@
1+ Copyright 2016 The Space Mono Project Authors (https://github.com/googlefonts/spacemono)
2+
3+ This Font Software is licensed under the SIL Open Font License, Version 1.1.
4+ This license is copied below, and is also available with a FAQ at:
5+ http://scripts.sil.org/OFL
6+
7+
8+ -----------------------------------------------------------
9+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10+ -----------------------------------------------------------
11+
12+ PREAMBLE
13+ The goals of the Open Font License (OFL) are to stimulate worldwide
14+ development of collaborative font projects, to support the font creation
15+ efforts of academic and linguistic communities, and to provide a free and
16+ open framework in which fonts may be shared and improved in partnership
17+ with others.
18+
19+ The OFL allows the licensed fonts to be used, studied, modified and
20+ redistributed freely as long as they are not sold by themselves. The
21+ fonts, including any derivative works, can be bundled, embedded,
22+ redistributed and/or sold with any software provided that any reserved
23+ names are not used by derivative works. The fonts and derivatives,
24+ however, cannot be released under any other type of license. The
25+ requirement for fonts to remain under this license does not apply
26+ to any document created using the fonts or their derivatives.
27+
28+ DEFINITIONS
29+ "Font Software" refers to the set of files released by the Copyright
30+ Holder(s) under this license and clearly marked as such. This may
31+ include source files, build scripts and documentation.
32+
33+ "Reserved Font Name" refers to any names specified as such after the
34+ copyright statement(s).
35+
36+ "Original Version" refers to the collection of Font Software components as
37+ distributed by the Copyright Holder(s).
38+
39+ "Modified Version" refers to any derivative made by adding to, deleting,
40+ or substituting -- in part or in whole -- any of the components of the
41+ Original Version, by changing formats or by porting the Font Software to a
42+ new environment.
43+
44+ "Author" refers to any designer, engineer, programmer, technical
45+ writer or other person who contributed to the Font Software.
46+
47+ PERMISSION & CONDITIONS
48+ Permission is hereby granted, free of charge, to any person obtaining
49+ a copy of the Font Software, to use, study, copy, merge, embed, modify,
50+ redistribute, and sell modified and unmodified copies of the Font
51+ Software, subject to the following conditions:
52+
53+ 1) Neither the Font Software nor any of its individual components,
54+ in Original or Modified Versions, may be sold by itself.
55+
56+ 2) Original or Modified Versions of the Font Software may be bundled,
57+ redistributed and/or sold with any software, provided that each copy
58+ contains the above copyright notice and this license. These can be
59+ included either as stand-alone text files, human-readable headers or
60+ in the appropriate machine-readable metadata fields within text or
61+ binary files as long as those fields can be easily viewed by the user.
62+
63+ 3) No Modified Version of the Font Software may use the Reserved Font
64+ Name(s) unless explicit written permission is granted by the corresponding
65+ Copyright Holder. This restriction only applies to the primary font name as
66+ presented to the users.
67+
68+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69+ Software shall not be used to promote, endorse or advertise any
70+ Modified Version, except to acknowledge the contribution(s) of the
71+ Copyright Holder(s) and the Author(s) or with their explicit written
72+ permission.
73+
74+ 5) The Font Software, modified or unmodified, in part or in whole,
75+ must be distributed entirely under this license, and must not be
76+ distributed under any other license. The requirement for fonts to
77+ remain under this license does not apply to any document created
78+ using the Font Software.
79+
80+ TERMINATION
81+ This license becomes null and void if any of the above conditions are
82+ not met.
83+
84+ DISCLAIMER
85+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93+ OTHER DEALINGS IN THE FONT SOFTWARE.
binary file
binary file
binary file
binary file
@@ -0,0 +1,21 @@
1+ // The only script in the product, two shortcuts inside chapter 25's 2kb, and every page works without it.
2+ (function () {
3+ function typing(el) {
4+ var t = el.tagName;
5+ return t === 'INPUT' || t === 'TEXTAREA' || t === 'SELECT' || el.isContentEditable;
6+ }
7+ document.addEventListener('keydown', function (e) {
8+ if (e.metaKey || e.ctrlKey || e.altKey || typing(e.target)) return;
9+
10+ if (e.key === '/') {
11+ var box = document.querySelector('input[name=q]');
12+ if (box) { box.focus(); box.select(); } else { location.href = '/search'; }
13+ e.preventDefault();
14+ return;
15+ }
16+ if (e.key === 't') {
17+ var files = document.querySelector('a[data-files]');
18+ if (files) { location.href = files.getAttribute('href'); e.preventDefault(); }
19+ }
20+ });
21+ })();
binary file
@@ -0,0 +1,14 @@
1+ {{define "footleft"}}<a href="{{.Source}}">barerepo</a>{{end}}
2+ {{define "body" -}}
3+ <div class="bar">
4+ <div class="left"><span class="brand"><a href="/">barerepo</a></span></div>
5+ </div>
6+ <div class="pane loose">
7+ <div class="title">404</div>
8+ <div class="muted small">
9+ {{if .Missing}}<a href="{{.Href}}">{{.Near}}</a> has no repository called {{.Missing}}.
10+ {{else if .Near}}<a href="{{.Href}}">{{.Near}}</a> exists. that path in it does not.
11+ {{else}}nothing exists at {{.Path}}{{end}}
12+ </div>
13+ </div>
14+ {{- end}}
@@ -0,0 +1,29 @@
1+ {{define "footleft"}}<a href="/{{.Owner}}/{{.Name}}/thread/{{.N}}">thread {{.N}}</a>{{end}}
2+ {{define "body" -}}
3+ <div class="bar">
4+ <div class="secondary">{{template "crumbs" .}} / <span class="here">comment on {{.Path}}:{{.Line}}</span></div>
5+ <div class="muted small">thread {{.N}}</div>
6+ </div>
7+ {{if .Error}}<div class="row"><div class="danger small">{{.Error}}</div></div>{{end}}
8+ <div class="row tall">
9+ <div class="muted small">{{.Rev}}</div>
10+ <div class="diff">
11+ {{range .Context}}
12+ <div class="{{if .Here}}add{{else}}ctx{{end}}"><span class="dnum">{{.Number}}</span>&nbsp;&nbsp;{{.Text}}</div>
13+ {{end}}
14+ </div>
15+ </div>
16+ <div class="row tall">
17+ <form method="post" action="/{{.Owner}}/{{.Name}}/thread/{{.N}}/comment">
18+ <input type="hidden" name="path" value="{{.Path}}">
19+ <input type="hidden" name="line" value="{{.Line}}">
20+ <input type="hidden" name="blob" value="{{.Blob}}">
21+ <textarea class="text" name="body" autofocus placeholder="markdown"></textarea>
22+ <div class="note"><button class="btn" type="submit">comment</button></div>
23+ </form>
24+ </div>
25+ <div class="row">
26+ <div class="muted small">the exact content of this file is recorded with your comment, so it
27+ still shows what you meant after the line changes.</div>
28+ </div>
29+ {{- end}}
@@ -0,0 +1,22 @@
1+ {{define "footleft"}}inbox{{end}}
2+ {{define "body" -}}
3+ {{template "topbar" .}}
4+ <div class="bar">
5+ <div class="secondary"><span class="here">inbox</span></div>
6+ <div class="muted small"><a href="/inbox.atom">feed</a> &middot; <a href="/keys">feed token</a></div>
7+ </div>
8+ {{range .Events}}
9+ {{if .Rule}}<div class="lastvisit">{{$.Seen}}</div>{{end}}
10+ <div class="row{{if .Dim}} dim{{end}}">
11+ <div class="between">
12+ <span><a href="{{.Href}}">{{.Text}}</a></span>
13+ <span class="muted small">{{.Ago}}</span>
14+ </div>
15+ {{if .Detail}}<div class="muted small">{{.Detail}}</div>{{end}}
16+ </div>
17+ {{end}}
18+ {{if not .Events}}
19+ <div class="row"><div class="muted">nothing yet. reply to a thread and you will hear about it.</div></div>
20+ {{end}}
21+ <div class="row"><div class="muted small">events older than 90 days are dropped.</div></div>
22+ {{- end}}
@@ -0,0 +1,105 @@
1+ {{define "footleft"}}{{.Account}} / keys{{end}}
2+ {{define "body" -}}
3+ {{template "topbar" .}}
4+ <div class="bar">
5+ <div class="secondary"><a href="/{{.Account}}">{{.Account}}</a> / <span class="here">keys</span></div>
6+ <div class="muted small">the only state the server owns</div>
7+ </div>
8+ {{if .Error}}<div class="row"><div class="danger small">{{.Error}}</div></div>{{end}}
9+ {{if .NewToken}}
10+ <div class="row tall">
11+ <div class="label">{{.NewTokenLabel}}</div>
12+ <pre class="box">{{.NewToken}}</pre>
13+ <div class="hint">this is the only time it is shown. only its hash is kept.</div>
14+ </div>
15+ {{end}}
16+
17+ <div class="row"><div class="muted small">ssh keys</div></div>
18+ {{range .Keys}}
19+ <div class="row">
20+ <div class="between">
21+ <span>{{.Algo}} {{.Fingerprint}}</span>
22+ <span class="muted small">added {{.Added}}</span>
23+ </div>
24+ <div class="muted small">{{if .Comment}}{{.Comment}} &middot; {{end}}{{.LastUsed}} &middot;
25+ <form method="post" action="/keys/delete" class="inline">
26+ <input type="hidden" name="id" value="{{.ID}}">
27+ <button class="linkbtn" type="submit">revoke</button>
28+ </form>
29+ </div>
30+ </div>
31+ {{end}}
32+ <div class="row">
33+ <form method="post" action="/keys">
34+ <div class="field">
35+ <div class="label">public key</div>
36+ <textarea class="text short" name="pubkey" spellcheck="false"
37+ placeholder="ssh-ed25519 AAAA..."></textarea>
38+ <div class="hint">cat ~/.ssh/id_ed25519.pub</div>
39+ </div>
40+ <button class="btn" type="submit">new key</button>
41+ </form>
42+ </div>
43+
44+ <div class="row"><div class="muted small">signing keys</div></div>
45+ {{range .GPGKeys}}
46+ <div class="row">
47+ <div class="between">
48+ <span>{{.Fingerprint}}</span>
49+ <span class="muted small">added {{.Added}}</span>
50+ </div>
51+ <div class="muted small">{{if .UID}}{{.UID}} &middot; {{end}}
52+ <form method="post" action="/gpgkeys/delete" class="inline">
53+ <input type="hidden" name="id" value="{{.ID}}">
54+ <button class="linkbtn" type="submit">revoke</button>
55+ </form>
56+ </div>
57+ </div>
58+ {{end}}
59+ <div class="row">
60+ <form method="post" action="/gpgkeys">
61+ <div class="field">
62+ <div class="label">signing key</div>
63+ <textarea class="text short" name="armor" spellcheck="false"
64+ placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----"></textarea>
65+ <div class="hint">gpg --armor --export you@example.com</div>
66+ </div>
67+ <button class="btn" type="submit">new signing key</button>
68+ </form>
69+ </div>
70+
71+ <div class="row"><div class="muted small">tokens</div></div>
72+ {{range .Tokens}}
73+ <div class="row">
74+ <div class="between">
75+ <span>{{.Name}}</span>
76+ <span class="muted small">created {{.Created}}</span>
77+ </div>
78+ <div class="muted small">{{.Detail}} &middot;
79+ <form method="post" action="/tokens/delete" class="inline">
80+ <input type="hidden" name="id" value="{{.ID}}">
81+ <button class="linkbtn" type="submit">revoke</button>
82+ </form>
83+ </div>
84+ </div>
85+ {{end}}
86+ <div class="row">
87+ <form method="post" action="/tokens" class="inline">
88+ <input type="hidden" name="kind" value="git">
89+ <button class="btn" type="submit">new git token</button>
90+ </form>
91+ &nbsp;
92+ <form method="post" action="/tokens" class="inline">
93+ <input type="hidden" name="kind" value="feed">
94+ <button class="btn" type="submit">new feed token</button>
95+ </form>
96+ </div>
97+ <div class="row"><div class="muted small">a token is shown once, inside the command that uses it. only its
98+ hash is kept, so it cannot be shown again. lost one? revoke it and make another.</div></div>
99+ <div class="row"><div class="muted small">a key signs you in and pushes. a signing key is only ever
100+ read, and names you on a commit you signed. a git token clones and pushes over https. a runner
101+ token attaches one machine to one repository, and is made on that repository's runners page. a
102+ feed token reads one feed and can write nothing.</div></div>
103+ <div class="row"><div class="muted small">everything else is in .barerepo/config, in the repository it
104+ belongs to.</div></div>
105+ {{- end}}
@@ -0,0 +1,73 @@
1+ {{define "layout" -}}
2+ <!doctype html>
3+ <html lang="en">
4+ <head>
5+ <meta charset="utf-8">
6+ <meta name="viewport" content="width=device-width, initial-scale=1">
7+ <title>{{.Title}}</title>
8+ <meta name="description" content="{{if .Share}}{{.Share}}{{else}}{{.Summary}}{{end}}">
9+ <meta property="og:site_name" content="barerepo">
10+ <meta property="og:type" content="website">
11+ <meta property="og:title" content="{{.Title}}">
12+ <meta property="og:description" content="{{if .Share}}{{.Share}}{{else}}{{.Summary}}{{end}}">
13+ <meta property="og:image" content="{{if .Card}}{{.Site}}{{.Card}}{{else}}{{.Site}}/static/og.png?v={{.Assets}}{{end}}">
14+ <meta property="og:image:width" content="1200">
15+ <meta property="og:image:height" content="630">
16+ <meta name="twitter:card" content="summary_large_image">
17+ <link rel="stylesheet" href="/static/barerepo.css?v={{.Assets}}">
18+ <script src="/static/keys.js?v={{.Assets}}" defer></script>
19+ </head>
20+ <body>
21+ <div class="card">
22+ <h2 class="sr-only">{{.Summary}}</h2>
23+ {{template "body" .}}
24+ <div class="foot"><span>{{template "footleft" .}}</span><span><a href="{{.Source}}">barerepo {{.Version}}</a></span></div>
25+ </div>
26+ </body>
27+ </html>
28+ {{- end}}
29+
30+ {{define "repotabs" -}}
31+ <div class="tabs">
32+ {{range .Tabs}}{{if .On}}<span class="on">{{.Label}}</span>
33+ {{else if .Ready}}<a href="{{.Href}}"{{if eq .Label "files"}} data-files="1"{{end}}>{{.Label}}</a>
34+ {{else}}<span class="muted">{{.Label}}</span>
35+ {{end}}{{end}}<span class="end">
36+ {{- if .Ends}}{{range $i, $e := .Ends}}{{if $i}} &middot; {{end}}
37+ {{- if $e.On}}<span class="here">{{$e.Label}}</span>
38+ {{- else if $e.Href}}<a href="{{$e.Href}}">{{$e.Label}}</a>
39+ {{- else}}{{$e.Label}}{{end}}{{end}}
40+ {{- else}}jump to file <span class="tag">t</span>{{end}}</span>
41+ </div>
42+ {{- end}}
43+
44+ {{define "crumbs" -}}
45+ <a href="/{{.Owner}}">{{.Owner}}</a> / <a href="/{{.Owner}}/{{.Name}}">{{.Name}}</a>
46+ {{- end}}
47+
48+ {{define "bits" -}}
49+ {{range $i, $b := .}}{{if $i}} &middot; {{end}}{{if $b.Danger}}<span class="danger">{{if $b.Href}}<a href="{{$b.Href}}">{{$b.Text}}</a>{{else}}{{$b.Text}}{{end}}</span>{{else if $b.Href}}<a href="{{$b.Href}}">{{$b.Text}}</a>{{else}}{{$b.Text}}{{end}}{{end}}
50+ {{- end}}
51+
52+ {{define "topbar" -}}
53+ <div class="bar">
54+ <div class="left">
55+ <span class="brand"><a href="/">barerepo</a></span>
56+ {{if .Account}}<span class="secondary"><a href="/new">new</a></span>{{end}}
57+ </div>
58+ <div class="right">
59+ <a class="searchbox" href="/search">search /</a>
60+ {{if .Account}}<span><a href="/{{.Account}}">{{.Account}}</a></span>
61+ {{else}}<span><a href="/signin">sign in</a></span>{{end}}
62+ </div>
63+ </div>
64+ {{- end}}
65+
66+ {{define "hunks" -}}
67+ <div class="diff">
68+ {{range .Hunks}}<div class="hunk">{{.Header}}</div>
69+ {{range .Lines}}<div class="{{if eq .Kind 43}}add{{else if eq .Kind 45}}del{{else}}ctx{{end}}">{{if .CommentHref}}<a class="dnum" href="{{.CommentHref}}">{{.New}}</a>{{else}}<span class="dnum">{{if .New}}{{.New}}{{end}}</span>{{end}}{{.Prefix}}{{.Text}}</div>
70+ {{end}}{{end}}
71+ </div>
72+ {{- end}}
73+
@@ -0,0 +1,43 @@
1+ {{define "footleft"}}<a href="{{.Source}}">barerepo</a>{{end}}
2+ {{define "body" -}}
3+ {{template "topbar" .}}
4+ <div class="pane">
5+ <div class="panetitle">new repository</div>
6+ {{if .Error}}<div class="danger small formerror">{{.Error}}</div>{{end}}
7+ <form method="post" action="/new">
8+ <div class="field">
9+ <div class="label">name</div>
10+ <input class="text" name="name" value="{{.Name}}" autofocus spellcheck="false" autocapitalize="off">
11+ <div class="hint">{{.Account}}/{{if .Name}}{{.Name}}{{else}}&lt;name&gt;{{end}}</div>
12+ </div>
13+ <div class="field">
14+ <div class="label">description</div>
15+ <input class="text" name="description" value="{{.Description}}" spellcheck="false">
16+ <div class="hint">optional. it goes in .barerepo/config with the rest.</div>
17+ </div>
18+ <div class="field">
19+ <div class="label">default branch</div>
20+ <input class="text" name="default_branch" value="{{.Branch}}" spellcheck="false">
21+ <div class="hint">any branch name</div>
22+ </div>
23+ <div class="field">
24+ <div class="label">visibility</div>
25+ <select class="text" name="visibility">
26+ <option value="private">private</option>
27+ <option value="public">public</option>
28+ </select>
29+ <div class="hint">stored in .barerepo/config, so it needs a commit. the next page shows the line.</div>
30+ </div>
31+ <div class="actions"><button class="btn" type="submit">create</button></div>
32+ </form>
33+ </div>
34+ <div class="pane-top">
35+ <div class="muted small note-under">or skip this form. push to a name that does not exist.</div>
36+ <pre class="box">git remote add origin {{.CloneBase}}/&lt;name&gt;
37+ git push -u origin master</pre>
38+ <div class="muted small stack note-wide">
39+ <div>the branch you push becomes the default branch.</div>
40+ <div>the repository starts private. public is one line in .barerepo/config.</div>
41+ </div>
42+ </div>
43+ {{- end}}
@@ -0,0 +1,40 @@
1+ {{define "footleft"}}<a href="{{.Source}}">barerepo</a>{{end}}
2+ {{define "body" -}}
3+ {{template "topbar" .}}
4+ <div class="profile">
5+ <div class="side">
6+ <div class="avatar">{{.Initials}}</div>
7+ <div class="name">{{.Who}}</div>
8+ <div class="muted small stack">
9+ <div>{{.RepoCount}}</div>
10+ <div>joined {{.Joined}}</div>
11+ <div><a href="/{{.Who}}.keys">ssh keys: {{.KeyCount}}</a></div>
12+ {{if .SigningKeys}}<div><a href="/{{.Who}}.gpg">signing keys: {{.SigningKeys}}</a></div>{{end}}
13+ <div><a href="/{{.Who}}.atom">feed</a></div>
14+ {{if .Me}}<div><a href="/keys">keys</a></div>{{end}}
15+ </div>
16+ </div>
17+ <div class="main">
18+ <div class="between repo-head">
19+ <span class="muted small">repositories</span>
20+ <span class="muted small">sorted by pushed</span>
21+ </div>
22+ {{range .Repos}}
23+ <div class="repo-row">
24+ <div class="between">
25+ <span><a href="/{{$.Who}}/{{.Name}}">{{.Name}}</a>{{if not .Public}} <span class="tag">private</span>{{end}}</span>
26+ <span class="muted small">{{.Ago}}</span>
27+ </div>
28+ {{if .Description}}<div class="secondary small">{{.Description}}</div>{{end}}
29+ <div class="muted small repo-meta">{{.Meta}}</div>
30+ </div>
31+ {{end}}
32+ {{if .Shown}}
33+ <div class="muted small repo-more">{{.Shown}} &middot; <a href="{{.AllHref}}">all</a></div>
34+ {{end}}
35+ {{if not .Repos}}
36+ <div class="repo-row muted small">no repositories yet.</div>
37+ {{end}}
38+ </div>
39+ </div>
40+ {{- end}}
@@ -0,0 +1,18 @@
1+ {{define "footleft"}}{{.Owner}} / {{.Name}}{{end}}
2+ {{define "body" -}}
3+ <div class="bar">
4+ <div class="secondary">{{template "crumbs" .}} / <span class="here">push rejected</span></div>
5+ <div class="muted small">{{.Head}}</div>
6+ </div>
7+ <div class="pane wide">
8+ <div class="label">you pushed to {{.Ref}}</div>
9+ <div class="muted small note-wide">.barerepo/config [access] push = {{.Push}} &middot; you are {{.You}}</div>
10+ {{if .MayPush}}
11+ <div class="muted small note-wide">you may push here now. try again.</div>
12+ {{else}}
13+ <div class="muted small note-wide">push here instead. it needs no permission.</div>
14+ <pre class="box">git push origin HEAD:refs/proposals/new</pre>
15+ {{end}}
16+ <div class="muted small note-wide">the same message was printed in your terminal.</div>
17+ </div>
18+ {{- end}}
@@ -0,0 +1,11 @@
1+ {{define "footleft"}}<a href="{{.SourceHref}}">source</a> &middot; <a href="{{.RawHref}}">raw</a>{{end}}
2+ {{define "body" -}}
3+ <div class="bar">
4+ <div class="secondary">{{template "crumbs" .}} / <span class="here">{{.Path}}</span></div>
5+ <div class="muted small">rendered</div>
6+ </div>
7+ {{template "repotabs" .}}
8+ <div class="row tall">
9+ <div class="body">{{.HTML}}</div>
10+ </div>
11+ {{- end}}
@@ -0,0 +1,26 @@
1+ {{define "footleft"}}{{if .Older}}<a href="{{.Older}}">older</a>{{else}}{{.Owner}} / {{.Name}}{{end}}{{end}}
2+ {{define "body" -}}
3+ <div class="bar">
4+ <div class="secondary">{{template "crumbs" .}} / <span class="here">releases</span></div>
5+ <div class="muted small">refs/notes/releases</div>
6+ </div>
7+ {{template "repotabs" .}}
8+ {{range .Releases}}
9+ <div class="row tall">
10+ <div class="between">
11+ <span><a href="{{.Href}}">{{.Tag}}</a></span>
12+ <span class="muted small">{{if .TaggerHref}}<a href="{{.TaggerHref}}">{{.Tagger}}</a>{{else}}{{.Tagger}}{{end}} &middot; {{.Ago}}</span>
13+ </div>
14+ {{if .Subject}}<div class="muted small">{{.Subject}}</div>{{end}}
15+ {{if .HasNotes}}<div class="body note">{{.Notes}}</div>{{end}}
16+ {{if .Files}}<div class="muted small">{{range $i, $f := .Files}}{{if $i}} &middot; {{end}}<a href="{{$f.Href}}">{{$f.Name}}</a> &middot; {{$f.Size}}{{end}}</div>{{end}}
17+ </div>
18+ {{end}}
19+ {{if not .Releases}}
20+ <div class="row"><div class="muted">no tags yet.</div></div>
21+ <div class="row"><div class="muted small">a release is a tag. make one and push it:</div>
22+ <pre class="box note">git tag -a v1.0.0 -m "what changed"
23+ git push origin v1.0.0</pre></div>
24+ {{end}}
25+ <div class="row"><div class="muted small">release notes clone with the repository. attached files do not.</div></div>
26+ {{- end}}
@@ -0,0 +1,29 @@
1+ {{define "footleft"}}{{if .Parent}}parent <a class="sha" href="{{.ParentHref}}">{{.Parent}}</a>{{else}}{{.Owner}} / {{.Name}}{{end}}{{end}}
2+ {{define "body" -}}
3+ <div class="bar">
4+ <div class="secondary">{{template "crumbs" .}} / <span class="here">{{.Commit.Short}}</span></div>
5+ <div class="muted small">{{if .Commit.AuthorHref}}<a href="{{.Commit.AuthorHref}}">{{.Commit.Author}}</a>{{else}}{{.Commit.Author}}{{end}} &middot; {{.Commit.Ago}} &middot; {{.FileCount}} &middot; +{{.Commit.Add}} -{{.Commit.Del}}{{if .Signature}} &middot; {{if .SigBad}}<span class="danger">{{.Signature}}</span>{{else if .SignerHref}}signed by <a href="{{.SignerHref}}">{{.Signer}}</a>{{else}}{{.Signature}}{{end}}{{end}}</div>
6+ </div>
7+ {{if .Verify}}
8+ <div class="row"><div class="muted small">check this signature yourself, against the key and not this
9+ page:</div>
10+ <div class="note"><pre class="box tall">{{.Verify}}</pre></div>
11+ </div>
12+ {{end}}
13+ <div class="row tall">
14+ <div>{{.Commit.Subject}}</div>
15+ {{if .Commit.Body}}<div class="muted small">{{.Commit.Body}}</div>{{end}}
16+ </div>
17+ {{range .Commit.Files}}
18+ <div class="row tall">
19+ <div class="muted small">{{if .Gone}}{{.Path}} &middot; deleted{{else}}<a href="{{$.FilesHref}}{{.Path}}">{{.Path}}</a>{{end}}{{if .OldPath}} &middot; was {{.OldPath}}{{end}}</div>
20+ {{if .Binary}}<div class="muted small">binary file</div>{{end}}
21+ {{if .Hunks}}
22+ {{template "hunks" .}}
23+ {{end}}
24+ </div>
25+ {{end}}
26+ <div class="row">
27+ <div class="muted small">{{if .Reachable}}reachable from <a href="/{{.Owner}}/{{.Name}}">{{.Branch}}</a>{{else}}not reachable from <a href="/{{.Owner}}/{{.Name}}">{{.Branch}}</a>{{end}}</div>
28+ </div>
29+ {{- end}}
@@ -0,0 +1,31 @@
1+ {{define "footleft"}}{{.Comparison.A}}...{{.Comparison.B}}{{end}}
2+ {{define "body" -}}
3+ <div class="bar">
4+ <div class="secondary">{{template "crumbs" .}} / <span class="here">compare</span></div>
5+ <div class="muted small">{{.Comparison.A}}...{{.Comparison.B}}</div>
6+ </div>
7+ {{template "repotabs" .}}
8+ <div class="row tall">
9+ <form class="compare" method="get" action="{{.Action}}">
10+ <label class="sr-only" for="a">base</label>
11+ <input class="rev" id="a" name="a" value="{{.Comparison.A}}" spellcheck="false">
12+ <span class="muted">...</span>
13+ <label class="sr-only" for="b">compare</label>
14+ <input class="rev" id="b" name="b" value="{{.Comparison.B}}" spellcheck="false">
15+ <button class="btn" type="submit">compare</button>
16+ </form>
17+ <div class="muted small note">{{.Stats}}</div>
18+ {{if .Refs}}
19+ <div class="muted small note">compare against
20+ {{range $i, $r := .Refs}}{{if $i}} &middot; {{end}}{{if $r.On}}<span class="here">{{$r.Name}}</span>{{else}}<a href="{{$r.Href}}">{{$r.Name}}</a>{{end}}{{end}}</div>
21+ {{end}}
22+ </div>
23+ {{range .Files}}
24+ <div class="row tall">
25+ <div class="muted small">{{if .Gone}}{{.Path}} &middot; deleted{{else}}<a href="{{$.FilesHref}}{{.Path}}">{{.Path}}</a>{{end}} &middot; +{{.Add}} -{{.Del}}</div>
26+ {{if .Hunks}}
27+ {{template "hunks" .}}
28+ {{end}}
29+ </div>
30+ {{end}}
31+ {{- end}}
@@ -0,0 +1,84 @@
1+ {{define "footleft"}}<a href="{{.HistoryHref}}">history</a> &middot; <a href="{{.BlameHref}}">blame</a> &middot; <a href="{{.RawHref}}">raw</a>{{end}}
2+ {{define "body" -}}
3+ <div class="bar">
4+ <div class="secondary">{{template "crumbs" .}} / <span class="here">.barerepo/config</span></div>
5+ <div class="muted small">{{if .Edited}}edited {{.Edited}} by
6+ {{if .EditorHref}}<a href="{{.EditorHref}}">{{.Editor}}</a>{{else}}{{.Editor}}{{end}}
7+ &middot; <a class="sha" href="{{.EditHref}}">{{.EditShort}}</a>{{end}}</div>
8+ </div>
9+ {{template "repotabs" .}}
10+ {{if .Error}}<div class="row"><div class="danger small">{{.Error}}</div></div>{{end}}
11+ {{if .Config}}
12+ <pre class="log">{{.Config}}</pre>
13+ {{else}}
14+ <div class="row"><div class="muted small">this repository has no .barerepo/config. everything is
15+ at its default. add one and push:</div>
16+ <pre class="box note">mkdir -p .forge &amp;&amp; printf '[repo]\nvisibility = "public"\n' &gt; .barerepo/config</pre></div>
17+ {{end}}
18+ <div class="row"><div class="muted small">edit the file, commit, push. there is no settings form.
19+ <span class="secondary">git log -p .barerepo/config</span> shows who changed what.</div></div>
20+ {{if .Hooks}}
21+ <div class="row">
22+ <div class="muted small">webhooks</div>
23+ {{range .Hooks}}
24+ <div class="hook">
25+ <div class="secondary">{{.URL}}</div>
26+ <div class="muted small">{{.Events}}</div>
27+ <div class="small {{if .Danger}}danger{{else}}muted{{end}}">{{.State}}</div>
28+ </div>
29+ {{end}}
30+ <div class="hint">a hook that fails 20 times in a row stops. fix the receiver, then change
31+ the url or remove the line and push to start it again.</div>
32+ </div>
33+ {{end}}
34+
35+ {{if .CopyTo}}
36+ <div class="row tall">
37+ <div class="label">copy</div>
38+ <form method="post" action="/{{.Owner}}/{{.Name}}/copy" class="compare">
39+ <button class="btn" type="submit">copy to {{.CopyTo}}</button>
40+ </form>
41+ <div class="hint">branches, tags, history, threads and notes come across. proposal refs do not:
42+ they belong to the original conversation. there is no link back and no badge. to contribute
43+ here, push a proposal.</div>
44+ <pre class="box note">git clone --mirror {{.CloneURL}}
45+ cd {{.Name}}.git
46+ git push {{.CopyURL}} 'refs/heads/*:refs/heads/*' 'refs/tags/*:refs/tags/*' 'refs/notes/*:refs/notes/*'</pre>
47+ </div>
48+ {{else if .CopyHave}}
49+ <div class="row tall">
50+ <div class="label">copy</div>
51+ <div class="muted small">you already have <a href="{{.CopyHave}}">{{.CopyHave}}</a>.</div>
52+ </div>
53+ {{end}}
54+
55+ {{if .IsOwner}}
56+ <div class="row tall">
57+ <div class="label">rename</div>
58+ <form method="post" action="/{{.Owner}}/{{.Name}}/rename" class="compare">
59+ <input class="rev" name="name" value="{{.Name}}" spellcheck="false">
60+ <button class="btn" type="submit">rename</button>
61+ </form>
62+ <div class="hint">the old name redirects forever and is never given to anyone else.</div>
63+ </div>
64+ <div class="row tall">
65+ <div class="label">transfer</div>
66+ <form method="post" action="/{{.Owner}}/{{.Name}}/transfer" class="compare">
67+ <input class="rev" name="owner" placeholder="account name" spellcheck="false">
68+ <input class="rev" name="confirm" placeholder="type {{.Name}} to confirm" spellcheck="false">
69+ <button class="btn" type="submit">transfer</button>
70+ </form>
71+ <div class="hint">nothing inside the repository changes. the new owner should edit
72+ [access] push themselves.</div>
73+ </div>
74+ <div class="row tall">
75+ <div class="label">delete</div>
76+ <form method="post" action="/{{.Owner}}/{{.Name}}/delete" class="compare">
77+ <input class="rev" name="confirm" placeholder="type {{.Name}} to confirm" spellcheck="false">
78+ <button class="btn" type="submit">delete</button>
79+ </form>
80+ <div class="hint">it stops serving at once. the data is kept for 30 days and then erased.
81+ mirror it first if you want your own copy.</div>
82+ </div>
83+ {{end}}
84+ {{- end}}
@@ -0,0 +1,32 @@
1+ {{define "footleft"}}{{.Owner}} / {{.Name}}{{end}}
2+ {{define "body" -}}
3+ <div class="bar">
4+ <div class="secondary"><a href="/{{.Owner}}">{{.Owner}}</a> / <span class="here">{{.Name}}</span></div>
5+ <div class="muted small">empty{{if .Branch}} &middot; {{.Branch}}{{end}}</div>
6+ </div>
7+ <div class="pane wide">
8+ <div class="label">paste this</div>
9+ <pre class="box tall">git init
10+ git remote add origin {{.CloneURL}}
11+ {{if .Description}}mkdir -p .forge &amp;&amp; cat &gt; .barerepo/config &lt;&lt;'EOF'
12+ [repo]
13+ visibility = "{{.Visibility}}"
14+ description = "{{.Description}}"
15+ EOF
16+ {{else if not .Public}}mkdir -p .forge &amp;&amp; printf '[repo]\nvisibility = "public"\n' &gt; .barerepo/config
17+ {{end}}git add -A
18+ git commit -m "first"
19+ git push -u origin {{if .Branch}}{{.Branch}}{{else}}master{{end}}</pre>
20+ {{if .Description}}
21+ <div class="hint">that block is what sets the visibility and the description. without it the
22+ repository stays private, because there is nowhere else to keep either.</div>
23+ {{else if not .Public}}
24+ <div class="hint">the third line is what makes it public. without it the repository stays
25+ private, because there is nowhere else to keep that.</div>
26+ {{end}}
27+ <div class="muted small stack note-wide">
28+ <div>already have a repo somewhere?</div>
29+ <div class="secondary">git remote set-url origin {{.CloneURL}} &amp;&amp; git push --all</div>
30+ </div>
31+ </div>
32+ {{- end}}
@@ -0,0 +1,21 @@
1+ {{define "footleft"}}{{if .RenderedHref}}<a href="{{.RenderedHref}}">rendered</a> &middot; {{end}}<a href="{{.HistoryHref}}">history</a> &middot; <a href="{{.RawHref}}">raw</a>{{end}}
2+ {{define "body" -}}
3+ <div class="bar">
4+ <div class="secondary">{{template "crumbs" .}} / <span class="here">{{.Path}}</span></div>
5+ <div class="muted small">{{.Meta}}</div>
6+ </div>
7+ {{template "repotabs" .}}
8+ {{if .File.Binary}}
9+ <div class="row"><div class="muted small">binary file &middot; {{.SizeText}} &middot; <a href="{{.RawHref}}">download</a></div></div>
10+ {{else if .File.TooBig}}
11+ <div class="row"><div class="muted small">{{.SizeText}} &middot; too large to render &middot; <a href="{{.RawHref}}">download</a></div></div>
12+ {{else}}
13+ <div class="filelines">
14+ {{range .Lines}}<div class="fileline"><div class="blame">{{.Blame}}</div><div class="num">{{.Number}}</div><div class="code">{{.Text}}</div></div>
15+ {{end}}</div>
16+ {{if .Truncated}}
17+ <div class="row"><div class="muted small">{{.Shown}} of {{.File.Lines|len}} lines &middot;
18+ <a href="{{.RawHref}}">the whole file</a></div></div>
19+ {{end}}
20+ {{end}}
21+ {{- end}}
@@ -0,0 +1,25 @@
1+ {{define "footleft"}}{{if .More}}<a href="{{.More}}">more</a>{{else}}{{.Branch}}{{end}}{{end}}
2+ {{define "body" -}}
3+ <div class="bar">
4+ <div class="secondary">{{if .Path}}{{template "crumbs" .}} / <span class="here">{{.Path}}</span>{{else}}<a href="/{{.Owner}}">{{.Owner}}</a> / <span class="here">{{.Name}}</span>{{end}}</div>
5+ <div class="muted small">{{.Branch}} &middot; clone <span class="secondary">{{.CloneURL}}</span></div>
6+ </div>
7+ {{template "repotabs" .}}
8+ {{if .Up}}
9+ <div class="row">
10+ <div><a href="{{.Up}}">..</a></div>
11+ </div>
12+ {{end}}
13+ {{range .Entries}}
14+ <div class="row">
15+ <div class="between">
16+ <span><a href="{{.Href}}">{{.Name}}{{if .Dir}}/{{end}}</a></span>
17+ <span class="muted small">{{.Author}} &middot; {{.Ago}}</span>
18+ </div>
19+ <div class="muted small">{{.Subject}}</div>
20+ </div>
21+ {{end}}
22+ {{if not .Entries}}
23+ <div class="row"><div class="muted">this directory is empty.</div></div>
24+ {{end}}
25+ {{- end}}
@@ -0,0 +1,27 @@
1+ {{define "footleft"}}{{if .Older}}<a href="{{.Older}}">older</a>{{else}}{{.Owner}} / {{.Name}}{{end}}{{end}}
2+
3+ {{define "body" -}}
4+ <div class="bar">
5+ <div class="secondary"><a href="/{{.Owner}}">{{.Owner}}</a> / <span class="here">{{.Name}}</span></div>
6+ <div class="muted small"><a href="/{{.Owner}}/{{.Name}}/compare">{{.Branch}}</a>{{if .ReadmeHref}} &middot; <a href="{{.ReadmeHref}}">readme</a>{{end}} &middot; clone <span class="secondary">{{.CloneURL}}</span></div>
7+ </div>
8+ {{template "repotabs" .}}
9+ {{if .OnlyPath}}
10+ <div class="row"><div class="muted small">history of
11+ {{if .FileHref}}<a href="{{.FileHref}}">{{.OnlyPath}}</a>{{else}}{{.OnlyPath}}, which is not in
12+ {{.Branch}} any more{{end}} &middot;
13+ <a href="/{{.Owner}}/{{.Name}}">the whole log</a></div></div>
14+ {{end}}
15+ {{if not .Commits}}
16+ <div class="row"><div class="muted">{{if .OnlyPath}}no commit touches this path.{{else}}nothing here yet.{{end}}</div></div>
17+ {{end}}
18+ {{range .Commits}}
19+ <div class="row tall" id="{{.Short}}">
20+ <div class="between">
21+ <span><a class="sha" href="{{.Href}}">{{.Short}}</a><span class="sig"{{if .SigNote}} title="{{.SigNote}}"{{end}}>{{if .Signed}}&#10003;{{end}}</span>&nbsp;{{.Subject}}</span>
22+ <span class="muted small">{{if .AuthorHref}}<a href="{{.AuthorHref}}">{{.Author}}</a>{{else}}{{.Author}}{{end}} &middot; {{.Ago}}</span>
23+ </div>
24+ <div class="muted small">{{.Summary}}</div>
25+ </div>
26+ {{end}}
27+ {{- end}}
@@ -0,0 +1,26 @@
1+ {{define "footleft"}}{{.Ref}}{{end}}
2+ {{define "body" -}}
3+ <div class="bar">
4+ <div class="secondary">{{template "crumbs" .}} / <span class="here">run <a class="sha" href="/{{.Owner}}/{{.Name}}/commit/{{.SHA}}">{{.Short}}</a></span></div>
5+ <div class="muted small">{{.Meta}}</div>
6+ </div>
7+ {{range .Runs}}
8+ <div class="row">
9+ <div>{{if .Name}}{{.Name}} &middot; {{end}}{{if .Failed}}<span class="danger">{{.Result}}</span> &middot; exit {{.Exit}}{{else}}{{.Result}}{{end}}</div>
10+ <div class="muted small">{{if .Ref}}{{if .RefHref}}<a href="{{.RefHref}}">{{.Ref}}</a>{{else}}{{.Ref}}{{end}} &middot; {{end}}{{if .Subject}}<a href="{{.CommitHref}}">{{.Subject}}</a>{{end}}{{if $.Author}} &middot; {{$.Author}}{{end}}{{if .Runner}} &middot; <a href="{{.RunnerHref}}">{{.Runner}}</a>{{end}}</div>
11+ </div>
12+ {{if .Shown}}<div class="row"><div class="muted small">the last {{.Shown}} of {{.Size}} &middot;
13+ <a href="{{.RawHref}}">the whole log</a></div></div>{{end}}
14+ <pre class="log">{{.Output}}</pre>
15+ <div class="row">
16+ <div class="muted small"><a href="{{.RawHref}}">raw log</a> &middot; {{.Size}}
17+ {{if $.CanRerun}} &middot;
18+ <form method="post" action="/{{$.Owner}}/{{$.Name}}/run/{{$.SHA}}/rerun" class="inline">
19+ <button class="linkbtn" type="submit">rerun</button>
20+ </form>{{end}}</div>
21+ </div>
22+ {{end}}
23+ {{if not .Runs}}
24+ <div class="row"><div class="muted">nothing has been built at this commit.</div></div>
25+ {{end}}
26+ {{- end}}
@@ -0,0 +1,32 @@
1+ {{define "footleft"}}<a href="/{{.Owner}}/{{.Name}}/runners">runners</a>{{end}}
2+ {{define "body" -}}
3+ <div class="bar">
4+ <div class="secondary">{{template "crumbs" .}} / <span class="here">add a runner</span></div>
5+ <div class="muted small">{{.Token}}</div>
6+ </div>
7+ <div class="pane wide">
8+ <div class="label">paste on any machine you want to build on.</div>
9+ <div class="label note-wide">linux, macos</div>
10+ <pre class="box">curl -sL {{.ExternalURL}}/runner.sh | sh -s {{.Token}}</pre>
11+ <div class="label note-wide">windows</div>
12+ <pre class="box">irm {{.ExternalURL}}/runner.ps1 | iex; barerepo-runner {{.Token}}</pre>
13+ <div class="label note-wide">already have the binary</div>
14+ <pre class="box">barerepo-runner {{.Token}} --labels build,test</pre>
15+ <div class="muted small stack note-wide">
16+ <div>the runner dials out. it needs no inbound port and no public address.</div>
17+ <div>token scopes to this repo. revoke it on your keys page.</div>
18+ <div>this token is shown once. only its hash is kept.</div>
19+ <div>the machine remembers this barerepo, so the line above needs no address.</div>
20+ <div>a runner is a program because it long-polls. the protocol is plain http:</div>
21+ <div class="secondary">POST /runner/attach &middot; GET /runner/poll &middot; POST /runner/log &middot;
22+ POST /runner/done &middot; POST /runner/artifact</div>
23+ <div>write your own if you would rather.</div>
24+ <div>a build attaches a file to a release with the last one. it is handed
25+ BAREREPO_URL, BAREREPO_REPO, BAREREPO_JOB and BAREREPO_JOB_TOKEN:</div>
26+ <pre class="box">curl -X POST "$BAREREPO_URL/runner/artifact" \
27+ -H "Barerepo-Token: $BAREREPO_JOB_TOKEN" -H "Barerepo-Job: $BAREREPO_JOB" \
28+ -H "Barerepo-Tag: v1.0.0" -H "Barerepo-File: johnbot-linux-amd64" \
29+ --data-binary @johnbot-linux-amd64</pre>
30+ </div>
31+ </div>
32+ {{- end}}
@@ -0,0 +1,27 @@
1+ {{define "footleft"}}{{.Attached}}{{end}}
2+ {{define "body" -}}
3+ <div class="bar">
4+ <div class="secondary">{{template "crumbs" .}} / <span class="here">runners</span></div>
5+ <div class="muted small">{{.Attached}}</div>
6+ </div>
7+ {{template "repotabs" .}}
8+ {{if .CanAdd}}
9+ <div class="row"><a class="btn" href="/{{.Owner}}/{{.Name}}/runners/new">add a runner</a></div>
10+ {{end}}
11+ {{range .Runners}}
12+ <div class="row{{if .Offline}} dim{{end}}">
13+ <div class="between">
14+ <span>{{.Hostname}}</span>
15+ <span class="muted small">{{.State}} &middot; {{.Seen}}</span>
16+ </div>
17+ <div class="muted small">{{.Detail}}{{if and .Offline $.CanAdd}} &middot;
18+ <form method="post" action="/{{$.Owner}}/{{$.Name}}/runners/forget" class="inline">
19+ <input type="hidden" name="id" value="{{.ID}}">
20+ <button class="linkbtn" type="submit">forget</button>
21+ </form>{{end}}</div>
22+ </div>
23+ {{end}}
24+ {{if not .Runners}}
25+ <div class="row"><div class="muted">no machines attached.</div></div>
26+ {{end}}
27+ {{- end}}
@@ -0,0 +1,29 @@
1+ {{define "footleft"}}{{if .Older}}<a href="{{.Older}}">older</a>{{else}}{{.Owner}} / {{.Name}}{{end}}{{end}}
2+ {{define "body" -}}
3+ <div class="bar">
4+ <div class="secondary">{{template "crumbs" .}} / <span class="here">runs</span></div>
5+ <div class="muted small">newest first</div>
6+ </div>
7+ {{template "repotabs" .}}
8+ {{range .Runs}}
9+ <div class="row">
10+ <div class="between">
11+ <span><a href="{{.Href}}">{{.Short}}</a>&nbsp;&nbsp;{{if .Name}}{{.Name}} &middot; {{end}}{{if .Failed}}<span class="danger">{{.Result}}</span>{{else}}{{.Result}}{{end}}</span>
12+ <span class="muted small">{{.Took}} &middot; {{.Ago}}</span>
13+ </div>
14+ <div class="muted small">{{if .Ref}}{{if .RefHref}}<a href="{{.RefHref}}">{{.Ref}}</a>{{else}}{{.Ref}}{{end}} &middot; {{end}}{{if .Runner}}<a href="{{.RunnerHref}}">{{.Runner}}</a>{{end}}{{if .Subject}} &middot; <a href="{{.CommitHref}}">{{.Subject}}</a>{{end}}</div>
15+ </div>
16+ {{end}}
17+ {{if not .Runs}}
18+ <div class="row"><div class="muted">no runs yet.</div></div>
19+ {{end}}
20+ {{if .Workflows}}
21+ <div class="row"><div class="muted small">building from
22+ {{range $i, $f := .Workflows}}{{if $i}}, {{end}}<span class="secondary">{{$f}}</span>{{end}}.
23+ set <span class="secondary">[build] command</span> in .barerepo/config to use that instead.</div></div>
24+ {{else if and (not .BuildOn) (not .Runs)}}
25+ <div class="row"><div class="muted small">builds are off. set
26+ <span class="secondary">[build] command</span> in .barerepo/config, or add a
27+ <span class="secondary">.github/workflows</span> file, to turn them on.</div></div>
28+ {{end}}
29+ {{- end}}
@@ -0,0 +1,22 @@
1+ {{define "footleft"}}{{if .Query}}"{{.Query}}"{{else}}search{{end}}{{end}}
2+ {{define "body" -}}
3+ {{template "topbar" .}}
4+ <div class="row tall">
5+ <form method="get" action="/search">
6+ <input class="text" name="q" value="{{.Query}}" autofocus spellcheck="false"
7+ placeholder="code, threads and repositories, all at once">
8+ </form>
9+ {{if .Query}}<div class="muted small note">{{.Count}}</div>{{end}}
10+ </div>
11+ {{range .Results}}
12+ <div class="row">
13+ <div class="muted small">{{.Kind}}</div>
14+ <div class="note-small"><a href="{{.Href}}">{{.Where}}</a></div>
15+ {{if .HasText}}<pre class="box note-small">{{.Text}}</pre>{{end}}
16+ {{if .HasCtx}}<div class="muted small">{{.Context}}</div>{{end}}
17+ </div>
18+ {{end}}
19+ {{if and .Query (not .Results)}}
20+ <div class="row"><div class="muted">nothing matched.</div></div>
21+ {{end}}
22+ {{- end}}
@@ -0,0 +1,49 @@
1+ {{define "footleft"}}<a href="{{.Source}}">barerepo</a>{{end}}
2+ {{define "body" -}}
3+ <div class="bar">
4+ <span class="brand">barerepo</span>
5+ <span class="secondary"><a href="/signup">new account</a></span>
6+ </div>
7+ <div class="pane loose">
8+ <div class="panetitle">sign in</div>
9+ {{if .Error}}<div class="danger small formerror">{{.Error}}{{if .Unknown}}
10+ <a href="/signup">make one</a>, or check the spelling.{{end}}</div>{{end}}
11+ {{if .Nonce}}
12+ <form method="post" action="/auth/verify">
13+ <input type="hidden" name="nonce" value="{{.Nonce}}">
14+ <input type="hidden" name="name" value="{{.Name}}">
15+ <div class="field">
16+ <div class="label">nonce</div>
17+ <pre class="box">{{.Nonce}}</pre>
18+ </div>
19+ <div class="field">
20+ <div class="label">sign it</div>
21+ <pre class="box">printf '%s' '{{.Nonce}}' | ssh-keygen -Y sign -f ~/.ssh/id_ed25519 -n barerepo-auth -</pre>
22+ <div class="hint">stock openssh, nothing to install, nothing left on disk.
23+ this nonce is good for 10 minutes.</div>
24+ </div>
25+ <div class="field">
26+ <div class="label">signature</div>
27+ <textarea class="text tall" name="signature" spellcheck="false" autofocus
28+ placeholder="-----BEGIN SSH SIGNATURE-----"></textarea>
29+ </div>
30+ <div><button class="btn" type="submit">sign in</button></div>
31+ </form>
32+ {{else}}
33+ <form method="post" action="/auth/challenge">
34+ <div class="field">
35+ <div class="label">name</div>
36+ <input class="text" name="name" value="{{.Name}}" autofocus spellcheck="false" autocapitalize="off">
37+ </div>
38+ <div class="actions"><button class="btn" type="submit">send challenge</button></div>
39+ </form>
40+ <div class="muted small stack">
41+ <div>we give you a nonce. you sign it with the key you already have.</div>
42+ <div class="secondary">printf '%s' '&lt;nonce&gt;' | ssh-keygen -Y sign -f ~/.ssh/id_ed25519 -n barerepo-auth -</div>
43+ <div>that is stock openssh. nothing to install.</div>
44+ <div>or <span class="secondary">br auth {{if .Name}}{{.Name}}{{else}}&lt;name&gt;{{end}}</span>,
45+ which does the same thing in one step.</div>
46+ </div>
47+ {{end}}
48+ </div>
49+ {{- end}}
@@ -0,0 +1,46 @@
1+ {{define "footleft"}}<a href="{{.Source}}">barerepo</a>{{end}}
2+ {{define "body" -}}
3+ <div class="bar">
4+ <span class="brand">barerepo</span>
5+ <span class="secondary"><a href="/signin">sign in</a></span>
6+ </div>
7+ <div class="pane loose">
8+ <div class="panetitle">new account</div>
9+ {{if .Error}}<div class="danger small formerror">{{.Error}}</div>{{end}}
10+ {{if .Nonce}}
11+ <form method="post" action="/signup">
12+ <input type="hidden" name="nonce" value="{{.Nonce}}">
13+ <div class="field">
14+ <div class="label">prove you hold the private half</div>
15+ <pre class="box">printf '%s' '{{.Nonce}}' | ssh-keygen -Y sign -f ~/.ssh/id_ed25519 -n barerepo-signup -</pre>
16+ <div class="hint">a public key is public, so anyone could paste yours. this is how the
17+ server knows it is yours. good for 10 minutes.</div>
18+ </div>
19+ <div class="field">
20+ <div class="label">signature</div>
21+ <textarea class="text tall" name="signature" spellcheck="false" autofocus
22+ placeholder="-----BEGIN SSH SIGNATURE-----"></textarea>
23+ </div>
24+ <div class="actions"><button class="btn" type="submit">create {{.Name}}</button></div>
25+ </form>
26+ {{else}}
27+ <form method="post" action="/signup">
28+ <div class="field">
29+ <div class="label">name</div>
30+ <input class="text" name="name" value="{{.Name}}" autofocus spellcheck="false" autocapitalize="off">
31+ </div>
32+ <div class="field">
33+ <div class="label">public key</div>
34+ <textarea class="text short" name="pubkey" spellcheck="false">{{.PubKey}}</textarea>
35+ <div class="hint">cat ~/.ssh/id_ed25519.pub</div>
36+ </div>
37+ <div class="actions"><button class="btn" type="submit">create</button></div>
38+ </form>
39+ {{end}}
40+ <div class="muted small stack">
41+ <div>your key is your account. there is no email and no password.</div>
42+ <div class="danger">losing every key loses the account, because there is no out-of-band recovery channel.</div>
43+ <div>add a second key from another machine today.</div>
44+ </div>
45+ </div>
46+ {{- end}}
@@ -0,0 +1,30 @@
1+ {{define "footleft"}}{{.Owner}} / {{.Name}}{{end}}
2+ {{define "body" -}}
3+ <div class="bar">
4+ <div class="secondary">{{template "crumbs" .}} / <span class="here">new thread</span></div>
5+ <div class="muted small">refs/notes/threads</div>
6+ </div>
7+ <div class="pane wide">
8+ {{if .Error}}<div class="danger small formerror">{{.Error}}</div>{{end}}
9+ <form method="post" action="/{{.Owner}}/{{.Name}}/threads">
10+ <div class="field">
11+ <div class="label">title</div>
12+ <input class="text" name="title" value="{{.Heading}}" autofocus>
13+ </div>
14+ <div class="field">
15+ <div class="label">body</div>
16+ <textarea class="text tall" name="body" placeholder="markdown">{{.Body}}</textarea>
17+ </div>
18+ <div class="field">
19+ <div class="label">attach a ref</div>
20+ <input class="text" name="ref" value="{{.Ref}}" spellcheck="false" placeholder="optional">
21+ <div class="hint">push first, then paste the ref. a thread with no ref is an issue.</div>
22+ </div>
23+ <div class="actions"><button class="btn" type="submit">open</button></div>
24+ </form>
25+ </div>
26+ <div class="pane-top">
27+ <div class="muted small note-under">or skip this form. a proposal opens a thread by itself.</div>
28+ <pre class="box">git push origin HEAD:refs/proposals/new</pre>
29+ </div>
30+ {{- end}}
@@ -0,0 +1,62 @@
1+ {{define "footleft"}}<a href="/{{.Owner}}/{{.Name}}/threads">threads</a>{{if .Threads}} &middot; {{.Threads}} open{{end}}{{end}}
2+ {{define "body" -}}
3+ <div class="bar">
4+ <div class="secondary">{{template "crumbs" .}} / <span class="here">thread {{.N}}</span></div>
5+ <div class="muted small">{{.NotesRef}}</div>
6+ </div>
7+ <div class="row tall">
8+ <div class="name">{{.Heading}}</div>
9+ <div class="muted small">{{template "bits" .Detail}}</div>
10+ </div>
11+ {{if .Error}}<div class="row"><div class="danger small">{{.Error}}</div></div>{{end}}
12+ {{range .Comments}}
13+ {{if .Run}}
14+ <div class="row tall">
15+ <div class="muted small">{{if .AuthorHref}}<a href="{{.AuthorHref}}">{{.Author}}</a>{{else}}{{.Author}}{{end}} &middot; {{.Ago}} &middot; self-hosted runner</div>
16+ <div class="{{if .Run.Failed}}danger{{else}}secondary{{end}}">{{if .Run.Name}}{{.Run.Name}} &middot; {{end}}<a href="{{.Run.Href}}">{{.Run.Result}}</a> &middot; {{.Run.Took}}</div>
17+ </div>
18+ {{else}}
19+ <div class="row tall">
20+ <div class="muted small">{{if .AuthorHref}}<a href="{{.AuthorHref}}">{{.Author}}</a>{{else}}{{.Author}}{{end}} &middot; {{.Ago}}{{if .Where}} &middot; on {{if .WhereHref}}<a href="{{.WhereHref}}">{{.Where}}</a>{{else}}{{.Where}}{{end}}{{if .Placement}}
21+ &middot; <span class="danger">{{.Placement}}</span>{{end}}{{end}}</div>
22+ {{if .Excerpt}}
23+ <div class="diff">
24+ <div class="ctx"><span class="num-inline">{{.Line}}</span>{{.Excerpt}}</div>
25+ </div>
26+ {{end}}
27+ <div class="body">{{.HTML}}</div>
28+ </div>
29+ {{end}}
30+ {{end}}
31+ {{if not .Comments}}
32+ <div class="row tall"><div class="muted small">no comments yet.</div></div>
33+ {{end}}
34+ {{if .Viewer}}
35+ <div class="row tall">
36+ <form method="post" action="/{{.Owner}}/{{.Name}}/thread/{{.N}}/reply">
37+ <textarea class="text" name="body" placeholder="reply"></textarea>
38+ <div class="note"><button class="btn" type="submit">reply</button></div>
39+ </form>
40+ {{if .CanClose}}
41+ <form method="post" action="/{{.Owner}}/{{.Name}}/thread/{{.N}}/close" class="inline">
42+ <button class="linkbtn" type="submit">{{if .Closed}}open again{{else}}close{{end}}</button>
43+ </form>
44+ {{end}}
45+ </div>
46+ {{else}}
47+ <div class="row tall">
48+ <div class="muted small"><a href="/signin">sign in</a> to reply, or push a note from your clone:</div>
49+ <div class="note">
50+ <pre class="box tall">git notes --ref=threads/{{.N}} append -m "your reply"
51+ git push origin refs/notes/threads/{{.N}}</pre>
52+ </div>
53+ </div>
54+ {{end}}
55+ <div class="row tall">
56+ <div class="muted small">this thread is in your clone. read it with no network, and read it if barerepo stops.</div>
57+ <div class="note">
58+ <pre class="box tall">git fetch origin "refs/notes/*:refs/notes/*"
59+ git log --show-notes=threads/{{.N}}</pre>
60+ </div>
61+ </div>
62+ {{- end}}
@@ -0,0 +1,23 @@
1+ {{define "footleft"}}{{if .Older}}<a href="{{.Older}}">older</a> &middot; {{end}}{{.Counts}}{{end}}
2+ {{define "body" -}}
3+ <div class="bar">
4+ <div class="secondary">{{template "crumbs" .}} / <span class="here">threads</span></div>
5+ <div class="muted small">{{.Counts}}</div>
6+ </div>
7+ {{template "repotabs" .}}
8+ {{if .Viewer}}
9+ <div class="row"><a class="btn" href="/{{.Owner}}/{{.Name}}/threads/new">new thread</a></div>
10+ {{end}}
11+ {{range .Threads}}
12+ <div class="row{{if .Dim}} dim{{end}}">
13+ <div class="between">
14+ <span{{if .Dim}} class="strike"{{end}}><a href="{{.Href}}">{{.N}}&nbsp;&nbsp;{{.Title}}</a></span>
15+ <span class="muted small">{{if .Author}}{{if .AuthorHref}}<a href="{{.AuthorHref}}">{{.Author}}</a>{{else}}{{.Author}}{{end}} &middot; {{end}}{{.Ago}}</span>
16+ </div>
17+ <div class="muted small">{{template "bits" .Detail}}</div>
18+ </div>
19+ {{end}}
20+ {{if not .Threads}}
21+ <div class="row"><div class="muted">{{.Empty}}</div></div>
22+ {{end}}
23+ {{- end}}
@@ -0,0 +1,498 @@
1+ package httpd
2+
3+ import (
4+ "embed"
5+ "fmt"
6+ "html/template"
7+ "net/http"
8+ "strings"
9+ "time"
10+ "unicode"
11+ "unicode/utf8"
12+
13+ "github.com/barerepo/server/internal/cache"
14+ "github.com/barerepo/server/internal/gitread"
15+ )
16+
17+ //go:embed templates
18+ var templateFS embed.FS
19+
20+ // Version is the only number the interface shows about itself, per chapter 25.
21+ const Version = "0.1.0"
22+
23+ // Source is where this program is kept, and it is absolute because an install elsewhere does not hold a copy of it.
24+ const Source = "https://barerepo.com/barerepo/server"
25+
26+ // pages are parsed once at start, each defining "body" and "footleft" for layout.html.
27+ var pages = map[string]*template.Template{}
28+
29+ func init() {
30+ layout := must(templateFS.ReadFile("templates/layout.html"))
31+ entries := must(templateFS.ReadDir("templates"))
32+ for _, e := range entries {
33+ if e.Name() == "layout.html" {
34+ continue
35+ }
36+ body := must(templateFS.ReadFile("templates/" + e.Name()))
37+ name := strings.TrimSuffix(e.Name(), ".html")
38+ pages[name] = template.Must(template.New(name).Parse(string(layout) + string(body)))
39+ }
40+ }
41+
42+ func must[T any](v T, err error) T {
43+ if err != nil {
44+ panic(err)
45+ }
46+ return v
47+ }
48+
49+ // render builds into memory first, because a template that fails mid-write cannot become an error page.
50+ func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, data any) {
51+ t, ok := pages[name]
52+ if !ok {
53+ s.oops(w, r, fmt.Errorf("no template %q", name))
54+ return
55+ }
56+ var buf strings.Builder
57+ if err := t.ExecuteTemplate(&buf, "layout", data); err != nil {
58+ s.oops(w, r, err)
59+ return
60+ }
61+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
62+ w.Write([]byte(buf.String()))
63+ }
64+
65+ // chrome is what every page needs and no page thinks about.
66+ type chrome struct {
67+ Title string
68+ Summary string
69+ Version string
70+ Source string
71+ // Share is what a link preview shows, because the summary above describes the page to a screen reader and reads as a spec anywhere else.
72+ Share string
73+ // Site is the absolute origin, because a share card cannot follow a relative image.
74+ Site string
75+ // Card is the share image for this page, and empty means the one for the whole site.
76+ Card string
77+ // Assets is what the stylesheet url carries, so a changed file is a url no browser has kept.
78+ Assets string
79+ }
80+
81+ // Site is the origin a share card quotes, set once at start from the external url.
82+ var Site string
83+
84+ func newChrome(title, summary string) chrome {
85+ return chrome{Title: title, Summary: summary, Version: Version, Source: Source, Assets: assetTag, Site: Site}
86+ }
87+
88+ // newCardChrome is a page whose share image says whose page it is.
89+ func newCardChrome(title, summary, card string) chrome {
90+ c := newChrome(title, summary)
91+ c.Card = card
92+ return c
93+ }
94+
95+ // bit is one item in a headline, linked where barerepo has somewhere to send it and plain where it does not.
96+ type bit struct {
97+ Text string
98+ Href string
99+ // Danger is the one colour the mockups use in a headline, and only a failed build earns it.
100+ Danger bool
101+ }
102+
103+ // plain is a bit that goes nowhere, which is most of a headline.
104+ func plain(text string) bit { return bit{Text: text} }
105+
106+ type tab struct {
107+ Label string
108+ Href string
109+ On bool
110+ // A tab that is not ready still shows but does not link, because a 404 is worse than nothing.
111+ Ready bool
112+ }
113+
114+ // built lists the views that exist, and is the only place that decides whether a tab links.
115+ var built = map[string]bool{
116+ "log": true,
117+ "files": true,
118+ "config": true,
119+ "threads": true,
120+ "runs": true,
121+ "releases": true,
122+ }
123+
124+ // repoTabs is the row under the breadcrumb, where config is barely a special case. Chapter 24.
125+ func repoTabs(owner, name, active, branch string, threads int) []tab {
126+ base := "/" + owner + "/" + name
127+ if branch == "" {
128+ branch = "HEAD"
129+ }
130+ labels := []struct{ key, label, href string }{
131+ {"log", "log", base},
132+ {"files", "files", base + "/files"},
133+ {"threads", threadLabel(threads), base + "/threads"},
134+ {"runs", "runs", base + "/runs"},
135+ // The mockups draw five tabs, and none of them links to releases, so its page had no way in.
136+ {"releases", "releases", base + "/releases"},
137+ {"config", "config", base + "/config"},
138+ }
139+ out := make([]tab, 0, len(labels))
140+ for _, l := range labels {
141+ out = append(out, tab{
142+ Label: l.label,
143+ Href: l.href,
144+ On: l.key == active,
145+ Ready: built[l.key],
146+ })
147+ }
148+ return out
149+ }
150+
151+ func threadLabel(n int) string {
152+ if n == 0 {
153+ return "threads"
154+ }
155+ return fmt.Sprintf("threads %d", n)
156+ }
157+
158+ // commitView is one entry on the log page.
159+ type commitView struct {
160+ gitread.Commit
161+ Ago string
162+ Summary string
163+ Href string
164+ // AuthorHref is the account page, where a name that is not an account still reads as a name.
165+ AuthorHref string
166+ // Diff says there is something to open, so a commit with no inlined hunk gets no disclosure.
167+ Diff bool
168+ Files []fileView
169+ // SigNote is what the mark means, in the same words the commit page uses.
170+ SigNote string
171+ }
172+
173+ type fileView struct {
174+ gitread.FileDiff
175+ Hunks []hunkView
176+ }
177+
178+ type hunkView struct {
179+ Header string
180+ Lines []lineView
181+ }
182+
183+ // lineView carries the prefix as data, so the template never decides what a diff line means.
184+ type lineView struct {
185+ Kind byte
186+ Prefix string
187+ Text string
188+ New int
189+ CommentHref string
190+ }
191+
192+ // Cache keeps rendered diffs, which chapter 25 asks for by name, keyed by the commit that made them.
193+ var Cache *cache.Disk
194+
195+ func newCommitView(owner, name string, c gitread.Commit) commitView {
196+ return commitView{
197+ Commit: c,
198+ Ago: ago(c.When),
199+ Summary: changeSummary(c),
200+ Href: "/" + owner + "/" + name + "/commit/" + c.SHA,
201+ }
202+ }
203+
204+ // SignatureNote is what the commit page says about a signature, and empty means there is none.
205+ func SignatureNote(c gitread.Commit, account string) (string, bool) {
206+ if !c.Signed {
207+ return "", false
208+ }
209+ switch c.SigState {
210+ case "G", "U":
211+ // The account that published the key, because the name inside a key is typed by its owner.
212+ if account != "" {
213+ return "signed by " + account, false
214+ }
215+ return "signed by " + c.Signer, false
216+ case "B":
217+ return "the signature does not match this commit", true
218+ case "X", "Y", "R":
219+ return "signed on a key that is expired or revoked", true
220+ }
221+ // Nobody has published the key this was signed with, so barerepo says so and claims nothing.
222+ return "signed", false
223+ }
224+
225+ // fillDiff builds the line views, which is the work a cached body exists to avoid.
226+ func fillDiff(v *commitView) {
227+ v.Files = viewFiles(v.Commit.Files, nil)
228+ for _, f := range v.Files {
229+ if len(f.Hunks) > 0 {
230+ v.Diff = true
231+ return
232+ }
233+ }
234+ }
235+
236+ func viewCommits(owner, name string, commits []gitread.Commit) []commitView {
237+ out := make([]commitView, 0, len(commits))
238+ for _, c := range commits {
239+ v := newCommitView(owner, name, c)
240+ fillDiff(&v)
241+ out = append(out, v)
242+ }
243+ return out
244+ }
245+
246+ // viewLog is one row per commit, every row the same, because this page draws no diff. Chapter 24.
247+ func viewLog(owner, name string, commits []gitread.Commit) []commitView {
248+ out := make([]commitView, 0, len(commits))
249+ for _, c := range commits {
250+ out = append(out, newCommitView(owner, name, c))
251+ }
252+ return out
253+ }
254+
255+ // linkAuthors resolves the names on a page in one query, because a git name is not always an account.
256+ func (s *Server) linkAuthors(r *http.Request, views []commitView) {
257+ names := authorNames(views)
258+ if len(names) == 0 || s.DB == nil {
259+ return
260+ }
261+ accounts, err := s.DB.AccountsExist(r.Context(), names)
262+ if err != nil {
263+ // A name left plain is a smaller failure than a page that will not render.
264+ return
265+ }
266+ fillAuthorHrefs(views, accounts)
267+ }
268+
269+ // fillAuthorHrefs fills in the account pages, and leaves a name that is not an account as plain text.
270+ func fillAuthorHrefs(views []commitView, accounts map[string]bool) {
271+ for i := range views {
272+ if accounts[views[i].Author] {
273+ views[i].AuthorHref = "/" + views[i].Author
274+ }
275+ // The same words the commit page uses, so a mark never claims more than was checked.
276+ signer := ""
277+ if accounts[views[i].Signer] {
278+ signer = views[i].Signer
279+ }
280+ views[i].SigNote, _ = SignatureNote(views[i].Commit, signer)
281+ }
282+ }
283+
284+ // authorNames is the distinct set to look up, because one query beats one per row.
285+ func authorNames(views []commitView) []string {
286+ seen := map[string]bool{}
287+ var out []string
288+ for _, v := range views {
289+ if v.Author != "" && !seen[v.Author] {
290+ seen[v.Author] = true
291+ out = append(out, v.Author)
292+ }
293+ }
294+ return out
295+ }
296+
297+ func prefixOf(kind byte) string {
298+ switch kind {
299+ case '+':
300+ return "+ "
301+ case '-':
302+ return "- "
303+ default:
304+ return " "
305+ }
306+ }
307+
308+ // changeSummary names which files changed and by how much, while few enough fit.
309+ func changeSummary(c gitread.Commit) string {
310+ if len(c.Files) == 0 {
311+ return "no changes"
312+ }
313+ var what string
314+ if len(c.Files) <= 2 {
315+ names := make([]string, len(c.Files))
316+ for i, f := range c.Files {
317+ names[i] = f.Path
318+ }
319+ what = strings.Join(names, ", ")
320+ } else {
321+ what = fmt.Sprintf("%d files", len(c.Files))
322+ }
323+ return fmt.Sprintf("%s · +%d -%d", what, c.Add, c.Del)
324+ }
325+
326+ // monthYear is the mockups' lowercase "mar 2026". Go's reference month is Jan, and a lowercase one is a literal.
327+ const monthYear = "Jan 2006"
328+
329+ // month is that date, lowercased, because the mockups have no capital letters anywhere.
330+ func month(t time.Time) string {
331+ if t.IsZero() {
332+ return ""
333+ }
334+ return strings.ToLower(t.Format(monthYear))
335+ }
336+
337+ // ago is the mockups' short form, 2h or 3d, because a column of numbers scans and prose does not.
338+ func ago(t time.Time) string {
339+ if t.IsZero() {
340+ return ""
341+ }
342+ d := time.Since(t)
343+ switch {
344+ case d < time.Minute:
345+ return "now"
346+ case d < time.Hour:
347+ return fmt.Sprintf("%dm", int(d.Minutes()))
348+ case d < 24*time.Hour:
349+ return fmt.Sprintf("%dh", int(d.Hours()))
350+ case d < 7*24*time.Hour:
351+ return fmt.Sprintf("%dd", int(d.Hours()/24))
352+ case d < 31*24*time.Hour:
353+ // The profile mockup writes three weeks as 3w, and a month is where the mockups stop counting them.
354+ return fmt.Sprintf("%dw", int(d.Hours()/24/7))
355+ case d < 365*24*time.Hour:
356+ return fmt.Sprintf("%dmo", int(d.Hours()/24/30))
357+ default:
358+ return fmt.Sprintf("%dy", int(d.Hours()/24/365))
359+ }
360+ }
361+
362+ // spoken is the sentence form the mockups use outside a column, where 40m becomes 40m ago, and now is left alone because nobody says now ago.
363+ func spoken(since string) string {
364+ if since == "" || since == "now" {
365+ return since
366+ }
367+ return since + " ago"
368+ }
369+
370+ // entryView is one row of the file tree.
371+ type entryView struct {
372+ gitread.Entry
373+ Ago string
374+ Href string
375+ }
376+
377+ func viewEntries(owner, name, ref string, entries []gitread.Entry) []entryView {
378+ base := "/" + owner + "/" + name
379+ out := make([]entryView, 0, len(entries))
380+ for _, e := range entries {
381+ kind := "/file/"
382+ if e.Dir {
383+ kind = "/files/"
384+ }
385+ out = append(out, entryView{
386+ Entry: e,
387+ Ago: ago(e.When),
388+ Href: base + kind + ref + "/" + e.Path,
389+ })
390+ }
391+ return out
392+ }
393+
394+ // fileLineView is one row of the file view: blame, number, code.
395+ type fileLineView struct {
396+ Number int
397+ Text string
398+ Blame string
399+ }
400+
401+ func viewFileLines(lines []gitread.FileLine) []fileLineView {
402+ out := make([]fileLineView, 0, len(lines))
403+ for _, l := range lines {
404+ blame := ""
405+ if l.Short != "" {
406+ blame = l.Short + " " + l.Author + " " + ago(l.When)
407+ }
408+ out = append(out, fileLineView{Number: l.Number, Text: l.Text, Blame: blame})
409+ }
410+ return out
411+ }
412+
413+ // size renders bytes the way the mockups do: 1.4kb, 8.1mb.
414+ func size(n int64) string {
415+ switch {
416+ case n < 1024:
417+ return fmt.Sprintf("%db", n)
418+ case n < 1024*1024:
419+ return unit(float64(n)/1024, "kb")
420+ default:
421+ return unit(float64(n)/(1024*1024), "mb")
422+ }
423+ }
424+
425+ // unit drops the decimal once the number carries itself, which is how the mockups write 210kb and 88kb beside 1.4kb and 4.1mb.
426+ func unit(v float64, suffix string) string {
427+ if v < 10 {
428+ return fmt.Sprintf("%.1f%s", v, suffix)
429+ }
430+ return fmt.Sprintf("%.0f%s", v, suffix)
431+ }
432+
433+ // viewFiles turns diffs into rows a template need not think about, and commentOn may be nil.
434+ func viewFiles(files []gitread.FileDiff, commentOn func(path string, line int) string) []fileView {
435+ out := make([]fileView, 0, len(files))
436+ for _, f := range files {
437+ fv := fileView{FileDiff: f}
438+ for _, h := range f.Hunks {
439+ hv := hunkView{Header: h.Header}
440+ for _, l := range h.Lines {
441+ lv := lineView{Kind: l.Kind, Prefix: prefixOf(l.Kind), Text: l.Text, New: l.New}
442+ if commentOn != nil && l.New > 0 {
443+ lv.CommentHref = commentOn(f.Path, l.New)
444+ }
445+ hv.Lines = append(hv.Lines, lv)
446+ }
447+ fv.Hunks = append(fv.Hunks, hv)
448+ }
449+ out = append(out, fv)
450+ }
451+ return out
452+ }
453+
454+ // highlight escapes first and marks after, or a source file could write markup into a result.
455+ func highlight(text, query string) template.HTML {
456+ var b strings.Builder
457+ for text != "" {
458+ // Offsets come from the text itself, because lowercasing it first moves them. U+023A shrinks.
459+ i, n := foldIndex(text, query)
460+ if i < 0 {
461+ break
462+ }
463+ b.WriteString(template.HTMLEscapeString(text[:i]))
464+ b.WriteString(`<span class="hit">`)
465+ b.WriteString(template.HTMLEscapeString(text[i : i+n]))
466+ b.WriteString(`</span>`)
467+ text = text[i+n:]
468+ }
469+ b.WriteString(template.HTMLEscapeString(text))
470+ return template.HTML(b.String())
471+ }
472+
473+ // foldIndex finds query in text ignoring case, and answers in the byte offsets text uses.
474+ func foldIndex(text, query string) (int, int) {
475+ if query == "" {
476+ return -1, 0
477+ }
478+ // A range over a string yields rune starts, so every offset returned is a whole character.
479+ for i := range text {
480+ if n := foldPrefix(text[i:], query); n > 0 {
481+ return i, n
482+ }
483+ }
484+ return -1, 0
485+ }
486+
487+ // foldPrefix reports how many bytes of s match query ignoring case, which need not be len(query).
488+ func foldPrefix(s, query string) int {
489+ used := 0
490+ for _, q := range query {
491+ r, n := utf8.DecodeRuneInString(s[used:])
492+ if n == 0 || unicode.ToLower(r) != unicode.ToLower(q) {
493+ return 0
494+ }
495+ used += n
496+ }
497+ return used
498+ }
@@ -0,0 +1,1012 @@
1+ package httpd
2+
3+ import (
4+ "fmt"
5+ "strings"
6+ "testing"
7+ "time"
8+
9+ "github.com/barerepo/server/internal/gitread"
10+ "github.com/barerepo/server/internal/run"
11+ "github.com/barerepo/server/internal/thread"
12+ "html/template"
13+ )
14+
15+ // renderCase is one template and what its page must say, shared by the render and field checks.
16+ type renderCase struct {
17+ data any
18+ want []string
19+ }
20+
21+ // Every template runs here, because a typo otherwise fails when a user asks for the page.
22+ func TestTemplatesRender(t *testing.T) {
23+ for name, c := range renderCases() {
24+ tpl, ok := pages[name]
25+ if !ok {
26+ t.Errorf("no template named %q", name)
27+ continue
28+ }
29+ var out strings.Builder
30+ if err := tpl.ExecuteTemplate(&out, "layout", c.data); err != nil {
31+ t.Errorf("%s: %v", name, err)
32+ continue
33+ }
34+ got := out.String()
35+ if !strings.HasPrefix(got, "<!doctype html>") || !strings.Contains(got, "</html>") {
36+ t.Errorf("%s: not a whole document", name)
37+ }
38+ for _, want := range c.want {
39+ if !strings.Contains(got, want) {
40+ t.Errorf("%s: missing %q", name, want)
41+ }
42+ }
43+ }
44+ // Every template, not only the ones somebody remembered, or a new page ships untested.
45+ for name := range pages {
46+ if _, ok := renderCases()[name]; !ok {
47+ t.Errorf("template %q has no case here, so nothing renders it before a user asks", name)
48+ }
49+ }
50+ }
51+
52+ func renderCases() map[string]renderCase {
53+ page := repoPage{
54+ chrome: newChrome("barerepo · john/johnbot", "a summary"),
55+ Owner: "john",
56+ Name: "johnbot",
57+ Branch: "master",
58+ CloneURL: "git@barerepo:john/johnbot",
59+ Tabs: repoTabs("john", "johnbot", "log", "master", 3),
60+ }
61+ commits := viewCommits("john", "johnbot", []gitread.Commit{{
62+ SHA: "a3f9c2d", Short: "a3f9c2", Author: "lisa",
63+ When: time.Now().Add(-2 * time.Hour), Subject: "fix panic when config is empty",
64+ Add: 4, Del: 1,
65+ Files: []gitread.FileDiff{{Path: "config.go", Add: 4, Del: 1, Hunks: []gitread.Hunk{{
66+ Header: "@@ -41,7 +41,10 @@ func Load",
67+ Lines: []gitread.Line{
68+ {Kind: ' ', Text: "f, err := os.Open(path)"},
69+ {Kind: '-', Text: "return cfg"},
70+ {Kind: '+', Text: "if len(raw) == 0 {"},
71+ },
72+ }}}},
73+ }})
74+
75+ return map[string]renderCase{
76+ "repo-log": {
77+ struct {
78+ repoPage
79+ Commits []commitView
80+ ReadmeHref string
81+ OnlyPath string
82+ FileHref string
83+ }{page, commits, "/john/johnbot/file/a3f9c2/README.md", "", ""},
84+ []string{"john / ", "johnbot", "a3f9c2", "fix panic", "barerepo 0.1.0"},
85+ },
86+ "push-rejected": {
87+ struct {
88+ repoPage
89+ Ref string
90+ Head string
91+ Push string
92+ You string
93+ MayPush bool
94+ }{page, "refs/heads/master", "a3f9c2 · 2h", `["john", "lisa"]`, "mark", false},
95+ // html/template writes a quote as &#34; in text, so the assertion says what a reader sees.
96+ []string{"you pushed to refs/heads/master", "&#34;john&#34;, &#34;lisa&#34;", "you are mark",
97+ "git push origin HEAD:refs/proposals/new", "printed in your terminal"},
98+ },
99+ "repo-empty": {
100+ struct {
101+ repoPage
102+ Public bool
103+ Description string
104+ Visibility string
105+ }{page, false, "", "private"},
106+ []string{"git init", "git@barerepo:john/johnbot", ".barerepo/config", "visibility"},
107+ },
108+ "repo-files": {
109+ struct {
110+ repoPage
111+ Path string
112+ Up string
113+ Entries []entryView
114+ More string
115+ }{page, "", "", viewEntries("john", "johnbot", "master", []gitread.Entry{
116+ {Name: "irc", Path: "irc", Dir: true, Author: "john", Subject: "split the daemon out"},
117+ {Name: "config.go", Path: "config.go", Author: "lisa", Subject: "fix panic when config is empty"},
118+ }), ""},
119+ []string{"irc/", "config.go", "split the daemon out", "/john/johnbot/files/master/irc"},
120+ },
121+ "repo-file": {
122+ struct {
123+ repoPage
124+ Path string
125+ File *gitread.File
126+ Lines []fileLineView
127+ Meta string
128+ SizeText string
129+ RawHref string
130+ HistoryHref string
131+ Shown int
132+ Truncated bool
133+ RenderedHref string
134+ }{page, "config.go", &gitread.File{Path: "config.go", Size: 1400},
135+ viewFileLines([]gitread.FileLine{{Number: 41, Text: "f, err := os.Open(path)",
136+ Short: "a3f9c2", Author: "lisa", When: time.Now().Add(-2 * time.Hour)}}),
137+ "61 lines · 1.4kb · master", "1.4kb", "/john/johnbot/raw/master/config.go",
138+ "/john/johnbot?path=config.go", 0, false, ""},
139+ []string{"config.go", "a3f9c2 lisa 2h", "class=\"num\"", "os.Open(path)", "raw", "history"},
140+ },
141+ "repo-commit": {
142+ struct {
143+ repoPage
144+ Commit commitView
145+ FileCount string
146+ Reachable bool
147+ Parent string
148+ ParentHref string
149+ FilesHref string
150+ Signature string
151+ SigBad bool
152+ SignerHref string
153+ Signer string
154+ Verify string
155+ }{page, commits[0], "1 file", true, "8b1d44",
156+ "/john/johnbot/commit/8b1d44", "/john/johnbot/file/a3f9c2/",
157+ "signed by lisa", false, "/lisa", "lisa", "git verify-commit a3f9c2d"},
158+ []string{"a3f9c2", "fix panic", "reachable from", `href="/lisa">lisa`,
159+ `href="/john/johnbot">master`, `href="/john/johnbot/commit/8b1d44">8b1d44`,
160+ `href="/john/johnbot/file/a3f9c2/config.go"`},
161+ },
162+ "repo-compare": {
163+ struct {
164+ repoPage
165+ Comparison *gitread.Comparison
166+ Files []fileView
167+ Stats string
168+ Action string
169+ FilesHref string
170+ Refs []refLink
171+ }{page, &gitread.Comparison{A: "master", B: "refs/proposals/47"},
172+ viewFiles(commits[0].Commit.Files, nil),
173+ "3 commits · 2 files · +81 -12 · no conflicts", "/john/johnbot/compare",
174+ "/john/johnbot/file/refs/proposals/47/",
175+ []refLink{{Name: "topic", Kind: "branch", Href: "/john/johnbot/compare/master...topic"}}},
176+ []string{"master", "refs/proposals/47", "no conflicts", "class=\"rev\"",
177+ `href="/john/johnbot/file/refs/proposals/47/config.go"`,
178+ `href="/john/johnbot/compare/master...topic"`},
179+ },
180+ "404": {
181+ struct {
182+ chrome
183+ Path string
184+ Near string
185+ Href string
186+ Missing string
187+ }{newChrome("barerepo · not found", "s"), "/john/johnbot/nope", "john/johnbot", "/john/johnbot", ""},
188+ []string{"404", "john/johnbot", "exists. that path in it does not."},
189+ },
190+ "readme": {
191+ struct {
192+ repoPage
193+ Path string
194+ HTML template.HTML
195+ SourceHref string
196+ RawHref string
197+ }{page, "README.md", template.HTML("<h1>barerepo</h1>"),
198+ "/john/johnbot/file/a3f9c2/README.md", "/john/johnbot/raw/a3f9c2/README.md"},
199+ []string{"README.md", "<h1>barerepo</h1>", "rendered",
200+ `href="/john/johnbot/file/a3f9c2/README.md"`, ">source<",
201+ `href="/john/johnbot/raw/a3f9c2/README.md"`},
202+ },
203+ "threads": {
204+ struct {
205+ repoPage
206+ Threads []threadRow
207+ Counts string
208+ Viewer string
209+ Empty string
210+ }{page, []threadRow{{N: 47, Title: "panic when config file is empty",
211+ Author: "lisa", AuthorHref: "/lisa", Ago: "2h",
212+ Href: "/john/johnbot/thread/47",
213+ Detail: []bit{plain("has proposal"), {Text: "+81 -12", Href: "/john/johnbot/compare"},
214+ {Text: "build failed", Danger: true}, plain("2 replies")}}},
215+ "3 open · 41 closed", "john", "no threads yet."},
216+ // html/template writes + as &#43; in text, which is why this checks the digits.
217+ []string{"panic when config file is empty", "3 open · 41 closed", "81 -12",
218+ `href="/lisa"`, `class="danger"`},
219+ },
220+ "thread-new": {
221+ struct {
222+ repoPage
223+ newThreadPage
224+ }{page, newThreadPage{Heading: "a title", Body: "a body", Ref: "refs/proposals/47"}},
225+ []string{"refs/proposals/47", "a body"},
226+ },
227+ "comment-line": {
228+ struct {
229+ repoPage
230+ N int
231+ Path string
232+ Line int
233+ Rev string
234+ Blob string
235+ Context []contextLine
236+ Error string
237+ }{page, 47, "config.go", 43, "refs/proposals/47", "a3f9c2d",
238+ []contextLine{{Number: 42, Text: "func Default() Config {"},
239+ {Number: 43, Text: "\treturn Config{}", Here: true}}, ""},
240+ []string{"config.go", "43", "func Default() Config {"},
241+ },
242+ "runs": {
243+ struct {
244+ repoPage
245+ Runs []runRow
246+ BuildOn bool
247+ Workflows []string
248+ }{page, []runRow{{Short: "a3f9c2", Name: "test (os ubuntu-latest)",
249+ Href: "/john/johnbot/run/a3f9c2",
250+ CommitHref: "/john/johnbot/commit/a3f9c2", Result: "build ok · test ok",
251+ Took: "18s", Ago: "40m", Subject: "fix panic", Ref: "refs/proposals/47",
252+ RefHref: "/john/johnbot/compare", Runner: "uproar.local",
253+ RunnerHref: "/john/johnbot/runners"}}, true, []string{".github/workflows/ci.yml"}},
254+ []string{"build ok · test ok", "uproar.local", `href="/john/johnbot/commit/a3f9c2"`,
255+ // Chapter 15A: a matrix builds one commit several times, so the row says which.
256+ "test (os ubuntu-latest)",
257+ // A page that says builds are off while builds run is contradicting itself.
258+ "building from", ".github/workflows/ci.yml"},
259+ },
260+ "run": {
261+ struct {
262+ repoPage
263+ SHA string
264+ Short string
265+ Meta string
266+ Ref string
267+ Author string
268+ Runs []runRow
269+ CanRerun bool
270+ }{page, "a3f9c2d", "a3f9c2", "1 run", "refs/proposals/47", "lisa",
271+ []runRow{{Result: "build ok", Took: "18s", Runner: "uproar.local",
272+ RunnerHref: "/john/johnbot/runners", Output: "all tests passed",
273+ RawHref: "/john/johnbot/run/a3f9c2d/log", Size: "1kb"}}, true},
274+ []string{"build ok", "all tests passed", "raw log"},
275+ },
276+ "runners": {
277+ struct {
278+ repoPage
279+ Runners []runnerRow
280+ Attached string
281+ CanAdd bool
282+ }{page, []runnerRow{{ID: 1, Hostname: "uproar.local", State: "idle",
283+ Seen: "2m", Detail: "linux/amd64"}}, "1 runner", true},
284+ []string{"uproar.local", "idle", "linux/amd64"},
285+ },
286+ "runner-setup": {
287+ struct {
288+ repoPage
289+ Token string
290+ ExternalURL string
291+ }{page, "rt_live_7Kq2mXe", "https://barerepo.example"},
292+ // Chapter 15: the token is in the copied line, and all three platforms show at once.
293+ []string{"rt_live_7Kq2mXe"},
294+ },
295+ "releases": {
296+ struct {
297+ repoPage
298+ Releases []releaseRow
299+ }{page, []releaseRow{{Tag: "v1.2.0", Href: "/john/johnbot/files/v1.2.0",
300+ Tagger: "john", TaggerHref: "/john", Ago: "3d",
301+ Subject: "fixes the empty config panic"}}},
302+ []string{"v1.2.0", `href="/john/johnbot/files/v1.2.0"`, `href="/john"`},
303+ },
304+ "repo-config": {
305+ struct {
306+ repoPage
307+ Config string
308+ Edited string
309+ Editor string
310+ EditorHref string
311+ EditHref string
312+ EditShort string
313+ RawHref string
314+ HistoryHref string
315+ BlameHref string
316+ IsOwner bool
317+ Hooks []hookRow
318+ Error string
319+ CopyTo string
320+ CopyHave string
321+ CopyURL string
322+ }{page, "[repo]\nvisibility = \"public\"",
323+ "3d", "john", "/john", "/john/johnbot/commit/a3f9c2", "a3f9c2",
324+ "/john/johnbot/raw/master/.barerepo/config",
325+ "/john/johnbot?path=.barerepo/config",
326+ "/john/johnbot/file/master/.barerepo/config", true,
327+ []hookRow{{URL: "https://example.com/hook", Events: "push",
328+ State: "stopped after 20 failures in a row · 500 Internal Server Error",
329+ Danger: true}}, "",
330+ "/lisa/johnbot", "", "git@barerepo:lisa/johnbot"},
331+ []string{"visibility", ".barerepo/config", "https://example.com/hook",
332+ "stopped after 20 failures in a row",
333+ "copy to /lisa/johnbot", "git@barerepo:lisa/johnbot", "proposal refs do not"},
334+ },
335+ "inbox": {
336+ struct {
337+ chrome
338+ Account string
339+ Events []eventRow
340+ Seen string
341+ }{newChrome("barerepo · inbox", "s"), "john",
342+ []eventRow{{Text: "lisa replied on john/johnbot thread 47",
343+ Ago: "2h", Href: "/john/johnbot/thread/47"}}, "last visit"},
344+ []string{"lisa replied", "/john/johnbot/thread/47", "atom"},
345+ },
346+ "search": {
347+ struct {
348+ chrome
349+ Account string
350+ Query string
351+ Count string
352+ Results []searchRow
353+ }{newChrome("barerepo · search", "s"), "john", "Default", "3 results",
354+ []searchRow{
355+ {Kind: "code", Where: template.HTML("john / johnbot / config.go:43"),
356+ Href: "/john/johnbot/file/master/config.go", HasText: true,
357+ Text: template.HTML("func Default() Config {")},
358+ {Kind: "thread", Where: template.HTML("john / johnbot 44 · does this work behind a proxy?"),
359+ Href: "/john/johnbot/thread/44", HasCtx: true,
360+ Context: template.HTML("mark mentions it twice")},
361+ {Kind: "repo", Where: template.HTML("john / johnbot"),
362+ Href: "/john/johnbot", HasCtx: true,
363+ Context: template.HTML("irc bot that refuses to leave")},
364+ }},
365+ []string{"Default", "3 results", "config.go:43",
366+ "44 · does this work behind a proxy?", "irc bot that refuses to leave"},
367+ },
368+ "profile": {
369+ struct {
370+ chrome
371+ Who string
372+ Account string
373+ Me bool
374+ Initials string
375+ Joined string
376+ KeyCount int
377+ SigningKeys int
378+ RepoCount string
379+ Repos []repoStatView
380+ Shown string
381+ AllHref string
382+ }{newChrome("barerepo · john", "s"), "john", "john", true, "jo", "mar 2026", 2, 1,
383+ "2 repositories", []repoStatView{{Name: "johnbot", Public: true,
384+ Ago: "2h", Meta: "go · 4mb · master"}}, "", ""},
385+ []string{"johnbot", "joined mar 2026", `href="/john/johnbot"`,
386+ `href="/john.keys"`, `href="/john.gpg"`, ">feed<"},
387+ },
388+ "new-repo": {
389+ struct {
390+ chrome
391+ Account string
392+ Name string
393+ Description string
394+ Branch string
395+ CloneBase string
396+ Error string
397+ }{newChrome("barerepo · new repo", "s"), "john", "", "an irc bot", "master",
398+ "git@localhost:john", ""},
399+ []string{"master", "git@localhost:john"},
400+ },
401+ "keys": {
402+ keysPage{chrome: newChrome("barerepo · keys", "s"), Account: "john",
403+ Keys: []keyView{{ID: 1, Algo: "ed25519", Fingerprint: "SHA256:ngsY",
404+ Comment: "john@laptop", Added: "mar 2026", LastUsed: "2h"}},
405+ Tokens: []tokenView{{ID: 1, Name: "runner", Detail: "john/johnbot",
406+ Created: "apr 2026"}}},
407+ []string{"ed25519", "SHA256:ngsY", "mar 2026", "runner"},
408+ },
409+ "signin": {
410+ signinPage{chrome: newChrome("barerepo · sign in", "s"), Name: "john",
411+ Nonce: "abc123"},
412+ []string{"john", "abc123"},
413+ },
414+ "signup": {
415+ signupPage{chrome: newChrome("barerepo · signup", "s"), Name: "john",
416+ PubKey: "ssh-ed25519 AAAA", Nonce: "abc123"},
417+ []string{"john", "abc123"},
418+ },
419+ "thread": {
420+ struct {
421+ repoPage
422+ N int
423+ Heading string
424+ Detail []bit
425+ NotesRef string
426+ Comments []commentRow
427+ Viewer string
428+ CanClose bool
429+ Closed bool
430+ Error string
431+ }{page, 47, "panic when config file is empty",
432+ []bit{plain("opened by lisa")}, "refs/notes/threads/47",
433+ []commentRow{{Author: "lisa", AuthorHref: "/lisa", Ago: "2h",
434+ HTML: template.HTML("<p>reproduces every time.</p>")}}, "john", true, false, ""},
435+ []string{"panic when config file is empty", "refs/notes/threads/47",
436+ "reproduces every time.", `href="/lisa"`, ">close<"},
437+ },
438+ }
439+
440+ }
441+
442+ // Untrusted content reaches every page, and html/template escapes by context. Said out loud.
443+ func TestTemplatesEscape(t *testing.T) {
444+ nasty := `<script>alert(1)</script>`
445+ commits := viewLog("john", "johnbot", []gitread.Commit{{
446+ Short: "a3f9c2", Author: nasty, Subject: nasty,
447+ Files: []gitread.FileDiff{{Path: nasty, Hunks: []gitread.Hunk{{
448+ Header: nasty,
449+ Lines: []gitread.Line{{Kind: '+', Text: nasty}},
450+ }}}},
451+ }})
452+ page := repoPage{chrome: newChrome(nasty, nasty), Owner: nasty, Name: nasty,
453+ CloneURL: nasty, Tabs: repoTabs("john", "johnbot", "log", "master", 0)}
454+
455+ var out strings.Builder
456+ err := pages["repo-log"].ExecuteTemplate(&out, "layout", struct {
457+ repoPage
458+ Commits []commitView
459+ ReadmeHref string
460+ OnlyPath string
461+ FileHref string
462+ }{page, commits, "", nasty, nasty})
463+ if err != nil {
464+ t.Fatal(err)
465+ }
466+ if strings.Contains(out.String(), "<script>") {
467+ t.Error("a commit message reached the page as markup")
468+ }
469+ if !strings.Contains(out.String(), "&lt;script&gt;") {
470+ t.Error("the commit message did not reach the page at all")
471+ }
472+ }
473+
474+ func TestAgo(t *testing.T) {
475+ now := time.Now()
476+ for d, want := range map[time.Duration]string{
477+ 30 * time.Second: "now",
478+ 5 * time.Minute: "5m",
479+ 3 * time.Hour: "3h",
480+ 50 * time.Hour: "2d",
481+ 24 * 40 * time.Hour: "1mo",
482+ 24 * 400 * time.Hour: "1y",
483+ } {
484+ if got := ago(now.Add(-d)); got != want {
485+ t.Errorf("ago(-%s) = %q, want %q", d, got, want)
486+ }
487+ }
488+ if ago(time.Time{}) != "" {
489+ t.Error("a zero time produced a duration")
490+ }
491+ }
492+
493+ // Chapter 42.7 drops an inline style or script in the browser, with nothing to say so.
494+ func TestNoInlineStyles(t *testing.T) {
495+ entries, err := templateFS.ReadDir("templates")
496+ if err != nil {
497+ t.Fatal(err)
498+ }
499+ for _, e := range entries {
500+ body, err := templateFS.ReadFile("templates/" + e.Name())
501+ if err != nil {
502+ t.Fatal(err)
503+ }
504+ if strings.Contains(string(body), "style=\"") {
505+ t.Errorf("%s has an inline style, which the content security policy drops", e.Name())
506+ }
507+ // 'self' allows a file from /static, so an inline script is still dropped silently.
508+ for _, tag := range strings.Split(string(body), "<script")[1:] {
509+ head, _, _ := strings.Cut(tag, ">")
510+ if !strings.Contains(head, `src="/static/`) {
511+ t.Errorf("%s has an inline script, which the policy drops", e.Name())
512+ }
513+ }
514+ }
515+ }
516+
517+ // Rule 2: name a `barerepo` command and show the plain one, which is already on the machine.
518+ func TestTheCliIsNeverTheOnlyWay(t *testing.T) {
519+ entries, err := templateFS.ReadDir("templates")
520+ if err != nil {
521+ t.Fatal(err)
522+ }
523+ // The plain command that has to appear alongside each barerepo subcommand.
524+ plainFor := map[string]string{
525+ "br auth": "ssh-keygen -Y sign",
526+ "br propose": "refs/proposals/new",
527+ "br reply": "git notes",
528+ "br fetch": "git fetch",
529+ "br notes": "git fetch",
530+ "br threads": "git log --show-notes",
531+ "barerepo copy": "git clone",
532+ }
533+ for _, e := range entries {
534+ body, err := templateFS.ReadFile("templates/" + e.Name())
535+ if err != nil {
536+ t.Fatal(err)
537+ }
538+ page := string(body)
539+ for cli, plain := range plainFor {
540+ if strings.Contains(page, cli) && !strings.Contains(page, plain) {
541+ t.Errorf("%s names %q but never shows %q, so it tells the reader "+
542+ "to run a program they have not installed", e.Name(), cli, plain)
543+ }
544+ }
545+ }
546+ }
547+
548+ // An embedded Title shadows the chrome's with no warning, so check the rendered document.
549+ func TestPageTitleIsTheChromeTitle(t *testing.T) {
550+ page := repoPage{
551+ chrome: newChrome("barerepo · thread 5", "s"),
552+ Owner: "john", Name: "johnbot",
553+ Tabs: repoTabs("john", "johnbot", "threads", "master", 1),
554+ }
555+ var out strings.Builder
556+ err := pages["thread"].ExecuteTemplate(&out, "layout", struct {
557+ repoPage
558+ N int
559+ Heading string
560+ Detail []bit
561+ NotesRef string
562+ Comments []commentRow
563+ Viewer string
564+ CanClose bool
565+ Closed bool
566+ Error string
567+ }{page, 5, "does this work behind a socks proxy?", []bit{plain("opened by mark")},
568+ "refs/notes/threads/5", nil, "", false, false, ""})
569+ if err != nil {
570+ t.Fatal(err)
571+ }
572+ if !strings.Contains(out.String(), "<title>barerepo · thread 5</title>") {
573+ t.Error("the document title is not the chrome's title")
574+ }
575+ if !strings.Contains(out.String(), "does this work behind a socks proxy?") {
576+ t.Error("the thread's own heading is missing from the body")
577+ }
578+ }
579+
580+ // A name with no account must say so, or the reader signs something that can never verify.
581+ func TestSigninNamesAnUnknownAccount(t *testing.T) {
582+ var out strings.Builder
583+ err := pages["signin"].ExecuteTemplate(&out, "layout", signinPage{
584+ chrome: signinChrome(),
585+ Name: "rock",
586+ Error: "there is no account named rock.",
587+ Unknown: true,
588+ })
589+ if err != nil {
590+ t.Fatal(err)
591+ }
592+ got := out.String()
593+ if !strings.Contains(got, "there is no account named rock.") {
594+ t.Error("the page does not say the account is missing")
595+ }
596+ if !strings.Contains(got, `href="/signup"`) {
597+ t.Error("the page does not offer signup")
598+ }
599+ if !strings.Contains(got, `value="rock"`) {
600+ t.Error("the name the reader typed was thrown away")
601+ }
602+ }
603+
604+ // Chapter 25 budgets 2kb for two shortcuts, and a page over it has grown something.
605+ func TestScriptBudget(t *testing.T) {
606+ body, err := static.ReadFile("static/keys.js")
607+ if err != nil {
608+ t.Fatal(err)
609+ }
610+ if len(body) > 2048 {
611+ t.Errorf("keys.js is %d bytes, over the 2kb budget in chapter 25", len(body))
612+ }
613+ entries, err := static.ReadDir("static")
614+ if err != nil {
615+ t.Fatal(err)
616+ }
617+ for _, e := range entries {
618+ if strings.HasSuffix(e.Name(), ".js") && e.Name() != "keys.js" {
619+ t.Errorf("static/%s exists; the budget is for keyboard shortcuts alone", e.Name())
620+ }
621+ }
622+ }
623+
624+ // A result is a line of somebody's source, so the mark goes on after escaping, never before.
625+ func TestHighlightEscapesFirst(t *testing.T) {
626+ got := string(highlight(`<script>alert("backoff")</script>`, "backoff"))
627+ if strings.Contains(got, "<script>") {
628+ t.Errorf("markup from the file survived: %s", got)
629+ }
630+ if !strings.Contains(got, `<span class="hit">backoff</span>`) {
631+ t.Errorf("the match was not marked: %s", got)
632+ }
633+ if !strings.Contains(got, "&lt;script&gt;") {
634+ t.Errorf("the line itself was lost: %s", got)
635+ }
636+ // The query is untrusted too.
637+ got = string(highlight("a <b> c", "<b>"))
638+ if strings.Contains(got, "<b>") {
639+ t.Errorf("a query that is markup got through: %s", got)
640+ }
641+ // Case does not have to match, because the search does not care.
642+ got = string(highlight("Backoff and backoff", "backoff"))
643+ if n := strings.Count(got, `class="hit"`); n != 2 {
644+ t.Errorf("marked %d of 2 matches: %s", n, got)
645+ }
646+ if strings.Contains(got, ">backoff and") {
647+ t.Error("the original casing was rewritten")
648+ }
649+ }
650+
651+ // The mockup puts the runner on the muted line and the result under it, failures loudest.
652+ func TestThreadShowsTheRunThatBuiltTheProposal(t *testing.T) {
653+ page := repoPage{
654+ chrome: newChrome("barerepo · thread 47", "s"),
655+ Owner: "john", Name: "johnbot",
656+ Tabs: repoTabs("john", "johnbot", "threads", "master", 1),
657+ }
658+ render := func(rows []commentRow) string {
659+ var out strings.Builder
660+ err := pages["thread"].ExecuteTemplate(&out, "layout", struct {
661+ repoPage
662+ N int
663+ Heading string
664+ Detail []bit
665+ NotesRef string
666+ Comments []commentRow
667+ Viewer string
668+ Error string
669+ }{page, 47, "panic when config file is empty", []bit{plain("opened by lisa")},
670+ "refs/notes/threads/47", rows, "", ""})
671+ if err != nil {
672+ t.Fatal(err)
673+ }
674+ return out.String()
675+ }
676+
677+ green := runRow{
678+ Href: "/john/johnbot/run/a3f9c2d", Result: "build ok · test ok",
679+ Took: "18s", Runner: "uproar.local", Ref: "refs/proposals/47",
680+ }
681+ got := render([]commentRow{{Author: "uproar.local", Ago: "40m", Run: &green}})
682+ for _, want := range []string{
683+ "uproar.local", "40m", "self-hosted runner",
684+ "build ok · test ok", "18s", "/john/johnbot/run/a3f9c2d",
685+ } {
686+ if !strings.Contains(got, want) {
687+ t.Errorf("the run row is missing %q", want)
688+ }
689+ }
690+ if strings.Contains(got, "danger") {
691+ t.Error("a green build is not news and must not be coloured as a failure")
692+ }
693+
694+ red := green
695+ red.Result, red.Failed = "build failed", true
696+ got = render([]commentRow{{Author: "uproar.local", Ago: "40m", Run: &red}})
697+ if !strings.Contains(got, `class="danger"`) {
698+ t.Error("a failed build has to be the thing the eye lands on")
699+ }
700+
701+ // A run row has no comment body, and the empty-state line is about comments, not runs.
702+ if strings.Contains(got, "no comments yet") {
703+ t.Error("a thread with a run is not an empty thread")
704+ }
705+ }
706+
707+ // Chapter 24's thread list names four filters, and a list of every thread ever is the reason.
708+ func TestThreadFilterKeepsTheRightStates(t *testing.T) {
709+ states := []thread.State{thread.Open, thread.Merged, thread.Closed, thread.Abandoned}
710+ want := map[string][]thread.State{
711+ "open": {thread.Open},
712+ "merged": {thread.Merged},
713+ // Abandoned is closed, because the filter row names four states and the model has five.
714+ "closed": {thread.Closed, thread.Abandoned},
715+ "all": states,
716+ }
717+ for filter, keep := range want {
718+ var got []thread.State
719+ for _, s := range states {
720+ if threadInState(s, filter) {
721+ got = append(got, s)
722+ }
723+ }
724+ if fmt.Sprint(got) != fmt.Sprint(keep) {
725+ t.Errorf("%s kept %v, want %v", filter, got, keep)
726+ }
727+ }
728+ // An unknown or absent state shows everything rather than nothing.
729+ for _, q := range []string{"", "banana", "OPEN"} {
730+ if threadFilter(q) != "all" {
731+ t.Errorf("state=%q became %q, and a bad filter must not empty the page", q, threadFilter(q))
732+ }
733+ }
734+ }
735+
736+ // The mockups put something different at the right of the tab row on every page.
737+ func TestTabRowEndsMatchTheMockups(t *testing.T) {
738+ render := func(ends []tab) string {
739+ var out strings.Builder
740+ err := pages["threads"].ExecuteTemplate(&out, "repotabs", repoPage{
741+ Owner: "john", Name: "johnbot",
742+ Tabs: repoTabs("john", "johnbot", "threads", "master", 3),
743+ Ends: ends,
744+ })
745+ if err != nil {
746+ t.Fatal(err)
747+ }
748+ return out.String()
749+ }
750+
751+ // threads.html: open · merged · closed · all, with the current one marked.
752+ got := render(threadFilterTabs("john", "johnbot", "open"))
753+ for _, want := range []string{
754+ `<span class="here">open</span>`,
755+ `href="/john/johnbot/threads?state=merged">merged`,
756+ `href="/john/johnbot/threads?state=closed">closed`,
757+ `href="/john/johnbot/threads">all`,
758+ } {
759+ if !strings.Contains(got, want) {
760+ t.Errorf("the threads filter row is missing %q\n%s", want, got)
761+ }
762+ }
763+ if strings.Contains(got, "jump to file") {
764+ t.Error("the threads page offered a file jump, which is a shortcut for the code pages")
765+ }
766+
767+ // runs.html: runners · add a runner. runners.html drops the link to the page you are on.
768+ if got := render(runnerEnds("john", "johnbot", "", true)); !strings.Contains(got, ">runners</a>") ||
769+ !strings.Contains(got, `href="/john/johnbot/runners/new">add a runner`) {
770+ t.Errorf("the runs page is missing the runner links\n%s", got)
771+ }
772+ if got := render(runnerEnds("john", "johnbot", "runners", true)); strings.Contains(got, ">runners</a>") {
773+ t.Error("the runners page linked to itself")
774+ }
775+
776+ // releases.html: a statement, with nothing to click.
777+ got = render([]tab{{Label: "newest first"}})
778+ if !strings.Contains(got, "newest first") || strings.Contains(got, "newest first</a>") {
779+ t.Errorf("newest first is a fact and not a control\n%s", got)
780+ }
781+
782+ // The code pages keep the file jump, which is the only page the t shortcut works on.
783+ if got := render(nil); !strings.Contains(got, `jump to file <span class="tag">t</span>`) {
784+ t.Errorf("the code pages lost the file jump\n%s", got)
785+ }
786+ }
787+
788+ // Go's reference month is Jan, so a lowercase one in a layout is a literal and every date reads "jan".
789+ func TestMonthFormatsTheMonthAndNotTheWordJan(t *testing.T) {
790+ for _, c := range []struct {
791+ when time.Time
792+ want string
793+ }{
794+ {time.Date(2026, 8, 18, 0, 0, 0, 0, time.UTC), "aug 2026"},
795+ {time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC), "jan 2026"},
796+ {time.Date(2025, 12, 31, 0, 0, 0, 0, time.UTC), "dec 2025"},
797+ } {
798+ if got := month(c.when); got != c.want {
799+ t.Errorf("month(%s) = %q, want %q", c.when.Format(time.RFC3339), got, c.want)
800+ }
801+ }
802+ if got := month(time.Time{}); got != "" {
803+ t.Errorf("a zero time became %q, and a key with no date must show nothing", got)
804+ }
805+ }
806+
807+ // A name in a git commit or a pushed note is whatever the writer set locally, so it is not an account.
808+ func TestOnlyRealAccountsBecomeLinks(t *testing.T) {
809+ accounts := map[string]bool{"lisa": true}
810+ if got := accountHref("lisa", accounts); got != "/lisa" {
811+ t.Errorf("an account did not link: %q", got)
812+ }
813+ for _, who := range []string{"donuts-are-good", "", "Lisa"} {
814+ if got := accountHref(who, accounts); got != "" {
815+ t.Errorf("%q linked to %q, and a name that is not an account must stay plain", who, got)
816+ }
817+ }
818+ }
819+
820+ // Chapter 12: the commit that closed a proposal is a fact the reader will want to open.
821+ func TestThreadHeadlineLinksTheMergedCommit(t *testing.T) {
822+ m := thread.Meta{
823+ Title: "t", State: thread.Merged, Author: "lisa",
824+ Ref: "refs/proposals/47", Merged: "a3f9c2db1e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b",
825+ }
826+ got := threadHeadline("john", "johnbot", m, 81, 12, map[string]bool{"lisa": true})
827+ want := map[string]string{
828+ "opened by lisa": "/lisa",
829+ "refs/proposals/47": "/john/johnbot/compare?a=HEAD&b=refs%2Fproposals%2F47",
830+ "merged at a3f9c2d": "/john/johnbot/commit/a3f9c2db1e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b",
831+ }
832+ seen := map[string]string{}
833+ for _, b := range got {
834+ seen[b.Text] = b.Href
835+ }
836+ for text, href := range want {
837+ if seen[text] != href {
838+ t.Errorf("%q linked to %q, want %q", text, seen[text], href)
839+ }
840+ }
841+ // The size is two numbers and not a destination.
842+ if seen["+81 -12"] != "" {
843+ t.Errorf("the diff size became a link to %q", seen["+81 -12"])
844+ }
845+ }
846+
847+ // A hash with nowhere to go is worse than plain text, so an unmerged thread offers no commit link.
848+ func TestThreadHeadlineOffersNoMergedLinkWhenNothingMerged(t *testing.T) {
849+ got := threadHeadline("john", "johnbot",
850+ thread.Meta{Title: "t", State: thread.Open}, 0, 0, nil)
851+ for _, b := range got {
852+ if strings.Contains(b.Text, "merged") {
853+ t.Errorf("an open thread claimed %q", b.Text)
854+ }
855+ }
856+ }
857+
858+ // The breadcrumb names the account and the repository on nearly every page, and both are places.
859+ func TestBreadcrumbsLinkTheOwnerAndTheRepository(t *testing.T) {
860+ page := repoPage{
861+ chrome: newChrome("barerepo · john/johnbot", "s"),
862+ Owner: "john", Name: "johnbot", Branch: "master",
863+ Tabs: repoTabs("john", "johnbot", "log", "master", 3),
864+ }
865+ var out strings.Builder
866+ if err := pages["repo-commit"].ExecuteTemplate(&out, "crumbs", page); err != nil {
867+ t.Fatal(err)
868+ }
869+ for _, want := range []string{`<a href="/john">john</a>`, `<a href="/john/johnbot">johnbot</a>`} {
870+ if !strings.Contains(out.String(), want) {
871+ t.Errorf("the breadcrumb is missing %q: %s", want, out.String())
872+ }
873+ }
874+
875+ // Nothing may go back to naming the owner and repository without linking them.
876+ for _, path := range templateFiles(t) {
877+ body := string(mustRead(t, path))
878+ if strings.Contains(body, `class="secondary">{{.Owner}} / {{.Name}}`) {
879+ t.Errorf("%s writes the breadcrumb by hand instead of using the crumbs template", path)
880+ }
881+ }
882+ }
883+
884+ func templateFiles(t *testing.T) []string {
885+ t.Helper()
886+ entries, err := templateFS.ReadDir("templates")
887+ if err != nil {
888+ t.Fatal(err)
889+ }
890+ var out []string
891+ for _, e := range entries {
892+ out = append(out, "templates/"+e.Name())
893+ }
894+ return out
895+ }
896+
897+ func mustRead(t *testing.T, path string) []byte {
898+ t.Helper()
899+ body, err := templateFS.ReadFile(path)
900+ if err != nil {
901+ t.Fatal(err)
902+ }
903+ return body
904+ }
905+
906+ // A matrix builds a proposal several times, and one row cannot say ok while one of them failed.
907+ func TestBuildSummaryNeverHidesAFailure(t *testing.T) {
908+ ok := run.Record{Labels: []string{"build", "test"}, Exit: 0}
909+ bad := run.Record{Labels: []string{"build"}, Exit: 1}
910+
911+ cases := []struct {
912+ why string
913+ recs []run.Record
914+ text string
915+ failed bool
916+ }{
917+ // One run keeps the mockup's wording, which is what the build itself reported.
918+ {"one green", []run.Record{ok}, "build ok · test ok", false},
919+ {"one red", []run.Record{bad}, "build failed", true},
920+ {"all green", []run.Record{ok, ok, ok}, "3 builds ok", false},
921+ // The newest is green here, and the row must still be red.
922+ {"newest green, one red", []run.Record{ok, bad, ok}, "1 of 3 builds failed", true},
923+ {"all red", []run.Record{bad, bad}, "2 of 2 builds failed", true},
924+ }
925+ for _, c := range cases {
926+ text, failed := buildSummary(c.recs)
927+ if text != c.text || failed != c.failed {
928+ t.Errorf("%s: got (%q, %v), want (%q, %v)", c.why, text, failed, c.text, c.failed)
929+ }
930+ }
931+ }
932+
933+ // search.html gives the box to the matched source line, so a thread or a repository must not draw one.
934+ func TestOnlyACodeSearchResultDrawsABox(t *testing.T) {
935+ rows := []searchRow{
936+ {Kind: "code", Where: template.HTML("john / johnbot / config.go:43"),
937+ Href: "/john/johnbot/file/master/config.go", HasText: true,
938+ Text: template.HTML("func Default() Config {")},
939+ {Kind: "thread", Where: template.HTML("john / johnbot 44 · does this work behind a proxy?"),
940+ Href: "/john/johnbot/thread/44"},
941+ {Kind: "repo", Where: template.HTML("john / johnbot"), Href: "/john/johnbot",
942+ HasCtx: true, Context: template.HTML("irc bot that refuses to leave")},
943+ }
944+ var out strings.Builder
945+ err := pages["search"].ExecuteTemplate(&out, "layout", struct {
946+ chrome
947+ Account string
948+ Query string
949+ Count string
950+ Results []searchRow
951+ }{newChrome("barerepo · search", "s"), "john", "Default", "3 results", rows})
952+ if err != nil {
953+ t.Fatal(err)
954+ }
955+ if n := strings.Count(out.String(), "<pre"); n != 1 {
956+ t.Errorf("the page drew %d boxes for one code match, one thread and one repository", n)
957+ }
958+ if !strings.Contains(out.String(), "44 · does this work behind a proxy?") {
959+ t.Error("a thread result lost its title, which the mockup puts beside the number")
960+ }
961+ }
962+
963+ // Chapter 25 budgets a page 2kb of javascript, and rule 4 forbids a client framework. Both here.
964+ func TestThePageScriptFitsTheBudget(t *testing.T) {
965+ entries, err := templateFS.ReadDir("templates")
966+ if err != nil {
967+ t.Fatal(err)
968+ }
969+ wanted := map[string]bool{}
970+ for _, e := range entries {
971+ body, err := templateFS.ReadFile("templates/" + e.Name())
972+ if err != nil {
973+ t.Fatal(err)
974+ }
975+ for _, tag := range strings.Split(string(body), "<script")[1:] {
976+ head, _, _ := strings.Cut(tag, ">")
977+ src, ok := srcOf(head)
978+ if !ok {
979+ continue
980+ }
981+ wanted[src] = true
982+ }
983+ }
984+ if len(wanted) == 0 {
985+ t.Fatal("found no script at all, so this test is reading the wrong thing")
986+ }
987+
988+ total := 0
989+ for src := range wanted {
990+ // The url carries a version so a browser can keep the file, and the file name is the path.
991+ name, _, _ := strings.Cut(strings.TrimPrefix(src, "/static/"), "?")
992+ body, err := static.ReadFile("static/" + name)
993+ if err != nil {
994+ t.Errorf("a page loads %s and it is not shipped: %v", src, err)
995+ continue
996+ }
997+ total += len(body)
998+ }
999+ if total > 2<<10 {
1000+ t.Errorf("a page pulls %d bytes of javascript, over chapter 25's 2kb", total)
1001+ }
1002+ }
1003+
1004+ // srcOf reads the src out of a script tag, since that is the only kind the policy allows.
1005+ func srcOf(tag string) (string, bool) {
1006+ _, rest, ok := strings.Cut(tag, `src="`)
1007+ if !ok {
1008+ return "", false
1009+ }
1010+ src, _, ok := strings.Cut(rest, `"`)
1011+ return src, ok
1012+ }
@@ -0,0 +1,2979 @@
1+ package httpd
2+
3+ import (
4+ "context"
5+ "errors"
6+ "html/template"
7+ "net/http"
8+ "net/url"
9+ "os"
10+ "sort"
11+ "strconv"
12+ "strings"
13+ "time"
14+
15+ "github.com/barerepo/server/internal/artifact"
16+ "github.com/barerepo/server/internal/gitread"
17+ "github.com/barerepo/server/internal/gitx"
18+ "github.com/barerepo/server/internal/markup"
19+ "github.com/barerepo/server/internal/proposal"
20+ "github.com/barerepo/server/internal/repo"
21+ "github.com/barerepo/server/internal/repocfg"
22+ "github.com/barerepo/server/internal/run"
23+ "github.com/barerepo/server/internal/search"
24+ "github.com/barerepo/server/internal/store"
25+ "github.com/barerepo/server/internal/thread"
26+ "github.com/barerepo/server/internal/token"
27+ "github.com/barerepo/server/internal/transport"
28+ "github.com/barerepo/server/internal/workflow"
29+ )
30+
31+ // serveWeb is the read-only web interface. Routes are appendix C.
32+ func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) {
33+ parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
34+ switch {
35+ case r.URL.Path == "/favicon.ico":
36+ // The design has no icon, and an empty answer beats a 404 in every console.
37+ w.WriteHeader(http.StatusNoContent)
38+ case r.URL.Path == "/":
39+ s.serveLanding(w, r)
40+ case r.URL.Path == "/runner.sh":
41+ s.serveRunnerScript(w, r)
42+ case r.URL.Path == "/runner.ps1":
43+ s.serveRunnerPS1(w, r)
44+ case r.URL.Path == "/runner/binary":
45+ s.serveRunnerBinary(w, r)
46+ case r.URL.Path == "/runner/attach" && r.Method == http.MethodPost:
47+ s.serveRunnerAttach(w, r)
48+ case r.URL.Path == "/runner/poll" && r.Method == http.MethodGet:
49+ s.serveRunnerPoll(w, r)
50+ case r.URL.Path == "/runner/log" && r.Method == http.MethodPost:
51+ s.serveRunnerLog(w, r)
52+ case r.URL.Path == "/runner/done" && r.Method == http.MethodPost:
53+ s.serveRunnerDone(w, r)
54+ case r.URL.Path == "/runner/artifact" && r.Method == http.MethodPost:
55+ s.serveRunnerArtifact(w, r)
56+ case r.URL.Path == "/keys" && isGet(r):
57+ s.serveKeys(w, r)
58+ case r.URL.Path == "/keys" && r.Method == http.MethodPost:
59+ s.serveAddKey(w, r)
60+ case r.URL.Path == "/gpgkeys" && r.Method == http.MethodPost:
61+ s.serveAddGPGKey(w, r)
62+ case r.URL.Path == "/gpgkeys/delete" && r.Method == http.MethodPost:
63+ s.serveDeleteGPGKey(w, r)
64+ case r.URL.Path == "/keys/delete" && r.Method == http.MethodPost:
65+ s.serveDeleteKey(w, r)
66+ case r.URL.Path == "/tokens" && r.Method == http.MethodPost:
67+ s.serveNewToken(w, r)
68+ case r.URL.Path == "/tokens/delete" && r.Method == http.MethodPost:
69+ s.serveDeleteToken(w, r)
70+ case r.URL.Path == "/inbox":
71+ s.serveInbox(w, r)
72+ case r.URL.Path == "/inbox.atom":
73+ s.serveInboxFeed(w, r)
74+ case r.URL.Path == "/search":
75+ s.serveSearch(w, r)
76+ case r.URL.Path == "/new":
77+ s.serveNewRepo(w, r)
78+ case r.URL.Path == "/signup":
79+ s.serveSignup(w, r)
80+ case r.URL.Path == "/signin" && isGet(r):
81+ s.serveSignin(w, r)
82+ case r.URL.Path == "/auth/challenge" && r.Method == http.MethodPost:
83+ s.serveChallenge(w, r)
84+ case r.URL.Path == "/auth/verify" && r.Method == http.MethodPost:
85+ s.serveVerify(w, r)
86+ case r.URL.Path == "/auth/claim" && isGet(r):
87+ s.serveClaim(w, r)
88+ case strings.HasPrefix(r.URL.Path, "/card/") && isGet(r):
89+ s.serveNamedCard(w, r, strings.Split(strings.TrimPrefix(r.URL.Path, "/card/"), "/"))
90+ case r.URL.Path == "/signout" && r.Method == http.MethodPost:
91+ s.serveSignout(w, r)
92+ case len(parts) == 1 && strings.HasSuffix(parts[0], ".keys"):
93+ s.serveUserSSHKeys(w, r, strings.TrimSuffix(parts[0], ".keys"))
94+ case len(parts) == 1 && strings.HasSuffix(parts[0], ".gpg"):
95+ s.serveUserKeys(w, r, strings.TrimSuffix(parts[0], ".gpg"))
96+ case len(parts) == 1 && strings.HasSuffix(parts[0], ".atom"):
97+ s.serveUserFeed(w, r, strings.TrimSuffix(parts[0], ".atom"))
98+ case len(parts) == 1:
99+ s.serveProfile(w, r, parts[0])
100+ case len(parts) == 2 && strings.HasSuffix(parts[1], ".atom"):
101+ s.serveRepoFeed(w, r, parts[0], strings.TrimSuffix(parts[1], ".atom"))
102+ case len(parts) == 2:
103+ s.serveRepoLog(w, r, parts[0], parts[1])
104+ case len(parts) == 3 && parts[2] == "config":
105+ s.serveRepoConfig(w, r, parts[0], parts[1], "")
106+ case len(parts) == 3 && (parts[2] == "rename" || parts[2] == "transfer") && r.Method == http.MethodPost:
107+ s.serveRepoMove(w, r, parts[0], parts[1], parts[2])
108+ case len(parts) == 3 && parts[2] == "copy" && r.Method == http.MethodPost:
109+ s.serveRepoCopy(w, r, parts[0], parts[1])
110+ case len(parts) == 3 && parts[2] == "delete" && r.Method == http.MethodPost:
111+ s.serveRepoDelete(w, r, parts[0], parts[1])
112+ case len(parts) == 3 && parts[2] == "rejected":
113+ s.serveRejected(w, r, parts[0], parts[1])
114+ case len(parts) == 3 && parts[2] == "releases":
115+ s.serveReleases(w, r, parts[0], parts[1])
116+ case len(parts) == 4 && parts[2] == "release":
117+ s.serveRelease(w, r, parts[0], parts[1], parts[3])
118+ case len(parts) == 5 && parts[2] == "release":
119+ s.serveReleaseFile(w, r, parts[0], parts[1], parts[3], parts[4])
120+ case len(parts) == 3 && parts[2] == "runs":
121+ s.serveRuns(w, r, parts[0], parts[1])
122+ case len(parts) == 4 && parts[2] == "run":
123+ s.serveRun(w, r, parts[0], parts[1], parts[3])
124+ case len(parts) == 5 && parts[2] == "run" && parts[4] == "log":
125+ s.serveRunLog(w, r, parts[0], parts[1], parts[3])
126+ case len(parts) == 5 && parts[2] == "run" && parts[4] == "rerun" && r.Method == http.MethodPost:
127+ s.serveRerun(w, r, parts[0], parts[1], parts[3])
128+ case len(parts) == 3 && parts[2] == "runners":
129+ s.serveRunners(w, r, parts[0], parts[1])
130+ case len(parts) == 4 && parts[2] == "runners" && (parts[3] == "new" || parts[3] == "token"):
131+ s.serveRunnerSetup(w, r, parts[0], parts[1])
132+ case len(parts) == 4 && parts[2] == "runners" && parts[3] == "forget" && r.Method == http.MethodPost:
133+ s.serveForgetRunner(w, r, parts[0], parts[1])
134+ case len(parts) == 3 && parts[2] == "threads.atom":
135+ s.serveThreadsFeed(w, r, parts[0], parts[1])
136+ case len(parts) == 3 && parts[2] == "threads" && r.Method == http.MethodPost:
137+ s.serveNewThreadPost(w, r, parts[0], parts[1])
138+ case len(parts) == 3 && parts[2] == "threads":
139+ s.serveThreads(w, r, parts[0], parts[1])
140+ case len(parts) == 4 && parts[2] == "threads" && parts[3] == "new":
141+ s.serveNewThreadForm(w, r, parts[0], parts[1], newThreadPage{})
142+ case len(parts) == 5 && parts[2] == "thread" && parts[4] == "comment":
143+ s.serveLineComment(w, r, parts[0], parts[1], parts[3])
144+ case len(parts) == 5 && parts[2] == "thread" && parts[4] == "close" && r.Method == http.MethodPost:
145+ s.serveThreadState(w, r, parts[0], parts[1], parts[3])
146+ case len(parts) == 5 && parts[2] == "thread" && parts[4] == "reply" && r.Method == http.MethodPost:
147+ s.serveReply(w, r, parts[0], parts[1], parts[3])
148+ case len(parts) == 4 && parts[2] == "thread":
149+ s.serveThread(w, r, parts[0], parts[1], parts[3])
150+ case len(parts) == 4 && parts[2] == "commit":
151+ s.serveCommit(w, r, parts[0], parts[1], parts[3])
152+ case len(parts) >= 3 && parts[2] == "compare":
153+ spec := ""
154+ if len(parts) > 3 {
155+ spec = strings.Join(parts[3:], "/")
156+ }
157+ s.serveCompare(w, r, parts[0], parts[1], spec)
158+ case len(parts) >= 5 && parts[2] == "raw":
159+ s.serveRaw(w, r, parts[0], parts[1], parts[3], strings.Join(parts[4:], "/"))
160+ case len(parts) == 3 && parts[2] == "readme":
161+ s.serveReadme(w, r, parts[0], parts[1])
162+ case len(parts) >= 5 && parts[2] == "file":
163+ s.serveFile(w, r, parts[0], parts[1], parts[3], strings.Join(parts[4:], "/"))
164+ case len(parts) >= 3 && parts[2] == "files":
165+ // /files, /files/<ref>, /files/<ref>/<path...>
166+ ref, path := "", ""
167+ if len(parts) > 3 {
168+ ref = parts[3]
169+ }
170+ if len(parts) > 4 {
171+ path = strings.Join(parts[4:], "/")
172+ }
173+ s.serveFiles(w, r, parts[0], parts[1], ref, path)
174+ default:
175+ s.notFound(w, r)
176+ }
177+ }
178+
179+ // visibilityWord is the value the pasted config carries, and only the exact word opens a repository.
180+ func visibilityWord(public bool) string {
181+ if public {
182+ return "public"
183+ }
184+ return "private"
185+ }
186+
187+ // oneLine keeps a pasted heredoc to one line of prose, because a newline in it would end the block.
188+ func oneLine(s string) string {
189+ s = strings.ReplaceAll(s, "\r", " ")
190+ s = strings.ReplaceAll(s, "\n", " ")
191+ return strings.TrimSpace(s)
192+ }
193+
194+ // serveLanding is the root: sign-in when signed out, per appendix C, and rule 7 lists nothing.
195+ func (s *Server) serveLanding(w http.ResponseWriter, r *http.Request) {
196+ if who := s.viewer(r); who != "" {
197+ http.Redirect(w, r, "/"+who, http.StatusFound)
198+ return
199+ }
200+ http.Redirect(w, r, "/signin", http.StatusFound)
201+ }
202+
203+ // notFound names what does exist near the path, per chapter 24, because the server knows.
204+ func (s *Server) notFound(w http.ResponseWriter, r *http.Request) {
205+ w.WriteHeader(http.StatusNotFound)
206+ near := s.nearest(r)
207+ s.render(w, r, "404", struct {
208+ chrome
209+ Path string
210+ Near string
211+ Href string
212+ // Missing is the repository name when the account is real and the repository is not.
213+ Missing string
214+ }{
215+ chrome: newChrome("barerepo · not found", "Not found page."),
216+ Path: r.URL.Path,
217+ Near: near,
218+ Href: s.nearestHref(r),
219+ Missing: missingRepo(r.URL.Path, near),
220+ })
221+ }
222+
223+ // missingRepo names what was asked for under an account that exists, or nothing when it is a file.
224+ func missingRepo(path, near string) string {
225+ if near == "" || strings.Contains(near, "/") {
226+ return ""
227+ }
228+ parts := strings.Split(strings.Trim(path, "/"), "/")
229+ if len(parts) < 2 || parts[1] == "" {
230+ return ""
231+ }
232+ return parts[1]
233+ }
234+
235+ // nearest walks back up the path until it finds something real.
236+ func (s *Server) nearest(r *http.Request) string {
237+ parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
238+ if len(parts) >= 2 && repo.Exists(s.Cfg.Paths.Repos, parts[0], parts[1]) {
239+ return parts[0] + "/" + parts[1]
240+ }
241+ if len(parts) >= 1 && parts[0] != "" {
242+ if _, err := s.DB.Account(r.Context(), parts[0]); err == nil {
243+ return parts[0]
244+ }
245+ }
246+ return ""
247+ }
248+
249+ func (s *Server) nearestHref(r *http.Request) string {
250+ if n := s.nearest(r); n != "" {
251+ return "/" + n
252+ }
253+ return "/"
254+ }
255+
256+ // listPage is how many rows a list page draws before it offers an older link. Chapter 25's payload.
257+ const listPage = 20
258+
259+ // repoPage is what every repository view needs before it can draw anything.
260+ type repoPage struct {
261+ chrome
262+ Owner string
263+ Name string
264+ Branch string
265+ CloneURL string
266+ Tabs []tab
267+ // Ends is the right of the tab row, which each mockup fills differently. Empty means the file jump.
268+ Ends []tab
269+ Older string
270+ // Threads is the open count, which the tab strip and the thread page footer both show.
271+ Threads int
272+ }
273+
274+ // openRepo resolves a repository and fills the chrome, and returns false once it has answered.
275+ func (s *Server) openRepo(w http.ResponseWriter, r *http.Request, owner, name, active string) (*transport.Result, repoPage, bool) {
276+ return s.openRepoFor(w, r, owner, name, active)
277+ }
278+
279+ func (s *Server) openRepoFor(w http.ResponseWriter, r *http.Request, owner, name, active string) (*transport.Result, repoPage, bool) {
280+ ctx := r.Context()
281+ res, err := s.Transport.Open(ctx, owner, name, s.viewer(r), transport.Read)
282+ var moved transport.Redirect
283+ switch {
284+ case errors.As(err, &moved):
285+ http.Redirect(w, r, s.movedPath(r, moved), http.StatusMovedPermanently)
286+ return nil, repoPage{}, false
287+ case err != nil:
288+ s.notFound(w, r)
289+ return nil, repoPage{}, false
290+ }
291+
292+ branch, err := repo.HeadBranch(ctx, res.Dir)
293+ if err != nil {
294+ branch = ""
295+ }
296+ page := repoPage{
297+ chrome: newCardChrome("barerepo · "+owner+"/"+name, "", "/card/"+owner+"/"+name),
298+ Owner: owner,
299+ Name: name,
300+ Branch: branch,
301+ CloneURL: s.cloneURL(owner, name),
302+ }
303+ // A shared link says whose repository it is, and its own description when it has written one.
304+ page.Share = owner + "/" + name
305+ if cfg, err := repocfg.Load(ctx, res.Dir); err == nil && strings.TrimSpace(cfg.Repo.Description) != "" {
306+ page.Share += ". " + strings.TrimSpace(cfg.Repo.Description)
307+ }
308+
309+ // Every mockup carries the open count in the tab strip, so it is read here and not per page.
310+ page.Threads = thread.OpenCount(ctx, res.Dir)
311+ page.Tabs = repoTabs(owner, name, active, branch, page.Threads)
312+ return res, page, true
313+ }
314+
315+ // profileChrome names the account in the link preview, because a shared profile is somebody's and not a page type.
316+ func profileChrome(name string, visible int) chrome {
317+ c := newCardChrome("barerepo · "+name, "User profile listing repositories sorted by last push.", "/card/"+name)
318+ c.Share = name + " on barerepo. " + plural(visible, "repository", "repositories") + "."
319+ return c
320+ }
321+
322+ func (s *Server) cloneURL(owner, name string) string {
323+ host := s.Cfg.Server.SSHHost
324+ if host == "" {
325+ return s.Cfg.Server.ExternalURL + "/" + owner + "/" + name
326+ }
327+ user := s.Cfg.Server.SSHUser
328+ if user == "" {
329+ user = "git"
330+ }
331+ if s.Cfg.Server.SSHPort != 0 && s.Cfg.Server.SSHPort != 22 {
332+ // scp-style syntax cannot carry a port, so say the whole url.
333+ return "ssh://" + user + "@" + host + ":" + itoa(s.Cfg.Server.SSHPort) + "/" + owner + "/" + name
334+ }
335+ return user + "@" + host + ":" + owner + "/" + name
336+ }
337+
338+ // serveRepoLog is every repository's landing page: the log with diffs open, not the tree.
339+ func (s *Server) serveRepoLog(w http.ResponseWriter, r *http.Request, owner, name string) {
340+ res, page, ok := s.openRepo(w, r, owner, name, "log")
341+ if !ok {
342+ return
343+ }
344+ if repo.IsEmpty(r.Context(), res.Dir) {
345+ page.Summary = "Empty repository page showing a single block of shell commands to paste."
346+ page.Title = "barerepo · " + owner + "/" + name
347+ // No commits means no .barerepo/config, so it stays private until the paste block says otherwise.
348+ wantsPublic := r.URL.Query().Get("visibility") == "public"
349+ s.render(w, r, "repo-empty", struct {
350+ repoPage
351+ Public bool
352+ // Description belongs in the paste block, because the server commits nothing. Chapter 11.
353+ Description string
354+ // Visibility is what the form was told, since the block writes the file and not a hint.
355+ Visibility string
356+ }{page, res.Config.Public() && !wantsPublic,
357+ oneLine(r.URL.Query().Get("description")), visibilityWord(wantsPublic)})
358+ return
359+ }
360+
361+ // path restricts the log to one file, which is the history link chapter 24 asks the file view for.
362+ only := strings.TrimPrefix(strings.TrimSpace(r.URL.Query().Get("path")), "/")
363+
364+ // from names where this page starts, so older history is reachable without an offset to keep.
365+ from := r.URL.Query().Get("from")
366+ start := "HEAD"
367+ if from != "" {
368+ if !gitx.ValidRev(from) {
369+ s.notFound(w, r)
370+ return
371+ }
372+ start = from
373+ }
374+ commits, err := gitread.Log(r.Context(), res.Dir, start, only, listPage+1)
375+ if err != nil {
376+ if from != "" {
377+ s.notFound(w, r)
378+ return
379+ }
380+ s.oops(w, r, err)
381+ return
382+ }
383+ if len(commits) > listPage {
384+ page.Older = "/" + owner + "/" + name + "?from=" + commits[listPage].SHA + pathQuery(only)
385+ commits = commits[:listPage]
386+ }
387+ page.Summary = "Repository log listing every commit, newest first. This is the landing page."
388+ if only != "" {
389+ page.Summary = "Repository log listing every commit that touched one file, newest first."
390+ page.Title += " " + only
391+ }
392+ views := viewLog(owner, name, commits)
393+ s.linkAuthors(r, views)
394+ // A readme is not the landing page, per chapter 24, but a repository that has one should say so.
395+ readme := ""
396+ if len(commits) > 0 {
397+ if gitread.Readme(r.Context(), res.Dir, commits[0].SHA) != "" {
398+ readme = "/" + owner + "/" + name + "/readme"
399+ }
400+ }
401+ s.render(w, r, "repo-log", struct {
402+ repoPage
403+ Commits []commitView
404+ ReadmeHref string
405+ // OnlyPath names the file the log is restricted to, and empty is the whole repository.
406+ OnlyPath string
407+ FileHref string
408+ }{page, views, readme, only, s.fileHrefIfThere(r, res.Dir, owner, name, page.Branch, only)})
409+ }
410+
411+ func itoa(n int) string {
412+ if n == 0 {
413+ return "0"
414+ }
415+ var b [20]byte
416+ i := len(b)
417+ for n > 0 {
418+ i--
419+ b[i] = byte('0' + n%10)
420+ n /= 10
421+ }
422+ return string(b[i:])
423+ }
424+
425+ // serveCommit is one commit, whole, because a person who opened a commit came to read it.
426+ func (s *Server) serveCommit(w http.ResponseWriter, r *http.Request, owner, name, rev string) {
427+ res, page, ok := s.openRepo(w, r, owner, name, "log")
428+ if !ok {
429+ return
430+ }
431+ c, err := gitread.Show(r.Context(), res.Dir, rev)
432+ if err != nil || c == nil {
433+ s.notFound(w, r)
434+ return
435+ }
436+ views := viewCommits(owner, name, []gitread.Commit{*c})
437+ s.linkAuthors(r, views)
438+ page.Title += " " + c.Short
439+ page.Summary = "Single commit page showing message, metadata and full diff."
440+
441+ parent := gitread.ParentShort(r.Context(), res.Dir, c.SHA)
442+ parentHref := ""
443+ // The key's own name is a claim by whoever made it, so barerepo prefers the account that published it.
444+ signer := ""
445+ if c.SigKey != "" {
446+ signer, _ = s.DB.AccountForGPGKey(r.Context(), c.SigKey)
447+ }
448+ note, bad := SignatureNote(*c, signer)
449+ // The commands that check it without barerepo, the way the thread page prints the ones that read it.
450+ verify := ""
451+ if c.Signed && signer != "" {
452+ verify = "curl " + s.Cfg.Server.ExternalURL + "/" + signer + ".gpg | gpg --import\n" +
453+ "git verify-commit " + c.SHA
454+ }
455+
456+ // The first commit has no parent, and a hash that goes nowhere is worse than plain text.
457+ if parent != "" {
458+ parentHref = "/" + owner + "/" + name + "/commit/" + parent
459+ }
460+ s.render(w, r, "repo-commit", struct {
461+ repoPage
462+ Commit commitView
463+ FileCount string
464+ Reachable bool
465+ Parent string
466+ ParentHref string
467+ FilesHref string
468+ // Signature is what this server can say about the signature, and empty means unsigned.
469+ Signature string
470+ SigBad bool
471+ // SignerHref is the account page, when the key that signed this is published by one.
472+ SignerHref string
473+ Signer string
474+ Verify string
475+ }{
476+ repoPage: page,
477+ Commit: views[0],
478+ FileCount: plural(len(c.Files), "file", "files"),
479+ Reachable: gitread.Reachable(r.Context(), res.Dir, c.SHA, "HEAD"),
480+ Parent: parent,
481+ ParentHref: parentHref,
482+ FilesHref: "/" + owner + "/" + name + "/file/" + c.SHA + "/",
483+ Signature: note,
484+ SigBad: bad,
485+ SignerHref: hrefFor(signer),
486+ Signer: signer,
487+ Verify: verify,
488+ })
489+ }
490+
491+ // markdownCap is chapter 42.4's cap again, because rendering does not make a huge file safe to send.
492+ const markdownCap = 1 << 20
493+
494+ // renderMarkdown draws a .md file the way the readme is drawn, and reports whether it drew it.
495+ func (s *Server) renderMarkdown(w http.ResponseWriter, r *http.Request, page repoPage,
496+ dir, owner, name, ref, path string) bool {
497+ if !gitx.ValidRev(ref) || !gitx.ValidPath(path) {
498+ return false
499+ }
500+ obj, err := gitx.CatFile(r.Context(), dir, ref+":"+path)
501+ if err != nil || obj == nil || obj.Type != "blob" {
502+ return false
503+ }
504+ if obj.Size > markdownCap || strings.IndexByte(obj.Body, 0) >= 0 {
505+ return false
506+ }
507+ page.Title += " " + path
508+ page.Summary = "A markdown file in the repository, rendered."
509+
510+ base := "/" + owner + "/" + name
511+ s.render(w, r, "readme", struct {
512+ repoPage
513+ Path string
514+ HTML template.HTML
515+ SourceHref string
516+ RawHref string
517+ }{
518+ repoPage: page,
519+ Path: path,
520+ HTML: template.HTML(markup.Render(obj.Body)),
521+ SourceHref: base + "/file/" + ref + "/" + path + "?source",
522+ RawHref: base + "/raw/" + ref + "/" + path,
523+ })
524+ return true
525+ }
526+
527+ // serveReadme renders the one file whose whole purpose is to be read as prose. Chapter 42.1 sanitises it.
528+ func (s *Server) serveReadme(w http.ResponseWriter, r *http.Request, owner, name string) {
529+ res, page, ok := s.openRepo(w, r, owner, name, "log")
530+ if !ok {
531+ return
532+ }
533+ head, err := gitx.ResolveRefOrAsk(r.Context(), res.Dir, "HEAD")
534+ if err != nil {
535+ s.notFound(w, r)
536+ return
537+ }
538+ path := gitread.Readme(r.Context(), res.Dir, head)
539+ if path == "" {
540+ s.notFound(w, r)
541+ return
542+ }
543+ obj, err := gitx.CatFile(r.Context(), res.Dir, head+":"+path)
544+ if err != nil || obj == nil {
545+ s.notFound(w, r)
546+ return
547+ }
548+ page.Title = "barerepo · " + owner + "/" + name + " " + path
549+ page.Summary = "The repository's readme, rendered."
550+
551+ s.render(w, r, "readme", struct {
552+ repoPage
553+ Path string
554+ // Rule 5 does not reach here, but chapter 42.1's allowlist is what makes any markdown safe.
555+ HTML template.HTML
556+ SourceHref string
557+ RawHref string
558+ }{
559+ repoPage: page,
560+ Path: path,
561+ HTML: template.HTML(markup.Render(obj.Body)),
562+ SourceHref: "/" + owner + "/" + name + "/file/" + head + "/" + path,
563+ RawHref: "/" + owner + "/" + name + "/raw/" + head + "/" + path,
564+ })
565+ }
566+
567+ // serveFiles is the file tree, not the landing page. Chapter 24 says why.
568+ func (s *Server) serveFiles(w http.ResponseWriter, r *http.Request, owner, name, ref, path string) {
569+ res, page, ok := s.openRepo(w, r, owner, name, "files")
570+ if !ok {
571+ return
572+ }
573+ if ref == "" {
574+ ref = page.Branch
575+ }
576+ if ref == "" {
577+ ref = "HEAD"
578+ }
579+ entries, err := gitread.Tree(r.Context(), res.Dir, ref, path)
580+ if err != nil {
581+ s.notFound(w, r)
582+ return
583+ }
584+ page.Title = "barerepo · " + owner + "/" + name + " files"
585+ page.Summary = "Repository file tree listing directories and files with last commit information."
586+
587+ base := "/" + owner + "/" + name + "/files/" + ref
588+ up := ""
589+ if path != "" {
590+ if i := strings.LastIndex(path, "/"); i >= 0 {
591+ up = base + "/" + path[:i]
592+ } else {
593+ up = base
594+ }
595+ }
596+ // Chapter 25 budgets any page at 15kb, and a directory of a thousand files is not that.
597+ views := viewEntries(owner, name, ref, entries)
598+ after := r.URL.Query().Get("after")
599+ if after != "" {
600+ for i, v := range views {
601+ if v.Name == after {
602+ views = views[i+1:]
603+ break
604+ }
605+ }
606+ }
607+ more := ""
608+ if len(views) > treePage {
609+ more = r.URL.Path + "?after=" + url.QueryEscape(views[treePage-1].Name)
610+ views = views[:treePage]
611+ }
612+ s.render(w, r, "repo-files", struct {
613+ repoPage
614+ Path string
615+ Up string
616+ Entries []entryView
617+ // More carries on from the last name drawn, since a tree is in name order and stays in it.
618+ More string
619+ }{page, path, up, views, more})
620+ }
621+
622+ func plural(n int, one, many string) string {
623+ if n == 1 {
624+ return "1 " + one
625+ }
626+ return itoa(n) + " " + many
627+ }
628+
629+ // renderedHref names the rendered form of a file, or nothing when the file has none.
630+ func renderedHref(owner, name, ref, path string) string {
631+ if !markdownPath(path) {
632+ return ""
633+ }
634+ return "/" + owner + "/" + name + "/file/" + ref + "/" + path
635+ }
636+
637+ // markdownPath reports a file the reader wants read rather than inspected.
638+ func markdownPath(path string) bool { return strings.HasSuffix(strings.ToLower(path), ".md") }
639+
640+ // serveFile is one file, with blame in the gutter on every line, unless it is prose.
641+ func (s *Server) serveFile(w http.ResponseWriter, r *http.Request, owner, name, ref, path string) {
642+ res, page, ok := s.openRepo(w, r, owner, name, "files")
643+ if !ok {
644+ return
645+ }
646+ // Before Open, because rendering wants the blob and blame is the expensive half of Open.
647+ if markdownPath(path) && !r.URL.Query().Has("source") {
648+ if s.renderMarkdown(w, r, page, res.Dir, owner, name, ref, path) {
649+ return
650+ }
651+ }
652+ f, err := gitread.Open(r.Context(), res.Dir, ref, path)
653+ if err != nil || f == nil {
654+ s.notFound(w, r)
655+ return
656+ }
657+ page.Title += " " + path
658+ page.Summary = "File view with blame information shown in the left gutter beside each line."
659+
660+ // Chapter 25 budgets this page at 40kb with blame on every line, which is what fits in it.
661+ lines := viewFileLines(f.Lines)
662+ shown, cut := 0, fitLines(lines)
663+ // One line can be the whole budget, so the count may be zero and the page still says why.
664+ truncated := cut < len(lines)
665+ if truncated {
666+ lines = lines[:cut]
667+ shown = cut
668+ }
669+
670+ meta := plural(len(f.Lines), "line", "lines") + " · " + size(f.Size)
671+ if ref != "" {
672+ meta += " · " + ref
673+ }
674+ s.render(w, r, "repo-file", struct {
675+ repoPage
676+ Path string
677+ File *gitread.File
678+ Lines []fileLineView
679+ Meta string
680+ SizeText string
681+ RawHref string
682+ // HistoryHref is the log restricted to this file, which is chapter 24's history link.
683+ HistoryHref string
684+ // Shown is how many lines the page drew when it could not draw them all.
685+ Shown int
686+ // Truncated says lines were cut, which Shown cannot, because cutting all of them says zero.
687+ Truncated bool
688+ // RenderedHref is empty unless this file has a rendered form to go back to.
689+ RenderedHref string
690+ }{page, path, f, lines, meta, size(f.Size),
691+ "/" + owner + "/" + name + "/raw/" + ref + "/" + path,
692+ "/" + owner + "/" + name + "?path=" + url.QueryEscape(path), shown, truncated,
693+ renderedHref(owner, name, ref, path)})
694+ }
695+
696+ // serveCompare is any ref against any ref, with both sides in the path so it can be pasted.
697+ func (s *Server) serveCompare(w http.ResponseWriter, r *http.Request, owner, name, spec string) {
698+ res, page, ok := s.openRepo(w, r, owner, name, "log")
699+ if !ok {
700+ return
701+ }
702+ base := "/" + owner + "/" + name + "/compare"
703+
704+ if a, b := r.URL.Query().Get("a"), r.URL.Query().Get("b"); a != "" && b != "" {
705+ // url.URL leaves the slashes in a ref name alone, which PathEscape would not.
706+ to := &url.URL{Path: base + "/" + a + "..." + b}
707+ http.Redirect(w, r, to.String(), http.StatusFound)
708+ return
709+ }
710+
711+ a, b, found := strings.Cut(spec, "...")
712+ if !found {
713+ // No sides given, so offer the default branch against itself: empty, and a filled form.
714+ a, b = page.Branch, page.Branch
715+ }
716+
717+ c, err := gitread.Compare(r.Context(), res.Dir, a, b)
718+ if err != nil {
719+ s.oops(w, r, err)
720+ return
721+ }
722+
723+ // A line number links only where a thread can hold the comment, so only a proposal. Chapter 35.3.
724+ var commentOn func(string, int) string
725+ if n := proposal.Number(b); n > 0 && s.viewer(r) != "" {
726+ if _, exists, _ := thread.ReadMeta(r.Context(), res.Dir, n); exists {
727+ base := "/" + owner + "/" + name + "/thread/" + itoa(n) + "/comment"
728+ commentOn = func(path string, line int) string {
729+ q := url.Values{"path": {path}, "line": {itoa(line)}, "rev": {b}}
730+ return base + "?" + q.Encode()
731+ }
732+ }
733+ }
734+ page.Title += " compare"
735+ page.Summary = "Compare view between two arbitrary refs showing combined diff stats."
736+
737+ s.render(w, r, "repo-compare", struct {
738+ repoPage
739+ Comparison *gitread.Comparison
740+ Files []fileView
741+ Stats string
742+ Action string
743+ // FilesHref opens a changed file on the b side, which is the side being proposed.
744+ FilesHref string
745+ // Refs is what exists to type, because a free text field with no visible options is unusable.
746+ Refs []refLink
747+ }{page, c, viewFiles(c.Files, commentOn), compareStats(c), base,
748+ "/" + owner + "/" + name + "/file/" + fileRef(r.Context(), res.Dir, b) + "/",
749+ refLinks(r.Context(), res.Dir, base, a)})
750+ }
751+
752+ // fileHrefIfThere links a path only where the file is still there, since a deleted one has no page.
753+ func (s *Server) fileHrefIfThere(r *http.Request, dir, owner, name, branch, only string) string {
754+ if only == "" {
755+ return ""
756+ }
757+ obj, err := gitx.CatFile(r.Context(), dir, branch+":"+only)
758+ if err != nil || obj.Type != "blob" {
759+ return ""
760+ }
761+ return "/" + owner + "/" + name + "/file/" + fileRef(r.Context(), dir, branch) + "/" + only
762+ }
763+
764+ // fileBudget is what the lines on the file view may weigh, under chapter 25's 40kb for the page.
765+ const fileBudget = 34 << 10
766+
767+ // fitLines counts the lines that fit, because a long line costs the page more than a short one.
768+ func fitLines(lines []fileLineView) int {
769+ spent := 0
770+ for i, line := range lines {
771+ // The markup around one line is a hundred bytes of it, whatever the line holds.
772+ spent += len(line.Text) + len(line.Blame) + 110
773+ if spent > fileBudget {
774+ return i
775+ }
776+ }
777+ return len(lines)
778+ }
779+
780+ // treePage is how many entries one directory draws, because chapter 25 budgets the page and not the tree.
781+ const treePage = 50
782+
783+ // pathQuery carries the file restriction onto the next link, or an older page drops back to the whole log.
784+ func pathQuery(only string) string {
785+ if only == "" {
786+ return ""
787+ }
788+ return "&path=" + url.QueryEscape(only)
789+ }
790+
791+ // isGet is true for a HEAD too, because HEAD is a GET that stops at the headers.
792+ func isGet(r *http.Request) bool {
793+ return r.Method == http.MethodGet || r.Method == http.MethodHead
794+ }
795+
796+ // fileRef resolves a ref for a file url, because a name holding a slash cannot be one path element.
797+ func fileRef(ctx context.Context, dir, ref string) string {
798+ if !strings.Contains(ref, "/") {
799+ return ref
800+ }
801+ // A short name like release/1.0 is not a ref path, so git is asked when the files cannot say.
802+ if sha, err := gitx.ResolveRefOrAsk(ctx, dir, ref); err == nil {
803+ return sha
804+ }
805+ return ref
806+ }
807+
808+ // refLink is one ref offered on the compare page, as a link that fills the b side and not a dropdown.
809+ type refLink struct {
810+ Name string
811+ Kind string
812+ Href string
813+ On bool
814+ }
815+
816+ // refLinks lists what a person could type, keeping the a side and swapping the b side. Chapter 24.
817+ func refLinks(ctx context.Context, dir, base, a string) []refLink {
818+ refs := gitread.Refs(ctx, dir)
819+ out := make([]refLink, 0, len(refs))
820+ for _, ref := range refs {
821+ out = append(out, refLink{
822+ Name: ref.Name,
823+ Kind: ref.Kind,
824+ Href: (&url.URL{Path: base + "/" + a + "..." + ref.Name}).String(),
825+ On: ref.Name == a,
826+ })
827+ }
828+ return out
829+ }
830+
831+ // compareStats is the one line under the two fields.
832+ func compareStats(c *gitread.Comparison) string {
833+ if c.Missing != "" {
834+ return "no ref named " + c.Missing
835+ }
836+ if c.A == c.B {
837+ return "the same ref on both sides"
838+ }
839+ conflict := "no conflicts"
840+ if c.Conflict {
841+ conflict = "conflicts"
842+ }
843+ return plural(c.Commits, "commit", "commits") + " · " +
844+ plural(len(c.Files), "file", "files") + " · " +
845+ "+" + itoa(c.Add) + " -" + itoa(c.Del) + " · " + conflict
846+ }
847+
848+ // serveRaw sends file bytes and nothing else: always a download, never a cookie. Chapter 42.3.
849+ func (s *Server) serveRaw(w http.ResponseWriter, r *http.Request, owner, name, ref, path string) {
850+ // A separate raw host is the real defence; the headers below are the second one.
851+ if s.Cfg.Server.RawURL != "" && !s.onRawHost(r) {
852+ to := &url.URL{Path: r.URL.Path}
853+ http.Redirect(w, r, strings.TrimRight(s.Cfg.Server.RawURL, "/")+to.String(), http.StatusFound)
854+ return
855+ }
856+
857+ // This handler reads no credential, so it answers for public repositories only.
858+ res, err := s.Transport.Open(r.Context(), owner, name, "", transport.Read)
859+ if err != nil {
860+ http.NotFound(w, r)
861+ return
862+ }
863+ if !gitx.ValidRev(ref) || !gitx.ValidPath(path) {
864+ http.NotFound(w, r)
865+ return
866+ }
867+ // One read of the blob, because the bytes are the whole answer and blame is no part of it.
868+ obj, err := gitx.CatFile(r.Context(), res.Dir, ref+":"+path)
869+ if err != nil || obj.Type != "blob" {
870+ http.NotFound(w, r)
871+ return
872+ }
873+
874+ h := w.Header()
875+ h.Set("Content-Type", "text/plain; charset=utf-8")
876+ h.Set("Content-Disposition", "attachment")
877+ h.Set("X-Content-Type-Options", "nosniff")
878+ h.Set("Content-Security-Policy", "default-src 'none'; sandbox")
879+ w.Write([]byte(obj.Body))
880+ }
881+
882+ // onRawHost reports whether the request already arrived on the raw host, so the redirect ends.
883+ func (s *Server) onRawHost(r *http.Request) bool {
884+ u, err := url.Parse(s.Cfg.Server.RawURL)
885+ return err == nil && u.Host != "" && r.Host == u.Host
886+ }
887+
888+ // serveProfile is a user and their repositories, newest push first.
889+ func (s *Server) serveProfile(w http.ResponseWriter, r *http.Request, name string) {
890+ ctx := r.Context()
891+ account, err := s.DB.Account(ctx, name)
892+ if err != nil {
893+ s.notFound(w, r)
894+ return
895+ }
896+ viewer := s.viewer(r)
897+ keys, _ := s.DB.Keys(ctx, name)
898+ gpg, _ := s.DB.GPGKeys(ctx, name)
899+
900+ names, err := s.DB.ReposOf(ctx, name)
901+ if err != nil {
902+ s.oops(w, r, err)
903+ return
904+ }
905+ var repos []repoStatView
906+ for _, rn := range names {
907+ res, err := s.Transport.Open(ctx, name, rn, viewer, transport.Read)
908+ if err != nil {
909+ continue // private, and this reader may not see it
910+ }
911+ st := gitread.Stat(ctx, res.Dir)
912+ repos = append(repos, repoStatView{
913+ Name: rn,
914+ Description: res.Config.Repo.Description,
915+ Public: res.Config.Public(),
916+ Ago: ago(st.Pushed),
917+ Pushed: st.Pushed,
918+ Meta: repoMeta(st, thread.OpenProposals(ctx, res.Dir), res.Config.Repo.Archived),
919+ })
920+ }
921+ // The name settles a tie, because this page shows the first twenty and a reload must not shuffle which twenty those are.
922+ sort.Slice(repos, func(i, j int) bool {
923+ if repos[i].Pushed.Equal(repos[j].Pushed) {
924+ return repos[i].Name < repos[j].Name
925+ }
926+ return repos[i].Pushed.After(repos[j].Pushed)
927+ })
928+ // Counted after the loop that skipped what this reader may not see, so it counts only those.
929+ visible := len(repos)
930+ // The mockup draws a count and a way past it, because a busy account is a page nobody can hold.
931+ shown := ""
932+ if r.URL.Query().Get("all") == "" && visible > listPage {
933+ shown = "showing " + itoa(listPage) + " of " + itoa(visible)
934+ repos = repos[:listPage]
935+ }
936+
937+ s.render(w, r, "profile", struct {
938+ chrome
939+ // Who is whose profile this is, and Account is who is reading, which the top bar wants.
940+ Who string
941+ Account string
942+ Me bool
943+ Initials string
944+ Joined string
945+ KeyCount int
946+ SigningKeys int
947+ RepoCount string
948+ Repos []repoStatView
949+ // Shown is empty when the whole list is on the page, which is most accounts.
950+ Shown string
951+ AllHref string
952+ }{
953+ chrome: profileChrome(name, visible),
954+ Who: name,
955+ Account: viewer,
956+
957+ Me: viewer == name,
958+ Initials: initials(name),
959+ Joined: month(account.Created),
960+ KeyCount: len(keys),
961+ SigningKeys: len(gpg),
962+ RepoCount: plural(visible, "repo", "repos"),
963+ Repos: repos,
964+ Shown: shown,
965+ AllHref: "/" + name + "?all=1",
966+ })
967+ }
968+
969+ type repoStatView struct {
970+ Name string
971+ Description string
972+ Public bool
973+ Ago string
974+ Pushed time.Time
975+ Meta string
976+ }
977+
978+ // repoMeta is a repository row's third line: what it holds, how big, and its default branch.
979+ func repoMeta(st gitread.RepoStat, proposals int, archived bool) string {
980+ parts := []string{}
981+ if st.Language != "" {
982+ parts = append(parts, st.Language)
983+ }
984+ if st.Size > 0 {
985+ parts = append(parts, size(st.Size))
986+ }
987+ if st.Branch != "" {
988+ parts = append(parts, st.Branch)
989+ }
990+ // Chapter 24 puts the open proposal count here, and a repository with none says nothing.
991+ if proposals > 0 {
992+ parts = append(parts, plural(proposals, "proposal", "proposals"))
993+ }
994+ // An archived repository rejects every push, so a reader sees that before writing anything.
995+ if archived {
996+ parts = append(parts, "archived")
997+ }
998+ return strings.Join(parts, " · ")
999+ }
1000+
1001+ func initials(name string) string {
1002+ if name == "" {
1003+ return ""
1004+ }
1005+ if len(name) == 1 {
1006+ return name
1007+ }
1008+ return name[:2]
1009+ }
1010+
1011+ // serveNewRepo is the form, and the two commands that make it unnecessary. Chapter 24.
1012+ func (s *Server) serveNewRepo(w http.ResponseWriter, r *http.Request) {
1013+ who, ok := s.requireViewer(w, r)
1014+ if !ok {
1015+ return
1016+ }
1017+ page := struct {
1018+ chrome
1019+ Account string
1020+ Name string
1021+ Description string
1022+ Branch string
1023+ CloneBase string
1024+ Error string
1025+ }{
1026+ chrome: newChrome("barerepo · new repo",
1027+ "Form for creating a new repository with a choice of default branch name."),
1028+ Account: who,
1029+ Branch: "master",
1030+ CloneBase: s.cloneBase(who),
1031+ }
1032+
1033+ if isGet(r) {
1034+ s.render(w, r, "new-repo", page)
1035+ return
1036+ }
1037+ page.Name = strings.TrimSpace(r.FormValue("name"))
1038+ page.Description = strings.TrimSpace(r.FormValue("description"))
1039+ if b := strings.TrimSpace(r.FormValue("default_branch")); b != "" {
1040+ page.Branch = b
1041+ }
1042+ public := r.FormValue("visibility") == "public"
1043+
1044+ if why := s.Transport.Claimed(r.Context(), who, page.Name); why != "" {
1045+ page.Error = why
1046+ w.WriteHeader(http.StatusBadRequest)
1047+ s.render(w, r, "new-repo", page)
1048+ return
1049+ }
1050+ dir, err := repo.Create(r.Context(), s.Cfg.Paths.Repos, who, page.Name, page.Branch, s.Transport.Bin)
1051+ if err != nil {
1052+ page.Error = err.Error()
1053+ w.WriteHeader(http.StatusBadRequest)
1054+ s.render(w, r, "new-repo", page)
1055+ return
1056+ }
1057+ if _, err := s.DB.ExecContext(r.Context(),
1058+ `INSERT INTO repos (owner, name, created_at) VALUES (?, ?, ?)`,
1059+ who, page.Name, time.Now().Unix()); err != nil {
1060+ os.RemoveAll(dir)
1061+ s.oops(w, r, err)
1062+ return
1063+ }
1064+
1065+ // No tree exists yet to hold either answer, so both are carried to the next page. Chapter 11.
1066+ q := url.Values{}
1067+ if public {
1068+ q.Set("visibility", "public")
1069+ }
1070+ if page.Description != "" {
1071+ q.Set("description", page.Description)
1072+ }
1073+ to := "/" + who + "/" + page.Name
1074+ if len(q) > 0 {
1075+ to += "?" + q.Encode()
1076+ }
1077+ http.Redirect(w, r, to, http.StatusFound)
1078+ }
1079+
1080+ // cloneBase is the prefix a clone url is built from, for the paste blocks.
1081+ func (s *Server) cloneBase(owner string) string {
1082+ if host := s.Cfg.Server.SSHHost; host != "" && s.Cfg.Server.SSHPort == 22 {
1083+ return "git@" + host + ":" + owner
1084+ }
1085+ return s.Cfg.Server.ExternalURL + "/" + owner
1086+ }
1087+
1088+ // serveThreads is one list: issues and proposals are one object, so no type filter. Chapter 13.
1089+ func (s *Server) serveThreads(w http.ResponseWriter, r *http.Request, owner, name string) {
1090+ res, page, ok := s.openRepo(w, r, owner, name, "threads")
1091+ if !ok {
1092+ return
1093+ }
1094+ list, err := thread.List(r.Context(), res.Dir)
1095+ if err != nil {
1096+ s.oops(w, r, err)
1097+ return
1098+ }
1099+ want := threadFilter(r.URL.Query().Get("state"))
1100+ open, closed := 0, 0
1101+ kept := make([]thread.Summary, 0, len(list))
1102+ for _, t := range list {
1103+ if t.Meta.State == thread.Open {
1104+ open++
1105+ } else {
1106+ closed++
1107+ }
1108+ // The counts are of the whole list, because a count that moves with the filter says nothing.
1109+ if threadInState(t.Meta.State, want) {
1110+ kept = append(kept, t)
1111+ }
1112+ }
1113+ // An older link from here, because this page drew every thread a repository had. Chapter 25.
1114+ if from := r.URL.Query().Get("from"); from != "" {
1115+ at := -1
1116+ for i := range kept {
1117+ if itoa(kept[i].N) == from {
1118+ at = i
1119+ break
1120+ }
1121+ }
1122+ if at < 0 {
1123+ s.notFound(w, r)
1124+ return
1125+ }
1126+ kept = kept[at:]
1127+ }
1128+ if len(kept) > listPage {
1129+ page.Older = "/" + owner + "/" + name + "/threads?from=" + itoa(kept[listPage].N) +
1130+ "&state=" + want
1131+ kept = kept[:listPage]
1132+ }
1133+
1134+ // Only the rows this page draws, since each one resolves a ref and measures a diff.
1135+ facts := s.threadRowFacts(r, res.Dir, owner, name, kept)
1136+ views := make([]threadRow, 0, len(kept))
1137+ for _, t := range kept {
1138+ views = append(views, threadRow{
1139+ N: t.N,
1140+ Title: titleOr(t.Meta.Title, t.N),
1141+ Author: t.Meta.Author,
1142+ Ago: ago(t.Updated),
1143+ Detail: threadDetail(owner, name, t, page.Branch, facts[t.N]),
1144+ Href: "/" + owner + "/" + name + "/thread/" + itoa(t.N),
1145+ Dim: t.Meta.State != thread.Open,
1146+ })
1147+ }
1148+ names := make([]string, 0, len(views))
1149+ for _, v := range views {
1150+ names = append(names, v.Author)
1151+ }
1152+ accounts := s.accountSet(r, names...)
1153+ for i := range views {
1154+ views[i].AuthorHref = accountHref(views[i].Author, accounts)
1155+ }
1156+
1157+ page.Title = "barerepo · " + owner + "/" + name + " threads"
1158+ page.Summary = "Thread list where issues and code proposals appear in one combined list."
1159+ page.Tabs = repoTabs(owner, name, "threads", page.Branch, open)
1160+ page.Ends = threadFilterTabs(owner, name, want)
1161+
1162+ s.render(w, r, "threads", struct {
1163+ repoPage
1164+ Threads []threadRow
1165+ Counts string
1166+ Viewer string
1167+ Empty string
1168+ }{page, views, itoa(open) + " open · " + itoa(closed) + " closed", s.viewer(r), threadEmpty(want)})
1169+ }
1170+
1171+ // threadFilter reads the state filter chapter 24's thread list names, defaulting to all.
1172+ func threadFilter(q string) string {
1173+ switch q {
1174+ case "open", "merged", "closed":
1175+ return q
1176+ default:
1177+ return "all"
1178+ }
1179+ }
1180+
1181+ // threadInState decides one row. Abandoned counts as closed, because the filter names four and not five.
1182+ func threadInState(state thread.State, want string) bool {
1183+ switch want {
1184+ case "open":
1185+ return state == thread.Open
1186+ case "merged":
1187+ return state == thread.Merged
1188+ case "closed":
1189+ return state == thread.Closed || state == thread.Abandoned
1190+ }
1191+ return true
1192+ }
1193+
1194+ // threadFilterTabs is the right of the tab row on this page, per the threads mockup.
1195+ func threadFilterTabs(owner, name, want string) []tab {
1196+ base := "/" + owner + "/" + name + "/threads"
1197+ out := make([]tab, 0, 4)
1198+ for _, state := range []string{"open", "merged", "closed", "all"} {
1199+ href := base + "?state=" + state
1200+ if state == "all" {
1201+ href = base
1202+ }
1203+ out = append(out, tab{Label: state, Href: href, On: state == want})
1204+ }
1205+ return out
1206+ }
1207+
1208+ // runnerEnds is the right of the tab row on the run pages, per the runs and runners mockups.
1209+ func runnerEnds(owner, name, active string, canAdd bool) []tab {
1210+ base := "/" + owner + "/" + name + "/runners"
1211+ out := []tab{}
1212+ // The runs page reaches the runner list; the runner list is already there and only offers the setup.
1213+ if active != "runners" {
1214+ out = append(out, tab{Label: "runners", Href: base})
1215+ }
1216+ // Attaching a machine needs push, so a reader without it is not offered a link that refuses.
1217+ if canAdd {
1218+ out = append(out, tab{Label: "add a runner", Href: base + "/new"})
1219+ }
1220+ return out
1221+ }
1222+
1223+ // threadEmpty says which list is empty, because "no threads yet" is wrong under a filter.
1224+ func threadEmpty(want string) string {
1225+ if want == "all" {
1226+ return "no threads yet."
1227+ }
1228+ return "no " + want + " threads."
1229+ }
1230+
1231+ type threadRow struct {
1232+ N int
1233+ Title string
1234+ Author string
1235+ AuthorHref string
1236+ Ago string
1237+ Detail []bit
1238+ Href string
1239+ Dim bool
1240+ }
1241+
1242+ // threadDetail is the line under a thread's title: what is attached, and what happened to it.
1243+ func threadDetail(owner, name string, t thread.Summary, branch string, f threadFacts) []bit {
1244+ base := "/" + owner + "/" + name
1245+ parts := []bit{}
1246+ switch t.Meta.State {
1247+ case thread.Merged:
1248+ // The words the push printed in the terminal, so the page never differs. Chapter 12.
1249+ parts = append(parts, plain("merged"))
1250+ if branch != "" {
1251+ parts = append(parts, bit{Text: "tip reachable from " + branch, Href: base})
1252+ }
1253+ case thread.Closed:
1254+ parts = append(parts, plain("closed"))
1255+ case thread.Abandoned:
1256+ parts = append(parts, plain("abandoned"))
1257+ default:
1258+ if t.Meta.Ref != "" {
1259+ parts = append(parts, plain("has proposal"))
1260+ } else {
1261+ parts = append(parts, plain("no proposal"))
1262+ }
1263+ }
1264+ // Chapter 24: the size, then the build, which is the order the mockup reads in.
1265+ if f.Add > 0 || f.Del > 0 {
1266+ parts = append(parts, bit{
1267+ Text: "+" + itoa(f.Add) + " -" + itoa(f.Del),
1268+ Href: base + "/compare?a=HEAD&b=" + url.QueryEscape(t.Meta.Ref),
1269+ })
1270+ }
1271+ if f.Build != "" {
1272+ parts = append(parts, bit{Text: f.Build, Href: f.BuildHref, Danger: f.Failed})
1273+ }
1274+ if t.Meta.Merged != "" && len(t.Meta.Merged) >= 7 {
1275+ parts = append(parts, bit{Text: "at " + t.Meta.Merged[:7], Href: base + "/commit/" + t.Meta.Merged})
1276+ }
1277+ // A count of nothing is not news, and the mockup leaves it off.
1278+ if t.Replies > 0 {
1279+ parts = append(parts, plain(plural(t.Replies, "reply", "replies")))
1280+ }
1281+ return parts
1282+ }
1283+
1284+ // buildSummary reduces a matrix of runs to one status, where any failure is the status. Chapter 15A.
1285+ func buildSummary(recs []run.Record) (string, bool) {
1286+ failed := 0
1287+ for _, rec := range recs {
1288+ if rec.Failed() {
1289+ failed++
1290+ }
1291+ }
1292+ // One run keeps the mockup's wording, which is the labels the build actually reported.
1293+ if len(recs) == 1 {
1294+ return runResult(recs[0]), recs[0].Failed()
1295+ }
1296+ // A green row hiding one red build is the failure chapter 19.1 is written against.
1297+ if failed > 0 {
1298+ return itoa(failed) + " of " + plural(len(recs), "build", "builds") + " failed", true
1299+ }
1300+ return plural(len(recs), "build", "builds") + " ok", false
1301+ }
1302+
1303+ // threadFacts is what a row needs from outside the note: the proposal's size and its last build.
1304+ type threadFacts struct {
1305+ Add int
1306+ Del int
1307+ Build string
1308+ BuildHref string
1309+ Failed bool
1310+ }
1311+
1312+ // threadRowFacts reads the size and build of every proposal on the page without a process per row.
1313+ func (s *Server) threadRowFacts(r *http.Request, dir, owner, name string, list []thread.Summary) map[int]threadFacts {
1314+ out := map[int]threadFacts{}
1315+ head, err := gitx.ResolveRefOrAsk(r.Context(), dir, "HEAD")
1316+ if err != nil {
1317+ return out
1318+ }
1319+ // Two processes for every run in the repository, rather than one for each row's commit.
1320+ runs, err := run.Recent(r.Context(), dir, 0)
1321+ if err != nil {
1322+ runs = nil
1323+ }
1324+ for _, t := range list {
1325+ if t.Meta.Ref == "" || !gitx.ValidRev(t.Meta.Ref) {
1326+ continue
1327+ }
1328+ tip, err := gitx.ResolveRefOrAsk(r.Context(), dir, t.Meta.Ref)
1329+ if err != nil || tip == "" {
1330+ continue
1331+ }
1332+ f := threadFacts{}
1333+ // Both ends are object hashes, so the answer is cached forever and costs nothing again.
1334+ f.Add, f.Del = gitread.DiffStatBetween(r.Context(), dir, head, tip)
1335+ mine := []run.Record{}
1336+ for _, rec := range runs[tip] {
1337+ // A commit lands on a branch and a proposal both, so a run that will not say is not ours.
1338+ if rec.Ref == t.Meta.Ref {
1339+ mine = append(mine, rec)
1340+ }
1341+ }
1342+ if len(mine) > 0 {
1343+ f.Build, f.Failed = buildSummary(mine)
1344+ f.BuildHref = "/" + owner + "/" + name + "/run/" + tip
1345+ }
1346+ out[t.N] = f
1347+ }
1348+ return out
1349+ }
1350+
1351+ // serveThread is one discussion, in order, with the two commands that read it offline. Chapter 24.
1352+ func (s *Server) serveThread(w http.ResponseWriter, r *http.Request, owner, name, num string) {
1353+ s.renderThread(w, r, owner, name, num, "")
1354+ }
1355+
1356+ // renderThread draws the same page with a refusal on it, because the reply box is the one here.
1357+ func (s *Server) renderThread(w http.ResponseWriter, r *http.Request, owner, name, num, fail string) {
1358+ res, page, ok := s.openRepo(w, r, owner, name, "threads")
1359+ if !ok {
1360+ return
1361+ }
1362+ n, err := strconv.Atoi(num)
1363+ if err != nil || n <= 0 {
1364+ s.notFound(w, r)
1365+ return
1366+ }
1367+ meta, comments, err := thread.Read(r.Context(), res.Dir, n)
1368+ if err != nil || meta.State == "" {
1369+ s.notFound(w, r)
1370+ return
1371+ }
1372+ tip := "HEAD"
1373+ if meta.Ref != "" && gitx.ValidRev(meta.Ref) {
1374+ tip = meta.Ref
1375+ }
1376+ views := make([]commentRow, 0, len(comments))
1377+ for _, c := range comments {
1378+ row := commentRow{
1379+ Author: c.Author, Ago: ago(c.Time), At: c.Time, Anchor: c.Anchor,
1380+ // Rule 5: anyone wrote this, so chapter 42.1's allowlist is what makes it safe as HTML.
1381+ HTML: template.HTML(markup.Render(c.Body)),
1382+ }
1383+ if a, ok := thread.ParseAnchor(c.Anchor); ok {
1384+ a.Blob, a.Side = c.Blob, c.Side
1385+ got := thread.Resolve(r.Context(), res.Dir, a, tip)
1386+ row.Excerpt = got.Excerpt
1387+ row.Line = got.Line
1388+ row.Where = a.Path + ":" + itoa(got.Line)
1389+ switch got.Placement {
1390+ case thread.Moved:
1391+ row.Placement = "moved"
1392+ case thread.Outdated:
1393+ row.Placement = "outdated"
1394+ case thread.Lost:
1395+ // Chapter 43.5: the original is gone, and saying so beats a meaningless line number.
1396+ row.Placement = "outdated, original unavailable"
1397+ row.Where = c.Anchor
1398+ }
1399+ // A lost anchor has no line to open, and the others land on the line they resolved to.
1400+ if got.Placement != thread.Lost {
1401+ row.WhereHref = "/" + owner + "/" + name + "/file/" + tip + "/" + a.Path +
1402+ "#L" + itoa(got.Line)
1403+ }
1404+ }
1405+ views = append(views, row)
1406+ }
1407+ views = append(views, s.threadRuns(r, res.Dir, owner, name, meta)...)
1408+ add, del := 0, 0
1409+ if meta.Ref != "" {
1410+ add, del = gitread.DiffStat(r.Context(), res.Dir, page.Branch, meta.Ref)
1411+ }
1412+ // A run belongs where it happened, so merge by time, stably, to keep the note's own order.
1413+ sort.SliceStable(views, func(i, j int) bool { return views[i].At.Before(views[j].At) })
1414+
1415+ page.Title += " thread " + num
1416+ page.Summary = "Discussion thread where issues and proposals are one object, stored in git notes."
1417+
1418+ names := []string{meta.Author}
1419+ for _, v := range views {
1420+ names = append(names, v.Author)
1421+ }
1422+ accounts := s.accountSet(r, names...)
1423+ for i := range views {
1424+ views[i].AuthorHref = accountHref(views[i].Author, accounts)
1425+ }
1426+
1427+ s.render(w, r, "thread", struct {
1428+ repoPage
1429+ N int
1430+ // Heading, not Title: an embedded Title would shadow the chrome's and rename the page.
1431+ Heading string
1432+ Detail []bit
1433+ NotesRef string
1434+ Comments []commentRow
1435+ Viewer string
1436+ // CanClose is the author or the repository owner, which is who chapter 35.6 names.
1437+ CanClose bool
1438+ Closed bool
1439+ Error string
1440+ }{
1441+ repoPage: page,
1442+ N: n,
1443+ Heading: titleOr(meta.Title, n),
1444+ Detail: threadHeadline(owner, name, meta, add, del, accounts),
1445+ NotesRef: thread.Ref(n),
1446+ Comments: views,
1447+ Viewer: s.viewer(r),
1448+ CanClose: mayClose(s.viewer(r), owner, meta),
1449+ Closed: meta.State == thread.Closed,
1450+ Error: fail,
1451+ })
1452+ }
1453+
1454+ // mayClose is the thread's author or the repository's owner, and a merged proposal is neither's.
1455+ func mayClose(viewer, owner string, meta thread.Meta) bool {
1456+ if viewer == "" || meta.State == thread.Merged {
1457+ return false
1458+ }
1459+ return viewer == owner || viewer == meta.Author
1460+ }
1461+
1462+ // serveThreadState closes a thread or opens it again, which chapter 35.6 gives two people.
1463+ func (s *Server) serveThreadState(w http.ResponseWriter, r *http.Request, owner, name, num string) {
1464+ res, _, ok := s.openRepo(w, r, owner, name, "threads")
1465+ if !ok {
1466+ return
1467+ }
1468+ n, err := strconv.Atoi(num)
1469+ if err != nil || n <= 0 {
1470+ s.notFound(w, r)
1471+ return
1472+ }
1473+ meta, found, err := thread.ReadMeta(r.Context(), res.Dir, n)
1474+ if err != nil || !found {
1475+ s.notFound(w, r)
1476+ return
1477+ }
1478+ if !mayClose(s.viewer(r), owner, meta) {
1479+ s.notFound(w, r)
1480+ return
1481+ }
1482+ want := thread.Closed
1483+ if meta.State == thread.Closed {
1484+ want = thread.Open
1485+ }
1486+ if err := thread.SetState(r.Context(), res.Dir, n, want, ""); err != nil {
1487+ s.oops(w, r, err)
1488+ return
1489+ }
1490+ http.Redirect(w, r, "/"+owner+"/"+name+"/thread/"+num, http.StatusFound)
1491+ }
1492+
1493+ type commentRow struct {
1494+ Author string
1495+ // AuthorHref is set only when the name in the note is an account barerepo knows.
1496+ AuthorHref string
1497+ // WhereHref opens the file the comment is anchored in, unless the anchor no longer resolves.
1498+ WhereHref string
1499+ Ago string
1500+ // At orders this row against the runs merged into the timeline. Ago is what the page shows.
1501+ At time.Time
1502+ Anchor string
1503+ // Where, Excerpt and Placement describe a line comment after its file moved on. Chapter 43.
1504+ Where string
1505+ Line int
1506+ Excerpt string
1507+ Placement string
1508+ HTML template.HTML
1509+ // Run is set when this row is a build result rather than a comment.
1510+ Run *runRow
1511+ }
1512+
1513+ // threadRuns are this proposal's builds, in the comment timeline, on one resolve and one read.
1514+ func (s *Server) threadRuns(r *http.Request, dir, owner, name string, meta thread.Meta) []commentRow {
1515+ if meta.Ref == "" || !gitx.ValidRev(meta.Ref) {
1516+ return nil
1517+ }
1518+ sha, err := gitx.ResolveRefOrAsk(r.Context(), dir, meta.Ref)
1519+ if err != nil || sha == "" {
1520+ return nil
1521+ }
1522+ recs, err := run.For(r.Context(), dir, sha)
1523+ if err != nil {
1524+ return nil
1525+ }
1526+ rows := make([]commentRow, 0, len(recs))
1527+ for _, rec := range recs {
1528+ // A commit lands on a branch and a proposal both, so a run that will not say is not ours.
1529+ if rec.Ref != meta.Ref {
1530+ continue
1531+ }
1532+ // Not runRow, which reads the commit subject this thread already shows as its heading.
1533+ row := runRow{
1534+ Short: short(sha),
1535+ // The matrix combination, so three builds of one proposal are three readable events.
1536+ Name: rec.Name,
1537+ Href: "/" + owner + "/" + name + "/run/" + sha,
1538+ Result: runResult(rec),
1539+ Failed: rec.Failed(),
1540+ Took: itoa(rec.Duration) + "s",
1541+ Ago: ago(rec.StartedAt()),
1542+ Ref: rec.Ref,
1543+ Runner: rec.Runner,
1544+ Exit: rec.Exit,
1545+ }
1546+ rows = append(rows, commentRow{
1547+ Author: rec.Runner,
1548+ Ago: ago(rec.StartedAt()),
1549+ At: rec.StartedAt(),
1550+ Run: &row,
1551+ })
1552+ }
1553+ return rows
1554+ }
1555+
1556+ func threadHeadline(owner, name string, m thread.Meta, add, del int, accounts map[string]bool) []bit {
1557+ base := "/" + owner + "/" + name
1558+ parts := []bit{}
1559+ if m.Author != "" {
1560+ parts = append(parts, bit{Text: "opened by " + m.Author, Href: accountHref(m.Author, accounts)})
1561+ }
1562+ if !m.Opened.IsZero() {
1563+ parts = append(parts, plain(ago(m.Opened)))
1564+ }
1565+ if m.Ref != "" {
1566+ // The proposal is a ref, and what a reader wants from it is the diff against the branch.
1567+ parts = append(parts, plain("has proposal"),
1568+ bit{Text: m.Ref, Href: base + "/compare?a=HEAD&b=" + url.QueryEscape(m.Ref)})
1569+ // The mockup puts the size here, because two numbers answer "should I read this".
1570+ if add > 0 || del > 0 {
1571+ parts = append(parts, plain("+"+itoa(add)+" -"+itoa(del)))
1572+ }
1573+ }
1574+ if m.State != thread.Open {
1575+ parts = append(parts, plain(string(m.State)))
1576+ }
1577+ if m.Merged != "" && len(m.Merged) >= 7 {
1578+ parts = append(parts, bit{Text: "merged at " + m.Merged[:7], Href: base + "/commit/" + m.Merged})
1579+ }
1580+ return parts
1581+ }
1582+
1583+ // accountHref is the profile page, or nothing, because a name in a note is whoever wrote the note.
1584+ func accountHref(who string, accounts map[string]bool) string {
1585+ if accounts[who] {
1586+ return "/" + who
1587+ }
1588+ return ""
1589+ }
1590+
1591+ // accountSet asks once which of these names are accounts, so a page never links to a 404.
1592+ func (s *Server) accountSet(r *http.Request, names ...string) map[string]bool {
1593+ seen := map[string]bool{}
1594+ var want []string
1595+ for _, n := range names {
1596+ if n != "" && !seen[n] {
1597+ seen[n] = true
1598+ want = append(want, n)
1599+ }
1600+ }
1601+ if len(want) == 0 || s.DB == nil {
1602+ return map[string]bool{}
1603+ }
1604+ got, err := s.DB.AccountsExist(r.Context(), want)
1605+ if err != nil {
1606+ // A name left plain is a smaller failure than a page that will not render.
1607+ return map[string]bool{}
1608+ }
1609+ return got
1610+ }
1611+
1612+ // titleOr names a thread whose proposal had an empty commit subject, because a row needs a name.
1613+ func titleOr(title string, n int) string {
1614+ if strings.TrimSpace(title) != "" {
1615+ return title
1616+ }
1617+ return "thread " + itoa(n)
1618+ }
1619+
1620+ // newThreadPage is the form and whatever was typed. Heading, not Title, so the chrome keeps its own.
1621+ type newThreadPage struct {
1622+ Heading string
1623+ Body string
1624+ Ref string
1625+ Error string
1626+ }
1627+
1628+ // serveNewThreadForm is one form: no ref is an issue, a ref is a proposal. Chapter 24.
1629+ func (s *Server) serveNewThreadForm(w http.ResponseWriter, r *http.Request, owner, name string, form newThreadPage) {
1630+ if _, ok := s.requireViewer(w, r); !ok {
1631+ return
1632+ }
1633+ _, page, ok := s.openRepo(w, r, owner, name, "threads")
1634+ if !ok {
1635+ return
1636+ }
1637+ page.Title += " new thread"
1638+ page.Summary = "New thread form with an optional field for attaching a pushed proposal ref."
1639+ s.render(w, r, "thread-new", struct {
1640+ repoPage
1641+ newThreadPage
1642+ }{page, form})
1643+ }
1644+
1645+ func (s *Server) serveNewThreadPost(w http.ResponseWriter, r *http.Request, owner, name string) {
1646+ who, ok := s.requireViewer(w, r)
1647+ if !ok {
1648+ return
1649+ }
1650+ res, _, ok := s.openRepo(w, r, owner, name, "threads")
1651+ if !ok {
1652+ return
1653+ }
1654+ form := newThreadPage{
1655+ Heading: strings.TrimSpace(r.FormValue("title")),
1656+ Body: strings.TrimSpace(r.FormValue("body")),
1657+ Ref: strings.TrimSpace(r.FormValue("ref")),
1658+ }
1659+ fail := func(msg string) {
1660+ form.Error = msg
1661+ w.WriteHeader(http.StatusBadRequest)
1662+ s.serveNewThreadForm(w, r, owner, name, form)
1663+ }
1664+ if form.Heading == "" {
1665+ fail("a thread needs a title.")
1666+ return
1667+ }
1668+ // Anyone who may read may comment. Chapter 18.
1669+ if !res.Config.MayRead(owner, who) {
1670+ s.notFound(w, r)
1671+ return
1672+ }
1673+ object := "HEAD"
1674+ if form.Ref != "" {
1675+ if !gitx.ValidRev(form.Ref) {
1676+ fail("that is not a ref name.")
1677+ return
1678+ }
1679+ if _, err := gitx.Run(r.Context(), res.Dir, "rev-parse", "--verify", "--quiet", form.Ref); err != nil {
1680+ fail("there is no ref named " + form.Ref + ". push it first.")
1681+ return
1682+ }
1683+ object = form.Ref
1684+ }
1685+ sha, err := gitx.Run(r.Context(), res.Dir, "rev-parse", "--verify", "--quiet", object)
1686+ if err != nil {
1687+ fail("this repository has no commits yet, so there is nothing to attach a thread to.")
1688+ return
1689+ }
1690+
1691+ n, err := proposal.Allocate(r.Context(), res.Dir)
1692+ if err != nil {
1693+ s.oops(w, r, err)
1694+ return
1695+ }
1696+ meta := thread.Meta{
1697+ Title: form.Heading, State: thread.Open, Ref: form.Ref,
1698+ Author: who, Opened: time.Now(),
1699+ }
1700+ first := thread.Comment{Author: who, Time: time.Now(), Body: form.Body}
1701+ if err := thread.Create(r.Context(), res.Dir, n, meta, strings.TrimSpace(sha), first); err != nil {
1702+ s.oops(w, r, err)
1703+ return
1704+ }
1705+ s.indexTalk(r, owner, name, res)
1706+ s.note(r, store.Event{Kind: store.ThreadOpened, Actor: who,
1707+ Repo: owner + "/" + name, Number: n, Title: form.Heading, Ref: form.Ref}, n)
1708+ http.Redirect(w, r, "/"+owner+"/"+name+"/thread/"+itoa(n), http.StatusFound)
1709+ }
1710+
1711+ func (s *Server) serveReply(w http.ResponseWriter, r *http.Request, owner, name, num string) {
1712+ who, ok := s.requireViewer(w, r)
1713+ if !ok {
1714+ return
1715+ }
1716+ res, _, ok := s.openRepo(w, r, owner, name, "threads")
1717+ if !ok {
1718+ return
1719+ }
1720+ n, err := strconv.Atoi(num)
1721+ if err != nil || n <= 0 {
1722+ s.notFound(w, r)
1723+ return
1724+ }
1725+ body := strings.TrimSpace(r.FormValue("body"))
1726+ if body == "" {
1727+ http.Redirect(w, r, "/"+owner+"/"+name+"/thread/"+num, http.StatusFound)
1728+ return
1729+ }
1730+ meta, _, err := thread.Read(r.Context(), res.Dir, n)
1731+ if err != nil || meta.State == "" {
1732+ s.notFound(w, r)
1733+ return
1734+ }
1735+ if !s.commentAllowed(who, owner+"/"+name, n) {
1736+ w.WriteHeader(http.StatusTooManyRequests)
1737+ s.renderThread(w, r, owner, name, num, tooManyComments)
1738+ return
1739+ }
1740+ // Attach to the thread's ref, or the default branch, so git notes find it either way.
1741+ object := "HEAD"
1742+ if meta.Ref != "" && gitx.ValidRev(meta.Ref) {
1743+ object = meta.Ref
1744+ }
1745+ sha, err := gitx.Run(r.Context(), res.Dir, "rev-parse", "--verify", "--quiet", object)
1746+ if err != nil {
1747+ s.oops(w, r, err)
1748+ return
1749+ }
1750+ if err := thread.Reply(r.Context(), res.Dir, n, strings.TrimSpace(sha),
1751+ thread.Comment{Author: who, Time: time.Now(), Body: body}); err != nil {
1752+ s.oops(w, r, err)
1753+ return
1754+ }
1755+ s.indexTalk(r, owner, name, res)
1756+ s.note(r, store.Event{Kind: store.ThreadReplied, Actor: who,
1757+ Repo: owner + "/" + name, Number: n, Title: meta.Title}, n)
1758+ http.Redirect(w, r, "/"+owner+"/"+name+"/thread/"+num, http.StatusFound)
1759+ }
1760+
1761+ // serveLineComment is chapter 35.3, and it records the blob so 43.4 can recover what was read.
1762+ func (s *Server) serveLineComment(w http.ResponseWriter, r *http.Request, owner, name, num string) {
1763+ who, ok := s.requireViewer(w, r)
1764+ if !ok {
1765+ return
1766+ }
1767+ res, page, ok := s.openRepo(w, r, owner, name, "threads")
1768+ if !ok {
1769+ return
1770+ }
1771+ n, err := strconv.Atoi(num)
1772+ if err != nil || n <= 0 {
1773+ s.notFound(w, r)
1774+ return
1775+ }
1776+ meta, _, err := thread.Read(r.Context(), res.Dir, n)
1777+ if err != nil || meta.State == "" {
1778+ s.notFound(w, r)
1779+ return
1780+ }
1781+
1782+ path := r.FormValue("path")
1783+ line, _ := strconv.Atoi(r.FormValue("line"))
1784+ if !gitx.ValidPath(path) || line <= 0 {
1785+ s.notFound(w, r)
1786+ return
1787+ }
1788+ rev := r.FormValue("rev")
1789+ if rev == "" || !gitx.ValidRev(rev) {
1790+ rev = meta.Ref
1791+ }
1792+ if rev == "" || !gitx.ValidRev(rev) {
1793+ rev = "HEAD"
1794+ }
1795+
1796+ if r.Method == http.MethodPost {
1797+ body := strings.TrimSpace(r.FormValue("body"))
1798+ if body == "" {
1799+ http.Redirect(w, r, "/"+owner+"/"+name+"/thread/"+num, http.StatusFound)
1800+ return
1801+ }
1802+ if !s.commentAllowed(who, owner+"/"+name, n) {
1803+ w.WriteHeader(http.StatusTooManyRequests)
1804+ s.renderThread(w, r, owner, name, num, tooManyComments)
1805+ return
1806+ }
1807+ sha, err := gitx.Run(r.Context(), res.Dir, "rev-parse", "--verify", "--quiet", rev)
1808+ if err != nil {
1809+ s.oops(w, r, err)
1810+ return
1811+ }
1812+ c := thread.Comment{
1813+ Author: who, Time: time.Now(), Body: body,
1814+ Anchor: path + ":" + itoa(line),
1815+ Blob: strings.TrimSpace(r.FormValue("blob")),
1816+ Side: "new",
1817+ // Chapter 26 keeps a revision an anchored comment needs, and this is what names it.
1818+ Revision: proposal.CurrentRevision(res.Dir, n),
1819+ }
1820+ if err := thread.Reply(r.Context(), res.Dir, n, strings.TrimSpace(sha), c); err != nil {
1821+ s.oops(w, r, err)
1822+ return
1823+ }
1824+ s.indexTalk(r, owner, name, res)
1825+ s.note(r, store.Event{Kind: store.ThreadReplied, Actor: who,
1826+ Repo: owner + "/" + name, Number: n, Title: meta.Title}, n)
1827+ http.Redirect(w, r, "/"+owner+"/"+name+"/thread/"+num, http.StatusFound)
1828+ return
1829+ }
1830+
1831+ f, err := gitread.Open(r.Context(), res.Dir, rev, path)
1832+ if err != nil || f == nil {
1833+ s.notFound(w, r)
1834+ return
1835+ }
1836+
1837+ page.Title += " comment on " + path
1838+ page.Summary = "Form for writing a comment against one line of one file."
1839+ s.render(w, r, "comment-line", struct {
1840+ repoPage
1841+ N int
1842+ Path string
1843+ Line int
1844+ Rev string
1845+ Blob string
1846+ Context []contextLine
1847+ Error string
1848+ }{page, n, path, line, rev, f.Blob, around(f.Lines, line, 3), ""})
1849+ }
1850+
1851+ // tooManyComments is said on both comment paths, because a reader need not know they differ.
1852+ const tooManyComments = "too many comments on this thread in the last hour. try later."
1853+
1854+ // commentAllowed is chapter 27's note spam barrier, per account per thread, since a comment is cheap.
1855+ func (s *Server) commentAllowed(who, repo string, n int) bool {
1856+ limit := s.Cfg.Limits.CommentPerHourPerThread
1857+ if limit <= 0 {
1858+ return true
1859+ }
1860+ key := who + " " + repo + "#" + itoa(n)
1861+ cutoff := time.Now().Add(-time.Hour)
1862+
1863+ s.commentsMu.Lock()
1864+ defer s.commentsMu.Unlock()
1865+ if s.comments == nil {
1866+ s.comments = map[string][]time.Time{}
1867+ }
1868+ kept := s.comments[key][:0]
1869+ for _, t := range s.comments[key] {
1870+ if t.After(cutoff) {
1871+ kept = append(kept, t)
1872+ }
1873+ }
1874+ if len(kept) >= limit {
1875+ s.comments[key] = kept
1876+ return false
1877+ }
1878+ s.comments[key] = append(kept, time.Now())
1879+ return true
1880+ }
1881+
1882+ // contextLine is one line of the excerpt shown above the comment box.
1883+ type contextLine struct {
1884+ Number int
1885+ Text string
1886+ Here bool
1887+ }
1888+
1889+ // around returns the lines either side of the one being commented on, so the writer sees it.
1890+ func around(lines []gitread.FileLine, at, span int) []contextLine {
1891+ var out []contextLine
1892+ for _, l := range lines {
1893+ if l.Number < at-span || l.Number > at+span {
1894+ continue
1895+ }
1896+ out = append(out, contextLine{Number: l.Number, Text: l.Text, Here: l.Number == at})
1897+ }
1898+ return out
1899+ }
1900+
1901+ // serveRunners lists the machines attached to a repository. Chapter 24.
1902+ func (s *Server) serveRunners(w http.ResponseWriter, r *http.Request, owner, name string) {
1903+ res, page, ok := s.openRepo(w, r, owner, name, "runs")
1904+ if !ok {
1905+ return
1906+ }
1907+ list, err := s.DB.RunnersOf(r.Context(), owner+"/"+name)
1908+ if err != nil {
1909+ s.oops(w, r, err)
1910+ return
1911+ }
1912+ work, err := s.DB.WorkOf(r.Context(), owner+"/"+name)
1913+ if err != nil {
1914+ s.log(r, err)
1915+ }
1916+ views := make([]runnerRow, 0, len(list))
1917+ for _, rn := range list {
1918+ offline := rn.Offline(30 * time.Second)
1919+ // Chapter 24 asks for a status, and a machine with a job in hand is neither idle nor gone.
1920+ state := "idle"
1921+ switch {
1922+ case offline:
1923+ state = "offline"
1924+ case work[rn.ID].Busy:
1925+ state = "busy"
1926+ }
1927+ detail := rn.OS + "/" + rn.Arch
1928+ if len(rn.Labels) > 0 {
1929+ detail += " · labels " + strings.Join(rn.Labels, ", ")
1930+ }
1931+ if n := work[rn.ID].Runs; n > 0 {
1932+ detail += " · " + plural(n, "run", "runs")
1933+ }
1934+ views = append(views, runnerRow{
1935+ ID: rn.ID, Hostname: rn.Hostname, State: state,
1936+ Seen: spoken(ago(rn.LastSeen)), Detail: detail, Offline: offline,
1937+ })
1938+ }
1939+ page.Title += " runners"
1940+ page.Summary = "List of attached build machines with labels, platform and last seen time."
1941+ page.Ends = runnerEnds(owner, name, "runners", res.Config.MayPush(owner, s.viewer(r)))
1942+ s.render(w, r, "runners", struct {
1943+ repoPage
1944+ Runners []runnerRow
1945+ Attached string
1946+ CanAdd bool
1947+ }{page, views, plural(len(views), "attached", "attached"),
1948+ res.Config.MayPush(owner, s.viewer(r))})
1949+ }
1950+
1951+ type runnerRow struct {
1952+ ID int64
1953+ Hostname string
1954+ State string
1955+ Seen string
1956+ Detail string
1957+ Offline bool
1958+ }
1959+
1960+ // serveRunnerSetup issues a token inside one copied line. Chapter 15: a second step is a bug.
1961+ func (s *Server) serveRunnerSetup(w http.ResponseWriter, r *http.Request, owner, name string) {
1962+ who, ok := s.requireViewer(w, r)
1963+ if !ok {
1964+ return
1965+ }
1966+ res, page, ok := s.openRepo(w, r, owner, name, "runs")
1967+ if !ok {
1968+ return
1969+ }
1970+ if !res.Config.MayPush(owner, who) {
1971+ s.notFound(w, r)
1972+ return
1973+ }
1974+ page.Title += " add a runner"
1975+ page.Summary = "Runner setup page showing one paste-ready command per operating system with the token already embedded."
1976+
1977+ // Chapter 24: the commands are the page, and a button to reveal them is the second step it forbids.
1978+ if err := s.DB.DropStaleRunnerTokens(r.Context(), who, owner+"/"+name); err != nil {
1979+ s.log(r, err)
1980+ }
1981+ tok, _, err := s.DB.CreateToken(r.Context(), token.Runner, who,
1982+ owner+"/"+name, "runner for "+owner+"/"+name)
1983+ if err != nil {
1984+ s.oops(w, r, err)
1985+ return
1986+ }
1987+ s.render(w, r, "runner-setup", struct {
1988+ repoPage
1989+ Token string
1990+ ExternalURL string
1991+ }{page, tok, s.Cfg.Server.ExternalURL})
1992+ }
1993+
1994+ func (s *Server) serveForgetRunner(w http.ResponseWriter, r *http.Request, owner, name string) {
1995+ who, ok := s.requireViewer(w, r)
1996+ if !ok {
1997+ return
1998+ }
1999+ res, _, ok := s.openRepo(w, r, owner, name, "runs")
2000+ if !ok {
2001+ return
2002+ }
2003+ if !res.Config.MayPush(owner, who) {
2004+ s.notFound(w, r)
2005+ return
2006+ }
2007+ id, _ := strconv.ParseInt(r.FormValue("id"), 10, 64)
2008+ if err := s.DB.ForgetRunner(r.Context(), owner+"/"+name, id); err != nil {
2009+ s.log(r, err)
2010+ }
2011+ http.Redirect(w, r, "/"+owner+"/"+name+"/runners", http.StatusFound)
2012+ }
2013+
2014+ // serveRuns lists build results, newest first. Chapter 24.
2015+ func (s *Server) serveRuns(w http.ResponseWriter, r *http.Request, owner, name string) {
2016+ res, page, ok := s.openRepo(w, r, owner, name, "runs")
2017+ if !ok {
2018+ return
2019+ }
2020+ // Every run, because paging needs the ones below the page and the notes are read whole anyway.
2021+ byCommit, err := run.Recent(r.Context(), res.Dir, 0)
2022+ if err != nil {
2023+ s.oops(w, r, err)
2024+ return
2025+ }
2026+ // One process for every built commit, where a git log per row is what this used to cost.
2027+ specs := make([]string, 0, len(byCommit))
2028+ for sha := range byCommit {
2029+ specs = append(specs, sha)
2030+ }
2031+ commits, err := gitx.Batch(r.Context(), res.Dir, specs)
2032+ if err != nil {
2033+ s.oops(w, r, err)
2034+ return
2035+ }
2036+ var rows []runRow
2037+ for sha, recs := range byCommit {
2038+ subject := ""
2039+ if c := commits[sha]; c != nil {
2040+ subject = commitSubject(c.Body)
2041+ }
2042+ for _, rec := range recs {
2043+ rows = append(rows, runRowFor(owner, name, sha, subject, rec))
2044+ }
2045+ }
2046+ // A matrix starts its jobs in one second, so the commit and the job name settle the order the clock cannot and two requests agree on it.
2047+ sort.Slice(rows, func(i, j int) bool {
2048+ if !rows[i].Started.Equal(rows[j].Started) {
2049+ return rows[i].Started.After(rows[j].Started)
2050+ }
2051+ if rows[i].Short != rows[j].Short {
2052+ return rows[i].Short < rows[j].Short
2053+ }
2054+ return rows[i].Name < rows[j].Name
2055+ })
2056+ // By position and not by time, because runs sharing a second would fall in the gap between pages.
2057+ start := 0
2058+ if from := r.URL.Query().Get("from"); from != "" {
2059+ n, err := strconv.Atoi(from)
2060+ if err != nil || n < 0 || n > len(rows) {
2061+ s.notFound(w, r)
2062+ return
2063+ }
2064+ start = n
2065+ }
2066+ rows = rows[start:]
2067+ if len(rows) > listPage {
2068+ page.Older = "/" + owner + "/" + name + "/runs?from=" + itoa(start+listPage)
2069+ rows = rows[:listPage]
2070+ }
2071+
2072+ page.Title += " runs"
2073+ page.Summary = "List of build runs with status, trigger and duration."
2074+ page.Ends = runnerEnds(owner, name, "", res.Config.MayPush(owner, s.viewer(r)))
2075+ // A page that says builds are off while builds are running is the page contradicting itself.
2076+ command := strings.TrimSpace(res.Config.Build.Command)
2077+ files := []string{}
2078+ if command == "" {
2079+ if head, err := gitx.ResolveRefOrAsk(r.Context(), res.Dir, "HEAD"); err == nil {
2080+ files = workflow.Files(r.Context(), res.Dir, head)
2081+ }
2082+ }
2083+ s.render(w, r, "runs", struct {
2084+ repoPage
2085+ Runs []runRow
2086+ BuildOn bool
2087+ // Workflows names what a repository builds from when it is not [build] command.
2088+ Workflows []string
2089+ }{page, rows, command != "" || len(files) > 0, files})
2090+ }
2091+
2092+ type runRow struct {
2093+ Short string
2094+ // Name is the workflow job, empty for a [build] command, which has only one. Chapter 15A.
2095+ Name string
2096+ Href string
2097+ // CommitHref is the commit the run built, which is not the run page. Chapter 24.
2098+ CommitHref string
2099+ Result string
2100+ Failed bool
2101+ Took string
2102+ Ago string
2103+ Subject string
2104+ // Shown is how much of the log the page holds when it cannot hold all of it.
2105+ Shown string
2106+ Ref string
2107+ // RefHref compares the proposal against the branch, which is what a ref is worth reading as.
2108+ RefHref string
2109+ Runner string
2110+ RunnerHref string
2111+ Exit int
2112+ Output string
2113+ RawHref string
2114+ Size string
2115+ Started time.Time
2116+ }
2117+
2118+ // runRowFor takes the subject, because both callers have the commit already.
2119+ func runRowFor(owner, name, sha, subject string, rec run.Record) runRow {
2120+ base := "/" + owner + "/" + name
2121+ refHref := ""
2122+ // A branch compares against nothing useful, and only a proposal has another side to show.
2123+ if strings.HasPrefix(rec.Ref, "refs/proposals/") {
2124+ refHref = base + "/compare?a=HEAD&b=" + url.QueryEscape(rec.Ref)
2125+ }
2126+ return runRow{
2127+ Short: short(sha),
2128+ Name: rec.Name,
2129+ Href: base + "/run/" + sha,
2130+ CommitHref: base + "/commit/" + sha,
2131+ RefHref: refHref,
2132+ RunnerHref: base + "/runners",
2133+ Result: runResult(rec),
2134+ Failed: rec.Failed(),
2135+ Took: itoa(rec.Duration) + "s",
2136+ Ago: ago(rec.StartedAt()),
2137+ Subject: subject,
2138+ Ref: rec.Ref,
2139+ Runner: rec.Runner,
2140+ Exit: rec.Exit,
2141+ RawHref: "/" + owner + "/" + name + "/run/" + sha + "/log",
2142+ Started: rec.StartedAt(),
2143+ }
2144+ }
2145+
2146+ // commitSubject reads a raw commit: a header block, a blank line, then the message.
2147+ func commitSubject(body string) string {
2148+ _, msg, found := strings.Cut(body, "\n\n")
2149+ if !found {
2150+ return ""
2151+ }
2152+ subject, _, _ := strings.Cut(msg, "\n")
2153+ return strings.TrimSpace(subject)
2154+ }
2155+
2156+ // commitAuthor reads the name off a commit object, which is who a build page is about.
2157+ func commitAuthor(body string) string {
2158+ for _, line := range strings.Split(body, "\n") {
2159+ if line == "" {
2160+ return ""
2161+ }
2162+ if rest, ok := strings.CutPrefix(line, "author "); ok {
2163+ if i := strings.LastIndex(rest, " <"); i > 0 {
2164+ return rest[:i]
2165+ }
2166+ }
2167+ }
2168+ return ""
2169+ }
2170+
2171+ // logOnPage is how much of a build log the page carries, since chapter 25 budgets the whole page.
2172+ const logOnPage = 12 << 10
2173+
2174+ // runResult reads as the mockup does: each label that passed, or the failure that stands out.
2175+ func runResult(rec run.Record) string {
2176+ if rec.Failed() {
2177+ if len(rec.Labels) > 0 {
2178+ return rec.Labels[0] + " failed"
2179+ }
2180+ return "failed"
2181+ }
2182+ if len(rec.Labels) == 0 {
2183+ return "ok"
2184+ }
2185+ parts := make([]string, 0, len(rec.Labels))
2186+ for _, l := range rec.Labels {
2187+ parts = append(parts, l+" ok")
2188+ }
2189+ return strings.Join(parts, " · ")
2190+ }
2191+
2192+ // serveRun is one commit's builds with the whole log as text, so ctrl-F works. Chapter 16.
2193+ func (s *Server) serveRun(w http.ResponseWriter, r *http.Request, owner, name, sha string) {
2194+ res, page, ok := s.openRepo(w, r, owner, name, "runs")
2195+ if !ok {
2196+ return
2197+ }
2198+ if !gitx.ValidRev(sha) {
2199+ s.notFound(w, r)
2200+ return
2201+ }
2202+ // A rev that does not resolve is not a commit with no runs, it is a page that does not exist.
2203+ if _, err := gitx.ResolveRefOrAsk(r.Context(), res.Dir, sha); err != nil {
2204+ s.notFound(w, r)
2205+ return
2206+ }
2207+ recs, err := run.For(r.Context(), res.Dir, sha)
2208+ if err != nil {
2209+ s.oops(w, r, err)
2210+ return
2211+ }
2212+ subject, author := "", ""
2213+ if c, err := gitx.Batch(r.Context(), res.Dir, []string{sha}); err == nil && c[sha] != nil {
2214+ subject = commitSubject(c[sha].Body)
2215+ author = commitAuthor(c[sha].Body)
2216+ }
2217+ rows := make([]runRow, 0, len(recs))
2218+ for _, rec := range recs {
2219+ row := runRowFor(owner, name, sha, subject, rec)
2220+ log, err := run.Log(r.Context(), res.Dir, rec)
2221+ if err != nil {
2222+ log = ""
2223+ }
2224+ row.Size = size(int64(len(log)))
2225+ // Chapter 25 budgets this page, and a long build must not be the thing that breaks it.
2226+ if len(log) > logOnPage {
2227+ log = log[len(log)-logOnPage:]
2228+ // The cut lands mid line, so it starts at the next one.
2229+ if i := strings.IndexByte(log, '\n'); i >= 0 {
2230+ log = log[i+1:]
2231+ }
2232+ row.Shown = size(int64(len(log)))
2233+ }
2234+ row.Output = log
2235+ rows = append(rows, row)
2236+ }
2237+ meta := ""
2238+ // run.html puts the ref that triggered the build in the footer, which is not the default branch.
2239+ ref := page.Branch
2240+ if len(rows) > 0 {
2241+ // The runner is named per run below because a matrix runs on more than one machine, and run.html reads this as a sentence, writing "1d ago" where a column says "1d".
2242+ meta = rows[0].Took
2243+ if said := spoken(rows[0].Ago); said != "" {
2244+ meta += " · " + said
2245+ }
2246+ if rows[0].Ref != "" {
2247+ ref = rows[0].Ref
2248+ }
2249+ }
2250+ page.Title += " run " + short(sha)
2251+ page.Summary = "Run detail page showing raw build log output as plain scrollable text."
2252+ s.render(w, r, "run", struct {
2253+ repoPage
2254+ SHA string
2255+ Short string
2256+ Meta string
2257+ Ref string
2258+ Author string
2259+ Runs []runRow
2260+ CanRerun bool
2261+ }{page, sha, short(sha), meta, ref, author, rows,
2262+ res.Config.MayPush(owner, s.viewer(r))})
2263+ }
2264+
2265+ // serveRunLog sends a build log as plain text, for ctrl-F, grep and pipes.
2266+ func (s *Server) serveRunLog(w http.ResponseWriter, r *http.Request, owner, name, sha string) {
2267+ res, _, ok := s.openRepo(w, r, owner, name, "runs")
2268+ if !ok {
2269+ return
2270+ }
2271+ recs, err := run.For(r.Context(), res.Dir, sha)
2272+ if err != nil || len(recs) == 0 {
2273+ s.notFound(w, r)
2274+ return
2275+ }
2276+ log, err := run.Log(r.Context(), res.Dir, recs[0])
2277+ if err != nil {
2278+ s.oops(w, r, err)
2279+ return
2280+ }
2281+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
2282+ w.Header().Set("X-Content-Type-Options", "nosniff")
2283+ w.Write([]byte(log))
2284+ }
2285+
2286+ // serveRerun queues the same commit again.
2287+ func (s *Server) serveRerun(w http.ResponseWriter, r *http.Request, owner, name, sha string) {
2288+ who, ok := s.requireViewer(w, r)
2289+ if !ok {
2290+ return
2291+ }
2292+ res, page, ok := s.openRepo(w, r, owner, name, "runs")
2293+ if !ok {
2294+ return
2295+ }
2296+ if !res.Config.MayPush(owner, who) || !gitx.ValidRev(sha) {
2297+ s.notFound(w, r)
2298+ return
2299+ }
2300+ if cmd := strings.TrimSpace(res.Config.Build.Command); cmd != "" {
2301+ if _, err := s.DB.QueueJob(r.Context(), owner+"/"+name, page.Branch, sha,
2302+ cmd, res.Config.Build.Image); err != nil {
2303+ s.oops(w, r, err)
2304+ return
2305+ }
2306+ }
2307+ http.Redirect(w, r, "/"+owner+"/"+name+"/run/"+sha, http.StatusFound)
2308+ }
2309+
2310+ func short(sha string) string {
2311+ if len(sha) > 7 {
2312+ return sha[:7]
2313+ }
2314+ return sha
2315+ }
2316+
2317+ // serveSearch is one query over code, threads and repositories, cross-repository by default.
2318+ func (s *Server) serveSearch(w http.ResponseWriter, r *http.Request) {
2319+ query := strings.TrimSpace(r.URL.Query().Get("q"))
2320+ page := struct {
2321+ chrome
2322+ Account string
2323+ Query string
2324+ Count string
2325+ Results []searchRow
2326+ }{
2327+ chrome: newChrome("barerepo · search",
2328+ "Search results combining code matches, threads and repositories in one list."),
2329+ Account: s.viewer(r),
2330+ Query: query,
2331+ }
2332+ if query == "" {
2333+ s.render(w, r, "search", page)
2334+ return
2335+ }
2336+
2337+ found := search.Search(r.Context(), s.DB, s.viewer(r), query, 50)
2338+ for _, res := range found {
2339+ // The box is the matched source line, so only a code match earns one, per search.html.
2340+ where := res.Owner + " / " + res.Name
2341+ switch res.Kind {
2342+ case search.Code:
2343+ where += " / " + res.Path + ":" + itoa(res.Line)
2344+ case search.Thread:
2345+ where += " " + res.Path + " · " + res.Text
2346+ }
2347+ page.Results = append(page.Results, searchRow{
2348+ Kind: res.Kind.String(), Where: highlight(where, query), Href: res.Href,
2349+ Text: highlight(strings.TrimSpace(res.Text), query),
2350+ Context: highlight(res.Context, query),
2351+ HasText: res.Kind == search.Code && strings.TrimSpace(res.Text) != "",
2352+ HasCtx: res.Context != "",
2353+ })
2354+ }
2355+ page.Count = plural(len(page.Results), "result", "results")
2356+ s.render(w, r, "search", page)
2357+ }
2358+
2359+ type searchRow struct {
2360+ Kind string
2361+ Where template.HTML
2362+ Href string
2363+ Text template.HTML
2364+ Context template.HTML
2365+ HasText bool
2366+ HasCtx bool
2367+ }
2368+
2369+ // indexTalk keeps chapter 17's index current when a comment is written here instead of pushed.
2370+ func (s *Server) indexTalk(r *http.Request, owner, name string, res *transport.Result) {
2371+ if s.DB == nil {
2372+ return
2373+ }
2374+ branch, err := repo.HeadBranch(r.Context(), res.Dir)
2375+ if err != nil {
2376+ return
2377+ }
2378+ t := search.Target{Owner: owner, Name: name, Dir: res.Dir, Ref: branch, Config: res.Config}
2379+ if err := search.IndexThreads(r.Context(), s.DB, t); err != nil {
2380+ s.log(r, err)
2381+ }
2382+ }
2383+
2384+ // note records an event, and no failure here is worth losing the comment that caused it.
2385+ func (s *Server) note(r *http.Request, e store.Event, number int) {
2386+ if err := s.DB.Record(r.Context(), e); err != nil {
2387+ s.log(r, err)
2388+ }
2389+ if err := s.DB.TookPart(r.Context(), e.Actor, e.Repo, number); err != nil {
2390+ s.log(r, err)
2391+ }
2392+ }
2393+
2394+ // serveInbox has no unread counts: per-user read state would need a new closed-list category. 19.4.
2395+ func (s *Server) serveInbox(w http.ResponseWriter, r *http.Request) {
2396+ who, ok := s.requireViewer(w, r)
2397+ if !ok {
2398+ return
2399+ }
2400+ events, err := s.DB.Inbox(r.Context(), who, 100)
2401+ if err != nil {
2402+ s.oops(w, r, err)
2403+ return
2404+ }
2405+ seen, err := s.DB.LastVisited(r.Context(), who)
2406+ if err != nil {
2407+ s.oops(w, r, err)
2408+ return
2409+ }
2410+
2411+ rows := make([]eventRow, 0, len(events))
2412+ ruled := false
2413+ for _, e := range events {
2414+ row := eventRow{
2415+ Text: eventLine(e), Detail: eventDetail(e), Ago: ago(e.Created),
2416+ Href: eventHref(e),
2417+ }
2418+ // One rule, at the point the reader had got to last time.
2419+ if !ruled && !seen.IsZero() && e.Created.Before(seen) {
2420+ row.Rule = true
2421+ ruled = true
2422+ }
2423+ row.Dim = ruled
2424+ rows = append(rows, row)
2425+ }
2426+ if err := s.DB.Visit(r.Context(), who); err != nil {
2427+ s.log(r, err)
2428+ }
2429+
2430+ s.render(w, r, "inbox", struct {
2431+ chrome
2432+ Account string
2433+ Events []eventRow
2434+ Seen string
2435+ }{
2436+ chrome: newChrome("barerepo · inbox", "Chronological list of events on repositories and threads the user participates in."),
2437+ Account: who,
2438+ Events: rows,
2439+ Seen: agoOr(seen),
2440+ })
2441+ }
2442+
2443+ type eventRow struct {
2444+ Text string
2445+ Detail string
2446+ Ago string
2447+ Href string
2448+ Rule bool
2449+ Dim bool
2450+ }
2451+
2452+ // eventLine reads as one sentence, because the inbox is one line per event.
2453+ func eventLine(e store.Event) string {
2454+ repo := e.Repo
2455+ if i := strings.Index(repo, "/"); i >= 0 {
2456+ repo = repo[i+1:]
2457+ }
2458+ n := itoa(e.Number)
2459+ switch e.Kind {
2460+ case store.ProposalOpened:
2461+ return e.Actor + " opened proposal " + n + " on " + repo
2462+ case store.ProposalUpdated:
2463+ return e.Actor + " updated proposal " + n + " on " + repo
2464+ case store.ProposalMerged:
2465+ return e.Actor + " merged proposal " + n + " on " + repo
2466+ case store.ThreadOpened:
2467+ return e.Actor + " opened thread " + n + " on " + repo
2468+ case store.ThreadReplied:
2469+ return e.Actor + " replied on " + repo + " thread " + n
2470+ case store.ThreadClosed:
2471+ return e.Actor + " closed thread " + n + " on " + repo
2472+ case store.RunFailed:
2473+ name := e.Title
2474+ if name == "" {
2475+ name = "build"
2476+ }
2477+ return name + " failed on " + repo + " " + e.Ref
2478+ case store.Pushed:
2479+ return e.Actor + " pushed " + e.Ref + " on " + repo
2480+ default:
2481+ return e.Actor + " " + e.Kind + " on " + repo
2482+ }
2483+ }
2484+
2485+ // eventDetail is the second line, which for a failed build is the runner and the exit code.
2486+ func eventDetail(e store.Event) string {
2487+ if e.Kind == store.RunFailed {
2488+ return e.Detail
2489+ }
2490+ return e.Title
2491+ }
2492+
2493+ func eventHref(e store.Event) string {
2494+ // A build that failed on a branch has no thread to open, so it goes where the logs are.
2495+ if e.Kind == store.RunFailed && e.Number == 0 {
2496+ return "/" + e.Repo + "/runs"
2497+ }
2498+ if e.Number > 0 {
2499+ return "/" + e.Repo + "/thread/" + itoa(e.Number)
2500+ }
2501+ return "/" + e.Repo
2502+ }
2503+
2504+ func agoOr(t time.Time) string {
2505+ if t.IsZero() {
2506+ return ""
2507+ }
2508+ return "last visited " + spoken(ago(t))
2509+ }
2510+
2511+ // serveReleases lists tags, and says notes clone and files do not, before a migration teaches it.
2512+ func (s *Server) serveReleases(w http.ResponseWriter, r *http.Request, owner, name string) {
2513+ res, page, ok := s.openRepo(w, r, owner, name, "releases")
2514+ if !ok {
2515+ return
2516+ }
2517+ list, err := gitread.Releases(r.Context(), res.Dir)
2518+ if err != nil {
2519+ s.oops(w, r, err)
2520+ return
2521+ }
2522+ if from := r.URL.Query().Get("from"); from != "" {
2523+ at := -1
2524+ for i := range list {
2525+ if list[i].Tag == from {
2526+ at = i
2527+ break
2528+ }
2529+ }
2530+ if at < 0 {
2531+ s.notFound(w, r)
2532+ return
2533+ }
2534+ list = list[at:]
2535+ }
2536+ if len(list) > listPage {
2537+ page.Older = "/" + owner + "/" + name + "/releases?from=" + url.QueryEscape(list[listPage].Tag)
2538+ list = list[:listPage]
2539+ }
2540+ // Only the bodies this page draws are read, because a note is a blob and a page is twenty of them.
2541+ gitread.ReadNotes(r.Context(), res.Dir, list)
2542+ // One pass over the blob store, so a repository with nothing attached costs one stat.
2543+ attached, _ := artifact.ListAll(s.Cfg.Paths.Artifacts, owner, name)
2544+ views := make([]releaseRow, 0, len(list))
2545+ names := make([]string, 0, len(list))
2546+ for _, rel := range list {
2547+ files := make([]fileLink, 0, len(attached[rel.Tag]))
2548+ for _, f := range attached[rel.Tag] {
2549+ files = append(files, fileLink{Name: f.Name, Size: size(f.Size),
2550+ Href: "/" + owner + "/" + name + "/release/" + url.PathEscape(rel.Tag) + "/" + f.Name})
2551+ }
2552+ views = append(views, releaseRow{
2553+ Tag: rel.Tag,
2554+ Href: "/" + owner + "/" + name + "/files/" + fileRef(r.Context(), res.Dir, rel.Tag),
2555+ Tagger: rel.Tagger,
2556+ Ago: ago(rel.When),
2557+ Subject: rel.Subject,
2558+ Notes: template.HTML(markup.Render(rel.Notes)),
2559+ HasNotes: strings.TrimSpace(rel.Notes) != "",
2560+ Files: files,
2561+ })
2562+ names = append(names, rel.Tagger)
2563+ }
2564+ accounts := s.accountSet(r, names...)
2565+ for i := range views {
2566+ views[i].TaggerHref = accountHref(views[i].Tagger, accounts)
2567+ }
2568+ page.Title += " releases"
2569+ page.Summary = "List of tagged releases with notes and attached files."
2570+ // A statement and not a control, because there is no other order to put them in.
2571+ page.Ends = []tab{{Label: "newest first"}}
2572+ s.render(w, r, "releases", struct {
2573+ repoPage
2574+ Releases []releaseRow
2575+ }{page, views})
2576+ }
2577+
2578+ // serveRelease opens the list at one tag, because chapter 24 draws no separate page for a release.
2579+ func (s *Server) serveRelease(w http.ResponseWriter, r *http.Request, owner, name, tag string) {
2580+ res, _, ok := s.openRepoFor(w, r, owner, name, "")
2581+ if !ok {
2582+ return
2583+ }
2584+ list, err := gitread.Releases(r.Context(), res.Dir)
2585+ if err != nil {
2586+ s.oops(w, r, err)
2587+ return
2588+ }
2589+ for _, rel := range list {
2590+ if rel.Tag == tag {
2591+ http.Redirect(w, r, "/"+owner+"/"+name+"/releases?from="+url.QueryEscape(tag),
2592+ http.StatusFound)
2593+ return
2594+ }
2595+ }
2596+ s.notFound(w, r)
2597+ }
2598+
2599+ // serveReleaseFile hands over one attached file, which chapter 22.2 keeps outside git.
2600+ func (s *Server) serveReleaseFile(w http.ResponseWriter, r *http.Request, owner, name, tag, file string) {
2601+ if _, _, ok := s.openRepoFor(w, r, owner, name, ""); !ok {
2602+ return
2603+ }
2604+ f, info, err := artifact.Open(s.Cfg.Paths.Artifacts, owner, name, tag, file)
2605+ if err != nil {
2606+ s.notFound(w, r)
2607+ return
2608+ }
2609+ defer f.Close()
2610+
2611+ // An attached file is somebody else's bytes, so it downloads and never renders. Chapter 42.3.
2612+ h := w.Header()
2613+ h.Set("Content-Type", "application/octet-stream")
2614+ h.Set("Content-Disposition", "attachment; filename="+strconv.Quote(file))
2615+ h.Set("X-Content-Type-Options", "nosniff")
2616+ h.Set("Content-Security-Policy", "default-src 'none'; sandbox")
2617+ http.ServeContent(w, r, file, info.ModTime(), f)
2618+ }
2619+
2620+ type releaseRow struct {
2621+ Tag string
2622+ // Href opens the files at the tag, because a release is a tag and a tag is a tree.
2623+ Href string
2624+ Tagger string
2625+ TaggerHref string
2626+ Ago string
2627+ Subject string
2628+ Notes template.HTML
2629+ HasNotes bool
2630+ // Files are the attached binaries, which clone --mirror does not take. Chapter 22.3.
2631+ Files []fileLink
2632+ }
2633+
2634+ // fileLink is one attached file on the releases page, named and measured as the mockup has it.
2635+ type fileLink struct {
2636+ Name string
2637+ Size string
2638+ Href string
2639+ }
2640+
2641+ // serveRepoConfig is one file view plus the three things that move the repository. Chapter 44.
2642+ func (s *Server) serveRepoConfig(w http.ResponseWriter, r *http.Request, owner, name, errMsg string) {
2643+ res, page, ok := s.openRepo(w, r, owner, name, "config")
2644+ if !ok {
2645+ return
2646+ }
2647+ body, err := gitx.Run(r.Context(), res.Dir, "show", "HEAD:"+repocfg.Path)
2648+ if err != nil {
2649+ body = ""
2650+ }
2651+ // The pieces, not a sentence, because the hash and the name both go somewhere. Chapter 24.
2652+ edited, editor, editSHA := "", "", ""
2653+ if out, err := gitx.Run(r.Context(), res.Dir, "log", "--max-count=1",
2654+ "--format=%ar\x1e%an\x1e%h\x1e%H", "HEAD", "--", repocfg.Path); err == nil {
2655+ if f := strings.SplitN(strings.TrimSpace(out), "\x1e", 4); len(f) == 4 {
2656+ edited, editor, editSHA = f[0], f[1], f[3]
2657+ }
2658+ }
2659+ editorHref := ""
2660+ if editor != "" {
2661+ if _, err := s.DB.Account(r.Context(), editor); err == nil {
2662+ editorHref = "/" + editor
2663+ }
2664+ }
2665+ page.Title += " .barerepo/config"
2666+ page.Summary = "Repository settings shown as a versioned file in the repository rather than a settings form."
2667+ // Chapter 21.1: copying is the answer to "no forks", so it is offered to a reader, not an owner.
2668+ copyTo, copyHave, copyURL := "", "", ""
2669+ // Copying is what a reader does with somebody else's project, so the owner is not offered it.
2670+ if who := s.viewer(r); who != "" && who != owner {
2671+ if repo.Exists(s.Cfg.Paths.Repos, who, name) {
2672+ copyHave = "/" + who + "/" + name
2673+ } else {
2674+ copyTo = "/" + who + "/" + name
2675+ copyURL = s.cloneURL(who, name)
2676+ }
2677+ }
2678+ s.render(w, r, "repo-config", struct {
2679+ repoPage
2680+ Config string
2681+ // The pieces of the last edit, since a hash and a name each go somewhere.
2682+ Edited string
2683+ Editor string
2684+ EditorHref string
2685+ EditHref string
2686+ EditShort string
2687+ RawHref string
2688+ // The config page is a file view with a fixed path, so it offers a file view's links. 24.
2689+ HistoryHref string
2690+ BlameHref string
2691+ IsOwner bool
2692+ Hooks []hookRow
2693+ Error string
2694+ CopyTo string
2695+ CopyHave string
2696+ CopyURL string
2697+ }{page, body, edited, editor, editorHref,
2698+ "/" + owner + "/" + name + "/commit/" + editSHA, short(editSHA),
2699+ "/" + owner + "/" + name + "/raw/" + fileRef(r.Context(), res.Dir, page.Branch) + "/" + repocfg.Path,
2700+ "/" + owner + "/" + name + "?path=" + url.QueryEscape(repocfg.Path),
2701+ "/" + owner + "/" + name + "/file/" + fileRef(r.Context(), res.Dir, page.Branch) + "/" + repocfg.Path,
2702+ s.viewer(r) == owner, s.hookRows(r, owner+"/"+name, res.Config), configError(res.Warning, errMsg),
2703+ copyTo, copyHave, copyURL})
2704+ }
2705+
2706+ // hrefFor is an account page, or nothing at all when there is no account to point at.
2707+ func hrefFor(account string) string {
2708+ if account == "" {
2709+ return ""
2710+ }
2711+ return "/" + account
2712+ }
2713+
2714+ // configError prefers the file's own parse failure, since a reader of this page is reading that file.
2715+ func configError(warning, errMsg string) string {
2716+ if warning != "" {
2717+ // The terminal reads it over two lines and a page reads it as one sentence.
2718+ return strings.ReplaceAll(warning, "\n", " · ")
2719+ }
2720+ return errMsg
2721+ }
2722+
2723+ // hookRow says what happened to one webhook, because delivery has nowhere else to be reported. 23.4.
2724+ type hookRow struct {
2725+ URL string
2726+ Events string
2727+ State string
2728+ Danger bool
2729+ }
2730+
2731+ // hookRows reads the delivery state of the hooks the file declares, in the order it declares them.
2732+ func (s *Server) hookRows(r *http.Request, name string, cfg repocfg.Config) []hookRow {
2733+ if len(cfg.Webhook) == 0 {
2734+ return nil
2735+ }
2736+ state, err := s.DB.HooksOf(r.Context(), name)
2737+ if err != nil {
2738+ s.log(r, err)
2739+ state = map[string]store.HookState{}
2740+ }
2741+ rows := make([]hookRow, 0, len(cfg.Webhook))
2742+ for _, h := range cfg.Webhook {
2743+ row := hookRow{URL: h.URL, Events: strings.Join(h.Events, ", ")}
2744+ st, known := state[h.URL]
2745+ // A name outside chapter 19.1's list never fires, and the file accepted the line. 23.1.
2746+ if unknown := unknownEvents(h.Events); len(unknown) > 0 {
2747+ row.State = strings.Join(unknown, ", ") + " is not an event barerepo sends, so it never fires"
2748+ if len(unknown) > 1 {
2749+ row.State = strings.Join(unknown, ", ") + " are not events barerepo sends, so they never fire"
2750+ }
2751+ row.Danger = true
2752+ rows = append(rows, row)
2753+ continue
2754+ }
2755+ switch {
2756+ case st.Disabled:
2757+ row.State = "stopped after " + plural(st.Failures, "failure", "failures") +
2758+ " in a row · " + st.LastError
2759+ row.Danger = true
2760+ case st.Failures > 0:
2761+ row.State = plural(st.Failures, "failure", "failures") +
2762+ " since the last delivery · " + st.LastError
2763+ row.Danger = true
2764+ case known:
2765+ row.State = "delivered " + ago(st.LastAt)
2766+ default:
2767+ row.State = "nothing has happened here yet"
2768+ }
2769+ rows = append(rows, row)
2770+ }
2771+ return rows
2772+ }
2773+
2774+ // unknownEvents names what a hook asked for that barerepo will never send.
2775+ func unknownEvents(events []string) []string {
2776+ var out []string
2777+ for _, e := range events {
2778+ if !store.KnownKind(e) {
2779+ out = append(out, e)
2780+ }
2781+ }
2782+ return out
2783+ }
2784+
2785+ // serveRepoMove renames or transfers, which the URL namespace owns, so it is not a git operation.
2786+ func (s *Server) serveRepoMove(w http.ResponseWriter, r *http.Request, owner, name, action string) {
2787+ who, ok := s.requireViewer(w, r)
2788+ if !ok {
2789+ return
2790+ }
2791+ if who != owner {
2792+ s.notFound(w, r)
2793+ return
2794+ }
2795+ newOwner, newName := owner, name
2796+ if action == "rename" {
2797+ newName = strings.TrimSpace(r.FormValue("name"))
2798+ if newName == name {
2799+ http.Redirect(w, r, "/"+owner+"/"+name+"/config", http.StatusFound)
2800+ return
2801+ }
2802+ } else {
2803+ newOwner = strings.TrimSpace(r.FormValue("owner"))
2804+ if strings.TrimSpace(r.FormValue("confirm")) != name {
2805+ s.serveRepoConfig(w, r, owner, name, "type "+name+" to confirm the transfer.")
2806+ return
2807+ }
2808+ }
2809+ // Chapter 44.1 confirms the target before anything moves, and this moved the directory first.
2810+ if newOwner != owner {
2811+ if _, err := s.DB.Account(r.Context(), newOwner); err != nil {
2812+ s.serveRepoConfig(w, r, owner, name, "there is no account named "+newOwner)
2813+ return
2814+ }
2815+ }
2816+ // A transfer is the third door into a namespace, and the other two ask this before opening.
2817+ if why := s.Transport.Claimed(r.Context(), newOwner, newName); why != "" {
2818+ s.serveRepoConfig(w, r, owner, name, why)
2819+ return
2820+ }
2821+ if err := repo.Move(s.Cfg.Paths.Repos, owner, name, newOwner, newName); err != nil {
2822+ s.serveRepoConfig(w, r, owner, name, err.Error())
2823+ return
2824+ }
2825+ if err := s.DB.Move(r.Context(), owner, name, newOwner, newName); err != nil {
2826+ // Put the directory back, so disk and database do not disagree.
2827+ _ = repo.Move(s.Cfg.Paths.Repos, newOwner, newName, owner, name)
2828+ s.serveRepoConfig(w, r, owner, name, err.Error())
2829+ return
2830+ }
2831+ // A transfer changes the owner, and the owner is half of who may read it. Chapter 18.
2832+ s.reindexReadable(r, newOwner, newName)
2833+ // Attached files sit under owner and name, so a move that forgets them orphans every one. 22.2.
2834+ if err := artifact.Move(s.Cfg.Paths.Artifacts, owner, name, newOwner, newName); err != nil {
2835+ s.log(r, err)
2836+ }
2837+ if action != "rename" {
2838+ s.note(r, store.Event{Kind: store.RepoTransferred, Actor: who,
2839+ Repo: newOwner + "/" + newName, Title: "was " + owner + "/" + name}, 0)
2840+ }
2841+ http.Redirect(w, r, "/"+newOwner+"/"+newName+"/config", http.StatusFound)
2842+ }
2843+
2844+ // serveRejected is the page the hook prints a url to, because terminals scroll. Chapter 24.
2845+ func (s *Server) serveRejected(w http.ResponseWriter, r *http.Request, owner, name string) {
2846+ res, page, ok := s.openRepo(w, r, owner, name, "")
2847+ if !ok {
2848+ return
2849+ }
2850+ ref := r.URL.Query().Get("ref")
2851+ if !gitx.ValidRef(ref) {
2852+ s.notFound(w, r)
2853+ return
2854+ }
2855+ // push-rejected.html heads the page with the refused commit and how long ago, which the hook put on the link because a request arriving here knows neither.
2856+ head := ref
2857+ // A delete carries the all-zero id, which names no commit and would head the page with noughts.
2858+ if sha := r.URL.Query().Get("sha"); len(sha) == 40 && strings.Trim(sha, "0") != "" && gitx.ValidRev(sha) {
2859+ head = short(sha)
2860+ if at, err := strconv.ParseInt(r.URL.Query().Get("at"), 10, 64); err == nil && at > 0 {
2861+ if said := spoken(ago(time.Unix(at, 0))); said != "" {
2862+ head += " · " + said
2863+ }
2864+ }
2865+ }
2866+ who := s.viewer(r)
2867+ page.Title += " push rejected"
2868+ page.Summary = "Page explaining exactly which server hook rejected a push and how to proceed."
2869+ s.render(w, r, "push-rejected", struct {
2870+ repoPage
2871+ Ref string
2872+ // Head is the commit and the moment, which the bar shows instead of repeating the ref.
2873+ Head string
2874+ Push string
2875+ You string
2876+ MayPush bool
2877+ }{page, ref, head, repocfg.List(res.Config.Access.Push), repocfg.Who(who),
2878+ res.Config.MayPush(owner, who)})
2879+ }
2880+
2881+ // serveRepoCopy is chapter 21.1, which is taking a project somewhere its maintainer will not go.
2882+ func (s *Server) serveRepoCopy(w http.ResponseWriter, r *http.Request, owner, name string) {
2883+ who, ok := s.requireViewer(w, r)
2884+ if !ok {
2885+ return
2886+ }
2887+ // Read access is the whole permission, because chapter 12 removed asking as a step.
2888+ if _, _, ok := s.openRepoFor(w, r, owner, name, "config"); !ok {
2889+ return
2890+ }
2891+ // A copy makes a repository in your namespace, so it asks what the other doors ask. 21.2 and 44.4.
2892+ if why := s.Transport.Claimed(r.Context(), who, name); why != "" {
2893+ s.serveRepoConfig(w, r, owner, name, why)
2894+ return
2895+ }
2896+ if _, err := repo.Copy(r.Context(), s.Cfg.Paths.Repos, owner, name, who, name, s.Transport.Bin); err != nil {
2897+ s.serveRepoConfig(w, r, owner, name, err.Error())
2898+ return
2899+ }
2900+ if _, err := s.DB.ExecContext(r.Context(),
2901+ `INSERT INTO repos (owner, name, created_at) VALUES (?, ?, ?)`,
2902+ who, name, time.Now().Unix()); err != nil {
2903+ if dir, derr := repo.Dir(s.Cfg.Paths.Repos, who, name); derr == nil {
2904+ os.RemoveAll(dir)
2905+ }
2906+ s.oops(w, r, err)
2907+ return
2908+ }
2909+ s.indexCopy(r, who, name)
2910+ http.Redirect(w, r, "/"+who+"/"+name, http.StatusFound)
2911+ }
2912+
2913+ // reindexReadable rewrites the index's read set from the tree, for a change no push announced.
2914+ func (s *Server) reindexReadable(r *http.Request, owner, name string) {
2915+ res, err := s.Transport.Open(r.Context(), owner, name, owner, transport.Read)
2916+ if err != nil {
2917+ s.log(r, err)
2918+ return
2919+ }
2920+ if err := s.DB.SetReadable(r.Context(), owner+"/"+name,
2921+ res.Config.Public(), store.Readers(owner, res.Config.Access.Push)); err != nil {
2922+ s.log(r, err)
2923+ }
2924+ }
2925+
2926+ // indexCopy gives a fresh copy its own documents, since the index is keyed by repository.
2927+ func (s *Server) indexCopy(r *http.Request, owner, name string) {
2928+ res, err := s.Transport.Open(r.Context(), owner, name, owner, transport.Read)
2929+ if err != nil {
2930+ s.log(r, err)
2931+ return
2932+ }
2933+ branch, err := repo.HeadBranch(r.Context(), res.Dir)
2934+ if err != nil {
2935+ return
2936+ }
2937+ t := search.Target{Owner: owner, Name: name, Dir: res.Dir, Ref: branch, Config: res.Config}
2938+ if err := search.IndexAll(r.Context(), s.DB, t); err != nil {
2939+ s.log(r, err)
2940+ }
2941+ }
2942+
2943+ // serveRepoDelete stops serving a repository and starts its window.
2944+ func (s *Server) serveRepoDelete(w http.ResponseWriter, r *http.Request, owner, name string) {
2945+ who, ok := s.requireViewer(w, r)
2946+ if !ok {
2947+ return
2948+ }
2949+ if who != owner {
2950+ s.notFound(w, r)
2951+ return
2952+ }
2953+ if strings.TrimSpace(r.FormValue("confirm")) != name {
2954+ s.serveRepoConfig(w, r, owner, name, "type "+name+" to confirm the delete.")
2955+ return
2956+ }
2957+ // A copy on alternates borrows these objects, so give it its own or the delete takes them.
2958+ if dir, err := repo.Dir(s.Cfg.Paths.Repos, owner, name); err == nil {
2959+ for _, dependent := range repo.Dependents(s.Cfg.Paths.Repos, dir) {
2960+ if err := repo.Detach(r.Context(), dependent); err != nil {
2961+ s.serveRepoConfig(w, r, owner, name,
2962+ "a copy of this repository still borrows its objects and could not be detached: "+err.Error())
2963+ return
2964+ }
2965+ }
2966+ }
2967+ if _, err := repo.Trash(s.Cfg.Paths.Repos, owner, name, time.Now()); err != nil {
2968+ s.serveRepoConfig(w, r, owner, name, err.Error())
2969+ return
2970+ }
2971+ if err := s.DB.Forget(r.Context(), owner, name); err != nil {
2972+ s.log(r, err)
2973+ }
2974+ // Attached files are not in git, so nothing else takes them when the repository goes. 22.2.
2975+ if err := artifact.Forget(s.Cfg.Paths.Artifacts, owner, name); err != nil {
2976+ s.log(r, err)
2977+ }
2978+ http.Redirect(w, r, "/"+owner, http.StatusFound)
2979+ }
@@ -0,0 +1,177 @@
1+ package httpd
2+
3+ import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "slices"
8+ "strings"
9+ "sync"
10+ "time"
11+
12+ "github.com/barerepo/server/internal/repo"
13+ "github.com/barerepo/server/internal/repocfg"
14+ "github.com/barerepo/server/internal/store"
15+ "github.com/barerepo/server/internal/webhook"
16+ )
17+
18+ // hookBatch bounds one pass, so a quiet server catches up and a busy one does not stall on it.
19+ const hookBatch = 100
20+
21+ // HookTick is how often the sender looks, which is the delay a receiver sees on a quiet server.
22+ const HookTick = 15 * time.Second
23+
24+ // hookPayload is the body a receiver gets, with the same names the event log uses.
25+ type hookPayload struct {
26+ Event string `json:"event"`
27+ Repo string `json:"repo"`
28+ URL string `json:"url"`
29+ Actor string `json:"actor"`
30+ Ref string `json:"ref,omitempty"`
31+ Number int `json:"number,omitempty"`
32+ Title string `json:"title,omitempty"`
33+ At string `json:"at"`
34+ }
35+
36+ // DeliverHooks sends every event since the cursor to the hooks that named it. Chapter 23.
37+ func (s *Server) DeliverHooks(ctx context.Context) {
38+ from, err := s.DB.WebhookCursor(ctx)
39+ if err != nil {
40+ s.warn("webhooks: cursor", "err", err)
41+ return
42+ }
43+ events, err := s.DB.EventsAfter(ctx, from, hookBatch)
44+ if err != nil {
45+ s.warn("webhooks: events", "err", err)
46+ return
47+ }
48+ for _, e := range events {
49+ s.deliverEvent(ctx, e)
50+ if err := s.DB.SetWebhookCursor(ctx, e.ID); err != nil {
51+ s.warn("webhooks: cursor", "err", err)
52+ return
53+ }
54+ }
55+ }
56+
57+ // warn logs when there is a logger, because the sender is a goroutine that must not panic.
58+ func (s *Server) warn(msg string, args ...any) {
59+ if s.Log == nil {
60+ return
61+ }
62+ s.Log.Error(msg, args...)
63+ }
64+
65+ // splitRepo takes owner/name apart, which is how the event log writes a repository.
66+ func splitRepo(full string) (owner, name string, ok bool) {
67+ owner, name, ok = strings.Cut(full, "/")
68+ return owner, name, ok && owner != "" && name != ""
69+ }
70+
71+ // deliverEvent posts one event to every hook of its repository that asked for the kind.
72+ func (s *Server) deliverEvent(ctx context.Context, e store.Event) {
73+ owner, name, ok := splitRepo(e.Repo)
74+ if !ok {
75+ return
76+ }
77+ dir, err := repo.Dir(s.Cfg.Paths.Repos, owner, name)
78+ if err != nil {
79+ return
80+ }
81+ // The last version that parsed still names the hooks, so a typo does not silently stop them.
82+ cfg, _ := repocfg.Load(ctx, dir)
83+ hooks := hooksFor(cfg, e.Kind)
84+ if len(hooks) == 0 {
85+ return
86+ }
87+ state, err := s.DB.HooksOf(ctx, e.Repo)
88+ if err != nil {
89+ s.warn("webhooks: state", "repo", e.Repo, "err", err)
90+ return
91+ }
92+ payload := hookPayload{
93+ Event: e.Kind,
94+ Repo: e.Repo,
95+ URL: s.Cfg.Server.ExternalURL + "/" + e.Repo,
96+ Actor: e.Actor, Ref: e.Ref, Number: e.Number, Title: e.Title,
97+ At: e.Created.UTC().Format(time.RFC3339),
98+ }
99+ // One event's hooks are independent, and a slow receiver must not hold up the others.
100+ var wg sync.WaitGroup
101+ for _, h := range hooks {
102+ if state[h.URL].Disabled {
103+ continue
104+ }
105+ wg.Add(1)
106+ go func() {
107+ defer wg.Done()
108+ s.deliverOne(ctx, e.Repo, h, payload, state[h.URL].Failures)
109+ }()
110+ }
111+ wg.Wait()
112+ }
113+
114+ // deliverOne posts to one hook and records what happened, since the config page is the only report.
115+ func (s *Server) deliverOne(ctx context.Context, name string, h repocfg.Webhook, payload hookPayload, failures int) {
116+ // A hook that is already failing gets one try, or twenty events take twenty backoffs each.
117+ attempts := webhook.Attempts
118+ if failures > 0 {
119+ attempts = 1
120+ }
121+ secret, err := hookSecret(h)
122+ if err == nil {
123+ err = webhook.Deliver(ctx, h.URL, secret, payload, attempts)
124+ }
125+ if err != nil {
126+ s.warn("webhook failed", "repo", name, "url", h.URL, "err", err)
127+ if err := s.DB.HookFailed(ctx, name, h.URL, err.Error()); err != nil {
128+ s.warn("webhooks: state", "err", err)
129+ }
130+ return
131+ }
132+ if err := s.DB.HookDelivered(ctx, name, h.URL); err != nil {
133+ s.warn("webhooks: state", "err", err)
134+ }
135+ }
136+
137+ // hookSecret reads the named value from the server's environment, because the file holds a name. 23.2.
138+ func hookSecret(h repocfg.Webhook) (string, error) {
139+ if h.SecretEnv == "" {
140+ return "", nil
141+ }
142+ v := os.Getenv(h.SecretEnv)
143+ if v == "" {
144+ return "", fmt.Errorf("%s is not set on this server, so nothing would sign the body", h.SecretEnv)
145+ }
146+ return v, nil
147+ }
148+
149+ // hooksFor is the events filter, and a hook naming no event asked for nothing.
150+ func hooksFor(cfg repocfg.Config, kind string) []repocfg.Webhook {
151+ var out []repocfg.Webhook
152+ for _, h := range cfg.Webhook {
153+ if h.URL == "" || !slices.Contains(h.Events, kind) {
154+ continue
155+ }
156+ out = append(out, h)
157+ }
158+ return out
159+ }
160+
161+ // DeliverEvery runs the sender until the context ends.
162+ func (s *Server) DeliverEvery(ctx context.Context, every time.Duration) {
163+ if err := s.DB.StartWebhooksHere(ctx); err != nil {
164+ s.warn("webhooks: start", "err", err)
165+ return
166+ }
167+ t := time.NewTicker(every)
168+ defer t.Stop()
169+ for {
170+ select {
171+ case <-ctx.Done():
172+ return
173+ case <-t.C:
174+ s.DeliverHooks(ctx)
175+ }
176+ }
177+ }
@@ -0,0 +1,120 @@
1+ package httpd
2+
3+ import (
4+ "testing"
5+
6+ "github.com/barerepo/server/internal/repocfg"
7+ "github.com/barerepo/server/internal/store"
8+ )
9+
10+ // A hook gets the events it named and no others, because chapter 23.2 makes the list the filter.
11+ func TestAHookOnlyGetsTheEventsItNamed(t *testing.T) {
12+ cfg := repocfg.Config{Webhook: []repocfg.Webhook{
13+ {URL: "https://deploy.example/hook", Events: []string{"push"}},
14+ {URL: "https://chat.example/hook", Events: []string{"proposal.opened", "thread.opened"}},
15+ {URL: "https://nothing.example/hook"},
16+ }}
17+ cases := []struct {
18+ kind string
19+ want []string
20+ }{
21+ {store.Pushed, []string{"https://deploy.example/hook"}},
22+ {store.ProposalOpened, []string{"https://chat.example/hook"}},
23+ {store.ThreadOpened, []string{"https://chat.example/hook"}},
24+ {store.RunFailed, nil},
25+ }
26+ for _, c := range cases {
27+ var got []string
28+ for _, h := range hooksFor(cfg, c.kind) {
29+ got = append(got, h.URL)
30+ }
31+ if len(got) != len(c.want) {
32+ t.Errorf("%s went to %v, wanted %v", c.kind, got, c.want)
33+ continue
34+ }
35+ for i := range got {
36+ if got[i] != c.want[i] {
37+ t.Errorf("%s went to %v, wanted %v", c.kind, got, c.want)
38+ break
39+ }
40+ }
41+ }
42+ }
43+
44+ // A url with no scheme cannot be delivered to, and a hook with no url is a line somebody started.
45+ func TestAHookWithNoURLIsNotDelivered(t *testing.T) {
46+ cfg := repocfg.Config{Webhook: []repocfg.Webhook{{Events: []string{"push"}}}}
47+ if got := hooksFor(cfg, store.Pushed); len(got) != 0 {
48+ t.Errorf("a hook with no url was selected: %v", got)
49+ }
50+ }
51+
52+ // The file names a secret and the server holds the value, so a missing value must say so. 23.2.
53+ func TestAMissingSecretIsAnErrorAndNotAnUnsignedDelivery(t *testing.T) {
54+ _, err := hookSecret(repocfg.Webhook{URL: "https://x.example", SecretEnv: "BAREREPO_TEST_ABSENT"})
55+ if err == nil {
56+ t.Fatal("a named secret that is not set delivered anyway, unsigned")
57+ }
58+
59+ t.Setenv("BAREREPO_TEST_PRESENT", "swordfish")
60+ got, err := hookSecret(repocfg.Webhook{URL: "https://x.example", SecretEnv: "BAREREPO_TEST_PRESENT"})
61+ if err != nil {
62+ t.Fatal(err)
63+ }
64+ if got != "swordfish" {
65+ t.Errorf("the secret read as %q", got)
66+ }
67+
68+ // No secret named is a choice, not a mistake, so it delivers unsigned.
69+ got, err = hookSecret(repocfg.Webhook{URL: "https://x.example"})
70+ if err != nil || got != "" {
71+ t.Errorf("an unnamed secret gave %q, %v", got, err)
72+ }
73+ }
74+
75+ func TestSplitRepo(t *testing.T) {
76+ cases := []struct {
77+ in string
78+ owner, name string
79+ ok bool
80+ }{
81+ {"john/johnbot", "john", "johnbot", true},
82+ {"john", "", "", false},
83+ {"/johnbot", "", "", false},
84+ {"john/", "", "", false},
85+ }
86+ for _, c := range cases {
87+ owner, name, ok := splitRepo(c.in)
88+ if ok != c.ok || (ok && (owner != c.owner || name != c.name)) {
89+ t.Errorf("%q gave %q %q %v", c.in, owner, name, ok)
90+ }
91+ }
92+ }
93+
94+ // Chapter 23.1 calls a webhook the escape hatch, and a file that accepts a name nothing sends is not one.
95+ func TestAHookNamingAnEventBarerepoNeverSendsSaysSo(t *testing.T) {
96+ got := unknownEvents([]string{"push", "proposal.opened"})
97+ if len(got) != 0 {
98+ t.Errorf("real event names were reported as unknown: %v", got)
99+ }
100+ // The near misses are the ones that matter, because a reader's eye passes over them.
101+ got = unknownEvents([]string{"push", "proposal.open", "run.succeeded", "thread.reply"})
102+ want := []string{"proposal.open", "run.succeeded", "thread.reply"}
103+ if len(got) != len(want) {
104+ t.Fatalf("got %v, wanted %v", got, want)
105+ }
106+ for i := range want {
107+ if got[i] != want[i] {
108+ t.Fatalf("got %v, wanted %v", got, want)
109+ }
110+ }
111+ // run.succeeded is the sharpest of them: chapter 19.1 names it only to say it is not an event.
112+ if store.KnownKind("run.succeeded") {
113+ t.Error("run.succeeded is an event kind, and chapter 19.1 says a green build is not news")
114+ }
115+ for _, k := range store.Kinds {
116+ if !store.KnownKind(k) {
117+ t.Errorf("%s is in the list and not known by the check that reads it", k)
118+ }
119+ }
120+ }
@@ -0,0 +1,218 @@
1+ // Package markup renders markdown then sanitizes it, and never trusts the renderer. Chapter 42.
2+ package markup
3+
4+ import (
5+ "bytes"
6+ "html"
7+ "io"
8+ "strconv"
9+ "strings"
10+
11+ "github.com/yuin/goldmark"
12+ "github.com/yuin/goldmark/extension"
13+ xhtml "golang.org/x/net/html"
14+ "golang.org/x/net/html/atom"
15+ )
16+
17+ // allowed is chapter 42.1's list, and an allowlist, so it strips elements not yet invented.
18+ var allowed = map[atom.Atom]bool{
19+ atom.P: true, atom.Br: true, atom.Strong: true, atom.Em: true, atom.Del: true,
20+ atom.Code: true, atom.Pre: true, atom.Blockquote: true,
21+ atom.H1: true, atom.H2: true, atom.H3: true, atom.H4: true, atom.H5: true, atom.H6: true,
22+ atom.Ul: true, atom.Ol: true, atom.Li: true,
23+ atom.A: true, atom.Img: true,
24+ atom.Table: true, atom.Thead: true, atom.Tbody: true, atom.Tr: true,
25+ atom.Th: true, atom.Td: true,
26+ atom.Hr: true,
27+ }
28+
29+ // allowedAttrs is the complete attribute list from chapter 42.1.
30+ var allowedAttrs = map[atom.Atom]map[string]bool{
31+ atom.A: {"href": true, "title": true},
32+ atom.Img: {"src": true, "alt": true, "title": true},
33+ atom.Code: {"class": true}, // language-* only, checked below
34+ atom.Ol: {"start": true}, // digits only, checked below
35+ }
36+
37+ // dropWhole takes the contents too, or a stripped <script> leaves its source on the page.
38+ var dropWhole = map[atom.Atom]bool{
39+ atom.Script: true, atom.Style: true, atom.Iframe: true, atom.Object: true,
40+ atom.Embed: true, atom.Form: true, atom.Svg: true, atom.Math: true,
41+ atom.Template: true, atom.Noscript: true,
42+ }
43+
44+ // void elements have no closing tag.
45+ var void = map[atom.Atom]bool{atom.Br: true, atom.Img: true, atom.Hr: true}
46+
47+ // Render turns a comment body into HTML that is safe to put on a page.
48+ func Render(markdown string) string {
49+ var buf bytes.Buffer
50+ // GFM adds tables and strikethrough, both allowed, and raw html stays off by default.
51+ md := goldmark.New(goldmark.WithExtensions(extension.GFM))
52+ if err := md.Convert([]byte(markdown), &buf); err != nil {
53+ // A body that will not render is shown as the text it is, escaped.
54+ return "<p>" + html.EscapeString(markdown) + "</p>"
55+ }
56+ return Sanitize(buf.String())
57+ }
58+
59+ // Sanitize strips everything chapter 42.1 does not allow.
60+ func Sanitize(unsafe string) string {
61+ var out strings.Builder
62+ z := xhtml.NewTokenizer(strings.NewReader(unsafe))
63+ // skipped holds the tags being dropped whole, so the matching end tag is the one that resumes.
64+ var skipped []atom.Atom
65+ var open []atom.Atom
66+
67+ for {
68+ switch z.Next() {
69+ case xhtml.ErrorToken:
70+ if z.Err() != io.EOF {
71+ return "" // a body that will not parse is not shown at all
72+ }
73+ // Close anything the input left open.
74+ for i := len(open) - 1; i >= 0; i-- {
75+ out.WriteString("</" + open[i].String() + ">")
76+ }
77+ return out.String()
78+
79+ case xhtml.TextToken:
80+ if len(skipped) == 0 {
81+ out.WriteString(html.EscapeString(string(z.Text())))
82+ }
83+
84+ case xhtml.StartTagToken:
85+ name, hasAttr := z.TagName()
86+ a := atom.Lookup(name)
87+ if len(skipped) > 0 || dropWhole[a] {
88+ if !void[a] {
89+ skipped = append(skipped, a)
90+ }
91+ continue
92+ }
93+ if !allowed[a] {
94+ continue // strip the tag, keep what is inside it
95+ }
96+ if writeTag(&out, z, a, hasAttr) && !void[a] {
97+ open = append(open, a)
98+ }
99+
100+ case xhtml.SelfClosingTagToken:
101+ name, hasAttr := z.TagName()
102+ a := atom.Lookup(name)
103+ if len(skipped) > 0 || dropWhole[a] || !allowed[a] {
104+ continue
105+ }
106+ writeTag(&out, z, a, hasAttr)
107+
108+ case xhtml.EndTagToken:
109+ name, _ := z.TagName()
110+ a := atom.Lookup(name)
111+ if len(skipped) > 0 {
112+ // Unwind to the tag this closes, so what it left open is dropped with it.
113+ for i := len(skipped) - 1; i >= 0; i-- {
114+ if skipped[i] == a {
115+ skipped = skipped[:i]
116+ break
117+ }
118+ }
119+ continue
120+ }
121+ if !allowed[a] || void[a] {
122+ continue
123+ }
124+ // Only close a tag this sanitizer actually opened.
125+ for i := len(open) - 1; i >= 0; i-- {
126+ if open[i] == a {
127+ out.WriteString("</" + a.String() + ">")
128+ open = append(open[:i], open[i+1:]...)
129+ break
130+ }
131+ }
132+ }
133+ }
134+ }
135+
136+ // blockedImage is chapter 42.2's "say so in the UI", because a silent gap looks like a broken page.
137+ const blockedImage = `<span class="blocked">remote image blocked, it would tell its host who read this</span>`
138+
139+ // writeTag emits one start tag, and reports whether it wrote one, since a blocked image writes none.
140+ func writeTag(out *strings.Builder, z *xhtml.Tokenizer, a atom.Atom, hasAttr bool) bool {
141+ permitted := allowedAttrs[a]
142+ external, blocked := false, false
143+ var attrs strings.Builder
144+ for hasAttr {
145+ var k, v []byte
146+ k, v, hasAttr = z.TagAttr()
147+ key := strings.ToLower(string(k))
148+ value := string(v)
149+
150+ // on* and style are on no allowlist, and this strips them from everything, explicitly.
151+ if strings.HasPrefix(key, "on") || key == "style" || !permitted[key] {
152+ continue
153+ }
154+ switch {
155+ case key == "href" || key == "src":
156+ clean, ok := safeURL(value)
157+ if !ok {
158+ continue
159+ }
160+ if a == atom.Img && isRemote(clean) {
161+ // A remote image leaks the reader's address to whoever wrote the comment. 42.2.
162+ blocked = true
163+ continue
164+ }
165+ if isRemote(clean) {
166+ external = true
167+ }
168+ value = clean
169+ case key == "start" && a == atom.Ol:
170+ // A list that starts at six says six, and digits are the whole of what start may hold.
171+ if _, err := strconv.Atoi(value); err != nil {
172+ continue
173+ }
174+ case key == "class" && a == atom.Code:
175+ if !strings.HasPrefix(value, "language-") {
176+ continue
177+ }
178+ }
179+ attrs.WriteString(" " + key + `="` + html.EscapeString(value) + `"`)
180+ }
181+ if blocked {
182+ out.WriteString(blockedImage)
183+ return false
184+ }
185+ out.WriteString("<" + a.String() + attrs.String())
186+ if a == atom.A && external {
187+ out.WriteString(` rel="nofollow noopener noreferrer"`)
188+ }
189+ out.WriteString(">")
190+ return true
191+ }
192+
193+ // safeURL allows http, https and mailto, checked after decoding, since a browser decodes too.
194+ func safeURL(raw string) (string, bool) {
195+ decoded := html.UnescapeString(raw)
196+ trimmed := strings.TrimLeft(decoded, " \t\r\n\x00\v\f")
197+ lower := strings.ToLower(trimmed)
198+
199+ // A scheme is what precedes the first colon, if it comes before any slash, question or hash.
200+ if i := strings.IndexAny(lower, ":/?#"); i >= 0 && lower[i] == ':' {
201+ scheme := lower[:i]
202+ switch scheme {
203+ case "http", "https", "mailto":
204+ default:
205+ return "", false
206+ }
207+ }
208+ if strings.ContainsAny(trimmed, "\x00\n\r") {
209+ return "", false
210+ }
211+ return trimmed, true
212+ }
213+
214+ func isRemote(u string) bool {
215+ l := strings.ToLower(u)
216+ return strings.HasPrefix(l, "http://") || strings.HasPrefix(l, "https://") ||
217+ strings.HasPrefix(l, "//")
218+ }
@@ -0,0 +1,168 @@
1+ package markup
2+
3+ import (
4+ "strings"
5+ "testing"
6+ )
7+
8+ // Chapter 45.4's attacks, each a permanent test: no tag survives, though inert words may.
9+ func TestChapter45Attacks(t *testing.T) {
10+ cases := []struct {
11+ name, body string
12+ forbidden []string
13+ }{
14+ {"script tag", `<script>alert(1)</script>`, []string{"<script"}},
15+ {"script inline in a paragraph", `hello <script>alert(1)</script> there`, []string{"<script"}},
16+ {"script split across lines", "a\n<script>\nalert(1)\n</script>\nb", []string{"<script"}},
17+ {"javascript link", `[x](javascript:alert(1))`, []string{"javascript:"}},
18+ {"image onerror", `<img src=x onerror=alert(1)>`, []string{"onerror", "alert(1)"}},
19+ {"encoded javascript", `<a href="java&#115;cript:alert(1)">x</a>`, []string{"javascript:", "java&#115;cript"}},
20+ {"data url", `[x](data:text/html;base64,PHNjcmlwdD4=)`, []string{"data:"}},
21+ {"vbscript", `[x](vbscript:msgbox(1))`, []string{"vbscript:"}},
22+ {"file url", `[x](file:///etc/passwd)`, []string{"file:"}},
23+ {"iframe", `<iframe src="https://evil.example"></iframe>`, []string{"<iframe", "evil.example"}},
24+ {"form", `<form action="/x"><input name="p"></form>`, []string{"<form", "<input"}},
25+ {"svg", `<svg onload="alert(1)"><circle /></svg>`, []string{"<svg", "onload"}},
26+ {"style attribute", `<p style="position:fixed;top:0">x</p>`, []string{"style="}},
27+ {"style element", `<style>body{display:none}</style>`, []string{"<style", "display:none"}},
28+ {"object", `<object data="evil.swf"></object>`, []string{"<object"}},
29+ {"embed", `<embed src="evil.swf">`, []string{"<embed"}},
30+ {"leading space scheme", `<a href=" javascript:alert(1)">x</a>`, []string{"javascript:"}},
31+ {"upper case scheme", `<a href="JaVaScRiPt:alert(1)">x</a>`, []string{"avascript:", "alert(1)"}},
32+ {"null byte scheme", "<a href=\"java\x00script:alert(1)\">x</a>", []string{"script:alert"}},
33+ {"meta refresh", `<meta http-equiv="refresh" content="0;url=https://evil.example">`, []string{"<meta", "refresh"}},
34+ {"base tag", `<base href="https://evil.example/">`, []string{"<base"}},
35+ }
36+ for _, c := range cases {
37+ got := strings.ToLower(Render(c.body))
38+ for _, bad := range c.forbidden {
39+ if strings.Contains(got, strings.ToLower(bad)) {
40+ t.Errorf("%s: output still contains %q\n %s", c.name, bad, got)
41+ }
42+ }
43+ }
44+ }
45+
46+ // What survives matters too, because a sanitizer that strips everything is safe and useless.
47+ func TestOrdinaryMarkdownSurvives(t *testing.T) {
48+ body := "# heading\n\nsome **bold** and *italic* and `code`.\n\n" +
49+ "- one\n- two\n\n> quoted\n\n```go\nfunc main() {}\n```\n\n" +
50+ "[forge](https://barerepo.example) and a table:\n\n" +
51+ "| a | b |\n|---|---|\n| 1 | 2 |\n"
52+ got := Render(body)
53+ for _, want := range []string{
54+ "<h1>heading</h1>", "<strong>bold</strong>", "<em>italic</em>",
55+ "<code>code</code>", "<ul>", "<li>one</li>", "<blockquote>",
56+ "<pre>", "func main()", "<table>", "<td>1</td>",
57+ `<a href="https://barerepo.example"`,
58+ } {
59+ if !strings.Contains(got, want) {
60+ t.Errorf("ordinary markdown lost %q\n %s", want, got)
61+ }
62+ }
63+ }
64+
65+ // Chapter 42.2: mark every external link, so no comment passes a referrer or window handle.
66+ func TestExternalLinksAreMarked(t *testing.T) {
67+ got := Render(`[x](https://elsewhere.example)`)
68+ if !strings.Contains(got, `rel="nofollow noopener noreferrer"`) {
69+ t.Errorf("an external link has no rel attribute: %s", got)
70+ }
71+ local := Render(`[x](/john/johnbot)`)
72+ if strings.Contains(local, "nofollow") {
73+ t.Errorf("a link inside the site was marked external: %s", local)
74+ }
75+ }
76+
77+ // Chapter 42.2: a remote image leaks the reader's address, and blocking is simpler and honest.
78+ func TestRemoteImagesAreBlocked(t *testing.T) {
79+ got := Render(`![x](https://tracker.example/pixel.gif)`)
80+ if strings.Contains(got, "tracker.example") {
81+ t.Errorf("a remote image survived: %s", got)
82+ }
83+ if !strings.Contains(got, "<img") {
84+ return // dropping the whole tag is also an acceptable answer
85+ }
86+ }
87+
88+ // A language class on a code block is the one class allowed, in chapter 42.1's form only.
89+ func TestCodeClass(t *testing.T) {
90+ if got := Sanitize(`<code class="language-go">x</code>`); !strings.Contains(got, `class="language-go"`) {
91+ t.Errorf("a language class was stripped: %s", got)
92+ }
93+ if got := Sanitize(`<code class="anything-else">x</code>`); strings.Contains(got, "class") {
94+ t.Errorf("an arbitrary class survived: %s", got)
95+ }
96+ }
97+
98+ // Text inside a stripped element stays out, or a script's body lands in front of the reader.
99+ func TestDroppedElementsTakeTheirContents(t *testing.T) {
100+ got := Sanitize(`<script>var secret = 1;</script><p>kept</p>`)
101+ if strings.Contains(got, "secret") {
102+ t.Errorf("the body of a script survived: %s", got)
103+ }
104+ if !strings.Contains(got, "kept") {
105+ t.Errorf("the text after it was lost: %s", got)
106+ }
107+ }
108+
109+ // A tag never opened must not close, or a comment escapes its box into the page's layout.
110+ func TestUnbalancedTagsCannotEscape(t *testing.T) {
111+ got := Sanitize(`</div></body><p>hi</p>`)
112+ for _, bad := range []string{"</div>", "</body>"} {
113+ if strings.Contains(got, bad) {
114+ t.Errorf("a stray %q survived: %s", bad, got)
115+ }
116+ }
117+ open := strings.Count(Sanitize("<p>a<p>b"), "<p>")
118+ closed := strings.Count(Sanitize("<p>a<p>b"), "</p>")
119+ if open != closed {
120+ t.Errorf("unbalanced output: %d open, %d closed", open, closed)
121+ }
122+ }
123+
124+ // Prose that mentions a tag keeps its sentence: the tag goes, the words stay, backticks show markup.
125+ func TestProseAroundTagsSurvives(t *testing.T) {
126+ got := Render("use <div> for that")
127+ if !strings.Contains(got, "use") || !strings.Contains(got, "for that") {
128+ t.Errorf("the sentence was lost: %s", got)
129+ }
130+ // Backticks are the way to show a tag, and they work.
131+ code := Render("use `<div>` for that")
132+ if !strings.Contains(code, "&lt;div&gt;") {
133+ t.Errorf("a tag in backticks did not survive: %s", code)
134+ }
135+ }
136+
137+ // Chapter 42.2 blocks a remote image and says so, because a silent gap reads as a broken page.
138+ func TestARemoteImageIsBlockedOutLoud(t *testing.T) {
139+ got := Render("![a cat](https://example.com/cat.png)")
140+ if strings.Contains(got, "example.com") {
141+ t.Errorf("a remote image reached the page, so the reader's address goes with it:\n%s", got)
142+ }
143+ if !strings.Contains(got, "remote image blocked") {
144+ t.Errorf("the page does not say the image was blocked:\n%s", got)
145+ }
146+ if strings.Contains(got, "<img") {
147+ t.Errorf("a source-less img was left behind, which draws as broken:\n%s", got)
148+ }
149+
150+ // A local image is barerepo's own bytes and stays.
151+ local := Render("![a diagram](/john/johnbot/raw/master/doc.png)")
152+ if !strings.Contains(local, "<img") || strings.Contains(local, "remote image blocked") {
153+ t.Errorf("a local image was blocked:\n%s", local)
154+ }
155+ }
156+
157+ func TestOrderedListKeepsItsStart(t *testing.T) {
158+ got := Render("6. six\n7. seven\n")
159+ if !strings.Contains(got, `start="6"`) {
160+ t.Fatalf("start was dropped: %s", got)
161+ }
162+ if bad := Render("1. one\n"); strings.Contains(bad, "start=") {
163+ t.Fatalf("a list starting at one should not carry start: %s", bad)
164+ }
165+ if evil := Render(`<ol start="x() javascript:"><li>a</li></ol>`); strings.Contains(evil, "start=") {
166+ t.Fatalf("a non numeric start should be dropped: %s", evil)
167+ }
168+ }
@@ -0,0 +1,77 @@
1+ // Package pktline is git's framing: four hex digits of length, 0000 flush, 0001 delimiter.
2+ package pktline
3+
4+ import (
5+ "bufio"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "strconv"
10+ "strings"
11+ )
12+
13+ const (
14+ flush = "0000"
15+ delim = "0001"
16+ )
17+
18+ // ErrFlush is returned by Read when it reads a flush packet.
19+ var ErrFlush = errors.New("flush")
20+
21+ // ErrDelim is returned by Read when it reads a delimiter packet.
22+ var ErrDelim = errors.New("delim")
23+
24+ type Reader struct{ r *bufio.Reader }
25+
26+ func NewReader(r io.Reader) *Reader { return &Reader{bufio.NewReader(r)} }
27+
28+ // Read returns the next packet's payload, with any trailing newline removed.
29+ func (p *Reader) Read() (string, error) {
30+ var head [4]byte
31+ if _, err := io.ReadFull(p.r, head[:]); err != nil {
32+ return "", err
33+ }
34+ n, err := strconv.ParseUint(string(head[:]), 16, 32)
35+ if err != nil {
36+ return "", fmt.Errorf("malformed pkt-line length %q", head)
37+ }
38+ switch n {
39+ case 0:
40+ return "", ErrFlush
41+ case 1:
42+ return "", ErrDelim
43+ }
44+ if n < 4 {
45+ return "", fmt.Errorf("impossible pkt-line length %d", n)
46+ }
47+ body := make([]byte, n-4)
48+ if _, err := io.ReadFull(p.r, body); err != nil {
49+ return "", err
50+ }
51+ return strings.TrimSuffix(string(body), "\n"), nil
52+ }
53+
54+ type Writer struct{ w io.Writer }
55+
56+ func NewWriter(w io.Writer) *Writer { return &Writer{w} }
57+
58+ // Write sends one packet with a newline, which git expects for these text protocols.
59+ func (p *Writer) Write(s string) error {
60+ if len(s)+5 > 0xffff {
61+ return errors.New("pkt-line too long")
62+ }
63+ _, err := fmt.Fprintf(p.w, "%04x%s\n", len(s)+5, s)
64+ return err
65+ }
66+
67+ // Flush sends a flush packet, which ends a section.
68+ func (p *Writer) Flush() error {
69+ _, err := io.WriteString(p.w, flush)
70+ return err
71+ }
72+
73+ // Delim sends a delimiter packet.
74+ func (p *Writer) Delim() error {
75+ _, err := io.WriteString(p.w, delim)
76+ return err
77+ }
@@ -0,0 +1,48 @@
1+ package pktline
2+
3+ import (
4+ "bytes"
5+ "errors"
6+ "strings"
7+ "testing"
8+ )
9+
10+ func TestRoundTrip(t *testing.T) {
11+ var buf bytes.Buffer
12+ w := NewWriter(&buf)
13+ for _, s := range []string{"version=1", "ok refs/proposals/47", strings.Repeat("x", 1000)} {
14+ if err := w.Write(s); err != nil {
15+ t.Fatal(err)
16+ }
17+ }
18+ if err := w.Flush(); err != nil {
19+ t.Fatal(err)
20+ }
21+ r := NewReader(&buf)
22+ for _, want := range []string{"version=1", "ok refs/proposals/47", strings.Repeat("x", 1000)} {
23+ got, err := r.Read()
24+ if err != nil || got != want {
25+ t.Fatalf("Read = %q, %v; want %q", got, err, want)
26+ }
27+ }
28+ if _, err := r.Read(); !errors.Is(err, ErrFlush) {
29+ t.Errorf("expected a flush packet, got %v", err)
30+ }
31+ }
32+
33+ // The exact framing matters: git is on the other end of it.
34+ func TestFraming(t *testing.T) {
35+ var buf bytes.Buffer
36+ NewWriter(&buf).Write("a")
37+ if got := buf.String(); got != "0006a\n" {
38+ t.Errorf("wrote %q, want %q", got, "0006a\n")
39+ }
40+ }
41+
42+ func TestReadRejectsGarbage(t *testing.T) {
43+ for _, in := range []string{"", "zzzz", "0003", "0010short"} {
44+ if _, err := NewReader(strings.NewReader(in)).Read(); err == nil {
45+ t.Errorf("Read(%q) returned no error", in)
46+ }
47+ }
48+ }
@@ -0,0 +1,131 @@
1+ // Package proposal is chapter 12: a change pushed to a ref, numbered by the repository itself.
2+ package proposal
3+
4+ import (
5+ "context"
6+ "fmt"
7+ "strconv"
8+ "strings"
9+
10+ "github.com/barerepo/server/internal/gitx"
11+ )
12+
13+ // CounterRef is a ref, so update-ref makes allocation atomic and the number travels with the repo.
14+ const CounterRef = "refs/meta/counter"
15+
16+ // maxRetries bounds the swap, where contention is two simultaneous pushes, not a herd.
17+ const maxRetries = 20
18+
19+ // Allocate returns the next number, shared by proposals and threads, which are one object.
20+ func Allocate(ctx context.Context, dir string) (int, error) {
21+ for attempt := 0; attempt < maxRetries; attempt++ {
22+ oldBlob, current, err := readCounter(ctx, dir)
23+ if err != nil {
24+ return 0, err
25+ }
26+ // Skip what is taken, because chapter 35.5 lets a push leave the counter behind.
27+ next := current + 1
28+ for taken(ctx, dir, next) {
29+ next++
30+ }
31+
32+ newBlob, err := gitx.RunStdin(ctx, dir, strconv.Itoa(next)+"\n", "hash-object", "-w", "--stdin")
33+ if err != nil {
34+ return 0, err
35+ }
36+ newBlob = strings.TrimSpace(newBlob)
37+
38+ args := []string{"update-ref", CounterRef, newBlob}
39+ // An empty old value means the ref must not exist, which makes the first allocation safe.
40+ args = append(args, oldBlob)
41+ if _, err := gitx.Run(ctx, dir, args...); err == nil {
42+ return next, nil
43+ }
44+ // Somebody else allocated between the read and the write. Read again.
45+ }
46+ return 0, fmt.Errorf("could not allocate a number after %d tries; too many pushes at once", maxRetries)
47+ }
48+
49+ // readCounter returns the counter blob and value, and a repository with neither starts at zero.
50+ func readCounter(ctx context.Context, dir string) (blob string, value int, err error) {
51+ out, err := gitx.Run(ctx, dir, "rev-parse", "--verify", "--quiet", CounterRef)
52+ if err != nil {
53+ return "", 0, nil // no counter yet
54+ }
55+ blob = strings.TrimSpace(out)
56+
57+ body, err := gitx.Run(ctx, dir, "cat-file", "blob", blob)
58+ if err != nil {
59+ // Refuse rather than restart the numbering, which threads already refer to.
60+ return "", 0, fmt.Errorf("%s is not readable: %w", CounterRef, err)
61+ }
62+ value, err = strconv.Atoi(strings.TrimSpace(body))
63+ if err != nil {
64+ return "", 0, fmt.Errorf("%s does not hold a number: %q", CounterRef, strings.TrimSpace(body))
65+ }
66+ return blob, value, nil
67+ }
68+
69+ func Ref(n int) string { return "refs/proposals/" + strconv.Itoa(n) }
70+
71+ // RevisionRef retains old tips outside refs/proposals, where a file and directory would collide.
72+ func RevisionRef(n, k int) string {
73+ return fmt.Sprintf("refs/revisions/%d/%d", n, k)
74+ }
75+
76+ // CurrentRevision is the number the content on screen will take when the next force-push retains it.
77+ func CurrentRevision(dir string, n int) int {
78+ refs, err := gitx.ListRefs(dir, RevisionPrefix(n))
79+ if err != nil {
80+ return 0
81+ }
82+ // The highest kept plus one, because pruning drops the low numbers and a count would reuse them.
83+ high := 0
84+ for name := range refs {
85+ k, err := strconv.Atoi(strings.TrimPrefix(name, RevisionPrefix(n)+"/"))
86+ if err == nil && k > high {
87+ high = k
88+ }
89+ }
90+ return high + 1
91+ }
92+
93+ // RevisionPrefix is where one proposal's retained tips live, as a ref prefix and not a glob.
94+ func RevisionPrefix(n int) string {
95+ return fmt.Sprintf("refs/revisions/%d", n)
96+ }
97+
98+ // RevisionGlob matches every retained revision of proposal n.
99+ func RevisionGlob(n int) string {
100+ return fmt.Sprintf("refs/revisions/%d/*", n)
101+ }
102+
103+ // NewRef is the magic name. It is reserved and never created.
104+ const NewRef = "refs/proposals/new"
105+
106+ // Number reads the proposal number out of a ref name, or returns 0.
107+ func Number(ref string) int {
108+ rest, ok := strings.CutPrefix(ref, "refs/proposals/")
109+ if !ok {
110+ return 0
111+ }
112+ // refs/proposals/47 and refs/proposals/47/rev/2 both belong to 47.
113+ if i := strings.Index(rest, "/"); i >= 0 {
114+ rest = rest[:i]
115+ }
116+ n, err := strconv.Atoi(rest)
117+ if err != nil || n <= 0 {
118+ return 0
119+ }
120+ return n
121+ }
122+
123+ // taken reports whether a number already names a thread or a proposal.
124+ func taken(ctx context.Context, dir string, n int) bool {
125+ for _, ref := range []string{Ref(n), "refs/notes/threads/" + strconv.Itoa(n)} {
126+ if _, err := gitx.Run(ctx, dir, "rev-parse", "--verify", "--quiet", ref); err == nil {
127+ return true
128+ }
129+ }
130+ return false
131+ }
@@ -0,0 +1,154 @@
1+ package proposal
2+
3+ import (
4+ "context"
5+ "os/exec"
6+ "strings"
7+ "sync"
8+ "testing"
9+
10+ "github.com/barerepo/server/internal/gitx"
11+ )
12+
13+ func bare(t *testing.T) string {
14+ t.Helper()
15+ if _, err := gitx.Version(context.Background()); err != nil {
16+ t.Skip("git is not installed")
17+ }
18+ dir := t.TempDir()
19+ cmd := exec.Command(gitx.Bin, "init", "--bare", "-q", "--initial-branch", "master", dir)
20+ if out, err := cmd.CombinedOutput(); err != nil {
21+ t.Fatalf("init: %v\n%s", err, out)
22+ }
23+ return dir
24+ }
25+
26+ func TestAllocateCountsUp(t *testing.T) {
27+ ctx := context.Background()
28+ dir := bare(t)
29+ for want := 1; want <= 5; want++ {
30+ got, err := Allocate(ctx, dir)
31+ if err != nil {
32+ t.Fatal(err)
33+ }
34+ if got != want {
35+ t.Fatalf("Allocate = %d, want %d", got, want)
36+ }
37+ }
38+ // The number lives in the repository, so a mirror carries it.
39+ if _, _, err := readCounter(ctx, dir); err != nil {
40+ t.Fatal(err)
41+ }
42+ }
43+
44+ // Chapter 45.2: two simultaneous pushes, two different numbers, twenty times over.
45+ func TestAllocateIsAtomic(t *testing.T) {
46+ ctx := context.Background()
47+ dir := bare(t)
48+
49+ const n = 20
50+ var wg sync.WaitGroup
51+ got := make([]int, n)
52+ errs := make([]error, n)
53+ start := make(chan struct{})
54+ for i := 0; i < n; i++ {
55+ wg.Add(1)
56+ go func(i int) {
57+ defer wg.Done()
58+ <-start
59+ got[i], errs[i] = Allocate(ctx, dir)
60+ }(i)
61+ }
62+ close(start)
63+ wg.Wait()
64+
65+ seen := map[int]bool{}
66+ for i, v := range got {
67+ if errs[i] != nil {
68+ t.Fatalf("allocation %d failed: %v", i, errs[i])
69+ }
70+ if seen[v] {
71+ t.Fatalf("number %d was handed out twice", v)
72+ }
73+ seen[v] = true
74+ }
75+ for want := 1; want <= n; want++ {
76+ if !seen[want] {
77+ t.Errorf("number %d was skipped", want)
78+ }
79+ }
80+ }
81+
82+ func TestNumber(t *testing.T) {
83+ for ref, want := range map[string]int{
84+ "refs/proposals/47": 47,
85+ "refs/proposals/47/rev/2": 47,
86+ "refs/proposals/1": 1,
87+ "refs/proposals/new": 0,
88+ "refs/heads/master": 0,
89+ "refs/proposals/0": 0,
90+ "refs/proposals/-1": 0,
91+ "refs/proposals/": 0,
92+ "": 0,
93+ } {
94+ if got := Number(ref); got != want {
95+ t.Errorf("Number(%q) = %d, want %d", ref, got, want)
96+ }
97+ }
98+ }
99+
100+ // A revision cannot live under the proposal ref, where git would need a file and directory both.
101+ func TestRevisionRefsCanCoexist(t *testing.T) {
102+ ctx := context.Background()
103+ dir := bare(t)
104+ empty, err := gitx.RunStdin(ctx, dir, "", "hash-object", "-w", "--stdin")
105+ if err != nil {
106+ t.Fatal(err)
107+ }
108+ sha := strings.TrimSpace(empty)
109+ if _, err := gitx.Run(ctx, dir, "update-ref", Ref(47), sha, ""); err != nil {
110+ t.Fatal(err)
111+ }
112+ if _, err := gitx.Run(ctx, dir, "update-ref", RevisionRef(47, 1), sha, ""); err != nil {
113+ t.Fatalf("a proposal and its retained revision cannot both exist: %v", err)
114+ }
115+ }
116+
117+ func TestRefNames(t *testing.T) {
118+ if Ref(47) != "refs/proposals/47" {
119+ t.Error(Ref(47))
120+ }
121+ if RevisionRef(47, 2) != "refs/revisions/47/2" {
122+ t.Error(RevisionRef(47, 2))
123+ }
124+ // Appendix A: the whole layout has to be valid to git.
125+ for _, r := range []string{Ref(47), RevisionRef(47, 2), NewRef, CounterRef} {
126+ if !gitx.ValidRef(r) {
127+ t.Errorf("%q is not a usable ref name", r)
128+ }
129+ }
130+ }
131+
132+ // A pushed thread leaves the counter behind, and reusing the number merges two conversations.
133+ func TestAllocateSkipsNumbersInUse(t *testing.T) {
134+ ctx := context.Background()
135+ dir := bare(t)
136+ empty, err := gitx.RunStdin(ctx, dir, "", "hash-object", "-w", "--stdin")
137+ if err != nil {
138+ t.Fatal(err)
139+ }
140+ sha := strings.TrimSpace(empty)
141+ // Somebody pushed thread 1 and thread 2 without asking the counter.
142+ for _, ref := range []string{"refs/notes/threads/1", "refs/notes/threads/2"} {
143+ if _, err := gitx.Run(ctx, dir, "update-ref", ref, sha, ""); err != nil {
144+ t.Fatal(err)
145+ }
146+ }
147+ got, err := Allocate(ctx, dir)
148+ if err != nil {
149+ t.Fatal(err)
150+ }
151+ if got != 3 {
152+ t.Errorf("Allocate = %d, want 3: it handed out a number already in use", got)
153+ }
154+ }
@@ -0,0 +1,166 @@
1+ package proposal
2+
3+ import (
4+ "context"
5+ "sort"
6+ "strconv"
7+ "strings"
8+ "time"
9+
10+ "github.com/barerepo/server/internal/gitx"
11+ "github.com/barerepo/server/internal/thread"
12+ )
13+
14+ // Expire drops proposal refs nothing touched inside the window, keeping every thread. Chapter 26.
15+ func Expire(ctx context.Context, dir string, window time.Duration, now time.Time) ([]int, error) {
16+ if window <= 0 {
17+ return nil, nil
18+ }
19+ refs, err := gitx.ListRefs(dir, "refs/proposals")
20+ if err != nil || len(refs) == 0 {
21+ return nil, err
22+ }
23+ list, err := thread.List(ctx, dir)
24+ if err != nil {
25+ return nil, err
26+ }
27+ touched := make(map[int]time.Time, len(list))
28+ for _, s := range list {
29+ touched[s.N] = s.Updated
30+ }
31+
32+ var gone []int
33+ for name, sha := range refs {
34+ n := Number(name)
35+ if n == 0 {
36+ continue
37+ }
38+ last, ok := touched[n]
39+ if !ok || last.IsZero() {
40+ // A ref with no thread behind it is dated by the commit it points at.
41+ last = commitTime(ctx, dir, sha)
42+ }
43+ if last.IsZero() || now.Sub(last) <= window {
44+ continue
45+ }
46+ if _, err := gitx.Run(ctx, dir, "update-ref", "-d", name, sha); err != nil {
47+ continue
48+ }
49+ gone = append(gone, n)
50+ }
51+ sort.Ints(gone)
52+ return gone, nil
53+ }
54+
55+ // commitTime reads a commit's own date through the object pool, so a sweep starts no process for it.
56+ func commitTime(ctx context.Context, dir, sha string) time.Time {
57+ objs, err := gitx.Batch(ctx, dir, []string{sha})
58+ if err != nil || objs[sha] == nil {
59+ return time.Time{}
60+ }
61+ head, _, _ := strings.Cut(objs[sha].Body, "\n\n")
62+ for _, line := range strings.Split(head, "\n") {
63+ rest, ok := strings.CutPrefix(line, "committer ")
64+ if !ok {
65+ continue
66+ }
67+ if i := strings.LastIndex(rest, "> "); i >= 0 {
68+ rest = rest[i+2:]
69+ }
70+ stamp, _, _ := strings.Cut(rest, " ")
71+ if secs, err := strconv.ParseInt(stamp, 10, 64); err == nil {
72+ return time.Unix(secs, 0)
73+ }
74+ }
75+ return time.Time{}
76+ }
77+
78+ // RevisionsKept is chapter 26's five, which is enough to see what a proposal looked like before.
79+ const RevisionsKept = 5
80+
81+ // PruneRevisions drops retained tips past the keep count, except the ones a comment is anchored to.
82+ func PruneRevisions(ctx context.Context, dir string, n, keep int) ([]int, error) {
83+ refs, err := gitx.ListRefs(dir, RevisionPrefix(n))
84+ if err != nil || len(refs) == 0 {
85+ return nil, err
86+ }
87+ ks := make([]int, 0, len(refs))
88+ at := make(map[int]string, len(refs))
89+ for name, sha := range refs {
90+ k, err := strconv.Atoi(strings.TrimPrefix(name, RevisionPrefix(n)+"/"))
91+ if err != nil || k <= 0 {
92+ continue
93+ }
94+ ks = append(ks, k)
95+ at[k] = sha
96+ }
97+ sort.Sort(sort.Reverse(sort.IntSlice(ks)))
98+ if len(ks) <= keep {
99+ return nil, nil
100+ }
101+
102+ needed, unknown := anchoredRevisions(ctx, dir, n)
103+ // A comment that does not say which revision it was written against pins all of them. 43.4.
104+ if unknown {
105+ return nil, nil
106+ }
107+
108+ var gone []int
109+ for _, k := range ks[keep:] {
110+ if needed[k] {
111+ continue
112+ }
113+ if _, err := gitx.Run(ctx, dir, "update-ref", "-d", RevisionRef(n, k), at[k]); err != nil {
114+ continue
115+ }
116+ gone = append(gone, k)
117+ }
118+ sort.Ints(gone)
119+ return gone, nil
120+ }
121+
122+ // anchoredRevisions reports which revisions a comment is anchored to, and whether one will not say.
123+ func anchoredRevisions(ctx context.Context, dir string, n int) (map[int]bool, bool) {
124+ _, comments, err := thread.Read(ctx, dir, n)
125+ if err != nil {
126+ return nil, true
127+ }
128+ needed := map[int]bool{}
129+ unknown := false
130+ for _, c := range comments {
131+ if c.Blob == "" {
132+ continue
133+ }
134+ if c.Revision <= 0 {
135+ unknown = true
136+ continue
137+ }
138+ needed[c.Revision] = true
139+ }
140+ return needed, unknown
141+ }
142+
143+ // Numbers lists every proposal a repository has retained anything for, whether or not its ref lives.
144+ func Numbers(dir string) []int {
145+ refs, err := gitx.ListRefs(dir, "refs/revisions")
146+ if err != nil {
147+ return nil
148+ }
149+ seen := map[int]bool{}
150+ for name := range refs {
151+ rest := strings.TrimPrefix(name, "refs/revisions/")
152+ head, _, ok := strings.Cut(rest, "/")
153+ if !ok {
154+ continue
155+ }
156+ if n, err := strconv.Atoi(head); err == nil && n > 0 {
157+ seen[n] = true
158+ }
159+ }
160+ out := make([]int, 0, len(seen))
161+ for n := range seen {
162+ out = append(out, n)
163+ }
164+ sort.Ints(out)
165+ return out
166+ }
@@ -0,0 +1,196 @@
1+ package proposal
2+
3+ import (
4+ "context"
5+ "os"
6+ "os/exec"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "github.com/barerepo/server/internal/gitx"
13+ "github.com/barerepo/server/internal/thread"
14+ )
15+
16+ // seeded is a bare repository with one commit on master, which a proposal ref can point at.
17+ func seeded(t *testing.T) string {
18+ t.Helper()
19+ dir := bare(t)
20+ work := t.TempDir()
21+ mustGit(t, "", "init", "-q", "-b", "master", work)
22+ if err := os.WriteFile(filepath.Join(work, "f"), []byte("hi\n"), 0o644); err != nil {
23+ t.Fatal(err)
24+ }
25+ mustGit(t, work, "add", "-A")
26+ mustGit(t, work, "-c", "user.email=t@x", "-c", "user.name=t", "commit", "-qm", "first")
27+ mustGit(t, work, "push", "-q", dir, "master")
28+ return dir
29+ }
30+
31+ func mustGit(t *testing.T, dir string, args ...string) string {
32+ t.Helper()
33+ cmd := exec.Command("git", args...)
34+ cmd.Dir = dir
35+ cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@x",
36+ "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@x")
37+ out, err := cmd.CombinedOutput()
38+ if err != nil {
39+ t.Fatalf("git %v: %v\n%s", args, err, out)
40+ }
41+ return strings.TrimSpace(string(out))
42+ }
43+
44+ // Chapter 26: a proposal ref pins commits, so it expires, and the thread that is small stays.
45+ func TestExpireDropsTheRefAndKeepsTheThread(t *testing.T) {
46+ if _, err := exec.LookPath("git"); err != nil {
47+ t.Skip("git is not installed")
48+ }
49+ ctx := context.Background()
50+ dir := seeded(t)
51+ tip := mustGit(t, dir, "rev-parse", "master")
52+
53+ // Two proposals, one touched long ago and one touched now.
54+ for _, n := range []int{1, 2} {
55+ mustGit(t, dir, "update-ref", Ref(n), tip)
56+ meta := thread.Meta{Title: "proposal", State: thread.Open, Ref: Ref(n),
57+ Author: "mark", Opened: time.Now()}
58+ if err := thread.Write(ctx, dir, n, "open", func(tr *thread.Tree) { tr.Meta = meta }); err != nil {
59+ t.Fatal(err)
60+ }
61+ }
62+
63+ // Nothing is stale yet, so a sweep with a long window must take nothing.
64+ gone, err := Expire(ctx, dir, 180*24*time.Hour, time.Now())
65+ if err != nil {
66+ t.Fatal(err)
67+ }
68+ if len(gone) != 0 {
69+ t.Fatalf("a fresh proposal was expired: %v", gone)
70+ }
71+
72+ // A year on, both are past the window.
73+ gone, err = Expire(ctx, dir, 180*24*time.Hour, time.Now().Add(365*24*time.Hour))
74+ if err != nil {
75+ t.Fatal(err)
76+ }
77+ if len(gone) != 2 {
78+ t.Fatalf("expired %v, wanted both", gone)
79+ }
80+ refs, err := gitx.ListRefs(dir, "refs/proposals")
81+ if err != nil {
82+ t.Fatal(err)
83+ }
84+ if len(refs) != 0 {
85+ t.Errorf("the refs are still here: %v", refs)
86+ }
87+ // The thread is the discussion and the whole point of keeping it.
88+ list, err := thread.List(ctx, dir)
89+ if err != nil {
90+ t.Fatal(err)
91+ }
92+ if len(list) != 2 {
93+ t.Errorf("expiring a ref took its thread with it: %d threads left", len(list))
94+ }
95+ if _, err := gitx.Run(ctx, dir, "cat-file", "-e", tip); err != nil {
96+ t.Error("the commit is gone before a gc has run, which update-ref alone cannot do")
97+ }
98+ }
99+
100+ // A window of zero is expiry switched off, not expiry of everything.
101+ func TestExpireOfZeroTakesNothing(t *testing.T) {
102+ if _, err := exec.LookPath("git"); err != nil {
103+ t.Skip("git is not installed")
104+ }
105+ ctx := context.Background()
106+ dir := seeded(t)
107+ mustGit(t, dir, "update-ref", Ref(1), mustGit(t, dir, "rev-parse", "master"))
108+
109+ gone, err := Expire(ctx, dir, 0, time.Now().Add(365*24*time.Hour))
110+ if err != nil {
111+ t.Fatal(err)
112+ }
113+ if len(gone) != 0 {
114+ t.Errorf("a zero window expired %v", gone)
115+ }
116+ }
117+
118+ // Chapter 26: keep the most recent five retained tips, and the ones a comment is anchored to.
119+ func TestPruneRevisionsKeepsFiveAndTheAnchoredOne(t *testing.T) {
120+ if _, err := exec.LookPath("git"); err != nil {
121+ t.Skip("git is not installed")
122+ }
123+ ctx := context.Background()
124+ dir := seeded(t)
125+ tip := mustGit(t, dir, "rev-parse", "master")
126+
127+ meta := thread.Meta{Title: "a proposal", State: thread.Open, Ref: Ref(1),
128+ Author: "mark", Opened: time.Now()}
129+ if err := thread.Write(ctx, dir, 1, "open", func(tr *thread.Tree) { tr.Meta = meta }); err != nil {
130+ t.Fatal(err)
131+ }
132+ for k := 1; k <= 8; k++ {
133+ mustGit(t, dir, "update-ref", RevisionRef(1, k), tip)
134+ }
135+ if got := CurrentRevision(dir, 1); got != 9 {
136+ t.Fatalf("CurrentRevision = %d, wanted 9", got)
137+ }
138+
139+ // One comment is anchored to revision 2, which is outside the newest five.
140+ anchored := thread.Comment{Author: "john", Time: time.Now(), Body: "this line",
141+ Anchor: "f:1", Blob: strings.Repeat("a", 40), Revision: 2}
142+ if err := thread.Reply(ctx, dir, 1, tip, anchored); err != nil {
143+ t.Fatal(err)
144+ }
145+
146+ gone, err := PruneRevisions(ctx, dir, 1, RevisionsKept)
147+ if err != nil {
148+ t.Fatal(err)
149+ }
150+ // Eight revisions, five newest kept, revision 2 held by its comment, so 1 and 3 go.
151+ if len(gone) != 2 || gone[0] != 1 || gone[1] != 3 {
152+ t.Fatalf("pruned %v, wanted [1 3]", gone)
153+ }
154+ left, err := gitx.ListRefs(dir, RevisionPrefix(1))
155+ if err != nil {
156+ t.Fatal(err)
157+ }
158+ if len(left) != 6 {
159+ t.Errorf("%d revisions left, wanted 6", len(left))
160+ }
161+ if _, ok := left[RevisionRef(1, 2)]; !ok {
162+ t.Error("the revision a comment is anchored to was deleted")
163+ }
164+ }
165+
166+ // A comment that does not say which revision it belongs to pins all of them, per 43.4.
167+ func TestAnAnchoredCommentWithNoRevisionHoldsEverything(t *testing.T) {
168+ if _, err := exec.LookPath("git"); err != nil {
169+ t.Skip("git is not installed")
170+ }
171+ ctx := context.Background()
172+ dir := seeded(t)
173+ tip := mustGit(t, dir, "rev-parse", "master")
174+
175+ meta := thread.Meta{Title: "a proposal", State: thread.Open, Ref: Ref(1),
176+ Author: "mark", Opened: time.Now()}
177+ if err := thread.Write(ctx, dir, 1, "open", func(tr *thread.Tree) { tr.Meta = meta }); err != nil {
178+ t.Fatal(err)
179+ }
180+ for k := 1; k <= 8; k++ {
181+ mustGit(t, dir, "update-ref", RevisionRef(1, k), tip)
182+ }
183+ old := thread.Comment{Author: "john", Time: time.Now(), Body: "written before revisions were recorded",
184+ Anchor: "f:1", Blob: strings.Repeat("b", 40)}
185+ if err := thread.Reply(ctx, dir, 1, tip, old); err != nil {
186+ t.Fatal(err)
187+ }
188+
189+ gone, err := PruneRevisions(ctx, dir, 1, RevisionsKept)
190+ if err != nil {
191+ t.Fatal(err)
192+ }
193+ if len(gone) != 0 {
194+ t.Errorf("pruned %v while a comment could not say what it needs", gone)
195+ }
196+ }
@@ -0,0 +1,90 @@
1+ package repo
2+
3+ import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "time"
10+ )
11+
12+ // KeyLister is the half of the store this needs, so a caller does not drag the whole database in.
13+ type KeyLister interface {
14+ AllKeys(ctx context.Context) ([]AuthKey, error)
15+ }
16+
17+ // AuthKey is one public key and the account it authorises.
18+ type AuthKey struct {
19+ Account string
20+ Algo string
21+ Blob string
22+ // Retired is when the key stopped granting access, and the zero time means it still does.
23+ Retired time.Time
24+ }
25+
26+ // keyOptions refuse everything ssh can do except run the one command. Chapter 41.3.
27+ const keyOptions = "no-port-forwarding,no-x11-forwarding,no-agent-forwarding,no-pty"
28+
29+ // WriteAuthorizedKeys rewrites the file from the database, because the database is what grants access.
30+ func WriteAuthorizedKeys(dir, bin string, keys []AuthKey) error {
31+ if dir == "" {
32+ return fmt.Errorf("no ssh directory to write into")
33+ }
34+ if bin == "" {
35+ bin = "barerepo"
36+ }
37+ var b strings.Builder
38+ b.WriteString("# Written by barerepo from its own database. Edits here are lost on the next write.\n")
39+ for _, k := range keys {
40+ if k.Account == "" || k.Algo == "" || k.Blob == "" {
41+ continue
42+ }
43+ // A quote in the account name would end the command, and a name cannot hold one anyway.
44+ if strings.ContainsAny(k.Account, "\"\\\n\r") {
45+ continue
46+ }
47+ fmt.Fprintf(&b, "command=\"%s ssh --account %s\",%s %s %s\n",
48+ bin, k.Account, keyOptions, k.Algo, k.Blob)
49+ }
50+ if err := os.MkdirAll(dir, 0o700); err != nil {
51+ return err
52+ }
53+ // Written beside the target and moved, so a reader never sees half a file and lose every key.
54+ tmp := filepath.Join(dir, ".authorized_keys.tmp")
55+ if err := os.WriteFile(tmp, []byte(b.String()), 0o600); err != nil {
56+ return err
57+ }
58+ return os.Rename(tmp, filepath.Join(dir, "authorized_keys"))
59+ }
60+
61+ // WriteAllowedSigners writes the keys git checks a commit signature against, in ssh-keygen's format.
62+ func WriteAllowedSigners(path string, keys []AuthKey) error {
63+ if path == "" {
64+ return fmt.Errorf("no allowed signers file to write")
65+ }
66+ var b strings.Builder
67+ b.WriteString("# Written by barerepo from its own database. Edits here are lost on the next write.\n")
68+ seen := map[string]bool{}
69+ for _, k := range keys {
70+ if k.Algo == "" || k.Blob == "" || seen[k.Blob] {
71+ continue
72+ }
73+ seen[k.Blob] = true
74+ // The principal is a wildcard because a commit names an address barerepo never issued, and the namespace is what stops a sign in signature counting as a commit signature.
75+ opts := "namespaces=\"git\""
76+ if !k.Retired.IsZero() {
77+ // One second past retirement in local time with no suffix, which is the only spelling ssh-keygen reads as a moment.
78+ opts += ",valid-before=\"" + k.Retired.Local().Add(time.Second).Format("20060102150405") + "\""
79+ }
80+ fmt.Fprintf(&b, "* %s %s %s\n", opts, k.Algo, k.Blob)
81+ }
82+ if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
83+ return err
84+ }
85+ tmp := path + ".tmp"
86+ if err := os.WriteFile(tmp, []byte(b.String()), 0o600); err != nil {
87+ return err
88+ }
89+ return os.Rename(tmp, path)
90+ }
@@ -0,0 +1,345 @@
1+ // Package repo turns an owner and a name into a bare repository, owned by the URL. Chapter 11.
2+ package repo
3+
4+ import (
5+ "context"
6+ "errors"
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "strconv"
11+ "strings"
12+ "time"
13+
14+ "github.com/barerepo/server/internal/gitx"
15+ )
16+
17+ // Dir resolves a path under root, validating the names and the result, because either alone has failed.
18+ func Dir(root, owner, name string) (string, error) {
19+ // The trash is a sibling of the accounts under root, so an account named trash would own repositories Walk skips and the window erases. 44.4.
20+ if !gitx.ValidName(owner) || !gitx.ValidRepoName(name) || owner == TrashDir {
21+ return "", fmt.Errorf("no such repository")
22+ }
23+ absRoot, err := filepath.Abs(root)
24+ if err != nil {
25+ return "", err
26+ }
27+ dir := filepath.Join(absRoot, owner, name+".git")
28+ if !strings.HasPrefix(dir, absRoot+string(os.PathSeparator)) {
29+ return "", fmt.Errorf("no such repository")
30+ }
31+ return dir, nil
32+ }
33+
34+ // Exists reports whether the repository is on disk.
35+ func Exists(root, owner, name string) bool {
36+ dir, err := Dir(root, owner, name)
37+ if err != nil {
38+ return false
39+ }
40+ st, err := os.Stat(filepath.Join(dir, "HEAD"))
41+ return err == nil && st.Mode().IsRegular()
42+ }
43+
44+ // Create makes the bare repository and the hooks, and no commit, because empty means empty.
45+ func Create(ctx context.Context, root, owner, name, defaultBranch, serverBin string) (string, error) {
46+ dir, err := Dir(root, owner, name)
47+ if err != nil {
48+ return "", err
49+ }
50+ if Exists(root, owner, name) {
51+ return "", errors.New("that repository already exists")
52+ }
53+ if defaultBranch == "" {
54+ defaultBranch = "master"
55+ }
56+ if !gitx.ValidRef("refs/heads/" + defaultBranch) {
57+ return "", fmt.Errorf("%q is not a usable branch name", defaultBranch)
58+ }
59+ if err := os.MkdirAll(filepath.Dir(dir), 0o750); err != nil {
60+ return "", err
61+ }
62+ // An explicit initial branch, because git's default is a local setting to not inherit.
63+ if _, err := gitx.Run(ctx, "", "init", "--bare", "--initial-branch", defaultBranch, "--", dir); err != nil {
64+ os.RemoveAll(dir)
65+ return "", err
66+ }
67+ if _, err := gitx.Run(ctx, dir, "symbolic-ref", "HEAD", "refs/heads/"+defaultBranch); err != nil {
68+ os.RemoveAll(dir)
69+ return "", err
70+ }
71+ if err := InstallHooks(dir, serverBin); err != nil {
72+ os.RemoveAll(dir)
73+ return "", err
74+ }
75+ return dir, nil
76+ }
77+
78+ // hooks are two lines each, so upgrading the binary upgrades every repository at once.
79+ var hooks = []string{"pre-receive", "post-receive", "proc-receive"}
80+
81+ // InstallHooks replaces the scripts and deletes the samples, so the directory holds what runs.
82+ func InstallHooks(dir, serverBin string) error {
83+ hookDir := filepath.Join(dir, "hooks")
84+ if err := os.MkdirAll(hookDir, 0o750); err != nil {
85+ return err
86+ }
87+ entries, err := os.ReadDir(hookDir)
88+ if err != nil {
89+ return err
90+ }
91+ for _, e := range entries {
92+ if strings.HasSuffix(e.Name(), ".sample") || e.Name() == "update" {
93+ os.Remove(filepath.Join(hookDir, e.Name()))
94+ }
95+ }
96+ if serverBin == "" {
97+ serverBin, err = os.Executable()
98+ if err != nil {
99+ return err
100+ }
101+ }
102+ for _, h := range hooks {
103+ body := fmt.Sprintf("#!/bin/sh\nexec %s hook %s\n", serverBin, h)
104+ path := filepath.Join(hookDir, h)
105+ tmp := path + ".tmp"
106+ if err := os.WriteFile(tmp, []byte(body), 0o750); err != nil {
107+ return err
108+ }
109+ if err := os.Rename(tmp, path); err != nil {
110+ return err
111+ }
112+ }
113+ // A prefix, not a glob, and replaced not added, or reinstalling stacks copies of it.
114+ if _, err := gitx.Run(context.Background(), dir,
115+ "config", "--replace-all", "receive.procReceiveRefs", "refs/proposals"); err != nil {
116+ return err
117+ }
118+ return nil
119+ }
120+
121+ // HeadBranch reads HEAD every time, because assuming master or main is wrong for half the world.
122+ func HeadBranch(ctx context.Context, dir string) (string, error) {
123+ // HEAD is a file, and every repository page needs this, so do not spend a process on it.
124+ if name, err := gitx.HeadBranch(dir); err == nil {
125+ return name, nil
126+ }
127+ out, err := gitx.Run(ctx, dir, "symbolic-ref", "--short", "HEAD")
128+ if err != nil {
129+ return "", err
130+ }
131+ return strings.TrimSpace(out), nil
132+ }
133+
134+ // IsEmpty reports whether the repository has no refs yet.
135+ func IsEmpty(ctx context.Context, dir string) bool {
136+ return !gitx.AnyRef(dir)
137+ }
138+
139+ // Move relocates the directory and rewrites nothing inside it, because that is not a transfer. 44.3.
140+ func Move(root, oldOwner, oldName, newOwner, newName string) error {
141+ from, err := Dir(root, oldOwner, oldName)
142+ if err != nil {
143+ return err
144+ }
145+ to, err := Dir(root, newOwner, newName)
146+ if err != nil {
147+ return err
148+ }
149+ if _, err := os.Stat(to); err == nil {
150+ return fmt.Errorf("%s/%s already exists on disk", newOwner, newName)
151+ }
152+ if err := os.MkdirAll(filepath.Dir(to), 0o750); err != nil {
153+ return err
154+ }
155+ return os.Rename(from, to)
156+ }
157+
158+ // TrashDir is where a delete waits, because barerepo has no channel to answer a support request. 44.4.
159+ const TrashDir = "trash"
160+
161+ // Trash moves a repository out of service, keeping it for the window.
162+ func Trash(root, owner, name string, at time.Time) (string, error) {
163+ from, err := Dir(root, owner, name)
164+ if err != nil {
165+ return "", err
166+ }
167+ absRoot, err := filepath.Abs(root)
168+ if err != nil {
169+ return "", err
170+ }
171+ to := filepath.Join(absRoot, TrashDir,
172+ fmt.Sprintf("%d-%s-%s.git", at.Unix(), owner, name))
173+ if err := os.MkdirAll(filepath.Dir(to), 0o750); err != nil {
174+ return "", err
175+ }
176+ return to, os.Rename(from, to)
177+ }
178+
179+ // EmptyTrash erases what is past the window, which chapter 44.4 puts at 30 days.
180+ func EmptyTrash(root string, olderThan time.Duration, now time.Time) (int, error) {
181+ dir := filepath.Join(root, TrashDir)
182+ entries, err := os.ReadDir(dir)
183+ if err != nil {
184+ return 0, nil
185+ }
186+ cutoff := now.Add(-olderThan).Unix()
187+ erased := 0
188+ for _, e := range entries {
189+ stamp, _, ok := strings.Cut(e.Name(), "-")
190+ if !ok {
191+ continue
192+ }
193+ at, err := strconv.ParseInt(stamp, 10, 64)
194+ if err != nil || at >= cutoff {
195+ continue
196+ }
197+ if err := os.RemoveAll(filepath.Join(dir, e.Name())); err != nil {
198+ return erased, err
199+ }
200+ erased++
201+ }
202+ return erased, nil
203+ }
204+
205+ // InTrash reports a name still in its window, where a push fails rather than creates. Appendix D.
206+ func InTrash(root, owner, name string) bool {
207+ entries, err := os.ReadDir(filepath.Join(root, TrashDir))
208+ if err != nil {
209+ return false
210+ }
211+ // A hyphen is a name character, so a suffix match reads evil/my-bot-x as bot/x and holds a name nobody deleted, and only the stamp varies.
212+ want := owner + "-" + name + ".git"
213+ for _, e := range entries {
214+ if _, rest, ok := strings.Cut(e.Name(), "-"); ok && rest == want {
215+ return true
216+ }
217+ }
218+ return false
219+ }
220+
221+ // Copy is copying, with no relationship afterwards, and it is fast because objects are shared. 21.1.
222+ func Copy(ctx context.Context, root, srcOwner, srcName, dstOwner, dstName, serverBin string) (string, error) {
223+ src, err := Dir(root, srcOwner, srcName)
224+ if err != nil {
225+ return "", err
226+ }
227+ if !Exists(root, srcOwner, srcName) {
228+ return "", fmt.Errorf("%s/%s does not exist", srcOwner, srcName)
229+ }
230+ dst, err := Dir(root, dstOwner, dstName)
231+ if err != nil {
232+ return "", err
233+ }
234+ if Exists(root, dstOwner, dstName) {
235+ return "", fmt.Errorf("%s/%s already exists", dstOwner, dstName)
236+ }
237+ if err := os.MkdirAll(filepath.Dir(dst), 0o750); err != nil {
238+ return "", err
239+ }
240+
241+ // --shared points at the source's objects through alternates, which is what makes this instant.
242+ if _, err := gitx.Run(ctx, "", "clone", "--bare", "--shared", "--quiet", "--", src, dst); err != nil {
243+ os.RemoveAll(dst)
244+ return "", err
245+ }
246+ // A clone brings heads and tags, so ask for notes and the counter, and never for proposals.
247+ if _, err := gitx.Run(ctx, dst, "fetch", "--quiet", src,
248+ "refs/notes/*:refs/notes/*", "refs/meta/*:refs/meta/*"); err != nil {
249+ os.RemoveAll(dst)
250+ return "", err
251+ }
252+ for _, ref := range []string{"refs/proposals/", "refs/revisions/"} {
253+ out, err := gitx.Run(ctx, dst, "for-each-ref", "--format=%(refname)", ref)
254+ if err != nil {
255+ continue
256+ }
257+ for _, name := range strings.Split(strings.TrimSpace(out), "\n") {
258+ if name != "" {
259+ _, _ = gitx.Run(ctx, dst, "update-ref", "-d", name)
260+ }
261+ }
262+ }
263+ if err := InstallHooks(dst, serverBin); err != nil {
264+ os.RemoveAll(dst)
265+ return "", err
266+ }
267+ return dst, nil
268+ }
269+
270+ // Detach un-borrows every object, so deleting a source does not take its copies' history.
271+ func Detach(ctx context.Context, dir string) error {
272+ alternates := filepath.Join(dir, "objects", "info", "alternates")
273+ if _, err := os.Stat(alternates); err != nil {
274+ return nil // borrows nothing
275+ }
276+ // Repack with -a to write every borrowed object here, then drop the alternates file.
277+ if _, err := gitx.Run(ctx, dir, "repack", "-a", "-d", "--quiet"); err != nil {
278+ return err
279+ }
280+ return os.Remove(alternates)
281+ }
282+
283+ // Dependents lists repositories that borrow objects from dir.
284+ func Dependents(root, dir string) []string {
285+ var out []string
286+ owners, err := os.ReadDir(root)
287+ if err != nil {
288+ return nil
289+ }
290+ for _, owner := range owners {
291+ if !owner.IsDir() || owner.Name() == TrashDir {
292+ continue
293+ }
294+ repos, err := os.ReadDir(filepath.Join(root, owner.Name()))
295+ if err != nil {
296+ continue
297+ }
298+ for _, r := range repos {
299+ candidate := filepath.Join(root, owner.Name(), r.Name())
300+ if candidate == dir {
301+ continue
302+ }
303+ body, err := os.ReadFile(filepath.Join(candidate, "objects", "info", "alternates"))
304+ if err != nil {
305+ continue
306+ }
307+ if strings.Contains(string(body), dir) {
308+ out = append(out, candidate)
309+ }
310+ }
311+ }
312+ return out
313+ }
314+
315+ // Walk visits every served repository, skipping the trash, where a delete waits out its window.
316+ func Walk(root string, fn func(owner, name, dir string) error) error {
317+ return filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
318+ if err != nil || !d.IsDir() {
319+ return err
320+ }
321+ if d.Name() == TrashDir {
322+ return filepath.SkipDir
323+ }
324+ if !strings.HasSuffix(p, ".git") {
325+ return nil
326+ }
327+ owner := filepath.Base(filepath.Dir(p))
328+ name := strings.TrimSuffix(filepath.Base(p), ".git")
329+ if err := fn(owner, name, p); err != nil {
330+ return err
331+ }
332+ return filepath.SkipDir
333+ })
334+ }
335+
336+ // Collect runs git's own maintenance, which chapter 26 puts on a schedule and never on a push.
337+ func Collect(ctx context.Context, dir string, afterDeletion bool) error {
338+ args := []string{"gc", "--quiet"}
339+ if !afterDeletion {
340+ // --auto costs one process and returns at once unless git thinks there is work.
341+ args = append(args, "--auto")
342+ }
343+ _, err := gitx.Run(ctx, dir, args...)
344+ return err
345+ }
@@ -0,0 +1,297 @@
1+ package repo
2+
3+ import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+ "time"
10+
11+ "github.com/barerepo/server/internal/gitx"
12+ )
13+
14+ // TestDirRefusesEscape covers the oldest attack there is, from chapter 45.4's list.
15+ func TestDirRefusesEscape(t *testing.T) {
16+ root := t.TempDir()
17+ bad := [][2]string{
18+ {"john", "../../etc"},
19+ {"../../etc", "johnbot"},
20+ {"john", ".."},
21+ {"john", "a/b"},
22+ {"john", ""},
23+ {"john", "John"},
24+ {"admin", "johnbot"},
25+ {"", "johnbot"},
26+ {"john", "sub/../../../../etc"},
27+ }
28+ for _, c := range bad {
29+ if dir, err := Dir(root, c[0], c[1]); err == nil {
30+ t.Errorf("Dir(%q, %q) = %q, want an error", c[0], c[1], dir)
31+ }
32+ }
33+ dir, err := Dir(root, "john", "johnbot")
34+ if err != nil {
35+ t.Fatalf("Dir: %v", err)
36+ }
37+ // A reserved name is a route only at the top level, so john/runner is somebody's repository.
38+ for _, name := range []string{"runner", "admin", "new", "raw"} {
39+ if _, err := Dir(root, "john", name); err != nil {
40+ t.Errorf("Dir(john, %q) = %v, want a repository named after a route to be allowed", name, err)
41+ }
42+ }
43+ if want := filepath.Join(root, "john", "johnbot.git"); dir != want {
44+ t.Errorf("Dir = %q, want %q", dir, want)
45+ }
46+ }
47+
48+ func TestCreate(t *testing.T) {
49+ if _, err := gitx.Version(context.Background()); err != nil {
50+ t.Skip("git is not installed")
51+ }
52+ ctx := context.Background()
53+ root := t.TempDir()
54+
55+ // Rule 6: HEAD is what was asked for, never a hardcoded name or git's local default.
56+ dir, err := Create(ctx, root, "john", "johnbot", "trunk", "/usr/local/bin/barerepo")
57+ if err != nil {
58+ t.Fatalf("Create: %v", err)
59+ }
60+ if b, err := HeadBranch(ctx, dir); err != nil || b != "trunk" {
61+ t.Errorf("HeadBranch = %q, %v, want trunk", b, err)
62+ }
63+ if !IsEmpty(ctx, dir) {
64+ t.Error("a new repository is not empty")
65+ }
66+ if !Exists(root, "john", "johnbot") {
67+ t.Error("Exists = false after Create")
68+ }
69+ if _, err := Create(ctx, root, "john", "johnbot", "trunk", ""); err == nil {
70+ t.Error("creating the same repository twice was allowed")
71+ }
72+
73+ // Chapter 41.5: two hooks, no samples, no update hook.
74+ entries, err := os.ReadDir(filepath.Join(dir, "hooks"))
75+ if err != nil {
76+ t.Fatal(err)
77+ }
78+ var names []string
79+ for _, e := range entries {
80+ names = append(names, e.Name())
81+ }
82+ if strings.Join(names, ",") != "post-receive,pre-receive,proc-receive" {
83+ t.Errorf("hooks = %v, want exactly pre-receive, post-receive and proc-receive", names)
84+ }
85+ // The value is a prefix, and a glob matches nothing and fails silently.
86+ got, err := gitx.Run(ctx, dir, "config", "--get", "receive.procReceiveRefs")
87+ if err != nil || strings.TrimSpace(got) != "refs/proposals" {
88+ t.Errorf("receive.procReceiveRefs = %q, %v; want refs/proposals", got, err)
89+ }
90+ body, err := os.ReadFile(filepath.Join(dir, "hooks", "pre-receive"))
91+ if err != nil {
92+ t.Fatal(err)
93+ }
94+ if want := "#!/bin/sh\nexec /usr/local/bin/barerepo hook pre-receive\n"; string(body) != want {
95+ t.Errorf("pre-receive = %q, want %q", body, want)
96+ }
97+ if fi, _ := os.Stat(filepath.Join(dir, "hooks", "pre-receive")); fi.Mode()&0o100 == 0 {
98+ t.Error("pre-receive is not executable")
99+ }
100+ }
101+
102+ // A ref name that is really a git argument must never reach a command.
103+ func TestCreateRefusesFlagAsBranch(t *testing.T) {
104+ if _, err := gitx.Version(context.Background()); err != nil {
105+ t.Skip("git is not installed")
106+ }
107+ root := t.TempDir()
108+ for _, branch := range []string{"--upload-pack=/bin/sh", "-x", "a b", "a..b"} {
109+ if _, err := Create(context.Background(), root, "john", "johnbot", branch, ""); err == nil {
110+ t.Errorf("branch %q was accepted", branch)
111+ }
112+ }
113+ }
114+
115+ // A deleted repository waits out its window with the name still claimed. Chapter 44.4.
116+ func TestTrash(t *testing.T) {
117+ if _, err := gitx.Version(context.Background()); err != nil {
118+ t.Skip("git is not installed")
119+ }
120+ root := t.TempDir()
121+ ctx := context.Background()
122+ if _, err := Create(ctx, root, "john", "johnbot", "master", ""); err != nil {
123+ t.Fatal(err)
124+ }
125+ now := time.Now()
126+ if _, err := Trash(root, "john", "johnbot", now); err != nil {
127+ t.Fatal(err)
128+ }
129+ if Exists(root, "john", "johnbot") {
130+ t.Error("the repository is still being served")
131+ }
132+ if !InTrash(root, "john", "johnbot") {
133+ t.Error("the name is not held while the window runs")
134+ }
135+ // Nothing inside the window is erased.
136+ if n, err := EmptyTrash(root, 30*24*time.Hour, now); err != nil || n != 0 {
137+ t.Errorf("erased %d inside the window (%v)", n, err)
138+ }
139+ if !InTrash(root, "john", "johnbot") {
140+ t.Error("the copy was erased early")
141+ }
142+ // Past it, it goes.
143+ if n, err := EmptyTrash(root, 30*24*time.Hour, now.Add(31*24*time.Hour)); err != nil || n != 1 {
144+ t.Errorf("erased %d past the window (%v)", n, err)
145+ }
146+ if InTrash(root, "john", "johnbot") {
147+ t.Error("the copy outlived the window")
148+ }
149+ }
150+
151+ func TestMove(t *testing.T) {
152+ if _, err := gitx.Version(context.Background()); err != nil {
153+ t.Skip("git is not installed")
154+ }
155+ root := t.TempDir()
156+ ctx := context.Background()
157+ if _, err := Create(ctx, root, "john", "johnbot", "master", ""); err != nil {
158+ t.Fatal(err)
159+ }
160+ if err := Move(root, "john", "johnbot", "lisa", "johnbot"); err != nil {
161+ t.Fatal(err)
162+ }
163+ if Exists(root, "john", "johnbot") || !Exists(root, "lisa", "johnbot") {
164+ t.Error("the directory did not move")
165+ }
166+ // Moving onto a name that exists must not clobber it.
167+ if _, err := Create(ctx, root, "john", "other", "master", ""); err != nil {
168+ t.Fatal(err)
169+ }
170+ if err := Move(root, "john", "other", "lisa", "johnbot"); err == nil {
171+ t.Error("a move over an existing repository was allowed")
172+ }
173+ }
174+
175+ // A copy borrows objects, so Detach is what stops a delete taking its history too.
176+ func TestCopyAndDetach(t *testing.T) {
177+ ctx := context.Background()
178+ if _, err := gitx.Version(ctx); err != nil {
179+ t.Skip("git is not installed")
180+ }
181+ root := t.TempDir()
182+ src, err := Create(ctx, root, "john", "johnbot", "master", "")
183+ if err != nil {
184+ t.Fatal(err)
185+ }
186+ // One commit, and a proposal ref that must not travel.
187+ blob, err := gitx.RunStdin(ctx, src, "hello\n", "hash-object", "-w", "--stdin")
188+ if err != nil {
189+ t.Fatal(err)
190+ }
191+ tree, err := gitx.RunStdin(ctx, src,
192+ "100644 blob "+strings.TrimSpace(blob)+"\tREADME\n", "mktree")
193+ if err != nil {
194+ t.Fatal(err)
195+ }
196+ commit, err := gitx.RunStdin(ctx, src, "", "commit-tree", strings.TrimSpace(tree), "-m", "first")
197+ if err != nil {
198+ t.Fatal(err)
199+ }
200+ sha := strings.TrimSpace(commit)
201+ for _, ref := range []string{"refs/heads/master", "refs/proposals/1", "refs/notes/threads/1"} {
202+ if _, err := gitx.Run(ctx, src, "update-ref", ref, sha); err != nil {
203+ t.Fatal(err)
204+ }
205+ }
206+
207+ if _, err := Copy(ctx, root, "john", "johnbot", "lisa", "johnbot", ""); err != nil {
208+ t.Fatal(err)
209+ }
210+ dst, _ := Dir(root, "lisa", "johnbot")
211+
212+ // Chapter 21.1: history and notes come across, proposals do not.
213+ for _, want := range []string{"refs/heads/master", "refs/notes/threads/1"} {
214+ if _, err := gitx.Run(ctx, dst, "rev-parse", "--verify", "--quiet", want); err != nil {
215+ t.Errorf("%s did not come across", want)
216+ }
217+ }
218+ if _, err := gitx.Run(ctx, dst, "rev-parse", "--verify", "--quiet", "refs/proposals/1"); err == nil {
219+ t.Error("a proposal ref came across; it belongs to the original conversation")
220+ }
221+
222+ // It borrows, which is what makes the copy instant.
223+ if Dependents(root, src) == nil {
224+ t.Error("the copy does not show as depending on the source")
225+ }
226+ if err := Detach(ctx, dst); err != nil {
227+ t.Fatal(err)
228+ }
229+ if Dependents(root, src) != nil {
230+ t.Error("the copy still borrows after detaching")
231+ }
232+ // And the history survives the source going away.
233+ if err := os.RemoveAll(src); err != nil {
234+ t.Fatal(err)
235+ }
236+ if _, err := gitx.Run(ctx, dst, "cat-file", "-e", sha+"^{commit}"); err != nil {
237+ t.Errorf("the copy lost its history when the source went: %v", err)
238+ }
239+ }
240+
241+ // Chapter 26: repack after bulk ref deletion, or the pack files retain everything just deleted.
242+ func TestCollectPacksWhatIsLooseAndKeepsWhatIsReachable(t *testing.T) {
243+ ctx := context.Background()
244+ if _, err := gitx.Version(ctx); err != nil {
245+ t.Skip("git is not installed")
246+ }
247+ root := t.TempDir()
248+ dir, err := Create(ctx, root, "john", "johnbot", "master", "")
249+ if err != nil {
250+ t.Fatal(err)
251+ }
252+ blob, err := gitx.RunStdin(ctx, dir, "hello\n", "hash-object", "-w", "--stdin")
253+ if err != nil {
254+ t.Fatal(err)
255+ }
256+ tree, err := gitx.RunStdin(ctx, dir,
257+ "100644 blob "+strings.TrimSpace(blob)+"\tREADME\n", "mktree")
258+ if err != nil {
259+ t.Fatal(err)
260+ }
261+ commit, err := gitx.RunStdin(ctx, dir, "", "commit-tree", strings.TrimSpace(tree), "-m", "first")
262+ if err != nil {
263+ t.Fatal(err)
264+ }
265+ sha := strings.TrimSpace(commit)
266+ if _, err := gitx.Run(ctx, dir, "update-ref", "refs/heads/master", sha); err != nil {
267+ t.Fatal(err)
268+ }
269+
270+ packs, err := filepath.Glob(filepath.Join(dir, "objects", "pack", "*.pack"))
271+ if err != nil {
272+ t.Fatal(err)
273+ }
274+ if len(packs) != 0 {
275+ t.Fatalf("the repository is packed before a collect, so this test proves nothing")
276+ }
277+
278+ if err := Collect(ctx, dir, true); err != nil {
279+ t.Fatal(err)
280+ }
281+ packs, err = filepath.Glob(filepath.Join(dir, "objects", "pack", "*.pack"))
282+ if err != nil {
283+ t.Fatal(err)
284+ }
285+ if len(packs) == 0 {
286+ t.Error("a collect after a deletion did not repack, so the packs keep what was deleted")
287+ }
288+ // What a ref still reaches must survive, which is the half a repack must not get wrong.
289+ if _, err := gitx.Run(ctx, dir, "cat-file", "-e", sha); err != nil {
290+ t.Errorf("the commit master points at did not survive the repack: %v", err)
291+ }
292+
293+ // The scheduled form is git's own --auto, which must be safe to run on a repository at rest.
294+ if err := Collect(ctx, dir, false); err != nil {
295+ t.Errorf("an automatic collect failed on a quiet repository: %v", err)
296+ }
297+ }
@@ -0,0 +1,246 @@
1+ // Package repocfg reads .barerepo/config with the namespace's authority, not the file's. Chapter 14.
2+ package repocfg
3+
4+ import (
5+ "context"
6+ "slices"
7+ "strings"
8+
9+ "github.com/BurntSushi/toml"
10+
11+ "github.com/barerepo/server/internal/cache"
12+ "github.com/barerepo/server/internal/gitx"
13+ )
14+
15+ // Path is where the file lives, in every repository, always.
16+ const Path = ".barerepo/config"
17+
18+ type Config struct {
19+ Repo Repo `toml:"repo"`
20+ Access Access `toml:"access"`
21+ Proposals Proposals `toml:"proposals"`
22+ Runners map[string][]string `toml:"runners"`
23+ Build Build `toml:"build"`
24+ Webhook []Webhook `toml:"webhook"`
25+ // FellBackTo names the commit whose settings are in force when the tip's file does not parse.
26+ FellBackTo string `toml:"-"`
27+ }
28+
29+ type Repo struct {
30+ DefaultBranch string `toml:"default_branch"`
31+ Visibility string `toml:"visibility"`
32+ Description string `toml:"description"`
33+ Archived bool `toml:"archived"`
34+ }
35+
36+ type Access struct {
37+ Push []string `toml:"push"`
38+ AllowForcePush []string `toml:"allow_force_push"`
39+ AllowDelete []string `toml:"allow_delete"`
40+ // RequireSigned refuses a branch or tag carrying a commit with no signature, and is off unless a repository asks for it.
41+ RequireSigned bool `toml:"require_signed_commits"`
42+ }
43+
44+ type Proposals struct {
45+ AcceptFrom string `toml:"accept_from"`
46+ RequireRuns []string `toml:"require_runs"`
47+ ExpireDays int `toml:"expire_days"`
48+ }
49+
50+ type Build struct {
51+ Command string `toml:"command"`
52+ Image string `toml:"image"`
53+ }
54+
55+ type Webhook struct {
56+ URL string `toml:"url"`
57+ Events []string `toml:"events"`
58+ SecretEnv string `toml:"secret_env"`
59+ }
60+
61+ // Default leaves visibility empty, which reads as private, because absent must be the safe answer.
62+ func Default() Config {
63+ return Config{
64+ Proposals: Proposals{AcceptFrom: "anyone", ExpireDays: 180},
65+ }
66+ }
67+
68+ // Load returns the defaults and the parse error together, because a bad file must not lock anyone out.
69+ func Load(ctx context.Context, dir string) (Config, error) {
70+ cfg := Default()
71+
72+ // Keyed by the commit the branch points at, which is a file read rather than a process.
73+ key := ""
74+ if branch, err := gitx.HeadBranch(dir); err == nil {
75+ if sha, err := gitx.ResolveRef(dir, "refs/heads/"+branch); err == nil {
76+ key = sha
77+ }
78+ }
79+ if key != "" {
80+ if body, ok := Cache.Get("config", key); ok {
81+ if len(body) == 0 {
82+ return cfg, nil // no config file at this commit
83+ }
84+ if _, err := toml.Decode(string(body), &cfg); err != nil {
85+ return lastGood(ctx, dir, key), err
86+ }
87+ return cfg, nil
88+ }
89+ }
90+
91+ obj, err := gitx.CatFile(ctx, dir, "HEAD:"+Path)
92+ if err != nil || obj.Type != "blob" {
93+ // No file, no HEAD, or an empty repository. All mean "defaults".
94+ if key != "" {
95+ Cache.Put("config", key, nil)
96+ }
97+ return cfg, nil
98+ }
99+ if key != "" {
100+ Cache.Put("config", key, []byte(obj.Body))
101+ }
102+ if strings.TrimSpace(obj.Body) == "" {
103+ return cfg, nil
104+ }
105+ if _, err := toml.Decode(obj.Body, &cfg); err != nil {
106+ return lastGood(ctx, dir, key), err
107+ }
108+ return cfg, nil
109+ }
110+
111+ // lastGood is chapter 14's fallback, because the defaults lock out everyone the file let in.
112+ func lastGood(ctx context.Context, dir, key string) Config {
113+ if key != "" && Cache != nil {
114+ if body, ok := Cache.Get("configgood", key); ok {
115+ return decodeGood(body)
116+ }
117+ }
118+ body := walkBack(ctx, dir)
119+ if key != "" && Cache != nil {
120+ Cache.Put("configgood", key, body)
121+ }
122+ return decodeGood(body)
123+ }
124+
125+ // decodeGood reads a version already known to parse, and no version is the defaults.
126+ func decodeGood(body []byte) Config {
127+ cfg := Default()
128+ if len(body) == 0 {
129+ return cfg
130+ }
131+ sha, rest, _ := strings.Cut(string(body), "\n")
132+ if _, err := toml.Decode(rest, &cfg); err != nil {
133+ return Default()
134+ }
135+ cfg.FellBackTo = sha
136+ return cfg
137+ }
138+
139+ // walkBack reads the file's own history newest first and stops at the first version that parses.
140+ func walkBack(ctx context.Context, dir string) []byte {
141+ // Bounded, because a file that has never parsed must not cost a walk of the whole history.
142+ out, err := gitx.Run(ctx, dir, "log", "--format=%H", "-n", "25", "HEAD", "--", Path)
143+ if err != nil {
144+ return nil
145+ }
146+ for _, sha := range strings.Fields(out) {
147+ obj, err := gitx.CatFile(ctx, dir, sha+":"+Path)
148+ if err != nil || obj.Type != "blob" {
149+ continue
150+ }
151+ var cfg Config
152+ if _, err := toml.Decode(obj.Body, &cfg); err != nil {
153+ continue
154+ }
155+ // The sha rides on the first line, so the page and the terminal can name what is in force.
156+ return []byte(sha + "\n" + obj.Body)
157+ }
158+ return nil
159+ }
160+
161+ // Cache holds parsed configuration by commit, and nil means read it every time.
162+ var Cache *cache.Disk
163+
164+ // List renders a toml array the way the file holds it, so the page and the terminal never differ.
165+ func List(items []string) string {
166+ if len(items) == 0 {
167+ return "[]"
168+ }
169+ quoted := make([]string, len(items))
170+ for i, s := range items {
171+ quoted[i] = `"` + s + `"`
172+ }
173+ return "[" + strings.Join(quoted, ", ") + "]"
174+ }
175+
176+ // Who names the pusher, where nobody is a reader who never signed in.
177+ func Who(account string) string {
178+ if account == "" {
179+ return "not signed in"
180+ }
181+ return account
182+ }
183+
184+ // Public opens only on the exact word, because a typo must never publish someone's code.
185+ func (c Config) Public() bool { return strings.EqualFold(c.Repo.Visibility, "public") }
186+
187+ // MayRead answers for user, where empty is an anonymous reader. Chapter 18.
188+ func (c Config) MayRead(owner, user string) bool {
189+ return c.Public() || (user != "" && (user == owner || c.mayPushRef(owner, user)))
190+ }
191+
192+ // MayPush covers heads and tags, and the owner is always allowed and never listed. Chapter 18.
193+ func (c Config) MayPush(owner, user string) bool {
194+ if c.Repo.Archived && user != owner {
195+ return false
196+ }
197+ return c.mayPushRef(owner, user)
198+ }
199+
200+ func (c Config) mayPushRef(owner, user string) bool {
201+ if user == "" {
202+ return false
203+ }
204+ return user == owner || slices.Contains(c.Access.Push, user)
205+ }
206+
207+ // MayPropose reads [proposals] accept_from, and rule 5 defaults it to every signed-in user.
208+ func (c Config) MayPropose(owner, user string) bool {
209+ if c.Repo.Archived {
210+ return false
211+ }
212+ if !c.MayRead(owner, user) {
213+ return false
214+ }
215+ switch strings.ToLower(c.Proposals.AcceptFrom) {
216+ case "push":
217+ return c.mayPushRef(owner, user)
218+ case "authenticated", "anyone", "":
219+ // "anyone" means anyone with an account, since every push is authenticated to get here.
220+ return user != ""
221+ default:
222+ return user != ""
223+ }
224+ }
225+
226+ // ForcePushAllowed permits every branch but the default, where the loss is not only the pusher's.
227+ func (c Config) ForcePushAllowed(branch, defaultBranch string) bool {
228+ return branch != defaultBranch || slices.Contains(c.Access.AllowForcePush, branch)
229+ }
230+
231+ // DeleteAllowed reports whether branch may be deleted.
232+ func (c Config) DeleteAllowed(branch, defaultBranch string) bool {
233+ return branch != defaultBranch || slices.Contains(c.Access.AllowDelete, branch)
234+ }
235+
236+ // ParseAt reads the file as one commit leaves it, so a typo is reported by the push that makes it.
237+ func ParseAt(ctx context.Context, dir, sha string) error {
238+ obj, err := gitx.CatFile(ctx, dir, sha+":"+Path)
239+ if err != nil || obj.Type != "blob" || strings.TrimSpace(obj.Body) == "" {
240+ // No file is not a broken file, and every repository without one runs on the defaults.
241+ return nil
242+ }
243+ var cfg Config
244+ _, err = toml.Decode(obj.Body, &cfg)
245+ return err
246+ }
@@ -0,0 +1,96 @@
1+ package repocfg
2+
3+ import "testing"
4+
5+ // The rule that matters most: a repository is closed unless it says otherwise.
6+ func TestVisibility(t *testing.T) {
7+ cases := map[string]bool{
8+ "public": true,
9+ "Public": true,
10+ "private": false,
11+ "": false, // no config file at all, the push-to-create case
12+ "publik": false, // a typo must not publish anyone's code
13+ "true": false,
14+ "open": false,
15+ }
16+ for v, want := range cases {
17+ c := Default()
18+ c.Repo.Visibility = v
19+ if got := c.Public(); got != want {
20+ t.Errorf("visibility %q: Public() = %v, want %v", v, got, want)
21+ }
22+ }
23+ }
24+
25+ func TestAccess(t *testing.T) {
26+ c := Default()
27+ c.Access.Push = []string{"lisa"}
28+
29+ // Private: owner and pushers only, and no anonymous reader.
30+ for user, want := range map[string]bool{"john": true, "lisa": true, "mark": false, "": false} {
31+ if got := c.MayRead("john", user); got != want {
32+ t.Errorf("private MayRead(%q) = %v, want %v", user, got, want)
33+ }
34+ }
35+ // Public: everyone reads, including anonymously.
36+ c.Repo.Visibility = "public"
37+ for user, want := range map[string]bool{"john": true, "mark": true, "": true} {
38+ if got := c.MayRead("john", user); got != want {
39+ t.Errorf("public MayRead(%q) = %v, want %v", user, got, want)
40+ }
41+ }
42+ // Push is the owner plus the list, and the owner is never in the list.
43+ for user, want := range map[string]bool{"john": true, "lisa": true, "mark": false, "": false} {
44+ if got := c.MayPush("john", user); got != want {
45+ t.Errorf("MayPush(%q) = %v, want %v", user, got, want)
46+ }
47+ }
48+ // Rule 5: anyone with an account may propose, without being granted it.
49+ if !c.MayPropose("john", "mark") {
50+ t.Error("MayPropose denied a signed-in stranger on a public repository")
51+ }
52+ if c.MayPropose("john", "") {
53+ t.Error("MayPropose allowed an anonymous pusher")
54+ }
55+ c.Proposals.AcceptFrom = "push"
56+ if c.MayPropose("john", "mark") {
57+ t.Error("accept_from = push still accepted a stranger")
58+ }
59+ }
60+
61+ // Force-push and deletion are free everywhere except the default branch.
62+ func TestForcePushAndDelete(t *testing.T) {
63+ c := Default()
64+ if !c.ForcePushAllowed("topic", "master") {
65+ t.Error("force-push to a topic branch was refused")
66+ }
67+ if c.ForcePushAllowed("master", "master") {
68+ t.Error("force-push to the default branch was allowed by default")
69+ }
70+ c.Access.AllowForcePush = []string{"master"}
71+ if !c.ForcePushAllowed("master", "master") {
72+ t.Error("allow_force_push did not take effect")
73+ }
74+ if c.DeleteAllowed("master", "master") {
75+ t.Error("the default branch could be deleted")
76+ }
77+ if !c.DeleteAllowed("topic", "master") {
78+ t.Error("a topic branch could not be deleted")
79+ }
80+ }
81+
82+ // An archived repository is read-only except for the owner, who pushes the line that undoes it.
83+ func TestArchived(t *testing.T) {
84+ c := Default()
85+ c.Repo.Archived = true
86+ c.Access.Push = []string{"lisa"}
87+ if !c.MayPush("john", "john") {
88+ t.Error("the owner could not push to an archived repository, so it could never be unarchived")
89+ }
90+ if c.MayPush("john", "lisa") {
91+ t.Error("a collaborator could push to an archived repository")
92+ }
93+ if c.MayPropose("john", "mark") {
94+ t.Error("an archived repository accepted a proposal")
95+ }
96+ }
@@ -0,0 +1,238 @@
1+ // Package run stores builds as notes, so build history clones and survives a move. Chapter 16.
2+ package run
3+
4+ import (
5+ "context"
6+ "encoding/json"
7+ "fmt"
8+ "sort"
9+ "strings"
10+ "time"
11+
12+ "github.com/barerepo/server/internal/gitx"
13+ )
14+
15+ // Ref holds every run in one ref, so `git log --show-notes=runs` prints them beside commits.
16+ const Ref = "refs/notes/runs"
17+
18+ // inlineLimit is 64kb, so one talkative build does not make every reader of the ref pay.
19+ const inlineLimit = 64 << 10
20+
21+ // Record is one run of one commit.
22+ type Record struct {
23+ Runner string `json:"runner"`
24+ Labels []string `json:"labels,omitempty"`
25+ // Name is the workflow job that produced this, so a matrix of builds is readable. Chapter 15A.
26+ Name string `json:"name,omitempty"`
27+ // Ref is what was built, because a commit reaches a branch and a proposal both.
28+ Ref string `json:"ref,omitempty"`
29+ Started int64 `json:"started"`
30+ Duration int `json:"duration"`
31+ Exit int `json:"exit"`
32+ // Output holds the log when it is small enough to sit in the note.
33+ Output string `json:"output,omitempty"`
34+ // Log names a blob holding the log when Output does not.
35+ Log string `json:"log,omitempty"`
36+ }
37+
38+ // Failed reports a run worth looking at, because chapter 19.1 says green is not news.
39+ func (r Record) Failed() bool { return r.Exit != 0 }
40+
41+ func (r Record) StartedAt() time.Time { return time.Unix(r.Started, 0) }
42+
43+ // recordSep separates runs of the same commit, as in chapter 13.
44+ const recordSep = "--"
45+
46+ const maxRetries = 20
47+
48+ // Append records a run against a commit.
49+ func Append(ctx context.Context, dir, sha string, rec Record, log string) error {
50+ if len(log) > inlineLimit {
51+ blob, err := gitx.RunStdin(ctx, dir, log, "hash-object", "-w", "--stdin")
52+ if err != nil {
53+ return err
54+ }
55+ rec.Log = strings.TrimSpace(blob)
56+ rec.Output = ""
57+ } else {
58+ rec.Output = log
59+ rec.Log = ""
60+ }
61+ body, err := json.Marshal(rec)
62+ if err != nil {
63+ return err
64+ }
65+
66+ for attempt := 0; attempt < maxRetries; attempt++ {
67+ old, notes, blobOf, err := read(ctx, dir)
68+ if err != nil {
69+ return err
70+ }
71+ existing := notes[sha]
72+ if strings.TrimSpace(existing) != "" {
73+ existing = strings.TrimRight(existing, "\n") + "\n" + recordSep + "\n"
74+ } else {
75+ existing = ""
76+ }
77+ notes[sha] = existing + string(body) + "\n"
78+ // This one changed, so it is the only note that has to be written.
79+ delete(blobOf, sha)
80+
81+ tree, err := writeTree(ctx, dir, notes, blobOf)
82+ if err != nil {
83+ return err
84+ }
85+ args := []string{"commit-tree", tree, "-m", "run on " + short(sha)}
86+ if old != "" {
87+ args = append(args, "-p", old)
88+ }
89+ commit, err := gitx.RunStdin(ctx, dir, "", args...)
90+ if err != nil {
91+ return err
92+ }
93+ if _, err := gitx.Run(ctx, dir, "update-ref", Ref, strings.TrimSpace(commit), old); err == nil {
94+ return nil
95+ }
96+ }
97+ return fmt.Errorf("could not record the run after %d tries", maxRetries)
98+ }
99+
100+ // For returns every run of one commit, newest first.
101+ func For(ctx context.Context, dir, sha string) ([]Record, error) {
102+ out, err := gitx.Run(ctx, dir, "notes", "--ref="+Ref, "show", sha)
103+ if err != nil {
104+ return nil, nil
105+ }
106+ return parse(out), nil
107+ }
108+
109+ // Recent returns the newest runs across the repository.
110+ func Recent(ctx context.Context, dir string, limit int) (map[string][]Record, error) {
111+ _, notes, _, err := read(ctx, dir)
112+ if err != nil {
113+ return nil, err
114+ }
115+ out := map[string][]Record{}
116+ for sha, body := range notes {
117+ if recs := parse(body); len(recs) > 0 {
118+ out[sha] = recs
119+ }
120+ }
121+ if limit <= 0 {
122+ return out, nil
123+ }
124+ return newest(out, limit), nil
125+ }
126+
127+ // newest keeps the most recent runs across every commit, because a page shows rows and not commits.
128+ func newest(byCommit map[string][]Record, limit int) map[string][]Record {
129+ type row struct {
130+ sha string
131+ rec Record
132+ }
133+ all := make([]row, 0, len(byCommit))
134+ for sha, recs := range byCommit {
135+ for _, rec := range recs {
136+ all = append(all, row{sha, rec})
137+ }
138+ }
139+ if len(all) <= limit {
140+ return byCommit
141+ }
142+ sort.SliceStable(all, func(i, j int) bool { return all[i].rec.Started > all[j].rec.Started })
143+ out := map[string][]Record{}
144+ for _, r := range all[:limit] {
145+ out[r.sha] = append(out[r.sha], r.rec)
146+ }
147+ return out
148+ }
149+
150+ func parse(note string) []Record {
151+ var out []Record
152+ for _, part := range strings.Split(note, "\n"+recordSep+"\n") {
153+ part = strings.TrimSpace(part)
154+ if part == "" {
155+ continue
156+ }
157+ var r Record
158+ if err := json.Unmarshal([]byte(part), &r); err == nil {
159+ out = append(out, r)
160+ }
161+ }
162+ sort.SliceStable(out, func(i, j int) bool { return out[i].Started > out[j].Started })
163+ return out
164+ }
165+
166+ // Log returns a run's output, reading the blob when it is not inline.
167+ func Log(ctx context.Context, dir string, r Record) (string, error) {
168+ if r.Log == "" {
169+ return r.Output, nil
170+ }
171+ return gitx.Run(ctx, dir, "cat-file", "blob", r.Log)
172+ }
173+
174+ // read gets every run in two processes, where a cat-file per note cost ten times that.
175+ func read(ctx context.Context, dir string) (commit string, notes, blobOf map[string]string, err error) {
176+ notes, blobOf = map[string]string{}, map[string]string{}
177+ head, err := gitx.Batch(ctx, dir, []string{Ref, Ref + "^{tree}"})
178+ if err != nil {
179+ return "", nil, nil, err
180+ }
181+ if head[Ref] == nil || head[Ref+"^{tree}"] == nil {
182+ return "", notes, blobOf, nil
183+ }
184+ commit = head[Ref].SHA
185+ entries := gitx.TreeEntries(head[Ref+"^{tree}"].Body)
186+ specs := make([]string, 0, len(entries))
187+ for _, sha := range entries {
188+ specs = append(specs, sha)
189+ }
190+ blobs, err := gitx.Batch(ctx, dir, specs)
191+ if err != nil {
192+ return "", nil, nil, err
193+ }
194+ for path, sha := range entries {
195+ if obj := blobs[sha]; obj != nil {
196+ notes[path] = obj.Body
197+ blobOf[path] = sha
198+ }
199+ }
200+ return commit, notes, blobOf, nil
201+ }
202+
203+ func writeTree(ctx context.Context, dir string, notes, blobOf map[string]string) (string, error) {
204+ paths := make([]string, 0, len(notes))
205+ for p := range notes {
206+ paths = append(paths, p)
207+ }
208+ sort.Strings(paths)
209+
210+ var entries strings.Builder
211+ for _, path := range paths {
212+ if strings.TrimSpace(notes[path]) == "" {
213+ continue
214+ }
215+ // A note this call did not touch already has a blob, and its content is its name.
216+ blob, ok := blobOf[path]
217+ if !ok {
218+ out, err := gitx.RunStdin(ctx, dir, notes[path], "hash-object", "-w", "--stdin")
219+ if err != nil {
220+ return "", err
221+ }
222+ blob = strings.TrimSpace(out)
223+ }
224+ fmt.Fprintf(&entries, "100644 blob %s\t%s\n", blob, path)
225+ }
226+ out, err := gitx.RunStdin(ctx, dir, entries.String(), "mktree")
227+ if err != nil {
228+ return "", err
229+ }
230+ return strings.TrimSpace(out), nil
231+ }
232+
233+ func short(sha string) string {
234+ if len(sha) > 7 {
235+ return sha[:7]
236+ }
237+ return sha
238+ }
@@ -0,0 +1,108 @@
1+ package run
2+
3+ import (
4+ "context"
5+ "os/exec"
6+ "strings"
7+ "testing"
8+
9+ "github.com/barerepo/server/internal/gitx"
10+ )
11+
12+ func repoWithCommit(t *testing.T) (dir, sha string) {
13+ t.Helper()
14+ if _, err := gitx.Version(context.Background()); err != nil {
15+ t.Skip("git is not installed")
16+ }
17+ dir = t.TempDir()
18+ run := func(args ...string) string {
19+ t.Helper()
20+ cmd := exec.Command(gitx.Bin, args...)
21+ cmd.Dir = dir
22+ cmd.Env = append(cmd.Environ(),
23+ "GIT_AUTHOR_NAME=m", "GIT_AUTHOR_EMAIL=m@x",
24+ "GIT_COMMITTER_NAME=m", "GIT_COMMITTER_EMAIL=m@x")
25+ out, err := cmd.CombinedOutput()
26+ if err != nil {
27+ t.Fatalf("git %v: %v\n%s", args, err, out)
28+ }
29+ return strings.TrimSpace(string(out))
30+ }
31+ run("init", "-q", "-b", "master")
32+ run("commit", "-q", "--allow-empty", "-m", "first")
33+ return dir, run("rev-parse", "HEAD")
34+ }
35+
36+ func TestAppendAndRead(t *testing.T) {
37+ ctx := context.Background()
38+ dir, sha := repoWithCommit(t)
39+
40+ rec := Record{Runner: "uproar.local", Labels: []string{"build", "test"},
41+ Started: 1787074650, Duration: 18, Exit: 0}
42+ if err := Append(ctx, dir, sha, rec, "$ make ci\nok\n"); err != nil {
43+ t.Fatal(err)
44+ }
45+ got, err := For(ctx, dir, sha)
46+ if err != nil || len(got) != 1 {
47+ t.Fatalf("For = %v, %v", got, err)
48+ }
49+ if got[0].Runner != "uproar.local" || got[0].Exit != 0 || got[0].Duration != 18 {
50+ t.Errorf("record lost fields: %+v", got[0])
51+ }
52+ if log, _ := Log(ctx, dir, got[0]); log != "$ make ci\nok\n" {
53+ t.Errorf("log = %q", log)
54+ }
55+
56+ // A commit can be built more than once, and both runs survive.
57+ if err := Append(ctx, dir, sha, Record{Runner: "lisa-mbp", Started: 1787074999, Exit: 2}, "boom"); err != nil {
58+ t.Fatal(err)
59+ }
60+ got, _ = For(ctx, dir, sha)
61+ if len(got) != 2 {
62+ t.Fatalf("got %d runs, want 2", len(got))
63+ }
64+ if got[0].Runner != "lisa-mbp" {
65+ t.Errorf("runs are not newest first: %+v", got)
66+ }
67+ if !got[0].Failed() {
68+ t.Error("exit 2 is not reported as failed")
69+ }
70+ }
71+
72+ // Chapter 16 claims build history is readable with git alone, and this is that claim tested.
73+ func TestGitCanReadTheRuns(t *testing.T) {
74+ ctx := context.Background()
75+ dir, sha := repoWithCommit(t)
76+ if err := Append(ctx, dir, sha,
77+ Record{Runner: "uproar.local", Started: 1, Exit: 0}, "ok"); err != nil {
78+ t.Fatal(err)
79+ }
80+ out, err := gitx.Run(ctx, dir, "log", "--show-notes=runs", "--max-count=1")
81+ if err != nil {
82+ t.Fatal(err)
83+ }
84+ if !strings.Contains(out, "Notes (runs)") || !strings.Contains(out, "uproar.local") {
85+ t.Errorf("git log --show-notes=runs did not print the run:\n%s", out)
86+ }
87+ }
88+
89+ // A long log goes to a blob so that reading the notes ref stays cheap.
90+ func TestLargeLogGoesToABlob(t *testing.T) {
91+ ctx := context.Background()
92+ dir, sha := repoWithCommit(t)
93+ big := strings.Repeat("x", inlineLimit+1)
94+ if err := Append(ctx, dir, sha, Record{Runner: "r", Started: 1}, big); err != nil {
95+ t.Fatal(err)
96+ }
97+ got, _ := For(ctx, dir, sha)
98+ if got[0].Output != "" {
99+ t.Error("a large log was inlined")
100+ }
101+ if got[0].Log == "" {
102+ t.Fatal("a large log has no blob")
103+ }
104+ log, err := Log(ctx, dir, got[0])
105+ if err != nil || len(log) != len(big) {
106+ t.Errorf("blob log came back as %d bytes, want %d (%v)", len(log), len(big), err)
107+ }
108+ }
@@ -0,0 +1,184 @@
1+ package search
2+
3+ import (
4+ "context"
5+ "strconv"
6+ "strings"
7+
8+ "github.com/barerepo/server/internal/gitx"
9+ "github.com/barerepo/server/internal/repocfg"
10+ "github.com/barerepo/server/internal/store"
11+ "github.com/barerepo/server/internal/thread"
12+ )
13+
14+ // maxIndexedBytes keeps one generated file out of the whole index. Chapter 17 indexes source.
15+ const maxIndexedBytes = 512 << 10
16+
17+ // Index is what the indexer needs from the database, an interface because a hook is its own process.
18+ type Index interface {
19+ PutDoc(ctx context.Context, d store.Doc, public bool, readers string) error
20+ PutDocs(ctx context.Context, repo, kind string, docs []store.Doc, public bool, readers string) error
21+ DeleteDoc(ctx context.Context, repo, kind, path string) error
22+ SetReadable(ctx context.Context, repo string, public bool, readers string) error
23+ HasDocs(ctx context.Context, repo string) (bool, error)
24+ }
25+
26+ // Target names one repository to the indexer, with everything the read filter needs.
27+ type Target struct {
28+ Owner string
29+ Name string
30+ Dir string
31+ Ref string
32+ Config repocfg.Config
33+ }
34+
35+ func (r Target) full() string { return r.Owner + "/" + r.Name }
36+
37+ func (r Target) readers() string { return store.Readers(r.Owner, r.Config.Access.Push) }
38+
39+ // IndexAll rebuilds every document for one repository, for a first push and for a reindex.
40+ func IndexAll(ctx context.Context, db Index, r Target) error {
41+ if err := indexCodeAll(ctx, db, r); err != nil {
42+ return err
43+ }
44+ if err := IndexThreads(ctx, db, r); err != nil {
45+ return err
46+ }
47+ return indexRepoRow(ctx, db, r)
48+ }
49+
50+ // IndexMeta rewrites what any push can change without touching a file: the read set and the talk.
51+ func IndexMeta(ctx context.Context, db Index, r Target) error {
52+ if err := db.SetReadable(ctx, r.full(), r.Config.Public(), r.readers()); err != nil {
53+ return err
54+ }
55+ if err := indexRepoRow(ctx, db, r); err != nil {
56+ return err
57+ }
58+ return IndexThreads(ctx, db, r)
59+ }
60+
61+ // IndexPush walks only the paths the push changed, which is what chapter 17 means by incremental.
62+ func IndexPush(ctx context.Context, db Index, r Target, old, new string) error {
63+ indexed, err := db.HasDocs(ctx, r.full())
64+ if err != nil {
65+ return err
66+ }
67+ if !indexed || !gitx.ValidRev(old) || strings.Trim(old, "0") == "" {
68+ return indexCodeAll(ctx, db, r)
69+ }
70+ changed, err := gitx.Run(ctx, r.Dir, "diff", "--name-only", "--no-renames", old, new)
71+ if err != nil {
72+ return indexCodeAll(ctx, db, r)
73+ }
74+ var paths []string
75+ for _, p := range strings.Split(strings.TrimRight(changed, "\n"), "\n") {
76+ if p != "" {
77+ paths = append(paths, p)
78+ }
79+ }
80+ return indexCodePaths(ctx, db, r, paths)
81+ }
82+
83+ // indexCodeAll reads every path at the tip, which is the one place a whole tree is walked.
84+ func indexCodeAll(ctx context.Context, db Index, r Target) error {
85+ if !gitx.ValidRev(r.Ref) {
86+ return nil
87+ }
88+ out, err := gitx.Run(ctx, r.Dir, "ls-tree", "-r", "--name-only", r.Ref)
89+ if err != nil {
90+ return nil
91+ }
92+ var paths []string
93+ for _, p := range strings.Split(strings.TrimRight(out, "\n"), "\n") {
94+ if p != "" {
95+ paths = append(paths, p)
96+ }
97+ }
98+ docs, err := readBlobs(ctx, r, paths)
99+ if err != nil {
100+ return err
101+ }
102+ return db.PutDocs(ctx, r.full(), store.Code, docs, r.Config.Public(), r.readers())
103+ }
104+
105+ // indexCodePaths updates the paths a push touched, and drops the ones it removed.
106+ func indexCodePaths(ctx context.Context, db Index, r Target, paths []string) error {
107+ docs, err := readBlobs(ctx, r, paths)
108+ if err != nil {
109+ return err
110+ }
111+ kept := make(map[string]bool, len(docs))
112+ for _, d := range docs {
113+ kept[d.Path] = true
114+ if err := db.PutDoc(ctx, d, r.Config.Public(), r.readers()); err != nil {
115+ return err
116+ }
117+ }
118+ for _, p := range paths {
119+ if kept[p] {
120+ continue
121+ }
122+ if err := db.DeleteDoc(ctx, r.full(), store.Code, p); err != nil {
123+ return err
124+ }
125+ }
126+ return nil
127+ }
128+
129+ // readBlobs reads the named paths at the tip through the object pool, skipping what is not source.
130+ func readBlobs(ctx context.Context, r Target, paths []string) ([]store.Doc, error) {
131+ if len(paths) == 0 || !gitx.ValidRev(r.Ref) {
132+ return nil, nil
133+ }
134+ specs := make([]string, 0, len(paths))
135+ for _, p := range paths {
136+ specs = append(specs, r.Ref+":"+p)
137+ }
138+ objs, err := gitx.Batch(ctx, r.Dir, specs)
139+ if err != nil {
140+ return nil, err
141+ }
142+ docs := make([]store.Doc, 0, len(paths))
143+ for i, p := range paths {
144+ obj := objs[specs[i]]
145+ if obj == nil || obj.Type != "blob" || obj.Size > maxIndexedBytes {
146+ continue
147+ }
148+ if strings.IndexByte(obj.Body, 0) >= 0 {
149+ continue
150+ }
151+ docs = append(docs, store.Doc{Repo: r.full(), Kind: store.Code, Path: p, Body: obj.Body})
152+ }
153+ return docs, nil
154+ }
155+
156+ // IndexThreads rewrites the discussion, which chapter 17 asks for on note write and not on query.
157+ func IndexThreads(ctx context.Context, db Index, r Target) error {
158+ list, err := thread.List(ctx, r.Dir)
159+ if err != nil {
160+ return nil
161+ }
162+ docs := make([]store.Doc, 0, len(list))
163+ for _, sum := range list {
164+ _, comments, err := thread.Read(ctx, r.Dir, sum.N)
165+ if err != nil {
166+ continue
167+ }
168+ var body strings.Builder
169+ for _, c := range comments {
170+ body.WriteString(c.Body)
171+ body.WriteByte('\n')
172+ }
173+ docs = append(docs, store.Doc{Repo: r.full(), Kind: store.Thread,
174+ Path: strconv.Itoa(sum.N), Title: sum.Meta.Title, Body: body.String()})
175+ }
176+ return db.PutDocs(ctx, r.full(), store.Thread, docs, r.Config.Public(), r.readers())
177+ }
178+
179+ // indexRepoRow is the repository itself, matched on its name and its description.
180+ func indexRepoRow(ctx context.Context, db Index, r Target) error {
181+ doc := store.Doc{Repo: r.full(), Kind: store.Repository,
182+ Title: r.full(), Body: r.Config.Repo.Description}
183+ return db.PutDoc(ctx, doc, r.Config.Public(), r.readers())
184+ }
@@ -0,0 +1,122 @@
1+ // Package search reads one index, filtered by read access inside the query. Chapter 17.
2+ package search
3+
4+ import (
5+ "context"
6+ "strings"
7+
8+ "github.com/barerepo/server/internal/store"
9+ )
10+
11+ // Kind orders the set: code first, because a barerepo is usually searched for a symbol.
12+ type Kind int
13+
14+ const (
15+ Code Kind = iota
16+ Thread
17+ Repo
18+ )
19+
20+ func (k Kind) String() string {
21+ switch k {
22+ case Code:
23+ return "code"
24+ case Thread:
25+ return "thread"
26+ default:
27+ return "repo"
28+ }
29+ }
30+
31+ // Result is one row of one set, so every row names the repository it came from.
32+ type Result struct {
33+ Kind Kind
34+ Owner string
35+ Name string
36+ Path string // code: the file. thread: the number.
37+ Line int
38+ Text string // the matching line, or the thread title
39+ Context string
40+ Href string
41+ }
42+
43+ // Finder is the index query, an interface so the page depends on the question and not the schema.
44+ type Finder interface {
45+ FindDocs(ctx context.Context, viewer, query string, limit int) ([]store.Doc, error)
46+ }
47+
48+ // Search asks the index once. The read filter is inside that query, never applied to the answer.
49+ func Search(ctx context.Context, db Finder, viewer, query string, limit int) []Result {
50+ query = strings.TrimSpace(query)
51+ if query == "" || limit <= 0 || db == nil {
52+ return nil
53+ }
54+ docs, err := db.FindDocs(ctx, viewer, query, limit)
55+ if err != nil {
56+ return nil
57+ }
58+ out := make([]Result, 0, len(docs))
59+ for _, d := range docs {
60+ if len(out) >= limit {
61+ break
62+ }
63+ owner, name, ok := strings.Cut(d.Repo, "/")
64+ if !ok {
65+ continue
66+ }
67+ switch d.Kind {
68+ case store.Code:
69+ for _, m := range matches(d.Body, query, linesPerFile) {
70+ out = append(out, Result{Kind: Code, Owner: owner, Name: name, Path: d.Path,
71+ Line: m.n, Text: m.text,
72+ Href: "/" + d.Repo + "/file/" + defaultRef + "/" + d.Path})
73+ }
74+ case store.Thread:
75+ line, _ := match(d.Body, query)
76+ out = append(out, Result{Kind: Thread, Owner: owner, Name: name, Path: d.Path,
77+ Text: d.Title, Context: line,
78+ Href: "/" + d.Repo + "/thread/" + d.Path})
79+ default:
80+ out = append(out, Result{Kind: Repo, Owner: owner, Name: name,
81+ Text: d.Title, Context: d.Body, Href: "/" + d.Repo})
82+ }
83+ }
84+ return out
85+ }
86+
87+ // defaultRef is what a file link resolves through, because HEAD is the branch this index was built from.
88+ const defaultRef = "HEAD"
89+
90+ // linesPerFile bounds one file's share of a result set, so a common word cannot fill the page.
91+ const linesPerFile = 3
92+
93+ // hit is one matching line and the number it sits on.
94+ type hit struct {
95+ text string
96+ n int
97+ }
98+
99+ // matches finds where the query is, so a reader sees the occurrences and not only the first.
100+ func matches(body, query string, limit int) []hit {
101+ want := strings.ToLower(query)
102+ lines := strings.Split(body, "\n")
103+ var out []hit
104+ for i, line := range lines {
105+ if !strings.Contains(strings.ToLower(line), want) {
106+ continue
107+ }
108+ out = append(out, hit{strings.TrimRight(line, "\r"), i + 1})
109+ if len(out) == limit {
110+ break
111+ }
112+ }
113+ return out
114+ }
115+
116+ // match is one line, for a thread, where the excerpt is context rather than a result of its own.
117+ func match(body, query string) (string, int) {
118+ if m := matches(body, query, 1); len(m) == 1 {
119+ return m[0].text, m[0].n
120+ }
121+ return "", 0
122+ }
@@ -0,0 +1,85 @@
1+ // Package sshx allows git's three verbs out of SSH_ORIGINAL_COMMAND and never uses a shell. 41.3.
2+ package sshx
3+
4+ import (
5+ "errors"
6+ "fmt"
7+ "strings"
8+
9+ "github.com/barerepo/server/internal/gitx"
10+ )
11+
12+ // Request is a parsed SSH_ORIGINAL_COMMAND.
13+ type Request struct {
14+ Verb string // git-upload-pack, git-receive-pack, git-upload-archive
15+ Owner string
16+ Name string
17+ }
18+
19+ func (r Request) Write() bool { return r.Verb == "git-receive-pack" }
20+
21+ // allowed is a list, not a pattern, because a pattern admits a fourth verb nobody decided on.
22+ var allowed = map[string]bool{
23+ "git-upload-pack": true,
24+ "git-receive-pack": true,
25+ "git-upload-archive": true,
26+ }
27+
28+ var errRefused = errors.New("barerepo accepts git over this connection and nothing else")
29+
30+ // Parse accepts a verb, a space and a quoted path, where `;` and `&&` are only bad names.
31+ func Parse(cmd string) (Request, error) {
32+ cmd = strings.TrimSpace(cmd)
33+ if cmd == "" {
34+ return Request{}, fmt.Errorf("this account has no shell. %w", errRefused)
35+ }
36+ verb, rest, ok := strings.Cut(cmd, " ")
37+ if !ok || !allowed[verb] {
38+ return Request{}, fmt.Errorf("%q is not a git command. %w", firstWord(cmd), errRefused)
39+ }
40+ path, err := unquote(strings.TrimSpace(rest))
41+ if err != nil {
42+ return Request{}, err
43+ }
44+ owner, name, err := splitPath(path)
45+ if err != nil {
46+ return Request{}, err
47+ }
48+ return Request{Verb: verb, Owner: owner, Name: name}, nil
49+ }
50+
51+ // unquote refuses an unquoted argument, because git always quotes the path it sends.
52+ func unquote(s string) (string, error) {
53+ if len(s) < 2 || s[0] != '\'' || s[len(s)-1] != '\'' {
54+ return "", fmt.Errorf("malformed repository argument")
55+ }
56+ s = s[1 : len(s)-1]
57+ if strings.Contains(s, "'") {
58+ return "", fmt.Errorf("malformed repository argument")
59+ }
60+ return s, nil
61+ }
62+
63+ // splitPath takes an owner and a name from git's four spellings, and builds no filesystem path.
64+ func splitPath(p string) (owner, name string, err error) {
65+ p = strings.TrimPrefix(p, "~")
66+ p = strings.TrimPrefix(p, "/")
67+ p = strings.TrimSuffix(p, "/")
68+ p = strings.TrimSuffix(p, ".git")
69+
70+ owner, name, ok := strings.Cut(p, "/")
71+ if !ok || strings.Contains(name, "/") {
72+ return "", "", fmt.Errorf("a repository is <account>/<name>, got %q", p)
73+ }
74+ if !gitx.ValidName(owner) || !gitx.ValidRepoName(name) {
75+ return "", "", fmt.Errorf("no such repository")
76+ }
77+ return owner, name, nil
78+ }
79+
80+ func firstWord(s string) string {
81+ if w, _, ok := strings.Cut(s, " "); ok {
82+ return w
83+ }
84+ return s
85+ }
@@ -0,0 +1,73 @@
1+ package sshx
2+
3+ import "testing"
4+
5+ func TestParse(t *testing.T) {
6+ good := map[string]Request{
7+ "git-upload-pack 'john/johnbot.git'": {"git-upload-pack", "john", "johnbot"},
8+ "git-upload-pack 'john/johnbot'": {"git-upload-pack", "john", "johnbot"},
9+ "git-receive-pack '/john/johnbot.git'": {"git-receive-pack", "john", "johnbot"},
10+ "git-receive-pack '~/john/johnbot.git'": {"git-receive-pack", "john", "johnbot"},
11+ "git-upload-archive 'john/johnbot.git'": {"git-upload-archive", "john", "johnbot"},
12+ " git-upload-pack 'john/johnbot.git' ": {"git-upload-pack", "john", "johnbot"},
13+ }
14+ for cmd, want := range good {
15+ got, err := Parse(cmd)
16+ if err != nil || got != want {
17+ t.Errorf("Parse(%q) = %+v, %v; want %+v", cmd, got, err, want)
18+ }
19+ }
20+
21+ // Chapter 45.4: every one must fail, and none may reach a shell to fail in.
22+ bad := []string{
23+ "",
24+ "rm -rf /",
25+ "bash",
26+ "git-upload-pack",
27+ "git-upload-pack 'john/johnbot.git'; rm -rf /",
28+ "git-upload-pack 'john/johnbot.git' && rm -rf /",
29+ "git-upload-pack '$(rm -rf /)'",
30+ "git-upload-pack '`rm -rf /`'",
31+ "git-upload-pack '../../etc/passwd'",
32+ "git-upload-pack 'john/../../etc'",
33+ "git-upload-pack 'john'",
34+ "git-upload-pack 'john/john/bot'",
35+ "git-upload-pack john/johnbot.git",
36+ "git-upload-pack 'john/johnbot.git",
37+ "git-shell -c 'git-upload-pack'",
38+ "git-receive-pack '--upload-pack=/bin/sh'",
39+ "scp -t /tmp/x",
40+ }
41+ for _, cmd := range bad {
42+ if got, err := Parse(cmd); err == nil {
43+ t.Errorf("Parse(%q) = %+v, want an error", cmd, got)
44+ }
45+ }
46+ }
47+
48+ // A system-looking path is only the account "etc", because containment is repo.Dir's job.
49+ func TestSystemLookingPathIsJustAName(t *testing.T) {
50+ got, err := Parse("git-upload-pack '/etc/passwd'")
51+ if err != nil {
52+ t.Fatalf("Parse: %v", err)
53+ }
54+ if want := (Request{"git-upload-pack", "etc", "passwd"}); got != want {
55+ t.Errorf("got %+v, want %+v", got, want)
56+ }
57+ }
58+
59+ func TestWrite(t *testing.T) {
60+ for cmd, want := range map[string]bool{
61+ "git-receive-pack 'john/johnbot.git'": true,
62+ "git-upload-pack 'john/johnbot.git'": false,
63+ "git-upload-archive 'john/johnbot.git'": false,
64+ } {
65+ r, err := Parse(cmd)
66+ if err != nil {
67+ t.Fatal(err)
68+ }
69+ if r.Write() != want {
70+ t.Errorf("%q: Write() = %v, want %v", cmd, r.Write(), want)
71+ }
72+ }
73+ }
@@ -0,0 +1,53 @@
1+ package sshx
2+
3+ import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "os"
8+
9+ "github.com/barerepo/server/internal/gitx"
10+ "github.com/barerepo/server/internal/hook"
11+ "github.com/barerepo/server/internal/transport"
12+ )
13+
14+ // Serve decides whether this named account may do this, then hands git the connection.
15+ func Serve(ctx context.Context, t *transport.Server, account, cmd string) error {
16+ r, err := Parse(cmd)
17+ if err != nil {
18+ return err
19+ }
20+ intent := transport.Read
21+ if r.Write() {
22+ // ssh runs one receive-pack per push, so there is no advertise to hold creation back from.
23+ intent = transport.Write
24+ }
25+ res, err := t.Open(ctx, r.Owner, r.Name, account, intent)
26+ var moved transport.Redirect
27+ switch {
28+ case errors.As(err, &moved):
29+ // ssh has no redirect, so say where it went and what to run about it.
30+ user := t.Cfg.Server.SSHUser
31+ if user == "" {
32+ user = "git"
33+ }
34+ return fmt.Errorf("%s/%s moved to %s/%s. update your remote:\n"+
35+ " git remote set-url origin %s@%s:%s/%s",
36+ r.Owner, r.Name, moved.Owner, moved.Name,
37+ user, t.Cfg.Server.SSHHost, moved.Owner, moved.Name)
38+ case err != nil:
39+ return err
40+ }
41+ if res.Warning != "" {
42+ fmt.Fprintln(os.Stderr, res.Warning)
43+ }
44+
45+ env := hook.Env{
46+ Account: account, Owner: res.Owner, Name: res.Name, Dir: res.Dir,
47+ Created: res.Created,
48+ URL: t.Cfg.Server.ExternalURL + "/" + res.Owner + "/" + res.Name,
49+ Config: t.Cfg.Path,
50+ }
51+ verb := r.Verb[len("git-"):]
52+ return gitx.Pipe(ctx, res.Dir, os.Stdin, os.Stdout, os.Stderr, env.Vars(), verb, ".")
53+ }
@@ -0,0 +1,492 @@
1+ package store
2+
3+ import (
4+ "context"
5+ "database/sql"
6+ "errors"
7+ "fmt"
8+ "strings"
9+ "time"
10+
11+ "golang.org/x/crypto/ssh"
12+
13+ "github.com/barerepo/server/internal/gitx"
14+ "github.com/barerepo/server/internal/repo"
15+ )
16+
17+ var (
18+ ErrNotFound = errors.New("not found")
19+ ErrTaken = errors.New("already taken")
20+ )
21+
22+ type Account struct {
23+ Name string
24+ Admin bool
25+ Created time.Time
26+ }
27+
28+ type PubKey struct {
29+ ID int64
30+ Account string
31+ Fingerprint string
32+ Algo string
33+ Blob string // the full authorized_keys line body, without options
34+ Comment string
35+ Created time.Time
36+ LastUsed time.Time
37+ // Retired is when the key stopped granting access, and the zero time means it still does.
38+ Retired time.Time
39+ }
40+
41+ // ParsePubKey reads authorized_keys form and rejects a private key pasted by mistake. 45.4.
42+ func ParsePubKey(raw string) (algo, blob, comment string, fingerprint string, err error) {
43+ raw = strings.TrimSpace(raw)
44+ if strings.Contains(raw, "PRIVATE KEY") {
45+ return "", "", "", "", errors.New("that is a private key. paste the .pub file instead, and treat the key you pasted as compromised")
46+ }
47+ key, comment, _, _, err := ssh.ParseAuthorizedKey([]byte(raw))
48+ if err != nil {
49+ return "", "", "", "", errors.New("that does not parse as an ssh public key")
50+ }
51+ switch key.Type() {
52+ case ssh.KeyAlgoED25519, ssh.KeyAlgoECDSA256, ssh.KeyAlgoECDSA384, ssh.KeyAlgoECDSA521, ssh.KeyAlgoRSA:
53+ default:
54+ return "", "", "", "", fmt.Errorf("unsupported key type %s", key.Type())
55+ }
56+ line := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(key)))
57+ fields := strings.Fields(line)
58+ return fields[0], fields[1], comment, ssh.FingerprintSHA256(key), nil
59+ }
60+
61+ // CheckNewAccount refuses a taken name before the challenge, not after the person has signed.
62+ func (db *DB) CheckNewAccount(ctx context.Context, name, rawKey string) error {
63+ if !gitx.ValidName(name) {
64+ if gitx.Reserved(name) {
65+ // A name a page already uses is not available, and why is nobody else's business.
66+ return fmt.Errorf("the name %q is %w", name, ErrTaken)
67+ }
68+ return fmt.Errorf("a name is 1 to 39 characters of a-z, 0-9 and -, and starts with a letter or digit")
69+ }
70+ if _, err := db.Account(ctx, name); err == nil {
71+ return fmt.Errorf("the name %q is %w", name, ErrTaken)
72+ }
73+ _, _, _, fp, err := ParsePubKey(rawKey)
74+ if err != nil {
75+ return err
76+ }
77+ var n int
78+ if err := db.QueryRowContext(ctx,
79+ `SELECT count(*) FROM pubkeys WHERE fingerprint = ?`, fp).Scan(&n); err != nil {
80+ return err
81+ }
82+ if n > 0 {
83+ return fmt.Errorf("that key is %w by another account", ErrTaken)
84+ }
85+ return nil
86+ }
87+
88+ // CreateAccount writes account and first key in one transaction, since neither works alone.
89+ func (db *DB) CreateAccount(ctx context.Context, name, rawKey string, admin bool) (*Account, error) {
90+ if !gitx.ValidName(name) {
91+ if gitx.Reserved(name) {
92+ // The same answer as a taken name, because they are the same answer to the visitor.
93+ return nil, fmt.Errorf("the name %q is %w", name, ErrTaken)
94+ }
95+ return nil, fmt.Errorf("a name is 1 to 39 characters of a-z, 0-9 and -, and starts with a letter or digit")
96+ }
97+ algo, blob, comment, fp, err := ParsePubKey(rawKey)
98+ if err != nil {
99+ return nil, err
100+ }
101+ now := now()
102+ tx, err := db.Begin(ctx)
103+ if err != nil {
104+ return nil, err
105+ }
106+ defer tx.Rollback()
107+
108+ _, err = tx.ExecContext(ctx, `INSERT INTO accounts (name, admin, created_at) VALUES (?, ?, ?)`,
109+ name, boolInt(admin), now.Unix())
110+ if err != nil {
111+ if isUnique(err) {
112+ return nil, fmt.Errorf("the name %q is %w", name, ErrTaken)
113+ }
114+ return nil, err
115+ }
116+ _, err = tx.ExecContext(ctx,
117+ `INSERT INTO pubkeys (account, fingerprint, algo, blob, comment, created_at) VALUES (?, ?, ?, ?, ?, ?)`,
118+ name, fp, algo, blob, comment, now.Unix())
119+ if err != nil {
120+ if isUnique(err) {
121+ return nil, fmt.Errorf("that key is %w by another account", ErrTaken)
122+ }
123+ return nil, err
124+ }
125+ if err := tx.Commit(); err != nil {
126+ return nil, err
127+ }
128+ // The file that grants ssh access is a copy of this table, so it is rewritten with it.
129+ if err := db.SyncAuthorizedKeys(ctx); err != nil {
130+ return nil, err
131+ }
132+ return &Account{Name: name, Admin: admin, Created: now}, nil
133+ }
134+
135+ func (db *DB) Account(ctx context.Context, name string) (*Account, error) {
136+ var a Account
137+ var admin int
138+ var created int64
139+ err := db.QueryRowContext(ctx, `SELECT name, admin, created_at FROM accounts WHERE name = ?`, name).
140+ Scan(&a.Name, &admin, &created)
141+ if errors.Is(err, sql.ErrNoRows) {
142+ return nil, ErrNotFound
143+ }
144+ if err != nil {
145+ return nil, err
146+ }
147+ a.Admin = admin == 1
148+ a.Created = time.Unix(created, 0)
149+ return &a, nil
150+ }
151+
152+ // AddKey stores another key, which chapter 10 urges, because losing every key loses the account.
153+ func (db *DB) AddKey(ctx context.Context, account, rawKey string) (*PubKey, error) {
154+ algo, blob, comment, fp, err := ParsePubKey(rawKey)
155+ if err != nil {
156+ return nil, err
157+ }
158+ now := now()
159+ // RETURNING rather than LastInsertId, which PostgreSQL does not have.
160+ var id int64
161+ err = db.QueryRowContext(ctx,
162+ `INSERT INTO pubkeys (account, fingerprint, algo, blob, comment, created_at)
163+ VALUES (?, ?, ?, ?, ?, ?) RETURNING id`,
164+ account, fp, algo, blob, comment, now.Unix()).Scan(&id)
165+ if err != nil {
166+ if isUnique(err) {
167+ return nil, fmt.Errorf("that key is %w", ErrTaken)
168+ }
169+ return nil, err
170+ }
171+ if err := db.SyncAuthorizedKeys(ctx); err != nil {
172+ return nil, err
173+ }
174+ return &PubKey{ID: id, Account: account, Fingerprint: fp, Algo: algo, Blob: blob, Comment: comment, Created: now}, nil
175+ }
176+
177+ // Keys lists an account's keys, oldest first.
178+ func (db *DB) Keys(ctx context.Context, account string) ([]PubKey, error) {
179+ rows, err := db.QueryContext(ctx,
180+ `SELECT id, account, fingerprint, algo, blob, comment, created_at, last_used
181+ FROM pubkeys WHERE account = ? AND retired_at IS NULL ORDER BY created_at, id`, account)
182+ if err != nil {
183+ return nil, err
184+ }
185+ defer rows.Close()
186+ var out []PubKey
187+ for rows.Next() {
188+ var k PubKey
189+ var created int64
190+ var used sql.NullInt64
191+ if err := rows.Scan(&k.ID, &k.Account, &k.Fingerprint, &k.Algo, &k.Blob, &k.Comment, &created, &used); err != nil {
192+ return nil, err
193+ }
194+ k.Created = time.Unix(created, 0)
195+ if used.Valid {
196+ k.LastUsed = time.Unix(used.Int64, 0)
197+ }
198+ out = append(out, k)
199+ }
200+ return out, rows.Err()
201+ }
202+
203+ // SSHDir is where authorized_keys is written, and empty writes none. Set once at start.
204+ var SSHDir string
205+
206+ // SSHBin is the program authorized_keys forces, which is this one.
207+ var SSHBin string
208+
209+ // SignersPath is where the keys a commit signature is checked against are written, and empty writes none.
210+ var SignersPath string
211+
212+ // SyncAuthorizedKeys rewrites the file from the database, which is the thing that grants access.
213+ func (db *DB) SyncAuthorizedKeys(ctx context.Context) error {
214+ if SSHDir == "" && SignersPath == "" {
215+ return nil
216+ }
217+ // Every key ever published, because a retired one still vouches for what it signed while it was live.
218+ if SignersPath != "" {
219+ all, err := db.AllKeysEverPublished(ctx)
220+ if err != nil {
221+ return err
222+ }
223+ signers := make([]repo.AuthKey, 0, len(all))
224+ for _, k := range all {
225+ signers = append(signers, repo.AuthKey{Account: k.Account, Algo: k.Algo, Blob: k.Blob, Retired: k.Retired})
226+ }
227+ if err := repo.WriteAllowedSigners(SignersPath, signers); err != nil {
228+ return err
229+ }
230+ }
231+ if SSHDir == "" {
232+ return nil
233+ }
234+ // Only the live keys open a door, so a retired one is gone from authorized_keys the moment it retires.
235+ keys, err := db.AllKeys(ctx)
236+ if err != nil {
237+ return err
238+ }
239+ out := make([]repo.AuthKey, 0, len(keys))
240+ for _, k := range keys {
241+ out = append(out, repo.AuthKey{Account: k.Account, Algo: k.Algo, Blob: k.Blob})
242+ }
243+ return repo.WriteAuthorizedKeys(SSHDir, SSHBin, out)
244+ }
245+
246+ // AllKeys lists every key that still grants access, for writing authorized_keys.
247+ func (db *DB) AllKeys(ctx context.Context) ([]PubKey, error) {
248+ return db.allKeys(ctx, true)
249+ }
250+
251+ // AllKeysEverPublished lists retired keys too, because a retired key still vouches for what it signed.
252+ func (db *DB) AllKeysEverPublished(ctx context.Context) ([]PubKey, error) {
253+ return db.allKeys(ctx, false)
254+ }
255+
256+ func (db *DB) allKeys(ctx context.Context, liveOnly bool) ([]PubKey, error) {
257+ where := ""
258+ if liveOnly {
259+ where = " WHERE retired_at IS NULL"
260+ }
261+ rows, err := db.QueryContext(ctx,
262+ `SELECT id, account, fingerprint, algo, blob, comment, created_at, last_used, retired_at
263+ FROM pubkeys`+where+` ORDER BY account, id`)
264+ if err != nil {
265+ return nil, err
266+ }
267+ defer rows.Close()
268+ var out []PubKey
269+ for rows.Next() {
270+ var k PubKey
271+ var created int64
272+ var used, retired sql.NullInt64
273+ if err := rows.Scan(&k.ID, &k.Account, &k.Fingerprint, &k.Algo, &k.Blob, &k.Comment, &created, &used, &retired); err != nil {
274+ return nil, err
275+ }
276+ k.Created = time.Unix(created, 0)
277+ if used.Valid {
278+ k.LastUsed = time.Unix(used.Int64, 0)
279+ }
280+ if retired.Valid {
281+ k.Retired = time.Unix(retired.Int64, 0)
282+ }
283+ out = append(out, k)
284+ }
285+ return out, rows.Err()
286+ }
287+
288+ // DeleteKey refuses the last one, because an account with no key cannot sign in or recover.
289+ func (db *DB) DeleteKey(ctx context.Context, account string, id int64) error {
290+ // The count belongs inside the delete, or two keys removed at once are both the one that stays.
291+ res, err := db.ExecContext(ctx,
292+ `UPDATE pubkeys SET retired_at = ? WHERE account = ? AND id = ? AND retired_at IS NULL
293+ AND (SELECT count(*) FROM pubkeys WHERE account = ? AND retired_at IS NULL) > 1`,
294+ now().Unix(), account, id, account)
295+ if err != nil {
296+ return err
297+ }
298+ if n, _ := res.RowsAffected(); n > 0 {
299+ return db.SyncAuthorizedKeys(ctx)
300+ }
301+ // Nothing went, so read which of the two reasons to give.
302+ var left int
303+ if err := db.QueryRowContext(ctx,
304+ `SELECT count(*) FROM pubkeys WHERE account = ? AND retired_at IS NULL`, account).Scan(&left); err != nil {
305+ return err
306+ }
307+ if left <= 1 {
308+ return errors.New("this is your only key. add another one first, or the account becomes unreachable")
309+ }
310+ return ErrNotFound
311+ }
312+
313+ func boolInt(b bool) int {
314+ if b {
315+ return 1
316+ }
317+ return 0
318+ }
319+
320+ // now is the one clock this package reads, so a test can hold it still.
321+ var now = time.Now
322+
323+ // isUnique reads either dialect's wording, because database/sql exposes no shared value.
324+ func isUnique(err error) bool {
325+ if err == nil {
326+ return false
327+ }
328+ msg := err.Error()
329+ return strings.Contains(msg, "UNIQUE constraint failed") || // sqlite
330+ strings.Contains(msg, "SQLSTATE 23505") || // postgres
331+ strings.Contains(msg, "duplicate key value")
332+ }
333+
334+ // ReposOf lists the repository names in an account's namespace.
335+ func (db *DB) ReposOf(ctx context.Context, owner string) ([]string, error) {
336+ rows, err := db.QueryContext(ctx,
337+ `SELECT name FROM repos WHERE owner = ? ORDER BY name`, owner)
338+ if err != nil {
339+ return nil, err
340+ }
341+ defer rows.Close()
342+ var out []string
343+ for rows.Next() {
344+ var n string
345+ if err := rows.Scan(&n); err != nil {
346+ return nil, err
347+ }
348+ out = append(out, n)
349+ }
350+ return out, rows.Err()
351+ }
352+
353+ // TouchKey records a sign-in, which the keys page shows so a lost key can be spotted. 32.5.
354+ func (db *DB) TouchKey(ctx context.Context, id int64) error {
355+ _, err := db.ExecContext(ctx, `UPDATE pubkeys SET last_used = ? WHERE id = ? AND retired_at IS NULL`, now().Unix(), id)
356+ return err
357+ }
358+
359+ // Accounts lists every account name.
360+ func (db *DB) Accounts(ctx context.Context) ([]string, error) {
361+ rows, err := db.QueryContext(ctx, `SELECT name FROM accounts ORDER BY name`)
362+ if err != nil {
363+ return nil, err
364+ }
365+ defer rows.Close()
366+ var out []string
367+ for rows.Next() {
368+ var n string
369+ if err := rows.Scan(&n); err != nil {
370+ return nil, err
371+ }
372+ out = append(out, n)
373+ }
374+ return out, rows.Err()
375+ }
376+
377+ // AccountsExist answers which of these names are accounts, in one query, so a page can link only what resolves.
378+ func (db *DB) AccountsExist(ctx context.Context, names []string) (map[string]bool, error) {
379+ out := map[string]bool{}
380+ if len(names) == 0 {
381+ return out, nil
382+ }
383+ // A git author name is whatever the committer set locally, so most of these will not be accounts.
384+ holes := make([]string, len(names))
385+ args := make([]any, len(names))
386+ for i, n := range names {
387+ holes[i] = "?"
388+ args[i] = n
389+ }
390+ rows, err := db.QueryContext(ctx,
391+ `SELECT name FROM accounts WHERE name IN (`+strings.Join(holes, ",")+`)`, args...)
392+ if err != nil {
393+ return nil, err
394+ }
395+ defer rows.Close()
396+ for rows.Next() {
397+ var n string
398+ if err := rows.Scan(&n); err != nil {
399+ return nil, err
400+ }
401+ out[n] = true
402+ }
403+ return out, rows.Err()
404+ }
405+
406+ // Move leaves a redirect behind forever, because a 404 breaks every clone and link. 44.2.
407+ func (db *DB) Move(ctx context.Context, oldOwner, oldName, newOwner, newName string) error {
408+ if !gitx.ValidName(newOwner) || !gitx.ValidRepoName(newName) {
409+ return fmt.Errorf("%q is not a usable name", newOwner+"/"+newName)
410+ }
411+ if _, err := db.Account(ctx, newOwner); err != nil {
412+ return fmt.Errorf("there is no account named %s", newOwner)
413+ }
414+ tx, err := db.Begin(ctx)
415+ if err != nil {
416+ return err
417+ }
418+ defer tx.Rollback()
419+
420+ var taken int
421+ if err := tx.QueryRowContext(ctx,
422+ `SELECT count(*) FROM repos WHERE owner = ? AND name = ?`, newOwner, newName).Scan(&taken); err != nil {
423+ return err
424+ }
425+ if taken > 0 {
426+ return fmt.Errorf("%s/%s already exists", newOwner, newName)
427+ }
428+ // The old name is never freed, or it inherits the old identity's threads. Chapter 21.2.
429+ if _, err := tx.ExecContext(ctx,
430+ `UPDATE repos SET owner = ?, name = ? WHERE owner = ? AND name = ?`,
431+ newOwner, newName, oldOwner, oldName); err != nil {
432+ return err
433+ }
434+ if _, err := tx.ExecContext(ctx,
435+ `INSERT INTO redirects (old_owner, old_name, new_owner, new_name, created_at)
436+ VALUES (?, ?, ?, ?, ?)`,
437+ oldOwner, oldName, newOwner, newName, now().Unix()); err != nil {
438+ return err
439+ }
440+ // Collapse the two hops, so an old clone follows one redirect and not a chain.
441+ if _, err := tx.ExecContext(ctx,
442+ `UPDATE redirects SET new_owner = ?, new_name = ?
443+ WHERE new_owner = ? AND new_name = ? AND NOT (old_owner = ? AND old_name = ?)`,
444+ newOwner, newName, oldOwner, oldName, oldOwner, oldName); err != nil {
445+ return err
446+ }
447+ // Every other table keys on the path, and one left behind is a runner that never builds again.
448+ from, to := oldOwner+"/"+oldName, newOwner+"/"+newName
449+ for _, q := range []string{
450+ `UPDATE tokens SET scope = ? WHERE scope = ?`,
451+ `UPDATE runners SET repo = ? WHERE repo = ?`,
452+ `UPDATE jobs SET repo = ? WHERE repo = ?`,
453+ `UPDATE events SET repo = ? WHERE repo = ?`,
454+ `UPDATE participation SET repo = ? WHERE repo = ?`,
455+ `UPDATE webhooks SET repo = ? WHERE repo = ?`,
456+ `UPDATE search_docs SET repo = ? WHERE repo = ?`,
457+ } {
458+ if _, err := tx.ExecContext(ctx, q, to, from); err != nil {
459+ return err
460+ }
461+ }
462+ return tx.Commit()
463+ }
464+
465+ // Forget drops the ownership row, while trash keeps the name claimed for its window. 44.4.
466+ func (db *DB) Forget(ctx context.Context, owner, name string) error {
467+ tx, err := db.Begin(ctx)
468+ if err != nil {
469+ return err
470+ }
471+ defer tx.Rollback()
472+ if _, err := tx.ExecContext(ctx,
473+ `DELETE FROM repos WHERE owner = ? AND name = ?`, owner, name); err != nil {
474+ return err
475+ }
476+ // Nothing may outlive the repository, or a token, a runner and an inbox line point at a 404.
477+ repo := owner + "/" + name
478+ for _, q := range []string{
479+ `DELETE FROM runners WHERE repo = ?`,
480+ `DELETE FROM tokens WHERE scope = ?`,
481+ `DELETE FROM jobs WHERE repo = ?`,
482+ `DELETE FROM events WHERE repo = ?`,
483+ `DELETE FROM participation WHERE repo = ?`,
484+ `DELETE FROM webhooks WHERE repo = ?`,
485+ `DELETE FROM search_docs WHERE repo = ?`,
486+ } {
487+ if _, err := tx.ExecContext(ctx, q, repo); err != nil {
488+ return err
489+ }
490+ }
491+ return tx.Commit()
492+ }
@@ -0,0 +1,59 @@
1+ package store
2+
3+ import (
4+ "regexp"
5+ "sort"
6+ "strings"
7+ "testing"
8+ )
9+
10+ // owner names the chapter 10 item a table belongs to, since one with no item makes rule 3 false.
11+ var owner = map[string]string{
12+ "accounts": "1. account name to public keys",
13+ "pubkeys": "1. account name to public keys",
14+ "gpgkeys": "1. account name to public keys",
15+ "repos": "2. namespace ownership",
16+ "tokens": "3. tokens",
17+ "challenges": "3. tokens",
18+ "signup_challenges": "3. tokens",
19+ "redirects": "4. namespace redirects",
20+ "events": "6. caches, indexes and derived state",
21+ "participation": "6. caches, indexes and derived state",
22+ "search_docs": "6. caches, indexes and derived state",
23+ "runners": "7. work in flight",
24+ "jobs": "7. work in flight",
25+ "webhooks": "7. work in flight",
26+ "webhook_cursor": "7. work in flight",
27+ }
28+
29+ var createTable = regexp.MustCompile(`CREATE TABLE ([a-z_]+)`)
30+
31+ // Rule 3: nothing is stored that is not a git object, except a closed list. This is that list.
32+ func TestEveryTableIsOnTheClosedList(t *testing.T) {
33+ found := map[string]bool{}
34+ for _, m := range migrations {
35+ for _, hit := range createTable.FindAllStringSubmatch(m.sqlite, -1) {
36+ found[hit[1]] = true
37+ }
38+ }
39+ if len(found) < 10 {
40+ t.Fatalf("only found %d tables, so this test is reading the wrong thing", len(found))
41+ }
42+ for name := range found {
43+ if _, ok := owner[name]; !ok {
44+ t.Errorf("the table %q is on no item of chapter 10's closed list. either it belongs "+
45+ "to one, or the list has grown and the chapter has to say so", name)
46+ }
47+ }
48+ // The other direction, so a dropped table does not leave a claim about storage that is not made.
49+ var stale []string
50+ for name := range owner {
51+ if !found[name] {
52+ stale = append(stale, name)
53+ }
54+ }
55+ sort.Strings(stale)
56+ if len(stale) > 0 {
57+ t.Errorf("the closed list claims %s, and no migration makes them", strings.Join(stale, ", "))
58+ }
59+ }
@@ -0,0 +1,183 @@
1+ package store
2+
3+ import (
4+ "context"
5+ "database/sql"
6+ "strings"
7+ "time"
8+ )
9+
10+ // Event kinds from chapter 19.1, without run.succeeded, because a green build is not news.
11+ const (
12+ ProposalOpened = "proposal.opened"
13+ ProposalUpdated = "proposal.updated"
14+ ProposalMerged = "proposal.merged"
15+ ThreadOpened = "thread.opened"
16+ ThreadReplied = "thread.replied"
17+ ThreadClosed = "thread.closed"
18+ Pushed = "push"
19+ RunFailed = "run.failed"
20+ RepoTransferred = "repo.transferred"
21+ )
22+
23+ // Kinds is chapter 19.1's list, complete, so a name outside it is a typo and not a future event.
24+ var Kinds = []string{
25+ ProposalOpened, ProposalUpdated, ProposalMerged,
26+ ThreadOpened, ThreadReplied, ThreadClosed,
27+ Pushed, RunFailed, RepoTransferred,
28+ }
29+
30+ // KnownKind reports whether a name is one barerepo will ever send. Chapter 23.2 lets a file name any.
31+ func KnownKind(name string) bool {
32+ for _, k := range Kinds {
33+ if k == name {
34+ return true
35+ }
36+ }
37+ return false
38+ }
39+
40+ // EventLife bounds the inbox, which is a feed, because the repository is the archive. 19.3.
41+ const EventLife = 90 * 24 * time.Hour
42+
43+ type Event struct {
44+ ID int64
45+ Kind string
46+ Actor string
47+ Repo string
48+ Ref string
49+ Number int
50+ Title string
51+ // Detail is the row's second line where Title is spoken for, as a failed build's is. 19.3.
52+ Detail string
53+ Created time.Time
54+ }
55+
56+ // Record writes an event, and a failure must never fail the push, so callers log and carry on.
57+ func (db *DB) Record(ctx context.Context, e Event) error {
58+ _, err := db.ExecContext(ctx,
59+ `INSERT INTO events (kind, actor, repo, ref, number, title, detail, created_at)
60+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
61+ e.Kind, e.Actor, e.Repo, e.Ref, e.Number, e.Title, e.Detail, now().Unix())
62+ return err
63+ }
64+
65+ // TookPart is the subscription: there is no watch button, only touching a thread. 19.2.
66+ func (db *DB) TookPart(ctx context.Context, account, repo string, number int) error {
67+ if account == "" || number == 0 {
68+ return nil
69+ }
70+ _, err := db.ExecContext(ctx,
71+ `INSERT INTO participation (account, repo, number) VALUES (?, ?, ?)
72+ ON CONFLICT DO NOTHING`, account, repo, number)
73+ return err
74+ }
75+
76+ // Inbox is what this account owns, plus every thread it took part in.
77+ func (db *DB) Inbox(ctx context.Context, account string, limit int) ([]Event, error) {
78+ cutoff := now().Add(-EventLife).Unix()
79+ rows, err := db.QueryContext(ctx,
80+ `SELECT id, kind, actor, repo, ref, number, title, detail, created_at FROM events
81+ WHERE created_at > ?
82+ AND (repo LIKE ? OR EXISTS (
83+ SELECT 1 FROM participation p
84+ WHERE p.account = ? AND p.repo = events.repo AND p.number = events.number))
85+ ORDER BY id DESC LIMIT ?`,
86+ cutoff, account+"/%", account, limit)
87+ if err != nil {
88+ return nil, err
89+ }
90+ defer rows.Close()
91+ return scanEvents(rows)
92+ }
93+
94+ // EventsFor returns one repository's events, for its feed.
95+ func (db *DB) EventsFor(ctx context.Context, repo string, limit int) ([]Event, error) {
96+ rows, err := db.QueryContext(ctx,
97+ `SELECT id, kind, actor, repo, ref, number, title, detail, created_at FROM events
98+ WHERE repo = ? ORDER BY id DESC LIMIT ?`, repo, limit)
99+ if err != nil {
100+ return nil, err
101+ }
102+ defer rows.Close()
103+ return scanEvents(rows)
104+ }
105+
106+ // EventsBy returns one account's activity, for their feed.
107+ func (db *DB) EventsBy(ctx context.Context, account string, limit int) ([]Event, error) {
108+ rows, err := db.QueryContext(ctx,
109+ `SELECT id, kind, actor, repo, ref, number, title, detail, created_at FROM events
110+ WHERE actor = ? ORDER BY id DESC LIMIT ?`, account, limit)
111+ if err != nil {
112+ return nil, err
113+ }
114+ defer rows.Close()
115+ return scanEvents(rows)
116+ }
117+
118+ func scanEvents(rows *sql.Rows) ([]Event, error) {
119+ var out []Event
120+ for rows.Next() {
121+ var e Event
122+ var created int64
123+ if err := rows.Scan(&e.ID, &e.Kind, &e.Actor, &e.Repo, &e.Ref, &e.Number, &e.Title, &e.Detail, &created); err != nil {
124+ return nil, err
125+ }
126+ e.Created = time.Unix(created, 0)
127+ out = append(out, e)
128+ }
129+ return out, rows.Err()
130+ }
131+
132+ // LastVisited places the inbox rule, and losing it costs a reader one line. 19.4.
133+ func (db *DB) LastVisited(ctx context.Context, account string) (time.Time, error) {
134+ var at sql.NullInt64
135+ if err := db.QueryRowContext(ctx,
136+ `SELECT last_visited FROM accounts WHERE name = ?`, account).Scan(&at); err != nil {
137+ return time.Time{}, err
138+ }
139+ if !at.Valid {
140+ return time.Time{}, nil
141+ }
142+ return time.Unix(at.Int64, 0), nil
143+ }
144+
145+ func (db *DB) Visit(ctx context.Context, account string) error {
146+ _, err := db.ExecContext(ctx,
147+ `UPDATE accounts SET last_visited = ? WHERE name = ?`, now().Unix(), account)
148+ return err
149+ }
150+
151+ // DropOldEvents throws away everything past the window.
152+ func (db *DB) DropOldEvents(ctx context.Context) error {
153+ _, err := db.ExecContext(ctx,
154+ `DELETE FROM events WHERE created_at < ?`, now().Add(-EventLife).Unix())
155+ return err
156+ }
157+
158+ // Owner reads the account a repository belongs to.
159+ func Owner(repo string) string {
160+ owner, _, _ := strings.Cut(repo, "/")
161+ return owner
162+ }
163+
164+ // ThreadKinds is what the threads page shows, so the threads feed carries the same. 19.5.
165+ var ThreadKinds = []string{ProposalOpened, ProposalUpdated, ProposalMerged, ThreadOpened, ThreadReplied, ThreadClosed}
166+
167+ // ThreadEventsFor is one repository's discussion, for /<user>/<repo>/threads.atom.
168+ func (db *DB) ThreadEventsFor(ctx context.Context, repo string, limit int) ([]Event, error) {
169+ args := []any{repo}
170+ for _, k := range ThreadKinds {
171+ args = append(args, k)
172+ }
173+ args = append(args, limit)
174+ q := `SELECT id, kind, actor, repo, ref, number, title, detail, created_at FROM events
175+ WHERE repo = ? AND kind IN (?` + strings.Repeat(", ?", len(ThreadKinds)-1) + `)
176+ ORDER BY id DESC LIMIT ?`
177+ rows, err := db.QueryContext(ctx, q, args...)
178+ if err != nil {
179+ return nil, err
180+ }
181+ defer rows.Close()
182+ return scanEvents(rows)
183+ }
@@ -0,0 +1,88 @@
1+ package store
2+
3+ import (
4+ "context"
5+ "errors"
6+ "time"
7+ )
8+
9+ // GPGKey is one signing key an account has published, so a signature on a commit can be checked.
10+ type GPGKey struct {
11+ ID int64
12+ Account string
13+ Fingerprint string
14+ UID string
15+ Armor string
16+ Created time.Time
17+ }
18+
19+ // AddGPGKey stores a key already parsed, since parsing needs gpg and this package needs none.
20+ func (db *DB) AddGPGKey(ctx context.Context, account, fingerprint, uid, armor string) error {
21+ _, err := db.ExecContext(ctx,
22+ `INSERT INTO gpgkeys (account, fingerprint, uid, armor, created_at) VALUES (?, ?, ?, ?, ?)`,
23+ account, fingerprint, uid, armor, now().Unix())
24+ return err
25+ }
26+
27+ // GPGKeys lists one account's keys, newest last, the way the keys page draws them.
28+ func (db *DB) GPGKeys(ctx context.Context, account string) ([]GPGKey, error) {
29+ rows, err := db.QueryContext(ctx,
30+ `SELECT id, account, fingerprint, uid, armor, created_at
31+ FROM gpgkeys WHERE account = ? ORDER BY id`, account)
32+ if err != nil {
33+ return nil, err
34+ }
35+ defer rows.Close()
36+ var out []GPGKey
37+ for rows.Next() {
38+ var k GPGKey
39+ var created int64
40+ if err := rows.Scan(&k.ID, &k.Account, &k.Fingerprint, &k.UID, &k.Armor, &created); err != nil {
41+ return nil, err
42+ }
43+ k.Created = time.Unix(created, 0)
44+ out = append(out, k)
45+ }
46+ return out, rows.Err()
47+ }
48+
49+ // AllGPGKeys is every key on the server, because one keyring verifies every commit.
50+ func (db *DB) AllGPGKeys(ctx context.Context) ([]GPGKey, error) {
51+ rows, err := db.QueryContext(ctx,
52+ `SELECT id, account, fingerprint, uid, armor, created_at FROM gpgkeys ORDER BY id`)
53+ if err != nil {
54+ return nil, err
55+ }
56+ defer rows.Close()
57+ var out []GPGKey
58+ for rows.Next() {
59+ var k GPGKey
60+ var created int64
61+ if err := rows.Scan(&k.ID, &k.Account, &k.Fingerprint, &k.UID, &k.Armor, &created); err != nil {
62+ return nil, err
63+ }
64+ k.Created = time.Unix(created, 0)
65+ out = append(out, k)
66+ }
67+ return out, rows.Err()
68+ }
69+
70+ // DeleteGPGKey revokes one key, and only its own account may.
71+ func (db *DB) DeleteGPGKey(ctx context.Context, account string, id int64) error {
72+ res, err := db.ExecContext(ctx, `DELETE FROM gpgkeys WHERE id = ? AND account = ?`, id, account)
73+ if err != nil {
74+ return err
75+ }
76+ if n, _ := res.RowsAffected(); n == 0 {
77+ return errors.New("no such key")
78+ }
79+ return nil
80+ }
81+
82+ // AccountForGPGKey names who published a key, which is the only claim about a signature barerepo owns.
83+ func (db *DB) AccountForGPGKey(ctx context.Context, fingerprint string) (string, error) {
84+ var account string
85+ err := db.QueryRowContext(ctx,
86+ `SELECT account FROM gpgkeys WHERE fingerprint = ?`, fingerprint).Scan(&account)
87+ return account, err
88+ }
@@ -0,0 +1,220 @@
1+ package store
2+
3+ import (
4+ "context"
5+ "database/sql"
6+ "errors"
7+ "strings"
8+ "time"
9+ )
10+
11+ // Job states, where a lost runner requeues once, because an infinite requeue is the classic failure.
12+ const (
13+ JobQueued = "queued"
14+ JobRunning = "running"
15+ JobDone = "done"
16+ JobLost = "lost"
17+ )
18+
19+ type Job struct {
20+ ID int64
21+ Repo string
22+ Ref string
23+ SHA string
24+ Command string
25+ Image string
26+ // Labels is the machine this job asked for, empty meaning any. Chapter 15A.
27+ Labels []string
28+ // Name is the workflow job and its matrix combination, so two builds of one commit are told apart.
29+ Name string
30+ State string
31+ RunnerID int64
32+ Attempts int
33+ Created time.Time
34+ Started time.Time
35+ Log string
36+ }
37+
38+ // QueueJob adds a build to a repository's queue.
39+ func (db *DB) QueueJob(ctx context.Context, repo, ref, sha, command, image string) (int64, error) {
40+ return db.QueueJobFor(ctx, repo, ref, sha, command, image, nil, "")
41+ }
42+
43+ // QueueJobFor queues a job only a machine with these labels may take, under a name. Chapter 15A.
44+ func (db *DB) QueueJobFor(ctx context.Context, repo, ref, sha, command, image string, labels []string, name string) (int64, error) {
45+ var id int64
46+ err := db.QueryRowContext(ctx,
47+ `INSERT INTO jobs (repo, ref, sha, command, image, labels, name, state, created_at)
48+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
49+ repo, ref, sha, command, image, strings.Join(labels, ","), name,
50+ JobQueued, now().Unix()).Scan(&id)
51+ return id, err
52+ }
53+
54+ // TakeJob hands over the oldest queued job, first in and first out. Chapter 15.
55+ func (db *DB) TakeJob(ctx context.Context, repo string, runner Runner) (*Job, error) {
56+ tx, err := db.Begin(ctx)
57+ if err != nil {
58+ return nil, err
59+ }
60+ defer tx.Rollback()
61+
62+ // The first job this machine can run is taken, so one waiting for another machine blocks nothing.
63+ rows, err := tx.QueryContext(ctx,
64+ `SELECT id, repo, ref, sha, command, image, labels, name, attempts, created_at
65+ FROM jobs WHERE repo = ? AND state = ? ORDER BY id`, repo, JobQueued)
66+ if err != nil {
67+ return nil, err
68+ }
69+ var j Job
70+ var created int64
71+ found := false
72+ for rows.Next() {
73+ var cand Job
74+ var labels string
75+ var at int64
76+ if err := rows.Scan(&cand.ID, &cand.Repo, &cand.Ref, &cand.SHA,
77+ &cand.Command, &cand.Image, &labels, &cand.Name, &cand.Attempts, &at); err != nil {
78+ rows.Close()
79+ return nil, err
80+ }
81+ cand.Labels = splitLabels(labels)
82+ if !runner.Satisfies(cand.Labels) {
83+ continue
84+ }
85+ j, created, found = cand, at, true
86+ break
87+ }
88+ rows.Close()
89+ if err := rows.Err(); err != nil {
90+ return nil, err
91+ }
92+ if !found {
93+ return nil, nil
94+ }
95+ j.Created = time.Unix(created, 0)
96+ j.State = JobRunning
97+ j.RunnerID = runner.ID
98+ j.Started = now()
99+
100+ // Still queued is part of the write, so two machines reaching for one job leave one holding it.
101+ res, err := tx.ExecContext(ctx,
102+ `UPDATE jobs SET state = ?, runner_id = ?, started_at = ?, attempts = attempts + 1
103+ WHERE id = ? AND state = ?`,
104+ JobRunning, runner.ID, j.Started.Unix(), j.ID, JobQueued)
105+ if err != nil {
106+ return nil, err
107+ }
108+ took, err := res.RowsAffected()
109+ if err != nil {
110+ return nil, err
111+ }
112+ if took == 0 {
113+ // Somebody else took it between the read and the write, and the runner asks again on its tick.
114+ return nil, nil
115+ }
116+ if err := tx.Commit(); err != nil {
117+ return nil, err
118+ }
119+ return &j, nil
120+ }
121+
122+ // Job reads one job.
123+ func (db *DB) Job(ctx context.Context, id int64) (*Job, error) {
124+ var j Job
125+ var runner sql.NullInt64
126+ var created int64
127+ var started sql.NullInt64
128+ err := db.QueryRowContext(ctx,
129+ `SELECT id, repo, ref, sha, command, image, name, state, runner_id, attempts,
130+ created_at, started_at, log
131+ FROM jobs WHERE id = ?`, id).
132+ Scan(&j.ID, &j.Repo, &j.Ref, &j.SHA, &j.Command, &j.Image, &j.Name, &j.State,
133+ &runner, &j.Attempts, &created, &started, &j.Log)
134+ if errors.Is(err, sql.ErrNoRows) {
135+ return nil, ErrNotFound
136+ }
137+ if err != nil {
138+ return nil, err
139+ }
140+ j.RunnerID = runner.Int64
141+ j.Created = time.Unix(created, 0)
142+ if started.Valid {
143+ j.Started = time.Unix(started.Int64, 0)
144+ }
145+ return &j, nil
146+ }
147+
148+ // AppendLog adds a chunk of build output to a running job.
149+ func (db *DB) AppendLog(ctx context.Context, id int64, runnerID int64, chunk string) error {
150+ res, err := db.ExecContext(ctx,
151+ `UPDATE jobs SET log = log || ? WHERE id = ? AND runner_id = ? AND state = ?`,
152+ chunk, id, runnerID, JobRunning)
153+ if err != nil {
154+ return err
155+ }
156+ if n, _ := res.RowsAffected(); n == 0 {
157+ return ErrNotFound
158+ }
159+ return nil
160+ }
161+
162+ // FinishJob closes a job out and returns it, so the caller can write the note.
163+ func (db *DB) FinishJob(ctx context.Context, id, runnerID int64) (*Job, error) {
164+ res, err := db.ExecContext(ctx,
165+ `UPDATE jobs SET state = ? WHERE id = ? AND runner_id = ? AND state = ?`,
166+ JobDone, id, runnerID, JobRunning)
167+ if err != nil {
168+ return nil, err
169+ }
170+ if n, _ := res.RowsAffected(); n == 0 {
171+ return nil, ErrNotFound
172+ }
173+ return db.Job(ctx, id)
174+ }
175+
176+ // RequeueLostJobs retries a silent runner's job once, then marks it lost rather than cycling.
177+ func (db *DB) RequeueLostJobs(ctx context.Context, olderThan time.Duration) (int, error) {
178+ cutoff := now().Add(-olderThan).Unix()
179+ res, err := db.ExecContext(ctx,
180+ `UPDATE jobs SET state = ?, runner_id = NULL
181+ WHERE state = ? AND started_at < ? AND attempts < 2`,
182+ JobQueued, JobRunning, cutoff)
183+ if err != nil {
184+ return 0, err
185+ }
186+ requeued, _ := res.RowsAffected()
187+
188+ if _, err := db.ExecContext(ctx,
189+ `UPDATE jobs SET state = ? WHERE state = ? AND started_at < ? AND attempts >= 2`,
190+ JobLost, JobRunning, cutoff); err != nil {
191+ return int(requeued), err
192+ }
193+ return int(requeued), nil
194+ }
195+
196+ // QueuedJobs lists what is waiting for a machine, which the runs page and the tests both ask for.
197+ func (db *DB) QueuedJobs(ctx context.Context, repo string) ([]Job, error) {
198+ rows, err := db.QueryContext(ctx,
199+ `SELECT id, repo, ref, sha, command, image, labels, name, attempts, created_at
200+ FROM jobs WHERE repo = ? AND state = ? ORDER BY id`, repo, JobQueued)
201+ if err != nil {
202+ return nil, err
203+ }
204+ defer rows.Close()
205+ var out []Job
206+ for rows.Next() {
207+ var j Job
208+ var labels string
209+ var created int64
210+ if err := rows.Scan(&j.ID, &j.Repo, &j.Ref, &j.SHA,
211+ &j.Command, &j.Image, &labels, &j.Name, &j.Attempts, &created); err != nil {
212+ return nil, err
213+ }
214+ j.Labels = splitLabels(labels)
215+ j.State = JobQueued
216+ j.Created = time.Unix(created, 0)
217+ out = append(out, j)
218+ }
219+ return out, rows.Err()
220+ }
@@ -0,0 +1,49 @@
1+ package store
2+
3+ import "strings"
4+
5+ // Satisfies reports whether this machine can take a job that asked for these labels.
6+ func (r Runner) Satisfies(want []string) bool {
7+ // A job that asks for nothing runs anywhere, which is what [build] command has always done.
8+ if len(want) == 0 {
9+ return true
10+ }
11+ for _, label := range want {
12+ if !r.has(label) {
13+ return false
14+ }
15+ }
16+ return true
17+ }
18+
19+ // has answers one label, translating GitHub's hosted names into what a runner reports about itself.
20+ func (r Runner) has(label string) bool {
21+ label = strings.ToLower(strings.TrimSpace(label))
22+ // Every barerepo runner is self-hosted, so the label that says so is always true here.
23+ if label == "self-hosted" {
24+ return true
25+ }
26+ for _, own := range r.Labels {
27+ if strings.EqualFold(strings.TrimSpace(own), label) {
28+ return true
29+ }
30+ }
31+ if os, ok := hostedOS(label); ok {
32+ return strings.EqualFold(r.OS, os)
33+ }
34+ return strings.EqualFold(r.OS, label) || strings.EqualFold(r.Arch, label)
35+ }
36+
37+ // hostedOS maps GitHub's runner images to an operating system, which is all barerepo can check.
38+ func hostedOS(label string) (string, bool) {
39+ switch {
40+ case strings.HasPrefix(label, "ubuntu-"), label == "ubuntu",
41+ strings.HasPrefix(label, "linux-"), label == "linux":
42+ return "linux", true
43+ case strings.HasPrefix(label, "macos-"), label == "macos", label == "darwin":
44+ return "darwin", true
45+ case strings.HasPrefix(label, "windows-"), label == "windows":
46+ return "windows", true
47+ }
48+ return "", false
49+ }
@@ -0,0 +1,46 @@
1+ package store
2+
3+ import "testing"
4+
5+ // Chapter 15A: a job names a machine the GitHub way, and barerepo answers with what it actually has.
6+ func TestSatisfiesTranslatesHostedLabels(t *testing.T) {
7+ linux := Runner{OS: "linux", Arch: "amd64"}
8+ mac := Runner{OS: "darwin", Arch: "arm64", Labels: []string{"big"}}
9+
10+ cases := []struct {
11+ runner Runner
12+ want []string
13+ ok bool
14+ why string
15+ }{
16+ {linux, nil, true, "a job that asks for nothing runs anywhere"},
17+ {linux, []string{"ubuntu-latest"}, true, "ubuntu-latest is a linux machine"},
18+ {linux, []string{"ubuntu-22.04"}, true, "any ubuntu image is still linux"},
19+ {linux, []string{"self-hosted"}, true, "every barerepo runner is self-hosted"},
20+ {linux, []string{"self-hosted", "linux"}, true, "both hold"},
21+ {linux, []string{"macos-latest"}, false, "a linux machine is not a mac"},
22+ {linux, []string{"windows-latest"}, false, "a linux machine is not windows"},
23+ {linux, []string{"amd64"}, true, "the architecture answers too"},
24+ {linux, []string{"gpu"}, false, "a label nobody declared is not satisfied"},
25+ {mac, []string{"macos-14"}, true, "any macos image is darwin"},
26+ {mac, []string{"big"}, true, "a declared label answers"},
27+ {mac, []string{"big", "macos-latest"}, true, "every label has to hold, and both do"},
28+ {mac, []string{"big", "ubuntu-latest"}, false, "every label has to hold, and one does not"},
29+ }
30+ for _, c := range cases {
31+ if got := c.runner.Satisfies(c.want); got != c.ok {
32+ t.Errorf("%s: Satisfies(%v) on %s = %v, want %v",
33+ c.why, c.want, c.runner.OS, got, c.ok)
34+ }
35+ }
36+ }
37+
38+ // Case must not decide whether a build runs.
39+ func TestSatisfiesIgnoresCase(t *testing.T) {
40+ r := Runner{OS: "Linux", Labels: []string{"GPU"}}
41+ for _, want := range [][]string{{"ubuntu-latest"}, {"gpu"}, {"LINUX"}, {" self-hosted "}} {
42+ if !r.Satisfies(want) {
43+ t.Errorf("Satisfies(%v) was false on a runner that has it", want)
44+ }
45+ }
46+ }
@@ -0,0 +1,162 @@
1+ package store
2+
3+ import (
4+ "context"
5+ "testing"
6+
7+ "github.com/barerepo/server/internal/token"
8+ )
9+
10+ // A rename or a transfer changes the path every table keys on, so every one of them has to follow.
11+ func TestMoveCarriesEverythingKeyedByTheRepository(t *testing.T) {
12+ ctx := context.Background()
13+ db := open(t)
14+ for _, who := range []string{"john", "lisa"} {
15+ if _, err := db.CreateAccount(ctx, who, keyFor(who), false); err != nil {
16+ t.Fatal(err)
17+ }
18+ }
19+ if _, err := db.ExecContext(ctx,
20+ `INSERT INTO repos (owner, name, created_at) VALUES ('john', 'johnbot', 0)`); err != nil {
21+ t.Fatal(err)
22+ }
23+
24+ const old, want = "john/johnbot", "lisa/ircbot"
25+ _, tok, err := db.CreateToken(ctx, token.Runner, "john", old, "a runner")
26+ if err != nil {
27+ t.Fatal(err)
28+ }
29+ if _, err := db.AttachRunner(ctx, tok.ID, old, "uproar.local", "linux", "amd64", nil); err != nil {
30+ t.Fatal(err)
31+ }
32+ if _, err := db.QueueJob(ctx, old, "refs/heads/master", "abc", "go test", ""); err != nil {
33+ t.Fatal(err)
34+ }
35+ if err := db.Record(ctx, Event{Kind: ThreadOpened, Actor: "lisa", Repo: old, Number: 1,
36+ Title: "something"}); err != nil {
37+ t.Fatal(err)
38+ }
39+ if err := db.TookPart(ctx, "lisa", old, 1); err != nil {
40+ t.Fatal(err)
41+ }
42+ if err := db.HookFailed(ctx, old, "https://deploy.example/hook", "it went wrong"); err != nil {
43+ t.Fatal(err)
44+ }
45+ if err := db.PutDoc(ctx, Doc{Repo: old, Kind: Code, Path: "a.go", Body: "package main"},
46+ true, Readers("john", nil)); err != nil {
47+ t.Fatal(err)
48+ }
49+
50+ if err := db.Move(ctx, "john", "johnbot", "lisa", "ircbot"); err != nil {
51+ t.Fatal(err)
52+ }
53+
54+ // Every table below is keyed by the path, and a miss in any one of them breaks something.
55+ if got, err := db.RunnersOf(ctx, want); err != nil || len(got) != 1 {
56+ t.Errorf("the runner did not follow: %d, %v", len(got), err)
57+ }
58+ if got, err := db.RunnersOf(ctx, old); err != nil || len(got) != 0 {
59+ t.Errorf("the runner is still at the old path: %d, %v", len(got), err)
60+ }
61+ // The token's scope is what a runner is checked against, so it has to move with the repository.
62+ var scope string
63+ if err := db.QueryRowContext(ctx, `SELECT scope FROM tokens WHERE id = ?`, tok.ID).Scan(&scope); err != nil {
64+ t.Fatal(err)
65+ }
66+ if scope != want {
67+ t.Errorf("the runner token is still scoped to %q, so it can never take another job", scope)
68+ }
69+ if got, err := db.QueuedJobs(ctx, want); err != nil || len(got) != 1 {
70+ t.Errorf("the queued job did not follow: %d, %v", len(got), err)
71+ }
72+ if got, err := db.EventsFor(ctx, want, 10); err != nil || len(got) != 1 {
73+ t.Errorf("the events did not follow: %d, %v", len(got), err)
74+ }
75+ if got, err := db.Inbox(ctx, "lisa", 10); err != nil || len(got) != 1 {
76+ t.Errorf("the new owner's inbox lost the thread she took part in: %d, %v", len(got), err)
77+ }
78+ if got, err := db.HooksOf(ctx, want); err != nil || len(got) != 1 {
79+ t.Errorf("the webhook state did not follow: %d, %v", len(got), err)
80+ }
81+ if got, err := db.FindDocs(ctx, "", "package main", 10); err != nil || len(got) != 1 ||
82+ got[0].Repo != want {
83+ t.Errorf("the search index did not follow: %v, %v", got, err)
84+ }
85+ }
86+
87+ func keyFor(who string) string {
88+ if who == "john" {
89+ return keyA
90+ }
91+ return keyB
92+ }
93+
94+ // seedRepo fills one row in every table that keys on a repository, so a move or a delete has work.
95+ func seedRepo(t *testing.T, db *DB, owner, name string) int64 {
96+ t.Helper()
97+ ctx := context.Background()
98+ repo := owner + "/" + name
99+ if _, err := db.ExecContext(ctx,
100+ `INSERT INTO repos (owner, name, created_at) VALUES (?, ?, 0)`, owner, name); err != nil {
101+ t.Fatal(err)
102+ }
103+ _, tok, err := db.CreateToken(ctx, token.Runner, owner, repo, "a runner")
104+ if err != nil {
105+ t.Fatal(err)
106+ }
107+ if _, err := db.AttachRunner(ctx, tok.ID, repo, "uproar.local", "linux", "amd64", nil); err != nil {
108+ t.Fatal(err)
109+ }
110+ if _, err := db.QueueJob(ctx, repo, "refs/heads/master", "abc", "go test", ""); err != nil {
111+ t.Fatal(err)
112+ }
113+ if err := db.Record(ctx, Event{Kind: ThreadOpened, Actor: owner, Repo: repo, Number: 1,
114+ Title: "something"}); err != nil {
115+ t.Fatal(err)
116+ }
117+ if err := db.TookPart(ctx, owner, repo, 1); err != nil {
118+ t.Fatal(err)
119+ }
120+ if err := db.HookFailed(ctx, repo, "https://deploy.example/hook", "it went wrong"); err != nil {
121+ t.Fatal(err)
122+ }
123+ if err := db.PutDoc(ctx, Doc{Repo: repo, Kind: Code, Path: "a.go", Body: "package main"},
124+ true, Readers(owner, nil)); err != nil {
125+ t.Fatal(err)
126+ }
127+ return tok.ID
128+ }
129+
130+ // A deleted repository leaves nothing pointing at it, or a token, a runner and an inbox line outlive it.
131+ func TestForgetDropsEverythingKeyedByTheRepository(t *testing.T) {
132+ ctx := context.Background()
133+ db := open(t)
134+ if _, err := db.CreateAccount(ctx, "john", keyA, false); err != nil {
135+ t.Fatal(err)
136+ }
137+ const repo = "john/johnbot"
138+ seedRepo(t, db, "john", "johnbot")
139+
140+ if err := db.Forget(ctx, "john", "johnbot"); err != nil {
141+ t.Fatal(err)
142+ }
143+
144+ if got, err := db.RunnersOf(ctx, repo); err != nil || len(got) != 0 {
145+ t.Errorf("a runner is still attached to a repository that is gone: %d, %v", len(got), err)
146+ }
147+ if got, err := db.TokensOf(ctx, "john"); err != nil || len(got) != 0 {
148+ t.Errorf("the keys page still lists a token for a repository that is gone: %d, %v", len(got), err)
149+ }
150+ if got, err := db.QueuedJobs(ctx, repo); err != nil || len(got) != 0 {
151+ t.Errorf("a job is still queued for a repository that is gone: %d, %v", len(got), err)
152+ }
153+ if got, err := db.EventsFor(ctx, repo, 10); err != nil || len(got) != 0 {
154+ t.Errorf("the inbox still links to a repository that is gone: %d, %v", len(got), err)
155+ }
156+ if got, err := db.HooksOf(ctx, repo); err != nil || len(got) != 0 {
157+ t.Errorf("webhook state outlived its repository: %d, %v", len(got), err)
158+ }
159+ if got, err := db.FindDocs(ctx, "", "package main", 10); err != nil || len(got) != 0 {
160+ t.Errorf("search still finds a repository that is gone: %v, %v", got, err)
161+ }
162+ }
@@ -0,0 +1,180 @@
1+ package store
2+
3+ import (
4+ "context"
5+ "database/sql"
6+ "errors"
7+ "strings"
8+ "time"
9+ )
10+
11+ // Runner is one machine on one repository, because barerepo dispatches and records but never executes.
12+ type Runner struct {
13+ ID int64
14+ Repo string
15+ Hostname string
16+ OS string
17+ Arch string
18+ Labels []string
19+ Attached time.Time
20+ LastSeen time.Time
21+ }
22+
23+ // Offline reports a runner that has missed three polls. Chapter 15.
24+ func (r Runner) Offline(interval time.Duration) bool {
25+ return time.Since(r.LastSeen) > 3*interval
26+ }
27+
28+ // AttachRunner keys on token and hostname, so restarting a machine leaves no second row.
29+ func (db *DB) AttachRunner(ctx context.Context, tokenID int64, repo, hostname, os, arch string, labels []string) (*Runner, error) {
30+ now := now()
31+ joined := strings.Join(labels, ",")
32+
33+ var id int64
34+ err := db.QueryRowContext(ctx,
35+ `SELECT id FROM runners WHERE token_id = ? AND hostname = ?`, tokenID, hostname).Scan(&id)
36+ switch {
37+ case errors.Is(err, sql.ErrNoRows):
38+ err = db.QueryRowContext(ctx,
39+ `INSERT INTO runners (token_id, repo, hostname, os, arch, labels, attached_at, last_seen)
40+ VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
41+ tokenID, repo, hostname, os, arch, joined, now.Unix(), now.Unix()).Scan(&id)
42+ if err != nil {
43+ return nil, err
44+ }
45+ case err != nil:
46+ return nil, err
47+ default:
48+ if _, err := db.ExecContext(ctx,
49+ `UPDATE runners SET os = ?, arch = ?, labels = ?, last_seen = ? WHERE id = ?`,
50+ os, arch, joined, now.Unix(), id); err != nil {
51+ return nil, err
52+ }
53+ }
54+ return &Runner{ID: id, Repo: repo, Hostname: hostname, OS: os, Arch: arch,
55+ Labels: labels, Attached: now, LastSeen: now}, nil
56+ }
57+
58+ // SeeRunner marks a runner as still there.
59+ func (db *DB) SeeRunner(ctx context.Context, id int64) error {
60+ _, err := db.ExecContext(ctx, `UPDATE runners SET last_seen = ? WHERE id = ?`, now().Unix(), id)
61+ return err
62+ }
63+
64+ func (db *DB) Runner(ctx context.Context, id int64) (*Runner, error) {
65+ var r Runner
66+ var labels string
67+ var attached, seen int64
68+ err := db.QueryRowContext(ctx,
69+ `SELECT id, repo, hostname, os, arch, labels, attached_at, last_seen FROM runners WHERE id = ?`, id).
70+ Scan(&r.ID, &r.Repo, &r.Hostname, &r.OS, &r.Arch, &labels, &attached, &seen)
71+ if errors.Is(err, sql.ErrNoRows) {
72+ return nil, ErrNotFound
73+ }
74+ if err != nil {
75+ return nil, err
76+ }
77+ r.Labels = splitLabels(labels)
78+ r.Attached = time.Unix(attached, 0)
79+ r.LastSeen = time.Unix(seen, 0)
80+ return &r, nil
81+ }
82+
83+ // RunnersOf lists the machines attached to a repository.
84+ func (db *DB) RunnersOf(ctx context.Context, repo string) ([]Runner, error) {
85+ rows, err := db.QueryContext(ctx,
86+ `SELECT id, repo, hostname, os, arch, labels, attached_at, last_seen
87+ FROM runners WHERE repo = ? ORDER BY last_seen DESC`, repo)
88+ if err != nil {
89+ return nil, err
90+ }
91+ defer rows.Close()
92+ var out []Runner
93+ for rows.Next() {
94+ var r Runner
95+ var labels string
96+ var attached, seen int64
97+ if err := rows.Scan(&r.ID, &r.Repo, &r.Hostname, &r.OS, &r.Arch, &labels, &attached, &seen); err != nil {
98+ return nil, err
99+ }
100+ r.Labels = splitLabels(labels)
101+ r.Attached = time.Unix(attached, 0)
102+ r.LastSeen = time.Unix(seen, 0)
103+ out = append(out, r)
104+ }
105+ return out, rows.Err()
106+ }
107+
108+ // ForgetRunner drops a machine. Chapter 24: offline machines can be forgotten.
109+ func (db *DB) ForgetRunner(ctx context.Context, repo string, id int64) error {
110+ res, err := db.ExecContext(ctx, `DELETE FROM runners WHERE repo = ? AND id = ?`, repo, id)
111+ if err != nil {
112+ return err
113+ }
114+ if n, _ := res.RowsAffected(); n == 0 {
115+ return ErrNotFound
116+ }
117+ return nil
118+ }
119+
120+ func splitLabels(s string) []string {
121+ if strings.TrimSpace(s) == "" {
122+ return nil
123+ }
124+ return strings.Split(s, ",")
125+ }
126+
127+ // RunnersByToken maps one account's runner tokens to the machines that attached with them.
128+ func (db *DB) RunnersByToken(ctx context.Context, account string) (map[int64][]Runner, error) {
129+ rows, err := db.QueryContext(ctx,
130+ `SELECT r.token_id, r.id, r.repo, r.hostname, r.os, r.arch, r.labels, r.attached_at, r.last_seen
131+ FROM runners r JOIN tokens t ON t.id = r.token_id
132+ WHERE t.account = ? ORDER BY r.last_seen DESC`, account)
133+ if err != nil {
134+ return nil, err
135+ }
136+ defer rows.Close()
137+ out := map[int64][]Runner{}
138+ for rows.Next() {
139+ var r Runner
140+ var tokenID int64
141+ var labels string
142+ var attached, seen int64
143+ if err := rows.Scan(&tokenID, &r.ID, &r.Repo, &r.Hostname, &r.OS, &r.Arch,
144+ &labels, &attached, &seen); err != nil {
145+ return nil, err
146+ }
147+ r.Labels = splitLabels(labels)
148+ r.Attached = time.Unix(attached, 0)
149+ r.LastSeen = time.Unix(seen, 0)
150+ out[tokenID] = append(out[tokenID], r)
151+ }
152+ return out, rows.Err()
153+ }
154+
155+ // RunnerWork is what a runner has done and whether it is doing something now. Chapter 24.
156+ type RunnerWork struct {
157+ Runs int
158+ Busy bool
159+ }
160+
161+ // WorkOf counts the jobs each runner of a repository has taken, and finds the ones running now.
162+ func (db *DB) WorkOf(ctx context.Context, repo string) (map[int64]RunnerWork, error) {
163+ rows, err := db.QueryContext(ctx,
164+ `SELECT runner_id, COUNT(*), SUM(CASE WHEN state = ? THEN 1 ELSE 0 END) FROM jobs
165+ WHERE repo = ? AND runner_id IS NOT NULL GROUP BY runner_id`, JobRunning, repo)
166+ if err != nil {
167+ return nil, err
168+ }
169+ defer rows.Close()
170+ out := map[int64]RunnerWork{}
171+ for rows.Next() {
172+ var id int64
173+ var runs, running int
174+ if err := rows.Scan(&id, &runs, &running); err != nil {
175+ return nil, err
176+ }
177+ out[id] = RunnerWork{Runs: runs, Busy: running > 0}
178+ }
179+ return out, rows.Err()
180+ }
@@ -0,0 +1,157 @@
1+ package store
2+
3+ import (
4+ "context"
5+ "strings"
6+ )
7+
8+ // Doc is one indexed thing: a file at the tip, a thread, or a repository. Chapter 17.
9+ type Doc struct {
10+ Repo string
11+ Kind string
12+ Path string
13+ Title string
14+ Body string
15+ }
16+
17+ // Doc kinds, ranked in this order, because a barerepo is usually searched for a symbol.
18+ const (
19+ Code = "code"
20+ Thread = "thread"
21+ Repository = "repo"
22+ )
23+
24+ // reader marks a name inside the readers column, so john never matches johnson.
25+ func reader(name string) string { return "|" + name + "|" }
26+
27+ // Readers renders the read set of a repository for the index, per chapter 18's binary read rule.
28+ func Readers(owner string, push []string) string {
29+ var b strings.Builder
30+ b.WriteString(reader(owner))
31+ for _, name := range push {
32+ if name != owner {
33+ b.WriteString(reader(name))
34+ }
35+ }
36+ return b.String()
37+ }
38+
39+ // PutDocs swaps one repository's documents of one kind, in a transaction, so a query sees one set.
40+ func (db *DB) PutDocs(ctx context.Context, repo, kind string, docs []Doc, public bool, readers string) error {
41+ tx, err := db.Begin(ctx)
42+ if err != nil {
43+ return err
44+ }
45+ defer tx.Rollback()
46+ if _, err := tx.ExecContext(ctx,
47+ `DELETE FROM search_docs WHERE repo = ? AND kind = ?`, repo, kind); err != nil {
48+ return err
49+ }
50+ flag := 0
51+ if public {
52+ flag = 1
53+ }
54+ for _, d := range docs {
55+ if _, err := tx.ExecContext(ctx,
56+ `INSERT INTO search_docs (repo, kind, path, title, body, public, readers)
57+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
58+ repo, kind, d.Path, d.Title, d.Body, flag, readers); err != nil {
59+ return err
60+ }
61+ }
62+ return tx.Commit()
63+ }
64+
65+ // SetReadable rewrites who may read a repository's documents, since visibility arrives by push.
66+ func (db *DB) SetReadable(ctx context.Context, repo string, public bool, readers string) error {
67+ flag := 0
68+ if public {
69+ flag = 1
70+ }
71+ _, err := db.ExecContext(ctx,
72+ `UPDATE search_docs SET public = ?, readers = ? WHERE repo = ?`, flag, readers, repo)
73+ return err
74+ }
75+
76+ // EmptyIndex drops every document, so a rebuild produces exactly the index git says it should.
77+ func (db *DB) EmptyIndex(ctx context.Context) error {
78+ _, err := db.ExecContext(ctx, `DELETE FROM search_docs`)
79+ return err
80+ }
81+
82+ // likePattern escapes what LIKE reads as syntax, so a query of 100% finds the string and not everything.
83+ func likePattern(query string) string {
84+ var b strings.Builder
85+ b.WriteByte('%')
86+ for _, c := range query {
87+ switch c {
88+ case '\\', '%', '_':
89+ b.WriteByte('\\')
90+ }
91+ b.WriteRune(c)
92+ }
93+ b.WriteByte('%')
94+ return b.String()
95+ }
96+
97+ // FindDocs is one query, filtered by read access inside it, because filtering after ranking leaks. 17.
98+ func (db *DB) FindDocs(ctx context.Context, viewer, query string, limit int) ([]Doc, error) {
99+ pattern := likePattern(strings.ToLower(query))
100+ args := []any{pattern, pattern}
101+ access := `public = 1`
102+ if viewer != "" {
103+ access = `(public = 1 OR readers LIKE ?)`
104+ args = append(args, "%"+reader(viewer)+"%")
105+ }
106+ args = append(args, limit)
107+ rows, err := db.QueryContext(ctx,
108+ `SELECT repo, kind, path, title, body FROM search_docs
109+ WHERE (LOWER(body) LIKE ? ESCAPE '\' OR LOWER(title) LIKE ? ESCAPE '\')
110+ AND `+access+`
111+ ORDER BY CASE kind WHEN 'code' THEN 0 WHEN 'thread' THEN 1 ELSE 2 END, repo, path
112+ LIMIT ?`, args...)
113+ if err != nil {
114+ return nil, err
115+ }
116+ defer rows.Close()
117+ var out []Doc
118+ for rows.Next() {
119+ var d Doc
120+ if err := rows.Scan(&d.Repo, &d.Kind, &d.Path, &d.Title, &d.Body); err != nil {
121+ return nil, err
122+ }
123+ out = append(out, d)
124+ }
125+ return out, rows.Err()
126+ }
127+
128+ // PutDoc writes or replaces one document, which is what an incremental push update needs.
129+ func (db *DB) PutDoc(ctx context.Context, d Doc, public bool, readers string) error {
130+ flag := 0
131+ if public {
132+ flag = 1
133+ }
134+ _, err := db.ExecContext(ctx,
135+ `INSERT INTO search_docs (repo, kind, path, title, body, public, readers)
136+ VALUES (?, ?, ?, ?, ?, ?, ?)
137+ ON CONFLICT (repo, kind, path) DO UPDATE SET
138+ title = excluded.title, body = excluded.body,
139+ public = excluded.public, readers = excluded.readers`,
140+ d.Repo, d.Kind, d.Path, d.Title, d.Body, flag, readers)
141+ return err
142+ }
143+
144+ // DeleteDoc drops one document, for a file a push removed.
145+ func (db *DB) DeleteDoc(ctx context.Context, repo, kind, path string) error {
146+ _, err := db.ExecContext(ctx,
147+ `DELETE FROM search_docs WHERE repo = ? AND kind = ? AND path = ?`, repo, kind, path)
148+ return err
149+ }
150+
151+ // HasDocs reports whether a repository has been indexed, so a first push does a full pass.
152+ func (db *DB) HasDocs(ctx context.Context, repo string) (bool, error) {
153+ var n int
154+ err := db.QueryRowContext(ctx,
155+ `SELECT COUNT(*) FROM search_docs WHERE repo = ?`, repo).Scan(&n)
156+ return n > 0, err
157+ }
@@ -0,0 +1,159 @@
1+ package store
2+
3+ import (
4+ "context"
5+
6+ "github.com/barerepo/server/internal/token"
7+ "strings"
8+ "testing"
9+ )
10+
11+ func indexed(t *testing.T, db *DB, repo, kind, path, title, body string, public bool, readers string) {
12+ t.Helper()
13+ if err := db.PutDoc(context.Background(),
14+ Doc{Repo: repo, Kind: kind, Path: path, Title: title, Body: body}, public, readers); err != nil {
15+ t.Fatal(err)
16+ }
17+ }
18+
19+ // A query is text, not syntax, so LIKE's own wildcards must not reach the database as wildcards.
20+ func TestAQueryWithAWildcardInItIsText(t *testing.T) {
21+ ctx := context.Background()
22+ db := open(t)
23+ indexed(t, db, "john/johnbot", Code, "a.go", "", "cover 100% of the branches", true, "")
24+ indexed(t, db, "john/johnbot", Code, "b.go", "", "nothing to do with coverage", true, "")
25+
26+ // Unescaped, a percent stands for anything, so this query would reach b.go through "coverage".
27+ docs, err := db.FindDocs(ctx, "", "c%e", 10)
28+ if err != nil {
29+ t.Fatal(err)
30+ }
31+ if len(docs) != 0 {
32+ t.Errorf("c%%e matched %d documents, and it is a string that appears in neither", len(docs))
33+ }
34+ if docs, err := db.FindDocs(ctx, "", "100%", 10); err != nil || len(docs) != 1 {
35+ t.Errorf("the literal 100%% found %d documents, wanted a.go alone: %v", len(docs), err)
36+ }
37+
38+ // An underscore is LIKE's single character wildcard, and here it is a character.
39+ indexed(t, db, "john/johnbot", Code, "c.go", "", "func readXall() error", true, "")
40+ docs, err = db.FindDocs(ctx, "", "read_all", 10)
41+ if err != nil {
42+ t.Fatal(err)
43+ }
44+ if len(docs) != 0 {
45+ t.Errorf("read_all matched readXall, so the underscore reached the database as a wildcard")
46+ }
47+ }
48+
49+ // The read set is a string of names between pipes, so one name must never match inside another.
50+ func TestOneReaderNameNeverMatchesInsideAnother(t *testing.T) {
51+ ctx := context.Background()
52+ db := open(t)
53+ indexed(t, db, "johnson/vault", Code, "s.go", "", "const launchCode = 1", false, Readers("johnson", nil))
54+
55+ if docs, err := db.FindDocs(ctx, "johnson", "launchCode", 10); err != nil || len(docs) != 1 {
56+ t.Errorf("johnson cannot read her own repository: %d, %v", len(docs), err)
57+ }
58+ for _, who := range []string{"", "lisa", "ami", "a"} {
59+ docs, err := db.FindDocs(ctx, who, "launchCode", 10)
60+ if err != nil {
61+ t.Fatal(err)
62+ }
63+ if len(docs) != 0 {
64+ t.Errorf("%q read a private repository they have no part in", who)
65+ }
66+ }
67+ }
68+
69+ // Chapter 18 grants read to [access] push as well as the owner, and the index carries both.
70+ func TestTheReadSetCarriesTheOwnerAndThePushList(t *testing.T) {
71+ got := Readers("john", []string{"lisa", "mark", "john"})
72+ if !strings.HasPrefix(got, "|john|") {
73+ t.Errorf("the owner is not first in %q", got)
74+ }
75+ if strings.Count(got, "|john|") != 1 {
76+ t.Errorf("the owner is listed twice in %q", got)
77+ }
78+ for _, name := range []string{"lisa", "mark"} {
79+ if !strings.Contains(got, "|"+name+"|") {
80+ t.Errorf("%s is missing from %q", name, got)
81+ }
82+ }
83+ }
84+
85+ // Ranking is code, then threads, then repositories, per chapter 17.
86+ func TestCodeRanksAboveThreadsAboveRepositories(t *testing.T) {
87+ ctx := context.Background()
88+ db := open(t)
89+ indexed(t, db, "john/johnbot", Repository, "", "john/johnbot", "a backoff bot", true, "")
90+ indexed(t, db, "john/johnbot", Thread, "44", "backoff is wrong", "it spins", true, "")
91+ indexed(t, db, "john/johnbot", Code, "retry.go", "", "func backoff() {}", true, "")
92+
93+ docs, err := db.FindDocs(ctx, "", "backoff", 10)
94+ if err != nil {
95+ t.Fatal(err)
96+ }
97+ var order []string
98+ for _, d := range docs {
99+ order = append(order, d.Kind)
100+ }
101+ want := []string{Code, Thread, Repository}
102+ if len(order) != len(want) {
103+ t.Fatalf("got %v, wanted %v", order, want)
104+ }
105+ for i := range want {
106+ if order[i] != want[i] {
107+ t.Fatalf("got %v, wanted %v", order, want)
108+ }
109+ }
110+ }
111+
112+ // Chapter 24 puts a run count and a status on every runner row, and both come from the jobs table.
113+ func TestWorkOfCountsRunsAndFindsTheBusyMachine(t *testing.T) {
114+ ctx := context.Background()
115+ db := open(t)
116+ if _, err := db.CreateAccount(ctx, "john", keyA, false); err != nil {
117+ t.Fatal(err)
118+ }
119+ _, tok, err := db.CreateToken(ctx, token.Runner, "john", "john/johnbot", "a runner")
120+ if err != nil {
121+ t.Fatal(err)
122+ }
123+ runner, err := db.AttachRunner(ctx, tok.ID, "john/johnbot", "uproar.local", "linux", "amd64", nil)
124+ if err != nil {
125+ t.Fatal(err)
126+ }
127+
128+ if work, err := db.WorkOf(ctx, "john/johnbot"); err != nil || len(work) != 0 {
129+ t.Errorf("a machine that has run nothing counted %d, %v", len(work), err)
130+ }
131+
132+ for i := 0; i < 3; i++ {
133+ if _, err := db.QueueJob(ctx, "john/johnbot", "refs/heads/master", "abc", "go test", ""); err != nil {
134+ t.Fatal(err)
135+ }
136+ job, err := db.TakeJob(ctx, "john/johnbot", *runner)
137+ if err != nil || job == nil {
138+ t.Fatalf("the runner could not take a job: %v", err)
139+ }
140+ // Two of the three finish, so the third leaves the machine busy.
141+ if i < 2 {
142+ if _, err := db.FinishJob(ctx, job.ID, runner.ID); err != nil {
143+ t.Fatal(err)
144+ }
145+ }
146+ }
147+
148+ work, err := db.WorkOf(ctx, "john/johnbot")
149+ if err != nil {
150+ t.Fatal(err)
151+ }
152+ got := work[runner.ID]
153+ if got.Runs != 3 {
154+ t.Errorf("the machine counted %d runs, wanted 3", got.Runs)
155+ }
156+ if !got.Busy {
157+ t.Error("a machine holding a running job did not read as busy")
158+ }
159+ }
@@ -0,0 +1,187 @@
1+ package store
2+
3+ import (
4+ "context"
5+ "database/sql"
6+ "errors"
7+ "time"
8+
9+ "github.com/barerepo/server/internal/token"
10+ )
11+
12+ // SessionLife bounds an unused session, kept in a table because revocation beats statelessness.
13+ const SessionLife = 30 * 24 * time.Hour
14+
15+ // NewSession issues a session for an account and returns the cookie value.
16+ func (db *DB) NewSession(ctx context.Context, account string) (string, error) {
17+ tok, hash, err := token.New(token.Session)
18+ if err != nil {
19+ return "", err
20+ }
21+ now := now()
22+ _, err = db.ExecContext(ctx,
23+ `INSERT INTO tokens (kind, hash, account, scope, label, created_at, expires_at)
24+ VALUES (?, ?, ?, '', '', ?, ?)`,
25+ string(token.Session), hash, account, now.Unix(), now.Add(SessionLife).Unix())
26+ if err != nil {
27+ return "", err
28+ }
29+ return tok, nil
30+ }
31+
32+ // SessionAccount returns who a session cookie belongs to, or ErrNotFound.
33+ func (db *DB) SessionAccount(ctx context.Context, cookie string) (string, error) {
34+ t, err := db.AccountForToken(ctx, token.Session, cookie)
35+ if err != nil {
36+ return "", err
37+ }
38+ return t.Account, nil
39+ }
40+
41+ // EndSession revokes one session. Signing out has to actually sign out.
42+ func (db *DB) EndSession(ctx context.Context, cookie string) error {
43+ _, err := db.ExecContext(ctx, `DELETE FROM tokens WHERE kind = ? AND hash = ?`,
44+ string(token.Session), token.Hash(cookie))
45+ return err
46+ }
47+
48+ // ClaimLife is short, because a claim is carried from one terminal to one browser and no further.
49+ const ClaimLife = 10 * time.Minute
50+
51+ // NewClaim issues the one-use code that trades a signature made in a terminal for a session.
52+ func (db *DB) NewClaim(ctx context.Context, account string) (string, error) {
53+ tok, hash, err := token.New(token.Claim)
54+ if err != nil {
55+ return "", err
56+ }
57+ now := now()
58+ _, err = db.ExecContext(ctx,
59+ `INSERT INTO tokens (kind, hash, account, scope, label, created_at, expires_at)
60+ VALUES (?, ?, ?, '', '', ?, ?)`,
61+ string(token.Claim), hash, account, now.Unix(), now.Add(ClaimLife).Unix())
62+ if err != nil {
63+ return "", err
64+ }
65+ return tok, nil
66+ }
67+
68+ // TakeClaim spends a claim and names its account, so a pasted link works once and never again.
69+ func (db *DB) TakeClaim(ctx context.Context, code string) (string, error) {
70+ t, err := db.AccountForToken(ctx, token.Claim, code)
71+ if err != nil {
72+ return "", err
73+ }
74+ if _, err := db.ExecContext(ctx, `DELETE FROM tokens WHERE id = ?`, t.ID); err != nil {
75+ return "", err
76+ }
77+ return t.Account, nil
78+ }
79+
80+ // Challenge is chapter 10's nonce, on disk so a restart costs nobody and verifying deletes it.
81+ type Challenge struct {
82+ Nonce string
83+ Account string
84+ }
85+
86+ // ChallengeLife is ten minutes, because signing means leaving for a terminal and coming back.
87+ const ChallengeLife = 10 * time.Minute
88+
89+ // NewChallenge stores a nonce for an account.
90+ func (db *DB) NewChallenge(ctx context.Context, account, nonce string) error {
91+ now := now()
92+ _, err := db.ExecContext(ctx,
93+ `INSERT INTO challenges (nonce, account, expires_at) VALUES (?, ?, ?)`,
94+ nonce, account, now.Add(ChallengeLife).Unix())
95+ return err
96+ }
97+
98+ // TakeChallenge consumes a nonce once, whether or not its signature was any good.
99+ func (db *DB) TakeChallenge(ctx context.Context, nonce string) (*Challenge, error) {
100+ var c Challenge
101+ var expires int64
102+ err := db.QueryRowContext(ctx,
103+ `SELECT nonce, account, expires_at FROM challenges WHERE nonce = ?`, nonce).
104+ Scan(&c.Nonce, &c.Account, &expires)
105+ if errors.Is(err, sql.ErrNoRows) {
106+ return nil, ErrNotFound
107+ }
108+ if err != nil {
109+ return nil, err
110+ }
111+ // The delete is what spends it, so the caller that removed the row is the one that may use it.
112+ res, err := db.ExecContext(ctx, `DELETE FROM challenges WHERE nonce = ?`, nonce)
113+ if err != nil {
114+ return nil, err
115+ }
116+ spent, err := res.RowsAffected()
117+ if err != nil {
118+ return nil, err
119+ }
120+ if spent == 0 {
121+ return nil, ErrNotFound
122+ }
123+ if now().Unix() > expires {
124+ return nil, ErrNotFound
125+ }
126+ return &c, nil
127+ }
128+
129+ // SweepExpired drops spent nonces and dead sessions, which accumulate and are worth nothing.
130+ func (db *DB) SweepExpired(ctx context.Context) error {
131+ cutoff := now().Unix()
132+ if _, err := db.ExecContext(ctx, `DELETE FROM challenges WHERE expires_at < ?`, cutoff); err != nil {
133+ return err
134+ }
135+ if _, err := db.ExecContext(ctx, `DELETE FROM signup_challenges WHERE expires_at < ?`, cutoff); err != nil {
136+ return err
137+ }
138+ _, err := db.ExecContext(ctx,
139+ `DELETE FROM tokens WHERE expires_at IS NOT NULL AND expires_at < ?`, cutoff)
140+ return err
141+ }
142+
143+ // SignupChallenge holds a name and key while the server waits for proof of the private half.
144+ type SignupChallenge struct {
145+ Nonce string
146+ Name string
147+ PubKey string
148+ }
149+
150+ // NewSignupChallenge parks a signup until its signature arrives.
151+ func (db *DB) NewSignupChallenge(ctx context.Context, nonce, name, pubkey string) error {
152+ _, err := db.ExecContext(ctx,
153+ `INSERT INTO signup_challenges (nonce, name, pubkey, expires_at) VALUES (?, ?, ?, ?)`,
154+ nonce, name, pubkey, now().Add(ChallengeLife).Unix())
155+ return err
156+ }
157+
158+ // TakeSignupChallenge spends a signup nonce on first use, good signature or not.
159+ func (db *DB) TakeSignupChallenge(ctx context.Context, nonce string) (*SignupChallenge, error) {
160+ var c SignupChallenge
161+ var expires int64
162+ err := db.QueryRowContext(ctx,
163+ `SELECT nonce, name, pubkey, expires_at FROM signup_challenges WHERE nonce = ?`, nonce).
164+ Scan(&c.Nonce, &c.Name, &c.PubKey, &expires)
165+ if errors.Is(err, sql.ErrNoRows) {
166+ return nil, ErrNotFound
167+ }
168+ if err != nil {
169+ return nil, err
170+ }
171+ // The delete is what spends it, so two requests racing one nonce make at most one account.
172+ res, err := db.ExecContext(ctx, `DELETE FROM signup_challenges WHERE nonce = ?`, nonce)
173+ if err != nil {
174+ return nil, err
175+ }
176+ spent, err := res.RowsAffected()
177+ if err != nil {
178+ return nil, err
179+ }
180+ if spent == 0 {
181+ return nil, ErrNotFound
182+ }
183+ if now().Unix() > expires {
184+ return nil, ErrNotFound
185+ }
186+ return &c, nil
187+ }
@@ -0,0 +1,497 @@
1+ // Package store holds chapter 10's closed list and nothing else, on SQLite or PostgreSQL.
2+ package store
3+
4+ import (
5+ "context"
6+ "database/sql"
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "strconv"
11+ "strings"
12+
13+ _ "github.com/jackc/pgx/v5/stdlib"
14+ _ "modernc.org/sqlite"
15+
16+ "github.com/barerepo/server/internal/config"
17+ )
18+
19+ type DB struct {
20+ *sql.DB
21+ Kind config.Kind
22+ }
23+
24+ // Open connects by URL and applies every migration, forward only. Chapter 41.8.
25+ func Open(ctx context.Context, dbURL string) (*DB, error) {
26+ kind, err := config.Config{Database: config.Database{URL: dbURL}}.DatabaseKind()
27+ if err != nil {
28+ return nil, err
29+ }
30+ driver, dsn, err := dataSource(kind, dbURL)
31+ if err != nil {
32+ return nil, err
33+ }
34+ sqldb, err := sql.Open(driver, dsn)
35+ if err != nil {
36+ return nil, err
37+ }
38+ if kind == config.SQLite {
39+ // One writer, because SQLite serialises anyway and a pool only makes that SQLITE_BUSY.
40+ sqldb.SetMaxOpenConns(1)
41+ } else {
42+ sqldb.SetMaxOpenConns(16)
43+ sqldb.SetMaxIdleConns(4)
44+ }
45+ if err := sqldb.PingContext(ctx); err != nil {
46+ sqldb.Close()
47+ return nil, fmt.Errorf("%s: %w", dbURL, err)
48+ }
49+ db := &DB{DB: sqldb, Kind: kind}
50+ if err := db.migrate(ctx); err != nil {
51+ sqldb.Close()
52+ return nil, err
53+ }
54+ return db, nil
55+ }
56+
57+ // dataSource turns one config URL into a driver name and a DSN.
58+ func dataSource(kind config.Kind, dbURL string) (driver, dsn string, err error) {
59+ switch kind {
60+ case config.SQLite:
61+ path := strings.TrimPrefix(dbURL, "sqlite:")
62+ path = strings.TrimPrefix(path, "//")
63+ if path == "" {
64+ return "", "", fmt.Errorf("database.url names no sqlite file")
65+ }
66+ if dir := filepath.Dir(path); dir != "." {
67+ if err := os.MkdirAll(dir, 0o750); err != nil {
68+ return "", "", err
69+ }
70+ }
71+ // WAL so a reader never blocks the writer, and foreign keys because SQLite ignores them.
72+ return "sqlite", "file:" + path +
73+ "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)" +
74+ "&_pragma=foreign_keys(1)&_pragma=synchronous(NORMAL)", nil
75+ case config.Postgres:
76+ return "pgx", dbURL, nil
77+ }
78+ return "", "", fmt.Errorf("unsupported database %q", kind)
79+ }
80+
81+ // rebind turns ? into $1 for PostgreSQL, which is the only dialect difference above the DDL.
82+ func (db *DB) rebind(q string) string {
83+ if db.Kind != config.Postgres {
84+ return q
85+ }
86+ var b strings.Builder
87+ b.Grow(len(q) + 8)
88+ n := 0
89+ for i := 0; i < len(q); i++ {
90+ if q[i] != '?' {
91+ b.WriteByte(q[i])
92+ continue
93+ }
94+ n++
95+ b.WriteByte('$')
96+ b.WriteString(strconv.Itoa(n))
97+ }
98+ return b.String()
99+ }
100+
101+ func (db *DB) ExecContext(ctx context.Context, q string, args ...any) (sql.Result, error) {
102+ return db.DB.ExecContext(ctx, db.rebind(q), args...)
103+ }
104+
105+ func (db *DB) QueryContext(ctx context.Context, q string, args ...any) (*sql.Rows, error) {
106+ return db.DB.QueryContext(ctx, db.rebind(q), args...)
107+ }
108+
109+ func (db *DB) QueryRowContext(ctx context.Context, q string, args ...any) *sql.Row {
110+ return db.DB.QueryRowContext(ctx, db.rebind(q), args...)
111+ }
112+
113+ // Tx is a transaction that rebinds placeholders the same way DB does.
114+ type Tx struct {
115+ *sql.Tx
116+ db *DB
117+ }
118+
119+ func (db *DB) Begin(ctx context.Context) (*Tx, error) {
120+ tx, err := db.DB.BeginTx(ctx, nil)
121+ if err != nil {
122+ return nil, err
123+ }
124+ return &Tx{Tx: tx, db: db}, nil
125+ }
126+
127+ func (tx *Tx) ExecContext(ctx context.Context, q string, args ...any) (sql.Result, error) {
128+ return tx.Tx.ExecContext(ctx, tx.db.rebind(q), args...)
129+ }
130+
131+ func (tx *Tx) QueryRowContext(ctx context.Context, q string, args ...any) *sql.Row {
132+ return tx.Tx.QueryRowContext(ctx, tx.db.rebind(q), args...)
133+ }
134+
135+ // migrations are append-only in two dialects, and TestMigrationsAgree checks they agree.
136+ type migration struct{ sqlite, postgres string }
137+
138+ var migrations = []migration{{
139+ sqlite: `
140+ CREATE TABLE accounts (
141+ name TEXT PRIMARY KEY,
142+ admin INTEGER NOT NULL DEFAULT 0,
143+ created_at INTEGER NOT NULL
144+ );
145+ CREATE TABLE pubkeys (
146+ id INTEGER PRIMARY KEY,
147+ account TEXT NOT NULL REFERENCES accounts(name) ON DELETE CASCADE,
148+ fingerprint TEXT NOT NULL UNIQUE,
149+ algo TEXT NOT NULL,
150+ blob TEXT NOT NULL,
151+ comment TEXT NOT NULL DEFAULT '',
152+ created_at INTEGER NOT NULL,
153+ last_used INTEGER
154+ );
155+ CREATE INDEX pubkeys_account ON pubkeys(account);
156+ CREATE TABLE repos (
157+ owner TEXT NOT NULL REFERENCES accounts(name) ON DELETE CASCADE,
158+ name TEXT NOT NULL,
159+ created_at INTEGER NOT NULL,
160+ PRIMARY KEY (owner, name)
161+ );
162+ CREATE TABLE tokens (
163+ id INTEGER PRIMARY KEY,
164+ kind TEXT NOT NULL,
165+ hash TEXT NOT NULL UNIQUE,
166+ account TEXT NOT NULL REFERENCES accounts(name) ON DELETE CASCADE,
167+ scope TEXT NOT NULL DEFAULT '',
168+ label TEXT NOT NULL DEFAULT '',
169+ created_at INTEGER NOT NULL,
170+ expires_at INTEGER,
171+ last_used INTEGER
172+ );
173+ CREATE INDEX tokens_account ON tokens(account, kind);
174+ CREATE TABLE redirects (
175+ old_owner TEXT NOT NULL,
176+ old_name TEXT NOT NULL,
177+ new_owner TEXT NOT NULL,
178+ new_name TEXT NOT NULL,
179+ created_at INTEGER NOT NULL,
180+ PRIMARY KEY (old_owner, old_name)
181+ );`,
182+ postgres: `
183+ CREATE TABLE accounts (
184+ name TEXT PRIMARY KEY,
185+ admin SMALLINT NOT NULL DEFAULT 0,
186+ created_at BIGINT NOT NULL
187+ );
188+ CREATE TABLE pubkeys (
189+ id BIGSERIAL PRIMARY KEY,
190+ account TEXT NOT NULL REFERENCES accounts(name) ON DELETE CASCADE,
191+ fingerprint TEXT NOT NULL UNIQUE,
192+ algo TEXT NOT NULL,
193+ blob TEXT NOT NULL,
194+ comment TEXT NOT NULL DEFAULT '',
195+ created_at BIGINT NOT NULL,
196+ last_used BIGINT
197+ );
198+ CREATE INDEX pubkeys_account ON pubkeys(account);
199+ CREATE TABLE repos (
200+ owner TEXT NOT NULL REFERENCES accounts(name) ON DELETE CASCADE,
201+ name TEXT NOT NULL,
202+ created_at BIGINT NOT NULL,
203+ PRIMARY KEY (owner, name)
204+ );
205+ CREATE TABLE tokens (
206+ id BIGSERIAL PRIMARY KEY,
207+ kind TEXT NOT NULL,
208+ hash TEXT NOT NULL UNIQUE,
209+ account TEXT NOT NULL REFERENCES accounts(name) ON DELETE CASCADE,
210+ scope TEXT NOT NULL DEFAULT '',
211+ label TEXT NOT NULL DEFAULT '',
212+ created_at BIGINT NOT NULL,
213+ expires_at BIGINT,
214+ last_used BIGINT
215+ );
216+ CREATE INDEX tokens_account ON tokens(account, kind);
217+ CREATE TABLE redirects (
218+ old_owner TEXT NOT NULL,
219+ old_name TEXT NOT NULL,
220+ new_owner TEXT NOT NULL,
221+ new_name TEXT NOT NULL,
222+ created_at BIGINT NOT NULL,
223+ PRIMARY KEY (old_owner, old_name)
224+ );`,
225+ }, {
226+ // Sign-in nonces, on disk so a restart mid-flow costs nobody, and deletable to stop a replay.
227+ sqlite: `
228+ CREATE TABLE challenges (
229+ nonce TEXT PRIMARY KEY,
230+ account TEXT NOT NULL REFERENCES accounts(name) ON DELETE CASCADE,
231+ expires_at INTEGER NOT NULL
232+ );`,
233+ postgres: `
234+ CREATE TABLE challenges (
235+ nonce TEXT PRIMARY KEY,
236+ account TEXT NOT NULL REFERENCES accounts(name) ON DELETE CASCADE,
237+ expires_at BIGINT NOT NULL
238+ );`,
239+ }, {
240+ // Runners and jobs are item 6: a lost queue is a build that did not run, not data gone.
241+ sqlite: `
242+ CREATE TABLE runners (
243+ id INTEGER PRIMARY KEY,
244+ token_id INTEGER NOT NULL REFERENCES tokens(id) ON DELETE CASCADE,
245+ repo TEXT NOT NULL,
246+ hostname TEXT NOT NULL,
247+ os TEXT NOT NULL DEFAULT '',
248+ arch TEXT NOT NULL DEFAULT '',
249+ labels TEXT NOT NULL DEFAULT '',
250+ attached_at INTEGER NOT NULL,
251+ last_seen INTEGER NOT NULL
252+ );
253+ CREATE INDEX runners_repo ON runners(repo);
254+ CREATE TABLE jobs (
255+ id INTEGER PRIMARY KEY,
256+ repo TEXT NOT NULL,
257+ ref TEXT NOT NULL,
258+ sha TEXT NOT NULL,
259+ command TEXT NOT NULL,
260+ image TEXT NOT NULL DEFAULT '',
261+ state TEXT NOT NULL,
262+ runner_id INTEGER,
263+ attempts INTEGER NOT NULL DEFAULT 0,
264+ created_at INTEGER NOT NULL,
265+ started_at INTEGER,
266+ log TEXT NOT NULL DEFAULT ''
267+ );
268+ CREATE INDEX jobs_queue ON jobs(repo, state, id);`,
269+ postgres: `
270+ CREATE TABLE runners (
271+ id BIGSERIAL PRIMARY KEY,
272+ token_id BIGINT NOT NULL REFERENCES tokens(id) ON DELETE CASCADE,
273+ repo TEXT NOT NULL,
274+ hostname TEXT NOT NULL,
275+ os TEXT NOT NULL DEFAULT '',
276+ arch TEXT NOT NULL DEFAULT '',
277+ labels TEXT NOT NULL DEFAULT '',
278+ attached_at BIGINT NOT NULL,
279+ last_seen BIGINT NOT NULL
280+ );
281+ CREATE INDEX runners_repo ON runners(repo);
282+ CREATE TABLE jobs (
283+ id BIGSERIAL PRIMARY KEY,
284+ repo TEXT NOT NULL,
285+ ref TEXT NOT NULL,
286+ sha TEXT NOT NULL,
287+ command TEXT NOT NULL,
288+ image TEXT NOT NULL DEFAULT '',
289+ state TEXT NOT NULL,
290+ runner_id BIGINT,
291+ attempts INTEGER NOT NULL DEFAULT 0,
292+ created_at BIGINT NOT NULL,
293+ started_at BIGINT,
294+ log TEXT NOT NULL DEFAULT ''
295+ );
296+ CREATE INDEX jobs_queue ON jobs(repo, state, id);`,
297+ }, {
298+ // A signup challenge holds name and key server-side, and cannot key on an account yet.
299+ sqlite: `
300+ CREATE TABLE signup_challenges (
301+ nonce TEXT PRIMARY KEY,
302+ name TEXT NOT NULL,
303+ pubkey TEXT NOT NULL,
304+ expires_at INTEGER NOT NULL
305+ );`,
306+ postgres: `
307+ CREATE TABLE signup_challenges (
308+ nonce TEXT PRIMARY KEY,
309+ name TEXT NOT NULL,
310+ pubkey TEXT NOT NULL,
311+ expires_at BIGINT NOT NULL
312+ );`,
313+ }, {
314+ // Events are item 6, rebuildable from git, and last_visited draws the inbox rule. 19.4.
315+ sqlite: `
316+ CREATE TABLE events (
317+ id INTEGER PRIMARY KEY,
318+ kind TEXT NOT NULL,
319+ actor TEXT NOT NULL,
320+ repo TEXT NOT NULL,
321+ ref TEXT NOT NULL DEFAULT '',
322+ number INTEGER NOT NULL DEFAULT 0,
323+ title TEXT NOT NULL DEFAULT '',
324+ created_at INTEGER NOT NULL
325+ );
326+ CREATE INDEX events_time ON events(created_at);
327+ CREATE INDEX events_repo ON events(repo, created_at);
328+ CREATE TABLE participation (
329+ account TEXT NOT NULL REFERENCES accounts(name) ON DELETE CASCADE,
330+ repo TEXT NOT NULL,
331+ number INTEGER NOT NULL,
332+ PRIMARY KEY (account, repo, number)
333+ );
334+ ALTER TABLE accounts ADD COLUMN last_visited INTEGER;`,
335+ postgres: `
336+ CREATE TABLE events (
337+ id BIGSERIAL PRIMARY KEY,
338+ kind TEXT NOT NULL,
339+ actor TEXT NOT NULL,
340+ repo TEXT NOT NULL,
341+ ref TEXT NOT NULL DEFAULT '',
342+ number INTEGER NOT NULL DEFAULT 0,
343+ title TEXT NOT NULL DEFAULT '',
344+ created_at BIGINT NOT NULL
345+ );
346+ CREATE INDEX events_time ON events(created_at);
347+ CREATE INDEX events_repo ON events(repo, created_at);
348+ CREATE TABLE participation (
349+ account TEXT NOT NULL REFERENCES accounts(name) ON DELETE CASCADE,
350+ repo TEXT NOT NULL,
351+ number INTEGER NOT NULL,
352+ PRIMARY KEY (account, repo, number)
353+ );
354+ ALTER TABLE accounts ADD COLUMN last_visited BIGINT;`,
355+ }, {
356+ // Chapter 15A: a job says which machine it needs and which workflow job it is.
357+ sqlite: `
358+ ALTER TABLE jobs ADD COLUMN labels TEXT NOT NULL DEFAULT '';
359+ ALTER TABLE jobs ADD COLUMN name TEXT NOT NULL DEFAULT '';`,
360+ postgres: `
361+ ALTER TABLE jobs ADD COLUMN labels TEXT NOT NULL DEFAULT '';
362+ ALTER TABLE jobs ADD COLUMN name TEXT NOT NULL DEFAULT '';`,
363+ }, {
364+ // Chapter 23.4: a hook's failures are the server's to remember, since .barerepo/config cannot.
365+ sqlite: `
366+ CREATE TABLE webhooks (
367+ repo TEXT NOT NULL,
368+ url TEXT NOT NULL,
369+ failures INTEGER NOT NULL DEFAULT 0,
370+ disabled_at INTEGER,
371+ last_error TEXT NOT NULL DEFAULT '',
372+ last_at INTEGER,
373+ PRIMARY KEY (repo, url)
374+ );
375+ CREATE TABLE webhook_cursor (
376+ id INTEGER PRIMARY KEY,
377+ event_id INTEGER NOT NULL
378+ );`,
379+ postgres: `
380+ CREATE TABLE webhooks (
381+ repo TEXT NOT NULL,
382+ url TEXT NOT NULL,
383+ failures INTEGER NOT NULL DEFAULT 0,
384+ disabled_at BIGINT,
385+ last_error TEXT NOT NULL DEFAULT '',
386+ last_at BIGINT,
387+ PRIMARY KEY (repo, url)
388+ );
389+ CREATE TABLE webhook_cursor (
390+ id INTEGER PRIMARY KEY,
391+ event_id BIGINT NOT NULL
392+ );`,
393+ }, {
394+ // Chapter 17: one index, derived from git, carrying who may read it so the filter is in the query.
395+ sqlite: `
396+ CREATE TABLE search_docs (
397+ id INTEGER PRIMARY KEY,
398+ repo TEXT NOT NULL,
399+ kind TEXT NOT NULL,
400+ path TEXT NOT NULL DEFAULT '',
401+ title TEXT NOT NULL DEFAULT '',
402+ body TEXT NOT NULL DEFAULT '',
403+ public INTEGER NOT NULL DEFAULT 0,
404+ readers TEXT NOT NULL DEFAULT ''
405+ );
406+ CREATE UNIQUE INDEX search_one ON search_docs(repo, kind, path);
407+ CREATE INDEX search_repo ON search_docs(repo);`,
408+ postgres: `
409+ CREATE TABLE search_docs (
410+ id BIGSERIAL PRIMARY KEY,
411+ repo TEXT NOT NULL,
412+ kind TEXT NOT NULL,
413+ path TEXT NOT NULL DEFAULT '',
414+ title TEXT NOT NULL DEFAULT '',
415+ body TEXT NOT NULL DEFAULT '',
416+ public INTEGER NOT NULL DEFAULT 0,
417+ readers TEXT NOT NULL DEFAULT ''
418+ );
419+ CREATE UNIQUE INDEX search_one ON search_docs(repo, kind, path);
420+ CREATE INDEX search_repo ON search_docs(repo);`,
421+ }, {
422+ // Chapter 10 item 1 is account name to public keys, and a signing key is one of those.
423+ sqlite: `
424+ CREATE TABLE gpgkeys (
425+ id INTEGER PRIMARY KEY,
426+ account TEXT NOT NULL REFERENCES accounts(name) ON DELETE CASCADE,
427+ fingerprint TEXT NOT NULL UNIQUE,
428+ uid TEXT NOT NULL DEFAULT '',
429+ armor TEXT NOT NULL,
430+ created_at INTEGER NOT NULL
431+ );
432+ CREATE INDEX gpgkeys_account ON gpgkeys(account);`,
433+ postgres: `
434+ CREATE TABLE gpgkeys (
435+ id BIGSERIAL PRIMARY KEY,
436+ account TEXT NOT NULL REFERENCES accounts(name) ON DELETE CASCADE,
437+ fingerprint TEXT NOT NULL UNIQUE,
438+ uid TEXT NOT NULL DEFAULT '',
439+ armor TEXT NOT NULL,
440+ created_at BIGINT NOT NULL
441+ );
442+ CREATE INDEX gpgkeys_account ON gpgkeys(account);`,
443+ }, {
444+ // Chapter 19.3: a failed build's second line is the runner and the exit, which title cannot hold.
445+ sqlite: `
446+ ALTER TABLE events ADD COLUMN detail TEXT NOT NULL DEFAULT '';`,
447+ postgres: `
448+ ALTER TABLE events ADD COLUMN detail TEXT NOT NULL DEFAULT '';`,
449+ }, {
450+ // A retired key stops opening doors and keeps vouching for what it already signed, so it is kept and dated.
451+ sqlite: `
452+ ALTER TABLE pubkeys ADD COLUMN retired_at INTEGER;`,
453+ postgres: `
454+ ALTER TABLE pubkeys ADD COLUMN retired_at BIGINT;`,
455+ }}
456+
457+ func (m migration) ddl(k config.Kind) string {
458+ if k == config.Postgres {
459+ return m.postgres
460+ }
461+ return m.sqlite
462+ }
463+
464+ func (db *DB) migrate(ctx context.Context) error {
465+ // One tracking table in both dialects, because PRAGMA user_version exists in only one.
466+ if _, err := db.DB.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
467+ version INTEGER PRIMARY KEY,
468+ applied_at BIGINT NOT NULL
469+ )`); err != nil {
470+ return err
471+ }
472+ var have int
473+ if err := db.QueryRowContext(ctx,
474+ `SELECT COALESCE(MAX(version), 0) FROM schema_migrations`).Scan(&have); err != nil {
475+ return err
476+ }
477+ for i := have; i < len(migrations); i++ {
478+ tx, err := db.Begin(ctx)
479+ if err != nil {
480+ return err
481+ }
482+ if _, err := tx.ExecContext(ctx, migrations[i].ddl(db.Kind)); err != nil {
483+ tx.Rollback()
484+ return fmt.Errorf("migration %d: %w", i+1, err)
485+ }
486+ if _, err := tx.ExecContext(ctx,
487+ `INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`,
488+ i+1, now().Unix()); err != nil {
489+ tx.Rollback()
490+ return err
491+ }
492+ if err := tx.Commit(); err != nil {
493+ return err
494+ }
495+ }
496+ return nil
497+ }
@@ -0,0 +1,274 @@
1+ package store
2+
3+ import (
4+ "context"
5+ "errors"
6+ "path/filepath"
7+ "regexp"
8+ "sort"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "github.com/barerepo/server/internal/config"
14+ )
15+
16+ // A real ed25519 public key, and the private key a user will paste by mistake.
17+ const (
18+ keyA = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH8fK2q0mR4vXeN1pLzT9wBcJdSgYo3Ea7kVnQxMuP2r laptop"
19+ keyB = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDXWNJHVvNjWLqL0YB2Cbp3ObGZlAqNKvGD5f8ZKr8Kx uproar"
20+ priv = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAA\n-----END OPENSSH PRIVATE KEY-----"
21+ )
22+
23+ // The tests run on SQLite for a fresh database each, and TestMigrationsAgree guards PostgreSQL.
24+ func open(t *testing.T) *DB {
25+ t.Helper()
26+ db, err := Open(context.Background(), "sqlite://"+filepath.Join(t.TempDir(), "barerepo.db"))
27+ if err != nil {
28+ t.Fatalf("Open: %v", err)
29+ }
30+ t.Cleanup(func() { db.Close() })
31+ return db
32+ }
33+
34+ func TestAccountLifecycle(t *testing.T) {
35+ ctx := context.Background()
36+ db := open(t)
37+
38+ if _, err := db.CreateAccount(ctx, "john", keyA, false); err != nil {
39+ t.Fatalf("CreateAccount: %v", err)
40+ }
41+ if _, err := db.CreateAccount(ctx, "john", keyB, false); !errors.Is(err, ErrTaken) {
42+ t.Errorf("second john: got %v, want ErrTaken", err)
43+ }
44+ if _, err := db.CreateAccount(ctx, "lisa", keyA, false); !errors.Is(err, ErrTaken) {
45+ t.Errorf("reused key: got %v, want ErrTaken", err)
46+ }
47+ // Chapter 42.5: a name that shadows a route is not an account.
48+ if _, err := db.CreateAccount(ctx, "inbox", keyB, false); err == nil {
49+ t.Error("account named inbox was accepted")
50+ }
51+ // Chapter 27 and 45.4: the private key paste.
52+ if _, err := db.CreateAccount(ctx, "lisa", priv, false); err == nil ||
53+ !strings.Contains(err.Error(), "private key") {
54+ t.Errorf("private key paste: got %v, want a message naming it", err)
55+ }
56+
57+ a, err := db.Account(ctx, "john")
58+ if err != nil || a.Name != "john" || a.Admin {
59+ t.Fatalf("Account: %v %+v", err, a)
60+ }
61+ if _, err := db.Account(ctx, "nobody"); !errors.Is(err, ErrNotFound) {
62+ t.Errorf("missing account: got %v, want ErrNotFound", err)
63+ }
64+
65+ // Chapter 10: an account with one key must not be able to lose it.
66+ keys, _ := db.Keys(ctx, "john")
67+ if len(keys) != 1 {
68+ t.Fatalf("Keys: got %d, want 1", len(keys))
69+ }
70+ if err := db.DeleteKey(ctx, "john", keys[0].ID); err == nil {
71+ t.Error("deleting the only key was allowed")
72+ }
73+ if _, err := db.AddKey(ctx, "john", keyB); err != nil {
74+ t.Fatalf("AddKey: %v", err)
75+ }
76+ if err := db.DeleteKey(ctx, "john", keys[0].ID); err != nil {
77+ t.Errorf("DeleteKey with two keys: %v", err)
78+ }
79+ if keys, _ = db.Keys(ctx, "john"); len(keys) != 1 {
80+ t.Errorf("after delete: got %d keys, want 1", len(keys))
81+ }
82+ }
83+
84+ func TestMigrateIsIdempotent(t *testing.T) {
85+ ctx := context.Background()
86+ db := open(t)
87+ if err := db.migrate(ctx); err != nil {
88+ t.Fatalf("second migrate: %v", err)
89+ }
90+ var n int
91+ if err := db.QueryRowContext(ctx,
92+ `SELECT COUNT(*) FROM schema_migrations`).Scan(&n); err != nil {
93+ t.Fatal(err)
94+ }
95+ if n != len(migrations) {
96+ t.Errorf("applied %d migrations, want %d", n, len(migrations))
97+ }
98+ }
99+
100+ // TestMigrationsAgree is what stops one hand-written dialect gaining a column alone.
101+ func TestMigrationsAgree(t *testing.T) {
102+ for i, m := range migrations {
103+ s, p := shape(m.sqlite), shape(m.postgres)
104+ if len(s) != len(p) {
105+ t.Fatalf("migration %d: sqlite has %v, postgres has %v", i+1, keysOf(s), keysOf(p))
106+ }
107+ for table, cols := range s {
108+ other, ok := p[table]
109+ if !ok {
110+ t.Errorf("migration %d: postgres is missing table %s", i+1, table)
111+ continue
112+ }
113+ if strings.Join(cols, ",") != strings.Join(other, ",") {
114+ t.Errorf("migration %d: table %s\n sqlite: %v\n postgres: %v",
115+ i+1, table, cols, other)
116+ }
117+ }
118+ }
119+ }
120+
121+ var (
122+ tableRe = regexp.MustCompile(`(?is)CREATE TABLE (\w+) \((.*?)\);`)
123+ colRe = regexp.MustCompile(`(?m)^\s*(\w+)\s+\w`)
124+ )
125+
126+ // shape reduces DDL to table name -> sorted column names.
127+ func shape(ddl string) map[string][]string {
128+ out := map[string][]string{}
129+ for _, m := range tableRe.FindAllStringSubmatch(ddl, -1) {
130+ var cols []string
131+ for _, line := range strings.Split(m[2], "\n") {
132+ if strings.Contains(strings.ToUpper(line), "PRIMARY KEY (") {
133+ continue
134+ }
135+ if c := colRe.FindStringSubmatch(line); c != nil {
136+ cols = append(cols, c[1])
137+ }
138+ }
139+ sort.Strings(cols)
140+ out[m[1]] = cols
141+ }
142+ return out
143+ }
144+
145+ func keysOf(m map[string][]string) []string {
146+ var out []string
147+ for k := range m {
148+ out = append(out, k)
149+ }
150+ sort.Strings(out)
151+ return out
152+ }
153+
154+ func TestDatabaseKind(t *testing.T) {
155+ cases := map[string]config.Kind{
156+ "sqlite:///var/lib/barerepo/forge.db": config.SQLite,
157+ "postgres://barerepo@localhost/barerepo": config.Postgres,
158+ "postgresql://barerepo@localhost/barerepo": config.Postgres,
159+ }
160+ for url, want := range cases {
161+ got, err := config.Config{Database: config.Database{URL: url}}.DatabaseKind()
162+ if err != nil || got != want {
163+ t.Errorf("%s: got %q %v, want %q", url, got, err, want)
164+ }
165+ }
166+ for _, bad := range []string{"", "mysql://x", "/var/lib/barerepo/forge.db"} {
167+ if _, err := (config.Config{Database: config.Database{URL: bad}}).DatabaseKind(); err == nil {
168+ t.Errorf("%q was accepted", bad)
169+ }
170+ }
171+ }
172+
173+ // The window has to fit leaving for a terminal and coming back, so this pins it.
174+ func TestChallengeLifeFitsAPerson(t *testing.T) {
175+ if ChallengeLife < 5*time.Minute {
176+ t.Errorf("ChallengeLife is %s, which is not enough time to switch to a terminal and back", ChallengeLife)
177+ }
178+ if ChallengeLife > time.Hour {
179+ t.Errorf("ChallengeLife is %s, which leaves a challenge open far longer than any sign-in takes", ChallengeLife)
180+ }
181+ }
182+
183+ // A nonce works once, and a wrong signature spends it, so nothing is tried repeatedly.
184+ func TestChallengeIsSpentOnFirstUse(t *testing.T) {
185+ ctx := context.Background()
186+ db := open(t)
187+ if _, err := db.CreateAccount(ctx, "john", keyA, false); err != nil {
188+ t.Fatal(err)
189+ }
190+ if err := db.NewChallenge(ctx, "john", "the-nonce"); err != nil {
191+ t.Fatal(err)
192+ }
193+ if c, err := db.TakeChallenge(ctx, "the-nonce"); err != nil || c.Account != "john" {
194+ t.Fatalf("first use: %v %v", c, err)
195+ }
196+ if _, err := db.TakeChallenge(ctx, "the-nonce"); !errors.Is(err, ErrNotFound) {
197+ t.Error("the same nonce was accepted twice")
198+ }
199+ }
200+
201+ func TestExpiredChallengeIsRefused(t *testing.T) {
202+ ctx := context.Background()
203+ db := open(t)
204+ if _, err := db.CreateAccount(ctx, "john", keyA, false); err != nil {
205+ t.Fatal(err)
206+ }
207+ if err := db.NewChallenge(ctx, "john", "stale"); err != nil {
208+ t.Fatal(err)
209+ }
210+ // Hold the clock forward past the window rather than sleeping.
211+ real := now
212+ now = func() time.Time { return real().Add(ChallengeLife + time.Second) }
213+ defer func() { now = real }()
214+
215+ if _, err := db.TakeChallenge(ctx, "stale"); !errors.Is(err, ErrNotFound) {
216+ t.Error("an expired challenge was accepted")
217+ }
218+ }
219+
220+ // This opens at each older version in turn and upgrades, which a fresh test database never does.
221+ func TestAnExistingDatabaseUpgradesToEveryLaterVersion(t *testing.T) {
222+ ctx := context.Background()
223+ all := migrations
224+ t.Cleanup(func() { migrations = all })
225+
226+ for stop := 1; stop <= len(all); stop++ {
227+ path := filepath.Join(t.TempDir(), "barerepo.db")
228+
229+ // Open as an older barerepo, which knows only the migrations up to that point.
230+ migrations = all[:stop]
231+ old, err := Open(ctx, "sqlite://"+path)
232+ if err != nil {
233+ t.Fatalf("opening at version %d: %v", stop, err)
234+ }
235+ old.Close()
236+
237+ // Then upgrade, the way a server does when its binary is replaced.
238+ migrations = all
239+ db, err := Open(ctx, "sqlite://"+path)
240+ if err != nil {
241+ t.Fatalf("upgrading from version %d: %v", stop, err)
242+ }
243+
244+ var have int
245+ if err := db.QueryRowContext(ctx,
246+ `SELECT COALESCE(MAX(version), 0) FROM schema_migrations`).Scan(&have); err != nil {
247+ t.Fatal(err)
248+ }
249+ if have != len(all) {
250+ t.Errorf("upgrading from %d reached version %d, want %d", stop, have, len(all))
251+ }
252+
253+ // The columns a later migration adds have to be there, or a query written for them fails.
254+ if _, err := db.QueryContext(ctx,
255+ `SELECT id, repo, ref, sha, command, image, labels, name, attempts, created_at
256+ FROM jobs WHERE state = ?`, JobQueued); err != nil {
257+ t.Errorf("upgrading from version %d left the jobs table incomplete: %v", stop, err)
258+ }
259+ db.Close()
260+ }
261+ }
262+
263+ // A migration already applied must never be edited, or an existing install never gets the change.
264+ func TestMigrationsAreAppendOnly(t *testing.T) {
265+ // Raising this is deliberate and fine; editing an earlier migration is what this catches.
266+ const known = 11
267+ if len(migrations) != known {
268+ t.Errorf("there are %d migrations and this test knows %d.\n"+
269+ "if you ADDED one, raise known to %d.\n"+
270+ "if you EDITED an existing one, undo it: an install already past that version "+
271+ "will never run it again, and will break on the first query using the change.",
272+ len(migrations), known, len(migrations))
273+ }
274+ }
@@ -0,0 +1,122 @@
1+ package store
2+
3+ import (
4+ "context"
5+ "database/sql"
6+ "errors"
7+ "time"
8+
9+ "github.com/barerepo/server/internal/token"
10+ )
11+
12+ // Token is one row of item 3 on the closed list.
13+ type Token struct {
14+ ID int64
15+ Kind token.Kind
16+ Account string
17+ Scope string // owner/name for a runner token, empty for a git token
18+ Label string
19+ Created time.Time
20+ LastUsed time.Time
21+ }
22+
23+ // CreateToken issues a token and returns it. The caller shows it once.
24+ func (db *DB) CreateToken(ctx context.Context, kind token.Kind, account, scope, label string) (string, *Token, error) {
25+ tok, hash, err := token.New(kind)
26+ if err != nil {
27+ return "", nil, err
28+ }
29+ now := now()
30+ var id int64
31+ err = db.QueryRowContext(ctx,
32+ `INSERT INTO tokens (kind, hash, account, scope, label, created_at)
33+ VALUES (?, ?, ?, ?, ?, ?) RETURNING id`,
34+ string(kind), hash, account, scope, label, now.Unix()).Scan(&id)
35+ if err != nil {
36+ return "", nil, err
37+ }
38+ return tok, &Token{ID: id, Kind: kind, Account: account, Scope: scope, Label: label, Created: now}, nil
39+ }
40+
41+ // AccountForToken names the owner and stamps last_used, which the keys page shows.
42+ func (db *DB) AccountForToken(ctx context.Context, kind token.Kind, tok string) (*Token, error) {
43+ if k, err := token.KindOf(tok); err != nil || k != kind {
44+ return nil, ErrNotFound
45+ }
46+ var t Token
47+ var created int64
48+ var expires sql.NullInt64
49+ err := db.QueryRowContext(ctx,
50+ `SELECT id, account, scope, label, created_at, expires_at FROM tokens WHERE kind = ? AND hash = ?`,
51+ string(kind), token.Hash(tok)).Scan(&t.ID, &t.Account, &t.Scope, &t.Label, &created, &expires)
52+ if errors.Is(err, sql.ErrNoRows) {
53+ return nil, ErrNotFound
54+ }
55+ if err != nil {
56+ return nil, err
57+ }
58+ nowT := now()
59+ if expires.Valid && nowT.Unix() > expires.Int64 {
60+ return nil, ErrNotFound
61+ }
62+ t.Kind = kind
63+ t.Created = time.Unix(created, 0)
64+ if _, err := db.ExecContext(ctx, `UPDATE tokens SET last_used = ? WHERE id = ?`, nowT.Unix(), t.ID); err != nil {
65+ return nil, err
66+ }
67+ return &t, nil
68+ }
69+
70+ // DeleteToken revokes one token belonging to account.
71+ func (db *DB) DeleteToken(ctx context.Context, account string, id int64) error {
72+ res, err := db.ExecContext(ctx, `DELETE FROM tokens WHERE account = ? AND id = ?`, account, id)
73+ if err != nil {
74+ return err
75+ }
76+ if n, _ := res.RowsAffected(); n == 0 {
77+ return ErrNotFound
78+ }
79+ return nil
80+ }
81+
82+ // TokensOf lists tokens newest first, without sessions, which nobody manages by hand.
83+ func (db *DB) TokensOf(ctx context.Context, account string) ([]Token, error) {
84+ rows, err := db.QueryContext(ctx,
85+ `SELECT id, kind, account, scope, label, created_at, last_used
86+ FROM tokens WHERE account = ? AND kind != ? AND kind != ? ORDER BY created_at DESC, id DESC`,
87+ account, string(token.Session), string(token.Claim))
88+ if err != nil {
89+ return nil, err
90+ }
91+ defer rows.Close()
92+ var out []Token
93+ for rows.Next() {
94+ var t Token
95+ var kind string
96+ var created int64
97+ var used sql.NullInt64
98+ if err := rows.Scan(&t.ID, &kind, &t.Account, &t.Scope, &t.Label, &created, &used); err != nil {
99+ return nil, err
100+ }
101+ t.Kind = token.Kind(kind)
102+ t.Created = time.Unix(created, 0)
103+ if used.Valid {
104+ t.LastUsed = time.Unix(used.Int64, 0)
105+ }
106+ out = append(out, t)
107+ }
108+ return out, rows.Err()
109+ }
110+
111+ // RunnerTokenGrace is how long an unused runner token survives a reload of the page that made it.
112+ const RunnerTokenGrace = time.Hour
113+
114+ // DropStaleRunnerTokens revokes runner tokens nobody pasted, so reloading the page cannot pile them up.
115+ func (db *DB) DropStaleRunnerTokens(ctx context.Context, account, repo string) error {
116+ _, err := db.ExecContext(ctx,
117+ `DELETE FROM tokens WHERE kind = ? AND account = ? AND scope = ?
118+ AND last_used IS NULL AND created_at < ?
119+ AND id NOT IN (SELECT token_id FROM runners)`,
120+ string(token.Runner), account, repo, now().Add(-RunnerTokenGrace).Unix())
121+ return err
122+ }
@@ -0,0 +1,146 @@
1+ package store
2+
3+ import (
4+ "context"
5+ "database/sql"
6+ "errors"
7+ "time"
8+ )
9+
10+ // HookFailureLimit is chapter 23.4's twenty, after which the hook stops and the page says so.
11+ const HookFailureLimit = 20
12+
13+ // HookState is what the server remembers about one webhook, keyed by the url in .barerepo/config.
14+ type HookState struct {
15+ URL string
16+ Failures int
17+ Disabled bool
18+ LastError string
19+ LastAt time.Time
20+ }
21+
22+ // HooksOf returns the delivery state of every hook this repository has ever had.
23+ func (db *DB) HooksOf(ctx context.Context, repo string) (map[string]HookState, error) {
24+ rows, err := db.QueryContext(ctx,
25+ `SELECT url, failures, disabled_at, last_error, last_at FROM webhooks WHERE repo = ?`, repo)
26+ if err != nil {
27+ return nil, err
28+ }
29+ defer rows.Close()
30+ out := map[string]HookState{}
31+ for rows.Next() {
32+ var h HookState
33+ var disabled, last sql.NullInt64
34+ if err := rows.Scan(&h.URL, &h.Failures, &disabled, &h.LastError, &last); err != nil {
35+ return nil, err
36+ }
37+ h.Disabled = disabled.Valid
38+ if last.Valid {
39+ h.LastAt = time.Unix(last.Int64, 0)
40+ }
41+ out[h.URL] = h
42+ }
43+ return out, rows.Err()
44+ }
45+
46+ // HookDelivered clears the count, because twenty consecutive failures means consecutive.
47+ func (db *DB) HookDelivered(ctx context.Context, repo, url string) error {
48+ return db.upsertHook(ctx, repo, url, false, "")
49+ }
50+
51+ // HookFailed counts one failure and disables the hook at the limit.
52+ func (db *DB) HookFailed(ctx context.Context, repo, url, reason string) error {
53+ return db.upsertHook(ctx, repo, url, true, reason)
54+ }
55+
56+ func (db *DB) upsertHook(ctx context.Context, repo, url string, failed bool, reason string) error {
57+ at := now().Unix()
58+ if !failed {
59+ res, err := db.ExecContext(ctx,
60+ `UPDATE webhooks SET failures = 0, disabled_at = NULL, last_error = '', last_at = ?
61+ WHERE repo = ? AND url = ?`, at, repo, url)
62+ if err != nil {
63+ return err
64+ }
65+ if n, _ := res.RowsAffected(); n > 0 {
66+ return nil
67+ }
68+ _, err = db.ExecContext(ctx,
69+ `INSERT INTO webhooks (repo, url, failures, last_error, last_at) VALUES (?, ?, 0, '', ?)`,
70+ repo, url, at)
71+ return err
72+ }
73+ res, err := db.ExecContext(ctx,
74+ `UPDATE webhooks SET failures = failures + 1, last_error = ?, last_at = ?,
75+ disabled_at = CASE WHEN failures + 1 >= ? THEN ? ELSE disabled_at END
76+ WHERE repo = ? AND url = ?`, reason, at, HookFailureLimit, at, repo, url)
77+ if err != nil {
78+ return err
79+ }
80+ if n, _ := res.RowsAffected(); n > 0 {
81+ return nil
82+ }
83+ _, err = db.ExecContext(ctx,
84+ `INSERT INTO webhooks (repo, url, failures, last_error, last_at) VALUES (?, ?, 1, ?, ?)`,
85+ repo, url, reason, at)
86+ return err
87+ }
88+
89+ // ForgetHook drops the state, so an operator who fixed the receiver can start it again.
90+ func (db *DB) ForgetHook(ctx context.Context, repo, url string) error {
91+ _, err := db.ExecContext(ctx, `DELETE FROM webhooks WHERE repo = ? AND url = ?`, repo, url)
92+ return err
93+ }
94+
95+ // WebhookCursor is the last event dispatched, so a restart neither repeats nor skips.
96+ func (db *DB) WebhookCursor(ctx context.Context) (int64, error) {
97+ var id int64
98+ err := db.QueryRowContext(ctx, `SELECT event_id FROM webhook_cursor WHERE id = 1`).Scan(&id)
99+ if errors.Is(err, sql.ErrNoRows) {
100+ return 0, nil
101+ }
102+ return id, err
103+ }
104+
105+ func (db *DB) SetWebhookCursor(ctx context.Context, id int64) error {
106+ res, err := db.ExecContext(ctx, `UPDATE webhook_cursor SET event_id = ? WHERE id = 1`, id)
107+ if err != nil {
108+ return err
109+ }
110+ if n, _ := res.RowsAffected(); n > 0 {
111+ return nil
112+ }
113+ _, err = db.ExecContext(ctx, `INSERT INTO webhook_cursor (id, event_id) VALUES (1, ?)`, id)
114+ return err
115+ }
116+
117+ // StartWebhooksHere puts the cursor at the newest event, so a first start sends no backlog.
118+ func (db *DB) StartWebhooksHere(ctx context.Context) error {
119+ if _, err := db.WebhookCursor(ctx); err != nil {
120+ return err
121+ }
122+ var id sql.NullInt64
123+ if err := db.QueryRowContext(ctx, `SELECT MAX(id) FROM events`).Scan(&id); err != nil {
124+ return err
125+ }
126+ var have int
127+ if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM webhook_cursor WHERE id = 1`).Scan(&have); err != nil {
128+ return err
129+ }
130+ if have > 0 {
131+ return nil
132+ }
133+ return db.SetWebhookCursor(ctx, id.Int64)
134+ }
135+
136+ // EventsAfter returns the events a hook has not seen, oldest first, which is the order they happened.
137+ func (db *DB) EventsAfter(ctx context.Context, after int64, limit int) ([]Event, error) {
138+ rows, err := db.QueryContext(ctx,
139+ `SELECT id, kind, actor, repo, ref, number, title, detail, created_at FROM events
140+ WHERE id > ? ORDER BY id ASC LIMIT ?`, after, limit)
141+ if err != nil {
142+ return nil, err
143+ }
144+ defer rows.Close()
145+ return scanEvents(rows)
146+ }
@@ -0,0 +1,122 @@
1+ package store
2+
3+ import (
4+ "context"
5+ "testing"
6+ )
7+
8+ // Chapter 23.4 disables a hook after twenty failures in a row, and "in a row" is the whole rule.
9+ func TestAHookStopsAfterTwentyFailuresInARow(t *testing.T) {
10+ ctx := context.Background()
11+ db := open(t)
12+
13+ const url = "https://example.com/hook"
14+ for i := range HookFailureLimit - 1 {
15+ if err := db.HookFailed(ctx, "john/johnbot", url, "500 Internal Server Error"); err != nil {
16+ t.Fatal(err)
17+ }
18+ state, err := db.HooksOf(ctx, "john/johnbot")
19+ if err != nil {
20+ t.Fatal(err)
21+ }
22+ if state[url].Disabled {
23+ t.Fatalf("the hook stopped after %d failures, before the limit of %d",
24+ i+1, HookFailureLimit)
25+ }
26+ }
27+ if err := db.HookFailed(ctx, "john/johnbot", url, "500 Internal Server Error"); err != nil {
28+ t.Fatal(err)
29+ }
30+ state, err := db.HooksOf(ctx, "john/johnbot")
31+ if err != nil {
32+ t.Fatal(err)
33+ }
34+ if !state[url].Disabled {
35+ t.Errorf("the hook failed %d times and is still enabled", HookFailureLimit)
36+ }
37+ if state[url].LastError == "" {
38+ t.Error("the config page has nothing to show about why it stopped")
39+ }
40+ }
41+
42+ // One delivery clears the count, or a hook that fails once a week eventually stops for no reason.
43+ func TestOneDeliveryClearsTheFailureCount(t *testing.T) {
44+ ctx := context.Background()
45+ db := open(t)
46+
47+ const url = "https://example.com/hook"
48+ for range HookFailureLimit - 1 {
49+ if err := db.HookFailed(ctx, "john/johnbot", url, "timeout"); err != nil {
50+ t.Fatal(err)
51+ }
52+ }
53+ if err := db.HookDelivered(ctx, "john/johnbot", url); err != nil {
54+ t.Fatal(err)
55+ }
56+ state, err := db.HooksOf(ctx, "john/johnbot")
57+ if err != nil {
58+ t.Fatal(err)
59+ }
60+ if state[url].Failures != 0 {
61+ t.Errorf("a delivered hook still counts %d failures", state[url].Failures)
62+ }
63+ if state[url].LastError != "" {
64+ t.Errorf("a delivered hook still shows the error %q", state[url].LastError)
65+ }
66+ if state[url].LastAt.IsZero() {
67+ t.Error("a delivered hook has no delivery time to show")
68+ }
69+ }
70+
71+ // The cursor is what stops a restart from sending every event in the table again.
72+ func TestTheCursorNeitherRepeatsNorSkips(t *testing.T) {
73+ ctx := context.Background()
74+ db := open(t)
75+
76+ for i := range 3 {
77+ if err := db.Record(ctx, Event{Kind: Pushed, Actor: "john", Repo: "john/johnbot",
78+ Ref: "refs/heads/master", Title: string(rune('a' + i))}); err != nil {
79+ t.Fatal(err)
80+ }
81+ }
82+ // A first start sends no backlog, because a new hook is not a request for history.
83+ if err := db.StartWebhooksHere(ctx); err != nil {
84+ t.Fatal(err)
85+ }
86+ at, err := db.WebhookCursor(ctx)
87+ if err != nil {
88+ t.Fatal(err)
89+ }
90+ pending, err := db.EventsAfter(ctx, at, 100)
91+ if err != nil {
92+ t.Fatal(err)
93+ }
94+ if len(pending) != 0 {
95+ t.Fatalf("a first start would have sent %d old events", len(pending))
96+ }
97+ if err := db.Record(ctx, Event{Kind: ThreadOpened, Actor: "lisa",
98+ Repo: "john/johnbot", Number: 4, Title: "a new one"}); err != nil {
99+ t.Fatal(err)
100+ }
101+ pending, err = db.EventsAfter(ctx, at, 100)
102+ if err != nil {
103+ t.Fatal(err)
104+ }
105+ if len(pending) != 1 || pending[0].Title != "a new one" {
106+ t.Fatalf("the event after the cursor is %v, wanted the one that just happened", pending)
107+ }
108+ // A second start keeps the cursor it has, or every restart replays the same events.
109+ if err := db.SetWebhookCursor(ctx, pending[0].ID); err != nil {
110+ t.Fatal(err)
111+ }
112+ if err := db.StartWebhooksHere(ctx); err != nil {
113+ t.Fatal(err)
114+ }
115+ again, err := db.WebhookCursor(ctx)
116+ if err != nil {
117+ t.Fatal(err)
118+ }
119+ if again != pending[0].ID {
120+ t.Errorf("a restart moved the cursor from %d to %d", pending[0].ID, again)
121+ }
122+ }
@@ -0,0 +1,159 @@
1+ package thread
2+
3+ import (
4+ "context"
5+ "fmt"
6+ "strconv"
7+ "strings"
8+
9+ "github.com/barerepo/server/internal/gitx"
10+ )
11+
12+ // Anchor is chapter 43's four fields, of which the blob hash recovers what the commenter saw.
13+ type Anchor struct {
14+ Path string
15+ Line int
16+ Blob string // the file's blob hash at comment time
17+ Side string // old | new
18+ }
19+
20+ // ParseAnchor reads the "config.go:43" form.
21+ func ParseAnchor(s string) (Anchor, bool) {
22+ path, num, ok := strings.Cut(s, ":")
23+ if !ok {
24+ return Anchor{}, false
25+ }
26+ line, err := strconv.Atoi(num)
27+ if err != nil || line <= 0 || !gitx.ValidPath(path) {
28+ return Anchor{}, false
29+ }
30+ return Anchor{Path: path, Line: line}, true
31+ }
32+
33+ func (a Anchor) String() string { return fmt.Sprintf("%s:%d", a.Path, a.Line) }
34+
35+ // Placement is where a comment lands once its file moved on, in chapter 43.3's three outcomes.
36+ type Placement string
37+
38+ const (
39+ // Exact means the blob is the one the commenter saw.
40+ Exact Placement = "exact"
41+ // Moved means the line survived at a different number.
42+ Moved Placement = "moved"
43+ // Outdated keeps the comment and shows the code it meant, never hidden or deleted. 43.4.
44+ Outdated Placement = "outdated"
45+ // Lost means the retained revision expired, and 43.5 asks this to degrade, not pretend.
46+ Lost Placement = "lost"
47+ )
48+
49+ // Resolved is an anchor after the file has changed.
50+ type Resolved struct {
51+ Placement Placement
52+ Line int // where to show it now
53+ Excerpt string // the line as the commenter saw it
54+ }
55+
56+ // Resolve maps an anchor forward from its blob hash, because a bare line number retrieves nothing.
57+ func Resolve(ctx context.Context, dir string, a Anchor, currentRef string) Resolved {
58+ if a.Blob == "" {
59+ return Resolved{Placement: Lost, Line: a.Line}
60+ }
61+ oldBody, err := gitx.Run(ctx, dir, "cat-file", "blob", a.Blob)
62+ if err != nil {
63+ return Resolved{Placement: Lost, Line: a.Line}
64+ }
65+ oldLines := strings.Split(strings.TrimSuffix(oldBody, "\n"), "\n")
66+ excerpt := ""
67+ if a.Line-1 < len(oldLines) {
68+ excerpt = oldLines[a.Line-1]
69+ }
70+
71+ newBlob, err := currentBlob(ctx, dir, currentRef, a.Path)
72+ if err != nil {
73+ return Resolved{Placement: Outdated, Line: a.Line, Excerpt: excerpt}
74+ }
75+ if newBlob == a.Blob {
76+ return Resolved{Placement: Exact, Line: a.Line, Excerpt: excerpt}
77+ }
78+
79+ line, ok := mapLine(ctx, dir, a.Blob, newBlob, a.Line)
80+ switch {
81+ case !ok:
82+ return Resolved{Placement: Outdated, Line: a.Line, Excerpt: excerpt}
83+ case line == a.Line:
84+ return Resolved{Placement: Exact, Line: line, Excerpt: excerpt}
85+ default:
86+ return Resolved{Placement: Moved, Line: line, Excerpt: excerpt}
87+ }
88+ }
89+
90+ func currentBlob(ctx context.Context, dir, ref, path string) (string, error) {
91+ if !gitx.ValidRev(ref) || !gitx.ValidPath(path) {
92+ return "", fmt.Errorf("bad ref or path")
93+ }
94+ out, err := gitx.Run(ctx, dir, "rev-parse", "--verify", "--quiet", ref+":"+path)
95+ return strings.TrimSpace(out), err
96+ }
97+
98+ // mapLine walks a line between blobs through their diff, and reports false once it was touched.
99+ func mapLine(ctx context.Context, dir, oldBlob, newBlob string, line int) (int, bool) {
100+ patch, err := gitx.Run(ctx, dir, "diff", "--unified=0", "--no-color", oldBlob, newBlob)
101+ if err != nil {
102+ return 0, false
103+ }
104+ // A hunk before the line shifts it, and a hunk containing it makes the line outdated.
105+ shift := 0
106+ for _, h := range strings.Split(patch, "\n") {
107+ if !strings.HasPrefix(h, "@@") {
108+ continue
109+ }
110+ oldStart, oldCount, _, newCount, ok := parseHunkHeader(h)
111+ if !ok {
112+ continue
113+ }
114+ switch {
115+ case oldCount == 0:
116+ // git writes @@ -2,0 +3,2 @@ for lines put after old line 2, so line 2 stays put.
117+ if oldStart < line {
118+ shift += newCount
119+ }
120+ case oldStart+oldCount <= line:
121+ shift += (newCount - oldCount)
122+ case oldStart <= line:
123+ // The line is inside this hunk's removed range.
124+ return 0, false
125+ }
126+ }
127+ return line + shift, true
128+ }
129+
130+ // parseHunkHeader reads "@@ -a,b +c,d @@".
131+ func parseHunkHeader(h string) (oldStart, oldCount, newStart, newCount int, ok bool) {
132+ body := strings.TrimPrefix(h, "@@ ")
133+ if i := strings.Index(body, " @@"); i >= 0 {
134+ body = body[:i]
135+ }
136+ oldPart, newPart, found := strings.Cut(body, " ")
137+ if !found {
138+ return 0, 0, 0, 0, false
139+ }
140+ oldStart, oldCount, ok1 := parseRange(strings.TrimPrefix(oldPart, "-"))
141+ newStart, newCount, ok2 := parseRange(strings.TrimPrefix(newPart, "+"))
142+ return oldStart, oldCount, newStart, newCount, ok1 && ok2
143+ }
144+
145+ func parseRange(s string) (start, count int, ok bool) {
146+ startText, countText, found := strings.Cut(s, ",")
147+ start, err := strconv.Atoi(startText)
148+ if err != nil {
149+ return 0, 0, false
150+ }
151+ count = 1
152+ if found {
153+ count, err = strconv.Atoi(countText)
154+ if err != nil {
155+ return 0, 0, false
156+ }
157+ }
158+ return start, count, true
159+ }
@@ -0,0 +1,149 @@
1+ package thread
2+
3+ import (
4+ "context"
5+ "os"
6+ "os/exec"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+
11+ "github.com/barerepo/server/internal/gitx"
12+ )
13+
14+ // anchorRepo commits a file, changes it in a named way, and returns the original blob hash.
15+ func anchorRepo(t *testing.T, before, after string) (dir, oldBlob string) {
16+ t.Helper()
17+ if _, err := gitx.Version(context.Background()); err != nil {
18+ t.Skip("git is not installed")
19+ }
20+ dir = t.TempDir()
21+ run := func(args ...string) string {
22+ t.Helper()
23+ cmd := exec.Command(gitx.Bin, args...)
24+ cmd.Dir = dir
25+ cmd.Env = append(cmd.Environ(),
26+ "GIT_AUTHOR_NAME=m", "GIT_AUTHOR_EMAIL=m@x",
27+ "GIT_COMMITTER_NAME=m", "GIT_COMMITTER_EMAIL=m@x")
28+ out, err := cmd.CombinedOutput()
29+ if err != nil {
30+ t.Fatalf("git %v: %v\n%s", args, err, out)
31+ }
32+ return strings.TrimSpace(string(out))
33+ }
34+ write := func(body string) {
35+ if err := os.WriteFile(filepath.Join(dir, "config.go"), []byte(body), 0o644); err != nil {
36+ t.Fatal(err)
37+ }
38+ }
39+ run("init", "-q", "-b", "master")
40+ write(before)
41+ run("add", "-A")
42+ run("commit", "-qm", "before")
43+ oldBlob = run("rev-parse", "HEAD:config.go")
44+ if after != before {
45+ write(after)
46+ run("add", "-A")
47+ run("commit", "-qm", "after")
48+ }
49+ return dir, oldBlob
50+ }
51+
52+ // Chapter 43.3's three outcomes, plus 43.5's fourth for when the original is gone.
53+ func TestResolve(t *testing.T) {
54+ const before = "package main\n\nfunc Load() error {\n\treturn nil\n}\n"
55+
56+ t.Run("unchanged blob is exact", func(t *testing.T) {
57+ dir, blob := anchorRepo(t, before, before)
58+ got := Resolve(context.Background(), dir,
59+ Anchor{Path: "config.go", Line: 4, Blob: blob}, "HEAD")
60+ if got.Placement != Exact || got.Line != 4 {
61+ t.Errorf("got %+v, want exact at 4", got)
62+ }
63+ if got.Excerpt != "\treturn nil" {
64+ t.Errorf("excerpt = %q", got.Excerpt)
65+ }
66+ })
67+
68+ t.Run("line moved down", func(t *testing.T) {
69+ // Two lines added above, so line 4 becomes line 6.
70+ after := "package main\n\nimport \"os\"\n\nfunc Load() error {\n\treturn nil\n}\n"
71+ dir, blob := anchorRepo(t, before, after)
72+ got := Resolve(context.Background(), dir,
73+ Anchor{Path: "config.go", Line: 4, Blob: blob}, "HEAD")
74+ if got.Placement != Moved || got.Line != 6 {
75+ t.Errorf("got %+v, want moved to 6", got)
76+ }
77+ // The excerpt is what the commenter saw, not what is there now.
78+ if got.Excerpt != "\treturn nil" {
79+ t.Errorf("excerpt = %q", got.Excerpt)
80+ }
81+ })
82+
83+ t.Run("line rewritten is outdated", func(t *testing.T) {
84+ after := "package main\n\nfunc Load() error {\n\treturn fmt.Errorf(\"no\")\n}\n"
85+ dir, blob := anchorRepo(t, before, after)
86+ got := Resolve(context.Background(), dir,
87+ Anchor{Path: "config.go", Line: 4, Blob: blob}, "HEAD")
88+ if got.Placement != Outdated {
89+ t.Errorf("got %+v, want outdated", got)
90+ }
91+ // Chapter 43.4: never hide an outdated comment, and show the code it referred to.
92+ if got.Excerpt != "\treturn nil" {
93+ t.Errorf("the original line was lost: %q", got.Excerpt)
94+ }
95+ })
96+
97+ t.Run("file deleted is outdated", func(t *testing.T) {
98+ dir, blob := anchorRepo(t, before, before)
99+ cmd := exec.Command(gitx.Bin, "rm", "-q", "config.go")
100+ cmd.Dir = dir
101+ cmd.Run()
102+ cmd = exec.Command(gitx.Bin, "-c", "user.email=m@x", "-c", "user.name=m", "commit", "-qm", "gone")
103+ cmd.Dir = dir
104+ cmd.Run()
105+ got := Resolve(context.Background(), dir,
106+ Anchor{Path: "config.go", Line: 4, Blob: blob}, "HEAD")
107+ if got.Placement != Outdated {
108+ t.Errorf("got %+v, want outdated", got)
109+ }
110+ if got.Excerpt != "\treturn nil" {
111+ t.Errorf("the original line was lost: %q", got.Excerpt)
112+ }
113+ })
114+
115+ t.Run("blob gone degrades to lost", func(t *testing.T) {
116+ dir, _ := anchorRepo(t, before, before)
117+ got := Resolve(context.Background(), dir,
118+ Anchor{Path: "config.go", Line: 4,
119+ Blob: "0000000000000000000000000000000000000000"}, "HEAD")
120+ if got.Placement != Lost {
121+ t.Errorf("got %+v, want lost", got)
122+ }
123+ })
124+
125+ t.Run("no blob stored degrades to lost", func(t *testing.T) {
126+ dir, _ := anchorRepo(t, before, before)
127+ got := Resolve(context.Background(), dir,
128+ Anchor{Path: "config.go", Line: 4}, "HEAD")
129+ if got.Placement != Lost {
130+ t.Errorf("got %+v, want lost", got)
131+ }
132+ })
133+ }
134+
135+ func TestParseAnchor(t *testing.T) {
136+ a, ok := ParseAnchor("config.go:43")
137+ if !ok || a.Path != "config.go" || a.Line != 43 {
138+ t.Errorf("got %+v %v", a, ok)
139+ }
140+ if a.String() != "config.go:43" {
141+ t.Errorf("String() = %q", a.String())
142+ }
143+ for _, bad := range []string{"", "config.go", "config.go:0", "config.go:-1",
144+ "config.go:x", "../../etc/passwd:1", ":4", "-rf:1"} {
145+ if _, ok := ParseAnchor(bad); ok {
146+ t.Errorf("ParseAnchor(%q) was accepted", bad)
147+ }
148+ }
149+ }
@@ -0,0 +1,610 @@
1+ // Package thread stores discussion in git notes, where a ref makes it a proposal. Chapter 13.
2+ package thread
3+
4+ import (
5+ "context"
6+ "crypto/sha256"
7+ "encoding/hex"
8+ "fmt"
9+ "sort"
10+ "strconv"
11+ "strings"
12+ "time"
13+
14+ "github.com/barerepo/server/internal/cache"
15+ "github.com/barerepo/server/internal/gitx"
16+ )
17+
18+ func Ref(n int) string { return "refs/notes/threads/" + strconv.Itoa(n) }
19+
20+ // State is where a thread is. Chapter 12 lists them.
21+ type State string
22+
23+ const (
24+ Open State = "open"
25+ Merged State = "merged"
26+ Closed State = "closed"
27+ Abandoned State = "abandoned"
28+ )
29+
30+ // Meta is not a valid object hash, so git notes ignores it and chapter 35.4 keeps working.
31+ type Meta struct {
32+ Title string
33+ State State
34+ Ref string // the attached proposal ref, if any
35+ Author string
36+ Opened time.Time
37+ // Merged is the commit that made the proposal reachable, observed and not caused. Rule 1.
38+ Merged string
39+ }
40+
41+ // Comment is one record inside a note.
42+ type Comment struct {
43+ Author string
44+ Time time.Time
45+ Anchor string // "config.go:43", optional
46+ Blob string // the file's blob hash when the comment was made
47+ Revision int
48+ Side string // old | new
49+ Body string
50+ }
51+
52+ // recordSep separates records inside one note.
53+ const recordSep = "--"
54+
55+ // Render writes a comment in the header-and-body form of chapter 13.
56+ func (c Comment) Render() string {
57+ var b strings.Builder
58+ fmt.Fprintf(&b, "author: %s\n", oneLine(c.Author))
59+ fmt.Fprintf(&b, "time: %d\n", c.Time.Unix())
60+ if c.Anchor != "" {
61+ fmt.Fprintf(&b, "anchor: %s\n", oneLine(c.Anchor))
62+ }
63+ if c.Blob != "" {
64+ fmt.Fprintf(&b, "blob: %s\n", oneLine(c.Blob))
65+ }
66+ if c.Revision > 0 {
67+ fmt.Fprintf(&b, "revision: %d\n", c.Revision)
68+ }
69+ if c.Side != "" {
70+ fmt.Fprintf(&b, "side: %s\n", oneLine(c.Side))
71+ }
72+ b.WriteString("\n")
73+ b.WriteString(escapeBody(strings.TrimRight(c.Body, "\n")))
74+ b.WriteString("\n")
75+ return b.String()
76+ }
77+
78+ // oneLine keeps a header value from becoming a second header, since a record is lines of key: value.
79+ func oneLine(s string) string {
80+ return strings.Map(func(r rune) rune {
81+ if r == '\n' || r == '\r' {
82+ return ' '
83+ }
84+ return r
85+ }, s)
86+ }
87+
88+ // escapeBody keeps a body from ending its own record, because a reader who types -- is not a separator.
89+ func escapeBody(body string) string {
90+ lines := strings.Split(body, "\n")
91+ for i, line := range lines {
92+ if allDashes(line) {
93+ lines[i] = "-" + line
94+ }
95+ }
96+ return strings.Join(lines, "\n")
97+ }
98+
99+ // unescapeBody undoes it, and a line of two dashes cannot arrive here because writing one adds a third.
100+ func unescapeBody(body string) string {
101+ lines := strings.Split(body, "\n")
102+ for i, line := range lines {
103+ if allDashes(line) && len(line) > len(recordSep) {
104+ lines[i] = line[1:]
105+ }
106+ }
107+ return strings.Join(lines, "\n")
108+ }
109+
110+ // allDashes reports a line that is only dashes, which is the shape a separator has.
111+ func allDashes(line string) bool {
112+ if len(line) < len(recordSep) {
113+ return false
114+ }
115+ return strings.Trim(line, "-") == ""
116+ }
117+
118+ // ParseComments reads every record in one note.
119+ func ParseComments(note string) []Comment {
120+ var out []Comment
121+ for _, record := range splitRecords(note) {
122+ if c, ok := parseComment(record); ok {
123+ out = append(out, c)
124+ }
125+ }
126+ // Union merge concatenates without regard to order, so the reader sorts.
127+ sort.SliceStable(out, func(i, j int) bool { return out[i].Time.Before(out[j].Time) })
128+ return out
129+ }
130+
131+ func splitRecords(note string) []string {
132+ var out []string
133+ var cur []string
134+ for _, line := range strings.Split(note, "\n") {
135+ if strings.TrimRight(line, " \t") == recordSep {
136+ out = append(out, strings.Join(cur, "\n"))
137+ cur = nil
138+ continue
139+ }
140+ cur = append(cur, line)
141+ }
142+ out = append(out, strings.Join(cur, "\n"))
143+ return out
144+ }
145+
146+ func parseComment(record string) (Comment, bool) {
147+ head, body, found := strings.Cut(strings.TrimLeft(record, "\n"), "\n\n")
148+ if !found {
149+ return Comment{}, false
150+ }
151+ var c Comment
152+ c.Body = unescapeBody(strings.TrimRight(body, "\n"))
153+ for _, line := range strings.Split(head, "\n") {
154+ key, value, ok := strings.Cut(line, ": ")
155+ if !ok {
156+ continue
157+ }
158+ switch key {
159+ case "author":
160+ c.Author = value
161+ case "time":
162+ if secs, err := strconv.ParseInt(value, 10, 64); err == nil {
163+ c.Time = time.Unix(secs, 0)
164+ }
165+ case "anchor":
166+ c.Anchor = value
167+ case "blob":
168+ c.Blob = value
169+ case "revision":
170+ c.Revision, _ = strconv.Atoi(value)
171+ case "side":
172+ c.Side = value
173+ }
174+ }
175+ if c.Author == "" {
176+ return Comment{}, false
177+ }
178+ return c, true
179+ }
180+
181+ // Render writes the meta blob.
182+ func (m Meta) Render() string {
183+ var b strings.Builder
184+ fmt.Fprintf(&b, "title: %s\n", oneLine(m.Title))
185+ fmt.Fprintf(&b, "state: %s\n", oneLine(string(m.State)))
186+ if m.Ref != "" {
187+ fmt.Fprintf(&b, "ref: %s\n", oneLine(m.Ref))
188+ }
189+ if m.Author != "" {
190+ fmt.Fprintf(&b, "author: %s\n", oneLine(m.Author))
191+ }
192+ if !m.Opened.IsZero() {
193+ fmt.Fprintf(&b, "opened: %d\n", m.Opened.Unix())
194+ }
195+ if m.Merged != "" {
196+ fmt.Fprintf(&b, "merged: %s\n", oneLine(m.Merged))
197+ }
198+ return b.String()
199+ }
200+
201+ // ParseMeta reads the meta blob.
202+ func ParseMeta(body string) Meta {
203+ m := Meta{State: Open}
204+ for _, line := range strings.Split(body, "\n") {
205+ key, value, ok := strings.Cut(line, ": ")
206+ if !ok {
207+ continue
208+ }
209+ switch key {
210+ case "title":
211+ m.Title = value
212+ case "state":
213+ m.State = State(value)
214+ case "ref":
215+ m.Ref = value
216+ case "author":
217+ m.Author = value
218+ case "opened":
219+ if secs, err := strconv.ParseInt(value, 10, 64); err == nil {
220+ m.Opened = time.Unix(secs, 0)
221+ }
222+ case "merged":
223+ m.Merged = value
224+ }
225+ }
226+ return m
227+ }
228+
229+ // ReadMeta loads a thread's metadata, or reports whether it exists.
230+ func ReadMeta(ctx context.Context, dir string, n int) (Meta, bool, error) {
231+ out, err := gitx.Run(ctx, dir, "cat-file", "blob", Ref(n)+":meta")
232+ if err != nil {
233+ return Meta{}, false, nil
234+ }
235+ return ParseMeta(out), true, nil
236+ }
237+
238+ // Summary is one row of the unified list, because splitting the two serves a schema, not a reader.
239+ type Summary struct {
240+ N int
241+ Meta Meta
242+ Replies int
243+ Updated time.Time
244+ }
245+
246+ // Cache keys a row by the note commit that wrote it, so a row is computed once and never again.
247+ var Cache *cache.Disk
248+
249+ // rowKind is the cache namespace, kept apart from the config and diff answers beside it.
250+ const rowKind = "thread-row"
251+
252+ // List reads every thread, newest activity first, without a git process for one it has seen.
253+ func List(ctx context.Context, dir string) ([]Summary, error) {
254+ refs, err := gitx.ListRefs(dir, "refs/notes/threads")
255+ if err != nil {
256+ return nil, err
257+ }
258+ var list []Summary
259+ var coldN []int
260+ var commits []string
261+ for name, sha := range refs {
262+ n, err := strconv.Atoi(strings.TrimPrefix(name, "refs/notes/threads/"))
263+ if err != nil || n <= 0 {
264+ continue
265+ }
266+ if body, ok := Cache.Get(rowKind, sha); ok {
267+ if s, ok := parseRow(string(body)); ok {
268+ s.N = n
269+ list = append(list, s)
270+ continue
271+ }
272+ }
273+ coldN = append(coldN, n)
274+ commits = append(commits, sha)
275+ }
276+ cold, err := readRows(ctx, dir, coldN, commits)
277+ if err != nil {
278+ return nil, err
279+ }
280+ for i, s := range cold {
281+ if s.N == 0 {
282+ continue
283+ }
284+ Cache.Put(rowKind, commits[i], []byte(renderRow(s)))
285+ list = append(list, s)
286+ }
287+ // The number settles what the clock cannot, because the thread list pages out of this order and seconds tie easily.
288+ sort.Slice(list, func(i, j int) bool {
289+ if list[i].Updated.Equal(list[j].Updated) {
290+ return list[i].N > list[j].N
291+ }
292+ return list[i].Updated.After(list[j].Updated)
293+ })
294+ return list, nil
295+ }
296+
297+ // OpenCount is what the tab strip shows on every repository page, so it must cost no process.
298+ func OpenCount(ctx context.Context, dir string) int {
299+ list, err := List(ctx, dir)
300+ if err != nil {
301+ return 0
302+ }
303+ n := 0
304+ for _, s := range list {
305+ if s.Meta.State == Open {
306+ n++
307+ }
308+ }
309+ return n
310+ }
311+
312+ // openKind caches a count under the refs that produced it, apart from the rows beside it.
313+ const openKind = "thread-open"
314+
315+ // OpenProposals counts the open threads that carry a ref, which is the number chapter 24 puts on a profile.
316+ func OpenProposals(ctx context.Context, dir string) int {
317+ refs, err := gitx.ListRefs(dir, "refs/notes/threads")
318+ if err != nil {
319+ return 0
320+ }
321+ // A profile asks this of every repository it lists, so one read answers it.
322+ key := refsDigest(refs)
323+ if body, ok := Cache.Get(openKind, key); ok {
324+ if n, err := strconv.Atoi(string(body)); err == nil {
325+ return n
326+ }
327+ }
328+ list, err := List(ctx, dir)
329+ if err != nil {
330+ return 0
331+ }
332+ n := 0
333+ for _, s := range list {
334+ if s.Meta.State == Open && s.Meta.Ref != "" {
335+ n++
336+ }
337+ }
338+ Cache.Put(openKind, key, []byte(strconv.Itoa(n)))
339+ return n
340+ }
341+
342+ // refsDigest names the exact state the count was read from, so a new note is a new key.
343+ func refsDigest(refs map[string]string) string {
344+ names := make([]string, 0, len(refs))
345+ for name := range refs {
346+ names = append(names, name)
347+ }
348+ sort.Strings(names)
349+ h := sha256.New()
350+ for _, name := range names {
351+ fmt.Fprintf(h, "%s %s\n", name, refs[name])
352+ }
353+ return hex.EncodeToString(h.Sum(nil))
354+ }
355+
356+ // OpenProposalsBy counts what one account has open here, which is chapter 27's cap on rule 5.
357+ func OpenProposalsBy(ctx context.Context, dir, account string) int {
358+ if account == "" {
359+ return 0
360+ }
361+ list, err := List(ctx, dir)
362+ if err != nil {
363+ return 0
364+ }
365+ n := 0
366+ for _, s := range list {
367+ if s.Meta.State == Open && s.Meta.Ref != "" && s.Meta.Author == account {
368+ n++
369+ }
370+ }
371+ return n
372+ }
373+
374+ // readRows builds the rows a cache miss left, in three batches whatever the thread count.
375+ func readRows(ctx context.Context, dir string, ns []int, commits []string) ([]Summary, error) {
376+ rows := make([]Summary, len(ns))
377+ if len(ns) == 0 {
378+ return rows, nil
379+ }
380+ commitObjs, err := gitx.Batch(ctx, dir, commits)
381+ if err != nil {
382+ return nil, err
383+ }
384+ trees := make([]string, len(ns))
385+ for i := range ns {
386+ c := commitObjs[commits[i]]
387+ if c == nil {
388+ continue
389+ }
390+ tree, when := commitTreeAndTime(c.Body)
391+ trees[i] = tree
392+ rows[i].Updated = when
393+ }
394+ treeObjs, err := gitx.Batch(ctx, dir, compact(trees))
395+ if err != nil {
396+ return nil, err
397+ }
398+ var blobs []string
399+ entries := make([]map[string]string, len(ns))
400+ for i := range ns {
401+ t := treeObjs[trees[i]]
402+ if t == nil {
403+ continue
404+ }
405+ entries[i] = gitx.TreeEntries(t.Body)
406+ for _, sha := range entries[i] {
407+ blobs = append(blobs, sha)
408+ }
409+ }
410+ blobObjs, err := gitx.Batch(ctx, dir, blobs)
411+ if err != nil {
412+ return nil, err
413+ }
414+ for i, n := range ns {
415+ meta, ok := entries[i]["meta"]
416+ if !ok || blobObjs[meta] == nil {
417+ continue
418+ }
419+ rows[i].N = n
420+ rows[i].Meta = ParseMeta(blobObjs[meta].Body)
421+ for name, sha := range entries[i] {
422+ if name == "meta" || blobObjs[sha] == nil {
423+ continue
424+ }
425+ rows[i].Replies += len(ParseComments(blobObjs[sha].Body))
426+ }
427+ }
428+ return rows, nil
429+ }
430+
431+ // compact drops the empty slots a missing commit left, since cat-file has nothing to say about "".
432+ func compact(in []string) []string {
433+ out := make([]string, 0, len(in))
434+ for _, s := range in {
435+ if s != "" {
436+ out = append(out, s)
437+ }
438+ }
439+ return out
440+ }
441+
442+ // commitTreeAndTime reads the two facts a row needs out of a raw commit object.
443+ func commitTreeAndTime(body string) (string, time.Time) {
444+ tree, when := "", time.Time{}
445+ for _, line := range strings.Split(body, "\n") {
446+ if line == "" {
447+ break
448+ }
449+ switch {
450+ case strings.HasPrefix(line, "tree "):
451+ tree = strings.TrimSpace(strings.TrimPrefix(line, "tree "))
452+ case strings.HasPrefix(line, "committer "):
453+ // The timestamp is the second field from the end, before the zone offset.
454+ fields := strings.Fields(line)
455+ if len(fields) >= 2 {
456+ if secs, err := strconv.ParseInt(fields[len(fields)-2], 10, 64); err == nil {
457+ when = time.Unix(secs, 0)
458+ }
459+ }
460+ }
461+ }
462+ return tree, when
463+ }
464+
465+ // renderRow writes a row for the cache, in the header form the notes themselves use.
466+ func renderRow(s Summary) string {
467+ var b strings.Builder
468+ fmt.Fprintf(&b, "updated: %d\n", s.Updated.Unix())
469+ fmt.Fprintf(&b, "replies: %d\n", s.Replies)
470+ b.WriteString("\n")
471+ b.WriteString(s.Meta.Render())
472+ return b.String()
473+ }
474+
475+ // parseRow reads a cached row back, and anything it does not understand is a miss.
476+ func parseRow(body string) (Summary, bool) {
477+ head, meta, ok := strings.Cut(body, "\n\n")
478+ if !ok {
479+ return Summary{}, false
480+ }
481+ var s Summary
482+ for _, line := range strings.Split(head, "\n") {
483+ key, value, ok := strings.Cut(line, ": ")
484+ if !ok {
485+ continue
486+ }
487+ switch key {
488+ case "updated":
489+ secs, err := strconv.ParseInt(value, 10, 64)
490+ if err != nil {
491+ return Summary{}, false
492+ }
493+ s.Updated = time.Unix(secs, 0)
494+ case "replies":
495+ n, err := strconv.Atoi(value)
496+ if err != nil {
497+ return Summary{}, false
498+ }
499+ s.Replies = n
500+ }
501+ }
502+ s.Meta = ParseMeta(meta)
503+ return s, true
504+ }
505+
506+ // MetaAt reads one note commit's meta, which is how a hook sees a thread before and after a push.
507+ func MetaAt(ctx context.Context, dir, commit string) (Meta, bool) {
508+ if commit == "" || strings.Trim(commit, "0") == "" {
509+ return Meta{}, false
510+ }
511+ spec := commit + "^{tree}"
512+ head, err := gitx.Batch(ctx, dir, []string{spec})
513+ if err != nil || head[spec] == nil {
514+ return Meta{}, false
515+ }
516+ meta, ok := gitx.TreeEntries(head[spec].Body)["meta"]
517+ if !ok {
518+ return Meta{}, false
519+ }
520+ blobs, err := gitx.Batch(ctx, dir, []string{meta})
521+ if err != nil || blobs[meta] == nil {
522+ return Meta{}, false
523+ }
524+ return ParseMeta(blobs[meta].Body), true
525+ }
526+
527+ // NumberOf reads a thread number out of its notes ref, or returns 0.
528+ func NumberOf(ref string) int {
529+ rest, ok := strings.CutPrefix(ref, "refs/notes/threads/")
530+ if !ok {
531+ return 0
532+ }
533+ n, err := strconv.Atoi(rest)
534+ if err != nil || n <= 0 {
535+ return 0
536+ }
537+ return n
538+ }
539+
540+ // Read loads a whole thread: its metadata and every comment, in time order.
541+ func Read(ctx context.Context, dir string, n int) (Meta, []Comment, error) {
542+ // Two processes, whatever the comment count, where a cat-file per note cost far more.
543+ head, err := gitx.Batch(ctx, dir, []string{Ref(n) + "^{tree}"})
544+ if err != nil {
545+ return Meta{}, nil, err
546+ }
547+ tree := head[Ref(n)+"^{tree}"]
548+ if tree == nil {
549+ return Meta{}, nil, nil
550+ }
551+ entries := gitx.TreeEntries(tree.Body)
552+ specs := make([]string, 0, len(entries))
553+ for _, sha := range entries {
554+ specs = append(specs, sha)
555+ }
556+ blobs, err := gitx.Batch(ctx, dir, specs)
557+ if err != nil {
558+ return Meta{}, nil, err
559+ }
560+ meta, ok := entries["meta"]
561+ if !ok || blobs[meta] == nil {
562+ return Meta{}, nil, nil
563+ }
564+ m := ParseMeta(blobs[meta].Body)
565+ var all []Comment
566+ for name, sha := range entries {
567+ if name == "meta" || blobs[sha] == nil {
568+ continue
569+ }
570+ all = append(all, ParseComments(blobs[sha].Body)...)
571+ }
572+ sort.SliceStable(all, func(i, j int) bool { return all[i].Time.Before(all[j].Time) })
573+ return m, all, nil
574+ }
575+
576+ // RecordsAt is every comment a thread's note holds, so a push that drops one can be told, since chapter 13 keeps them as records and only appends.
577+ func RecordsAt(ctx context.Context, dir, commit string) map[string]bool {
578+ out := map[string]bool{}
579+ if commit == "" || strings.Trim(commit, "0") == "" {
580+ return out
581+ }
582+ spec := commit + "^{tree}"
583+ head, err := gitx.Batch(ctx, dir, []string{spec})
584+ if err != nil || head[spec] == nil {
585+ return out
586+ }
587+ // meta is the thread's own record and is guarded on its own, so only the notes are read here.
588+ specs := make([]string, 0, 4)
589+ for name, sha := range gitx.TreeEntries(head[spec].Body) {
590+ if name != "meta" {
591+ specs = append(specs, sha)
592+ }
593+ }
594+ if len(specs) == 0 {
595+ return out
596+ }
597+ blobs, err := gitx.Batch(ctx, dir, specs)
598+ if err != nil {
599+ return out
600+ }
601+ for _, sha := range specs {
602+ if blobs[sha] == nil {
603+ continue
604+ }
605+ for _, c := range ParseComments(blobs[sha].Body) {
606+ out[c.Render()] = true
607+ }
608+ }
609+ return out
610+ }
@@ -0,0 +1,276 @@
1+ package thread
2+
3+ import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "os/exec"
8+ "strconv"
9+ "strings"
10+ "sync"
11+ "testing"
12+ "time"
13+
14+ "github.com/barerepo/server/internal/gitx"
15+ )
16+
17+ func repoWithCommit(t *testing.T) (dir, sha string) {
18+ t.Helper()
19+ if _, err := gitx.Version(context.Background()); err != nil {
20+ t.Skip("git is not installed")
21+ }
22+ dir = t.TempDir()
23+ run := func(args ...string) string {
24+ t.Helper()
25+ cmd := exec.Command(gitx.Bin, args...)
26+ cmd.Dir = dir
27+ cmd.Env = append(cmd.Environ(),
28+ "GIT_AUTHOR_NAME=lisa", "GIT_AUTHOR_EMAIL=m@x",
29+ "GIT_COMMITTER_NAME=lisa", "GIT_COMMITTER_EMAIL=m@x")
30+ out, err := cmd.CombinedOutput()
31+ if err != nil {
32+ t.Fatalf("git %v: %v\n%s", args, err, out)
33+ }
34+ return strings.TrimSpace(string(out))
35+ }
36+ run("init", "-q", "-b", "master")
37+ run("commit", "-q", "--allow-empty", "-m", "first")
38+ return dir, run("rev-parse", "HEAD")
39+ }
40+
41+ func TestThreadRoundTrip(t *testing.T) {
42+ ctx := context.Background()
43+ dir, sha := repoWithCommit(t)
44+
45+ m := Meta{
46+ Title: "panic when config file is empty", State: Open,
47+ Ref: "refs/proposals/47", Author: "lisa", Opened: time.Unix(1787074650, 0),
48+ }
49+ first := Comment{Author: "lisa", Time: time.Unix(1787074650, 0),
50+ Body: "fresh install, empty config.toml, immediate nil deref on line 44."}
51+ if err := Create(ctx, dir, 47, m, sha, first); err != nil {
52+ t.Fatal(err)
53+ }
54+ if err := Create(ctx, dir, 47, m, sha, first); err == nil {
55+ t.Error("a thread was opened twice with the same number")
56+ }
57+
58+ got, exists, err := ReadMeta(ctx, dir, 47)
59+ if err != nil || !exists {
60+ t.Fatalf("ReadMeta: %v %v", exists, err)
61+ }
62+ if got.Title != m.Title || got.State != Open || got.Ref != m.Ref || got.Author != "lisa" {
63+ t.Errorf("meta round trip lost something: %+v", got)
64+ }
65+
66+ if err := Reply(ctx, dir, 47, sha, Comment{
67+ Author: "john", Time: time.Unix(1787078250, 0), Anchor: "config.go:43",
68+ Body: "Default() allocates every call. make it a package var?",
69+ }); err != nil {
70+ t.Fatal(err)
71+ }
72+
73+ // The whole point: git itself can read this, with no barerepo involved.
74+ out, err := gitx.Run(ctx, dir, "log", "--show-notes=threads/47", "--max-count=1")
75+ if err != nil {
76+ t.Fatal(err)
77+ }
78+ for _, want := range []string{"Notes (threads/47)", "immediate nil deref", "package var?"} {
79+ if !strings.Contains(out, want) {
80+ t.Errorf("git log --show-notes did not show %q\n%s", want, out)
81+ }
82+ }
83+ // And so can git notes show, by the object's hash.
84+ note, err := gitx.Run(ctx, dir, "notes", "--ref=threads/47", "show", sha)
85+ if err != nil {
86+ t.Fatalf("git notes show: %v", err)
87+ }
88+ comments := ParseComments(note)
89+ if len(comments) != 2 {
90+ t.Fatalf("got %d comments, want 2: %+v", len(comments), comments)
91+ }
92+ if comments[0].Author != "lisa" || comments[1].Author != "john" {
93+ t.Errorf("comments are out of order: %+v", comments)
94+ }
95+ if comments[1].Anchor != "config.go:43" {
96+ t.Errorf("the anchor was lost: %+v", comments[1])
97+ }
98+ }
99+
100+ // Chapter 45.3's thousand, at full count outside short mode, since each write costs processes.
101+ func TestConcurrentRepliesAtScale(t *testing.T) {
102+ if testing.Short() {
103+ t.Skip("chapter 45.3's full count takes minutes")
104+ }
105+ ctx := context.Background()
106+ dir, sha := repoWithCommit(t)
107+ if err := Create(ctx, dir, 1, Meta{Title: "scale", State: Open}, "", Comment{}); err != nil {
108+ t.Fatal(err)
109+ }
110+
111+ const rounds, writers = 50, 20
112+ for round := range rounds {
113+ var wg sync.WaitGroup
114+ errs := make([]error, writers)
115+ start := make(chan struct{})
116+ for i := range writers {
117+ wg.Add(1)
118+ go func(i int) {
119+ defer wg.Done()
120+ <-start
121+ errs[i] = Reply(ctx, dir, 1, sha, Comment{
122+ Author: "writer",
123+ Time: time.Unix(int64(1787074650+round*writers+i), 0),
124+ Body: fmt.Sprintf("round %d writer %d", round, i),
125+ })
126+ }(i)
127+ }
128+ close(start)
129+ wg.Wait()
130+ for i, err := range errs {
131+ if err != nil {
132+ t.Fatalf("round %d writer %d: %v", round, i, err)
133+ }
134+ }
135+ }
136+
137+ note, err := gitx.Run(ctx, dir, "notes", "--ref=threads/1", "show", sha)
138+ if err != nil {
139+ t.Fatal(err)
140+ }
141+ if got := len(ParseComments(note)); got != rounds*writers {
142+ t.Errorf("kept %d comments of %d: %d were dropped",
143+ got, rounds*writers, rounds*writers-got)
144+ }
145+ }
146+
147+ // Chapter 45.3: two comments at one moment, both surviving, because the loser reads again.
148+ func TestConcurrentReplies(t *testing.T) {
149+ ctx := context.Background()
150+ dir, sha := repoWithCommit(t)
151+ if err := Create(ctx, dir, 1, Meta{Title: "concurrency", State: Open}, "", Comment{}); err != nil {
152+ t.Fatal(err)
153+ }
154+
155+ const n = 12
156+ var wg sync.WaitGroup
157+ errs := make([]error, n)
158+ start := make(chan struct{})
159+ for i := 0; i < n; i++ {
160+ wg.Add(1)
161+ go func(i int) {
162+ defer wg.Done()
163+ <-start
164+ errs[i] = Reply(ctx, dir, 1, sha, Comment{
165+ Author: "writer", Time: time.Unix(int64(1787074650+i), 0),
166+ Body: "comment " + string(rune('a'+i)),
167+ })
168+ }(i)
169+ }
170+ close(start)
171+ wg.Wait()
172+ for i, err := range errs {
173+ if err != nil {
174+ t.Fatalf("reply %d failed: %v", i, err)
175+ }
176+ }
177+ note, err := gitx.Run(ctx, dir, "notes", "--ref=threads/1", "show", sha)
178+ if err != nil {
179+ t.Fatal(err)
180+ }
181+ if got := len(ParseComments(note)); got != n {
182+ t.Errorf("kept %d comments of %d; the rest were dropped", got, n)
183+ }
184+ }
185+
186+ func TestSetState(t *testing.T) {
187+ ctx := context.Background()
188+ dir, _ := repoWithCommit(t)
189+ if err := Create(ctx, dir, 5, Meta{Title: "t", State: Open}, "", Comment{}); err != nil {
190+ t.Fatal(err)
191+ }
192+ if err := SetState(ctx, dir, 5, Merged, "a3f9c2d"); err != nil {
193+ t.Fatal(err)
194+ }
195+ m, _, _ := ReadMeta(ctx, dir, 5)
196+ if m.State != Merged || m.Merged != "a3f9c2d" {
197+ t.Errorf("state = %+v", m)
198+ }
199+ if m.Title != "t" {
200+ t.Error("changing the state lost the title")
201+ }
202+ }
203+
204+ func TestParseCommentsIgnoresJunk(t *testing.T) {
205+ if got := ParseComments("no headers here"); len(got) != 0 {
206+ t.Errorf("parsed %d comments from junk", len(got))
207+ }
208+ if got := ParseComments(""); len(got) != 0 {
209+ t.Errorf("parsed %d comments from nothing", len(got))
210+ }
211+ }
212+
213+ // helperEnv carries one reply into the child process started below.
214+ const helperEnv = "BAREREPO_THREAD_HELPER"
215+
216+ // TestReplyHelper is a child process: it writes one comment and exits.
217+ func TestReplyHelper(t *testing.T) {
218+ spec := os.Getenv(helperEnv)
219+ if spec == "" {
220+ t.Skip("not a helper process")
221+ }
222+ fields := strings.Split(spec, "\x1e")
223+ if len(fields) != 3 {
224+ t.Fatalf("helper got %q", spec)
225+ }
226+ when, _ := strconv.ParseInt(fields[2], 10, 64)
227+ err := Reply(context.Background(), fields[0], 1, fields[1], Comment{
228+ Author: "writer", Time: time.Unix(when, 0),
229+ Body: fmt.Sprintf("from process %d", when),
230+ })
231+ if err != nil {
232+ t.Fatalf("helper reply: %v", err)
233+ }
234+ }
235+
236+ // Chapter 45.3 across processes, which is a hook and the web server writing at once, and all the swap saves.
237+ func TestConcurrentRepliesAcrossProcesses(t *testing.T) {
238+ ctx := context.Background()
239+ dir, sha := repoWithCommit(t)
240+ if err := Create(ctx, dir, 1, Meta{Title: "processes", State: Open}, "", Comment{}); err != nil {
241+ t.Fatal(err)
242+ }
243+
244+ const n = 8
245+ var wg sync.WaitGroup
246+ errs := make([]error, n)
247+ start := make(chan struct{})
248+ for i := range n {
249+ wg.Add(1)
250+ go func(i int) {
251+ defer wg.Done()
252+ cmd := exec.Command(os.Args[0], "-test.run=TestReplyHelper", "-test.v")
253+ cmd.Env = append(os.Environ(),
254+ fmt.Sprintf("%s=%s\x1e%s\x1e%d", helperEnv, dir, sha, 1787074650+i))
255+ <-start
256+ if out, err := cmd.CombinedOutput(); err != nil {
257+ errs[i] = fmt.Errorf("%v\n%s", err, out)
258+ }
259+ }(i)
260+ }
261+ close(start)
262+ wg.Wait()
263+ for i, err := range errs {
264+ if err != nil {
265+ t.Fatalf("process %d: %v", i, err)
266+ }
267+ }
268+
269+ note, err := gitx.Run(ctx, dir, "notes", "--ref=threads/1", "show", sha)
270+ if err != nil {
271+ t.Fatal(err)
272+ }
273+ if got := len(ParseComments(note)); got != n {
274+ t.Errorf("kept %d comments of %d; a second writer's comment was dropped", got, n)
275+ }
276+ }
@@ -0,0 +1,356 @@
1+ package thread
2+
3+ import (
4+ "context"
5+ "fmt"
6+ "hash/fnv"
7+ "sort"
8+ "strconv"
9+ "strings"
10+ "sync"
11+ "time"
12+
13+ "github.com/barerepo/server/internal/gitx"
14+ )
15+
16+ // maxRetries bounds the swap on the notes ref, where contention is two simultaneous comments.
17+ const maxRetries = 20
18+
19+ // stripes serialise this process's writers. A map keyed by repository and thread would only grow.
20+ var stripes [64]sync.Mutex
21+
22+ // lockFor picks the mutex guarding one thread's ref.
23+ func lockFor(dir string, n int) *sync.Mutex {
24+ h := fnv.New32a()
25+ h.Write([]byte(dir + "/" + strconv.Itoa(n)))
26+ return &stripes[h.Sum32()%uint32(len(stripes))]
27+ }
28+
29+ // Write swaps the note ref, so a losing writer reads again rather than dropping a comment.
30+ func Write(ctx context.Context, dir string, n int, message string, mutate func(t *Tree)) error {
31+ // One writer at a time here, so the swap below only ever loses to another process.
32+ mu := lockFor(dir, n)
33+ mu.Lock()
34+ defer mu.Unlock()
35+ for attempt := 0; attempt < maxRetries; attempt++ {
36+ old, tree, err := read(ctx, dir, n)
37+ if err != nil {
38+ return err
39+ }
40+ mutate(tree)
41+
42+ treeSHA, err := tree.write(ctx, dir)
43+ if err != nil {
44+ return err
45+ }
46+ args := []string{"commit-tree", treeSHA, "-m", message}
47+ if old != "" {
48+ args = append(args, "-p", old)
49+ }
50+ commit, err := gitx.RunStdin(ctx, dir, "", args...)
51+ if err != nil {
52+ return err
53+ }
54+ commit = strings.TrimSpace(commit)
55+
56+ if _, err := gitx.Run(ctx, dir, "update-ref", Ref(n), commit, old); err == nil {
57+ return nil
58+ }
59+ // Somebody wrote between the read and the write, so read again with their record in.
60+ }
61+ return fmt.Errorf("could not write thread %d after %d tries", n, maxRetries)
62+ }
63+
64+ // Tree is one note tree: a meta blob and a note per annotated object.
65+ type Tree struct {
66+ Meta Meta
67+ Notes map[string]string // object sha -> note body
68+ order []string // the order paths were first seen, so trees are stable
69+ // blobs keeps each entry's hash, so an untouched note costs no git process to rewrite.
70+ blobs map[string]string
71+ dirty map[string]bool
72+ // metaBlob and metaWas let an unchanged meta blob be reused.
73+ metaBlob string
74+ metaWas string
75+ }
76+
77+ // Append adds a comment to the note on object.
78+ func (t *Tree) Append(object string, c Comment) {
79+ body := t.Notes[object]
80+ if strings.TrimSpace(body) != "" {
81+ body = strings.TrimRight(body, "\n") + "\n" + recordSep + "\n"
82+ } else {
83+ body = ""
84+ }
85+ t.set(object, body+c.Render())
86+ }
87+
88+ func (t *Tree) set(path, body string) {
89+ if t.Notes == nil {
90+ t.Notes = map[string]string{}
91+ }
92+ if _, seen := t.Notes[path]; !seen {
93+ t.order = append(t.order, path)
94+ }
95+ t.Notes[path] = body
96+ if t.dirty == nil {
97+ t.dirty = map[string]bool{}
98+ }
99+ t.dirty[path] = true
100+ }
101+
102+ // keep records the hash of an entry this write does not touch.
103+ func (t *Tree) keep(path, body, blob string) {
104+ if t.Notes == nil {
105+ t.Notes = map[string]string{}
106+ }
107+ if t.blobs == nil {
108+ t.blobs = map[string]string{}
109+ }
110+ if _, seen := t.Notes[path]; !seen {
111+ t.order = append(t.order, path)
112+ }
113+ t.Notes[path] = body
114+ t.blobs[path] = blob
115+ }
116+
117+ // read loads the current note tree, or an empty one, in two processes whatever the note count.
118+ func read(ctx context.Context, dir string, n int) (commit string, t *Tree, err error) {
119+ t = &Tree{Meta: Meta{State: Open}}
120+ // A file read in a bare repository, falling back to git, which a worktree needs to be right.
121+ commit, err = gitx.ResolveRefOrAsk(ctx, dir, Ref(n))
122+ if err != nil {
123+ return "", t, nil // no thread yet
124+ }
125+
126+ // The tree of the commit this write will name as its parent, not of the ref, which can move.
127+ head, err := gitx.Batch(ctx, dir, []string{commit + "^{tree}"})
128+ if err != nil {
129+ return "", nil, err
130+ }
131+ root := head[commit+"^{tree}"]
132+ if root == nil {
133+ return "", nil, fmt.Errorf("thread %d has no tree at %s", n, commit)
134+ }
135+ entries := gitx.TreeEntries(root.Body)
136+ // Sorted, because git answers a tree in name order and the entry order decides nothing else.
137+ paths := make([]string, 0, len(entries))
138+ for path := range entries {
139+ paths = append(paths, path)
140+ }
141+ sort.Strings(paths)
142+ specs := make([]string, 0, len(paths))
143+ for _, path := range paths {
144+ specs = append(specs, entries[path])
145+ }
146+ blobs, err := gitx.Batch(ctx, dir, specs)
147+ if err != nil {
148+ return "", nil, err
149+ }
150+ for _, path := range paths {
151+ sha := entries[path]
152+ blob := blobs[sha]
153+ if blob == nil || blob.Type != "blob" {
154+ continue
155+ }
156+ if path == "meta" {
157+ t.Meta = ParseMeta(blob.Body)
158+ t.metaBlob = sha
159+ t.metaWas = blob.Body
160+ continue
161+ }
162+ t.keep(path, blob.Body, sha)
163+ }
164+ return commit, t, nil
165+ }
166+
167+ // write builds the tree object.
168+ func (t *Tree) write(ctx context.Context, dir string) (string, error) {
169+ var entries strings.Builder
170+ add := func(path, body, known string) error {
171+ blob := known
172+ if blob == "" {
173+ out, err := gitx.RunStdin(ctx, dir, body, "hash-object", "-w", "--stdin")
174+ if err != nil {
175+ return err
176+ }
177+ blob = strings.TrimSpace(out)
178+ }
179+ fmt.Fprintf(&entries, "100644 blob %s\t%s\n", blob, path)
180+ return nil
181+ }
182+ // Only what this write changed is hashed again.
183+ meta := t.Meta.Render()
184+ known := ""
185+ if t.metaBlob != "" && meta == t.metaWas {
186+ known = t.metaBlob
187+ }
188+ if err := add("meta", meta, known); err != nil {
189+ return "", err
190+ }
191+ for _, path := range t.order {
192+ body := t.Notes[path]
193+ if strings.TrimSpace(body) == "" {
194+ continue
195+ }
196+ reuse := ""
197+ if !t.dirty[path] {
198+ reuse = t.blobs[path]
199+ }
200+ if err := add(path, body, reuse); err != nil {
201+ return "", err
202+ }
203+ }
204+ out, err := gitx.RunStdin(ctx, dir, entries.String(), "mktree")
205+ if err != nil {
206+ return "", err
207+ }
208+ return strings.TrimSpace(out), nil
209+ }
210+
211+ // Create opens a thread, or returns an error if n already exists.
212+ func Create(ctx context.Context, dir string, n int, m Meta, object string, first Comment) error {
213+ if _, exists, err := ReadMeta(ctx, dir, n); err != nil {
214+ return err
215+ } else if exists {
216+ return fmt.Errorf("thread %d already exists", n)
217+ }
218+ msg := fmt.Sprintf("open thread %d", n)
219+ if m.Author != "" {
220+ msg += " by " + m.Author
221+ }
222+ return Write(ctx, dir, n, msg, func(t *Tree) {
223+ t.Meta = m
224+ if object != "" && strings.TrimSpace(first.Body) != "" {
225+ t.Append(object, first)
226+ }
227+ })
228+ }
229+
230+ // Reply appends a comment, authored by barerepo, because an account has no email to borrow.
231+ func Reply(ctx context.Context, dir string, n int, object string, c Comment) error {
232+ msg := fmt.Sprintf("reply on thread %d", n)
233+ if c.Author != "" {
234+ msg += " by " + c.Author
235+ }
236+ return Write(ctx, dir, n, msg, func(t *Tree) {
237+ t.Append(object, c)
238+ })
239+ }
240+
241+ // SetState moves a thread, where merging is a fact the server observed rather than caused.
242+ func SetState(ctx context.Context, dir string, n int, state State, mergedAt string) error {
243+ return Write(ctx, dir, n, fmt.Sprintf("thread %d is %s", n, state), func(t *Tree) {
244+ t.Meta.State = state
245+ if mergedAt != "" {
246+ t.Meta.Merged = mergedAt
247+ }
248+ })
249+ }
250+
251+ // NotesAt reads every note as one commit leaves them, so a push can be compared with what it replaced.
252+ func NotesAt(ctx context.Context, dir, commit string) map[string]string {
253+ out := map[string]string{}
254+ if commit == "" || strings.Trim(commit, "0") == "" {
255+ return out
256+ }
257+ spec := commit + "^{tree}"
258+ head, err := gitx.Batch(ctx, dir, []string{spec})
259+ if err != nil || head[spec] == nil {
260+ return out
261+ }
262+ entries := gitx.TreeEntries(head[spec].Body)
263+ specs := make([]string, 0, len(entries))
264+ for path, sha := range entries {
265+ if path != "meta" {
266+ specs = append(specs, sha)
267+ }
268+ }
269+ blobs, err := gitx.Batch(ctx, dir, specs)
270+ if err != nil {
271+ return out
272+ }
273+ for path, sha := range entries {
274+ if path == "meta" {
275+ continue
276+ }
277+ if blob := blobs[sha]; blob != nil && blob.Type == "blob" {
278+ out[path] = blob.Body
279+ }
280+ }
281+ return out
282+ }
283+
284+ // Repair gives text appended by hand the author the push knew, since 35.5 writes no header.
285+ func Repair(ctx context.Context, dir string, n int, was map[string]string, who string, at time.Time) error {
286+ if who == "" {
287+ return nil
288+ }
289+ _, tree, err := read(ctx, dir, n)
290+ if err != nil || tree == nil {
291+ return err
292+ }
293+ if !needsRepair(tree, was) {
294+ return nil
295+ }
296+ return Write(ctx, dir, n, "attribute a pushed reply to "+who, func(t *Tree) {
297+ for _, object := range t.order {
298+ extra, ok := appended(was[object], t.Notes[object])
299+ if !ok {
300+ continue
301+ }
302+ c := Comment{Author: who, Time: at, Body: strings.TrimSpace(extra)}
303+ t.set(object, rejoin(was[object], c))
304+ }
305+ })
306+ }
307+
308+ // needsRepair asks the question Repair answers, so an ordinary push writes no commit at all.
309+ func needsRepair(t *Tree, was map[string]string) bool {
310+ for _, object := range t.order {
311+ if _, ok := appended(was[object], t.Notes[object]); ok {
312+ return true
313+ }
314+ }
315+ return false
316+ }
317+
318+ // appended returns the text this push added, and false when it added none that needs an author.
319+ func OnlyAppends(ctx context.Context, dir, old, now string) bool {
320+ was := NotesAt(ctx, dir, old)
321+ if len(was) == 0 {
322+ return false
323+ }
324+ has := NotesAt(ctx, dir, now)
325+ for object, before := range was {
326+ after, ok := has[object]
327+ if !ok || !strings.HasPrefix(after, before) {
328+ return false
329+ }
330+ }
331+ return true
332+ }
333+
334+ func appended(old, now string) (string, bool) {
335+ if !strings.HasPrefix(now, old) {
336+ // A rewrite is not an append, and barerepo does not know whose words the new ones replaced.
337+ return "", false
338+ }
339+ extra := now[len(old):]
340+ if strings.TrimSpace(extra) == "" {
341+ return "", false
342+ }
343+ // A record that already names its author was written by something that knows the format.
344+ if _, ok := parseComment(extra); ok {
345+ return "", false
346+ }
347+ return extra, true
348+ }
349+
350+ // rejoin puts the repaired record back after what was there, separated the way every record is.
351+ func rejoin(old string, c Comment) string {
352+ if strings.TrimSpace(old) == "" {
353+ return c.Render()
354+ }
355+ return strings.TrimRight(old, "\n") + "\n" + recordSep + "\n" + c.Render()
356+ }
@@ -0,0 +1,56 @@
1+ // Package token makes item 3's credentials, stores only a hash, and shows each once.
2+ package token
3+
4+ import (
5+ "crypto/rand"
6+ "crypto/sha256"
7+ "crypto/subtle"
8+ "encoding/base64"
9+ "encoding/hex"
10+ "fmt"
11+ "strings"
12+ )
13+
14+ // Kind is a token's scope and its visible prefix, so a leaked one can be found by grep.
15+ type Kind string
16+
17+ const (
18+ Git Kind = "gt" // push and clone over https
19+ Runner Kind = "rt" // one machine, one repository
20+ Feed Kind = "ft" // read one atom feed, and nothing else
21+ Session Kind = "se" // a browser session cookie
22+ Claim Kind = "cl" // one use, trades a signed nonce for a browser session
23+ )
24+
25+ func (k Kind) prefix() string { return string(k) + "_live_" }
26+
27+ // New returns the only copy of a token, plus the hash to store, and nothing recovers it later.
28+ func New(k Kind) (tok, hash string, err error) {
29+ buf := make([]byte, 16)
30+ if _, err := rand.Read(buf); err != nil {
31+ return "", "", err
32+ }
33+ tok = k.prefix() + base64.RawURLEncoding.EncodeToString(buf)
34+ return tok, Hash(tok), nil
35+ }
36+
37+ // Hash is unsalted SHA-256, right for 128 machine-generated bits with no dictionary to fear.
38+ func Hash(tok string) string {
39+ sum := sha256.Sum256([]byte(tok))
40+ return hex.EncodeToString(sum[:])
41+ }
42+
43+ // KindOf reads the claimed kind, so a lookup can be scoped before it happens.
44+ func KindOf(tok string) (Kind, error) {
45+ for _, k := range []Kind{Git, Runner, Feed, Session, Claim} {
46+ if strings.HasPrefix(tok, k.prefix()) {
47+ return k, nil
48+ }
49+ }
50+ return "", fmt.Errorf("that does not look like a barerepo token")
51+ }
52+
53+ // Equal compares two hashes in constant time.
54+ func Equal(a, b string) bool {
55+ return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
56+ }
@@ -0,0 +1,212 @@
1+ // Package transport writes chapters 11 and 18 once, because a rule on one transport is not a rule.
2+ package transport
3+
4+ import (
5+ "context"
6+ "database/sql"
7+ "errors"
8+ "fmt"
9+ "os"
10+ "time"
11+
12+ "path/filepath"
13+
14+ "github.com/barerepo/server/internal/config"
15+ "github.com/barerepo/server/internal/gitx"
16+ "github.com/barerepo/server/internal/repo"
17+ "github.com/barerepo/server/internal/repocfg"
18+ "github.com/barerepo/server/internal/store"
19+ )
20+
21+ type Server struct {
22+ Cfg config.Config
23+ DB *store.DB
24+ // Bin is the barerepo binary the hooks call. Empty means this executable.
25+ Bin string
26+ }
27+
28+ var (
29+ // A reader without access gets this too, or "forbidden" would announce the private repository.
30+ ErrNotFound = errors.New("no such repository")
31+ ErrDenied = errors.New("denied")
32+ )
33+
34+ // Redirect is a moved repository, kept forever, because a 404 breaks every clone. 44.2.
35+ type Redirect struct{ Owner, Name string }
36+
37+ func (r Redirect) Error() string { return "moved to " + r.Owner + "/" + r.Name }
38+
39+ // Result is a resolved repository.
40+ type Result struct {
41+ Dir string
42+ Owner string
43+ Name string
44+ Config repocfg.Config
45+ Created bool // this call created it, per chapter 11
46+ // Warning reaches the pusher's terminal, because a malformed config must not lock anyone out.
47+ Warning string
48+ }
49+
50+ // Intent exists because git over http splits a push in two, and only the second is a push.
51+ type Intent int
52+
53+ const (
54+ // Read is git-upload-pack: clone, fetch, ls-remote.
55+ Read Intent = iota
56+ // Announce must never create, or ls-remote and push --dry-run would claim names by accident.
57+ Announce
58+ // Write is the push itself, and is the only thing that may create.
59+ Write
60+ )
61+
62+ func (i Intent) write() bool { return i != Read }
63+
64+ // Open resolves a repository for account user, who is already authenticated.
65+ func (s *Server) Open(ctx context.Context, owner, name, user string, intent Intent) (*Result, error) {
66+ write := intent.write()
67+ if to, err := s.redirect(ctx, owner, name); err != nil {
68+ return nil, err
69+ } else if to != nil {
70+ return nil, *to
71+ }
72+
73+ if !repo.Exists(s.Cfg.Paths.Repos, owner, name) {
74+ switch intent {
75+ case Read:
76+ return nil, ErrNotFound
77+ case Announce:
78+ // Answer "no refs" instead of creating, and let the push that follows create it.
79+ if err := s.mayCreate(ctx, owner, name, user); err != nil {
80+ return nil, err
81+ }
82+ dir, err := s.emptyTemplate(ctx)
83+ if err != nil {
84+ return nil, err
85+ }
86+ return &Result{Dir: dir, Owner: owner, Name: name, Config: repocfg.Default()}, nil
87+ }
88+ return s.create(ctx, owner, name, user)
89+ }
90+
91+ dir, err := repo.Dir(s.Cfg.Paths.Repos, owner, name)
92+ if err != nil {
93+ return nil, ErrNotFound
94+ }
95+ res := &Result{Dir: dir, Owner: owner, Name: name}
96+ res.Config, err = repocfg.Load(ctx, dir)
97+ if err != nil {
98+ // Chapter 14: fall back and warn, so the file cannot lock the owner out of fixing it.
99+ res.Warning = fmt.Sprintf("%s does not parse: %v", repocfg.Path, err)
100+ if res.Config.FellBackTo != "" {
101+ res.Warning += fmt.Sprintf("\nthe settings from %.7s are still in force", res.Config.FellBackTo)
102+ } else {
103+ res.Warning += "\nno earlier version parses either, so the defaults are in force"
104+ }
105+ }
106+
107+ if !res.Config.MayRead(owner, user) {
108+ return nil, ErrNotFound
109+ }
110+ if write && res.Config.Repo.Archived && user != owner {
111+ return nil, fmt.Errorf("%w: this repository is archived. the owner can unarchive it in %s",
112+ ErrDenied, repocfg.Path)
113+ }
114+ // pre-receive decides which refs may be written, so reaching receive-pack grants nothing.
115+ return res, nil
116+ }
117+
118+ // mayCreate answers whether this push could create the repository, without creating it.
119+ func (s *Server) mayCreate(ctx context.Context, owner, name, user string) error {
120+ // Someone else's namespace answers the same whether the name is free or private, or the two messages list what alice keeps private. Chapter 18.
121+ switch {
122+ case user == "", user != owner:
123+ return ErrNotFound
124+ case !s.Cfg.Behavior.AllowPushToCreate:
125+ return fmt.Errorf("%w: this server does not create repositories by push. make it at %s/new first",
126+ ErrDenied, s.Cfg.Server.ExternalURL)
127+ }
128+ if _, err := s.DB.Account(ctx, owner); err != nil {
129+ return ErrNotFound
130+ }
131+ if why := s.Claimed(ctx, owner, name); why != "" {
132+ return fmt.Errorf("%w: %s", ErrDenied, why)
133+ }
134+ return nil
135+ }
136+
137+ // Claimed says why a name cannot be taken, or nothing when it can. Chapters 21.2 and 44.4.
138+ func (s *Server) Claimed(ctx context.Context, owner, name string) string {
139+ // A renamed name is never freed, or the redirect points at a repository that is not the one. 21.2.
140+ if to, err := s.redirect(ctx, owner, name); err == nil && to != nil {
141+ return owner + "/" + name + " is now " + to.Owner + "/" + to.Name +
142+ ". the old name is kept forever, so nothing that points at it breaks."
143+ }
144+ // A deleted name is held while its data waits out the window, or a restore has nowhere to land.
145+ if repo.InTrash(s.Cfg.Paths.Repos, owner, name) {
146+ return owner + "/" + name + " was deleted. its name is held for 30 days, then it is free."
147+ }
148+ return ""
149+ }
150+
151+ // emptyTemplate is one refless repository shared by every advertisement, so git states its own caps.
152+ func (s *Server) emptyTemplate(ctx context.Context) (string, error) {
153+ dir := filepath.Join(s.Cfg.Paths.Cache, "empty.git")
154+ if _, err := os.Stat(filepath.Join(dir, "HEAD")); err == nil {
155+ return dir, nil
156+ }
157+ if err := os.MkdirAll(s.Cfg.Paths.Cache, 0o750); err != nil {
158+ return "", err
159+ }
160+ if _, err := gitx.Run(ctx, "", "init", "--bare", "--initial-branch", "master", "--", dir); err != nil {
161+ return "", err
162+ }
163+ return dir, nil
164+ }
165+
166+ // create implements push-to-create, chapter 11 and appendix D.
167+ func (s *Server) create(ctx context.Context, owner, name, user string) (*Result, error) {
168+ if err := s.mayCreate(ctx, owner, name, user); err != nil {
169+ return nil, err
170+ }
171+
172+ // HEAD is a placeholder until post-receive sees which branch actually arrived.
173+ dir, err := repo.Create(ctx, s.Cfg.Paths.Repos, owner, name, "master", s.bin())
174+ if err != nil {
175+ return nil, err
176+ }
177+ if _, err := s.DB.ExecContext(ctx,
178+ `INSERT INTO repos (owner, name, created_at) VALUES (?, ?, ?)`,
179+ owner, name, time.Now().Unix()); err != nil {
180+ os.RemoveAll(dir)
181+ return nil, err
182+ }
183+ cfg := repocfg.Default()
184+ // Chapter 11: publishing by accident is final, and hiding by accident is one config line.
185+ cfg.Repo.Visibility = "private"
186+ return &Result{Dir: dir, Owner: owner, Name: name, Config: cfg, Created: true}, nil
187+ }
188+
189+ // redirect follows a rename or transfer. Chapter 44.2.
190+ func (s *Server) redirect(ctx context.Context, owner, name string) (*Redirect, error) {
191+ var to Redirect
192+ err := s.DB.QueryRowContext(ctx,
193+ `SELECT new_owner, new_name FROM redirects WHERE old_owner = ? AND old_name = ?`,
194+ owner, name).Scan(&to.Owner, &to.Name)
195+ if errors.Is(err, sql.ErrNoRows) {
196+ return nil, nil
197+ }
198+ if err != nil {
199+ return nil, err
200+ }
201+ return &to, nil
202+ }
203+
204+ func (s *Server) bin() string {
205+ if s.Bin != "" {
206+ return s.Bin
207+ }
208+ if p, err := os.Executable(); err == nil {
209+ return p
210+ }
211+ return "barerepo"
212+ }
@@ -0,0 +1,97 @@
1+ package webhook
2+
3+ import (
4+ "bytes"
5+ "context"
6+ "crypto/hmac"
7+ "crypto/sha256"
8+ "encoding/hex"
9+ "encoding/json"
10+ "fmt"
11+ "net"
12+ "net/http"
13+ "syscall"
14+ "time"
15+ )
16+
17+ // Attempts is how many times a delivery is tried. Chapter 23.4.
18+ const Attempts = 3
19+
20+ // Client refuses a denied address, whatever a name resolved to or a redirect asks for.
21+ func Client() *http.Client {
22+ // Checked in the dialer, after resolving and before connecting, closing the window between.
23+ dialer := &net.Dialer{
24+ Timeout: 10 * time.Second,
25+ Control: func(network, address string, c syscall.RawConn) error {
26+ return AllowedAddr(address)
27+ },
28+ }
29+ return &http.Client{
30+ Timeout: 30 * time.Second,
31+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
32+ // Re-check every redirect, because a pass that then hops to 169.254.169.254 is the attack.
33+ if len(via) >= 5 {
34+ return fmt.Errorf("too many redirects")
35+ }
36+ return Allowed(req.Context(), req.URL.String())
37+ },
38+ Transport: &http.Transport{
39+ DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
40+ if err := AllowedAddr(addr); err != nil {
41+ return nil, err
42+ }
43+ return dialer.DialContext(ctx, network, addr)
44+ },
45+ },
46+ }
47+ }
48+
49+ // Sign is HMAC-SHA256, with the config naming a secret whose value lives on the server.
50+ func Sign(secret string, body []byte) string {
51+ mac := hmac.New(sha256.New, []byte(secret))
52+ mac.Write(body)
53+ return "sha256=" + hex.EncodeToString(mac.Sum(nil))
54+ }
55+
56+ // Deliver posts one event, retrying with backoff up to attempts times.
57+ func Deliver(ctx context.Context, url, secret string, payload any, attempts int) error {
58+ body, err := json.Marshal(payload)
59+ if err != nil {
60+ return err
61+ }
62+ if err := Allowed(ctx, url); err != nil {
63+ return err
64+ }
65+ client := Client()
66+
67+ var last error
68+ for attempt := range max(attempts, 1) {
69+ if attempt > 0 {
70+ select {
71+ case <-ctx.Done():
72+ return ctx.Err()
73+ case <-time.After(time.Duration(1<<attempt) * time.Second):
74+ }
75+ }
76+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
77+ if err != nil {
78+ return err
79+ }
80+ req.Header.Set("Content-Type", "application/json")
81+ req.Header.Set("User-Agent", "barerepo")
82+ if secret != "" {
83+ req.Header.Set("Barerepo-Signature", Sign(secret, body))
84+ }
85+ resp, err := client.Do(req)
86+ if err != nil {
87+ last = err
88+ continue
89+ }
90+ resp.Body.Close()
91+ if resp.StatusCode < 300 {
92+ return nil
93+ }
94+ last = fmt.Errorf("%s", resp.Status)
95+ }
96+ return last
97+ }
@@ -0,0 +1,101 @@
1+ // Package webhook is one HTTP POST, which is why barerepo can refuse every integration. 23.
2+ package webhook
3+
4+ import (
5+ "context"
6+ "fmt"
7+ "net"
8+ "net/url"
9+ )
10+
11+ // denied are the ranges a user-controlled URL may never reach. Chapter 23.3, and classic SSRF.
12+ var denied = mustParse(
13+ "127.0.0.0/8", // loopback
14+ "10.0.0.0/8", // private
15+ "172.16.0.0/12", // private
16+ "192.168.0.0/16", // private
17+ "169.254.0.0/16", // link local, and the cloud metadata service
18+ "100.64.0.0/10", // carrier grade nat
19+ "::1/128", // loopback
20+ "fc00::/7", // unique local
21+ "fe80::/10", // link local
22+ "0.0.0.0/8", // this network
23+ "::/128", // unspecified
24+ )
25+
26+ func mustParse(cidrs ...string) []*net.IPNet {
27+ out := make([]*net.IPNet, 0, len(cidrs))
28+ for _, c := range cidrs {
29+ _, n, err := net.ParseCIDR(c)
30+ if err != nil {
31+ panic(err)
32+ }
33+ out = append(out, n)
34+ }
35+ return out
36+ }
37+
38+ // Allowed checks every resolved address, not the string, since a public name can mean loopback.
39+ func Allowed(ctx context.Context, raw string) error {
40+ u, err := url.Parse(raw)
41+ if err != nil {
42+ return fmt.Errorf("that is not a url")
43+ }
44+ if u.Scheme != "https" {
45+ // Plain http would send the signature and the body in clear.
46+ return fmt.Errorf("a webhook url must be https")
47+ }
48+ host := u.Hostname()
49+ if host == "" {
50+ return fmt.Errorf("that url has no host")
51+ }
52+ addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
53+ if err != nil {
54+ return fmt.Errorf("%s does not resolve", host)
55+ }
56+ if len(addrs) == 0 {
57+ return fmt.Errorf("%s resolves to nothing", host)
58+ }
59+ for _, a := range addrs {
60+ if err := allowedIP(a.IP); err != nil {
61+ return err
62+ }
63+ }
64+ return nil
65+ }
66+
67+ // allowedIP checks one address, and every address must pass, or a mixed answer is the way through.
68+ func allowedIP(ip net.IP) error {
69+ if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
70+ ip.IsInterfaceLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast() {
71+ return fmt.Errorf("%s is not a public address", ip)
72+ }
73+ for _, n := range denied {
74+ if n.Contains(ip) {
75+ return fmt.Errorf("%s is in %s, which webhooks may not reach", ip, n)
76+ }
77+ }
78+ return nil
79+ }
80+
81+ // AllowedAddr runs at connect time too, closing the gap where a name changes what it resolves to.
82+ func AllowedAddr(addr string) error {
83+ host, _, err := net.SplitHostPort(addr)
84+ if err != nil {
85+ host = addr
86+ }
87+ ip := net.ParseIP(host)
88+ if ip == nil {
89+ return fmt.Errorf("%s is not an address", host)
90+ }
91+ return allowedIP(ip)
92+ }
93+
94+ // DeniedRanges is what the deny list holds, for the config page to show.
95+ func DeniedRanges() []string {
96+ out := make([]string, 0, len(denied))
97+ for _, n := range denied {
98+ out = append(out, n.String())
99+ }
100+ return out
101+ }
@@ -0,0 +1,81 @@
1+ package webhook
2+
3+ import (
4+ "context"
5+ "strings"
6+ "testing"
7+ )
8+
9+ // Chapter 23.3 names the ranges a webhook may never reach. Each is here.
10+ func TestDeniedAddresses(t *testing.T) {
11+ for _, addr := range []string{
12+ "127.0.0.1:443", "127.9.9.9:443", "10.1.2.3:443", "172.16.0.1:443",
13+ "172.31.255.255:443", "192.168.1.1:443", "169.254.169.254:443",
14+ "100.64.0.1:443", "[::1]:443", "[fc00::1]:443", "[fe80::1]:443",
15+ "0.0.0.0:443", "[::]:443", "224.0.0.1:443",
16+ } {
17+ if err := AllowedAddr(addr); err == nil {
18+ t.Errorf("%s was allowed", addr)
19+ }
20+ }
21+ for _, addr := range []string{
22+ "93.184.216.34:443", "8.8.8.8:443", "172.32.0.1:443", "[2606:2800::1]:443",
23+ } {
24+ if err := AllowedAddr(addr); err != nil {
25+ t.Errorf("%s was refused: %v", addr, err)
26+ }
27+ }
28+ }
29+
30+ // The check is on the resolved address, and localhost is a plain name that answers loopback.
31+ func TestResolvedNotSpelled(t *testing.T) {
32+ ctx := context.Background()
33+ if err := Allowed(ctx, "https://localhost/hook"); err == nil {
34+ t.Error("localhost was allowed")
35+ }
36+ if err := Allowed(ctx, "https://127.0.0.1/hook"); err == nil {
37+ t.Error("a loopback literal was allowed")
38+ }
39+ }
40+
41+ func TestSchemeAndShape(t *testing.T) {
42+ ctx := context.Background()
43+ for _, u := range []string{
44+ "http://example.com/hook", // plain http would send the signature in clear
45+ "file:///etc/passwd",
46+ "gopher://example.com/",
47+ "https://",
48+ "not a url at all",
49+ "ftp://example.com/",
50+ } {
51+ if err := Allowed(ctx, u); err == nil {
52+ t.Errorf("%q was allowed", u)
53+ }
54+ }
55+ }
56+
57+ // The config names the secret and never holds it, so the server's value must reproduce it.
58+ func TestSign(t *testing.T) {
59+ got := Sign("s3cret", []byte(`{"a":1}`))
60+ if !strings.HasPrefix(got, "sha256=") || len(got) != 71 {
61+ t.Errorf("signature looks wrong: %s", got)
62+ }
63+ if Sign("s3cret", []byte(`{"a":1}`)) != got {
64+ t.Error("signing is not deterministic")
65+ }
66+ if Sign("other", []byte(`{"a":1}`)) == got {
67+ t.Error("a different secret produced the same signature")
68+ }
69+ if Sign("s3cret", []byte(`{"a":2}`)) == got {
70+ t.Error("a different body produced the same signature")
71+ }
72+ }
73+
74+ func TestDeniedRangesAreListed(t *testing.T) {
75+ got := strings.Join(DeniedRanges(), " ")
76+ for _, want := range []string{"127.0.0.0/8", "169.254.0.0/16", "fc00::/7", "100.64.0.0/10"} {
77+ if !strings.Contains(got, want) {
78+ t.Errorf("%s is not in the list shown to operators", want)
79+ }
80+ }
81+ }
@@ -0,0 +1,93 @@
1+ package workflow
2+
3+ import (
4+ "context"
5+ "sort"
6+ "strings"
7+
8+ "github.com/barerepo/server/internal/gitx"
9+ )
10+
11+ // Load reads every workflow at a commit, in two processes whatever the file count. Chapter 25.
12+ func Load(ctx context.Context, dir, commit string, c Context) ([]Job, error) {
13+ if commit == "" {
14+ return nil, nil
15+ }
16+ spec := commit + ":" + Dir
17+ tree, err := gitx.Batch(ctx, dir, []string{spec})
18+ if err != nil {
19+ return nil, err
20+ }
21+ // No directory is the ordinary case, and it is not an error.
22+ dirObj := tree[spec]
23+ if dirObj == nil || dirObj.Type != "tree" {
24+ return nil, nil
25+ }
26+ entries := gitx.TreeEntries(dirObj.Body)
27+ names := make([]string, 0, len(entries))
28+ for name := range entries {
29+ if isWorkflowFile(name) {
30+ names = append(names, name)
31+ }
32+ }
33+ // Sorted, so two reads of one commit queue the same jobs in the same order.
34+ sort.Strings(names)
35+ if len(names) == 0 {
36+ return nil, nil
37+ }
38+ specs := make([]string, 0, len(names))
39+ for _, name := range names {
40+ specs = append(specs, entries[name])
41+ }
42+ blobs, err := gitx.Batch(ctx, dir, specs)
43+ if err != nil {
44+ return nil, err
45+ }
46+
47+ var out []Job
48+ for _, name := range names {
49+ blob := blobs[entries[name]]
50+ if blob == nil || blob.Type != "blob" {
51+ continue
52+ }
53+ path := Dir + "/" + name
54+ jobs, err := Parse(path, blob.Body, c)
55+ if err != nil {
56+ // One unreadable file does not hide the others, and the reader is told which.
57+ out = append(out, Job{Workflow: name, Path: path, ID: name,
58+ Skipped: []Skip{{Step: path, Reason: err.Error()}}})
59+ continue
60+ }
61+ out = append(out, jobs...)
62+ }
63+ return out, nil
64+ }
65+
66+ // isWorkflowFile takes what GitHub takes, which is yml and yaml at the top of that one directory.
67+ func isWorkflowFile(name string) bool {
68+ return strings.HasSuffix(name, ".yml") || strings.HasSuffix(name, ".yaml")
69+ }
70+
71+ // Files lists the workflow files at a commit, in one process, for a page that only needs to say so.
72+ func Files(ctx context.Context, dir, commit string) []string {
73+ if commit == "" {
74+ return nil
75+ }
76+ spec := commit + ":" + Dir
77+ tree, err := gitx.Batch(ctx, dir, []string{spec})
78+ if err != nil {
79+ return nil
80+ }
81+ obj := tree[spec]
82+ if obj == nil || obj.Type != "tree" {
83+ return nil
84+ }
85+ var out []string
86+ for name := range gitx.TreeEntries(obj.Body) {
87+ if isWorkflowFile(name) {
88+ out = append(out, Dir+"/"+name)
89+ }
90+ }
91+ sort.Strings(out)
92+ return out
93+ }
@@ -0,0 +1,178 @@
1+ package workflow
2+
3+ import (
4+ "fmt"
5+ "sort"
6+ "strings"
7+ )
8+
9+ // combo is one set of matrix values, which becomes one job. Chapter 15A.
10+ type combo map[string]string
11+
12+ // expand turns a matrix into the combinations it names, in a stable order.
13+ func expand(m map[string]any) ([]combo, []string) {
14+ var notes []string
15+ keys := make([]string, 0, len(m))
16+ for k := range m {
17+ // include and exclude are not axes, they change the product the axes make.
18+ if k == "include" || k == "exclude" {
19+ continue
20+ }
21+ keys = append(keys, k)
22+ }
23+ sort.Strings(keys)
24+
25+ // One empty combination, widened by each axis in turn, which is the product.
26+ out := []combo{{}}
27+ for _, k := range keys {
28+ values := stringList(m[k])
29+ if len(values) == 0 {
30+ notes = append(notes, "matrix axis "+k+" has no values barerepo can read")
31+ continue
32+ }
33+ var next []combo
34+ for _, c := range out {
35+ for _, v := range values {
36+ widened := combo{}
37+ for ck, cv := range c {
38+ widened[ck] = cv
39+ }
40+ widened[k] = v
41+ next = append(next, widened)
42+ }
43+ }
44+ out = next
45+ }
46+
47+ out = applyExclude(out, m["exclude"])
48+ if _, has := m["include"]; has {
49+ // include can add keys and whole combinations, and guessing at it would run the wrong builds.
50+ notes = append(notes, "matrix include is not applied, so only the listed combinations run")
51+ }
52+ if len(out) == 1 && len(out[0]) == 0 {
53+ return nil, notes
54+ }
55+ if len(out) == 0 {
56+ // The axes made combinations and exclude removed every one, so there is nothing to build.
57+ notes = append(notes, "excludes every combination its matrix makes")
58+ }
59+ return out, notes
60+ }
61+
62+ // applyExclude drops the combinations a matrix said not to build.
63+ func applyExclude(in []combo, raw any) []combo {
64+ rules, ok := raw.([]any)
65+ if !ok {
66+ return in
67+ }
68+ out := in[:0]
69+ for _, c := range in {
70+ if !excluded(c, rules) {
71+ out = append(out, c)
72+ }
73+ }
74+ return out
75+ }
76+
77+ // excluded reports whether one combination matches any exclude rule, which needs every key to match.
78+ func excluded(c combo, rules []any) bool {
79+ for _, raw := range rules {
80+ rule, ok := raw.(map[string]any)
81+ if !ok || len(rule) == 0 {
82+ continue
83+ }
84+ all := true
85+ for k, v := range rule {
86+ if c[k] != scalar(v) {
87+ all = false
88+ break
89+ }
90+ }
91+ if all {
92+ return true
93+ }
94+ }
95+ return false
96+ }
97+
98+ // suffix names one combination, so two jobs from one matrix are told apart in the runs list.
99+ func (c combo) suffix() string {
100+ if len(c) == 0 {
101+ return ""
102+ }
103+ keys := make([]string, 0, len(c))
104+ for k := range c {
105+ keys = append(keys, k)
106+ }
107+ sort.Strings(keys)
108+ parts := make([]string, 0, len(keys))
109+ for _, k := range keys {
110+ // The axis is named, because a reader of the runs list cannot guess which 1.26 this is.
111+ parts = append(parts, k+" "+c[k])
112+ }
113+ return " (" + strings.Join(parts, ", ") + ")"
114+ }
115+
116+ // fill replaces the matrix expressions in one string, which is how runs-on and a run step get values.
117+ func (c combo) fill(s string) string {
118+ if len(c) == 0 || !strings.Contains(s, "${{") {
119+ return s
120+ }
121+ var b strings.Builder
122+ rest := s
123+ for {
124+ before, after, found := strings.Cut(rest, "${{")
125+ if !found {
126+ b.WriteString(rest)
127+ return b.String()
128+ }
129+ b.WriteString(before)
130+ inner, tail, closed := strings.Cut(after, "}}")
131+ if !closed {
132+ b.WriteString("${{")
133+ b.WriteString(after)
134+ return b.String()
135+ }
136+ name := strings.TrimSpace(inner)
137+ if key, ok := strings.CutPrefix(name, "matrix."); ok {
138+ if v, known := c[key]; known {
139+ b.WriteString(v)
140+ rest = tail
141+ continue
142+ }
143+ }
144+ b.WriteString("${{" + inner + "}}")
145+ rest = tail
146+ }
147+ }
148+
149+ // vars exports the combination, so a step reading $MATRIX_OS works the way a step reading env does.
150+ func (c combo) vars() string {
151+ if len(c) == 0 {
152+ return ""
153+ }
154+ keys := make([]string, 0, len(c))
155+ for k := range c {
156+ keys = append(keys, k)
157+ }
158+ sort.Strings(keys)
159+ var b strings.Builder
160+ for _, k := range keys {
161+ fmt.Fprintf(&b, "export MATRIX_%s=%s\n", envName(k), shellQuote(c[k]))
162+ }
163+ return b.String()
164+ }
165+
166+ // envName turns a matrix key into a shell name, since go-version is not a usable variable.
167+ func envName(k string) string {
168+ var b strings.Builder
169+ for _, r := range strings.ToUpper(k) {
170+ switch {
171+ case r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
172+ b.WriteRune(r)
173+ default:
174+ b.WriteByte('_')
175+ }
176+ }
177+ return b.String()
178+ }
@@ -0,0 +1,554 @@
1+ // Package workflow reads .github/workflows and runs the parts barerepo can, naming the parts it cannot.
2+ package workflow
3+
4+ import (
5+ "fmt"
6+ "sort"
7+ "strings"
8+
9+ "gopkg.in/yaml.v3"
10+ )
11+
12+ // Dir is where GitHub keeps them, and barerepo reads them where they already are. Chapter 15A.
13+ const Dir = ".github/workflows"
14+
15+ // Context is what the push knew, which is what fills the github expressions a workflow uses.
16+ type Context struct {
17+ Repo string // owner/name
18+ Ref string
19+ SHA string
20+ }
21+
22+ // RefName is the branch or tag alone, which is what github.ref_name means.
23+ func (c Context) RefName() string {
24+ for _, prefix := range []string{"refs/heads/", "refs/tags/"} {
25+ if name, ok := strings.CutPrefix(c.Ref, prefix); ok {
26+ return name
27+ }
28+ }
29+ return c.Ref
30+ }
31+
32+ // vars are the environment GitHub sets, which barerepo sets too, so a workflow reading them works.
33+ func (c Context) vars() [][2]string {
34+ return [][2]string{
35+ {"CI", "true"},
36+ {"GITHUB_ACTIONS", "true"},
37+ {"GITHUB_REPOSITORY", c.Repo},
38+ {"GITHUB_REF", c.Ref},
39+ {"GITHUB_REF_NAME", c.RefName()},
40+ {"GITHUB_SHA", c.SHA},
41+ {"GITHUB_EVENT_NAME", "push"},
42+ {"GITHUB_WORKFLOW", ""},
43+ {"GITHUB_JOB", ""},
44+ }
45+ }
46+
47+ // substitutions maps the expressions barerepo knows onto the shell that answers them.
48+ func substitutions() map[string]string {
49+ return map[string]string{
50+ "github.repository": `"$GITHUB_REPOSITORY"`,
51+ "github.ref": `"$GITHUB_REF"`,
52+ "github.ref_name": `"$GITHUB_REF_NAME"`,
53+ "github.sha": `"$GITHUB_SHA"`,
54+ "github.event_name": `"$GITHUB_EVENT_NAME"`,
55+ "github.workspace": `"$PWD"`,
56+ "github.workflow": `"$GITHUB_WORKFLOW"`,
57+ "github.job": `"$GITHUB_JOB"`,
58+ "runner.os": `"$RUNNER_OS"`,
59+ "runner.arch": `"$RUNNER_ARCH"`,
60+ "runner.temp": `"$RUNNER_TEMP"`,
61+ }
62+ }
63+
64+ // substitute fills the expressions barerepo knows and reports the ones it does not.
65+ func substitute(run string) (string, []string) {
66+ known := substitutions()
67+ var unknown []string
68+ var b strings.Builder
69+ rest := run
70+ for {
71+ before, after, found := strings.Cut(rest, "${{")
72+ if !found {
73+ b.WriteString(rest)
74+ return b.String(), unknown
75+ }
76+ b.WriteString(before)
77+ inner, tail, closed := strings.Cut(after, "}}")
78+ if !closed {
79+ // An unclosed expression is left exactly as written, since guessing at it is worse.
80+ b.WriteString("${{")
81+ b.WriteString(after)
82+ return b.String(), unknown
83+ }
84+ name := strings.TrimSpace(inner)
85+ if shell, ok := known[name]; ok {
86+ b.WriteString(shell)
87+ } else {
88+ b.WriteString("${{" + inner + "}}")
89+ unknown = append(unknown, "${{"+name+"}}")
90+ }
91+ rest = tail
92+ }
93+ }
94+
95+ // Job is one workflow job reduced to what a barerepo runner needs: a machine, an image and a script.
96+ type Job struct {
97+ // Workflow is the name in the file, and Path is the file, so a run can say which one it is.
98+ Workflow string
99+ Path string
100+ Name string
101+ ID string
102+ // RunsOn is what the job asked for, kept verbatim, because it is matched against runner labels.
103+ RunsOn []string
104+ Image string
105+ Script string
106+ // Needs names jobs that must finish first, which barerepo does not order. Reported, never silent.
107+ Needs []string
108+ // Skipped is every step barerepo could not translate, so a green build never means less than it says.
109+ Skipped []Skip
110+ }
111+
112+ // Skip is one thing barerepo did not do, and why, because a quiet omission is a lie about the build.
113+ type Skip struct {
114+ Step string
115+ Reason string
116+ }
117+
118+ // Runnable reports whether anything is left to run after the skipping.
119+ func (j Job) Runnable() bool { return strings.TrimSpace(j.Script) != "" }
120+
121+ // file is the shape of a workflow yaml, holding only the keys barerepo reads.
122+ type file struct {
123+ Name string `yaml:"name"`
124+ Env map[string]any `yaml:"env"`
125+ Jobs map[string]job `yaml:"jobs"`
126+ }
127+
128+ type job struct {
129+ Name string `yaml:"name"`
130+ RunsOn any `yaml:"runs-on"`
131+ Needs any `yaml:"needs"`
132+ If string `yaml:"if"`
133+ Env map[string]any `yaml:"env"`
134+ Container any `yaml:"container"`
135+ Strategy *strategy `yaml:"strategy"`
136+ Steps []step `yaml:"steps"`
137+ }
138+
139+ type strategy struct {
140+ Matrix map[string]any `yaml:"matrix"`
141+ }
142+
143+ type step struct {
144+ Name string `yaml:"name"`
145+ Uses string `yaml:"uses"`
146+ Run string `yaml:"run"`
147+ If string `yaml:"if"`
148+ Env map[string]any `yaml:"env"`
149+ With map[string]any `yaml:"with"`
150+ // Shell is honoured only where it is a shell barerepo can start.
151+ Shell string `yaml:"shell"`
152+ WorkingDirectory string `yaml:"working-directory"`
153+ }
154+
155+ // Parse reads one workflow file. A file barerepo cannot parse is reported, not guessed at.
156+ func Parse(path, body string, c Context) ([]Job, error) {
157+ var f file
158+ if err := yaml.Unmarshal([]byte(body), &f); err != nil {
159+ return nil, fmt.Errorf("%s is not yaml barerepo can read: %w", path, err)
160+ }
161+ if len(f.Jobs) == 0 {
162+ return nil, nil
163+ }
164+ // A map has no order, so the ids are sorted to keep two reads of one file identical.
165+ ids := make([]string, 0, len(f.Jobs))
166+ for id := range f.Jobs {
167+ ids = append(ids, id)
168+ }
169+ sort.Strings(ids)
170+
171+ out := make([]Job, 0, len(ids))
172+ for _, id := range ids {
173+ j := f.Jobs[id]
174+ combos, notes := matrixOf(j)
175+ if len(combos) == 0 {
176+ // Nothing to build, and a job that runs with ${{ matrix.os }} still in it builds nonsense.
177+ skipped := make([]Skip, 0, len(notes))
178+ for _, n := range notes {
179+ skipped = append(skipped, Skip{Step: "job " + id, Reason: n})
180+ }
181+ out = append(out, Job{Workflow: workflowName(f, path), ID: id, Name: j.Name,
182+ Path: path, Skipped: skipped})
183+ continue
184+ }
185+ for _, m := range combos {
186+ built := convert(path, f, id, j, c, m)
187+ for _, n := range notes {
188+ built.Skipped = append(built.Skipped, Skip{Step: "job " + id, Reason: n})
189+ }
190+ out = append(out, built)
191+ }
192+ }
193+ return out, nil
194+ }
195+
196+ // maxCombos is GitHub's own ceiling on one matrix, so a file that builds here builds there too. 15A.
197+ const maxCombos = 256
198+
199+ // matrixOf returns the combinations a job builds, which is one empty combination when it has no matrix.
200+ func matrixOf(j job) ([]combo, []string) {
201+ if j.Strategy == nil || len(j.Strategy.Matrix) == 0 {
202+ return []combo{{}}, nil
203+ }
204+ // Axes multiply, so eight of them name millions, and the file is read in a push. Counted, not built.
205+ if tooManyCombos(j.Strategy.Matrix) {
206+ return nil, []string{fmt.Sprintf("names more than %d combinations, and barerepo builds at most that many", maxCombos)}
207+ }
208+ combos, notes := expand(j.Strategy.Matrix)
209+ // expand answers nil when no axis widened anything, and an empty list when exclude emptied it.
210+ if combos == nil {
211+ return []combo{{}}, notes
212+ }
213+ return combos, notes
214+ }
215+
216+ // tooManyCombos multiplies the axes the way expand will, and stops at the ceiling rather than at the end.
217+ func tooManyCombos(m map[string]any) bool {
218+ n := 1
219+ for k, v := range m {
220+ // include and exclude are not axes, and exclude only ever removes, so neither widens this.
221+ if k == "include" || k == "exclude" {
222+ continue
223+ }
224+ values := len(stringList(v))
225+ if values == 0 {
226+ continue
227+ }
228+ n *= values
229+ if n > maxCombos {
230+ return true
231+ }
232+ }
233+ return false
234+ }
235+
236+ // convert turns one yaml job and one matrix combination into a barerepo job. Chapter 15A.
237+ func convert(path string, f file, id string, j job, c Context, m combo) Job {
238+ out := Job{Workflow: workflowName(f, path), ID: id, Name: j.Name, Path: path}
239+ if out.Name == "" {
240+ out.Name = id
241+ }
242+ // The combination is in the name, so two builds of one job are told apart in the runs list.
243+ out.Name += m.suffix()
244+ // The matrix is filled first, because runs-on and the image are usually what it decides.
245+ out.RunsOn = fillAll(m, stringList(j.RunsOn))
246+ out.Needs = stringList(j.Needs)
247+ out.Image = m.fill(containerImage(j.Container))
248+ // barerepo queues jobs and does not order them, so the wait this job asked for is named, not obeyed. 15A.
249+ if len(out.Needs) > 0 {
250+ // What is skipped is the waiting, not the job, and the hook reads Step as the thing skipped.
251+ out.Skipped = append(out.Skipped, Skip{Step: "the needs on job " + id,
252+ Reason: "names " + strings.Join(out.Needs, ", ") + ", and barerepo does not order jobs"})
253+ }
254+
255+ // An expression is a program, and barerepo has no evaluator, so a guarded job is not run blind.
256+ if j.If != "" {
257+ out.Skipped = append(out.Skipped, Skip{Step: "job " + id,
258+ Reason: "runs under a condition barerepo cannot evaluate: if " + j.If})
259+ return out
260+ }
261+ var script strings.Builder
262+ // GitHub fails a job on its first failing step, and sh -c without this would run on and pass.
263+ script.WriteString("set -e\n")
264+ // The environment GitHub sets, so a workflow reading $GITHUB_SHA needs no change to work here.
265+ for _, kv := range c.vars() {
266+ value := kv[1]
267+ if kv[0] == "GITHUB_WORKFLOW" {
268+ value = out.Workflow
269+ }
270+ if kv[0] == "GITHUB_JOB" {
271+ value = id
272+ }
273+ fmt.Fprintf(&script, "export %s=%s\n", kv[0], shellQuote(value))
274+ }
275+ // These two are the runner's own, so the script asks the machine rather than the server.
276+ script.WriteString("export RUNNER_OS=$(uname -s)\n")
277+ script.WriteString("export RUNNER_ARCH=$(uname -m)\n")
278+ script.WriteString("export RUNNER_TEMP=${TMPDIR:-/tmp}\n")
279+ script.WriteString(m.vars())
280+ writeEnv(&script, f.Env, "the workflow env", &out)
281+ writeEnv(&script, j.Env, "the job env", &out)
282+
283+ // Only a step that puts a command in the script is work, and the preamble above is not. 15A.
284+ commands := false
285+ for i, s := range j.Steps {
286+ name := stepName(s, i)
287+ switch {
288+ case s.If != "":
289+ out.Skipped = append(out.Skipped, Skip{Step: name,
290+ Reason: "runs under a condition barerepo cannot evaluate: if " + s.If})
291+ case s.Uses != "":
292+ shim, note, ok := knownAction(s.Uses, s.With)
293+ if !ok {
294+ out.Skipped = append(out.Skipped, Skip{Step: name,
295+ Reason: "uses " + s.Uses + ", which barerepo does not run"})
296+ continue
297+ }
298+ // A handled action still earns a line, so the log says what barerepo did about it.
299+ fmt.Fprintf(&script, "\n# %s: %s\n", name, note)
300+ script.WriteString(shim)
301+ commands = commands || shim != ""
302+ case strings.TrimSpace(s.Run) == "":
303+ out.Skipped = append(out.Skipped, Skip{Step: name, Reason: "has nothing to run"})
304+ case !usableShell(s.Shell):
305+ out.Skipped = append(out.Skipped, Skip{Step: name,
306+ Reason: "asks for the " + s.Shell + " shell"})
307+ default:
308+ s.Run = m.fill(s.Run)
309+ writeStep(&script, name, s, &out)
310+ commands = true
311+ }
312+ }
313+ if !commands {
314+ // A script of nothing but exports would pass, and a green build that ran none of the job is a lie.
315+ out.Skipped = append(out.Skipped, Skip{Step: "job " + id,
316+ Reason: "has no step barerepo can run, so barerepo did not queue it"})
317+ return out
318+ }
319+ out.Script = script.String()
320+ return out
321+ }
322+
323+ // writeStep puts one run step into the script, with its own environment and directory.
324+ func writeStep(script *strings.Builder, name string, s step, out *Job) {
325+ // An expression left in would reach sh as ${{, which is a bad substitution and fails the step.
326+ run, unknown := substitute(s.Run)
327+ s.Run = run
328+ fmt.Fprintf(script, "\n# %s\n", name)
329+ if len(s.Env) > 0 || s.WorkingDirectory != "" {
330+ // A subshell keeps a step's directory and environment from leaking into the next one.
331+ script.WriteString("(\n")
332+ writeEnv(script, s.Env, name, out)
333+ if s.WorkingDirectory != "" {
334+ fmt.Fprintf(script, "cd %s\n", shellQuote(s.WorkingDirectory))
335+ }
336+ script.WriteString(s.Run)
337+ if !strings.HasSuffix(s.Run, "\n") {
338+ script.WriteString("\n")
339+ }
340+ script.WriteString(")\n")
341+ } else {
342+ script.WriteString(s.Run)
343+ if !strings.HasSuffix(s.Run, "\n") {
344+ script.WriteString("\n")
345+ }
346+ }
347+ // What barerepo could not fill is still reported, because it will fail the step when sh reads it.
348+ if len(unknown) > 0 {
349+ out.Skipped = append(out.Skipped, Skip{Step: name,
350+ Reason: "keeps expressions barerepo does not substitute: " + strings.Join(unknown, ", ")})
351+ }
352+ }
353+
354+ // writeEnv turns an env block into export lines, skipping what is not a plain scalar.
355+ func writeEnv(script *strings.Builder, env map[string]any, scope string, out *Job) {
356+ if len(env) == 0 {
357+ return
358+ }
359+ keys := make([]string, 0, len(env))
360+ for k := range env {
361+ keys = append(keys, k)
362+ }
363+ sort.Strings(keys)
364+ for _, k := range keys {
365+ if !usableEnvName(k) {
366+ // The key is written into the script unquoted, so a key that is not a name is shell. 15A.
367+ out.Skipped = append(out.Skipped, Skip{Step: scope,
368+ Reason: k + " is not a name a shell can export, so barerepo did not set it"})
369+ continue
370+ }
371+ raw, ok := env[k].(string)
372+ if !ok {
373+ if text := scalar(env[k]); text != "" {
374+ fmt.Fprintf(script, "export %s=%s\n", k, shellQuote(text))
375+ continue
376+ }
377+ // An empty export is a lie about the value, so the variable is left unset and named.
378+ out.Skipped = append(out.Skipped, Skip{Step: scope,
379+ Reason: k + " is not a plain value, so barerepo did not set it"})
380+ continue
381+ }
382+ // A quoted expression would export its own text, which is worse than failing. 15A.
383+ value, unknown := substitute(raw)
384+ if len(unknown) > 0 {
385+ out.Skipped = append(out.Skipped, Skip{Step: scope,
386+ Reason: k + " keeps expressions barerepo does not substitute: " + strings.Join(unknown, ", ")})
387+ }
388+ fmt.Fprintf(script, "export %s=%s\n", k, shellQuote(value))
389+ }
390+ }
391+
392+ // usableEnvName reports whether a key can be exported, which is the shell's name and nothing wider.
393+ func usableEnvName(k string) bool {
394+ for i, r := range k {
395+ switch {
396+ case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r == '_':
397+ case i > 0 && r >= '0' && r <= '9':
398+ default:
399+ return false
400+ }
401+ }
402+ return k != ""
403+ }
404+
405+ // tool is one thing a setup action promises, which barerepo checks for rather than installs.
406+ type tool struct {
407+ bin string
408+ version string // the with: key naming the version, if the action takes one
409+ name string
410+ }
411+
412+ // setups are the actions barerepo answers, being the ones a repository reaches for after checkout.
413+ var setups = map[string]tool{
414+ "actions/setup-go": {bin: "go", version: "go-version", name: "go"},
415+ "actions/setup-node": {bin: "node", version: "node-version", name: "node"},
416+ "actions/setup-python": {bin: "python3", version: "python-version", name: "python"},
417+ "actions/setup-java": {bin: "java", version: "java-version", name: "java"},
418+ "actions/setup-dotnet": {bin: "dotnet", version: "dotnet-version", name: "dotnet"},
419+ }
420+
421+ // knownAction answers an action with shell, or reports that barerepo does not run it.
422+ func knownAction(uses string, with map[string]any) (shim, note string, ok bool) {
423+ name, _, _ := strings.Cut(uses, "@")
424+ switch name {
425+ case "actions/checkout":
426+ return "", "barerepo cloned this repository at the commit already", true
427+ case "actions/cache":
428+ // Chapter 15: the build runs on a machine the user owns, which keeps its own state.
429+ return "", "barerepo does not cache between builds, so this build starts from the clone", true
430+ }
431+ if t, found := setups[name]; found {
432+ return setupShim(t, with), setupNote(t, with), true
433+ }
434+ return "", "", false
435+ }
436+
437+ // setupShim checks the tool is there rather than installing one, because the runner is not barerepo's.
438+ func setupShim(t tool, with map[string]any) string {
439+ var b strings.Builder
440+ fmt.Fprintf(&b, "command -v %s >/dev/null 2>&1 || {\n", t.bin)
441+ fmt.Fprintf(&b, " echo 'this workflow needs %s, and it is not on this runner' >&2\n", t.name)
442+ b.WriteString(" exit 1\n}\n")
443+ if v := scalar(with[t.version]); v != "" {
444+ // The version is printed rather than enforced, since a build should not fail on a patch digit.
445+ fmt.Fprintf(&b, "echo 'this workflow asked for %s %s. this runner has:'\n", t.name, v)
446+ fmt.Fprintf(&b, "%s --version\n", t.bin)
447+ }
448+ return b.String()
449+ }
450+
451+ // setupNote says in one line what barerepo did, so the log never implies a toolchain was installed.
452+ func setupNote(t tool, with map[string]any) string {
453+ if v := scalar(with[t.version]); v != "" {
454+ return "barerepo does not install toolchains, so this checks the runner has " + t.name + " " + v
455+ }
456+ return "barerepo does not install toolchains, so this checks the runner has " + t.name
457+ }
458+
459+ // usableShell reports whether barerepo can start what the step asked for. Empty means the default.
460+ func usableShell(s string) bool {
461+ switch s {
462+ case "", "bash", "sh":
463+ return true
464+ }
465+ return false
466+ }
467+
468+ // fillAll runs a combination through every string, which is how runs-on gets its value.
469+ func fillAll(m combo, in []string) []string {
470+ out := make([]string, 0, len(in))
471+ for _, s := range in {
472+ out = append(out, m.fill(s))
473+ }
474+ return out
475+ }
476+
477+ // stringList takes a yaml value that is one string or a list of them.
478+ func stringList(v any) []string {
479+ switch t := v.(type) {
480+ case string:
481+ if t == "" {
482+ return nil
483+ }
484+ return []string{t}
485+ case []any:
486+ out := make([]string, 0, len(t))
487+ for _, item := range t {
488+ if s := scalar(item); s != "" {
489+ out = append(out, s)
490+ }
491+ }
492+ return out
493+ }
494+ return nil
495+ }
496+
497+ // containerImage reads container: as either a bare image or a map with one.
498+ func containerImage(v any) string {
499+ switch t := v.(type) {
500+ case string:
501+ return t
502+ case map[string]any:
503+ return scalar(t["image"])
504+ }
505+ return ""
506+ }
507+
508+ // scalar renders a yaml scalar as the shell sees it, and anything else as nothing.
509+ func scalar(v any) string {
510+ switch t := v.(type) {
511+ case string:
512+ return t
513+ case int:
514+ return fmt.Sprint(t)
515+ case bool:
516+ return fmt.Sprint(t)
517+ case float64:
518+ return strings.TrimSuffix(fmt.Sprintf("%v", t), ".0")
519+ }
520+ return ""
521+ }
522+
523+ func stepName(s step, i int) string {
524+ switch {
525+ case s.Name != "":
526+ return oneLine(s.Name)
527+ case s.Uses != "":
528+ return oneLine(s.Uses)
529+ default:
530+ return fmt.Sprintf("step %d", i+1)
531+ }
532+ }
533+
534+ // oneLine keeps a name from ending the comment it is written into, because the next line is a command and the name comes from the build file. 15A.
535+ func oneLine(s string) string {
536+ return strings.Map(func(r rune) rune {
537+ if r == '\n' || r == '\r' {
538+ return ' '
539+ }
540+ return r
541+ }, s)
542+ }
543+
544+ func workflowName(f file, path string) string {
545+ if f.Name != "" {
546+ return f.Name
547+ }
548+ return strings.TrimPrefix(path, Dir+"/")
549+ }
550+
551+ // shellQuote wraps a value in single quotes, which is the only quoting sh does not look inside.
552+ func shellQuote(s string) string {
553+ return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
554+ }
@@ -0,0 +1,555 @@
1+ package workflow
2+
3+ import (
4+ "errors"
5+ "os/exec"
6+ "strings"
7+ "testing"
8+ )
9+
10+ // The common case: a go repository whose whole CI is three run steps behind a checkout.
11+ func TestParseTheOrdinaryWorkflow(t *testing.T) {
12+ jobs, err := Parse(Dir+"/ci.yml", `
13+ name: ci
14+ on: [push]
15+ env:
16+ CGO_ENABLED: "0"
17+ jobs:
18+ test:
19+ runs-on: ubuntu-latest
20+ steps:
21+ - uses: actions/checkout@v4
22+ - name: build
23+ run: go build ./...
24+ - name: test
25+ run: |
26+ go vet ./...
27+ go test ./...
28+ `, Context{Repo: "john/bot", Ref: "refs/heads/master", SHA: "a3f9c2d"})
29+ if err != nil {
30+ t.Fatal(err)
31+ }
32+ if len(jobs) != 1 {
33+ t.Fatalf("got %d jobs, want 1", len(jobs))
34+ }
35+ j := jobs[0]
36+ if j.Workflow != "ci" || j.ID != "test" {
37+ t.Errorf("job identity is %+v", j)
38+ }
39+ if len(j.RunsOn) != 1 || j.RunsOn[0] != "ubuntu-latest" {
40+ t.Errorf("runs-on = %v", j.RunsOn)
41+ }
42+ for _, want := range []string{
43+ "export CGO_ENABLED='0'",
44+ "go build ./...",
45+ "go vet ./...",
46+ "go test ./...",
47+ // Checkout is answered rather than skipped, because barerepo has already cloned.
48+ "barerepo cloned this repository",
49+ } {
50+ if !strings.Contains(j.Script, want) {
51+ t.Errorf("the script is missing %q\n%s", want, j.Script)
52+ }
53+ }
54+ if len(j.Skipped) != 0 {
55+ t.Errorf("nothing here is untranslatable, yet it skipped %+v", j.Skipped)
56+ }
57+ if !j.Runnable() {
58+ t.Error("a job with three run steps is not runnable")
59+ }
60+ }
61+
62+ // Rule: never skip quietly. Every step barerepo does not run has to be named and explained.
63+ func TestEverySkipIsNamedAndExplained(t *testing.T) {
64+ jobs, err := Parse(Dir+"/ci.yml", `
65+ jobs:
66+ build:
67+ runs-on: ubuntu-latest
68+ steps:
69+ - uses: actions/setup-node@v4
70+ with: {node-version: 20}
71+ - name: only on main
72+ if: github.ref == 'refs/heads/main'
73+ run: ./deploy.sh
74+ - name: powershell
75+ shell: pwsh
76+ run: Write-Host hi
77+ - name: real work
78+ run: make ci
79+ `, Context{Repo: "john/bot", Ref: "refs/heads/master", SHA: "a3f9c2d"})
80+ if err != nil {
81+ t.Fatal(err)
82+ }
83+ j := jobs[0]
84+ if !strings.Contains(j.Script, "make ci") {
85+ t.Error("the one runnable step did not survive the skipping")
86+ }
87+ // setup-node is answered with a check, not skipped, so it is not in the list below.
88+ if !strings.Contains(j.Script, "command -v node") {
89+ t.Errorf("setup-node did not become a check\n%s", j.Script)
90+ }
91+ want := map[string]string{
92+ "only on main": "cannot evaluate",
93+ "powershell": "pwsh shell",
94+ }
95+ if len(j.Skipped) != len(want) {
96+ t.Fatalf("skipped %d steps, want %d: %+v", len(j.Skipped), len(want), j.Skipped)
97+ }
98+ for _, s := range j.Skipped {
99+ frag, ok := want[s.Step]
100+ if !ok {
101+ t.Errorf("unexpected skip of %q", s.Step)
102+ continue
103+ }
104+ if !strings.Contains(s.Reason, frag) {
105+ t.Errorf("%s was skipped because %q, want it to mention %q", s.Step, s.Reason, frag)
106+ }
107+ }
108+ }
109+
110+ // sh reads ${{ as a bad substitution, so an expression barerepo knows is filled, not passed through.
111+ func TestKnownExpressionsAreFilledIn(t *testing.T) {
112+ jobs, err := Parse(Dir+"/ci.yml", `
113+ jobs:
114+ b:
115+ runs-on: [self-hosted, linux]
116+ steps:
117+ - run: echo building ${{ github.sha }} of ${{ github.repository }} on ${{ runner.os }}
118+ `, Context{Repo: "john/bot", Ref: "refs/heads/master", SHA: "a3f9c2d"})
119+ if err != nil {
120+ t.Fatal(err)
121+ }
122+ j := jobs[0]
123+ if len(j.RunsOn) != 2 || j.RunsOn[1] != "linux" {
124+ t.Errorf("a runs-on list did not survive: %v", j.RunsOn)
125+ }
126+ if len(j.Skipped) != 0 {
127+ t.Errorf("expressions barerepo knows were reported instead of filled: %+v", j.Skipped)
128+ }
129+ if strings.Contains(j.Script, "${{") {
130+ t.Errorf("an expression reached the script, where sh calls it a bad substitution\n%s", j.Script)
131+ }
132+
133+ // Run it the way the runner does, and the values have to arrive.
134+ out, err := exec.Command("sh", "-c", j.Script).CombinedOutput()
135+ if err != nil {
136+ t.Fatalf("the script does not run: %v\n%s", err, out)
137+ }
138+ for _, want := range []string{"building a3f9c2d", "of john/bot"} {
139+ if !strings.Contains(string(out), want) {
140+ t.Errorf("the output is missing %q\n%s", want, out)
141+ }
142+ }
143+ // runner.os is the machine's own answer, so it is whatever this machine is.
144+ if !strings.Contains(string(out), "on ") {
145+ t.Errorf("runner.os produced nothing\n%s", out)
146+ }
147+ }
148+
149+ // A workflow reading the environment directly must work too, because GitHub sets these.
150+ func TestGitHubEnvironmentIsSet(t *testing.T) {
151+ jobs, err := Parse(Dir+"/ci.yml", `
152+ name: ci
153+ jobs:
154+ unit:
155+ steps:
156+ - run: echo "$GITHUB_REPOSITORY $GITHUB_REF_NAME $GITHUB_SHA $GITHUB_WORKFLOW $GITHUB_JOB $CI"
157+ `, Context{Repo: "john/bot", Ref: "refs/heads/master", SHA: "a3f9c2d"})
158+ if err != nil {
159+ t.Fatal(err)
160+ }
161+ out, err := exec.Command("sh", "-c", jobs[0].Script).CombinedOutput()
162+ if err != nil {
163+ t.Fatalf("the script does not run: %v\n%s", err, out)
164+ }
165+ // ref_name is the branch alone, which is what a workflow means by it.
166+ want := "john/bot master a3f9c2d ci unit true"
167+ if !strings.Contains(string(out), want) {
168+ t.Errorf("the environment is wrong.\n got: %s\nwant: %s", out, want)
169+ }
170+ }
171+
172+ // An expression barerepo cannot fill is left alone and reported, rather than guessed at.
173+ func TestUnknownExpressionsAreStillReported(t *testing.T) {
174+ jobs, err := Parse(Dir+"/ci.yml", `
175+ jobs:
176+ b:
177+ steps:
178+ - run: deploy --key ${{ secrets.DEPLOY_KEY }}
179+ `, Context{Repo: "john/bot"})
180+ if err != nil {
181+ t.Fatal(err)
182+ }
183+ j := jobs[0]
184+ if len(j.Skipped) != 1 || !strings.Contains(j.Skipped[0].Reason, "${{secrets.DEPLOY_KEY}}") {
185+ t.Fatalf("an unfillable expression was not reported: %+v", j.Skipped)
186+ }
187+ if !strings.Contains(j.Script, "${{ secrets.DEPLOY_KEY }}") {
188+ t.Error("an expression barerepo cannot fill was rewritten instead of left alone")
189+ }
190+ }
191+
192+ // A matrix builds several times, so barerepo queues one job per combination. Chapter 15A.
193+ func TestAMatrixBecomesOneJobPerCombination(t *testing.T) {
194+ jobs, err := Parse(Dir+"/ci.yml", `
195+ jobs:
196+ m:
197+ runs-on: ${{ matrix.os }}
198+ strategy:
199+ matrix:
200+ os: [ubuntu-latest, macos-latest]
201+ go: ["1.25", "1.26"]
202+ exclude:
203+ - os: macos-latest
204+ go: "1.25"
205+ steps:
206+ - run: echo testing go ${{ matrix.go }} on ${{ matrix.os }}
207+ `, Context{Repo: "john/bot", SHA: "a3f9c2d"})
208+ if err != nil {
209+ t.Fatal(err)
210+ }
211+ // Two by two is four, less the one excluded.
212+ if len(jobs) != 3 {
213+ t.Fatalf("got %d jobs, want 3: %+v", len(jobs), jobs)
214+ }
215+
216+ seen := map[string][]string{}
217+ for _, j := range jobs {
218+ if !j.Runnable() {
219+ t.Errorf("%s is not runnable", j.Name)
220+ }
221+ if len(j.Skipped) != 0 {
222+ t.Errorf("%s skipped something: %+v", j.Name, j.Skipped)
223+ }
224+ if strings.Contains(j.Script, "${{") {
225+ t.Errorf("%s kept an expression\n%s", j.Name, j.Script)
226+ }
227+ seen[j.Name] = j.RunsOn
228+ }
229+
230+ // Each job asks for the machine its combination named, and sorted axes keep the names stable.
231+ want := map[string]string{
232+ "m (go 1.25, os ubuntu-latest)": "ubuntu-latest",
233+ "m (go 1.26, os ubuntu-latest)": "ubuntu-latest",
234+ "m (go 1.26, os macos-latest)": "macos-latest",
235+ }
236+ for name, machine := range want {
237+ got, ok := seen[name]
238+ if !ok {
239+ t.Errorf("no job named %q, got %v", name, seen)
240+ continue
241+ }
242+ if len(got) != 1 || got[0] != machine {
243+ t.Errorf("%s asks for %v, want %s", name, got, machine)
244+ }
245+ }
246+ // The excluded pair must not have been built.
247+ if _, built := seen["m (go 1.25, os macos-latest)"]; built {
248+ t.Error("an excluded combination was queued")
249+ }
250+ }
251+
252+ // The combination has to reach the command, or every job in the matrix runs the same build.
253+ func TestMatrixValuesReachTheScript(t *testing.T) {
254+ jobs, err := Parse(Dir+"/ci.yml", `
255+ jobs:
256+ m:
257+ strategy:
258+ matrix:
259+ go-version: ["1.26"]
260+ steps:
261+ - run: echo "expression ${{ matrix.go-version }} env $MATRIX_GO_VERSION"
262+ `, Context{Repo: "john/bot"})
263+ if err != nil {
264+ t.Fatal(err)
265+ }
266+ if len(jobs) != 1 {
267+ t.Fatalf("got %d jobs, want 1", len(jobs))
268+ }
269+ out, err := exec.Command("sh", "-c", jobs[0].Script).CombinedOutput()
270+ if err != nil {
271+ t.Fatalf("the script does not run: %v\n%s", err, out)
272+ }
273+ // Both forms have to work: the expression, and the variable a workflow might read directly.
274+ if want := "expression 1.26 env 1.26"; !strings.Contains(string(out), want) {
275+ t.Errorf("got %q, want it to contain %q", out, want)
276+ }
277+ }
278+
279+ // include can add keys and whole combinations, so barerepo says it did not apply it rather than guess.
280+ func TestMatrixIncludeIsReportedNotGuessedAt(t *testing.T) {
281+ jobs, err := Parse(Dir+"/ci.yml", `
282+ jobs:
283+ m:
284+ strategy:
285+ matrix:
286+ os: [ubuntu-latest]
287+ include:
288+ - os: windows-latest
289+ experimental: true
290+ steps:
291+ - run: make
292+ `, Context{Repo: "john/bot"})
293+ if err != nil {
294+ t.Fatal(err)
295+ }
296+ if len(jobs) != 1 {
297+ t.Fatalf("include added a combination barerepo cannot work out: %d jobs", len(jobs))
298+ }
299+ if len(jobs[0].Skipped) != 1 || !strings.Contains(jobs[0].Skipped[0].Reason, "include") {
300+ t.Errorf("include was applied or ignored quietly: %+v", jobs[0].Skipped)
301+ }
302+ }
303+
304+ // A condition is a program and barerepo has no evaluator, so a guarded job is not run blind.
305+ func TestAConditionalJobIsDeclinedWhole(t *testing.T) {
306+ jobs, err := Parse(Dir+"/ci.yml", `
307+ jobs:
308+ m:
309+ if: github.ref == 'refs/heads/main'
310+ steps:
311+ - run: ./deploy.sh
312+ `, Context{Repo: "john/bot"})
313+ if err != nil {
314+ t.Fatal(err)
315+ }
316+ if jobs[0].Runnable() {
317+ t.Error("a job behind a condition was run without the condition being checked")
318+ }
319+ }
320+
321+ // container: names the image, which is what barerepo passes to the runner. Chapter 14.
322+ func TestContainerBecomesTheImage(t *testing.T) {
323+ for _, body := range []string{
324+ "jobs:\n b:\n container: golang:1.26\n steps:\n - run: make\n",
325+ "jobs:\n b:\n container:\n image: golang:1.26\n steps:\n - run: make\n",
326+ } {
327+ jobs, err := Parse(Dir+"/ci.yml", body, Context{Repo: "john/bot", Ref: "refs/heads/master", SHA: "a3f9c2d"})
328+ if err != nil {
329+ t.Fatal(err)
330+ }
331+ if jobs[0].Image != "golang:1.26" {
332+ t.Errorf("image = %q from\n%s", jobs[0].Image, body)
333+ }
334+ }
335+ }
336+
337+ // Two reads of one file must give the same script, or a cache keyed by commit is worthless.
338+ func TestParseIsStable(t *testing.T) {
339+ body := `
340+ jobs:
341+ b:
342+ env: {B: "2", A: "1", C: "3"}
343+ steps:
344+ - run: make
345+ a:
346+ steps:
347+ - run: make
348+ `
349+ first, err := Parse(Dir+"/ci.yml", body, Context{Repo: "john/bot", Ref: "refs/heads/master", SHA: "a3f9c2d"})
350+ if err != nil {
351+ t.Fatal(err)
352+ }
353+ for i := 0; i < 8; i++ {
354+ again, err := Parse(Dir+"/ci.yml", body, Context{Repo: "john/bot", Ref: "refs/heads/master", SHA: "a3f9c2d"})
355+ if err != nil {
356+ t.Fatal(err)
357+ }
358+ if len(again) != len(first) {
359+ t.Fatalf("job count moved between reads")
360+ }
361+ for k := range first {
362+ if again[k].ID != first[k].ID || again[k].Script != first[k].Script {
363+ t.Fatalf("read %d differs:\n%q\n%q", i, first[k].Script, again[k].Script)
364+ }
365+ }
366+ }
367+ if first[0].ID != "a" {
368+ t.Errorf("jobs are not in a stable order: %s first", first[0].ID)
369+ }
370+ }
371+
372+ // A file barerepo cannot read is an error, never a silently empty build.
373+ func TestBrokenYamlIsAnError(t *testing.T) {
374+ if _, err := Parse(Dir+"/ci.yml", "jobs:\n - this: [is not\n", Context{Repo: "john/bot", Ref: "refs/heads/master", SHA: "a3f9c2d"}); err == nil {
375+ t.Error("malformed yaml parsed without complaint")
376+ }
377+ }
378+
379+ // A build must never install a toolchain onto a machine barerepo does not own, so setup checks instead.
380+ func TestSetupActionsCheckRatherThanInstall(t *testing.T) {
381+ jobs, err := Parse(Dir+"/ci.yml", `
382+ jobs:
383+ b:
384+ steps:
385+ - uses: actions/setup-go@v5
386+ with:
387+ go-version: "1.26"
388+ - uses: actions/setup-python@v5
389+ - uses: actions/cache@v4
390+ with:
391+ path: ~/.cache
392+ - run: make
393+ `, Context{Repo: "john/bot", Ref: "refs/heads/master", SHA: "a3f9c2d"})
394+ if err != nil {
395+ t.Fatal(err)
396+ }
397+ j := jobs[0]
398+ if len(j.Skipped) != 0 {
399+ t.Errorf("a handled action was also reported as skipped: %+v", j.Skipped)
400+ }
401+ for _, want := range []string{
402+ // The tool is checked for, and a missing one stops the build rather than failing oddly later.
403+ "command -v go >/dev/null 2>&1 || {",
404+ "this workflow needs go, and it is not on this runner",
405+ "exit 1",
406+ // The asked-for version is printed, not enforced, so a patch digit cannot fail a build.
407+ "this workflow asked for go 1.26",
408+ "go --version",
409+ "command -v python3",
410+ // The log has to say a toolchain was not installed, or a green build implies one was.
411+ "barerepo does not install toolchains",
412+ "barerepo does not cache between builds",
413+ "make",
414+ } {
415+ if !strings.Contains(j.Script, want) {
416+ t.Errorf("the script is missing %q\n%s", want, j.Script)
417+ }
418+ }
419+ // No command here may change the runner. Comments are prose and are not commands.
420+ for _, line := range strings.Split(j.Script, "\n") {
421+ if strings.HasPrefix(strings.TrimSpace(line), "#") {
422+ continue
423+ }
424+ for _, never := range []string{"apt-get", "brew install", "npm install", "pip install", "| sh"} {
425+ if strings.Contains(line, never) {
426+ t.Errorf("a command runs %q, and barerepo must not change the runner: %s", never, line)
427+ }
428+ }
429+ }
430+ }
431+
432+ // A setup action with no version still checks, because the tool being absent is the real failure.
433+ func TestSetupWithNoVersionStillChecks(t *testing.T) {
434+ jobs, err := Parse(Dir+"/ci.yml", "jobs:\n b:\n steps:\n - uses: actions/setup-node@v4\n", Context{Repo: "john/bot", Ref: "refs/heads/master", SHA: "a3f9c2d"})
435+ if err != nil {
436+ t.Fatal(err)
437+ }
438+ script := jobs[0].Script
439+ if !strings.Contains(script, "command -v node") {
440+ t.Errorf("no check was written\n%s", script)
441+ }
442+ if strings.Contains(script, "asked for node ") {
443+ t.Errorf("a version was claimed that the workflow never gave\n%s", script)
444+ }
445+ }
446+
447+ // The runner passes the script to sh -c, which without this runs every step and reports the last.
448+ func TestAFailingStepFailsTheBuild(t *testing.T) {
449+ jobs, err := Parse(Dir+"/ci.yml", `
450+ jobs:
451+ b:
452+ steps:
453+ - name: fails
454+ run: exit 3
455+ - name: passes
456+ run: echo second step ran
457+ `, Context{Repo: "john/bot", Ref: "refs/heads/master", SHA: "a3f9c2d"})
458+ if err != nil {
459+ t.Fatal(err)
460+ }
461+ script := jobs[0].Script
462+ if !strings.HasPrefix(script, "set -e\n") {
463+ t.Fatalf("the script does not stop at the first failure\n%s", script)
464+ }
465+
466+ // Run it the way the runner does, and the build must fail without reaching the second step.
467+ cmd := exec.Command("sh", "-c", script)
468+ out, err := cmd.CombinedOutput()
469+ if err == nil {
470+ t.Errorf("a script whose first step exits 3 reported success\n%s", out)
471+ }
472+ if strings.Contains(string(out), "second step ran") {
473+ t.Errorf("a step after a failing one still ran\n%s", out)
474+ }
475+ var ee *exec.ExitError
476+ if errors.As(err, &ee) && ee.ExitCode() != 3 {
477+ t.Errorf("the build exited %d, losing the step's own code 3", ee.ExitCode())
478+ }
479+ }
480+
481+ // Chapter 15A: an expression barerepo cannot fill is declined out loud, and env is where secrets live.
482+ func TestAnExpressionInEnvIsSubstitutedOrDeclined(t *testing.T) {
483+ src := `
484+ name: ci
485+ on: [push]
486+ jobs:
487+ test:
488+ runs-on: ubuntu-latest
489+ env:
490+ SHA: ${{ github.sha }}
491+ TOKEN: ${{ secrets.NPM_TOKEN }}
492+ steps:
493+ - run: echo "$SHA"
494+ `
495+ jobs, err := Parse(Dir+"/ci.yml", src, Context{Repo: "john/bot", Ref: "refs/heads/master", SHA: "a3f9c2d"})
496+ if err != nil {
497+ t.Fatal(err)
498+ }
499+ if len(jobs) != 1 {
500+ t.Fatalf("got %d jobs", len(jobs))
501+ }
502+ j := jobs[0]
503+
504+ // The ones barerepo knows are filled in, or the build reads the literal text of an expression.
505+ if !strings.Contains(j.Script, "export SHA=") || strings.Contains(j.Script, "export SHA='${{") {
506+ t.Errorf("github.sha was not substituted in env:\n%s", j.Script)
507+ }
508+ // The one it does not know is named, because a token that silently holds its own name is worse.
509+ said := false
510+ for _, s := range j.Skipped {
511+ if strings.Contains(s.Reason, "TOKEN") && strings.Contains(s.Reason, "secrets.NPM_TOKEN") {
512+ said = true
513+ }
514+ }
515+ if !said {
516+ t.Errorf("a secrets expression in env was neither filled nor reported: %v", j.Skipped)
517+ }
518+ }
519+
520+ // A value that is not a plain scalar is left unset and named, since an empty export is a lie.
521+ func TestAnEnvValueThatIsNotPlainIsNamed(t *testing.T) {
522+ src := `
523+ name: ci
524+ on: [push]
525+ jobs:
526+ test:
527+ runs-on: ubuntu-latest
528+ env:
529+ LIST:
530+ - one
531+ - two
532+ steps:
533+ - run: echo hi
534+ `
535+ jobs, err := Parse(Dir+"/ci.yml", src, Context{Repo: "john/bot", Ref: "refs/heads/master", SHA: "a3f9c2d"})
536+ if err != nil {
537+ t.Fatal(err)
538+ }
539+ if len(jobs) != 1 {
540+ t.Fatalf("got %d jobs", len(jobs))
541+ }
542+ j := jobs[0]
543+ if strings.Contains(j.Script, "export LIST=") {
544+ t.Errorf("a list was exported as something:\n%s", j.Script)
545+ }
546+ said := false
547+ for _, s := range j.Skipped {
548+ if strings.Contains(s.Reason, "LIST is not a plain value") {
549+ said = true
550+ }
551+ }
552+ if !said {
553+ t.Errorf("an env value barerepo could not use was dropped silently: %v", j.Skipped)
554+ }
555+ }
@@ -0,0 +1,38 @@
1+ <!doctype html>
2+ <html lang="en">
3+ <head>
4+ <meta charset="utf-8">
5+ <meta name="viewport" content="width=device-width, initial-scale=1">
6+ <title>barerepo · not found</title>
7+ <link rel="stylesheet" href="tokens.css">
8+ </head>
9+ <body>
10+ <div class="wrap">
11+
12+ <div style="font-family: var(--font-mono); font-size: 13px; line-height: 1.6; color: var(--text-primary); background: #ffffff; border: 0.5px solid var(--border); border-radius: 12px; min-height: calc(100vh - 26px); display: flex; flex-direction: column;">
13+
14+ <h2 class="sr-only">Not found page.</h2>
15+ <div style="display:flex; align-items:center; justify-content:space-between; padding:9px 18px; border-bottom:0.5px solid var(--border);">
16+ <div style="display:flex; gap:20px; align-items:center;">
17+ <span style="letter-spacing:-0.5px; text-decoration:underline;">barerepo</span>
18+ <span style="color:var(--text-secondary); text-decoration:underline;">new</span>
19+ </div>
20+ <div style="display:flex; gap:16px; align-items:center; color:var(--text-secondary);">
21+ <span style="border:0.5px solid var(--border); border-radius:8px; padding:3px 10px; color:var(--text-muted);">search /</span>
22+ <span>john</span>
23+ </div>
24+ </div>
25+ <div style="padding:26px 18px;">
26+ <div style="margin-bottom:6px;">404</div>
27+ <div style="color:var(--text-muted); font-size:12px;">john/johnbot exists. that path in it does not.</div>
28+ </div>
29+
30+ <div style="position:sticky; bottom:0; margin-top:auto; background:#ffffff; border-top:0.5px solid var(--border); border-radius:0 0 12px 12px; padding:9px 18px; display:flex; justify-content:space-between; color:var(--text-muted); font-size:12px;">
31+ <span>barerepo</span><span>barerepo 0.1.0</span>
32+ </div>
33+
34+ </div>
35+
36+ </div>
37+ </body>
38+ </html>
@@ -0,0 +1,52 @@
1+ # barerepo
2+
3+ The UI mockups. Open `index.html` to browse all 24 views.
4+
5+ ## What is here
6+
7+ ```
8+ index.html contact sheet linking every view
9+ build.py regenerates every page from one shared chrome
10+ tokens.css the entire stylesheet
11+ docs/BUILD.md implementation order, stack, performance budget, traps
12+ ```
13+
14+ Everything else is a generated page. Edit `build.py` and re-run it rather than
15+ editing HTML by hand. The five original pages were hand-written and drifted,
16+ which is why the generator exists.
17+
18+ ```
19+ python3 build.py
20+ ```
21+
22+ ## Reading order
23+
24+ 1. The book, parts I and II. The argument, and how git works.
25+ 2. `index.html`. What it looks like.
26+ 3. The book, parts III to VI. The mechanics, and every page.
27+ 4. The book, part VII. How to use the site. Every task, with commands.
28+ 5. The book, part VIII. How to deploy it, and how to know it is correct.
29+ 6. `docs/BUILD.md`. What to build first.
30+
31+ The book has its own repository, `barerepo/book`. It is not here.
32+
33+ Read chapter 42 before you render any user content. It is a security chapter.
34+
35+ The book is the only source of truth. Part VII is written in Simplified
36+ Technical English. Parts I to VI are normal prose, because they contain argument
37+ and not procedure.
38+
39+ ## The short version
40+
41+ The server owns almost nothing. Identity is an ssh key you hold. Discussion is
42+ git notes in your clone. Settings are a file in your tree. Builds run on your
43+ hardware. Pull requests are refs you push without asking permission, and merging
44+ happens on your machine with `git merge` while the server watches.
45+
46+ Every view shows the command that performs the action, because the goal is to put
47+ the user closer to the machine rather than further from it.
48+
49+ ## Status
50+
51+ Spec complete. Implementation in progress at the repository root, stage 1 of
52+ `docs/BUILD.md`.
@@ -0,0 +1,711 @@
1+ #!/usr/bin/env python3
2+ """Emits every static mockup page. Run: python3 build.py"""
3+
4+ import os
5+
6+ OUT = os.path.dirname(os.path.abspath(__file__))
7+
8+ SHELL = '''<!doctype html>
9+ <html lang="en">
10+ <head>
11+ <meta charset="utf-8">
12+ <meta name="viewport" content="width=device-width, initial-scale=1">
13+ <title>barerepo{title}</title>
14+ <link rel="stylesheet" href="tokens.css">
15+ </head>
16+ <body>
17+ <div class="wrap">
18+
19+ <div style="font-family: var(--font-mono); font-size: 13px; line-height: 1.6; color: var(--text-primary); background: #ffffff; border: 0.5px solid var(--border); border-radius: 12px; min-height: calc(100vh - 26px); display: flex; flex-direction: column;">
20+
21+ <h2 class="sr-only">{sr}</h2>
22+ {body}
23+ <div style="position:sticky; bottom:0; margin-top:auto; background:#ffffff; border-top:0.5px solid var(--border); border-radius:0 0 12px 12px; padding:9px 18px; display:flex; justify-content:space-between; color:var(--text-muted); font-size:12px;">
24+ <span>{fl}</span><span>{fr}</span>
25+ </div>
26+
27+ </div>
28+
29+ </div>
30+ </body>
31+ </html>
32+ '''
33+
34+ def topbar(user='john', right_extra=''):
35+ return ('<div style="display:flex; align-items:center; justify-content:space-between; padding:9px 18px; '
36+ 'border-bottom:0.5px solid var(--border);">\n'
37+ ' <div style="display:flex; gap:20px; align-items:center;">\n'
38+ ' <span style="letter-spacing:-0.5px; text-decoration:underline;">barerepo</span>\n'
39+ ' <span style="color:var(--text-secondary); text-decoration:underline;">new</span>\n'
40+ ' </div>\n'
41+ ' <div style="display:flex; gap:16px; align-items:center; color:var(--text-secondary);">\n'
42+ ' <span style="border:0.5px solid var(--border); border-radius:8px; padding:3px 10px; '
43+ 'color:var(--text-muted);">search /</span>\n'
44+ f' <span>{user}</span>{right_extra}\n'
45+ ' </div>\n'
46+ '</div>\n')
47+
48+ def crumb(path, right):
49+ return ('<div style="display:flex; align-items:center; justify-content:space-between; padding:9px 18px; '
50+ 'border-bottom:0.5px solid var(--border);">\n'
51+ f' <div style="color:var(--text-secondary);">{path}</div>\n'
52+ f' <div style="color:var(--text-muted); font-size:12px;">{right}</div>\n'
53+ '</div>\n')
54+
55+ def repotabs(active, extra_right='jump to file <span style="border:0.5px solid var(--border); border-radius:4px; padding:0 5px;">t</span>'):
56+ tabs = ['log', 'files', 'threads 3', 'runs', 'config']
57+ out = ('<div style="display:flex; gap:22px; padding:8px 18px; border-bottom:0.5px solid var(--border); '
58+ 'color:var(--text-secondary); font-size:12px;">\n')
59+ for t in tabs:
60+ if t.split()[0] == active:
61+ out += (f' <span style="color:var(--text-primary); border-bottom:1.5px solid var(--text-primary); '
62+ f'padding-bottom:4px;">{t}</span>\n')
63+ else:
64+ out += f' <span style="text-decoration:underline;">{t}</span>\n'
65+ out += f' <span style="margin-left:auto; color:var(--text-muted);">{extra_right}</span>\n</div>\n'
66+ return out
67+
68+ def lnk(t):
69+ return f'<span style="text-decoration:underline;">{t}</span>'
70+
71+ def row(inner, muted=False, tall=False):
72+ c = ' color:var(--text-muted);' if muted else ''
73+ pad = '13px 18px' if tall else '12px 18px'
74+ return f'<div style="padding:{pad}; border-bottom:0.5px solid var(--border);{c}">\n{inner}\n</div>\n'
75+
76+ def between(a, b):
77+ return (f'<div style="display:flex; justify-content:space-between;">'
78+ f'<span>{a}</span><span style="color:var(--text-muted); font-size:12px;">{b}</span></div>')
79+
80+ def sub(t):
81+ return f'<div style="color:var(--text-muted); font-size:12px;">{t}</div>'
82+
83+ def box(t, mono_muted=True):
84+ c = 'var(--text-secondary)' if mono_muted else 'var(--text-primary)'
85+ return (f'<div style="background:var(--surface-1); border:0.5px solid var(--border); border-radius:8px; '
86+ f'padding:9px 12px; color:{c}; font-size:12px; word-break:break-all;">{t}</div>')
87+
88+ def field(label, value, hint=''):
89+ h = f'\n <div style="color:var(--text-muted); font-size:12px; margin-top:4px;">{hint}</div>' if hint else ''
90+ return (f'<div style="margin-bottom:16px;">\n'
91+ f' <div style="color:var(--text-muted); font-size:12px; margin-bottom:5px;">{label}</div>\n'
92+ f' <div style="border:0.5px solid var(--border-strong); border-radius:8px; padding:7px 11px; '
93+ f'word-break:break-all;">{value}</div>{h}\n</div>')
94+
95+ def btn(t):
96+ return (f'<div style="border:0.5px solid var(--text-primary); border-radius:8px; padding:7px 16px; '
97+ f'display:inline-block;">{t}</div>')
98+
99+ def hunk_start(header):
100+ """Reads the new-side start line out of an @@ header."""
101+ import re
102+ m = re.search(r'\+(\d+)', header)
103+ return int(m.group(1)) if m else 1
104+
105+
106+ def diff(header, lines):
107+ """A diff box. Lines carry their number, because 35.3 says to press it."""
108+ out = ('<div style="border:0.5px solid var(--border); border-radius:8px; overflow:hidden; font-size:12px; '
109+ 'margin-top:8px;">\n'
110+ f' <div style="padding:3px 11px; color:var(--text-muted); background:var(--surface-1); '
111+ f'border-bottom:0.5px solid var(--border);">{header}</div>\n')
112+ n = hunk_start(header)
113+ num = ('display:inline-block; width:30px; margin-right:9px; text-align:right; '
114+ 'color:var(--text-muted); text-decoration:underline;')
115+ for kind, txt in lines:
116+ if kind == '+':
117+ st = 'background:var(--bg-success); color:var(--text-success);'
118+ gutter = f'<span style="{num}">{n}</span>'
119+ txt = gutter + '+&nbsp;' + txt
120+ n += 1
121+ elif kind == '-':
122+ st = 'background:var(--bg-danger); color:var(--text-danger);'
123+ plain = num.replace('text-decoration:underline;', '')
124+ gutter = f'<span style="{plain}">&nbsp;</span>'
125+ txt = gutter + '-&nbsp;' + txt
126+ elif kind == 'note':
127+ st = 'color:var(--text-muted); text-align:center;'
128+ else:
129+ st = 'color:var(--text-secondary);'
130+ gutter = f'<span style="{num}">{n}</span>'
131+ txt = gutter + '&nbsp;&nbsp;' + txt
132+ n += 1
133+ out += f' <div style="padding:2px 11px; {st}">{txt}</div>\n'
134+ return out + '</div>\n'
135+
136+ PAGES = {}
137+
138+ # ---------------------------------------------------------------- identity
139+
140+ PAGES['signin.html'] = dict(
141+ title=' · sign in', fl='barerepo', fr='barerepo 0.1.0',
142+ sr='Sign in page where the server challenges an ssh key instead of asking for a password.',
143+ body=('<div style="display:flex; align-items:center; justify-content:space-between; padding:9px 18px; '
144+ 'border-bottom:0.5px solid var(--border);"><span style="letter-spacing:-0.5px;">barerepo</span>'
145+ '<span style="color:var(--text-secondary);">new account</span></div>\n'
146+ '<div style="padding:26px 18px; max-width:430px;">\n'
147+ ' <div style="margin-bottom:20px;">sign in</div>\n'
148+ + field('name', 'john<span style="color:var(--text-muted);">|</span>') +
149+ ' <div style="margin-bottom:18px;">' + btn('send challenge') + '</div>\n'
150+ ' <div style="color:var(--text-muted); font-size:12px; line-height:1.9;">\n'
151+ ' <div>we give you a nonce. you sign it with the key you already have.</div>\n'
152+ ' <div style="color:var(--text-secondary);">'
153+ 'printf \'%s\' \'&lt;nonce&gt;\' | ssh-keygen -Y sign -f ~/.ssh/id_ed25519 -n barerepo-auth -</div>\n'
154+ ' <div>that is stock openssh. nothing to install.</div>\n'
155+ ' <div>or <span style="color:var(--text-secondary);">br auth john</span>, '
156+ 'which does the same thing in one step.</div>\n'
157+ ' </div>\n</div>\n'))
158+
159+ PAGES['keys.html'] = dict(
160+ title=' · keys', fl='john / keys', fr='barerepo 0.1.0',
161+ sr='Page listing ssh keys, runner tokens and feed tokens, each with a revoke control.',
162+ body=(topbar() +
163+ crumb('john / <span style="color:var(--text-primary);">keys</span>',
164+ 'the only state the server owns') +
165+ row('<div style="color:var(--text-muted); font-size:12px;">ssh keys</div>') +
166+ row(between('ed25519 SHA256:8fK2q0mR4vXeN1pLzT9wBcJdSgYo3Ea7kVnQxMuP2r', 'added mar 2026') +
167+ sub('laptop · last used 2h · <span style="color:var(--text-secondary); text-decoration:underline;">revoke</span>')) +
168+ row(between('ed25519 SHA256:Qw3rTy7uIoP0aSdFgHjKlZxCvBnM4eR8tYu1IoP2aSd', 'added apr 2026') +
169+ sub('uproar · last used 4d · <span style="color:var(--text-secondary); text-decoration:underline;">revoke</span>')) +
170+ row('<div style="color:var(--text-muted); font-size:12px;">runner tokens</div>') +
171+ row(between('uproar.local', 'created 3d') +
172+ sub('john/johnbot · labels build, test · last seen 40m · '
173+ '<span style="color:var(--text-secondary); text-decoration:underline;">revoke</span>')) +
174+ row('<div style="color:var(--text-muted); font-size:12px;">feed tokens</div>') +
175+ row(between('inbox', 'created 3d') +
176+ sub('read only · last read 20m · '
177+ '<span style="color:var(--text-secondary); text-decoration:underline;">revoke</span>')) +
178+ row(btn('new key') + '&nbsp;&nbsp;' + btn('new runner token') +
179+ '&nbsp;&nbsp;' + btn('new feed token')) +
180+ row(sub('a token is shown once, inside the command that uses it. only its hash is kept, '
181+ 'so it cannot be shown again. lost one? revoke it and make another.')) +
182+ row(sub('a key signs you in and pushes. a runner token attaches one machine to one '
183+ 'repository. a feed token reads one feed and can write nothing.')) +
184+ row(sub('everything else is in .barerepo/config, in the repository it belongs to.'))))
185+
186+ # ---------------------------------------------------------------- repo start
187+
188+ PAGES['new-repo.html'] = dict(
189+ title=' · new repo', fl='barerepo', fr='barerepo 0.1.0',
190+ sr='Form for creating a new repository with a choice of default branch name.',
191+ body=(topbar() +
192+ '<div style="padding:22px 18px; max-width:430px;">\n'
193+ ' <div style="margin-bottom:20px;">new repository</div>\n'
194+ + field('name', 'johnbot<span style="color:var(--text-muted);">|</span>',
195+ 'john/johnbot') +
196+ field('description', '<span style="color:var(--text-muted);">optional</span>') +
197+ field('default branch', 'master',
198+ 'any branch name') +
199+ field('visibility', 'public') +
200+ ' <div style="margin-bottom:22px;">' + btn('create') + '</div>\n</div>\n' +
201+ '<div style="padding:16px 18px; border-top:0.5px solid var(--border);">\n'
202+ ' <div style="color:var(--text-muted); font-size:12px; margin-bottom:8px;">'
203+ 'or skip this form. push to a name that does not exist.</div>\n'
204+ + box('git remote add origin git@barerepo:john/&lt;name&gt;<br>git push -u origin master') +
205+ ' <div style="color:var(--text-muted); font-size:12px; margin-top:10px; line-height:1.9;">\n'
206+ ' <div>the branch you push becomes the default branch.</div>\n'
207+ ' <div>the repository starts private. public is one line in .barerepo/config.</div>\n'
208+ ' </div>\n</div>\n'))
209+
210+ PAGES['repo-empty.html'] = dict(
211+ title=' · johnbot', fl='john / johnbot', fr='barerepo 0.1.0',
212+ sr='Empty repository page showing a single block of shell commands to paste.',
213+ body=(crumb('john / <span style="color:var(--text-primary);">johnbot</span>', 'empty · master') +
214+ '<div style="padding:20px 18px;">\n'
215+ ' <div style="color:var(--text-muted); font-size:12px; margin-bottom:8px;">paste this</div>\n'
216+ ' <div style="background:var(--surface-1); border:0.5px solid var(--border); border-radius:8px; '
217+ 'padding:11px 13px; color:var(--text-secondary); font-size:12px; line-height:1.9;">'
218+ 'git init<br>git remote add origin git@barerepo:john/johnbot<br>'
219+ 'mkdir -p .forge &amp;&amp; printf \'[repo]\\nvisibility = "public"\\n\' &gt; .barerepo/config<br>'
220+ 'git add -A<br>'
221+ 'git commit -m "first"<br>git push -u origin master</div>\n'
222+ ' <div style="color:var(--text-muted); font-size:12px; margin-top:10px;">'
223+ 'the third line is what makes it public. without it the repository stays private, '
224+ 'because there is nowhere else to keep that.</div>\n'
225+ ' <div style="color:var(--text-muted); font-size:12px; margin-top:14px; line-height:1.9;">\n'
226+ ' <div>already have a repo somewhere?</div>\n'
227+ ' <div style="color:var(--text-secondary);">git remote set-url origin git@barerepo:john/johnbot '
228+ '&amp;&amp; git push --all</div>\n </div>\n</div>\n'))
229+
230+ # ---------------------------------------------------------------- repo browse
231+
232+ PAGES['repo-files.html'] = dict(
233+ title=' · johnbot files', fl='master', fr='barerepo 0.1.0',
234+ sr='Repository file tree listing directories and files with last commit information.',
235+ body=(crumb('john / <span style="color:var(--text-primary);">johnbot</span>',
236+ 'master · clone <span style="color:var(--text-secondary);">git@barerepo:john/johnbot</span>') +
237+ repotabs('files') +
238+ row(between('irc/', 'john · 6h') + sub('drop the retry loop, it never fired')) +
239+ row(between('cmd/', 'john · 2d') + sub('split the daemon out')) +
240+ row(between('config.go', 'lisa · 2h') + sub('fix panic when config is empty')) +
241+ row(between('config_test.go', 'lisa · 2h') + sub('cover the empty case')) +
242+ row(between('go.mod', 'dave · 3d') + sub('bump deps')) +
243+ row(between('.barerepo/config', 'john · 3d') + sub('add lisa to push')) +
244+ row(between('README', 'john · 4mo') + sub('it is an irc bot'))))
245+
246+ PAGES['repo-file.html'] = dict(
247+ title=' · config.go', fl='history · raw', fr='barerepo 0.1.0',
248+ sr='File view with blame information shown in the left gutter beside each line.',
249+ body=(crumb('john / johnbot / <span style="color:var(--text-primary);">config.go</span>',
250+ '61 lines · 1.4kb · master') +
251+ repotabs('files') +
252+ '<div style="padding:12px 0; font-size:12px;">\n' +
253+ ''.join(
254+ f'<div style="display:flex; gap:0;">'
255+ f'<div style="width:150px; flex-shrink:0; color:var(--text-muted); padding:1px 10px 1px 18px; '
256+ f'border-right:0.5px solid var(--border); white-space:nowrap; overflow:hidden;">{b}</div>'
257+ f'<div style="width:34px; flex-shrink:0; color:var(--text-muted); text-align:right; '
258+ f'padding:1px 8px;">{n}</div>'
259+ f'<div style="padding:1px 10px; color:var(--text-primary);">{c}</div></div>\n'
260+ for b, n, c in [
261+ ('a3f9c2 lisa 2h', '41', '&nbsp;&nbsp;f, err := os.Open(path)'),
262+ ('a3f9c2 lisa 2h', '42', '&nbsp;&nbsp;if err != nil {'),
263+ ('a3f9c2 lisa 2h', '43', '&nbsp;&nbsp;&nbsp;&nbsp;return nil, err'),
264+ ('a3f9c2 lisa 2h', '44', '&nbsp;&nbsp;}'),
265+ ('a3f9c2 lisa 2h', '45', '&nbsp;&nbsp;if len(raw) == 0 {'),
266+ ('a3f9c2 lisa 2h', '46', '&nbsp;&nbsp;&nbsp;&nbsp;return Default(), nil'),
267+ ('a3f9c2 lisa 2h', '47', '&nbsp;&nbsp;}'),
268+ ('7c1e08 john 4mo', '48', '&nbsp;&nbsp;return cfg, nil'),
269+ ('7c1e08 john 4mo', '49', '}'),
270+ ]) +
271+ '</div>\n'))
272+
273+ PAGES['repo-commit.html'] = dict(
274+ title=' · a3f9c2', fl='parent 8b1d44', fr='barerepo 0.1.0',
275+ sr='Single commit page showing message, metadata and full diff.',
276+ body=(crumb('john / johnbot / <span style="color:var(--text-primary);">a3f9c2</span>',
277+ 'lisa · 2h · 1 file · +4 -1') +
278+ row('<div>fix panic when config is empty</div>' +
279+ sub('empty config.toml hit a nil deref on load. return defaults instead.')) +
280+ row(sub('config.go') + diff('@@ -41,7 +41,10 @@ func Load', [
281+ (' ', 'f, err := os.Open(path)'),
282+ ('-', 'return cfg'),
283+ ('+', 'if len(raw) == 0 {'),
284+ ('+', '&nbsp;&nbsp;return Default(), nil'),
285+ ('+', '}'),
286+ ('+', 'return cfg, nil'),
287+ (' ', '}')])) +
288+ row(sub('reachable from master · closed thread 47 · build ok on uproar.local'))))
289+
290+ PAGES['repo-compare.html'] = dict(
291+ title=' · compare', fl='master...refs/proposals/47', fr='barerepo 0.1.0',
292+ sr='Compare view between two arbitrary refs showing combined diff stats.',
293+ body=(crumb('john / johnbot / <span style="color:var(--text-primary);">compare</span>',
294+ 'master...refs/proposals/47') +
295+ repotabs('log') +
296+ row('<div style="display:flex; gap:10px; align-items:center;">' +
297+ box('master') + '<span style="color:var(--text-muted);">...</span>' +
298+ box('refs/proposals/47') + '</div>' +
299+ '<div style="margin-top:8px;">' + sub('3 commits · 2 files · +81 -12 · no conflicts') + '</div>') +
300+ row(sub('config.go · +7 -1') + diff('@@ -41,7 +41,10 @@ func Load', [
301+ ('-', 'return cfg'),
302+ ('+', 'if len(raw) == 0 {'),
303+ ('+', '&nbsp;&nbsp;return Default(), nil'),
304+ ('+', '}')])) +
305+ row(sub('config_test.go · +74 -11') + diff('@@ -12,4 +12,18 @@ func TestLoad', [
306+ ('+', 'func TestLoadEmpty(t *testing.T) {'),
307+ ('note', '70 more added lines')]))))
308+
309+ # ---------------------------------------------------------------- threads
310+
311+ PAGES['threads.html'] = dict(
312+ title=' · threads', fl='3 open · 41 closed', fr='barerepo 0.1.0',
313+ sr='Thread list where issues and code proposals appear in one combined list.',
314+ body=(crumb('john / johnbot / <span style="color:var(--text-primary);">threads</span>',
315+ '3 open · 41 closed') +
316+ repotabs('threads', lnk('open') + ' · ' + lnk('merged') + ' · ' + lnk('closed') + ' · ' + lnk('all')) +
317+ row(between('47&nbsp;&nbsp;panic when config file is empty', 'lisa · 2h') +
318+ sub('has proposal · +81 -12 · build ok · 2 replies')) +
319+ row(between('46&nbsp;&nbsp;switch to pure-go sqlite', 'mark · 1d') +
320+ sub('has proposal · +14 -9 · <span style="color:var(--text-danger);">build failed</span>')) +
321+ row(between('44&nbsp;&nbsp;does this work behind a socks proxy?', 'anon · 5d') +
322+ sub('question · no proposal · 6 replies')) +
323+ row('<div style="display:flex; justify-content:space-between; color:var(--text-muted);">'
324+ '<span style="text-decoration:line-through;">45&nbsp;&nbsp;bump deps</span>'
325+ '<span style="font-size:12px;">dave · 3d</span></div>' +
326+ sub('merged · tip reachable from master'), muted=True)))
327+
328+ PAGES['thread-new.html'] = dict(
329+ title=' · new thread', fl='john / johnbot', fr='barerepo 0.1.0',
330+ sr='New thread form with an optional field for attaching a pushed proposal ref.',
331+ body=(crumb('john / johnbot / <span style="color:var(--text-primary);">new thread</span>',
332+ 'refs/notes/threads') +
333+ '<div style="padding:20px 18px; max-width:560px;">\n'
334+ + field('title', 'panic when config file is empty<span style="color:var(--text-muted);">|</span>') +
335+ ' <div style="margin-bottom:16px;">\n'
336+ ' <div style="color:var(--text-muted); font-size:12px; margin-bottom:5px;">body</div>\n'
337+ ' <div style="border:0.5px solid var(--border-strong); border-radius:8px; padding:8px 11px; '
338+ 'min-height:80px; color:var(--text-muted);">markdown|</div>\n </div>\n'
339+ + field('attach a ref', '<span style="color:var(--text-muted);">optional</span>',
340+ 'push first, then paste the ref') +
341+ ' <div>' + btn('open') + '</div>\n</div>\n'))
342+
343+ # ---------------------------------------------------------------- runners
344+
345+ PAGES['runner-setup.html'] = dict(
346+ title=' · add a runner', fl='john / johnbot / runs', fr='barerepo 0.1.0',
347+ sr='Runner setup page showing one paste-ready command per operating system with the token already embedded.',
348+ body=(crumb('john / johnbot / <span style="color:var(--text-primary);">add a runner</span>',
349+ 'rt_live_7Kq2mXe') +
350+ '<div style="padding:18px;">\n'
351+ ' <div style="color:var(--text-muted); font-size:12px; margin-bottom:10px;">'
352+ 'paste on any machine you want to build on.</div>\n'
353+ ' <div style="color:var(--text-muted); font-size:12px; margin:14px 0 5px;">linux, macos</div>\n'
354+ + box('curl -sL barerepo.sh | sh -s rt_live_7Kq2mXe') +
355+ ' <div style="color:var(--text-muted); font-size:12px; margin:14px 0 5px;">windows</div>\n'
356+ + box('irm barerepo.sh/ps | iex; forge runner rt_live_7Kq2mXe') +
357+ ' <div style="color:var(--text-muted); font-size:12px; margin:14px 0 5px;">'
358+ 'already have the binary</div>\n'
359+ + box('barerepo runner rt_live_7Kq2mXe --labels build,test') +
360+ ' <div style="color:var(--text-muted); font-size:12px; margin-top:16px; line-height:1.9;">\n'
361+ ' <div>the runner dials out. it needs no inbound port and no public address.</div>\n'
362+ ' <div>token scopes to this repo. revoke it on your keys page.</div>\n'
363+ ' <div>this page refreshes the moment a runner attaches.</div>\n'
364+ ' <div>a runner is a program because it long-polls. the protocol is plain http:</div>\n'
365+ ' <div style="color:var(--text-secondary);">'
366+ 'POST /runner/attach &middot; GET /runner/poll &middot; POST /runner/log &middot; '
367+ 'POST /runner/done</div>\n'
368+ ' <div>write your own if you would rather. chapter 15 is the whole spec.</div>\n'
369+ ' </div>\n</div>\n'))
370+
371+ PAGES['runners.html'] = dict(
372+ title=' · runners', fl='2 attached', fr='barerepo 0.1.0',
373+ sr='List of attached build machines with labels, platform and last seen time.',
374+ body=(crumb('john / johnbot / <span style="color:var(--text-primary);">runners</span>',
375+ '2 attached') +
376+ repotabs('runs', lnk('add a runner')) +
377+ row(between('uproar.local', 'idle · 40m ago') +
378+ sub('linux/amd64 · labels build, test · 214 runs')) +
379+ row(between('lisa-mbp', 'busy · now') +
380+ sub('darwin/arm64 · labels build · 31 runs')) +
381+ row(between('winbox', 'offline · 6d ago') +
382+ sub('windows/amd64 · labels build · 4 runs · '
383+ '<span style="color:var(--text-secondary); text-decoration:underline;">forget</span>'), muted=True)))
384+
385+ PAGES['runs.html'] = dict(
386+ title=' · runs', fl='john / johnbot', fr='barerepo 0.1.0',
387+ sr='List of build runs with status, trigger and duration.',
388+ body=(crumb('john / johnbot / <span style="color:var(--text-primary);">runs</span>',
389+ 'newest first') +
390+ repotabs('runs', lnk('runners') + ' · ' + lnk('add a runner')) +
391+ row(between('a3f9c2&nbsp;&nbsp;build ok · test ok', '18s · 40m') +
392+ sub('refs/proposals/47 · uproar.local')) +
393+ row(between('e91b7d&nbsp;&nbsp;<span style="color:var(--text-danger);">build failed</span>',
394+ '6s · 1d') +
395+ sub('refs/proposals/46 · uproar.local')) +
396+ row(between('8b1d44&nbsp;&nbsp;build ok · test ok', '21s · 6h') +
397+ sub('master · lisa-mbp'))))
398+
399+ PAGES['run.html'] = dict(
400+ title=' · run e91b7d', fl='refs/proposals/46', fr='barerepo 0.1.0',
401+ sr='Run detail page showing raw build log output as plain scrollable text.',
402+ body=(crumb('john / johnbot / <span style="color:var(--text-primary);">run e91b7d</span>',
403+ 'uproar.local · 6s · 1d ago') +
404+ row('<div><span style="color:var(--text-danger);">build failed</span> · exit 2</div>' +
405+ sub('refs/proposals/46 · switch to pure-go sqlite · mark')) +
406+ '<div style="padding:12px 18px; font-size:12px; line-height:1.75; color:var(--text-secondary);">\n'
407+ ' <div style="color:var(--text-muted);">$ go build ./...</div>\n'
408+ ' <div>go: downloading modernc.org/sqlite v1.34.1</div>\n'
409+ ' <div>go: downloading modernc.org/libc v1.55.3</div>\n'
410+ ' <div style="color:var(--text-danger);">store/db.go:14:2: '
411+ 'cannot find module providing package github.com/mattn/go-sqlite3</div>\n'
412+ ' <div style="color:var(--text-danger);">exit status 2</div>\n'
413+ ' <div style="color:var(--text-muted); margin-top:10px;">$ exit 2</div>\n</div>\n' +
414+ row(sub(lnk('raw log') + ' · ' + lnk('rerun') + ' · 18kb'))))
415+
416+ # ---------------------------------------------------------------- search / edges
417+
418+ PAGES['search.html'] = dict(
419+ title=' · search', fl='"backoff"', fr='barerepo 0.1.0',
420+ sr='Search results combining code matches, threads and repositories in one list.',
421+ body=(topbar() +
422+ '<div style="padding:14px 18px; border-bottom:0.5px solid var(--border);">' +
423+ field('', 'backoff<span style="color:var(--text-muted);">|</span>') +
424+ sub('47 results') + '</div>\n' +
425+ row(sub('code') + '<div style="margin-top:4px;">johnbot / irc/conn.go:88</div>' +
426+ '<div style="background:var(--surface-1); border:0.5px solid var(--border); border-radius:8px; '
427+ 'padding:6px 11px; margin-top:6px; font-size:12px; color:var(--text-secondary);">'
428+ 'time.Sleep(<span style="background:var(--bg-success); color:var(--text-success);">backoff</span>(i))'
429+ '</div>') +
430+ row(sub('code') + '<div style="margin-top:4px;">johnbot / irc/retry.go:9</div>' +
431+ '<div style="background:var(--surface-1); border:0.5px solid var(--border); border-radius:8px; '
432+ 'padding:6px 11px; margin-top:6px; font-size:12px; color:var(--text-secondary);">'
433+ 'func <span style="background:var(--bg-success); color:var(--text-success);">backoff</span>'
434+ '(n int) time.Duration {</div>') +
435+ row(sub('thread') + '<div style="margin-top:4px;">johnbot 44 · does this work behind a socks proxy?</div>'
436+ + sub('mark mentions backoff twice · 5d')) +
437+ row(sub('repo') + '<div style="margin-top:4px;">john / johnbot</div>' +
438+ sub('irc bot that refuses to leave'))))
439+
440+ PAGES['push-rejected.html'] = dict(
441+ title=' · push rejected', fl='john / johnbot', fr='barerepo 0.1.0',
442+ sr='Page explaining exactly which server hook rejected a push and how to proceed.',
443+ body=(crumb('john / johnbot / <span style="color:var(--text-primary);">push rejected</span>',
444+ 'e91b7d · 2m ago') +
445+ row('<div>you pushed to refs/heads/master</div>' +
446+ sub('.barerepo/config [access] push = ["john", "lisa"] · you are mark')) +
447+ '<div style="padding:14px 18px; border-bottom:0.5px solid var(--border);">\n'
448+ ' <div style="color:var(--text-muted); font-size:12px; margin-bottom:8px;">'
449+ 'push here instead. it needs no permission.</div>\n'
450+ + box('git push origin HEAD:refs/proposals/new') + '</div>\n' +
451+ row(sub('the same message was printed in your terminal.'))))
452+
453+ PAGES['404.html'] = dict(
454+ title=' · not found', fl='barerepo', fr='barerepo 0.1.0',
455+ sr='Not found page.',
456+ body=(topbar() +
457+ '<div style="padding:26px 18px;">\n'
458+ ' <div style="margin-bottom:6px;">404</div>\n'
459+ ' <div style="color:var(--text-muted); font-size:12px;">'
460+ 'john/johnbot exists. that path in it does not.</div>\n</div>\n'))
461+
462+ # ---------------------------------------------------------------- index
463+
464+ def build_index():
465+ groups = [
466+ ('identity', [
467+ ('signup.html', 'signup', 'ssh key is the only credential'),
468+ ('signin.html', 'sign in', 'server challenges the key'),
469+ ('keys.html', 'keys and tokens', 'the only state the server owns'),
470+ ]),
471+ ('repo, first contact', [
472+ ('new-repo.html', 'new repo', 'default branch is master'),
473+ ('repo-empty.html', 'empty repo', 'one block to paste'),
474+ ]),
475+ ('browsing', [
476+ ('repo-log.html', 'repo log', 'diffs inline, this is the landing page'),
477+ ('repo-files.html', 'file tree', 'secondary, not the front door'),
478+ ('repo-file.html', 'file view', 'blame in the gutter, always on'),
479+ ('repo-commit.html', 'commit', 'one commit, full diff'),
480+ ('repo-compare.html', 'compare', 'any ref against any ref'),
481+ ]),
482+ ('threads', [
483+ ('threads.html', 'thread list', 'issues and proposals in one list'),
484+ ('thread.html', 'thread', 'discussion stored in git notes'),
485+ ('thread-new.html', 'new thread', 'attach a ref to make it a proposal'),
486+ ]),
487+ ('runners', [
488+ ('runner-setup.html', 'add a runner', 'one line, token already in it'),
489+ ('runners.html', 'runners', 'your machines'),
490+ ('runs.html', 'runs', 'triggered by push'),
491+ ('run.html', 'run detail', 'raw log, no step theater'),
492+ ]),
493+ ('activity', [
494+ ('inbox.html', 'inbox', 'chronological, no read state, atom'),
495+ ('releases.html', 'releases', 'tag, notes, attached files'),
496+ ]),
497+ ('everything else', [
498+ ('search.html', 'search', 'code, threads, repos in one result set'),
499+ ('profile.html', 'profile', 'repo list'),
500+ ('repo-config.html', 'repo config', 'settings as a versioned file'),
501+ ('push-rejected.html', 'push rejected', 'says what the hook refused'),
502+ ('404.html', '404', ''),
503+ ]),
504+ ]
505+ body = ('<div style="padding:9px 18px; border-bottom:0.5px solid var(--border);">'
506+ 'barerepo · ui mockups</div>\n')
507+ for name, items in groups:
508+ body += (f'<div style="padding:9px 18px; border-bottom:0.5px solid var(--border); '
509+ f'color:var(--text-muted); font-size:12px;">{name}</div>\n')
510+ for href, label, desc in items:
511+ body += (f'<a href="{href}" style="display:block; padding:10px 18px; '
512+ f'border-bottom:0.5px solid var(--border); color:var(--text-primary); '
513+ f'text-decoration:none;"><div><span style="text-decoration:underline;">{label}</span></div>'
514+ f'<div style="color:var(--text-muted); font-size:12px;">{desc}</div></a>\n')
515+ return dict(title=' · mockups', fl='24 views', fr='barerepo 0.1.0',
516+ sr='Index of every mockup page in this set.', body=body)
517+
518+ PAGES['inbox.html'] = dict(
519+ title=' · inbox', fl='inbox', fr='barerepo 0.1.0',
520+ sr='Chronological list of events on repositories and threads the user participates in.',
521+ body=(topbar() +
522+ crumb('<span style="color:var(--text-primary);">inbox</span>',
523+ lnk('atom') + ' · ' + lnk('feed token')) +
524+ row(between('lisa opened proposal 47 on johnbot', '2h') +
525+ sub('panic when config file is empty')) +
526+ row(between('john replied on johnbot thread 47', '1h') +
527+ sub('Default() allocates every call. make it a package var?')) +
528+ row(between('build failed on johnbot refs/proposals/46', '1d') +
529+ sub('uproar.local · exit 2')) +
530+ '<div style="padding:5px 18px; border-bottom:0.5px solid var(--border); '
531+ 'border-top:0.5px solid var(--border-strong); color:var(--text-muted); '
532+ 'font-size:12px;">last visited 2d ago</div>\n' +
533+ row(between('mark opened thread 44 on johnbot', '5d') +
534+ sub('does this work behind a socks proxy?'), muted=True) +
535+ row(between('dave merged proposal 45 on johnbot', '3d') +
536+ sub('bump deps'), muted=True) +
537+ row(sub('events older than 90 days are dropped'))))
538+
539+ PAGES['releases.html'] = dict(
540+ title=' · releases', fl='john / johnbot', fr='barerepo 0.1.0',
541+ sr='List of tagged releases with notes and attached files.',
542+ body=(crumb('john / johnbot / <span style="color:var(--text-primary);">releases</span>',
543+ 'refs/notes/releases') +
544+ repotabs('log', 'newest first') +
545+ row(between('v1.2.0', 'john · 3d') +
546+ sub('fixes the empty config panic. thanks lisa.') +
547+ '<div style="margin-top:6px;">' +
548+ sub('johnbot-linux-amd64 · 8.1mb · johnbot-darwin-arm64 · 7.9mb') + '</div>') +
549+ row(between('v1.1.0', 'john · 2mo') +
550+ sub('reconnect handling') +
551+ '<div style="margin-top:6px;">' + sub('johnbot-linux-amd64 · 8.0mb') + '</div>') +
552+ row(sub('release notes clone with the repository. attached files do not.'))))
553+
554+ # ---------------------------------------------------------------- folded in
555+
556+ # These five pages were hand-written before this generator existed and drifted
557+ # away from it. They are here now, so `python3 build.py` really does write
558+ # every page from one shared chrome, which is what the README claims.
559+
560+ PAGES['signup.html'] = dict(
561+ title=' · signup', fl='barerepo', fr='barerepo 0.1.0',
562+ sr='Signup page where an ssh public key is the only credential collected.',
563+ body=('<div style="display:flex; align-items:center; justify-content:space-between; padding:9px 18px; '
564+ 'border-bottom:0.5px solid var(--border);"><span style="letter-spacing:-0.5px;">barerepo</span>'
565+ '<span style="color:var(--text-secondary); text-decoration:underline;">sign in</span></div>\n'
566+ '<div style="padding:26px 18px; max-width:430px;">\n'
567+ ' <div style="margin-bottom:20px;">new account</div>\n'
568+ + field('name', 'john<span style="color:var(--text-muted);">|</span>') +
569+ field('public key',
570+ '<span style="color:var(--text-secondary); font-size:12px;">ssh-ed25519 '
571+ 'AAAAC3NzaC1lZDI1NTE5AAAAIH8fK2q0mR4vXeN1pLzT9wBcJdSgYo3Ea7kVnQxMuP2r</span>',
572+ 'cat ~/.ssh/id_ed25519.pub') +
573+ ' <div style="margin-bottom:18px;">' + btn('create') + '</div>\n'
574+ ' <div style="color:var(--text-muted); font-size:12px; line-height:1.9;">\n'
575+ ' <div>your key is your account. there is no email and no password.</div>\n'
576+ ' <div style="color:var(--text-danger);">losing every key loses the account, '
577+ 'because there is no out-of-band recovery channel.</div>\n'
578+ ' <div>add a second key from another machine today.</div>\n'
579+ ' </div>\n</div>\n'))
580+
581+ PAGES['repo-log.html'] = dict(
582+ title=' · johnbot', fl=lnk('older'), fr='barerepo 0.1.0',
583+ sr='Repository log with every commit diff expanded inline. This is the landing page.',
584+ body=(crumb('john / <span style="color:var(--text-primary);">johnbot</span>',
585+ 'master · clone <span style="color:var(--text-secondary);">git@barerepo:john/johnbot</span>') +
586+ repotabs('log') +
587+ row(between('a3f9c2&nbsp;&nbsp;fix panic when config is empty', 'lisa · 2h') +
588+ sub('config.go · +4 -1') +
589+ diff('@@ -41,7 +41,10 @@ func Load', [
590+ (' ', 'f, err := os.Open(path)'),
591+ ('-', 'return cfg'),
592+ ('+', 'if len(raw) == 0 {'),
593+ ('+', '&nbsp;&nbsp;return Default(), nil'),
594+ ('+', '}'),
595+ ('+', 'return cfg, nil'),
596+ (' ', '}')]), tall=True) +
597+ row(between('8b1d44&nbsp;&nbsp;drop the retry loop, it never fired', 'john · 6h') +
598+ sub('irc/conn.go · +0 -23') +
599+ diff('@@ -88,23 +88,0 @@ func (c *Conn) dial', [
600+ ('-', 'for i := 0; i &lt; maxRetry; i++ {'),
601+ ('-', '&nbsp;&nbsp;time.Sleep(backoff(i))'),
602+ ('note', '18 more removed lines')]), tall=True) +
603+ row(between('dave&nbsp;&nbsp;merged proposal 45, bump deps', '14 files · +302 -288') +
604+ sub('3d · large diff collapsed · ' + lnk('expand')), tall=True)))
605+
606+ PAGES['repo-config.html'] = dict(
607+ title=' · .barerepo/config', fl=lnk('history') + ' · ' + lnk('blame') + ' · ' + lnk('raw'),
608+ fr='barerepo 0.1.0',
609+ sr='Repository settings shown as a versioned file in the repository rather than a settings form.',
610+ body=(crumb('john / johnbot / <span style="color:var(--text-primary);">.barerepo/config</span>',
611+ 'edited 3d by john · a3f9c2') +
612+ repotabs('config') +
613+ '<div style="padding:14px 18px; border-bottom:0.5px solid var(--border); font-size:12px; '
614+ 'line-height:1.85;">\n'
615+ ' <div style="color:var(--text-muted);">[repo]</div>\n'
616+ ' <div>default_branch = <span style="color:var(--text-secondary);">"master"</span></div>\n'
617+ ' <div>visibility &nbsp;&nbsp;&nbsp;= <span style="color:var(--text-secondary);">"public"</span></div>\n'
618+ ' <div>archived &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;= <span style="color:var(--text-secondary);">false</span></div>\n'
619+ ' <div style="height:9px;"></div>\n'
620+ ' <div style="color:var(--text-muted);">[proposals]</div>\n'
621+ ' <div>accept_from &nbsp;= <span style="color:var(--text-secondary);">"anyone"</span></div>\n'
622+ ' <div>require_runs = <span style="color:var(--text-secondary);">["build", "test"]</span></div>\n'
623+ ' <div style="height:9px;"></div>\n'
624+ ' <div style="color:var(--text-muted);">[access]</div>\n'
625+ ' <div>push &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;= '
626+ '<span style="color:var(--text-secondary);">["john", "lisa"]</span></div>\n'
627+ ' <div>allow_force_push = <span style="color:var(--text-secondary);">[]</span></div>\n'
628+ ' <div style="height:9px;"></div>\n'
629+ ' <div style="color:var(--text-muted);">[runners]</div>\n'
630+ ' <div>"uproar.local" = <span style="color:var(--text-secondary);">["build", "test"]</span></div>\n'
631+ '</div>\n' +
632+ row(sub('edit the file, commit, push. there is no settings form. '
633+ '<span style="color:var(--text-secondary);">git log -p .barerepo/config</span> '
634+ 'shows who changed what.')) +
635+ row(sub('raw file content is served from this host as a download. private files have no raw '
636+ 'link until the operator sets a separate raw host.'))))
637+
638+ PAGES['thread.html'] = dict(
639+ title=' · thread 47', fl='threads · 3 open', fr='barerepo 0.1.0',
640+ sr='Discussion thread where issues and proposals are one object, stored in git notes.',
641+ body=(crumb('john / johnbot / <span style="color:var(--text-primary);">thread 47</span>',
642+ 'refs/notes/threads/47') +
643+ row('<div style="margin-bottom:4px;">panic when config file is empty</div>' +
644+ sub('opened by lisa · 2h · has proposal · '
645+ '<span style="color:var(--text-secondary);">refs/proposals/47</span> · +81 -12'),
646+ tall=True) +
647+ row(sub('lisa · 2h') +
648+ '<div>fresh install, empty config.toml, immediate nil deref on line 44. '
649+ 'reproduces every time.</div>', tall=True) +
650+ row(sub('lisa · 2h · attached a proposal') +
651+ diff('config.go @@ -41,7 +41,10 @@', [
652+ ('+', 'if len(raw) == 0 {'),
653+ ('+', '&nbsp;&nbsp;return Default(), nil'),
654+ ('+', '}')]), tall=True) +
655+ row(sub('john · 1h · on config.go:43') +
656+ '<div>Default() allocates every call. make it a package var?</div>', tall=True) +
657+ row(sub('uproar.local · 40m · self-hosted runner') +
658+ '<div style="color:var(--text-secondary);">build ok · test ok · 18s</div>', tall=True) +
659+ row('<div style="border:0.5px solid var(--border-strong); border-radius:8px; padding:8px 11px; '
660+ 'color:var(--text-muted); min-height:38px;">reply|</div>', tall=True) +
661+ row(sub('this thread is in your clone. read it with no network, and read it if barerepo stops.') +
662+ '<div style="margin-top:8px;">' +
663+ box('git fetch origin "refs/notes/*:refs/notes/*"<br>'
664+ 'git log --show-notes=threads/47') + '</div>', tall=True)))
665+
666+ PAGES['profile.html'] = dict(
667+ title=' · john', fl='barerepo', fr='barerepo 0.1.0',
668+ sr='User profile listing repositories sorted by last push.',
669+ body=(topbar() +
670+ '<div style="display:flex; gap:28px; padding:20px 18px; flex-wrap:wrap;">\n'
671+ ' <div style="width:150px; flex-shrink:0;">\n'
672+ ' <div style="width:76px; height:76px; border:0.5px solid var(--border-strong); '
673+ 'border-radius:8px; display:flex; align-items:center; justify-content:center; font-size:26px; '
674+ 'color:var(--text-secondary); margin-bottom:10px;">jd</div>\n'
675+ ' <div style="margin-bottom:2px;">john</div>\n'
676+ ' <div style="color:var(--text-secondary); font-size:12px; margin-bottom:12px;">'
677+ 'writes bots. mostly go.</div>\n'
678+ ' <div style="color:var(--text-muted); font-size:12px; line-height:1.9;">\n'
679+ ' <div>14 repos</div>\n <div>joined mar 2026</div>\n <div>ssh keys: 3</div>\n'
680+ ' <div>' + lnk('atom') + '</div>\n'
681+ ' </div>\n </div>\n'
682+ ' <div style="flex:1; min-width:260px;">\n'
683+ ' <div style="display:flex; justify-content:space-between; padding-bottom:8px; '
684+ 'border-bottom:0.5px solid var(--border); color:var(--text-muted); font-size:12px;">'
685+ '<span>repositories</span><span>sorted by pushed</span></div>\n'
686+ + ''.join(
687+ ' <div style="padding:11px 0; border-bottom:0.5px solid var(--border);">\n'
688+ f' <div style="display:flex; justify-content:space-between;"><span>{nm}</span>'
689+ f'<span style="color:var(--text-muted); font-size:12px;">{when}</span></div>\n'
690+ f' <div style="color:var(--text-secondary); font-size:12px;">{desc}</div>\n'
691+ f' <div style="color:var(--text-muted); font-size:12px; margin-top:3px;">{meta}</div>\n'
692+ ' </div>\n'
693+ for nm, when, desc, meta in [
694+ (lnk('johnbot'), '2h', 'irc bot that refuses to leave',
695+ 'go · 4.1mb · master · 3 proposals'),
696+ (lnk('dotfiles'), '1d', 'nvim, zsh, too many aliases', 'shell · 210kb · master'),
697+ ('uproar <span style="color:var(--text-muted); font-size:12px; '
698+ 'border:0.5px solid var(--border); border-radius:4px; padding:0 5px;">private</span>',
699+ '4d', 'server configs, do not read', 'nix · 88kb · main'),
700+ (lnk('sqlite-notes'), '3w', 'append-only notes over sqlite',
701+ 'c · 1.2mb · master · archived'),
702+ ]) +
703+ ' <div style="padding-top:11px; color:var(--text-muted); font-size:12px;">showing 4 of 14 · '
704+ + lnk('all') + '</div>\n </div>\n</div>\n'))
705+
706+ PAGES['index.html'] = build_index()
707+
708+ for fname, p in PAGES.items():
709+ with open(os.path.join(OUT, fname), 'w', encoding='utf-8') as f:
710+ f.write(SHELL.format(**p))
711+ print(fname)
@@ -0,0 +1,139 @@
1+ # barerepo build guide
2+
3+ Read the book first, in the `barerepo/book` repository. This file is the order
4+ of operations.
5+
6+ ## Stack
7+
8+ Nothing here is mandatory, but each choice follows from a rule in chapter 5 of the book.
9+
10+ - **Go.** Single static binary, no runtime to install, cross-compiles for the
11+ runner on windows/macos/linux from one build host.
12+ - **Server-side HTML templates.** Rule 4 forbids a client framework. `html/template`
13+ is sufficient. There is no build step, no bundler, no node_modules.
14+ - **SQLite or PostgreSQL** for the server-owned items in chapter 10 of the book,
15+ plus indexes. Not for repo content. One `[database] url` picks which; see
16+ chapter 41.7.1. SQLite is the default and is the right answer for almost every
17+ install. Postgres exists for people who already run one.
18+ Write against `database/sql` and keep the schema to what both dialects accept,
19+ so neither one becomes the only one that is actually tested.
20+ - **libgit2 bindings or shelling out to `git`.** Start by shelling out; it is
21+ faster to get right and the process overhead is invisible next to network time.
22+ Optimize only if profiling says so.
23+ - **No ORM.** A handful of tables, all of them on chapter 10's closed list. Two
24+ dialects of DDL, hand-written, and one place that
25+ turns `?` into `$1` for Postgres.
26+
27+ ## Order
28+
29+ Each stage should be independently usable. Do not build stage N+1 until N works.
30+
31+ **1. Git over ssh and http.** Serve `git-upload-pack` and `git-receive-pack`.
32+ Authenticate ssh by public key. At the end of this stage you can clone and push,
33+ and nothing else exists. This is the whole product's foundation; if it is not
34+ fast and correct, nothing above it matters.
35+
36+ **2. Read-only web views.** Log, file tree, file view with blame gutter, commit,
37+ compare. No accounts yet, public repos only. Mockups: `repo-log.html`,
38+ `repo-files.html`, `repo-file.html`, `repo-commit.html`, `repo-compare.html`.
39+
40+ Note the landing page is the **log**, not the file tree. Nobody navigates code by
41+ clicking folders. Every row on it is the same row: what changed, by how much, and a
42+ the hash as the link to it. No diff is drawn there.
43+
44+ **3. Accounts.** Signup, signin, keys page. Nonce challenge flow. Namespace
45+ ownership. Mockups: `signup.html`, `signin.html`, `keys.html`, `profile.html`,
46+ `new-repo.html`, `repo-empty.html`.
47+
48+ **4. Proposals.** The pre-receive hook that allocates `refs/proposals/<n>`. The
49+ post-receive hook that detects reachability and closes. Compare view against a
50+ proposal ref. Mockup: `push-rejected.html`. This page matters more than it
51+ looks, because rejection is where new contributors get stuck.
52+
53+ **5. Threads.** Notes storage, union merge strategy, comment rendering, the
54+ unified list. Mockups: `threads.html`, `thread.html`, `thread-new.html`.
55+
56+ **6. Runners.** Token issue and revoke. Runner protocol: the runner dials out and
57+ long-polls; it never needs an inbound port, which is what makes "paste one line on
58+ your laptop" actually work. Mockups: `runner-setup.html`, `runners.html`,
59+ `runs.html`, `run.html`.
60+
61+ **6A. GitHub workflows.** Read `.github/workflows` when `[build] command` is
62+ absent, translate the `run:` steps, and decline everything else by name. Match
63+ `runs-on` against attached runners and point at the add-a-runner page when none
64+ fits. Chapter 15A. The rule that makes it worth having is that a skipped step is
65+ never silent.
66+
67+ **7. Search.** One index, one result set across code, threads and repos. Filter
68+ every query against the requesting user's readable set, per chapter 17. Mockup:
69+ `search.html`.
70+
71+ **8. Feeds and the inbox.** The event log, the inbox page, Atom output, feed
72+ tokens. Nothing before this stage tells anyone that anything happened, so it is
73+ not optional. Mockup: `inbox.html`.
74+
75+ **9. The rest.** Releases and artifacts, webhooks with the SSRF deny list, server
76+ side copy, rename with permanent redirects, archiving. Mockup: `releases.html`.
77+ Chapters 20 to 23A cover these; none of them changes anything below them.
78+
79+ ## The runner command
80+
81+ This is the piece to get exactly right, because it is the most visible proof of
82+ the no-interstitial thesis.
83+
84+ ```
85+ curl -sL barerepo.sh | sh -s rt_live_7Kq2mXe
86+ ```
87+
88+ The token is *in the copied line*. There is no "now go to settings and paste
89+ this" step, no OS tab to click, no config file to create. One copy, one paste,
90+ the machine is attached and the page it was copied from updates.
91+
92+ Requirements:
93+ - Token embedded in the displayed command, per repo, revocable from `keys.html`.
94+ - Three platforms shown simultaneously. Do not use tabs, because tabs hide two thirds of
95+ the answer to save nine lines of space.
96+ - Runner dials out. No inbound port, no public address, works from a laptop behind
97+ NAT.
98+ - Same single binary as the server, different subcommand.
99+
100+ ## Performance budget
101+
102+ Treat these as build-failing thresholds, not aspirations. They are asserted in the
103+ test suite and are not shown to users.
104+
105+ | View | Time | Payload |
106+ |---|---|---|
107+ | Any page with no diff | under 10ms | under 15kb |
108+ | Log | under 20ms | under 30kb |
109+ | Any page | under 2kb JS | |
110+ | Raw file content | separate host, or headers per 42.3 | |
111+
112+ Diff rendering is the one genuinely hard case. Cache rendered diffs by blob pair
113+ hash; they are immutable, so the cache never needs invalidation.
114+
115+ ## Traps
116+
117+ - **Do not hardcode `master` or `main`.** Read the repo's HEAD. Some third-party
118+ tooling assumes `main`; your own CI must not.
119+ - **Do not add a merge button.** Every request for one is a request to become
120+ GitHub. The answer is a fetch command.
121+ - **Do not let the notes conflict problem slide to launch.** It is the single
122+ most likely thing to make threads look broken to a second user.
123+ - **Rate limit proposal refs per key per repo.** Rule 5 is an open door.
124+ - **Expire unreferenced proposal refs** or the repo grows without bound.
125+ - **Filter search by read access inside the query.** Filtering after ranking leaks
126+ the count of private matches. This is the highest-severity mistake available in
127+ this codebase.
128+ - **Reject oversized blobs in pre-receive**, naming the file. LFS is off by
129+ default and a size limit is what makes that decision hold.
130+ - **Deny private address ranges for webhooks**, resolved and re-checked after
131+ every redirect.
132+ - **Derive the reserved-name list from the route table**, per chapter 42.5. A
133+ route added without a reservation is a route an account name can shadow.
134+ - **The owner is exempt from the archived flag.** Otherwise archiving is a
135+ one-way door, because unarchiving arrives by push. Chapter 21.3.
136+ - **Tests run on SQLite.** It needs no service, so every test gets a fresh empty
137+ database. Guard the Postgres schema with a test that compares the two DDL sets
138+ column by column; that test needs no server. Run the suite against a real
139+ Postgres before a release, not on every commit.
@@ -0,0 +1,57 @@
1+ <!doctype html>
2+ <html lang="en">
3+ <head>
4+ <meta charset="utf-8">
5+ <meta name="viewport" content="width=device-width, initial-scale=1">
6+ <title>barerepo · inbox</title>
7+ <link rel="stylesheet" href="tokens.css">
8+ </head>
9+ <body>
10+ <div class="wrap">
11+
12+ <div style="font-family: var(--font-mono); font-size: 13px; line-height: 1.6; color: var(--text-primary); background: #ffffff; border: 0.5px solid var(--border); border-radius: 12px; min-height: calc(100vh - 26px); display: flex; flex-direction: column;">
13+
14+ <h2 class="sr-only">Chronological list of events on repositories and threads the user participates in.</h2>
15+ <div style="display:flex; align-items:center; justify-content:space-between; padding:9px 18px; border-bottom:0.5px solid var(--border);">
16+ <div style="display:flex; gap:20px; align-items:center;">
17+ <span style="letter-spacing:-0.5px; text-decoration:underline;">barerepo</span>
18+ <span style="color:var(--text-secondary); text-decoration:underline;">new</span>
19+ </div>
20+ <div style="display:flex; gap:16px; align-items:center; color:var(--text-secondary);">
21+ <span style="border:0.5px solid var(--border); border-radius:8px; padding:3px 10px; color:var(--text-muted);">search /</span>
22+ <span>john</span>
23+ </div>
24+ </div>
25+ <div style="display:flex; align-items:center; justify-content:space-between; padding:9px 18px; border-bottom:0.5px solid var(--border);">
26+ <div style="color:var(--text-secondary);"><span style="color:var(--text-primary);">inbox</span></div>
27+ <div style="color:var(--text-muted); font-size:12px;"><span style="text-decoration:underline;">atom</span> · <span style="text-decoration:underline;">feed token</span></div>
28+ </div>
29+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
30+ <div style="display:flex; justify-content:space-between;"><span>lisa opened proposal 47 on johnbot</span><span style="color:var(--text-muted); font-size:12px;">2h</span></div><div style="color:var(--text-muted); font-size:12px;">panic when config file is empty</div>
31+ </div>
32+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
33+ <div style="display:flex; justify-content:space-between;"><span>john replied on johnbot thread 47</span><span style="color:var(--text-muted); font-size:12px;">1h</span></div><div style="color:var(--text-muted); font-size:12px;">Default() allocates every call. make it a package var?</div>
34+ </div>
35+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
36+ <div style="display:flex; justify-content:space-between;"><span>build failed on johnbot refs/proposals/46</span><span style="color:var(--text-muted); font-size:12px;">1d</span></div><div style="color:var(--text-muted); font-size:12px;">uproar.local · exit 2</div>
37+ </div>
38+ <div style="padding:5px 18px; border-bottom:0.5px solid var(--border); border-top:0.5px solid var(--border-strong); color:var(--text-muted); font-size:12px;">last visited 2d ago</div>
39+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border); color:var(--text-muted);">
40+ <div style="display:flex; justify-content:space-between;"><span>mark opened thread 44 on johnbot</span><span style="color:var(--text-muted); font-size:12px;">5d</span></div><div style="color:var(--text-muted); font-size:12px;">does this work behind a socks proxy?</div>
41+ </div>
42+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border); color:var(--text-muted);">
43+ <div style="display:flex; justify-content:space-between;"><span>dave merged proposal 45 on johnbot</span><span style="color:var(--text-muted); font-size:12px;">3d</span></div><div style="color:var(--text-muted); font-size:12px;">bump deps</div>
44+ </div>
45+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
46+ <div style="color:var(--text-muted); font-size:12px;">events older than 90 days are dropped</div>
47+ </div>
48+
49+ <div style="position:sticky; bottom:0; margin-top:auto; background:#ffffff; border-top:0.5px solid var(--border); border-radius:0 0 12px 12px; padding:9px 18px; display:flex; justify-content:space-between; color:var(--text-muted); font-size:12px;">
50+ <span>inbox</span><span>barerepo 0.1.0</span>
51+ </div>
52+
53+ </div>
54+
55+ </div>
56+ </body>
57+ </html>
@@ -0,0 +1,56 @@
1+ <!doctype html>
2+ <html lang="en">
3+ <head>
4+ <meta charset="utf-8">
5+ <meta name="viewport" content="width=device-width, initial-scale=1">
6+ <title>barerepo · mockups</title>
7+ <link rel="stylesheet" href="tokens.css">
8+ </head>
9+ <body>
10+ <div class="wrap">
11+
12+ <div style="font-family: var(--font-mono); font-size: 13px; line-height: 1.6; color: var(--text-primary); background: #ffffff; border: 0.5px solid var(--border); border-radius: 12px; min-height: calc(100vh - 26px); display: flex; flex-direction: column;">
13+
14+ <h2 class="sr-only">Index of every mockup page in this set.</h2>
15+ <div style="padding:9px 18px; border-bottom:0.5px solid var(--border);">barerepo · ui mockups</div>
16+ <div style="padding:9px 18px; border-bottom:0.5px solid var(--border); color:var(--text-muted); font-size:12px;">identity</div>
17+ <a href="signup.html" style="display:block; padding:10px 18px; border-bottom:0.5px solid var(--border); color:var(--text-primary); text-decoration:none;"><div><span style="text-decoration:underline;">signup</span></div><div style="color:var(--text-muted); font-size:12px;">ssh key is the only credential</div></a>
18+ <a href="signin.html" style="display:block; padding:10px 18px; border-bottom:0.5px solid var(--border); color:var(--text-primary); text-decoration:none;"><div><span style="text-decoration:underline;">sign in</span></div><div style="color:var(--text-muted); font-size:12px;">server challenges the key</div></a>
19+ <a href="keys.html" style="display:block; padding:10px 18px; border-bottom:0.5px solid var(--border); color:var(--text-primary); text-decoration:none;"><div><span style="text-decoration:underline;">keys and tokens</span></div><div style="color:var(--text-muted); font-size:12px;">the only state the server owns</div></a>
20+ <div style="padding:9px 18px; border-bottom:0.5px solid var(--border); color:var(--text-muted); font-size:12px;">repo, first contact</div>
21+ <a href="new-repo.html" style="display:block; padding:10px 18px; border-bottom:0.5px solid var(--border); color:var(--text-primary); text-decoration:none;"><div><span style="text-decoration:underline;">new repo</span></div><div style="color:var(--text-muted); font-size:12px;">default branch is master</div></a>
22+ <a href="repo-empty.html" style="display:block; padding:10px 18px; border-bottom:0.5px solid var(--border); color:var(--text-primary); text-decoration:none;"><div><span style="text-decoration:underline;">empty repo</span></div><div style="color:var(--text-muted); font-size:12px;">one block to paste</div></a>
23+ <div style="padding:9px 18px; border-bottom:0.5px solid var(--border); color:var(--text-muted); font-size:12px;">browsing</div>
24+ <a href="repo-log.html" style="display:block; padding:10px 18px; border-bottom:0.5px solid var(--border); color:var(--text-primary); text-decoration:none;"><div><span style="text-decoration:underline;">repo log</span></div><div style="color:var(--text-muted); font-size:12px;">diffs inline, this is the landing page</div></a>
25+ <a href="repo-files.html" style="display:block; padding:10px 18px; border-bottom:0.5px solid var(--border); color:var(--text-primary); text-decoration:none;"><div><span style="text-decoration:underline;">file tree</span></div><div style="color:var(--text-muted); font-size:12px;">secondary, not the front door</div></a>
26+ <a href="repo-file.html" style="display:block; padding:10px 18px; border-bottom:0.5px solid var(--border); color:var(--text-primary); text-decoration:none;"><div><span style="text-decoration:underline;">file view</span></div><div style="color:var(--text-muted); font-size:12px;">blame in the gutter, always on</div></a>
27+ <a href="repo-commit.html" style="display:block; padding:10px 18px; border-bottom:0.5px solid var(--border); color:var(--text-primary); text-decoration:none;"><div><span style="text-decoration:underline;">commit</span></div><div style="color:var(--text-muted); font-size:12px;">one commit, full diff</div></a>
28+ <a href="repo-compare.html" style="display:block; padding:10px 18px; border-bottom:0.5px solid var(--border); color:var(--text-primary); text-decoration:none;"><div><span style="text-decoration:underline;">compare</span></div><div style="color:var(--text-muted); font-size:12px;">any ref against any ref</div></a>
29+ <div style="padding:9px 18px; border-bottom:0.5px solid var(--border); color:var(--text-muted); font-size:12px;">threads</div>
30+ <a href="threads.html" style="display:block; padding:10px 18px; border-bottom:0.5px solid var(--border); color:var(--text-primary); text-decoration:none;"><div><span style="text-decoration:underline;">thread list</span></div><div style="color:var(--text-muted); font-size:12px;">issues and proposals in one list</div></a>
31+ <a href="thread.html" style="display:block; padding:10px 18px; border-bottom:0.5px solid var(--border); color:var(--text-primary); text-decoration:none;"><div><span style="text-decoration:underline;">thread</span></div><div style="color:var(--text-muted); font-size:12px;">discussion stored in git notes</div></a>
32+ <a href="thread-new.html" style="display:block; padding:10px 18px; border-bottom:0.5px solid var(--border); color:var(--text-primary); text-decoration:none;"><div><span style="text-decoration:underline;">new thread</span></div><div style="color:var(--text-muted); font-size:12px;">attach a ref to make it a proposal</div></a>
33+ <div style="padding:9px 18px; border-bottom:0.5px solid var(--border); color:var(--text-muted); font-size:12px;">runners</div>
34+ <a href="runner-setup.html" style="display:block; padding:10px 18px; border-bottom:0.5px solid var(--border); color:var(--text-primary); text-decoration:none;"><div><span style="text-decoration:underline;">add a runner</span></div><div style="color:var(--text-muted); font-size:12px;">one line, token already in it</div></a>
35+ <a href="runners.html" style="display:block; padding:10px 18px; border-bottom:0.5px solid var(--border); color:var(--text-primary); text-decoration:none;"><div><span style="text-decoration:underline;">runners</span></div><div style="color:var(--text-muted); font-size:12px;">your machines</div></a>
36+ <a href="runs.html" style="display:block; padding:10px 18px; border-bottom:0.5px solid var(--border); color:var(--text-primary); text-decoration:none;"><div><span style="text-decoration:underline;">runs</span></div><div style="color:var(--text-muted); font-size:12px;">triggered by push</div></a>
37+ <a href="run.html" style="display:block; padding:10px 18px; border-bottom:0.5px solid var(--border); color:var(--text-primary); text-decoration:none;"><div><span style="text-decoration:underline;">run detail</span></div><div style="color:var(--text-muted); font-size:12px;">raw log, no step theater</div></a>
38+ <div style="padding:9px 18px; border-bottom:0.5px solid var(--border); color:var(--text-muted); font-size:12px;">activity</div>
39+ <a href="inbox.html" style="display:block; padding:10px 18px; border-bottom:0.5px solid var(--border); color:var(--text-primary); text-decoration:none;"><div><span style="text-decoration:underline;">inbox</span></div><div style="color:var(--text-muted); font-size:12px;">chronological, no read state, atom</div></a>
40+ <a href="releases.html" style="display:block; padding:10px 18px; border-bottom:0.5px solid var(--border); color:var(--text-primary); text-decoration:none;"><div><span style="text-decoration:underline;">releases</span></div><div style="color:var(--text-muted); font-size:12px;">tag, notes, attached files</div></a>
41+ <div style="padding:9px 18px; border-bottom:0.5px solid var(--border); color:var(--text-muted); font-size:12px;">everything else</div>
42+ <a href="search.html" style="display:block; padding:10px 18px; border-bottom:0.5px solid var(--border); color:var(--text-primary); text-decoration:none;"><div><span style="text-decoration:underline;">search</span></div><div style="color:var(--text-muted); font-size:12px;">code, threads, repos in one result set</div></a>
43+ <a href="profile.html" style="display:block; padding:10px 18px; border-bottom:0.5px solid var(--border); color:var(--text-primary); text-decoration:none;"><div><span style="text-decoration:underline;">profile</span></div><div style="color:var(--text-muted); font-size:12px;">repo list</div></a>
44+ <a href="repo-config.html" style="display:block; padding:10px 18px; border-bottom:0.5px solid var(--border); color:var(--text-primary); text-decoration:none;"><div><span style="text-decoration:underline;">repo config</span></div><div style="color:var(--text-muted); font-size:12px;">settings as a versioned file</div></a>
45+ <a href="push-rejected.html" style="display:block; padding:10px 18px; border-bottom:0.5px solid var(--border); color:var(--text-primary); text-decoration:none;"><div><span style="text-decoration:underline;">push rejected</span></div><div style="color:var(--text-muted); font-size:12px;">says what the hook refused</div></a>
46+ <a href="404.html" style="display:block; padding:10px 18px; border-bottom:0.5px solid var(--border); color:var(--text-primary); text-decoration:none;"><div><span style="text-decoration:underline;">404</span></div><div style="color:var(--text-muted); font-size:12px;"></div></a>
47+
48+ <div style="position:sticky; bottom:0; margin-top:auto; background:#ffffff; border-top:0.5px solid var(--border); border-radius:0 0 12px 12px; padding:9px 18px; display:flex; justify-content:space-between; color:var(--text-muted); font-size:12px;">
49+ <span>24 views</span><span>barerepo 0.1.0</span>
50+ </div>
51+
52+ </div>
53+
54+ </div>
55+ </body>
56+ </html>
@@ -0,0 +1,71 @@
1+ <!doctype html>
2+ <html lang="en">
3+ <head>
4+ <meta charset="utf-8">
5+ <meta name="viewport" content="width=device-width, initial-scale=1">
6+ <title>barerepo · keys</title>
7+ <link rel="stylesheet" href="tokens.css">
8+ </head>
9+ <body>
10+ <div class="wrap">
11+
12+ <div style="font-family: var(--font-mono); font-size: 13px; line-height: 1.6; color: var(--text-primary); background: #ffffff; border: 0.5px solid var(--border); border-radius: 12px; min-height: calc(100vh - 26px); display: flex; flex-direction: column;">
13+
14+ <h2 class="sr-only">Page listing ssh keys, runner tokens and feed tokens, each with a revoke control.</h2>
15+ <div style="display:flex; align-items:center; justify-content:space-between; padding:9px 18px; border-bottom:0.5px solid var(--border);">
16+ <div style="display:flex; gap:20px; align-items:center;">
17+ <span style="letter-spacing:-0.5px; text-decoration:underline;">barerepo</span>
18+ <span style="color:var(--text-secondary); text-decoration:underline;">new</span>
19+ </div>
20+ <div style="display:flex; gap:16px; align-items:center; color:var(--text-secondary);">
21+ <span style="border:0.5px solid var(--border); border-radius:8px; padding:3px 10px; color:var(--text-muted);">search /</span>
22+ <span>john</span>
23+ </div>
24+ </div>
25+ <div style="display:flex; align-items:center; justify-content:space-between; padding:9px 18px; border-bottom:0.5px solid var(--border);">
26+ <div style="color:var(--text-secondary);">john / <span style="color:var(--text-primary);">keys</span></div>
27+ <div style="color:var(--text-muted); font-size:12px;">the only state the server owns</div>
28+ </div>
29+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
30+ <div style="color:var(--text-muted); font-size:12px;">ssh keys</div>
31+ </div>
32+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
33+ <div style="display:flex; justify-content:space-between;"><span>ed25519 SHA256:8fK2q0mR4vXeN1pLzT9wBcJdSgYo3Ea7kVnQxMuP2r</span><span style="color:var(--text-muted); font-size:12px;">added mar 2026</span></div><div style="color:var(--text-muted); font-size:12px;">laptop · last used 2h · <span style="color:var(--text-secondary); text-decoration:underline;">revoke</span></div>
34+ </div>
35+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
36+ <div style="display:flex; justify-content:space-between;"><span>ed25519 SHA256:Qw3rTy7uIoP0aSdFgHjKlZxCvBnM4eR8tYu1IoP2aSd</span><span style="color:var(--text-muted); font-size:12px;">added apr 2026</span></div><div style="color:var(--text-muted); font-size:12px;">uproar · last used 4d · <span style="color:var(--text-secondary); text-decoration:underline;">revoke</span></div>
37+ </div>
38+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
39+ <div style="color:var(--text-muted); font-size:12px;">runner tokens</div>
40+ </div>
41+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
42+ <div style="display:flex; justify-content:space-between;"><span>uproar.local</span><span style="color:var(--text-muted); font-size:12px;">created 3d</span></div><div style="color:var(--text-muted); font-size:12px;">john/johnbot · labels build, test · last seen 40m · <span style="color:var(--text-secondary); text-decoration:underline;">revoke</span></div>
43+ </div>
44+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
45+ <div style="color:var(--text-muted); font-size:12px;">feed tokens</div>
46+ </div>
47+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
48+ <div style="display:flex; justify-content:space-between;"><span>inbox</span><span style="color:var(--text-muted); font-size:12px;">created 3d</span></div><div style="color:var(--text-muted); font-size:12px;">read only · last read 20m · <span style="color:var(--text-secondary); text-decoration:underline;">revoke</span></div>
49+ </div>
50+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
51+ <div style="border:0.5px solid var(--text-primary); border-radius:8px; padding:7px 16px; display:inline-block;">new key</div>&nbsp;&nbsp;<div style="border:0.5px solid var(--text-primary); border-radius:8px; padding:7px 16px; display:inline-block;">new runner token</div>&nbsp;&nbsp;<div style="border:0.5px solid var(--text-primary); border-radius:8px; padding:7px 16px; display:inline-block;">new feed token</div>
52+ </div>
53+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
54+ <div style="color:var(--text-muted); font-size:12px;">a token is shown once, inside the command that uses it. only its hash is kept, so it cannot be shown again. lost one? revoke it and make another.</div>
55+ </div>
56+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
57+ <div style="color:var(--text-muted); font-size:12px;">a key signs you in and pushes. a runner token attaches one machine to one repository. a feed token reads one feed and can write nothing.</div>
58+ </div>
59+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
60+ <div style="color:var(--text-muted); font-size:12px;">everything else is in .barerepo/config, in the repository it belongs to.</div>
61+ </div>
62+
63+ <div style="position:sticky; bottom:0; margin-top:auto; background:#ffffff; border-top:0.5px solid var(--border); border-radius:0 0 12px 12px; padding:9px 18px; display:flex; justify-content:space-between; color:var(--text-muted); font-size:12px;">
64+ <span>john / keys</span><span>barerepo 0.1.0</span>
65+ </div>
66+
67+ </div>
68+
69+ </div>
70+ </body>
71+ </html>
@@ -0,0 +1,59 @@
1+ <!doctype html>
2+ <html lang="en">
3+ <head>
4+ <meta charset="utf-8">
5+ <meta name="viewport" content="width=device-width, initial-scale=1">
6+ <title>barerepo · new repo</title>
7+ <link rel="stylesheet" href="tokens.css">
8+ </head>
9+ <body>
10+ <div class="wrap">
11+
12+ <div style="font-family: var(--font-mono); font-size: 13px; line-height: 1.6; color: var(--text-primary); background: #ffffff; border: 0.5px solid var(--border); border-radius: 12px; min-height: calc(100vh - 26px); display: flex; flex-direction: column;">
13+
14+ <h2 class="sr-only">Form for creating a new repository with a choice of default branch name.</h2>
15+ <div style="display:flex; align-items:center; justify-content:space-between; padding:9px 18px; border-bottom:0.5px solid var(--border);">
16+ <div style="display:flex; gap:20px; align-items:center;">
17+ <span style="letter-spacing:-0.5px; text-decoration:underline;">barerepo</span>
18+ <span style="color:var(--text-secondary); text-decoration:underline;">new</span>
19+ </div>
20+ <div style="display:flex; gap:16px; align-items:center; color:var(--text-secondary);">
21+ <span style="border:0.5px solid var(--border); border-radius:8px; padding:3px 10px; color:var(--text-muted);">search /</span>
22+ <span>john</span>
23+ </div>
24+ </div>
25+ <div style="padding:22px 18px; max-width:430px;">
26+ <div style="margin-bottom:20px;">new repository</div>
27+ <div style="margin-bottom:16px;">
28+ <div style="color:var(--text-muted); font-size:12px; margin-bottom:5px;">name</div>
29+ <div style="border:0.5px solid var(--border-strong); border-radius:8px; padding:7px 11px; word-break:break-all;">johnbot<span style="color:var(--text-muted);">|</span></div>
30+ <div style="color:var(--text-muted); font-size:12px; margin-top:4px;">john/johnbot</div>
31+ </div><div style="margin-bottom:16px;">
32+ <div style="color:var(--text-muted); font-size:12px; margin-bottom:5px;">description</div>
33+ <div style="border:0.5px solid var(--border-strong); border-radius:8px; padding:7px 11px; word-break:break-all;"><span style="color:var(--text-muted);">optional</span></div>
34+ </div><div style="margin-bottom:16px;">
35+ <div style="color:var(--text-muted); font-size:12px; margin-bottom:5px;">default branch</div>
36+ <div style="border:0.5px solid var(--border-strong); border-radius:8px; padding:7px 11px; word-break:break-all;">master</div>
37+ <div style="color:var(--text-muted); font-size:12px; margin-top:4px;">any branch name</div>
38+ </div><div style="margin-bottom:16px;">
39+ <div style="color:var(--text-muted); font-size:12px; margin-bottom:5px;">visibility</div>
40+ <div style="border:0.5px solid var(--border-strong); border-radius:8px; padding:7px 11px; word-break:break-all;">public</div>
41+ </div> <div style="margin-bottom:22px;"><div style="border:0.5px solid var(--text-primary); border-radius:8px; padding:7px 16px; display:inline-block;">create</div></div>
42+ </div>
43+ <div style="padding:16px 18px; border-top:0.5px solid var(--border);">
44+ <div style="color:var(--text-muted); font-size:12px; margin-bottom:8px;">or skip this form. push to a name that does not exist.</div>
45+ <div style="background:var(--surface-1); border:0.5px solid var(--border); border-radius:8px; padding:9px 12px; color:var(--text-secondary); font-size:12px; word-break:break-all;">git remote add origin git@barerepo:john/&lt;name&gt;<br>git push -u origin master</div> <div style="color:var(--text-muted); font-size:12px; margin-top:10px; line-height:1.9;">
46+ <div>the branch you push becomes the default branch.</div>
47+ <div>the repository starts private. public is one line in .barerepo/config.</div>
48+ </div>
49+ </div>
50+
51+ <div style="position:sticky; bottom:0; margin-top:auto; background:#ffffff; border-top:0.5px solid var(--border); border-radius:0 0 12px 12px; padding:9px 18px; display:flex; justify-content:space-between; color:var(--text-muted); font-size:12px;">
52+ <span>barerepo</span><span>barerepo 0.1.0</span>
53+ </div>
54+
55+ </div>
56+
57+ </div>
58+ </body>
59+ </html>
@@ -0,0 +1,71 @@
1+ <!doctype html>
2+ <html lang="en">
3+ <head>
4+ <meta charset="utf-8">
5+ <meta name="viewport" content="width=device-width, initial-scale=1">
6+ <title>barerepo · john</title>
7+ <link rel="stylesheet" href="tokens.css">
8+ </head>
9+ <body>
10+ <div class="wrap">
11+
12+ <div style="font-family: var(--font-mono); font-size: 13px; line-height: 1.6; color: var(--text-primary); background: #ffffff; border: 0.5px solid var(--border); border-radius: 12px; min-height: calc(100vh - 26px); display: flex; flex-direction: column;">
13+
14+ <h2 class="sr-only">User profile listing repositories sorted by last push.</h2>
15+ <div style="display:flex; align-items:center; justify-content:space-between; padding:9px 18px; border-bottom:0.5px solid var(--border);">
16+ <div style="display:flex; gap:20px; align-items:center;">
17+ <span style="letter-spacing:-0.5px; text-decoration:underline;">barerepo</span>
18+ <span style="color:var(--text-secondary); text-decoration:underline;">new</span>
19+ </div>
20+ <div style="display:flex; gap:16px; align-items:center; color:var(--text-secondary);">
21+ <span style="border:0.5px solid var(--border); border-radius:8px; padding:3px 10px; color:var(--text-muted);">search /</span>
22+ <span>john</span>
23+ </div>
24+ </div>
25+ <div style="display:flex; gap:28px; padding:20px 18px; flex-wrap:wrap;">
26+ <div style="width:150px; flex-shrink:0;">
27+ <div style="width:76px; height:76px; border:0.5px solid var(--border-strong); border-radius:8px; display:flex; align-items:center; justify-content:center; font-size:26px; color:var(--text-secondary); margin-bottom:10px;">jd</div>
28+ <div style="margin-bottom:2px;">john</div>
29+ <div style="color:var(--text-secondary); font-size:12px; margin-bottom:12px;">writes bots. mostly go.</div>
30+ <div style="color:var(--text-muted); font-size:12px; line-height:1.9;">
31+ <div>14 repos</div>
32+ <div>joined mar 2026</div>
33+ <div>ssh keys: 3</div>
34+ <div><span style="text-decoration:underline;">atom</span></div>
35+ </div>
36+ </div>
37+ <div style="flex:1; min-width:260px;">
38+ <div style="display:flex; justify-content:space-between; padding-bottom:8px; border-bottom:0.5px solid var(--border); color:var(--text-muted); font-size:12px;"><span>repositories</span><span>sorted by pushed</span></div>
39+ <div style="padding:11px 0; border-bottom:0.5px solid var(--border);">
40+ <div style="display:flex; justify-content:space-between;"><span><span style="text-decoration:underline;">johnbot</span></span><span style="color:var(--text-muted); font-size:12px;">2h</span></div>
41+ <div style="color:var(--text-secondary); font-size:12px;">irc bot that refuses to leave</div>
42+ <div style="color:var(--text-muted); font-size:12px; margin-top:3px;">go · 4.1mb · master · 3 proposals</div>
43+ </div>
44+ <div style="padding:11px 0; border-bottom:0.5px solid var(--border);">
45+ <div style="display:flex; justify-content:space-between;"><span><span style="text-decoration:underline;">dotfiles</span></span><span style="color:var(--text-muted); font-size:12px;">1d</span></div>
46+ <div style="color:var(--text-secondary); font-size:12px;">nvim, zsh, too many aliases</div>
47+ <div style="color:var(--text-muted); font-size:12px; margin-top:3px;">shell · 210kb · master</div>
48+ </div>
49+ <div style="padding:11px 0; border-bottom:0.5px solid var(--border);">
50+ <div style="display:flex; justify-content:space-between;"><span>uproar <span style="color:var(--text-muted); font-size:12px; border:0.5px solid var(--border); border-radius:4px; padding:0 5px;">private</span></span><span style="color:var(--text-muted); font-size:12px;">4d</span></div>
51+ <div style="color:var(--text-secondary); font-size:12px;">server configs, do not read</div>
52+ <div style="color:var(--text-muted); font-size:12px; margin-top:3px;">nix · 88kb · main</div>
53+ </div>
54+ <div style="padding:11px 0; border-bottom:0.5px solid var(--border);">
55+ <div style="display:flex; justify-content:space-between;"><span><span style="text-decoration:underline;">sqlite-notes</span></span><span style="color:var(--text-muted); font-size:12px;">3w</span></div>
56+ <div style="color:var(--text-secondary); font-size:12px;">append-only notes over sqlite</div>
57+ <div style="color:var(--text-muted); font-size:12px; margin-top:3px;">c · 1.2mb · master · archived</div>
58+ </div>
59+ <div style="padding-top:11px; color:var(--text-muted); font-size:12px;">showing 4 of 14 · <span style="text-decoration:underline;">all</span></div>
60+ </div>
61+ </div>
62+
63+ <div style="position:sticky; bottom:0; margin-top:auto; background:#ffffff; border-top:0.5px solid var(--border); border-radius:0 0 12px 12px; padding:9px 18px; display:flex; justify-content:space-between; color:var(--text-muted); font-size:12px;">
64+ <span>barerepo</span><span>barerepo 0.1.0</span>
65+ </div>
66+
67+ </div>
68+
69+ </div>
70+ </body>
71+ </html>
@@ -0,0 +1,37 @@
1+ <!doctype html>
2+ <html lang="en">
3+ <head>
4+ <meta charset="utf-8">
5+ <meta name="viewport" content="width=device-width, initial-scale=1">
6+ <title>barerepo · push rejected</title>
7+ <link rel="stylesheet" href="tokens.css">
8+ </head>
9+ <body>
10+ <div class="wrap">
11+
12+ <div style="font-family: var(--font-mono); font-size: 13px; line-height: 1.6; color: var(--text-primary); background: #ffffff; border: 0.5px solid var(--border); border-radius: 12px; min-height: calc(100vh - 26px); display: flex; flex-direction: column;">
13+
14+ <h2 class="sr-only">Page explaining exactly which server hook rejected a push and how to proceed.</h2>
15+ <div style="display:flex; align-items:center; justify-content:space-between; padding:9px 18px; border-bottom:0.5px solid var(--border);">
16+ <div style="color:var(--text-secondary);">john / johnbot / <span style="color:var(--text-primary);">push rejected</span></div>
17+ <div style="color:var(--text-muted); font-size:12px;">e91b7d · 2m ago</div>
18+ </div>
19+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
20+ <div>you pushed to refs/heads/master</div><div style="color:var(--text-muted); font-size:12px;">.barerepo/config [access] push = ["john", "lisa"] · you are mark</div>
21+ </div>
22+ <div style="padding:14px 18px; border-bottom:0.5px solid var(--border);">
23+ <div style="color:var(--text-muted); font-size:12px; margin-bottom:8px;">push here instead. it needs no permission.</div>
24+ <div style="background:var(--surface-1); border:0.5px solid var(--border); border-radius:8px; padding:9px 12px; color:var(--text-secondary); font-size:12px; word-break:break-all;">git push origin HEAD:refs/proposals/new</div></div>
25+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
26+ <div style="color:var(--text-muted); font-size:12px;">the same message was printed in your terminal.</div>
27+ </div>
28+
29+ <div style="position:sticky; bottom:0; margin-top:auto; background:#ffffff; border-top:0.5px solid var(--border); border-radius:0 0 12px 12px; padding:9px 18px; display:flex; justify-content:space-between; color:var(--text-muted); font-size:12px;">
30+ <span>john / johnbot</span><span>barerepo 0.1.0</span>
31+ </div>
32+
33+ </div>
34+
35+ </div>
36+ </body>
37+ </html>
@@ -0,0 +1,45 @@
1+ <!doctype html>
2+ <html lang="en">
3+ <head>
4+ <meta charset="utf-8">
5+ <meta name="viewport" content="width=device-width, initial-scale=1">
6+ <title>barerepo · releases</title>
7+ <link rel="stylesheet" href="tokens.css">
8+ </head>
9+ <body>
10+ <div class="wrap">
11+
12+ <div style="font-family: var(--font-mono); font-size: 13px; line-height: 1.6; color: var(--text-primary); background: #ffffff; border: 0.5px solid var(--border); border-radius: 12px; min-height: calc(100vh - 26px); display: flex; flex-direction: column;">
13+
14+ <h2 class="sr-only">List of tagged releases with notes and attached files.</h2>
15+ <div style="display:flex; align-items:center; justify-content:space-between; padding:9px 18px; border-bottom:0.5px solid var(--border);">
16+ <div style="color:var(--text-secondary);">john / johnbot / <span style="color:var(--text-primary);">releases</span></div>
17+ <div style="color:var(--text-muted); font-size:12px;">refs/notes/releases</div>
18+ </div>
19+ <div style="display:flex; gap:22px; padding:8px 18px; border-bottom:0.5px solid var(--border); color:var(--text-secondary); font-size:12px;">
20+ <span style="color:var(--text-primary); border-bottom:1.5px solid var(--text-primary); padding-bottom:4px;">log</span>
21+ <span style="text-decoration:underline;">files</span>
22+ <span style="text-decoration:underline;">threads 3</span>
23+ <span style="text-decoration:underline;">runs</span>
24+ <span style="text-decoration:underline;">config</span>
25+ <span style="margin-left:auto; color:var(--text-muted);">newest first</span>
26+ </div>
27+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
28+ <div style="display:flex; justify-content:space-between;"><span>v1.2.0</span><span style="color:var(--text-muted); font-size:12px;">john · 3d</span></div><div style="color:var(--text-muted); font-size:12px;">fixes the empty config panic. thanks lisa.</div><div style="margin-top:6px;"><div style="color:var(--text-muted); font-size:12px;">johnbot-linux-amd64 · 8.1mb · johnbot-darwin-arm64 · 7.9mb</div></div>
29+ </div>
30+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
31+ <div style="display:flex; justify-content:space-between;"><span>v1.1.0</span><span style="color:var(--text-muted); font-size:12px;">john · 2mo</span></div><div style="color:var(--text-muted); font-size:12px;">reconnect handling</div><div style="margin-top:6px;"><div style="color:var(--text-muted); font-size:12px;">johnbot-linux-amd64 · 8.0mb</div></div>
32+ </div>
33+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
34+ <div style="color:var(--text-muted); font-size:12px;">release notes clone with the repository. attached files do not.</div>
35+ </div>
36+
37+ <div style="position:sticky; bottom:0; margin-top:auto; background:#ffffff; border-top:0.5px solid var(--border); border-radius:0 0 12px 12px; padding:9px 18px; display:flex; justify-content:space-between; color:var(--text-muted); font-size:12px;">
38+ <span>john / johnbot</span><span>barerepo 0.1.0</span>
39+ </div>
40+
41+ </div>
42+
43+ </div>
44+ </body>
45+ </html>
@@ -0,0 +1,47 @@
1+ <!doctype html>
2+ <html lang="en">
3+ <head>
4+ <meta charset="utf-8">
5+ <meta name="viewport" content="width=device-width, initial-scale=1">
6+ <title>barerepo · a3f9c2</title>
7+ <link rel="stylesheet" href="tokens.css">
8+ </head>
9+ <body>
10+ <div class="wrap">
11+
12+ <div style="font-family: var(--font-mono); font-size: 13px; line-height: 1.6; color: var(--text-primary); background: #ffffff; border: 0.5px solid var(--border); border-radius: 12px; min-height: calc(100vh - 26px); display: flex; flex-direction: column;">
13+
14+ <h2 class="sr-only">Single commit page showing message, metadata and full diff.</h2>
15+ <div style="display:flex; align-items:center; justify-content:space-between; padding:9px 18px; border-bottom:0.5px solid var(--border);">
16+ <div style="color:var(--text-secondary);">john / johnbot / <span style="color:var(--text-primary);">a3f9c2</span></div>
17+ <div style="color:var(--text-muted); font-size:12px;">lisa · 2h · 1 file · +4 -1</div>
18+ </div>
19+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
20+ <div>fix panic when config is empty</div><div style="color:var(--text-muted); font-size:12px;">empty config.toml hit a nil deref on load. return defaults instead.</div>
21+ </div>
22+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
23+ <div style="color:var(--text-muted); font-size:12px;">config.go</div><div style="border:0.5px solid var(--border); border-radius:8px; overflow:hidden; font-size:12px; margin-top:8px;">
24+ <div style="padding:3px 11px; color:var(--text-muted); background:var(--surface-1); border-bottom:0.5px solid var(--border);">@@ -41,7 +41,10 @@ func Load</div>
25+ <div style="padding:2px 11px; color:var(--text-secondary);"><span style="display:inline-block; width:30px; margin-right:9px; text-align:right; color:var(--text-muted); text-decoration:underline;">41</span>&nbsp;&nbsp;f, err := os.Open(path)</div>
26+ <div style="padding:2px 11px; background:var(--bg-danger); color:var(--text-danger);"><span style="display:inline-block; width:30px; margin-right:9px; text-align:right; color:var(--text-muted); ">&nbsp;</span>-&nbsp;return cfg</div>
27+ <div style="padding:2px 11px; background:var(--bg-success); color:var(--text-success);"><span style="display:inline-block; width:30px; margin-right:9px; text-align:right; color:var(--text-muted); text-decoration:underline;">42</span>+&nbsp;if len(raw) == 0 {</div>
28+ <div style="padding:2px 11px; background:var(--bg-success); color:var(--text-success);"><span style="display:inline-block; width:30px; margin-right:9px; text-align:right; color:var(--text-muted); text-decoration:underline;">43</span>+&nbsp;&nbsp;&nbsp;return Default(), nil</div>
29+ <div style="padding:2px 11px; background:var(--bg-success); color:var(--text-success);"><span style="display:inline-block; width:30px; margin-right:9px; text-align:right; color:var(--text-muted); text-decoration:underline;">44</span>+&nbsp;}</div>
30+ <div style="padding:2px 11px; background:var(--bg-success); color:var(--text-success);"><span style="display:inline-block; width:30px; margin-right:9px; text-align:right; color:var(--text-muted); text-decoration:underline;">45</span>+&nbsp;return cfg, nil</div>
31+ <div style="padding:2px 11px; color:var(--text-secondary);"><span style="display:inline-block; width:30px; margin-right:9px; text-align:right; color:var(--text-muted); text-decoration:underline;">46</span>&nbsp;&nbsp;}</div>
32+ </div>
33+
34+ </div>
35+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
36+ <div style="color:var(--text-muted); font-size:12px;">reachable from master · closed thread 47 · build ok on uproar.local</div>
37+ </div>
38+
39+ <div style="position:sticky; bottom:0; margin-top:auto; background:#ffffff; border-top:0.5px solid var(--border); border-radius:0 0 12px 12px; padding:9px 18px; display:flex; justify-content:space-between; color:var(--text-muted); font-size:12px;">
40+ <span>parent 8b1d44</span><span>barerepo 0.1.0</span>
41+ </div>
42+
43+ </div>
44+
45+ </div>
46+ </body>
47+ </html>
@@ -0,0 +1,57 @@
1+ <!doctype html>
2+ <html lang="en">
3+ <head>
4+ <meta charset="utf-8">
5+ <meta name="viewport" content="width=device-width, initial-scale=1">
6+ <title>barerepo · compare</title>
7+ <link rel="stylesheet" href="tokens.css">
8+ </head>
9+ <body>
10+ <div class="wrap">
11+
12+ <div style="font-family: var(--font-mono); font-size: 13px; line-height: 1.6; color: var(--text-primary); background: #ffffff; border: 0.5px solid var(--border); border-radius: 12px; min-height: calc(100vh - 26px); display: flex; flex-direction: column;">
13+
14+ <h2 class="sr-only">Compare view between two arbitrary refs showing combined diff stats.</h2>
15+ <div style="display:flex; align-items:center; justify-content:space-between; padding:9px 18px; border-bottom:0.5px solid var(--border);">
16+ <div style="color:var(--text-secondary);">john / johnbot / <span style="color:var(--text-primary);">compare</span></div>
17+ <div style="color:var(--text-muted); font-size:12px;">master...refs/proposals/47</div>
18+ </div>
19+ <div style="display:flex; gap:22px; padding:8px 18px; border-bottom:0.5px solid var(--border); color:var(--text-secondary); font-size:12px;">
20+ <span style="color:var(--text-primary); border-bottom:1.5px solid var(--text-primary); padding-bottom:4px;">log</span>
21+ <span style="text-decoration:underline;">files</span>
22+ <span style="text-decoration:underline;">threads 3</span>
23+ <span style="text-decoration:underline;">runs</span>
24+ <span style="text-decoration:underline;">config</span>
25+ <span style="margin-left:auto; color:var(--text-muted);">jump to file <span style="border:0.5px solid var(--border); border-radius:4px; padding:0 5px;">t</span></span>
26+ </div>
27+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
28+ <div style="display:flex; gap:10px; align-items:center;"><div style="background:var(--surface-1); border:0.5px solid var(--border); border-radius:8px; padding:9px 12px; color:var(--text-secondary); font-size:12px; word-break:break-all;">master</div><span style="color:var(--text-muted);">...</span><div style="background:var(--surface-1); border:0.5px solid var(--border); border-radius:8px; padding:9px 12px; color:var(--text-secondary); font-size:12px; word-break:break-all;">refs/proposals/47</div></div><div style="margin-top:8px;"><div style="color:var(--text-muted); font-size:12px;">3 commits · 2 files · +81 -12 · no conflicts</div></div>
29+ </div>
30+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
31+ <div style="color:var(--text-muted); font-size:12px;">config.go · +7 -1</div><div style="border:0.5px solid var(--border); border-radius:8px; overflow:hidden; font-size:12px; margin-top:8px;">
32+ <div style="padding:3px 11px; color:var(--text-muted); background:var(--surface-1); border-bottom:0.5px solid var(--border);">@@ -41,7 +41,10 @@ func Load</div>
33+ <div style="padding:2px 11px; background:var(--bg-danger); color:var(--text-danger);"><span style="display:inline-block; width:30px; margin-right:9px; text-align:right; color:var(--text-muted); ">&nbsp;</span>-&nbsp;return cfg</div>
34+ <div style="padding:2px 11px; background:var(--bg-success); color:var(--text-success);"><span style="display:inline-block; width:30px; margin-right:9px; text-align:right; color:var(--text-muted); text-decoration:underline;">41</span>+&nbsp;if len(raw) == 0 {</div>
35+ <div style="padding:2px 11px; background:var(--bg-success); color:var(--text-success);"><span style="display:inline-block; width:30px; margin-right:9px; text-align:right; color:var(--text-muted); text-decoration:underline;">42</span>+&nbsp;&nbsp;&nbsp;return Default(), nil</div>
36+ <div style="padding:2px 11px; background:var(--bg-success); color:var(--text-success);"><span style="display:inline-block; width:30px; margin-right:9px; text-align:right; color:var(--text-muted); text-decoration:underline;">43</span>+&nbsp;}</div>
37+ </div>
38+
39+ </div>
40+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
41+ <div style="color:var(--text-muted); font-size:12px;">config_test.go · +74 -11</div><div style="border:0.5px solid var(--border); border-radius:8px; overflow:hidden; font-size:12px; margin-top:8px;">
42+ <div style="padding:3px 11px; color:var(--text-muted); background:var(--surface-1); border-bottom:0.5px solid var(--border);">@@ -12,4 +12,18 @@ func TestLoad</div>
43+ <div style="padding:2px 11px; background:var(--bg-success); color:var(--text-success);"><span style="display:inline-block; width:30px; margin-right:9px; text-align:right; color:var(--text-muted); text-decoration:underline;">12</span>+&nbsp;func TestLoadEmpty(t *testing.T) {</div>
44+ <div style="padding:2px 11px; color:var(--text-muted); text-align:center;">70 more added lines</div>
45+ </div>
46+
47+ </div>
48+
49+ <div style="position:sticky; bottom:0; margin-top:auto; background:#ffffff; border-top:0.5px solid var(--border); border-radius:0 0 12px 12px; padding:9px 18px; display:flex; justify-content:space-between; color:var(--text-muted); font-size:12px;">
50+ <span>master...refs/proposals/47</span><span>barerepo 0.1.0</span>
51+ </div>
52+
53+ </div>
54+
55+ </div>
56+ </body>
57+ </html>
@@ -0,0 +1,59 @@
1+ <!doctype html>
2+ <html lang="en">
3+ <head>
4+ <meta charset="utf-8">
5+ <meta name="viewport" content="width=device-width, initial-scale=1">
6+ <title>forge · .barerepo/config</title>
7+ <link rel="stylesheet" href="tokens.css">
8+ </head>
9+ <body>
10+ <div class="wrap">
11+
12+ <div style="font-family: var(--font-mono); font-size: 13px; line-height: 1.6; color: var(--text-primary); background: #ffffff; border: 0.5px solid var(--border); border-radius: 12px; min-height: calc(100vh - 26px); display: flex; flex-direction: column;">
13+
14+ <h2 class="sr-only">Repository settings shown as a versioned file in the repository rather than a settings form.</h2>
15+ <div style="display:flex; align-items:center; justify-content:space-between; padding:9px 18px; border-bottom:0.5px solid var(--border);">
16+ <div style="color:var(--text-secondary);">john / johnbot / <span style="color:var(--text-primary);">.barerepo/config</span></div>
17+ <div style="color:var(--text-muted); font-size:12px;">edited 3d by john · a3f9c2</div>
18+ </div>
19+ <div style="display:flex; gap:22px; padding:8px 18px; border-bottom:0.5px solid var(--border); color:var(--text-secondary); font-size:12px;">
20+ <span style="text-decoration:underline;">log</span>
21+ <span style="text-decoration:underline;">files</span>
22+ <span style="text-decoration:underline;">threads 3</span>
23+ <span style="text-decoration:underline;">runs</span>
24+ <span style="color:var(--text-primary); border-bottom:1.5px solid var(--text-primary); padding-bottom:4px;">config</span>
25+ <span style="margin-left:auto; color:var(--text-muted);">jump to file <span style="border:0.5px solid var(--border); border-radius:4px; padding:0 5px;">t</span></span>
26+ </div>
27+ <div style="padding:14px 18px; border-bottom:0.5px solid var(--border); font-size:12px; line-height:1.85;">
28+ <div style="color:var(--text-muted);">[repo]</div>
29+ <div>default_branch = <span style="color:var(--text-secondary);">"master"</span></div>
30+ <div>visibility &nbsp;&nbsp;&nbsp;= <span style="color:var(--text-secondary);">"public"</span></div>
31+ <div>archived &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;= <span style="color:var(--text-secondary);">false</span></div>
32+ <div style="height:9px;"></div>
33+ <div style="color:var(--text-muted);">[proposals]</div>
34+ <div>accept_from &nbsp;= <span style="color:var(--text-secondary);">"anyone"</span></div>
35+ <div>require_runs = <span style="color:var(--text-secondary);">["build", "test"]</span></div>
36+ <div style="height:9px;"></div>
37+ <div style="color:var(--text-muted);">[access]</div>
38+ <div>push &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;= <span style="color:var(--text-secondary);">["john", "lisa"]</span></div>
39+ <div>allow_force_push = <span style="color:var(--text-secondary);">[]</span></div>
40+ <div style="height:9px;"></div>
41+ <div style="color:var(--text-muted);">[runners]</div>
42+ <div>"uproar.local" = <span style="color:var(--text-secondary);">["build", "test"]</span></div>
43+ </div>
44+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
45+ <div style="color:var(--text-muted); font-size:12px;">edit the file, commit, push. there is no settings form. <span style="color:var(--text-secondary);">git log -p .barerepo/config</span> shows who changed what.</div>
46+ </div>
47+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
48+ <div style="color:var(--text-muted); font-size:12px;">raw file content is served from this host as a download. private files have no raw link until the operator sets a separate raw host.</div>
49+ </div>
50+
51+ <div style="position:sticky; bottom:0; margin-top:auto; background:#ffffff; border-top:0.5px solid var(--border); border-radius:0 0 12px 12px; padding:9px 18px; display:flex; justify-content:space-between; color:var(--text-muted); font-size:12px;">
52+ <span><span style="text-decoration:underline;">history</span> · <span style="text-decoration:underline;">blame</span> · <span style="text-decoration:underline;">raw</span></span><span>barerepo 0.1.0</span>
53+ </div>
54+
55+ </div>
56+
57+ </div>
58+ </body>
59+ </html>
@@ -0,0 +1,37 @@
1+ <!doctype html>
2+ <html lang="en">
3+ <head>
4+ <meta charset="utf-8">
5+ <meta name="viewport" content="width=device-width, initial-scale=1">
6+ <title>barerepo · johnbot</title>
7+ <link rel="stylesheet" href="tokens.css">
8+ </head>
9+ <body>
10+ <div class="wrap">
11+
12+ <div style="font-family: var(--font-mono); font-size: 13px; line-height: 1.6; color: var(--text-primary); background: #ffffff; border: 0.5px solid var(--border); border-radius: 12px; min-height: calc(100vh - 26px); display: flex; flex-direction: column;">
13+
14+ <h2 class="sr-only">Empty repository page showing a single block of shell commands to paste.</h2>
15+ <div style="display:flex; align-items:center; justify-content:space-between; padding:9px 18px; border-bottom:0.5px solid var(--border);">
16+ <div style="color:var(--text-secondary);">john / <span style="color:var(--text-primary);">johnbot</span></div>
17+ <div style="color:var(--text-muted); font-size:12px;">empty · master</div>
18+ </div>
19+ <div style="padding:20px 18px;">
20+ <div style="color:var(--text-muted); font-size:12px; margin-bottom:8px;">paste this</div>
21+ <div style="background:var(--surface-1); border:0.5px solid var(--border); border-radius:8px; padding:11px 13px; color:var(--text-secondary); font-size:12px; line-height:1.9;">git init<br>git remote add origin git@forge:john/johnbot<br>mkdir -p .forge &amp;&amp; printf '[repo]\nvisibility = "public"\n' &gt; .barerepo/config<br>git add -A<br>git commit -m "first"<br>git push -u origin master</div>
22+ <div style="color:var(--text-muted); font-size:12px; margin-top:10px;">the third line is what makes it public. without it the repository stays private, because there is nowhere else to keep that.</div>
23+ <div style="color:var(--text-muted); font-size:12px; margin-top:14px; line-height:1.9;">
24+ <div>already have a repo somewhere?</div>
25+ <div style="color:var(--text-secondary);">git remote set-url origin git@barerepo:john/johnbot &amp;&amp; git push --all</div>
26+ </div>
27+ </div>
28+
29+ <div style="position:sticky; bottom:0; margin-top:auto; background:#ffffff; border-top:0.5px solid var(--border); border-radius:0 0 12px 12px; padding:9px 18px; display:flex; justify-content:space-between; color:var(--text-muted); font-size:12px;">
30+ <span>john / johnbot</span><span>barerepo 0.1.0</span>
31+ </div>
32+
33+ </div>
34+
35+ </div>
36+ </body>
37+ </html>
@@ -0,0 +1,47 @@
1+ <!doctype html>
2+ <html lang="en">
3+ <head>
4+ <meta charset="utf-8">
5+ <meta name="viewport" content="width=device-width, initial-scale=1">
6+ <title>barerepo · config.go</title>
7+ <link rel="stylesheet" href="tokens.css">
8+ </head>
9+ <body>
10+ <div class="wrap">
11+
12+ <div style="font-family: var(--font-mono); font-size: 13px; line-height: 1.6; color: var(--text-primary); background: #ffffff; border: 0.5px solid var(--border); border-radius: 12px; min-height: calc(100vh - 26px); display: flex; flex-direction: column;">
13+
14+ <h2 class="sr-only">File view with blame information shown in the left gutter beside each line.</h2>
15+ <div style="display:flex; align-items:center; justify-content:space-between; padding:9px 18px; border-bottom:0.5px solid var(--border);">
16+ <div style="color:var(--text-secondary);">john / johnbot / <span style="color:var(--text-primary);">config.go</span></div>
17+ <div style="color:var(--text-muted); font-size:12px;">61 lines · 1.4kb · master</div>
18+ </div>
19+ <div style="display:flex; gap:22px; padding:8px 18px; border-bottom:0.5px solid var(--border); color:var(--text-secondary); font-size:12px;">
20+ <span style="text-decoration:underline;">log</span>
21+ <span style="color:var(--text-primary); border-bottom:1.5px solid var(--text-primary); padding-bottom:4px;">files</span>
22+ <span style="text-decoration:underline;">threads 3</span>
23+ <span style="text-decoration:underline;">runs</span>
24+ <span style="text-decoration:underline;">config</span>
25+ <span style="margin-left:auto; color:var(--text-muted);">jump to file <span style="border:0.5px solid var(--border); border-radius:4px; padding:0 5px;">t</span></span>
26+ </div>
27+ <div style="padding:12px 0; font-size:12px;">
28+ <div style="display:flex; gap:0;"><div style="width:150px; flex-shrink:0; color:var(--text-muted); padding:1px 10px 1px 18px; border-right:0.5px solid var(--border); white-space:nowrap; overflow:hidden;">a3f9c2 lisa 2h</div><div style="width:34px; flex-shrink:0; color:var(--text-muted); text-align:right; padding:1px 8px;">41</div><div style="padding:1px 10px; color:var(--text-primary);">&nbsp;&nbsp;f, err := os.Open(path)</div></div>
29+ <div style="display:flex; gap:0;"><div style="width:150px; flex-shrink:0; color:var(--text-muted); padding:1px 10px 1px 18px; border-right:0.5px solid var(--border); white-space:nowrap; overflow:hidden;">a3f9c2 lisa 2h</div><div style="width:34px; flex-shrink:0; color:var(--text-muted); text-align:right; padding:1px 8px;">42</div><div style="padding:1px 10px; color:var(--text-primary);">&nbsp;&nbsp;if err != nil {</div></div>
30+ <div style="display:flex; gap:0;"><div style="width:150px; flex-shrink:0; color:var(--text-muted); padding:1px 10px 1px 18px; border-right:0.5px solid var(--border); white-space:nowrap; overflow:hidden;">a3f9c2 lisa 2h</div><div style="width:34px; flex-shrink:0; color:var(--text-muted); text-align:right; padding:1px 8px;">43</div><div style="padding:1px 10px; color:var(--text-primary);">&nbsp;&nbsp;&nbsp;&nbsp;return nil, err</div></div>
31+ <div style="display:flex; gap:0;"><div style="width:150px; flex-shrink:0; color:var(--text-muted); padding:1px 10px 1px 18px; border-right:0.5px solid var(--border); white-space:nowrap; overflow:hidden;">a3f9c2 lisa 2h</div><div style="width:34px; flex-shrink:0; color:var(--text-muted); text-align:right; padding:1px 8px;">44</div><div style="padding:1px 10px; color:var(--text-primary);">&nbsp;&nbsp;}</div></div>
32+ <div style="display:flex; gap:0;"><div style="width:150px; flex-shrink:0; color:var(--text-muted); padding:1px 10px 1px 18px; border-right:0.5px solid var(--border); white-space:nowrap; overflow:hidden;">a3f9c2 lisa 2h</div><div style="width:34px; flex-shrink:0; color:var(--text-muted); text-align:right; padding:1px 8px;">45</div><div style="padding:1px 10px; color:var(--text-primary);">&nbsp;&nbsp;if len(raw) == 0 {</div></div>
33+ <div style="display:flex; gap:0;"><div style="width:150px; flex-shrink:0; color:var(--text-muted); padding:1px 10px 1px 18px; border-right:0.5px solid var(--border); white-space:nowrap; overflow:hidden;">a3f9c2 lisa 2h</div><div style="width:34px; flex-shrink:0; color:var(--text-muted); text-align:right; padding:1px 8px;">46</div><div style="padding:1px 10px; color:var(--text-primary);">&nbsp;&nbsp;&nbsp;&nbsp;return Default(), nil</div></div>
34+ <div style="display:flex; gap:0;"><div style="width:150px; flex-shrink:0; color:var(--text-muted); padding:1px 10px 1px 18px; border-right:0.5px solid var(--border); white-space:nowrap; overflow:hidden;">a3f9c2 lisa 2h</div><div style="width:34px; flex-shrink:0; color:var(--text-muted); text-align:right; padding:1px 8px;">47</div><div style="padding:1px 10px; color:var(--text-primary);">&nbsp;&nbsp;}</div></div>
35+ <div style="display:flex; gap:0;"><div style="width:150px; flex-shrink:0; color:var(--text-muted); padding:1px 10px 1px 18px; border-right:0.5px solid var(--border); white-space:nowrap; overflow:hidden;">7c1e08 john 4mo</div><div style="width:34px; flex-shrink:0; color:var(--text-muted); text-align:right; padding:1px 8px;">48</div><div style="padding:1px 10px; color:var(--text-primary);">&nbsp;&nbsp;return cfg, nil</div></div>
36+ <div style="display:flex; gap:0;"><div style="width:150px; flex-shrink:0; color:var(--text-muted); padding:1px 10px 1px 18px; border-right:0.5px solid var(--border); white-space:nowrap; overflow:hidden;">7c1e08 john 4mo</div><div style="width:34px; flex-shrink:0; color:var(--text-muted); text-align:right; padding:1px 8px;">49</div><div style="padding:1px 10px; color:var(--text-primary);">}</div></div>
37+ </div>
38+
39+ <div style="position:sticky; bottom:0; margin-top:auto; background:#ffffff; border-top:0.5px solid var(--border); border-radius:0 0 12px 12px; padding:9px 18px; display:flex; justify-content:space-between; color:var(--text-muted); font-size:12px;">
40+ <span>history · raw</span><span>barerepo 0.1.0</span>
41+ </div>
42+
43+ </div>
44+
45+ </div>
46+ </body>
47+ </html>
@@ -0,0 +1,57 @@
1+ <!doctype html>
2+ <html lang="en">
3+ <head>
4+ <meta charset="utf-8">
5+ <meta name="viewport" content="width=device-width, initial-scale=1">
6+ <title>barerepo · johnbot files</title>
7+ <link rel="stylesheet" href="tokens.css">
8+ </head>
9+ <body>
10+ <div class="wrap">
11+
12+ <div style="font-family: var(--font-mono); font-size: 13px; line-height: 1.6; color: var(--text-primary); background: #ffffff; border: 0.5px solid var(--border); border-radius: 12px; min-height: calc(100vh - 26px); display: flex; flex-direction: column;">
13+
14+ <h2 class="sr-only">Repository file tree listing directories and files with last commit information.</h2>
15+ <div style="display:flex; align-items:center; justify-content:space-between; padding:9px 18px; border-bottom:0.5px solid var(--border);">
16+ <div style="color:var(--text-secondary);">john / <span style="color:var(--text-primary);">johnbot</span></div>
17+ <div style="color:var(--text-muted); font-size:12px;">master · clone <span style="color:var(--text-secondary);">git@barerepo:john/johnbot</span></div>
18+ </div>
19+ <div style="display:flex; gap:22px; padding:8px 18px; border-bottom:0.5px solid var(--border); color:var(--text-secondary); font-size:12px;">
20+ <span style="text-decoration:underline;">log</span>
21+ <span style="color:var(--text-primary); border-bottom:1.5px solid var(--text-primary); padding-bottom:4px;">files</span>
22+ <span style="text-decoration:underline;">threads 3</span>
23+ <span style="text-decoration:underline;">runs</span>
24+ <span style="text-decoration:underline;">config</span>
25+ <span style="margin-left:auto; color:var(--text-muted);">jump to file <span style="border:0.5px solid var(--border); border-radius:4px; padding:0 5px;">t</span></span>
26+ </div>
27+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
28+ <div style="display:flex; justify-content:space-between;"><span>irc/</span><span style="color:var(--text-muted); font-size:12px;">john · 6h</span></div><div style="color:var(--text-muted); font-size:12px;">drop the retry loop, it never fired</div>
29+ </div>
30+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
31+ <div style="display:flex; justify-content:space-between;"><span>cmd/</span><span style="color:var(--text-muted); font-size:12px;">john · 2d</span></div><div style="color:var(--text-muted); font-size:12px;">split the daemon out</div>
32+ </div>
33+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
34+ <div style="display:flex; justify-content:space-between;"><span>config.go</span><span style="color:var(--text-muted); font-size:12px;">lisa · 2h</span></div><div style="color:var(--text-muted); font-size:12px;">fix panic when config is empty</div>
35+ </div>
36+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
37+ <div style="display:flex; justify-content:space-between;"><span>config_test.go</span><span style="color:var(--text-muted); font-size:12px;">lisa · 2h</span></div><div style="color:var(--text-muted); font-size:12px;">cover the empty case</div>
38+ </div>
39+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
40+ <div style="display:flex; justify-content:space-between;"><span>go.mod</span><span style="color:var(--text-muted); font-size:12px;">dave · 3d</span></div><div style="color:var(--text-muted); font-size:12px;">bump deps</div>
41+ </div>
42+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
43+ <div style="display:flex; justify-content:space-between;"><span>.barerepo/config</span><span style="color:var(--text-muted); font-size:12px;">john · 3d</span></div><div style="color:var(--text-muted); font-size:12px;">add lisa to push</div>
44+ </div>
45+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
46+ <div style="display:flex; justify-content:space-between;"><span>README</span><span style="color:var(--text-muted); font-size:12px;">john · 4mo</span></div><div style="color:var(--text-muted); font-size:12px;">it is an irc bot</div>
47+ </div>
48+
49+ <div style="position:sticky; bottom:0; margin-top:auto; background:#ffffff; border-top:0.5px solid var(--border); border-radius:0 0 12px 12px; padding:9px 18px; display:flex; justify-content:space-between; color:var(--text-muted); font-size:12px;">
50+ <span>master</span><span>barerepo 0.1.0</span>
51+ </div>
52+
53+ </div>
54+
55+ </div>
56+ </body>
57+ </html>
@@ -0,0 +1,61 @@
1+ <!doctype html>
2+ <html lang="en">
3+ <head>
4+ <meta charset="utf-8">
5+ <meta name="viewport" content="width=device-width, initial-scale=1">
6+ <title>barerepo · johnbot</title>
7+ <link rel="stylesheet" href="tokens.css">
8+ </head>
9+ <body>
10+ <div class="wrap">
11+
12+ <div style="font-family: var(--font-mono); font-size: 13px; line-height: 1.6; color: var(--text-primary); background: #ffffff; border: 0.5px solid var(--border); border-radius: 12px; min-height: calc(100vh - 26px); display: flex; flex-direction: column;">
13+
14+ <h2 class="sr-only">Repository log with every commit diff expanded inline. This is the landing page.</h2>
15+ <div style="display:flex; align-items:center; justify-content:space-between; padding:9px 18px; border-bottom:0.5px solid var(--border);">
16+ <div style="color:var(--text-secondary);">john / <span style="color:var(--text-primary);">johnbot</span></div>
17+ <div style="color:var(--text-muted); font-size:12px;">master · clone <span style="color:var(--text-secondary);">git@barerepo:john/johnbot</span></div>
18+ </div>
19+ <div style="display:flex; gap:22px; padding:8px 18px; border-bottom:0.5px solid var(--border); color:var(--text-secondary); font-size:12px;">
20+ <span style="color:var(--text-primary); border-bottom:1.5px solid var(--text-primary); padding-bottom:4px;">log</span>
21+ <span style="text-decoration:underline;">files</span>
22+ <span style="text-decoration:underline;">threads 3</span>
23+ <span style="text-decoration:underline;">runs</span>
24+ <span style="text-decoration:underline;">config</span>
25+ <span style="margin-left:auto; color:var(--text-muted);">jump to file <span style="border:0.5px solid var(--border); border-radius:4px; padding:0 5px;">t</span></span>
26+ </div>
27+ <div style="padding:13px 18px; border-bottom:0.5px solid var(--border);">
28+ <div style="display:flex; justify-content:space-between;"><span>a3f9c2&nbsp;&nbsp;fix panic when config is empty</span><span style="color:var(--text-muted); font-size:12px;">lisa · 2h</span></div><div style="color:var(--text-muted); font-size:12px;">config.go · +4 -1</div><div style="border:0.5px solid var(--border); border-radius:8px; overflow:hidden; font-size:12px; margin-top:8px;">
29+ <div style="padding:3px 11px; color:var(--text-muted); background:var(--surface-1); border-bottom:0.5px solid var(--border);">@@ -41,7 +41,10 @@ func Load</div>
30+ <div style="padding:2px 11px; color:var(--text-secondary);"><span style="display:inline-block; width:30px; margin-right:9px; text-align:right; color:var(--text-muted); text-decoration:underline;">41</span>&nbsp;&nbsp;f, err := os.Open(path)</div>
31+ <div style="padding:2px 11px; background:var(--bg-danger); color:var(--text-danger);"><span style="display:inline-block; width:30px; margin-right:9px; text-align:right; color:var(--text-muted); ">&nbsp;</span>-&nbsp;return cfg</div>
32+ <div style="padding:2px 11px; background:var(--bg-success); color:var(--text-success);"><span style="display:inline-block; width:30px; margin-right:9px; text-align:right; color:var(--text-muted); text-decoration:underline;">42</span>+&nbsp;if len(raw) == 0 {</div>
33+ <div style="padding:2px 11px; background:var(--bg-success); color:var(--text-success);"><span style="display:inline-block; width:30px; margin-right:9px; text-align:right; color:var(--text-muted); text-decoration:underline;">43</span>+&nbsp;&nbsp;&nbsp;return Default(), nil</div>
34+ <div style="padding:2px 11px; background:var(--bg-success); color:var(--text-success);"><span style="display:inline-block; width:30px; margin-right:9px; text-align:right; color:var(--text-muted); text-decoration:underline;">44</span>+&nbsp;}</div>
35+ <div style="padding:2px 11px; background:var(--bg-success); color:var(--text-success);"><span style="display:inline-block; width:30px; margin-right:9px; text-align:right; color:var(--text-muted); text-decoration:underline;">45</span>+&nbsp;return cfg, nil</div>
36+ <div style="padding:2px 11px; color:var(--text-secondary);"><span style="display:inline-block; width:30px; margin-right:9px; text-align:right; color:var(--text-muted); text-decoration:underline;">46</span>&nbsp;&nbsp;}</div>
37+ </div>
38+
39+ </div>
40+ <div style="padding:13px 18px; border-bottom:0.5px solid var(--border);">
41+ <div style="display:flex; justify-content:space-between;"><span>8b1d44&nbsp;&nbsp;drop the retry loop, it never fired</span><span style="color:var(--text-muted); font-size:12px;">john · 6h</span></div><div style="color:var(--text-muted); font-size:12px;">irc/conn.go · +0 -23</div><div style="border:0.5px solid var(--border); border-radius:8px; overflow:hidden; font-size:12px; margin-top:8px;">
42+ <div style="padding:3px 11px; color:var(--text-muted); background:var(--surface-1); border-bottom:0.5px solid var(--border);">@@ -88,23 +88,0 @@ func (c *Conn) dial</div>
43+ <div style="padding:2px 11px; background:var(--bg-danger); color:var(--text-danger);"><span style="display:inline-block; width:30px; margin-right:9px; text-align:right; color:var(--text-muted); ">&nbsp;</span>-&nbsp;for i := 0; i &lt; maxRetry; i++ {</div>
44+ <div style="padding:2px 11px; background:var(--bg-danger); color:var(--text-danger);"><span style="display:inline-block; width:30px; margin-right:9px; text-align:right; color:var(--text-muted); ">&nbsp;</span>-&nbsp;&nbsp;&nbsp;time.Sleep(backoff(i))</div>
45+ <div style="padding:2px 11px; color:var(--text-muted); text-align:center;">18 more removed lines</div>
46+ </div>
47+
48+ </div>
49+ <div style="padding:13px 18px; border-bottom:0.5px solid var(--border);">
50+ <div style="display:flex; justify-content:space-between;"><span>dave&nbsp;&nbsp;merged proposal 45, bump deps</span><span style="color:var(--text-muted); font-size:12px;">14 files · +302 -288</span></div><div style="color:var(--text-muted); font-size:12px;">3d · large diff collapsed · <span style="text-decoration:underline;">expand</span></div>
51+ </div>
52+
53+ <div style="position:sticky; bottom:0; margin-top:auto; background:#ffffff; border-top:0.5px solid var(--border); border-radius:0 0 12px 12px; padding:9px 18px; display:flex; justify-content:space-between; color:var(--text-muted); font-size:12px;">
54+ <span><span style="text-decoration:underline;">older</span></span><span>barerepo 0.1.0</span>
55+ </div>
56+
57+ </div>
58+
59+ </div>
60+ </body>
61+ </html>
@@ -0,0 +1,42 @@
1+ <!doctype html>
2+ <html lang="en">
3+ <head>
4+ <meta charset="utf-8">
5+ <meta name="viewport" content="width=device-width, initial-scale=1">
6+ <title>barerepo · run e91b7d</title>
7+ <link rel="stylesheet" href="tokens.css">
8+ </head>
9+ <body>
10+ <div class="wrap">
11+
12+ <div style="font-family: var(--font-mono); font-size: 13px; line-height: 1.6; color: var(--text-primary); background: #ffffff; border: 0.5px solid var(--border); border-radius: 12px; min-height: calc(100vh - 26px); display: flex; flex-direction: column;">
13+
14+ <h2 class="sr-only">Run detail page showing raw build log output as plain scrollable text.</h2>
15+ <div style="display:flex; align-items:center; justify-content:space-between; padding:9px 18px; border-bottom:0.5px solid var(--border);">
16+ <div style="color:var(--text-secondary);">john / johnbot / <span style="color:var(--text-primary);">run e91b7d</span></div>
17+ <div style="color:var(--text-muted); font-size:12px;">uproar.local · 6s · 1d ago</div>
18+ </div>
19+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
20+ <div><span style="color:var(--text-danger);">build failed</span> · exit 2</div><div style="color:var(--text-muted); font-size:12px;">refs/proposals/46 · switch to pure-go sqlite · mark</div>
21+ </div>
22+ <div style="padding:12px 18px; font-size:12px; line-height:1.75; color:var(--text-secondary);">
23+ <div style="color:var(--text-muted);">$ go build ./...</div>
24+ <div>go: downloading modernc.org/sqlite v1.34.1</div>
25+ <div>go: downloading modernc.org/libc v1.55.3</div>
26+ <div style="color:var(--text-danger);">store/db.go:14:2: cannot find module providing package github.com/mattn/go-sqlite3</div>
27+ <div style="color:var(--text-danger);">exit status 2</div>
28+ <div style="color:var(--text-muted); margin-top:10px;">$ exit 2</div>
29+ </div>
30+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
31+ <div style="color:var(--text-muted); font-size:12px;"><span style="text-decoration:underline;">raw log</span> · <span style="text-decoration:underline;">rerun</span> · 18kb</div>
32+ </div>
33+
34+ <div style="position:sticky; bottom:0; margin-top:auto; background:#ffffff; border-top:0.5px solid var(--border); border-radius:0 0 12px 12px; padding:9px 18px; display:flex; justify-content:space-between; color:var(--text-muted); font-size:12px;">
35+ <span>refs/proposals/46</span><span>barerepo 0.1.0</span>
36+ </div>
37+
38+ </div>
39+
40+ </div>
41+ </body>
42+ </html>
@@ -0,0 +1,42 @@
1+ <!doctype html>
2+ <html lang="en">
3+ <head>
4+ <meta charset="utf-8">
5+ <meta name="viewport" content="width=device-width, initial-scale=1">
6+ <title>barerepo · add a runner</title>
7+ <link rel="stylesheet" href="tokens.css">
8+ </head>
9+ <body>
10+ <div class="wrap">
11+
12+ <div style="font-family: var(--font-mono); font-size: 13px; line-height: 1.6; color: var(--text-primary); background: #ffffff; border: 0.5px solid var(--border); border-radius: 12px; min-height: calc(100vh - 26px); display: flex; flex-direction: column;">
13+
14+ <h2 class="sr-only">Runner setup page showing one paste-ready command per operating system with the token already embedded.</h2>
15+ <div style="display:flex; align-items:center; justify-content:space-between; padding:9px 18px; border-bottom:0.5px solid var(--border);">
16+ <div style="color:var(--text-secondary);">john / johnbot / <span style="color:var(--text-primary);">add a runner</span></div>
17+ <div style="color:var(--text-muted); font-size:12px;">rt_live_7Kq2mXe</div>
18+ </div>
19+ <div style="padding:18px;">
20+ <div style="color:var(--text-muted); font-size:12px; margin-bottom:10px;">paste on any machine you want to build on.</div>
21+ <div style="color:var(--text-muted); font-size:12px; margin:14px 0 5px;">linux, macos</div>
22+ <div style="background:var(--surface-1); border:0.5px solid var(--border); border-radius:8px; padding:9px 12px; color:var(--text-secondary); font-size:12px; word-break:break-all;">curl -sL barerepo.sh | sh -s rt_live_7Kq2mXe</div> <div style="color:var(--text-muted); font-size:12px; margin:14px 0 5px;">windows</div>
23+ <div style="background:var(--surface-1); border:0.5px solid var(--border); border-radius:8px; padding:9px 12px; color:var(--text-secondary); font-size:12px; word-break:break-all;">irm barerepo.sh/ps | iex; forge runner rt_live_7Kq2mXe</div> <div style="color:var(--text-muted); font-size:12px; margin:14px 0 5px;">already have the binary</div>
24+ <div style="background:var(--surface-1); border:0.5px solid var(--border); border-radius:8px; padding:9px 12px; color:var(--text-secondary); font-size:12px; word-break:break-all;">barerepo runner rt_live_7Kq2mXe --labels build,test</div> <div style="color:var(--text-muted); font-size:12px; margin-top:16px; line-height:1.9;">
25+ <div>the runner dials out. it needs no inbound port and no public address.</div>
26+ <div>token scopes to this repo. revoke it on your keys page.</div>
27+ <div>this page refreshes the moment a runner attaches.</div>
28+ <div>a runner is a program because it long-polls. the protocol is plain http:</div>
29+ <div style="color:var(--text-secondary);">POST /runner/attach &middot; GET /runner/poll &middot; POST /runner/log &middot; POST /runner/done</div>
30+ <div>write your own if you would rather. chapter 15 is the whole spec.</div>
31+ </div>
32+ </div>
33+
34+ <div style="position:sticky; bottom:0; margin-top:auto; background:#ffffff; border-top:0.5px solid var(--border); border-radius:0 0 12px 12px; padding:9px 18px; display:flex; justify-content:space-between; color:var(--text-muted); font-size:12px;">
35+ <span>john / johnbot / runs</span><span>barerepo 0.1.0</span>
36+ </div>
37+
38+ </div>
39+
40+ </div>
41+ </body>
42+ </html>
@@ -0,0 +1,45 @@
1+ <!doctype html>
2+ <html lang="en">
3+ <head>
4+ <meta charset="utf-8">
5+ <meta name="viewport" content="width=device-width, initial-scale=1">
6+ <title>barerepo · runners</title>
7+ <link rel="stylesheet" href="tokens.css">
8+ </head>
9+ <body>
10+ <div class="wrap">
11+
12+ <div style="font-family: var(--font-mono); font-size: 13px; line-height: 1.6; color: var(--text-primary); background: #ffffff; border: 0.5px solid var(--border); border-radius: 12px; min-height: calc(100vh - 26px); display: flex; flex-direction: column;">
13+
14+ <h2 class="sr-only">List of attached build machines with labels, platform and last seen time.</h2>
15+ <div style="display:flex; align-items:center; justify-content:space-between; padding:9px 18px; border-bottom:0.5px solid var(--border);">
16+ <div style="color:var(--text-secondary);">john / johnbot / <span style="color:var(--text-primary);">runners</span></div>
17+ <div style="color:var(--text-muted); font-size:12px;">2 attached</div>
18+ </div>
19+ <div style="display:flex; gap:22px; padding:8px 18px; border-bottom:0.5px solid var(--border); color:var(--text-secondary); font-size:12px;">
20+ <span style="text-decoration:underline;">log</span>
21+ <span style="text-decoration:underline;">files</span>
22+ <span style="text-decoration:underline;">threads 3</span>
23+ <span style="color:var(--text-primary); border-bottom:1.5px solid var(--text-primary); padding-bottom:4px;">runs</span>
24+ <span style="text-decoration:underline;">config</span>
25+ <span style="margin-left:auto; color:var(--text-muted);"><span style="text-decoration:underline;">add a runner</span></span>
26+ </div>
27+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
28+ <div style="display:flex; justify-content:space-between;"><span>uproar.local</span><span style="color:var(--text-muted); font-size:12px;">idle · 40m ago</span></div><div style="color:var(--text-muted); font-size:12px;">linux/amd64 · labels build, test · 214 runs</div>
29+ </div>
30+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
31+ <div style="display:flex; justify-content:space-between;"><span>lisa-mbp</span><span style="color:var(--text-muted); font-size:12px;">busy · now</span></div><div style="color:var(--text-muted); font-size:12px;">darwin/arm64 · labels build · 31 runs</div>
32+ </div>
33+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border); color:var(--text-muted);">
34+ <div style="display:flex; justify-content:space-between;"><span>winbox</span><span style="color:var(--text-muted); font-size:12px;">offline · 6d ago</span></div><div style="color:var(--text-muted); font-size:12px;">windows/amd64 · labels build · 4 runs · <span style="color:var(--text-secondary); text-decoration:underline;">forget</span></div>
35+ </div>
36+
37+ <div style="position:sticky; bottom:0; margin-top:auto; background:#ffffff; border-top:0.5px solid var(--border); border-radius:0 0 12px 12px; padding:9px 18px; display:flex; justify-content:space-between; color:var(--text-muted); font-size:12px;">
38+ <span>2 attached</span><span>barerepo 0.1.0</span>
39+ </div>
40+
41+ </div>
42+
43+ </div>
44+ </body>
45+ </html>
@@ -0,0 +1,45 @@
1+ <!doctype html>
2+ <html lang="en">
3+ <head>
4+ <meta charset="utf-8">
5+ <meta name="viewport" content="width=device-width, initial-scale=1">
6+ <title>barerepo · runs</title>
7+ <link rel="stylesheet" href="tokens.css">
8+ </head>
9+ <body>
10+ <div class="wrap">
11+
12+ <div style="font-family: var(--font-mono); font-size: 13px; line-height: 1.6; color: var(--text-primary); background: #ffffff; border: 0.5px solid var(--border); border-radius: 12px; min-height: calc(100vh - 26px); display: flex; flex-direction: column;">
13+
14+ <h2 class="sr-only">List of build runs with status, trigger and duration.</h2>
15+ <div style="display:flex; align-items:center; justify-content:space-between; padding:9px 18px; border-bottom:0.5px solid var(--border);">
16+ <div style="color:var(--text-secondary);">john / johnbot / <span style="color:var(--text-primary);">runs</span></div>
17+ <div style="color:var(--text-muted); font-size:12px;">newest first</div>
18+ </div>
19+ <div style="display:flex; gap:22px; padding:8px 18px; border-bottom:0.5px solid var(--border); color:var(--text-secondary); font-size:12px;">
20+ <span style="text-decoration:underline;">log</span>
21+ <span style="text-decoration:underline;">files</span>
22+ <span style="text-decoration:underline;">threads 3</span>
23+ <span style="color:var(--text-primary); border-bottom:1.5px solid var(--text-primary); padding-bottom:4px;">runs</span>
24+ <span style="text-decoration:underline;">config</span>
25+ <span style="margin-left:auto; color:var(--text-muted);"><span style="text-decoration:underline;">runners</span> · <span style="text-decoration:underline;">add a runner</span></span>
26+ </div>
27+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
28+ <div style="display:flex; justify-content:space-between;"><span>a3f9c2&nbsp;&nbsp;build ok · test ok</span><span style="color:var(--text-muted); font-size:12px;">18s · 40m</span></div><div style="color:var(--text-muted); font-size:12px;">refs/proposals/47 · uproar.local</div>
29+ </div>
30+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
31+ <div style="display:flex; justify-content:space-between;"><span>e91b7d&nbsp;&nbsp;<span style="color:var(--text-danger);">build failed</span></span><span style="color:var(--text-muted); font-size:12px;">6s · 1d</span></div><div style="color:var(--text-muted); font-size:12px;">refs/proposals/46 · uproar.local</div>
32+ </div>
33+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
34+ <div style="display:flex; justify-content:space-between;"><span>8b1d44&nbsp;&nbsp;build ok · test ok</span><span style="color:var(--text-muted); font-size:12px;">21s · 6h</span></div><div style="color:var(--text-muted); font-size:12px;">master · lisa-mbp</div>
35+ </div>
36+
37+ <div style="position:sticky; bottom:0; margin-top:auto; background:#ffffff; border-top:0.5px solid var(--border); border-radius:0 0 12px 12px; padding:9px 18px; display:flex; justify-content:space-between; color:var(--text-muted); font-size:12px;">
38+ <span>john / johnbot</span><span>barerepo 0.1.0</span>
39+ </div>
40+
41+ </div>
42+
43+ </div>
44+ </body>
45+ </html>
@@ -0,0 +1,50 @@
1+ <!doctype html>
2+ <html lang="en">
3+ <head>
4+ <meta charset="utf-8">
5+ <meta name="viewport" content="width=device-width, initial-scale=1">
6+ <title>barerepo · search</title>
7+ <link rel="stylesheet" href="tokens.css">
8+ </head>
9+ <body>
10+ <div class="wrap">
11+
12+ <div style="font-family: var(--font-mono); font-size: 13px; line-height: 1.6; color: var(--text-primary); background: #ffffff; border: 0.5px solid var(--border); border-radius: 12px; min-height: calc(100vh - 26px); display: flex; flex-direction: column;">
13+
14+ <h2 class="sr-only">Search results combining code matches, threads and repositories in one list.</h2>
15+ <div style="display:flex; align-items:center; justify-content:space-between; padding:9px 18px; border-bottom:0.5px solid var(--border);">
16+ <div style="display:flex; gap:20px; align-items:center;">
17+ <span style="letter-spacing:-0.5px; text-decoration:underline;">barerepo</span>
18+ <span style="color:var(--text-secondary); text-decoration:underline;">new</span>
19+ </div>
20+ <div style="display:flex; gap:16px; align-items:center; color:var(--text-secondary);">
21+ <span style="border:0.5px solid var(--border); border-radius:8px; padding:3px 10px; color:var(--text-muted);">search /</span>
22+ <span>john</span>
23+ </div>
24+ </div>
25+ <div style="padding:14px 18px; border-bottom:0.5px solid var(--border);"><div style="margin-bottom:16px;">
26+ <div style="color:var(--text-muted); font-size:12px; margin-bottom:5px;"></div>
27+ <div style="border:0.5px solid var(--border-strong); border-radius:8px; padding:7px 11px; word-break:break-all;">backoff<span style="color:var(--text-muted);">|</span></div>
28+ </div><div style="color:var(--text-muted); font-size:12px;">47 results</div></div>
29+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
30+ <div style="color:var(--text-muted); font-size:12px;">code</div><div style="margin-top:4px;">johnbot / irc/conn.go:88</div><div style="background:var(--surface-1); border:0.5px solid var(--border); border-radius:8px; padding:6px 11px; margin-top:6px; font-size:12px; color:var(--text-secondary);">time.Sleep(<span style="background:var(--bg-success); color:var(--text-success);">backoff</span>(i))</div>
31+ </div>
32+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
33+ <div style="color:var(--text-muted); font-size:12px;">code</div><div style="margin-top:4px;">johnbot / irc/retry.go:9</div><div style="background:var(--surface-1); border:0.5px solid var(--border); border-radius:8px; padding:6px 11px; margin-top:6px; font-size:12px; color:var(--text-secondary);">func <span style="background:var(--bg-success); color:var(--text-success);">backoff</span>(n int) time.Duration {</div>
34+ </div>
35+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
36+ <div style="color:var(--text-muted); font-size:12px;">thread</div><div style="margin-top:4px;">johnbot 44 · does this work behind a socks proxy?</div><div style="color:var(--text-muted); font-size:12px;">mark mentions backoff twice · 5d</div>
37+ </div>
38+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
39+ <div style="color:var(--text-muted); font-size:12px;">repo</div><div style="margin-top:4px;">john / johnbot</div><div style="color:var(--text-muted); font-size:12px;">irc bot that refuses to leave</div>
40+ </div>
41+
42+ <div style="position:sticky; bottom:0; margin-top:auto; background:#ffffff; border-top:0.5px solid var(--border); border-radius:0 0 12px 12px; padding:9px 18px; display:flex; justify-content:space-between; color:var(--text-muted); font-size:12px;">
43+ <span>"backoff"</span><span>barerepo 0.1.0</span>
44+ </div>
45+
46+ </div>
47+
48+ </div>
49+ </body>
50+ </html>
@@ -0,0 +1,38 @@
1+ <!doctype html>
2+ <html lang="en">
3+ <head>
4+ <meta charset="utf-8">
5+ <meta name="viewport" content="width=device-width, initial-scale=1">
6+ <title>barerepo · sign in</title>
7+ <link rel="stylesheet" href="tokens.css">
8+ </head>
9+ <body>
10+ <div class="wrap">
11+
12+ <div style="font-family: var(--font-mono); font-size: 13px; line-height: 1.6; color: var(--text-primary); background: #ffffff; border: 0.5px solid var(--border); border-radius: 12px; min-height: calc(100vh - 26px); display: flex; flex-direction: column;">
13+
14+ <h2 class="sr-only">Sign in page where the server challenges an ssh key instead of asking for a password.</h2>
15+ <div style="display:flex; align-items:center; justify-content:space-between; padding:9px 18px; border-bottom:0.5px solid var(--border);"><span style="letter-spacing:-0.5px;">barerepo</span><span style="color:var(--text-secondary);">new account</span></div>
16+ <div style="padding:26px 18px; max-width:430px;">
17+ <div style="margin-bottom:20px;">sign in</div>
18+ <div style="margin-bottom:16px;">
19+ <div style="color:var(--text-muted); font-size:12px; margin-bottom:5px;">name</div>
20+ <div style="border:0.5px solid var(--border-strong); border-radius:8px; padding:7px 11px; word-break:break-all;">john<span style="color:var(--text-muted);">|</span></div>
21+ </div> <div style="margin-bottom:18px;"><div style="border:0.5px solid var(--text-primary); border-radius:8px; padding:7px 16px; display:inline-block;">send challenge</div></div>
22+ <div style="color:var(--text-muted); font-size:12px; line-height:1.9;">
23+ <div>we give you a nonce. you sign it with the key you already have.</div>
24+ <div style="color:var(--text-secondary);">printf '%s' '&lt;nonce&gt;' | ssh-keygen -Y sign -f ~/.ssh/id_ed25519 -n barerepo-auth -</div>
25+ <div>that is stock openssh. nothing to install.</div>
26+ <div>or <span style="color:var(--text-secondary);">br auth john</span>, which does the same thing in one step.</div>
27+ </div>
28+ </div>
29+
30+ <div style="position:sticky; bottom:0; margin-top:auto; background:#ffffff; border-top:0.5px solid var(--border); border-radius:0 0 12px 12px; padding:9px 18px; display:flex; justify-content:space-between; color:var(--text-muted); font-size:12px;">
31+ <span>barerepo</span><span>barerepo 0.1.0</span>
32+ </div>
33+
34+ </div>
35+
36+ </div>
37+ </body>
38+ </html>
@@ -0,0 +1,41 @@
1+ <!doctype html>
2+ <html lang="en">
3+ <head>
4+ <meta charset="utf-8">
5+ <meta name="viewport" content="width=device-width, initial-scale=1">
6+ <title>barerepo · signup</title>
7+ <link rel="stylesheet" href="tokens.css">
8+ </head>
9+ <body>
10+ <div class="wrap">
11+
12+ <div style="font-family: var(--font-mono); font-size: 13px; line-height: 1.6; color: var(--text-primary); background: #ffffff; border: 0.5px solid var(--border); border-radius: 12px; min-height: calc(100vh - 26px); display: flex; flex-direction: column;">
13+
14+ <h2 class="sr-only">Signup page where an ssh public key is the only credential collected.</h2>
15+ <div style="display:flex; align-items:center; justify-content:space-between; padding:9px 18px; border-bottom:0.5px solid var(--border);"><span style="letter-spacing:-0.5px;">barerepo</span><span style="color:var(--text-secondary); text-decoration:underline;">sign in</span></div>
16+ <div style="padding:26px 18px; max-width:430px;">
17+ <div style="margin-bottom:20px;">new account</div>
18+ <div style="margin-bottom:16px;">
19+ <div style="color:var(--text-muted); font-size:12px; margin-bottom:5px;">name</div>
20+ <div style="border:0.5px solid var(--border-strong); border-radius:8px; padding:7px 11px; word-break:break-all;">john<span style="color:var(--text-muted);">|</span></div>
21+ </div><div style="margin-bottom:16px;">
22+ <div style="color:var(--text-muted); font-size:12px; margin-bottom:5px;">public key</div>
23+ <div style="border:0.5px solid var(--border-strong); border-radius:8px; padding:7px 11px; word-break:break-all;"><span style="color:var(--text-secondary); font-size:12px;">ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH8fK2q0mR4vXeN1pLzT9wBcJdSgYo3Ea7kVnQxMuP2r</span></div>
24+ <div style="color:var(--text-muted); font-size:12px; margin-top:4px;">cat ~/.ssh/id_ed25519.pub</div>
25+ </div> <div style="margin-bottom:18px;"><div style="border:0.5px solid var(--text-primary); border-radius:8px; padding:7px 16px; display:inline-block;">create</div></div>
26+ <div style="color:var(--text-muted); font-size:12px; line-height:1.9;">
27+ <div>your key is your account. there is no email and no password.</div>
28+ <div style="color:var(--text-danger);">losing every key loses the account, because there is no out-of-band recovery channel.</div>
29+ <div>add a second key from another machine today.</div>
30+ </div>
31+ </div>
32+
33+ <div style="position:sticky; bottom:0; margin-top:auto; background:#ffffff; border-top:0.5px solid var(--border); border-radius:0 0 12px 12px; padding:9px 18px; display:flex; justify-content:space-between; color:var(--text-muted); font-size:12px;">
34+ <span>barerepo</span><span>barerepo 0.1.0</span>
35+ </div>
36+
37+ </div>
38+
39+ </div>
40+ </body>
41+ </html>
@@ -0,0 +1,42 @@
1+ <!doctype html>
2+ <html lang="en">
3+ <head>
4+ <meta charset="utf-8">
5+ <meta name="viewport" content="width=device-width, initial-scale=1">
6+ <title>barerepo · new thread</title>
7+ <link rel="stylesheet" href="tokens.css">
8+ </head>
9+ <body>
10+ <div class="wrap">
11+
12+ <div style="font-family: var(--font-mono); font-size: 13px; line-height: 1.6; color: var(--text-primary); background: #ffffff; border: 0.5px solid var(--border); border-radius: 12px; min-height: calc(100vh - 26px); display: flex; flex-direction: column;">
13+
14+ <h2 class="sr-only">New thread form with an optional field for attaching a pushed proposal ref.</h2>
15+ <div style="display:flex; align-items:center; justify-content:space-between; padding:9px 18px; border-bottom:0.5px solid var(--border);">
16+ <div style="color:var(--text-secondary);">john / johnbot / <span style="color:var(--text-primary);">new thread</span></div>
17+ <div style="color:var(--text-muted); font-size:12px;">refs/notes/threads</div>
18+ </div>
19+ <div style="padding:20px 18px; max-width:560px;">
20+ <div style="margin-bottom:16px;">
21+ <div style="color:var(--text-muted); font-size:12px; margin-bottom:5px;">title</div>
22+ <div style="border:0.5px solid var(--border-strong); border-radius:8px; padding:7px 11px; word-break:break-all;">panic when config file is empty<span style="color:var(--text-muted);">|</span></div>
23+ </div> <div style="margin-bottom:16px;">
24+ <div style="color:var(--text-muted); font-size:12px; margin-bottom:5px;">body</div>
25+ <div style="border:0.5px solid var(--border-strong); border-radius:8px; padding:8px 11px; min-height:80px; color:var(--text-muted);">markdown|</div>
26+ </div>
27+ <div style="margin-bottom:16px;">
28+ <div style="color:var(--text-muted); font-size:12px; margin-bottom:5px;">attach a ref</div>
29+ <div style="border:0.5px solid var(--border-strong); border-radius:8px; padding:7px 11px; word-break:break-all;"><span style="color:var(--text-muted);">optional</span></div>
30+ <div style="color:var(--text-muted); font-size:12px; margin-top:4px;">push first, then paste the ref</div>
31+ </div> <div><div style="border:0.5px solid var(--text-primary); border-radius:8px; padding:7px 16px; display:inline-block;">open</div></div>
32+ </div>
33+
34+ <div style="position:sticky; bottom:0; margin-top:auto; background:#ffffff; border-top:0.5px solid var(--border); border-radius:0 0 12px 12px; padding:9px 18px; display:flex; justify-content:space-between; color:var(--text-muted); font-size:12px;">
35+ <span>john / johnbot</span><span>barerepo 0.1.0</span>
36+ </div>
37+
38+ </div>
39+
40+ </div>
41+ </body>
42+ </html>
@@ -0,0 +1,55 @@
1+ <!doctype html>
2+ <html lang="en">
3+ <head>
4+ <meta charset="utf-8">
5+ <meta name="viewport" content="width=device-width, initial-scale=1">
6+ <title>barerepo · thread 47</title>
7+ <link rel="stylesheet" href="tokens.css">
8+ </head>
9+ <body>
10+ <div class="wrap">
11+
12+ <div style="font-family: var(--font-mono); font-size: 13px; line-height: 1.6; color: var(--text-primary); background: #ffffff; border: 0.5px solid var(--border); border-radius: 12px; min-height: calc(100vh - 26px); display: flex; flex-direction: column;">
13+
14+ <h2 class="sr-only">Discussion thread where issues and proposals are one object, stored in git notes.</h2>
15+ <div style="display:flex; align-items:center; justify-content:space-between; padding:9px 18px; border-bottom:0.5px solid var(--border);">
16+ <div style="color:var(--text-secondary);">john / johnbot / <span style="color:var(--text-primary);">thread 47</span></div>
17+ <div style="color:var(--text-muted); font-size:12px;">refs/notes/threads/47</div>
18+ </div>
19+ <div style="padding:13px 18px; border-bottom:0.5px solid var(--border);">
20+ <div style="margin-bottom:4px;">panic when config file is empty</div><div style="color:var(--text-muted); font-size:12px;">opened by lisa · 2h · has proposal · <span style="color:var(--text-secondary);">refs/proposals/47</span> · +81 -12</div>
21+ </div>
22+ <div style="padding:13px 18px; border-bottom:0.5px solid var(--border);">
23+ <div style="color:var(--text-muted); font-size:12px;">lisa · 2h</div><div>fresh install, empty config.toml, immediate nil deref on line 44. reproduces every time.</div>
24+ </div>
25+ <div style="padding:13px 18px; border-bottom:0.5px solid var(--border);">
26+ <div style="color:var(--text-muted); font-size:12px;">lisa · 2h · attached a proposal</div><div style="border:0.5px solid var(--border); border-radius:8px; overflow:hidden; font-size:12px; margin-top:8px;">
27+ <div style="padding:3px 11px; color:var(--text-muted); background:var(--surface-1); border-bottom:0.5px solid var(--border);">config.go @@ -41,7 +41,10 @@</div>
28+ <div style="padding:2px 11px; background:var(--bg-success); color:var(--text-success);"><span style="display:inline-block; width:30px; margin-right:9px; text-align:right; color:var(--text-muted); text-decoration:underline;">41</span>+&nbsp;if len(raw) == 0 {</div>
29+ <div style="padding:2px 11px; background:var(--bg-success); color:var(--text-success);"><span style="display:inline-block; width:30px; margin-right:9px; text-align:right; color:var(--text-muted); text-decoration:underline;">42</span>+&nbsp;&nbsp;&nbsp;return Default(), nil</div>
30+ <div style="padding:2px 11px; background:var(--bg-success); color:var(--text-success);"><span style="display:inline-block; width:30px; margin-right:9px; text-align:right; color:var(--text-muted); text-decoration:underline;">43</span>+&nbsp;}</div>
31+ </div>
32+
33+ </div>
34+ <div style="padding:13px 18px; border-bottom:0.5px solid var(--border);">
35+ <div style="color:var(--text-muted); font-size:12px;">john · 1h · on config.go:43</div><div>Default() allocates every call. make it a package var?</div>
36+ </div>
37+ <div style="padding:13px 18px; border-bottom:0.5px solid var(--border);">
38+ <div style="color:var(--text-muted); font-size:12px;">uproar.local · 40m · self-hosted runner</div><div style="color:var(--text-secondary);">build ok · test ok · 18s</div>
39+ </div>
40+ <div style="padding:13px 18px; border-bottom:0.5px solid var(--border);">
41+ <div style="border:0.5px solid var(--border-strong); border-radius:8px; padding:8px 11px; color:var(--text-muted); min-height:38px;">reply|</div>
42+ </div>
43+ <div style="padding:13px 18px; border-bottom:0.5px solid var(--border);">
44+ <div style="color:var(--text-muted); font-size:12px;">this thread is in your clone. read it with no network, and read it if barerepo stops.</div><div style="margin-top:8px;"><div style="background:var(--surface-1); border:0.5px solid var(--border); border-radius:8px; padding:9px 12px; color:var(--text-secondary); font-size:12px; word-break:break-all;">git fetch origin "refs/notes/*:refs/notes/*"<br>git log --show-notes=threads/47</div></div>
45+ </div>
46+
47+ <div style="position:sticky; bottom:0; margin-top:auto; background:#ffffff; border-top:0.5px solid var(--border); border-radius:0 0 12px 12px; padding:9px 18px; display:flex; justify-content:space-between; color:var(--text-muted); font-size:12px;">
48+ <span>threads · 3 open</span><span>barerepo 0.1.0</span>
49+ </div>
50+
51+ </div>
52+
53+ </div>
54+ </body>
55+ </html>
@@ -0,0 +1,48 @@
1+ <!doctype html>
2+ <html lang="en">
3+ <head>
4+ <meta charset="utf-8">
5+ <meta name="viewport" content="width=device-width, initial-scale=1">
6+ <title>barerepo · threads</title>
7+ <link rel="stylesheet" href="tokens.css">
8+ </head>
9+ <body>
10+ <div class="wrap">
11+
12+ <div style="font-family: var(--font-mono); font-size: 13px; line-height: 1.6; color: var(--text-primary); background: #ffffff; border: 0.5px solid var(--border); border-radius: 12px; min-height: calc(100vh - 26px); display: flex; flex-direction: column;">
13+
14+ <h2 class="sr-only">Thread list where issues and code proposals appear in one combined list.</h2>
15+ <div style="display:flex; align-items:center; justify-content:space-between; padding:9px 18px; border-bottom:0.5px solid var(--border);">
16+ <div style="color:var(--text-secondary);">john / johnbot / <span style="color:var(--text-primary);">threads</span></div>
17+ <div style="color:var(--text-muted); font-size:12px;">3 open · 41 closed</div>
18+ </div>
19+ <div style="display:flex; gap:22px; padding:8px 18px; border-bottom:0.5px solid var(--border); color:var(--text-secondary); font-size:12px;">
20+ <span style="text-decoration:underline;">log</span>
21+ <span style="text-decoration:underline;">files</span>
22+ <span style="color:var(--text-primary); border-bottom:1.5px solid var(--text-primary); padding-bottom:4px;">threads 3</span>
23+ <span style="text-decoration:underline;">runs</span>
24+ <span style="text-decoration:underline;">config</span>
25+ <span style="margin-left:auto; color:var(--text-muted);"><span style="text-decoration:underline;">open</span> · <span style="text-decoration:underline;">merged</span> · <span style="text-decoration:underline;">closed</span> · <span style="text-decoration:underline;">all</span></span>
26+ </div>
27+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
28+ <div style="display:flex; justify-content:space-between;"><span>47&nbsp;&nbsp;panic when config file is empty</span><span style="color:var(--text-muted); font-size:12px;">lisa · 2h</span></div><div style="color:var(--text-muted); font-size:12px;">has proposal · +81 -12 · build ok · 2 replies</div>
29+ </div>
30+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
31+ <div style="display:flex; justify-content:space-between;"><span>46&nbsp;&nbsp;switch to pure-go sqlite</span><span style="color:var(--text-muted); font-size:12px;">mark · 1d</span></div><div style="color:var(--text-muted); font-size:12px;">has proposal · +14 -9 · <span style="color:var(--text-danger);">build failed</span></div>
32+ </div>
33+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border);">
34+ <div style="display:flex; justify-content:space-between;"><span>44&nbsp;&nbsp;does this work behind a socks proxy?</span><span style="color:var(--text-muted); font-size:12px;">anon · 5d</span></div><div style="color:var(--text-muted); font-size:12px;">question · no proposal · 6 replies</div>
35+ </div>
36+ <div style="padding:12px 18px; border-bottom:0.5px solid var(--border); color:var(--text-muted);">
37+ <div style="display:flex; justify-content:space-between; color:var(--text-muted);"><span style="text-decoration:line-through;">45&nbsp;&nbsp;bump deps</span><span style="font-size:12px;">dave · 3d</span></div><div style="color:var(--text-muted); font-size:12px;">merged · tip reachable from master</div>
38+ </div>
39+
40+ <div style="position:sticky; bottom:0; margin-top:auto; background:#ffffff; border-top:0.5px solid var(--border); border-radius:0 0 12px 12px; padding:9px 18px; display:flex; justify-content:space-between; color:var(--text-muted); font-size:12px;">
41+ <span>3 open · 41 closed</span><span>barerepo 0.1.0</span>
42+ </div>
43+
44+ </div>
45+
46+ </div>
47+ </body>
48+ </html>
@@ -0,0 +1,39 @@
1+ :root {
2+ --font-mono: "Space Mono", monospace;
3+ --surface-2: #ffffff;
4+ --surface-1: #f7f7f7;
5+ --text-primary: #000000;
6+ --text-secondary: #555555;
7+ --text-muted: #888888;
8+ --border: #dddddd;
9+ --border-strong: #999999;
10+ --radius: 0;
11+ --text-danger: #a32d2d;
12+ --text-success: #2f6b0f;
13+ --bg-danger: #fbe3e3;
14+ --bg-success: #e6f2d9;
15+ }
16+
17+ html { height: 100%; }
18+ body {
19+ margin: 0;
20+ padding: 0;
21+ background: #ffffff;
22+ color: #000000;
23+ }
24+
25+ .wrap {
26+ margin: 0;
27+ padding: 0;
28+ text-align: left;
29+ }
30+
31+ .sr-only {
32+ position: absolute;
33+ width: 1px;
34+ height: 1px;
35+ overflow: hidden;
36+ clip: rect(0 0 0 0);
37+ }
38+
39+ body { padding: 12px; box-sizing: border-box; min-height: 100vh; }
@@ -0,0 +1,186 @@
1+ // Package testserver runs a real barerepo in a test, so the other two repos can drive this one.
2+ package testserver
3+
4+ import (
5+ "context"
6+ "fmt"
7+ "net/http/httptest"
8+ "os"
9+ "os/exec"
10+ "path/filepath"
11+ "strings"
12+ "sync"
13+ "testing"
14+
15+ "github.com/barerepo/server/internal/cache"
16+ "github.com/barerepo/server/internal/config"
17+ "github.com/barerepo/server/internal/gitread"
18+ "github.com/barerepo/server/internal/httpd"
19+ "github.com/barerepo/server/internal/repocfg"
20+ "github.com/barerepo/server/internal/store"
21+ "github.com/barerepo/server/internal/thread"
22+ "github.com/barerepo/server/internal/token"
23+ "github.com/barerepo/server/internal/transport"
24+ )
25+
26+ // Instance is one running barerepo, torn down when the test ends.
27+ type Instance struct {
28+ URL string
29+ Cfg config.Config
30+ DB *store.DB
31+ }
32+
33+ var (
34+ buildOnce sync.Once
35+ buildPath string
36+ buildErr error
37+ )
38+
39+ // Binary builds the server once per test binary, because the hooks are this program calling itself.
40+ func Binary(tb testing.TB) string {
41+ tb.Helper()
42+ buildOnce.Do(func() {
43+ dir, err := os.MkdirTemp("", "barerepo-testserver-")
44+ if err != nil {
45+ buildErr = err
46+ return
47+ }
48+ buildPath = filepath.Join(dir, "barerepo")
49+ out, err := exec.Command("go", "build", "-o", buildPath,
50+ "github.com/barerepo/server/cmd/barerepo").CombinedOutput()
51+ if err != nil {
52+ buildErr = fmt.Errorf("building the server: %v\n%s", err, out)
53+ }
54+ })
55+ if buildErr != nil {
56+ tb.Fatal(buildErr)
57+ }
58+ return buildPath
59+ }
60+
61+ // New starts a barerepo on a loopback port with its own disk and database.
62+ func New(tb testing.TB) *Instance {
63+ tb.Helper()
64+ bin := Binary(tb)
65+ root := tb.TempDir()
66+
67+ cfg := config.Default()
68+ cfg.Paths.Repos = filepath.Join(root, "repos")
69+ cfg.Paths.Cache = filepath.Join(root, "cache")
70+ cfg.Paths.Artifacts = filepath.Join(root, "artifacts")
71+ cfg.Database.URL = "sqlite://" + filepath.Join(root, "barerepo.db")
72+ cfg.Path = filepath.Join(root, "barerepo.toml")
73+
74+ body := fmt.Sprintf("[paths]\nrepos = %q\ncache = %q\nartifacts = %q\n\n[database]\nurl = %q\n",
75+ cfg.Paths.Repos, cfg.Paths.Cache, cfg.Paths.Artifacts, cfg.Database.URL)
76+ if err := os.WriteFile(cfg.Path, []byte(body), 0o600); err != nil {
77+ tb.Fatal(err)
78+ }
79+ for _, d := range []string{cfg.Paths.Repos, cfg.Paths.Cache, cfg.Paths.Artifacts} {
80+ if err := os.MkdirAll(d, 0o750); err != nil {
81+ tb.Fatal(err)
82+ }
83+ }
84+
85+ // The server writes this on every key change, and the hook checks signatures against it.
86+ store.SignersPath = filepath.Join(cfg.Paths.Cache, "allowed_signers")
87+ db, err := store.Open(context.Background(), cfg.Database.URL)
88+ if err != nil {
89+ tb.Fatal(err)
90+ }
91+ tb.Cleanup(func() { db.Close() })
92+
93+ shared := cache.New(cfg.Paths.Cache)
94+ gitread.Cache = shared
95+ repocfg.Cache = shared
96+ thread.Cache = shared
97+ httpd.Cache = shared
98+
99+ srv := &httpd.Server{Cfg: cfg, DB: db,
100+ Transport: &transport.Server{Cfg: cfg, DB: db, Bin: bin}}
101+ ts := httptest.NewServer(srv.Handler())
102+ tb.Cleanup(ts.Close)
103+
104+ cfg.Server.ExternalURL = ts.URL
105+ srv.Cfg = cfg
106+ srv.Transport.Cfg = cfg
107+ body += fmt.Sprintf("\n[server]\nexternal_url = %q\n", ts.URL)
108+ if err := os.WriteFile(cfg.Path, []byte(body), 0o600); err != nil {
109+ tb.Fatal(err)
110+ }
111+ return &Instance{URL: ts.URL, Cfg: cfg, DB: db}
112+ }
113+
114+ // Account makes an account with a fresh key and hands back a token for git over https.
115+ func (in *Instance) Account(tb testing.TB, name string) string {
116+ tb.Helper()
117+ dir := tb.TempDir()
118+ key := filepath.Join(dir, "id")
119+ if out, err := exec.Command("ssh-keygen", "-t", "ed25519", "-N", "", "-C", name,
120+ "-f", key, "-q").CombinedOutput(); err != nil {
121+ tb.Fatalf("ssh-keygen: %v\n%s", err, out)
122+ }
123+ pub, err := os.ReadFile(key + ".pub")
124+ if err != nil {
125+ tb.Fatal(err)
126+ }
127+ if _, err := in.DB.CreateAccount(context.Background(), name, string(pub), false); err != nil {
128+ tb.Fatal(err)
129+ }
130+ tok, _, err := in.DB.CreateToken(context.Background(), token.Git, name, "", "test")
131+ if err != nil {
132+ tb.Fatal(err)
133+ }
134+ return tok
135+ }
136+
137+ // RunnerToken issues a token scoped to one repository, which is what chapter 15 requires.
138+ func (in *Instance) RunnerToken(tb testing.TB, account, owner, name string) string {
139+ tb.Helper()
140+ tok, _, err := in.DB.CreateToken(context.Background(), token.Runner, account,
141+ owner+"/"+name, "test runner")
142+ if err != nil {
143+ tb.Fatal(err)
144+ }
145+ return tok
146+ }
147+
148+ // Repo pushes a first commit, which is how a repository comes into being here.
149+ func (in *Instance) Repo(tb testing.TB, tok, owner, name string) string {
150+ tb.Helper()
151+ work := tb.TempDir()
152+ write := func(path, body string) {
153+ full := filepath.Join(work, path)
154+ if err := os.MkdirAll(filepath.Dir(full), 0o750); err != nil {
155+ tb.Fatal(err)
156+ }
157+ if err := os.WriteFile(full, []byte(body), 0o600); err != nil {
158+ tb.Fatal(err)
159+ }
160+ }
161+ write("config.go", "package main\n")
162+ write(".barerepo/config", "[repo]\nvisibility = \"public\"\n")
163+ for _, args := range [][]string{
164+ {"init", "-q", "-b", "master"},
165+ {"add", "-A"},
166+ {"commit", "-qm", "first"},
167+ {"push", "-q", in.URLFor(tok, "/"+owner+"/"+name), "master"},
168+ } {
169+ cmd := exec.Command("git", args...)
170+ cmd.Dir = work
171+ cmd.Env = append(os.Environ(),
172+ "GIT_AUTHOR_NAME=tester", "GIT_AUTHOR_EMAIL=t@x",
173+ "GIT_COMMITTER_NAME=tester", "GIT_COMMITTER_EMAIL=t@x",
174+ "GIT_TERMINAL_PROMPT=0", "GIT_CONFIG_COUNT=1",
175+ "GIT_CONFIG_KEY_0=credential.helper", "GIT_CONFIG_VALUE_0=")
176+ if out, err := cmd.CombinedOutput(); err != nil {
177+ tb.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
178+ }
179+ }
180+ return work
181+ }
182+
183+ // URLFor is the clone url with a token in it, which is how a test pushes without an agent.
184+ func (in *Instance) URLFor(tok, path string) string {
185+ return strings.Replace(in.URL, "://", "://x:"+tok+"@", 1) + path
186+ }
reachable from master
barerepo / serverbarerepo 0.1.0