Skip to content

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

Draft
brettheap wants to merge 10 commits into
fix/1161-check-names-case-twinsfrom
fix/1183-case-twins-across-repositories

Conversation

@brettheap

@brettheap brettheap commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #1189 (fix/1161-check-names-case-twins, head 58aa9d2b); it lands after #1189. Its base is #1189's branch, and its diff against 58aa9d2b is adopt-project.py and tests/test_adopt_check_throwaway_repos.py alone. #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, execute lost 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. check printed plan ok for 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 (a after A, d/x after D/x), and git says the following paths have collided. Then:

  • where the twin that goes sorts last (A root, a code), git rm a deleted the one file both names share, and git add -A staged the kept twin's deletion: adopt-lost, exit 1;
  • where it sorts first (A code, a root), git rm A refused with the 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-repo works on objects. The loss was only in the assembly's work tree, between _mount_the_legs's git rm loop and _stage_the_split's git add -A -- ..

Now _mount_the_legs does four things:

  • it lists the assembly's index once, each path with its mode (git ls-files -s -z, read-only, _index_modes), and keeps each _case_twin_sets set with a path at or under a path it removes (_split_case_twins);
  • before each removal with -f, it asks each path the index holds at or under that path, folder by folder from the top, with lstat (_beyond_a_symlink, _is_a_symlink), and takes a path beyond a symlink out of the index alone, with git --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);
  • it removes each path a set has a path at or under with git --literal-pathspecs rm -r -q -f -- (GIT_RM_FORCED), where anything is left there, and every other path with today's GIT_RM;
  • after the last git rm, it writes the twins of those sets that the root keeps back from the index with git checkout-index -f -z --stdin (_write_from_the_index). A refusal there is CommandFailed, one of LATE_FAILURES, naming the command as _git_rm names its own.

A source with no such set, as nearly every source is, gets the same git rm commands 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, check then execute --local-remote-dir

Each twin holds a blob of its own unless noted. Base is #1189's head as it was then, 4d40ce9f. Head is ef18f059's adopt-project.py. The merge of 58aa9d2b changed 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, at c9f0cc18 and at this head, are in its own table below.

