diff --git a/adopt-project.py b/adopt-project.py index ddd75be0..0b0506fa 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -114,8 +114,15 @@ one git cannot make or ask there is `adopt-git-unasked`, which says why (#910). The symlink probe keeps its plain `git init` and asks only `core.symlinks`; where git cannot make it, a - symlink is taken to check out as one, and a NOTE says why. It - prints what will happen and changes nothing. + symlink is taken to check out as one, and a NOTE says why. + Source paths the plan keeps in one repository that differ only + in case, `A` and `a`, or a file and a folder above another + path, `f` and `F/x`, are `plan-case-twins` on every disk, as a + disk that ignores case holds each set as one name (#1161). A + set of one name whose paths hold one blob in one mode is a NOTE + in a leg, and in the root only where it is two paths whose names + differ only in their last part. + It prints what will happen and changes nothing. execute asks about both legs before it makes either (#798), then creates the two legs, extracts them with `git filter-repo`, makes the one split commit on a branch of the source, sets the @@ -9740,6 +9747,180 @@ def _unsafe_path_findings(plan: Plan) -> tuple[list[str], list[str]]: return found, [UNSAFE_PATH_REMEDIATION] if found else [] +# --------------------------------------------------------------------------- +# Case twins the plan keeps in one repository (#1161) +# --------------------------------------------------------------------------- +# +# A source commit can hold paths that differ only in case, `A` and `a`, or a +# file and, in another case, a folder above another path, `f` and `F/x`: git +# holds each under its own name and blob on any disk (#1033). A disk that +# ignores case, as a macOS or Windows disk does by default, holds each such +# set as one name, and a clone there checks out only one path of it (git's +# "the following paths have collided"). `check` passed them wherever the +# plan kept them. Measured for #1161 with git 2.43 and the tool at 4c7b8acd, +# the work directory and every repository on a disk that ignores case and +# keeps it: `check` printed `plan ok` for each set kept in the root or in +# one leg, but for folder twins (`D/x`, `d/x`) in 2 of 4 fresh sources, +# where the #654 rehearsal said `plan-file-reencoded`, git saying `will not +# add file alias`, and `execute` refused that before any leg. A file and a +# folder (`F/x`, `f`) stopped `execute` after both legs and the split were +# pushed, as `adopt-lost: F/x`. File twins in the root were adopted, each +# set kept as it was in the repository made. Twins in the code leg were +# adopted so in 2 runs, and in 3 others, for #1189's review, `execute` +# stopped after both legs were pushed, mounting them, at `git rm -r` ("the +# following files have local modifications"). +# +# So `check` names them, on every disk, as #520 names a branch in another +# case: one plan is then not good on one machine and lost on another. +# `plan-case-twins` names, for each repository the split makes -- the root, +# the spec leg, the code leg -- each set of SOURCE paths, each sent there by +# one answered entry (`_answered_legs`), that a disk ignoring case cannot +# hold apart (`_case_twin_sets`). Only the source's paths are asked: a kept +# path that is a twin of a file the shape writes into the root, `Makefile`, +# `README.md`, `AGENTS.md`, `CLAUDE.md` or `.gitmodules`, is not (#1191). A +# path git refuses is `plan-path-git-refuses`'s alone, and a path no entry +# answers once is another finding's. Twins the plan sends to DIFFERENT +# repositories, and a twin it drops beside one it keeps, are not asked +# either, though `execute` on a disk that ignores case loses them too, as +# `adopt-lost` after both legs are pushed (#1183). +# +# A set of ONE NAME whose paths hold one blob in one mode loses nothing on +# such a disk: the one path it checks out holds what each path holds. In a +# leg it is a NOTE, not this finding (`_one_blob_twins`): `execute` there +# verified every such set measured for #1189, two files in one folder in 6 +# runs, 5 in the code leg and 1 in the spec leg, and for its second review +# folder twins (`src/D/x`, `src/d/x`), alone and beside folders that only +# merge, and symlinks to one target, 1 run each. In the root the NOTE takes +# only two paths whose names differ only in their last part, `A` and `a`, +# the shape `execute` verified there in every run measured: 2 of files, +# and for the second review 1 of symlinks to one target and 1 of +# executables; and with the rule above, in 2 runs each, files `A` and `a` +# and files `D/A` and `D/a` in one folder: `check` printed the NOTE and +# `plan ok`, and `execute` verified. Other sets of one blob in the root met +# git's `will not add file alias` there, which the #654 rehearsal reports +# as `plan-file-reencoded`, in 1 run each for the second review: folder +# twins (`D/x`, `d/x`), alone and beside folders that only merge, in +# `check`; three paths (`AB`, `Ab`, `ab`) in `execute` alone, before any +# leg, after `check` had said `plan ok`. With the rule above, in 2 runs +# each, `check` met it for both, beside `plan-case-twins`, and `execute` +# refused both as `plan-case-twins` before any leg. So in the root they +# stay this finding, and its remediation, a rename. A file against a +# folder is always the finding: such a disk cannot hold a file and a folder +# under one name, whatever their bytes. + +#: `check`'s finding, and the code of `execute`'s refusal, for paths the +#: plan keeps in one repository that a disk ignoring case holds as one. +CASE_TWINS = "plan-case-twins" +#: Each repository the split makes, by the `leg:` that sends a path there, +#: as `plan-case-twins` names it, in the order it is asked. +TWIN_REPOSITORIES = {"root": "the assembly root", "spec": "the spec leg", + "code": "the code leg"} +#: What a plan holding such twins is told to do, once after every finding. +#: It offers no answer that sends the twins of a set into different +#: repositories, or drops one: on a disk that ignores case `execute` loses +#: those too, after both legs are pushed (#1183). +CASE_TWINS_REMEDIATION = ( + "Remediation: rename paths in the source until no two that one " + "repository would hold differ only in case -- each twin of a set but " + "one, or the folder above a twin -- commit that, and re-run `plan`: a " + "plan is written against one commit of the source, and `check` calls it " + "`plan-stale` once the source has moved.") + + +def _case_twin_sets(paths: list[str]) -> list[list[str]]: + """Each set of two or more of `paths` that a disk ignoring case holds as + one name, in the order of `paths`, as is each set: paths equal once case + is folded (`casefold`, as `_KeptTree` folds), and a path equal so to a + folder above another (`f` and `F/x`), which such a disk cannot hold as a + file and a folder at once. Paths that only share a folder in another + case (`D/x` and `d/y`) are in no set: such a disk merges the folders, + and the names in them differ. + + Each path joins the set of the first name above it, or its own, that is + some path's whole name folded: two paths such a disk holds as one share + that name, and two it holds apart do not.""" + whole = {path.casefold() for path in paths} + sets: dict[str, list[str]] = {} + for path in paths: + parts = path.casefold().split("/") + name = next(above for above in ("/".join(parts[:end]) + for end in range(1, len(parts) + 1)) + if above in whole) + sets.setdefault(name, []).append(path) + return [named for named in sets.values() if len(named) > 1] + + +def _one_blob_twins(named: list[str], kinds: dict, leg: str) -> bool: + """Whether the set `named`, which the plan keeps in the repository + `leg:` `leg` sends it to, is left to the NOTE: every path folds to ONE + name and holds one blob in one mode, `kinds` giving each path's `(mode, + object)`, so the one path a disk ignoring case checks out holds what + each holds. In the root, only TWO paths whose names differ only in + their last part, `A` and `a`, are: folder twins (`D/x`, `d/x`) and sets + of three or more met git's `will not add file alias` there, and stay + the finding. A file against a folder is never such a set, nor are + submodules, whose object is a commit and no blob.""" + if leg == "root" and (len(named) != 2 or len( + {path.rpartition("/")[0] for path in named}) != 1): + return False + return (len({path.casefold() for path in named}) == 1 + and len({kinds[path] for path in named}) == 1 + and kinds[named[0]][0] != GITLINK_MODE) + + +def _case_twin_findings(plan: Plan, tree: list, + refused: list[str] | tuple = () + ) -> tuple[list[str], list[str], list[str]]: + """`plan-case-twins` for each repository the split makes that the plan + keeps case twins in (`_case_twin_sets`), in `TWIN_REPOSITORIES`' order, + naming `NAMED_PATHS` of them set by set and counting the rest; what to + print once after every finding; and a NOTE for each repository whose + sets of one name hold one blob in one mode, in the root only sets of + two paths whose names differ only in their last part + (`_one_blob_twins`), which the finding leaves out. Only the paths ONE + answered entry sends there are asked (`_answered_legs`), and none git + refuses, `refused`: `plan-path-git-refuses` names those.""" + held = set(refused) + kinds = {path: (mode, oid) for path, mode, oid, _ in tree} + answered = _answered_legs(plan.entries, [path for path, _, _, _ in tree + if path not in held]) + found, notes = [], [] + for leg, where in TWIN_REPOSITORIES.items(): + sets = _case_twin_sets([path for path, (answer, _) in answered.items() + if answer == leg]) + alike = [named for named in sets + if _one_blob_twins(named, kinds, leg)] + sets = [named for named in sets if named not in alike] + if alike: + same = [path for named in alike for path in named] + notes.append( + f"NOTE the plan keeps {len(alike)} " + f"{'set' if len(alike) == 1 else 'sets'} of case twins in " + f"{where} whose paths hold one blob in one mode, {len(same)} " + f"paths: {_named_and_counted(same)}. A disk that ignores " + "case holds each set as one name, and a clone made there " + "checks out one path of each, git saying `the following " + "paths have collided`; that path holds what each path of the " + "set holds, so none is lost, and `check` names no " + f"`{CASE_TWINS}` for them.") + if not sets: + continue + twins = [path for named in sets for path in named] + found.append( + f"FINDING {CASE_TWINS}: the plan keeps {len(sets)} " + f"{'set' if len(sets) == 1 else 'sets'} of case twins in {where}, " + f"{len(twins)} paths, the paths of a set differing only in case, " + "or one naming in another case a folder above another: " + f"{_named_and_counted(twins)}. A disk that ignores case, as a " + "macOS or Windows disk does by default, holds each set as one " + "name: a clone made there checks out only one path of each set, " + "and `execute` could stop there before any leg exists, at git's " + "`will not add file alias`, or after both legs are pushed, as " + "`adopt-lost` or at `git rm` mounting the legs, so `execute` " + "refuses them on every disk.") + return found, [CASE_TWINS_REMEDIATION] if found else [], notes + + # --------------------------------------------------------------------------- # The two branch names a plan gives `execute` (#420, #520) # --------------------------------------------------------------------------- @@ -9835,7 +10016,8 @@ def _entry_findings(plan: Plan, source: Source, ) -> tuple[list[str], list[str], list[str]]: """Every FINDING `check` prints about the plan's ENTRIES, in its order, what it prints once after them, and the NOTEs it prints beside them: - each entry's path, then coverage, then each entry's `leg:`, the two + each entry's path, then the case twins the plan keeps in one repository + (#1161), then coverage, then each entry's `leg:`, the two mount paths and what the root keeps where the shape writes, the pins, then each pin's digest the clone `plan` read it from recomputes otherwise (#449), then each value `execute` would write into the @@ -9904,6 +10086,16 @@ def _entry_findings(plan: Plan, source: Source, refuses as a path, `..` or `.Git/x` (#910), as `plan-path-git-refuses`, asked of git first, in a throwaway repository of its own. + THEN THE CASE TWINS (#1161), `plan-case-twins`: the source paths, each + sent by one answered entry, that one repository would hold and a disk + ignoring case holds as one name (`_case_twin_findings`). Their first + fix is a rename in the source too, so they come before the findings + about the answers, and they ask no path git refuses, which the finding + before them names. A set of one name whose paths hold one blob in one + mode loses nothing there, and is a NOTE instead, listed first of the + NOTEs returned here: in a leg, and in the root where it is two paths + whose names differ only in their last part. + `check`'S THROWAWAY REPOSITORIES (#910) are made in the work directory. The first is the split's own `git clone` of the source (#1083), asked as `execute` makes it, so that one this git will not make is said before @@ -9983,7 +10175,9 @@ def _entry_findings(plan: Plan, source: Source, probe = _SymlinkProbe(source.work_root) unsafe, unsafe_remediation = _source_path_findings(plan, tree, targets, refused) - findings = (unsafe + twins, twins_remediation, twin_notes = _case_twin_findings(plan, tree, + refused) + findings = (unsafe + twins + _coverage_findings([str(e.get("path")) for e in plan.entries], [path for path, _, _, _ in tree]) @@ -10020,12 +10214,12 @@ def _entry_findings(plan: Plan, source: Source, return (findings + misread + split + linked + asked + unwritable_findings + value_findings + names + tracking_findings + moved + field_findings, - unsafe_remediation + unsafe_remediation + twins_remediation + ([MANIFEST_MISREAD_REMEDIATION] if misread else []) + split_remediation + unwritable_remediation + value_remediation + names_remediation + tracking_remediation + moved_remediation + field_remediation, - pin_notes + notes + unasked_notes + probe.notes()) + twin_notes + pin_notes + notes + unasked_notes + probe.notes()) # --------------------------------------------------------------------------- diff --git a/tests/test_adopt_check_throwaway_repos.py b/tests/test_adopt_check_throwaway_repos.py index 84ef2ee3..5493dca9 100644 --- a/tests/test_adopt_check_throwaway_repos.py +++ b/tests/test_adopt_check_throwaway_repos.py @@ -52,6 +52,15 @@ refuses. This bench has no usable disk that ignores case, so the row that needs one skips here. +#1161: so `check` passed twins wherever the plan kept them, though a disk +that ignores case holds each set as one name. Measured with git 2.43 on +4c7b8acd, the work directory and every repository on such a disk: `execute` +lost `F/x` beside `f` after both legs were pushed (`adopt-lost`), and was +refused at the folder twins `D/x` and `d/x` as `plan-file-reencoded` in 2 +of 4 fresh sources. Now `plan-case-twins` names each set the plan keeps in +one repository, the root or a leg, on every disk, and `execute` refuses it +before any leg exists. + #1136 followed, measured on bba168bd with git 2.43. In each case `check` said `plan ok`, and `execute` made both legs and stopped extracting the spec leg: @@ -186,9 +195,11 @@ def with_paths(repo: Path, paths: tuple[str, ...], own_blobs: bool = False, links: tuple[str, ...] = ()) -> None: """A commit on `main` whose tree gains each of `paths`, whether git takes it as a path or not: `git mktree` writes what it is given, and no - index is asked. With `own_blobs`, each path's blob holds its own name, - so an index that holds one path's blob under another's name is seen. - Each of `links` is written as a symlink (mode 120000), not a file.""" + index is asked. A folder HEAD already holds, `src/` say, gains them + beside what it holds, and is not written twice. With `own_blobs`, each + path's blob holds its own name, so an index that holds one path's blob + under another's name is seen. Each of `links` is written as a symlink + (mode 120000), not a file.""" shared = blob_of(repo, b"x\n") nested: dict = {} for path in paths: @@ -200,14 +211,19 @@ def with_paths(repo: Path, paths: tuple[str, ...], blob_of(repo, f"{path}\n".encode("utf-8")) if own_blobs else shared) - def rows(names: dict) -> list[str]: - return [f"{below[0]} blob {below[1]}\t{name}" - if isinstance(below, tuple) - else f"040000 tree {mktree(repo, rows(below))}\t{name}" - for name, below in names.items()] - - held = git("ls-tree", "HEAD", cwd=repo).stdout.splitlines() - tree = mktree(repo, held + rows(nested)) + def rows(names: dict, folder: str) -> list[str]: + held = {line.split("\t", 1)[1]: line for line in git( + "ls-tree", f"HEAD:{folder}", cwd=repo, + check=False).stdout.splitlines()} + for name, below in names.items(): + inner = f"{folder}/{name}" if folder else name + held[name] = (f"{below[0]} blob {below[1]}\t{name}" + if isinstance(below, tuple) else + f"040000 tree {mktree(repo, rows(below, inner))}" + f"\t{name}") + return list(held.values()) + + tree = mktree(repo, rows(nested, "")) commit = git("commit-tree", tree, "-p", "HEAD", "-m", "Add paths", cwd=repo).stdout.strip() git("update-ref", "refs/heads/main", commit, cwd=repo) @@ -487,7 +503,7 @@ def test_on_a_disk_that_ignores_case_each_twin_is_held_or_named(adopter, assert fates == as_measured(ALL_TWINS, TWINS_REFUSED) -def test_check_names_each_twin_git_refuses_and_no_twin_it_holds(tmp_path): +def test_check_names_each_twin_git_refuses_and_each_set_it_holds(tmp_path): """`check` over a source holding the twins of `file-twins`, `folder-twins` and `a-folder-and-a-file`, and the two refused sets above, with `core.ignorecase` true in the global config. On a disk that @@ -499,8 +515,10 @@ def test_check_names_each_twin_git_refuses_and_no_twin_it_holds(tmp_path): `plan-file-reencoded`; this row does not see it, because the rehearsal asks nothing of a source that holds a path git refuses (`_reencoded_files_findings`, `if refused: return []`). - `refused_paths` meets every twin, and `plan-path-git-refuses` names, in - the tree's order, the four git refuses and none of the six it holds.""" + `refused_paths` meets every twin: `plan-path-git-refuses` names, in the + tree's order, the four git refuses, and `plan-case-twins` (#1161) names + the six it holds, set by set, because the plan keeps them all in the + root, and none of the four, which are the first finding's alone.""" source = a_source(tmp_path) with_paths(source, ALL_TWINS, own_blobs=True) result = checked(planned(source, tmp_path, TWIN_ANSWERS), @@ -513,8 +531,448 @@ def test_check_names_each_twin_git_refuses_and_no_twin_it_holds(tmp_path): "`.Git/A`, `.Git/a` and `.Git/x`. Each clone the split makes of the " "source would stop at the first, `unable to checkout working tree`, " "so `execute` refuses them before it makes either leg, whatever the " - "plan answers for them."] + "plan answers for them.", + twins_kept("3 sets", ROOT_TWINS, "6 paths", + "`A`, `a`, `D/x`, `d/x`, `F/x` and `f`")] assert result.stderr.count(PATH_REMEDY) == 1, result.stderr + assert result.stderr.count(TWINS_REMEDY) == 1, result.stderr + + +# --------------------------------------------------------------------------- +# #1161: the case twins the plan keeps in one repository +# --------------------------------------------------------------------------- + +#: The finding #1161 adds, the code of `execute`'s refusal, how its +#: remediation begins, and the whole of it: a rename in the source, and no +#: answer that sends the twins into different repositories, which a disk +#: that ignores case loses too (#1183, r1189's MUST 1). +PLAN_CASE_TWINS = "plan-case-twins" +TWINS_REMEDY = "Remediation: rename paths in the source until no two" +TWINS_REMEDIATION = ( + "Remediation: rename paths in the source until no two that one " + "repository would hold differ only in case -- each twin of a set but " + "one, or the folder above a twin -- commit that, and re-run `plan`: a " + "plan is written against one commit of the source, and `check` calls it " + "`plan-stale` once the source has moved.") +#: Where `plan-case-twins` says the plan keeps a set. +ROOT_TWINS, SPEC_TWINS, CODE_TWINS = ("the assembly root", "the spec leg", + "the code leg") + + +def twins_kept(sets: str, where: str, paths: str, named: str) -> str: + """`plan-case-twins` as `check` prints it for `sets` sets of twins in + `where`, `paths` paths, named as `named`.""" + return (f"FINDING {PLAN_CASE_TWINS}: the plan keeps {sets} of case twins " + f"in {where}, {paths}, the paths of a set differing only in " + "case, or one naming in another case a folder above another: " + f"{named}. A disk that ignores case, as a macOS or Windows disk " + "does by default, holds each set as one name: a clone made there " + "checks out only one path of each set, and `execute` could stop " + "there before any leg exists, at git's `will not add file " + "alias`, or after both legs are pushed, as `adopt-lost` or at " + "`git rm` mounting the legs, so `execute` refuses them on every " + "disk.") + + +def one_blob_twins(sets: str, where: str, paths: str, named: str) -> str: + """The NOTE `check` prints for `sets` sets of twins in `where` whose + paths fold to one name and hold one blob in one mode, `paths` paths, + named as `named`: `plan-case-twins` leaves them out (r1189's SHOULD + 5).""" + return (f"NOTE the plan keeps {sets} of case twins in {where} whose " + f"paths hold one blob in one mode, {paths}: {named}. A disk that " + "ignores case holds each set as one name, and a clone made there " + "checks out one path of each, git saying `the following paths " + "have collided`; that path holds what each path of the set " + "holds, so none is lost, and `check` names no " + f"`{PLAN_CASE_TWINS}` for them.") + + +#: `(the paths a source commit gains, each with a blob of its own, the +#: plan's answers, what `check` finds)`. An answer left out is the one +#: `plan` wrote: `specs/` goes to the spec leg and `src/` to the code leg. +#: On 4c7b8acd, #1158's head, `check` printed `plan ok` for every one. +TWINS_KEPT = [ + pytest.param(("A", "a"), {"A": ROOT, "a": ROOT}, + [twins_kept("1 set", ROOT_TWINS, "2 paths", "`A` and `a`")], + id="file-twins"), + pytest.param(("D/x", "d/x"), {"D/": ROOT, "d/": ROOT}, + [twins_kept("1 set", ROOT_TWINS, "2 paths", + "`D/x` and `d/x`")], + id="folder-twins"), + pytest.param(("F/x", "f"), {"F/": ROOT, "f": ROOT}, + [twins_kept("1 set", ROOT_TWINS, "2 paths", + "`F/x` and `f`")], + id="a-folder-and-a-file"), + pytest.param(("E", "e/x"), {"E": ROOT, "e/": ROOT}, + [twins_kept("1 set", ROOT_TWINS, "2 paths", + "`E` and `e/x`")], + id="a-file-and-a-folder"), + pytest.param(("AB", "Ab", "aB"), {"AB": ROOT, "Ab": ROOT, "aB": ROOT}, + [twins_kept("1 set", ROOT_TWINS, "3 paths", + "`AB`, `Ab` and `aB`")], + id="three-twins-none-in-lower-case"), + pytest.param(("specs/A", "specs/a"), {}, + [twins_kept("1 set", SPEC_TWINS, "2 paths", + "`specs/A` and `specs/a`")], + id="twins-in-the-spec-leg"), + pytest.param(("src/É", "src/é"), {}, + [twins_kept("1 set", CODE_TWINS, "2 paths", + "`src/É` and `src/é`")], + id="non-ascii-twins-in-the-code-leg"), + pytest.param(("src/A", "src/D/x", "src/a", "src/d/x"), {}, + [twins_kept("2 sets", CODE_TWINS, "4 paths", + "`src/A`, `src/a`, `src/D/x` and `src/d/x`")], + id="two-sets-in-the-code-leg-set-by-set"), + pytest.param(("A", "a", "specs/A", "specs/a", "src/A", "src/a"), + {"A": ROOT, "a": ROOT}, + [twins_kept("1 set", ROOT_TWINS, "2 paths", "`A` and `a`"), + twins_kept("1 set", SPEC_TWINS, "2 paths", + "`specs/A` and `specs/a`"), + twins_kept("1 set", CODE_TWINS, "2 paths", + "`src/A` and `src/a`")], + id="a-set-in-each-repository"), + pytest.param(tuple("AaBbCcDdEeFf"), dict.fromkeys("AaBbCcDdEeFf", ROOT), + [twins_kept("6 sets", ROOT_TWINS, "12 paths", + "`A`, `a`, `B`, `b`, `C`, `c`, `D`, `d`, `E`, " + "`e` and 2 more")], + id="ten-named-and-the-rest-counted"), + # r1189's SHOULD 6: a file against a folder below the top level, and + # one against a folder two levels above another path. + pytest.param(("src/E", "src/e/x"), {}, + [twins_kept("1 set", CODE_TWINS, "2 paths", + "`src/E` and `src/e/x`")], + id="a-file-and-a-folder-in-the-code-leg"), + pytest.param(("f", "F/G/x"), {"f": ROOT, "F/": ROOT}, + [twins_kept("1 set", ROOT_TWINS, "2 paths", + "`F/G/x` and `f`")], + id="a-file-and-a-folder-two-levels-above"), + # Known limit 1: `casefold` folds `ß` to `ss`, as `lower` does not. + pytest.param(("src/straße", "src/STRASSE"), {}, + [twins_kept("1 set", CODE_TWINS, "2 paths", + "`src/STRASSE` and `src/straße`")], + id="sharp-s-folded-as-ss"), + pytest.param(("AB", "Ab", "aB", "ab"), dict.fromkeys(("AB", "Ab", "aB", + "ab"), ROOT), + [twins_kept("1 set", ROOT_TWINS, "4 paths", + "`AB`, `Ab`, `aB` and `ab`")], + id="one-set-of-four"), +] + + +@pytest.mark.parametrize(("paths", "answers", "found"), TWINS_KEPT) +def test_check_names_the_case_twins_the_plan_keeps_in_one_repository( + tmp_path, paths, answers, found): + """A disk that ignores case holds each set as one name. Measured for + #1161 with git 2.43 on 4c7b8acd, the work directory and every + repository on such a disk: `check` printed `plan ok` for each set, and + `execute` lost `F/x` after both legs were pushed (`adopt-lost`), or was + refused at folder twins as `plan-file-reencoded`, git saying `will not + add file alias`. Now `check` names each set on every disk, one finding + for each repository that holds one, root, spec leg, code leg, and says + its remediation once.""" + source = a_source(tmp_path) + with_paths(source, paths, own_blobs=True) + result = checked(planned(source, tmp_path, answers)) + assert "REFUSED" not in result.stderr, result.stderr + assert result.returncode == 1, result.stderr + assert finding_lines(result) == found + assert result.stderr.count(TWINS_REMEDY) == 1, result.stderr + assert TWINS_REMEDIATION in result.stderr.splitlines(), result.stderr + assert "NOTE the plan keeps" not in result.stdout, result.stdout + + +def test_twins_come_before_coverage_and_name_executes_refusal( + adopter, monkeypatch, tmp_path): + """r1189's SHOULD 6: beside a path no entry covers, `plan-case-twins` + is listed before `plan-uncovered`, as a rename in the source is their + first fix, and so it is the code of `execute`'s refusal.""" + source = a_source(tmp_path) + with_paths(source, ("A", "a", "z"), own_blobs=True) + plan = planned(source, tmp_path, {"A": ROOT, "a": ROOT, "z": ROOT}) + lines = plan.read_bytes().decode().splitlines(keepends=True) + start = lines.index(" - path: z\n") + end = start + 1 + while lines[end].startswith(" "): + end += 1 + plan.write_bytes("".join(lines[:start] + lines[end:]).encode()) + assert [code for code, _ in findings(plan)] == [PLAN_CASE_TWINS, + "plan-uncovered"] + refusal = refused_before_any_leg(adopter, monkeypatch, plan, tmp_path) + assert refusal.code == PLAN_CASE_TWINS + + +#: `(the paths a source commit gains, all with one blob, or each with its +#: own, the plan's answers, the finding, the NOTE)`: r1189's SHOULD 5. A +#: set of one name whose paths hold one blob in one mode is checked out as +#: one path holding what each holds, and no path is lost; its own blobs +#: lose one. A file against a folder is the finding with one blob too. In +#: the root the NOTE takes only two paths whose names differ only in their +#: last part (r1189b's SHOULD 1): on a disk that ignores case, folder twins +#: and three paths of one blob there met git's `will not add file alias`, +#: and in a leg they verified. +ONE_BLOB = [ + pytest.param(("A", "a"), False, {"A": ROOT, "a": ROOT}, [], + [one_blob_twins("1 set", ROOT_TWINS, "2 paths", + "`A` and `a`")], + id="one-blob"), + pytest.param(("A", "a"), True, {"A": ROOT, "a": ROOT}, + [twins_kept("1 set", ROOT_TWINS, "2 paths", "`A` and `a`")], + [], id="two-blobs"), + pytest.param(("src/AB", "src/Ab", "src/ab"), False, {}, [], + [one_blob_twins("1 set", CODE_TWINS, "3 paths", + "`src/AB`, `src/Ab` and `src/ab`")], + id="one-blob-in-the-code-leg"), + pytest.param(("F/x", "f"), False, {"F/": ROOT, "f": ROOT}, + [twins_kept("1 set", ROOT_TWINS, "2 paths", + "`F/x` and `f`")], + [], id="a-folder-and-a-file-with-one-blob"), + pytest.param(("D/x", "d/x"), False, {"D/": ROOT, "d/": ROOT}, + [twins_kept("1 set", ROOT_TWINS, "2 paths", + "`D/x` and `d/x`")], + [], id="folder-twins-of-one-blob-in-the-root"), + pytest.param(("AB", "Ab", "ab"), False, + dict.fromkeys(("AB", "Ab", "ab"), ROOT), + [twins_kept("1 set", ROOT_TWINS, "3 paths", + "`AB`, `Ab` and `ab`")], + [], id="three-of-one-blob-in-the-root"), + pytest.param(("D/A", "D/a"), False, {"D/": ROOT}, [], + [one_blob_twins("1 set", ROOT_TWINS, "2 paths", + "`D/A` and `D/a`")], + id="two-of-one-blob-in-one-folder-of-the-root"), + pytest.param(("src/D/x", "src/d/x"), False, {}, [], + [one_blob_twins("1 set", CODE_TWINS, "2 paths", + "`src/D/x` and `src/d/x`")], + id="folder-twins-of-one-blob-in-the-code-leg"), + # The spec leg takes the NOTE as the code leg does: only the root asks + # for two paths in one folder. + pytest.param(("specs/D/x", "specs/d/x"), False, {}, [], + [one_blob_twins("1 set", SPEC_TWINS, "2 paths", + "`specs/D/x` and `specs/d/x`")], + id="folder-twins-of-one-blob-in-the-spec-leg"), + # A file against a folder in a leg, one blob and one last name: the + # whole paths fold to two names, so it is the finding there too. + pytest.param(("src/F/f", "src/f"), False, {}, + [twins_kept("1 set", CODE_TWINS, "2 paths", + "`src/F/f` and `src/f`")], + [], id="a-folder-and-a-file-of-one-blob-in-the-code-leg"), +] + + +@pytest.mark.parametrize(("paths", "own_blobs", "answers", "found", "noted"), + ONE_BLOB) +def test_a_set_of_one_blob_is_a_note_and_not_the_finding( + tmp_path, paths, own_blobs, answers, found, noted): + source = a_source(tmp_path) + with_paths(source, paths, own_blobs=own_blobs) + result = checked(planned(source, tmp_path, answers)) + assert "REFUSED" not in result.stderr, result.stderr + assert result.returncode == (1 if found else 0), result.stderr + assert finding_lines(result) == found + assert [line for line in result.stdout.splitlines() + if line.startswith("NOTE the plan keeps")] == noted + assert ("plan ok" in result.stdout) is not found, result.stdout + assert (TWINS_REMEDY in result.stderr) is bool(found), result.stderr + + +def asked_of(adopter, rows: list[tuple[str, str, str]], leg: str = ROOT): + """`_case_twin_findings` over a tree of `(path, mode, object)` rows, the + plan keeping each in the repository `leg:` `leg` sends it to, the root + unless named: its findings and NOTEs, no remediation but where there is + a finding.""" + plan = SimpleNamespace(entries=[{"path": path, "leg": leg} + for path, _, _ in rows]) + found, remedies, notes = adopter._case_twin_findings( + plan, [(path, mode, oid, 1) for path, mode, oid in rows]) + assert remedies == ([TWINS_REMEDIATION] if found else []) + return found, notes + + +ONE = "1" * 40 +OTHER = "2" * 40 + + +#: `(the tree's rows, the findings, the NOTEs)`: one blob in one mode, +#: asked of the function itself, as `with_paths` writes no mode but a +#: file's or a symlink's. +ONE_BLOB_ROWS = [ + pytest.param([("A", "100644", ONE), ("a", "100755", ONE)], + [twins_kept("1 set", ROOT_TWINS, "2 paths", "`A` and `a`")], + [], id="one-blob-in-two-modes"), + pytest.param([("A", "160000", ONE), ("a", "160000", ONE)], + [twins_kept("1 set", ROOT_TWINS, "2 paths", "`A` and `a`")], + [], id="submodules-at-one-commit"), + pytest.param([("A", "120000", ONE), ("a", "120000", ONE)], [], + [one_blob_twins("1 set", ROOT_TWINS, "2 paths", + "`A` and `a`")], + id="symlinks-to-one-target"), + pytest.param([("AB", "100644", ONE), ("Ab", "100644", ONE), + ("ab", "100644", OTHER)], + [twins_kept("1 set", ROOT_TWINS, "3 paths", + "`AB`, `Ab` and `ab`")], + [], id="one-of-three-another-blob"), + pytest.param([("A", "100644", ONE), ("B", "100644", ONE), + ("a", "100644", ONE), ("b", "100644", OTHER)], + [twins_kept("1 set", ROOT_TWINS, "2 paths", "`B` and `b`")], + [one_blob_twins("1 set", ROOT_TWINS, "2 paths", + "`A` and `a`")], + id="a-set-of-each-in-one-repository"), + pytest.param([("A", "100644", ONE), ("B", "100644", OTHER), + ("a", "100644", ONE), ("b", "100644", OTHER)], [], + [one_blob_twins("2 sets", ROOT_TWINS, "4 paths", + "`A`, `a`, `B` and `b`")], + id="two-sets-of-one-blob-set-by-set"), + # One name as `casefold` folds it, as the set itself was folded. + pytest.param([("STRASSE", "100644", ONE), ("straße", "100644", ONE)], + [], [one_blob_twins("1 set", ROOT_TWINS, "2 paths", + "`STRASSE` and `straße`")], + id="sharp-s-and-ss-of-one-blob"), + # Two paths in the root whose names differ in their folder AND their + # last part are not the NOTE's shape: their folders are twins too. + pytest.param([("D/A", "100644", ONE), ("d/a", "100644", ONE)], + [twins_kept("1 set", ROOT_TWINS, "2 paths", + "`D/A` and `d/a`")], + [], id="two-of-one-blob-in-folders-in-another-case"), +] + + +@pytest.mark.parametrize(("rows", "found", "noted"), ONE_BLOB_ROWS) +def test_only_one_blob_in_one_mode_is_left_to_the_note(adopter, rows, found, + noted): + assert asked_of(adopter, rows) == (found, noted) + + +@pytest.mark.parametrize("mode", ["100755", "120000"], + ids=["executable", "symlink"]) +def test_every_path_of_a_leg_set_is_asked_its_mode(adopter, mode): + """Three paths of one name and one blob in the code leg, the last of + them an executable or a symlink: the set holds two modes, so it is the + finding, and no NOTE, whichever path differs.""" + rows = [("src/AB", "100644", ONE), ("src/Ab", "100644", ONE), + ("src/ab", mode, ONE)] + assert asked_of(adopter, rows, "code") == ( + [twins_kept("1 set", CODE_TWINS, "3 paths", + "`src/AB`, `src/Ab` and `src/ab`")], []) + + +def test_the_one_blob_note_is_the_first_of_the_entries_notes( + adopter, monkeypatch, tmp_path): + """`_entry_findings` lists the twins' NOTE before the pins' NOTEs, as it + lists their finding before the pins' findings.""" + source = a_source(tmp_path) + with_paths(source, ("A", "a")) + plan = adopter.Plan.load(planned(source, tmp_path, {"A": ROOT, "a": ROOT})) + work = tmp_path / "work" + work.mkdir() + opened = plan.open_source(None, work) + monkeypatch.setattr(adopter, "_pin_digest_findings", + lambda _plan: ([], ["NOTE a pin's"])) + found, _, notes = adopter._entry_findings(plan, opened) + assert found == [] + assert notes == [one_blob_twins("1 set", ROOT_TWINS, "2 paths", + "`A` and `a`"), "NOTE a pin's"] + + +#: `(the paths, the answers)` of sources no repository the split makes holds +#: twins of, and `check` finds nothing in, on 4c7b8acd and now. +NO_TWINS_KEPT = [ + pytest.param(("A", "a", "D/x", "d/x"), + {"A": ROOT, "a": "code", "D/": ROOT, "d/": "code"}, + id="twins-in-different-repositories"), + pytest.param(("A", "a"), {"A": ROOT, "a": "drop"}, id="a-twin-dropped"), + pytest.param(("D/x", "d/y"), {"D/": ROOT, "d/": ROOT}, + id="folders-in-another-case-holding-other-names"), + pytest.param(("A", "b", "D/x", "e/x"), + {"A": ROOT, "b": ROOT, "D/": ROOT, "e/": ROOT}, + id="no-twins"), +] + + +@pytest.mark.parametrize(("paths", "answers"), NO_TWINS_KEPT) +def test_paths_no_one_repository_holds_as_one_name_are_no_finding( + tmp_path, paths, answers): + """Folders in another case merge into one whose names differ, and no + path is lost. Twins the plan sends to different repositories, and a + twin it drops beside one it keeps, are not this finding, which asks one + repository at a time; on a disk that ignores case `execute` loses them + too, as `adopt-lost` after both legs are pushed (r1189's SHOULD 3, + #1183).""" + source = a_source(tmp_path) + with_paths(source, paths, own_blobs=True) + assert findings(planned(source, tmp_path, answers)) == [] + + +def test_a_twin_still_unanswered_is_named_by_plan_unresolved_alone( + tmp_path): + """`a` still has `leg: null`: `plan-unresolved` names it, and no set is + named while one of its twins goes nowhere yet.""" + source = a_source(tmp_path) + with_paths(source, ("A", "a"), own_blobs=True) + result = checked(planned(source, tmp_path, {"A": ROOT})) + assert result.returncode == 1, result.stderr + assert codes(result) == ["plan-unresolved"], result.stderr + assert TWINS_REMEDY not in result.stderr, result.stderr + + +def test_execute_refuses_case_twins_before_any_leg(adopter, monkeypatch, + tmp_path): + """The set measured to stop `execute` after both legs were pushed.""" + source = a_source(tmp_path) + with_paths(source, ("F/x", "f"), own_blobs=True) + refusal = refused_before_any_leg( + adopter, monkeypatch, + planned(source, tmp_path, {"F/": ROOT, "f": ROOT}), tmp_path) + assert refusal.code == PLAN_CASE_TWINS + assert TWINS_REMEDY in refusal.remediation + + +def test_execute_takes_sets_of_one_blob_past_checks_findings( + adopter, monkeypatch, tmp_path): + """The NOTE is no refusal: a plan keeping sets of one blob in the root + (`A`, `a`) and in the code leg (`src/A`, `src/a`) is `plan ok`, and + `execute` goes on past `check`'s findings to its next step, which a + sentinel stops here before any leg exists.""" + source = a_source(tmp_path) + with_paths(source, ("A", "a", "src/A", "src/a")) + plan = planned(source, tmp_path, {"A": ROOT, "a": ROOT}) + assert findings(plan) == [] + + class NextStep(Exception): + """`execute` reached the step after `_refuse_what_check_finds`.""" + + def next_step(*_args): + raise NextStep + + monkeypatch.setattr(adopter, "_require_filter_repo", lambda: None) + monkeypatch.setattr(adopter, "_refuse_symlinks_checked_out_as_files", + next_step) + remotes = tmp_path / "remotes" + with pytest.raises(NextStep): + adopter.cmd_execute(Namespace( + plan=str(plan), source=None, local_remote_dir=remotes, + allow_empty_leg=[], yes=True, work_dir=tmp_path / "work")) + assert not remotes.exists() + + +def test_on_a_disk_that_ignores_case_check_names_the_twins_too(tmp_path): + """The rows above ask nothing of the disk, and `plan-case-twins` asks + nothing of it either. This row runs `check` with its work directory on + the disk `git init` is asked about, and is skipped where that disk keeps + case, as every CI disk does (CI is Linux only since #597).""" + work = tmp_path / "work" + git("init", "-q", str(work / "probe"), cwd=tmp_path) + if git("config", "--local", "--get", "core.ignorecase", + cwd=work / "probe", check=False).stdout.strip() != "true": + pytest.skip("needs a disk that ignores case: `git init` wrote no " + "`core.ignorecase=true` in the work directory, so that " + "disk keeps case; the rows above find the twins on " + "every disk") + source = a_source(tmp_path) + with_paths(source, ("A", "a"), own_blobs=True) + result = checked(planned(source, tmp_path, {"A": ROOT, "a": ROOT}), None, + "--work-dir", str(work / "check")) + assert finding_lines(result) == [ + twins_kept("1 set", ROOT_TWINS, "2 paths", "`A` and `a`")] # --------------------------------------------------------------------------- diff --git a/tests/test_adopt_collision_make_reads.py b/tests/test_adopt_collision_make_reads.py index cc4f13f1..d7db30df 100644 --- a/tests/test_adopt_collision_make_reads.py +++ b/tests/test_adopt_collision_make_reads.py @@ -53,8 +53,8 @@ from test_adopt_contracts_dir import (SPEC_PATH, SPLIT_BRANCH, adopt_with_legs_mounted_away, needs_filter_repo) -from test_adopt_make_reads_first import (disk_keeps_case, make, needs_make, - tree_keeping) +from test_adopt_make_reads_first import (disk_keeps_case, keeps_twins, make, + needs_make, tree_keeping) from test_adopt_printed_sentences import NAMES, entries_of, makefile_lines from test_pinned_copy_text import ADOPTED_SENTENCES, TEMPLATE_AGENTS, flat @@ -293,7 +293,9 @@ def copied(clone: Path, name: str) -> Path: @needs_filter_repo @needs_make -@pytest.mark.parametrize("kept, first", CLONES) +@pytest.mark.parametrize("kept, first", + [row for row in CLONES + if not keeps_twins(row.values[0])]) def test_the_sentences_are_true_of_what_make_does_in_the_clone( tmp_path, kept, first): """r1046's measurement, taken for each kept name (#1048). After a real @@ -304,7 +306,14 @@ def test_the_sentences_are_true_of_what_make_does_in_the_clone( of a `GNUmakefile` or a `makefile` is what `make` does: `make contracts-dir` has no rule at the root until that file holds the block, or includes a Makefile that does, and then it prints the spec leg's - `contracts/`.""" + `contracts/`. + + The rows that keep `Makefile` and `makefile` side by side, + `Makefile+makefile` and `all-three`, are left out: the plan keeps a set + of case twins in the root, which `check` names and `execute` refuses + before any leg as `plan-case-twins` (#1161), so there is no clone to run + `make` in. `test_execute_refuses_makefile_beside_makefile_before_any_leg`, + in `test_adopt_make_reads_first.py`, pins that refusal for those trees.""" if not disk_keeps_case(tmp_path): pytest.skip("this disk ignores case: it cannot hold the rows apart") adopted = adopt_with_legs_mounted_away(tmp_path, by_hand=False, @@ -359,7 +368,9 @@ def test_the_sentences_are_true_of_what_make_does_in_the_clone( @needs_filter_repo @needs_make -@pytest.mark.parametrize("first", ["GNUmakefile", "makefile"]) +@pytest.mark.parametrize("first", + [name for name in ("GNUmakefile", "makefile") + if not keeps_twins(("Makefile", name))]) def test_where_the_file_make_reads_includes_makefile_the_merge_alone_is_enough( tmp_path, first): """#1105's review, SHOULD-1, measured in a real adoption's clone. The @@ -370,7 +381,15 @@ def test_where_the_file_make_reads_includes_makefile_the_merge_alone_is_enough( prints the spec leg's `contracts/`. The split message's follow-up says `make` reads that file and that the targets reach it once it includes a Makefile that has them, which the merge makes so, and does not say they - do not arrive with that merge alone.""" + do not arrive with that merge alone. + + The `makefile` row is left out: it keeps `Makefile` and `makefile` side + by side in the root, each with its own blob, a set of case twins, which + `check` names and `execute` refuses before any leg as `plan-case-twins` + (#1161), so there is no clone to run `make` in. + `test_execute_refuses_makefile_beside_makefile_before_any_leg`, in + `test_adopt_make_reads_first.py`, pins that refusal for such a tree. + The `GNUmakefile` row keeps no twins and runs as it did.""" if not disk_keeps_case(tmp_path): pytest.skip("this disk ignores case: it cannot hold the rows apart") tree = tree_keeping(("Makefile", first)) diff --git a/tests/test_adopt_make_reads_first.py b/tests/test_adopt_make_reads_first.py index 8e577317..eb293ef6 100644 --- a/tests/test_adopt_make_reads_first.py +++ b/tests/test_adopt_make_reads_first.py @@ -22,6 +22,13 @@ has its own `which` target, so `make which` at the root of a real adoption prints the name of the file GNU make read, and the line names that file. +Where the source keeps `Makefile` and `makefile` side by side (the rows +`Makefile+makefile` and `all-three`), the plan keeps a set of case twins in +the root. Since #1161 `check` names it as `plan-case-twins` on every disk and +exits 1, and `execute` refuses it before any leg exists, so those two rows ask +`check` for the finding beside the line and `execute` for its refusal, and +are in no clone: there is none to run `make` in. + README.md's sentence (#977's (b)) said "the root Makefile it materializes exports `CONTRACTS_DIR`", untrue where the source keeps a `Makefile` of its own. Its section is bound to the handbook's `adopt` section @@ -44,8 +51,9 @@ import pytest -from conftest import (ADOPT, REPO, git, load_without_bytecode, - make_source_repo, run_script, write_plan) +from conftest import (ADOPT, REPO, findings, git, load_without_bytecode, + make_source_repo, refused_before_any_leg, run_script, + write_plan) from test_adopt_contracts_dir import (EDITS, MAKEFILE_FOLLOW_UP, OWN_MAKEFILE_FOLLOW_UP, PROJECT, SPEC_PATH, SPLIT_BRANCH, TREE, @@ -126,6 +134,29 @@ def reads(name: str, joint: str = "; and") -> str: "GNUmakefile", id="all-three"), ] +#: The code of the finding #1161 adds and of `execute`'s refusal, and what +#: `check` says first of the one set the rows that keep `Makefile` and +#: `makefile` side by side hold. +PLAN_CASE_TWINS = "plan-case-twins" +TWINS_FINDING = ( + f"FINDING {PLAN_CASE_TWINS}: the plan keeps 1 set of case twins in the " + "assembly root, 2 paths, the paths of a set differing only in case, or " + "one naming in another case a folder above another: `Makefile` and " + "`makefile`. A disk that ignores case") + + +def keeps_twins(kept: tuple) -> bool: + """Whether the source keeps `Makefile` and `makefile` side by side.""" + return {"Makefile", "makefile"} <= set(kept) + + +#: The rows that keep both names, which `check` and `execute` meet as case +#: twins, by their kept names; and the rows `execute` splits that keep a +#: `Makefile` or one of the other two names, which a clone can run `make` in. +TWIN_ROWS = [pytest.param(row.values[0], id=row.id) + for row in ROWS if keeps_twins(row.values[0])] +CLONE_ROWS = [row for row in ROWS[1:] if not keeps_twins(row.values[0])] + #: `(path, leg)` rows no shipped rule writes, which a `--path-policy` rule can #: (a person's answer in the plan changes no follow-up): `MAKEFILE` kept in #: the root, a name GNU make never reads where case is kept and one a disk @@ -231,7 +262,9 @@ def test_plan_writes_the_line_and_check_prints_it(tmp_path, kept, said, first): """The rules keep each of GNU make's three names in the root (`root-project-makefile`), so `plan` writes the line for the source as - it is, and `check` prints it as the plan holds it.""" + it is, and `check` prints it as the plan holds it. Where the source + keeps `Makefile` and `makefile` side by side, `check` also names them as + case twins (`plan-case-twins`) and exits 1, on every disk.""" if not disk_keeps_case(tmp_path): pytest.skip("this disk ignores case: it cannot hold the rows apart") source = make_source_repo(tmp_path / "Thing", tree_keeping(kept), @@ -242,10 +275,41 @@ def test_plan_writes_the_line_and_check_prints_it(tmp_path, kept, said, follow_ups = [str(item) for item in load_yaml(plan)["follow_ups"]] assert makefile_lines(follow_ups) == [said] checked = run_script(ADOPT, "check", "--plan", str(plan)) - assert checked.returncode == 0, checked.stderr + checked.stdout + if keeps_twins(kept): + found = [line for line in checked.stderr.splitlines() + if line.startswith("FINDING ")] + assert checked.returncode == 1, checked.stderr + checked.stdout + assert len(found) == 1 and found[0].startswith(TWINS_FINDING), found + else: + assert checked.returncode == 0, checked.stderr + checked.stdout assert f" follow-up: {said}" in checked.stdout +@pytest.mark.parametrize("kept", TWIN_ROWS) +def test_execute_refuses_makefile_beside_makefile_before_any_leg( + adopter, monkeypatch, tmp_path, kept): + """A disk that ignores case holds the root's `Makefile` and `makefile` + as one name, so `execute` refuses them on every disk, as it does any set + of case twins (#1161), before it makes a leg: no bare repository, no + clone of the assembly or a leg in the work directory, and no split + branch in the source. Before #1161 the same source was split and + verified on a disk that keeps case, which left all three.""" + if not disk_keeps_case(tmp_path): + pytest.skip("this disk ignores case: it cannot hold the rows apart") + source = make_source_repo(tmp_path / "Thing", tree_keeping(kept), + edits=EDITS) + plan = tmp_path / "adoption-plan.yaml" + written = write_plan(source, plan, project=PROJECT) + assert written.returncode == 0, written.stderr + written.stdout + assert [code for code, _ in findings(plan)] == [PLAN_CASE_TWINS] + refusal = refused_before_any_leg(adopter, monkeypatch, plan, tmp_path) + assert refusal.code == PLAN_CASE_TWINS + assert "`Makefile` and `makefile`" in refusal.detail + assert git("branch", "--list", SPLIT_BRANCH, + cwd=source).stdout.strip() == "" + assert not list((tmp_path / "work").glob(f"{PROJECT}*")) + + def make(clone: Path, *args: str) -> subprocess.CompletedProcess: return subprocess.run( ["make", "--no-print-directory", "-s", *args], cwd=str(clone), @@ -255,7 +319,7 @@ def make(clone: Path, *args: str) -> subprocess.CompletedProcess: @needs_filter_repo @needs_make -@pytest.mark.parametrize("kept, said, first", ROWS[1:]) +@pytest.mark.parametrize("kept, said, first", CLONE_ROWS) def test_the_split_message_names_the_file_make_reads_in_the_clone( tmp_path, kept, said, first): """The measurement the line rests on, on a disk that keeps case. After a @@ -264,7 +328,8 @@ def test_the_split_message_names_the_file_make_reads_in_the_clone( says GNU make reads; `make contracts-dir` there has no rule, for that file is the source's; and the block is in the file the line says the shape's Makefile is written as, where the same target prints the spec - leg's `contracts/`.""" + leg's `contracts/`. The rows that keep `Makefile` and `makefile` side by + side are not here: `execute` refuses them (#1161).""" if not disk_keeps_case(tmp_path): pytest.skip("this disk ignores case: it cannot hold the rows apart") adopted = adopt_with_legs_mounted_away(tmp_path, by_hand=False, diff --git a/tests/test_adopt_next_make_reads_first.py b/tests/test_adopt_next_make_reads_first.py index 8d221e77..38cde83b 100644 --- a/tests/test_adopt_next_make_reads_first.py +++ b/tests/test_adopt_next_make_reads_first.py @@ -43,8 +43,8 @@ from test_adopt_contracts_dir import EDITS, TREE, needs_filter_repo from test_adopt_e2e import (BOTH_ASIDE, kept_scripts, local_next, next_block, pasted) -from test_adopt_make_reads_first import (disk_keeps_case, make, needs_make, - own_makefile) +from test_adopt_make_reads_first import (disk_keeps_case, keeps_twins, make, + needs_make, own_makefile) PROJECT = "Northwind" SCRIPT = "scripts/bootstrap.py" @@ -272,7 +272,8 @@ def executed_and_cloned(tmp_path: Path, kept: tuple, answer=None): @needs_filter_repo @needs_make @pytest.mark.parametrize("kept, said, first", - [row for row in ROWS if row.values[2] != "Makefile"]) + [row for row in ROWS if row.values[2] != "Makefile" + and not keeps_twins(row.values[0])]) def test_the_printed_bootstrap_runs_in_the_clone_next_makes( tmp_path, kept, said, first): """The measurement, on a disk that keeps case. After a real `execute`, @@ -288,7 +289,14 @@ def test_the_printed_bootstrap_runs_in_the_clone_next_makes( includes a Makefile holding the shape's targets (the root's `Makefile`, once the follow-up's merge is made where the source kept its own), `make -n bootstrap` runs the shape's recipe. The merge alone does not: - that is what the NOTE said before.""" + that is what the NOTE said before. + + The rows that keep `Makefile` and `makefile` side by side, + `Makefile+makefile` and `all-three`, are left out: the plan keeps a set + of case twins in the root, which `execute` refuses before any leg as + `plan-case-twins` (#1161), so there is no clone to run `make` in. + `test_execute_refuses_makefile_beside_makefile_before_any_leg`, in + `test_adopt_make_reads_first.py`, pins that refusal for those trees.""" if not disk_keeps_case(tmp_path): pytest.skip("this disk ignores case: it cannot hold the rows apart") lines, clone = executed_and_cloned(