From 80a10fa965bb26cb47c3fe660e695ce809c9ec4e Mon Sep 17 00:00:00 2001 From: dawn <90008@klbr.net> Date: Thu, 17 Sep 2026 04:53:48 +0300 Subject: [PATCH] nix improve --- packages/coding-agent/skills/nix/SKILL.md | 2 + .../skills/nix/src/nix/__init__.py | 66 +++++++++++++++++-- .../coding-agent/skills/nix/tests/test_nix.py | 53 +++++++++++++++ 3 files changed, 116 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/skills/nix/SKILL.md b/packages/coding-agent/skills/nix/SKILL.md index 920aef0f2..8f3a6299e 100644 --- a/packages/coding-agent/skills/nix/SKILL.md +++ b/packages/coding-agent/skills/nix/SKILL.md @@ -35,6 +35,8 @@ shell.hook # shellHook text, read without runn shell # repr shows the captured toolchain paths ``` +Every local flake path must sit in a git work tree, because nix copies a non-git flake directory into `/nix/store` wholesale — including `target/`, `node_modules/`, `.venv/`. A jj workspace is the trap: `.jj/` makes it look like its own repository, but it has no `.git`, so the copy is unfiltered. A refused call says `only nix.develop on a git repo`; run the devshell from the enclosing git checkout, or colocate the workspace with `jj git init --colocate`. + `nix.develop()` captures the environment (PATH + exported vars). `shell.run(command)` has exactly the ordinary `bash(command)` process API, but snapshots the captured devShell env into the child. It uses the kernel's current working directory, like `bash()`. Nothing mutates kernel state until you call `shell.activate()`. `nix.dev_env(repo)` is the shortcut that captures and activates in one step (the old global-merge behavior). diff --git a/packages/coding-agent/skills/nix/src/nix/__init__.py b/packages/coding-agent/skills/nix/src/nix/__init__.py index a9df5c433..63568c887 100644 --- a/packages/coding-agent/skills/nix/src/nix/__init__.py +++ b/packages/coding-agent/skills/nix/src/nix/__init__.py @@ -38,6 +38,59 @@ def _has_flake(path: Path) -> bool: return (path / "flake.nix").exists() +def _git_root(path: Path) -> str | None: + """The enclosing git work tree, or None when the path has none. + + Returns "" when no git binary is available: then nothing is knowable, so + callers must not block on this. + """ + try: + proc = subprocess.run( + ["git", "-C", str(path), "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + ) + except OSError: + return "" + return proc.stdout.strip() if proc.returncode == 0 else None + + +_REMOTE_PREFIXES = ( + "github:", "gitlab:", "sourcehut:", "git+", "tarball+", "http:", "https:", "flake:", +) + + +def _local_dir(flake: str) -> Path | None: + """The directory a flake reference names, when it names a local one.""" + if flake.startswith(_REMOTE_PREFIXES): + return None + path = Path(flake.removeprefix("path:").split("#", 1)[0]).expanduser() + return path.resolve() if path.is_dir() else None + + +def _require_git_flake(flake: str, call: str) -> str: + """Refuse a local flake that is not inside a git work tree. + + nix copies a non-git flake directory into /nix/store wholesale, so a single + `nix develop` in a directory holding a build tree (cargo `target/`, + `node_modules/`, `.venv/`) dumps all of it into the store. Only in a git work + tree does nix copy the tracked files instead. + """ + path = _local_dir(flake) + if path is None or _git_root(path) is not None: + return flake + hint = "" + if (path / ".jj").exists(): + hint = ( + " This is a jj workspace: it has no .git of its own, so run the devshell from the" + " enclosing git checkout, or colocate it with `jj git init --colocate`." + ) + raise ValueError( + f"only nix.{call} on a git repo: {path} is not inside a git work tree, so nix would copy" + f" the entire directory into /nix/store.{hint}" + ) + + def _run(*args: str, check: bool = True, cwd: str | None = None) -> subprocess.CompletedProcess[str]: cmd = ["nix", *args] if not args or args[0] != "nix" else list(args) r = subprocess.run(cmd, capture_output=True, text=True, cwd=cwd) @@ -66,7 +119,7 @@ _SINGLE_DERIVATION_CATEGORIES = {"formatter", "defaultPackage"} def show(flake: str = ".") -> dict[str, list[str] | dict[str, str]]: """Flake outputs summary: {category: sorted attr names} (or {system: name} for formatter).""" - data = _flake_json(flake) + data = _flake_json(_require_git_flake(flake, "show")) summary: dict[str, list[str] | dict[str, str]] = {} for category, systems in data.items(): if not isinstance(systems, dict): @@ -95,7 +148,7 @@ def eval(expr: str, flake: str | None = None) -> Any: `expr` as a nix language expression (`--expr`). """ if flake: - r = _run("nix", "eval", "--json", "--no-pretty", f"{flake}#{expr}") + r = _run("nix", "eval", "--json", "--no-pretty", f"{_require_git_flake(flake, 'eval')}#{expr}") else: r = _run("nix", "eval", "--json", "--no-pretty", "--expr", expr) return json.loads(r.stdout) if r.stdout.strip() else None @@ -113,6 +166,7 @@ def fmt( command = ["nix", "fmt"] if impure: command.append("--impure") + _require_git_flake(flake, "fmt") if flake != ".": command.append(flake) if args: @@ -122,7 +176,7 @@ def fmt( def run(app: str, *args: str, flake: str = ".") -> BashHandle: """Start `nix run # -- ` and return the native bash handle.""" - command = ["nix", "run", f"{flake}#{app}"] + command = ["nix", "run", f"{_require_git_flake(flake, 'run')}#{app}"] if args: command += ["--", *args] return _bash(shlex.join(command)) @@ -147,7 +201,7 @@ def build( if expr is not None: command += ["--expr", expr] else: - command.append(f"{flake}#{attr}") + command.append(f"{_require_git_flake(flake, 'build')}#{attr}") return _bash(shlex.join(command)) @@ -166,7 +220,7 @@ def check( command.append("--no-build") if keep_going: command.append("--keep-going") - command.append(flake) + command.append(_require_git_flake(flake, "check")) return _bash(shlex.join(command)) @@ -271,6 +325,7 @@ async def develop(repo: str | os.PathLike[str] = ".") -> DevShell: repo_path = str(Path(repo).expanduser().resolve()) if not _has_flake(Path(repo_path)): raise ValueError(f"no flake.nix in {repo_path}; pass the repo path") + _require_git_flake(repo_path, "develop") env = await asyncio.to_thread(_read_env, repo_path) return DevShell(repo_path, env) @@ -313,6 +368,7 @@ def hook(repo: str | os.PathLike[str] = ".") -> str: repo_path = str(Path(repo).expanduser().resolve()) if not _has_flake(Path(repo_path)): raise ValueError(f"no flake.nix in {repo_path}; pass the repo path") + _require_git_flake(repo_path, "hook") return _shell_hook(repo_path) or "" diff --git a/packages/coding-agent/skills/nix/tests/test_nix.py b/packages/coding-agent/skills/nix/tests/test_nix.py index 731a35f59..0823d13ac 100644 --- a/packages/coding-agent/skills/nix/tests/test_nix.py +++ b/packages/coding-agent/skills/nix/tests/test_nix.py @@ -1,7 +1,10 @@ from __future__ import annotations import asyncio +import subprocess +import tempfile import unittest +from pathlib import Path from unittest.mock import patch import nix @@ -86,5 +89,55 @@ class NixCommandSurfaceTests(unittest.TestCase): asyncio.run(_test()) +class LocalFlakeGuardTests(unittest.TestCase): + """A local flake outside a git work tree is refused before nix runs. + + nix copies such a directory into /nix/store wholesale, so one devshell load + in a directory holding a build tree dumps the whole target/ into the store. + """ + + def test_a_local_flake_outside_a_git_work_tree_is_refused(self) -> None: + with tempfile.TemporaryDirectory(prefix="nix-nongit-") as temporary: + (Path(temporary) / "flake.nix").write_text("{ outputs = {}; }") + with self.assertRaisesRegex(ValueError, "only nix.check on a git repo"): + nix.check(temporary) + + def test_a_jj_workspace_gets_the_colocation_hint(self) -> None: + with tempfile.TemporaryDirectory(prefix="nix-jj-") as temporary: + (Path(temporary) / ".jj").mkdir() + with self.assertRaisesRegex(ValueError, "jj git init --colocate"): + nix._require_git_flake(temporary, "develop") + + def test_a_declared_call_refuses_before_it_runs_nix(self) -> None: + with tempfile.TemporaryDirectory(prefix="nix-decl-") as temporary: + (Path(temporary) / "flake.nix").write_text("{ outputs = {}; }") + with patch.object(nix, "_bash", side_effect=AssertionError("nix ran anyway")): + for call, expected in ( + (lambda: nix.fmt(flake=temporary), "only nix.fmt on a git repo"), + (lambda: nix.build("pkg", flake=temporary), "only nix.build on a git repo"), + (lambda: nix.eval("x", flake=temporary), "only nix.eval on a git repo"), + ): + with self.assertRaisesRegex(ValueError, expected): + call() + with self.assertRaisesRegex(ValueError, "only nix.develop on a git repo"): + asyncio.run(nix.develop(temporary)) + + def test_a_git_work_tree_is_accepted(self) -> None: + root = subprocess.run( + ["git", "-C", str(Path(__file__).resolve().parent), "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + ) + if root.returncode != 0: + self.skipTest("tests are not running inside a git checkout") + found = root.stdout.strip() + self.assertEqual(nix._require_git_flake(found, "develop"), found) + self.assertEqual(nix._require_git_flake(f"{found}#devShells", "show"), f"{found}#devShells") + + def test_remote_installables_are_left_alone(self) -> None: + for flake in ("github:nixos/nixpkgs", "git+https://example.invalid/repo", "nixpkgs"): + self.assertEqual(nix._require_git_flake(flake, "show"), flake) + + if __name__ == "__main__": unittest.main() -- 2.51.2