Fork-deletion safety: deleting a parent with fork children preserves the pack set as a meta repository and re-points children (no-children path unchanged) #451
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#451
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?
What's requested
Ruling (user): deleting a fork parent that still has fork children must not break the children. The deleted parent's storage (shared pack objects) is preserved as a meta repository — a storage-only prefix with no servable repo manifest — and every fork child is re-pointed to it so reads, pushes, and clones keep working. Fork-network bookkeeping (
meta/forks.json, fork indexes, GC walk inputs) is updated to match. The no-children deletion path is unchanged: deleting a childless repo (fork or not) wipes the full prefix exactly asRegistry.Deletedoes today.Evidence (current behavior, static read — no live repro)
internal/wal/registry.goRegistry.Delete(~line 287): deletes the manifest as the linearization point, then pages through every key underrepos/<owner>/<repo>/and deletes it — includingwal/<checksum>pack objects that fork children reference under the parent's prefix. Nothing checksmeta/forks.json. A parent with children is deleted identically to a childless repo; children lose every pack their manifests reference and every read falls off the end of the chain.internal/wal/forkread.go(resolveForkChain/readForkDoc): children resolve ancestry through per-ancestorfork.json(Parent+Root), trying the own prefix first and falling back outward on miss. Once the parent prefix is fully wiped,readForkDocon the parent returnsok=falseand the chain simply ends — the fallback finds nothing. Comment in-file: "a deleted ancestor contributes nothing".internal/maintain/forknet.go(forkNetworkLive,readForkIndex): the GC walk unions live packs from the parent'smeta/forks.jsonand children's manifests; it skips subtrees whose child manifest is 404. A fully-deleted parent prefix meansreadForkIndexon the parent finds nothing — children's packs stop being protected the moment the parent goes, so maintain'sremoveSupersededmay delete packs live children still reference.internal/pulls/pulls.goForksKey(repos/<o>/<r>/meta/forks.json, CAS'd, frozen overwritable-key class) andinternal/pulls/forks.goListForks: the parent-side index is the bookkeeping source of truth.cmd/walhub/serve.gorepoRegistry.Deleteandinternal/api/summary.gorepoDelete(DELETESub: "", AuthAdmin): today's delete surface passes straight through toRegistry.Deletewith no fork check.Cross-check: audit ticket #449 lists "parent deletion/GC" among its audit areas — this ticket is the ruling, delivered ahead of the audit findings, so the audit should mark this item as covered by this ticket rather than re-report it.
Architecture notes
repos/<o>/<r>/survives as a storage-only "meta repo": thewal/pack set is preserved verbatim, the servable repo state (manifest, refs, checkpoints, issues, pulls, policy, access) is deleted, and a small bookkeeping doc is kept — the preservedmeta/forks.json(child list) plus a marker file (e.g.meta/tombstone.json) recording what happened. The parent id does not change, so children's existingfork.jsonpointers (parent,root) stay textually correct; "re-pointing" means verifying/repairing those pointers and updating bookkeeping, not renaming ids. If planner prefers deleting the parent id entirely and electing a child (or a synthetic id) as the new root, that changes every child'sfork.json— pick one and note it.internal/wal/forkread.gomust treat a meta (tombstoned) ancestor as chain-alive:readForkDoccurrently ends the chain when the ancestor'sfork.jsonis gone — decide whether the meta repo keeps a minimalfork.json(so existing chain logic works untouched) or the reader learns to readmeta/tombstone.jsonas a chain node. Keeping a minimalfork.jsonis the smaller diff; note the choice.internal/maintain/forknet.gowalks from the parent'smeta/forks.jsonand reads child manifests. The preserved index keeps that walk working — verify the walk's parent probe (exact-key GET, no LIST) tolerates the parent manifest being absent while the index is present, and that packs preserved in the meta prefix are unioned as live. Keep it fail-closed (corrupt/absent meta index must never unlock deletion of children-referenced packs).internal/wal/registry.goRegistry.Delete) so every caller (internal/apiDELETE handler, CLI ops incmd/walhub) inherits it — or in theapi.Reposadapter if registry-level scope is wrong for some caller; pick one and note it. The API contract (docs/features/03 §8, docs/go/05 §5.1.2 delete) needs the amendment stated explicitly: "delete with children → meta-repo conversion, not a wipe".fork_parentprojection (internal/api/summary.go,~fETag suffix) must keep resolving through the meta repo; the count and "forked from" line must not regress.ForkInfoininternal/api/env.gois the seam.meta/forks.jsondeletes exactly as today — full prefix wipe, same linearization point, same error shape. Acceptance criterion below pins this.meta/forks.json(internal/pulls/pulls.goheader),(repo, kind)single-flight tasks (internal/wal/tasks.go) if the conversion runs as a narrated task, exact-key GETs (never LIST) per law 4, and theCreate-arbitrates-the-name discipline ininternal/pulls/forkexec.go(a meta repo must not collide with a racing re-create of the same name — decide: conversion makes the name unusable until tombstone GC, or re-create adopts/absorbs the meta prefix).listRepos/liveRepos(cmd/walhub/serve.goliveReposrequires a manifest HEAD) — that invisibility is probably desirable but must be stated, and explore/repos listings must not surface meta repos.Acceptance criteria
wal/pack set and fork bookkeeping; children's clone/push/read paths keep working afterwards (fork read fallback resolves through the meta repo).fork.jsonchain valid end-to-end; parent-sidemeta/forks.jsonpreserved and consistent with the child list).internal/maintain/forknet.go) still protects packs referenced by live children after a parent deletion; a corrupt/missing meta index fails closed.Registry.Delete(full prefix wipe, manifest-first linearization, same errors).fork_parent,forkscount) on children keeps working through the meta repo, ETag suffix intact.Fixed by PR #462 (#462): delete-with-children converts the parent to a storage-only meta repo (packs + forks.json + fork.json + tombstone preserved, servable state swept, no child rewrites); no-children path byte-identical; re-create absorbs per the issue's planner's-call (refusal needed a create-path probe that broke the push fast-path budget — evidence in PR); GC walk unchanged + pinned; e2e with real git (clone/push/reads + absorb) green.
REVIEW PR #462 (fix/issue-451, fork-deletion safety) — adversarial pass, verified in scratch worktree /tmp/pr462. No browser (no browser-facing change — storage/delete paths only, e2e + unit reasoning as instructed).
VERDICT: ready to merge (one trivial docstring fix pushed as
9da05a7).(1) WITH-CHILDREN PATH — SOUND. internal/wal/registry.go Delete: liveForkChildren probe runs BEFORE the handle teardown and the manifest-delete linearization point; conversion order is tombstone → manifest delete → sweepPrefix (registry.go:324-345). Preserve set in metarepo.go metaPreserved: wal/ + meta/forks.json + fork.json + meta/tombstone.json — complete: fork children reference only parent wal/ checksums + fork.json chain (fork shares packs by reference, nothing copied); LFS is per-repo uploads with upstream read-through only (no pulls/ LFS refs at all), bundles/ are regenerable derived state, everything else under the prefix is parent-scoped servable state. Manifest gone = Open 404 + refreshList/Exists manifest-gated = invisible to listings (proven by TestDelete451/with-children/meta-conversion asserting Open-fails + not-listed). Children read/push/clone post-delete proven by TestE2E_ForkDeleteKeepsChildrenWorking (real git: clone content, child push lands, summary fork_parent intact) — PASS.
(2) RE-POINTING — SATISFACTORY, zero-child-write holds. Parent id never changes; readForkDoc reads repos//fork.json which is preserved, so resolveForkChain needs zero logic changes (forkread.go diff is comment-only — correct). Chain-through-tombstoned-parent proven by TestDelete451Chain (leaf chain [o/r a/b] + sharedGet of both root.pack and mid.pack after BOTH ancestor deletes) and the absorb case (chain intact + child pack read after re-create). GC-race window also covered: snapshot-taken-pre-delete sweeping post-delete still pins via preserved index (TestForkNetworkGCMetaParent).
(3) ABSORB-vs-REFUSE — ABSORB CORRECT, evidence checks out. Registry.Create is a bare manifest PutCreate (registry.go:235-247), no probe anywhere on the create path — absorb is automatic and zero-trip. Refusal is structurally unimplementable probeless (Open=NotFound + Create=412 cannot coexist on one key — presence is binary), and a tombstone probe would bill HEADs on the push fast path. TestPushFastPathZeroCollabRoundTrips green UNMODIFIED (22 trips/2 pushes). Stale tombstone is informational only (Delete never reads it — liveness re-derives from the index every call, proven by tombstone-GC subtests). Orphan-pack cost (pre-delete generation unowned until childless wipe) is stated in metarepo.go header + both docs — acceptable, disclosed. Tombstone-GC cycle proven both directions (TestDelete451Chain end, TestCreate451AbsorbsMeta end: full wipe incl. tombstone, names free).
(4) NO-CHILDREN PATH BYTE-IDENTICAL — YES. Absent/empty/all-stale index takes the untouched full-wipe code path (registry.go:349+); pinned by no-children/full-wipe (zero keys left, re-create free), stale-index-row/full-wipe, empty-repo-row-skipped.
(5) GC WALK — SOUND. forknet.go forkNetworkLive never probes the parent manifest (reads own index + child manifest.pb exact-GETs only) — docs claim 'the walk never probes the parent manifest' VERIFIED in code. Preserved index keeps in-flight-snapshot passes pinning; corrupt variant aborts with removed=0 (TestForkNetworkGCMetaCorruptIndex) — fail closed. Per-repo GC lists only its own wal/ prefix and no snapshot ever starts from a meta prefix, so preserved packs are structurally safe.
(6) BOOKKEEPING — SOUND. meta/forks.json preserved verbatim = consistent (ids unchanged, no rewrite needed); stale rows self-neutralize (manifest-404 children pin nothing in both liveForkChildren and the GC walk). Parent's own fork.json preserved keeps multi-level chains resolving. Indexes need no mutation — 'updated to match' holds vacuously and correctly.
(7) FAIL-CLOSED — all 9 TestDelete451FailClosed subtests verified: index-GET-error, child-probe-error, tombstone-write-error (all 3 abort pre-linearization, manifest intact asserted), sweep-LIST-error, sweep-DELETE-error, sweep-inner-DELETE-error (abort, packs survive; tombstone+manifest-delete already committed but re-delete via API self-heals since repoDelete calls Repos.Delete with no Open gate — summary.go:334), nil-body-index (doubt=error), empty-repo-row-skipped, tombstone-nil-children. Partial-failure states converge on retry (conversion is idempotent, childless delete absorbs the marker).
(8) HYGIENE — wal 95.1% / maintain 95.5% (gate holds, measured); -race clean on wal+maintain; sim tier green (12s); e2e forkdelete PASS; pulls + api + server + cmd suites green; gofmt/vet clean; go build clean; go.mod/go.sum/package.json untouched (no new deps); docs amended (03 §7+Decisions, 05 §5.1.2+Decisions) and accurate against code (walk claim, coexistence claim both verified). Law 4: conversion uses exact-key probes; LIST only in the non-hot delete sweep. Law 8: metarepo.go mirrors the pulls index shape locally, no upward import. Concurrency subsection present; no locks held across store calls. Covers audit #449 parent-deletion/GC item as claimed.
FIX APPLIED (pushed
9da05a7): e2e/forkdelete_test.go:19 docstring said 'the parent name refuses re-create' while step 8 asserts absorb (PUT→201) — corrected to 'absorbs on re-create'. Comment-only; e2e re-run green after push.Note: server-suite UI failures seen mid-review were environmental (scratch web/dist unbuilt — only .keep tracked); built web in scratch, suite green. Restored tracked web/dist/.keep after the build wiped it; scratch worktree removed. Main worktree untouched (clean).
No browser used (no browser-facing change). Do NOT merge per instructions — awaiting maintainer.
Fixed by PR #462 (review clean + one docstring fix by reviewer; tombstone path, zero-write re-pointing, absorb, GC all verified; sim + e2e green), merged. Closing.