Skip to content

Commit 6b6ee36

Browse files
forge: forks, compare, and pull requests with real merges (tier 3a)
Fork = git clone --local into your namespace (WebID or did:nostr hex), lineage in git config forge.parent. Compare spans forks via forge- internal path fetches into reusable hidden refs. PRs keep separate numbering (documented deviation) and reuse the issues machinery verbatim — PR bodies and review comments are pod resources too, same placeholder-on-delete. Merge is bare-repo-safe modern git: merge-tree --write-tree (501 below git 2.38), commit-tree with two parents, and a compare-and-swap update-ref so a moved base is an honest 409, never a clobber; ff-when-possible recorded as such; conflicts surface the GitHub-style banner with the conflicted paths and the PR stays open. Merged PRs freeze {baseSha, headSha} so their tabs stay exact forever. Findings: merge-tree as a version precondition; git 2.53 treats forge- internal path fetches as user-initiated (no protocol.file.allow needed, the bounded override pre-documented); refs/forge/* is advertised by upload-pack — uploadpack.hideRefs named as the cure. 72/72 tests.
1 parent 8a45bd1 commit 6b6ee36

3 files changed

Lines changed: 1492 additions & 30 deletions

File tree

forge/README.md

Lines changed: 159 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
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

33
The useful slice of Gogs/Gitea as a JSS plugin: push a repo over smart
44
HTTP, 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
99
architecture below). Tier 2.5 makes **did:nostr agents first-class**
1010
(hex-pubkey namespaces, a NIP-98 → push-token exchange, forge-hosted
1111
issue 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
1316
framework: every page is server-rendered HTML with inline CSS, all git
1417
work is done by the system `git` binary, and the wire protocol is
1518
delegated 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+
107150
Errors are `{ error }` with 4xx. `cloneUrl` is absolute: the origin comes
108151
from `api.serverInfo` (#601) at request time, with `config.baseUrl` as
109152
the reverse-proxy override.
@@ -182,6 +225,73 @@ Every issues page embeds one dependency-free inline
182225
With JS off the pages stay fully readable and a `<noscript>` note says
183226
interactive 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

Comments
 (0)