Skip to content

Commit 74a21cd

Browse files
committed
feat: add support for patching icedos modules
1 parent 8496519 commit 74a21cd

3 files changed

Lines changed: 186 additions & 16 deletions

File tree

lib/genflake.nix

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@ let
88

99
inherit (lib)
1010
all
11+
any
1112
boolToString
1213
concatMapStrings
1314
concatStringsSep
1415
elem
1516
evalModules
1617
fileContents
1718
filter
19+
flatten
1820
generators
1921
hasPrefix
2022
imap0
@@ -47,6 +49,17 @@ let
4749
".private.toml"
4850
];
4951

52+
# Patch files declared by `[[icedos.repositories]]` `patches`. They must
53+
# survive into the filtered config flake so the build stage can read them
54+
# from `inputs.icedos-config`: build-stage eval is pure and cannot reach the
55+
# host config root the way the impure genflake eval can.
56+
repoPatchKeep = flatten (
57+
map (r: (r.patches or [ ]) ++ map (ip: ip.patches or [ ]) (r.inputPatches or [ ])) (
58+
icedos.repositories or [ ]
59+
)
60+
);
61+
keepPatch = rel: any (pp: pp == rel || hasPrefix "${rel}/" pp) repoPatchKeep;
62+
5063
filteredConfigRoot = builtins.path {
5164
name = "icedos-config";
5265
path = /. + ICEDOS_CONFIG_ROOT;
@@ -58,7 +71,8 @@ let
5871
in
5972
(elem relativePath configRootKeep)
6073
|| (relativePath == "extra-modules")
61-
|| (hasPrefix "extra-modules/" relativePath);
74+
|| (hasPrefix "extra-modules/" relativePath)
75+
|| (keepPatch relativePath);
6276
};
6377

6478
channels = icedos.system.channels or [ ];

lib/icedos.nix

Lines changed: 156 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ let
2929
filterByAttrs
3030
findFirst
3131
flatMap
32-
inputHasPatches
3332
inputIsOverride
3433
mkInputName
3534
stringStartsWith
@@ -56,12 +55,70 @@ let
5655
}) config.repositories
5756
);
5857

58+
# Apply patches to a flake source and return a realised, context-free store
59+
# path usable as a locked `path:` flake input. Realising (readDir/IFD) makes
60+
# the path exist when genflake renders the input; discarding context lets the
61+
# `nix eval --raw` genflake output accept it (it forbids store-path context).
62+
# Shared by whole-repo patches (fetchModulesRepository) and input patches
63+
# (_getModuleInputs) so the three pure-eval gotchas are handled in one place.
64+
_mkPatchedSource =
65+
{
66+
name,
67+
src,
68+
patches,
69+
}:
70+
let
71+
inherit (builtins) readDir seq unsafeDiscardStringContext;
72+
patched = pkgs.applyPatches { inherit name src patches; };
73+
in
74+
seq (readDir patched) (unsafeDiscardStringContext (toString patched));
75+
76+
# Resolve config-root-relative patch strings (from config.toml) to store
77+
# paths the applyPatches builder can read in its sandbox. Stage-aware:
78+
# - genflake: impure `--file` eval with `inputs` empty → copy from the host
79+
# config root via builtins.path.
80+
# - build: pure flake eval where host paths are unreadable and
81+
# ICEDOS_CONFIG_ROOT is masked → read from the config flake input
82+
# (genflake keeps these files in `filteredConfigRoot`).
83+
# Used for whole-repo patches and consumer-declared input patches; author
84+
# input patches are Nix path literals and bypass this (already store paths).
85+
# Closes over the top-level `inputs`, so callers that shadow `inputs` (e.g.
86+
# _getModuleInputs, where `inputs` is a module's input set) still resolve
87+
# icedos-config correctly.
88+
_resolveConfigPatches =
89+
patches:
90+
map (
91+
p:
92+
if (ICEDOS_STAGE == "genflake") then
93+
builtins.path { path = /. + "${ICEDOS_CONFIG_ROOT}/${p}"; }
94+
else
95+
inputs.icedos-config + "/${p}"
96+
) patches;
97+
98+
# Consumer-declared input patches from config.toml, as a flat
99+
# "<repo url>|<module>|<input>" -> [patch strings] lookup. Lets a user patch a
100+
# module's flake input without forking the module — the consumer-facing analog
101+
# of a module author's `inputs.<x>.patches`. Separator `|` never appears in a
102+
# flake url / module name / input name.
103+
_consumerInputPatches = builtins.listToAttrs (
104+
flatten (
105+
map (
106+
repo:
107+
map (ip: {
108+
name = "${repo.url}|${ip.module}|${ip.input}";
109+
value = ip.patches;
110+
}) (repo.inputPatches or [ ])
111+
) config.repositories
112+
)
113+
);
114+
59115
# Fetch a modules repository, resolving the URL and loading its icedos modules
60116
# Handles overrides, flake resolution, and module file loading
61117
fetchModulesRepository =
62118
{
63119
url,
64120
overrides,
121+
patches ? [ ],
65122
...
66123
}:
67124
let
@@ -109,19 +166,52 @@ let
109166
# Build complete flake URL with revision
110167
flakeUrl = "${baseUrl}${flakeRev}";
111168

