Skip to content

adopt-project.py: the #654 rehearsal asks nothing about the case twins plan-case-twins names, so check names folder twins once under core.ignorecase=true, never again as plan-file-reencoded over git's will not add file alias, and names the files beside them as it does without that setting (#1184) - #1297

Open
brettheap wants to merge 2 commits into
mainfrom
l3-1184-rehearsal-case-twins

Conversation

@brettheap

@brettheap brettheap commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

What changed

A v1.2.1 fix for #1184, a follow-up lane 1 filed from its measurements on #1161's branch (PR #1189). It lands under Brett Heap's v1.2.1 merge word (2026-10-23T13:10:26Z) once reviewed and green.

What a person sees change

check, and execute's refusal, name a set of case twins once, as plan-case-twins, and no longer a second time as plan-file-reencoded, a finding about bytes:

  • Under core.ignorecase=true (the default on a disk that ignores case, or set globally on one that keeps it), folder twins (D/x, d/x) of one blob or two, three paths of one name (AB, Ab, ab) and two .gitattributes in folder twins, kept in the root, were plan-case-twins and then plan-file-reencoded with git's fatal: will not add file alias 'D/x' ('d/x' already exists in index). Now they are plan-case-twins alone, and execute's refusal lists 1 finding where it listed 2.
  • Git stopped the rehearsal's git add -u at that alias, so a file that really re-encodes beside the twins went unnamed under the setting: a CRLF notes.txt under * text eol=lf, kept beside D/x and d/x. Now it is named, and check prints what the same source prints without the setting.
  • Twins whose own bytes git would store otherwise (CRLF under text), in the root or a leg, were also named a second time, as plan-file-reencoded, with the setting or without it. Now the set is plan-case-twins alone. Its remediation, a rename, comes first, and the plan and check after it ask about the renamed files. That second line was true -- their bytes do re-encode -- so a person now learns it one check later: after git mv a.md b.md, plan and check name A.md and b.md as plan-file-reencoded, with the setting or without it, in the root and in the code leg.

Every other line check prints is the same, byte for byte (table below). The sets of one blob that the NOTE takes are still asked, and their findings do not change.

(A) or (B): (A), measured and read