scenario the plan's answers base 4d40ce9f head
across A, D/ root; a, d/ code exit 1, adopt-lost A, D/x verified
acrossrev A, D/ code; a, d/ root exit 2, REFUSED mounting the legs at git rm verified
acrossspec A, D/ root; a, d/ spec exit 1, adopt-lost A, D/x verified
dropped A root; a drop exit 1, adopt-lost A verified
droppedrev A drop; a root exit 2, REFUSED mounting the legs verified
dropboth A, a drop exit 2, REFUSED mounting the legs verified
specode A spec; a code exit 2, REFUSED mounting the legs verified
ffacross F/ root; f code exit 1, adopt-lost F/x verified
ffacrossrev F/ code; f root verified verified
ffdropped F/ root; f drop exit 1, adopt-lost F/x verified
samebytesacross A root; a code; one blob not measured verified
samebytesdropped A root; a drop; one blob not measured verified
dirmergeacross D/x root; d/y code not measured verified
deepacross D/E/x root; d/e/x, d/e/y code not measured verified
none no twins not measured verified
root A, a root not run check exit 1 and execute exit 2, plan-case-twins before any leg (#1189's refusal, unchanged)

"verified" means check printed plan ok, and execute exited 0 with adoption verified: every source path is in exactly one place. Where base was measured, its check printed plan ok too.

On the bench disk, which keeps case, base verified all 15 scenarios above but root. At head, only across and acrossrev were run there, and both verified (see Known limits).

Why

  • -f on 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. -f skips 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, since check refuses 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 folder D/ and a symlink d are one name, and the clone writes d last, so D is that symlink: given D/x, git rm -f unlinks x in the folder the symlink leads to, wherever that is (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's review, MUST 1). And git rm of 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 gitlink D the clone left as the symlink d, git rm -f empties the folder d leads to (review round 2, MUST 1). So before each -f, each path at or under it is asked with lstat, 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.)
  • A path beyond a symlink, or a gitlink whose own path is no folder, leaves the index alone (git rm --cached) rather than being refused. --cached 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. 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 meets leg-remote-exists.
  • Written back after the loop, from the index. Writing each kept twin before its sibling's git rm was rejected: one git rm -r can remove two twins at once (a dropped folder holding d/x and d/X, which plan-case-twins does 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.
  • --cached was rejected as the removal of a twin's path. On a disk that keeps case it leaves the file, and git add -A adds it again. It is used only for a path beyond a symlink, where git add -A adds nothing beyond it.
  • The index is asked, not the disk's case. No core.ignorecase probe 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 -f is a symlink, and whether a gitlink's own path there is a folder, each with lstat (_is_itself_a), as _is_a asks: nothing there is no symlink and no folder, and a refused lstat is raised, one of LATE_FAILURES.
  • Names as bytes. The listing is read as _git_path reads a path (UTF-8, a byte that is not UTF-8 kept as a surrogate). The names checkout-index reads are handed back as the bytes the tree holds (surrogateescape), on its input, and each path lstat asks is handed over as _path_argument spells it (_on_the_disk), so no locale and no length of a command line is in the way. A row runs the mount under LC_ALL=C PYTHONUTF8=0 for 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 what execute does since this change, and its words no longer name a stop at git rm (the review round, SHOULD 1).

Tests

In tests/test_adopt_check_throwaway_repos.py, section "#1183", before "L1-92". Base is 58aa9d2b (this PR's base: its adopt-project.py swapped into this head's tree) for the first rows; the review round's rows are measured against c9f0cc18, the head the review read, and round 2's against 63c52718, each in its own section below. Head is b557859f.

row base 58aa9d2b head
test_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) 6 failed: a wrong blob for the kept twin (across, dropped, a-folder-kept-beside-a-file); CommandFailed git --literal-pathspecs rm -r -q -- A (across-the-other-way, both-dropped, one-to-each-leg) 6 passed
test_a_source_with_no_twin_set_split_is_mounted_as_before (guard) passed passed
test_only_a_path_a_twin_is_at_or_under_is_removed_with_f failed: no -f and no checkout-index among the commands passed
test_a_path_git_cannot_write_back_is_refused_naming_the_command failed: no _write_from_the_index passed
test_a_forced_git_rm_git_refuses_is_refused_naming_it_with_f failed: _git_rm takes no forced passed
test_twins_whose_names_are_not_utf_8_keep_their_own_blobs failed: the kept twin staged with its sibling's blob passed
test_on_a_disk_that_ignores_case_execute_keeps_twins_it_splits (4 shapes, FUSE only) skipped skipped
test_execute_adopts_twins_it_sends_to_different_repositories (the control, any disk) passed passed

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 init writes no core.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's forced, 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 a finally with its blob checked equal to HEAD's:

  • at the first rows: 34/40 killed;
  • with the two rows this PR adds for M05, M21 and M23: 37/40 killed.

The three survivors are equivalent:

  • forced: bool = True as _git_rm's default: its one caller always passes forced;
  • an empty ls-files record kept: the name "" is a set of one, and no set of one is kept;
  • check_program dropped from _write_from_the_index: CHECKOUT_INDEX is a constant git command, all text, which check_program always allows.

The review round's mutants are in its section.

Gate at b557859f, one pytest call per line, under a stub gh, GH_TOKEN=bogus-w1237g, an empty GH_CONFIG_DIR, GIT_CONFIG_GLOBAL=/dev/null, GIT_SSH_COMMAND=false and LANES_LANE unset (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, execute loses 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.py in 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) and 63c52718 (NIT 1).

MUST 1: the forced git rm removed a file through a symlink a case twin left

Finding. 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 clone writes d last, so D is that symlink. At c9f0cc18, git rm -r -q -f -- D unlinked D/x through it, the file x in the folder the symlink leads to, outside the work directory, and execute exited 0 with adoption verified. At 58aa9d2b, without -f, git refused it at git rm and nothing was deleted.

Fix (0b8252c3). Before each removal with -f, _taken_beyond_a_symlink asks each path the index holds at or under the path, folder by folder from the top, with lstat. _beyond_a_symlink names the first folder that is a symlink, through _is_a_symlink, a stat helper made as _is_a is made: nothing there (_absent) is no symlink, and a refused lstat is raised, never read as none, so execute names it as REFUSED mounting the legs (LATE_FAILURES). A path beyond a symlink is taken out of the index alone with git 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 -f as before; where none is left, no -f runs. The choice of --cached over a refusal is in Why.

FUSE, plan, check then execute --local-remote-dir, r1237's bench (git 2.43; the folder victim/, holding x and keep, is outside --work-dir; logs under _lane1-scratch/w1237f/runs/):

scenario the plan's answers c9f0cc18 this head (0b8252c3's tool)
symvictim D/ code; d leading to absolute victim, root verified, victim/x deleted verified, victim holds x and keep
symvictimdrop D/ drop; d leading to absolute victim, root verified, x deleted (r1237) verified, x kept
symvictimrel D/ code; d leading to ../../victim, root verified, x deleted (r1237) verified, x kept
syminside D/ code; d leading to E, root; E/ root exit 1, adopt-lost E/x (r1237) verified
syminsidedrop D/ drop; d leading to E, root; E/ root exit 1, adopt-lost E/x verified
symboth D/ code; d leading to absolute victim, spec verified, x deleted verified, x kept
symrev D (a symlink to victim) code; d/ root exit 2, REFUSED mounting the legs, fatal: git rm: 'D': Is a directory; x kept the same; x kept (see Known limits)

check printed plan ok for every one. At 58aa9d2b, r1237 measured symvictim, symvictimdrop, symvictimrel and syminside: each REFUSED mounting the legs at git rm, "local modifications: D/x", the folder kept whole. symrev gave the same refusal at 58aa9d2b as at c9f0cc18 and this head.

Rows (any disk; the clone's symlink made by hand where the disk keeps case, as r1237's probe made it):

row c9f0cc18 head
test_no_file_is_removed_through_a_symlink_a_case_twin_left, 7 shapes: the probe's shape (D/x code, d leading 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/x code, D/y drop). Each asserts that the folder the link leads to holds what it held, the commands the mount runs, and that git add -A stages nothing beyond the link 7 failed: the folder the link leads to lost x (6), or E/x (a-link-to-a-folder-the-root-keeps) 7 passed
test_a_folder_that_is_no_symlink_is_still_removed_with_f (the control: a real folder D beside the symlink d, removed with -f as before) passed passed
test_the_first_symlink_on_the_way_is_named_and_nothing_else (6 shapes) 6 failed: no _beyond_a_symlink 6 passed
test_a_folder_on_the_way_nobody_may_search_is_raised_not_read_as_none failed: no _beyond_a_symlink passed

Head: 15 passed. c9f0cc18: 14 failed, 1 passed (the control), with c9f0cc18's adopt-project.py (blob 13579470) and this head's test file.

