Skip to content

nh-nixos: add delete subcommand to remove and gc specific generations#656

Open
d4rk wants to merge 4 commits into
nix-community:masterfrom
d4rk:delete-subcommand
Open

nh-nixos: add delete subcommand to remove and gc specific generations#656
d4rk wants to merge 4 commits into
nix-community:masterfrom
d4rk:delete-subcommand

Conversation

@d4rk

@d4rk d4rk commented May 25, 2026

Copy link
Copy Markdown

This pull request introduces a delete subcommand to nh os allowing users to delete and garbage collect specific system generations.

Examples Usages

  • nh os delete 80
  • nh os delete 91 92

The generation IDs can be obtained from nh os info.

By default, it will also run nix store gc and switch-to-configuration boot to remove the boot menu of the pruned generations. These can be controlled with no-gc and no-boot.

I'm a new user of nh and in my workflow I sometimes prefer to prune specific generations rather than everything older than a timestamp, so I decided to try adding support for it to nh.

Sanity Checking

  • I have read and understood the contribution guidelines
  • I have updated the changelog as per my changes
  • I have tested, and self-reviewed my code
  • Style and consistency
    • I ran nix fmt to format my Nix code
    • I ran cargo fmt to format my Rust code
    • I have added appropriate documentation to new code
    • My changes are consistent with the rest of the codebase
  • Correctness
    • I ran cargo clippy and fixed any new linter warnings.
  • If new changes are particularly complex:
    • My code includes comments in particularly complex areas to explain the
      logic
    • I have documented the motive for those changes in the PR body or commit
      description.
  • Tested on platform(s):
    • x86_64-linux
    • aarch64-linux
    • x86_64-darwin
    • aarch64-darwin

AI Disclosure

Per the AI Policy, I'm disclosing that this feature was implemented with the assistance of Antigravity CLI. With that said, I've:

  • reviewed the code thoroughly
  • tried to ensure that it's following coding patterns and paradigms consistent with the project
  • tested locally on my own system

Add a 👍 reaction to pull requests you find important.

@coderabbitai

coderabbitai Bot commented May 25, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds a new nh os delete subcommand that allows users to delete specific NixOS generations by generation ID. The feature includes validation, dry-run support, interactive confirmation, and optional cleanup operations like garbage collection and bootloader reconfiguration.

Changes

OS Delete Subcommand

Layer / File(s) Summary
CLI argument contract and feature requirements
crates/nh-nixos/src/args.rs
New OsSubcommand::Delete(OsDeleteArgs) enum variant with a struct defining required generation IDs and options for profile path, dry-run, confirmation, GC toggle, bootloader toggle, and root-check bypass. Delete is mapped to legacy-features requirements alongside Info and Rollback.
Command dispatch and deletion implementation
crates/nh-nixos/src/nixos.rs
OsDeleteArgs is imported and dispatched from OsArgs::run. The delete() method validates elevation and profile, resolves the current generation, gathers existing generation links, validates requested generations (blocking deletion of the current one), supports --dry and --ask flows, deletes generation links via elevated rm, optionally runs the bootloader hook and nix store gc, and logs completion.
Feature documentation
CHANGELOG.md
Changelog entry documents that the new delete subcommand removes generations by ID with default execution of nix-store gc and switch-to-configuration boot.

Suggested reviewers

  • faukah
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: adding a delete subcommand to nh os for removing and garbage collecting specific generations.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The PR description clearly documents the new delete subcommand with usage examples, behavior explanations, and implementation context.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@CHANGELOG.md`:
- Around line 27-29: Update the CHANGELOG entry for `nh os delete` to use the
same gc command form as the implementation by replacing "nix-store gc" with "nix
store gc" so the release note text exactly matches the command invoked by `nh os
delete`.

In `@crates/nh-nixos/src/args.rs`:
- Around line 332-333: Update the help/doc comment text for the no-gc flag to
match the actual command executed: change the current comment "Don't run nix
store --gc" to "Don't run nix store gc" next to the #[arg(long = "no-gc", alias
= "nogc")] attribute so the CLI help correctly reflects the implemented
behavior.

In `@crates/nh-nixos/src/nixos.rs`:
- Around line 1547-1555: When !self.no_boot and the switch_to_configuration path
does not exist, emit a clear warning instead of silently skipping; locate the
block around switch_to_configuration and add a branch that checks
switch_to_configuration.exists() and, if false, calls the project's logging
facility (e.g. tracing::warn! or log::warn!) with a message like "Bootloader
refresh requested but /run/current-system/bin/switch-to-configuration not found;
skipping boot menu update" so users know the post-delete boot refresh was not
performed; keep the existing successful-path
Command::new(switch_to_configuration)... unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 9ed35ed0-ee99-45d1-9ea6-4ac594ebbb7d

📥 Commits

Reviewing files that changed from the base of the PR and between 444ba51 and a348a0a.

📒 Files selected for processing (3)
  • CHANGELOG.md
  • crates/nh-nixos/src/args.rs
  • crates/nh-nixos/src/nixos.rs

Comment thread CHANGELOG.md
Comment on lines +27 to +29
- `nh os delete` deletes specific generations by generation ID. Runs
`nix-store gc` and `switch-to-configuration boot` by default after the
generations are removed.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use the same GC command form as the implementation.

This entry says nix-store gc, while the command invoked is nix store gc. Aligning phrasing avoids ambiguity in release notes.

Suggested patch
-- `nh os delete` deletes specific generations by generation ID. Runs
-  `nix-store gc` and `switch-to-configuration boot` by default after the
+`nh os delete` deletes specific generations by generation ID. Runs
+  `nix store gc` and `switch-to-configuration boot` by default after the
   generations are removed.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CHANGELOG.md` around lines 27 - 29, Update the CHANGELOG entry for `nh os
delete` to use the same gc command form as the implementation by replacing
"nix-store gc" with "nix store gc" so the release note text exactly matches the
command invoked by `nh os delete`.

