Skip to content

Commit 255d2b3

Browse files
committed
feat: add support for patching icedos modules
1 parent 1c8acd9 commit 255d2b3

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
@@ -46,12 +45,70 @@ let
4645
}) config.repositories
4746
);
4847

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

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

105-
# Extract icedos modules from the flake
106-
modules = flake.icedosModules { icedosLib = finalIcedosLib; };
193+
# Extract icedos modules from the (possibly patched) flake
194+
modules = moduleFlake.icedosModules { icedosLib = finalIcedosLib; };
107195
in
108196
{
109197
url = nameParsed.baseUrl;
110-
fetchUrl = baseUrl;
111-
inherit (flake) narHash;
198+
# Patched repos are emitted as a locked `path:` input pointing at the
199+
# realised patched tree; unpatched repos keep their upstream url.
200+
fetchUrl = if hasPatches then "path:${patchedPath}" else baseUrl;
201+
inherit (moduleFlake) narHash;
112202
files = flatten modules;
113203
}
114-
// (optionalAttrs (hasAttr "rev" flake) { inherit (flake) rev; });
204+
// (optionalAttrs (!hasPatches && hasAttr "rev" baseFlake) { inherit (baseFlake) rev; });
115205

116206
# Convert external modules into flake input declarations
117207
# Filters out modules marked to skip as inputs
@@ -155,7 +245,6 @@ let
155245
modules:
156246
let
157247
inherit (builtins) attrNames filter getFlake;
158-
inherit (pkgs) applyPatches;
159248
modulesWithInputs = filter (hasAttr "inputs") modules;
160249
in
161250
flatten (
@@ -170,7 +259,17 @@ let
170259
i:
171260
let
172261
isOverride = inputIsOverride { input = inputs.${i}; };
173-
hasPatches = inputHasPatches { input = inputs.${i}; };
262+
263+
# Author patches (Nix path literals in the module, already store
264+
# paths) + consumer patches (config.toml strings declared via
265+
# `[[icedos.repositories.inputPatches]]`, resolved here). Both feed
266+
# one patched input; author patches apply first.
267+
_authorPatches = inputs.${i}.patches or [ ];
268+
_consumerPatches = _resolveConfigPatches (
269+
_consumerInputPatches."${_repoInfo.url}|${meta.name}|${i}" or [ ]
270+
);
271+
patches = _authorPatches ++ _consumerPatches;
272+
hasPatches = patches != [ ];
174273

175274
moduleIdentifier = mkInputName {
176275
parts = [
@@ -208,10 +307,10 @@ let
208307

209308
_patchSrcUrl = "${_patchSrcParsed.baseUrl}${_patchSrcRev}";
210309

211-
patchedInputSource = applyPatches {
310+
patchedInputSource = _mkPatchedSource {
212311
name = "${moduleIdentifier}-${i}-patched";
213-
patches = inputs.${i}.patches;
214312
src = getFlake _patchSrcUrl |> toString;
313+
inherit patches;
215314
};
216315

217316
patchedInput = rec {
@@ -361,6 +460,31 @@ let
361460
else
362461
existingOverrides;
363462

463+
# Build a set of repo-url -> patch-list mappings from config repositories.
464+
# Mirrors `_buildOverridesMap` so a repository's `patches` apply to EVERY
465+
# fetch of that url — including transitive (self-)dependency fetches, which
466+
# otherwise re-fetch the repo unpatched and leak an unpatched input (the repo
467+
# maps to a single flake input, so its patch set must be consistent).
468+
_buildPatchesMap =
469+
{
470+
newDeps,
471+
loadOverrides,
472+
existingPatches,
473+
}:
474+
let
475+
inherit (builtins) filter listToAttrs;
476+
filteredDeps = filter (dep: (dep.patches or [ ]) != [ ]) newDeps;
477+
in
478+
if loadOverrides then
479+
listToAttrs (
480+
map (dep: {
481+
name = dep.url;
482+
value = dep.patches;
483+
}) filteredDeps
484+
)
485+
else
486+
existingPatches;
487+
364488
# Load module files from a repository and ensure a default module exists
365489
# Returns list of modules with _repoInfo attached to each
366490
_loadModulesFromRepo =
@@ -461,6 +585,7 @@ let
461585
newDeps,
462586
existingDeps ? [ ],
463587
existingOverrides ? [ ],
588+
existingPatches ? { },
464589
loadOverrides ? false,
465590
}:
466591
let
@@ -477,6 +602,13 @@ let
477602
inherit newDeps loadOverrides existingOverrides;
478603
};
479604

605+
# Build patch map (repo url -> patch list) the same way, so a repo's
606+
# patches follow it across the whole dependency tree, not just its
607+
# top-level config entry.
608+
patchesMap = _buildPatchesMap {
609+
inherit newDeps loadOverrides existingPatches;
610+
};
611+
480612
# Process each dependency and accumulate modules + missing-reference diagnostics
481613
result =
482614
foldl'
@@ -487,9 +619,17 @@ let
487619
missingModules = filter (mod: !_isModuleLoaded existingDeps newDep.url mod) (newDep.modules or [ ]);
488620

489621
# Fetch repository if new modules are needed or default isn't loaded
490-
newRepo = optional (
491-
((length missingModules) > 0) || !_isModuleLoaded existingDeps newDep.url "default"
492-
) (fetchModulesRepository (newDep // { inherit overrides; }));
622+
newRepo =
623+
optional (((length missingModules) > 0) || !_isModuleLoaded existingDeps newDep.url "default")
624+
(
625+
fetchModulesRepository (
626+
newDep
627+
// {
628+
inherit overrides;
629+
patches = patchesMap.${newDep.url} or [ ];
630+
}
631+
)
632+
);
493633

494634
# All modules present in the fetched repository (includes synthesized "default")
495635
repoModules = flatMap _loadModulesFromRepo newRepo;
@@ -542,6 +682,7 @@ let
542682
newDeps = innerDeps;
543683
existingDeps = allKnownKeys;
544684
existingOverrides = overrides;
685+
existingPatches = patchesMap;
545686
}
546687
else
547688
{

modules/options.nix

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,21 @@ in
123123
overrideUrl = mkStrOption { default = ""; };
124124
fetchOptionalDependencies = mkBoolOption { default = false; };
125125
modules = mkStrListOption { default = [ ]; };
126+
# Patch files applied to the whole repo source on top of its pinned rev.
127+
# Paths are config-root-relative (they must live inside the config repo
128+
# so they reach the store). The repo analog of a module input's
129+
# `patches` (see `_getModuleInputs`).
130+
patches = mkStrListOption { default = [ ]; };
131+
132+
# Consumer-declared input patches: patch a specific module's specific
133+
# flake input from config, without forking the module (the consumer
134+
# analog of a module author's `inputs.<input>.patches`). `patches` are
135+
# config-root-relative files; they apply after any author patches.
136+
inputPatches = mkSubmoduleListOption { default = [ ]; } {
137+
module = mkStrOption { };
138+
input = mkStrOption { };
139+
patches = mkStrListOption { default = [ ]; };
140+
};
126141
};
127142

128143
users = mkSubmoduleAttrsOption { } {

0 commit comments

Comments
 (0)