From 818976c67bdeba16b9adc6e2b60040bb10c14eec Mon Sep 17 00:00:00 2001 From: Daniel Lazaro Date: Mon, 24 Aug 2026 19:48:24 -0400 Subject: [PATCH 1/2] feat: add flake-edit auto-follow support --- README.md | 2 +- dev/modules/_lib/default.nix | 116 +++++++++++++++--- dev/modules/unit-tests/inputsExpr.nix | 114 ++++++++++++++++- docs/astro.config.mjs | 1 + docs/src/content/docs/guides/auto-follow.mdx | 42 +++++++ .../content/docs/guides/lock-flattening.mdx | 2 + docs/src/content/docs/reference/options.mdx | 9 ++ modules/options/auto-follow.nix | 4 + modules/options/default.nix | 1 + modules/options/inputs.nix | 10 ++ modules/write-flake.nix | 57 ++++++++- 11 files changed, 337 insertions(+), 21 deletions(-) create mode 100644 docs/src/content/docs/guides/auto-follow.mdx create mode 100644 modules/options/auto-follow.nix diff --git a/README.md b/README.md index 4b1103b..b568b4b 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ This means - `flake check` ensures files are up to date. - App for `flake.nix` generator: `nix run .#write-flake` - Custom do-not-edit header. -- Automatic flake.lock [flattening](#automatic-flakelock-flattening). +- Reduce duplicate inputs using automatic follows powered by [`flake-edit`](https://github.com/a-kenji/flake-edit), or via automatic `flake.lock` [flattening](#automatic-flakelock-flattening). - Incrementally add [flake-parts-builder](#parts_templates) templates. - Pick flakeModules for different feature sets. - [Dendritic](https://vic.github.io/dendrix/Dendritic.html) flake template. diff --git a/dev/modules/_lib/default.nix b/dev/modules/_lib/default.nix index 1715f95..680104b 100644 --- a/dev/modules/_lib/default.nix +++ b/dev/modules/_lib/default.nix @@ -31,21 +31,28 @@ let in lib.pipe { } ops; - nonEmptyInputs = input: { - nonEmptyMerge = { + nonEmptyInputs = + input: + let inputs = inputsFollow input.inputs; + in + { + nonEmptyMerge = lib.optionalAttrs (inputs != { }) { inherit inputs; }; }; - }; - inputsFollow = lib.mapAttrs ( - _: input: - mergeNonEmptyAttrs input { - follows = { - testEmpty = v: !builtins.isString v; - }; - inputs = nonEmptyInputs input; - } - ); + inputsFollow = + inputs: + lib.filterAttrs (_: input: input != { }) ( + lib.mapAttrs ( + _: input: + mergeNonEmptyAttrs input { + follows = { + testEmpty = v: !builtins.isString v; + }; + inputs = nonEmptyInputs input; + } + ) inputs + ); inputsExpr = lib.mapAttrs ( _name: input: @@ -77,14 +84,85 @@ let } ); + mergeAutoFollows = + configured: expr: existing: + lib.mapAttrs ( + name: input: + let + existingInput = existing.${name} or { }; + nested = mergeNestedAutoFollows (configured.${name}.inputs or { }) (input.inputs or { }) ( + existingInput.inputs or { } + ); + in + (removeAttrs input [ "inputs" ]) // lib.optionalAttrs (nested != { }) { inputs = nested; } + ) expr; + + mergeNestedAutoFollows = + configured: expr: existing: + let + names = lib.unique ((builtins.attrNames expr) ++ (builtins.attrNames existing)); + in + lib.listToAttrs ( + lib.filter (entry: entry.value != { }) ( + map ( + name: + let + configuredInput = configured.${name} or { }; + exprInput = expr.${name} or { }; + existingInput = existing.${name} or { }; + automaticallyManaged = configuredInput.autoFollow or true; + nested = mergeNestedAutoFollows (configuredInput.inputs or { }) (exprInput.inputs or { }) ( + existingInput.inputs or { } + ); + preservedFollow = lib.optionalAttrs ( + automaticallyManaged && builtins.isString (existingInput.follows or null) + ) { follows = existingInput.follows; }; + in + { + inherit name; + value = + (removeAttrs exprInput ([ "inputs" ] ++ lib.optional automaticallyManaged "follows")) + // preservedFollow + // lib.optionalAttrs (nested != { }) { inputs = nested; }; + } + ) names + ) + ); + + autoFollowIgnores = + configured: + lib.concatLists ( + lib.mapAttrsToList ( + parent: input: collectAutoFollowIgnores [ parent ] (input.inputs or { }) + ) configured + ); + + collectAutoFollowIgnores = + path: inputs: + lib.concatLists ( + lib.mapAttrsToList ( + name: input: + let + inputPath = path ++ [ name ]; + disabled = !(input.autoFollow or true); + configuredFollow = input.follows or null; + in + if disabled && builtins.isString configuredFollow then + throw "auto-follow cannot be disabled for ${lib.concatStringsSep "." inputPath} while follows is configured" + else + lib.optional disabled (lib.concatStringsSep "." inputPath) + ++ collectAutoFollowIgnores inputPath (input.inputs or { }) + ) inputs + ); + nixAttr = - name: value: + collapse: name: value: let childIsAttr = builtins.isAttrs value; childIsOne = builtins.length (builtins.attrNames value) == 1; - nested = lib.head (lib.mapAttrsToList nixAttr value); + nested = lib.head (lib.mapAttrsToList (nixAttr collapse) value); in - if childIsAttr && childIsOne then + if collapse && childIsAttr && childIsOne then { name = "${name}.${nested.name}"; value = nested.value; @@ -118,6 +196,7 @@ let { attrSortPriority = [ ]; attrSep = " "; + collapseAttrs = true; } else lib.pipe styles [ @@ -126,9 +205,10 @@ let { attrSortPriority ? [ ], attrSep ? " ", + collapseAttrs ? true, }: { - inherit attrSortPriority attrSep; + inherit attrSortPriority attrSep collapseAttrs; } ) ]; @@ -148,7 +228,7 @@ let lib.strings.escapeNixString expr else if lib.isAttrs expr then lib.pipe expr [ - (priorityMapAttrsToList nixAttr style.attrSortPriority) + (priorityMapAttrsToList (nixAttr style.collapseAttrs) style.attrSortPriority) (map ( { name, value }: "${name} = ${ @@ -181,8 +261,10 @@ let in { inherit + autoFollowIgnores inputsExpr isNonEmptyString + mergeAutoFollows priorityComparator priorityMapAttrsToList nixCode diff --git a/dev/modules/unit-tests/inputsExpr.nix b/dev/modules/unit-tests/inputsExpr.nix index 409a602..95ad12c 100644 --- a/dev/modules/unit-tests/inputsExpr.nix +++ b/dev/modules/unit-tests/inputsExpr.nix @@ -1,6 +1,10 @@ { lib, ... }: let - inherit (import ./../_lib lib) inputsExpr; + inherit (import ./../_lib lib) + autoFollowIgnores + inputsExpr + mergeAutoFollows + ; tests.inputsExpr."test on empty inputs" = { expr = inputsExpr { }; @@ -79,6 +83,114 @@ let }; }; + tests.inputsExpr."test autoFollow metadata is not rendered" = { + expr = inputsExpr { + foo = { + url = "foo"; + inputs.bar.autoFollow = false; + }; + }; + expected.foo.url = "foo"; + }; + + tests.inputsExpr."test preserves existing automatic follows" = { + expr = mergeAutoFollows { + foo = { + inputs = { }; + }; + } { foo.url = "foo"; } { foo.inputs.bar.follows = "baz"; }; + expected = { + foo = { + url = "foo"; + inputs.bar.follows = "baz"; + }; + }; + }; + + tests.inputsExpr."test preserves follows for inputs introduced by preProcess" = { + expr = mergeAutoFollows { } { injected.url = "injected"; } { + injected.inputs.nixpkgs.follows = "nixpkgs"; + }; + expected = { + injected = { + url = "injected"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + }; + + tests.inputsExpr."test automatic follows override configured follows" = { + expr = + mergeAutoFollows + { + foo.inputs.bar = { + autoFollow = true; + follows = "quux"; + inputs = { }; + }; + } + { + foo = { + url = "foo"; + inputs.bar.follows = "quux"; + }; + } + { foo.inputs.bar.follows = "baz"; }; + expected = { + foo = { + url = "foo"; + inputs.bar.follows = "baz"; + }; + }; + }; + + tests.inputsExpr."test opting out removes an automatic follow" = { + expr = mergeAutoFollows { + foo.inputs.bar = { + autoFollow = false; + follows = null; + inputs = { }; + }; + } { foo.url = "foo"; } { foo.inputs.bar.follows = "baz"; }; + expected.foo.url = "foo"; + }; + + tests.inputsExpr."test collects nested auto-follow exclusions" = { + expr = autoFollowIgnores { + foo.inputs = { + bar = { + autoFollow = false; + inputs = { }; + }; + baz = { + autoFollow = true; + inputs.quux = { + autoFollow = false; + inputs = { }; + }; + }; + }; + }; + expected = [ + "foo.bar" + "foo.baz.quux" + ]; + }; + + tests.inputsExpr."test rejects follows on an auto-follow exclusion" = { + expr = + (builtins.tryEval ( + builtins.deepSeq (autoFollowIgnores { + foo.inputs.bar = { + autoFollow = false; + follows = "target"; + inputs = { }; + }; + }) null + )).success; + expected = false; + }; + in { flake = { inherit tests; }; diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 815f88b..305ac47 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -61,6 +61,7 @@ export default defineConfig({ { label: "Templates", slug: "guides/templates" }, { label: "The outputs Function", slug: "guides/outputs" }, { label: "Hooks", slug: "guides/hooks" }, + { label: "Automatic Follows", slug: "guides/auto-follow" }, { label: "Lock Flattening", slug: "guides/lock-flattening" }, { label: "flake-parts-builder", diff --git a/docs/src/content/docs/guides/auto-follow.mdx b/docs/src/content/docs/guides/auto-follow.mdx new file mode 100644 index 0000000..3293ea2 --- /dev/null +++ b/docs/src/content/docs/guides/auto-follow.mdx @@ -0,0 +1,42 @@ +--- +title: Automatic Follows +description: Use flake-edit to maintain follows declarations automatically. +--- + +flake-file can use [`flake-edit follow`](https://github.com/a-kenji/flake-edit) to add and remove `follows` declarations whenever `flake.nix` is generated. + +This is an alternative to the [`flake.lock` flattening hook](/guides/lock-flattening). Both approaches reduce duplicate transitive inputs, but they store the result in different places. Generally, choose one rather than enabling both. + +```nix +{ + flake-file.auto-follow.enable = true; +} +``` + +`write-flake` updates `flake.lock`, runs `flake-edit follow`, and updates the lock again. The generated-file check runs the same follow operation on a temporary copy, so `nix flake check` also detects missing or stale automatic follows. + +Automatic follows require `pkgs.flake-edit` version 0.3.5 or newer. Enabling the feature with an older or missing package produces an evaluation error identifying the required version. + +## Automatic follows or lock flattening? + +| | Automatic follows | Lock flattening hook | +|---|---|---| +| Source of truth | `follows` declarations in `flake.nix` | Rewritten relationships in `flake.lock` | +| Visibility | Relationships are visible when reviewing `flake.nix` | Keeps `flake.nix` free of generated follows | +| Configuration | Supports per-nested-input opt-outs | Configured for the whole lock file | +| Writes | Updates both `flake.nix` and `flake.lock` | Rewrites only `flake.lock` after generation | +| Best fit | Explicit, reviewable Nix-native follows | Existing lock-flattening workflows or minimal generated flakes | + +Use automatic follows when you want deduplication represented as ordinary flake declarations. Use the lock hook when you want deduplication to remain an implementation detail of the generated lock file. + +## Opting out an input + +When automatic follows are enabled, flake-edit manages nested `follows` declarations by default. Exclude a particular nested input when it should not be followed automatically: + +```nix +{ + flake-file.inputs.crane.inputs.nixpkgs.autoFollow = false; +} +``` + +An exclusion cannot also configure `follows`: one requests no automatic follow while the other requests an explicit follow. flake-file reports that conflicting configuration when automatic follows are enabled. diff --git a/docs/src/content/docs/guides/lock-flattening.mdx b/docs/src/content/docs/guides/lock-flattening.mdx index 6040e72..0d58bf0 100644 --- a/docs/src/content/docs/guides/lock-flattening.mdx +++ b/docs/src/content/docs/guides/lock-flattening.mdx @@ -7,6 +7,8 @@ description: Automatically flatten flake.lock to reduce dependency duplication. Nix flakes fetch every input's own lock file, which can result in many copies of the same package set (e.g. multiple `nixpkgs` versions) being downloaded and stored. Flattening forces all inputs to share common dependencies. +The lock-flattening hook is an alternative to [automatic `follows` declarations with `flake-edit`](/guides/auto-follow). Flattening keeps generated follows out of `flake.nix` and records the deduplication only in `flake.lock`. See the [comparison table](/guides/auto-follow#automatic-follows-or-lock-flattening) before choosing; generally, enable one approach rather than both. + ## Built-in Modules flake-file ships support for two established flattening tools: diff --git a/docs/src/content/docs/reference/options.mdx b/docs/src/content/docs/reference/options.mdx index 57883bc..0518125 100644 --- a/docs/src/content/docs/reference/options.mdx +++ b/docs/src/content/docs/reference/options.mdx @@ -40,6 +40,7 @@ Each input is declared under `flake-file.inputs.`. | `flake-file.inputs..flake` | `bool` | Is the input a flake? (default: `true`) | | `flake-file.inputs..follows` | `str` | Follow another input's resolved value | | `flake-file.inputs..inputs..follows` | `str` | Nested transitive follow | +| `flake-file.inputs..inputs..autoFollow` | `bool` | Allow `flake-edit follow` to automatically follow this nested input (default: `true`) | | `flake-file.inputs..inputs..inputs...` | `…` | Recursively follow deeper transitive deps | ### Example @@ -92,3 +93,11 @@ Each hook has: | `flake-file.prune-lock.program` | `pkgs -> drv` | Function building the lock-pruning executable | See [Lock Flattening guide](/guides/lock-flattening) for usage. + +## Automatic Follow Options + +| Option | Type | Description | +|---|---|---| +| `flake-file.auto-follow.enable` | `bool` | Maintain follows with `flake-edit follow` during writes and checks | + +See the [Automatic Follows guide](/guides/auto-follow) for automatic management and per-input exclusions. diff --git a/modules/options/auto-follow.nix b/modules/options/auto-follow.nix new file mode 100644 index 0000000..b11ace0 --- /dev/null +++ b/modules/options/auto-follow.nix @@ -0,0 +1,4 @@ +{ lib, ... }: +{ + options.flake-file.auto-follow.enable = lib.mkEnableOption "automatic flake input follows maintained with `flake-edit follow`"; +} diff --git a/modules/options/default.nix b/modules/options/default.nix index f5e59ec..abc43d9 100644 --- a/modules/options/default.nix +++ b/modules/options/default.nix @@ -2,6 +2,7 @@ imports = [ ./flake-file.nix ./inputs.nix + ./auto-follow.nix ./outputs.nix ./do-not-edit.nix ./formatter.nix diff --git a/modules/options/inputs.nix b/modules/options/inputs.nix index 60c9409..f5e6bf3 100644 --- a/modules/options/inputs.nix +++ b/modules/options/inputs.nix @@ -12,6 +12,16 @@ let type = lib.types.lazyAttrsOf ( lib.types.submodule { options = { + autoFollow = lib.mkOption { + description = '' + Whether `flake-edit follow` may automatically add a follows declaration + for this nested input. Set to false to exclude this input from automatic + following. This cannot be combined with a configured follows value when + automatic follows are enabled. + ''; + default = true; + type = lib.types.bool; + }; follows = follows-option; inputs = inputs-follow-option; }; diff --git a/modules/write-flake.nix b/modules/write-flake.nix index da09c31..c3e0e31 100644 --- a/modules/write-flake.nix +++ b/modules/write-flake.nix @@ -6,8 +6,10 @@ }@top: let inherit (import ./../dev/modules/_lib lib) + autoFollowIgnores inputsExpr isNonEmptyString + mergeAutoFollows priorityMapAttrsToList nixCode ; @@ -15,6 +17,16 @@ let inherit (config.flake-file.style) sep sortPriority; flake-file = config.flake-file; + auto-follow = flake-file.auto-follow; + + existingInputs = + if auto-follow.enable then (import "${top.inputs.self}/flake.nix").inputs or { } else { }; + + renderedInputs = + let + expr = flake-file.preProcess (inputsExpr flake-file.inputs); + in + if auto-follow.enable then mergeAutoFollows flake-file.inputs expr existingInputs else expr; unformatted = let @@ -63,11 +75,12 @@ let flakeInputs = "inputs = ${ nixCode { - expr = flake-file.preProcess (inputsExpr flake-file.inputs); + expr = renderedInputs; styles = [ { attrSortPriority = sortPriority.inputs; attrSep = sep.inputs; + collapseAttrs = !auto-follow.enable; } { attrSortPriority = sortPriority.inputSchema; @@ -98,6 +111,34 @@ let ''; }; + autoFollowConfig = + pkgs: + pkgs.writeText "flake-edit.toml" '' + [follow] + ignore = ${builtins.toJSON (autoFollowIgnores flake-file.inputs)} + transitive_min = 0 + aliases = {} + ''; + + autoFollowCommand = + pkgs: + let + minimumVersion = "0.3.5"; + package = + pkgs.flake-edit or (throw "flake-file auto-follow requires pkgs.flake-edit >= ${minimumVersion}"); + version = package.version or "unknown"; + flake-edit = + if lib.versionAtLeast version minimumVersion then + pkgs.lib.getExe package + else + throw "flake-file auto-follow requires pkgs.flake-edit >= ${minimumVersion}, but found ${version}"; + configFile = autoFollowConfig pkgs; + in + '' + ${flake-edit} --no-lock --non-interactive --no-cache --config ${configFile} follow + ${pkgs.lib.getExe (flake-file.formatter pkgs)} flake.nix + ''; + write-flake = pkgs: let @@ -110,12 +151,17 @@ let pkgs.writeShellApplication { name = "write-flake"; meta.description = "Generate a flake.nix file"; - runtimeInputs = [ pkgs.diffutils ]; + runtimeInputs = [ pkgs.diffutils ] ++ lib.optionals auto-follow.enable [ pkgs.nix ]; text = '' cd ${config.flake-file.intoPath} if ! cmp -s ${formatted pkgs} flake.nix; then cat ${formatted pkgs} > flake.nix fi + ${lib.optionalString auto-follow.enable '' + nix flake lock + ${autoFollowCommand pkgs} + nix flake lock --offline + ''} ${hooks} ''; }; @@ -137,6 +183,13 @@ let '' set -e diff -u ${top.inputs.self}/flake.nix ${formatted pkgs} + ${lib.optionalString auto-follow.enable '' + cp ${top.inputs.self}/flake.nix flake.nix + cp ${top.inputs.self}/flake.lock flake.lock + chmod u+w flake.nix flake.lock + ${autoFollowCommand pkgs} + diff -u ${top.inputs.self}/flake.nix flake.nix + ''} ${hooks} touch $out ''; From cbbd703611d3ed1525be45efe2278f05336024b0 Mon Sep 17 00:00:00 2001 From: Daniel Lazaro Date: Tue, 25 Aug 2026 15:29:13 -0400 Subject: [PATCH 2/2] feat: add auto-follow flake module --- docs/src/content/docs/guides/auto-follow.mdx | 5 ++++- docs/src/content/docs/guides/flake-modules.mdx | 6 ++++++ docs/src/content/docs/overview.mdx | 2 +- modules/auto-follow.nix | 4 ++++ modules/default.nix | 3 +++ 5 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 modules/auto-follow.nix diff --git a/docs/src/content/docs/guides/auto-follow.mdx b/docs/src/content/docs/guides/auto-follow.mdx index 3293ea2..845c4b7 100644 --- a/docs/src/content/docs/guides/auto-follow.mdx +++ b/docs/src/content/docs/guides/auto-follow.mdx @@ -8,11 +8,14 @@ flake-file can use [`flake-edit follow`](https://github.com/a-kenji/flake-edit) This is an alternative to the [`flake.lock` flattening hook](/guides/lock-flattening). Both approaches reduce duplicate transitive inputs, but they store the result in different places. Generally, choose one rather than enabling both. ```nix +{ inputs, ... }: { - flake-file.auto-follow.enable = true; + imports = [ inputs.flake-file.flakeModules.auto-follow ]; } ``` +Alternatively, enable the underlying option directly with `flake-file.auto-follow.enable = true`. + `write-flake` updates `flake.lock`, runs `flake-edit follow`, and updates the lock again. The generated-file check runs the same follow operation on a temporary copy, so `nix flake check` also detects missing or stale automatic follows. Automatic follows require `pkgs.flake-edit` version 0.3.5 or newer. Enabling the feature with an older or missing package produces an evaluation error identifying the required version. diff --git a/docs/src/content/docs/guides/flake-modules.mdx b/docs/src/content/docs/guides/flake-modules.mdx index 3aba24f..db55971 100644 --- a/docs/src/content/docs/guides/flake-modules.mdx +++ b/docs/src/content/docs/guides/flake-modules.mdx @@ -74,6 +74,12 @@ Enables automatic `flake.lock` flattening using [fzakaria/nix-auto-follow](https Source: [`modules/prune-lock/nix-auto-follow.nix`](https://github.com/vic/flake-file/tree/main/modules/prune-lock/nix-auto-follow.nix) +## `flakeModules.auto-follow` + +Enables automatic `follows` declarations maintained by [`flake-edit follow`](https://github.com/a-kenji/flake-edit). This is an alternative to the lock-flattening modules above. + +Source: [`modules/auto-follow.nix`](https://github.com/vic/flake-file/tree/main/modules/auto-follow.nix) + ## `flakeModules.npins` Defines `flake-file` options for [npins](https://github.com/andir/npins)-based environments. Exposes `write-npins`. Supports `github`, `gitlab`, `channel`, `tarball`, and `git` schemes. Respects `follows` for deduplication. Prunes stale pins automatically. diff --git a/docs/src/content/docs/overview.mdx b/docs/src/content/docs/overview.mdx index ae053c9..41479b9 100644 --- a/docs/src/content/docs/overview.mdx +++ b/docs/src/content/docs/overview.mdx @@ -53,7 +53,7 @@ flake-file treats `flake.nix` as a generated artifact. You declare inputs and se - All built-in flakeModules: `default`, `dendritic`, `import-tree`, `npins`, `unflake`, `tack`, `allfollow`, `nix-auto-follow`, and `flake-parts-builder`. + All built-in flakeModules: `default`, `dendritic`, `import-tree`, `npins`, `unflake`, `tack`, `allfollow`, `nix-auto-follow`, `auto-follow`, and `flake-parts-builder`. Learn More diff --git a/modules/auto-follow.nix b/modules/auto-follow.nix new file mode 100644 index 0000000..592bae7 --- /dev/null +++ b/modules/auto-follow.nix @@ -0,0 +1,4 @@ +{ lib, ... }: +{ + flake-file.auto-follow.enable = lib.mkDefault true; +} diff --git a/modules/default.nix b/modules/default.nix index cb32a96..2bebcc6 100644 --- a/modules/default.nix +++ b/modules/default.nix @@ -5,6 +5,7 @@ let default # for flake-parts flakes (keep as default for compatibility) allfollow nix-auto-follow + auto-follow dendritic import-tree npins @@ -61,6 +62,8 @@ let nix-auto-follow.imports = [ ./prune-lock/nix-auto-follow.nix ]; + auto-follow.imports = [ ./auto-follow.nix ]; + import-tree.imports = [ ./import-tree.nix ]; dendritic.imports = [ ./dendritic ];