diff --git a/adopt-project.py b/adopt-project.py index 556dbc53..ed9c8b1d 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -692,8 +692,9 @@ def _pin_files(plan: "Plan") -> list[tuple[str, str | None]]: def _kept_pin_findings(plan: "Plan", tree: list, - named_links: frozenset | set = frozenset() - ) -> list[str]: + named_links: frozenset | set = frozenset(), + source: "Source | None" = None, + probe: "_SymlinkProbe | None" = None) -> list[str]: """A FINDING for each pin file a path the plan keeps in the root already holds: a declared pin's (#273), or the shape's own pin (#524). The materializer writes the pin beside such a path, under `COLLISION_DIR`, @@ -720,36 +721,116 @@ def _kept_pin_findings(plan: "Plan", tree: list, in the plan makes a link no clone makes. It is still a link found there, so the disk that ignores case is not asked about that file, which keeps `_links_at_a_pin` reading the same disk. One link, one - finding.""" + finding. + + THROUGH THE SYMLINKS THE PLAN KEEPS ABOVE A PIN'S FILE (#904, L1-K2 of + #457), as the root walk follows the shape's own paths. With `contracts + -> governance` and `governance/` kept, a `governance/openglass-pin.yaml` + of the source's passed `check`, for nothing is kept at the literal path + `contracts/openglass-pin.yaml`; `execute` wrote the pin beside it and + said `adoption verified`, and the root's first bootstrap read the + source's file there (`yaml-unparsable`, or `neutral-pin-unreadable` for + a link to `README.md`). Where `source` is given, so the links can be + read, the pin's file is asked where its walk reaches it + (`_pin_file_reached`). A walk the links stop is the root walk's to + name, as `plan-root-symlink`, and is not asked here: so is one that + stops at a link `named_links` holds, whose walk the root walk does not + name either (`walk.empty`), and which `plan-empty-symlink` or + `plan-bad-symlink-target` names.""" kept = _kept_in_root(plan.entries, tree) if not kept: return [] - disks = ((_KeptTree(kept, fold=False), ""), - (_KeptTree(kept, fold=True), _CASE_ASIDE)) + walks = _pin_walks(plan, source, tree, probe) + disks = ((_KeptTree(kept, fold=False), "", walks[0]), + (_KeptTree(kept, fold=True), _CASE_ASIDE, walks[1])) out = [] for rel, product in _pin_files(plan): - for disk, aside in disks: - at = disk.at(rel) - rows = [row for row in ([at] if at else disk.under(rel)) - if row[1] != GITLINK_MODE] - if not rows: - continue - path, mode, entry = rows[0] - if at and path in named_links: + for disk, aside, walked in disks: + said = _kept_pin_finding(disk, aside, walked.get(rel), rel, + product, named_links) + if said is not None: + out.extend(said) break - under = "" if entry == path else f" under the entry {_spelled(entry)}" - where = "at" if at else "inside" - written = (_declared_pin_held(product) if product - else _shape_pin_held(mode if at else None)) - out.append( - f"FINDING {ROOT_HOLDS_PIN}: the plan keeps {_kind_of(mode)} " - f"{_spelled(path)} in the assembly root{under}, {where} " - f"`{rel}`{aside}, where `execute` writes {written}: " - f"{_fix_the_kept_path(entry)}.") - break return out +def _kept_pin_finding(disk: "_KeptTree", aside: str, walk: "_Walk | None", + rel: str, product: str | None, + named_links: frozenset | set) -> list[str] | None: + """`_kept_pin_findings`' FINDING for the pin's file `rel` on `disk`, as + the one line of a list: none, `[]`, where what the root keeps there is + a symlink `named_links` holds (#979), and None where it keeps nothing + there, so the next disk is asked.""" + reached, link = _pin_file_reached(walk, rel) + at = disk.at(reached) + rows = [row for row in ([at] if at else disk.under(reached)) + if row[1] != GITLINK_MODE] + if not rows: + return None + path, mode, entry = rows[0] + if at and path in named_links: + return [] + under = "" if entry == path else f" under the entry {_spelled(entry)}" + where = "at" if at else "inside" + held = mode if at else None + written = (_declared_pin_held(product) if product + else _shape_pin_held(held)) + through, fix = _through_the_kept_link(link, reached, entry) + return [f"FINDING {ROOT_HOLDS_PIN}: the plan keeps {_kind_of(mode)} " + f"{_spelled(path)} in the assembly root{under}, {where} " + f"`{rel}`{through}{aside}, where `execute` writes {written}: " + f"{fix}."] + + +def _through_the_kept_link(link: tuple[str, str] | None, reached: str, + entry: str) -> tuple[str, str]: + """How `plan-root-holds-pin` says the root walk reached the pin's file + at `reached` (#904): through `link`, the first symlink the plan keeps + above it, and its fix, to send that link to a leg, drop it, or rename + the file; and, with no link, no words, and the fix of the kept `entry` + (`_fix_the_kept_path`).""" + if link is None: + return "", _fix_the_kept_path(entry) + return (f" through the symlink {_spelled(link[0])} to " + f"{_spelled(link[1])}", + f"send {_spelled(link[0])} to a leg or `drop` it, or rename " + f"{_spelled(reached)} in the source and re-run `plan`") + + +def _pin_walks(plan: "Plan", source: "Source | None", tree: list, + probe: "_SymlinkProbe | None") -> tuple[dict, dict]: + """Each path the root walk follows, `{path: its walk}`, on a disk that + keeps case and on one that ignores it, for `_kept_pin_findings`: none + where there is no `source` to read the links from, or no link to read. + `probe` is `_entry_findings`' one probe, so git is asked once.""" + if source is None or not any(mode == SYMLINK_MODE + for _, mode, _, _ in tree): + return {}, {} + if probe is None: + probe = _SymlinkProbe(source.work_root) + out = [] + for fold in (False, True): + walked: dict = {} + for walk in _PlannedRoot(plan, source, tree, probe=probe, + fold=fold).walks(): + walked.setdefault(walk.path, walk) + out.append(walked) + return out[0], out[1] + + +def _pin_file_reached(walk: "_Walk | None", rel: str + ) -> tuple[str, tuple[str, str] | None]: + """Where the pin's file `rel` is, as `open()` reaches it through the + symlinks the plan keeps above it (`_Walk.reached`), and the first of + them; `rel` itself, and None, where no walk was made, the walk meets + no symlink above `rel`, or it stops: the root walk names a link that + stops it (`plan-root-symlink`), and the pin's file is then asked at its + own path, as before.""" + if walk is None or walk.problem is not None or walk.reached == rel: + return rel, None + return walk.reached, walk.links[0] + + def _declared_pin_held(product: str) -> str: """What `plan-root-holds-pin` says of a declared pin's file (#273).""" return (f"the pin the plan declares on `{product}`. The shape's pin would " @@ -773,15 +854,19 @@ def _shape_pin_held(mode: str | None) -> str: def _pin_findings(plan: "Plan", tree: list, - named_links: frozenset | set = frozenset()) -> list[str]: + named_links: frozenset | set = frozenset(), + source: "Source | None" = None, + probe: "_SymlinkProbe | None" = None) -> list[str]: """Every FINDING about the plan's pins: each entry `execute` cannot write a pin file from, two pins at one file or one at a file the shape writes, two whose products one `SHAPE_PIN_SOURCE_` names (#275), and a path the root keeps where a pin is written, the shape's own among them (#524), but for a symlink there that `named_links` - holds (#979).""" + holds (#979), and through the symlinks it keeps above it where `source` + is given (#904).""" return (_read_pins(plan)[1] - + _kept_pin_findings(plan, tree, named_links)) + + _kept_pin_findings(plan, tree, named_links, source=source, + probe=probe)) # --------------------------------------------------------------------------- @@ -6810,7 +6895,9 @@ def _coverage_findings(entry_paths: list[str], tree_paths: list[str]) -> list[st def _leg_findings(plan: Plan, tree: list | None, - named_links: frozenset | set = frozenset()) -> list[str]: + named_links: frozenset | set = frozenset(), + source: Source | None = None, + probe: "_SymlinkProbe | None" = None) -> list[str]: """`leg:` is answered with one of the four words, each leg's mount path is canonical (#169), and neither overlaps a path the assembly root holds (#172); and the root keeps nothing where the shape writes (#262, #263). @@ -6819,7 +6906,9 @@ def _leg_findings(plan: Plan, tree: list | None, one kept AT `project.yaml` is theirs, and `plan-root-blocks-manifest` leaves it; so is one kept AT the copy under `shape/` of a path the root holds, which `plan-root-blocks-shape-copy` leaves, and one AT a pin's - file, which `plan-root-holds-pin` leaves (#979). + file, which `plan-root-holds-pin` leaves (#979). `source` and `probe` + are `_entry_findings`': with them, a pin's file is asked through the + symlinks the plan keeps above it (#904). The path findings live HERE, and not in `cmd_check`, so that whatever reads this function reports a bad path from the one place that knows what @@ -6878,7 +6967,8 @@ def _leg_findings(plan: Plan, tree: list | None, # Each finding about an entry names the `plan` that records the pin # whole. A leg mounted over a pin's file is # `plan-leg-path-collides`, above, with the shape's own paths. - findings += _pin_findings(plan, tree, named_links) + findings += _pin_findings(plan, tree, named_links, source=source, + probe=probe) return findings @@ -6950,10 +7040,12 @@ def _leg_findings(plan: Plan, tree: list | None, # holds nothing but submodules) is `plan-submodule-root`'s too and is not said # again: it is not written over -- in a clone a submodule is an empty # directory, and `place` stops at it. A symlink AT the copy is found here, -# and as a write THROUGH it, because nothing else on main says it; but one -# whose target `plan-empty-symlink` or `plan-bad-symlink-target` names is -# those findings' alone, as at the manifest (#979). They do not say where -# it sits: given a target git makes, and still kept there, it is this +# because nothing else on main says it, and `place` stops at it too: +# `copy_in_the_way` counts a link there, dangling or not, as something +# already there, and writes nothing through it (#904, L1-K9 of #457). But +# one whose target `plan-empty-symlink` or `plan-bad-symlink-target` names +# is those findings' alone, as at the manifest (#979). They do not say +# where it sits: given a target git makes, and still kept there, it is this # finding's again. # # A SYMLINK AT A SHAPE PATH counts as the root holding it, though @@ -7143,13 +7235,20 @@ def _blocked_sentence(code: str, rels: list[str], copies: list[str], "would stop writing the shape's files after both leg " f"repositories were created and pushed: {fix}.") relation, aside = _overlap(copies[0], path) + # WHAT THE MATERIALIZER DOES, and when (#904, L1-K9 of #457). The + # sentence said `execute` "would write the copy through it after both + # leg repositories were created and pushed", and neither half held: + # `execute` refuses the plan before any leg exists, and `place` asks + # `copy_in_the_way`, which stops at a symlink there, dangling or not, as + # at a file, and writes nothing there: the copies `place` writes before + # it, in its order, are written. consequence = { - "is": "`execute` would write the copy " + "is": "the materializer writes no copy " f"{'through' if mode == SYMLINK_MODE else 'over'} {them}", - "contains": "`execute` cannot write a file where a directory is, " - "so it would stop", - "is inside": "`execute` cannot make a directory where a file is, " - "so it would stop"}[relation] + "contains": "the materializer cannot write a file where a directory " + "is", + "is inside": "the materializer cannot make a directory where a file " + "is"}[relation] return ( f"the shape's {'copies' if many else 'copy'} of " f"{_first_and_more(rels)} {'are' if many else 'is'} written to " @@ -7158,8 +7257,10 @@ def _blocked_sentence(code: str, rels: list[str], copies: list[str], f"{'Those paths' if many else 'That path'} " f"{_PLURAL_RELATION[relation] if many else relation} " f"{_kind_of(mode)} {named}{aside}. The plan keeps {them} in the " - f"assembly root{under}, and {consequence} after both leg " - f"repositories were created and pushed. {SHAPE_DIR_RESERVED}: {fix}.") + f"assembly root{under}, and {consequence}: it would stop there " + "(`materialize-copy-blocked`), writing nothing there, after both " + "leg repositories were created and pushed, so `execute` refuses " + f"this plan before either exists. {SHAPE_DIR_RESERVED}: {fix}.") def _root_shape_findings(plan: Plan, tree: list, @@ -9364,13 +9465,19 @@ def _entry_findings(plan: Plan, source: Source, refused, unasked = _paths_git_refuses(source, tree), None except _Unasked as exc: refused, unasked = [], exc + # One answer to whether git checks a symlink out as one, and to how it + # keeps the clone's refs, for every question whose walks may need it on + # any source (#654, #910): made before `_leg_findings`, whose walks of + # the pins' files ask it too (#904). + probe = _SymlinkProbe(source.work_root) unsafe, unsafe_remediation = _source_path_findings(plan, tree, targets, refused) findings = (unsafe + _coverage_findings([str(e.get("path")) for e in plan.entries], [path for path, _, _, _ in tree]) - + _leg_findings(plan, tree, _named_symlinks(tree, targets))) + + _leg_findings(plan, tree, _named_symlinks(tree, targets), + source=source, probe=probe)) # Beside the pins' other findings, which `_leg_findings` lists last: a # digest the clone `plan` read it from recomputes otherwise (#449). digest_findings, pin_notes = _pin_digest_findings(plan) @@ -9395,9 +9502,6 @@ def _entry_findings(plan: Plan, source: Source, plan, () if tracking is None or _a_branch_execute_writes(source, TRACKING_KEY, str(tracking)) else (MANIFEST_FIELDS[TRACKING_KEY],)) - # One answer to whether git checks a symlink out as one, for every - # question whose walks may need it on any source (#654, #910). - probe = _SymlinkProbe(source.work_root) linked = _root_symlink_findings(plan, source, tree, probe) asked, unasked_notes = ( unasked.said() if unasked is not None @@ -9976,7 +10080,9 @@ def _print_what_will_happen(plan: Plan, source: Source, names: dict, # target as it stands, and a climb above the root from the work directory, # where `execute` makes the assembly's clone. Each says what is true where it # runs, and `execute` asks before any leg exists. Whether `git` checks a -# symlink out as a symlink at all is asked the same way. Where it does not +# symlink out as a symlink at all is asked the same way, and so is how it +# keeps the clone's refs, which decides what the clone's own `.git` holds +# (`_clone_ref_store`, #904). Where it does not # (`core.symlinks` false, Git for Windows' default), a link AT a path the # shape writes is a plain file in the clone, which `place` finds there and # writes the copy beside, so nothing about where that link leads is refused @@ -10017,34 +10123,204 @@ def _print_what_will_happen(plan: Plan, source: Source, names: dict, #: What a symlink with an absolute target leads to. ON_THIS_MACHINE = ("an absolute path: a place on this machine, not in the " "assembly") +#: Why a symlink ABOVE a path the shape writes is not followed into the +#: clone's own `.git` (D9 of #183's re-verify): the shape would write its +#: files there, where no commit holds them. +NOT_INTO_GIT = ("a symlink above a path the shape writes is followed only " + "through directories the root holds, never into `.git`") #: The parts of a path that name no entry, and leave a walk where it is. HERE = ("", ".") -#: What the assembly's clone holds in its own `.git` when the shape is -#: written, on git's default configuration (F3 and 2b of #183's re-verify): -#: `.git` and the files git makes in every clone it checks out, and, in -#: CLONE_GIT_TREES, the directories that hold the source's objects and refs, -#: their logs, and the stores of the two legs `execute` has mounted by then, -#: each counted with whatever is under it, which the source and the plan -#: decide and this check does not read. A symlink AT a path the shape writes -#: that leads to one of them, or to anything under one of CLONE_GIT_TREES, is -#: taken to find something there, so `place` writes the shape's copy beside -#: it and keeps the link, as on `main`. Of a name under those directories -#: that is NOT there, that is false, and the link is not refused here: the -#: shape's file is written through it into `.git`, or the run stops there -#: after both legs exist, as on `main` (J1 of #183's re-verify). Nothing -#: else is in that `.git` before the split's commit: a template makes -#: `description`, `hooks` and `info`, and `execute` clones with none (#243). -#: A link that leads the shape to anything else there writes its file INTO -#: `.git`, where no commit holds it. (On a git configured otherwise -- the -#: reftable backend, `core.logAllRefUpdates=false` -- some of these are -#: missing, and a link to one is not refused here: the shape is written -#: through it, or stops there after both legs exist.) -CLONE_GIT = frozenset({".git", ".git/HEAD", ".git/config", ".git/index", - ".git/packed-refs"}) -#: `CLONE_GIT` as a disk that ignores case finds it. -CLONE_GIT_FOLDED = frozenset(path.casefold() for path in CLONE_GIT) -#: The directories of the clone's `.git` counted with all they hold. -CLONE_GIT_TREES = (".git/objects", ".git/refs", ".git/logs", ".git/modules") +#: The assembly's clone's own `.git`, by its path in the assembly root. +CLONE_GIT_DIR = ".git" +#: What git makes in the assembly's clone's own `.git` before the shape is +#: written, NAMED, where it was counted by directory (#904, L1-J1 of #457). +#: Measured with git 2.43.0 and 2.47.1 on the clone `execute` makes (`git +#: clone --template=`, `git checkout -B `, then a `git +#: submodule add` for each leg): `.git` and its `HEAD`, `config` and +#: `index`; the object store with its `info` and `pack`; `refs`; and +#: `modules`, holding a store for each leg at `modules/`. A +#: template makes `description`, `hooks` and `info`, and `execute` clones +#: with none (#243). A symlink AT a path the shape writes that leads to one +#: of these finds something there, so `place` writes the shape's copy beside +#: it and keeps the link, as on `main` (F3 and 2b of #183's re-verify). +#: +#: `.git/objects`, `.git/refs`, `.git/logs` and `.git/modules` were counted +#: with whatever might be under them, which held for a name that is there +#: and not for one that is not: `CLAUDE.md -> .git/refs/remotes/origin/main` +#: checked ok, and `execute` wrote the shape's `CLAUDE.md` into that file of +#: the clone's `.git`, where no commit holds it, and said `adoption +#: verified`; `-> .git/objects/zz/nothing` stopped at the materializer after +#: both legs were pushed. A name `_CloneGit` does not hold is now nowhere, +#: and a link that leads the shape to it is refused before any leg exists. +CLONE_GIT_DIRS = (".git", ".git/objects", ".git/objects/info", + ".git/objects/pack", ".git/refs", ".git/modules") +CLONE_GIT_FILES = (".git/HEAD", ".git/config", ".git/index") +#: Where git's own files backend, its default, keeps the refs. `git clone` +#: writes every ref in `packed-refs` but three it writes loose under `refs`: +#: the branch it checks out, which is the source's HEAD branch, the +#: `refs/remotes/origin/HEAD` that names that branch, and then the split +#: branch `execute` checks out (`_CloneGit.of`). Where git writes reflogs, a +#: log of each is under `logs`, with `logs/HEAD`. +FILES_BACKEND_DIRS = (".git/refs/heads", ".git/refs/tags") +FILES_BACKEND_FILES = (".git/packed-refs",) +REFLOG_DIRS = (".git/logs",) +REFLOG_FILES = (".git/logs/HEAD",) +#: And where the reftable backend keeps them (git 2.45 and later, where +#: `init.defaultRefFormat` or `GIT_DEFAULT_REF_FORMAT` says `reftable`; +#: L1-K8 of #457, measured with git 2.47.1): a `refs/heads` that is a file, +#: and `reftable`, whose tables hold every ref and every reflog, so there is +#: no `packed-refs` and no `logs`. A link to `.git/reftable` was refused +#: where the copy goes beside it, and one to `.git/packed-refs` checked ok +#: and had the shape's file written there. +REFTABLE_DIRS = (".git/reftable",) +REFTABLE_FILES = (".git/refs/heads", ".git/reftable/tables.list") +#: What a directory of the clone's `.git` holds where `check` reads nothing +#: of what is in it: `(where, what it holds)`. +OBJECT_STORE = (".git/objects", + "the clone's object store, which holds what the source's own " + "store holds when `execute` clones it and the objects " + "`execute` writes before the shape") +REFTABLES = (".git/reftable", + "where the reftable backend keeps the clone's refs, in tables " + "git names as it writes them") +#: And the refs of the branch `git clone` checks out, where the source's +#: HEAD is on no branch: what it writes in each directory of them, and in +#: `logs/` the reflog of that (`_guessed_refs`). git guesses the branch, +#: and where it can guess none it checks out none and writes neither. +GUESSED_BRANCH = "which git guesses where the source's HEAD is on no branch" +GUESSED_REFS = {"refs/heads": "the branch it checks out", + "refs/remotes": "`origin/HEAD` for the branch it checks out"} + + +class _CloneGit: + """What the assembly's clone holds in its own `.git` when the shape is + written, as a disk that keeps case, or one that ignores it, finds it + (#904): each name git makes there, as a directory or a file, and the + directories `check` reads nothing in, each with what it holds. + + A NAME IN ONE OF THOSE that is not named is neither there nor not there: + the source's objects, a leg's store, which `git submodule add` makes + from the leg after this check, and the reftable backend's tables. + `holds` and `is_dir` answer None of it, and the walk refuses a link that + leads the shape there, saying why: whether `place` would write the + shape's copy beside it or the shape's file into `.git` cannot be told + before any leg exists.""" + + def __init__(self, fold: bool) -> None: + self.fold = fold + self._dirs: set[str] = set() + self._files: set[str] = set() + self._unread: dict[str, tuple[str, str]] = {} + + @classmethod + def of(cls, plan: Plan, source: Source, legs: dict[str, str], + probe: "_SymlinkProbe", fold: bool) -> "_CloneGit": + """What the clone of `source` that `execute` makes for `plan` holds, + each leg's store at its mount (`legs`), with the refs as git keeps + them where `probe`'s work directory is (`ref_store`).""" + reftable, reflogs = probe.ref_store() + git = cls(fold) + git._name(CLONE_GIT_DIRS, CLONE_GIT_FILES) + git._unread_in(*OBJECT_STORE) + for mount, role in legs.items(): + store = f"{CLONE_GIT_DIR}/modules/{mount}" + git._add(store, directory=True) + git._unread_in(store, ( + f"the store of the {role} leg, which `git submodule add` " + "makes from that leg, with this machine's git template, " + "after this check")) + if reftable: + git._name(REFTABLE_DIRS, REFTABLE_FILES) + git._unread_in(*REFTABLES) + return git + git._name(FILES_BACKEND_DIRS, FILES_BACKEND_FILES) + split = _split_branch_of(plan) + refs = [f"{BRANCH_REFS}{split}"] if split else [] + head = _cloned_branch(source) + guessed = [] if head else ["refs/heads", "refs/remotes"] + if head: + refs += [f"{BRANCH_REFS}{head}", ORIGIN_HEAD] + if reflogs: + git._name(REFLOG_DIRS, REFLOG_FILES) + refs += [f"logs/{ref}" for ref in refs] + guessed += [f"logs/{top}" for top in guessed] + for ref in refs: + git._add(f"{CLONE_GIT_DIR}/{ref}", directory=False) + for top in guessed: + git._unread_in(f"{CLONE_GIT_DIR}/{top}", _guessed_refs(top)) + return git + + def _key(self, path: str) -> str: + return path.casefold() if self.fold else path + + def _name(self, dirs: tuple, files: tuple) -> None: + for path in dirs: + self._add(path, directory=True) + for path in files: + self._add(path, directory=False) + + def _add(self, path: str, *, directory: bool) -> None: + """`path`, and each directory above it.""" + parts = path.split("/") + self._dirs.update(self._key("/".join(parts[:end])) + for end in range(1, len(parts))) + (self._dirs if directory else self._files).add(self._key(path)) + + def _unread_in(self, top: str, holds: str) -> None: + self._unread[self._key(top)] = (top, holds) + + def unread(self, path: str) -> tuple[str, str] | None: + """`(the directory, what it holds)` where `path` is in one `check` + reads nothing in and is no name git makes there, else None.""" + key = self._key(path) + if key in self._dirs or key in self._files: + return None + return next((found for top, found in self._unread.items() + if f"{key}/".startswith(f"{top}/")), None) + + def holds(self, path: str) -> bool | None: + """Whether something is at `path` in the clone's `.git`; None where + that cannot be told (`unread`).""" + key = self._key(path) + if key in self._dirs or key in self._files: + return True + return None if self.unread(path) else False + + def is_dir(self, path: str) -> bool | None: + """Whether `path` is a directory in the clone's `.git`; None where + that cannot be told (`unread`).""" + if self._key(path) in self._dirs: + return True + return None if self.unread(path) else False + + +def _guessed_refs(top: str) -> str: + """What `_CloneGit` says `.git/` holds where the source's HEAD is + on no branch (#904): `refs/heads` the branch `git clone` checks out, + `refs/remotes` its `origin/HEAD`, and the two in `logs/` their reflogs. + """ + refs = top[len("logs/"):] if top.startswith("logs/") else top + what = (GUESSED_REFS[refs] if refs == top + else f"the reflog of {GUESSED_REFS[refs]}") + return f"where `git clone` writes {what}, {GUESSED_BRANCH}" + + +def _split_branch_of(plan: Plan) -> str | None: + """The split branch `execute` checks out in the assembly's clone, as + `_check_split_branch` reads it; None where `execute` will not hand git + the name, which `check` names as its own finding.""" + if _branch_name_refusal(plan, *SPLIT_BRANCH_KEY) is not None: + return None + key, default, _ = SPLIT_BRANCH_KEY + return checked_value(key, plan.get(key, default)) + + +def _cloned_branch(source: Source) -> str | None: + """The branch `git clone` checks out in a clone of `source`: the one + its HEAD is on. None where HEAD is on no branch the source holds, so + that git guesses one, or checks none out.""" + head = _branch_under(_symbolic_ref(source.path, "HEAD"), BRANCH_REFS) + return head if head in _branch_names(source.path, BRANCH_REFS) else None def _absolute_here(target: str) -> bool: @@ -10063,12 +10339,15 @@ class _Walk: writes the shape's copy beside it under `shape/`; the symlink it met that git does not make as the blob holds it, if any, whose target is empty (#303, #388), holds a NUL byte, or is longer than Linux takes (#908), - and which a finding of the source names and the walk does not; and, - once a symlink has been met, what stops it, or None.""" + and which a finding of the source names and the walk does not; where + the path's own last name is looked for, through the symlinks met ABOVE + it (`reached`, #904); and, once a symlink has been met, what stops it, + or None.""" def __init__(self, path: str) -> None: self.path = path self.links: list[tuple[str, str]] = [] + self.reached = "" self.landed = "" self.outside = False self.taken = False @@ -10088,8 +10367,10 @@ class _PlannedRoot: not recursive. It does not hold a path the plan sends to a leg or drops: the mount's `git rm` has removed it by the time the shape is written. A symlink AT a path the shape writes also finds what the clone's own - `.git` holds by then (`CLONE_GIT`, and `CLONE_GIT_TREES` counted whole), - and nothing else in `.git`. Where `git` checks a symlink out as a plain + `.git` holds by then (`_CloneGit`, each name git makes there, #904), and + nothing else in `.git`, and its `..` steps back out of a directory git + makes there; a symlink ABOVE one is not followed into `.git` at all + (D9). Where `git` checks a symlink out as a plain file, a symlink AT a path the shape writes is that file, wherever it leads: `probe` asks this command's work directory, and only for a link a walk cannot follow (`_a_file_here`). The walk itself still follows a @@ -10119,7 +10400,9 @@ def __init__(self, plan: Plan, source: Source, tree: list, *, self.source = source self.probe = probe self.fold = fold + self._plan = plan self._tree = tree + self._git_holds: _CloneGit | None = None self.entries: dict[str, tuple[str, str]] = { GITMODULES: (FILE_MODES[0], "")} self.gone: dict[str, str] = {} @@ -10253,6 +10536,9 @@ def _next(self, walk: _Walk, done: list[str], item: tuple, queue: list) -> tuple[list[str], list]: """`done` and the queue after one part of the path.""" part, origin, last, ends = item + if not origin and not queue: + # The path's own last name, where the links above it lead it. + walk.reached = "/".join([*done, part]) link = self._link("/".join([*done, part]), part) if link is not None: return done, self._through(walk, link, ends, queue) @@ -10273,16 +10559,26 @@ def _real(self, path: str) -> str | None: def _has(self, path: str) -> bool: """Whether `place` is taken to find something at `path`: in this - tree, as `Path.exists()` would say, or in the clone's own `.git`, - one of `CLONE_GIT` or anything under one of `CLONE_GIT_TREES`, which - is not read, so a name there that is missing counts too (J1). Only - this asks the clone's `.git`; `_is_dir` does not, so a link ABOVE a - path the shape writes still cannot lead it into `.git` (D9).""" - if self._real(path) is not None or any( - self._under(path, top) for top in CLONE_GIT_TREES): - return True - return (path.casefold() in CLONE_GIT_FOLDED if self.fold - else path in CLONE_GIT) + tree, as `Path.exists()` would say, or in the clone's own `.git`, a + name git makes there (`_CloneGit`), so a name there that is missing + does not count, nor one `check` reads nothing of (#904, J1). Only + this and the `..` of a link AT a path the shape writes ask the + clone's `.git`; `_is_dir` does not, so a link ABOVE a path the shape + writes still cannot lead it into `.git` (D9).""" + return self._real(path) is not None or ( + self._in_git(path) and self._git().holds(path) is True) + + def _in_git(self, path: str) -> bool: + """Whether `path` is the clone's own `.git` or in it.""" + return self._under(path, CLONE_GIT_DIR) + + def _git(self) -> _CloneGit: + """What the clone's `.git` holds, read the first time a walk is led + there, so that a plan with no link into it asks nothing of git.""" + if self._git_holds is None: + self._git_holds = _CloneGit.of(self._plan, self.source, + self.legs, self.probe, self.fold) + return self._git_holds def _is_dir(self, path: str) -> bool: real = self._real(path) @@ -10443,8 +10739,7 @@ def _step(self, walk: _Walk, done: list[str], part: str, last: bool, if part == "..": here = "/".join(done) if not self._is_dir(here): - walk.problem = (f"it passes through {_spelled(here)}, which " - "is not a directory in the assembly root") + walk.problem = self._no_directory(here, last) return done[:-1] done = [*done, part] if walk.links: @@ -10472,14 +10767,70 @@ def _arrival(self, done: list[str], link: str, instead.""" here = "/".join(done) held = self._is_dir(here) if queue else self._has(here) - return None if held else self._dangling(link, here) - - def _dangling(self, link: str, here: str) -> str: + return None if held else self._dangling(link, here, bool(queue)) + + def _no_directory(self, here: str, last: bool) -> str | None: + """Why a `..` cannot step back out of `here`, or None. In the + clone's own `.git`, a link AT a path the shape writes steps out of a + directory git makes there, as the end of its target is found there + (#904, J2: `CLAUDE.md -> .git/refs/../HEAD` was refused as though + `.git/refs` were nowhere); a link ABOVE one is never followed into + `.git` (D9).""" + if not self._in_git(here): + return (f"it passes through {_spelled(here)}, which is not a " + "directory in the assembly root") + if not last: + return (f"it passes through {_spelled(here)}, in the assembly's " + f"own `.git`, and {NOT_INTO_GIT}") + is_dir = self._git().is_dir(here) + if is_dir: + return None + if is_dir is None: + return (f"it passes through {_spelled(here)}, " + f"{self._unread(here)}, so whether it is a directory " + "cannot be told before any leg exists") + return (f"it passes through {_spelled(here)}, in the assembly's own " + "`.git`, where its clone holds no directory of that name by " + "the time the shape is written") + + def _unread(self, here: str) -> str: + """Where `here` is in a directory of the clone's `.git` that `check` + reads nothing in, and what that directory holds.""" + top, holds = self._git().unread(here) + return (f"inside {_spelled(top)}, {holds}, and `check` reads " + "nothing there") + + def _into_git(self, lead: str, here: str, above: bool) -> str: + """Why the shape cannot be written where a symlink leads it, to + `here` in the clone's own `.git` (#904): above a path the shape + writes, never; at one, the clone holds nothing there by then, or + what it holds there is not read.""" + if above: + return (f"{lead}is in the assembly's own `.git`, and " + f"{NOT_INTO_GIT}") + if self._git().unread(here): + return (f"{lead}is {self._unread(here)}, so whether `place` would " + "write the shape's copy beside it or the shape's file " + "into `.git` cannot be told before any leg exists") + then = ("the shape would write its file through the link into " + "`.git`, where no commit holds it" + if self._git().is_dir(here.rpartition("/")[0]) else + "the shape would find no directory there to write its file " + "in, and stop after both leg repositories were created and " + "pushed") + return (f"{lead}is in the assembly's own `.git`, where its clone " + f"holds nothing of that name by the time the shape is " + f"written, so {then}") + + def _dangling(self, link: str, here: str, above: bool = False) -> str: """Where the destination `here` of the symlink `link` is, instead of - in this tree: past what a leg's mount holds, in a leg, dropped, or - nowhere.""" + in this tree: in the clone's own `.git`, past what a leg's mount + holds, in a leg, dropped, or nowhere. `above`: `link` is above a + path the shape writes, which goes on beneath `here`.""" lead = (f"the symlink {_spelled(link)} leads to {_spelled(here)}, " "which ") + if self._in_git(here): + return self._into_git(lead, here, above) mount = next((mount for mount in self.legs if self._under(here, mount)), None) if mount: @@ -10740,13 +11091,45 @@ def _checks_out_symlinks(work_root: Path) -> bool: return answer.stdout.strip() != FALSE_WORD +#: How git keeps a repository's refs where nothing says otherwise: on its +#: files backend, with reflogs. +GIT_DEFAULT_REFS = (False, True) + + +def _clone_ref_store(work_root: Path) -> tuple[bool, bool]: + """`(reftable, reflogs)`: whether a repository `git` makes in + `work_root`, where `execute` makes the assembly's clone, keeps its refs + on the reftable backend, and whether it writes reflogs (#904, L1-K8). + + Asked of a throwaway repository, as `_checks_out_symlinks` asks, rather + than of the configuration: `git init` and `git clone` pick the backend + from `GIT_DEFAULT_REF_FORMAT` or `init.defaultRefFormat`, never from + the repository cloned, and git before 2.45 has no reftable backend + whatever they say; and a non-bare repository writes reflogs unless + `core.logAllRefUpdates` says `false` where `git init` reads it.""" + probe = Path(tempfile.mkdtemp(prefix="refs-probe-", dir=work_root)) + try: + run(["git", "init", "-q", str(probe)]) + reftable = (probe / CLONE_GIT_DIR / "reftable").is_dir() + answer = subprocess.run( + ["git", "config", "--type=bool", "--get", + "core.logAllRefUpdates"], + cwd=str(probe), capture_output=True, text=True, + encoding="utf-8", errors="replace", check=False) + finally: + shutil.rmtree(probe, ignore_errors=True) + return reftable, answer.stdout.strip() != FALSE_WORD + + class _SymlinkProbe: """`_checks_out_symlinks` of one command's work directory, asked the first time a walk needs the answer and then kept, so a plan that never needs it makes no probe (B-OFF of #183's re-verify). With no work directory -- a `Source` that no command opened -- or one where no repository can be made, a symlink is taken to be checked out as a - symlink.""" + symlink. And `_clone_ref_store` of the same directory, asked and kept + the same way, the first time a walk is led into the clone's own `.git` + (`ref_store`, #904).""" def __init__(self, work_root: Path | None) -> None: self.work_root = work_root @@ -10754,6 +11137,25 @@ def __init__(self, work_root: Path | None) -> None: #: Why git could not be asked, where it refused to make the #: repository (#910), for `notes`. self.unasked: str | None = None + self._refs: tuple[bool, bool] | None = None + #: And why it could not be asked how it keeps refs (#904). + self.refs_unasked: str | None = None + + def ref_store(self) -> tuple[bool, bool]: + """`(reftable, reflogs)` of a repository `git` makes there; git's + defaults where there is no work directory, or no repository can be + made in it, and where git refused to make one, as `symlinks` takes + a disk that holds symlinks there, saying why in a NOTE (`notes`).""" + if self._refs is None: + try: + self._refs = (GIT_DEFAULT_REFS if self.work_root is None + else _clone_ref_store(self.work_root)) + except OSError: + self._refs = GIT_DEFAULT_REFS + except (CommandFailed, Refusal) as exc: + self._refs = GIT_DEFAULT_REFS + self.refs_unasked = _init_refused(exc) + return self._refs def symlinks(self) -> bool: """Whether `git` checks a symlink out AS a symlink there.""" @@ -10781,22 +11183,38 @@ def symlinks(self) -> bool: # prints the finding the probe stands for, and says why in a # NOTE (`notes`, #910). self._answer = True - self.unasked = ( - f"`{' '.join(exc.args_list)}` exited {exc.code}: " - f"{_spelled(exc.output)}" - if isinstance(exc, CommandFailed) else exc.detail) + self.unasked = _init_refused(exc) return self._answer def notes(self) -> list[str]: """The NOTE `check` prints where git refused to make the repository - it asks (#910): none where it was asked, or never needed.""" - if self.unasked is None: - return [] - return [f"NOTE `check` could not ask git whether it checks a symlink " - f"out as a symlink in {_spelled(str(self.work_root))}, the " - f"work directory: {self.unasked}. It took it that git does, " - "as on a disk that holds symlinks, so a finding about a " - "symlink above says what such a disk makes of it."] + it asks (#910), and the one for the repository it asks how git keeps + refs (#904): none where it was asked, or never needed.""" + where = _spelled(str(self.work_root)) + notes = [] + if self.unasked is not None: + notes.append( + f"NOTE `check` could not ask git whether it checks a symlink " + f"out as a symlink in {where}, the work directory: " + f"{self.unasked}. It took it that git does, as on a disk " + "that holds symlinks, so a finding about a symlink above " + "says what such a disk makes of it.") + if self.refs_unasked is not None: + notes.append( + f"NOTE `check` could not ask git how it keeps the refs of a " + f"repository made in {where}, the work directory: " + f"{self.refs_unasked}. It took git's defaults, the files " + "backend with reflogs, so a finding about a symlink into the " + "clone's own `.git` says what such a clone holds.") + return notes + + +def _init_refused(exc: "CommandFailed | Refusal") -> str: + """Why `git init` of a probe's throwaway repository failed, in git's + words, as a `_SymlinkProbe` NOTE says it (#910).""" + return (f"`{' '.join(exc.args_list)}` exited {exc.code}: " + f"{_spelled(exc.output)}" + if isinstance(exc, CommandFailed) else exc.detail) def _refuse_symlinks_checked_out_as_files(plan: Plan, source: Source, diff --git a/tests/test_adopt_root_shape_paths.py b/tests/test_adopt_root_shape_paths.py index 7f6afef5..b06fd86c 100644 --- a/tests/test_adopt_root_shape_paths.py +++ b/tests/test_adopt_root_shape_paths.py @@ -33,6 +33,7 @@ from __future__ import annotations import importlib.util +import os import re import shutil import sys @@ -77,6 +78,13 @@ RESERVED = ("`shape/` is reserved for the shape's own copies of files the " "root already holds") AFTER_THE_LEGS = "after both leg repositories were created and pushed" +#: What #262's finding says the materializer does at a copy whose place is +#: taken, and when (#904, L1-K9 of #457): it stops there, writing nothing +#: there, though the copies before it are written, and only after both +#: legs exist, so `execute` refuses the plan first. +STOPS = (f"it would stop there (`{BLOCKED}`), writing nothing there, " + f"{AFTER_THE_LEGS}, so `execute` refuses this plan before either " + "exists") OWN_NOTES = "# Our shape notes\n" @@ -201,9 +209,9 @@ def test_a_file_kept_at_a_path_the_shape_writes_is_not_a_finding(adopter): "the shape's copy of `README.md` is written to `shape/README.md`, " "because the root already holds `README.md`. That path is the file " "`shape/README.md`. The plan keeps it in the assembly root under " - "the entry `shape/`, and `execute` would write the copy over it " - f"{AFTER_THE_LEGS}. {RESERVED}: send `shape/` to a leg, `drop` it, " - "or rename it in the source and re-run `plan`.", + "the entry `shape/`, and the materializer writes no copy over it: " + f"{STOPS}. {RESERVED}: send `shape/` to a leg, `drop` it, or rename " + "it in the source and re-run `plan`.", id="a-file-at-the-copy"), pytest.param( "shape/Makefile", [row("Makefile"), row("shape/Makefile/notes.md"), @@ -211,25 +219,27 @@ def test_a_file_kept_at_a_path_the_shape_writes_is_not_a_finding(adopter): "the shape's copy of `Makefile` is written to `shape/Makefile`, " "because the root already holds `Makefile`. That path contains the " "file `shape/Makefile/notes.md` and 1 more. The plan keeps them in " - "the assembly root under the entry `shape/`, and `execute` cannot " - "write a file where a directory is, so it would stop " - f"{AFTER_THE_LEGS}. {RESERVED}: send `shape/` to a leg, `drop` it, " - "or rename it in the source and re-run `plan`.", + "the assembly root under the entry `shape/`, and the materializer " + f"cannot write a file where a directory is: {STOPS}. {RESERVED}: " + "send `shape/` to a leg, `drop` it, or rename it in the source and " + "re-run `plan`.", id="a-directory-at-the-copy"), pytest.param( "shape", [row("README.md"), row("shape")], "the shape's copy of `README.md` is written to `shape/README.md`, " "because the root already holds `README.md`. That path is inside " - "the file `shape`. The plan keeps it in the assembly root, and " - "`execute` cannot make a directory where a file is, so it would " - f"stop {AFTER_THE_LEGS}. {RESERVED}: send `shape` to a leg, `drop` " - "it, or rename it in the source and re-run `plan`.", + "the file `shape`. The plan keeps it in the assembly root, and the " + "materializer cannot make a directory where a file is: " + f"{STOPS}. {RESERVED}: send `shape` to a leg, `drop` it, or rename " + "it in the source and re-run `plan`.", id="a-file-on-the-way-to-the-copy"), ]) def test_a_kept_path_where_the_shape_writes_a_copy_is_a_finding( adopter, kept, tree, said): - """#262's three cases, in the words of the finding: the copy would be - written over a file, onto a directory, or beneath a file.""" + """#262's three cases, in the words of the finding: the copy's place is + a file, a directory, or beneath a file, and the materializer, which + writes no copy over what is there, stops at each and writes nothing + (#904, L1-K9 of #457).""" entry = kept.split("/")[0] + ("/" if "/" in kept else "") assert found(adopter, [(entry, "root"), ("README.md", "root"), ("Makefile", "root")], tree) == [ @@ -328,16 +338,21 @@ def test_a_symlink_or_submodule_is_not_a_file_where_a_directory_is( def test_a_symlink_at_the_copy_itself_is_in_the_way(adopter): - """`place` writes the copy THROUGH a link at its path, onto whatever the - link leads to: something is AT the path, whatever it is. Nothing on main - finds a link there, so it is said here, as a write through it and not - over it.""" + """Something is AT the copy's path, whatever the link leads to, so it is + in the way. Nothing on main finds a link there, so it is said here, and + in what the materializer does: `place` asks `copy_in_the_way`, which + counts a link there, dangling or not, as already there, stops, and + writes nothing through it (#904, L1-K9 of #457). The finding said + "`execute` would write the copy through it after both leg repositories + were created and pushed", and neither half held: `execute` refuses the + plan before any leg exists, and the materializer writes no copy through + a link (`test_the_materializer_stops_at_a_link_at_the_copy`).""" said, = found(adopter, [("shape/", "root"), ("README.md", "root")], [row("README.md"), row("shape/README.md", "120000")]) assert "That path is the symlink `shape/README.md`." in said assert said.startswith(f"FINDING {COPY_IN_THE_WAY}: ") - assert "`execute` would write the copy through it " in said - assert "over it" not in said + assert f"the materializer writes no copy through it: {STOPS}. " in said + assert "would write the copy" not in said @pytest.mark.parametrize("rows", [ @@ -564,7 +579,7 @@ def test_the_finding_says_the_root_holds_the_path_only_once_case_is_ignored( "written to `shape/README.md`, because the root already holds " f"`README.md`{CASE_ASIDE}. That path is the file `shape/README.md`. " "The plan keeps it in the assembly root under the entry `shape/`, " - f"and `execute` would write the copy over it {AFTER_THE_LEGS}. " + f"and the materializer writes no copy over it: {STOPS}. " f"{RESERVED}: send `shape/` to a leg, `drop` it, or rename it in the " "source and re-run `plan`.") @@ -1052,6 +1067,26 @@ def test_the_materializer_never_writes_a_copy_over_what_is_there( assert (target / kept).read_bytes() == before +@needs_disk_symlinks +def test_the_materializer_stops_at_a_link_at_the_copy(tmp_path): + """L1-K9 of #457: what `plan-root-blocks-shape-copy` says of a link at + the copy, on the disk. A link that leads nowhere, `shape/README.md -> + ../nowhere.md`, is in the way as a file is: the materializer refuses, + the link is as it was, and nothing is written where it leads.""" + target = materialized_beside(tmp_path, {"README.md": "# Thing\n"}) + (target / "shape").mkdir() + (target / "shape" / "README.md").symlink_to("../nowhere.md") + values = template_values() + with pytest.raises(Refusal) as caught: + materialize_assembly_root(REPO, target, values, + collision_dir="shape") + assert caught.value.code == BLOCKED + assert "`shape/README.md` is already there" in caught.value.detail + assert "and nothing was written there" in caught.value.detail + assert os.readlink(target / "shape" / "README.md") == "../nowhere.md" + assert not (target / "nowhere.md").exists() + + def test_the_materializer_still_writes_a_copy_where_the_way_is_free(tmp_path): """The refusal is for a taken copy only: the collision it handled before is still written beside the file the source keeps.""" diff --git a/tests/test_adopt_root_symlinks.py b/tests/test_adopt_root_symlinks.py index a8261a24..b1739eae 100644 --- a/tests/test_adopt_root_symlinks.py +++ b/tests/test_adopt_root_symlinks.py @@ -155,6 +155,8 @@ NOWHERE_MD = "nowhere.md" #: The probe B-OFF makes, as tests put a stand-in in its place. PROBE = "_checks_out_symlinks" +#: And the probe of how git keeps the clone's refs (#904). +REF_PROBE = "_clone_ref_store" SYMLINKS_OFF_CODE = "adopt-symlinks-off" #: What the assembly's clone holds in its own `.git` when the shape is #: written, besides `HEAD`: the files git makes in every clone it checks @@ -162,6 +164,28 @@ #: and the stores of the legs `execute` has mounted (2b of #183's re-verify). GIT_KEPT = (".git/index", ".git/packed-refs", ".git/logs/HEAD", ".git/refs/heads", ".git/objects/pack", ".git/modules/spec") +#: And the names in those directories that #904 names, where it counted +#: each directory with whatever might be under it (L1-J1 of #457): what git +#: makes in every such clone on its files backend, the branch the clone +#: checks out (the source's HEAD branch, `main` here), `origin/HEAD`, the +#: split branch and a log of each among them. +GIT_NAMED = (".git/config", ".git/refs/tags", ".git/objects/info", + ".git/modules/code", ".git/refs/heads/main", + ".git/refs/remotes/origin/HEAD", f".git/refs/heads/{BRANCH}", + ".git/logs/refs/heads/main", + ".git/logs/refs/remotes/origin/HEAD", + f".git/logs/refs/heads/{BRANCH}") +#: Names in those directories that the clone does NOT hold, which checked +#: ok while they were counted whole: `execute` wrote the shape's file into +#: each, in the clone's `.git` (L1-J1 of #457). +GIT_MISSING = (".git/refs/remotes/origin/main", ".git/logs/refs/heads/nope", + ".git/modules/nope", ".git/refs/heads/nope") +#: A name under no directory the clone's `.git` holds; one in its object +#: store, which `check` does not read; and one in a leg's store, which is +#: made after `check`. +GIT_NO_DIRECTORY = ".git/nowhere/x" +GIT_OBJECT = ".git/objects/zz/nothing" +GIT_IN_A_STORE = ".git/modules/spec/nope" SPEC_OTHER = "spec/docs/other.md" VENDOR = "vendor" PERSONAL = "a personal file\n" @@ -407,6 +431,65 @@ def no_filter_repo_probe(adopter, monkeypatch): # --------------------------------------------------------------------------- NOT_IN_THE_TREE = "is not in the source tree" +#: Why a link the shape writes through cannot lead it into the assembly's +#: own `.git` (#904): ABOVE a path the shape writes, never (D9); AT one, to +#: a name the clone does not hold by then, the shape's file would go into +#: `.git`, or find no directory there; and where `check` does not read +#: what is there, which of the two cannot be told. +IN_ITS_GIT = "is in the assembly's own `.git`" +NOT_INTO_GIT = ("a symlink above a path the shape writes is followed only " + "through directories the root holds, never into `.git`") +ABOVE_INTO_GIT = f"{IN_ITS_GIT}, and {NOT_INTO_GIT}" +NOTHING_THERE = (f"{IN_ITS_GIT}, where its clone holds nothing of that name " + "by the time the shape is written, so ") +WRITTEN_INTO_GIT = (NOTHING_THERE + "the shape would write its file through " + "the link into `.git`, where no commit holds it") +NO_DIRECTORY_IN_GIT = (NOTHING_THERE + "the shape would find no directory " + "there to write its file in, and stop after both leg " + "repositories were created and pushed") +OBJECT_STORE = ("the clone's object store, which holds what the source's own " + "store holds when `execute` clones it and the objects " + "`execute` writes before the shape") +SPEC_STORE = ("the store of the spec leg, which `git submodule add` makes " + "from that leg, with this machine's git template, after this " + "check") +REFTABLES = ("where the reftable backend keeps the clone's refs, in tables " + "git names as it writes them") +#: Where the source's HEAD is on no branch: each link into the refs of the +#: branch `git clone` checks out, which git guesses, and the directory of +#: them `check` reads nothing in, with what it holds. +GUESSED = "which git guesses where the source's HEAD is on no branch" +GUESSED_REFS = { + ".git/refs/heads/main": ( + ".git/refs/heads", + f"where `git clone` writes the branch it checks out, {GUESSED}"), + ".git/refs/remotes/origin/HEAD": ( + ".git/refs/remotes", + "where `git clone` writes `origin/HEAD` for the branch it checks " + f"out, {GUESSED}"), + ".git/logs/refs/heads/main": ( + ".git/logs/refs/heads", + "where `git clone` writes the reflog of the branch it checks out, " + f"{GUESSED}"), + ".git/logs/refs/remotes/origin/HEAD": ( + ".git/logs/refs/remotes", + "where `git clone` writes the reflog of `origin/HEAD` for the branch " + f"it checks out, {GUESSED}"), +} + + +def inside(top: str, holds: str) -> str: + """Where a name is that `check` reads nothing of: in `top`, which holds + `holds`.""" + return f"inside {ticked(top)}, {holds}, and `check` reads nothing there" + + +def unread(top: str, holds: str) -> str: + """Why a link AT a path the shape writes is refused where it leads into + such a place.""" + return (f"is {inside(top, holds)}, so whether `place` would write the " + "shape's copy beside it or the shape's file into `.git` cannot " + "be told before any leg exists") SPEC_MOUNT = "it enters `spec`, where the plan mounts the spec leg" ON_THIS_MACHINE = ("an absolute path: a place on this machine, not in the " "assembly") @@ -478,7 +561,7 @@ def too_many(at: str) -> str: id="into-the-other-mount"), pytest.param({}, {GITHUB: GIT_INFO}, {GITHUB: ROOT}, (GITHUB, GIT_INFO, WORKFLOW, - leads(GITHUB, GIT_INFO, NOT_IN_THE_TREE)), + leads(GITHUB, GIT_INFO, ABOVE_INTO_GIT)), id="D9-into-dot-git"), pytest.param({}, {GITHUB: "../Northwind/ci"}, {GITHUB: ROOT}, (GITHUB, "../Northwind/ci", WORKFLOW, climbs(GITHUB)), @@ -553,12 +636,54 @@ def too_many(at: str) -> str: # `.git`, where no commit holds it. pytest.param({}, {CLAUDE: GIT_DESCRIPTION}, {CLAUDE: ROOT}, (CLAUDE, GIT_DESCRIPTION, CLAUDE, - leads(CLAUDE, GIT_DESCRIPTION, NOT_IN_THE_TREE)), + leads(CLAUDE, GIT_DESCRIPTION, WRITTEN_INTO_GIT)), id="at-a-path-to-what-only-a-template-puts-in-dot-git"), pytest.param({}, {CLAUDE: GIT_HOOKS}, {CLAUDE: ROOT}, (CLAUDE, GIT_HOOKS, CLAUDE, - leads(CLAUDE, GIT_HOOKS, NOT_IN_THE_TREE)), + leads(CLAUDE, GIT_HOOKS, WRITTEN_INTO_GIT)), id="at-a-path-to-the-hooks-only-a-template-makes"), + # #904, L1-J1 of #457: a name the clone's `.git` does not hold, in a + # directory git makes there, which checked ok while `.git/objects`, + # `.git/refs`, `.git/logs` and `.git/modules` were counted whole. + *(pytest.param({}, {CLAUDE: missing}, {CLAUDE: ROOT}, + (CLAUDE, missing, CLAUDE, + leads(CLAUDE, missing, WRITTEN_INTO_GIT)), + id="J1-at-a-path-to-no-" + missing[5:].replace("/", "-")) + for missing in GIT_MISSING), + pytest.param({}, {CLAUDE: GIT_NO_DIRECTORY}, {CLAUDE: ROOT}, + (CLAUDE, GIT_NO_DIRECTORY, CLAUDE, + leads(CLAUDE, GIT_NO_DIRECTORY, NO_DIRECTORY_IN_GIT)), + id="at-a-path-to-no-directory-in-dot-git"), + pytest.param({}, {CLAUDE: GIT_OBJECT}, {CLAUDE: ROOT}, + (CLAUDE, GIT_OBJECT, CLAUDE, + leads(CLAUDE, GIT_OBJECT, + unread(".git/objects", OBJECT_STORE))), + id="J1-at-a-path-into-the-object-store"), + pytest.param({}, {CLAUDE: GIT_IN_A_STORE}, {CLAUDE: ROOT}, + (CLAUDE, GIT_IN_A_STORE, CLAUDE, + leads(CLAUDE, GIT_IN_A_STORE, + unread(".git/modules/spec", SPEC_STORE))), + id="at-a-path-into-a-legs-store"), + # J2 of #457: a `..` in the clone's `.git`. ABOVE a path the shape + # writes it is refused, as D9 refuses the link, and says so; AT one it + # steps back out only of a directory git makes there. + pytest.param({}, {GITHUB: ".git/refs/.."}, {GITHUB: ROOT}, + (GITHUB, ".git/refs/..", WORKFLOW, + "it passes through `.git/refs`, in the assembly's own " + f"`.git`, and {NOT_INTO_GIT}"), + id="J2-above-a-path-back-out-of-dot-git-refs"), + pytest.param({}, {CLAUDE: ".git/refs/nope/../HEAD"}, {CLAUDE: ROOT}, + (CLAUDE, ".git/refs/nope/../HEAD", CLAUDE, + "it passes through `.git/refs/nope`, in the assembly's own " + "`.git`, where its clone holds no directory of that name by " + "the time the shape is written"), + id="J2-at-a-path-back-out-of-no-directory-in-dot-git"), + pytest.param({}, {CLAUDE: ".git/objects/ab/../info"}, {CLAUDE: ROOT}, + (CLAUDE, ".git/objects/ab/../info", CLAUDE, + "it passes through `.git/objects/ab`, " + f"{inside('.git/objects', OBJECT_STORE)}, so whether it is " + "a directory cannot be told before any leg exists"), + id="J2-at-a-path-back-out-of-the-object-store"), # B2: the copy under `shape/` lands on something already there. pytest.param({}, {SHAPE_DIR: "."}, {SHAPE_DIR: ROOT}, (SHAPE_DIR, ".", SHAPE_README, @@ -626,8 +751,9 @@ def test_one_finding_per_link_however_many_shape_paths_run_through_it( def test_a_link_at_the_copy_is_one_finding_by_the_rule_that_sees_it( adopter, no_filter_repo_probe, tmp_path): """A symlink kept AT the copy under `shape/` of a path the root holds is - #262's: `plan-root-blocks-shape-copy` names it, the shape's copy written - THROUGH it, and this rule does not say the same link again. `execute` + #262's: `plan-root-blocks-shape-copy` names it, saying the materializer + writes no copy through it and stops there (#904, L1-K9 of #457), and + this rule does not say the same link again. `execute` refuses with that line, before any leg. Where the root holds the path only through a symlink above it (`.github -> ci`, the project's own `ci/workflows/validate.yml` kept), #262's rule, which follows no link, @@ -643,7 +769,8 @@ def test_a_link_at_the_copy_is_one_finding_by_the_rule_that_sees_it( if line.startswith(COPY_FINDING)] assert len(said) == 1, checked.stderr assert f"That path is the symlink {ticked(SHAPE_README)}." in said[0] - assert "`execute` would write the copy through it " in said[0] + assert ("the materializer writes no copy through it: it would stop " + "there (`materialize-copy-blocked`)") in said[0] refusal = refused_by(adopter, execute_args(held, plan)) assert refusal.code == COPY_IN_THE_WAY assert f" {said[0]}" in refusal.detail.splitlines() @@ -954,6 +1081,108 @@ def test_a_link_at_a_pins_file_is_one_finding_by_the_rule_that_sees_it( no_leg_was_created(tmp_path) +#: What `plan-root-holds-pin` says of a declared pin's file. +DECLARED_HELD = ("the pin the plan declares on `openGlass`. The shape's pin " + "would be written beside it, under `shape/`, and the root's " + "own `validate-pins.py` would read the source's file as that " + "pin") +#: `(files, links, what is kept at the pin's file, the link it is reached +#: through, aside)`: rv183g's source, `governance/` kept, holding the pin's +#: file, reached through a kept link above `contracts/`. `place` finds it +#: there, writes the pin beside it, and the root's `contracts/ +#: openglass-pin.yaml` is the source's: `check` passed each, and the +#: root's first bootstrap refused the file as `yaml-unparsable` and the +#: link as `neutral-pin-unreadable` (#904, L1-K2 of #457). +HELD_THROUGH = [ + pytest.param({GOVERNANCE_PIN: "not a pin\n"}, {CONTRACTS: GOVERNANCE}, + "the file", CONTRACTS, "", id="a-file"), + pytest.param({}, {CONTRACTS: GOVERNANCE, GOVERNANCE_PIN: README}, + "the symlink", CONTRACTS, "", id="a-link-to-the-readme"), + pytest.param({GOVERNANCE_PIN: "not a pin\n"}, {"Contracts": GOVERNANCE}, + "the file", "Contracts", CASE_ASIDE, id="through-a-case-twin"), +] + + +def held_pins(result) -> list[str]: + return [line for line in result.stderr.splitlines() + if line.startswith(f"FINDING {ROOT_HOLDS_PIN}: ")] + + +def held_through(kind: str, link: str, aside: str, held: str, rel: str, + written: str) -> str: + """`plan-root-holds-pin` for `held`, kept under `governance/` and + reached as the pin's file `rel` through the symlink `link`.""" + return (f"FINDING {ROOT_HOLDS_PIN}: the plan keeps {kind} {ticked(held)} " + "in the assembly root under the entry `governance/`, at " + f"{ticked(rel)} through the symlink {ticked(link)} to " + f"`governance`{aside}, where `execute` writes {written}: send " + f"{ticked(link)} to a leg or `drop` it, or rename {ticked(held)} " + "in the source and re-run `plan`.") + + +@pytest.mark.parametrize(("files", "links", "kind", "link", "aside"), + HELD_THROUGH) +def test_a_pins_file_the_root_keeps_through_a_link_is_refused_before_any_leg( + adopter, no_filter_repo_probe, links_as_links, tmp_path, files, links, + kind, link, aside): + """The pin's file is asked through the links the plan keeps above it, + as the walk follows it, and the source's file there is #273's + `plan-root-holds-pin`, once, naming the link; `execute` refuses with the + same line before any leg. A link there that leads nowhere is the walk's + still (`PIN_THROUGH`): one link, one finding.""" + base_files, _ = THROUGH_GOVERNANCE + _, plan = planned(tmp_path, {**base_files, **files}, links, + {"governance/": ROOT, link: ROOT}) + declare(plan, A_PIN) + checked = check(plan) + assert checked.returncode == 1, checked.stderr + checked.stdout + assert symlink_findings(checked) == [], checked.stderr + line = held_through(kind, link, aside, GOVERNANCE_PIN, PIN_FILE, + DECLARED_HELD) + assert held_pins(checked) == [line], checked.stderr + refusal = refused_by(adopter, execute_args(tmp_path, plan)) + assert refusal.code == ROOT_HOLDS_PIN + assert f" {line}" in refusal.detail.splitlines() + no_leg_was_created(tmp_path) + + +def test_the_shapes_own_pin_the_root_keeps_through_a_link_is_refused_too( + adopter, no_filter_repo_probe, links_as_links, tmp_path): + """The same for the shape's own pin, with no pin declared: `place` + wrote it beside `governance/shape-pin.yaml`, and `execute`'s + verification failed as `adopt-shape-pin-unreadable` after both legs + (measured on main 6c861635 and 5e539a87).""" + files, legs = THROUGH_GOVERNANCE + held = f"{GOVERNANCE}/shape-pin.yaml" + _, plan = planned(tmp_path, {**files, held: "x\n"}, + {CONTRACTS: GOVERNANCE}, legs) + checked = check(plan) + assert checked.returncode == 1, checked.stderr + checked.stdout + said = held_pins(checked) + assert len(said) == 1, checked.stderr + whole = held_through("the file", CONTRACTS, "", held, SHAPE_PIN, + "the shape's own pin") + head, _, tail = whole.partition("the shape's own pin") + assert said[0].startswith(head + "the shape's own pin: "), said[0] + assert said[0].endswith(tail), said[0] + refusal = refused_by(adopter, execute_args(tmp_path, plan)) + assert refusal.code == ROOT_HOLDS_PIN + no_leg_was_created(tmp_path) + + +def test_a_pins_file_through_a_link_to_where_nothing_is_kept_is_no_finding( + links_as_links, tmp_path): + """The control: nothing is kept at `governance/openglass-pin.yaml`, so + `place` writes the pin there, through the link, and the root reads it + at `contracts/openglass-pin.yaml`, as on main.""" + files, legs = THROUGH_GOVERNANCE + _, plan = planned(tmp_path, files, {CONTRACTS: GOVERNANCE}, legs) + declare(plan, A_PIN) + checked = check(plan) + assert held_pins(checked) == [], checked.stderr + assert symlink_findings(checked) == [], checked.stderr + + #: `pins:` blocks `execute` writes no pin file from, with the code `check` #: names each by. NOT_A_PIN = [ @@ -1245,6 +1474,17 @@ def test_a_link_at_a_path_into_what_execute_makes_beside_the_assembly( id="at-a-path-to-the-clones-own-" + kept[5:].replace("/", "-")) for kept in GIT_KEPT), + # #904: what git makes in those directories, named (L1-J1 of #457); + # and J2's `..` back out of a directory git makes there, which was + # refused as no directory in the assembly root, as main adopts it. + *(pytest.param({}, {CLAUDE: named}, {CLAUDE: ROOT}, + id="at-a-path-to-the-clones-own-" + + named[5:].replace("/", "-")) + for named in GIT_NAMED), + pytest.param({}, {CLAUDE: ".git/refs/../HEAD"}, {CLAUDE: ROOT}, + id="J2-at-a-path-back-out-of-dot-git-refs"), + pytest.param({}, {CLAUDE: ".git/../AGENTS.md"}, {CLAUDE: ROOT}, + id="J2-at-a-path-back-out-of-dot-git"), ] #: Links no path the shape writes runs through: main keeps each exactly as @@ -1366,6 +1606,7 @@ def test_an_adoption_with_no_link_beneath_a_shape_path_makes_no_probe( """An ordinary adoption runs exactly as before: nothing is made in the work directory to ask `git` a question this plan does not raise.""" monkeypatch.setattr(adopter, PROBE, no_probe) + monkeypatch.setattr(adopter, REF_PROBE, no_probe) links_as_files(monkeypatch) _, plan = planned(tmp_path, files, links, legs) loaded = adopter.Plan.load(plan) @@ -1632,6 +1873,238 @@ def test_a_work_directory_no_probe_can_be_made_in_reports_as_before( assert f" {line}" in caught.value.detail.splitlines() +# --------------------------------------------------------------------------- +# What the clone's own `.git` holds, as this machine's git keeps refs (#904) +# --------------------------------------------------------------------------- +# +# Which names git makes in the clone's `.git` depends on how git here keeps +# refs: on its files backend, its default, or on the reftable backend (git +# 2.45 and later, where `GIT_DEFAULT_REF_FORMAT` or `init.defaultRefFormat` +# says so: L1-K8 of #457), and with reflogs or none (`core.logAllRefUpdates`). +# `check` and `execute` each ask a throwaway repository in their own work +# directory, once, and only where a walk is led into the clone's `.git`. + +GIT_VERSION = re.search(r"(\d+)\.(\d+)", subprocess.run( + ["git", "--version"], capture_output=True, text=True, + check=False).stdout) +#: Whether this runner's git has the reftable backend. +REFTABLE_GIT = GIT_VERSION is not None and ( + int(GIT_VERSION[1]), int(GIT_VERSION[2])) >= (2, 45) +needs_reftable = pytest.mark.skipif( + not REFTABLE_GIT, reason="the reftable backend needs git 2.45 or later") +REF_FORMAT = "GIT_DEFAULT_REF_FORMAT" +LOG_HEAD = ".git/logs/HEAD" +PACKED_REFS = ".git/packed-refs" +TABLES = ".git/reftable" + + +def refs_on_reftable(monkeypatch) -> None: + """`git` told to make every repository on the reftable backend.""" + monkeypatch.setenv(REF_FORMAT, "reftable") + + +def no_reflogs(monkeypatch) -> None: + """`git` told to write no reflog, after the runner's own + `GIT_CONFIG_*`.""" + for name, entry in appended_to_command_scope( + os.environ, [("core.logAllRefUpdates", "false")]).items(): + monkeypatch.setenv(name, entry) + + +def test_the_ref_probe_answers_what_git_would_do(adopter, tmp_path, + monkeypatch): + """Git's defaults where nothing says otherwise, the files backend with + reflogs; none where `core.logAllRefUpdates` is false; and nothing is + left behind.""" + monkeypatch.delenv(REF_FORMAT, raising=False) + assert adopter._clone_ref_store(tmp_path) == (False, True) + no_reflogs(monkeypatch) + assert adopter._clone_ref_store(tmp_path) == (False, False) + assert list(tmp_path.iterdir()) == [] + + +@needs_reftable +def test_the_ref_probe_answers_reftable_where_git_makes_it(adopter, tmp_path, + monkeypatch): + refs_on_reftable(monkeypatch) + assert adopter._clone_ref_store(tmp_path) == (True, True) + + +def test_with_no_work_directory_the_refs_are_gits_defaults(adopter, + monkeypatch): + """A `Source` that no command opened has no work directory to ask: no + probe is made, and git's defaults are taken.""" + monkeypatch.setattr(adopter, REF_PROBE, no_probe) + assert adopter._SymlinkProbe(None).ref_store() == (False, True) + + +#: `(where CLAUDE.md leads, why it is refused, or None)` where the clone +#: keeps its refs on the reftable backend: measured with git 2.47.1, its +#: `.git` holds `reftable/tables.list` and the tables, and a `refs/heads` +#: that is a file, and no `packed-refs` and no `logs` (L1-K8 of #457). +ON_REFTABLE = [ + pytest.param(TABLES, None, id="the-tables"), + pytest.param(f"{TABLES}/tables.list", None, id="their-list"), + pytest.param(".git/refs/heads", None, id="refs-heads-a-file"), + pytest.param(GIT_HEAD, None, id="head"), + pytest.param(PACKED_REFS, WRITTEN_INTO_GIT, id="no-packed-refs"), + pytest.param(LOG_HEAD, NO_DIRECTORY_IN_GIT, id="no-logs"), + pytest.param(".git/refs/heads/main", NO_DIRECTORY_IN_GIT, + id="no-loose-branch"), + pytest.param(f"{TABLES}/0x01.ref", unread(TABLES, REFTABLES), + id="a-table"), +] + + +def root_symlink_findings_here(adopter, base: Path, plan: Path) -> list: + """`check`'s symlink findings, asked in this process, where a stand-in + for a probe answers.""" + loaded = adopter.Plan.load(plan) + (base / WORK).mkdir(exist_ok=True) + source = loaded.open_source(None, base / WORK) + return adopter._root_symlink_findings(loaded, source, source.tree()) + + +@pytest.mark.parametrize(("target", "why"), ON_REFTABLE) +def test_on_the_reftable_backend_the_clones_git_is_what_git_makes_there( + adopter, links_as_links, tmp_path, monkeypatch, target, why): + """Asked of any git, the probe answered: a link to the tables was + refused, though the copy goes beside them, and one to `packed-refs` + checked ok and had the shape's file written there.""" + monkeypatch.setattr(adopter, REF_PROBE, lambda _work_root: (True, True)) + _, plan = planned(tmp_path, {}, {CLAUDE: target}, {CLAUDE: ROOT}) + assert root_symlink_findings_here(adopter, tmp_path, plan) == ( + [] if why is None + else [finding(CLAUDE, target, CLAUDE, leads(CLAUDE, target, why))]) + + +@needs_reftable +def test_where_git_keeps_refs_on_reftable_check_and_execute_ask_it( + adopter, no_filter_repo_probe, links_as_links, tmp_path, + monkeypatch): + """The same of a real git that makes reftable repositories, as `check` + and `execute` each ask it.""" + refs_on_reftable(monkeypatch) + there = tmp_path / "there" + _, plan = planned(there, {}, {CLAUDE: TABLES}, {CLAUDE: ROOT}) + passed_alike(adopter, there, plan) + gone = tmp_path / "gone" + _, plan = planned(gone, {}, {CLAUDE: PACKED_REFS}, {CLAUDE: ROOT}) + refused_alike(adopter, gone, plan, finding( + CLAUDE, PACKED_REFS, CLAUDE, + leads(CLAUDE, PACKED_REFS, WRITTEN_INTO_GIT))) + + +def test_where_git_writes_no_reflogs_a_link_to_one_is_refused( + adopter, no_filter_repo_probe, links_as_links, tmp_path, + monkeypatch): + """`core.logAllRefUpdates` false: the clone has no `.git/logs`, so the + shape would find no directory for `.git/logs/HEAD` and stop after both + legs; `check` and `execute` refuse it before them.""" + _, plan = planned(tmp_path, {}, {CLAUDE: LOG_HEAD}, {CLAUDE: ROOT}) + no_reflogs(monkeypatch) + refused_alike(adopter, tmp_path, plan, finding( + CLAUDE, LOG_HEAD, CLAUDE, leads(CLAUDE, LOG_HEAD, + NO_DIRECTORY_IN_GIT))) + + +@pytest.mark.parametrize("target", GUESSED_REFS) +def test_where_the_sources_head_is_on_no_branch_its_branch_is_not_named( + adopter, no_filter_repo_probe, links_as_links, tmp_path, target): + """`git clone` checks out the branch the source's HEAD is on. Where HEAD + is on none, git guesses one, so a link to `refs/heads/main` cannot be + told to find something, and is refused saying so; and so is one to the + `origin/HEAD` it writes for that branch, or to the reflog of either, + each saying what git writes there (rv1091's NIT 2 on #1091).""" + top, holds = GUESSED_REFS[target] + source, plan = planned(tmp_path, {}, {CLAUDE: target}, {CLAUDE: ROOT}) + git("checkout", "-q", "--detach", cwd=source) + refused_alike(adopter, tmp_path, plan, finding( + CLAUDE, target, CLAUDE, leads(CLAUDE, target, unread(top, holds)))) + + +#: How `git init` fails where `GIT_DEFAULT_HASH` names no hash, as #910's +#: probe tests raise it, and how the NOTE that says so begins. +UNKNOWN_HASH = "fatal: unknown hash algorithm 'bogus'" +NO_REF_PROBE_NOTE = ("NOTE `check` could not ask git how it keeps the refs " + "of a repository made in ") + + +@pytest.mark.parametrize("failure", [ + pytest.param(lambda adopter: adopter.CommandFailed( + ["git", "init", "-q", "probe"], None, 128, UNKNOWN_HASH), + id="command-failed"), + pytest.param(lambda adopter: adopter.Refusal( + "git-failed", f"`git init` exited 128: {UNKNOWN_HASH}"), + id="refusal")]) +def test_a_ref_probe_git_will_not_make_takes_gits_defaults_and_says_so( + adopter, monkeypatch, tmp_path, failure): + """As #910 has the symlink probe do: a `git init` that fails otherwise + than with an `OSError` stops nothing; git's defaults are taken, and a + NOTE says why, in git's words.""" + def refused(_work_root): + raise failure(adopter) + + monkeypatch.setattr(adopter, REF_PROBE, refused) + probe = adopter._SymlinkProbe(tmp_path) + assert probe.ref_store() == adopter.GIT_DEFAULT_REFS + notes = probe.notes() + assert len(notes) == 1, notes + assert notes[0].startswith(NO_REF_PROBE_NOTE), notes[0] + assert UNKNOWN_HASH in notes[0] + + +def test_a_ref_probe_in_a_work_directory_it_cannot_write_gives_no_note( + adopter, monkeypatch, tmp_path): + """An `OSError` is `adopt-work-dir-unwritable`'s to say (#524), and the + probe adds no NOTE of its own.""" + def unwritable(_work_root): + raise PermissionError(13, "Permission denied") + + monkeypatch.setattr(adopter, REF_PROBE, unwritable) + probe = adopter._SymlinkProbe(tmp_path) + assert probe.ref_store() == adopter.GIT_DEFAULT_REFS + assert probe.notes() == [] + + +def test_check_says_where_git_would_not_make_the_ref_probe( + links_as_links, tmp_path, monkeypatch): + """`GIT_DEFAULT_HASH=bogus` fails the ref probe's `git init`, and not + `check`'s throwaway repositories, which name the source's format: + `check` passes a link to `.git/HEAD`, as git's defaults hold it, and + says why it took them.""" + _, plan = planned(tmp_path, {}, {CLAUDE: GIT_HEAD}, {CLAUDE: ROOT}) + monkeypatch.setenv("GIT_DEFAULT_HASH", "bogus") + checked = check(plan) + assert checked.returncode == 0, checked.stderr + checked.stdout + assert NO_REF_PROBE_NOTE in checked.stdout, checked.stdout + assert UNKNOWN_HASH in checked.stdout, checked.stdout + + +def test_the_ref_probe_is_made_once_however_many_walks_ask(adopter, tmp_path, + monkeypatch): + """Two links into the clone's `.git`, each met by both disks' walks and + by the pins' walks, make one probe of how git keeps refs.""" + asked = [] + + def probe(work_root): + asked.append(work_root) + return adopter.GIT_DEFAULT_REFS + + monkeypatch.setattr(adopter, REF_PROBE, probe) + _, plan = planned(tmp_path, {}, + {CLAUDE: GIT_HEAD, AGENTS: ".git/refs/heads/nope"}, + {CLAUDE: ROOT, AGENTS: ROOT}) + loaded = adopter.Plan.load(plan) + work = tmp_path / WORK + work.mkdir() + source = loaded.open_source(None, work) + with pytest.raises(adopter.Refusal) as caught: + adopter._refuse_what_check_finds(loaded, source) + assert caught.value.code == ROOT_SYMLINK + assert len(asked) == 1 + + # --------------------------------------------------------------------------- # The materializer's own refusals, for a caller that skipped `check` # --------------------------------------------------------------------------- @@ -2032,6 +2505,14 @@ def adopted_beside(result, source: Path, links: dict) -> None: id="a-log-and-the-packs"), pytest.param({CLAUDE: ".git/refs/heads", AGENTS: ".git/modules/spec"}, id="the-branches-and-a-legs-store"), + # #904: J2's step back out of `.git/refs`, and names in the directories + # that were counted whole (L1-J1 of #457). + pytest.param({CLAUDE: ".git/refs/../HEAD", + AGENTS: ".git/refs/remotes/origin/HEAD"}, + id="J2-back-out-of-refs-and-origins-head"), + pytest.param({CLAUDE: ".git/refs/heads/main", + AGENTS: f".git/logs/refs/heads/{BRANCH}"}, + id="the-cloned-branch-and-the-split-branchs-log"), ] @@ -2048,6 +2529,21 @@ def test_links_into_what_the_clones_own_git_holds_adopt_beside_them( adopted_beside(execute(tmp_path, plan), source, links) +@needs_filter_repo +@needs_disk_symlinks +@needs_reftable +def test_on_reftable_links_to_what_its_git_holds_adopt_beside_them( + tmp_path, monkeypatch): + """L1-K8 of #457 end to end: where git keeps refs on the reftable + backend, the clone's `.git` holds `reftable` and a `refs/heads` file, + so `place` writes the copies beside the links, and the adoption + verifies.""" + refs_on_reftable(monkeypatch) + links = {CLAUDE: TABLES, AGENTS: ".git/refs/heads"} + source, plan = planned(tmp_path, {}, links, {link: ROOT for link in links}) + adopted_beside(execute(tmp_path, plan), source, links) + + @needs_filter_repo @pytest.mark.parametrize(PLANNED, AT_A_FILE) def test_with_symlinks_off_execute_adopts_beside_the_file(