The issue offers two fixes: (A) leave out of the rehearsal's question the twins that plan-case-twins already names, or (B) report git's refusal in its own words. This PR does (A), for three reasons:

  1. (B) is already in place, and it is the duplicate. _refused_finding(STORE_REFUSED, ...) already quotes git (git said fatal: will not add file alias ...). The problem is a second finding, with a bytes code, for a set that plan-case-twins has just named and that execute already refuses under that code (_refuse_what_check_finds takes the first finding's code). Rewording or recoding it still prints two findings for one set.
  2. Git's refusal hides real findings, and (B) cannot recover them. add -u dies at the alias, so the rehearsal reads no stored list at all. Measured: under the setting, the CRLF notes.txt beside the folder twins went unnamed in 3 of 3 runs, though the same source names it without the setting. With (A), git is never asked about the twins, so it does not stop there, and the rest is asked as before.
  3. The rehearsal's answers about twins are unreliable under core.ignorecase. For A.md and a.md, each CRLF with its own blob, main's finding named both without the setting and only a.md under it (git's index holds an alias under one name). On a disk that ignores case, the one checked-out file stands for two blobs, so the compare and the store would report "other bytes" with no git refusal to reword. That last case is read from the code, not measured: the bench has no writable disk that ignores case. (A) covers it, and (B) does not reach it.

(A) is also the rule the rehearsal already follows for plan-path-git-refuses: a source holding a path git refuses is asked nothing more (if refused: return []). It is the same one-finding-per-cause rule that #1253 applied to plan-attributes-reencoded.

The change. A new _case_twins_kept(plan, tree, refused) returns, for each repository, the sets that plan-case-twins names and the sets left to the NOTE. _case_twin_findings now loops over it (its output is unchanged), and _reencoded_files_findings takes the named sets' paths and:

  • leaves them out of files, so they are never wanted, held, checked out, compared or stored again;
  • leaves a .gitattributes among them out of what its git add -u stores again (held for STORED). It is still checked out, so its rules still reach the files beside it. On a disk that ignores case the two folders are one and hold one .gitattributes, so one twin's rules reach the files of both; that is read, not measured, and the rename plan-case-twins asks for comes first either way.

No refusal's code or wording changes.

Measured: before and after, every line

Git 2.43, a disk that keeps case, GIT_CONFIG_GLOBAL a scratch file that is empty or holds core.ignorecase=true. Each run used a fresh source (a_source + with_paths), plan, then check. Main is a worktree at f981468b. Branch is this head. Commit ids are normalised. "Identical" means stdout and stderr are byte for byte the same.

Sources. Each is a_source (README.md, .gitignore, specs/001-feature/spec.md, src/app/main.py, .github/workflows/ci.yml) plus:

  • file-twins-own: A.md and a.md in the root, each with its own blob.
  • file-twins-one-blob: the same with one blob, which is the NOTE's set.
  • folder-twins-own and folder-twins-one-blob: D/x and d/x in the root.
  • three-one-blob: AB, Ab and ab in the root, with one blob.
  • file-and-folder: F/x and f in the root.
  • folder-twins-code-leg: src/D/x and src/d/x.
  • twins-spec-leg: specs/A and specs/a.
  • attributes-twins: D/.gitattributes and d/.gitattributes in the root.
  • control-crlf: a CRLF notes.txt kept under a root * text eol=lf, with no twins.
  • folder-twins-and-crlf and file-twins-and-crlf: the own-blob twins plus control-crlf.
  • no-twins: nothing added.
  • crlf-twins: A.md and a.md, each CRLF with its own blob, under * text eol=lf.
  • crlf-twins-in-code-leg: the same as src/A.md and src/a.md.

In the last column:

  • "Identical but the refusal (and the count line)" means that the branch prints main's stdout byte for byte, and main's stderr without the one plan-file-reencoded line that quotes git's fatal: will not add file alias. Its 2 finding(s) line becomes 1 finding(s).
  • "The refusal hid notes.txt" means that the branch prints, byte for byte, what both sides print for that source with the setting unset.
  • The crlf-twins rows (2 runs a side) show the third bullet at the top: the dropped plan-file-reencoded line named only twins, and nothing else changed.
Source global core.ignorecase main f981468b: exit, findings branch: exit, findings the rest, byte for byte
file-twins-own unset 1, plan-case-twins (3 of 3) 1, plan-case-twins (3 of 3) identical
file-twins-one-blob unset 0, plan ok (3 of 3) 0, plan ok (3 of 3) identical
folder-twins-own unset 1, plan-case-twins (3 of 3) 1, plan-case-twins (3 of 3) identical
folder-twins-one-blob unset 1, plan-case-twins (3 of 3) 1, plan-case-twins (3 of 3) identical
three-one-blob unset 1, plan-case-twins (3 of 3) 1, plan-case-twins (3 of 3) identical
file-and-folder unset 1, plan-case-twins (3 of 3) 1, plan-case-twins (3 of 3) identical
folder-twins-code-leg unset 1, plan-case-twins (3 of 3) 1, plan-case-twins (3 of 3) identical
twins-spec-leg unset 1, plan-case-twins (3 of 3) 1, plan-case-twins (3 of 3) identical
attributes-twins unset 1, plan-case-twins (3 of 3) 1, plan-case-twins (3 of 3) identical
control-crlf unset 1, plan-file-reencoded (3 of 3) 1, plan-file-reencoded (3 of 3) identical
folder-twins-and-crlf unset 1, plan-case-twins + plan-file-reencoded (3 of 3) 1, plan-case-twins + plan-file-reencoded (3 of 3) identical
file-twins-and-crlf unset 1, plan-case-twins + plan-file-reencoded (3 of 3) 1, plan-case-twins + plan-file-reencoded (3 of 3) identical
no-twins unset 0, plan ok (3 of 3) 0, plan ok (3 of 3) identical
file-twins-own true 1, plan-case-twins (3 of 3) 1, plan-case-twins (3 of 3) identical
file-twins-one-blob true 0, plan ok (3 of 3) 0, plan ok (3 of 3) identical
folder-twins-own true 1, plan-case-twins + plan-file-reencoded (git's alias refusal) (3 of 3) 1, plan-case-twins (3 of 3) identical but the refusal (and the count line)
folder-twins-one-blob true 1, plan-case-twins + plan-file-reencoded (git's alias refusal) (3 of 3) 1, plan-case-twins (3 of 3) identical but the refusal (and the count line)
three-one-blob true 1, plan-case-twins + plan-file-reencoded (git's alias refusal) (3 of 3) 1, plan-case-twins (3 of 3) identical but the refusal (and the count line)
file-and-folder true 1, plan-case-twins (3 of 3) 1, plan-case-twins (3 of 3) identical
folder-twins-code-leg true 1, plan-case-twins (3 of 3) 1, plan-case-twins (3 of 3) identical
twins-spec-leg true 1, plan-case-twins (3 of 3) 1, plan-case-twins (3 of 3) identical
attributes-twins true 1, plan-case-twins + plan-file-reencoded (git's alias refusal) (3 of 3) 1, plan-case-twins (3 of 3) identical but the refusal (and the count line)
control-crlf true 1, plan-file-reencoded (3 of 3) 1, plan-file-reencoded (3 of 3) identical
folder-twins-and-crlf true 1, plan-case-twins + plan-file-reencoded (git's alias refusal) (3 of 3) 1, plan-case-twins + plan-file-reencoded (3 of 3) the refusal hid notes.txt; now identical to the unset run
file-twins-and-crlf true 1, plan-case-twins + plan-file-reencoded (3 of 3) 1, plan-case-twins + plan-file-reencoded (3 of 3) identical
no-twins true 0, plan ok (3 of 3) 0, plan ok (3 of 3) identical
crlf-twins unset 1, plan-case-twins + plan-file-reencoded (2 of 2) 1, plan-case-twins (2 of 2) identical but the line naming only the twins, A.md and a.md (and the count line)
crlf-twins true 1, plan-case-twins + plan-file-reencoded (2 of 2) 1, plan-case-twins (2 of 2) identical but the line naming only one twin, a.md (and the count line)
crlf-twins-in-code-leg unset 1, plan-case-twins + plan-file-reencoded (2 of 2) 1, plan-case-twins (2 of 2) identical but the line naming only the twins, src/A.md and src/a.md (and the count line)
crlf-twins-in-code-leg true 1, plan-case-twins + plan-file-reencoded (2 of 2) 1, plan-case-twins (2 of 2) identical but the line naming only the twins, src/A.md and src/a.md (and the count line)

Each source gave one outcome in all of its runs on each side.

Rows

10 rows in tests/test_adopt_check_throwaway_repos.py, under a new section #1184: one finding for one set, under core.ignorecase=true. That module already holds this code's rows: the core.ignorecase rows of #1033, the plan-case-twins rows of #1189 (with with_paths, twins_kept, one_blob_twins and config_with), and the rehearsal rows of #1253. tests/test_adopt_reencoded_files.py holds #654's own byte rows, and has no twin or core.ignorecase helper.

Row main f981468b branch
test_a_set_git_will_not_add_beside_its_twin_is_named_once[folder-twins] FAILED passed
...[folder-twins-of-one-blob] FAILED passed
...[three-of-one-blob] FAILED passed
...[gitattributes-in-folder-twins] FAILED passed
test_execute_refuses_such_a_set_as_its_one_finding FAILED passed
test_a_file_beside_the_twins_is_asked_as_without_the_setting FAILED passed
test_twins_git_would_store_as_other_bytes_are_the_set_alone[root] FAILED passed
...[code-leg] FAILED passed
test_the_twins_rules_still_reach_the_files_beside_them FAILED passed
test_a_set_the_note_takes_is_still_asked (control) passed passed

9 fail on main. The control is new. It shows that a NOTE set (one CRLF blob, A.md and a.md) is still plan-file-reencoded, so the rehearsal leaves out only the sets the finding names. A control where plan-file-reencoded fires for real re-encoding with no twins already exists, and passes here: test_the_rehearsal_answers_in_the_sources_object_format[control-a-sha1-source], a kept CRLF notes.txt under * text eol=lf.

The docstring of test_check_names_each_twin_git_refuses_and_each_set_it_holds said that the rehearsal "refuses" the folder twins as plan-file-reencoded. It now says this happened until #1184. The module docstring gains a #1184 paragraph.

Mutants

Each mutant was applied to the clone's adopt-project.py, and the rows above were run with #1189's NOTE rows and the #1033 row. Each file was then restored from the saved copy and checked with cmp (equal each time).

Mutant Killed by
M1: twins kept in files 9 rows: the four named_once, execute, beside, other_bytes (root and code leg), the_twins_rules_still_reach_the_files_beside_them
M2: twin .gitattributes kept in the store's held named_once[gitattributes-in-folder-twins], the_twins_rules_still_reach_the_files_beside_them
M3: the NOTE's sets left out too test_a_set_the_note_takes_is_still_asked
M4: _case_twins_kept keeps the NOTE's sets among the named 10 rows: five test_a_set_of_one_blob_is_a_note_and_not_the_finding, four test_only_one_blob_in_one_mode_is_left_to_the_note, the control
M5: _case_twin_findings drops refused test_check_names_each_twin_git_refuses_and_each_set_it_holds
M6 (RV1184's): the twins a leg takes are asked again other_bytes[code-leg]
M7 (RV1184's): twin .gitattributes not checked out the_twins_rules_still_reach_the_files_beside_them
M9 (RV1184's): every file in a folder that holds a named twin left out the_twins_rules_still_reach_the_files_beside_them

Gates

The first and last rows ran on head 2e020b45 (fix round 1), the other three on 9607ef6e: adopt-project.py is the same file at both heads (sha256 483c73a5499785a9...), and no other test module imports test_adopt_check_throwaway_repos.py. Git 2.43, Python 3.12, one pytest at a time with -rs. The host rules applied: a stub gh (exit 97) first on PATH, git-filter-repo 2.47.0 next, GH_TOKEN=bogus, an empty GH_CONFIG_DIR, GIT_SSH_COMMAND=false, GIT_CONFIG_GLOBAL=/dev/null and EMAIL=tests@openreposhape.invalid.

Gate Result
tests/test_adopt_check_throwaway_repos.py + tests/test_adopt_root_symlinks.py (its fixture module: links_as_links, unwritable_work) 437 passed, 5 skipped in 388.18s. The skips are 2 that need a disk that ignores case, and 3 reftable rows that need git 2.45
every other module that greps plan-file-reencoded or plan-case-twins: test_adopt_collision_make_reads, test_adopt_eol_block_through_links, test_adopt_make_reads_first, test_adopt_nested_attributes, test_adopt_next_make_reads_first, test_adopt_printed_sentences, test_adopt_reencoded_files, test_adopt_symlink_target_bytes 443 passed, 2 skipped in 302.43s. The skips are 2 eol_block_through_links rows for a root that keeps a link
tests/test_shape_doctor.py (background) 446 passed, 3 skipped in 439.08s. The skips are Windows-only PowerShell rows
tests/test_scaffold_leg_path_collisions.py 104 passed in 3.73s
the guards: test_repo_hygiene, test_cli_reference, test_agents_md_codes, test_git_output_decoding, test_stdout_encoding, test_path_guard_hygiene, test_no_auto_maintenance, test_update_add_codes_documented 470 passed, 3 skipped in 53.50s. The skips are 1 Windows default, and 2 because the session's GIT_CONFIG_GLOBAL was set

test_adopt_check_throwaway_repos.py and test_adopt_symlink_target_bytes.py ran in the first two gates. They share no fixture.

Merges

git merge-tree --write-tree of this head 2e020b45 with main, and with each open PR that touches adopt-project.py. The list comes from gh pr list --state open --json number,headRefOid,files, and each head was fetched by URL as refs/pull/<n>/head and matched to its headRefOid.

With Head Tree Result
main 0aef55b9 a1cfbd63 clean
#1294 ce9fbee6 ccd0b0f3 clean
#1281 4994e35a 93220f8e clean
#1280 5cb1abdb 6aa6c946 CONFLICT in adopt-project.py, but none of it is this head's (see below)
#1268 1ce9c4f9 0ac86014 clean
#1265 39b21c07 71310775 clean
#1249 3146c3ca 9852b989 clean
#1237 fa6d189f d40209c9 CONFLICT in tests/test_adopt_check_throwaway_repos.py, one hunk, at line 1031 (see below)
#1225 cc230f2f fe67aa32 clean
#1210 791106c2 76f5f565 clean
#1198 d38e4e36 3d67662d clean
#1194 906172b4 c01e6cb0 clean
#1171 ce1dd661 09eea79a clean

Fix round 1

RV1184's review found three mutants of the rehearsal's twin exclusion that no row killed. Two rows close them. adopt-project.py does not change: it is the same file at 9607ef6e and at 2e020b45.

  • test_twins_git_would_store_as_other_bytes_are_the_set_alone now runs in the root and in the code leg ([root], [code-leg]). M6 asks the twins a leg takes again, and the root-only row let it through: RV1184's live probe with it brought back plan-file-reencoded for src/A.md and src/a.md beside plan-case-twins. [code-leg] kills it.
  • test_the_twins_rules_still_reach_the_files_beside_them is new. D/ and d/ each hold *.txt text eol=lf and a CRLF .txt (D/n.txt, d/m.txt): check names the two files as plan-file-reencoded after plan-case-twins, with the setting as without it. In RV1184's live probes M7 (twin .gitattributes not checked out) and M9 (every file in a folder that holds a named twin left out) each turned that into plan-case-twins alone, and the row kills both.
  • Rows: 10 for adopt-project.py: the #654 rehearsal names folder case twins as plan-file-reencoded where git refused them as a file alias #1184 (10 passed, 223 deselected in 14.72s). With main's adopt-project.py swapped in: 9 failed, 1 passed, 223 deselected in 11.51s (9 fail, and the control passes), restored with cmp equal.
  • Mutants, all killed, each restored with cmp equal and git diff --quiet HEAD -- adopt-project.py: M1 (9 failed, 20 passed in 21.40s), M2 (2 failed, 27 passed in 24.34s), M3 (1 failed, 28 passed in 22.93s), M4 (10 failed, 19 passed in 31.86s), M5 (1 failed, 28 passed in 27.05s), M6 (1 failed, 28 passed in 24.05s), M7 (1 failed, 28 passed in 22.44s), M9 (1 failed, 28 passed in 23.47s). RV1184's M8 (refused or () to ()) is equivalent: when refused is non-empty the rehearsal returns [] before the twins are used.
  • Gates at 2e020b45: the throwaway module with test_adopt_root_symlinks.py in one run: 437 passed, 5 skipped in 388.18s; the guards: 470 passed, 3 skipped in 53.50s. The other gates stand from 9607ef6e (see Gates).

The body also gains the opening section above and ## Out of scope below, and the Merges section is measured against this head, main 0aef55b9 and #1237 at fa6d189f.

Out of scope

Closes #1184

Lane: openreposhape-3 (openRepoShape-3)

🤖 Generated with Claude Code

Summary by Sourcery

Report case-twin collisions once while continuing to evaluate unrelated re-encoding findings under core.ignorecase.

Bug Fixes:

  • Prevent case-twin paths from being reported a second time as file re-encoding findings, avoiding duplicate findings and Git alias-refusal noise.
  • Ensure files and attributes beside case twins continue to be checked, while preserving the existing handling of one-blob NOTE sets.

Enhancements:

  • Share case-twin classification between findings and the re-encoding rehearsal so remediation and subsequent checks consistently process each set once.

Documentation:

  • Document the one-finding-per-set behavior and the interaction with core.ignorecase and Git alias refusals.

Tests:

  • Add coverage for duplicate case-twin findings, execute refusals, adjacent re-encoded files, twin attributes, and NOTE-set controls.

…s `plan-case-twins` names, so `check` names folder twins once under `core.ignorecase=true`, never again as `plan-file-reencoded` over git's `will not add file alias`, and names the files beside them as it does without that setting (#1184)

Measured on f981468 with git 2.43, on a disk that keeps case under a
global `core.ignorecase=true`, 3 of 3 runs each: folder twins (`D/x`,
`d/x`) of one blob or two, three paths of one blob (`AB`, `Ab`, `ab`) and
two `.gitattributes` in folder twins, all kept in the root, were
`plan-case-twins` and then `plan-file-reencoded`, git saying `fatal: will
not add file alias` at the rehearsal's `git add -u`; and a CRLF
`notes.txt` kept beside the folder twins went unnamed, since git stopped
that `git add` at the alias. Twins whose own bytes git would store
otherwise were named a second time too, with the setting or without it.

`_case_twins_kept` now gives both `_case_twin_findings` and
`_reencoded_files_findings` the sets each repository holds. The rehearsal
leaves the sets the finding names out of the files it asks about, and
their `.gitattributes` out of what its `git add` stores again (their rules
still reach the files beside them). The sets of one blob the NOTE takes
are asked as before. Every other line `check` prints is byte for byte as
it was.

Rows in tests/test_adopt_check_throwaway_repos.py, 7 failing on main, and
a control that passes on both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Nov 10, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

Implementation

Share the case-twin classification between the case-twin finding and the re-encoding rehearsal, so sets already named as plan-case-twins are not asked about again while adjacent files and NOTE-only sets remain checked.

  • adopt-project.py L10278-10294, L10356-10378, L13715-13720 (+5 small edits)

Tests

Document and test that named case-twin sets are reported only once, while adjacent re-encoded files, twin rules, and NOTE-only sets retain their existing checks.

  • tests/test_adopt_check_throwaway_repos.py L96-105, L535-537, L1000-1148

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-3, 2026-11-10T06:33:14Z) RV1184: READY AFTER FIXES at 9607ef6

Independent review of #1297 (for #1184, v1.2.1), lane openRepoShape-3. I wrote none of #1297. The head is still 9607ef6e, read back from GitHub at the end of the review, and closingIssuesReferences reads [1184]. Main was de55beb0 when I started and is now 3bf35b22. Neither commit touches adopt-project.py or the test module: git diff --quiet f981468bd de55beb03 -- adopt-project.py tests/test_adopt_check_throwaway_repos.py tests/conftest.py passed, and 3bf35b22 changes only scripts/shape_advisory.py and its own test. So main's adopt-project.py is still the PR's base.

The code fix is correct and every claim I could measure held. Nothing is a MUST. Three SHOULDs remain: two rows that guard behaviour the body claims but no row pins (three non-equivalent mutants survive every existing row), and two body sections lane 3's template asks for.

MUST

None.

SHOULD

S1. No row pins twins in a leg whose own bytes re-encode. The body's third bullet says "in the root or a leg", but test_twins_git_would_store_as_other_bytes_are_the_set_alone covers only the root. Mutant M6 lets moved twins be asked again, using and (path not in twins or path not in kept) in files. It survived: 133 passed, 2 skipped, 147 deselected. A live probe with M6 brought back plan-file-reencoded: the plan sends 2 files to a leg or drops them ... src/A.md and src/a.md beside plan-case-twins, with the setting and without it. Fix: parametrize that row.

@pytest.mark.parametrize(("prefix", "answers", "where"), [
    pytest.param("", {"A.md": ROOT, "a.md": ROOT}, ROOT_TWINS, id="root"),
    pytest.param("src/", {}, CODE_TWINS, id="code-leg"),
])
def test_twins_git_would_store_as_other_bytes_are_the_set_alone(
        tmp_path, prefix, answers, where):
    source = a_source(tmp_path, files={
        ROOT_ATTRIBUTES: TEXT_EOL_LF, f"{prefix}A.md": CRLF,
        f"{prefix}a.md": b"three\r\n"})
    plan = planned(source, tmp_path, answers)
    for env in (None, ignoring_case(tmp_path)):
        result = checked(plan, env)
        assert finding_lines(result) == [twins_kept(
            "1 set", where, "2 paths",
            f"`{prefix}A.md` and `{prefix}a.md`")], result.stderr

S2. No row pins "its rules still reach the files beside it". The body and the new comment above _reencoded_files_findings' section both claim that twin .gitattributes rules still reach the files beside them, and that the rest is asked. Two mutants break this and both survived (133 passed, 2 skipped, 147 deselected each):

  • M7 stops checking out twin .gitattributes (and path not in twins added to rules).
  • M9 leaves out every file in a folder that holds a named twin.

In a live probe, each turned plan-case-twins + plan-file-reencoded (D/n.txt and d/m.txt) into plan-case-twins alone. The only "beside" row keeps notes.txt in the root, outside the twin folders. Fix: add this row.

def test_the_twins_rules_still_reach_the_files_beside_them(tmp_path):
    rule = b"*.txt text eol=lf\n"
    paths = ("D/.gitattributes", "d/.gitattributes", "D/n.txt", "d/m.txt")
    source = a_source(tmp_path, files=dict(zip(paths,
                                               (rule, rule, CRLF, CRLF))))
    plan = planned(source, tmp_path, dict.fromkeys(paths, ROOT))
    without, ignored = checked(plan), checked(plan, ignoring_case(tmp_path))
    assert codes(without) == [PLAN_CASE_TWINS, REENCODED_FILES], \
        without.stderr
    assert "`D/n.txt` and `d/m.txt`" in finding_lines(without)[1]
    assert finding_lines(ignored) == finding_lines(without), ignored.stderr

I ran both rows (3 rows in all) from a scratch module in my own worktree:

Adopt-project.py Result
head 3 passed in 5.31s
M6 1 failed, 2 passed in 3.64s (fails [code-leg])
M7 1 failed, 2 passed in 4.05s (fails the rules row)
M9 1 failed, 2 passed in 5.08s (fails the rules row)
main 3 failed in 4.24s

S3. Body: lane 3's opening line and "Out of scope" section are missing. Insert this above ## What a person sees change:

What changed

A v1.2.1 fix for #1184, a follow-up lane 1 filed from its measurements on #1161's branch (PR #1189). It lands under Brett Heap's v1.2.1 merge word (2026-10-23T13:10:26Z) once reviewed and green.

Add this before Closes #1184:

Out of scope

NIT

N1. The #1237 sentence is wrong. #1237 does not change plan-case-twins' wording or twins_kept: its diff has no FINDING {CASE_TWINS} or def twins_kept line. It also moved to fa6d189f. Replace the #1237 bullet under "Merges" with:

N2. The Merges table is against f981468b. Refresh three rows:

N3. Say the side effect plainly. After the third bullet's last sentence, add:

That second line was true -- their bytes do re-encode -- so a person now learns it one check later: after git mv a.md b.md, plan and check name A.md and b.md as plan-file-reencoded, with the setting or without it, in the root and in the code leg.

N4. Say what a disk that ignores case does with disagreeing twin rules. After "It is still checked out, so its rules still reach the files beside it.", add:

On a disk that ignores case the two folders are one and hold one .gitattributes, so one twin's rules reach the files of both; that is read, not measured, and the rename plan-case-twins asks for comes first either way.

Evidence

1. Before and after

My probe built each source under an empty scratch GIT_CONFIG_GLOBAL, then ran plan and check under an empty file and under one holding core.ignorecase = true. It ran on main and on the head, 2 runs a side, with a fresh source each run: 20 sources plus 2 edge shapes. Git is 2.43 on a disk that keeps case. Every source gave one outcome in both runs on each side.

Source unset: main / head ignorecase=true: main ignorecase=true: head
folder twins D/x, d/x, own blobs 1, twins / identical 1, twins + reencoded (will not add file alias 'D/x' ('d/x' ...)) 1, twins
folder twins, one blob identical the same alias line twins
AB, Ab, ab, one blob identical alias 'Ab' ('ab' ...) twins
file twins A.md, a.md, LF, own blobs identical identical identical
file twins, CRLF own blobs, no rule identical identical identical
D/.gitattributes, d/.gitattributes identical alias 'D/.gitattributes' twins
folder twins beside a CRLF notes.txt identical (twins + notes.txt) twins + alias; notes.txt unnamed twins + notes.txt, byte for byte the unset run
re-encoding twins, root main twins + reencoded A.md and a.md / head twins twins + reencoded a.md only twins
re-encoding twins, code leg main twins + moved src/A.md and src/a.md / head twins the same twins
NOTE set, one CRLF blob identical (reencoded, A.md and a.md) identical (a.md only, both sides) identical
NOTE set, LF identical, 0, plan ok identical identical
real re-encoding, no twins identical identical identical
leg sets (code, spec), file and folder, twins across root and code leg identical identical identical
root twins + code-leg twins + code-leg NOTE set identical twins x2 + alias twins x2
twin .gitattributes: rules agree / disagree / rule in one only identical alias; the CRLF files beside them unnamed byte for byte the unset run
edge: .gitattributes + .GITATTRIBUTES; code-leg twin .gitattributes identical identical identical

Every line the head prints other than the dropped twin plan-file-reencoded line and its count line is byte-identical to main, stdout and stderr, after normalising paths, ids and throwaway names. The only added lines are the files git's alias refusal had hidden, and those equal the unset run byte for byte.

execute for folder twins under ignorecase=true, and for re-encoding twins in the root unset, 2 of 2 runs a side:

  • exit 2 on both sides, REFUSED plan-case-twins;
  • main lists 2 finding(s), the head 1 finding(s);
  • the remediation says "no leg repository was created", and the remotes folder was never made.

2. The .gitattributes claim

  • Rules agree (*.txt text eol=lf in both): under the setting the head names D/n.txt and d/m.txt, as main does without it. Main under the setting printed only the alias refusal.
  • Rules disagree (d/ holds *.txt -text): the head names D/n.txt alone, in both configs, byte for byte main's unset output. That is honest for this machine, since on a disk that keeps case only D/'s rule reaches D/n.txt. The set is refused by plan-case-twins in any case, and the next check after the rename asks again. On a disk that ignores case the folders merge under one .gitattributes; I read that from the code and did not measure it (N4).

3. The side effect

Nothing a person needs is lost, but it arrives one round later. I committed git mv a.md b.md (src/ in the leg case), then re-ran plan and check:

  • root: plan-case-twins (A.md, a.md) became plan-file-reencoded (A.md, b.md), unset and under the setting;
  • code leg: the same with src/A.md, src/b.md.

execute refuses the set either way (item 1). The body says this, but not plainly that a true finding is deferred (N3).

4. The refactor

_case_twins_kept is a pure extraction. plan-case-twins lines, NOTE lines and the rename remediation are identical, main against head, in 40 of 40 source-config pairs. Those include a NOTE set (root and code leg), sets in the code and spec legs, sets in the root and a leg at once, twins split across root and leg (no finding on either side), and a file against a folder.

refused is the right set. When it is non-empty the rehearsal returns [] before twins is used, and when it is [] or None the call gets (). So M8, _case_twins_kept(plan, tree, ()), is equivalent (it survived, as expected).

5. The writer's quirk

Under the setting, a one-blob CRLF NOTE set is named as a.md only, on main and the head, 2 of 2 runs each. It is out of #1184's scope (one finding, not two) and is a follow-up candidate (S3).

6. The rows

  • Head: 8 passed, 223 deselected in 9.57s.
  • Main's adopt-project.py swapped in: 7 failed, 1 passed, 223 deselected in 10.90s. The 7 failures are the 4 named_once rows, execute, beside and other_bytes; the control passed.
  • Restored, then cmp equal and git diff --quiet HEAD.

The assertions are tight. named_once and other_bytes compare the whole finding_lines list, beside compares the code list and both runs' lines, and execute checks the code and every detail line. The double finding coming back in any order or under any code fails them.

Mutants, each run over the throwaway module with -k 'twin or case or note or rehearsal or reencod' plus tests/test_adopt_reencoded_files.py (135 rows), each restored and cmp equal:

Mutant Result
M6: moved twins asked again survived (S1)
M7: twin .gitattributes not checked out survived (S2)
M8: refused or () to () survived; equivalent (item 4)
M9: whole twin folders left out survived (S2)

7. The gates

Head 9607ef6e, one pytest at a time, -rs, under the stub gh, git-filter-repo (a40bce548d2c), GIT_CONFIG_GLOBAL=/dev/null:

  • throwaway + test_adopt_root_symlinks.py, one run: 435 passed, 5 skipped in 368.21s (0:06:08) (2 need a disk that ignores case, 3 need git 2.45 for reftable)
  • the 8 other modules that grep either code: 443 passed, 2 skipped in 316.85s (0:05:16) (2 eol_block_through_links rows for a root that keeps a link)
  • tests/test_shape_doctor.py, in the background: 446 passed, 3 skipped in 463.77s (0:07:43) (Windows PowerShell)
  • tests/test_scaffold_leg_path_collisions.py: 104 passed in 3.02s
  • the guards: 470 passed, 3 skipped in 54.49s (1 Windows default, 2 because GIT_CONFIG_GLOBAL was set)

All of these match the body's numbers.

8. Merges

I measured #1237 at both heads, as asked.

9. The body

  • It says what a person sees change, why (A) over (B), and that the disk that ignores case is read, not measured.
  • It has one closing keyword (Closes #1184) and is ASCII except the robot line. A Sourcery summary follows that line, added by the bot.
  • Every number I re-measured matches.
  • Fixes: S3, N1 to N4.

Not measured

  • Anything on a disk that ignores case: none is writable here.
  • CI tests was still running when I last read the checks; tests-git-bash, package and Sonar were SUCCESS.

Paths

Lane 3's response (lane openRepoShape-3): all of it is taken. A fix round, FX1184, adds S1's parametrized row and S2's row to tests/test_adopt_check_throwaway_repos.py and gates them. It also rebuilds the body with S3 and N1 to N4 and the new counts. An opus delta review follows. The follow-up candidate in S3's "Out of scope" goes to Brett Heap for his word before anything is filed. #1297 stays a draft with no milestone until it reads READY.

…n bytes re-encode runs in the root and in the code leg, and a new row holds that twin `.gitattributes` still reach the files beside them, so the rehearsal asking moved twins again, leaving out twin `.gitattributes`, or leaving out a whole twin folder fails a row (#1184)

RV1184's review of #1297 found three mutants of the rehearsal's twin
exclusion that every row let through. M6 asks the twins a leg takes
again: `test_twins_git_would_store_as_other_bytes_are_the_set_alone` is
now parametrized over the root and the code leg, and fails M6 at
`[code-leg]`. M7 stops checking out twin `.gitattributes` and M9 leaves
out every file in a folder that holds a named twin:
`test_the_twins_rules_still_reach_the_files_beside_them` keeps `D/n.txt`
and `d/m.txt` named beside `plan-case-twins`, with `core.ignorecase=true`
as without it, and fails both.

adopt-project.py is not touched. 10 rows for #1184, 9 failing on main and
a control that passes on both.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-3, 2026-11-10T06:58:37Z) RV1184b: READY at 2e020b4

This is a delta review of #1297's fix round 1 (9607ef6e..2e020b45, one commit). The PR head reads 2e020b45, and closingIssuesReferences reads [1184]. The round takes S1, S2, S3 and N1 to N4 from the round-0 review, and every number I re-measured matches. The live body is still round 0: lane 3 should apply /workspace/projects/openRepoShape-worktrees/_lane3-scratch/W1184-scratch/pr-body-fx.md (sha256 d84cf4ad6b7271129607c10acb995101aa43df0f8e2b3960814be9f8c0a3110e). After editing, re-check closingIssuesReferences.

MUST / SHOULD

None.

NIT

N5. The #1237 bullet under "Merges" says "over both PRs' new rows" twice. Replace its last sentence with:

#1237 leaves plan-case-twins' wording and the module's twins_kept as they are, and both PRs' new rows pass in that resolution: 49 passed, 4 skipped in 23.88s (the 4 need a disk that ignores case).

Evidence

1. S1 and S2: the rows

The two rows are the ones the round-0 review gave, word for word in their code; only the docstrings are new.

  • test_twins_git_would_store_as_other_bytes_are_the_set_alone is parametrized [root] and [code-leg].
  • test_the_twins_rules_still_reach_the_files_beside_them is new.

I ran the #1184 rows at 2e020b45, in my own clone with my own -k:

Run Result
head 10 passed, 223 deselected in 11.28s
main's adopt-project.py swapped in 9 failed, 1 passed, 223 deselected in 10.95s (the control passed)
M6 1 failed, 9 passed, 223 deselected in 11.61s, killed by other_bytes[code-leg]
M7 1 failed, 9 passed, 223 deselected in 12.81s, killed by the_twins_rules_still_reach_the_files_beside_them
M9 1 failed, 9 passed, 223 deselected in 12.46s, killed by the same row

The main run's 9 failures are the 4 named_once rows, execute, beside, other_bytes[root], other_bytes[code-leg] and rules_still_reach. Each file was restored and cmp equal, and the clone is clean at 2e020b45.

2. Nothing else changed

  • The diff touches only tests/test_adopt_check_throwaway_repos.py, +36 / -10, all in those two rows. I read it in full.
  • git diff --quiet 9607ef6e 2e020b45 -- adopt-project.py passes, and cmp with my saved head copy is equal.
  • ROOT and ROOT_ATTRIBUTES come from test_adopt_nested_attributes (line 124). CODE_TWINS is the module's own (line 580). No new import is needed.
  • No test module imports test_adopt_check_throwaway_repos (git grep over tests/ finds none).

3. The body (pr-body-fx.md against the round-0 live body)

Fixes taken. S3's opening section and "Out of scope", and N3 and N4, are word for word. N1 and N2 are taken, re-measured at 2e020b45 and main 0aef55b9.

Merge trees. I spot-checked them myself:

The #1237 resolution. I rebuilt it at 2e020b45 (both sections, this one first) and ran both PRs' new rows: 49 passed, 4 skipped, 223 deselected in 26.14s. The 4 skips need a disk that ignores case.

"Fix round 1" against the logs:

  • G1 437 passed, 5 skipped in 388.18s (0:06:28) matches fx-gate-g1.log.
  • The guards' 470 passed, 3 skipped in 53.50s matches fx-gate-g2.log.
  • M1 to M9 match mut-fx-a.log and mut-fx-b.log in order and count (M1 9 failed ... M9 1 failed, 28 passed in 23.47s), each restored cmp equal.
  • The 12 PR trees match merge-fx-head.txt.
  • The rows line (14.72s), the main-swapped line (11.51s) and the resolution line (23.88s) appear only in values-fx.json, with no log behind them. My own runs give the same counts (10 / 9 failed + 1 passed / 49 + 4).

Format. The body is ASCII above the robot line (line 186). Closes #1184 is its only closing keyword.

"The other gates stand at 9607ef6e." The claim holds. adopt-project.py is byte-identical, and no other module imports the changed test module. So the other modules gate, the doctor and the scaffold collisions run the same code as at 9607ef6e, and the guards were re-run at 2e020b45.

4. The gate at 2e020b45

tests/test_adopt_check_throwaway_repos.py with tests/test_adopt_root_symlinks.py, in one run, -rs: 437 passed, 5 skipped in 374.18s (0:06:14). The 2 throwaway skips need a disk that ignores case; the 3 root-symlinks skips are reftable rows that need git 2.45. It is 435 at 9607ef6e plus the 2 new rows.

Not measured

  • No other gate at 2e020b45, as the brief says.
  • Anything on a disk that ignores case.

Paths

  • Clone, detached at 2e020b45 and clean: /workspace/projects/openRepoShape-worktrees/_lane3-scratch/RV1184. The refs/fx/* and refs/rv/* refs live only there.
  • Scratch: /workspace/projects/openRepoShape-worktrees/_lane3-scratch/RV1184-scratch (gate-fx-g1.log, gate-m1237c.log, rows-k2.txt, k-m1237c.txt, worktree m1237c-wt).
  • Nothing is pushed, posted or written to a register, and no background job is left running.

Lane 3's response (lane openRepoShape-3): N5 is taken. The live body is now round 1 (sha256 bb632e17f5ec104c), with the Sourcery block as the bot left it, and closingIssuesReferences reads [1184]. So #1297 is READY at 2e020b45. Lane 3 sets v1.2.1, marks it ready, and places it on #473 by its READY time. It lands after #1237: its refresh then meets #1237's section in the test module, and keeps both, this one first, as both reviews measured.

@brettheap brettheap added this to the v1.2.1 milestone Nov 10, 2026
@brettheap
brettheap marked this pull request as ready for review November 10, 2026 06:59
@sourcery-ai

sourcery-ai Bot commented Nov 10, 2026

Copy link
Copy Markdown

Sorry @brettheap, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 4 days and 14 hours by commenting @sourcery-ai review. Upgrade to get a review now.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

adopt-project.py: the #654 rehearsal names folder case twins as plan-file-reencoded where git refused them as a file alias

1 participant