Body. The Why bullet on -f is corrected (it is now the first bullet), and the bullet rejecting --cached says where it is used.

SHOULD 1: plan-case-twins' words named a stop at git rm that -f took away

Measured first (FUSE, 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 to code; A and a to spec; D/ and d/ to code; F/ and f to code; src/A and src/a under src/): all 5 execute exit 0, adoption verified. r1237 measured the same at c9f0cc18; at 58aa9d2b, 4 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), and execute refused 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 at git rm is gone.

Fix (f645fcb8), the three places together:

NIT 1: _split_case_twins' docstring

63c52718: 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 being plan-case-twins; its paths are removed with -f too, and none is written back.

Mutants of the round's changed lines

16 mutants of 0b8252c3's new lines, in a clone at 0b8252c3, against the section's 31 rows, each restored in a finally with its blob checked equal to HEAD's (00621758), 16 restores True. 15/16 killed.

id mutant result killed by
m01 GIT_RM_CACHED without --cached KILLED the 7 symlink shapes
m02 _git_rm ignores cached KILLED the 7 symlink shapes
m03 _is_a_symlink asks os.stat, which follows the symlink KILLED the 7 symlink shapes; the-folder-a-symlink, a-folder-below-it-a-symlink
m04 _is_a_symlink reads every OSError as no symlink KILLED test_a_folder_on_the_way_nobody_may_search_is_raised_not_read_as_none only
m05 _is_a_symlink raises an absent errno too KILLED nothing-there, a-file-on-the-way
m06 _beyond_a_symlink asks the path itself too KILLED both-in-a-dropped-folder-beside-other-files, the-path-itself-a-symlink
m07 the symlink's own path taken even where it is above the removed path KILLED entries-below-the-link-taken-path-by-path only
m08 each path taken one by one, never the symlink's KILLED 6 symlink shapes
m09 a path already taken asked and taken again KILLED two-files-beyond-the-link-taken-at-once only
m10 any path taken skips -f for the rest KILLED both-in-a-dropped-folder-beside-other-files only
m11 -f runs even where nothing is left KILLED 6 symlink shapes
m12 nothing asked before -f (c9f0cc18's loop) KILLED the 7 symlink shapes
m13 _beyond_a_symlink names the path, not the folder KILLED 6 symlink shapes; the-folder-a-symlink, a-folder-below-it-a-symlink
m14 the paths under the removed path asked with the arguments swapped KILLED 6 symlink shapes
m15 bool(taken) and dropped from the answer SURVIVED (equivalent) none
m16 _is_a_symlink answers for a folder KILLED 15 rows, the control among them

The survivor is equivalent: _taken_beyond_a_symlink is called only for a path a twin is at or under, which held lists, so the paths under it are never none, and with none taken all(...) 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) and b557859f (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, "path itself is not asked: git rm of a symlink unlinks the symlink, never what it leads to", is false for a gitlink. git rm of 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 gitlink D and .gitmodules and keeps d, a symlink to a folder outside the work directory, in the root. check printed plan ok. On the FUSE disk, execute stopped at git --literal-pathspecs rm -r -q -f -- D with fatal: could not remove 'D', after both legs were pushed, and the outside folder was EMPTY. The same happened with M/ dropped, holding M/D and M/d. At 58aa9d2b, without -f, git refused both plans and deleted nothing.

Measured first (git 2.43, the bench disk, an index holding the gitlink D; probe w1237g/probes/gitlink_kinds.sh):

the disk at the gitlink's path git rm -r -q -f git rm -r -q --cached
a symlink to victim/ exit 128, could not remove 'D', victim emptied exit 0, victim whole
a file exit 128, could not remove 'D', nothing deleted exit 0
below a file (M a file, the gitlink M/D; rm -f -- M) exit 128, could not remove 'M/D' exit 0
nothing exit 0 exit 0
the empty folder a clone writes exit 0, the folder gone exit 0, the folder left

Fix (9259da2b):

  • _index_modes replaces _index_paths. It is still the one read-only listing, now git ls-files -s -z, read as _index_entries reads it, and it gives each path and its mode.
  • _taken_beyond_a_symlink also 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 with lstat. Where that path is no folder (a symlink, a file, or nothing there), the gitlink is taken out of the index alone with git rm --cached, as a path beyond a symlink is. A folder there, the empty one a clone writes for a gitlink, is removed with -f as before.
  • _is_itself_a(path, kind) is _is_a_symlink's lstat with the kind as an argument, and _is_a_symlink now calls it. So the gitlink's path is asked as _is_a asks: nothing there is no folder, and a refused lstat is raised, one of LATE_FAILURES. _on_the_disk is _beyond_a_symlink's bytes join, now shared by both questions.
  • The comments on GIT_RM_FORCED and GIT_RM_CACHED, and the docstrings of _git_rm, _beyond_a_symlink, _taken_beyond_a_symlink and _mount_the_legs, no longer say that the path itself never needs asking. _taken_beyond_a_symlink's docstring holds the measurements.

FUSE, plan, check then execute --local-remote-dir (git 2.43; r1237b's bench, copied to w1237g/fuse/, with four new glink* scenarios; the folder victim/, holding x and keep, is outside --work-dir; 1 run each; logs w1237g/runs/fuse-g1.txt, fuse-b1.txt, fuse-g2.txt):

scenario the plan's answers 63c52718 this head (b557859f)
glinkdrop gitlink D and .gitmodules drop; d leading to victim, root exit 2, REFUSED mounting the legs at rm -r -q -f -- D, could not remove 'D'; victim EMPTY verified, victim whole
glinkdir M/D a gitlink, M/d leading to victim, M/z; M/ and .gitmodules drop exit 2 at rm -r -q -f -- M, could not remove 'M/D'; victim EMPTY verified, victim whole
glinkcode (new) gitlink D and .gitmodules code; d leading to victim, root exit 2 at rm -r -q -f -- D; victim EMPTY verified, victim whole; the code leg holds D and .gitmodules
glinkfile (new) gitlink D and .gitmodules drop; d a file, root exit 2 at rm -r -q -f -- D, could not remove 'D' verified
glinkfiletop (new) M/D a gitlink; M/ and .gitmodules drop; m a file, root exit 2 at rm -r -q -f -- M, could not remove 'M/D' verified
glinkfolder (new) gitlink D and .gitmodules drop; d/x root verified verified

check printed plan ok for 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, symsrcdrop and mktwin each verified, with victim whole. symrev stopped as before, at fatal: git rm: 'D': Is a directory, exit 2, with x kept (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's adopt-project.py, blob a1d93c76, with this head's test file):

row 63c52718 head
test_nothing_is_removed_through_a_symlink_at_a_gitlink_or_beyond, 5 gitlink shapes: the clone left the gitlink D the symlink d and the plan drops D, or sends it to the code leg; M/D left the symlink M/d beside M/z, M/ dropped; D left the file d; M left the file m above the gitlink M/D. Each asserts that the folder outside holds what it held, the commands the mount runs, and the root's index after git add -A 5 failed, CommandFailed: 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 5 passed: --cached -- D then checkout-index of d; --cached -- M/D then -f -- M; --cached -- M/D then checkout-index of m
test_a_gitlink_the_disk_holds_a_folder_at_is_still_removed_with_f (the control: the clone's empty folder for D, removed with -f as before) passed passed

63c52718: 5 failed, 1 passed (the control). Head: 6 passed.

check naming 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 already plan-case-twins, since the one-blob NOTE leaves out submodules (_one_blob_twins). A gitlink the root keeps is already a check finding (#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 --cached rather than refusing it.

SHOULD 1: no row pinned that every path held is asked (R11)

3ff03feb adds a-file-beside-the-folder-the-clone-left-the-link. S/D/x and S/z go to code, s/d is a symlink kept in the root, and the clone left S/D that symlink. The mount runs --cached -- S/D, -f -- S, then checkout-index of s/d, and the index keeps nothing under S. Under R11 (only the twins asked), the -f -- S is missing and the root would keep S/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/ and DD/ go to code, and d and dd are symlinks kept in the root, each folder left as its link. The mount runs --cached -- D, --cached -- DD, then checkout-index of d and dd. Under R04 it stops at CommandFailed: git --literal-pathspecs rm -r -q --cached -- DD.

NIT 2: R02 and R03

  • R02 (the last symlink on the way named): a-symlink-beyond-the-first-symlink in test_the_first_symlink_on_the_way_is_named_and_nothing_else (D a symlink, and a symlink E in the folder it leads to: D is named), and test_nothing_beyond_the_first_symlink_on_the_way_is_asked (D leads to a folder this user may not search: D is named, and nothing there is asked; under R02, lstat of D/E raises PermissionError).
  • R03 (text, not bytes, to lstat): test_the_folder_on_the_way_is_asked_as_bytes_under_a_c_locale runs _mount_the_legs in adopt-project.py execute: under a non-UTF-8 locale, a moved or dropped path that is not ASCII kills git rm after both legs are pushed #527's child under LC_ALL=C PYTHONUTF8=0 (MOUNT_IN_THE_C_LOCALE, c_locale_or_skip from tests/test_adopt_git_rm_bytes.py), for Ü/x sent to code and ü, a symlink to a folder outside, kept, the clone having left Ü that symlink. One correction to the review: handed the text, lstat raises UnicodeEncodeError, but that is a ValueError, which _is_itself_a (as _is_a_symlink before it) reads as nothing there. So R03 raises nothing: it sends git rm -f -- Ü, which unlinks x in the folder outside. The row asserts the folder whole.

All four rows pass at 63c52718 too: they pin what it already did.

NIT 3: the test module's #1183 paragraph

b557859f: it now says that -f also 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 a finally with 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.

id mutant killed by
W01 _index_modes reads the object, not the mode the 5 gitlink shapes
W02 no gitlink's own path asked the 5 gitlink shapes
W03 only a gitlink whose path is a symlink taken, not a file or nothing a-gitlink-the-clone-left-a-file, a-gitlink-below-a-file-the-clone-left
W04 any path with no folder there taken, gitlink or not 13 rows, round 1's among them
W05 _is_itself_a reads nothing there as of the kind asked nothing-there, a-file-on-the-way, a-gitlink-below-a-file-the-clone-left
W06 a gitlink with no folder takes the whole removed path ...-in-a-dropped-folder, a-gitlink-below-a-file-the-clone-left
W07 _beyond_a_symlink asks the path itself at each step 15 rows
W08 _index_modes decodes names with replace test_twins_whose_names_are_not_utf_8_keep_their_own_blobs
W09 _is_itself_a asks S_ISLNK whatever the kind the 3 link shapes and the folder control
W10 the gitlink's path asked with stat, which follows a symlink the 3 link shapes
R02 the last symlink on the way named a-symlink-beyond-the-first-symlink, test_nothing_beyond_the_first_symlink_on_the_way_is_asked
R03 lstat handed the text, not the bytes the C-locale row: the folder outside lost x
R04 the paths under a removed path matched with no / two-folders-one-name-a-prefix-of-the-other (CommandFailed ... --cached -- DD)
R11 only the twins asked, not every path held a-file-beside-the-folder-the-clone-left-the-link (no -f -- S)

Known limits

  • The bench disk at head was not run in full. Only across and acrossrev were run there, and both verified. At base, all 15 verified there. At base on FUSE, samebytesacross, samebytesdropped, dirmergeacross, deepacross and none were not measured, and root was not run.
  • three gave no result. That is a defect in the bench script, not in the tool: the plan quotes the name Å, and the bench's path: match does not. The scenario (A root, a code, Å 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 tables above.
  • symrev still stops at the mount, as at 58aa9d2b. A symlink D that sorts first and goes to a leg, beside a folder d/ the root keeps: on a disk that ignores case the clone leaves the folder, and git rm -f -- D meets it, fatal: git rm: 'D': Is a directory, exit 2 after both legs are pushed. Nothing is deleted (the folder the link leads to keeps x). This round does not change it; it is a follow-up for lane 1 to file.
  • One run each for every FUSE scenario of both review rounds, all with git 2.43. No real macOS or Windows disk was available.
  • The gitlink rows make the clone's symlink, or its one file, by hand on CI's disks, as round 1's rows do; the FUSE bench measured the same six shapes whole.

Not in this PR

  • The remediation's option "or answer the twins of each set into different repositories, or drop all but one, and run check again". 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 but symrev, 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, with TWINS_REMEDIATION and the doc trio.
  • A check that asks the work directory's disk for core.ignorecase. This change makes execute keep these sets on every disk, so check needs no such probe for them.
  • symrev (see Known limits).

Closes #1183

Lane: openreposhape-1 (openRepoShape-1)

🤖 Generated with Claude Code

Brett Heap and others added 4 commits October 23, 2026 12:16
… 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>
@brettheap brettheap added this to the v1.2.1 milestone Oct 23, 2026
@sourcery-ai

sourcery-ai Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

Implementation

Add 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.

  • adopt-project.py L1887-1911, L14367-14394, L14401-14589 (+6 small edits)

Tests

Add regression coverage for split and dropped case twins, byte-preserving write-back, command selection, symlink and gitlink safety, and end-to-end adoption.

  • tests/test_adopt_check_throwaway_repos.py L49-69, L75-79, L964-1689 (+5 small edits)

Tips and commands

Interacting with Sourcery

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

Customizing Your Experience

Access your dashboard to:

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

Getting Help

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-10-24T14:47:26Z) r1237, lane 1's independent reviewer (not the writer), reviewed #1237 at c9f0cc18: NOT READY, with one MUST, one SHOULD and one NIT.

The full review

r1237: independent review of PR #1237 at c9f0cc18

Verdict: NOT READY. One finding is a MUST: on a disk that ignores case, the new forced git rm deletes files outside the work directory through a symlink, and execute still exits 0 with "adoption verified". At base (58aa9d2b) the same plan is refused at git rm and nothing is deleted.

This is a partial report. Lane 1 sent LAST STEP at 14:42Z. Three read-only runs are still going; they are listed in checkpoint.md.

Findings

MUST 1: git rm -f deletes through a symlink left by the clone, outside the work directory, and execute exits 0

Where: adopt-project.py:14338 (GIT_RM_FORCED), :14352 (removal = GIT_RM_FORCED if forced else GIT_RM) and :14455-14457 (the forced loop). The body's "Why" bullet "-f skips only git's question whether the file on the disk still holds what the index holds" is also wrong.

The shape that triggers it:

  • The source has a folder D/ and a symlink d that leads to a directory, so the two are a set to _case_twin_sets.
  • The plan sends D/ to a leg (or drops it) and keeps d in the root (or sends it elsewhere).
  • check prints plan ok.
  • The clone writes d last, so D is the symlink on such a disk.
  • git rm -r -f -- D then unlinks D/x through the symlink, in the folder the symlink points to.
  • Without -f, git refuses it as "local modifications".

Evidence 1: git 2.43 on the bench disk. The repo has D/x tracked and D replaced by a symlink to victim/:

--- not forced:  error: the following file has local modifications: D/x   exit 1   victim: x kept
--- forced:      exit 0   victim: x gone

Evidence 2: execute whole on the FUSE disk that ignores case. My bench is r1237/fuse/bench.py. The victim/ folder is outside --work-dir.

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.lstat every 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, and git add -A does not follow it. The other option is to refuse there.
  • Add a row on the bench disk in the shape of the probe: D/x code, d a symlink to a folder outside the assembly, D replaced by that symlink. It should assert that the outside file survives and that D/x leaves 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 gave execute exit 2, REFUSED mounting the legs at git rm of A, A, D and src. legff verified.
  • At head (hn): legcode, legspec, legcodedir and legff gave execute 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"):

  1. adopt-project.py:9418-9422, the finding as printed: "...execute could stop there before any leg exists, at git's will not add file alias, or after both legs are pushed, as adopt-lost or at git rm mounting the legs, so execute refuses them on every disk." Drop the bolded clause. I have not yet measured whether "after both legs are pushed, as adopt-lost" still holds, and for which repository; it may only hold for root sets. That check is the queued hr run (root, rootff and rootdir with no finding, at head).
  2. tests/test_adopt_check_throwaway_repos.py:537-543 (twins_kept) pins the same words, ending with "git rm mounting the legs" at :542. It must change in step with item 1.
  3. adopt-project.py:9266-9270, a comment: "...in 3 others, for adopt-project.py: check names the case twins the plan keeps in one repository as plan-case-twins on every disk, a one-blob set as a NOTE in a leg and, in the root, two paths in one folder, and execute refuses the finding before any leg (#1161) #1189's review, execute stopped after both legs were pushed, mounting them, at git rm -r (...)". This is history; add "until adopt-project.py: on a disk that ignores case, execute loses 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's adopt-project.py, blob 5fac9479) with head's tests, the 16 rows in adopt-project.py: on a disk that ignores case, execute loses 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 passes forced;
  • a set of one name "" is never kept;
  • CHECKOUT_INDEX is 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 #N finds nothing else, and the Sourcery block holds none.
  • Stacking: the statement is right. c49de697 has parents a455c759 and 58aa9d2b, and the diff against 58aa9d2b is 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 -f is 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): hn legsrc, 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.
  • 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 four test_shape_doctor.py shards.

State

  • The review checkout /workspace/projects/openRepoShape-worktrees/l1-r1237 is clean (git status --short is empty).
  • The checkpoint is /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1237/checkpoint.md.
  • The scratch clones are mut, base and fz, in /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1237/. base carries base's tool on purpose, and fz holds untracked copies of the tool.
  • I pushed nothing and posted nothing.

Lane: openreposhape-1 (openRepoShape-1)

Brett Heap and others added 3 commits November 8, 2026 13:50
… 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>
@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-11-08T14:13:18Z) #1237's fix round is pushed: the head is 63c52718, and an independent re-review (r1237b) has started.

Lane: openreposhape-1 (openRepoShape-1)

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-11-08T14:46:26Z) r1237b, lane 1's independent reviewer (not the writer), re-reviewed #1237's fix round at 63c52718: NOT READY, with a new MUST, a SHOULD and three NITs.

