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 10 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 GuideImplementationAdd index-based case-twin detection and safe mount removals, restoring root-kept twins while avoiding forced deletion through symlinks or non-directory gitlinks and updating related diagnostics.
TestsAdd regression coverage for split and dropped case twins, byte-preserving write-back, command selection, symlink and gitlink safety, and end-to-end adoption.
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)
… holds beyond a symlink on the disk leaves the index alone (`git rm --cached`), so on a disk that ignores case no file is removed through a symlink a case twin left (#1183) #1237's review (r1237, MUST 1): on a disk that ignores case, a folder `D/` the plan sends to a leg (or drops) and a symlink `d` it keeps are one name, and the split's clone writes `d` last, so `D` is that symlink. The forced `git rm -r -q -f -- D` then unlinked `D/x` through it: the file `x` in the folder the symlink leads to, outside the work directory, and `execute` printed `adoption verified`. Without `-f`, git refused it. Now `_mount_the_legs` lists the index once (`_index_paths`), and before each removal with `-f` asks each path the index holds at or under it, folder by folder from the top, with `lstat` (`_beyond_a_symlink`, through `_is_a_symlink`, which asks as `_is_a` asks: nothing there is no symlink, and a refused `lstat` is raised, one of `LATE_FAILURES`). A path beyond a symlink is taken out of the index alone (`GIT_RM_CACHED`, `_taken_beyond_a_symlink`): the symlink's own path where it is at or under the removed path, else the path. The rest are removed with `-f` as before; where nothing is left, no `-f` runs. `--cached` rather than a refusal: it touches no file on the disk, git counts a path beyond a symlink as gone from the work tree, `git add -A` does not follow a symlink into a folder, and the symlink is the twin the root keeps, written back from the index, so the root's tree is the plan's and each leg's is the objects'. A refusal would come after both legs are pushed, for a plan that is good on every disk. Rows (section #1183): `test_no_file_is_removed_through_a_symlink_a_case_twin_left` (7 shapes: the probe's absolute link, `../../victim`, the folder dropped, a link to a folder the root keeps, both in a dropped folder beside other files, two files beyond the link, entries below the link), the control `test_a_folder_that_is_no_symlink_is_still_removed_with_f`, `test_the_first_symlink_on_the_way_is_named_and_nothing_else` (6) and `test_a_folder_on_the_way_nobody_may_search_is_raised_not_read_as_none`. At c9f0cc1 the 7 symlink shapes fail (the folder the link leads to lost `x`, or `E/x`); the control passes at both. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ould stop `execute` after both legs are pushed as `adopt-lost`, and no longer names a stop at `git rm` mounting the legs, which #1183's `-f` took away (#1183) #1237's review (r1237, SHOULD 1): the finding's words "or after both legs are pushed, as `adopt-lost` or at `git rm` mounting the legs" went stale with #1183's `-f`. Measured first, git 2.43 on a FUSE disk that ignores case, this head with `_case_twin_findings` patched in a scratch copy to name no finding, 1 run each: - twins kept in one leg (`A` and `a` in the code leg, and in the spec leg; `D/x` and `d/x`; `F/x` and `f`; `src/A` and `src/a`): `execute` exit 0, `adoption verified` (r1237 measured the same at c9f0cc1; at 58aa9d2 four of them stopped at `git rm`); - in the root: `A` and `a` verified; `F/x` beside `f` exit 1, `adopt-lost: F/x`, after both legs were pushed; `D/x` beside `d/x` `check` exit 1, `plan-file-reencoded` (`will not add file alias`), `execute` refused before any leg. So "after both legs are pushed, as `adopt-lost`" still holds, for a set in the assembly root, and the `git rm` stop is gone. The three places change together: the finding's words; `twins_kept` in `tests/test_adopt_check_throwaway_repos.py`, which pins them; and the history comment above the finding, which now says the `git rm` stop lasted "until #1183" and records these runs. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the plan keeps whole in one leg, which reach the mount only as `check`'s one-blob NOTE and whose paths are removed with `-f` too (#1183) #1237's review (r1237, NIT 1): the docstring said the function returns "a set the plan sends into different repositories, or drops a path of". It also returns a set the plan keeps whole in one leg: every other such set is `plan-case-twins`, refused before any leg, so one reaches the mount only as a set of one blob in one mode (`_one_blob_twins`), and its paths, at or under the paths the leg takes, are removed with `-f`; none is written back. Docstring only. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
(lane openRepoShape-1, 2026-11-08T14:13:18Z) #1237's fix round is pushed: the head is
Lane: openreposhape-1 (openRepoShape-1) |
|
(lane openRepoShape-1, 2026-11-08T14:46:26Z) r1237b, lane 1's independent reviewer (not the writer), re-reviewed #1237's fix round at
The full re-reviewr1237b: re-review of PR #1237's fix round at
|
| scenario | the plan's answers | 58aa9d2b |
c9f0cc18 |
head 63c52718 |
|---|---|---|---|---|
| symvictim | D/ code; d→victim, root |
refused, kept (r1237) | x deleted (r1237) | verified, kept |
| symvictimdrop | D/ drop |
refused, kept (r1237) | x deleted (r1237) | verified, kept |
| symvictimrel | D/ code; d→../../victim |
refused, kept (r1237) | x deleted (r1237) | verified, kept |
| syminside | D/ code; d→E; E/ root |
refused (r1237) | adopt-lost E/x (r1237) |
verified |
| syminsidedrop | D/ drop; d→E; E/ root |
not run | exit 1, adopt-lost E/x |
verified |
| symboth | D/ code; d→victim, spec |
not run | verified, x deleted | verified, kept |
| symrev | D (link) code; d/ root |
exit 2, Is a directory, kept | the same | the same |
| symdeep (new) | D/E/x code; d/e→victim, root |
exit 2, local modifications, kept | verified, x deleted | verified, kept |
| symchain (new) | d→E→victim, both root |
not run | verified, x deleted | verified, kept |
| symmulti (new) | AB/ code, Ab/ drop, ab→victim; F/ spec, f→victim |
not run | verified, x and y deleted | verified, kept |
| symfile, symkeepdir, symdangle, symsrcdrop (new) | see fuse/bench.py |
not run | not run | each verified, kept |
| glinkdrop (new) | gitlink D and .gitmodules drop; d→victim, root |
exit 2, local modifications, kept | exit 2, EMPTY | exit 2, EMPTY |
| glinkdir (new) | M/D gitlink, M/d→victim, M/z; M/ drop |
exit 2, kept | not run | exit 2, EMPTY |
| mktwin (new) | Makefile drop; MAKEFILE→victim/x, root |
exit 1, adopt-lost MAKEFILE |
verified, kept | verified, kept |
check printed plan ok in every row.
Mutants (my own; clone r1237b/mut at 63c52718; the section's 31 rows unless noted; each restored in a finally)
| id | mutant | result | killed by |
|---|---|---|---|
| R01 | _beyond_a_symlink skips the first folder |
KILLED | 6 symlink shapes, the-folder-a-symlink |
| R02 | names the LAST symlink on the way | SURVIVED (NIT 2) | none |
| R03 | lstat gets text, not bytes |
SURVIVED (NIT 2) | none |
| R04 | under with no / boundary |
SURVIVED (NIT 1; the probe stops at CommandFailed) |
none |
| R05 | the folder is named case-folded | KILLED | 8 rows |
| R06 | a path taken is not remembered | KILLED | 6 symlink shapes |
| R07 | nothing there is read as a symlink | KILLED | nothing-there, a-file-on-the-way |
| R08 | the symlink-under-top test with its arguments swapped | KILLED | both-in-a-dropped-folder..., entries-below-the-link... |
| R09 | the finding drops "for a set in the assembly root" | KILLED (against the 27 word rows; the section alone does not reach them) | all 27 |
| R10b | _is_a_symlink replaced by Path.is_symlink() |
survives on 3.12; KILLED under the 3.14-like shim | test_a_folder_on_the_way_nobody_may_search... |
| R11 | split passed instead of held |
SURVIVED (SHOULD 1; the probe leaves S/z) |
none |
Killed: 7/11. Survivors are R02, R03, R04 and R11; none is equivalent. I withdrew R10s, which was wrongly built (os.lstat still raised first). Every restore was True, and mut is clean: blob a1d93c76 equals HEAD's.
Commands and summary lines (probe env: stub gh, GH_TOKEN=bogus-r1237b, empty GH_CONFIG_DIR, GIT_CONFIG_GLOBAL=/dev/null, GIT_SSH_COMMAND=false, EMAIL=r1237b@openreposhape.invalid, env -u LANES_LANE; CPython 3.12.3)
c9f0cc18's tool with head's tests, whole throwaway file:41 failed, 77 passed, 6 skipped(the 14 new rows plus the 27 word rows).- Head, whole throwaway file:
118 passed, 6 skipped. - Head, 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 section (31 ids):27 passed, 4 skipped. Under the 3.14-like shim:27 passed, 4 skipped. tests/test_adopt_make_reads_first.py:33 passed.test_adopt_clone_template.py,test_adopt_git_rm_bytes.py,test_adopt_late_failure.py,test_adopt_mount_ignored.py,test_adopt_submodules.py:316 passed.tests/test_scaffold_leg_path_collisions.py:104 passed.- The two scans (
test_path_guard_hygiene.py,test_no_auto_maintenance.py):107 passed, 2 skipped. - Doc trio:
171 passed. - Probes (
test_r1237b_probe.py):- head
3 failed, 3 passed: the two gitlink probes emptied the folder, and one failure was my own setup, fixed and re-run as2 passed; c9f0cc185 failed, 1 passed;58aa9d2b1 failed(the same setup error),5 passed;- R11 and R04 shapes: head
2 passed; R11 tool1 failed, 1 passed; R04 tool1 failed, 1 passed.
- head
- FUSE batches:
h1,b2,c2,n2andh3/c3/b3each exited 0. Logs are inr1237b/runs/fuse-*.txt.
Not covered
tests/test_shape_doctor.py(4 shards): not in the brief's list, not run.- The 4 FUSE-only rows were not run inside the FUSE namespace.
- A gitlink sent to a leg (rather than dropped): not measured.
- No probe was run that kills R02 or R03.
- Some
58aa9d2bandc9f0cc18cells were not run (marked above). Every FUSE scenario was run once. - The tree merged with
5c4fcd9dwas predicted only, not gated. - No real macOS or Windows disk was available.
State
- My review checkout
/workspace/projects/openRepoShape-worktrees/l1-r1237bis clean (git status --shortis empty). - Scratch is
/workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1237b/:fzholds untracked tool copies andtests/test_r1237b_probe.py;c9holdsc9f0cc18's tool on purpose;mutis clean;fuse/is my copy of r1237's bench.
- I pushed nothing and posted nothing. No run of mine is live.
Lane: openreposhape-1 (openRepoShape-1)
…dex holds at or under a removed path whose own path the disk holds as no folder (a symlink, a file, or nothing) leaves the index alone (`git rm --cached`), so a gitlink whose case twin is a symlink no longer empties the folder it leads to (#1183) r1237b's MUST 1. `git rm` of a gitlink's entry removes the folder at its path with all it holds, and follows a symlink there. `_beyond_a_symlink` asked only the folders on the way, never the path itself, so a dropped gitlink `D` the clone left as its twin `d`, a symlink to a folder outside the work directory, was given `git rm -f -- D`, which emptied that folder and stopped at `fatal: could not remove 'D'` after both legs were pushed. - `_index_modes` replaces `_index_paths`: one `git ls-files -s -z`, read as `_index_entries` reads it, giving each path and its mode. - `_taken_beyond_a_symlink` asks a gitlink's own path with `lstat` once no folder on the way is a symlink, and takes it with `--cached` where it is no folder (`_is_itself_a`, `stat.S_ISDIR`). - `_is_itself_a` is `_is_a_symlink`'s `lstat` with the kind as an argument; `_on_the_disk` is `_beyond_a_symlink`'s bytes join, shared. - The `GIT_RM_FORCED` and `GIT_RM_CACHED` comments and the `_beyond_a_symlink`, `_git_rm`, `_taken_beyond_a_symlink` and `_mount_the_legs` docstrings no longer say the path itself never needs asking. Rows: five shapes (the gitlink made the link and dropped, sent to a leg, or in a dropped folder; made a file; below a file) and the folder control. At 63c5271 the five fail: three empty the folder outside, two stop at `could not remove`. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ks every path the index holds under a removed path, not only the twins, that a path under one is the path or below it after a `/`, that the first symlink on the way is named and nothing beyond it asked, and that `lstat` is handed bytes under a C locale (#1183) r1237b's SHOULD 1, NIT 1 and NIT 2: rows for its surviving mutants R11, R04, R02 and R03. Each passes at 63c5271 and fails under its mutant. - `a-file-beside-the-folder-the-clone-left-the-link` (R11): `S/D/x` and `S/z` go to code, `s/d` is a link kept in the root, `S/D` the link: `--cached -- S/D`, `-f -- S`, then `checkout-index` of `s/d`. - `two-folders-one-name-a-prefix-of-the-other` (R04): `D/` and `DD/` to code, `d` and `dd` links kept: `--cached` of each, no stop. - `a-symlink-beyond-the-first-symlink` and `test_nothing_beyond_the_first_symlink_on_the_way_is_asked` (R02): `D` is named; past it, a folder nobody may search raised. - `test_the_folder_on_the_way_is_asked_as_bytes_under_a_c_locale` (R03): `_mount_the_legs` in #527's child under `LC_ALL=C PYTHONUTF8=0`; handed text, `lstat` raises `UnicodeEncodeError`, which `_is_itself_a` reads as nothing there, and `git rm -f` unlinks `x` in the folder outside. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ph says that `-f` also skipped what kept git from removing through a symlink, a folder's twin or a gitlink's, and that such a path now leaves the index alone (#1183) r1237b's NIT 3: the paragraph described only `-f` and the write-back. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
(lane openRepoShape-1, 2026-11-08T15:48:11Z) #1237's second fix round, for r1237b's NOT READY (issuecomment-6722903675), is pushed at head
Lane: openreposhape-1 (openRepoShape-1) |
|
(lane openRepoShape-1, 2026-11-08T16:20:39Z) Independent re-review r1237c of #1237 at
r1237c: re-review of PR #1237's second fix round at
|
| shape asked about | measured as | result at head |
|---|---|---|
| a gitlink deeper under a removed folder | glinkdeep (FUSE); probe gitlink-two-levels-down |
--cached -- M/N/D, -f -- M, write-back of m/n/d; outside folder whole |
| the symlink at the gitlink leads inside the assembly | glinkinside; probe gitlink-link-inside |
verified; E/x kept |
| a dangling symlink at the gitlink | glinkdangle; probe |
verified, --cached -- D |
| two gitlinks with one name | glinktwo |
verified |
| a set kept whole in one leg | glinkleg |
check exit 1 with plan-case-twins; execute refused before any leg |
| a gitlink sent to a leg | glinkcode, glinkspec; probe gitlink-spec-link-code |
verified; the leg holds D and .gitmodules |
| a gitlink beside a file twin | glinkfile, glinkfilerev |
verified |
| names that are not UTF-8 | probe gitlink-not-utf-8 |
--cached -- D\udcff, write-back b'd\xff\0'; outside folder whole |
| a gitlink under the C locale | probe gitlink-in-the-c-locale |
outside folder whole, nothing raised |
| an index entry at stage above 0 | not reachable | the assembly is a fresh clone of one commit. _index_modes' dict would keep the last stage, which nothing can meet |
Two further points:
- The non-forced
git rmnever meets a gitlink at a symlink. For the disk to hold a symlink at a gitlink's path, or at a folder above it, some index path must fold to that name. That puts the gitlink in a_case_twin_setsset, and because the gitlink is always removed (the root keeps none, adopt-project.py: a plan that sends.gitmodulesto one leg and a submodule it registers to another passescheckandexecute, saysadoption verified, and the assembly's recursive clone then fails:planshould ask,checkshould find,executeshould refuse #166), the path removed is always forced. - A real folder at the gitlink is still removed with
-f, and that is safe. git's recursive removal unlinks symlinks inside the folder and does not follow them:glinkfolderlink(d/sleads outside) verified with the outside folder whole.
Parsing. _index_modes splits each ls-files -s -z record at the first TAB, which is safe because mode, object and stage hold none. It decodes the name with _git_path (UTF-8 with surrogateescape), as _index_entries and the old _index_paths did. The empty trailing record is skipped. My mutants C01 (split at a space), C07 (strict decode) and C21 (modes passed in place of paths) are each killed.
Errors. _is_itself_a handles errors as _is_a does:
_absenterrnos (ENOENT, ENOTDIR, EBADF, ELOOP) mean nothing is there;- any other
OSError(EACCES) is raised; - ELOOP cannot happen there, because the folders on the way were already found not to be symlinks;
- ENOTDIR is the intended
glinkfiletopcase.
The ValueError catch is harmless on POSIX:
_on_the_diskalways passes bytes, and no git path holds a NUL. C09, which re-raises theValueError, survives as an equivalent mutant.- In the gitlink branch, reading a
ValueErroras nothing there leads to--cached, the safe direction. - In the symlink branch the same reading would mean
-f. That is R03's hazard, and the C-locale row now pins it.
The plan. For each shape, the split branch held exactly the twins the plan keeps in the root (for example d -> victim for glinkdrop, E/x and d -> E for glinkinside, D/ with .gitmodules in the code leg for glinkcode). --cached is right for a gitlink. It touches nothing on the disk and skips git's absorb step. A twin left at the path is either written back or removed by its own git rm (glinkspec: -f -- d unlinks the symlink). .gitmodules is removed and written fresh as before.
My own probe (r1237c/probes/gk.sh, git 2.43, absolute paths) matches the writer's table. For an index gitlink D:
disk at D |
rm -f |
rm --cached |
|---|---|---|
symlink to victim |
exit 128, could not remove 'D', victim emptied |
exit 0, victim keeps keep x |
| file | exit 128 | exit 0 |
a file above it (M, gitlink M/D) |
exit 128 | exit 0 |
| nothing | exit 0 | exit 0 |
| empty folder | exit 0, folder gone | exit 0, folder left |
The same probe also shows a hazard in the plain removal and the base's forced one:
- plain
git rmof a symlink to an outside repository:Migrating git directory of 'D' ...before thelocal modificationsrefusal; - at
63c52718'srm -f, the same migration, then the outside folder emptied.
2. The writer's R03 correction holds. Under LC_ALL=C PYTHONUTF8=0, os.lstat of non-ASCII text raises ['UnicodeEncodeError', 'UnicodeError', 'ValueError', ...] (probes/clocale_lstat.py). I re-ran R03 against head: 1 failed, 37 passed, 4 skipped, killed by test_the_folder_on_the_way_is_asked_as_bytes_under_a_c_locale. The failure is assert {'keep': b'keep\n'} == {...}: the mount silently unlinked x through the symlink and raised nothing.
3. Not making check name gitlink twin sets: the argument holds.
- At head, every split-gitlink plan I ran adopts: 15 of 15 on FUSE (
h16,h29), and 8 of 8 on the bench disk that keeps case (plain-p1, outside folder whole). A finding would refuse all of them on every disk. - Sets kept whole in one leg are already
plan-case-twins(glinkleg). - What is left is the risk of shapes nobody has measured, on real macOS or Windows disks. That is the same trade round 1 made when it chose
--cachedover a refusal for paths beyond a symlink.
Bench table
FUSE disk that ignores case, git 2.43, plan, then check, then execute --local-remote-dir, one run each. check printed plan ok everywhere except glinkleg. The outside folder is victim/, holding x and keep.
| scenario | the plan's answers | 58aa9d2b |
63c52718 |
head b557859f |
|---|---|---|---|---|
| glinkdrop | gitlink D and .gitmodules drop; d→victim, root |
exit 2, local modifications; kept | exit 2, could not remove 'D'; EMPTY |
verified; whole |
| glinkdir | M/ and .gitmodules drop (M/D gitlink, M/d→victim, M/z) |
not run | exit 2 'M/D'; EMPTY |
verified; whole |
| glinkcode | D and .gitmodules code; d→victim, root |
not run | exit 2; EMPTY | verified; code leg holds .gitmodules and D |
| glinkfile | D drop; d a file, root |
not run | exit 2 | verified |
| glinkfiletop | M/ drop (M/D); m a file, root |
not run | exit 2 'M/D' |
verified |
| glinkfolder | D drop; d/x root |
not run | verified | verified |
| glinkrev (mine) | link D→victim root; gitlink d drop |
not run | verified | verified; whole |
| glinkinside (mine) | D drop; d→E, root; E/ root |
not run | exit 2, could not remove 'D' |
verified |
| glinkdeep (mine) | M/ drop (M/N/D, M/z); m/n/d→victim, root |
not run | exit 2 'M/N/D'; EMPTY |
verified; whole |
| glinkfolderlink (mine) | D drop; d/s→victim and d/x, root |
not run | verified; whole | verified; whole |
| glinkdangle (mine) | D drop; d→nowhere, root |
not run | verified | verified |
| glinkspec (mine) | D and .gitmodules spec; d→victim code |
not run | exit 2; EMPTY | verified; whole |
| glinktwo (mine) | gitlinks D and d drop |
not run | verified | verified |
| glinkrepo (mine) | as glinkdrop; victim is a git repository | exit 2; .git migrated into the assembly |
exit 2; EMPTY, .git too |
verified; .git/[43 entries] whole |
| glinkfilerev (mine) | file D root; gitlink d drop |
not run | verified | verified |
| glinkleg (mine) | D, .gitmodules, d all code |
not run | plan-case-twins; refused before any leg |
the same |
| symvictim, symvictimdrop, symvictimrel, syminside, syminsidedrop, symboth | as r1237 | not run | not run | each verified; whole |
On the bench disk that keeps case, at head: glinkdrop, glinkdir, glinkcode, glinkdeep, glinkspec, glinkrepo, glinkfile and glinkfiletop each verified with the outside folder whole.
Rows (63c52718's tool, blob a1d93c76, with head's tests):
- The 11 new rows:
5 failed, 6 passed. The 5 gitlink rows each fail withCommandFailed: git --literal-pathspecs rm -r -q -f -- D(3) or-- M(2). The control, R11, R04, both R02 rows and R03 pass. - Head:
11 passed.
My probes in the rows' style (r1237c/probes/test_r1237c_probe.py, run from untracked copies, since moved out):
- Head:
8 passed. 63c52718:7 failed, 1 passed. The outside folder was emptied ingitlink-two-levels-down,gitlink-not-utf-8,gitlink-spec-link-codeand the C-locale probe. The others stopped at-f, or ran-fwhere head runs--cached(dangling).gitlink-sorts-lastpasses on both.
Mutant table
Clone r1237c/mut at b557859f; the section's 42 rows; each file restored in a finally. Every restore was True with blob 7e72553e, and mut status: '' at the end.
| id | mutant | result | killed by |
|---|---|---|---|
| C01 | _index_modes splits a record at a space, not the tab |
KILLED (25) | 25 rows across the section |
| C02 | a file at a gitlink's path read as a folder | KILLED (1) | a-gitlink-the-clone-left-a-file |
| C04 | the gitlink test reads the removed path's mode | KILLED (2) | ...-in-a-dropped-folder, a-gitlink-below-a-file... |
| C05 | gitlink's own path asked before the folders on the way | SURVIVED (NIT 1) | none; my probe2 kills it (PermissionError) |
| C06 | _on_the_disk not joined to the assembly |
KILLED (16) | symlink, gitlink and control rows |
| C07 | _index_modes decodes names strictly |
KILLED (1) | test_twins_whose_names_are_not_utf_8... |
| C09 | _is_itself_a re-raises a ValueError |
SURVIVED, equivalent on POSIX (bytes, no NUL) | none |
| C13 | _is_a_symlink asks S_ISDIR |
KILLED (21) | |
| C21 | _split_case_twins passed the modes |
KILLED (25) | |
| C33 | _beyond_a_symlink asks every folder, naming the first symlink |
KILLED (1) | test_nothing_beyond_the_first_symlink_on_the_way_is_asked |
| R03 | r1237b's R03 again (text to lstat) |
KILLED (1) | the C-locale row; outside folder lost x |
9 of 11 killed. C09 is equivalent, and C05 is a real survivor (NIT 1). The writer's own log (w1237g/runs/mutants.txt) shows 14 of 14 killed with 14 restores True, as the body says.
Commands and summary lines
All runs used the probe environment: stub gh, GH_TOKEN=bogus-r1237c, empty GH_CONFIG_DIR, GIT_CONFIG_GLOBAL=/dev/null, GIT_SSH_COMMAND=false, EMAIL=r1237c@openreposhape.invalid, env -u LANES_LANE, CPython 3.12.3, at most 2 pytest calls at once.
- New 11 rows:
63c52718tool5 failed, 6 passed in 11.54s; head11 passed in 13.72s. - 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 section (42 ids) at head:38 passed, 4 skipped in 35.57s. Under the 3.14-like shim:38 passed, 4 skipped in 34.93s. - The whole throwaway file at head:
129 passed, 6 skipped in 125.24s. - The five mount files:
316 passed in 332.04s. tests/test_scaffold_leg_path_collisions.py:104 passed in 5.15s.- The two scans:
107 passed, 2 skipped in 27.08s. - The doc trio:
171 passed in 39.97s. - Probes: head
8 passed;63c527187 failed, 1 passed. Probe2: head1 passed, under C051 failed. - FUSE batches
h1,h2,h3,b1,b2ando1each exited 0. The bench-disk batchp1ran 8 of 8. - Merge with 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 head.git merge-tree --write-tree 7d2f651e b557859fis not clean: exit 1, withCONFLICT (content): Merge conflict in tests/test_adopt_check_throwaway_repos.py. Both sides add a paragraph to the module docstring (adopt-project.py:checkpasses case twins (aandA,d/xandD/x) that the plan keeps, though a clone on a disk that ignores case checks each pair out as one file; measureexecutethere and decide whetherchecknames them (#1033) #1161's "adopt-project.py: #910's throwaways leave out a parent's -c settings and GIT_ATTR_SOURCE, so check says plan ok where execute stops after both legs, and three read the environment after making the throwaway (follow-up of #1083) #1136 followed ..." and 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).adopt-project.pymerges by itself. Resolved by keeping both paragraphs, as scratch commitcc74a546(tree0d3d77ef); the section there gives38 passed, 4 skipped in 40.84s. Lane 1's restack will need that resolution. - Body. The title is 249 characters. There is exactly one closing form,
Closes #1183. The diff against58aa9d2bis the two files alone (2 files changed, 1042 insertions(+), 27 deletions(-)). The round-2 tables, counts and blobs match my measurements, apart from NIT 2.
Not covered
tests/test_shape_doctor.py: not on the brief's list, not run.- No real macOS or Windows disk. The FUSE-only rows were not run inside the FUSE namespace. One run per FUSE scenario. At
58aa9d2bonlyglinkrepoandglinkdropwere run. - Twins that differ only in Unicode normalization (NFC and NFD, one name on macOS) are outside
_case_twin_setsand outside 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. A gitlink with such a symlink twin would reach git's plaingit rm, whose absorb step moves an outside repository's.git(see the probe above). This is reasoned, not measured. - Index entries above stage 0: reasoned unreachable, not probed.
State
/workspace/projects/openRepoShape-worktrees/l1-r1237cis clean atb557859f.- Scratch is
/workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1237c/:mutis clean;xbholds63c52718's tool on purpose;mgholds the scratch merge commit;- the logs are in
runs/andmutlogs/, the probes inprobes/.
- I pushed nothing and posted nothing. No run of mine is live.
Lane: openreposhape-1 (openRepoShape-1)
…` before `git filter-repo`, so under a Latin-1 locale a source under a directory named with the byte 0xE9 is extracted, where filter-repo died printing that url as U+FFFD, and every branch of the source is still rewritten as before (#1247) (#1251) **Head `abeac66d` is a fix round for RV1251's review of `825923c7`** (#1251 (comment)): one SHOULD and two NITs, set out under "Review round 1" below. The sections after that describe this head. `_extract_leg` (`adopt-project.py`) now unsets the leg clone's `remote.origin.url`, in the clone's own config file (`--file <work>/.git/config`), right after its `checkout -B` and before `git filter-repo` runs. filter-repo then prints no url, so a Latin-1 stdout has no U+FFFD to choke on, and it still turns every remote-tracking branch into a branch first, so the legs are the ones main makes. ## The defect, reproduced on main - **Where:** main `b1f45fa7`, git 2.43.0, Python 3.12.3, git-filter-repo 2.47.0 (`--version` prints `a40bce548d2c`). - **Launcher:** pip's entry point, `#!.../frvenv/bin/python3`, WITHOUT `-E`. So any `PYTHON*` variable would reach filter-repo, and the reproduction sets none: no `PYTHONIOENCODING`, no `PYTHONUTF8`. - **Locale:** Latin-1, built as #1214 and #1106 build it (`localedef -c`, `LOCPATH`, `LC_ALL=en_US.ISO-8859-1`). - **Source:** `make_source_repo` under a directory `caf` plus U+00E9, made as the byte 0xE9. The probe runs `plan`, `check` and `execute --local-remote-dir` as the existing row does, but without its `PYTHONIOENCODING=utf-8:strict`. - **Result:** `plan` exit 0, `check` exit 0 (`plan ok`), and `execute` exit 2: ```text creating the leg repositories bare .../remotes/Northwind-spec.git bare .../remotes/Northwind-code.git REFUSED extracting the spec leg. THE EXACT COMMAND WAS: git filter-repo --paths-from-file .../work/spec-paths.txt --force (in .../work/Northwind-spec) exit 1 --- output --- Traceback (most recent call last): File ".../frvenv/bin/git-filter-repo", line 8, in <module> sys.exit(main()) File ".../git_filter_repo.py", line 4973, in main filter.run() File ".../git_filter_repo.py", line 4878, in run self._migrate_origin_to_heads() File ".../git_filter_repo.py", line 4462, in _migrate_origin_to_heads print(m) UnicodeEncodeError: 'latin-1' codec can't encode character '\ufffd' in position 237: ordinal not in range(256) --- end output --- ``` Both bare leg repositories were left behind. So the defect does not need a `-E` launcher: a person in a Latin-1 locale sets no `PYTHONIOENCODING`, and pip's launcher fails as well. **The existing row, `test_adopt_template_values.py::test_a_utf8_source_directory_still_adopts_with_local_remotes`, under Latin-1 on main:** - With pip's launcher it PASSES (1 passed). Its `PYTHONIOENCODING=utf-8:strict` reaches filter-repo through `execute`'s environment. - With a copy of that launcher whose only change is `-E` on the shebang (a model of the pipx launcher #1247 quotes), it FAILS with the traceback above. That matches W1196's log. - A check that `-E` drops the variable: with `PYTHONIOENCODING=bogus-codec`, pip's launcher dies at start-up, while the `-E` copy prints its version. - On this head the row passes under Latin-1 with both launchers. ## The fix, and the two candidates measured first **Candidate 1 in #1247, `git remote remove origin` before filter-repo, changes what the legs hold.** It is rejected. - `git remote remove` deletes `refs/remotes/origin/*` with the remote. filter-repo then no longer makes the source's other branches into branches, and never rewrites them. - **Measured** with `execute` on main and on candidate 1, over two ASCII sources with fixed dates. For each leg's bare repository the probe compared: every ref, every commit with its tree, parents and full message, and every reachable object. - **The standard source** (`make_source_repo`, one branch): identical. - **A source with a `release` branch** whose commit R changes `specs/001-feature/tasks.md`, no tag reaching it, and on `main` a `git cherry-pick -x R`: the spec leg's tip differs, `0316169e` on main against `82176493` on candidate 1, with the same tree `9ef5b676`. On main its message reads `(cherry picked from commit 407d03a0...)`, R as rewritten. Under candidate 1 it reads `(cherry picked from commit 30e9dd30...)`, R's id in the source. - **It also changes behaviour that `check` describes.** As a mutant, it fails the existing `test_adopt_split_branch.py::test_the_extraction_stops_at_a_source_branch_in_its_way` (both rows, `DID NOT RAISE CommandFailed`). With it, a source branch in git's way no longer stops the extraction. **Candidate 2, `LC_ALL=C.UTF-8` for the child, depends on the host.** - **Measured here:** under `python3 -E`, a pipe's encoding is `utf-8` under `LC_ALL=C.UTF-8`. It is also `utf-8` under `C`, `POSIX` and a locale name that does not exist, because CPython falls back to C and turns on its UTF-8 mode. - **But** it hands the child a locale by name and leans on that interpreter behaviour. It changes the locale of every git command filter-repo starts. And on Windows, `LC_ALL` does not set a pipe's encoding (read, not run). - No `PYTHON*` variable is a cure, because a `-E` launcher drops it. **What this PR does: unset the url only (`git config --file <work>/.git/config --unset remote.origin.url`).** It depends on no locale. It names the clone's own config file, so `GIT_CONFIG` in the environment cannot redirect it to a file of the person's (RV1251's SHOULD-1, below). It goes on from a clone that has no url to unset, and from no other failure (NIT-1, below). - filter-repo's `_migrate_origin_to_heads` (2.47.0) turns `refs/remotes/origin/*` into `refs/heads/*` first. Then it returns when `remote.origin.url` is not in the config, before the NOTICE that printed the url and before its own `git remote rm origin`. - **The legs are byte-identical to main's** in both probes above, the cherry-pick source included (`diff` of the dumps: empty). - **What stays in each work clone** (measured on this head, a plain `execute`): the url-less `[remote "origin"]` with its fetch refspec `+refs/heads/*:refs/remotes/origin/*`, and `branch.main.remote = origin` and `branch.main.merge = refs/heads/main`. Nothing reads any of it. `_push_the_leg` pushes to the leg's url, and `_mount_the_legs` mounts the clone by its path. RV1251 measured that the mounted submodules hash the same as main's. - The change is one `run` in `_extract_leg` (now with a `try`), with a comment. No pinned or template copy of `adopt-project.py` exists, and no document describes this step. ## Review round 1 (RV1251 at `825923c7`) **SHOULD-1, fixed: the unset named no config file.** `GIT_CONFIG` in the environment (a legacy variable git still reads) names the file a bare `git config` writes. - **Reproduced** with `GIT_CONFIG` naming a file that holds `[remote "origin"] url = ...` and the standard source, in `execute`: - main: exit 0, `adoption verified`, the file unchanged. - `825923c7`: exit 2, `REFUSED extracting the spec leg`, both bare legs made and nothing pushed, and the file empty. The first leg's unset took the only key out of it, and filter-repo, reading that empty file as the config, died with a `ValueError` in `get_config_settings`. (RV1251's file ended in an unset exit 5 instead.) - this head: exit 0, `adoption verified`, the file byte for byte as it was. - **Fix:** `run(["git", GIT_CONFIG, "--file", str(work / ".git" / "config"), "--unset", "remote.origin.url"], cwd=work)`. `--local` is no way round it: with `GIT_CONFIG` set, git 2.43 exits 129 (only one config file at a time). - **`work / ".git"` is a directory wherever `execute` reaches the clone:** - the clone is made by one `git clone -q <source> <work>`, and `work` is `work_root / <name>`, where `work_root` is `os.path.realpath(--work-dir)` or a `tempfile.mkdtemp` folder (`_work_root`): an absolute path; - an exported `GIT_DIR` does not move a clone (measured: the clone has its own `.git`); - an exported `GIT_WORK_TREE` does make the clone another shape (the git dir is `work` itself), but `execute` stops on it first, on main and on this head alike, at the legs' `git init --bare` (`REFUSED a git or gh command failed`); - a path whose directory is not there gives exit 255 (`could not lock config file ... No such file or directory`), which is refused. - **Not changed:** filter-repo's own `git config --list` also reads the file `GIT_CONFIG` names (the `ValueError` above came from it), so under `GIT_CONFIG` it sees that file's settings, as on main. On this head the run ends `adoption verified` with the legs main makes. **NIT-1, handled: a clone with no `origin` url.** A global `clone.defaultRemoteName = upstream` gives the clone a remote that is not `origin`, so `git config --unset remote.origin.url` has no key and exits 5 (measured, git 2.43). It can happen. - **Before:** main fails at `mounting the legs` (`No such remote 'origin'`), after both legs are pushed. `825923c7` failed earlier, at the unset (exit 5, an empty output block), with nothing pushed. - **Now:** `_extract_leg` goes on from exit 5 and from no other exit (`GIT_CONFIG_KEY_ABSENT = 5`). The run fails where main fails, not earlier and not worse. Any other exit of the unset (255 for a file git cannot lock, 129 for a usage it refuses) is refused as any failed command is. - **Not mended here:** `clone.defaultRemoteName` itself. `execute` fails on it on main too. It is a follow-up for its own issue. - Exit 5 also covers a config with more than one `remote.origin.url` (measured); that too goes on, as main does. **NIT-2, corrected in this body:** the claim that the fix "depends on no locale and no variable" (it now names its config file, so `GIT_CONFIG` cannot redirect it); the work clone's leftovers, now listed under "What stays in each work clone" (the url-less `[remote "origin"]`, `branch.main.remote` and `branch.main.merge`); and the note that #1141 conflicts, dropped: it merges clean at `0b8fb320`. ## Rows `tests/test_adopt_filter_repo_origin_url.py`, new, five rows: - **`test_filter_repo_starts_with_no_origin_url_and_every_branch`** (any locale). `_extract_leg` itself runs over an ASCII source with a second branch, with `run` spied at the `git filter-repo` call. When filter-repo starts, the clone has no `remote.origin.url` and still has `refs/remotes/origin/main` and `refs/remotes/origin/beside`. filter-repo's output holds neither `Removing 'origin' remote` nor the source path. Afterwards the clone has `refs/heads/beside`, and the leg holds the clone's HEAD. - **`test_execute_adopts_a_source_named_in_latin1_under_latin1`** (Latin-1). It runs `plan`, `check` and `execute --local-remote-dir` for #1247's source (a directory `caf` plus the byte 0xE9) in a Latin-1 locale the test builds as #1106 builds one. No `PYTHON*` variable is set except `PYTHONUTF8=0`. It asserts no traceback, `check` exit 0, `execute` exit 0 and `adoption verified`. - It skips, by name and with `localedef`'s words, where no Latin-1 locale can be built, and on Windows. It also skips where a name that is not UTF-8 cannot be made (`skip_where_a_name_cannot_be_not_utf_8`). - It builds its locale whatever the gate's locale is, so it fails on main under a UTF-8 gate too, which is what CI runs. - **`test_execute_leaves_the_file_git_config_names_alone`** (new, SHOULD-1). `execute` with `GIT_CONFIG` naming a file that holds a `[remote "origin"]` url: `adoption verified`, and the file is byte for byte as it was. The child gets none of the gate's locale variables, so the row runs alike under a UTF-8 and a Latin-1 gate. - **`test_a_clone_with_no_origin_url_goes_on_to_filter_repo`** (new, NIT-1). `_extract_leg` itself under a global `clone.defaultRemoteName = upstream`: the unset exits 5 (recorded as it is raised), the clone's `remote.upstream.url` is the source, and the leg is pushed. - **`test_an_unset_that_fails_for_any_other_reason_stops_the_leg`** (new, NIT-1). A `config.lock` in the clone makes the unset exit 255: `CommandFailed`, before `git filter-repo` and with nothing pushed. It needs no git-filter-repo. - The earlier rows' setup is now two helpers, `extract_the_code_leg` and `adopt_each_step`. The file is ASCII with no backslash escape; each non-ASCII character is built with `chr` or `bytes`. **Counts** (5 rows; the same file in each tree): | code | UTF-8 gate, pip and `-E` | Latin-1 gate, pip and `-E` | |---|---|---| | main (`_extract_leg` as on `dffc4226`; unchanged in `5926ccca`) | 4 failed, 1 passed (the GIT_CONFIG row passes: main leaves the file alone) | 4 failed, 1 passed | | `825923c7` (the previous head) | 2 failed, 3 passed (the GIT_CONFIG row and the exit-5 row fail) | 2 failed, 3 passed | | this head | 5 passed | 5 passed | The two earlier rows fail on main and pass here. The GIT_CONFIG row fails on `825923c7` and passes here. The exit-5 row fails on `825923c7` and on main (no unset to record) and passes here. The lock row passes on `825923c7`, which refuses every exit; it guards the new `except`. ## Mutants Each mutant was applied in a worktree at this head, the five rows plus the two `test_the_extraction_stops_at_a_source_branch_in_its_way` rows were run under UTF-8 and under Latin-1, and `adopt-project.py` was restored with its sha256 checked after each one (`RESTORED sha256 ok`, tree as before). | mutant | UTF-8 | Latin-1 | killed by | |---|---|---|---| | M1: drop the unset | 4 failed, 3 passed | same | the two earlier rows, the exit-5 row and the lock row (no unset to see) | | M2: the unset moved after `git filter-repo` | 2 failed, 5 passed | same | the two earlier rows | | M3: `git remote remove origin` instead | 5 failed, 2 passed | same | the any-locale row, the exit-5 row, the lock row and both split-branch rows | | M4: drop `--file <path>` | 1 failed, 6 passed | same | the GIT_CONFIG row | | M5: `--local` instead of `--file <path>` (exit 129 under `GIT_CONFIG`) | 1 failed, 6 passed | same | the GIT_CONFIG row | | M6: accept every exit of the unset | 1 failed, 6 passed | same | the lock row | | M7: accept none (no `try`) | 1 failed, 6 passed | same | the exit-5 row | | M8: `--file <work>/config`, a path with no file (exit 5, carried on) | 3 failed, 4 passed | same | the any-locale row, the Latin-1 row and the lock row | ## Gates All runs used `/usr/bin/python3 -m pytest -p no:cacheprovider` with one reused basetemp per run kind and `-o tmp_path_retention_policy=failed`, at most 2 pytest runs at a time. The environment had a stub `gh` first on PATH, `GH_TOKEN=bogus`, an empty `GH_CONFIG_DIR`, `GIT_CONFIG_GLOBAL` an empty file and `GIT_CONFIG_NOSYSTEM=1`, and no `GIT_ALLOW_PROTOCOL`, `GIT_SSH_COMMAND`, `GIT_CONFIG`, `LANES_*` or `PYTHON*` variable. filter-repo was pip's launcher (`a40bce548d2c`) unless a row says otherwise. **The tree gated is a scratch merge** of this head and `origin/main` `5926ccca` (built with `merge-tree` and `commit-tree`; its diff against main is this PR's two files). One hygiene row caught a path in the first attempt (`/home/someone/...` in the new test, `test_no_committed_file_names_a_host_absolute_path`); the row now names `https://example.invalid/keep-me.git`, and everything below ran on the final head. **Gate A, UTF-8 (`LC_ALL=C.UTF-8`), one pytest, 23 files:** **1518 passed, 3 skipped** on the scratch merge. - the new file, `test_adopt_template_values.py`, `test_adopt_split_branch.py` and `test_adopt_e2e.py`; - the other four files that name `_extract_leg` or `_push_the_leg`: `test_adopt_filter_repo_failure.py`, `test_adopt_history_gitlinks.py`, `test_adopt_late_failure.py` and `test_adopt_plan_escapes.py`; - `test_adopt_check_throwaway_repos.py`, `test_adopt_partial_clone_source.py`, `test_adopt_pins.py`, `test_adopt_git_rm_bytes.py` and `test_adopt_plan_partial_clone.py` (new on main); - `test_repo_hygiene.py`, `test_path_guard_hygiene.py`, `test_cli_reference.py` and `test_scaffold_leg_path_collisions.py`; - the files that scan every test file: `test_no_auto_maintenance.py`, `test_non_utf8_git_failures.py`, `test_git_output_decoding.py`, `test_setup_project_repo_misdrawn.py`, `test_shape_advisory.py` and `test_openreposhape_package.py`. - **Main `5926ccca`, the same 22 files without the new one:** 1513 passed, 3 skipped. The head's extra 5 are the five rows. - **Skips (`-rs`):** the same 3 on both, none for lack of git-filter-repo: `test_adopt_check_throwaway_repos.py:482` (a disk that keeps case) and `test_no_auto_maintenance.py:222` x2 (`GIT_CONFIG_GLOBAL` set). **`tests/test_shape_doctor.py`, UTF-8, 4 shards from a bash script** (449 ids): **Head (scratch merge):** 118, 110, 118 and 100 passed, with 3 skipped in the last shard (the PowerShell line, Windows only): 446 passed, 3 skipped. RV1251 and W1247 measured 446 passed and 3 skipped on main at `11560eb9` and `b1f45fa7`; it was not re-run on `5926ccca`, because this PR touches nothing the doctor reads. **Latin-1** (the locale built as above, `PYTHONUTF8=0`), the new file and `test_adopt_template_values.py`: | run | main `5926ccca` | this head (scratch merge) | |---|---|---| | new file + `test_adopt_template_values.py`, pip launcher | 4 failed, 69 passed, 5 skipped (template values only) | 4 failed, 74 passed, 5 skipped | | the same, `-E` launcher | 5 failed, 68 passed, 5 skipped (template values only) | 4 failed, 74 passed, 5 skipped | - **The 4 failures are the same rows on main and on this head, under both launchers.** All four already fail on main and are #1214's to judge, not this PR's: `test_plan_refuses_a_flag_no_file_can_hold` at `--elected-by` and `--reference` with U+DCE9, `test_plan_and_check_say_the_same_sentence`, and `test_execute_refuses_a_local_source_directory_that_is_not_utf8` (under Latin-1 the byte 0xE9 is valid text). - The fifth failure on main under `-E` is `test_a_utf8_source_directory_still_adopts_with_local_remotes`, which this head fixes. ## Merge-trees `git merge-tree --write-tree` of this head against: - **main:** clean against `5926ccca`, and the merged `adopt-project.py` keeps the `--file` call. - **#1177 (`5208e874`) and #1225 (`73c9f57e`)**, the only open PRs that touch `_extract_leg`: both clean, each keeps the `--file` call. - **#1141 (`0b8fb320`):** clean. The earlier note that it conflicts is stale and is dropped. - RV1251 merge-tree'd the 27 other open PRs that touch `adopt-project.py` against the previous head: 26 clean, and #1237 conflicts in `tests/test_adopt_check_throwaway_repos.py` with main alone. This round re-ran main, #1177 and #1225 (the only PRs that touch `_extract_leg`) and #1141 (the one the earlier body got wrong). ## Not done, and limits - Windows, another one-byte locale (cp1252 included) and a real pipx install were not run. The `-E` launcher here is a one-line copy of pip's. - **Read from the code, not run:** filter-repo prints nothing else that holds the url. Its other prints of `origin` are under `--sensitive-data-removal`, which `execute` never passes. Closes #1247 Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>



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. #1189's head has moved since (5c4fcd9d, a merge of main); lane 1 restacks this PR after #1189 lands.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 four things:git ls-files -s -z, read-only,_index_modes), and keeps each_case_twin_setsset with a path at or under a path it removes (_split_case_twins);-f, it asks each path the index holds at or under that path, folder by folder from the top, withlstat(_beyond_a_symlink,_is_a_symlink), and takes a path beyond a symlink out of the index alone, withgit --literal-pathspecs rm -r -q --cached --(GIT_RM_CACHED,_taken_beyond_a_symlink), which touches no file on the disk (the review round below, MUST 1); and it asks each gitlink there, once no folder on the way is a symlink, at its own path, and takes it out of the index alone too where the disk holds no folder there (review round 2, MUST 1);git --literal-pathspecs rm -r -q -f --(GIT_RM_FORCED), where anything is left there, 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, nothing asked of the disk 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. The review round's symlink scenarios, atc9f0cc18and at this head, are in its own table below.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, and never on a path beyond a symlink or on a gitlink whose own path is no folder.-fskips 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. But the same question was what kept git from removing through a symlink. On a disk that ignores case, a folderD/and a symlinkdare one name, and the clone writesdlast, soDis that symlink: givenD/x,git rm -funlinksxin the folder the symlink leads to, wherever that is (adopt-project.py: the mount removes a path a case twin is at or under withgit rm -fand writes the twins the root keeps back from the index, so on a disk that ignores caseexecutekeeps a set the plan splits across repositories or drops (#1183) #1237's review, MUST 1). Andgit rmof a gitlink's entry is no unlink: git removes the folder at its path with everything in it, and follows a symlink there, so given a gitlinkDthe clone left as the symlinkd,git rm -fempties the folderdleads to (review round 2, MUST 1). So before each-f, each path at or under it is asked withlstat, folder by folder, and so is a gitlink's own path; a path beyond a symlink, and a gitlink whose own path is no folder (a symlink, a file, or nothing), leave the index alone. Every other path keeps the command it had, and rows pin that it does. (This bullet first said that-f"skips only git's question whether the file on the disk still holds what the index holds", which the first review showed false; then it asked only the folders on the way, never a gitlink's own path, which the second review showed unsafe.)git rm --cached) rather than being refused.--cachedtouches no file on the disk; git counts a path beyond a symlink as gone from the work tree;git add -Adoes not follow a symlink into a folder; and the symlink is the twin the root keeps, written back from the index. For a gitlink, what the disk holds at its path is its twin, or nothing: a twin the root keeps is written back the same way, and one the plan moves or drops is removed with the rest (glinkdir). So nothing is written outside the work directory, the root's tree is the plan's, and each leg's is the objects'. A refusal would come after both legs are pushed, for a plan that is good on every disk, and a re-run meetsleg-remote-exists.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 as the removal of a twin's path. On a disk that keeps case it leaves the file, andgit add -Aadds it again. It is used only for a path beyond a symlink, wheregit add -Aadds nothing beyond it.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, and the symlink a clone leaves, by hand, on CI's Linux disks. The disk is asked only whether a folder on the way to a path removed with-fis a symlink, and whether a gitlink's own path there is a folder, each withlstat(_is_itself_a), as_is_aasks: nothing there is no symlink and no folder, and a refusedlstatis raised, one ofLATE_FAILURES._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, and each pathlstatasks is handed over as_path_argumentspells it (_on_the_disk), so no locale and no length of a command line is in the way. A row runs the mount underLC_ALL=C PYTHONUTF8=0for a twin name outside ASCII (review round 2, NIT 2).plan-case-twins's remediation text is unchanged. Its comment and the comment above the finding say whatexecutedoes since this change, and its words no longer name a stop atgit rm(the review round, SHOULD 1).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) for the first rows; the review round's rows are measured againstc9f0cc18, the head the review read, and round 2's against63c52718, each in its own section below. Head isb557859f.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 for these rows: 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.The review round's mutants are in its section.
Gate at
b557859f, one pytest call per line, under a stubgh,GH_TOKEN=bogus-w1237g, an emptyGH_CONFIG_DIR,GIT_CONFIG_GLOBAL=/dev/null,GIT_SSH_COMMAND=falseandLANES_LANEunset (CPython 3.12.3 on the bench; CI runs 3.14):tests/test_adopt_check_throwaway_repos.py: 129 passed, 6 skipped (the 4 FUSE-only rows, and 2 outside this section); its 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 section alone, 42 rows: 38 passed, 4 skipped;tests/test_adopt_make_reads_first.py: 33 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_scaffold_leg_path_collisions.py: 104 passed;tests/test_path_guard_hygiene.py tests/test_no_auto_maintenance.py: 107 passed, 2 skipped;tests/test_agents_md_codes.py tests/test_cli_reference.py tests/test_repo_hygiene.py: 171 passed;tests/test_shape_doctor.pyin 4 shards of 112: 111 passed, 1 skipped; 112 passed; 111 passed, 1 skipped; 111 passed, 1 skipped.Review round (r1237: one MUST, one SHOULD, one NIT)
The review read
c9f0cc18. The round is three commits:0b8252c3(MUST 1, the fix and its rows),f645fcb8(SHOULD 1) and63c52718(NIT 1).MUST 1: the forced
git rmremoved a file through a symlink a case twin leftFinding. On a disk that ignores case, a folder
D/the plan sends to a leg (or drops) and a symlinkdit keeps are one name, and the clone writesdlast, soDis that symlink. Atc9f0cc18,git rm -r -q -f -- DunlinkedD/xthrough it, the filexin the folder the symlink leads to, outside the work directory, andexecuteexited 0 withadoption verified. At58aa9d2b, without-f, git refused it atgit rmand nothing was deleted.Fix (
0b8252c3). Before each removal with-f,_taken_beyond_a_symlinkasks each path the index holds at or under the path, folder by folder from the top, withlstat._beyond_a_symlinknames the first folder that is a symlink, through_is_a_symlink, a stat helper made as_is_ais made: nothing there (_absent) is no symlink, and a refusedlstatis raised, never read as none, soexecutenames it asREFUSED mounting the legs(LATE_FAILURES). A path beyond a symlink is taken out of the index alone withgit rm --cached(GIT_RM_CACHED): the symlink's own path where it is at or under the removed path, which takes every path beyond it in one command, else the path itself. The paths left are removed with-fas before; where none is left, no-fruns. The choice of--cachedover a refusal is in Why.FUSE,
plan,checkthenexecute --local-remote-dir, r1237's bench (git 2.43; the foldervictim/, holdingxandkeep, is outside--work-dir; logs under_lane1-scratch/w1237f/runs/):c9f0cc180b8252c3's tool)D/code;dleading to absolutevictim, rootvictim/xdeletedvictimholdsxandkeepD/drop;dleading to absolutevictim, rootxdeleted (r1237)xkeptD/code;dleading to../../victim, rootxdeleted (r1237)xkeptD/code;dleading toE, root;E/rootadopt-lostE/x(r1237)D/drop;dleading toE, root;E/rootadopt-lostE/xD/code;dleading to absolutevictim, specxdeletedxkeptD(a symlink tovictim) code;d/rootREFUSED mounting the legs,fatal: git rm: 'D': Is a directory;xkeptxkept (see Known limits)checkprintedplan okfor every one. At58aa9d2b, r1237 measured symvictim, symvictimdrop, symvictimrel and syminside: eachREFUSED mounting the legsatgit rm, "local modifications: D/x", the folder kept whole.symrevgave the same refusal at58aa9d2bas atc9f0cc18and this head.Rows (any disk; the clone's symlink made by hand where the disk keeps case, as r1237's probe made it):
c9f0cc18test_no_file_is_removed_through_a_symlink_a_case_twin_left, 7 shapes: the probe's shape (D/xcode,dleading to a folder outside the assembly), the relative link../../victim, the folder dropped, a link to a folder the root keeps (E), both in a dropped folder beside other files (src/D/x,src/d), two files beyond the link, and entries below the link (D/xcode,D/ydrop). Each asserts that the folder the link leads to holds what it held, the commands the mount runs, and thatgit add -Astages nothing beyond the linkx(6), orE/x(a-link-to-a-folder-the-root-keeps)test_a_folder_that_is_no_symlink_is_still_removed_with_f(the control: a real folderDbeside the symlinkd, removed with-fas before)test_the_first_symlink_on_the_way_is_named_and_nothing_else(6 shapes)_beyond_a_symlinktest_a_folder_on_the_way_nobody_may_search_is_raised_not_read_as_none_beyond_a_symlinkHead: 15 passed.
c9f0cc18: 14 failed, 1 passed (the control), withc9f0cc18'sadopt-project.py(blob13579470) and this head's test file.Body. The Why bullet on
-fis corrected (it is now the first bullet), and the bullet rejecting--cachedsays where it is used.SHOULD 1:
plan-case-twins' words named a stop atgit rmthat-ftook awayMeasured first (FUSE, this head with
_case_twin_findingspatched, in a scratch copy, to name no finding, 1 run each):Aandato code;Aandato spec;D/andd/to code;F/andfto code;src/Aandsrc/aundersrc/): all 5executeexit 0,adoption verified. r1237 measured the same atc9f0cc18; at58aa9d2b, 4 of them stopped atgit rm;Aandaverified;F/xbesidefexit 1,adopt-lost: F/x, after both legs were pushed;D/xbesided/xcheckexit 1,plan-file-reencoded(will not add file alias), andexecuterefused it before any leg.So "after both legs are pushed, as
adopt-lost" still holds, for a set in the assembly root, and the stop atgit rmis gone.Fix (
f645fcb8), the three places together:executecould stop there before any leg exists, at git'swill not add file alias, or, for a set in the assembly root, after both legs are pushed, asadopt-lost, soexecuterefuses them on every disk.";twins_keptintests/test_adopt_check_throwaway_repos.py, which pins those words;git rmstop now lasts "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", followed by the runs above.NIT 1:
_split_case_twins' docstring63c52718: it also names a set the plan keeps whole in one leg. Such a set reaches the mount only as one of one blob in one mode, the NOTE (_one_blob_twins), every other beingplan-case-twins; its paths are removed with-ftoo, and none is written back.Mutants of the round's changed lines
16 mutants of
0b8252c3's new lines, in a clone at0b8252c3, against the section's 31 rows, each restored in afinallywith its blob checked equal to HEAD's (00621758), 16 restores True. 15/16 killed.GIT_RM_CACHEDwithout--cached_git_rmignorescached_is_a_symlinkasksos.stat, which follows the symlinkthe-folder-a-symlink,a-folder-below-it-a-symlink_is_a_symlinkreads everyOSErroras no symlinktest_a_folder_on_the_way_nobody_may_search_is_raised_not_read_as_noneonly_is_a_symlinkraises an absent errno toonothing-there,a-file-on-the-way_beyond_a_symlinkasks the path itself tooboth-in-a-dropped-folder-beside-other-files,the-path-itself-a-symlinkentries-below-the-link-taken-path-by-pathonlytwo-files-beyond-the-link-taken-at-onceonly-ffor the restboth-in-a-dropped-folder-beside-other-filesonly-fruns even where nothing is left-f(c9f0cc18's loop)_beyond_a_symlinknames the path, not the folderthe-folder-a-symlink,a-folder-below-it-a-symlinkbool(taken) anddropped from the answer_is_a_symlinkanswers for a folderThe survivor is equivalent:
_taken_beyond_a_symlinkis called only for a path a twin is at or under, whichheldlists, so the paths under it are never none, and with none takenall(...)is already False. The later two commits change only the finding's words, a comment and a docstring.Review round 2 (r1237b: one MUST, one SHOULD, three NITs)
The review read
63c52718. The round is three commits:9259da2b(MUST 1, the fix and its rows),3ff03feb(SHOULD 1, NIT 1 and NIT 2, rows) andb557859f(NIT 3). It merges neither #1189's moved head nor main.MUST 1: a gitlink whose case twin is a symlink
Finding. Round 1's rule, "
pathitself is not asked:git rmof a symlink unlinks the symlink, never what it leads to", is false for a gitlink.git rmof a gitlink's entry removes the folder at its path with everything in it, and opens that path as a folder, so it follows a symlink there. Take a plan that drops a gitlinkDand.gitmodulesand keepsd, a symlink to a folder outside the work directory, in the root.checkprintedplan ok. On the FUSE disk,executestopped atgit --literal-pathspecs rm -r -q -f -- Dwithfatal: could not remove 'D', after both legs were pushed, and the outside folder was EMPTY. The same happened withM/dropped, holdingM/DandM/d. At58aa9d2b, without-f, git refused both plans and deleted nothing.Measured first (git 2.43, the bench disk, an index holding the gitlink
D; probew1237g/probes/gitlink_kinds.sh):git rm -r -q -fgit rm -r -q --cachedvictim/could not remove 'D',victimemptiedvictimwholecould not remove 'D', nothing deletedMa file, the gitlinkM/D;rm -f -- M)could not remove 'M/D'Fix (
9259da2b):_index_modesreplaces_index_paths. It is still the one read-only listing, nowgit ls-files -s -z, read as_index_entriesreads it, and it gives each path and its mode._taken_beyond_a_symlinkalso asks each gitlink at or under the removed path. Where no folder on the way is a symlink, it asks the gitlink's own path withlstat. Where that path is no folder (a symlink, a file, or nothing there), the gitlink is taken out of the index alone withgit rm --cached, as a path beyond a symlink is. A folder there, the empty one a clone writes for a gitlink, is removed with-fas before._is_itself_a(path, kind)is_is_a_symlink'slstatwith the kind as an argument, and_is_a_symlinknow calls it. So the gitlink's path is asked as_is_aasks: nothing there is no folder, and a refusedlstatis raised, one ofLATE_FAILURES._on_the_diskis_beyond_a_symlink's bytes join, now shared by both questions.GIT_RM_FORCEDandGIT_RM_CACHED, and the docstrings of_git_rm,_beyond_a_symlink,_taken_beyond_a_symlinkand_mount_the_legs, no longer say that the path itself never needs asking._taken_beyond_a_symlink's docstring holds the measurements.FUSE,
plan,checkthenexecute --local-remote-dir(git 2.43; r1237b's bench, copied tow1237g/fuse/, with four newglink*scenarios; the foldervictim/, holdingxandkeep, is outside--work-dir; 1 run each; logsw1237g/runs/fuse-g1.txt,fuse-b1.txt,fuse-g2.txt):63c52718b557859f)Dand.gitmodulesdrop;dleading tovictim, rootREFUSED mounting the legsatrm -r -q -f -- D,could not remove 'D';victimEMPTYvictimwholeM/Da gitlink,M/dleading tovictim,M/z;M/and.gitmodulesdroprm -r -q -f -- M,could not remove 'M/D';victimEMPTYvictimwholeDand.gitmodulescode;dleading tovictim, rootrm -r -q -f -- D;victimEMPTYvictimwhole; the code leg holdsDand.gitmodulesDand.gitmodulesdrop;da file, rootrm -r -q -f -- D,could not remove 'D'M/Da gitlink;M/and.gitmodulesdrop;ma file, rootrm -r -q -f -- M,could not remove 'M/D'Dand.gitmodulesdrop;d/xrootcheckprintedplan okfor every one, and each exit 2 came after both legs were pushed.r1237b's symlink scenarios, re-run at this head (
fuse-g2.txt):symvictim,symvictimdrop,symvictimrel,syminside,syminsidedrop,symboth,symdeep,symfile,symkeepdir,symdangle,symchain,symmulti,symsrcdropandmktwineach verified, withvictimwhole.symrevstopped as before, atfatal: git rm: 'D': Is a directory, exit 2, withxkept (see Known limits).Rows (any disk; on a disk that keeps case the clone's symlink, or its one file, is made by hand,
as_the_twin;63c52718'sadopt-project.py, bloba1d93c76, with this head's test file):63c52718test_nothing_is_removed_through_a_symlink_at_a_gitlink_or_beyond, 5 gitlink shapes: the clone left the gitlinkDthe symlinkdand the plan dropsD, or sends it to the code leg;M/Dleft the symlinkM/dbesideM/z,M/dropped;Dleft the filed;Mleft the filemabove the gitlinkM/D. Each asserts that the folder outside holds what it held, the commands the mount runs, and the root's index aftergit add -ACommandFailed:git --literal-pathspecs rm -r -q -f -- D(3),-- M(2). Run as a probe, the three link shapes emptied the folder outside (['keep', 'x'] -> []), and the two file shapes deleted nothing--cached -- Dthencheckout-indexofd;--cached -- M/Dthen-f -- M;--cached -- M/Dthencheckout-indexofmtest_a_gitlink_the_disk_holds_a_folder_at_is_still_removed_with_f(the control: the clone's empty folder forD, removed with-fas before)63c52718: 5 failed, 1 passed (the control). Head: 6 passed.checknaming a twin set that holds a gitlink: not done. Every such set that reaches the mount is one the plan splits. A set kept in one leg that holds a gitlink is alreadyplan-case-twins, since the one-blob NOTE leaves out submodules (_one_blob_twins). A gitlink the root keeps is already acheckfinding (#166). For a split set, the mount now takes the gitlink out of the index alone wherever the disk holds no folder at its path. All six gitlink plans above verified on the FUSE disk, and the rows hold the same on CI's. A finding would refuse, on every disk, plans that now adopt on each disk measured. That is the reason round 1 gave for taking a path beyond a symlink with--cachedrather than refusing it.SHOULD 1: no row pinned that every path held is asked (R11)
3ff03febaddsa-file-beside-the-folder-the-clone-left-the-link.S/D/xandS/zgo to code,s/dis a symlink kept in the root, and the clone leftS/Dthat symlink. The mount runs--cached -- S/D,-f -- S, thencheckout-indexofs/d, and the index keeps nothing underS. Under R11 (only the twins asked), the-f -- Sis missing and the root would keepS/z, which the plan sent to code.NIT 1: no row pinned the
/boundary (R04)two-folders-one-name-a-prefix-of-the-other:D/andDD/go to code, anddandddare symlinks kept in the root, each folder left as its link. The mount runs--cached -- D,--cached -- DD, thencheckout-indexofdanddd. Under R04 it stops atCommandFailed: git --literal-pathspecs rm -r -q --cached -- DD.NIT 2: R02 and R03
a-symlink-beyond-the-first-symlinkintest_the_first_symlink_on_the_way_is_named_and_nothing_else(Da symlink, and a symlinkEin the folder it leads to:Dis named), andtest_nothing_beyond_the_first_symlink_on_the_way_is_asked(Dleads to a folder this user may not search:Dis named, and nothing there is asked; under R02,lstatofD/EraisesPermissionError).lstat):test_the_folder_on_the_way_is_asked_as_bytes_under_a_c_localeruns_mount_the_legsin adopt-project.pyexecute: under a non-UTF-8 locale, a moved or dropped path that is not ASCII killsgit rmafter both legs are pushed #527's child underLC_ALL=C PYTHONUTF8=0(MOUNT_IN_THE_C_LOCALE,c_locale_or_skipfromtests/test_adopt_git_rm_bytes.py), forÜ/xsent to code andü, a symlink to a folder outside, kept, the clone having leftÜthat symlink. One correction to the review: handed the text,lstatraisesUnicodeEncodeError, but that is aValueError, which_is_itself_a(as_is_a_symlinkbefore it) reads as nothing there. So R03 raises nothing: it sendsgit rm -f -- Ü, which unlinksxin the folder outside. The row asserts the folder whole.All four rows pass at
63c52718too: they pin what it already did.NIT 3: the test module's #1183 paragraph
b557859f: it now says that-falso skipped what kept git from removing through a symlink, a folder's twin (r1237) or a gitlink's (r1237b), and that such a path now leaves the index alone.Mutants of round 2
14 mutants, in a clone at
b557859f, against the section's 42 rows, each restored in afinallywith its blob checked equal to HEAD's (7e72553e), 14 restores True: 10 of the round's changed lines, and r1237b's four survivors re-applied. 14/14 killed._index_modesreads the object, not the modea-gitlink-the-clone-left-a-file,a-gitlink-below-a-file-the-clone-left_is_itself_areads nothing there as of the kind askednothing-there,a-file-on-the-way,a-gitlink-below-a-file-the-clone-left...-in-a-dropped-folder,a-gitlink-below-a-file-the-clone-left_beyond_a_symlinkasks the path itself at each step_index_modesdecodes names withreplacetest_twins_whose_names_are_not_utf_8_keep_their_own_blobs_is_itself_aasksS_ISLNKwhatever the kindstat, which follows a symlinka-symlink-beyond-the-first-symlink,test_nothing_beyond_the_first_symlink_on_the_way_is_askedlstathanded the text, not the bytesx/two-folders-one-name-a-prefix-of-the-other(CommandFailed ... --cached -- DD)a-file-beside-the-folder-the-clone-left-the-link(no-f -- S)Known limits
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.symrevstill stops at the mount, as at58aa9d2b. A symlinkDthat sorts first and goes to a leg, beside a folderd/the root keeps: on a disk that ignores case the clone leaves the folder, andgit rm -f -- Dmeets it,fatal: git rm: 'D': Is a directory, exit 2 after both legs are pushed. Nothing is deleted (the folder the link leads to keepsx). This round does not change it; it is a follow-up for lane 1 to file.Not in this PR
checkagain". This PR does not call it safe on every disk. Round 1 called it so, and the second review then found the gitlink shapes, which this round fixes. It is now safe in every shape measured butsymrev, on the FUSE disk that ignores case with git 2.43 and on CI's kind of disk; no real macOS or Windows disk was measured. It is left for a follow-up, withTWINS_REMEDIATIONand the doc trio.checkthat asks the work directory's disk forcore.ignorecase. This change makesexecutekeep these sets on every disk, socheckneeds no such probe for them.symrev(see Known limits).Closes #1183
Lane: openreposhape-1 (openRepoShape-1)
🤖 Generated with Claude Code