Plan 0011
Structured commit history from the Nix git fetcher
Git inputs opt into exportHistory = true and evaluation sees inputs.foo.history: a deterministic, depth-bounded list of commits (rev, parents, author, message, optionally paths touched), cached by rev, never in lock files. Changelogs become a pure function of a locked input.
- Status
- Load-bearing
- Ambition
- 5 extends the Nix fetcher itself, carried as a fork patch
- Impact
- 5 changelogs become a pure function of locked inputs
- Effort
- 4 fetcher patch, caching, tests
- Risk
- 4 a fork patch to the core fetcher that upstream may never take
- Maturity
- 6 patch and example landed in the nix-ix series
- Leverage
- 6 any flake input can grow structured-history consumers
- Taste
- 4 design settled in review, implementation is mechanical
- Authors
- Created
- Updated
- Tracking issue
- #2108
- Supersedes
- -
- Superseded by
- -
Summary
Extend the Nix git fetcher (as a patch in the nix-ix series) so a git input can opt into exposing its commit history as structured evaluation-time data. builtins.fetchGit { ...; exportHistory = true; historyDepth = 200; } (and the same attributes on fetchTree and flake inputs of type git) yields a history attribute: a list of commits, newest first, in a deterministic topological order, each carrying rev, parents, author, committer, the full message, and (with historyPaths, on by default) the paths touched relative to the first parent. The history is a pure function of the locked rev (exactly like revCount and lastModified, which it generalises), so it is memoised in the fetcher cache and never appears in lock files. Consumers filter and render in Nix: a per-subtree CHANGELOG becomes map render (filter (touches "packages/foo") input.history).
Motivation
Two concrete consumers exist today and both are worked around with imperative scripting:
- Mirror changelogs. The mirror machinery (
packages/mirror, #2022) generates standalone repos per workspace package. A standard-formatCHANGELOG.mdper package subtree is the natural next artifact, and the data it needs — which commits touchedpackages/<name>and what they said — is precisely commit history filtered by path. Today the only options are at-push scripting insidemirror(imperative, a second source of truth, invisible to eval) or nothing. With history as input data, the changelog is derived in Nix from the same locked input the build uses. - Release notes / provenance in eval. Anything that wants “what changed between these two revs of an input” (site update feeds, blast-radius-style summaries, version-bump PR bodies) currently shells out to
git logoutside the eval boundary, against a checkout that may not even match the locked rev.
The underlying gap is old and upstream-recognised: Nix deliberately strips .git from fetched sources because .git is nondeterministic (packfile layout, ref state, reflogs), and upstream has repeatedly declined leaveDotGit-for-flakes on exactly those grounds (nixpkgs fetchers document leaveDotGit as non-reproducible; NixOS/nix#8567 tracks the demand). But the useful subset of .git — the commit graph reachable from the fetched rev — is content-addressed and immutable: rev transitively pins every parent, author, message and tree. Nix already ships two scalar projections of that graph as first-class deterministic metadata: revCount and lastModified. This plan generalises them to the structured projection, dodging the nondeterminism objection entirely.
Detailed design
Surface
Three new attributes on the git input scheme, gated behind a new git-export-history experimental feature (mirroring how verified-fetches gates publicKeys):
exportHistory(bool, defaultfalse): opt in. The result attrset (fromfetchGit/fetchTree, and each flake input’ssourceInfo— thusinputs.foo.historyandself.history) gains ahistoryattribute.historyDepth(integer, default100): emit at most this many commits, walked level-by-level from the tip (numerically smallest hash first among ties, including at the cap boundary);0means unlimited, an explicit opt-in to unbounded eval data. The bound counts emitted commits, not generation distance: merge fan-out means a generation bound of 100 on nixpkgs covers tens of thousands of commits (#2156), while a commit-count bound keepshistoryDepth = 100meaning 100 commits. On linear history the two coincide.historyDepthorhistoryPathswithoutexportHistoryis an error;exportHistorywithshallow = trueis an error (the walk needs the real parent chain). One interaction needed explicit handling: flake inputs re-enter the fetcher throughfetchTree, which injectsshallow = trueby default for git inputs;exportHistory = truesuppresses that injected default (an explicitshallow = truestill errors), soinputs.foo = { type = "git"; url = ...; exportHistory = true; }and the URL formgit+https://...?exportHistory=1&historyDepth=200both work unmodified.historyPaths(bool, defaulttrue): include the per-commitpathsfield. Path extraction is a tree diff per commit and dominates export cost on large-tree repositories (~150ms/commit on nixpkgs, measured), so metadata-only consumers (changelogs keyed on messages, provenance) switch it off and a depth-100 nixpkgs export completes in well under a second instead of hours (#2156).
Schema
history is a list of attrsets, one per commit:
{
rev = "bfbb7cba2473ac8250ad92d38f42ccfe6dd3d9ac";
parents = [ "44a78f5cb08a78e4998b043344a409075deb8a80" ]; # all parents, even outside the exported set
author = { name = "…"; email = "…"; time = 1783373709; tzOffset = -420; };
committer = { name = "…"; email = "…"; time = 1783373709; tzOffset = -420; };
message = "fix: tweak a
"; # full message, verbatim
paths = [ { path = "a.txt"; status = "M"; } ]; # vs first parent; A/M/D/T, sorted by path
} paths is the tree diff against the first parent (against the empty tree for root commits), with rename detection off — rename detection is a similarity heuristic, not a function of the trees, so it has no place in a deterministic schema. Merge commits therefore show their first-parent delta, which is the git log --first-parent convention changelog tools already assume.
Determinism argument
Every emitted byte is a function of the commit objects reachable from rev:
- The set is fixed by
revandhistoryDepth: a level-by-level walk of parent edges (numerically smallest hash first among ties, including at the cap boundary) emits at mosthistoryDepthcommits, so merge fan-out cannot blow up the emitted set (#2156). Commit objects are immutable and content-addressed, so the set is the same on every machine that can resolverev. - The order is specified, not inherited from a library: children always precede parents (topological), and among candidates whose already-emitted-set children are all emitted, the numerically smallest commit hash goes first. No timestamps, no clone order, no libgit2 version dependence. (
git rev-list --topo-orderby contrast leaves tie order unspecified.) - The fields are commit-object data (
author/committerincluding timezone offset, message bytes) plus tree-object data (pathsvia tree-to-tree diff, no blob reads, no similarity detection), sorted by path.
Hence history == f(rev, historyDepth, historyPaths), the same invariant revCount == f(rev) already relies on — which is also why it stays out of lock files: locking derived-from-rev data would only create a second thing to keep consistent (checkLocks would have to validate it, schema evolution would invalidate locks). Like revCount, it is instead memoised in the fetcher cache (~/.cache/nix/fetcher-cache-*.sqlite) under key ("gitHistory", {rev, depth, paths, format}), where format versions the schema.
Mechanics (patch anatomy)
One patch (0010-libfetchers-export-structured-git-commit-history.patch) in the nix-ix series:
GitRepo::getHistoryJson(rev, depth)insrc/libfetchers/git-utils.cc: the libgit2 walk + JSON rendering described above.GitInputScheme(src/libfetchers/git.cc): the two attributes (URL query and attrs forms), validation, and fetch-time computation into the fetcher cache right whererevCountis computed — the repository is guaranteed present there.- A new
InputScheme::getHistoryJson()virtual (defaultstd::nullopt), so the exposure point does not hard-code git. The git override reads the fetcher cache; on a miss (source substituted bynarHash, so the fetcher never ran on this machine; or the cache was deleted) it re-fetches the repo and recomputes. That miss path is the one case whereexportHistorycosts a network fetch that plain substitution would have avoided. emitTreeAttrs(src/libexpr/primops/fetchTree.cc): the single choke point through whichfetchGit,fetchTreeand every flake input’ssourceInfoare rendered (callFlakeroutes locked inputs throughfetchFinalTree), parses the cached JSON into eval values. One exposure site covers all three surfaces, and because it never touchesinput.attrs, nothing can leak intoflake.lock.
Cost
Measured with the prototype on a full local nixpkgs clone (964,455 commits, Apple M-series, warm store copy, cold fetcher cache per depth):
Measured with the prototype (Apple M-series, local full clones, historyDepth in generations):
NixOS/nix (23,114 commits; a synthetic empty-tree tip isolates walk cost from the source copy):
historyDepth | commits emitted | cold eval | warm eval (fetcher-cache hit) | JSON size |
|---|---|---|---|---|
| 100 (default) | 875 | 1.9 s | 0.32 s | 1.0 MB |
| 1000 | 19,560 | 10.3 s | 2.5 s | 17.8 MB |
| 0 (unlimited) | 23,115 | 10.5 s | 2.7 s | 20.0 MB |
The walk costs ~0.4 ms/commit on nix-sized trees (first-parent tree diff dominated); the warm numbers are the SQLite read plus parseJSON into eval values, which is what every evaluation after the first pays.
nixpkgs (964,455 commits) is the honest upper bound and it is not viable inline: historyDepth = 100 reaches tens of thousands of commits (merge fan-out makes generation distance much larger than linear count: on the nix repo, 100 generations already cover 875 commits) and each first-parent tree diff on a nixpkgs-sized tree costs ~150 ms, so the depth-100 cold walk did not finish inside 65 minutes. Plain git log --format=%H --name-status -n 10000 on the same clone also blows a 30-minute budget, so this is the price of paths-touched at that repo scale, not an implementation artifact. Consumers wanting nixpkgs-scale history need a paths-free variant (see Unresolved questions).
The walk is linear in emitted commits with a per-commit tree-diff cost; the fetcher cache makes every evaluation after the first a SQLite lookup plus JSON parse. Eval-side, the list is materialised eagerly when exportHistory = true, which is why the default depth is bounded at 100: nixpkgs-scale history (20 MB of JSON for the nix repo already; far more with nixpkgs’s commit count) belongs in a store path, not evaluator memory (see Unresolved questions).
Partial clones are unaffected in principle: the walk reads commit and tree objects only, never blobs, so a --filter=blob:none clone (if the fetcher ever grows one) still serves it. Shallow clones are rejected up front.
Drawbacks
- Eager eval materialisation.
historyis built when the input’s sourceInfo is rendered, even if never forced. Bounded by the default depth, but a flake with manyexportHistoryinputs pays it on every eval (post-fetcher-cache: lookup + parse, measured 0.3 s at the default depth and 2.5 s at depth 1000 on the nix repo). - A schema is a contract. Once consumers render changelogs from it, field changes need the
formatcache-version bump and consumer migration. The schema is deliberately minimal (no GPG signature status, no rename detection, no committer-vs-author disambiguation beyond both being present) to keep the contract small. - Substitution loses its shortcut. A
narHash-substituted input withexportHistory = truemust still fetch the git repo the first time its history is read on a machine. This is inherent: the store path does not contain the history (by design), so the data must come from the repo once. - Fork surface. One more patch to rebase across nix-src bumps. It touches
git.cc/git-utils.cc/fetchTree.ccin append-mostly ways, and the DAG keeps it independent of the build-status series.
Alternatives
- Do nothing / at-push scripting in
mirror. The mirror tool shellsgit logat sync time and writes CHANGELOG.md imperatively. Works, but the changelog becomes a second source of truth generated outside eval, per consumer, with none of the caching or determinism guarantees, and every other would-be consumer (release notes, provenance) re-implements it. leaveDotGit-style: keep.gitin the store path. Upstream has rejected this repeatedly because.gitis nondeterministic (packing, refs, reflogs) and it drags the whole object database into the NAR. This proposal is the deterministic subset of that idea.- FOD-based export. A fixed-output derivation running
git loginside the sandbox. Requires a hash-per-input-per-depth to maintain by hand (or hash-drift pain on every bump), pays a full network re-clone inside the FOD sandbox (the fetcher cache is invisible to it), and produces a store path eval cannot read without IFD anyway. - IFD over the fetched tree. Impossible by construction: the fetched store path deliberately contains no
.git, so there is nothing for a derivation to read history from. history.jsonstore path instead of inline attrs (input.historyFile). Solves eager-eval memory for huge histories and keeps eval lazy (builtins.fromJSON (builtins.readFile ...)on demand). Costs a store path per (rev, depth) and pushes parsing onto every consumer. The inline list with a bounded default depth was chosen for v1 ergonomics; the store-path variant is the natural escape hatch if unbounded-history consumers materialise (Unresolved questions).
Prior art
revCount/lastModified: the existing precedent that history-derived, rev-determined metadata is legitimate fetcher output; this plan is their generalisation, and reuses their exact caching pattern (Cache::Key{"gitRevCount", {rev}}→{"gitHistory", {rev, depth, paths, format}}).- Plan 0009 / the
nix-ixpatch series: the fork-local delivery vehicle —lib/util/patched-src.nix,dag.json, per-patch upstream intent inlib/fork-packages.nix(this patch:hold, see below). builtins.fetchTreelocked-attr flow:callFlake→fetchFinalTree→emitTreeAttrsis the existing single path this design hooks for all three user surfaces.- Elsewhere: Bazel’s
git_repositoryexposes nothing like this (changelog generation lives in release tooling); Buck2 likewise.jj/git log --format=%H%x00...streaming formats are the imperative equivalents consumers use today. The “history as data, views derived per consumer” split follows the repo’s own pipeline convention (transport / durable log / per-query views).
Upstreaming posture
Written to be upstreamable, parked fork-local. The design deliberately avoids every objection upstream raised against leaveDotGit (nondeterminism, NAR bloat): the export is deterministic by construction, opt-in, feature-gated (git-export-history), out of lock files, and adds no bytes to fetched store paths. The patch is self-contained against 2.34.7 with an upstream-style functional test and release note, so nix run .#upstream-pr can carry it when the freeze lifts. It stays upstream = "hold" in lib/fork-packages.nix for now: upstreaming is paused repo-wide pending NixOS/nix#15984 (see #2021), and a feature of this size should open as an upstream issue/plan discussion first, not a cold PR.
Unresolved questions
- Default depth.
100is a guess biased toward safety. Mirror changelogs for old packages want deeper history; is1000still cheap enough (see Cost) to be the default, or should the mirror consumer just sethistoryDepth = 0explicitly on its (small) per-package repos? - A
historyPaths = falseknob. The tree diffs are the dominant walk cost (~0.4 ms/commit on nix-sized trees, ~150 ms/commit on nixpkgs-sized ones). A metadata-only mode would make even nixpkgs-scale history walkable in seconds for consumers that only need messages/authors. Worth adding before or after v1? - Store-path variant. Should
exportHistory = "file"(or a separatehistoryFile = true) emit the JSON as a store path instead of inline attrs, for unbounded histories? v1 ships inline-only; deciding this before consumers exist avoids a schema fork later. - Laziness.
emitTreeAttrsbuilds the list eagerly. A thunkedhistoryattr (pay only when forced) would erase the per-eval cost for consumers that rarely force it; is the added evaluator plumbing worth it before a real workload complains? self.historyfor the current flake. Works when the flake is fetched as a git input; a dirty local checkout has no locked rev and silently omits the attribute. Is silent omission the right contract, or should dirty trees expose history up todirtyRev’s parent?
Future possibilities
- Mirror CHANGELOG.md:
mirror genconsumesinputs.self.historyfiltered bypackages/<name>/and writes a Keep-a-Changelog file into each standalone repo — the motivating consumer, unblocked by this plan. nix flake metadata --historyrendering the same data from the CLI.- Range queries (
historySince = <rev>): stop the walk at a base rev, the natural “changes between two locked revs” primitive for update PRs. Deterministic for the same reasons. - Upstream plan once #15984 resolves, proposing the schema as documented fetchTree output.
Revision: cost bounding (2026-07-07, #2156)
The first prototype bounded the walk by generation distance and always extracted paths. On nixpkgs, merge fan-out made historyDepth = 100 cover tens of thousands of commits at ~150ms of tree diff each, so a depth-100 export ran for hours (#2156). Two changes, shipped in the same patch: historyDepth now counts emitted commits, and historyPaths = false skips path extraction entirely. Measured after the change: metadata-only depth-100 history on nixpkgs completes in 0.2s wall (0.17s with an uncacheable depth to rule out cache hits); paths-on stays ~160ms/commit, now bounded by the commit-count cap. The fetcher-cache key gained the paths dimension and bumped its schema format.
andrewgazelka