1- # forge — a personal git forge (tier 1: hosting + browsing; tier 2: issues; tier 2.5: nostr agents + xlogin)
1+ # forge — a personal git forge (tier 1: hosting + browsing; tier 2: issues; tier 2.5: nostr agents + xlogin; tier 3a: forks + pull requests )
22
33The useful slice of Gogs/Gitea as a JSS plugin: push a repo over smart
44HTTP, get a GitHub-style (light theme) web UI for it — repo list, file
@@ -9,7 +9,10 @@ the author's pod** (the forge keeps only a pointer index — see the
99architecture below). Tier 2.5 makes ** did: nostr agents first-class**
1010(hex-pubkey namespaces, a NIP-98 → push-token exchange, forge-hosted
1111issue bodies for podless agents) and puts the vendored ** xlogin** widget
12- on the issues pages. Zero npm dependencies, zero build step, no
12+ on the issues pages. Tier 3a adds ** forks, compare, and pull requests
13+ with real merges** (` merge-tree --write-tree ` + ` commit-tree ` +
14+ compare-and-swap ` update-ref ` — see "Forks & pull requests").
15+ Zero npm dependencies, zero build step, no
1316framework: every page is server-rendered HTML with inline CSS, all git
1417work is done by the system ` git ` binary, and the wire protocol is
1518delegated to the stock ` git-http-backend ` CGI (gitscratch's proven
@@ -70,6 +73,11 @@ git -c http.extraHeader="Authorization: Bearer <token>" push forge main
7073| ` .../issues?state=open\|closed&page=N ` | issue list: GitHub-style filter tabs, green open / purple closed icons, relative times, comment counts |
7174| ` .../issues/<n> ` | thread: issue body then comments in comment boxes (identicon, author → WebID link, relative time, ` owner ` badge), markdown bodies |
7275| ` .../issues/new ` | new-issue form (vanilla-JS client, see below) |
76+ | ` .../compare/<base>...[<owner>:]<ref> ` | compare view: ahead/behind counts, the ahead commit list, the structured diff, an "Open pull request" button |
77+ | ` .../pulls?state=open\|merged\|closed&page=N ` | PR list: three-state filter tabs — green open, purple merged, red closed |
78+ | ` .../pulls/<n> ` | PR conversation: state banner (merged / closed / clean-with-merge-button / conflict list), thread, comment form |
79+ | ` .../pulls/<n>/commits ` , ` .../pulls/<n>/files ` | GitHub-style sub-tabs: the ahead commits, the structured diff |
80+ | ` .../pulls/new?base=...&head=... ` | new-PR form (from the compare page) |
7381| ` <prefix>/<owner>/<name>.git/... ` | git smart HTTP (` info/refs ` , ` git-upload-pack ` , ` git-receive-pack ` ) |
7482| ` <prefix>/api/token ` | POST: exchange any ` getAgent ` credential for a push token (see "Nostr agents") |
7583| ` <prefix>/api/hosted/<hex>/<uuid> ` | GET (public) / DELETE (author-only): a podless agent's hosted issue words |
@@ -104,6 +112,41 @@ renderer's already-escaped HTML, safe to inject as markup. Shapes:
104112 files: [ { name, binary, adds, dels, hunks: [ { header,
105113 lines: [ { type: 'add'|'del'|'ctx'|'meta', oldLine, newLine, text }] }] }] }`
106114
115+ Tier 3a (additive):
116+
117+ - ` api/repos/<o>/<n> ` gains ` parent ` (` "<owner>/<name>" | null ` — fork
118+ lineage) and ` forks ` (count); list entries gain ` parent ` .
119+ - ` POST api/repos/<o>/<n>/fork ` (authed; optional ` {name} ` override so
120+ you can fork your own repo under a new name) → 201
121+ ` { owner, name, parent, url, cloneUrl } ` ; 409 if the target exists.
122+ - ` GET .../compare/<base>...[<owner>:]<ref> ` →
123+ `{ base: {ref, sha}, head: {owner, repo, ref, sha}, aheadBy, behindBy,
124+ mergeBase, hasMore, commits, files }` (same commit/diff shapes as
125+ above — one parser, every surface).
126+ - ` GET .../pulls?state=open|merged|closed&page=N ` →
127+ `{ state, page, perPage, hasMore, openCount, mergedCount, closedCount,
128+ pulls: [ { number, title, state, author, authorInfo, createdAt, base,
129+ head: {owner, repo, ref}, merged, comments }] }`
130+ - ` GET .../pulls/<n> ` → the list fields plus ` baseSha ` (the CAS token the
131+ UI hands back at merge time), ` head.sha ` , ` mergeable ` (` true|false|null `
132+ — null when an end is gone or merge-tree is unavailable), ` conflicts `
133+ (paths, from merge-tree), ` merged ` (`{sha, mergedBy, at, baseSha,
134+ headSha, fastForward} | null` ) and the resolved ` thread` (issues shape
135+ verbatim).
136+ - ` POST .../pulls ` ` {title, body, base, head} ` → 201
137+ ` { number, url, resourceUrl, hosted? } ` — body stored in the author's
138+ pod (or forge-hosted for podless agents), exactly like issues. 422 when
139+ the head is unresolvable or there are no commits between base and head.
140+ - ` POST .../pulls/<n>/comments ` ` {body} ` , ` POST .../pulls/<n>/close ` /
141+ ` .../reopen ` — the issues beats (owner or PR author; merged is final,
142+ 422).
143+ - ` POST .../pulls/<n>/merge ` ` {expectedBase?} ` (TARGET repo owner only) →
144+ 200 ` { number, state: 'merged', sha, fastForward } ` ; 409
145+ ` { error: 'merge conflict', conflicts } ` or 409 when the base moved
146+ (stale ` expectedBase ` , or a race caught by update-ref's old-value
147+ guard); 501 naming the git version when ` merge-tree --write-tree ` is
148+ missing (needs git ≥ 2.38).
149+
107150Errors are ` { error } ` with 4xx. ` cloneUrl ` is absolute: the origin comes
108151from ` api.serverInfo ` (#601 ) at request time, with ` config.baseUrl ` as
109152the reverse-proxy override.
@@ -182,6 +225,73 @@ Every issues page embeds one dependency-free inline
182225With JS off the pages stay fully readable and a ` <noscript> ` note says
183226interactive actions need JavaScript and sign-in.
184227
228+ ## Forks & pull requests (tier 3a)
229+
230+ The flow: ** fork** (` POST api/repos/<o>/<n>/fork ` ) runs `git clone
231+ --local --bare` into the caller's namespace — same repo name, 409 on
232+ collision, optional ` {name} ` override (that's how you fork your * own*
233+ repo, which GitHub also allows; a same-name self-fork would always
234+ collide). Lineage is recorded in the fork's bare-repo config
235+ (` forge.parent = <o>/<n> ` ); the fork's home and the repo lists show
236+ "forked from …", the parent shows a fork count, and a freshly pushed
237+ non-default branch on a fork earns an "open a pull request?" hint.
238+ ** Compare** (` /compare/<base>...<head> ` ) takes a ref of THIS repo as
239+ base and either a same-repo ref or ` <owner>:<ref> ` as head. The fork
240+ rule is deliberately simple and name-based: * the head owner's repo with
241+ the same name* — lineage chains are not chased. Cross-repo heads are
242+ fetched ** by filesystem path** (both ends forge-owned, never a
243+ user-supplied URL) into a hidden, reusable ref in the base repo
244+ (` refs/forge/heads/<owner>/<ref> ` , force-updated per call — repeat
245+ compares refresh it, nothing to clean up). A ** pull request** is the
246+ issues architecture verbatim: the body/comments live in their authors'
247+ pods (or forge-hosted for podless nostr agents), the spine lives in
248+ ` pluginDir/pulls/<owner>/<repo>.json ` with the same atomic-write +
249+ per-repo-lock discipline, plus base ref, head ` {owner, repo, ref} ` and
250+ merge info.
251+
252+ ** Merge semantics — real git, bare-repo safe.** The target repos are
253+ bare, so nothing ever checks out a worktree:
254+
255+ 1 . head objects are path-fetched into the base repo (as in compare);
256+ 2 . ` git merge-tree --write-tree ` (git ≥ 2.38 — probed at activate; the
257+ merge route answers ** 501 naming the installed version** when it's
258+ missing) computes the merged tree, or reports the conflicted paths,
259+ which the PR page renders as a GitHub-style "this branch has
260+ conflicts" banner (merge button withheld, PR stays open, the API says
261+ 409 + ` conflicts ` );
262+ 3 . a clean tree is committed with ` git commit-tree ` — ** two parents,
263+ honest authorship** : author = the merging agent's display id with a
264+ synthesized ` <owner>@forge.invalid ` email, committer = ` forge ` ,
265+ message ` Merge pull request #N from <owner>:<ref> ` ;
266+ 4 . ` git update-ref refs/heads/<base> <new> <old> ` lands it with an
267+ ** old-value guard — the compare-and-swap** . The PR page pins the base
268+ sha it rendered (` baseSha ` in the detail JSON, ` expectedBase ` in the
269+ merge POST); if the base moved since the diff the merger saw, the
270+ route answers 409 before touching anything, and even a race between
271+ the route's own read and the ref write is caught by update-ref
272+ itself.
273+
274+ ** ff-when-possible policy** : when the base is an ancestor of the head,
275+ the merge is a plain compare-and-swapped ` update-ref ` — no synthetic
276+ merge commit — recorded as merged with ` fastForward: true ` and the head
277+ sha as the merge sha. (GitHub's default always creates a merge commit;
278+ this forge prefers not to invent history where none is needed. The
279+ Commits/Files tabs of a merged PR stay exact either way: the shas are
280+ frozen in the merge record.)
281+
282+ ** Separate numbering, documented deviation** : GitHub numbers issues and
283+ PRs from one shared counter; here ` pulls/<owner>/<repo>.json ` numbers
284+ independently of the issues index. Sharing would entangle every PR
285+ write with the issues index (one more lock, one more file rewritten,
286+ crossed failure modes) for no user value in a personal forge — ` #N ` is
287+ unambiguous because issues and pulls live under different routes.
288+
289+ Auth: fork needs any authenticated agent (it writes to the CALLER's
290+ namespace); PR create/comment need an authenticated agent with
291+ somewhere to keep the words (pod or hosted); close/reopen are for the
292+ target repo owner or the PR author; ** merge is target repo owner
293+ only** . Anonymous writes are 401, everything else 403 — house rules.
294+
185295## Nostr agents (tier 2.5)
186296
187297### Identity model — hex canonical, npub display-only
@@ -334,8 +444,9 @@ detection is a NUL sniff over the first 8 KB.
334444 bundle came from. A ` <pre> ` with line numbers covers tier-1 browsing;
335445 highlighting is a candidate for a later wave * if* it can be done
336446 server-side and dependency-free.
337- - ** No search, no PRs/webhooks** — later tiers (see Findings 3). Issues
338- arrived in tier 2; PRs want the same pod-native treatment.
447+ - ** No search, no webhooks** — later tiers (see Findings 3). Issues
448+ arrived in tier 2; PRs arrived in tier 3a with the same pod-native
449+ treatment (bodies in pods, spine in pluginDir).
339450- ** No browser-git** — every byte of git logic is the system binary;
340451 server-side rendering made the old client bundle unnecessary.
341452
@@ -536,3 +647,47 @@ signing real events in the tests:
536647 round-trip on the auth hot path that the agent cannot opt out of.
537648 One more reason the token exchange is the right shape: it pays that
538649 cost once per TTL, not per request.
650+
651+ ### 13. merge-tree --write-tree is the whole trick — and its availability is a real precondition
652+
653+ Everything GitHub does with a merge queue and a worktree farm, a bare
654+ repo can do with three plumbing commands: ` merge-tree --write-tree `
655+ (compute the merged tree OR the conflicted paths, no worktree),
656+ ` commit-tree -p A -p B ` (make the two-parent commit), `update-ref
657+ <ref > <new > <old >` (land it atomically with a compare-and-swap). But
658+ ` merge-tree --write-tree ` only exists since git 2.38 (2022); on older
659+ gits there is NO bare-safe conflict-aware merge without a scratch
660+ clone. The forge probes ` git --version ` at activate and the merge route
661+ answers 501 naming the installed version rather than pretending — the
662+ dev machine's git 2.53.0 is fine, and the conflict output's
663+ ` --name-only ` section parses cleanly into the banner's path list. One
664+ sharp edge found while parsing: merge-tree signals conflicts via ** exit
665+ code 1** , so the child-process wrapper must treat exit 1 + stdout as
666+ data, not as failure.
667+
668+ ### 14. protocol.file.allow was NOT needed — direct path fetches still count as user-initiated
669+
670+ The CVE-2022 -39253 hardening (git ≥ 2.38.1) demoted ` protocol.file.allow `
671+ to ` user ` , which kills ` file:// ` /local-path transports ** when triggered
672+ indirectly** (submodule clones). The forge's cross-repo fetches
673+ (` git -C <base> fetch <abs-path-to-head> +refs/heads/x:refs/forge/... ` )
674+ are direct top-level invocations, which the ` user ` default still
675+ permits — measured on git 2.53.0: no ` -c protocol.file.allow=always `
676+ override was required. Written down because it is one hardening release
677+ away from mattering: if a future git demotes direct local fetches too,
678+ the per-invocation override is the correct, bounded fix — both ends are
679+ forge-owned paths under pluginDir, no user-supplied URLs ever reach the
680+ fetch argv (owner/name/ref are strictly validated first).
681+
682+ ### 15. Hidden refs are visible refs: refs/forge/* rides the wire, harmlessly
683+
684+ The reusable compare refs (` refs/forge/heads/<owner>/<ref> ` ) live
685+ outside refs/heads and refs/tags, so no UI surface lists them — but
686+ ` git ls-remote ` shows them and a fetching client may copy them, because
687+ upload-pack advertises everything. That is accepted, not accidental:
688+ the objects they pin are exactly the fork branches a compare or PR
689+ already published, so nothing leaks; the cost is advertisement noise
690+ proportional to compared branches. The alternative (fetch into a
691+ temp ref + delete) re-downloads objects on every compare AND still
692+ leaves the objects in the odb until gc. If it ever grates,
693+ ` uploadpack.hideRefs=refs/forge ` per repo is the one-line cure.
0 commit comments