diff --git a/adopt-project.py b/adopt-project.py index 4e4b3d0c..f6ebf40d 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -10265,7 +10265,7 @@ def _unsafe_path_findings(plan: Plan) -> tuple[list[str], list[str]]: # 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 +# git's `will not add file alias` there, which the #654 rehearsal reported # 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 @@ -10275,6 +10275,23 @@ def _unsafe_path_findings(plan: Plan) -> tuple[list[str], list[str]]: # 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. +# +# ONE FINDING FOR ONE SET (#1184). The rehearsal still asked about the +# twins this finding names, and git's `will not add file alias` was said +# again, as `plan-file-reencoded`, a finding about bytes. Measured on +# f981468b with git 2.43, on a disk that keeps case under a global +# `core.ignorecase=true`, in 3 of 3 runs each: folder twins (`D/x`, `d/x`) +# of one blob or two, three paths of one blob (`AB`, `Ab`, `ab`) and two +# `.gitattributes` in folder twins, all in the root; and as git stops the +# split's `git add` there, a CRLF `notes.txt` the root keeps beside the +# folder twins went unnamed, which the same source names without that +# setting. Twins git would store as other bytes, CRLF under `text`, were +# named again too, with the setting or without it, and under it in the +# root as one twin of the two. So the rehearsal asks nothing about the +# paths this finding names (`_case_twins_kept`), as it asks nothing of a +# source holding a path git refuses, and asks the rest; the sets the NOTE +# takes are still asked. The rename this finding asks for comes first, and +# the `plan` and `check` after it ask about the renamed files. #: `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. @@ -10336,6 +10353,29 @@ def _one_blob_twins(named: list[str], kinds: dict, leg: str) -> bool: and kinds[named[0]][0] != GITLINK_MODE) +def _case_twins_kept(plan: Plan, tree: list, + refused: list[str] | tuple = () + ) -> list[tuple[str, list[list[str]], list[list[str]]]]: + """`(where, the sets `plan-case-twins` names, the sets left to its NOTE)` + for each repository the split makes, in `TWIN_REPOSITORIES`' order, as + `_case_twin_findings` says them and the #654 rehearsal leaves the first + out (#1184). Only the paths ONE answered entry sends there are asked + (`_answered_legs`), and none git refuses, `refused`.""" + 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]) + kept = [] + 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)] + kept.append((where, [named for named in sets if named not in alike], + alike)) + return kept + + def _case_twin_findings(plan: Plan, tree: list, refused: list[str] | tuple = () ) -> tuple[list[str], list[str], list[str]]: @@ -10348,17 +10388,8 @@ def _case_twin_findings(plan: Plan, tree: list, (`_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] + for where, sets, alike in _case_twins_kept(plan, tree, refused): if alike: same = [path for named in alike for path in named] notes.append( @@ -13681,9 +13712,12 @@ def _reencoded_attributes_findings(plan: Plan, source: Source, # and counting the rest: what the root keeps, what goes to a leg or is # dropped, the pinned copies, and then each of git's refusals. The root's own # `.gitattributes` is #530's D5(a) question above, and is not asked again; a -# symlink and a gitlink get no rule applied. The files the split renders -# from values it knows only once the legs are pushed (the manifest, the pin -# files, the README) and the mounts' `.gitmodules` are not asked. +# symlink and a gitlink get no rule applied. The case twins +# `plan-case-twins` names are that finding's alone, and asked nothing here +# (#1184): not as files, nor as rules the split's `git add` stores again, +# though their rules still reach the files beside them. The files the split +# renders from values it knows only once the legs are pushed (the manifest, +# the pin files, the README) and the mounts' `.gitmodules` are not asked. #: `check`'s finding, and the code of `execute`'s refusal, for a file the #: plan keeps or moves, or a pinned copy, that this machine's git would store @@ -14124,7 +14158,10 @@ def _reencoded_files_findings(plan: Plan, source: Source, tree: list, asked nothing more, and `_Unasked` is raised where git cannot be asked (#910). `refused` is `_paths_git_refuses`' answer, where `_entry_findings` has it, so the rehearsal asks no path question of its own; given none, - it asks.""" + it asks. The case twins `plan-case-twins` names are asked nothing + (#1184): git's `will not add file alias` for them, under + `core.ignorecase=true`, said that finding again as this one, and + stopped the split's `git add` before it stored any other file.""" kept_rows = _kept_in_root(plan.entries, tree) kept = {path for path, _, _ in kept_rows} disk = _KeptTree(kept_rows, False) @@ -14132,8 +14169,10 @@ def _reencoded_files_findings(plan: Plan, source: Source, tree: list, own = top[0] if top is not None and top[1] in FILE_MODES else None at_the_top = top[0] if top is not None else None order = {path: at for at, (path, _, _, _) in enumerate(tree)} + twins = {path for _, sets, _ in _case_twins_kept(plan, tree, refused or ()) + for named in sets for path in named} files = [(path, mode, oid) for path, mode, oid, _ in tree - if mode in FILE_MODES and path != own] + if mode in FILE_MODES and path != own and path not in twins] rules = [(path, mode, oid) for path, mode, oid, _ in tree if mode in (*FILE_MODES, SYMLINK_MODE) and path.rsplit("/", 1)[-1].casefold() == ATTRIBUTES] @@ -14186,8 +14225,8 @@ def _reencoded_files_findings(plan: Plan, source: Source, tree: list, return [_refused_finding(COMPARE_REFUSED, compare_said)] return [_moved_finding(taken)] if taken else [] held = {*[row for row in wanted if row[0] in kept], - *[row for row in rules - if row[0] in kept and row[0] != at_the_top]} + *[row for row in rules if row[0] in kept + and row[0] != at_the_top and row[0] not in twins]} asked.hold(STORED, held) stored_said = asked.refused(["add", "-u"], STORED) blobs = {path: oid for path, _, oid in held} diff --git a/tests/test_adopt_check_throwaway_repos.py b/tests/test_adopt_check_throwaway_repos.py index 8c19f063..981efe08 100644 --- a/tests/test_adopt_check_throwaway_repos.py +++ b/tests/test_adopt_check_throwaway_repos.py @@ -93,6 +93,16 @@ git's words. So a `.gitattributes` the plan keeps under `.github/`, whose filter fails, is the split's store's refusal alone, its folder named in UTF-8 or not (r1253's SHOULD-2). + +#1184 followed, measured on f981468b with git 2.43 on a disk that keeps +case, under a global `core.ignorecase=true`. Where `plan-case-twins` named +folder twins (`D/x`, `d/x`), three paths of one blob (`AB`, `Ab`, `ab`) or +two `.gitattributes` in folder twins, all kept in the root, the +rehearsal's `git add` refused them, `will not add file alias`, and `check` +said so again as `plan-file-reencoded`, in 3 of 3 runs each; a CRLF +`notes.txt` kept beside the folder twins went unnamed, as git stopped +there. Now the rehearsal asks nothing about the twins `plan-case-twins` +names, and asks the rest as it does without that setting. """ from __future__ import annotations @@ -522,8 +532,9 @@ def test_check_names_each_twin_git_refuses_and_each_set_it_holds(tmp_path): repository, so the global setting is the one each throwaway reads. `plan` asks about each twin as a path of its own (`planned` answers each by name). Under a global `core.ignorecase=true` the #654 - rehearsal's `git add` refuses the folder twins `D/x` and `d/x` as - `plan-file-reencoded`; this row does not see it, because the rehearsal + rehearsal's `git add` refused the folder twins `D/x` and `d/x` as + `plan-file-reencoded` until #1184, which leaves out the twins + `plan-case-twins` names; this row never saw 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: `plan-path-git-refuses` names, in the @@ -986,6 +997,155 @@ def test_on_a_disk_that_ignores_case_check_names_the_twins_too(tmp_path): twins_kept("1 set", ROOT_TWINS, "2 paths", "`A` and `a`")] +# --------------------------------------------------------------------------- +# #1184: one finding for one set, under `core.ignorecase=true` +# --------------------------------------------------------------------------- + +#: What git says where its `git add` will not hold a twin beside another. +FILE_ALIAS = "fatal: will not add file alias" + +#: `(the twins a source commit gains, each with a blob of its own or all +#: with one, the plan's answers, how `plan-case-twins` names them)`. On +#: f981468b, with git 2.43 under a global `core.ignorecase=true`, `check` +#: named each set again, in 3 of 3 runs, as `plan-file-reencoded`, git +#: saying `will not add file alias` at the rehearsal's `git add`. +ALIASED_TWINS = [ + pytest.param(("D/x", "d/x"), True, {"D/": ROOT, "d/": ROOT}, + "`D/x` and `d/x`", id="folder-twins"), + pytest.param(("D/x", "d/x"), False, {"D/": ROOT, "d/": ROOT}, + "`D/x` and `d/x`", id="folder-twins-of-one-blob"), + pytest.param(("AB", "Ab", "ab"), False, + dict.fromkeys(("AB", "Ab", "ab"), ROOT), + "`AB`, `Ab` and `ab`", id="three-of-one-blob"), + pytest.param(("D/.gitattributes", "d/.gitattributes"), True, + {"D/": ROOT, "d/": ROOT}, + "`D/.gitattributes` and `d/.gitattributes`", + id="gitattributes-in-folder-twins"), +] + + +def ignoring_case(base: Path) -> dict: + """A global `core.ignorecase=true`, which a throwaway repository on a + disk that keeps case reads, as one on a disk that ignores case reads + the `true` its `git init` writes.""" + return config_with(base, "[core]", "\tignorecase = true") + + +@pytest.mark.parametrize(("twins", "own_blobs", "answers", "named"), + ALIASED_TWINS) +def test_a_set_git_will_not_add_beside_its_twin_is_named_once( + tmp_path, twins, own_blobs, answers, named): + """#1184: the #654 rehearsal asks nothing about the twins + `plan-case-twins` names, so git's `will not add file alias` for them is + not said again as `plan-file-reencoded`, a finding about bytes.""" + source = a_source(tmp_path) + with_paths(source, twins, own_blobs=own_blobs) + result = checked(planned(source, tmp_path, answers), + ignoring_case(tmp_path)) + assert "REFUSED" not in result.stderr, result.stderr + assert result.returncode == 1, result.stderr + assert finding_lines(result) == [ + twins_kept("1 set", ROOT_TWINS, f"{len(twins)} paths", named)] + assert FILE_ALIAS not in result.stderr, result.stderr + + +def test_execute_refuses_such_a_set_as_its_one_finding(adopter, monkeypatch, + tmp_path): + """`execute`'s refusal lists `check`'s findings: the set, once.""" + for name, value in ignoring_case(tmp_path).items(): + monkeypatch.setenv(name, value) + source = a_source(tmp_path) + with_paths(source, ("D/x", "d/x"), own_blobs=True) + refusal = refused_before_any_leg( + adopter, monkeypatch, + planned(source, tmp_path, {"D/": ROOT, "d/": ROOT}), tmp_path) + assert refusal.code == PLAN_CASE_TWINS + assert refusal.detail.splitlines()[1:] == [ + " " + twins_kept("1 set", ROOT_TWINS, "2 paths", "`D/x` and `d/x`")] + + +def test_a_file_beside_the_twins_is_asked_as_without_the_setting(tmp_path): + """git stopped the rehearsal's `git add` at the alias, and so the CRLF + `notes.txt` the root keeps beside the twins, under `* text eol=lf`, went + unnamed under `core.ignorecase=true` on f981468b, 3 of 3 runs. Now the + rest is asked, and `check` says what it says without the setting, the + file's `plan-file-reencoded` included.""" + source = a_source(tmp_path, files={ROOT_ATTRIBUTES: TEXT_EOL_LF, + NOTES: CRLF}) + with_paths(source, ("D/x", "d/x"), own_blobs=True) + plan = planned(source, tmp_path, {"D/": ROOT, "d/": ROOT, NOTES: ROOT}) + without, ignored = checked(plan), checked(plan, ignoring_case(tmp_path)) + assert codes(without) == [PLAN_CASE_TWINS, REENCODED_FILES], \ + without.stderr + assert KEPT_CRLF in finding_lines(without)[1] + assert "`notes.txt`" in finding_lines(without)[1] + assert finding_lines(ignored) == finding_lines(without), ignored.stderr + + +@pytest.mark.parametrize(("prefix", "answers", "where"), [ + pytest.param("", {"A.md": ROOT, "a.md": ROOT}, ROOT_TWINS, id="root"), + pytest.param("src/", {}, CODE_TWINS, id="code-leg"), +]) +def test_twins_git_would_store_as_other_bytes_are_the_set_alone( + tmp_path, prefix, answers, where): + """`A.md` and `a.md`, each CRLF under `* text eol=lf`, and each its own + blob, in the root or in the code leg: on f981468b `check` named them as + `plan-file-reencoded` too, after `plan-case-twins`, the root's by their + names and the leg's as files the plan moves. The rename + `plan-case-twins` asks for comes first, and the `plan` and `check` after + it ask about the renamed files.""" + source = a_source(tmp_path, files={ + ROOT_ATTRIBUTES: TEXT_EOL_LF, f"{prefix}A.md": CRLF, + f"{prefix}a.md": b"three\r\n"}) + plan = planned(source, tmp_path, answers) + for env in (None, ignoring_case(tmp_path)): + result = checked(plan, env) + assert finding_lines(result) == [twins_kept( + "1 set", where, "2 paths", + f"`{prefix}A.md` and `{prefix}a.md`")], result.stderr + + +def test_the_twins_rules_still_reach_the_files_beside_them(tmp_path): + """Twin `.gitattributes` files stay checked out, and the files in the + folders that hold twins stay asked: `D/` and `d/` each keep the rule + `*.txt text eol=lf` and a CRLF `.txt`, and `check` names the two files + as `plan-file-reencoded` after `plan-case-twins`, with the setting as + without it.""" + rule = b"*.txt text eol=lf\n" + paths = ("D/.gitattributes", "d/.gitattributes", "D/n.txt", "d/m.txt") + source = a_source(tmp_path, files=dict(zip(paths, + (rule, rule, CRLF, CRLF)))) + plan = planned(source, tmp_path, dict.fromkeys(paths, ROOT)) + without, ignored = checked(plan), checked(plan, ignoring_case(tmp_path)) + assert codes(without) == [PLAN_CASE_TWINS, REENCODED_FILES], \ + without.stderr + assert "`D/n.txt` and `d/m.txt`" in finding_lines(without)[1] + assert finding_lines(ignored) == finding_lines(without), ignored.stderr + + +def test_a_set_the_note_takes_is_still_asked(tmp_path): + """The control: the rehearsal leaves out only the sets the finding + names. `A.md` and `a.md`, one CRLF blob under `* text eol=lf`, are the + NOTE's, and are still `plan-file-reencoded`. Under the setting git's + index holds the set under one name, and the finding names that one, as + it did on f981468b.""" + source = a_source(tmp_path, files={ROOT_ATTRIBUTES: TEXT_EOL_LF, + "A.md": CRLF, "a.md": CRLF}) + plan = planned(source, tmp_path, {"A.md": ROOT, "a.md": ROOT}) + for env in (None, ignoring_case(tmp_path)): + result = checked(plan, env) + assert codes(result) == [REENCODED_FILES], result.stderr + assert [line for line in result.stdout.splitlines() + if line.startswith("NOTE the plan keeps")] == [ + one_blob_twins("1 set", ROOT_TWINS, "2 paths", + "`A.md` and `a.md`")] + if env is None: + assert ("the plan keeps 2 files in the assembly root, and this " + "machine's `git add` would store them as other bytes " + "than their blobs once the split's checkout has written " + "them: `A.md` and `a.md`.") in finding_lines(result)[0] + + # --------------------------------------------------------------------------- # L1-92: in the work directory # ---------------------------------------------------------------------------