From 08252cbf58bc353fac6a43d289edef9212e930cc Mon Sep 17 00:00:00 2001 From: Sam Estep Date: Wed, 25 Mar 2026 09:57:45 -0400 Subject: [PATCH] Support `devenv.lock` --- README.md | 32 +++++---- src/main.rs | 194 ++++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 162 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index cd3e6c7..70955b2 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Without `npc`, you could either (Usually the second one.) -With `npc`, you can simply `npc bisect` directly in your own project to find what was the last time `nixpkgs-unstable` pointed to a commit that works for you, trying only commits to which that branch has pointed at some point in the past, and skipping everything else. Then commit the change to `flake.lock`, and continue on your way. +With `npc`, you can simply `npc bisect` directly in your own project to find what was the last time `nixpkgs-unstable` pointed to a commit that works for you, trying only commits to which that branch has pointed at some point in the past, and skipping everything else. Then commit the change to your lock file, and continue on your way. ## Installation @@ -173,7 +173,7 @@ a8d610af3f1a5fb71e23e08434d8d61a466fc942 2025-11-20 01:07:48 -0500 If you don't pass `-n`/`--max-count` then there will be too many commits to fit on one screen, so the Git pager will be used to let you scroll through the list of commits, search for specific commits or dates, etc. -If you are in a directory that has a `flake.lock` file, you don't need to specify the branch name explicitly; `npc` will determine it automatically: +If you are in a directory that has a `flake.lock` or `devenv.lock` file, you don't need to specify the branch name explicitly; `npc` will determine it automatically: ```sh npc log @@ -181,19 +181,21 @@ npc log There are a couple caveats to this, though: -- Currently `npc` only looks for flake inputs that look like `github:NixOS/nixpkgs` optionally followed by some branch name, and ignores other possible ways of specifying Nixpkgs. If your flake refers to the Nixpkgs repo in a different way that you'd like `npc` to support, please [let me know](CONTRIBUTING.md)! +- Currently `npc` only looks for flake inputs that look like `github:NixOS/nixpkgs` optionally followed by some branch name, and ignores other possible ways of specifying Nixpkgs. If your lock file refers to the Nixpkgs repo in a different way that you'd like `npc` to support, please [let me know](CONTRIBUTING.md)! -- If your `flake.lock` file has multiple independent versions of Nixpkgs, even if they happen to currently point to the same commit, `npc` will not automatically choose one; you'll need to explicitly choose one yourself via the `--input` flag: +- If your lock file has multiple independent versions of Nixpkgs, even if they happen to currently point to the same commit, `npc` will not automatically choose one; you'll need to explicitly choose one yourself via the `--input` flag: ```sh npc log --input nixpkgs ``` -- The `log` subcommand always just prints all the commits that a Nixpkgs branch has ever pointed to according to the `npc` cache, modulo the `-n`/`--max-count` argument. That is, it may even show commits newer than the commit you currently have in your `flake.lock`. +- The `log` subcommand always just prints all the commits that a Nixpkgs branch has ever pointed to according to the `npc` cache, modulo the `-n`/`--max-count` argument. That is, it may even show commits newer than the commit you currently have in your lock file. + +If you want to pick a specific lock file (for example, when both `flake.lock` and `devenv.lock` are present), pass `--lock-file PATH`. ### `npc checkout` -This command runs [`nix flake update`](https://nix.dev/manual/nix/2.32/command-ref/new-cli/nix3-flake-update) with [`--override-input`](https://nix.dev/manual/nix/2.32/command-ref/new-cli/nix3-flake-update#opt-override-input) to modify your `flake.lock` file: +This command runs [`nix flake update`](https://nix.dev/manual/nix/2.32/command-ref/new-cli/nix3-flake-update) with [`--override-input`](https://nix.dev/manual/nix/2.32/command-ref/new-cli/nix3-flake-update#opt-override-input) to modify your lock file (by default `flake.lock`, or `devenv.lock` if `flake.lock` is missing): ```sh # Fun fact: this is the most recent commit that was on @@ -202,21 +204,23 @@ This command runs [`nix flake update`](https://nix.dev/manual/nix/2.32/command-r npc checkout 1d7db1b9e4cf1ee075a9f52e5c36f7b9f4207502 ``` -Just like the `log` subcommand, `checkout` attempts to use your `flake.lock` to automatically infer the name of the flake input to modify. Similarly, if there are multiple instances of Nixpkgs in `flake.lock` then it will ask you to explicitly specify one. +Just like the `log` subcommand, `checkout` attempts to use your lock file to automatically infer the name of the flake input to modify. Similarly, if there are multiple instances of Nixpkgs in the lock file then it will ask you to explicitly specify one. + +You can override the detected lock file with `--lock-file PATH`. -Other than saving you some typing, the primary difference between this and just running `nix flake update` yourself is that it checks whether the commit you give it is actually a commit that has been a tip of your Nixpkgs branch at some point in the past. If not, that's an error. The goal of this is to maintain consistency and reduce surprises: if your `flake.nix` says you're using the `nixos-unstable` branch, it'd be weird for your `flake.lock` to list a commit that has never been the tip of that branch. +Other than saving you some typing, the primary difference between this and just running `nix flake update` yourself is that it checks whether the commit you give it is actually a commit that has been a tip of your Nixpkgs branch at some point in the past. If not, that's an error. The goal of this is to maintain consistency and reduce surprises: if your `flake.nix` says you're using the `nixos-unstable` branch, it'd be weird for your lock file to list a commit that has never been the tip of that branch. ### `npc bisect` This is like `git bisect`, except instead finding the commit in your repository that introduced a bug, it finds the most recent Nixpkgs commit that doesn't have the bug but was at some point the tip of a given branch. -The easiest way to use this is in a flake where it can automatically do the equivalent of `npc checkout` while narrowing in on a specific commit. But you can also do it without a `flake.lock` if you specify a branch; the difference is just that you'll need to explicity specify each commit, rather than `npc` automatically reading the current commit from `flake.lock` at each step. +The easiest way to use this is in a flake where it can automatically do the equivalent of `npc checkout` while narrowing in on a specific commit. But you can also do it without a lock file if you specify a branch; the difference is just that you'll need to explicity specify each commit, rather than `npc` automatically reading the current commit from the lock file at each step. Note that if you are currently bisecting in a given directory, running `npc status` will also print the current bisection status. #### `npc bisect start` -Just like `checkout`, if you have exactly one Nixpkgs input in `flake.lock` then you don't need to specify any further information: +Just like `checkout`, if you have exactly one Nixpkgs input in the lock file then you don't need to specify any further information: ```sh npc bisect start @@ -224,19 +228,21 @@ npc bisect start If you have multiple Nixpkgs inputs then you need to specify one via `--input` on this command. Since bisection status is stored per directory, you only need to specify that when you start bisecting, and not each time you mark a commit as `bad` or `good`. +As with the other commands, you can point at a specific lock file using `--lock-file PATH`. + Either way, that will start bisecting in "flake mode"; if you instead want to bisect outside the context of any flake, simply specify a branch: ```sh npc bisect start nixpkgs-unstable ``` -Note that if you specify a branch, the bisection will not use "flake mode" even if you also have a unique Nixpkgs input with that branch in `flake.lock`. Specifying a branch here will cause the other `npc bisect` commands to ignore `flake.lock` entirely. +Note that if you specify a branch, the bisection will not use "flake mode" even if you also have a unique Nixpkgs input with that branch in the lock file. Specifying a branch here will cause the other `npc bisect` commands to ignore the lock file entirely. #### `npc bisect bad` / `npc bisect new` These subcommands are aliases of each other, just like with Git. They mark a Nixpkgs commit as being broken, or more generally, as being after the change that we are trying to pinpoint. -If you are bisecting in "flake mode" then you can run this with no argument, and `npc` will read the current commit from your `flake.lock` according to whatever flake input was determined when you first started bisecting: +If you are bisecting in "flake mode" then you can run this with no argument, and `npc` will read the current commit from your lock file according to whatever flake input was determined when you first started bisecting: ```sh npc bisect bad @@ -258,7 +264,7 @@ Once you finish bisecting, all the state is still stored. This is to allow you t npc bisect reset ``` -The main difference from `git bisect reset` is that this only deletes the bisection state, and does not make any further modifications to your `flake.lock`. That is, it does not bring you back to the original Nixpkgs commit you were on before you started bisecting; you'd need to do that manually if you didn't want to end up on the commit that `npc bisect` found. +The main difference from `git bisect reset` is that this only deletes the bisection state, and does not make any further modifications to your lock file. That is, it does not bring you back to the original Nixpkgs commit you were on before you started bisecting; you'd need to do that manually if you didn't want to end up on the commit that `npc bisect` found. ## Contributing diff --git a/src/main.rs b/src/main.rs index 8f8428a..a511543 100644 --- a/src/main.rs +++ b/src/main.rs @@ -37,6 +37,7 @@ const URL: &str = concat!( const GIT: &str = env!("GIT_BIN"); const NIX: &str = env!("NIX_BIN"); +const DEVENV: &str = "devenv"; const REMOTES: &str = " origin\thttps://github.com/NixOS/nixpkgs.git (fetch) [tree:0] @@ -481,7 +482,7 @@ impl Cache { None => bail!("please specify a commit"), Some(input) => { // The `resolve` function doesn't allow both branch and input to be `Some`. - let (_, input) = resolve(None, Some(input))?; + let (_, input, _) = resolve(None, Some(input), bisection.lock_file.clone())?; // The returned input could only be `None` if we had given a `None` input. let FlakeInput { name, rev } = input.unwrap(); bisection.input = Some(name); // Restore the field we took earlier. @@ -518,13 +519,16 @@ impl Cache { fs::write(path, json)?; bisection.print(); if let Some(input) = &bisection.input { + let Some(lock_file) = bisection.lock_file.as_deref() else { + bail!("could not determine lock file to update"); + }; let status = bisection.status(); if status.done && let Some((good, _)) = status.last_good { - flake_update(input, good)?; + update_lock(input, good, lock_file)?; } else if let Some(next) = bisection.next { - flake_update(input, next)?; + update_lock(input, next, lock_file)?; } } Ok(()) @@ -828,27 +832,25 @@ struct FlakeLock { } impl FlakeLock { - fn new() -> anyhow::Result> { - let err = match fs::read_to_string("flake.lock") { + fn load(path: &Path) -> anyhow::Result { + let label = lock_label(path); + let err = match fs::read_to_string(path) { Ok(json) => match serde_json::from_str::(&json) { Ok(flake_lock) => { let v = flake_lock.version; if v != 7 { - bail!("expected `flake.lock` format version 7 but this one is version {v}"); + bail!("expected {label} format version 7 but this one is version {v}"); } - return Ok(Some(flake_lock)); + return Ok(flake_lock); } Err(err) => anyhow!(err), }, - Err(err) => match err.kind() { - io::ErrorKind::NotFound => return Ok(None), - _ => anyhow!(err), - }, + Err(err) => anyhow!(err), }; - Err(err.context("`flake.lock` broken")) + Err(err.context(format!("{label} broken"))) } - fn resolve(&self, path: &[impl AsRef]) -> anyhow::Result<&str> { + fn resolve(&self, path: &[impl AsRef], label: &str) -> anyhow::Result<&str> { let mut key: &str = &self.root; for name in path { let name = name.as_ref(); @@ -857,12 +859,12 @@ impl FlakeLock { .. }) = self.nodes.get(key) else { - bail!("could not find inputs for node {key} in `flake.lock`"); + bail!("could not find inputs for node {key} in {label}"); }; match inputs.get(name) { - None => bail!("node {key} in `flake.lock` has no input named {name}"), + None => bail!("node {key} in {label} has no input named {name}"), Some(FlakePath::Direct(new_key)) => key = new_key, - Some(FlakePath::Indirect(path)) => key = self.resolve(path)?, + Some(FlakePath::Indirect(path)) => key = self.resolve(path, label)?, } } Ok(key) @@ -902,24 +904,40 @@ fn filter_node(node: &FlakeNode) -> Option<(Branch, Sha)> { fn resolve( branch: Option, flake_input: Option, -) -> anyhow::Result<(Branch, Option)> { - match (branch, flake_input, FlakeLock::new()?) { - (Some(_), Some(_), _) => bail!("cannot specify both branch and `--input`"), - (_, Some(_), None) => bail!("specified `--input` but no `flake.lock`"), - (None, None, None) => bail!("no branch specified and no `flake.lock` found"), - (Some(branch), None, _) => Ok((branch, None)), - (None, Some(name), Some(flake_lock)) => { + lock_file: Option, +) -> anyhow::Result<(Branch, Option, Option)> { + if branch.is_some() && flake_input.is_some() { + bail!("cannot specify both branch and `--input`"); + } + + let lock_file = select_lock_file(lock_file)?; + let mut flake_lock = None::<(PathBuf, FlakeLock)>; + if branch.is_none() || flake_input.is_some() { + let Some(path) = lock_file.clone() else { + if flake_input.is_some() { + bail!("specified `--input` but no lock file found"); + } + bail!("no branch specified and no lock file found"); + }; + flake_lock = Some((path.clone(), FlakeLock::load(&path)?)); + } + + match (branch, flake_input, flake_lock) { + (Some(branch), None, _) => Ok((branch, None, None)), + (None, Some(name), Some((path, flake_lock))) => { + let label = lock_label(&path); let parts: Vec<_> = name.split('/').collect(); - let key = flake_lock.resolve(&parts)?; + let key = flake_lock.resolve(&parts, &label)?; let Some(node) = flake_lock.nodes.get(key) else { - bail!("no node named {key} in `flake.lock`"); + bail!("no node named {key} in {label}"); }; let Some((bran, rev)) = filter_node(node) else { bail!("expected Nixpkgs in flake input named {name}"); }; - Ok((bran, Some(FlakeInput { name, rev }))) + Ok((bran, Some(FlakeInput { name, rev }), Some(path))) } - (None, None, Some(flake_lock)) => { + (None, None, Some((path, flake_lock))) => { + let label = lock_label(&path); let mut paths = Vec::new(); let mut stack = vec![(None, &flake_lock.root)]; while let Some((path, key)) = stack.pop() { @@ -942,10 +960,10 @@ fn resolve( Some((index, branch, rev)) }); let Some((mut index, branch, rev)) = it.next() else { - bail!("no Nixpkgs input found in `flake.lock`"); + bail!("no Nixpkgs input found in {label}"); }; if it.next().is_some() { - bail!("multiple Nixpkgs inputs in `flake.lock`; please specify `--input`"); + bail!("multiple Nixpkgs inputs in {label}; please specify `--input`"); } let mut parts = Vec::<&str>::new(); while let (Some((parent, part)), _) = paths[index] { @@ -954,32 +972,79 @@ fn resolve( } parts.reverse(); let name = parts.join("/"); - Ok((branch, Some(FlakeInput { name, rev }))) + Ok((branch, Some(FlakeInput { name, rev }), Some(path))) } + _ => bail!("no branch specified and no lock file found"), } } -fn flake_update(name: &str, sha: Sha) -> anyhow::Result<()> { +fn update_lock(input: &str, sha: Sha, lock_file: &Path) -> anyhow::Result<()> { + let label = lock_label(lock_file); + if lock_file + .file_name() + .is_some_and(|name| name == "devenv.lock") + { + let top_level = input.split('/').next().unwrap_or(input); + let status = Command::new(DEVENV) + .args([ + "update", + "--override-input", + top_level, + &format!("github:NixOS/nixpkgs/{sha}"), + ]) + .status()?; + if !status.success() { + bail!("failed to update {label}"); + } + return Ok(()); + } + // Even if the user already has flakes enabled, our hardcoded Nix path may bypass that e.g. on // Determinate Nix, so we pass flags to enable them regardless. - let status = Command::new(NIX) - .args([ - "--extra-experimental-features", - "nix-command flakes", - "flake", - "update", - name, - "--override-input", - name, - &format!("github:NixOS/nixpkgs/{sha}"), - ]) - .status()?; + let mut cmd = Command::new(NIX); + cmd.args([ + "--extra-experimental-features", + "nix-command flakes", + "flake", + "update", + input, + "--override-input", + input, + &format!("github:NixOS/nixpkgs/{sha}"), + ]); + if lock_file != Path::new("flake.lock") { + cmd.arg("--output-lock-file").arg(lock_file); + cmd.arg("--reference-lock-file").arg(lock_file); + } + let status = cmd.status()?; if !status.success() { - bail!("failed to update `flake.lock`"); + bail!("failed to update {label}"); } Ok(()) } +fn lock_label(path: &Path) -> String { + format!("`{}`", path.display()) +} + +fn select_lock_file(lock_file: Option) -> anyhow::Result> { + if let Some(path) = lock_file { + if path.try_exists()? { + return Ok(Some(path)); + } + bail!("lock file not found: {}", path.display()); + } + let flake_lock = Path::new("flake.lock"); + if flake_lock.try_exists()? { + return Ok(Some(flake_lock.to_path_buf())); + } + let devenv_lock = Path::new("devenv.lock"); + if devenv_lock.try_exists()? { + return Ok(Some(devenv_lock.to_path_buf())); + } + Ok(None) +} + struct BisectStatus { /// The first known bad commit. first_bad: Option<(Sha, usize)>, @@ -994,6 +1059,8 @@ struct BisectStatus { struct Bisection { branch: Branch, input: Option, + #[serde(default)] + lock_file: Option, bad: IndexMap, good: IndexMap, next: Option, @@ -1111,12 +1178,16 @@ enum Commands { #[clap(long, value_name = "NAME")] input: Option, + /// Path to a lock file (`flake.lock` or `devenv.lock`) + #[clap(long, value_name = "PATH")] + lock_file: Option, + /// Limit the number of shown commits #[clap(short = 'n', long, value_name = "NUMBER")] max_count: Option, }, - /// Set a different Nixpkgs commit in `flake.lock` + /// Set a different Nixpkgs commit in the lock file Checkout { /// Nixpkgs Git revision rev: String, @@ -1124,6 +1195,10 @@ enum Commands { /// Slash-separated flake input name #[clap(long, value_name = "NAME")] input: Option, + + /// Path to a lock file (`flake.lock` or `devenv.lock`) + #[clap(long, value_name = "PATH")] + lock_file: Option, }, /// Use binary search to find the newest commit of a branch before a bug @@ -1143,6 +1218,10 @@ enum Bisect { /// Slash-separated flake input name #[clap(long, value_name = "NAME")] input: Option, + + /// Path to a lock file (`flake.lock` or `devenv.lock`) + #[clap(long, value_name = "PATH")] + lock_file: Option, }, /// Mark this commit as "bad" @@ -1319,11 +1398,12 @@ async fn main() -> anyhow::Result<()> { Commands::Log { branch, input, + lock_file, max_count, }, ) => { let branch = branch.map(|name| name.parse()).transpose()?; - let (branch, _) = resolve(branch, input)?; + let (branch, _, _) = resolve(branch, input, lock_file)?; let commits = cache.branch(branch)?; let mut child = cache .git() @@ -1351,30 +1431,42 @@ async fn main() -> anyhow::Result<()> { Ok(()) } - (Ok(cache), Commands::Checkout { rev, input }) => { + ( + Ok(cache), + Commands::Checkout { + rev, + input, + lock_file, + }, + ) => { let sha = cache.sha(&rev)?; - let (branch, Some(input)) = resolve(None, input)? else { + let (branch, Some(input), Some(lock_file)) = resolve(None, input, lock_file)? else { bail!("could not determine flake input to update"); }; if !cache.branch(branch)?.contains(&sha) { bail!("{sha} not found in history of {branch}"); }; - flake_update(&input.name, sha)?; + update_lock(&input.name, sha, &lock_file)?; Ok(()) } (Ok(cache), Commands::Bisect { bisect }) => { let path = cache.path(CacheKey::Bisect(&env::current_dir()?)); match bisect { - Bisect::Start { branch, input } => { + Bisect::Start { + branch, + input, + lock_file, + } => { if fs::exists(&path)? { bail!("already bisecting here; to start anew, run `{NAME} bisect reset`"); } let branch = branch.map(|name| name.parse()).transpose()?; - let (branch, input) = resolve(branch, input)?; + let (branch, input, lock_file) = resolve(branch, input, lock_file)?; let bisection = Bisection { branch, input: input.map(|input| input.name), + lock_file, bad: IndexMap::new(), good: IndexMap::new(), next: None,