Comment on lines +332 to +333
/// Don't run nix store --gc
#[arg(long = "no-gc", alias = "nogc")]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Align --no-gc help text with the executed command.

Line 332 currently says nix store --gc, but the implementation runs nix store gc. Updating this avoids CLI-help confusion.

Suggested patch
-  /// Don't run nix store --gc
+  /// Don't run nix store gc
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// Don't run nix store --gc
#[arg(long = "no-gc", alias = "nogc")]
/// Don't run nix store gc
#[arg(long = "no-gc", alias = "nogc")]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/nh-nixos/src/args.rs` around lines 332 - 333, Update the help/doc
comment text for the no-gc flag to match the actual command executed: change the
current comment "Don't run nix store --gc" to "Don't run nix store gc" next to
the #[arg(long = "no-gc", alias = "nogc")] attribute so the CLI help correctly
reflects the implemented behavior.

Comment on lines +1547 to +1555
if !self.no_boot && switch_to_configuration.exists() {
Command::new(switch_to_configuration)
.arg("boot")
.elevate(elevate.then_some(elevation))
.message("Updating bootloader entries")
.with_required_env()
.run()
.wrap_err("Failed to update bootloader after deleting generations")?;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Warn when bootloader refresh is requested but hook is unavailable.

If --no-boot is not set and /run/current-system/bin/switch-to-configuration is missing, this currently skips boot menu refresh silently. A warning here would make post-delete state clearer.

Suggested patch
     let switch_to_configuration =
       Path::new("/run/current-system/bin/switch-to-configuration");
     if !self.no_boot && switch_to_configuration.exists() {
       Command::new(switch_to_configuration)
         .arg("boot")
         .elevate(elevate.then_some(elevation))
         .message("Updating bootloader entries")
         .with_required_env()
         .run()
         .wrap_err("Failed to update bootloader after deleting generations")?;
+    } else if !self.no_boot {
+      warn!(
+        "Skipping bootloader update: {} not found",
+        switch_to_configuration.display()
+      );
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/nh-nixos/src/nixos.rs` around lines 1547 - 1555, When !self.no_boot
and the switch_to_configuration path does not exist, emit a clear warning
instead of silently skipping; locate the block around switch_to_configuration
and add a branch that checks switch_to_configuration.exists() and, if false,
calls the project's logging facility (e.g. tracing::warn! or log::warn!) with a
message like "Bootloader refresh requested but
/run/current-system/bin/switch-to-configuration not found; skipping boot menu
update" so users know the post-delete boot refresh was not performed; keep the
existing successful-path Command::new(switch_to_configuration)... unchanged.

@faukah faukah left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey there! First of all, thank you for your pull request and thank you for the AI disclosure. <3 I do agree that this is something that nh is currently missing and that this is quite useful.

Two preliminary notes:

  • I'm not too happy about this being yet another thing in nh os. In the long term, I feel like we should add some sort of "tidy" sub-command, which bundles several things like this exact feature. This is something for me and @NotAShelf to consider in the future though; Just adding this to nh os completely fine for this PR.
  • It may make sense to add a check if all of the generations actually exist before we start deleting them. I prefer "all-or-nothing" behavior more than it just failing at some point during running if the user specifies an incorrect generation number.

I left a few review comments on things I spotted a first glance. I'll do a proper review later; I'm still not caffeinated for enough for that yet.

}
}

info!("Deleting generation(s)...");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It'd be nice if it told you here which generations it's about to remove.


info!("Deleting generation(s)...");
for (generation_num, path) in &paths_to_delete {
Command::new("rm")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's not shell out to rm when we have a great standard library.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a way to use remove_file with privilege escalation? This removal, at least on my system, requires sudo.

I tested it by swapping the Command with a simple fs::remove_file(path).context("Failed to remove generation")?;, and if failed. Apologies if I missed the obvious approach.

> Deleting generation(s)...
Error: 
   0: Failed to remove generation
   1: Permission denied (os error 13)

Location:
   crates/nh-nixos/src/nixos.rs:1529

Thinking about it some more, another option is to invoke nix-env --delete-generations, also wrapped in an elevated Command. Something like:

nix-env -p /nix/var/nix/profiles/system --delete-generations 90 91

But your FAQ notes that you're trying to move away from shelling out to nix CLI when possible, so I haven't made the change yet. Will await feedback.

Comment on lines +1558 to +1565
Command::new("nix")
.args(["store", "gc"])
.dry(self.dry)
.message("Performing garbage collection on the nix store")
.show_output(true)
.with_required_env()
.run()?;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instead of calling nix store gc, why not just use nh-clean?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is definitely my ignorance, but how would that work? My understanding is that nh clean will perform deletions based on a retention policy (--keep, etc) and then run gc. So a few clarifying questions:

  1. Is there a way to invoke it to just have it do gc?
  2. If there is, I assume we don't want to shell it out since that would be nh spawning itself?

It seems like potentially refactoring this snippet into nh-core and invoking it is one approach, but wondering if that's overkill :)

@d4rk

d4rk commented May 25, 2026

Copy link
Copy Markdown
Author

Thanks for the quick initial review! I had a few follow-up questions. I'll make changes based on responses. Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants