aboutsummaryrefslogtreecommitdiffstats
path: root/pkgs
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-13 18:46:19 +0000
committertv <tv@krebsco.de>2026-11-13 19:48:42 +0100
commit841ddf3e6d5472362e45f3dbc079a368285467f9 (patch)
tree25f80597e1f34781540f5df917f02e4540787d87 /pkgs
parent0e0880fc606e9277c1a52e637cebe7dd3438410d (diff)
populate: draw flake inputs natively in Rust
The program decides how to draw inputs and derive copies of flake and flake-input sources itself. The target runs fixed snippets, which send back what it needs to know: where paths resolve to and what's there, the lock files of flakes as nix flake metadata prints them, and what tells whether a derived copy needs to be made again. The program checks that drawn inputs exist in the lock files, computes the keys of derived copies, and writes generated flakes. Derived copies get the same keys as with the shell implementation, so switching implementations doesn't make them again.
Diffstat (limited to 'pkgs')
-rw-r--r--pkgs/populate/docs/design.md16
-rw-r--r--pkgs/populate/src/flake.rs1351
-rw-r--r--pkgs/populate/src/populate.rs2
-rw-r--r--pkgs/populate/src/sh.rs261
4 files changed, 1021 insertions, 609 deletions
diff --git a/pkgs/populate/docs/design.md b/pkgs/populate/docs/design.md
index 3340ccf..9e606b2 100644
--- a/pkgs/populate/docs/design.md
+++ b/pkgs/populate/docs/design.md
@@ -177,12 +177,12 @@ Populate runs rsync, Git, ssh, and Nix as programs, found in `PATH`.
## Status
The program delivers all source types, draws inputs from sources, makes
-backups, and prunes. Drawing inputs, in `src/flake.rs`, builds the same
-shell code that `shell.nix` generates, with every value quoted; it moves
-to Rust like delivering flake and flake-input sources did, in
-`src/flake_input.rs`, which reads what Nix prints as JSON itself, and runs
-fixed snippets on the target that send JSON or store paths back.
+backups, and prunes. It decides what to do itself: the snippets it runs
+on the target send back what it needs to know, e.g. what Nix prints as
+JSON, where paths resolve to, or store paths. Delivering flake and
+flake-input sources is in `src/flake_input.rs`, drawing inputs and
+deriving copies in `src/flake.rs`.
-The jq programs in `flake-jq.nix`, which the shell implementation and the
-jq test use, have equivalents in Rust, with the jq test's cases as unit
-tests.
+The jq programs and Nix functions in `flake-jq.nix`, which the shell
+implementation and the jq test use, have equivalents in Rust, with unit
+tests, among them the jq test's cases.
diff --git a/pkgs/populate/src/flake.rs b/pkgs/populate/src/flake.rs
index f03e443..b6b0790 100644
--- a/pkgs/populate/src/flake.rs
+++ b/pkgs/populate/src/flake.rs
@@ -1,37 +1,20 @@
-//! Drawing inputs of flakes in sources from other sources.
+//! Drawing inputs of flakes in sources from other sources, and deriving
+//! what flake and flake-input sources that get patched, get their lock
+//! files rewritten, or are materialized deliver.
//!
-//! This builds the same shell code as shell.nix generates, with every value
-//! quoted, so both implementations behave alike while the code moves to
-//! Rust piece by piece.
+//! Populate decides what to do. The target runs the fixed snippets of
+//! sh::draw, which send back what populate needs to know: where paths
+//! resolve to, the lock files of flakes, and what tells whether a derived
+//! copy needs to be made again.
-use crate::run::{quote as q, Context};
+use crate::run::{Context, Failure, Result};
use crate::sh;
use crate::spec::{Follows, Source};
use serde_json::{Map, Value};
use std::collections::{BTreeMap, BTreeSet};
-/// Nix function returning 1 if the lock file locks has an input at path, a
-/// list of input names, resolving inputs that follow others, and else 0.
-/// Like lockHasInput in flake-jq.nix, for the shell code drawing inputs.
-pub const LOCK_HAS_INPUT: &str = r#"locks: path: let
- nodes = locks.nodes;
- input = key: name: (nodes.${key}.inputs or {}).${name} or null;
- # The node an input refers to, following follows from the root.
- node = ref:
- if ref == null then null
- else if builtins.isList ref then
- builtins.foldl'
- (key: name: if key == null then null else node (input key name))
- locks.root
- ref
- else ref;
- has = key: path:
- if path == [] then true
- else let next = node (input key (builtins.head path)); in
- next != null && has next (builtins.tail path);
-in
- if has locks.root path then "1" else "0"
-"#;
+/// What needs flakes, for the error if Nix on the target lacks them.
+const DRAWING: &str = "drawing flake inputs from sources";
fn bool_setting(settings: &Map<String, Value>, key: &str, default: bool) -> bool {
settings
@@ -69,630 +52,676 @@ pub fn derives_flake(source: &Source) -> bool {
|| (bool_setting(settings, "rewriteLock", true) && !source.follows().is_empty()))
}
-fn use_state(sentinel: &str, names: &[&str]) -> String {
- let mut call = format!("use_state {}", q(sentinel));
- for name in names {
- call.push(' ');
- call.push_str(&q(name));
+/// A path on the target, where it resolves to, and what's there.
+#[derive(Debug, Default)]
+struct Resolved {
+ path: String,
+ dir: bool,
+ git: bool,
+ submodules: bool,
+ flake: bool,
+}
+
+/// What a path is in a URL: with every byte percent-encoded that isn't a
+/// letter, a digit, or one of -._~/. Nix decodes that, and otherwise
+/// takes, e.g., ? and # for delimiters, and refuses blanks.
+fn url_path(path: &str) -> String {
+ path.bytes()
+ .map(|b| {
+ if b.is_ascii_alphanumeric() || b"-._~/".contains(&b) {
+ (b as char).to_string()
+ } else {
+ format!("%{b:02x}")
+ }
+ })
+ .collect()
+}
+
+/// Source names become input names in generated flakes, where Nix accepts
+/// fewer names than populate. Others get replaced by one derived from
+/// them.
+fn input_name(name: &str) -> String {
+ let ok = name.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
+ && name
+ .chars()
+ .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
+ && name != "self";
+ if ok {
+ name.to_string()
+ } else {
+ format!("source-{}", &sha256_hex(name.as_bytes())[..16])
}
- call
}
-/// Draws inputs of flakes in sources from other sources, and derives what
-/// the target paths of flake and flake-input sources that get patched, get
-/// their lock files rewritten, or are materialized get. Sources get
-/// processed in the order they draw inputs from each other. Returns the
-/// shell code to run on the target, which also removes state that sources
-/// don't need anymore.
-///
-/// Inputs get drawn by rewriting the lock file of the flake in the source
-/// with nix flake lock --override-input: in place for file and git sources,
-/// and in a copy for flake and flake-input sources. Sources with
-/// rewriteLock set to false, and symlink sources, get a generated flake in
-/// .populate/flakes/<name> instead, which re-exports the outputs of the
-/// flake in the source, with those inputs drawn from the other sources.
-/// Lock files get rewritten or generated on every run, so they match the
-/// sources.
-pub fn flakes(context: &Context, dir: &str, sources: &BTreeMap<String, Source>) -> String {
- let sentinel = format!("{dir}/.populate");
- let flakes_dir = format!("{sentinel}/flakes");
- let derived_dir = format!("{sentinel}/derived");
- let patches_dir = format!("{sentinel}/patches");
- let follows: BTreeMap<&str, Vec<Follows>> = sources
- .iter()
- .map(|(n, s)| (n.as_str(), s.follows()))
- .collect();
- let drawing: BTreeSet<&str> = follows
- .iter()
- .filter(|(_, f)| !f.is_empty())
- .map(|(n, _)| *n)
- .collect();
- let generated: BTreeSet<&str> = sources
- .iter()
- .filter(|(_, s)| has_generated_flake(s))
- .map(|(n, _)| n.as_str())
- .collect();
- let derived: BTreeSet<&str> = sources
- .iter()
- .filter(|(_, s)| derives_flake(s))
- .map(|(n, _)| n.as_str())
- .collect();
- let patched: BTreeSet<&str> = sources
- .iter()
- .filter(|(_, s)| !patches(&s.settings).is_empty())
- .map(|(n, _)| n.as_str())
- .collect();
- let names: BTreeSet<&str> = drawing.union(&derived).copied().collect();
+/// The follows of a source that draws the input at path on the way to it,
+/// if any: the one with the longest path that path starts with.
+fn parent_of<'a>(follows: &'a [Follows], path: &[String]) -> Option<&'a Follows> {
+ (1..path.len())
+ .rev()
+ .find_map(|n| follows.iter().find(|f| f.path == path[..n]))
+}
- // Source names become input names in generated flakes, where Nix
- // accepts fewer names than populate. Others get replaced by one
- // derived from them.
- let input_name = |name: &str| -> String {
- let ok = name.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
- && name
- .chars()
- .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
- && name != "self";
- if ok {
- name.to_string()
- } else {
- format!("source-{}", &sha256_hex(name.as_bytes())[..16])
- }
- };
- let drawn_from =
- |name: &str| -> Vec<String> { follows[name].iter().map(|f| f.source.clone()).collect() };
- let sorted = toposort(&names, &drawn_from);
+/// Where the input at path of the flake in the source named name is in the
+/// flake that the source's inputs get drawn into: the flake itself, or its
+/// generated flake. On the way, inputs that get drawn from sources are the
+/// flakes of those sources, where drawing went on, so the path continues
+/// there. A generated flake has its source and the sources that one draws
+/// from as inputs, and nothing else, so the path names them there.
+fn drawn_path(
+ name: &str,
+ path: &[String],
+ follows: &BTreeMap<&str, Vec<Follows>>,
+ generated: &BTreeSet<&str>,
+) -> Vec<String> {
+ let parent = parent_of(&follows[name], path);
+ let rest = |p: &Follows| drawn_path(&p.source, &path[p.path.len()..], follows, generated);
+ match (generated.contains(name), parent) {
+ (true, None) => [vec![input_name(name)], path.to_vec()].concat(),
+ (true, Some(p)) => [vec![input_name(&p.source)], rest(p)].concat(),
+ (false, None) => path.to_vec(),
+ (false, Some(p)) => [p.path.clone(), rest(p)].concat(),
+ }
+}
- // Shell word for the URL of the flake that inputs drawn from a source
- // use: its generated flake, if it has one, so overrides don't need to
- // be repeated, or else the flake in the source. Nix refuses path: URLs
- // with symlinks on the way, which the target directory may be, so paths
- // get resolved.
- let url = |name: &str| -> String {
- if generated.contains(name) {
- format!(
- "\"path:$(url_path \"$(readlink -f {})\")\"",
- q(&format!("{flakes_dir}/{name}"))
- )
- } else {
- format!("\"$(flake_url {})\"", q(name))
- }
- };
+/// The inputs that drawing the input at path of the flake in the source
+/// named name needs to exist, as (name, path) of the source whose flake
+/// has the input at path.
+fn input_checks(
+ name: &str,
+ path: &[String],
+ follows: &BTreeMap<&str, Vec<Follows>>,
+) -> Vec<(String, Vec<String>)> {
+ match parent_of(&follows[name], path) {
+ None => vec![(name.to_string(), path.to_vec())],
+ Some(p) => [
+ vec![(name.to_string(), p.path.clone())],
+ input_checks(&p.source, &path[p.path.len()..], follows),
+ ]
+ .concat(),
+ }
+}
- // Where the input at path of the flake in the source named name is in
- // the flake that the source's inputs get drawn into: the flake itself,
- // or its generated flake. On the way, inputs that get drawn from
- // sources are the flakes of those sources, where drawing went on, so
- // the path continues there. A generated flake has its source and the
- // sources that one draws from as inputs, and nothing else, so the path
- // names them there.
- fn parent_of<'a>(follows: &'a [Follows], path: &[String]) -> Option<&'a Follows> {
- (1..path.len())
- .rev()
- .find_map(|n| follows.iter().find(|f| f.path == path[..n]))
+/// Whether the lock file locks, as nix flake metadata prints it, has an
+/// input at path, a list of input names, resolving inputs that follow
+/// others.
+fn lock_has_input(locks: &Value, path: &[String]) -> bool {
+ let nodes = &locks["nodes"];
+ let Some(root) = locks["root"].as_str() else {
+ return false;
+ };
+ let input = |key: &str, name: &str| -> Option<&Value> {
+ Some(&nodes[key]["inputs"][name]).filter(|input| !input.is_null())
+ };
+ // The node an input refers to, following follows from the root.
+ fn node<'a>(
+ reference: Option<&'a Value>,
+ root: &'a str,
+ input: &dyn Fn(&str, &str) -> Option<&'a Value>,
+ depth: usize,
+ ) -> Option<&'a str> {
+ if depth > 64 {
+ return None;
+ }
+ match reference? {
+ Value::String(key) => Some(key),
+ Value::Array(names) => names.iter().try_fold(root, |key, name| {
+ node(input(key, name.as_str()?), root, input, depth + 1)
+ }),
+ _ => None,
+ }
}
- fn drawn_path(
- name: &str,
- path: &[String],
- follows: &BTreeMap<&str, Vec<Follows>>,
- generated: &BTreeSet<&str>,
- input_name: &dyn Fn(&str) -> String,
- ) -> Vec<String> {
- let parent = parent_of(&follows[name], path);
- match (generated.contains(name), parent) {
- (true, None) => [vec![input_name(name)], path.to_vec()].concat(),
- (true, Some(p)) => [
- vec![input_name(&p.source)],
- drawn_path(
- &p.source,
- &path[p.path.len()..],
- follows,
- generated,
- input_name,
- ),
- ]
- .concat(),
- (false, None) => path.to_vec(),
- (false, Some(p)) => [
- p.path.clone(),
- drawn_path(
- &p.source,
- &path[p.path.len()..],
- follows,
- generated,
- input_name,
- ),
- ]
- .concat(),
+ let mut key = root;
+ for name in path {
+ match node(input(key, name), root, &input, 0) {
+ Some(next) => key = next,
+ None => return false,
}
}
- let drawn_path =
- |name: &str, path: &[String]| drawn_path(name, path, &follows, &generated, &input_name);
+ true
+}
- // The inputs that drawing the input at path of the flake in the source
- // named name needs to exist, as (name, path) of the source whose flake
- // has the input at path.
- fn input_checks(
- name: &str,
- path: &[String],
- follows: &BTreeMap<&str, Vec<Follows>>,
- ) -> Vec<(String, Vec<String>)> {
- match parent_of(&follows[name], path) {
- None => vec![(name.to_string(), path.to_vec())],
- Some(p) => [
- vec![(name.to_string(), p.path.clone())],
- input_checks(&p.source, &path[p.path.len()..], follows),
- ]
- .concat(),
- }
+/// The store path a path below the store is in, and the rest of the path.
+fn split_store_path(path: &str) -> (&str, &str) {
+ let end = path
+ .match_indices('/')
+ .nth(3)
+ .map(|(i, _)| i)
+ .unwrap_or(path.len());
+ let rest = &path[end..];
+ (&path[..end], rest.strip_prefix('/').unwrap_or(rest))
+}
+
+/// The sources, and what drawing inputs and deriving copies needs to know
+/// about them.
+struct Flakes<'a> {
+ context: &'a Context,
+ dir: &'a str,
+ sources: &'a BTreeMap<String, Source>,
+ follows: BTreeMap<&'a str, Vec<Follows>>,
+ /// The sources drawing inputs from others.
+ drawing: BTreeSet<&'a str>,
+ /// The sources with a generated flake.
+ generated: BTreeSet<&'a str>,
+ /// The sources with derived state.
+ derived: BTreeSet<&'a str>,
+}
+
+impl<'a> Flakes<'a> {
+ fn sentinel(&self) -> String {
+ format!("{}/.populate", self.dir)
}
- // Sets the positional parameters to nix flake lock's arguments for
- // drawing the inputs of the flake in source name from other sources.
- let override_args = |name: &str| -> String {
- let mut text = String::from("set --\n");
- for f in &follows[name] {
- text += &format!(
- "set -- \"$@\" --override-input {} {}\n",
- q(&drawn_path(name, &f.path).join("/")),
- url(&f.source)
- );
- }
- text
- };
+ fn state(&self, kind: &str, name: &str) -> String {
+ format!("{}/{kind}/{name}", self.sentinel())
+ }
- // Rewrites the lock file of the flake in a file or git source in place.
- let rewrite = |name: &str| -> String {
- format!(
- "(\n{}own_lock \"$(readlink -f {})\"\nnix flake lock \"$@\" \"$(flake_url {})\" >&2\n)\n",
- override_args(name),
- q(&format!("{dir}/{name}")),
- q(name)
- )
- };
+ fn path(&self, name: &str) -> String {
+ format!("{}/{name}", self.dir)
+ }
- // Derives what the target path of a flake or flake-input source gets
- // from what got delivered to .populate/derived/<name>/original: a copy,
- // which gets patched and gets its lock file rewritten. Unless
- // materialized, the copy gets added to the store, and the target path
- // becomes a link to it. Copies get made again only if what they derive
- // from changes: materialized ones if the delivered store path, the
- // patches, or what inputs get drawn from which sources changes, and
- // others also if the sources they draw inputs from change.
- let derive = |name: &str| -> String {
- let source = &sources[name];
- let settings = &source.settings;
- let materialize = bool_setting(settings, "materialize", false);
- let rewrite_lock = bool_setting(settings, "rewriteLock", true) && drawing.contains(name);
- let target_path = format!("{dir}/{name}");
- let dir_ = format!("{derived_dir}/{name}");
- let has_patches = !patches(settings).is_empty();
- let follows_json: Vec<Value> = follows[name]
- .iter()
- .map(|f| serde_json::json!({ "path": f.path, "source": f.source }))
- .collect();
- // Like Nix' toJSON: attributes sorted by name.
- let spec = serde_json::to_string(&serde_json::json!({
- "follows": follows_json,
- "materialize": materialize,
- "rewriteLock": rewrite_lock,
- }))
- .expect("serializes");
- let patch_dir = format!("{patches_dir}/{name}");
- let apply_patches = if has_patches {
- format!(
- r#"ls {pd} | while read -r patch; do
- if ! patch -p1 -s --batch --no-backup-if-mismatch \
- -i {pd}/"$patch" >&2; then
- >&2 printf 'error: patch %s does not apply to source %s\n' \
- "$(printf %s "$patch" | cut -c6-)" {name}
- exit 1
- fi
-done
-"#,
- pd = q(&patch_dir),
- name = q(name)
- )
+ /// The shell code of snippet for the target, with the definitions it
+ /// uses, enabling flakes if flakes is set.
+ fn on_target(snippet: &str, flakes: bool) -> String {
+ let require = if flakes {
+ format!("require_flakes {}\n", crate::run::quote(DRAWING))
} else {
String::new()
};
- let remove_work = format!("remove_tree {}", q(&format!("{dir_}/work")));
- let key_patches = if has_patches {
- format!(
- "ls {pd} | while read -r patch; do\n sha256sum {pd}/\"$patch\"\ndone\n",
- pd = q(&patch_dir)
- )
- } else {
- String::new()
- };
- let key_drawn = if rewrite_lock && !materialize {
- let mut text = override_args(name);
- text += "printf '%s\\n' \"$@\"\n# Sources outside the store can change without changing their\n# path.\n";
- let mut seen = BTreeSet::new();
- for source in drawn_from(name) {
- if !seen.insert(source.clone()) {
- continue;
- }
- let source = source.as_str();
- let p = if generated.contains(source) {
- format!("{flakes_dir}/{source}")
- } else {
- format!("{dir}/{source}")
- };
- text += &format!(
- "drawn=$(readlink -f {})\ncase $drawn in\n /nix/store/*) ;;\n *) nix-hash --type sha256 \"$drawn\" ;;\nesac\n",
- q(&p)
- );
- }
- text
- } else {
- String::new()
- };
- let body = if materialize {
- let relock = if rewrite_lock {
- format!(
- "{}own_lock {}\nnix flake lock \"$@\" \"$(flake_url {})\" >&2\n",
- override_args(name),
- q(&target_path),
- q(name)
- )
- } else {
- String::new()
- };
- format!(
- r#"if ! test -e {key} ||
- test "$(cat {key})" != "$key" ||
- test -L {tp} ||
- ! test -d {tp}; then
- cp -R "$original" {work}
- chmod -R u+w {work}
- (
- cd {work}
- {apply_patches}
- )
- remove_foreign_link {tp} {tp_name}
- rm -fR {tp}
- mv {work} {tp}
- write_key "$key"
-fi
-{relock}
-"#,
- key = q(&format!("{dir_}/key")),
- tp = q(&target_path),
- tp_name = q(&context.name(&target_path)),
- work = q(&format!("{dir_}/work")),
- )
- } else {
- let relock = if rewrite_lock {
- format!(
- "{}# Not just the path, which Nix takes for the enclosing Git\n# work tree, if any.\nnix flake lock \"$@\" \"path:$(url_path \"$work\")${{subdir:+?dir=$subdir}}\" >&2\n",
- override_args(name)
- )
- } else {
- String::new()
- };
- format!(
- r#"if test -e {key} &&
- test "$(cat {key})" = "$key" &&
- result=$(readlink {result}) &&
- nix-store --check-validity "$result" 2>/dev/null; then
- :
-else
- # The copy keeps the name of the delivered store path.
- mkdir {work}
- work=$(readlink -f {work})/$(basename "$path" | cut -c34-)
- cp -R "$path" "$work"
- chmod -R u+w "$work"
- (
- cd "$work"/"$subdir"
- {apply_patches}
- )
- {relock}
- result=$(nix-store --add-fixed --recursive sha256 "$work")
- {remove_work}
- nix-store --realise "$result" \
- --add-root {result} --indirect >/dev/null
- write_key "$key"
-fi
-make_room_for_link {tp} {tp_name}
-# .populate/derived/<name>/result roots the store path.
-ln -fns "$result${{subdir:+/$subdir}}" {tp}
-"#,
- key = q(&format!("{dir_}/key")),
- result = q(&format!("{dir_}/result")),
- work = q(&format!("{dir_}/work")),
- tp = q(&target_path),
- tp_name = q(&context.name(&target_path)),
- )
- };
format!(
- r#"(
- # Writes the key $1 to a new file, which then replaces the key, so
- # nothing at its place gets written through, e.g. a symlink.
- write_key() {{
- key_file=$(mktemp {key}.XXXXXX)
- printf '%s\n' "$1" > "$key_file"
- mv -f "$key_file" {key}
- }}
- original=$(readlink -f {original})
- path=$(printf '%s\n' "$original" | cut -d/ -f1-4)
- subdir=${{original#"$path"}}
- subdir=${{subdir#/}}
- key=$(
- {{
- printf '%s\n' "$original" {spec}
- {key_patches}
- {key_drawn}
- }} | sha256sum | cut -c-64
- )
- {remove_work}
- {body}
-)
-"#,
- key = q(&format!("{dir_}/key")),
- original = q(&format!("{dir_}/original")),
- spec = q(&spec),
+ "{}{}{require}{snippet}",
+ sh::prelude(),
+ sh::draw::DEFINITIONS
)
- };
+ }
- // Generates the flake of a source in .populate/flakes/<name>.
- let generate = |name: &str| -> String {
- let mut drawn: Vec<&str> = Vec::new();
- for f in &follows[name] {
- if !drawn.contains(&f.source.as_str()) {
- drawn.push(&f.source);
- }
+ fn run(&self, snippet: &str, flakes: bool, args: &[&str]) -> Result<()> {
+ self.context.run(&Self::on_target(snippet, flakes), args)
+ }
+
+ /// What snippet prints, as the fields ending in null bytes.
+ fn fields(&self, snippet: &str, flakes: bool, args: &[&str]) -> Result<Vec<String>> {
+ let output = self
+ .context
+ .output(&Self::on_target(snippet, flakes), args)?;
+ let mut fields: Vec<String> = output.split('\0').map(str::to_string).collect();
+ // What follows the last null byte.
+ fields.pop();
+ Ok(fields)
+ }
+
+ /// Resolves paths on the target.
+ fn resolve(&self, paths: &[String], flakes: bool) -> Result<Vec<Resolved>> {
+ let args: Vec<&str> = paths.iter().map(String::as_str).collect();
+ let fields = self.fields(sh::draw::RESOLVE, flakes, &args)?;
+ if fields.len() != 2 * paths.len() {
+ return Err(Failure::Message(
+ "error: can't resolve paths on the target".to_string(),
+ ));
}
- let json = |s: &str| serde_json::to_string(s).expect("serializes");
- // Nix ignores overrides below an input that follows another input,
- // so an input of an input drawn from a source gets overridden at
- // that source.
- let override_line = |f: &Follows| -> String {
- format!(
- "inputs.{}.follows = {};",
- drawn_path(name, &f.path)
- .iter()
- .map(|n| json(n))
- .collect::<Vec<_>>()
- .join(".inputs."),
- json(&input_name(&f.source))
- )
- };
- let mut text = format!("(\nurl=$(flake_url {})\n", q(name));
- for (i, source) in drawn.iter().enumerate() {
- text += &format!("url{}={}\n", i + 1, url(source));
+ Ok(fields
+ .chunks(2)
+ .map(|field| Resolved {
+ path: field[1].clone(),
+ dir: field[0].contains('d'),
+ git: field[0].contains('g'),
+ submodules: field[0].contains('s'),
+ flake: field[0].contains('f'),
+ })
+ .collect())
+ }
+
+ /// The flake URL of the source named name, at resolved.
+ fn flake_url(name: &str, resolved: &Resolved) -> Result<String> {
+ if !resolved.dir {
+ return Err(Failure::Message(format!(
+ "error: source {name} is not a directory"
+ )));
}
- let fd = q(&format!("{flakes_dir}/{name}"));
- text += &format!("rm -fR {fd}\nmkdir {fd}\ncd {fd}\n{{\n echo '{{'\n");
- text += &format!(
- " printf ' inputs.%s.url = \"%s\";\\n' {} \"$url\"\n",
- q(&json(&input_name(name)))
- );
- for (i, source) in drawn.iter().enumerate() {
- text += &format!(
- " printf ' inputs.%s.url = \"%s\";\\n' {} \"$url{}\"\n",
- q(&json(&input_name(source))),
- i + 1
- );
- if !generated.contains(source) {
- text += &format!(
- " if ! test -e \"$(readlink -f {})\"/flake.nix; then\n printf ' inputs.%s.flake = false;\\n' {}\n fi\n",
- q(&format!("{dir}/{source}")),
- q(&json(&input_name(source)))
- );
+ let path = url_path(&resolved.path);
+ Ok(if resolved.git && resolved.submodules {
+ format!("git+file:{path}?submodules=1")
+ } else if resolved.git {
+ format!("git+file:{path}")
+ } else {
+ format!("path:{path}")
+ })
+ }
+
+ /// The flake URL of the source named name, as it is now.
+ fn own_url(&self, name: &str) -> Result<String> {
+ let resolved = self.resolve(&[self.path(name)], false)?;
+ Self::flake_url(name, &resolved[0])
+ }
+
+ /// The sources that the source named name draws inputs from, each
+ /// once.
+ fn drawn_from(&self, name: &str) -> Vec<&'a str> {
+ let mut drawn: Vec<&'a str> = Vec::new();
+ for f in &self.follows[name] {
+ let source = self
+ .sources
+ .get_key_value(&f.source)
+ .map(|(k, _)| k.as_str());
+ if let Some(source) = source.filter(|s| !drawn.contains(s)) {
+ drawn.push(source);
}
}
- for f in &follows[name] {
- text += &format!(" printf ' %s\\n' {}\n", q(&override_line(f)));
- }
- text += &format!(
- " printf ' outputs = inputs: inputs.%s;\\n' {}\n echo '}}'\n}} > flake.nix\n",
- q(&json(&input_name(name)))
- );
- text += "# Not just ., which Nix takes for the enclosing Git work tree, if any.\nnix flake lock \"path:$(url_path \"$(pwd -P)\")\" >&2\n)\n";
- text
- };
+ drawn
+ }
- let process = |name: &str| -> String {
- let mut text = String::new();
- if derived.contains(name) {
- text += &derive(name);
- }
- if generated.contains(name) {
- text += &generate(name);
- } else if drawing.contains(name) && !derived.contains(name) {
- text += &rewrite(name);
+ /// Where the flake that inputs drawn from the source named name use
+ /// is: its generated flake, if it has one, so overrides don't need to
+ /// be repeated, or else the flake in the source.
+ fn drawn_flake(&self, name: &str) -> String {
+ if self.generated.contains(name) {
+ self.state("flakes", name)
+ } else {
+ self.path(name)
}
- text
- };
+ }
- // Removes the entries of the state directory that sources named names
- // don't need anymore, and the directory once it's empty.
- let remove_stale = |state_dir: &str, names: &BTreeSet<&str>| -> String {
- let mut call = format!("remove_others {} ''", q(state_dir));
- for name in names {
- call += &format!(" {}", q(name));
+ /// The URLs of the flakes that inputs drawn from the sources use, and
+ /// whether they have a flake.nix. Nix refuses path: URLs with
+ /// symlinks on the way, which the target directory may be, so paths
+ /// get resolved.
+ fn drawn_urls(&self, sources: &[&str]) -> Result<BTreeMap<String, (String, bool)>> {
+ let paths: Vec<String> = sources.iter().map(|s| self.drawn_flake(s)).collect();
+ let resolved = self.resolve(&paths, false)?;
+ let mut urls = BTreeMap::new();
+ for (source, resolved) in sources.iter().zip(resolved) {
+ let url = if self.generated.contains(source) {
+ format!("path:{}", url_path(&resolved.path))
+ } else {
+ Self::flake_url(source, &resolved)?
+ };
+ // A generated flake is a flake.
+ let flake = resolved.flake || self.generated.contains(source);
+ urls.insert(source.to_string(), (url, flake));
}
- format!(
- "if test -d {d}; then\n {call}\n if test -z \"$(ls -A {d})\"; then\n rmdir {d}\n fi\nfi\n",
- d = q(state_dir)
- )
- };
+ Ok(urls)
+ }
- let all_names: Vec<&str> = sources.keys().map(String::as_str).collect();
- let mut text = format!("set -efu\n{}\n", use_state(&sentinel, &all_names));
- if !names.is_empty() {
- if !drawing.is_empty() {
- text += "require_flakes 'drawing flake inputs from sources'\n";
+ /// nix flake lock's arguments for drawing the inputs of the flake in
+ /// the source named name from other sources.
+ fn override_args(&self, name: &str) -> Result<Vec<String>> {
+ let urls = self.drawn_urls(&self.drawn_from(name))?;
+ let mut args = Vec::new();
+ for f in &self.follows[name] {
+ args.push("--override-input".to_string());
+ args.push(drawn_path(name, &f.path, &self.follows, &self.generated).join("/"));
+ args.push(urls[&f.source].0.clone());
}
- text += &format!(
- r#"
-# Nix rewrites lock files in place, which also changes what else is a hard
-# link to the lock file, e.g. in a backup, or what it is a symlink to. So
-# the lock file of the flake in the directory $1 gets replaced by a copy
-# first. The copy goes to a new file, so nothing that's there already gets
-# written through.
-own_lock() {{
- if test -e "$1"/flake.lock; then
- own_lock_copy=$(mktemp "$1"/.flake.lock.XXXXXX)
- cp -p "$1"/flake.lock "$own_lock_copy"
- mv -f "$own_lock_copy" "$1"/flake.lock
- fi
-}}
-
-# Prints the flake URL of the source named $1.
-flake_url() {{
- path=$(readlink -f {dir}/"$1")
- if ! test -d "$path"; then
- >&2 printf 'error: source %s is not a directory\n' "$1"
- exit 1
- fi
- if test -e "$path"/.git; then
- if test -e "$path"/.gitmodules; then
- printf 'git+file:%s?submodules=1' "$(url_path "$path")"
- else
- printf 'git+file:%s' "$(url_path "$path")"
- fi
- else
- printf 'path:%s' "$(url_path "$path")"
- fi
-}}
+ Ok(args)
+ }
-# Inputs can only be drawn into flakes, so check all sources before
-# changing anything.
-missing=
-"#,
- dir = q(dir)
- );
- for name in &drawing {
- let p = if derived.contains(name) {
- format!("{derived_dir}/{name}/original")
+ /// Fails unless each source drawing inputs contains a flake that has
+ /// the inputs drawn, before anything changes. Nix ignores inputs drawn
+ /// that a flake doesn't have, with a warning, so a misspelt input name
+ /// would draw nothing.
+ fn check(&self) -> Result<()> {
+ // The flake in a source as delivered.
+ let delivered = |name: &str| {
+ if self.derived.contains(name) {
+ format!("{}/original", self.state("derived", name))
} else {
- format!("{dir}/{name}")
- };
- text += &format!(
- "if ! test -e {}/flake.nix; then\n missing=\"$missing \"{}\nfi\n",
- q(&p),
- q(name)
- );
+ self.path(name)
+ }
+ };
+ let drawing: Vec<&str> = self.drawing.iter().copied().collect();
+ let paths: Vec<String> = drawing.iter().map(|n| delivered(n)).collect();
+ let resolved = self.resolve(&paths, true)?;
+ let missing: Vec<String> = drawing
+ .iter()
+ .zip(&resolved)
+ .filter(|(_, resolved)| !resolved.flake)
+ .map(|(name, _)| {
+ format!("error: source {name} draws flake inputs from other sources, but contains no flake.nix")
+ })
+ .collect();
+ if !missing.is_empty() {
+ return Err(Failure::Message(missing.join("\n")));
}
- let derived_names: Vec<&str> = derived.iter().copied().collect();
- text += &format!(
- r#"if test -n "$missing"; then
- >&2 printf 'error: source %s draws flake inputs from other sources, but contains no flake.nix\n' $missing
- exit 1
-fi
-# Nix ignores inputs drawn that the flake doesn't have, with a warning, so a
-# misspelt input name would draw nothing. Prints the flake URL of the
-# flake in the source named $1 as delivered.
-own_flake_url() {{
- case " "{derived_names}" " in
- *" $1 "*)
- printf 'path:%s' "$(url_path "$(readlink -f {derived_dir}/"$1"/original)")"
- ;;
- *)
- flake_url "$1"
- ;;
- esac
-}}
-# The lock files of the flakes in the sources, as Nix locks them now, with
-# an empty flake in place of each input drawn from a source, which may not
-# be reachable, as the one the flake's lock file pins.
-locks_dir=$(mktemp -d)
-echo '{{ outputs = _: {{}}; }}' > "$locks_dir"/flake.nix
-"#,
- derived_names = q(&derived_names.join(" ")),
- derived_dir = q(&derived_dir),
- );
+ // The lock files of the flakes in the sources, as Nix locks them
+ // now, with an empty flake in place of each input drawn from a
+ // source, which may not be reachable, as the one the flake's lock
+ // file pins.
let mut locked: Vec<String> = Vec::new();
for name in &drawing {
- for f in &follows[*name] {
- for (c, _) in input_checks(name, &f.path, &follows) {
+ for f in &self.follows[name] {
+ for (c, _) in input_checks(name, &f.path, &self.follows) {
if !locked.contains(&c) {
locked.push(c);
}
}
}
}
- for name in &locked {
- text += "set --\n";
- for f in &follows[name.as_str()] {
- text += &format!(
- "set -- \"$@\" --override-input {} \"path:$locks_dir\"\n",
- q(&f.path.join("/"))
- );
- }
- text += &format!(
- r#"if ! nix flake metadata --json --no-write-lock-file "$@" \
- "$(own_flake_url {n})" > "$locks_dir"/{n}.json; then
- >&2 printf 'error: source %s draws inputs from other sources, but its flake can'"'"'t be locked\n' \
- {n}
- exit 1
-fi
-"#,
- n = q(name)
- );
+ let paths: Vec<String> = locked.iter().map(|n| delivered(n)).collect();
+ let resolved = self.resolve(&paths, false)?;
+ let mut locks = BTreeMap::new();
+ for (name, resolved) in locked.iter().zip(&resolved) {
+ let url = if self.derived.contains(name.as_str()) {
+ format!("path:{}", url_path(&resolved.path))
+ } else {
+ Self::flake_url(name, resolved)?
+ };
+ let overrides: Vec<String> = self.follows[name.as_str()]
+ .iter()
+ .map(|f| f.path.join("/"))
+ .collect();
+ let mut args = vec![url.as_str(), name.as_str()];
+ args.extend(overrides.iter().map(String::as_str));
+ let output = self
+ .context
+ .output(&Self::on_target(sh::draw::METADATA, true), &args)?;
+ let metadata: Value = serde_json::from_str(&output).map_err(|e| {
+ Failure::Message(format!("error: can't read flake metadata of {url}: {e}"))
+ })?;
+ locks.insert(name.clone(), metadata["locks"].clone());
}
- text += &format!(
- r#"# Fails, naming the input, unless the flake in the source named $1 has
-# an input at the path $2, a JSON list of input names. $3 is the source
-# that the input $4 of the source $5 gets drawn from.
-require_input() {{
- if ! test "$(
- METADATA=$(cat "$locks_dir/$1.json") INPUT=$2 nix eval --impure --raw --expr {expr}
- )" = 1; then
- if test "$1" = "$5"; then
- >&2 printf 'error: source %s draws input %s from source %s, but its flake has no input %s\n' \
- "$5" "$4" "$3" "$4"
- else
- >&2 printf 'error: source %s draws input %s from source %s, but the flake of source %s has no input %s\n' \
- "$5" "$4" "$3" "$1" "$(printf %s "$2" | tr -d '[]"' | tr , /)"
- fi
- exit 1
- fi
-}}
-"#,
- expr = q(&format!(
- "({LOCK_HAS_INPUT})\n (builtins.fromJSON (builtins.getEnv \"METADATA\")).locks\n (builtins.fromJSON (builtins.getEnv \"INPUT\"))\n"
- ))
- );
+
for name in &drawing {
- for f in &follows[*name] {
- for (c, path) in input_checks(name, &f.path, &follows) {
- text += &format!(
- "require_input {} {} \\\n {} {} {}\n",
- q(&c),
- q(&serde_json::to_string(&path).expect("serializes")),
- q(&f.source),
- q(&f.path.join("/")),
- q(name)
- );
+ for f in &self.follows[name] {
+ let input = f.path.join("/");
+ for (c, path) in input_checks(name, &f.path, &self.follows) {
+ if lock_has_input(&locks[&c], &path) {
+ continue;
+ }
+ let source = &f.source;
+ return Err(Failure::Message(if c == *name {
+ format!("error: source {name} draws input {input} from source {source}, but its flake has no input {input}")
+ } else {
+ format!(
+ "error: source {name} draws input {input} from source {source}, but the flake of source {c} has no input {}",
+ path.join("/")
+ )
+ }));
}
}
}
- text += "rm -R \"$locks_dir\"\n\n";
- if !generated.is_empty() {
- text += &format!("mkdir -p {}\n", q(&flakes_dir));
+ Ok(())
+ }
+
+ /// Rewrites the lock file of the flake in a file or git source in
+ /// place.
+ fn rewrite(&self, name: &str) -> Result<()> {
+ let args = self.override_args(name)?;
+ let resolved = self.resolve(&[self.path(name)], false)?;
+ let url = Self::flake_url(name, &resolved[0])?;
+ let mut all = vec![resolved[0].path.as_str(), url.as_str()];
+ all.extend(args.iter().map(String::as_str));
+ self.run(sh::draw::LOCK, true, &all)
+ }
+
+ /// Derives what the target path of a flake or flake-input source gets
+ /// from what got delivered to .populate/derived/<name>/original: a
+ /// copy, which gets patched and gets its lock file rewritten. Unless
+ /// materialized, the copy gets added to the store, and the target path
+ /// becomes a link to it. Copies get made again only if what they
+ /// derive from changes: materialized ones if the delivered store path,
+ /// the patches, or what inputs get drawn from which sources changes,
+ /// and others also if the sources they draw inputs from change. The
+ /// key of a copy, in .populate/derived/<name>/key, is the hash of all
+ /// that.
+ fn derive(&self, name: &str) -> Result<()> {
+ let settings = &self.sources[name].settings;
+ let materialize = bool_setting(settings, "materialize", false);
+ let rewrite_lock =
+ bool_setting(settings, "rewriteLock", true) && self.drawing.contains(name);
+ let target_path = self.path(name);
+ let target_name = self.context.name(&target_path);
+ let state = self.state("derived", name);
+ let patch_dir = if patches(settings).is_empty() {
+ String::new()
+ } else {
+ self.state("patches", name)
+ };
+ let args = if rewrite_lock {
+ self.override_args(name)?
+ } else {
+ Vec::new()
+ };
+ // Sources outside the store can change without changing their
+ // path.
+ let drawn: Vec<String> = if rewrite_lock && !materialize {
+ self.drawn_from(name)
+ .iter()
+ .map(|s| self.drawn_flake(s))
+ .collect()
+ } else {
+ Vec::new()
+ };
+
+ let mut state_args = vec![state.as_str(), target_path.as_str(), patch_dir.as_str()];
+ state_args.extend(drawn.iter().map(String::as_str));
+ let fields = self.fields(sh::draw::DERIVED_STATE, false, &state_args)?;
+ let malformed =
+ || Failure::Message(format!("error: can't read the state of source {name}"));
+ let (head, rest) = fields.split_at_checked(4).ok_or_else(malformed)?;
+ let [original, key_file, result, target_kind] = head else {
+ return Err(malformed());
+ };
+ let separator = rest
+ .iter()
+ .position(String::is_empty)
+ .ok_or_else(malformed)?;
+ let (patch_sums, hashes) = (&rest[..separator], &rest[separator + 1..]);
+ if hashes.len() != drawn.len() {
+ return Err(malformed());
+ }
+
+ // Like Nix' toJSON: attributes sorted by name.
+ let follows: Vec<Value> = self.follows[name]
+ .iter()
+ .map(|f| serde_json::json!({ "path": f.path, "source": f.source }))
+ .collect();
+ let spec = serde_json::json!({
+ "follows": follows,
+ "materialize": materialize,
+ "rewriteLock": rewrite_lock,
+ });
+ let mut text = format!("{original}\n{spec}\n");
+ for sum in patch_sums {
+ text += &format!("{sum}\n");
+ }
+ if !drawn.is_empty() {
+ for arg in &args {
+ text += &format!("{arg}\n");
+ }
+ for hash in hashes.iter().filter(|h| !h.is_empty()) {
+ text += &format!("{hash}\n");
+ }
+ }
+ let key = sha256_hex(text.as_bytes());
+ let unchanged = *key_file == key;
+
+ if materialize {
+ if !unchanged || target_kind != "directory" {
+ let work = format!("{state}/work");
+ let key_path = format!("{state}/key");
+ self.run(
+ sh::draw::MATERIALIZE,
+ false,
+ &[
+ original,
+ &work,
+ &target_path,
+ &target_name,
+ &patch_dir,
+ name,
+ &key_path,
+ &key,
+ ],
+ )?;
+ }
+ if rewrite_lock {
+ let url = self.own_url(name)?;
+ let mut all = vec![target_path.as_str(), url.as_str()];
+ all.extend(args.iter().map(String::as_str));
+ self.run(sh::draw::LOCK, true, &all)?;
+ }
+ return Ok(());
}
- for name in &sorted {
- text += &process(name);
+
+ let (path, subdir) = split_store_path(original);
+ let result = if unchanged { result.as_str() } else { "" };
+ let mut all = vec![
+ state.as_str(),
+ path,
+ subdir,
+ &target_path,
+ &target_name,
+ result,
+ &patch_dir,
+ name,
+ &key,
+ ];
+ all.extend(args.iter().map(String::as_str));
+ self.run(sh::draw::ADD, rewrite_lock, &all)
+ }
+
+ /// Generates the flake of a source in .populate/flakes/<name>, which
+ /// re-exports the outputs of the flake in the source, with inputs drawn
+ /// from other sources.
+ fn generate(&self, name: &str) -> Result<()> {
+ let json = |s: &str| serde_json::to_string(s).expect("serializes");
+ let url = self.own_url(name)?;
+ let drawn = self.drawn_from(name);
+ let urls = self.drawn_urls(&drawn)?;
+ let mut text = format!(
+ "{{\n inputs.{}.url = \"{url}\";\n",
+ json(&input_name(name))
+ );
+ for source in &drawn {
+ let (url, flake) = &urls[*source];
+ let input = json(&input_name(source));
+ text += &format!(" inputs.{input}.url = \"{url}\";\n");
+ if !flake {
+ text += &format!(" inputs.{input}.flake = false;\n");
+ }
+ }
+ // Nix ignores overrides below an input that follows another input,
+ // so an input of an input drawn from a source gets overridden at
+ // that source.
+ for f in &self.follows[name] {
+ let path: Vec<String> = drawn_path(name, &f.path, &self.follows, &self.generated)
+ .iter()
+ .map(|n| json(n))
+ .collect();
+ text += &format!(
+ " inputs.{}.follows = {};\n",
+ path.join(".inputs."),
+ json(&input_name(&f.source))
+ );
}
+ text += &format!(
+ " outputs = inputs: inputs.{};\n}}\n",
+ json(&input_name(name))
+ );
+ self.run(
+ sh::draw::GENERATE,
+ true,
+ &[&self.state("flakes", name), &text],
+ )
+ }
+
+ /// Removes the state that sources don't need anymore.
+ fn clean_up(&self) -> Result<()> {
+ let list = |names: &mut dyn Iterator<Item = &str>| names.collect::<Vec<_>>().join(" ");
+ let all = list(&mut self.sources.keys().map(String::as_str));
+ let not_flakes = list(
+ &mut self
+ .sources
+ .iter()
+ .filter(|(_, s)| !matches!(s.kind.as_str(), "flake" | "flake-input"))
+ .map(|(n, _)| n.as_str()),
+ );
+ let generated = list(&mut self.generated.iter().copied());
+ let derived = list(&mut self.derived.iter().copied());
+ let patched = list(
+ &mut self
+ .sources
+ .iter()
+ .filter(|(_, s)| !patches(&s.settings).is_empty())
+ .map(|(n, _)| n.as_str()),
+ );
+ self.run(
+ sh::draw::CLEAN_UP,
+ false,
+ &[
+ &self.sentinel(),
+ &all,
+ &not_flakes,
+ &generated,
+ &derived,
+ &patched,
+ ],
+ )
}
- text += "# Remove roots of flake inputs of sources that are neither flake nor\n# flake-input sources anymore.\n";
- for (name, source) in sources {
- if !matches!(source.kind.as_str(), "flake" | "flake-input") {
- text += &format!("rm -fR {}\n", q(&format!("{sentinel}/flake-inputs/{name}")));
+}
+
+/// Draws inputs of flakes in sources from other sources, derives what the
+/// target paths of flake and flake-input sources that get patched, get
+/// their lock files rewritten, or are materialized get, and removes state
+/// that sources don't need anymore. Sources get processed in the order
+/// they draw inputs from each other.
+///
+/// Inputs get drawn by rewriting the lock file of the flake in the source
+/// with nix flake lock --override-input: in place for file and git sources,
+/// and in a copy for flake and flake-input sources. Sources with
+/// rewriteLock set to false, and symlink sources, get a generated flake in
+/// .populate/flakes/<name> instead, which re-exports the outputs of the
+/// flake in the source, with those inputs drawn from the other sources.
+/// Lock files get rewritten or generated on every run, so they match the
+/// sources.
+pub fn flakes(context: &Context, dir: &str, sources: &BTreeMap<String, Source>) -> Result<()> {
+ let follows: BTreeMap<&str, Vec<Follows>> = sources
+ .iter()
+ .map(|(n, s)| (n.as_str(), s.follows()))
+ .collect();
+ let names_where = |p: &dyn Fn(&Source) -> bool| -> BTreeSet<&str> {
+ sources
+ .iter()
+ .filter(|(_, s)| p(s))
+ .map(|(n, _)| n.as_str())
+ .collect()
+ };
+ let flakes = Flakes {
+ context,
+ dir,
+ sources,
+ drawing: names_where(&|s| !s.follows().is_empty()),
+ generated: names_where(&has_generated_flake),
+ derived: names_where(&derives_flake),
+ follows,
+ };
+ let names: BTreeSet<&str> = flakes.drawing.union(&flakes.derived).copied().collect();
+ if !names.is_empty() {
+ let mut args = vec![flakes.sentinel()];
+ args.extend(sources.keys().cloned());
+ let args: Vec<&str> = args.iter().map(String::as_str).collect();
+ flakes.run(sh::draw::USE_STATE, false, &args)?;
+ if !flakes.drawing.is_empty() {
+ flakes.check()?;
+ }
+ let drawn_from = |name: &str| -> Vec<String> {
+ flakes.follows[name]
+ .iter()
+ .map(|f| f.source.clone())
+ .collect()
+ };
+ for name in toposort(&names, &drawn_from) {
+ if flakes.derived.contains(name) {
+ flakes.derive(name)?;
+ }
+ if flakes.generated.contains(name) {
+ flakes.generate(name)?;
+ } else if flakes.drawing.contains(name) && !flakes.derived.contains(name) {
+ flakes.rewrite(name)?;
+ }
}
}
- let fi = q(&format!("{sentinel}/flake-inputs"));
- text += &format!(
- "if test -d {fi} &&\n test -z \"$(ls -A {fi})\"; then\n rmdir {fi}\nfi\n# Remove what sources left behind that don't need it anymore.\n"
- );
- text += &remove_stale(&flakes_dir, &generated);
- text += &remove_stale(&derived_dir, &derived);
- text += &remove_stale(&patches_dir, &patched);
- format!("{}\n{text}", sh::prelude())
+ flakes.clean_up()
}
/// The names sorted so each comes after the ones it draws inputs from, as
@@ -798,6 +827,7 @@ fn sha256_hex(data: &[u8]) -> String {
#[cfg(test)]
mod tests {
use super::*;
+ use serde_json::json;
#[test]
fn sha256() {
@@ -810,4 +840,127 @@ mod tests {
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
+
+ #[test]
+ fn url_paths() {
+ assert_eq!(url_path("/var/src/a-b_c.d~e"), "/var/src/a-b_c.d~e");
+ assert_eq!(url_path("/x y?#%"), "/x%20y%3f%23%25");
+ assert_eq!(url_path("/รค"), "/%c3%a4");
+ }
+
+ #[test]
+ fn input_names() {
+ assert_eq!(input_name("nixpkgs"), "nixpkgs");
+ assert_eq!(
+ input_name("self"),
+ format!("source-{}", &sha256_hex(b"self")[..16])
+ );
+ assert!(input_name("1x").starts_with("source-"));
+ assert!(input_name("a.b").starts_with("source-"));
+ }
+
+ #[test]
+ fn store_paths() {
+ assert_eq!(
+ split_store_path("/nix/store/abc-source/sub/dir"),
+ ("/nix/store/abc-source", "sub/dir")
+ );
+ assert_eq!(
+ split_store_path("/nix/store/abc-source"),
+ ("/nix/store/abc-source", "")
+ );
+ }
+
+ fn path(names: &[&str]) -> Vec<String> {
+ names.iter().map(|n| n.to_string()).collect()
+ }
+
+ /// A lock file with inputs a and b, where a has an input c, and its
+ /// input b follows the root's b.
+ fn locks() -> Value {
+ json!({
+ "root": "root",
+ "nodes": {
+ "root": { "inputs": { "a": "a", "b": "b_2" } },
+ "a": { "inputs": { "b": ["b"], "c": "c", "gone": [] } },
+ "b_2": { "inputs": { "d": "d" } },
+ "c": {},
+ "d": {}
+ }
+ })
+ }
+
+ #[test]
+ fn lock_inputs() {
+ let locks = locks();
+ assert!(lock_has_input(&locks, &path(&[])));
+ assert!(lock_has_input(&locks, &path(&["a"])));
+ assert!(lock_has_input(&locks, &path(&["a", "c"])));
+ assert!(lock_has_input(&locks, &path(&["a", "b", "d"])));
+ assert!(!lock_has_input(&locks, &path(&["x"])));
+ assert!(!lock_has_input(&locks, &path(&["a", "x"])));
+ assert!(!lock_has_input(&locks, &path(&["a", "b", "x"])));
+ // An empty follows path refers to the root.
+ assert!(lock_has_input(&locks, &path(&["a", "gone", "a"])));
+ }
+
+ #[test]
+ fn cyclic_follows() {
+ let mut locks = locks();
+ locks["nodes"]["a"]["inputs"]["b"] = json!(["a", "b"]);
+ assert!(!lock_has_input(&locks, &path(&["a", "b"])));
+ }
+
+ /// A source's name, and the paths of the inputs it draws, with the
+ /// sources it draws them from.
+ type Entry<'a> = (&'a str, &'a [(&'a [&'a str], &'a str)]);
+
+ fn follows(entries: &[Entry]) -> BTreeMap<&'static str, Vec<Follows>> {
+ entries
+ .iter()
+ .map(|(name, fs)| {
+ let name: &'static str = Box::leak(name.to_string().into_boxed_str());
+ let fs = fs
+ .iter()
+ .map(|(p, s)| Follows {
+ path: path(p),
+ source: s.to_string(),
+ })
+ .collect();
+ (name, fs)
+ })
+ .collect()
+ }
+
+ #[test]
+ fn drawn_paths() {
+ // top draws dep from source dep, and dep's inner from source
+ // inner; dep draws nothing itself.
+ let follows = follows(&[
+ ("top", &[(&["dep"], "dep"), (&["dep", "inner"], "inner")]),
+ ("dep", &[]),
+ ("inner", &[]),
+ ]);
+ let none = BTreeSet::new();
+ assert_eq!(
+ drawn_path("top", &path(&["dep", "inner"]), &follows, &none),
+ path(&["dep", "inner"])
+ );
+ let generated: BTreeSet<&str> = ["top"].into();
+ assert_eq!(
+ drawn_path("top", &path(&["dep"]), &follows, &generated),
+ path(&["top", "dep"])
+ );
+ assert_eq!(
+ drawn_path("top", &path(&["dep", "inner"]), &follows, &generated),
+ path(&["dep", "inner"])
+ );
+ assert_eq!(
+ input_checks("top", &path(&["dep", "inner"]), &follows),
+ vec![
+ ("top".to_string(), path(&["dep"])),
+ ("dep".to_string(), path(&["inner"]))
+ ]
+ );
+ }
}
diff --git a/pkgs/populate/src/populate.rs b/pkgs/populate/src/populate.rs
index ad0ec35..226cee2 100644
--- a/pkgs/populate/src/populate.rs
+++ b/pkgs/populate/src/populate.rs
@@ -34,7 +34,7 @@ pub fn populate(context: &Context, spec: &Spec) -> Result<()> {
})?;
deliver(context, name, source, &path, workdir)?;
}
- context.run(&flake::flakes(context, dir, &spec.source), &[])?;
+ flake::flakes(context, dir, &spec.source)?;
if spec.prune {
let flakes = spec
.source
diff --git a/pkgs/populate/src/sh.rs b/pkgs/populate/src/sh.rs
index 1a2d2c7..8282370 100644
--- a/pkgs/populate/src/sh.rs
+++ b/pkgs/populate/src/sh.rs
@@ -522,7 +522,7 @@ require_flakes() {
}
"#;
-/// The definitions shell code generated for flakes uses.
+/// The definitions the shell code for flakes uses.
pub fn prelude() -> String {
[
&use_state(),
@@ -730,3 +730,262 @@ nix eval --impure --raw --expr '
'
"#;
}
+
+/// Shell code for drawing inputs of flakes in sources from other sources,
+/// and for deriving what flake and flake-input sources that get patched,
+/// get their lock files rewritten, or are materialized deliver. Each gets
+/// the definitions of prelude and DEFINITIONS. Populate decides what to
+/// do, from what these send back.
+pub mod draw {
+ /// Defines own_lock DIR, apply_patches PATCH_DIR NAME, and write_key
+ /// KEY_FILE KEY.
+ ///
+ /// Nix rewrites lock files in place, which also changes what else is a
+ /// hard link to the lock file, e.g. in a backup, or what it is a
+ /// symlink to. So own_lock replaces the lock file of the flake in DIR
+ /// by a copy first. The copy goes to a new file, so nothing that's
+ /// there already gets written through.
+ ///
+ /// apply_patches applies the patches in PATCH_DIR, in order, to the
+ /// working directory, failing, naming the patch and the source NAME, if
+ /// one doesn't apply.
+ ///
+ /// write_key writes KEY to a new file, which then replaces KEY_FILE, so
+ /// nothing at its place gets written through, e.g. a symlink.
+ pub const DEFINITIONS: &str = r#"
+own_lock() {
+ if test -e "$1"/flake.lock; then
+ own_lock_copy=$(mktemp "$1"/.flake.lock.XXXXXX)
+ cp -p "$1"/flake.lock "$own_lock_copy"
+ mv -f "$own_lock_copy" "$1"/flake.lock
+ fi
+}
+apply_patches() {
+ ls "$1" | while read -r patch; do
+ if ! patch -p1 -s --batch --no-backup-if-mismatch -i "$1"/"$patch" >&2; then
+ >&2 printf 'error: patch %s does not apply to source %s\n' \
+ "$(printf %s "$patch" | cut -c6-)" "$2"
+ exit 1
+ fi
+ done
+}
+write_key() {
+ key_file=$(mktemp "$1".XXXXXX)
+ printf '%s\n' "$2" > "$key_file"
+ mv -f "$key_file" "$1"
+}
+"#;
+
+ /// use_state SENTINEL NAME...
+ pub const USE_STATE: &str = r#"
+use_state "$@"
+"#;
+
+ /// resolve PATH...: prints, for each PATH, what's there and where it
+ /// resolves to, as two fields, each ending in a null byte. The first
+ /// holds d if PATH is a directory, g if it has .git, s if it has
+ /// .gitmodules, and f if it has flake.nix.
+ pub const RESOLVE: &str = r#"
+for path; do
+ resolved=$(readlink -f "$path") || resolved=
+ flags=
+ if test -d "$path"; then flags=${flags}d; fi
+ if test -e "$path"/.git; then flags=${flags}g; fi
+ if test -e "$path"/.gitmodules; then flags=${flags}s; fi
+ if test -e "$path"/flake.nix; then flags=${flags}f; fi
+ printf '%s\0%s\0' "$flags" "$resolved"
+done
+"#;
+
+ /// metadata URL NAME OVERRIDE...: prints what nix flake metadata prints
+ /// for the flake at URL, of the source NAME, as Nix locks it now, with
+ /// an empty flake in place of each input at an OVERRIDE path, which
+ /// gets drawn from a source and may not be reachable as the flake's
+ /// lock file pins it.
+ pub const METADATA: &str = r#"
+url=$1 name=$2
+shift 2
+empty=$(mktemp -d)
+echo '{ outputs = _: {}; }' > "$empty"/flake.nix
+n=$#
+while test "$n" -gt 0; do
+ set -- "$@" --override-input "$1" "path:$empty"
+ shift
+ n=$((n - 1))
+done
+if ! nix flake metadata --json --no-write-lock-file "$@" "$url"; then
+ rm -R "$empty"
+ >&2 printf 'error: source %s draws inputs from other sources, but its flake can'"'"'t be locked\n' \
+ "$name"
+ exit 1
+fi
+rm -R "$empty"
+"#;
+
+ /// lock DIR URL ARG...: rewrites the lock file of the flake in DIR,
+ /// at URL, with nix flake lock's arguments ARG.
+ pub const LOCK: &str = r#"
+dir=$1 url=$2
+shift 2
+own_lock "$dir"
+nix flake lock "$@" "$url" >&2
+"#;
+
+ /// derived_state DIR TARGET_PATH PATCH_DIR DRAWN...: removes what an
+ /// interrupted run left in the source's derived state DIR, and prints
+ /// what tells whether the copy there needs to be made again, as fields
+ /// ending in null bytes: where DIR/original resolves to, the key of the
+ /// copy, the store path DIR/result links to, if the store has it, link
+ /// or directory, if TARGET_PATH is one, a line of sha256sum for each
+ /// patch in PATCH_DIR, an empty field, and the hash of each DRAWN path,
+ /// or nothing if it resolves into the store.
+ pub const DERIVED_STATE: &str = r#"
+dir=$1 target_path=$2 patch_dir=$3
+shift 3
+remove_tree "$dir"/work
+printf '%s\0' "$(readlink -f "$dir"/original)"
+if test -e "$dir"/key; then
+ printf '%s\0' "$(cat "$dir"/key)"
+else
+ printf '\0'
+fi
+if result=$(readlink "$dir"/result) &&
+ nix-store --check-validity "$result" 2>/dev/null; then
+ printf '%s\0' "$result"
+else
+ printf '\0'
+fi
+if test -L "$target_path"; then
+ printf 'link\0'
+elif test -d "$target_path"; then
+ printf 'directory\0'
+else
+ printf '\0'
+fi
+if test -n "$patch_dir"; then
+ ls "$patch_dir" | while read -r patch; do
+ printf '%s\0' "$(sha256sum "$patch_dir"/"$patch")"
+ done
+fi
+printf '\0'
+for drawn; do
+ drawn=$(readlink -f "$drawn")
+ case $drawn in
+ /nix/store/*) printf '\0' ;;
+ *) printf '%s\0' "$(nix-hash --type sha256 "$drawn")" ;;
+ esac
+done
+"#;
+
+ /// materialize ORIGINAL WORK TARGET_PATH TARGET_NAME PATCH_DIR NAME
+ /// KEY_FILE KEY: puts a copy of ORIGINAL, made in WORK and patched with
+ /// the patches in PATCH_DIR, if that isn't empty, at TARGET_PATH, and
+ /// writes its key. TARGET_NAME is TARGET_PATH's name in messages, and
+ /// NAME the source's.
+ pub const MATERIALIZE: &str = r#"
+original=$1 work=$2 target_path=$3 target_name=$4 patch_dir=$5 name=$6
+key_file=$7 key=$8
+cp -R "$original" "$work"
+chmod -R u+w "$work"
+if test -n "$patch_dir"; then
+ (
+ cd "$work"
+ apply_patches "$patch_dir" "$name"
+ )
+fi
+remove_foreign_link "$target_path" "$target_name"
+rm -fR "$target_path"
+mv "$work" "$target_path"
+write_key "$key_file" "$key"
+"#;
+
+ /// add DIR PATH SUBDIR TARGET_PATH TARGET_NAME RESULT PATCH_DIR NAME
+ /// KEY ARG...: links SUBDIR of the store path RESULT at TARGET_PATH.
+ /// If RESULT is empty, that's a copy of the store path PATH, made in
+ /// the source's derived state DIR, patched with the patches in
+ /// PATCH_DIR, if that isn't empty, with the lock file of its flake in
+ /// SUBDIR rewritten with nix flake lock's arguments ARG, if any, and
+ /// added to the store, rooted by DIR/result, with its key in DIR/key.
+ /// TARGET_NAME is TARGET_PATH's name in messages, and NAME the
+ /// source's.
+ pub const ADD: &str = r#"
+dir=$1 path=$2 subdir=$3 target_path=$4 target_name=$5 result=$6
+patch_dir=$7 name=$8 key=$9
+shift 9
+if test -z "$result"; then
+ # The copy keeps the name of the store path.
+ mkdir "$dir"/work
+ work=$(readlink -f "$dir"/work)/$(basename "$path" | cut -c34-)
+ cp -R "$path" "$work"
+ chmod -R u+w "$work"
+ if test -n "$patch_dir"; then
+ (
+ cd "$work"/"$subdir"
+ apply_patches "$patch_dir" "$name"
+ )
+ fi
+ if test $# -gt 0; then
+ # Not just the path, which Nix takes for the enclosing Git work tree,
+ # if any.
+ nix flake lock "$@" "path:$(url_path "$work")${subdir:+?dir=$subdir}" >&2
+ fi
+ result=$(nix-store --add-fixed --recursive sha256 "$work")
+ remove_tree "$dir"/work
+ nix-store --realise "$result" --add-root "$dir"/result --indirect >/dev/null
+ write_key "$dir"/key "$key"
+fi
+make_room_for_link "$target_path" "$target_name"
+# The source's derived state roots the store path.
+ln -fns "$result${subdir:+/$subdir}" "$target_path"
+"#;
+
+ /// generate DIR TEXT: makes DIR a flake, with TEXT as its flake.nix,
+ /// and locks it.
+ pub const GENERATE: &str = r#"
+dir=$1 text=$2
+mkdir -p "$(dirname "$dir")"
+rm -fR "$dir"
+mkdir "$dir"
+cd "$dir"
+printf %s "$text" > flake.nix
+# Not just ., which Nix takes for the enclosing Git work tree, if any.
+nix flake lock "path:$(url_path "$(pwd -P)")" >&2
+"#;
+
+ /// clean_up SENTINEL NAMES NOT_FLAKES GENERATED DERIVED PATCHED: removes
+ /// the state sources don't need anymore. Each argument after SENTINEL
+ /// is a list of source names, separated by blanks: of all sources, of
+ /// those that are neither flake nor flake-input sources, whose roots
+ /// for the garbage collector go, and of those that have a generated
+ /// flake, derived state, and patches.
+ pub const CLEAN_UP: &str = r#"
+sentinel=$1
+# The names are portable file names, so splitting them at blanks is fine.
+# shellcheck disable=SC2086
+use_state "$sentinel" $2
+# shellcheck disable=SC2086
+for name in $3; do
+ rm -fR "$sentinel"/flake-inputs/"$name"
+done
+if test -d "$sentinel"/flake-inputs &&
+ test -z "$(ls -A "$sentinel"/flake-inputs)"; then
+ rmdir "$sentinel"/flake-inputs
+fi
+remove_stale() {
+ stale_dir=$1
+ shift
+ if test -d "$stale_dir"; then
+ remove_others "$stale_dir" '' "$@"
+ if test -z "$(ls -A "$stale_dir")"; then
+ rmdir "$stale_dir"
+ fi
+ fi
+}
+# shellcheck disable=SC2086
+remove_stale "$sentinel"/flakes $4
+# shellcheck disable=SC2086
+remove_stale "$sentinel"/derived $5
+# shellcheck disable=SC2086
+remove_stale "$sentinel"/patches $6
+"#;
+}