112-
# Load the flake (either fresh or from inputs)
113-
flake = if (ICEDOS_STAGE == "genflake") then (getFlake flakeUrl) else inputs.${repoName};
169+
# Resolve the repo flake: fresh at genflake, from the locked input at
170+
# build. For a patched repo the build-stage input already resolves to the
171+
# patched tree (see `fetchUrl` below), so `baseFlake` is the patched flake
172+
# there — fine, since the patch machinery is only forced at genflake.
173+
baseFlake = if (ICEDOS_STAGE == "genflake") then (getFlake flakeUrl) else inputs.${repoName};
174+
175+
# Optional whole-repo patches — the repo analog of `_getModuleInputs`'
176+
# input patching. The patched tree is emitted as the repo's own `path:`
177+
# flake input (see `fetchUrl`): nix locks that input (narHash in
178+
# flake.lock), so the build stage consumes it as a normal locked input
179+
# rather than via `getFlake`, which pure eval rejects for an unlocked
180+
# path. The diff stays on the locked rev since it is applied to the
181+
# upstream `baseFlake` resolved at genflake.
182+
hasPatches = patches != [ ];
183+
184+
# Realised, context-free patched tree (see `_mkPatchedSource`); patch
185+
# files are config-root-relative strings resolved via the shared helper.
186+
# Emitted below as the repo's own locked `path:` input.
187+
patchedPath = _mkPatchedSource {
188+
name = "${repoName}-patched";
189+
src = baseFlake.outPath;
190+
patches = _resolveConfigPatches patches;
191+
};
192+
193+
# icedos modules come from: upstream when unpatched; the freshly patched
194+
# tree at genflake (impure getFlake ok); the locked patched input at build.
195+
moduleFlake =
196+
if !hasPatches then
197+
baseFlake
198+
else if (ICEDOS_STAGE == "genflake") then
199+
getFlake "path:${patchedPath}"
200+
else
201+
inputs.${repoName};
114202

115-
# Extract icedos modules from the flake
116-
modules = flake.icedosModules { icedosLib = finalIcedosLib; };
203+
# Extract icedos modules from the (possibly patched) flake
204+
modules = moduleFlake.icedosModules { icedosLib = finalIcedosLib; };
117205
in
118206
{
119207
url = nameParsed.baseUrl;
120-
fetchUrl = baseUrl;
121-
inherit (flake) narHash;
208+
# Patched repos are emitted as a locked `path:` input pointing at the
209+
# realised patched tree; unpatched repos keep their upstream url.
210+
fetchUrl = if hasPatches then "path:${patchedPath}" else baseUrl;
211+
inherit (moduleFlake) narHash;
122212
files = flatten modules;
123213
}
124-
// (optionalAttrs (hasAttr "rev" flake) { inherit (flake) rev; });
214+
// (optionalAttrs (!hasPatches && hasAttr "rev" baseFlake) { inherit (baseFlake) rev; });
125215

