diff --git a/doc/manual/rl-next/nix-store-delete-unlink.md b/doc/manual/rl-next/nix-store-delete-unlink.md new file mode 100644 index 000000000..76426e93c --- /dev/null +++ b/doc/manual/rl-next/nix-store-delete-unlink.md @@ -0,0 +1,10 @@ +--- +synopsis: nix store delete can now unlink a GC root before deleting its closure +cls: [4660] +category: Improvements +credits: [Qyriad] +--- + +Ever build something, and then you want to delete it and whatever dependencies it downloaded? +Before you had to resolve the `result` symlink and copy it, then delete it, *then* `nix store delete --delete-closure --skip-live` on the path you copied. +Now you can just pass `--unlink` and the `result` symlink itself. diff --git a/lix/libcmd/command.hh b/lix/libcmd/command.hh index 5c2c64436..0482af0cd 100644 --- a/lix/libcmd/command.hh +++ b/lix/libcmd/command.hh @@ -171,7 +171,7 @@ struct RawInstallablesCommand : virtual Args, SourceExprCommand std::vector getFlakeRefsForCompletion() override; -private: +protected: std::vector rawInstallables; }; diff --git a/lix/nix/store-delete.cc b/lix/nix/store-delete.cc index 23151218c..ef3907864 100644 --- a/lix/nix/store-delete.cc +++ b/lix/nix/store-delete.cc @@ -4,6 +4,7 @@ #include "lix/libstore/store-api.hh" #include "lix/libstore/store-cast.hh" #include "lix/libstore/gc-store.hh" +#include "lix/libutil/file-system.hh" #include "store-delete.hh" namespace nix { @@ -12,6 +13,7 @@ struct CmdStoreDelete : StorePathsCommand { GCOptions options { .action = GCOptions::gcDeleteSpecific }; bool deleteClosure = false; + bool unlink = false; CmdStoreDelete() { @@ -30,6 +32,11 @@ struct CmdStoreDelete : StorePathsCommand .description = "Also attempt to delete all paths in the given paths' closures.", .handler = {&deleteClosure, true} }); + addFlag({ + .longName = "unlink", + .description = "Unlink specified GC roots before deleting them", + .handler = {&unlink, true}, + }); realiseMode = Realise::Nothing; } @@ -49,7 +56,16 @@ struct CmdStoreDelete : StorePathsCommand { auto & gcStore = require(*store); - for (auto & path : storePaths) { + // If the user specified --unlink, try to remove any store-pointing symlinks specified + // on the command-line. + if (this->unlink) { + for (auto const & path : parsePathsToUnlink(store)) { + printTalkative("unlinking '%s'", path); + deletePath(path); + } + } + + for (auto const & path : storePaths) { if (deleteClosure) { aio().blockOn(store->computeFSClosure(path, options.pathsToDelete)); } else { @@ -61,6 +77,29 @@ struct CmdStoreDelete : StorePathsCommand PrintFreed freed(options.action, results); aio().blockOn(gcStore.collectGarbage(options, results)); } + + std::unordered_set parsePathsToUnlink(ref store) const + { + // Reaching into a distant base class because this C++ inheritance-centric command API can bite me. + return this->rawInstallables + | std::views::filter([store](std::string const & rawInst) { + // Installables are never parsed as paths unless there's a slash. + // We also only care about paths that aren't actually in the store... + if (!rawInst.contains('/') || !isLink(rawInst) || store->isInStore(rawInst)) { + return false; + } + + // ...just paths that *point* to the store. + try { + [[maybe_unused]] Path const resolved = store->followLinksToStore(rawInst); + return true; + } catch (BadStorePath const &) { + return false; + } + }) + | std::ranges::to(); + } + }; void registerNixStoreDelete() diff --git a/tests/functional2/commands/test_store_delete/__init__.py b/tests/functional2/commands/test_store_delete/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/functional2/commands/test_store_delete/assets/drv.nix b/tests/functional2/commands/test_store_delete/assets/drv.nix new file mode 100644 index 000000000..c01a84ac5 --- /dev/null +++ b/tests/functional2/commands/test_store_delete/assets/drv.nix @@ -0,0 +1,23 @@ +with import ./config.nix; + +let + dependency = mkDerivation { + name = "dependency"; + builder = builtins.toFile "builder.sh" + '' + #!/usr/bin/env bash + echo "beans" > "$out" + ''; + }; + +in +mkDerivation { + name = "some-package"; + builder = builtins.toFile "builder.sh" + '' + #!/usr/bin/env bash + mkdir "$out" + echo "$dependency" > "$out/toe-kind" + ''; + inherit dependency; +} diff --git a/tests/functional2/commands/test_store_delete/test_unlink.py b/tests/functional2/commands/test_store_delete/test_unlink.py new file mode 100644 index 000000000..572872901 --- /dev/null +++ b/tests/functional2/commands/test_store_delete/test_unlink.py @@ -0,0 +1,68 @@ +import sys + +import pytest + +from functional2.testlib.fixtures.file_helper import with_files, CopyFile +from functional2.testlib.fixtures.nix import Nix +from functional2.testlib.utils import get_global_asset + +_mo_files = {"config.nix": get_global_asset("config.nix"), "drv.nix": CopyFile("assets/drv.nix")} + + +@pytest.fixture(autouse=True) +def commands(nix: Nix): + nix.settings.feature("nix-command") + + +@pytest.mark.skipif( + sys.platform == "darwin", + reason="installables from symlinks to darwin-fake-redirected store paths are broken", +) +@with_files(_mo_files) +def test_store_delete_unlink(nix: Nix): + cmd = nix.nix(["build", "-f", "drv.nix", "-o", "result", "--print-out-paths"]) + cwd = cmd.cwd + + built = cmd.run().ok().stdout_plain + built_path = nix.physical_store_path_for(built) + assert built_path.exists() + + result_path = cwd.joinpath("result") + assert result_path.exists(follow_symlinks=False) + assert result_path.is_symlink() + + # Deleting the "symlink-to-store-path" installable should fail, because the symlink itself + # keeps the store path alive. + nix.nix(["store", "delete", "./result"]).run().expect(1) + assert result_path.exists(follow_symlinks=False) + assert built_path.exists() + + # But with --unlink, it'll remove that root and thus actually delete the path. + nix.nix(["store", "delete", "--unlink", "./result"]).run().ok() + assert not result_path.exists(follow_symlinks=False), "--unlink didn't remove gc root" + assert not built_path.exists(), "nix store delete didn't actually delete" + + +@pytest.mark.skipif( + sys.platform == "darwin", + reason="installables from symlinks to darwin-fake-redirected store paths are broken", +) +@with_files(_mo_files) +def test_store_delete_unlink_closure(nix: Nix): + cmd = nix.nix(["build", "-f", "drv.nix", "-o", "result", "--print-out-paths"]) + cwd = cmd.cwd + + built = cmd.run().ok().stdout_plain + result_path = cwd.joinpath("result") + + # We just built some paths; let's make sure they exist now, before we delete them. + requisites = nix.nix(["path-info", "--recursive", built]).run().ok().stdout_plain.splitlines() + req_paths = [nix.physical_store_path_for(req) for req in requisites] + for req in req_paths: + assert req.exists() + + nix.nix(["store", "delete", "--unlink", "--delete-closure", "./result"]).run().ok() + assert not result_path.exists(follow_symlinks=False) + + for req in req_paths: + assert not req.exists(), f"{req} should be deleted but it's still here"