Fork button next to Clone on the code page + fork-creation page (owner/name/visibility/branch/description) and a completed server-side fork operation #424
Labels
No labels
actions
bug
cli
duplicate
enhancement
fork
forum
git storage
help wanted
insights
invalid
issues
moderation
oidc
ownership transfer
packages
pr/merge protection rules
projects
pull requests
question
releases
sponsorships
tags
webhooks
wiki
wontfix
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
crueber/walhub#424
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Fork button on the code page + fork-creation form + completed fork operation
What's requested
web/src/pages/Repo.jsx, same pill row as<CloneMenu>— line ~98). It navigates to a fork-creation page (route under the repo, e.g./:owner/:repo/fork), styled with the existing pill/CTA vocabulary.web/src/pages/Fork.jsx, new), Forgejo-inspired — same shape, our own design language (cite the siblingNew.jsxcreate-repo form as the in-repo reference implementation for field layout, visibility control, and the submit pattern). Fields:<repo>-fork, matching the server default; validate with the existing repo-part rules; show a taken-name conflict inline, not just a toast).internal/api/placeholder.goaccepts exactlypublic|authenticated|private).internal/api/env.go:399-401), so the fork flow must either accept a settings write post-create or the fork input must thread it — planner's call.StartFork/runFork(internal/pulls/merge.go:548,:596) only writes the collaboration objects and narrates the manifest step:POST /api/v1/repos/{owner}/{repo}/forkstop-level twins (internal/pulls/http.go:32,:278-299);ForkInput{target_owner?, name?}(merge.go:531);fork.jsonCreate-once provenance (ForkKey,pulls.go:285;ForkDocmodel.go:147); parentmeta/forks.jsonCAS'd child index (merge.go:609-631); social forks counter via the nil-safeForksCounterseam (merge.go:637-643→internal/social/service.go:354 IncForks);(repo, kind)single-flight task with 202 + TaskRecord; SDKrepo.forks.fork()(web/sdk/src/pulls.js:71-79); API docs page lists it (Apidocs.jsx:70).ForkExecutor.ShareManifest(merge.go:543-545) is nil in this wave — no fork manifest is created, so a "successful" fork leaves an unborn prefix (repos/<o2>/<r2>/fork.jsonwith no child manifest; the #150 race is permanent, not transient). Wire the executor at composition (cmd/walhub): copy the parent manifest referencing the shared pack set verbatim, fresh refs snapshot + checkpoint,Createchildmanifest.pbwithmin_seq = seq+1— perdocs/features/03_pull_requests.md§7, which specifies all of this and is NOT yet implemented. The fork-network GC rule (maintain consults children's manifests before pack deletion) must land in the same change or packs can be collected out from under a live fork.ForkInputhas novisibility,branch, ordescription. Extend it (or accept follow-up settings writes) so the form's fields are real; define the server behavior for each (visibility threaded toEnsureRepoAccess; branch = a selected ref becomes the child's Head in the fresh refs snapshot; description per the settings-TOML note above). Reject explicitly what is out of scope rather than silently ignoring fields.fork.jsonparent pointer +meta/forks.json) but nothing renders it: no summary projection (internal/api/summary.gohas no fork field; mind the ETag contract — any new summary field must be covered by the ETag,~-suffix precedent), and no UI shows "forked from<parent>" on the repo page. Add the projection and render the indication in the repo header (near the title/identity block, composed into the existing layout — not an orphaned row), linking to the parent repo. Grafted onto this ticket because the parent pointer is the same feature's data; if the projection is contentious it can be sequenced, but the fork page is not shippable without the completed fork operation.Acceptance criteria
ForkExecutoris wired: a completed fork yields a servable child repo (manifest + shared packs + refs snapshot), not an unborn prefix.fork.json/meta/forks.json/social counter behavior unchanged (tests already pin these).node --testandgo testgreen.Amendment (user-directed): fork-network tracking is a first-class requirement
The fork relationship graph carries valuable information and must be tracked, counted, and queryable — not just displayed once as a header line. Folding into this ticket's server-side scope:
ForksCounterseam already exists — extend the summary projection so the count is version-keyed in the ETag like the other counters, per the #382 mutability rule). The count updates on fork creation (and on fork-network GC if §7 enforcement lands).meta/forks.jsonCAS index must be a queryable, listable relation —GET /repos/{o}/{r}/forksreturns the live fork list (owner, name, forked-at timestamp), paginated per the API conventions. The index is the source of truth; the counter derives from it (never independently maintained).fork.jsonprovenance must record its immediate parent AND the root ancestor (the network is a tree, not just one hop), so "where they are" is answerable for the whole network from any node. Verify the existing Create-once provenance captures both or extend it.no-cache/ccMutable class, version-keyed ETag).These are requirements of this ticket, not follow-ups: a fork that lands without its count/index/network records intact is not done.
Fixes in #431 (fix/issue-424, against main — review only, do not merge).
All 7 acceptance criteria: Fork button + Fork.jsx (owner/name/visibility/branch/description, inline conflicts) + servable child via wired ForkExecutor + fork-network GC + unchanged fork.json/forks.json/counter pins + forked-from + ETag + headless/Go tests (race, cover gates hold).
Two findings beyond the plan, both documented in 03 Decisions: (1) repo-prefixed pack keys cannot serve verbatim references, so §7 sharing rides a failure-path ancestor read fallback (law-12 amendment); (2) merge/update-branch commits never reached the bucket (merged parents unclonable) — fixed in the same change via PackTip + atomic pack-carrying publish, required for any fork of a merged repo.
Review #431 (fix/issue-424) — findings
Verified in a scratch worktree; one fix commit pushed to
origin/fix/issue-424(1d90aff). No browser drive per instructions (tests + reasoning; noted explicitly below).Fixed during review (pushed as
1d90aff)GC fail-open edges, now fail-closed (
internal/maintain/forknet.go): a child-manifest GET error other than 404 (e.g. a transient store 500) skipped the subtree and the sweep proceeded to delete — packs of a live fork unprotected. Now: 404 → skip (deleted fork pins nothing, pinned byTestForkNetworkGCDeletedChild); any other transport error → abort with nothing deleted. Same treatment for corrupt child manifests and unreadable child indexes (were skip → now abort), and for probe-cap exhaustion with unvisited children remaining (was silent partial live set → now aborts; triggers at 33+ direct forks). Rationale: deleted packs are unrecoverable, a deferred sweep just retries. Four new regression tests (TestForkNetworkGCTransientChildError,...ChildIndexError,...CorruptChild,...CapExceeded);docs/features/03_pull_requests.md§7 Decisions records the rule (law 12). Maintain coverage 95.3% → 95.5%.Non-blocking finding (follow-up, precise remediation)
Post-share failure strands the target name (
internal/pulls/merge.gorunFork+StartForkpre-check): commit order is share (manifest Create) → access → fork.json, but the sync-409 pre-check fires when EITHER fork.json OR the child manifest exists, while the task adopt path only rescues when fork.json is ours. Consequences, all verified by code reading: (a) crash between manifest Create and fork.json → retry hits sync 409 on the manifest; (b) transientAccessBootfailure after share → same; (c) crash between the two checkpoint PUTs → retry 412s on orphan checkpoints → fork.json absent → task 409. Each permanently burns the name (no in-change repair path). No data loss — orphans are unreferenced — but recovery is manual bucket cleanup. Remediation options: (i) provenance-before-share plus a rescue path inStartFork(fork.json ours + manifest absent → proceed to adopt); (ii) manifest-aware adopt (verify child packs == parent packs verbatim + HeadSeq match, then Create fork.json); (iii) for (c) only, semantic-compare on checkpoint 412 (seq/packs/refs/head, ignoring timestamps) then continue to the authoritative manifest Create. Recommend (i)+(ii) as follow-up; not merge-blocking.Checked green (adversarial pass)
ShareExecutorcopies the pack set verbatim (forkexec.go:176-183), fresh sorted refs snapshot + checkpoint under the child prefix, child manifest Create withmin_seq=head+1, rev 1 (:198-217);consistentParentmanifest→refs→manifest stability gate (:233-271); closure HEAD-check fails loud on a missing pack (:145-157); empty parent → fresh empty manifest, branch-on-unborn rejected (:103-122). Taken-name sync-409 pre-check + task CAS adopt. E2E polls the child summary (fork_parent+ description), lists the live index, pushes a cross-fork branch and opens the cross-fork PR — servable confirmed. Note: no pure cold-clone assertion exists; the fork push exercises parent-object resolution through the fallback on the receive path.pack-objects --revs --stdoutwith stdin caret lines;--not/--allcorrectly avoided as an exit-129 usage error),index-packin a scratch git-dir, empty tip → ref-only, pack failure fails loud (mergepack_test.gopins all five cases). Atomic txn+pack viaUpdateRefWithPack— a ref can never dangle. The "merged parents unclonable" root cause is credible and the fix sound.sharedGet,downloadShared, remotereadRaw); ancestors only after own-NotFound; transport errors fail at once; total miss returns the OWN miss verbatim — failure-path-only confirmed, non-forks byte-identical. Depth cap 8 + visited set + Parent/Root pointers (dead-middle survival).forkMuis a leaf lock, resolution outside the lock (13 §2 rule 4). Caveat: every repo pays +1fork.jsonGET per remote-index rebuild (cached per handle lifetime); sim budgets are green so law 6 holds empirically.OwnerGate(self/member-org, admin bypass; nil = legacy-open in tests) with compile-time seam assertions.fork.jsonRoot additive omitempty;meta/forks.jsonshape untouched; social counter still incremented best-effort. Full pulls suite green.EnsureRepoAccess(fail-loud pre-commit); branch normalized (short→refs/heads/, tags/other namespaces explicitly rejected) + existence-checked under the task; description ≤1024, C0-rejected, TOML-escaped by construction into child settings (atomic at Create). Strict-decode allows exactly the five keys (unknown → 400).fork_parentomitempty + always-presentforkscount; ETag~f<version>.<count>.<parent-hash>suffix; mutable-collab class; nil/absent hook → byte-identical ETag (pinned incl. a revalidation test). UI: "forked from" parent link in the identity block, Fork pill with count in the Clone row,/forkslist page. Nit: the fork-list rail link only renders when count > 0, so a zero-fork repo has no UI path to its (empty)/forkspage.(child, pull-fork)single-flight + 202 +TaskRecord✓. Note: the flight key is the child name only — two different parents forking to the same target would join each other's task; vanishingly rare, flagging for awareness.Service.ForkExec/OwnerGate/AccessBoot+api.Env.ForkInfo, all nil-safe (pre-#424 behavior preserved); core never imports upward (mirrored structs); no new deps.Test results (scratch worktree @
1d90aff)gofmtclean;go vetclean (pulls/wal/maintain/api/cmd);go build ./...✓go test -race: pulls ✓, wal(+rw) ✓, maintain ✓, api ✓, cmd ✓node --test: 895/895 ✓ (fork.test.js4/4; smoke run against a scratch PR server on :18099 — this env's :8080 occupant is an unrelated auth-gated instance that smoke's default base URL hits; not a PR issue)vite build+ esbuild SDK ✓ (chunk-size warning pre-existing).opencode/).MERGE RECOMMENDATION: ready to merge — with the name-stranding finding accepted as follow-up work. No pack-safety hole remains: the two GC fail-open edges found are fixed and tested in
1d90aff.Fixed by PR #431 (review clean + two fail-open GC edges fixed by reviewer with regression tests; servable child proven via cross-fork-PR e2e; stranded-name follow-up filed as #432), merged. Closing.