126216
# Convert external modules into flake input declarations
127217
# Filters out modules marked to skip as inputs
@@ -165,7 +255,6 @@ let
165255
modules:
166256
let
167257
inherit (builtins) attrNames filter getFlake;
168-
inherit (pkgs) applyPatches;
169258
modulesWithInputs = filter (hasAttr "inputs") modules;
170259
in
171260
flatten (
@@ -180,7 +269,17 @@ let
180269
i:
181270
let
182271
isOverride = inputIsOverride { input = inputs.${i}; };
183-
hasPatches = inputHasPatches { input = inputs.${i}; };
272+
273+
# Author patches (Nix path literals in the module, already store
274+
# paths) + consumer patches (config.toml strings declared via
275+
# `[[icedos.repositories.inputPatches]]`, resolved here). Both feed
276+
# one patched input; author patches apply first.
277+
_authorPatches = inputs.${i}.patches or [ ];
278+
_consumerPatches = _resolveConfigPatches (
279+
_consumerInputPatches."${_repoInfo.url}|${meta.name}|${i}" or [ ]
280+
);
281+
patches = _authorPatches ++ _consumerPatches;
282+
hasPatches = patches != [ ];
184283

185284
moduleIdentifier = mkInputName {
186285
parts = [
@@ -218,10 +317,10 @@ let
218317

219318
_patchSrcUrl = "${_patchSrcParsed.baseUrl}${_patchSrcRev}";
220319

221-
patchedInputSource = applyPatches {
320+
patchedInputSource = _mkPatchedSource {
222321
name = "${moduleIdentifier}-${i}-patched";
223-
patches = inputs.${i}.patches;
224322
src = getFlake _patchSrcUrl |> toString;
323+
inherit patches;
225324
};
226325

227326
patchedInput = rec {
@@ -371,6 +470,31 @@ let
371470
else
372471
existingOverrides;
373472

473+
# Build a set of repo-url -> patch-list mappings from config repositories.
474+
# Mirrors `_buildOverridesMap` so a repository's `patches` apply to EVERY
475+
# fetch of that url — including transitive (self-)dependency fetches, which
476+
# otherwise re-fetch the repo unpatched and leak an unpatched input (the repo
477+
# maps to a single flake input, so its patch set must be consistent).
478+
_buildPatchesMap =
479+
{
480+
newDeps,
481+
loadOverrides,
482+
existingPatches,
483+
}:
484+
let
485+
inherit (builtins) filter listToAttrs;
486+
filteredDeps = filter (dep: (dep.patches or [ ]) != [ ]) newDeps;
487+
in
488+
if loadOverrides then
489+
listToAttrs (
490+
map (dep: {
491+
name = dep.url;
492+
value = dep.patches;
493+
}) filteredDeps
494+
)
495+
else
496+
existingPatches;
497+
374498
# Load module files from a repository and ensure a default module exists
375499
# Returns list of modules with _repoInfo attached to each
376500
_loadModulesFromRepo =
@@ -475,6 +599,7 @@ let
475599
newDeps,
476600
existingDeps ? [ ],
477601
existingOverrides ? [ ],
602+
existingPatches ? { },
478603
loadOverrides ? false,
479604
}:
480605
let
@@ -491,6 +616,13 @@ let
491616
inherit newDeps loadOverrides existingOverrides;
492617
};
493618

619+
# Build patch map (repo url -> patch list) the same way, so a repo's
620+
# patches follow it across the whole dependency tree, not just its
621+
# top-level config entry.
622+
patchesMap = _buildPatchesMap {
623+
inherit newDeps loadOverrides existingPatches;
624+
};
625+
494626
# Process each dependency and accumulate modules + missing-reference diagnostics
495627
result =
496628
foldl'
@@ -501,9 +633,17 @@ let
501633
missingModules = filter (mod: !_isModuleLoaded existingDeps newDep.url mod) (newDep.modules or [ ]);
502634

503635
# Fetch repository if new modules are needed or default isn't loaded
504-
newRepo = optional (
505-
((length missingModules) > 0) || !_isModuleLoaded existingDeps newDep.url "default"
506-
) (fetchModulesRepository (newDep // { inherit overrides; }));
636+
newRepo =
637+
optional (((length missingModules) > 0) || !_isModuleLoaded existingDeps newDep.url "default")
638+
(
639+
fetchModulesRepository (
640+
newDep
641+
// {
642+
inherit overrides;
643+
patches = patchesMap.${newDep.url} or [ ];
644+
}
645+
)
646+
);
507647

508648
# All modules present in the fetched repository (includes synthesized "default")
509649
repoModules = flatMap _loadModulesFromRepo newRepo;
@@ -558,6 +698,7 @@ let
558698
newDeps = innerDeps;
559699
existingDeps = allKnownKeys;
560700
existingOverrides = overrides;
701+
existingPatches = patchesMap;
561702
}
562703
else
563704
{

modules/options.nix

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,21 @@ in
135135
fetchDependencies = mkBoolOption { default = true; };
136136
fetchOptionalDependencies = mkBoolOption { default = false; };
137137
modules = mkStrListOption { default = [ ]; };
138+
# Patch files applied to the whole repo source on top of its pinned rev.
139+
# Paths are config-root-relative (they must live inside the config repo
140+
# so they reach the store). The repo analog of a module input's
141+
# `patches` (see `_getModuleInputs`).
142+
patches = mkStrListOption { default = [ ]; };
143+
144+
# Consumer-declared input patches: patch a specific module's specific
145+
# flake input from config, without forking the module (the consumer
146+
# analog of a module author's `inputs.<input>.patches`). `patches` are
147+
# config-root-relative files; they apply after any author patches.
148+
inputPatches = mkSubmoduleListOption { default = [ ]; } {
149+
module = mkStrOption { };
150+
input = mkStrOption { };
151+
patches = mkStrListOption { default = [ ]; };
152+
};
138153
};
139154

140155
users = mkSubmoduleAttrsOption { } {

0 commit comments

Comments
 (0)