Plan 0010
Fork patch-series machinery vs the state of the art
The de-fork patch machinery benchmarked against Debian DEP-3, Yocto, the kernel/b4 workflow, Gerrit, jujutsu, and friends: the format/DAG/rerere core already matches or exceeds the state of the art. Three adoptions, reasoned rejections for the rest.
- Status
- Load-bearing
- Ambition
- 3 benchmark and selective adoption, not new machinery
- Impact
- 4 three adoptions, mostly confirms the existing design
- Effort
- 3 survey plus three bounded changes
- Risk
- 2 analysis document, worst case is wasted reading
- Maturity
- 7 the machinery it documents is in daily use
- Leverage
- 5 keeps the fork loop healthy, which everything vendored rides on
- Taste
- 6 judging which practices deserve adoption is the tasteful part
- Authors
- Created
- Updated
- Tracking issue
- #2098
- Supersedes
- -
- Superseded by
- -
Summary
We benchmarked the de-fork patch machinery (#1893, #1903, #1909, #1912, #1973) against the strongest patch-series practice in the wild: Debian DEP-3, Yocto/OpenEmbedded Upstream-Status + patchtest, the kernel/b4/lore workflow and patatt attestation, Gerrit Change-Id, jujutsu, stgit/quilt, git-branchless/GitButler, Fedora dist-git/rpmautospec, Intel 0-day per-patch build gating, nixpkgs patch policy, and Android ACK fork-drift management. Verdict: our format determinism (canonical git format-patch --zero-commit --no-signature --no-stat -N bytes, asserted at eval in lib/util/patched-src.nix), per-patch dependency DAG with pairwise commute verification (dag.json, unmatched by quilt/stgit/Debian practice), and conflict automation (committed rerere + mergiraf) already match or exceed the state of the art. We adopt three things and reject the rest with reasons: (1) endorse #2087’s inline commit-body reason-of-record as the standardized mechanism (landed via #2100 while this plan was drafted, backfill included); (2) a fork drift metric in the fork-sync loop; (3) per-attempt-patch closure build gates, scheduled, never per-PR. Tracking: #2098.
Motivation
Five forks carry 29 patches through this machinery, and the estate is growing. Before growing it further we owed ourselves an answer to two questions: is any part of the design below the industry bar, and is there proven practice worth importing? This plan is that survey, scored against a fixed rubric, with an explicit adopt/reject/defer verdict per option so the rejections are on the record with reasons rather than silently re-litigated later.
Rubric
Recommendations were scored against: (R1) checkable grounding (code path or cited source); (R2) binding constraints: pure eval, deterministic patch bytes, no new IFD, the index.lib seams ix consumes preserved; (R3) prefer an existing standard over a house format where one exists, and metadata must survive the git am → rebase → git format-patch round trip; (R4) explicit cost/benefit per adoption, specific non-strawman reason per rejection; (R5) per-PR CI cost stays flat; (R6) one reason mechanism shared with #2087; (R7) metrics actionable, not vanity.
What we have (grounding)
lib/util/patched-src.nix: the patch dir is the single source of truth (“the series is derived frombuiltins.readDir patchDir… never a hand-kept list”, lines 7-8); canonical-format assertions at eval (lines 32-58) make patched bytes deterministic and make any second writer of patch files fail loudly.lib/fork-packages.nix: 5 forks, 29 patches (codex 1, llm-clippy 14, nix 9, btop 2, mesa 3); per-patch upstreaming stanceupstream = "attempt" | "hold" | "never"+ stance reason; repo-levelupstreamPolicygates (prsWelcome,aiPrsAllowed). All 9 nix patches are attempt-marked today.packages/rebase-patches: scratch-repo rebase with committed rerere state and a mergiraf merge driver, deterministic re-export,dag.jsonregeneration with commute verification (invariants: applies-with-declared-ancestors-only, pairwise byte-identical commutation, topo order, byte-stable regen, base matches pin, intent coherence, body-states-reason for every patch since #2100).packages/upstream-sync: tool-owned live stateupstream-status.json(pr, retired, duplicates; save atdefault.nix:161, PR refresh viaghat:182); PR opening delegated toupstream-pr, which ships a patch plus itsdag.jsonancestor closure (:400-406).checks.<sys>.patched-src-*(seconds-fast apply gate) andpatch-dag-*(invariant check);fork-sync.ymlcron every 3h (line 15) rebasesautoUpdateforks (codex, btop) into a rolling PR; nix/clippy/mesa pins are manual.
Verdicts
Adopt
A1 — Inline reason-of-record per patch (endorse #2087; landed via #2100). The reason a patch exists is its commit-message body, enforced for every patch: #2100 extended the previously attempt-only body check in packages/rebase-patches/dag-check.nu (now invariant (e), backed by dag body-has-reason in dag-lib.nu) to the whole series, and backfilled the 13 mute patches in the same PR. This is the existing standard, not a house format: the kernel requires the why in the commit message (submitting-patches); DEP-3’s one required field (Description/Subject) was designed to map onto format-patch output (Subject/From aliases; parsing stops at the --- separator) (DEP-3); nixpkgs policy requires purpose + upstream link per patch (NixOS/nixpkgs#246766; nixpkgs-hammering missing-patch-comment). #2087 verbatim: “The reason of record should stay INLINE in the patch’s own commit-message body (it round-trips through git am / rebase / git format-patch and reaches upstream reviewers; a separate metadata file would drift). The reason field in lib/fork-packages.nix stays what it is: the rationale for the upstreaming stance, not the patch’s existence.” So there is one mechanism per fact: body = why the patch exists; fork-packages.nix reason = why the stance. Migration cost, stated (and paid by #2100 as predicted): backfilling bodies is a one-time byte-churn confined to patch files whose bodies were empty, one deliberate re-export commit per fork. dag.json is unaffected (nodes key on filenames, which derive from subjects; deps derive from diffs) and rerere preimages key on conflict content, also unaffected.
A2 — Fork drift metric. upstream-sync drift: per fork, commits behind upstream’s default branch (forge compare API against the flake.lock pinned rev), age of the pinned base in days, patch count by stance, and retired-awaiting-drop count; human table plus --json. Wired into fork-sync.yml: step summary and the rolling PR body each 3h run. Actionable (R7): drift of the manual-pin forks (nix, clippy, mesa) is invisible today and is the trigger to schedule a manual rebase; retired > 0 means a rebase shrinks the series. Cost: a few API calls per cron run, zero new CI jobs.
A3 — Per-attempt-patch closure build gates, scheduled, never per-PR. Kernel bisectability (every patch must build standalone; Intel 0-day tests patch-by-patch) is the precedent, but our sharper local reason: upstream-pr ships a patch + its dag.json ancestor closure as a standalone PR against the bare base, so a non-building closure means we ship upstream a broken PR. Mechanism: for each attempt-marked patch, build the package with patchedSrc restricted to that patch’s closure. Closure computation is pure eval: dag.json is a committed repo file read with builtins.fromJSON — no IFD. Seam: ix.patchedSrc gains an optional patch-list argument defaulting to today’s readDir discovery (additive; existing callers unchanged, including ix’s cross-repo use). Exposure: an attrset outside checks.* so per-PR flake-check cost is flat (R5). Triggers, deliberately decoupled from contributor iteration: workflow_dispatch + post-merge on main when a fork’s patch dir changed + preflight in upstream-sync --open. Cost, honestly: attempt patches are today exactly the 9 nix patches, and nix is a heavy C++ build, so a nix base bump or patch edit re-pays up to 9 package builds off the merge path; between changes the gate derivations are cache hits when the shared cache is warm. The flag is per-fork opt-in in fork-packages.nix, default off.
Reject
R1 — In-patch mutable status headers (Yocto Upstream-Status / DEP-3 Origin, Bug, Forwarded, Applied-Upstream) as source of truth. The repo splits three facts across three owners: intent (fork-packages.nix: hand-written, gates outward automation, needs nix eval), live state (upstream-status.json: tool-owned), description (commit body: the only part that travels). Yocto and Debian target distro patch dirs where the patch file is the only home, so everything must live in it. Importing in-patch status here would create a second home for a mutable fact and churn canonical patch bytes on every PR open/merge (status changes without content changes) — the bytes rerere, the DAG, and review all key off. On R3: the standards-preference binds when metadata must travel; the only artifact our upstreams consume is a GitHub/GitLab PR, which carries the commit body. The taxonomy is not currently expressed in round-trippable form; it is reconstructable at export time by joining fork-packages.nix + upstream-status.json, and we document the mapping so an exporter is mechanical if a consumer ever appears. To Yocto Upstream-Status (valid set: Pending | Submitted | Backport | Denied | Inappropriate | Inactive-Upstream; “Accepted” was removed in Yocto 3.2, see the recipe style guide): attempt with no PR → Pending; attempt with open PR → Submitted [pr url]; attempt merged (retired) → Backport [pr url]; attempt with closed-unmerged PR → Denied; never → Inappropriate [reason]; hold → lossy, nearest is Pending with the hold reason (accepted: hold exists to gate our automation, not to inform a distro). To DEP-3, which has no status tokens, only fields: Forwarded: <pr url> once submitted (else no / not-needed for never), Applied-Upstream: <pr url or commit> once merged, Bug: for the upstream tracker.
R2 — jj (jujutsu) as machinery. First-class conflicts and auto-rebased descendants are the best interactive-rebase UX in the industry, but they solve the recurring-conflict problem we already automated (committed rerere replays + mergiraf structural merges + zdiff3), and jj cannot be the source of truth without abandoning the .patch files pure-eval applyPatches requires. A required new tool and second mental model is not paid for by a 29-patch estate. Humans may still use jj ad hoc on the scratch repo; document, do not integrate.
R3 — stgit / quilt. Same substrate (ordered patch dir), strictly weaker invariants: no commute check, no deterministic re-export contract. stg import/export would add a second writer of patch bytes that does not honor the canonical flag set asserted at eval in patched-src.nix. rebase-patches already is our stg rebase with deterministic export.
R4 — b4 / lore email workflow, cover letters, patch versioning. Our upstreams take GitHub/GitLab PRs, not mailing lists. The upstream-sync PR body is the cover letter; PR pushes are the rerolls; dag.json base is base-commit. Revisit only if we ever upstream to a lore-based project.
R5 — patatt / gitsign provenance signing. Patches do leave the repo (upstream-sync --open pushes a branch and opens a PR), but they leave as platform commits whose authorship, integrity, and review trail are established by GitHub/GitLab push identity and PR history — which is exactly what our upstream maintainers consume. None of them verify patatt/DKIM detached signatures (a kernel/lore practice), and gitsign signs commit objects that do not survive format-patch anyway. Detached signatures would add key management with zero current verifiers. Same revisit trigger as R4.
R6 — Fedora dist-git / rpmautospec, Android ACK subject tags. Derive-metadata-from-git is already embodied (dag.json and upstream-status.json are tool-derived); ACK subject tags (UPSTREAM:/FROMLIST:/ANDROID:) are a weaker in-subject encoding of our intent field and would pollute upstream-facing subjects. Fedora’s comment-above-Patch convention is a SHOULD-level, unenforced ancestor of what #2087 enforces mechanically.
R7 — git-branchless / GitButler. Stack-editing ergonomics for live branches; our stack lives as files and is materialized transiently by rebase-patches. GitButler’s Gerrit mode auto-injects Change-Id trailers, which we separately defer (D1).
Defer
D1 — Stable per-patch identity (Gerrit Change-Id-style trailer). The one stable-ID mechanism that survives the format-patch round trip, and it would decouple fork-packages.nix intent keys and dag.json nodes from mutable NNNN-title filenames. Deferred for two reasons: no consumer — no tool keys off cross-rename identity today (the intent-coherence invariant fails loudly on stale keys and subject renames are rare) — and adding a body trailer is itself a V1-class byte-churn event. Also, in-patch Change-Id trailers pollute upstream-bound output (kernel checkpatch has a dedicated GERRIT_CHANGE_ID error; projects require removing it before submission), so adoption would need strip-on-export. If rename churn materializes, adopt inside an already-open churn window (a body backfill or rebase re-export), with upstream-pr stripping the trailer on export.
Drawbacks
Each adoption’s cost is stated inline above; the honest bill is A3’s: a nix base bump or attempt-patch edit re-pays up to 9 heavy C++ package builds off the merge path, and the per-fork opt-in flag is the pressure valve if that bill ever outgrows the value of pre-upstream confidence. A2 adds a dependence on forge compare APIs to the cron loop (degraded, not broken, when a forge call fails). A1’s backfill byte-churn was a real one-time cost, already paid by #2100.
Alternatives
The reject list above is the alternatives survey, each with its specific reason. The “do nothing” alternative is the baseline the grounding section describes: it is already at or above the state of the art, which is exactly why the three adoptions are incremental (a metric, a scheduled gate, and an endorsement of an in-flight issue) rather than machinery replacements.
Prior art
The surveyed practice itself: DEP-3, Yocto Upstream-Status + patchtest, the kernel’s submitting-patches and b4/lore/patatt tooling, Gerrit Change-Id, jujutsu, stgit/quilt, git-branchless/GitButler, Fedora dist-git/rpmautospec, Intel 0-day per-patch build gating, nixpkgs patch policy (NixOS/nixpkgs#246766), and Android ACK subject tags. In-repo: plan-adjacent groundwork in #1893, #1903, #1909, #1912, #1973, and the reason-of-record enforcement in #2087/#2100.
Adoption plan
PR 1: this plan. PR 2: drift metric (upstream-sync subcommand + fork-sync.yml wiring); independent of #2087. PR 3: closure build gates (patchedSrc patch-list seam + gate attrset + scheduled workflow + upstream-sync --open preflight); does not churn patch bytes; fork-packages.nix opt-in flag lands default-off, enabled for nix. #2087’s reason mechanism and its backfill landed via #2100, sequenced as deliberate re-exports exactly as A1 prescribes.
Unresolved questions
- GitLab drift (mesa): the compare API differs from GitHub’s; if counts are impractical, degrade to base-age-only for GitLab forks and say so in the table.
- Closure-gate economics if attempt-marked patches ever concentrate in an even heavier package: the per-fork opt-in is the pressure valve; revisit if a bump’s rebuild bill exceeds the value of pre-upstream confidence.
andrewgazelka