The full re-review

r1237b: re-review of PR #1237's fix round at 63c52718 (diff c9f0cc18..63c52718)

Verdict: NOT READY.

The fix for r1237's MUST 1 holds for every symlink shape I measured: r1237's six scenarios plus 8 new shapes, on the case-folding FUSE disk. SHOULD 1 and NIT 1 are done correctly.

But the round's working rule is false for one kind of index entry: "path itself is not asked: git rm of a symlink unlinks the symlink, never what it leads to". For a gitlink, git rm removes the folder at the path recursively, and it follows a symlink there. A source with a gitlink D and a symlink twin d that leads out of the work directory still gets plan ok. Then execute pushes both legs and empties the folder d leads to.

Base 58aa9d2b refused the same plan and deleted nothing. So the deletion arrives with #1237, and the round does not close it.

Findings

MUST 1 (new): a gitlink whose case twin is a symlink: the forced git rm empties a folder outside the work directory

Where:

  • adopt-project.py:14466-14467: _beyond_a_symlink never asks the path itself.
  • :14509 and :14570-14572: the loop passes such a path straight to GIT_RM_FORCED.
  • The comment at :14361-14367.
  • The body: the Why bullet (line 52), and "Not in this PR" (line 186: "now safe on every disk measured but symrev").

Evidence 1, git 2.43.0 on the bench disk (r1237b/probes/gitlink_rm.sh). The index holds the gitlink D, and the disk's D is a symlink to victim/:

forced:  fatal: could not remove 'D'   rm -f exit 128          victim after: (empty)
plain:   error: the following file has local modifications: D   rm exit 1   victim after: keep x
cached:  rm --cached exit 0                                    victim after: keep x

With subfolders (r1237b/probes/gitlink_nested.sh):

before: . ./sub ./sub/deeper ./sub/deeper/z ./sub/y ./x
after:  .

The whole tree under the target goes; only the folder itself survives.

Evidence 2, execute whole on the FUSE disk (my copy of r1237's kit):

  • glinkdrop: the gitlink D and .gitmodules dropped; d leads to the absolute victim, kept in the root.
    • Head: check exit 0, plan ok. execute exit 2, REFUSED mounting the legs at git --literal-pathspecs rm -r -q -f -- D, fatal: could not remove 'D', with both legs pushed. victim after: EMPTY.
    • c9f0cc18: the same, EMPTY.
    • 58aa9d2b: exit 2 at git --literal-pathspecs rm -r -q -- D, "local modifications". victim kept.
  • glinkdir: M/D a gitlink, M/d leading to victim, plus M/z; M/ and .gitmodules dropped.
    • Head: exit 2 at rm -r -q -f -- M, could not remove 'M/D'. EMPTY.
    • 58aa9d2b: refused, kept.
  • Logs: runs/fuse-h1.txt, fuse-c2.txt, fuse-b2.txt.

Evidence 3, CI's kind of disk. These are scratch probes in the rows' own style: r1237b/fz/tests/test_r1237b_probe.py, with the clone's symlink made by hand.

  • gitlink-at-the-link and ...-in-a-dropped-folder:
    • head and c9f0cc18: victim ['keep','x'] -> [], CommandFailed: git --literal-pathspecs rm -r -q -f -- D (or -- M);
    • 58aa9d2b: victim kept, CommandFailed ... rm -r -q -- D.
  • gitlink-beyond-the-link (D/s): head runs --cached -- D and keeps the folder whole. The round's check of the leading folders covers this case.

Fix:

  • Ask the path itself wherever the index holds a gitlink there. Read the modes with git ls-files -s -z, or --format.
  • Where lstat says such a path is a symlink (or not a folder), take it out with git rm --cached. The probe above shows --cached is safe here.
  • Correct the docstring at :14466 and the comment at :14361.
  • Add two rows: D a gitlink beside d, a symlink to a folder outside, with D made that symlink and {"drop": ["D"]}; and the M/ variant. Both fail at 63c52718 today.
  • Optionally, also make check name any case-twin set that holds a gitlink, before any leg exists.
  • Correct the body's Why bullet and its "Not in this PR" line.

SHOULD 1 (new): no row pins that _taken_beyond_a_symlink asks every path held, not just the twins (mutant R11 survives)

Where: adopt-project.py:14571 (it passes held) and :14509; BEYOND_A_LINK at tests/test_adopt_check_throwaway_repos.py:1243.

Evidence:

  • R11 (held replaced by split at :14571): 27 passed, 4 skipped on the section's 31 rows.
  • Probe a-file-beside-the-link-folder: S/D/x and S/z go to code; s/d is a link kept in the root; S/D is made the link.
    • Head runs --cached -- S/D, -f -- S, then the write-back, and leaves nothing under S.
    • R11 runs --cached -- S/D and the write-back, with no -f. INDEX R11 left under a removed top: ['S/z']: the root would keep a path the plan sent to code.

Fix: add that shape as a row. Its commands are --cached -- S/D, -f -- S, then checkout-index of s/d.

NIT 1: no row pins the / boundary in under (:14509; mutant R04 survives)

In the probe two-tops-one-a-prefix-of-the-other, the plan sends D/ and DD/ to code and keeps d and dd as links in the root. Under R04 the mount stops: CommandFailed: git --literal-pathspecs rm -r -q --cached -- DD. Head passes. Fix: add that shape as a row.

NIT 2: mutants R02 and R03 survive (judged by reasoning; I ran no probe that kills them)

  • R02 names the last symlink on the way rather than the first. It then asks lstat beyond the first symlink, inside the folder it leads to. A folder there that this user may not search would be raised after both legs are pushed.
  • R03 hands lstat text instead of bytes. Under LC_ALL=C PYTHONUTF8=0 that raises UnicodeEncodeError for a non-ASCII twin name under a forced path. The body's "Names as bytes" bullet claims this case, and no row pins it.

NIT 3: the test module's #1183 paragraph does not mention the symlink case

tests/test_adopt_check_throwaway_repos.py:50-59 still describes only -f and the write-back. Optional.

NOTE (landing)

What the round gets right (measured)

  • MUST 1's original shape is fixed. At head, all six of r1237's scenarios verify with the outside folder whole. My 8 new shapes do too:

    • a link one level down;
    • a link to a file;
    • a dangling link;
    • a link to a link;
    • the link sent to a leg while the folder stays;
    • a set of three plus a second set;
    • both twins in a dropped folder;
    • a MAKEFILE link beside the shape's Makefile.

    Three of them (symdeep, symmulti, symchain) deleted x (and y) at c9f0cc18, so they really test the fix. A non-UTF-8 link probe takes --cached -- D\udcff at head and lost x at c9f0cc18.

  • The rows. I ran c9f0cc18's tool (blob 13579470) with head's tests. All 14 new rows fail for the reasons the body gives: 6 lost x, 1 lost E/x, and 7 have no _beyond_a_symlink. The control passes. At head all 15 pass.

  • Errors. _is_a_symlink treats refusals exactly as _is_a does: _absent means no symlink, any other OSError is raised, and ValueError means no symlink. ELOOP and ENOTDIR cannot hide a symlink, because folders are asked from the top. R07 is killed, and R10b is killed under the 3.14-like shim.

  • --cached rather than a refusal. The argument holds for every symlink shape I measured: the root's tree is the plan's, and each run verifies. It is also the right remedy for MUST 1.

  • symrev is pre-existing and deletes nothing. At 58aa9d2b, c9f0cc18 and head alike, it stops with fatal: git rm: 'D': Is a directory, exit 2, and x is kept.

  • SHOULD 1, re-measured at head with the finding patched out, one run each:

    • legcode, legspec, legcodedir, legff and legsrc: verified;
    • root: verified;
    • rootff: exit 1, adopt-lost: F/x, after both legs were pushed;
    • rootdir: check exit 1, plan-file-reencoded; execute exit 2 with no leg made.

    The three places say it word for word, and all 27 rows that pin the words kill R09.

  • NIT 1: the new docstring is accurate.

  • m15 is equivalent. A path is forced only when some twin at or under it is in held, so under is never empty, and with nothing taken all(...) is already False.

  • The body.

    • The title is 249 characters.
    • There is exactly one closing form, Closes #1183 (line 190).
    • The round's tables match my measurements. Its 58aa9d2b cells for four scenarios match r1237's logs (r1237/runs/fuse-b2-symvictim.txt, fuse-b3.txt).
    • The writer's mutant log shows 15 killed, m15 surviving, and 16 restores True.
    • The later two commits change only the finding's words, a comment and a docstring (17+/6−).

Bench table (FUSE disk that ignores case, git 2.43, plan/check/execute --local-remote-dir, 1 run each)

The outside folder is victim/, holding x and keep. A cell marked "(r1237)" comes from r1237's logs; I did not re-run it.

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, execute loses 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 as 2 passed;
    • c9f0cc18 5 failed, 1 passed;
    • 58aa9d2b 1 failed (the same setup error), 5 passed;
    • R11 and R04 shapes: head 2 passed; R11 tool 1 failed, 1 passed; R04 tool 1 failed, 1 passed.
  • FUSE batches: h1, b2, c2, n2 and h3/c3/b3 each exited 0. Logs are in r1237b/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 58aa9d2b and c9f0cc18 cells were not run (marked above). Every FUSE scenario was run once.
  • The tree merged with 5c4fcd9d was predicted only, not gated.
  • No real macOS or Windows disk was available.

State

  • My review checkout /workspace/projects/openRepoShape-worktrees/l1-r1237b is clean (git status --short is empty).
  • Scratch is /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1237b/:
    • fz holds untracked tool copies and tests/test_r1237b_probe.py;
    • c9 holds c9f0cc18's tool on purpose;
    • mut is 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)

