Repository navigation
adopt-project.py: the mount removes a path a case twin is at or under with git rm -f and writes the twins the root keeps back from the index, so on a disk that ignores case execute keeps a set the plan splits across repositories or drops (#1183) - #1237
brettheap wants to merge 4 commits into
Conversation
… with `git rm -f`, and writes each twin the root keeps back from the index, so a disk that ignores case no longer loses a set the plan splits across repositories or drops a path of (#1183) Measured with git 2.43 at 4d40ce9, on a FUSE disk that ignores case: the split's clone checks out one file per twin set, holding the bytes of the twin git wrote last. Where the twin that goes sorts last (`A` root, `a` code), `git rm a` deleted the one shared file and `execute` stopped with `adopt-lost` after both legs and the split were pushed; where it sorts first (`A` code, `a` root), `git rm A` refused with "local modifications" and `execute` stopped mounting the legs. `_split_case_twins` lists the assembly's index once and keeps each `_case_twin_sets` set with a path at or under a removed path. Each removed path with such a twin is removed with `GIT_RM_FORCED`; every other path keeps `GIT_RM`. After the last `git rm`, the set's paths the root keeps are written back with `git checkout-index -f -z --stdin`. A source with no split set gets the same `git rm` commands and nothing written back. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eps each case twin the root keeps with its own blob where the plan sends its twin to a leg or drops it, removes only such a path with `-f`, writes back only the kept twins, and that `execute` adopts such a split, whole, on a disk that ignores case (#1183) The mount rows make each set one file on any disk (hard links to the twin git writes last), as #1183's trace measured on a disk that ignores case. At head 10 passed, 4 skipped (the FUSE-only rows, on a disk that keeps case); at base 4d40ce9 8 failed: the six mount rows (a wrong blob, or `CommandFailed` at `git rm -- A`), the `-f` command row and the write-back row. The no-split command row and the control pass at both. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#1189 (fix/1161-check-names-case-twins) was restacked onto main by merges and gained two commits of its own since 4d40ce9: `plan-case-twins` in the root leaves to the NOTE only two paths of one blob whose names differ only in their last part, and its rows. No conflict: git merged the comment above `plan-case-twins`, where both sides changed a paragraph, by itself. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t rm` git refuses is named with `-f`, and that the mount keeps case twins whose names are not UTF-8 with their own blobs, reading the index and handing the kept twin back as bytes (#1183) Mutants of ef18f05's new lines left three that change what the tool does alive: `_git_rm` naming `GIT_RM` in its `CommandFailed` where it ran `GIT_RM_FORCED`, and `_split_case_twins` decoding the listing, or `_write_from_the_index` encoding the names, as strict UTF-8, which raises a `UnicodeError` mounting the legs, after both are pushed. `z_rows` reads git's NUL-ended rows as `_git_path` reads a path, so `an_assembly` and `staged` hold a name that is not UTF-8; `an_assembly` takes the function that adds the paths, and `with_names_not_utf8` adds them through the index, as `with_paths` hands `git mktree` text. The row skips where the disk takes no such name. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reviewer's GuideUpdates Sequence diagram for preserving split case twins during leg mountingsequenceDiagram
participant Mount as _mount_the_legs
participant Index as Git index
participant Git as git
participant Disk as Case-insensitive disk
Mount->>Index: git ls-files -z
Index-->>Mount: source paths
Mount->>Mount: _split_case_twins
loop removed paths
Mount->>Git: _git_rm(path, forced)
Git->>Disk: git rm -r -q -f -- path
end
opt root-kept twins
Mount->>Git: _write_from_the_index(paths)
Git->>Index: checkout-index -f -z --stdin
Index-->>Disk: write kept twin bytes
end
Mount-->>Mount: continue staging and verification
Flow diagram for case-twin handling during assemblyflowchart TD
A[_mount_the_legs] --> B[git ls-files -z]
B --> C[_split_case_twins]
C --> D{Path at or under a case-twin set?}
D -->|Yes| E[_git_rm with -f]
D -->|No| F[_git_rm without -f]
E --> G{Twin kept in root?}
F --> H[Continue mounting]
G -->|Yes| I[_write_from_the_index]
G -->|No| H
I --> H
H --> J[Stage split and verify adoption]
File-Level Changes
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
|
|
(lane openRepoShape-1, 2026-10-24T14:47:26Z) r1237, lane 1's independent reviewer (not the writer), reviewed #1237 at
The full reviewr1237: independent review of PR #1237 at
|
| scenario | head c9f0cc18 |
base 58aa9d2b |
|---|---|---|
D/ code, d→abs victim root |
check plan ok; execute exit 0, adoption verified; victim after: keep only (x deleted) |
execute exit 2, REFUSED mounting the legs at git --literal-pathspecs rm -r -q -- D, "local modifications: D/x"; victim intact |
D/ drop, d→abs victim root |
exit 0, verified, x deleted |
not run yet (batch2 b3) |
D/ code, d→../../victim root |
exit 0, verified, x deleted |
not run yet |
Logs: runs/fuse-h2-symvictim.txt, runs/fuse-b2-symvictim.txt and runs/fuse-h3.txt.
Evidence 3: the same on CI's kind of disk. The probe is probes/probe_symlink_row.py, which calls _mount_the_legs with D made a symlink by hand:
- head:
mount: returned,victim after: ['keep']; - base:
mount raised CommandFailed - ['git', '--literal-pathspecs', 'rm', '-r', '-q', '--', 'D'],victim after: ['keep', 'x'].
Fix:
- Before a forced removal, check with
os.lstatevery leading folder of each index path at or under it. Use the module's_is_a-style helper, not a bare guard. - Where a leading folder is a symlink, remove that path from the index only (
git rm --cached -r). git counts a path beyond a symlink as absent from the work tree, andgit add -Adoes not follow it. The other option is to refuse there. - Add a row on the bench disk in the shape of the probe:
D/xcode,da symlink to a folder outside the assembly,Dreplaced by that symlink. It should assert that the outside file survives and thatD/xleaves the index. That row fails at head today.
SHOULD 1: plan-case-twins' sentence is now stale, and the PR leaves it so
I measured the claim in the brief and it holds. On FUSE, with _case_twin_findings patched to name no finding:
- At base (
bn): legcode, legspec, legcodedir and legsrc gaveexecute exit 2,REFUSED mounting the legsatgit rmofA,A,Dandsrc. legff verified. - At head (
hn): legcode, legspec, legcodedir and legff gaveexecute exit 0,adoption verified. legsrc is still running.
So no set kept in one repository can now stop at git rm. Root sets never reach git rm at all.
These places must change, and the PR changes none of them (its body says so under "Not in this PR"):
adopt-project.py:9418-9422, the finding as printed: "...executecould stop there before any leg exists, at git'swill not add file alias, or after both legs are pushed, asadopt-lostor atgit rmmounting the legs, soexecuterefuses them on every disk." Drop the bolded clause. I have not yet measured whether "after both legs are pushed, asadopt-lost" still holds, and for which repository; it may only hold for root sets. That check is the queuedhrrun (root, rootff and rootdir with no finding, at head).tests/test_adopt_check_throwaway_repos.py:537-543(twins_kept) pins the same words, ending with "git rmmounting the legs" at :542. It must change in step with item 1.adopt-project.py:9266-9270, a comment: "...in 3 others, for adopt-project.py:checknames the case twins the plan keeps in one repository asplan-case-twinson every disk, a one-blob set as a NOTE in a leg and, in the root, two paths in one folder, andexecuterefuses the finding before any leg (#1161) #1189's review,executestopped after both legs were pushed, mounting them, atgit rm -r(...)". This is history; add "until adopt-project.py: on a disk that ignores case,executeloses a case twin the plan keeps in the root when its twin goes to a leg #1183".
No doc file names plan-case-twins. A git grep finds it only in adopt-project.py, the throwaway test file and test_adopt_make_reads_first.py, and the last of these does not quote the clause. #1189's review (r1189c) expected #1237 to update the sentence.
NIT 1: _split_case_twins' docstring (adopt-project.py:14374) is too narrow
It says the function returns "a set the plan sends into different repositories, or drops a path of". It also returns sets kept whole in one leg, such as the one-blob NOTE sets in a leg, and those are removed with -f too. The hn runs above take that path.
NOTE (landing): closingIssuesReferences is []
The PR's base is fix/1161-check-names-case-twins, and GitHub does not link closing issues for a non-default base. Once #1189 lands and this PR is retargeted to main, check that Closes #1183 links. Also note that #1189 is still a DRAFT, at 58aa9d2b.
Commands and summary lines (probe env: stub gh, GH_TOKEN=bogus-r1237, empty GH_CONFIG_DIR, GIT_CONFIG_GLOBAL=/dev/null, GIT_SSH_COMMAND=false, EMAIL=r1237@openreposhape.invalid, env -u LANES_LANE)
- Head,
tests/test_adopt_check_throwaway_repos.py(whole file):103 passed, 6 skipped in 258.05s. This matches the body. - Base tool (
58aa9d2b'sadopt-project.py, blob5fac9479) with head's tests, the 16 rows in adopt-project.py: on a disk that ignores case,executeloses a case twin the plan keeps in the root when its twin goes to a leg #1183's section:10 failed, 2 passed, 4 skipped. This matches the body's table, failure reason for failure reason:- wrong blob: across, dropped, a-folder-kept-beside-a-file and the non-UTF-8 row;
CommandFailed: git --literal-pathspecs rm -r -q -- A: across-the-other-way, both-dropped and one-to-each-leg;- the command list without
-f; - no
_write_from_the_index; _git_rm() takes 2 positional arguments.
tests/test_scaffold_leg_path_collisions.py:104 passed.tests/test_path_guard_hygiene.py tests/test_no_auto_maintenance.py:107 passed, 2 skipped.- Doc trio:
171 passed.
Mutants (my own; clone r1237/mut; #1183's 16 rows; each restored in a finally, blob equal to HEAD's 13579470)
| id | mutant | result | killed by |
|---|---|---|---|
| r01 | GIT_RM_FORCED without -f |
KILLED | across-the-other-way, both-dropped, one-to-each-leg, cmd-order row, forced-refusal row |
| r02 | _at_or_under with no / boundary |
KILLED | test_a_source_with_no_twin_set_split_is_mounted_as_before |
| r03 | _at_or_under matches the path itself only |
KILLED | across, across-the-other-way |
| r04 | forced flag with its arguments swapped | KILLED | across-the-other-way |
| r05 | kept = every twin of a split set |
KILLED | 9 rows |
| r06 | no write-back | KILLED | across, dropped, folder-beside-file, cmd-order, non-UTF-8 |
| r07 | write-back before the git rm loop |
KILLED | test_only_a_path_a_twin_is_at_or_under_is_removed_with_f only |
| r08 | write-back names encoded as strict UTF-8 | KILLED | non-UTF-8 row |
| r09-r16 | the listing decoded with replace, a set split only by its first twin, checkout-index without -f, the unforced command named, a write-back failure ignored, every path forced, every set taken as split, the unforced command run |
still running (PID 2834213) | n/a |
So far: 8/8 killed, with 8 restores True. On r07: the hard-link simulation alone does not catch the order. Only the row that checks the command order kills it, so that row is load-bearing.
The writer's tally reconciles with its own logs. w1183/runs/mutants-ef18f059.out shows 34/40, with M05, M07, M16, M21, M22 and M23 surviving. w1183/runs/mutants-ef18f059-r2.out shows "killed 37/40; survivors: M07 ..., M16 ..., M22 ...", and every restore line shows blob 13579470. I agree that the three survivors are equivalent:
_git_rm's only caller (:14456) always passesforced;- a set of one name
""is never kept; CHECKOUT_INDEXis a constant command made only of text.
Body checks
- Title: 249 characters (cap 250).
- Closing form: exactly one,
Closes #1183(body line 111). My regex scan for close/fix/resolve/cover followed by#Nfinds nothing else, and the Sourcery block holds none. - Stacking: the statement is right.
c49de697has parentsa455c759and58aa9d2b, and the diff against58aa9d2bis the two named files (438+/20−). - Gate counts: the four I re-ran match. I did not re-run
test_adopt_make_reads_first.py(33) or the group of five files (316). - The "Why" bullet on
-fis false (MUST 1).
Not covered
- Mutants r09 to r16 are running (PID 2834213; log
r1237/runs/mutants.txt). - FUSE batches are still running:
- PID 2828083 (batch1):
hnlegsrc, then head unpatched with legcode and root; - PID 2862988 (batch2): syminside, rootnote3 (two one-blob twins in the root plus a third sent to code), base runs of the symlink variants, a re-check of the body's FUSE table at head, symboth, and root, rootff and rootdir with no finding at head and base.
- Logs are under
r1237/runs/fuse-*.txt.
- PID 2828083 (batch1):
- The 3.14-like shim run was not made. The shim is written in
r1237/shim/, and the PR adds no pathlib checks. - Not run:
test_adopt_make_reads_first.py,test_adopt_git_rm_bytes.py(the mount in the C locale), the other mount files and the fourtest_shape_doctor.pyshards.
State
- The review checkout
/workspace/projects/openRepoShape-worktrees/l1-r1237is clean (git status --shortis empty). - The checkpoint is
/workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1237/checkpoint.md. - The scratch clones are
mut,baseandfz, in/workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1237/.basecarries base's tool on purpose, andfzholds untracked copies of the tool. - I pushed nothing and posted nothing.
Lane: openreposhape-1 (openRepoShape-1)



Stacked on #1189 (
fix/1161-check-names-case-twins, head58aa9d2b); it lands after #1189. Its base is #1189's branch, and its diff against58aa9d2bisadopt-project.pyandtests/test_adopt_check_throwaway_repos.pyalone.Outside v1.2.0, by Brett Heap's ruling of 2026-10-21T10:32:04Z ("leave the follow-ups out of v1.2.0").
What
On a disk that ignores case,
executelost a case twin the plan keeps in the root when its twin goes to a leg or is dropped, or stopped mounting the legs, in each case after both legs were pushed.checkprintedplan okfor every such plan:plan-case-twins(#1161, #1189) asks only about twins the plan keeps in ONE repository.Measured with git 2.43 on a FUSE disk that ignores case and keeps it, in a private namespace. The split's clone checks out one file per twin set, holding the bytes of the twin git wrote last in the index's order (
aafterA,d/xafterD/x), and git saysthe following paths have collided. Then:Aroot,acode),git rm adeleted the one file both names share, andgit add -Astaged the kept twin's deletion:adopt-lost, exit 1;Acode,aroot),git rm Arefused withthe following file has local modifications: A:REFUSED mounting the legs, exit 2. That also happened with BOTH twins dropped, and with one twin sent to each leg.Each leg was always right, since
git filter-repoworks on objects. The loss was only in the assembly's work tree, between_mount_the_legs'sgit rmloop and_stage_the_split'sgit add -A -- ..Now
_mount_the_legsdoes three things:git ls-files -z, read-only), and keeps each_case_twin_setsset with a path at or under a path it removes (_split_case_twins);git --literal-pathspecs rm -r -q -f --(GIT_RM_FORCED), and every other path with today'sGIT_RM;git rm, it writes the twins of those sets that the root keeps back from the index withgit checkout-index -f -z --stdin(_write_from_the_index). A refusal there isCommandFailed, one ofLATE_FAILURES, naming the command as_git_rmnames its own.A source with no such set, as nearly every source is, gets the same
git rmcommands as before and nothing written back. The one command added for it is the read-only listing.FUSE scenarios measured,
plan,checkthenexecute --local-remote-dirEach twin holds a blob of its own unless noted. Base is #1189's head as it was then,
4d40ce9f. Head isef18f059'sadopt-project.py. The merge of58aa9d2bchanged that file only in the root NOTE's rule, which none of these plans reaches, since each keeps no set in one repository.4d40ce9fA,D/root;a,d/codeadopt-lostA,D/xA,D/code;a,d/rootREFUSED mounting the legsatgit rmA,D/root;a,d/specadopt-lostA,D/xAroot;adropadopt-lostAAdrop;arootREFUSED mounting the legsA,adropREFUSED mounting the legsAspec;acodeREFUSED mounting the legsF/root;fcodeadopt-lostF/xF/code;frootF/root;fdropadopt-lostF/xAroot;acode; one blobAroot;adrop; one blobD/xroot;d/ycodeD/E/xroot;d/e/x,d/e/ycodeA,arootcheckexit 1 andexecuteexit 2,plan-case-twinsbefore any leg (#1189's refusal, unchanged)"verified" means
checkprintedplan ok, andexecuteexited 0 withadoption verified: every source path is in exactly one place. Where base was measured, itscheckprintedplan oktoo.On the bench disk, which keeps case, base verified all 15 scenarios above but
root. At head, onlyacrossandacrossrevwere run there, and both verified (see Known limits).Why
-fon the paths a twin is at or under, and on no other.-fskips only git's question whether the file on the disk still holds what the index holds. In a fresh clone, that question can fail only through the collision, sincecheckrefuses re-encoded files before any leg. Every other path keeps the command it had, and a row pins that it does.git rmwas rejected: onegit rm -rcan remove two twins at once (a dropped folder holdingd/xandd/X, whichplan-case-twinsdoes not ask about, because drop is no repository), and on a disk that ignores case the write and the removal meet the same file. A row pins the order.--cachedwas rejected. On a disk that keeps case it leaves the file, andgit add -Aadds it again.core.ignorecaseprobe is made. The same commands run on every disk, and on a disk that keeps case the write-back rewrites the bytes each kept twin already holds. This is why the rows can make the one shared file themselves, with hard links, on CI's Linux disks._git_pathreads a path (UTF-8, a byte that is not UTF-8 kept as a surrogate). The namescheckout-indexreads are handed back as the bytes the tree holds (surrogateescape), on its input, so no locale and no length of a command line is in the way.plan-case-twins's remediation text is unchanged. Its comment and the comment above the finding now say whatexecutedoes since this change.Tests
In
tests/test_adopt_check_throwaway_repos.py, section "#1183", before "L1-92". Base is58aa9d2b(this PR's base: itsadopt-project.pyswapped into this head's tree). Head isc9f0cc18.58aa9d2btest_the_mount_keeps_each_twin_the_root_keeps_with_its_own_blob(6 shapes: across, across-the-other-way, dropped, both-dropped, one-to-each-leg, a-folder-kept-beside-a-file)CommandFailedgit --literal-pathspecs rm -r -q -- A(across-the-other-way, both-dropped, one-to-each-leg)test_a_source_with_no_twin_set_split_is_mounted_as_before(guard)test_only_a_path_a_twin_is_at_or_under_is_removed_with_f-fand nocheckout-indexamong the commandstest_a_path_git_cannot_write_back_is_refused_naming_the_command_write_from_the_indextest_a_forced_git_rm_git_refuses_is_refused_naming_it_with_f_git_rmtakes noforcedtest_twins_whose_names_are_not_utf_8_keep_their_own_blobstest_on_a_disk_that_ignores_case_execute_keeps_twins_it_splits(4 shapes, FUSE only)test_execute_adopts_twins_it_sends_to_different_repositories(the control, any disk)Totals: base 10 failed, 2 passed, 4 skipped; head 12 passed, 4 skipped. The 4 skips are the rows that need a disk that ignores case, and every CI disk keeps case. They are skipped where
git initwrites nocore.ignorecase=true. The rows that drive the mount make each set one file themselves, with hard links, as the trace measured.Mutants of
ef18f059's new lines (GIT_RM_FORCED,_git_rm'sforced,CHECKOUT_INDEX,_at_or_under,_split_case_twins,_write_from_the_index,_mount_the_legs' use of them), run against the section's rows in a clone, each restored in afinallywith its blob checked equal to HEAD's:M05,M21andM23: 37/40 killed.The three survivors are equivalent:
forced: bool = Trueas_git_rm's default: its one caller always passesforced;ls-filesrecord kept: the name""is a set of one, and no set of one is kept;check_programdropped from_write_from_the_index:CHECKOUT_INDEXis a constantgitcommand, all text, whichcheck_programalways allows.Gate at
c9f0cc18, one pytest call per line, under a stubgh,GH_TOKEN=bogus, an emptyGH_CONFIG_DIR,GIT_CONFIG_GLOBAL=/dev/nullandGIT_SSH_COMMAND=false(CPython 3.12.3 on the bench; CI runs 3.14):tests/test_adopt_check_throwaway_repos.py: 103 passed, 6 skipped (the 4 FUSE-only rows, and 2 outside this section);tests/test_adopt_make_reads_first.py: 33 passed;tests/test_path_guard_hygiene.py tests/test_no_auto_maintenance.py: 107 passed, 2 skipped;tests/test_scaffold_leg_path_collisions.py: 104 passed;tests/test_adopt_clone_template.py tests/test_adopt_git_rm_bytes.py tests/test_adopt_late_failure.py tests/test_adopt_mount_ignored.py tests/test_adopt_submodules.py: 316 passed.tests/test_shape_doctor.py(4 shards) is lane 1's to run.Known limits
The bench disk at head was not run in full. Only
acrossandacrossrevwere run there, and both verified. At base, all 15 verified there. At base on FUSE,samebytesacross,samebytesdropped,dirmergeacross,deepacrossandnonewere not measured, androotwas not run.threegave no result. That is a defect in the bench script, not in the tool: the plan quotes the nameÅ, and the bench'spath:match does not. The scenario (Aroot,acode,Ådrop,åroot) was not measured on either side.The FUSE-only rows have not been run on a FUSE disk. They skip on every CI disk. The scenarios they hold were measured on FUSE through the bench, as in the table above.
plan-case-twins's sentence is now partly stale (lane 1 rules on adopt-project.py:checknames the case twins the plan keeps in one repository asplan-case-twinson every disk, a one-blob set as a NOTE in a leg and, in the root, two paths in one folder, andexecuterefuses the finding before any leg (#1161) #1189's text; it is unchanged here). It saysexecute"could stop ... after both legs are pushed, asadopt-lostor atgit rmmounting the legs" for twins the plan keeps in one repository. Measured on the FUSE disk with_case_twin_findingspatched, in a scratch copy, to return no finding, for twins kept in one leg (Aandato code;Aandato spec;D/andd/to code;F/andfto code;src/Aandsrc/aundersrc/to code):58aa9d2b:checkprintedplan ok, andexecuteexited 2 withREFUSED mounting the legs, after both legs were pushed, in four of the five: atgit --literal-pathspecs rm -r -q -- <path>,<path>beingA(to code),A(to spec),Dandsrc, git saying "the following file has local modifications".F/andfverified.adoption verified. Thegit rmstop is gone for twins in one leg.plan-case-twinsincheck(exit 1), andexecuterefuses them before any leg (exit 2).The finding still stands: such a leg holds twins that a clone of it on such a disk cannot check out apart. Only the clause naming
git rmno longer describes whatexecutewould meet for a leg.Not in this PR
checkagain". It is now safe on every disk measured, but it is left for a follow-up, withTWINS_REMEDIATIONand the doc trio.plan-case-twins's sentence (see Known limits).checkthat asks the work directory's disk forcore.ignorecase. This change makesexecutekeep these sets on every disk, socheckneeds no such probe for them.Closes #1183
Lane: openreposhape-1 (openRepoShape-1)
🤖 Generated with Claude Code
Summary by Sourcery
Keep case-distinct paths intact when adoption moves or drops their twins on case-insensitive filesystems.
Bug Fixes:
Enhancements:
Tests: