02 - Git hosting
On this page 11
Actually hosting git: repositories on disk, the wire protocol, and reading code in a browser.
The rule for this whole phase: the system git binary does the git work. It is a declared pantry
dependency. Do not add a git library, and do not reimplement packfile handling in TypeScript. Every
path that touches a repository on disk goes through app/Actions/Git/, so the storage layout stays
known to exactly one place.
Storage
- Bare repositories at
storage/repos/{owner}/{repository}.git - One helper that resolves an owner and repository name to an absolute path, rejecting
.., absolute paths, and anything that escapes the root (app/Actions/Git/storage.ts). Every other caller uses it, and the resolved path is checked against the root a second time, so if the allowlist and the resolution ever disagree the allowlist is what is wrong. git init --bareon creation, withcore.hooksPathpointed at a shared hook directory. One shared directory rather than a copy of the scripts in every repository: copies drift, and the repositories nobody pushes to would keep whichever version they were created with, which is exactly where a silent failure goes unnoticed longest.buddy git:hookswrites them and repoints every repository, which is the deploy step after an upgrade.- Repository size accounting, updated after receives and after a fork. From
git count-objects -vrather thandu: forks share their objects through hardlinks, soducounts the same bytes once per fork and a hundred forks look like a hundred times the disk - Deleting a repository moves it aside with a timestamp rather than unlinking, so an accidental delete is recoverable for a retention window. The database work happens first: a failure then is a delete that did not happen, where the other order leaves a repository that is gone from disk and still listed
- A retention sweep that removes
storage/repos-deleted/entries older than the window, at thirty days - longer than a holiday, which is the case that matters. A directory whose name the sweep cannot read is never removed: the bytes in there are the last copy, so anything unexpected is a reason for a person to look rather than a reason to delete
Models
app/Models/Repository.ts: polymorphicowner(user or organization),name,slug,description,visibility(public, private, internal),default_branch,is_fork,parent_id,is_archived,is_template,size_kb,stars_count,forks_count,open_issues_count,pushed_at- Unique constraint on
(owner_type, owner_id, name), and on(repository_id, user_id)for stars, watches and collaborators - Counter columns are denormalized on purpose; every writer updates them in the same transaction
as the row it counts. They are recomputed rather than incremented - see
app/Actions/Repo/counters.tsfor why an increment nobody can verify is an increment that has been wrong since it was written. What keeps this true istests/unit/repo-counters.test.ts, which reads the action and job sources, finds the writes that change something counted, and insists the same file recounts it: a denormalized counter does not go wrong in the arithmetic, it goes wrong at the eighth call site somebody adds without knowing the other seven exist. It found one on its first run.MirrorMetadataSyncJobimported a mirror's issues - hundreds at once, and the upstream's closes between runs - and never recounted, so a mirrored repository read0 open issueshowever many it had. Nobody files that as a bug, because it looks exactly like a repository with no issues app/Models/RepoCollaborator.ts:repository_id,user_id,permission, andPOST /api/repos/collaboratorsto write one. The table was read by the access checks from the start and written by nothing, so a personal repository could not be shared with anybody at all - the team grant covers only repositories an organization owns, which leaves out what a self-hosted instance is mostly made of. Behindcollaborator:manage, the admin rung rather than maintain, because this endpoint can hand somebodyadminin one request where reaching the same thing through a team takes a team to exist and somebody to be put in it.app/Models/Star.ts,app/Models/Watch.tswith asubscriptionlevel (all, participating, ignore)app/Models/ProtectedBranch.ts: pattern, required approvals, dismiss stale reviews, required status checks, restrict who can push, allow force push, allow deletionapp/Models/RepoTopic.ts, normalised to lower case with spaces as dashes soTypeScriptandtypescriptare one topic. A row per topic rather than a list on the repository, because the query that justifies a topic runs the other way - every repository taggedrust- and a comma-joined string cannot be indexed for itapp/Models/Release.tsandapp/Models/ReleaseAsset.ts, published from the framework default (buddy publish:model Release) and extended rather than written fresh. A userland model replaces a framework default instead of merging with it, so a hand-written one emittedALTER TABLE releases DROP COLUMN versionwhile the framework's own dashboard actions went on selecting it. Every framework column is still there, and where the two mean the same thing the framework's is used rather than duplicated:versionis the tag,statusis draft or published so there is no second flag to disagree with it,notesis the body,authorsits besideuser_id. Onlytypechanged, from required to optional - a git tag is not a decision about major, minor or patch- Fixed upstream so nobody else finds it the hard way:
buddy generate:migrationsnow refuses to write a migration that drops columns from a table a userland model took over from a framework default, naming the columns and pointing atbuddy publish:model(storage/framework/core/database/src/shadowed-models.tsin the Stacks checkout).STACKS_ALLOW_SHADOW_DROPS=1for somebody who means it - A release is a tag plus notes, so the tag has to exist first. Creating it here was the alternative and is worse: it makes publishing a release something that changes what a clone contains. Deleting a release leaves the tag alone for the same reason
target_shais recorded at publication rather than resolved on read, because a tag can be moved - and a release whose notes describe a commit nobody can name again is worth less than no release- "Latest" is the highest version, not the most recently published. Sorting by date is the
obvious implementation and is wrong in the case that matters: a patch backported to an old
branch and published today would become the version every install script fetches. Drafts and
prereleases are never latest, and
v1.10.0outranksv1.9.0 - Uploading and serving release assets. Simpler rules than an issue attachment and deliberately
stricter: an attachment is often a screenshot somebody wants to see inline, so that module has
to decide which types are safe to render. A release asset is a compiled artefact somebody
downloads and runs, so there is no allowlist to get wrong - every asset goes out as an opaque
download with
nosniff, whatever it is called. A SHA-256 is recorded on upload and published beside the file, because a checksum nobody can see is a checksum nobody can check - A name is refused rather than replaced when it is already taken on that release: an asset name is what an install script fetches, and quietly swapping the bytes under a published name is the worst version of that endpoint
- A draft's assets are not downloadable by anybody who cannot see the draft, and answer 404 rather than 403 - the existence of an unannounced release is exactly what a draft is keeping
Smart HTTP
-
routes/git.ts, registered inapp/Routes.tswith an empty prefix so URLs are/{owner}/{repository}.git/... -
GET /{owner}/{repository}.git/info/refs?service=git-upload-pack(clone and fetch discovery) -
POST /{owner}/{repository}.git/git-upload-pack -
POST /{owner}/{repository}.git/git-receive-pack -
Stream request and response bodies. Buffering a packfile is how this breaks on a real repository, and it will pass every test written against a small one.
-
Name the repository as the service's own argument, never
..upload-packandreceive-packtake the repository positionally and resolve it themselves; they do not read--git-dir. Passing.made every request operate on the server process's working directory, which is the application's own checkout - so a clone of any URL served the forge's source, a clone of a private repository served it too (the permission check passes on the repository that was asked for, and a different one is handed over), and a push wrote its refs into the application's repository. Nothing about it looked wrong: the protocol is spoken correctly,git clonesucceeds and checks out a real tree,git pushreports a new branch. Found by asking not "did the clone work" but "which repository did it clone". -
HTTP basic auth: username plus access token. Password login over git is not accepted. This authenticated against a
personal_access_tokenstable that no migration ever created, so every authenticated git request failed on a missing relation. It now goes through the access tokens from phase 1, and the token's own grants decide the answer: a read-only token belonging to a maintainer cannot push, and a token scoped to two repositories cannot touch a third. The username is not checked, because the token already names its owner and treating it as meaningful would fail a correct token for a cosmetic reason. -
Anonymous read for public repositories; everything else authenticates. Verified against a real client: a public repository clones anonymously, a private one answers 404 rather than 403 so its existence is not confirmed, a read-only token can fetch and is refused a push, and an anonymous push is refused. Worth knowing when testing this by hand -
gitwill silently reuse a credential from the system keychain, so an "anonymous" push that succeeds may not be anonymous.-c credential.helper=is what makes the test mean anything. -
Correct content types and the
no-cacheheaders git expects. git caches aggressively otherwise, and a stale ref advertisement makes a fetch quietly miss commits. -
Tests: the argument rule above, checked the way the bug would have been caught - run the real command from inside a different repository and assert the answer belongs to the one that was named. Both directions, so it cannot pass by accident, plus the old behaviour pinned as a demonstration of why it was invisible.
-
Verified by hand against a live server: shallow clone, full clone, incremental fetch, push, and the hook chain firing through the HTTP path (
pushed_atmoves, which is the end-to-end proof that the push reached the application). -
The same as an automated end-to-end test (
tests/e2e/git-http.test.ts). It boots the router on an ephemeral port, creates a repository behind a row, and drives the realgitclient through clone, push and fetch - plus the JSON API on the same server. It skips itself, loudly, without a database, and CI now has Postgres so it does not skip there. Two things it cost: the git driver has to be async, because a synchronous child blocks the very event loop the in-process server answers on andgit clonethen waits sixty seconds for a response nobody can write; and the run's hooks go in a directory of its own, because installing into the shared one repoints every repository on the machine at a server that stops existing when the file finishes -
A repository large enough that streaming matters, as a test rather than by inspection. Every other test in that file would pass against a server that read the whole pack into memory and then wrote it, because the fixture is three files; the failure that matters appears on somebody's real repository, where buffering means one clone holds hundreds of megabytes and ten concurrent clones take the process down. So it pushes and clones ~7.5 MB of random content - random so nothing delta-compresses the question away - checks it comes back byte for byte through
fsckand a full file comparison, and then reads the wire directly, becausegit clonecannot say how the bytes arrived. Two things separate streaming from buffering and both are asserted: the response declares noContent-Length, since a server that knows the length has already built the whole thing, and the first bytes arrive well before the last rather than everything landing at once at the end -
bun testwalked the whole project looking for test files,pantry/included - a package tree of hundreds of thousands of files. It follows the symlinks it finds, and once a few hundred are open the process is out of file descriptors: the nextspawnfailsEBADF, and every test that shells out to git fails with it. Thirty-five of them did, in code nothing had touched. Reproduced down to one directory of 800 symlinks anywhere under the project root, needing no import by anything, and fixed byroot = "tests"inbunfig.toml- the tests are intests/, and a test runner has no business reading a dependency tree.bun runwas never affected, because it resolves modules rather than scanning for files, which is why the application worked while the suite did not
Receiving a push
- Post-receive hook posts ref updates back to the application. A hook rather than diffing the
refs either side of
receive-pack, which is simpler and wrong twice: two pushes to one repository interleave, and the answer would only exist for pushes arriving over HTTP. git hands a hook the exact updates, and it fires for a push over SSH and for one made on the server by hand. It posts to loopback with a shared secret, and the secret gets the request heard and nothing more - every ref line is re-parsed and shape-checked, and the repository is resolved from its path on disk rather than from a name in the body. app/Jobs/ProcessPushJob.tson thegitqueue, doing the work asynchronously. The hook runs insidegit pushwith somebody standing at a prompt, so nothing that walks commits belongs in it:- Update
pushed_atand the default branch when it moves. The default branch is only ever adopted on the first push into an empty repository, where the row saysmainand the pusher pushedmaster. Any other time, a push that could repoint it is a push that can change what everybody sees when they open the repository. - Refresh open pull requests whose head branch changed: the head sha is brought up to date and the mergeable state is marked unknown. Recomputing mergeability needs a merge simulation and stays where it was; what matters here is that nothing shows a stale "no conflicts" against a branch that has moved, because a wrong green is worse than no green.
- Close issues referenced by closing keywords in the pushed commits, on the same terms a merge closes one: this repository only, issues only. No actor is recorded - a commit's author is free text that anybody can set, and attributing a close to a local account on the strength of one would put words in somebody's mouth.
- Emit
push:receivedfor webhooks, notifications, and the activity feed - Queue a search reindex. Phase 6 built the index, and
ProcessPushJobhas dispatchedIndexRepositoryJobsince; what was missing was anything proving it. The rest oftests/e2e/search-push-reindex.test.tsexercises the job directly, so deleting the dispatch left the file green while "recently active" quietly became "recently reindexed" - the exact failure its own header describes. The dispatch is asserted now, and checked by removing it and watching the test fail. Asserted on the dispatch rather than on the index because whether the job runs inline or waits for a worker is the queue driver's business; queueing it is what this box claims and it is true under both.
- Update
- Enforce protected branch rules at receive time, rejecting the push with a message git shows
the user. A pre-receive hook, because receive time is the only moment where refusing is
worth anything: once the ref is written the dropped commits are unreachable and everybody who
fetches has the rewritten history. Whether a push is a force push is asked of git rather than
of the client -
--forceis a flag somebody chose to send, dropping history is what actually happened. The hook fails open: an unreachable application allows the push, because branch protection is a guard rail against a mistake and a forge that stops accepting pushes when its web process restarts is a forge people work around. - Manage the rules, rather than only enforce them.
POST /api/repos/protected-branches, behindbranch:protect- an ability that had been inapp/Permissions.tsandapp/TokenScopes.tssince the permission table was written and was checked by nothing, because the endpoint it described did not exist. Until this, the only way to protect a branch was anINSERTby hand, which made the enforcement above a feature nobody could turn on. Upserted on the pattern, since two rules formainwould make the answer depend on which came back first and the enforcement reads every match - so the looser of the two is the one somebody discovers. A pattern that could never match a branch name is refused rather than stored: a rule that silently protects nothing is worse than no rule, because the settings page shows it and everybody believes it. Creating, changing and removing one are all in the audit log, and the removal is the one that matters - a force push at an unprotected branch is an ordinary push and is recorded nowhere, so "remove the rule, rewrite the history, put it back" leaves no trace unless the removal does. - The rest of what a branch protection call describes. A rule now carries
require_up_to_date(GitHub'srequired_status_checks.strict),enforce_admins, andpush_restrictions- the three knobs in aPUT /repos/{owner}/{repo}/branches/{branch}/protectionpayload that had nowhere to land, next to the required approvals, dismissal, checks, force pushes and deletions that already did. Each of the three decides the same way at the push gate and at the merge, because a restriction enforced on one door has a button beside it. Two defaults are chosen against GitHub's and both for the same reason - a migration must not weaken a rule somebody already wrote.enforce_adminsdefaults to on, since every rule on every instance was written when there was no bypass at all and a column defaulting to off would hand every administrator a silent exemption the day it ran; and an unreadablepush_restrictionsreads as unrestricted rather than as "nobody", which is the opposite of how an unreadable check list is read, because the failure there is a weakened rule and the failure here is a branch nobody can write to including whoever would fix the row. A push whose actor cannot be identified is refused at a restricted branch, which is the one rule in the file that fails closed - and to make that answerable at all,git-receive-packover HTTP now carries the pusher's id into the hook environment the way the SSH daemon always has. Until that, a push over HTTPS was anonymous as far as the gate was concerned, which also meant every push-protection bypass was logged against nobody. Using the admin exemption is written to the audit log at both doors: an exemption nobody can find afterwards is indistinguishable from a protection that was never on. - A page that writes the rules, so the endpoint is not the only door. The settings screen has claimed branch protection in its own description since it was written and had no section for it, so every rule on every instance still needed an API client. One form per rule and one blank one, posting to the endpoint the reference documents - what the page can express and what a client can express are the same set, and neither can drift ahead of the other.
- Tests: force push to a protected branch is rejected, and a push that closes an issue does. Both against real git, including one that proves git runs the hooks at all - it says nothing when it skips a hook it cannot execute, so a hook that never runs and a hook that always allows are indistinguishable from outside.
Push protection
Scanning for a leaked credential after the push has landed is a cleanup procedure, not a defense: the secret is in the reflog, in every clone, and possibly in a mirror before anyone reads the alert. Rejecting the push is the only version of this feature that prevents anything, and receive time is the one moment where rejecting is still possible.
- Scan the incoming pack for credential shapes before accepting it, in that order of certainty. The detectors are ordered by how sure they are because the failure that matters is the false positive: a miss costs one credential, and a wrong refusal on a test fixture costs the whole feature. The entropy heuristic is last and narrowest, and needs a variable name that says what the value is, a long value, real entropy, and that the value is not one of the placeholders every README contains. Entropy is worth measuring rather than assuming: English words run together score above a real base64 key, which is why the name carries most of the signal.
- Reject with a message git prints legibly, naming the file, the line, and what it looks like - with the value redacted, because the finding reaches a terminal, the audit log and possibly a support thread, and a message that quotes the whole credential leaks it a second time to help with the first.
- A bypass that requires a reason and is recorded in the audit log
(
git push -o secret-scan=bypass -o reason="..."). Push options are the channel, which meansreceive.advertisePushOptionshas to be on or git never transmits them and the documented escape silently does nothing. Every refusal says what the override needs, because a refusal that does not is the one that turns into "just disable the scanner". - Patterns are configurable per instance, in
config/push-protection.ts. A configured pattern is compiled once and tried against a pathological input with a time budget: a regular expression is a program, and one written carelessly takes exponential time - a scanner that hangs is a push that hangs, which is indistinguishable from the forge being down. - Scan history on demand (
buddy git:scan), reporting rather than rejecting. There is nothing left to refuse: the commits are in every clone and in the reflog. It says so, and says that rotating the credential is the step that ends the exposure - removing it from history afterwards is tidying up, and rewrites everybody's copy. - Tests: a known token shape is rejected, ten documented placeholders are not, and the bypass is
logged. Two things this cost, both of which reported success while doing nothing:
the pushed objects are quarantined - during pre-receive they are in a temporary directory
that only the hook process can see, so the application cannot read a byte of the push without
the hook forwarding
GIT_OBJECT_DIRECTORY, and a scanner built without that finds nothing and looks like it works; and the zero sha is forty hex characters, so it passes a full-sha check and a created branch was scanned as the range000…000..<new>, which resolves to nothing - the exact case somebody pushing a new branch with a key is in. - A generated hook that does not parse refuses every push, and nothing inside it can catch that:
a syntax error happens before its own
tryexists. Both scripts are parsed in a test.
Browsing
app/Actions/Browse/TreeAction.ts- directory listing at a ref and pathapp/Actions/Browse/BlobAction.ts- file contents, with binary detection and a size ceilingapp/Actions/Browse/CommitsAction.ts- history, optionally scoped to a path. Paged by sha rather than by offset: history is append-only at the tip, so a push while somebody is on page three shifts every commit down by one and an offset then repeats one and skips oneapp/Actions/Browse/CommitAction.ts- a single commit with the files it changed. A merge is diffed against its first parent, becausediff-treeon a merge with no options prints nothing and a page confidently reporting that a merge changed no files is worse than no pageapp/Actions/Browse/BranchesAction.ts,TagsAction.tsapp/Actions/Browse/BlameAction.ts, capped at 5000 lines. The porcelain format states each commit once and then refers to it by sha, so the parser has to remember - reading each line independently leaves every line after the first with no author, which looks like a blank column rather than a bugapp/Actions/Browse/CompareAction.ts- two refs, the basis for opening a pull request. Diffed from the merge base, never from the base tipapp/Actions/Git/RawFileAction.tsandArchiveAction.ts(zip and tar.gz viagit archive), both streamed. Neither serves a repository's content as its own type:index.htmlreturned astext/htmlfrom this origin runs script with this application's cookies, so everything istext/plainorapplication/octet-streamwithnosniff, and every archive carries a directory prefix so it cannot unpack over whatever directory somebody is standing in- One place decides whether a read may proceed (
app/Actions/Browse/context.ts), because there are ten of these endpoints and ten chances to forget the visibility check - Syntax highlighting server-side. The client does not download a highlighter.
- Render README, and markdown files generally, at the tree view. The README goes under the
listing, the way every forge does it, but only in a directory - inside a file the file is the
subject. Rendering happens in the view rather than through
@markdown: the directive runs before interpolation, so it would markdown-render the literal{!! readme.text !!}token and then drop the file's text into the page untouched, which for a mirrored repository means whatever HTML its README happens to contain
Repository management
app/Actions/Repo/CreateRepositoryAction.ts- row and bare repository together, cleaning up the row if the disk operation failsapp/Actions/Repo/UpdateSettingsAction.ts,DeleteRepositoryAction.ts,TransferRepositoryAction.ts. NoArchiveRepositoryAction: archiving is a flag on the settings endpoint, because a rename and an archive share the rule that the row and the directory have to end up agreeing, and splitting them is how that gets implemented twice. The rules are pure inapp/Actions/Repo/settings.tsand tested away from the database.- Archived means readable and frozen everywhere, not only for pushes.
authorizeRepositoryrefuses every ability except reading, settings, delete and transfer, stated as an allowlist inapp/Permissions.tsso an ability added later is frozen by default - A transfer needs admin on the repository and the right to create in the destination, and drops the old owner's collaborator grants rather than carrying them into a structure the new owner did not choose
app/Actions/Repo/ForkRepositoryAction.tsusinggit clone --bare --local, recorded as a fork.--localhardlinks the object store, which is what makes forking a large repository cheap enough to be the normal way to contribute; the test asserts the link count rather than the claimapp/Actions/Repo/StarAction.ts,WatchAction.ts. Starring toggles because the page cannot know whether the star it drew has been pressed since; watching does not, because it has three answers and the middle one is the one people want- Unique indexes on
(owner_type, owner_id, name)and on the person-plus-repository pairs, so the read-then-write checks in create, fork, rename and transfer have something behind them - Cascade the repository foreign keys. Fifteen tables hang off a repository and every
constraint was
NO ACTION, so a delete had to remove the children first, in the right order, in every place that deletes - and the place that misses one leaves rows nothing can reach. The database now removes them, which also covers the deletes the application never made: a manualDELETE, a restore, another service sharing the schema. Only the repository relation cascades; deleting a user deliberately does not take their issues, comments or reviews with them, because that is a history other people took part in. Three things had to change:bun-query-builderadded a second foreign key instead of replacing the first (0.2.18). A column created inline withREFERENCESalready carries a constraint the server named itself (x_repository_id_fkey), andaddForeignKeyaddedx_repository_id_fkbeside it. A server enforces every constraint it holds, so the migration applied cleanly, the cascade was real, and deletes went on failing against theNO ACTIONnext to it - with nothing in the output saying so- A declared foreign key column lost its relation's
onDelete(0.2.21). Writingrepository_idinattributesis the ordinary way to give it a validation rule, and it cost the relation its cascade silently: the same model with the samebelongsTocascaded or did not depending on whether its_idcolumn happened to be written down twice - A
belongsTocould not declareonDeleteat all (stacks 0.70.289). The field existed onForeignKeyConfigfor the explicit attribute-level form, and the generator emitted it only for the pivot table of a many-to-many - so the relation every one of these fifteen columns comes from had nowhere to say it. Now onBaseRelation, documented instacks-models - No hand-written migration in the end. One was needed at first, to drop the constraints
this database was created with before the cascade was declared - and needing it was the
signal that the models were wrong rather than the generator.
buddy migrate:regeneraterebuilt the whole corpus from the models (107 files from 104 models, replacing 139), which put every constraint inline on itsCREATE TABLEand left the repair nothing to do. There is no real data yet, so replaying from scratch cost nothing - Nine foreign keys existed only as attributes. Two were found by the framework's own
audit once the corpus came from the models (
issues.milestone_id,access_tokens.organization_id); reading the emitted DDL for_idcolumns with noREFERENCESfound seven more, all of them a role rather than an owner:issues.closed_by_id,pull_requests.merged_by_id,pull_requests.stack_parent_id,review_threads.resolved_by_id,access_tokens.revoked_by_id,pull_request_reviewers.requested_by_id, andrepositories.parent_id. None had a constraint, so a row could point at a user, milestone or repository that no longer existed. All declared now with the action each actually wants:cascadewhere the row is meaningless without its parent,set nulleverywhere else - an issue outlives whoever closed it, a thread stays resolved when that account is gone, and a fork detaches from a deleted upstream rather than going with it, which is whatpurge.tsdoes by hand today. The rest of the bare_idcolumns are external identifiers (provider_id,transaction_id) or the_idhalf of a polymorphic pair, and correctly carry no constraint tests/unit/migrations-from-models.test.tskeeps it that way. It refuses a file the generator would not have named, refuses hand-written commentary in the corpus (explain it in the model, where it survives a regeneration), and checks every relation that declares anonDeletereaches a real constraint - a model that says the database will clean up while the database does nothing is worse than saying nothing at all- Verified against the real database: fifteen constraints, all
CASCADE, none duplicated, and a bareDELETE FROM repositories- no ordering, no purge, nothing the application knows about - takes its labels, topics, issues and stars with it. That statement failed before this change app/Actions/Repo/purge.tsanddependents.tsare gone - three hundred lines that read the foreign keys out ofinformation_schema, sorted the tables so nothing was deleted before the rows pointing at it, and emptied them one by one. A second implementation of a rule the database holds, and the kind that goes wrong quietly when somebody adds a table and only one of the two learns about it. The delete is one statement now- Deleting it uncovered a real gap, which is why it was worth doing. A polymorphic row
cannot be reached by a foreign key:
issue_comments.commentable_idis an issue on one row and a pull request on the next, so a constraint would name one table and reject the other. Before the schema came from the models,commentable_idcarriedREFERENCES issues(id)- wrong, since it would have rejected every comment on a pull request - and the old walk followed that wrong constraint to find these rows. Removing it was right; nothing replacing it was not, and deleting a repository left its comments, reactions and timeline entries behind.app/Actions/Repo/polymorphic.tssweeps exactly those five tables, as an explicit list somebody can read rather than a graph that decides for itself.audit_eventsandactivitiesare deliberately left: a log that disappears with its subject cannot tell you what happened to it deleteWhereInreturned how many ids it was asked about, not how many rows it deleted, while its own comment said "rows matched". The sweep's first run reported removing two reactions and two notification mutes when it had removed one and none. The counts go into the audit record, so that is a lie in the one place meant to say what happened. It countsRETURNINGrows now -execute()hands back an empty array for a plainDELETEwith no count on it to read
app/Actions/Pull/MergePullRequestAction.tsclosed issues withupdateTable(...).where('id', 'in', ids), which the query builder renders asin $1- so merging a pull request had never closed anything it said it closed. ThroughupdateWhereInfromapp/Actions/Support/rows.tsnowapp/Jobs/RepositoryMaintenanceJob.ts-git gcand repack, nightly at 03:30, plus the retention sweep above. Repositories are measured before being packed rather than run throughgc --auto: git's own thresholds are tuned for a person's working copy, and a forge receives pushes undertransfer.unpackLimitas loose objects, so it accumulates them far faster. Verified against a repository with 1205 loose objects: 4820 kB became one 2 kB pack- Initial commit options on create: README, .gitignore, license. Written with plumbing
(
hash-object,mktree,commit-tree,update-ref) rather than by checking out a worktree to make one commit. Off by default, which is the half that matters: a repository created to receive an existing history must be empty, or the first push is a non-fast-forward rejection against a commit nobody made - Ten licences, every text verbatim in
resources/licenses/*.txt. The long ones - Apache-2.0, GPL-3.0, AGPL-3.0, LGPL-3.0, MPL-2.0 - were fetched from apache.org, gnu.org and mozilla.org rather than typed, which is the only honest way to have them. Files rather than string literals: a thirty-five thousand character constant in a source file is a constant nobody reviews. The year and the holder are filled into the slot each document marks for them and nowhere else, so the three that have no such slot are left exactly as published
Views
-
resources/views/[owner]/[repository]/index.stx- tree and README, throughRepoBrowser -
The clone box on the repository page, built from the host the page arrived on rather than from configuration. The two disagree exactly when it matters - behind a proxy, on a second domain, on a port a developer picked, or on an instance whose operator never set
APP_URL- and a clone URL that is right in production and wrong on the machine you are standing at is a clone URL nobody trusts. The rule is inapp/Actions/Repo/cloneUrl.ts, so it is tested rather than written into a template -
Every page that says "not found" now answers 404. The status was declared in the source with
definePageMeta({ status }), read before anything runs, which is no use to a page that is only sometimes an error page: a repository, an issue or a settings page cannot know whether the thing exists until it has looked. Every one of them rendered "no such repository" under a 200, which tells a crawler, a cache and an uptime check that the page is fine.stxgainedsetResponseStatus()(0.2.155) and the render cache now carries the status with the HTML, so a cached not-found page does not go back to 200 on its second request**And for a long time after that was written, none of them did.** Two things were wrong, and each hid the other. `@stacksjs/bun-router`'s file-based view path - the one `route.serve()` uses, which is to say the API server, the e2e suite and a production boot - built the same Response for every page: status 200, no headers of the page's own. And the ask went through `resources/functions/http.ts`, a module, which could never work: `setResponseStatus` is a *context binding*, and the host builds a server script's scope out of the keys it supplies, so the name is in scope inside the script and nowhere else. A module that imports nothing of the sort can guard it with `typeof` all day and get `undefined` every time. So twenty-eight call sites across twenty-five views were asking a function that did nothing to set a status the host would have discarded anyway. Fixed at both ends: bun-router 0.0.23 hands the script `setResponseStatus`, `notFound` and `setResponseHeader` and answers with what the page asked for, and the binding is now taken at the top of each script - `const setStatus = typeof setResponseStatus === 'function' ? setResponseStatus : () => {}` - where it exists. The profile page's e2e test carried `expect(status).toBe(200)` with a note saying the day the router carried it through, this would fail and somebody would tighten it; it reads `404` now -
Every page in the product had the wrong name, and most had no name at all. The layout read
ReviewOS, which wants a variable called title, while a page sets one with@section('title', …)- so the seven pages that declared a title were ignored, and the other fifty-four named an identifier that does not exist.Where it got worse is the part worth keeping. On a page carrying a component with a client script - `CloneUrlBox`, which is on every repository page - that interpolation is not evaluated at all, so **the template's own source went into the browser tab**: every repository, tree, blob and settings page was called `ReviewOS` in the tab, in a bookmark, and in anything that scraped a link preview. Found by chasing what looked like a render failure while building the windowed blob view, and reproduced down to the one component. `@yield('title', 'ReviewOS')`, the way the marketing layout already did it, and the repository surface names itself: `owner/repo`, `path · owner/repo`, `Branches · owner/repo`. The regression test asserts no *markup* in a document's head is left unevaluated, which fails for any expression that stops being evaluated rather than only for this one. -
A trailing slash 404'd on pages that plainly exist.
/{owner}/{repository}/looked for{owner}/{repository}//index.stxand matched no dynamic route either. Fixed in stx 0.2.155; it is the same page -
resources/views/[owner]/[repository]/tree/[ref]/[...path].stx- a directory at a ref.Held by `tests/e2e/browse-tree.test.ts`, written against a repository with a file that exists only at the root and one that exists only inside a directory, because a page that renders the *wrong* directory looks exactly like a page that works. **One level deep is as far as it goes, and the reason is in the router rather than here.** `@stacksjs/router` matches a catch-all against exactly one segment - `route.get('/probe/{rest}*')` answers `/probe/a` and 404s `/probe/a/b`, with no view routing involved - so `/tree/main/app/nested` is unreachable. Written up with the reproduction in [phase 13](./13-mirroring.md), which is where it was first noticed and misattributed to stx's parameter binding. The API is unaffected: `TreeAction` takes `path` as a query parameter, so nothing routed carries the slashes. -
Every issue page was broken, and looked like a missing issue. The server script referred to
ownerHandleand declaredowner, so it threw on its first line, stx fell back to static extraction, and every variable rendered undefined - which lands on the "no such issue" branch. Silent by design, and indistinguishable from an issue number nobody has used. One name for one thing is the fix;STX_DEBUG=1is how to see it -
.../tree/[...path].stxand.../commits/[ref]/index.stx(a blob is the same route as a tree: the path either is a directory or it is not, which is one round trip rather than two) -
A branch with a slash in its name went to the wrong ref.
/tree/{ref}/{path}is ambiguous by construction:fix/rounding/srcis a branch calledfix/roundingholdingsrc, or a branch calledfixholdingrounding/src, and nothing in the URL says which. Splitting on the first slash sent everyfix/,feat/andrelease/branch - which is what branch names normally look like - to a ref that does not exist, git resolved nothing, and the page fell back to the default branch: the reader got other files under the name they clicked, with no error and no empty page.splitRefAndPathresolves it against the repository's actual refs, longest match first, andjoinRefAndPathbuilds the links, so reading a URL and writing one cannot disagree -
.../commit/[sha].stx- one commit, its message, its parents and the files it changed with counts. The file list only, no patch: a commit touching four hundred files would otherwise render the whole diff into one page, and the review screen is where a diff belongs. A merge says out loud that its numbers are against the first parent -
.../branches.stxand.../tags.stx, each row carrying the commit on the end of it. The default branch is named rather than left to be inferred from the order, and tags are sorted by the date they point at - alphabetical order puts v10 between v1 and v2, on exactly the list people come to read -
Every browse view was passing the wrong path to git.
repository.disk_pathis relative tostorage/repos, sogit --git-dir annaroberts/checkout.gitresolved against the server's working directory, found nothing, and every loader returned its empty answer - the commit history, the file tree and the README all rendered "nothing here" on repositories that were full, with no error anywhere.repositoryForViewnow returns the absolute path, so a page never has to know the layout -
.../releases.stx- newest version first, not newest publication, with the notes rendered and both the uploaded assets and the source archives (generated from the tag on demand, so they cannot drift from the tag they claim to be). A draft shows only to somebody who could have written it -
.../settings.stx- name, description, default branch, visibility, topics, and a danger zone that says what each thing costs before somebody presses it. Reading the repository is not enough to see the page: somebody who cannot change anything gets the same answer a stranger gets, because a settings page that renders read-only is one people file bugs about -
Every form in the product was returning 403. The CSRF middleware is double-submit and takes its value from an
x-csrf-tokenheader or a_tokenbody field. A single-page app reads the cookie and echoes the header; this application deliberately runs no client-side JavaScript, so its forms could send neither - and opening an issue, commenting, creating a label, creating a milestone and merging a pull request all failed before reaching an action.<CsrfField />puts the token in the body, in one component rather than a line per form, because a form that forgets it is a button that silently does nothing -
resources/views/new.stx- create a repository. The owner list is built from the same membership rule the endpoint enforces, because offering an owner the endpoint would refuse is handing somebody a 403 after they have filled in a form. The scaffold boxes start unticked and the page says why: a first commit here means the first push of an existing project is refused for not being a fast forward, against a commit nobody wrote, which reads as the forge being broken rather than as a choice made on this page -
Components:
RepoHeader,FileTree,CodeView,CloneUrlBox,BranchPicker,CommitList,MarkdownContent. Each one takes rows that are already decided - a name, a link, one piece of text - shaped byapp/Actions/Browse/rows.tswhere a test can reach them. A component with no<script>block cannot import anything and one with a script can, which is exactly what makes building links inside a template tempting; the link rule has already been wrong once, and it was wrong in a way no test could have caught while it lived in the markup.RepoBrowseris 203 lines rather than 352, and the four ways a file is not shown - missing, binary, too large, markdown - are one decision in the view rather than four branches in the template
Later in this phase
-
SSH transport, through ts-ssh.
./buddy git:sshservesgit clone,git fetchandgit pushover a key, on port 2222 by default because binding 22 needs root and a forge that asks to be run as root gets run as root. The host key is created on first start and never regenerated: one that changes between restarts makes every client that ever connected print the warning about a changed fingerprint, which is the warning that is supposed to mean something.What is in `app/Actions/Git/ssh.ts` is only what a protocol library must not decide for a forge - which key is whose (`ssh_keys`, by fingerprint), what a command string means, and whether it is allowed (`mayUseService`, the same function the HTTP routes ask, because a second opinion about permissions is two answers waiting to disagree). Everything below that is the package: the handshake in order, curve25519, AES-GCM and AES-CTR with encrypt-then-MAC, channels and their windows, and strict key exchange, which closes Terrapin. Rekeying matters more here than it looks. RFC 4253 asks for new keys after an hour or a gigabyte, which a clone of any large repository passes, and OpenSSH renegotiates on its own schedule whether or not a server is ready - one that is not leaves the client stuck at `rekeying in progress` and unable to send another byte. A clone survives that by accident because the client is only receiving; **a push does not**, which is the transport this forge cares about most. It is implemented and tested against the real client now Two things the wiring had to get right and one it had to fix. The command parser is not a shell - no expansion, no globbing, and nothing but the two services, because somebody with a valid key sends that string. `--stateless-rpc` is the HTTP framing and must not be passed here, or the client hangs waiting for a round nobody will send. And a push over SSH now carries the pusher into the hooks through `REVIEWOS_ACTOR_ID`: over HTTPS that comes from the Authorization header, and without it a bypass over SSH was recorded against nobody, which is the one thing the audit trail exists to prevent `tests/e2e/git-ssh.test.ts` runs the real git and ssh clients at it: the clone names the file it expects, because a transport that serves the wrong repository looks perfect from the client's side. A stranger's registered key can read the public repository and cannot push to it, cannot see the private one, and an unregistered key cannot connect at all -
Git LFS, through ts-git-lfs - a package of its own, because pointer files and the batch API are a specification anybody implementing LFS needs and not something a forge should own. What is wired here is the three things it will not decide for a host: where objects live (
storage/lfs/{owner}/{name}, beside the bare repository and never inside it, so an LFS object never appears ingit count-objectsand makes the size accounting wrong), who may read and write (mayUseService, the same function the wire protocol asks - a second opinion about permissions is a bug waiting for the two to disagree), and where locks live (repository_lfs_locks, because a lock a deploy forgets is a lock somebody was relying on)- A real
git lfsclient found a bug eight passing tests did not. An anonymous client is refused with 401 and a challenge, not 403.git lfstries anonymously first - it cannot know whether a public repository needs a credential to push to - and it treats 403 as final, so the push failed with "you may not write to this repository" while the client was holding a perfectly good token. Every test sent credentials, so every test passed - The client cases are behind
REVIEWOS_LFS_CLIENT_TESTS=1. Spawning a Go binary is what has to be survivable rather than what is being tested, and on a host whose swap is exhausted the kernel kills the process group rather than the allocation
- A real
-
Commit signature verification against registered GPG keys, on the commit page.
app/Actions/Git/signature.tsreads the signature off a commit object and decides which registered keys could have made it;verify.tsbuilds a throwaway keyring from those keys and asks git to verify, rather than running gpg itself - the same rule as the rest of this phase, and it also avoids owning the payload reconstruction, which is the one computation here where being slightly wrong accuses somebody of forging a commit they wrote. Two rules it will not bend on, both tested: a good signature by a key nobody registered is not verified, and a good signature by a key that does not claim the commit's author address is not verified either - anybody can sign a commit claiming to be somebody else.gnupg.orgis a declared pantry dependency, besidegit, for the same reason: the binary does the cryptography rather than a reimplementation of OpenPGP in TypeScript. There is a gpg, and the gpg-dependent tests pass against it.REVIEWOS_GPG_TESTS=1 bun test tests/e2e/git-signature.test.tsis 8 passing, the good-signature-by-a-registered-key case among them, soverify.tsis proven against a real gpg rather than only in a shell. This has now been wrong twice, and both wrong answers cost more than the thing they described. First the blocker was recorded as memory pressure - gpg allocates locked, unswappable secure memory, and on a machine whose swap is exhausted the kernel kills the whole process group rather than the allocation. That did happen, but it is not why this was stuck. Then it was recorded aspantry install gnupg.orgreporting twenty-eight packages installed and placing none of them. That was also wrong, and in the more expensive direction: it blamed a tool that was working. gnupg.org installed correctly every time. A project install goes to<project>/pantry/, not to the pantry root -pantry/gnupg.org/v2.4.8/bin/gpg, symlinked intopantry/.bin/gpg, which runs and reports 2.4.8. Searching~/.local/share/pantry,/.pkgx,/.pantryand~/Librarywas searching everywhere except where it is. What made the install look silent was two separate reporting bugs in pantry, both since fixed upstream (fix(list),fix(install)):pantry listreaddata_dir/packages, a directory that has never existed - installs go todata_dir/global/packages- and it never looked at project installs at all, so it answered "0 package(s) installed" on a machine with a full store. And everypantry installappended a duplicate key todeps.yamlrather than re-pinning the existing one, which is where this file's fourgnupg.org: ^2.4.8lines came from.which gpgfinding nothing was neither of those: the pantry shell hook puts<project>/pantry/.binonPATHoncd, so a shell already sitting in the project when the install ran never picked it up. After acdinto the project,which gpgresolves. The gate stays.REVIEWOS_GPG_TESTS=1was written for the kernel-kill risk, not for the absence of a binary, and that reason is untouched by having one: a process killed by the OOM killer takes the whole run down and reports nothing, and it cannot be probed for from inside the process it would kill. Opting in is a judgement about the host, which CI should make- Wired up.
app/Actions/Git/signatures.tsis the database half - the keys come out ofgpg_keys, the signer is resolved to a person - andsignatureBadgeinapp/Actions/Browse/rows.tsdecides what a reader is told, because that is a judgement rather than a formatting step. Unverified and invalid are different claims and only one of them accuses somebody: a key nobody registered may be perfectly good, so it reads as "we do not know" rather than "we know this is wrong". An unsigned commit gets no badge at all - most commits are unsigned, and a mark on nearly every row is a mark people learn to ignore, which is the same mark that has to mean something on the day a signature is bad - On the commit page and not on the history. One commit is one gpg process, which is fine for
a page and is not fine for a list of thirty. The list wants the answer stored rather than
recomputed, and storing it wants a
commitstable that does not exist yet - so a slow list was the alternative, and nobody waits four seconds to read a subject line - Found the last thing standing between the tests passing and the feature working.
gitfindsgpgonPATH, and pantry's shell hook only puts<project>/pantry/.binthere when a shellcds into the project. A server process has not been anywhere: a systemd unit, a DockerCMD, abun testrun all start without it. So every signature on a correctly configured instance would read "this server could not check the signature", with the binary installed, declared, and sitting on disk.gitEnvironmentinapp/Actions/Git/git.tsputs it back for every git child. Appended rather than prepended, which is a deliberate retreat: putting it first makes the project's git win over the host's, which sounds tidier and broke three wire-protocol tests. Appending fills a gap rather than taking a decision away tests/unit/git-environment.test.tspins the order, because "the host's PATH comes first" is exactly the kind of thing somebody tidies back explicitly rather than inherit
- Wired up.
Browsing
-
resources/components/RepoBrowser.stx: tree, file and README in one component, so the root route and the deep-path route cannot drift apart -
resources/functions/browse.ts: ls-tree parsing, sorting, breadcrumbs, README detection, sizes - all pure and tested away from git -
app/Actions/Browse/load.ts:listTree,readBlob,lastCommit,branchNames -
Binary and oversized files are declined rather than streamed into the page
-
30 unit tests, including a filename containing a newline, which is legal and is why the listing is NUL-delimited rather than line-delimited
-
Branch picker, a plain
<details>so it works before any JavaScript runs -
Syntax highlighting in the file view, server-side, sharing one token palette with the diff via the layout so the two cannot disagree about what a keyword looks like
-
File view verified in a browser:
config/app.tsrenders 33 numbered lines with keyword, string and comment tokens -
Tag picker alongside branches, newest first and capped at 30
-
Commit history view
-
Commit history at
/owner/repo/commits/ref -
tagNamesandcommitHistoryloaders, NUL-delimited for the same reason the tree listing is -
Tag picker in the ref menu, and browsing at a tag verified
Catch-all routing, resolved
Deep paths now work: /stacks/stacks/tree/main/storage/framework renders that directory, and
/stacks/stacks/tree/v0.70.230/app browses at a tag.
It took four fixes, because stx compiled routes in four separate places and each got catch-alls wrong differently:
stx-routercollected parameter names in the order its three replace-sweeps ran, while capture groups end up in pattern order, so any pattern mixing a catch-all with ordinary segments bound every value to the wrong name- the dev server built names straight from the brackets, keeping the dots (
params['...path']rather thanparams.path) and compiling the segment to([^/]+), which cannot span a separator - the production server captured
:namegreedily, takingpath*including the asterisk - SSR matched
\w+, and an asterisk is not a word character, so a catch-all route did not exist there at all
Three now share stx-router's compiler, which was already correct; the fourth keeps its own because
it emits several alternates per file, but no longer disagrees about what a catch-all means. Released
as stx 0.2.151.
The app also carried eight copies of stx-router at three versions, which is why patching one never
took effect. overrides pins a single version.
-
The repository page says what the repository is, not only what is in it
Everything in the About panel already existed and none of it was on a page. Topics have had their own table since phase 6 and are imported from every GitHub mirror, and nothing anywhere linked to
/explore?topic=…- so the query that justifies topics being a table rather than a string was unreachable from the one screen that showed them. Languages are measured byMeasureLanguagesJoband were read only by the explore screen. Releases have a table, a page and a tested rule for which one is latest. The licence, code of conduct, contributing guide and security policy are files in the tree the browse screen has already listed by the time it draws anything.So the panel is mostly a matter of putting what is known where somebody is looking. The two decisions in it that are rules rather than markup are in
app/Actions/Repo/about.tsand tested: which spellings of each health file count (including the.github/copies, with the root winning), and which licence aLICENSEfile is - matched on the distinctive line each one leads with, and null rather than a guess, because somebody deciding whether they can ship this reads that word and believes it.Root only. It describes the repository, and a reader three directories down is looking at a directory.
Deliberately not in it: a homepage link, which needs a column nothing has yet and which the mirror metadata sync would then want to import; and a contributors list, which means
git shortlogover the whole history on every page load - GitHub precomputes that for a reason. -
The last-commit bar goes somewhere, and so does the history
It drew a sha, a subject and an author, and none of the three was a link - so the commit it named and the history behind it were each one hop away and neither was reachable from the screen that named them.
The commit list route became a catch-all in the process, for the reason
/tree/is one:fix/roundingis a branch, and a route taking one segment answered 404 for every repository whose branches have slashes in them. The rest of the URL after the ref is a path, socommits/main/src/parser.tsis the history of one file. -
Somebody can star a repository, and choose what they hear about it
starsandwatcheshave had a table, a model, an endpoint and a unique index since phase 1, and no control anywhere in the interface. The only way to star a repository on this forge was to post to the API by hand, andstars_countwas a column the seeder filled and nothing else ever moved.Both are forms, because these pages run no client-side JavaScript - so both endpoints answer a browser with a redirect back to the page and a script with the JSON they always sent, and
/repos/watchesgained POST because HTML cannot send PUT. Drawn on all twelve screens that shareRepoHeader: a star button that appears on one tab and vanishes on the next teaches a reader that the repository changed between two views of itself.Writing the test found a second bug.
WatchActionhas always documented an empty ornonesubscription as "stop watching" - a state deliberately distinct fromignore- while its validation rule listed only the three stored values, so the one way to clear a watch was refused with a 422 naming a value the endpoint's own documentation tells you to send. -
A homepage on a repository, and a contributors list that does not cost a history walk
The two things the About panel was written without, and each was deferred for a different reason - one needed a column, and one needed to not be computed on read.
The homepage is a column, a settings field, and an import: GitHub has the field and
MirrorMetadataSyncJobnow carries it across, so a mirrored repository arrives with it filled in. The one interesting part is that it is a security decision rather than a text field. It becomes anhrefon a public page, sousableHomepageallowshttpandhttpsand nothing else - an allowlist, because a denylist has to be right about every scheme a browser has ever supported and only has to be wrong once - and the check runs where the value is set rather than where it is rendered, since the render happens in several places and the one somebody forgets is the one that ships. The mirror import goes through the same function, so a mirror cannot be a way past a rule the form enforces. A barestacksjs.comis completed tohttps://; something with no dot in it is refused, so a typo does not become a link to a path on this site.The contributors list is measured and stored, following
repository_languagesexactly: a table, a job on thesearchqueue, and a dispatch from the push hook.git shortlog -sne --no-mergesrather than counting a log in TypeScript, because git groups in C and prints one line per person - so the output is bounded by the number of contributors rather than by the number of commits. Merges are excluded, or the list becomes a list of who has merge rights rather than of who wrote the code.A contributor is an email address. Git's author is a name and an address chosen by whoever ran the commit, with no connection to an account here, and on a mirror almost nobody has one - so a local account is attached only on an exact address match, the rest are named without a link, and somebody with three addresses appears three times. Every cleverer rule guesses that two strangers are one person, and that guess credits somebody's work to somebody else. The address itself is never rendered: it is the key the table is grouped by, and a public page is a different audience from somebody who has cloned the repository and can read
git log.Three bugs fell out of building it.
MirrorSyncJobnever queued either measure - onlyProcessPushJobdid, and a mirror does not go through it. No mirrored repository has ever had a language breakdown or a contributor list, which on an instance like this one is most of them.findRepositoryByPathprojects an explicit column list, sohomepageread asundefinedon every page until it was added to it - the same failure mode the merge settings had, and the comment there now has company.And
featured.tscalledinnerJoinwith three arguments where the query builder needs four. It throws at execution, the catch around it swallowed it, and no repository on the explore or featured lists has ever shown a language. The catch was there for an instance that had never run the measure; it hid a bug in the query instead.