Brett Heap and others added 3 commits November 8, 2026 15:17
…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>
@sonarqubecloud

sonarqubecloud Bot commented Nov 8, 2026

Copy link
Copy Markdown

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-11-08T15:48:11Z) #1237's second fix round, for r1237b's NOT READY (issuecomment-6722903675), is pushed at head b557859f. A fresh independent re-review starts now.

Lane: openreposhape-1 (openRepoShape-1)

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-11-08T16:20:39Z) Independent re-review r1237c of #1237 at b557859f: READY, with three NITs and no MUST or SHOULD.

r1237c: re-review of PR #1237's second fix round at b557859f (diff 63c52718..b557859f)

Verdict: READY.

r1237b's MUST 1 is fixed. Every gitlink shape I ran left the outside folder whole at head: r1237b's two, the writer's four and 10 new ones of mine, on the FUSE disk that ignores case and on the bench disk that keeps case. At 63c52718 six of those shapes emptied the outside folder, one of them an outside git repository whose .git went too. SHOULD 1, NITs 1 to 3 and the R03 correction all hold. Each finding is marked MUST (wrong behaviour), SHOULD (a gap worth holding the PR for) or NIT (minor).

Findings

NIT 1: no row pins that a gitlink's own path is asked only after the folders on the way (my mutant C05 survives)

