okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

dawnnix shim: carry every dev shell export into the cached shell74cf623

main
7.2 KiB190 linesraw
1"""How the nix shim rewrites a command line in a jj workspace without .git,
2and what a cached dev shell must carry. Each rewrite case costs a real nix
3evaluation end to end, and the mistakes are quiet: a lock file written over
4the workspace's (`--inputs-from .` once wrote nixpkgs' empty lock into it),
5a flake inserted into a command that takes none, or a lock written into the
6main checkout the ref names. The carry cases run a fake nix whose
7print-dev-env writes a script shaped like nix's own: a cached shell dropped
8every variable whose value the capture's own environment already held
9(mkShell's env.* among them), so `nix develop -c env` showed no CGO_ENABLED
10and a cgo link failed."""
11
12import os
13from pathlib import Path
14import shutil
15import sys
16import tempfile
17import unittest
18from unittest import mock
19
20sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "priv" / "python"))
21import albedo_nix  # noqa: E402
22
23
24@unittest.skipUnless(shutil.which("nix"), "needs nix for its flag table")
25class RewriteTest(unittest.TestCase):
26    def setUp(self):
27        scratch = tempfile.TemporaryDirectory()
28        self.addCleanup(scratch.cleanup)
29        self.workspace = Path(scratch.name).resolve() / "ws"
30        (self.workspace / ".jj").mkdir(parents=True)
31        (self.workspace / "flake.nix").write_text("{ outputs = _: {}; }\n")
32        os.environ["ALBEDO_HOME"] = scratch.name
33        nix = shutil.which("nix")
34        assert nix is not None
35        self.table = albedo_nix.flag_table(nix)
36        patch = mock.patch.object(albedo_nix, "flake_ref", return_value="REF")
37        patch.start()
38        self.addCleanup(patch.stop)
39
40    def read(self, line: str) -> albedo_nix.Command:
41        return albedo_nix.read(line.split(), self.table)
42
43    def rewrite(self, line: str) -> str | None:
44        command = self.read(line)
45        result = albedo_nix.rewritten(command, self.workspace)
46        if result is None:
47            return None
48        lock = str(self.workspace / "flake.lock")
49        return " ".join(result[0]).replace(lock, "LOCK")
50
51    def test_the_workspace_lock_is_written_only_for_the_workspace_flake(self):
52        cases = {
53            "build": "build --output-lock-file LOCK REF",
54            "-L build .#x": "-L build --output-lock-file LOCK REF#x",
55            "run .#x ./arg": "run --output-lock-file LOCK REF#x ./arg",
56            "flake update nixpkgs": "flake update --output-lock-file LOCK --flake REF nixpkgs",
57            "profile install .#x": "profile install --output-lock-file LOCK REF#x",
58            # another flake is locked too: the workspace's lock is left alone
59            "build .#a nixpkgs#b": "build --no-write-lock-file REF#a nixpkgs#b",
60            "shell --inputs-from . nixpkgs#hello -c hello": (
61                "shell --no-write-lock-file --inputs-from REF nixpkgs#hello -c hello"
62            ),
63            "why-depends .#a nixpkgs#b": "why-depends --no-write-lock-file REF#a nixpkgs#b",
64        }
65        for line, expected in cases.items():
66            with self.subTest(line):
67                self.assertEqual(self.rewrite(line), expected)
68
69    def test_commands_that_name_no_local_flake_are_left_alone(self):
70        for line in [
71            "build nixpkgs#hello",
72            "eval --expr 1",
73            "eval",
74            "build -f . hello",
75            "store gc",
76            "path-info --all",
77            "flake show github:owner/repo",
78        ]:
79            with self.subTest(line):
80                self.assertIsNone(self.rewrite(line))
81
82    def test_the_formatter_runs_from_the_commit_at_the_flake_root(self):
83        command = self.read("fmt --impure src")
84        result = albedo_nix.rewritten(command, self.workspace)
85        assert result is not None
86        words, environment = result
87        self.assertEqual(
88            words,
89            [
90                "run",
91                "--output-lock-file",
92                str(self.workspace / "flake.lock"),
93                f"REF#formatter.{albedo_nix.system()}",
94                "--impure",
95                "--",
96                "src",
97            ],
98        )
99        self.assertEqual(environment, {"PRJ_ROOT": str(self.workspace)})
100
101    def test_committing_a_lock_into_the_main_checkout_is_refused(self):
102        with self.assertRaises(albedo_nix.NixError):
103            self.rewrite("flake lock --commit-lock-file")
104
105    def test_unknown_commands_and_flags_go_to_nix_to_refuse(self):
106        for line in ["frobnicate .", "build --frobnicate"]:
107            with self.subTest(line), self.assertRaises(albedo_nix.Unreadable):
108                self.read(line)
109
110
111class ExportTest(unittest.TestCase):
112    def test_exports_are_the_scripts_own(self):
113        script = (
114            'nix_saved_PATH="$PATH"\n'
115            "PATH=''\n"
116            "export PATH\n"
117            "CC='clang'\n"
118            "export CC\n"
119            "HOME='/homeless-shelter'\n"  # assigned, not exported: the caller keeps theirs
120            "declare -a outputs=('out')\n"
121            "f ()\n"
122            "{\n"
123            '    export SOURCE_DATE_EPOCH="$time";\n'  # a function body: does not run
124            "}\n"
125            'export NIX_LDFLAGS${role_post}+=" -L$1/lib";\n'
126            'export NIX_BUILD_TOP="$(mktemp -d)"\n'
127        )
128        self.assertEqual(albedo_nix.exported(script), {"CC", "NIX_BUILD_TOP"})
129
130
131class DeltaTest(unittest.TestCase):
132    def test_a_matching_value_is_kept_only_when_the_shell_provides_it(self):
133        change = albedo_nix.delta(
134            {"CGO_ENABLED": "1", "TERM": "xterm"},
135            {"CGO_ENABLED": "1", "TERM": "xterm"},
136            frozenset({"CGO_ENABLED"}),
137        )
138        self.assertEqual(change["set"], {"CGO_ENABLED": "1"})
139
140
141class CaptureTest(unittest.TestCase):
142    SCRIPT = """\
143nix_saved_PATH="$PATH"
144nix_saved_XDG_DATA_DIRS="$XDG_DATA_DIRS"
145PATH='/shell/bin'
146export PATH
147CGO_ENABLED='1'
148export CGO_ENABLED
149export CGO_LDFLAGS='-L/shell/lib'
150shellHook='export HOOKED=from-hook'
151export shellHook
152PATH="$PATH${nix_saved_PATH:+:$nix_saved_PATH}"
153XDG_DATA_DIRS="$XDG_DATA_DIRS${nix_saved_XDG_DATA_DIRS:+:$nix_saved_XDG_DATA_DIRS}"
154export NIX_BUILD_TOP="$(mktemp -d -t nix-shell.XXXXXX)"
155export TMP="$NIX_BUILD_TOP"
156export TMPDIR="$NIX_BUILD_TOP"
157export TEMP="$NIX_BUILD_TOP"
158export TEMPDIR="$NIX_BUILD_TOP"
159eval "${shellHook:-}"
160"""
161
162    FAKE = """#!/bin/sh
163while [ $# -gt 0 ]; do
164  if [ "$1" = "--profile" ]; then profile=$2; shift; fi
165  shift
166done
167cat > "$profile" <<'JSON'
168{"variables": {}}
169JSON
170cat <<'RC'
171"""
172
173    def test_a_capture_inside_the_shell_still_carries_its_exports(self):
174        with tempfile.TemporaryDirectory() as scratch:
175            fake = Path(scratch, "nix")
176            fake.write_text(self.FAKE + self.SCRIPT + "RC\n")
177            fake.chmod(0o755)
178            with mock.patch.dict(os.environ, {"CGO_ENABLED": "1"}):
179                change = albedo_nix.capture(
180                    str(fake), ["installable"], Path(scratch, "profile"), Path(scratch)
181                )
182            self.assertEqual(change["set"]["CGO_ENABLED"], "1")
183            self.assertEqual(change["set"]["CGO_LDFLAGS"], "-L/shell/lib")
184            self.assertEqual(change["set"]["HOOKED"], "from-hook")
185            self.assertNotIn("HOME", change["set"])
186            self.assertEqual(change["prepend"]["PATH"], "/shell/bin")
187
188
189if __name__ == "__main__":
190    unittest.main()