From 1913e27c4f0b1fe10f3b3386290a27626f8ef7d7 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 12:35:00 +0000 Subject: [PATCH 01/17] tests/test_adopt_check_throwaway_repos.py: rows pin that `refused_paths` holds each case twin under its own name with its own blob, or names it, with `core.ignorecase` true (#1033) `refused_paths` (#910, #1031) writes its own index with `update-index -z --index-info` and no row said what that index makes of case twins where `core.ignorecase` is true, the default on a disk that ignores case. Measured with git 2.43: git holds both twins of a path it takes (a file, a folder, a folder beside a file), each with its own blob, and leaves out both twins of a path it refuses, which `plan-path-git-refuses` names. - `with_paths` gains `own_blobs`, so a twin held with its sibling's blob would show; - ten rows ask `refused_paths` in a rehearsal whose own config holds `core.ignorecase` true or false (the control), and read the index with `git ls-files -s`; - one row asks a disk that ignores case itself, and skips where `git init` wrote no `core.ignorecase=true` (every CI disk, Linux since #597); - one `check` row, under a global `core.ignorecase=true`, pins that `plan-path-git-refuses` names the four twins git refuses and none of the six it holds, and that `plan` asks about each twin by name. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- tests/test_adopt_check_throwaway_repos.py | 166 +++++++++++++++++++++- 1 file changed, 159 insertions(+), 7 deletions(-) diff --git a/tests/test_adopt_check_throwaway_repos.py b/tests/test_adopt_check_throwaway_repos.py index e105895b..70d56ad4 100644 --- a/tests/test_adopt_check_throwaway_repos.py +++ b/tests/test_adopt_check_throwaway_repos.py @@ -29,6 +29,14 @@ `plan-path-git-refuses`, a repository git cannot make or ask there is `adopt-git-unasked`, which says why, and the probe's failure stands for its finding, with a NOTE. + +#1033: `refused_paths` writes its own index with `update-index -z +--index-info`, and no row said what that index makes of case twins (`a` and +`A`) where `core.ignorecase` is true, the default on a disk that ignores +case. Measured with git 2.43, it holds both twins of a path git takes, each +with its own blob, and `plan-path-git-refuses` names both twins of a path git +refuses. This bench has no usable disk that ignores case, so the row that +needs one skips here. """ from __future__ import annotations @@ -125,23 +133,30 @@ def mktree(repo: Path, rows: list[str]) -> str: return made.stdout.decode().strip() -def with_paths(repo: Path, paths: tuple[str, ...]) -> None: +def blob_of(repo: Path, data: bytes) -> str: + return subprocess.run(["git", "hash-object", "-w", "--stdin"], + cwd=str(repo), input=data, capture_output=True, + check=True).stdout.decode().strip() + + +def with_paths(repo: Path, paths: tuple[str, ...], + own_blobs: bool = False) -> 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.""" - blob = subprocess.run(["git", "hash-object", "-w", "--stdin"], - cwd=str(repo), input=b"x\n", capture_output=True, - check=True).stdout.decode().strip() + 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.""" + shared = blob_of(repo, b"x\n") nested: dict = {} for path in paths: *directories, name = path.split("/") here = nested for directory in directories: here = here.setdefault(directory, {}) - here[name] = None + here[name] = (blob_of(repo, f"{path}\n".encode("utf-8")) + if own_blobs else shared) def rows(names: dict) -> list[str]: - return [f"100644 blob {blob}\t{name}" if below is None + return [f"100644 blob {below}\t{name}" if isinstance(below, str) else f"040000 tree {mktree(repo, rows(below))}\t{name}" for name, below in names.items()] @@ -295,6 +310,143 @@ def test_the_rehearsal_asks_nothing_more_of_a_tree_git_refuses(adopter, assert list(work.iterdir()) == [] +# --------------------------------------------------------------------------- +# #1033: case twins, with `core.ignorecase` true +# --------------------------------------------------------------------------- + +#: `(the case twins a source commit holds, the ones git refuses)`. `git +#: mktree` writes twins that no checkout on a disk that ignores case could, +#: each with a blob of its own (`with_paths`). +CASE_TWINS = [ + pytest.param(("A", "a"), (), id="file-twins"), + pytest.param(("D/x", "d/x"), (), id="folder-twins"), + pytest.param(("F/x", "f"), (), id="a-folder-and-a-file"), + pytest.param((".Git/A", ".Git/a"), (".Git/A", ".Git/a"), + id="twins-under-a-path-git-refuses"), + pytest.param((".GIT/x", ".Git/x"), (".GIT/x", ".Git/x"), + id="twins-git-refuses"), +] +#: Every twin above in one source commit; the ones git refuses, in the +#: tree's order; and the plan's answers for them all. +ALL_TWINS = ("A", "a", "D/x", "d/x", "F/x", "f", ".Git/A", ".Git/a", + ".GIT/x", ".Git/x") +TWINS_REFUSED = (".GIT/x", ".Git/A", ".Git/a", ".Git/x") +TWIN_ANSWERS = {"A": ROOT, "a": ROOT, "D/": ROOT, "d/": ROOT, "F/": ROOT, + "f": ROOT, ".GIT/": ROOT, ".Git/": ROOT} + + +def with_twins(adopter, base: Path, twins: tuple[str, ...]): + """`a_source` whose commit gains `twins`, each with a blob of its own, + opened as `check` opens it, with `base/work` its work directory.""" + path = a_source(base) + with_paths(path, twins, own_blobs=True) + work = base / "work" + work.mkdir() + return adopter.Source(path, None, "main", + git("rev-parse", "HEAD", cwd=path).stdout.strip(), + work_root=work) + + +def twins_asked(adopter, source, twins: tuple[str, ...], + ignorecase: str | None) -> tuple[str, list[str], dict]: + """`(core.ignorecase as the rehearsal's own config holds it, the paths + `refused_paths` names, each twin's fate)`, the fate read from the index + with `git ls-files -s`: `held` under its own name with its own blob, + `named`, `held with another blob`, or `dropped without a word`. Where + `ignorecase` is given it is written into that config first, as `git + init` writes `true` on a disk that ignores case; None keeps what `git + init` wrote. The index must hold every path of the tree it does not + name, and nothing else.""" + tree = source.tree() + blobs = {path: oid for path, _, oid, _ in tree} + with adopter._SplitRehearsal(source) as asked: + if ignorecase is not None: + git("config", "core.ignorecase", ignorecase, cwd=asked.work) + written = git("config", "--local", "--get", "core.ignorecase", + cwd=asked.work, check=False).stdout.strip() + named = asked.refused_paths(tree) + held = asked.staged(adopter.PATHS_HELD) + assert sorted(held) == [path for path, _, _, _ in tree + if path not in named], held + return written, named, { + twin: "named" if twin in named + else "held" if held.get(twin) == blobs[twin] + else "held with another blob" if twin in held + else "dropped without a word" for twin in twins} + + +def as_measured(twins: tuple[str, ...], refused: tuple[str, ...]) -> dict: + return {twin: "named" if twin in refused else "held" for twin in twins} + + +@pytest.mark.parametrize("ignorecase", ["true", "false"]) +@pytest.mark.parametrize(("twins", "refused"), CASE_TWINS) +def test_each_case_twin_is_held_with_its_own_blob_or_named( + adopter, tmp_path, twins, refused, ignorecase): + """`refused_paths` writes its index with `update-index -z --index-info`. + Measured with git 2.43 and `core.ignorecase` true in the rehearsal's + own config: + - git holds both twins of a path it takes, each under its own name with + its own blob, at a file, a folder, or a folder beside a file; + - git leaves out both twins of a path it refuses, and `refused_paths` + names each one. + No twin is folded into the other, and none is dropped without a word. + `false` is the control, and git answers the same.""" + source = with_twins(adopter, tmp_path, twins) + written, named, fates = twins_asked(adopter, source, twins, ignorecase) + assert written == ignorecase + assert named == list(refused) + assert fates == as_measured(twins, refused) + + +def test_on_a_disk_that_ignores_case_each_twin_is_held_or_named(adopter, + tmp_path): + """Where the work directory's disk ignores case, as on macOS and + Windows, `git init` writes `core.ignorecase=true` into each throwaway + repository's own config. `refused_paths` reads nothing else of the disk: + `update-index --index-info` and `ls-files` read the index, never the + work tree. The rows above write that setting by hand, so they answer + for such a disk on any disk. This row asks the disk itself, and is + skipped on a disk that keeps case, as every CI disk is (CI is Linux + only since #597).""" + source = with_twins(adopter, tmp_path, ALL_TWINS) + written, named, fates = twins_asked(adopter, source, ALL_TWINS, None) + if written != "true": + pytest.skip("needs a disk that ignores case: `git init` wrote no " + f"`core.ignorecase=true` under {tmp_path / 'work'}, so " + "that disk keeps case; the rows above write the setting " + "into the throwaway repository by hand instead") + assert named == list(TWINS_REFUSED) + assert fates == as_measured(ALL_TWINS, TWINS_REFUSED) + + +def test_check_names_each_twin_git_refuses_and_no_twin_it_holds(tmp_path): + """`check` over a source holding every twin above, with + `core.ignorecase` true in the global config. On a disk that keeps case, + `git init` writes no `core.ignorecase` into a throwaway 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). No + finding refuses twins of each other: `check` refuses only a twin of a + path the shape writes, or of a leg's mount (`plan-root-blocks-shape`, + `plan-leg-path-collides`). So `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.""" + source = a_source(tmp_path) + with_paths(source, ALL_TWINS, own_blobs=True) + result = checked(planned(source, tmp_path, TWIN_ANSWERS), + config_with(tmp_path, "[core]", "\tignorecase = true")) + assert "REFUSED" not in result.stderr, result.stderr + assert result.returncode == 1, result.stderr + assert finding_lines(result) == [ + f"FINDING {PATH_GIT_REFUSES}: the source commit holds 4 paths this " + "machine's git refuses as a path, and holds in no index: `.GIT/x`, " + "`.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."] + assert result.stderr.count(PATH_REMEDY) == 1, result.stderr + + # --------------------------------------------------------------------------- # L1-92: in the work directory # --------------------------------------------------------------------------- From 0301af57042c195e9cbce5747e50309f21fe1404 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 14:46:35 +0000 Subject: [PATCH 02/17] tests/test_adopt_check_throwaway_repos.py: rows pin a twin git refuses by its mode beside the one it holds (a symlink `.GITMODULES` and the file `.gitmodules`), a file beside a folder, three twins and a non-ASCII pair (#1033) Round 1 review of #1158: - `with_paths` and `with_twins` take `links`, written as mode 120000, and `test_a_twin_git_refuses_by_its_mode_is_named_beside_the_one_it_holds` asks `refused_paths` for the pair with `core.ignorecase` true and false: git refuses the symlink and holds the file, so an answer that folds the index's paths into one case now fails (hand mutant HM4 was not equivalent). - `CASE_TWINS` gains `a-file-and-a-folder`, `three-twins` and `non-ascii-twins` (spelled with escapes, so the file stays ASCII). - The `check` row's docstring no longer says no finding refuses twins of each other: under a global `core.ignorecase=true` the #654 rehearsal refuses `D/x` and `d/x`, and the row does not see it because the rehearsal asks nothing of a source that holds a path git refuses. - The disk row's skip text no longer names the per-run work directory. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Sonnet 5.5 --- tests/test_adopt_check_throwaway_repos.py | 54 ++++++++++++++++------- 1 file changed, 39 insertions(+), 15 deletions(-) diff --git a/tests/test_adopt_check_throwaway_repos.py b/tests/test_adopt_check_throwaway_repos.py index 70d56ad4..bfbce2b1 100644 --- a/tests/test_adopt_check_throwaway_repos.py +++ b/tests/test_adopt_check_throwaway_repos.py @@ -140,11 +140,12 @@ def blob_of(repo: Path, data: bytes) -> str: def with_paths(repo: Path, paths: tuple[str, ...], - own_blobs: bool = False) -> None: + 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.""" + 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: @@ -152,11 +153,13 @@ def with_paths(repo: Path, paths: tuple[str, ...], here = nested for directory in directories: here = here.setdefault(directory, {}) - here[name] = (blob_of(repo, f"{path}\n".encode("utf-8")) + here[name] = ("120000" if path in links else "100644", + blob_of(repo, f"{path}\n".encode("utf-8")) if own_blobs else shared) def rows(names: dict) -> list[str]: - return [f"100644 blob {below}\t{name}" if isinstance(below, 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()] @@ -321,6 +324,9 @@ def test_the_rehearsal_asks_nothing_more_of_a_tree_git_refuses(adopter, pytest.param(("A", "a"), (), id="file-twins"), pytest.param(("D/x", "d/x"), (), id="folder-twins"), pytest.param(("F/x", "f"), (), id="a-folder-and-a-file"), + pytest.param(("E", "e/x"), (), id="a-file-and-a-folder"), + pytest.param(("AB", "Ab", "ab"), (), id="three-twins"), + pytest.param(("\u00c9", "\u00e9"), (), id="non-ascii-twins"), pytest.param((".Git/A", ".Git/a"), (".Git/A", ".Git/a"), id="twins-under-a-path-git-refuses"), pytest.param((".GIT/x", ".Git/x"), (".GIT/x", ".Git/x"), @@ -335,11 +341,12 @@ def test_the_rehearsal_asks_nothing_more_of_a_tree_git_refuses(adopter, "f": ROOT, ".GIT/": ROOT, ".Git/": ROOT} -def with_twins(adopter, base: Path, twins: tuple[str, ...]): +def with_twins(adopter, base: Path, twins: tuple[str, ...], + links: tuple[str, ...] = ()): """`a_source` whose commit gains `twins`, each with a blob of its own, opened as `check` opens it, with `base/work` its work directory.""" path = a_source(base) - with_paths(path, twins, own_blobs=True) + with_paths(path, twins, own_blobs=True, links=links) work = base / "work" work.mkdir() return adopter.Source(path, None, "main", @@ -399,6 +406,22 @@ def test_each_case_twin_is_held_with_its_own_blob_or_named( assert fates == as_measured(twins, refused) +@pytest.mark.parametrize("ignorecase", ["true", "false"]) +def test_a_twin_git_refuses_by_its_mode_is_named_beside_the_one_it_holds( + adopter, tmp_path, ignorecase): + """Git refuses a path by its mode too: a symlink named `.GITMODULES`, in + any case, and holds the file `.gitmodules`. Measured with git 2.43, + `core.ignorecase` unset, false and true: so git can refuse one twin and + hold the other, and an answer that folds the index's paths into one case + would take the refused twin for a held one.""" + twins = (".GITMODULES", ".gitmodules") + source = with_twins(adopter, tmp_path, twins, links=(".GITMODULES",)) + written, named, fates = twins_asked(adopter, source, twins, ignorecase) + assert written == ignorecase + assert named == [".GITMODULES"] + assert fates == {".GITMODULES": "named", ".gitmodules": "held"} + + def test_on_a_disk_that_ignores_case_each_twin_is_held_or_named(adopter, tmp_path): """Where the work directory's disk ignores case, as on macOS and @@ -413,9 +436,9 @@ def test_on_a_disk_that_ignores_case_each_twin_is_held_or_named(adopter, written, named, fates = twins_asked(adopter, source, ALL_TWINS, None) if written != "true": pytest.skip("needs a disk that ignores case: `git init` wrote no " - f"`core.ignorecase=true` under {tmp_path / 'work'}, so " - "that disk keeps case; the rows above write the setting " - "into the throwaway repository by hand instead") + "`core.ignorecase=true` in the work directory, so that " + "disk keeps case; the rows above write the setting into " + "the throwaway repository by hand instead") assert named == list(TWINS_REFUSED) assert fates == as_measured(ALL_TWINS, TWINS_REFUSED) @@ -425,12 +448,13 @@ def test_check_names_each_twin_git_refuses_and_no_twin_it_holds(tmp_path): `core.ignorecase` true in the global config. On a disk that keeps case, `git init` writes no `core.ignorecase` into a throwaway 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). No - finding refuses twins of each other: `check` refuses only a twin of a - path the shape writes, or of a leg's mount (`plan-root-blocks-shape`, - `plan-leg-path-collides`). So `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.""" + 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 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.""" source = a_source(tmp_path) with_paths(source, ALL_TWINS, own_blobs=True) result = checked(planned(source, tmp_path, TWIN_ANSWERS), From 4c7b8acd64b8846eee58febce78a8f83910ca0a0 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 15:22:25 +0000 Subject: [PATCH 03/17] tests/test_adopt_check_throwaway_repos.py: the comment above `ALL_TWINS` and the `check` row's docstring name the twins they hold, and the held-or-named row's docstring says each twin, not both (#1033) Round 2 re-check of #1158, two comment NITs, no code change: - The comment above `ALL_TWINS` and the `check` row's docstring said they held "every twin above". They hold the twins of `file-twins`, `folder-twins` and `a-folder-and-a-file` and the two refused sets, and not the twins of `a-file-and-a-folder`, `three-twins` and `non-ascii-twins`. Both now say so. `ALL_TWINS` itself is unchanged. - `test_each_case_twin_is_held_with_its_own_blob_or_named` said "both twins" of a path git takes, and `three-twins` holds three. It says "each twin". Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Sonnet 5.5 --- tests/test_adopt_check_throwaway_repos.py | 27 +++++++++++++---------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/tests/test_adopt_check_throwaway_repos.py b/tests/test_adopt_check_throwaway_repos.py index bfbce2b1..770c7b7c 100644 --- a/tests/test_adopt_check_throwaway_repos.py +++ b/tests/test_adopt_check_throwaway_repos.py @@ -332,8 +332,9 @@ def test_the_rehearsal_asks_nothing_more_of_a_tree_git_refuses(adopter, pytest.param((".GIT/x", ".Git/x"), (".GIT/x", ".Git/x"), id="twins-git-refuses"), ] -#: Every twin above in one source commit; the ones git refuses, in the -#: tree's order; and the plan's answers for them all. +#: The twins of `file-twins`, `folder-twins` and `a-folder-and-a-file`, and +#: the two refused sets above, in one source commit; the ones git refuses, +#: in the tree's order; and the plan's answers for them all. ALL_TWINS = ("A", "a", "D/x", "d/x", "F/x", "f", ".Git/A", ".Git/a", ".GIT/x", ".Git/x") TWINS_REFUSED = (".GIT/x", ".Git/A", ".Git/a", ".Git/x") @@ -393,7 +394,7 @@ def test_each_case_twin_is_held_with_its_own_blob_or_named( """`refused_paths` writes its index with `update-index -z --index-info`. Measured with git 2.43 and `core.ignorecase` true in the rehearsal's own config: - - git holds both twins of a path it takes, each under its own name with + - git holds each twin of a path it takes, each under its own name with its own blob, at a file, a folder, or a folder beside a file; - git leaves out both twins of a path it refuses, and `refused_paths` names each one. @@ -444,15 +445,17 @@ def test_on_a_disk_that_ignores_case_each_twin_is_held_or_named(adopter, def test_check_names_each_twin_git_refuses_and_no_twin_it_holds(tmp_path): - """`check` over a source holding every twin above, with - `core.ignorecase` true in the global config. On a disk that keeps case, - `git init` writes no `core.ignorecase` into a throwaway 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 asks nothing of a source that holds a path - git refuses (`_reencoded_files_findings`, `if refused: return []`). + """`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 + keeps case, `git init` writes no `core.ignorecase` into a throwaway + 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 + 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.""" source = a_source(tmp_path) From f264cef15c864159a68861c37904e7bff3422f8f Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 21:09:34 +0000 Subject: [PATCH 04/17] adopt-project.py: `check` names the case twins the plan keeps in one repository, `A` and `a`, `D/x` and `d/x`, `f` and `F/x`, as `plan-case-twins` on every disk, and `execute` refuses them before any leg (#1161) A disk that ignores case holds each such set as one name. Measured with git 2.43 at 4c7b8acd, on such a disk, `check` printed `plan ok` for every set; `execute` lost `F/x` after both legs were pushed, and was refused at folder twins as `plan-file-reencoded` in 2 of 4 fresh sources. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 126 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 121 insertions(+), 5 deletions(-) diff --git a/adopt-project.py b/adopt-project.py index ed9c8b1d..fe4e4956 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -112,8 +112,12 @@ 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. 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). 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 @@ -9239,6 +9243,110 @@ 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, and twins in one leg, were +# adopted, each set kept as it was in the repository made. +# +# 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 -- the paths ONE answered entry sends there +# (`_answered_legs`) that a disk ignoring case cannot hold apart +# (`_case_twin_sets`). A path git refuses is `plan-path-git-refuses`'s +# alone, a path no entry answers once is another finding's, and one the +# plan drops lands nowhere. Twins in DIFFERENT repositories are not this +# finding: each repository holds its own apart. + +#: `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. +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. Or answer the twins of each " + "set into different repositories, the root, the spec leg and the code " + "leg, and run `check` again.") + + +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 _case_twin_findings(plan: Plan, tree: list, + refused: list[str] | tuple = () + ) -> tuple[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; and what + to print once after every finding. 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) + answered = _answered_legs(plan.entries, [path for path, _, _, _ in tree + if path not in held]) + found = [] + for leg, where in TWIN_REPOSITORIES.items(): + sets = _case_twin_sets([path for path, (answer, _) in answered.items() + if answer == leg]) + 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` run there can stop before any leg exists, at " + "git's `will not add file alias`, or after both legs are pushed, " + "as `adopt-lost`.") + return found, [CASE_TWINS_REMEDIATION] if found else [] + + # --------------------------------------------------------------------------- # The two branch names a plan gives `execute` (#420, #520) # --------------------------------------------------------------------------- @@ -9334,7 +9442,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 @@ -9403,6 +9512,12 @@ 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 paths one answered + entry sends into one repository that 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. + `check`'S THROWAWAY REPOSITORIES (#910) are made in the work directory. Each but #183's symlink probe is made with the source's object format and runs no hook of this machine's; where git cannot make or ask one, @@ -9472,7 +9587,8 @@ 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 = _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]) @@ -9509,7 +9625,7 @@ 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 From 2b334fba512d242033d49cc7f12978fbb0c4cb59 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 21:13:25 +0000 Subject: [PATCH 05/17] tests/test_adopt_check_throwaway_repos.py: rows pin `plan-case-twins` for each set the plan keeps in the root, the spec leg or the code leg, its absence for twins in different repositories, a dropped twin and folders that only merge, and `execute`'s refusal before any leg; #1158's row names the six twins it holds; `with_paths` merges into a folder HEAD holds (#1161) `with_paths` wrote a second `src` or `specs` tree beside HEAD's (git fsck: duplicateEntries); it now merges a new path into the folder HEAD already holds. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- tests/test_adopt_check_throwaway_repos.py | 219 ++++++++++++++++++++-- 1 file changed, 204 insertions(+), 15 deletions(-) diff --git a/tests/test_adopt_check_throwaway_repos.py b/tests/test_adopt_check_throwaway_repos.py index 770c7b7c..cd81d0b9 100644 --- a/tests/test_adopt_check_throwaway_repos.py +++ b/tests/test_adopt_check_throwaway_repos.py @@ -37,6 +37,15 @@ with its own blob, and `plan-path-git-refuses` names both twins of a path git 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. """ from __future__ import annotations @@ -143,9 +152,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: @@ -157,14 +168,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) @@ -444,7 +460,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 @@ -456,8 +472,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), @@ -470,8 +488,179 @@ 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, and how its +#: remediation begins. +PLAN_CASE_TWINS = "plan-case-twins" +TWINS_REMEDY = "Remediation: rename paths in the source until no two" +#: 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` run there " + "can stop before any leg exists, at git's `will not add file " + "alias`, or after both legs are pushed, as `adopt-lost`.") + + +#: `(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"), + 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"), +] + + +@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 + + +#: `(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): + """Twins the plan sends to different repositories are held apart, one + a drop takes out is in none, and folders in another case merge into one + whose names differ.""" + 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 + + +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_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`")] # --------------------------------------------------------------------------- From a43edbdc9ca2711224d7b36a5b7394d5ff61bb66 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 21:17:15 +0000 Subject: [PATCH 06/17] tests/test_adopt_check_throwaway_repos.py: the three-twin row holds no path in lower case, so a fold of the set's names that skips `casefold` is seen, and the unresolved twin's row says no twins remediation is printed (#1161) Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- tests/test_adopt_check_throwaway_repos.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_adopt_check_throwaway_repos.py b/tests/test_adopt_check_throwaway_repos.py index cd81d0b9..a9326478 100644 --- a/tests/test_adopt_check_throwaway_repos.py +++ b/tests/test_adopt_check_throwaway_repos.py @@ -541,10 +541,10 @@ def twins_kept(sets: str, where: str, paths: str, named: str) -> str: [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}, + 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"), + "`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`")], @@ -628,6 +628,7 @@ def test_a_twin_still_unanswered_is_named_by_plan_unresolved_alone( 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, From 8684af860e0d02b123c797eec5f0e2fa16671d76 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 23 Oct 2026 02:38:51 +0000 Subject: [PATCH 07/17] tests/test_adopt_make_reads_first.py: the two rows that keep `Makefile` and `makefile` side by side expect `check`'s `plan-case-twins` and exit 1, and `execute` is asked to refuse that source before any leg, so they no longer run `make` in a clone (#1161) `test_plan_writes_the_line_and_check_prints_it`, at `[Makefile+makefile]` and `[all-three]`: `check` exits 1 and prints the one `plan-case-twins` finding naming `Makefile` and `makefile`, beside the follow-up line it still prints. The other params still expect exit 0. `test_the_split_message_names_the_file_make_reads_in_the_clone` drops those two params: `execute` now refuses their source before any leg exists, so there is no clone to run `make` in. `test_execute_refuses_makefile_beside_makefile_before_any_leg`, at the same two params, asserts the refusal as `plan-case-twins`: no bare repository, no assembly or leg clone in the work directory, no split branch in the source. Where `check` run on a disk that keeps case once passed this source and `execute` split and verified it, both now refuse it on every disk, as for any other set of case twins. Measured on CPython 3.12 and under a 3.14-like shim, the whole file: 4c7b8acd 4 failed, 29 passed; head 33 passed. The 4 are the two changed `check` params and the two refusal params. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Sonnet 5.5 --- tests/test_adopt_make_reads_first.py | 77 +++++++++++++++++++++++++--- 1 file changed, 71 insertions(+), 6 deletions(-) diff --git a/tests/test_adopt_make_reads_first.py b/tests/test_adopt_make_reads_first.py index 59a53bca..445a85fc 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 @@ -220,7 +251,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), @@ -231,10 +264,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), @@ -244,7 +308,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 @@ -253,7 +317,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, From 226f938009e3d156be7d26103f85854fe9e34be2 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 23 Oct 2026 07:52:40 +0000 Subject: [PATCH 08/17] adopt-project.py: `plan-case-twins` leaves a set of one name whose paths hold one blob in one mode to a NOTE, its remediation no longer offers to answer the twins into different repositories, and its sentence says `execute` refuses them on every disk (#1161) r1189's review of #1189, as lane 1 ruled: - MUST 1: CASE_TWINS_REMEDIATION drops "Or answer the twins of each set into different repositories ... and run `check` again": on a disk that ignores case `execute` loses twins in different repositories after both legs are pushed (#1183). A comment says why the option is gone. - MUST 2: the section comment says the finding asks each set of SOURCE paths, each sent by one answered entry, and not a kept path that is a twin of a file the shape writes into the root (#1191). - SHOULD 3: the comment no longer says a dropped twin lands nowhere; twins in different repositories and a dropped twin are lost too (#1183). - SHOULD 4: the comment gives both measured outcomes for twins in the code leg: adopted in 2 runs, stopped at `git rm -r` in 3. - SHOULD 5: `_one_blob_twins` leaves a set whose paths fold to one name and hold one blob in one mode (not a submodule) out of the finding, and `check` names it in a NOTE, the first of `_entry_findings`' NOTEs. On a disk that ignores case `execute` verified such a set in 8 of 8 runs (2 in the root, 5 in the code leg, 1 in the spec leg). A file against a folder stays the finding. - NIT 7: the finding says `execute` would stop there, before any leg or after both are pushed, so it refuses them on every disk. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 118 +++++++++++++++++++++++++++++++++-------------- 1 file changed, 84 insertions(+), 34 deletions(-) diff --git a/adopt-project.py b/adopt-project.py index fe4e4956..89cb86da 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -112,12 +112,13 @@ 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. 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). 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. + 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 @@ -9260,18 +9261,33 @@ def _unsafe_path_findings(plan: Plan) -> tuple[list[str], list[str]]: # 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, and twins in one leg, were -# adopted, each set kept as it was in the repository made. +# 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 -- the paths ONE answered entry sends there -# (`_answered_legs`) that a disk ignoring case cannot hold apart -# (`_case_twin_sets`). A path git refuses is `plan-path-git-refuses`'s -# alone, a path no entry answers once is another finding's, and one the -# plan drops lands nowhere. Twins in DIFFERENT repositories are not this -# finding: each repository holds its own apart. +# 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, and +# `execute` there verified `A` and `a` of one blob in every run measured +# for #1189: 8, 2 kept in the root, 5 in the code leg and 1 in the spec +# leg. It is a NOTE, not this finding (`_one_blob_twins`). 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. @@ -9281,14 +9297,15 @@ def _unsafe_path_findings(plan: Plan) -> tuple[list[str], list[str]]: 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. Or answer the twins of each " - "set into different repositories, the root, the spec leg and the code " - "leg, and run `check` again.") + "`plan-stale` once the source has moved.") def _case_twin_sets(paths: list[str]) -> list[list[str]]: @@ -9314,22 +9331,50 @@ def _case_twin_sets(paths: list[str]) -> list[list[str]]: return [named for named in sets.values() if len(named) > 1] +def _one_blob_twins(named: list[str], kinds: dict) -> bool: + """Whether every path of the set `named` folds to ONE name and holds one + blob in one mode, `kinds` giving each path's `(mode, object)`: the one + path a disk ignoring case checks out then holds what each holds. A + file against a folder is never such a set, nor are submodules, whose + object is a commit and no blob.""" + 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]]: + ) -> 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; and what - to print once after every finding. Only the paths ONE answered entry - sends there are asked (`_answered_legs`), and none git refuses, - `refused`: `plan-path-git-refuses` names those.""" + 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 (`_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 = [] + 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)] + 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] @@ -9341,10 +9386,11 @@ def _case_twin_findings(plan: Plan, tree: list, 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` run there can stop before any leg exists, at " - "git's `will not add file alias`, or after both legs are pushed, " - "as `adopt-lost`.") - return found, [CASE_TWINS_REMEDIATION] if found else [] + "and `execute` would 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 # --------------------------------------------------------------------------- @@ -9512,11 +9558,14 @@ 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 paths one answered - entry sends into one repository that 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. + 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. `check`'S THROWAWAY REPOSITORIES (#910) are made in the work directory. Each but #183's symlink probe is made with the source's object format @@ -9587,7 +9636,8 @@ def _entry_findings(plan: Plan, source: Source, probe = _SymlinkProbe(source.work_root) unsafe, unsafe_remediation = _source_path_findings(plan, tree, targets, refused) - twins, twins_remediation = _case_twin_findings(plan, tree, refused) + 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], @@ -9630,7 +9680,7 @@ def _entry_findings(plan: Plan, source: Source, + 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()) # --------------------------------------------------------------------------- From e4f8cc51e772fa1c44d9a612c6b17f6141c000ba Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 23 Oct 2026 07:52:40 +0000 Subject: [PATCH 09/17] =?UTF-8?q?tests/test=5Fadopt=5Fcheck=5Fthrowaway=5F?= =?UTF-8?q?repos.py:=20rows=20pin=20a=20file=20against=20a=20folder=20belo?= =?UTF-8?q?w=20the=20top=20level=20and=20two=20levels=20up,=20`=C3=9F`=20f?= =?UTF-8?q?olded=20as=20`ss`,=20one=20set=20of=20four,=20`plan-case-twins`?= =?UTF-8?q?=20before=20`plan-uncovered`=20and=20as=20`execute`'s=20code,?= =?UTF-8?q?=20the=20whole=20remediation,=20and=20a=20set=20of=20one=20blob?= =?UTF-8?q?=20in=20one=20mode=20as=20a=20NOTE=20(#1161)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r1189's review of #1189: - SHOULD 6, rows that kill its surviving mutants: `src/E` with `src/e/x` in the code leg (m03), `f` with `F/G/x` (m04), `src/straße` with `src/STRASSE`, which pins known limit 1 (m01), the twins beside a path no entry covers, asserting the order of the findings and `execute`'s refusal code (m11), and one set of four paths (m15). - MUST 1: the `TWINS_KEPT` row asserts the whole remediation line, which offers no answer into different repositories. - SHOULD 5: `A` and `a` with one blob are a NOTE, `check` exits 0; with two blobs they are the finding; three paths of one blob in the code leg are a NOTE; `F/x` and `f` of one blob stay the finding. `_case_twin_findings` asked itself: one blob in two modes, submodules at one commit and one of three paths in another blob are the finding; symlinks to one target are a NOTE; a repository can hold a set of each, and two NOTE sets are named set by set. - SHOULD 3: the controls' docstring says twins in different repositories and a dropped twin are lost too on a disk that ignores case (#1183). - NIT 7: `twins_kept` holds the finding's new sentence. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- tests/test_adopt_check_throwaway_repos.py | 180 +++++++++++++++++++++- 1 file changed, 172 insertions(+), 8 deletions(-) diff --git a/tests/test_adopt_check_throwaway_repos.py b/tests/test_adopt_check_throwaway_repos.py index a9326478..ffb1ffb7 100644 --- a/tests/test_adopt_check_throwaway_repos.py +++ b/tests/test_adopt_check_throwaway_repos.py @@ -499,10 +499,18 @@ def test_check_names_each_twin_git_refuses_and_each_set_it_holds(tmp_path): # #1161: the case twins the plan keeps in one repository # --------------------------------------------------------------------------- -#: The finding #1161 adds, the code of `execute`'s refusal, and how its -#: remediation begins. +#: 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") @@ -516,9 +524,25 @@ def twins_kept(sets: str, where: str, paths: str, named: str) -> str: "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` run there " - "can stop before any leg exists, at git's `will not add file " - "alias`, or after both legs are pushed, as `adopt-lost`.") + "checks out only one path of each set, and `execute` would 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 @@ -570,6 +594,26 @@ def twins_kept(sets: str, where: str, paths: str, named: str) -> str: "`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"), ] @@ -591,6 +635,123 @@ def test_check_names_the_case_twins_the_plan_keeps_in_one_repository( 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. +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.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]]): + """`_case_twin_findings` over a tree of `(path, mode, object)` rows, the + plan keeping each in the root: its findings and NOTEs, no remediation + but where there is a finding.""" + plan = SimpleNamespace(entries=[{"path": path, "leg": ROOT} + 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"), +] + + +@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) #: `(the paths, the answers)` of sources no repository the split makes holds @@ -611,9 +772,12 @@ def test_check_names_the_case_twins_the_plan_keeps_in_one_repository( @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): - """Twins the plan sends to different repositories are held apart, one - a drop takes out is in none, and folders in another case merge into one - whose names differ.""" + """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)) == [] From 6e3ebfa62327704bab8b5198a41141d3c333790f Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 23 Oct 2026 08:00:21 +0000 Subject: [PATCH 10/17] tests/test_adopt_check_throwaway_repos.py: a row pins that `_entry_findings` lists the one-blob twins' NOTE before the pins' NOTEs, as its docstring says (#1161) The order the docstring states had no row: moving `twin_notes` after the other NOTEs passed every row before this one. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- tests/test_adopt_check_throwaway_repos.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_adopt_check_throwaway_repos.py b/tests/test_adopt_check_throwaway_repos.py index ffb1ffb7..aa42dc0b 100644 --- a/tests/test_adopt_check_throwaway_repos.py +++ b/tests/test_adopt_check_throwaway_repos.py @@ -754,6 +754,24 @@ def test_only_one_blob_in_one_mode_is_left_to_the_note(adopter, rows, found, assert asked_of(adopter, rows) == (found, noted) +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 = [ From 4d40ce9fda29f522abb1e8780152c96f59badc28 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 23 Oct 2026 08:08:23 +0000 Subject: [PATCH 11/17] =?UTF-8?q?tests/test=5Fadopt=5Fcheck=5Fthrowaway=5F?= =?UTF-8?q?repos.py:=20a=20row=20pins=20that=20`STRASSE`=20and=20`stra?= =?UTF-8?q?=C3=9Fe`=20of=20one=20blob=20are=20one=20name=20for=20the=20NOT?= =?UTF-8?q?E,=20folded=20with=20`casefold`=20as=20their=20set=20was=20(#11?= =?UTF-8?q?61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit f1189's mutant n15 (`lower` in place of `casefold` in `_one_blob_twins`) passed every row before this one: no row held a one-blob set that only full case folding makes one name. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- tests/test_adopt_check_throwaway_repos.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_adopt_check_throwaway_repos.py b/tests/test_adopt_check_throwaway_repos.py index aa42dc0b..168e5fc9 100644 --- a/tests/test_adopt_check_throwaway_repos.py +++ b/tests/test_adopt_check_throwaway_repos.py @@ -745,6 +745,11 @@ def asked_of(adopter, rows: list[tuple[str, str, str]]): [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"), ] From 44ae0a0e6d710360f9949f71bca952cdf0d2a12d Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 23 Oct 2026 12:16:28 +0000 Subject: [PATCH 12/17] adopt-project.py: in the root, `plan-case-twins` leaves to the NOTE only a set of two paths of one blob whose names differ only in their last part, `A` and `a`, and keeps folder twins and sets of three or more of one blob as the finding; its sentence says `execute` could stop there (#1161) r1189b's review of #1189, as lane 1 ruled: - SHOULD 1 (the preferred fix): `_one_blob_twins` now takes the repository a set is kept in. In the root it answers yes only for two paths whose names differ only in their last part, the shape measured to verify there (`A`/`a` of files, of symlinks to one target, of executables). On r1189b's FUSE disk, folder twins of one blob in the root (`D/x`, `d/x`) met git's `will not add file alias` in `check`, and three paths of one blob (`AB`, `Ab`, `ab`) in `execute` alone, after `check` said `plan ok`: both are the finding again, with its rename remediation. In the legs the NOTE is as it was, since those runs verified. The section comment, the module docstring's `check` paragraph, `_case_twin_findings`' and `_entry_findings`' docstrings say so. - NIT 2: the finding says `execute` "could stop there", as file twins in the root verified on that disk. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 66 +++++++++++++++++++++++++++++++++--------------- 1 file changed, 45 insertions(+), 21 deletions(-) diff --git a/adopt-project.py b/adopt-project.py index 89cb86da..4ce5f22c 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -116,8 +116,10 @@ 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. + 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 @@ -9282,12 +9284,24 @@ def _unsafe_path_findings(plan: Plan) -> tuple[list[str], list[str]]: # `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, and -# `execute` there verified `A` and `a` of one blob in every run measured -# for #1189: 8, 2 kept in the root, 5 in the code leg and 1 in the spec -# leg. It is a NOTE, not this finding (`_one_blob_twins`). 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. +# 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. 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`. 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. @@ -9331,12 +9345,19 @@ def _case_twin_sets(paths: list[str]) -> list[list[str]]: return [named for named in sets.values() if len(named) > 1] -def _one_blob_twins(named: list[str], kinds: dict) -> bool: - """Whether every path of the set `named` folds to ONE name and holds one - blob in one mode, `kinds` giving each path's `(mode, object)`: the one - path a disk ignoring case checks out then holds what each holds. A - file against a folder is never such a set, nor are submodules, whose - object is a commit and no blob.""" +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) @@ -9349,10 +9370,11 @@ def _case_twin_findings(plan: Plan, tree: list, 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 (`_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.""" + 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 @@ -9361,7 +9383,8 @@ def _case_twin_findings(plan: Plan, tree: list, 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)] + 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] @@ -9386,7 +9409,7 @@ def _case_twin_findings(plan: Plan, tree: list, 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` would stop there before any leg exists, at git's " + "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.") @@ -9565,7 +9588,8 @@ def _entry_findings(plan: Plan, source: Source, 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. + 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. Each but #183's symlink probe is made with the source's object format From a131fc25dcadf9d4cdfc8911b6382e15b1ec954e Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 23 Oct 2026 12:16:28 +0000 Subject: [PATCH 13/17] tests/test_adopt_check_throwaway_repos.py: rows pin that in the root folder twins and three paths of one blob are `plan-case-twins`, two paths of one blob in one folder are the NOTE, and folder twins of one blob in the code leg stay the NOTE (#1161) r1189b's SHOULD 1, as lane 1 ruled: `test_a_set_of_one_blob_is_a_note_and_not_the_finding` gains folder-twins-of-one-blob-in-the-root and three-of-one-blob-in-the-root (the finding), two-of-one-blob-in-one-folder-of-the-root and folder-twins-of-one-blob-in-the-code-leg (the NOTE), and `test_only_one_blob_in_one_mode_is_left_to_the_note` gains two-of-one-blob-in-folders-in-another-case (`D/A`, `d/a`: the finding). `twins_kept` says "could stop", as the finding now does (NIT 2). Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- tests/test_adopt_check_throwaway_repos.py | 31 +++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/tests/test_adopt_check_throwaway_repos.py b/tests/test_adopt_check_throwaway_repos.py index 168e5fc9..41668532 100644 --- a/tests/test_adopt_check_throwaway_repos.py +++ b/tests/test_adopt_check_throwaway_repos.py @@ -524,7 +524,7 @@ def twins_kept(sets: str, where: str, paths: str, named: str) -> str: "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` would stop " + "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 " @@ -663,7 +663,11 @@ def test_twins_come_before_coverage_and_name_executes_refusal( #: 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. +#: 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", @@ -680,6 +684,23 @@ def test_twins_come_before_coverage_and_name_executes_refusal( [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"), ] @@ -750,6 +771,12 @@ def asked_of(adopter, rows: list[tuple[str, str, str]]): [], [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"), ] From e45e1225f992d89e8d624e06445cab02699ea955 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 23 Oct 2026 18:08:11 +0000 Subject: [PATCH 14/17] adopt-project.py: the comment above `plan-case-twins` counts the runs measured with the root NOTE's rule, 2 each: `A` and `a` and `D/A` and `D/a` verified, folder twins and three paths of one blob refused before any leg (#1161) Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/adopt-project.py b/adopt-project.py index 4ce5f22c..5fac9479 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -9293,15 +9293,19 @@ def _unsafe_path_findings(plan: Plan) -> tuple[list[str], list[str]]: # 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. 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`. 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. +# 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. From 58aa9d2bac414882cf4c536c613e80ba16bfce69 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 23 Oct 2026 18:09:37 +0000 Subject: [PATCH 15/17] tests/test_adopt_check_throwaway_repos.py: rows pin that folder twins of one blob in the spec leg are the NOTE, a file against a folder of one blob and one last name in the code leg is the finding, a leg set's third path in another mode is the finding, and `execute` takes sets of one blob past `check`'s findings (#1161) Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- tests/test_adopt_check_throwaway_repos.py | 62 +++++++++++++++++++++-- 1 file changed, 58 insertions(+), 4 deletions(-) diff --git a/tests/test_adopt_check_throwaway_repos.py b/tests/test_adopt_check_throwaway_repos.py index 41668532..4999e37c 100644 --- a/tests/test_adopt_check_throwaway_repos.py +++ b/tests/test_adopt_check_throwaway_repos.py @@ -701,6 +701,18 @@ def test_twins_come_before_coverage_and_name_executes_refusal( [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"), ] @@ -720,11 +732,12 @@ def test_a_set_of_one_blob_is_a_note_and_not_the_finding( assert (TWINS_REMEDY in result.stderr) is bool(found), result.stderr -def asked_of(adopter, rows: list[tuple[str, str, str]]): +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 root: its findings and NOTEs, no remediation - but where there is a finding.""" - plan = SimpleNamespace(entries=[{"path": path, "leg": ROOT} + 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]) @@ -786,6 +799,19 @@ def test_only_one_blob_in_one_mode_is_left_to_the_note(adopter, rows, found, 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 @@ -857,6 +883,34 @@ def test_execute_refuses_case_twins_before_any_leg(adopter, monkeypatch, 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 From 7d2f651e7fefdccc1455c95a799721064c2b1209 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 8 Nov 2026 14:39:45 +0000 Subject: [PATCH 16/17] tests/test_adopt_next_make_reads_first.py: the printed-bootstrap rows leave out the trees that keep case twins, which execute now refuses as plan-case-twins (#1161) `test_the_printed_bootstrap_runs_in_the_clone_next_makes` (#1121) ran a real `execute` at `[Makefile+makefile]` and `[all-three]`, sources that keep `Makefile` and `makefile` side by side in the root, each with its own blob. Since #1161 `execute` refuses that set of case twins before any leg as `plan-case-twins`, so there is no clone to run `make` in: at the merge 5c4fcd9d both failed (`REFUSED plan-case-twins`, `assert 2 == 0`). Its parametrize now leaves out the rows `keeps_twins` (from `test_adopt_make_reads_first`) names, as #1189 did there in 8684af86, and its docstring says why, and that `test_execute_refuses_makefile_beside_makefile_before_any_leg` pins the refusal for those trees. Every other row is as it was: the test runs 7 rows, the file's other functions all of ROWS. Measured on CPython 3.12, the whole file: at 5c4fcd9d 2 failed, 38 passed; with this change 38 passed. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- tests/test_adopt_next_make_reads_first.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) 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( From 472944b5d345e89a69eeee220f1a2593e8c261af Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 8 Nov 2026 16:52:16 +0000 Subject: [PATCH 17/17] tests/test_adopt_collision_make_reads.py: the real-adoption rows leave out the trees that keep case twins, which check names and execute refuses as plan-case-twins (#1161) `test_the_sentences_are_true_of_what_make_does_in_the_clone` (#1105) ran a real adoption at `[Makefile+makefile]` and `[all-three]`, and `test_where_the_file_make_reads_includes_makefile_the_merge_alone_is_enough` (#1105) at `[makefile]`: sources that keep `Makefile` and `makefile` side by side in the root, each with its own blob. Since #1161 `check` names that set of case twins as `plan-case-twins` and `execute` refuses it before any leg, so there is no clone to run `make` in: at the merge 47478d51 all three failed at `adopt_with_legs_mounted_away`'s `check` (`FINDING plan-case-twins: ... `Makefile` and `makefile``, `assert 1 == 0`). Both parametrizes now leave out the rows `keeps_twins` (from `test_adopt_make_reads_first`) names, as 7d2f651e did in `test_adopt_next_make_reads_first.py`, and each docstring says why, and that `test_execute_refuses_makefile_beside_makefile_before_any_leg` pins the refusal for those trees. Every other row is as it was: the clone test runs 5 of CLONES, the include test its `[GNUmakefile]` row, and the file's other functions, which call `_predict_collisions` and `follow_ups_for` and run no `check`, all their rows, `[Makefile+makefile]` and `[all-three]` among them. Measured on CPython 3.12, the whole file: at 47478d51 3 failed, 28 passed; with this change 28 passed, the same 28 node ids. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- tests/test_adopt_collision_make_reads.py | 31 +++++++++++++++++++----- 1 file changed, 25 insertions(+), 6 deletions(-) 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))