Where: adopt-project.py:14562-14569, the if link is not None: / elif held[path] == GITLINK_MODE ... order in _taken_beyond_a_symlink. Rows go in AT_A_GITLINK_OR_BEYOND_A_LINK, tests/test_adopt_check_throwaway_repos.py:1498.

Evidence:

  • C05 swaps the two branches, so the gitlink's own lstat comes first. On the section's 42 rows: 38 passed, 4 skipped.
  • Under C05 the gitlink's path is asked through the symlink, inside the folder the symlink leads to. That is the same thing R02 does, which round 2 pinned only for _beyond_a_symlink.
  • My probe r1237c/probes/test_r1237c_probe2.py shows it. The gitlink D/G goes with D/ dropped, and d is a symlink to a folder this user may not search, kept in the root; the clone left D as that symlink.
    • Head: 1 passed. The commands are --cached -- D, then checkout-index of d.
    • Under C05: 1 failed, with PermissionError: [Errno 13] Permission denied: b'.../work/Northwind/D/G'. In a real run that stop would come after both legs are pushed.

Fix: add that shape as a row (a gitlink below the clone's symlink, the symlink leading to a folder nobody may search, CANNOT_RUN_AS_ROOT). Expect [([*CACHED_AS, "D"], None), (WRITTEN_AS, b"d\0")].

NIT 2: three body lines are stale or miscounted

Fix: name 7d2f651e, say five rows (six with the control), and name #1250.

NIT 3 (optional): the body could also say that a refusal through git was not safe

On FUSE at 58aa9d2b (#1237's base, no -f), glinkrepo (the outside folder is a git repository) was refused. But first git moved that repository's .git into the assembly:

  • Migrating git directory of 'D' ..., then local modifications, exit 2;
  • victim after: .git='gitdir: ../work/Northwind/.git/modules/D\n' keep x.

--cached skips that step. At head glinkrepo verifies, and the outside repository is unchanged (.git/[43 entries] before and after). This adds weight to the Why bullet on --cached. Optional.

Item by item

1. MUST 1's fix: correctness. No shape I measured deleted or wrote anything outside the work directory at head, or lost a path the plan keeps. Each "verified" means adoption verified: every source path is in exactly one place.

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:

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:

  • _absent errnos (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 glinkfiletop case.

The ValueError catch is harmless on POSIX:

  • _on_the_disk always passes bytes, and no git path holds a NUL. C09, which re-raises the ValueError, survives as an equivalent mutant.
  • In the gitlink branch, reading a ValueError as 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 rm of a symlink to an outside repository: Migrating git directory of 'D' ... before the local modifications refusal;
  • at 63c52718's rm -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 (h1 6, h2 9), 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 --cached over 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 with CommandFailed: 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 in gitlink-two-levels-down, gitlink-not-utf-8, gitlink-spec-link-code and the C-locale probe. The others stopped at -f, or ran -f where head runs --cached (dangling). gitlink-sorts-last passes 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.

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 58aa9d2b only glinkrepo and glinkdrop were run.
  • Twins that differ only in Unicode normalization (NFC and NFD, one name on macOS) are outside _case_twin_sets and outside adopt-project.py: on a disk that ignores case, execute loses 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 plain git 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-r1237c is clean at b557859f.
  • Scratch is /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1237c/:
    • mut is clean;
    • xb holds 63c52718's tool on purpose;
    • mg holds the scratch merge commit;
    • the logs are in runs/ and mutlogs/, the probes in probes/.
  • I pushed nothing and posted nothing. No run of mine is live.

Lane: openreposhape-1 (openRepoShape-1)

brettheap added a commit that referenced this pull request Nov 8, 2026
…` 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>

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant