okami.mom/albedo

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

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

main
27.9 KiB723 linesraw
1"""albedo's nix: the program behind the `nix` shim (albedo_shims.py) that
2the model's jobs find first on PATH.
3
4`nix` in a jj workspace that has no .git hands nix a plain path, and nix
5copies that whole directory into the store, build and target dirs included,
6on every evaluation. The shim names the workspace's commit in the
7repository's git store instead (git+file://...?rev=...), so nix reads tracked
8files only, and points the lock file nix may write back at the workspace
9(--output-lock-file), where nix would have put it in a git checkout.
10
11`nix develop [installable] -c program ...` runs the program in a dev shell
12captured once, as `nix develop` would build it (the dev shell script and its
13shellHook), and kept as the difference it makes to the environment. It is
14cached under $ALBEDO_HOME/cache/nix-develop by the content of the files dev
15shells are made of (SHELL_FILES), behind a profile nix's garbage collector
16keeps, so an unchanged flake costs no nix call in any checkout of the
17repository.
18
19Commands are read with nix's own flag table (`nix __dump-cli`, cached per nix
20binary); everything the shim has no reason to change goes to nix unchanged.
21ALBEDO_PLAIN_NIX=1 runs nix as is.
22"""
23
24from __future__ import annotations
25
26import fcntl
27import fnmatch
28import hashlib
29import json
30import os
31import platform
32import re
33import shutil
34import subprocess
35import sys
36import tempfile
37import time
38from dataclasses import dataclass
39from pathlib import Path
40from typing import Any, NoReturn
41from urllib.parse import quote
42
43FEATURES = ["--extra-experimental-features", "nix-command flakes"]
44# What a dev shell is made of; an edit anywhere else keeps the cached shell.
45SHELL_FILES = ("*.nix", "flake.lock", "rust-toolchain", "rust-toolchain.toml")
46# Never walked for SHELL_FILES in a directory outside version control.
47UNWALKED = {"node_modules", "target", "build", "_build", "dist", "result"}
48CACHE_LIMIT = 32  # cached shells; the least recently used goes first
49REMOTE_TTL = 86400  # seconds a shell from a flake elsewhere (github:...) is kept
50# the capture a record's delta describes; an older one missed exports and is
51# rebuilt rather than replayed
52FORMAT = "exports"
53STEP_TIMEOUT = 600  # seconds for one jj or git call, or a shellHook
54# Set by sourcing the shell script, meaningless outside the bash that did it.
55VOLATILE = {"NIX_BUILD_TOP", "TMP", "TMPDIR", "TEMP", "TEMPDIR"}
56VOLATILE |= {"PWD", "OLDPWD", "SHLVL", "_"}
57# the nix compiler wrapper's inputs, which no build cache key covers (albedo_shims)
58WRAPPER = ("NIX_CFLAGS", "NIX_LDFLAGS", "NIX_CC", "NIX_HARDENING_ENABLE")
59# develop flags a cached shell honours: they change what nix prints, not the shell
60QUIET = {"verbose", "quiet", "print-build-logs", "log-format", "show-trace"}
61QUIET |= {"debug", "accept-flake-config", "no-write-lock-file", "no-update-lock-file"}
62# flags that write into the repository the ref names, not into the workspace
63WRITING = {"commit-lock-file"}
64# nix's own spellings of commands the flag table lists under another name,
65# by the command they are spelled under
66ALIASES = {
67    (): {"shell": ["env", "shell"], "dev-shell": ["develop"]},
68    ("profile",): {"install": ["add"]},
69}
70# argument labels (nix __dump-cli) of one word naming a flake or its installable;
71# "installables" is every word
72FLAKE_ARGS = {"installable", "flake-url", "package", "dependency"}
73VARIADIC = {"args", "strings", "paths", "elements", "hashes", "inputs"}
74# flags that make installables something other than flakes
75NOT_FLAKES = {"expr", "file"}
76# commands that run the formatter of the flake at `.`, and the nix verb for that
77FORMATTERS = {
78    ("fmt",): "run",
79    ("formatter", "run"): "run",
80    ("formatter", "build"): "build",
81}
82NOTE = "albedo:"
83REFUSED = (
84    "nix would copy this jj workspace (it has no .git) into the store, build "
85    "output included. Name the flake by its commit (git+file://<repo>?rev=<commit>) "
86    "or set ALBEDO_PLAIN_NIX=1 to copy anyway."
87)
88
89# what a dev shell does to an environment: "set", "prepend", and "unset"
90Change = dict[str, Any]
91
92
93class NixError(RuntimeError):
94    """jj, git, or nix could not prepare the shell; the message has their output."""
95
96
97class Unreadable(Exception):
98    """A flag or command nix's own table does not have: nix refuses it
99    itself, or it is an alias the shim does not know."""
100
101
102@dataclass(frozen=True)
103class Flake:
104    """A local flake and the checkout it lives in."""
105
106    directory: Path  # holds flake.nix
107    root: Path  # the jj workspace, the git work tree, or the directory itself
108    vcs: str  # "jj", "git", or "" outside version control
109
110    @property
111    def copied(self) -> bool:
112        """Whether nix, handed this directory, copies all of it: a jj
113        workspace with no .git beside .jj is a plain path to nix."""
114        return self.vcs == "jj" and not (self.root / ".git").exists()
115
116
117def _above(start: Path, name: str) -> Path | None:
118    for directory in (start, *start.parents):
119        if (directory / name).exists():
120            return directory
121    return None
122
123
124def locate(start: Path) -> Flake | None:
125    """The flake at or above `start`. jj wins over git at the same root, as
126    the picker reads a colocated repository."""
127    directory = _above(start, "flake.nix")
128    if directory is None:
129        return None
130    jj, git = _above(directory, ".jj"), _above(directory, ".git")
131    if jj is not None and (git is None or git == jj or git in jj.parents):
132        return Flake(directory, jj, "jj")
133    if git is not None:
134        return Flake(directory, git, "git")
135    return Flake(directory, directory, "")
136
137
138def system() -> str:
139    """This host's nix system, as nix names it."""
140    machine = {"arm64": "aarch64", "amd64": "x86_64"}.get(platform.machine().lower())
141    kernel = "darwin" if sys.platform == "darwin" else "linux"
142    return f"{machine or platform.machine()}-{kernel}"
143
144
145def _local(base: str) -> bool:
146    """Whether an installable's flake part names a local directory."""
147    return base in ("", ".", "..") or base.startswith(("./", "../", "/", "path:"))
148
149
150def _step(argv: list[str], cwd: Path | None, what: str) -> str:
151    """One program the shim needs the output of, or a NixError with it."""
152    try:
153        done = subprocess.run(
154            argv, cwd=cwd, capture_output=True, text=True, timeout=STEP_TIMEOUT
155        )
156    except (OSError, subprocess.TimeoutExpired) as error:
157        raise NixError(f"{what} failed: {error}") from error
158    if done.returncode != 0:
159        raise NixError(
160            f"{what} failed (exit {done.returncode}):\n{done.stderr[-4000:]}"
161        )
162    return done.stdout
163
164
165def _home() -> Path:
166    return Path(os.environ.get("ALBEDO_HOME") or os.path.expanduser("~/.albedo"))
167
168
169# ---- reading a command line ----
170
171
172def flag_table(nix: str) -> dict[str, Any]:
173    """nix's commands and the arity of each flag, from `nix __dump-cli`,
174    kept per nix binary."""
175    binary = os.path.realpath(nix)
176    cache = _home() / "cache" / "nix-cli"
177    entry = cache / (hashlib.sha256(binary.encode()).hexdigest()[:32] + ".json")
178    try:
179        return json.loads(entry.read_text())
180    except (OSError, ValueError):
181        pass
182    dump = json.loads(_step([binary, "__dump-cli"], None, "nix __dump-cli"))["args"]
183
184    def node(command: dict[str, Any]) -> dict[str, Any]:
185        flags = command.get("flags", {})
186        return {
187            "flags": {name: flag.get("arity") or 0 for name, flag in flags.items()},
188            "short": {
189                flag["shortName"]: name
190                for name, flag in flags.items()
191                if flag.get("shortName")
192            },
193            "args": [arg.get("label", "") for arg in command.get("args", [])],
194            "commands": {
195                name: node(sub) for name, sub in command.get("commands", {}).items()
196            },
197        }
198
199    table = node(dump)
200    cache.mkdir(parents=True, exist_ok=True)
201    temporary = cache / f".{entry.name}.{os.getpid()}"
202    temporary.write_text(json.dumps(table))
203    temporary.replace(entry)
204    return table
205
206
207@dataclass
208class Command:
209    """One nix command line, read with the flag table."""
210
211    words: list[str]
212    path: list[str]  # the subcommand, e.g. ["flake", "check"]
213    named: list[int]  # indices of the words that spell the subcommand
214    positional: list[int]  # indices of the words that are not flags or values
215    flags: dict[str, list[int]]  # flag name -> index of each use
216    args: list[str]  # the subcommand's argument labels
217    program: list[str]  # what follows --command / -c (develop, shell)
218    stop: int  # index of `--`, or of the end
219
220    @property
221    def passed(self) -> list[str]:
222        """What follows `--`."""
223        return self.words[self.stop + 1 :]
224
225    @property
226    def end(self) -> int:
227        """Where flags for the subcommand can go: just past its words."""
228        return self.named[-1] + 1 if self.named else 0
229
230
231def read(words: list[str], table: dict[str, Any]) -> Command:
232    """The command `words` (nix's arguments) spell, or Unreadable."""
233    node, path, named = table, [], []
234    positional: list[int] = []
235    flags: dict[str, list[int]] = {}
236    program: list[str] = []
237    index = 0
238    while index < len(words):
239        word = words[index]
240        if word == "--":
241            break
242        if word.startswith("--"):
243            name = word[2:]
244        elif word.startswith("-") and len(word) == 2:
245            name = node["short"].get(word[1]) or table["short"].get(word[1], "")
246        elif word.startswith("-") and word != "-":
247            raise Unreadable(word)
248        else:
249            names = ALIASES.get(tuple(path), {}).get(word, [word])
250            if not positional and names[0] in node["commands"]:
251                for name in names:
252                    node = node["commands"][name]
253                path.extend(names)
254                named.append(index)
255            elif node["commands"]:
256                raise Unreadable(word)  # a command nix spells some other way
257            else:
258                positional.append(index)
259            index += 1
260            continue
261        if name == "command" and path in (["develop"], ["env", "shell"]):
262            program = words[index + 1 :]
263            flags.setdefault(name, []).append(index)
264            index = len(words)
265            break
266        arity = node["flags"].get(name, table["flags"].get(name))
267        if arity is None:
268            raise Unreadable(word)
269        flags.setdefault(name, []).append(index)
270        index += 1 + arity
271    return Command(words, path, named, positional, flags, node["args"], program, index)
272
273
274def _flake_words(command: Command) -> list[int]:
275    """Indices of the words that name a flake (or an installable of one),
276    following the subcommand's argument labels."""
277    if NOT_FLAKES & command.flags.keys():
278        return []  # installables are attribute paths or store paths then
279    found, left = [], list(command.positional)
280    for label in command.args:
281        if not left:
282            break
283        if label == "installables":
284            found, left = found + left, []
285        elif label in FLAKE_ARGS:
286            found.append(left.pop(0))
287        elif label in VARIADIC:
288            left = []
289        else:
290            left.pop(0)
291    return found
292
293
294# ---- refs that never copy a jj workspace ----
295
296
297def flake_ref(flake: Flake) -> str:
298    """The ref nix reads `flake` through without copying the working copy."""
299    if flake.vcs == "git":
300        return str(flake.directory)
301    if flake.vcs != "jj":
302        return f"path:{flake.directory}"
303    # jj log snapshots the working copy, so the commit holds every edit
304    log = _step(
305        ["jj", "log", "-r", "@", "--no-graph", "-T", '"commit=" ++ commit_id ++ "\\n"'],
306        flake.root,
307        "jj log",
308    )
309    commit = re.search(r"commit=([0-9a-f]{40})", log)
310    if commit is None:
311        raise NixError(f"jj log named no commit:\n{log[-2000:]}")
312    lines = _step(["jj", "git", "root"], flake.root, "jj git root").splitlines()
313    store = Path([line for line in lines if line.strip()][-1].strip())
314    # a colocated .git is read through its work tree, a bare store as itself
315    repository = store.parent if store.name == ".git" else store
316    ref = f"git+file://{quote(str(repository))}?rev={commit.group(1)}"
317    relative = flake.directory.relative_to(flake.root)
318    return ref if relative == Path(".") else f"{ref}&dir={quote(relative.as_posix())}"
319
320
321def _copied(word: str, cwd: Path) -> Flake | None:
322    """The flake `word` names when it is a local one nix would copy."""
323    base = word.partition("#")[0]
324    if not _local(base):
325        return None
326    flake = locate((cwd / base.removeprefix("path:")).resolve())
327    return flake if flake is not None and flake.copied else None
328
329
330def _formatter(
331    command: Command, flake: Flake, verb: str
332) -> tuple[list[str], dict[str, str]]:
333    """`nix fmt` and `nix formatter run|build` as the `nix run|build` of the
334    workspace flake's formatter, run from the flake's root as nix fmt does."""
335    words = command.words
336    skipped = {*command.named, *command.positional}
337    keep = [word for i, word in enumerate(words[: command.stop]) if i not in skipped]
338    passed = [words[i] for i in command.positional] + command.passed
339    lock = ["--output-lock-file", str(flake.directory / "flake.lock")]
340    rewritten = [
341        verb,
342        *lock,
343        f"{flake_ref(flake)}#formatter.{system()}",
344        *keep,
345        *(["--", *passed] if passed and verb == "run" else []),
346    ]
347    return rewritten, {"PRJ_ROOT": str(flake.directory)}
348
349
350def rewritten(command: Command, cwd: Path) -> tuple[list[str], dict[str, str]] | None:
351    """`command` with every local flake of a jj workspace named by its
352    commit, and the environment changes that go with it; None when no word
353    names such a flake.
354
355    nix writes a lock file it had to create or update into the repository
356    the ref names, the main checkout, so the workspace's own goes to the
357    workspace (--output-lock-file); that flag covers every flake the command
358    locks, so a command that locks another flake too writes none."""
359    verb = FORMATTERS.get(tuple(command.path))
360    if verb is not None:
361        flake = _copied(".", cwd)
362        return None if flake is None else _formatter(command, flake, verb)
363    words = list(command.words)
364    targets = _flake_words(command)
365    if command.path == ["flake", "update"]:
366        targets = [index + 1 for index in command.flags.get("flake", [])]
367    takes = command.path == ["flake", "update"] or any(
368        label == "installables" or label in FLAKE_ARGS for label in command.args
369    )
370    # nix reads `.` when no flake is named, except for nix eval
371    implicit = (
372        _copied(".", cwd)
373        if takes
374        and not targets
375        and (command.path == ["flake", "update"] or not command.positional)
376        and command.path != ["eval"]
377        and not (NOT_FLAKES | {"stdin", "all"}) & command.flags.keys()
378        else None
379    )
380    rewrites: dict[int, Flake] = {}
381    for index in [*targets, *(i + 1 for i in command.flags.get("inputs-from", []))]:
382        flake = _copied(words[index], cwd)
383        if flake is not None:
384            rewrites[index] = flake
385    if not rewrites and implicit is None:
386        return None
387    if WRITING & command.flags.keys():
388        raise NixError(
389            f"--commit-lock-file would commit in the main checkout; {REFUSED}"
390        )
391    for index, flake in rewrites.items():
392        _, hash_, attribute = words[index].partition("#")
393        words[index] = flake_ref(flake) + hash_ + attribute
394    locked = {rewrites[index].directory for index in targets if index in rewrites}
395    others = [index for index in targets if index not in rewrites]
396    if implicit is not None:
397        ref = flake_ref(implicit)
398        flag = ["--flake", ref] if command.path == ["flake", "update"] else [ref]
399        words[command.end : command.end] = flag
400        locked = {implicit.directory}
401    if not {"output-lock-file", "no-write-lock-file"} & command.flags.keys():
402        if len(locked) == 1 and not others:
403            lock = ["--output-lock-file", str(locked.pop() / "flake.lock")]
404        else:
405            lock = ["--no-write-lock-file"]
406        words[command.end : command.end] = lock
407    return words, {}
408
409
410# ---- dev shells ----
411
412
413def _shell_files(flake: Flake) -> list[str]:
414    """The checkout's SHELL_FILES, relative to its root: what jj or git
415    tracks, or a walk outside version control."""
416    if flake.vcs == "jj":
417        fileset = " | ".join(f'glob:"**/{pattern}"' for pattern in SHELL_FILES)
418        output = _step(
419            ["jj", "file", "list", "--ignore-working-copy", fileset],
420            flake.root,
421            "jj file list",
422        )
423        return output.splitlines()
424    if flake.vcs == "git":
425        specs = [f":(glob)**/{pattern}" for pattern in SHELL_FILES]
426        output = _step(
427            ["git", "ls-files", "-z", "--", *specs], flake.root, "git ls-files"
428        )
429        return [path for path in output.split("\0") if path]
430    found = []
431    for directory, names, entries in os.walk(flake.root):
432        names[:] = [n for n in names if not n.startswith(".") and n not in UNWALKED]
433        found += [
434            os.path.relpath(os.path.join(directory, entry), flake.root)
435            for entry in entries
436            if any(fnmatch.fnmatch(entry, pattern) for pattern in SHELL_FILES)
437        ]
438    return found
439
440
441def _key(flake: Flake, attribute: str) -> str:
442    """Names the shell by what makes it: the system, the attribute, where the
443    flake sits in its checkout, and the content of every shell file."""
444    digest = hashlib.sha256()
445    relative = flake.directory.relative_to(flake.root).as_posix()
446    for part in (system(), attribute, relative):
447        digest.update(part.encode() + b"\0")
448    for path in sorted(_shell_files(flake)):
449        try:
450            content = (flake.root / path).read_bytes()
451        except OSError:
452            continue  # tracked, but deleted in the working copy
453        digest.update(path.encode() + b"\0" + hashlib.sha256(content).digest())
454    return digest.hexdigest()[:32]
455
456
457# one `export NAME` of a dev shell script, and one of its `nix_saved_NAME`
458EXPORT = re.compile(r"^export ([A-Za-z_][A-Za-z0-9_]*)(?:[ \t]*[=;]|[ \t]*$)", re.M)
459SAVED = re.compile(r"^nix_saved_([A-Za-z_][A-Za-z0-9_]*)=", re.M)
460
461
462def exported(script: str) -> frozenset[str]:
463    """The variables a dev shell gives every program in it: what its script
464    exports at the top level (the function bodies do not run), minus the
465    search paths it composes from the caller's own, saved as
466    `nix_saved_<name>`. `nix develop` exports these whatever the caller's
467    environment held, so a cached shell carries them all."""
468    return frozenset(EXPORT.findall(script)) - frozenset(SAVED.findall(script))
469
470
471def delta(
472    before: dict[str, str],
473    after: dict[str, str],
474    provided: frozenset[str] = frozenset(),
475) -> Change:
476    """What a dev shell did to an environment: variables it set, prefixes it
477    put before a list it kept (PATH), and variables it unset. `provided` is
478    what the shell gives every program (exported()); those stay in even where
479    the capture's own environment already held the same value, which is how a
480    cached shell came to drop mkShell's env.* values."""
481    changed: dict[str, str] = {}
482    prepended: dict[str, str] = {}
483    for name, value in after.items():
484        old = before.get(name)
485        if name in VOLATILE or (old == value and name not in provided):
486            continue
487        if old and value.endswith(":" + old):
488            prepended[name] = value[: -len(old) - 1]
489        else:
490            changed[name] = value
491    unset = [
492        n for n in before if n not in after and n not in VOLATILE and n.isidentifier()
493    ]
494    return {"set": changed, "prepend": prepended, "unset": unset}
495
496
497def applied(change: Change, base: dict[str, str]) -> dict[str, str]:
498    """`base` as the dev shell leaves it."""
499    unset = set(change.get("unset", ()))
500    environment = {name: value for name, value in base.items() if name not in unset}
501    environment.update(change.get("set", {}))
502    for name, prefix in change.get("prepend", {}).items():
503        environment[name] = (
504            f"{prefix}:{environment[name]}" if environment.get(name) else prefix
505        )
506    return environment
507
508
509def capture(nix: str, arguments: list[str], profile: Path, cwd: Path | None) -> Change:
510    """Build the dev shell, source it with its shellHook, and keep what it
511    changed. The profile is a garbage-collector root for the shell. nix's
512    progress and the hook's output go to stderr."""
513    profile.parent.mkdir(parents=True, exist_ok=True)
514    with tempfile.TemporaryDirectory(prefix="albedo-nix-") as scratch:
515        script = Path(scratch, "rc")
516        with script.open("w") as out:
517            built = subprocess.run(
518                [
519                    nix,
520                    "print-dev-env",
521                    *FEATURES,
522                    "--profile",
523                    str(profile),
524                    *arguments,
525                ],
526                cwd=cwd,
527                stdout=out,
528            )
529        if built.returncode != 0:
530            raise NixError(f"nix print-dev-env failed (exit {built.returncode})")
531        provided = exported(script.read_text())
532        variables = json.loads(Path(os.path.realpath(profile)).read_text()).get(
533            "variables", {}
534        )
535        bash = variables.get("shell", {}).get("value", "")
536        if os.path.basename(bash) != "bash":
537            bash = shutil.which("bash") or "bash"  # the script needs bash 4 or later
538        sourced = subprocess.run(
539            [
540                bash,
541                "--noprofile",
542                "--norc",
543                "-c",
544                'exec 3>&1 1>&2; . "$1"; rm -rf "$NIX_BUILD_TOP"; env -0 >&3',
545                "bash",
546                str(script),
547            ],
548            cwd=cwd,
549            stdout=subprocess.PIPE,
550            timeout=STEP_TIMEOUT,
551        )
552        if sourced.returncode != 0:
553            raise NixError(
554                f"the dev shell's shellHook failed (exit {sourced.returncode})"
555            )
556    after = dict(
557        item.split("=", 1)
558        for item in sourced.stdout.decode("utf-8", "surrogateescape").split("\0")
559        if "=" in item
560    )
561    change = delta(dict(os.environ), after, provided)
562    wrapper = sorted((n, v) for n, v in after.items() if n.startswith(WRAPPER))
563    if wrapper:  # the shell's compiler-wrapper flags, for the cargo shim
564        digest = hashlib.sha256(json.dumps(wrapper).encode()).hexdigest()[:16]
565        change["set"]["ALBEDO_NIX_ENV"] = digest
566    return change
567
568
569def _cache() -> Path:
570    return _home() / "cache" / "nix-develop"
571
572
573def _cached(key: str, ttl: float | None) -> dict[str, Any] | None:
574    entry = _cache() / f"{key}.json"
575    try:
576        record = json.loads(entry.read_text())
577    except (OSError, ValueError):
578        return None
579    if record.get("format") != FORMAT:
580        return None
581    profile = _cache() / record.get("profile", key)
582    stale = ttl is not None and time.time() - float(record.get("created", 0)) > ttl
583    if stale or not os.path.exists(profile):  # collected since, or never built
584        return None
585    os.utime(entry)
586    return record
587
588
589def _keep(key: str, record: dict[str, Any]) -> None:
590    """Store a shell, then drop the least recently used past CACHE_LIMIT."""
591    cache = _cache()
592    temporary = cache / f".{key}.{os.getpid()}"
593    temporary.write_text(json.dumps({**record, "created": time.time()}))
594    temporary.replace(cache / f"{key}.json")
595    entries = sorted(cache.glob("*.json"), key=lambda path: path.stat().st_mtime)
596    for old in entries[:-CACHE_LIMIT]:
597        profile = cache / old.stem
598        for path in [
599            old,
600            profile,
601            profile.with_suffix(".lock"),
602            *cache.glob(f"{old.stem}-*-link"),
603        ]:
604            path.unlink(missing_ok=True)
605
606
607def dev_shell(nix: str, installable: str, cwd: Path) -> Change | None:
608    """The cached dev shell for `installable`, built first if it is not
609    cached; None for a local installable with no flake.nix. Two shims asking
610    for the same shell at once share one build."""
611    base, hash_, attribute = installable.partition("#")
612    if _local(base):
613        flake = locate((cwd / base.removeprefix("path:")).resolve())
614        if flake is None:
615            return None
616        key, ttl, where = _key(flake, attribute), None, flake.directory
617    else:  # nixpkgs#hello, github:owner/repo: nothing to copy
618        flake, ttl, where = None, REMOTE_TTL, None
619        key = hashlib.sha256(f"{system()}\0{installable}".encode()).hexdigest()[:32]
620    record = _cached(key, ttl)
621    if record is not None:
622        return record["delta"]
623    _cache().mkdir(parents=True, exist_ok=True)
624    with (_cache() / f"{key}.lock").open("w") as lock:
625        fcntl.flock(lock, fcntl.LOCK_EX)
626        record = _cached(key, ttl)  # built while this one waited
627        if record is None:
628            arguments = [installable]
629            if flake is not None:
630                ref = flake_ref(flake)
631                arguments = [ref + hash_ + attribute]
632                if flake.copied:
633                    arguments += [
634                        "--output-lock-file",
635                        str(flake.directory / "flake.lock"),
636                    ]
637            print(
638                f"{NOTE} building the dev shell for {where or installable}; "
639                "later runs reuse it until its nix files change",
640                file=sys.stderr,
641            )
642            change = capture(nix, arguments, _cache() / key, where)
643            record = {
644                "ref": arguments[0],
645                "delta": change,
646                "profile": key,
647                "format": FORMAT,
648            }
649            _keep(key, record)
650            # nix may have written the lock file it locked, a shell file too
651            if flake is not None and (written := _key(flake, attribute)) != key:
652                _keep(written, record)
653            return change
654    return record["delta"]
655
656
657# ---- the shim ----
658
659
660def develop(nix: str, here: str, command: Command, cwd: Path) -> None:
661    """Run a `nix develop ... -c program` in its cached dev shell; return
662    when a flag asks for more than the shell's environment."""
663    if command.path != ["develop"] or not command.program:
664        return
665    if set(command.flags) - QUIET - {"command"}:
666        return
667    named = [command.words[index] for index in command.positional]
668    change = dev_shell(nix, named[0] if named else ".", cwd)
669    if change is None:
670        return
671    environment = applied(change, dict(os.environ))
672    # the shims stay first, ahead of the shell's own programs
673    shims = [
674        entry
675        for entry in os.environ.get("PATH", "").split(os.pathsep)
676        if Path(entry).parent == Path(here).parent
677    ]
678    rest = environment.get("PATH", "").split(os.pathsep)
679    environment["PATH"] = os.pathsep.join(
680        [*shims, *(entry for entry in rest if entry not in shims)]
681    )
682    found = shutil.which(command.program[0], path=environment["PATH"])
683    if found is None:
684        sys.exit(f"{NOTE} {command.program[0]}: not found in the dev shell")
685    os.execve(found, command.program, environment)
686
687
688def main(argv: list[str]) -> NoReturn:
689    here, words = argv[1], argv[2:]
690    path = os.environ.get("PATH", "").split(os.pathsep)
691    nix = shutil.which("nix", path=os.pathsep.join(e for e in path if e != here))
692    if nix is None:
693        sys.exit(f"{NOTE} nix is not on PATH")
694    cwd = Path.cwd()
695    try:
696        table = flag_table(nix)
697    except NixError as error:
698        flake = locate(cwd)
699        if flake is not None and flake.copied:
700            sys.exit(f"{NOTE} {error}\n{NOTE} {REFUSED}")
701        os.execv(nix, [nix, *words])
702    try:
703        command = read(words, table)
704    except Unreadable:
705        os.execv(nix, [nix, *words])  # a flag nix does not know: nix says so
706    try:
707        develop(nix, here, command, cwd)
708        result = rewritten(command, cwd)
709    except NixError as error:
710        sys.exit(f"{NOTE} {error}")
711    if result is None:
712        os.execv(nix, [nix, *words])
713    words, extra = result
714    print(
715        f"{NOTE} reading this jj workspace from its commit, not copying it: "
716        f"nix {' '.join(words)}",
717        file=sys.stderr,
718    )
719    os.execve(nix, [nix, *words], {**os.environ, **extra})
720
721
722if __name__ == "__main__":
723    main(sys.argv)