Skip to content

adopt-project.py: check finds, and execute refuses before any leg, a kept nested .gitattributes that outranks the root's line-ending rule for a pinned copy, and a root .gitattributes that git add would re-encode (#530) - #619

Merged
brettheap merged 8 commits into
mainfrom
fix/530-adopt-nested-gitattributes
Oct 16, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-1 (openRepoShape-1)
Claim: #530 (comment)

What

adopt-project.py check now reports two findings about the .gitattributes files of a source. execute refuses the same plans with the same codes before any leg repository exists. Both go through _entry_findings, with every other finding of check's (#168).

plan-nested-attributes, #530's line. It reports a .gitattributes that the plan keeps in the assembly root below the root, such as .github/.gitattributes or scripts/.gitattributes, when its rules outrank the root's line-ending rule for a pinned copy. The finding names the kept file, the pinned copies it reaches, and the exact line its own directory spells for each copy. For example:

FINDING plan-nested-attributes: the plan keeps `.github/.gitattributes` in the assembly root, and git reads it after the root's own `.gitattributes` for each path under `.github/`, so its rules outrank the root's line-ending rule for the pinned copy `.github/workflows/validate.yml`: a clone would check it out with CRLF line endings on every platform, Linux and macOS included, and the root's first `make bootstrap` would report shape-copy-drift on it, after `execute` had said `adoption verified`. Send `.github/.gitattributes` to a leg or `drop` it, or end `.github/.gitattributes` in the source with the line `/workflows/validate.yml text eol=lf`, commit, and re-run `plan`.

plan-attributes-reencoded, the D5(a) line. Lane 2 added this line to #530 at 2026-10-15T17:20:19Z, from the checklist triage Brett Heap approved at 16:49:38Z. It reports the source's own root .gitattributes, which the split appends #471's block to, when this machine's git add would store other bytes than the source's followed by the block, or would refuse them. For example, for a blob committed with CRLF under * text eol=lf:

FINDING plan-attributes-reencoded: the plan keeps the source's `.gitattributes` in the assembly root, where the split appends #471's block to it, and this machine's `git add` would store other bytes there than the source's followed by the block, as git does where its rules for the file make it text and the blob holds CRLF line endings, or where an `ident`, `working-tree-encoding` or `filter` rule reaches it. The verification would then report the source's `.gitattributes` as `adopt-lost`, in no leg and not in the root tree, after both legs exist. Commit the file in the source as git stores it (`git add --renormalize -- .gitattributes`, then commit), or change the rule git applies to it, and re-run `plan`.

Where git add refuses, the finding quotes git, for example (git said fatal: CRLF would be replaced by LF in .gitattributes) under this machine's core.safecrlf=true.

Some sources adopt as they did before:

  • a source with no .gitattributes kept above a pinned copy;
  • one whose root .gitattributes git add stores as written.

On #457, this closes the two lines marked → #530:

The two riders marked → #530 there stay open (see "Not in this PR"):

Why (repro on main)

A nested .gitattributes

#471 (PR #511, landed as 5ed3e5d) gives every pinned copy at the root one line-ending rule. Where the source keeps its own root .gitattributes, that rule is a /<copy> text eol=lf line in the block the split appends to that file. Where the source keeps none, it is the shape's own * text=auto eol=lf. Git reads attributes for a path from the .gitattributes of its own directory and of each directory above it, and a deeper file outranks a shallower one. So a kept nested file outranks that rule for every copy under it.

Measured on main at 2247dbf, with git 2.43.0 and git filter-repo 2.47.0:

  • There were twelve real adoptions (plan, the nested file answered root, check, execute --local-remote-dir).
  • Each was cloned three ways: as configured, with core.autocrlf=true, and with core.eol=crlf.
  • For each clone I read git check-attr text eol, the bytes on disk of every pinned copy, and the first bootstrap.
kept nested file (root .gitattributes: * text=auto, unless noted) copies with CRLF clones that wrote CRLF execute on main
.github/.gitattributes: *.yml eol=crlf .github/workflows/validate.yml all 3, Linux's default included exit 0, adoption verified
the same, the source keeping no root .gitattributes the same all 3 exit 0, verified
.github/.gitattributes: * text=auto eol=crlf the same all 3 exit 0, verified
.github/.gitattributes: *.yml winfile, the root defining [attr]winfile text eol=crlf the same all 3 exit 0, verified
.github/.gitattributes: * !eol (with and without a root file) the same autocrlf and eol-crlf exit 0, verified
scripts/.gitattributes: *.py eol=crlf, scripts/ kept in the root (with and without a root file) the five scripts all 3 exit 0, verified
.github/.gitattributes: * -text, * binary, * text, *.png binary none none exit 0, verified

In every row with CRLF:

eol=crlf breaks the Linux and macOS clones too, not only Windows.

r530's review found the same defect through the old crlf spelling:

  • A kept *.yml -crlf eol=crlf under a root * text=auto checked the copy out with CRLF in all three clones.
  • * -crlf !eol did the same in the two clones that write CRLF.

* -text and * binary change no byte. git check-attr says text: unset there, but a checkout writes a file that is not text as its blob, and the copy's blob is LF (the materializer writes it with write_lf). All three clones held LF, and the bootstrap passed. So they are not findings.

The root's own .gitattributes, as git add stores it

The split appends #471's block to the source's root .gitattributes, the one file of the source's it writes into. So the split's git add -A -- . stores that file again, through git's rules for it:

  • under text, or an eol (which says text), git stores CRLF as LF;
  • ident collapses an expanded $Id: …$.

The verification (_land_the_eol_block) expects the source's blob followed by the block, finds neither, and reports the file lost after both legs exist.

Measured on main at 3f768a6, with git 2.43.0 and git filter-repo 2.47.0. Each source's root .gitattributes blob was committed byte for byte (hash-object --no-filters), then plan (nothing to answer), check, and execute --local-remote-dir:

root .gitattributes blob check execute on main 3f768a6
* text eol=lf, CRLF 0 1, FINDING adopt-lost: .gitattributes (3e48839d08fd) is in no leg, not in the root tree, and not listed as drop, both leg remotes made
* text eol=crlf, CRLF 0 1, the same adopt-lost, both leg remotes made
* eol=lf, CRLF 0 1, the same, both leg remotes made
# $Id: deadbeef $, then * ident 0 1, the same, both leg remotes made
* working-tree-encoding=UTF-16LE 0 2, REFUSED mounting the legs (git rm: the following file has local modifications), both leg remotes made
* text=auto, CRLF 0 0, adoption verified
* text eol=lf, LF 0 0, verified

At 2423eaa, where this question is still main's, these adopted and verified as well:

  • * text=auto eol=lf, CRLF;
  • * text eol=crlf, LF;
  • CRLF with .gitattributes -text;
  • CRLF under rules that name no .gitattributes (*.md text, *.png binary, and * text=auto with a CRLF *.md text eol=lf line).

text=auto leaves alone a file whose indexed blob already holds CRLF, which is why those adopt.

How

A nested .gitattributes

A finding, not a write. Brett Heap ruled at 2026-10-12T22:48Z that the root's .gitattributes is the one file of the source's that the split writes into, because no file beside it can reach the copies at the root. A nested file is not that case: the person can end it, in the source, with the lines the finding names, or keep it out of the root. Neither needs adopt to:

  • write a second file of the source's;
  • make a second exception to "by blob sha" in the verification;
  • give update-shape.py --add a second block to extend.

The brief's option (b), appending lines to each nested file, would need all three, and Brett's ruling covers one file. So this is option (a): check finds it and execute refuses it before any leg.

Git is asked, never imitated. _AttributesProbe is a throwaway repository in the system's temporary directory, as _ask_git_branch makes one. Its work tree holds only these files, at the paths the split's tree will hold them:

git check-attr -z crlf text eol is then asked about each pinned copy where the split puts it. That place is read from _PlannedRoot's walks, which follow the root's symlinks as the materializer does. So patterns, macros, precedence and case folding are git's own. The probe reads no system or global attributes file (GIT_ATTR_NOSYSTEM=1, and -c core.attributesFile= set to an empty file), because another machine's clone has neither. A macro defined in a global attributes file here, such as [attr]keepbytes -text, would otherwise make a kept *.yml keepbytes eol=crlf look kept, though a clone on another machine writes that copy CRLF.

Nor does the rest of this machine's setup reach it. _probe_env gives git check-attr this process's environment with these changes:

  • none of another repository's GIT_DIR and its kin, no GIT_ATTR_SOURCE, no GIT_CONFIG_PARAMETERS;
  • no system config (GIT_CONFIG_NOSYSTEM=1) and an empty file as the global config (GIT_CONFIG_GLOBAL);
  • attr.tree taken out of git's command scope (GIT_CONFIG_COUNT), whose other entries are kept in their order.

Measured with git 2.43:

  • GIT_ATTR_SOURCE or attr.tree naming the empty tree made check-attr read no rule at all, which silenced the finding.
  • GIT_ATTR_SOURCE=HEAD made the probe fail in its empty repository, and check printed REFUSED git-failed.

A test sets each of the four (the two GIT_ATTR_SOURCE values, and attr.tree in the global config and in the command scope) and shows the finding stands.

checked_out_as reads the answer as git's convert.c does for a checkout:

  • text, else crlf (its old spelling, read only where text is unspecified), says whether the file is text, and one that is not is written as its blob;
  • eol=lf keeps LF, and eol=crlf writes CRLF everywhere;
  • with neither, only input keeps LF without asking core.autocrlf and core.eol.

One finding per cause.

Both disks. The question is asked as a disk that keeps case does, and again as one that ignores it (core.ignorecase, and a .GitHub/.gitattributes read for .github/), as #262's findings are. A finding only the second makes says so with CASE_ASIDE, and says the copy is written CRLF "on such a disk, whatever git is set to".

A kept nested symlink counts. A checkout reads a .gitattributes from the index first, and there a symlink's blob is its target text. Measured with git 2.43: a kept .github/.gitattributes linking to *.yml eol=crlf checked the copy out with CRLF. A target that is a path names no attribute. For a symlink, the remedy says to replace it with a regular file.

The root's own .gitattributes

A finding, as the nested one is. The fix is the source's: commit the file as git stores it, or change the rule git applies to it. execute refuses it before any leg exists.

Asked of git as the split asks it. _stored_as_written makes a throwaway repository in the system's temporary directory and removes it afterwards.

  • Its index holds the source's own blob at .gitattributes, as the assembly's checkout does (hash-object -w --no-filters, then update-index --cacheinfo). So git's check-in rules that read the index answer as they do there; text=auto leaves alone a blob that already holds CRLF.
  • Its work tree holds what the verification expects at the root: the source's bytes followed by the block, from _root_attributes, the bytes the nested question uses too.
  • git add is run there, and the oid it stages is compared with hash-object --no-filters of the same bytes.

The materializer appends the block to the checkout's bytes, and git's check-in undoes what its checkout did to line endings, an ident or an encoding. So the blob's own bytes get the same answer.

This machine's settings are read, unlike in the nested question. The split's git add runs here, with this machine's config and attributes: a global attributes file, core.autocrlf, core.safecrlf and a filter driver all count. So the question takes only _inherited_env, which _probe_env is now built on:

  • no other repository's GIT_DIR and its kin;
  • no GIT_ATTR_SOURCE or GIT_CONFIG_PARAMETERS;
  • no command-scope attr.tree.

In the throwaway repository HEAD names nothing, and the empty tree, which any repository can name, holds no rule, so neither GIT_ATTR_SOURCE nor a command-scope attr.tree is passed. -c core.fsmonitor=false keeps a daemon from being started for it. Where git add refuses, the finding gives git's last line.

Asked only where the split writes into the file: where the plan keeps the source's root .gitattributes as a regular file. A source that keeps none gets the shape's own, and a symlink there is written through by nothing. Neither asks git anything.

The new code is one import, one section of adopt-project.py after _refuse_symlinks_checked_out_as_files, and one line in _entry_findings after _root_symlink_findings, with a clause in its docstring. The D5(a) line adds a second section after the first, a second line in _entry_findings after the first with its own clause, and _inherited_env, split out of _probe_env.

Nothing in scripts/shape_materialize.py, update-shape.py, the docs or the templates changes.

Byte for byte. At bbaa56a, I compared the split commits made by main and by this branch from the four sources that adopt (-text, binary, text, *.png binary):

  • Every pinned copy, the root's .gitattributes, shape/.gitattributes and the kept .github/.gitattributes are identical.
  • The only paths that differ record the leg commits, the shape's commit and tree digest, or the source's path, which differ between any two runs.

The D5(a) question prints nothing for a root .gitattributes that git add stores as written. All eight of its controls above, and a * filter= rule no driver here defines, adopt and verify end to end at ab7e8ef, as on main.

Tests

tests/test_adopt_nested_attributes.py, new, has 27 test functions and 79 cases: 18 and 58 for a nested .gitattributes, 9 and 21 for the root's own.

A nested .gitattributes:

  • checked_out_as for 16 crlf/text/eol combinations, each checked against real checkouts with git 2.43 (as configured, with core.autocrlf=true and with core.eol=crlf);
  • the finding for each cause in the first table above, and for r530's *.yml -crlf eol=crlf: its file, copies, lines and the words for where CRLF is written;
  • execute's refusal before any leg (refused_before_any_leg, which needs no git filter-repo);
  • a root .gitattributes that is a symlink, and a kept nested symlink read as its target;
  • _outranking asked directly with a root that keeps the copy LF and one that does not;
  • a kept shape/.gitattributes, which only adopt-project.py check passes a plan that keeps the source's own shape/ in the root, and place() then writes the shape's collision copy onto it after both legs are pushed: a real shape/README.md is overwritten while execute prints "nothing was overwritten" and ends adopt-lost, and a shape/Makefile/ directory or a file named shape is a traceback #262 names;
  • nine controls that are no finding:
    • -text, binary and text;
    • -crlf with crlf=input, and crlf -text eol=crlf;
    • another path, and the finding's own remedy;
    • the file sent to a leg, and a deeper kept file that restores eol=lf;
  • the naming of causes:
    • one file named per cause;
    • a file that alone keeps the copy LF is not named;
    • a cause two files make only together is still named;
    • each cause with its own lines;
  • the case-folding aside and its words;
  • this machine's own attributes not read (a global [attr]keepbytes -text macro);
  • no tree this machine names steering the question (the four settings above);
  • the spelling of a copy's line;
  • nothing asked of git for a source with no kept file above a copy;
  • with git filter-repo, the remedied source and a -text source are adopted and cloned three ways:
    • as configured, with core.autocrlf=true and with core.eol=crlf;
    • git check-attr eol says lf for every pinned copy, and no pinned copy holds CRLF on disk;
    • the source's own README is CRLF where the clone was asked for it (the control);
    • the first bootstrap passes.

The root's own .gitattributes:

  • found: CRLF under * text eol=lf, * text eol=crlf, * eol=lf and * text, and an expanded $Id under * ident;
  • six controls that are no finding:
    • CRLF under * text=auto and * text=auto eol=lf;
    • LF under * text eol=lf and * text eol=crlf;
    • CRLF its own rules leave alone (.gitattributes -text);
    • CRLF no rule names;
  • * working-tree-encoding=UTF-16LE over the root's own file;
  • execute's refusal before any leg;
  • core.safecrlf=true in the command scope, which makes git refuse: the finding in git's words;
  • this machine's global attributes file read for the split (.gitattributes text eol=lf over a CRLF blob whose own rules say nothing of it);
  • GIT_ATTR_SOURCE (HEAD, the empty tree) and a command-scope attr.tree not steering it;
  • nothing asked of git for a source with no root .gitattributes, or with a symlink there;
  • with git filter-repo, the finding's way out taken (git add --renormalize, commit, plan again): check finds nothing, and execute verifies.

How every run below was made:

  • /usr/bin/python3 -m pytest, with a stub gh first on PATH, a bogus GH_TOKEN and an empty GH_CONFIG_DIR;

  • GIT_CONFIG_GLOBAL=/dev/null, and no GIT_ALLOW_PROTOCOL;

  • each log names its head or tree and dirty 0, and ends in pytest's own exit code.

  • Both modes at ab7e8ef (tree d21a7d9d), dirty 0, in a standalone clone of the commit. The targeted set is 72 files: grep -l -E 'adopt-project|adopt_project|\bADOPT\b' tests/*.py, which includes tests/conftest.py (no tests), tests/test_shape_doctor.py and tests/test_scaffold_leg_path_collisions.py. Each mode ran in 4 parallel chunks of whole files.

    mode passed skipped failed exit
    git-filter-repo 2.47.0 on PATH 5,780 13 0 0 in every chunk
    hidden (PATH=<stub gh>:/usr/local/bin:/usr/bin:/bin) 5,460 333 0 0 in every chunk

    Each mode collected the same 5,793 tests.

  • On the merge with main 3f768a6 (siblings.py and family.py: make siblings asks about the family folder before it clones, clones each member on its tracking branch, and family.py siblings runs the holder's own copy (#543) #578, one commit past ci: run the test suite on Linux only #597's 654c476). git merge-tree --write-tree ab7e8efe 3f768a66 is clean, giving tree bddd1d1a. That tree was committed in a standalone clone (4f9dee1e), dirty 0. There the grep gives 73 files, because main added tests/test_adopt_shape_pin_and_work_dir.py. Both modes ran, 4 chunks each.

    mode passed skipped failed exit
    git-filter-repo on PATH 5,868 13 0 0 in every chunk
    hidden 5,547 334 0 0 in every chunk
  • At 2423eaa, the fix round before the D5(a) line: the same set passed, 5,759 passed and 13 skipped, and 5,440 passed and 332 skipped. Its merge with 3f768a6 (tree a8e3916c, 73 files) passed too, 5,847 and 13, and 5,527 and 333. Every chunk exited 0.

3196d85d changes only three comments, which now cite #530's review, and the module passes there: 79 passed, 0 skipped, 0 failed with git filter-repo on PATH.

Revert table: each change reverted alone in adopt-project.py, and the new module run each time.

change reverted alone new tests that fail (of 79)
R0, the whole PR (adopt-project.py as on main 2247dbf) 56. The 23 that pass hold on main too: the nine nested controls, the six root controls, the seven end-to-end cases and the shape/.gitattributes case.
Rprev, both later rounds (adopt-project.py as at bbaa56a) 26: the 12 of r530's fixes, and the 14 of D5(a)
Rd5, the D5(a) line whole (adopt-project.py as at 2423eaa) 14: every D5(a) test except the six controls and the end-to-end remedy
R1, the nested line in _entry_findings 20: all seven causes, the refusal, both symlink cases, case folding, the four naming tests, the isolation test, and the four tree settings
R2, checked_out_as: a copy that is not text kept unspecified unset crlf => kept, unset unspecified crlf => kept, set unset crlf => kept, set unset native => kept, crlf-set-under-minus-text
R3, checked_out_as: input kept input unspecified unspecified => kept, unspecified input unset => kept
R4, checked_out_as: text read before crlf unset set crlf => everywhere, set unset crlf => kept, set unset native => kept, unset set unspecified => by config, crlf-unset-beside-the-roots-text, crlf-set-under-minus-text
R5, the source's own root lines under the block (macros) a-macro-the-root-defines, and the 12 D5(a) tests that ask git about those lines
R6, a linked root asked as the block, not as no rules the root symlink case
R7, the disk that ignores case the case-folding aside
R7b, the fold-only words the case-folding aside
R8, every kept file above an outranked copy named test_only_a_file_that_changes_a_copy_is_named, test_a_file_that_alone_keeps_the_copy_lf_is_not_named, test_a_cause_two_files_make_only_together_is_still_named
R8a, a file named when it changes git's answer (as at bbaa56a) test_a_file_that_alone_keeps_the_copy_lf_is_not_named
R8b, no fallback where only files together break a copy test_a_cause_two_files_make_only_together_is_still_named
R9, the root's own files must keep the copy LF the-root-leaves-it-crlf
R10, a kept shape/.gitattributes left to #262 the shape/.gitattributes case
R11, a kept nested symlink read the nested symlink case
R12, a name no line can spell a-name-a-line-cannot-spell
R13, no probe unless a kept file is above a copy above-no-copy
R14, core.attributesFile set to an empty file the isolation test
R15, GIT_ATTR_SOURCE not inherited the two GIT_ATTR_SOURCE cases of each question (4)
R16, no system or global config for the nested question attr-tree-in-the-global-config
R17, attr.tree taken out of the command scope attr-tree-in-the-command-scope, for each question (2)
R18, the D5(a) line in _entry_findings 12: the five found, the encoding, the refusal, core.safecrlf, this machine's attributes, and the three tree settings
R19, the source's blob held in the probe's index crlf-under-text-auto, crlf-under-text-auto-eol-lf
R20, this machine's attributes file read by the probe's git add test_this_machines_own_attributes_are_read_for_the_split
R21, only _inherited_env given to the probe's git add the three tree settings of the D5(a) question
R22, git add's refusal read test_what_this_machines_git_add_refuses_is_found_in_gits_words
R23, a kept root symlink not asked about a-root-attributes-symlink

The table was run at ab7e8ef in a standalone clone of the commit, so the worktree was never edited. The file was restored after each row, and its sha256 checked (86e5f2a8 before and after).

Windows

CI is Linux only since #597 (Brett Heap's estate-wide decision of 2026-10-15T16:54:43Z), so no Windows job runs these tests now. They are still written to hold there:

  • The tests make no symlink on the disk. The symlinks are written into the tree with update-index --cacheinfo, so they need no privilege.
  • Every path is legal on NTFS.
  • Both probes write their files with write_bytes.
  • The D5(a) tests commit the root .gitattributes byte for byte (hash-object --no-filters), so a checkout's newline translation does not change what they test.
  • The nested finding is the same on a disk that ignores case, because its probe passes core.ignorecase explicitly and writes each file at the spelling it is asked under.

Before #597, at bbaa56a, the nested-file tests also passed (48 of 48) under a Windows newline emulation: a pytest plugin, kept outside the repository, that makes text-mode Path.write_text and text-mode stdin write \r\n.

Not in this PR

Known limits

  • A kept symlink can lead a copy where tests-windows never executes an adoption: git-filter-repo is not installed there, so the eight adopt suites skip and v1.0.0's "adopt works on Windows" is unverified #471's block does not reach it. The block names each copy by its pinned path, and a symlink the root keeps can lead a copy somewhere else.
  • A copy only the case-folding disk reaches. When one kept file reaches some copies on both disks and more on the disk that ignores case, its finding is the first disk's. The extra copies are named once that finding's lines are in and check is run again.
  • A remedy line for a name no plain line spells. A copy that a kept symlink leads to a name with a space or a glob character gets "a text eol=lf line that names <path>" instead of a line to paste, though git would read a C-style quoted pattern ("/ci files/validate.yml" text eol=lf) or a backslash-escaped one (/a\[1\]/x.yml text eol=lf).
  • plan-attributes-reencoded is asked with this machine's settings. A filter driver, core.safecrlf or a global attributes file configured here counts, as it does for an execute run here; an execute on another machine can meet another answer. GIT_ATTR_SOURCE, an attr.tree in git's command scope and GIT_CONFIG_PARAMETERS are not passed to the question, though the split's own git add reads them; an attr.tree in the global or system config is passed, as the split reads it.

Closes #530

🤖 Generated with Claude Code

Summary by Sourcery

Detect and reject .gitattributes configurations that could cause pinned-copy line-ending drift or corrupt the source attributes during adoption.

Bug Fixes:

  • Prevent adoption plans from silently producing line-ending drift or losing the source root .gitattributes during execution.

Enhancements:

  • Add plan findings for nested .gitattributes files that override pinned-copy line-ending rules and for root .gitattributes files that git add would re-encode or reject.
  • Refuse affected executions before creating any leg repositories and use Git attribute and staging behavior to determine findings.

Tests:

  • Add comprehensive coverage for attribute precedence, symlinks, case handling, Git configuration isolation, re-encoding scenarios, early refusal, and end-to-end clone verification.

Brett Heap and others added 4 commits October 15, 2026 11:22
…, a `.gitattributes` the plan keeps below the root whose rules outrank the root's line-ending rule for a pinned copy, so a kept `.github/.gitattributes` saying `*.yml eol=crlf` no longer adopts as `adoption verified` with `.github/workflows/validate.yml` checked out CRLF in every clone (#530)

#471 (PR #511) gives every pinned copy at the assembly root one
line-ending rule: a `/<copy> text eol=lf` line in the block the split
appends to the source's own root `.gitattributes`, or the shape's own
`* text=auto eol=lf` where the source keeps none. Git reads attributes
for a path from the `.gitattributes` of its own directory and of each
directory above it, and a deeper file outranks a shallower one, so a
source's `.github/.gitattributes` or `scripts/.gitattributes` that the
plan keeps in the root outranks that rule for every copy under it.

Measured on main at 2247dbf with git 2.43, twelve real adoptions, each
cloned three ways (as configured, `core.autocrlf=true`, `core.eol=crlf`).
A kept `.github/.gitattributes` saying `*.yml eol=crlf`, `* text=auto
eol=crlf`, or `*.yml winfile` under a root that defines `[attr]winfile
text eol=crlf`, checked `.github/workflows/validate.yml` out with CRLF in
all three clones, Linux's default included; so did `*.py eol=crlf` in a
kept `scripts/.gitattributes` for the five scripts, with or without a
root `.gitattributes` of the source's own. `* !eol` did it in the two
clones that write CRLF. Every one of those runs printed `adoption
verified` and exited 0, and the root's first `make bootstrap` then
reported shape-copy-drift on each copy reached. `* -text`, `* binary`,
`* text` and `*.png binary` changed no byte, and their bootstraps passed:
a checkout writes a file that is not text as its blob, and the blob is
LF.

A FINDING, NOT A WRITE. Brett Heap ruled at 2026-10-12T22:48Z that the
root's `.gitattributes` is the one file of the source's that the split
writes into, because no file beside it can reach the copies at the root.
A nested file is not that case: the person can end it, in the source,
with the lines the finding names, or keep it out of the root. Neither
needs this tool to write a second file of theirs, to make a second
exception to "by blob sha" in the verification, or to give
`update-shape.py --add` a second block to extend. So `check` reports
`plan-nested-attributes`, and `execute` refuses it with every other
finding of `check`'s (#168), before any leg exists. A source with no
`.gitattributes` kept above a pinned copy asks nothing new, and adopts
as it did.

GIT IS ASKED, NEVER IMITATED. A throwaway repository holds, where the
split's tree will hold them, only the root's `.gitattributes` as the
split writes it (the source's bytes and the block, or the shape's own),
the shape's `shape/.gitattributes` where it goes beside the source's, and
the kept nested files. `git check-attr crlf text eol` is asked about each
pinned copy where the split puts it, read from `_PlannedRoot`'s walks,
which follow the root's symlinks as the materializer does. No system or
global attributes file is read, since another machine's clone has
neither: a global `* -crlf` here would make every copy look kept.
`checked_out_as` reads the answer as git's `convert.c` does for a
checkout: a copy that is not text, or says `eol=lf`, is written as its
LF blob; `eol=crlf` writes CRLF everywhere; text with no `eol` writes
CRLF wherever git is set to.

ONE FINDING PER CAUSE. A copy is outranked when the root's own files
keep it LF and the kept nested files, added, do not. Each kept file
that, added to the root's own files alone, changes what git says of
such a copy is named once, with every copy it reaches and the line its
own directory spells for each, e.g. `/workflows/validate.yml text
eol=lf` at the end of `.github/.gitattributes`. A deeper kept file that
restores LF leaves nothing to find, and one that says only what the
root says is not named. Asked as a disk that keeps case does and as one
that ignores it (`core.ignorecase`), as #262's findings are; a finding
only the second makes says so. A kept nested symlink counts: a checkout
reads a `.gitattributes` from the index first, where a symlink's blob is
its target text, and git 2.43 wrote CRLF under a kept
`.github/.gitattributes` linking to `*.yml eol=crlf`. A root
`.gitattributes` that is a symlink is #471's follow-up, not this
finding: its copies are asked against the block that follow-up asks a
regular file to hold.

tests/test_adopt_nested_attributes.py, new: what a checkout writes for
14 `crlf`/`text`/`eol` combinations; the finding for each cause above,
with its words and lines; `execute`'s refusal before any leg; a root
`.gitattributes` that is a symlink, and a kept nested symlink; the
outranking asked directly of a root that keeps the copy LF and of one
that does not; a kept `shape/.gitattributes`, which #262 alone names;
eight controls that are no finding; one file named per cause, and each
cause with its own lines; the case-folding aside; this machine's own
attributes not read; a copy's line spelled only where git reads it
literally; nothing asked of git for a source with no kept file above a
copy; and, with `git filter-repo`, the remedied source and a `-text` one
adopted and cloned three ways, with `git check-attr` saying `eol: lf` for
every pinned copy, no CRLF on disk, and a passing first bootstrap.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…re `crlf`, as git does, so a kept `.github/.gitattributes` saying `*.yml -crlf eol=crlf` is found instead of adopting with `.github/workflows/validate.yml` CRLF in every clone, and asks git where no `GIT_ATTR_SOURCE`, `attr.tree` or global attributes macro of this machine reaches (#530)

Review r530 of bbaa56a found that `checked_out_as`, the one function
that reads git's answer, read the old `crlf` attribute before `text`.
Git's `convert.c` (`convert_attrs`) does the opposite: `text` first, and
`crlf` only where `text` is unspecified. Measured with git 2.43, a kept
`.github/.gitattributes` saying `*.yml -crlf eol=crlf` under a root
`* text=auto` passed `check`, adopted as `adoption verified`, and the
copy was CRLF in every clone; `*.yml crlf -text eol=crlf`, which keeps
the copy LF, was refused.

TEXT BEFORE CRLF. `checked_out_as` now reads
`_TEXT_ACTIONS.get(text) or _TEXT_ACTIONS.get(crlf)`. Three rows of the
test table asserted git's behaviour backwards and are corrected
(`unset set crlf => everywhere`, `set unset crlf => kept`,
`set unset native => kept`), and two are added
(`unset unspecified crlf => kept`, `unset set unspecified => by
config`). All 16 rows were checked against real checkouts with git 2.43,
as configured, with `core.autocrlf=true` and with `core.eol=crlf`. The
two sources above are cases of the outranked and kept tables
(`crlf-unset-beside-the-roots-text`, `crlf-set-under-minus-text`).

THIS MACHINE STAYS OUT OF THE ANSWER. A macro defined in a global
attributes file here, such as `[attr]keepbytes -text`, would otherwise
make a kept `*.yml keepbytes eol=crlf` look kept, though a clone on
another machine writes that copy CRLF; the isolation test now uses that
case, and fails if `core.attributesFile` is not replaced. `_probe_env`
also drops `GIT_ATTR_SOURCE` and `GIT_CONFIG_PARAMETERS`, reads no system
config and an empty file as the global config, and strips `attr.tree`
from git's command scope (`GIT_CONFIG_COUNT`, whose other entries are
kept). With git 2.43, `GIT_ATTR_SOURCE` or `attr.tree` naming the empty
tree made `check-attr` read no rule at all, which silenced the finding,
and `GIT_ATTR_SOURCE=HEAD` made the probe fail with `REFUSED
git-failed`; a test sets each and shows the finding stands.

ONE FINDING PER CAUSE, NAMED BY ITS EFFECT. A kept file is named for a
copy when, added to the root's own files alone, it makes a checkout
write that copy with CRLF; only where no file alone does, each that
changes what git says of the copy is named. So a `* text=auto` above a
deeper `* eol=crlf` is no longer told its own line would fix the copy.
A copy still outranked once the named lines are in is named again by
the next `check`, under the file that then decides it.

A finding that only a case-insensitive disk makes says the copy is
written CRLF "on such a disk, whatever git is set to", no longer "on
every platform, Linux and macOS included".

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, a source root `.gitattributes` that this machine's `git add` would store as other bytes than the split writes, so a blob committed CRLF under `* text eol=lf` or `* text eol=crlf` no longer passes `check` and ends `adopt-lost` after both legs exist (#530)

Lane 2's D5(a) line on #530, approved by Brett Heap at
2026-10-15T16:49:38Z: the source's root `.gitattributes` is the one file
of the source's the split writes into (#471), and the split's
`git add -A` stores it again through git's rules for it. Measured at
2423eaa with git 2.43: a blob committed CRLF under `* text eol=lf`,
`* text eol=crlf` or `* eol=lf`, or holding `$Id: deadbeef $` under
`* ident`, passed `check` (exit 0); `execute` made both leg remotes and
exited 1 with `FINDING adopt-lost: .gitattributes`, because the root held
neither the source's blob nor its bytes followed by the block. A CRLF
blob under `* text=auto` or `* text=auto eol=lf`, an LF blob under
`* text eol=lf` or `* text eol=crlf`, and a CRLF blob no rule names, or
one the file says is `-text`, adopted and verified, and still do.

ASKED OF GIT AS THE SPLIT ASKS IT. `_stored_as_written` makes a throwaway
repository whose index holds the source's blob at `.gitattributes`, as
the assembly's checkout does, so the check-in rules that read the index
(`text=auto` leaves alone a blob that already holds CRLF) answer as they
do there. Its work tree holds what the verification expects at the root:
the source's bytes followed by the block, from `_root_attributes`, as the
nested question uses them. Then `git add`, and the staged oid is compared
with `hash-object --no-filters` of the same bytes.

THIS MACHINE'S SETTINGS ARE READ, unlike the nested question's. The
split's `git add` runs here, with this machine's config and attributes,
so a global attributes file, `core.autocrlf`, `core.safecrlf` and a
`filter` driver all count. `_probe_env` is split into `_inherited_env`
(no other repository's `GIT_DIR` and kin, no `GIT_ATTR_SOURCE`, no
`GIT_CONFIG_PARAMETERS`, and no command-scope `attr.tree`), which both
questions take, and the isolation only the nested question adds. Where
`git add` refuses, as `core.safecrlf=true` does over such a CRLF, the
finding quotes git's last line.

`plan-attributes-reencoded` is one of `_entry_findings`', so `execute`
refuses it with `check`'s other findings before any leg exists (#168).
Its way out is the source's: commit the file as git stores it
(`git add --renormalize -- .gitattributes`, then commit), or change the
rule git applies to it, and re-run `plan`. A test takes that way out and
adopts. A root that keeps no `.gitattributes` of the source's, or keeps a
symlink there, asks git nothing.

`* working-tree-encoding=UTF-16LE`, which reaches the root's own file, is
found too. The line's two riders are left open: the `.github -> ci`
spelling is the block's, which `shape_materialize.py` writes and
`update-shape.py --add` extends; and a `working-tree-encoding=` or
`filter=` rule over a pinned copy is a question about the materializer's
own bytes, with a checkout half that no `git add` here can answer.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… literal #530 adds is repeated three times, so Sonar's S1192 no longer flags `"--plan"`, `"config/attributes"` or `"* text\n"` (#530)

The module's `check` and `execute` runs go through one helper each
(`checked`, `adopted_by_execute`), `config/attributes` and `* text` are
named once (`LINKED_RULES`, `TEXT_SET`), and `_stored_as_written` decodes
git's refusal with `errors="replace"` alone, as `_one_line`'s caller at
:1234 does, instead of adding a sixteenth `"utf-8"`. No test changes
what it asserts.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Oct 15, 2026

Copy link
Copy Markdown

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

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

@sourcery-ai

sourcery-ai Bot commented Oct 15, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR adds two preflight findings to adopt-project.py: Git-accurate detection of nested .gitattributes rules that can override pinned-copy line endings, and detection of root .gitattributes content that the split's git add would rewrite or reject. Both findings are wired through the existing check/execute finding pipeline and backed by comprehensive isolation and end-to-end tests.

Sequence diagram for adoption preflight attribute validation

sequenceDiagram
    participant User
    participant Adopt as adopt-project.py
    participant Plan
    participant Probe as ThrowawayGitRepository
    participant Git
    participant Execute
    participant Legs as LegRepositories

    User->>Adopt: check
    Adopt->>Plan: Load plan and source tree
    Adopt->>Probe: Build planned .gitattributes layout
    Probe->>Git: check-attr -z crlf text eol
    Git-->>Probe: Attributes for pinned copies
    Probe-->>Adopt: Nested rule findings
    Adopt->>Probe: Stage source .gitattributes and run git add
    Probe->>Git: git add .gitattributes
    Git-->>Probe: Stored oid or refusal
    Probe-->>Adopt: Re-encoding finding
    Adopt-->>User: plan ok or FINDING

    User->>Execute: execute
    Execute->>Adopt: _entry_findings
    Adopt-->>Execute: Same findings
    alt Finding exists
        Execute-->>User: REFUSED before any leg
    else No finding
        Execute->>Legs: Create and mount leg repositories
        Legs-->>Execute: Adoption result
        Execute-->>User: adoption verified
    end
Loading

Flow diagram for preflight refusal before leg creation

flowchart TD
    Start[Load adoption plan] --> Findings[_entry_findings]
    Findings --> NestedCheck{Nested .gitattributes overrides pinned-copy rule?}
    NestedCheck -- Yes --> NestedFinding[Emit plan-nested-attributes]
    NestedCheck -- No --> RootCheck{Root .gitattributes would be rewritten or rejected by git add?}
    RootCheck -- Yes --> RootFinding[Emit plan-attributes-reencoded]
    RootCheck -- No --> Clear[No new attribute finding]
    NestedFinding --> Refuse[check reports finding; execute refuses]
    RootFinding --> Refuse
    Clear --> Existing[Continue existing preflight findings]
    Existing --> Legs{All findings clear?}
    Legs -- No --> Refuse
    Legs -- Yes --> Create[Create leg repositories]
    Create --> Verify[Materialize and verify adoption]
Loading

File-Level Changes

Change Details Files
Adds Git-backed detection for nested .gitattributes files whose rules override the root line-ending policy for pinned copies, and refuses affected plans before creating leg repositories.
  • Models the materialized assembly in a throwaway repository and uses git check-attr to evaluate precedence, macros, symlinks, case folding, and checkout behavior.
  • Identifies affected pinned copies, attributes files, and remediation lines, including CRLF behavior and case-only findings.
  • Integrates the finding into _entry_findings so check reports plan-nested-attributes and execute shares the same refusal path.
  • Isolates inherited Git environment and configuration so unrelated repositories, attribute trees, and global/system attributes do not affect the nested-file probe.
adopt-project.py
Detects when staging the source root .gitattributes would re-encode or reject the bytes produced by appending the line-ending block.
  • Stages the original blob in a throwaway index and runs this machine's git add against the expected appended content.
  • Reports plan-attributes-reencoded, including Git's refusal text, for line-ending conversion, ident, encoding, filter, and safe-crlf cases.
  • Preserves the split's inherited machine-specific Git behavior while excluding unrelated repositories and attribute-tree sources.
  • Refuses affected plans before any leg exists and provides renormalization or rule-change remediation.
adopt-project.py
Adds extensive unit, isolation, refusal, symlink, case-folding, and end-to-end coverage for both new plan findings.
  • Validates checkout semantics for legacy crlf, text, and eol combinations against Git behavior.
  • Covers nested-file causes, precedence, macros, symlinks, copied paths, root/shape interactions, environment isolation, and no-probe controls.
  • Covers root-file re-encoding, Git refusal messages, machine attributes, encoding rules, tree-source isolation, and non-applicable cases.
  • Verifies refusals occur before leg creation and that remediated adoptions clone with LF-pinned copies and pass bootstrap.
tests/test_adopt_nested_attributes.py

Assessment against linked issues

Issue Objective Addressed Explanation
#530 Prevent adoption from reporting success when a kept nested .gitattributes overrides the root line-ending rule and causes a pinned copy to check out with CRLF. ✅

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

…iew, not a reviewer's working name (#530)

Main's code cites only public reviews; the lane-internal name in these
three comments meant nothing to a reader of the repository. Comment-only.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One conflict, in `_entry_findings`' return in adopt-project.py: #583
(#520, #420) added its branch-name findings (`names`) between the
validator's and the tracking branch's, and this branch added the nested
`.gitattributes` and re-encoded root `.gitattributes` findings (#530)
after `_root_symlink_findings`. Both are kept, in the order the merged
docstring gives: the symlinks, then #530's two, then the unwritable
values, the validator, the branch names, the tracking branch, the moved
submodules and the late plan values. `names_remediation` was already
merged in its place. Nothing else conflicted.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 15, 2026
…es in Git for Windows' own bash, so README and the handbook say Git Bash runs the one-line install, tested in CI but for a typed yes, which it cannot take there (#585) (#596)

**Fix round 7: main after #598 (3fb21d9) (head 52524fe).** Lane 3's #598 (#595) landed on main as 3fb21d9. It rewrote README step 3's `install.sh` paragraph and the handbook's masthead and `start` for `uv`, and for the two readinesses install.sh now ends with. It also changed `install.sh` and `tests/test_install_sh.py`, and added `tests/test_install_sh_filter_repo.py`. One merge commit, **52524fe3**, merges main 396c0ff: 55faad2 (#591), fbbbc8d (#572), 7fdec0a (#624), 3fb21d9 (#598) and 396c0ff (#570). No other change.
- **Conflicts.** README.md had 1 region (step 3's `install.sh` paragraph). docs/handbook.html had 8: the consent rules' `install.sh` item and the seven § Starting markers. `tests/test_install_sh.py` merged as text, and so did the handbook's masthead and `start`.
- **The head holds all of main.** `git merge-tree --write-tree 52524fe origin/main` gives 52524fe's own tree, 6d8ffea. `git diff --stat origin/main 52524fe` names only this PR's six files (436+, 147-).

**The README and handbook resolution with #598 (52524fe).**
- **README, before.** This PR (4c3b72a) had added "; in Git Bash it can take none" after step 3's "each behind a typed `yes` at your terminal". #598 rewrote the same paragraph: "It installs `git-filter-repo` with `pipx`, or `uv`, where it can, or prints the command, runs `openRepoShape --preflight`, and says last if the machine is ready to adopt or only to scaffold. `install.sh` never runs `sudo`; the preflight offers what is missing, each behind a typed `yes` at your terminal."
- **Fitting the cap.** Keeping both made nine lines where #598 has eight, and README was at its cap. The cap was not raised. Two rewordings fit it, and no fact is dropped:
  - "`install.sh` is attached to every release and installs the newest, with no `gh` and no login." became "`install.sh`, attached to every release, installs the newest with no `gh` and no login."
  - #598's "`install.sh` never runs `sudo`;" became "It never runs `sudo` itself;". "It" is install.sh, the subject of the sentence before it, and "itself" is #564's own word for the contrast with the preflight.
- **README, after** (8 lines, at most 80 characters, as 78 README lines on main already are):
  ```
  `install.sh`, attached to every release, installs the newest with no `gh` and no
  login. Its command is PINNED to the release tag, recorded in `openRepoShape.ref`
  beside it: `setup.sh` and the standard are fetched at that tag, not at main. It
  installs `git-filter-repo` with `pipx`, or `uv`, where it can, or prints the
  command, runs `openRepoShape --preflight`, and says last if the machine is ready
  to adopt or only to scaffold. It never runs `sudo` itself; the preflight offers
  what is missing, each behind a typed `yes` at your terminal; in Git Bash it can
  take none. `| bash -s -- v1.0.1` names a release; the same line again upgrades.
  ```
  #598's `pipx`/`uv` sentence and this PR's clause are word for word. README is **1449** lines, its cap (1422 + 5 for `550.txt` + 22 for `564.txt`).
- **The Git Bash clause is still true after #598.** Under `curl … | bash`, `[ -t 0 ] || exec </dev/null` (install.sh:342; #598 moved it down from :277 and left its text alone) leaves the preflight no terminal on stdin. In Git Bash, setup-project.py runs on native Windows Python, where `open_terminal()` returns None (`os.name == "nt"`, setup-project.py:1372). So `can_ask()` is False, and `offer()` takes no `yes`. #598 changes what install.sh does before and after the preflight, not how the preflight reads an answer.
- **Handbook, consent rules.** The `install.sh` item keeps #598's text, "installs `git-filter-repo` with `pipx` or `uv` where one is there, and itself never runs `sudo` or installs either of them", and then this PR's clause, word for word: "; in Git Bash it can take no `yes`, so it installs nothing."
- **Handbook, masthead and `start`.** They merged as text: this PR's "macOS, Linux, WSL2, Git Bash" heading and PowerShell line, beside #598's "where `pipx` or `uv` can install it … and whether the machine is ready to adopt or only to scaffold". The `start` step-3 paragraph is #598's. It is `data-platform="linux mac wsl"`, as before, so it carries no Git Bash clause.
- **The seven § "Starting a project in a new organisation" markers.** This PR's 278a1947 conflicted with #598's d1666fa6. All seven now record `badbb6b273215042ba0e8ae167fd760dc25430ee`, computed with `tests/test_repo_hygiene.py`'s own `readme_segments` and `readme_fingerprint`. That driver reproduces both parents' hashes (main d1666fa6, 4c3b72a 278a1947). All 23 markers match the merged README.
- **`tests/test_install_sh.py`.** #598's hunks are `no_pipx` and `no_pipx_but_apt`'s printed commands, the test's docstring, and the line before the printed command, which now starts "there is neither pipx nor uv". They sit beside this PR's `bash_path` in `present_on_path` and in the same test's preflight line. Both sides' tests keep their meaning.

**Fix round 6: retarget to main after #587 (22eacee) (head 4c3b72a).** #587 squash-landed on main as 22eacee at 2026-10-15T20:27:52Z, and GitHub retargeted this PR to `main`. 22eacee's tree, 831e71d, is `git merge-tree --write-tree 2b6dbb7 3d8fa05`: main holds exactly #587's final head 3d8fa05. So this round adds two merge commits and no other change:
- **3080ac0d** merges #587's final head, 3d8fa05. It conflicted only in README.md and docs/handbook.html, with lane 4's #588 (709eec1), which 3d8fa05 had merged from main. The resolution keeps both sides' text (below).
- **4c3b72ae** merges main, 77bb5f0: 22eacee, plus 2b6dbb7 (#584), which the squash carried but 3d8fa05 lacked, and #615's 77bb5f0, which landed after it. Four files conflicted, because the squash is not an ancestor of 3d8fa05 (merge base 62ef256). For each of them, main's copy is the same blob as 3d8fa05's, and 77bb5f0 does not touch it. Each therefore takes this branch's copy, which is 3d8fa05's copy plus this PR's change:

  | File | `3d8fa05e:` = `origin/main:` (77bb5f0) = `22eaceed:` |
  |---|---|
  | README.md | d164131 |
  | docs/handbook.html | 59ec34d |
  | tests/test_install_sh.py (add/add) | 34c7de5 |
  | tests/test_openreposhape_command.py | 5e49547 |

  No file was merged by hand. `git merge-tree --write-tree 4c3b72a origin/main` gives 4c3b72a's own tree, 97843ec, so the head holds all of main. `git diff --stat origin/main 4c3b72a` names only this PR's six files: `.github/workflows/tests.yml`, README.md, docs/handbook.html, `tests/conftest.py` and the two suites (430+, 141-).

**The README and handbook resolution with #588 (3080ac0).** #588 ends README's "Windows, natively" and the handbook's Windows panel with one sentence: "Printed commands are quoted for a POSIX shell, so paste them into Git Bash. Only `shape-doctor.py`'s lines, `setup-project.py`'s family lines and `family.py`'s `NEXT`, `switch -c`, `add` and `git rm -r --cached` lines are quoted for PowerShell, on Windows." That sentence now follows this PR's Git Bash sentences, word for word. `tests/test_readme_windows_quoting_sentence.py` holds the handbook to README's words, and it passes.
- **README**, before (3d8fa05): "That two-liner IS the Windows way in; the `openRepoShape` command in step 3 below is a macOS, Linux and WSL2 convenience with no Windows twin, because on Windows there is nothing to install. Printed commands are quoted …"
- **README**, after: "That two-liner IS the Windows way in from PowerShell, which has no twin of step 3's `openRepoShape` command. Git Bash runs that command and step 3's one line, tested in CI but for a typed `yes`, which its preflight cannot take there. Printed commands are quoted …"
- **Fitting the cap.** The two texts side by side came to seven lines where #588 had six, and README was at its cap. So "no twin of the `openRepoShape` command in step 3 below" became "no twin of step 3's `openRepoShape` command". Step 3 is "Install the `openRepoShape` command", so the meaning is the same. The six lines rewrap, and README stays at **1449** lines, its cap (1422 + 5 for `550.txt` + 22 for `564.txt`). No raise.
- **#588's other README hunk**, the tighter `python setup-project.py` and "Two commands rather than one pipe" paragraphs, merged as text.
- **Handbook Windows panel.** It keeps this PR's "There is no `openRepoShape` command to install in PowerShell … so none of its offers can be accepted there." and then #588's sentence, unchanged.
- **The seven § "Starting a project in a new organisation" markers** (this PR's 34723d9a against #588's d77a1e59) now record `278a19479524fe8aae292daaca84492902b140ed`, the merged section's hash. It was computed with `tests/test_repo_hygiene.py`'s own `readme_segments` and `readme_fingerprint`, and all 23 handbook markers match the merged README.
- **Consistency.** Every Windows, Git Bash, PowerShell and WSL2 sentence was read again in the merged text. #588's sentence is true in Git Bash too: the named tools run under native Windows Python there, where `os.name` is `nt`, so their lines are quoted for PowerShell, "on Windows", as it says. See checklist candidate 7.

**Fix round 5 (head fe58d66).** One comments-only commit to `tests.yml`, for the re-review's N1 and N3. The header says the FULL suite runs on Linux only, so read alone it stays true beside the exception. The job's comment gives the measured "about a minute and a half (86 s on the first run, 50 s of it pytest)" where it said about three minutes.

**Fix round 4 (head 5f001de).** Brett Heap merged #597 ("ci: run the test suite on Linux only") at 2026-10-15T16:54:43Z, which removed the Windows job these suites ran in. His word at 2026-10-15T18:20:35Z: "Option 1: keep a small Git Bash job for #596". That round added two commits:
- **f6552862** merged lane 3's `l3-564-carry-to-main` b23b245 (#587's branch, this PR's base until fix round 6), which held main 654c476 and so #597. `tests.yml` became that branch's, byte for byte: the guard step and the macOS comment edit went with the two jobs #597 removed. The handbook keeps #597's macOS panel beside this PR's Git Bash text, and its seven markers were moved.
- **5f001de1** adds the `tests-git-bash` job, and fixes the macOS sentence that #597 made say "CI runs on Linux only".

v1.0.1's one-line install (#564) is meant to work in Git Bash on Windows, but nothing ran it there: `tests/test_openreposhape_command.py` and `tests/test_install_sh.py` wore `WINDOWS_SKIP`. They now run in the `tests-git-bash` job, under Git Bash.

**Which bash.** `BASH` in `tests/conftest.py` is Git for Windows' `usr\bin\bash.exe`, found from `git --exec-path`, never from PATH. Probe 1 on `windows-latest` showed why:
- A bare `bash` through `subprocess` is System32's WSL launcher. It prints "Windows Subsystem for Linux has no installed distributions." in UTF-16. CreateProcess searches System32 before PATH.
- `shutil.which("bash")` finds `Git\bin\bash.EXE`. That launcher puts Git's `mingw64\bin` and `usr\bin` first on PATH, so Git's real `curl` came before the suite's fake one. The old install.sh suite, run that way, reached github.com and printed the real newest release: `release: v1.0.1, the newest release of opensoft/openRepoShape (https://github.com/opensoft/openRepoShape/releases/latest)`.

A `git_bash_session` fixture puts Git's `mingw64\bin` (or `clangarm64\bin` on ARM64), `usr\bin` and `MSYSTEM` where Git Bash puts them, behind any fake a test puts first. `BASH_SKIP` replaces `WINDOWS_SKIP` in these two files only.

**What the tests now do on Windows.**
- **Paths.** Every path handed to bash, or expected in what it prints, is spelled the way Git Bash spells it (`/d/a/...`). The conversion uses Git's own `cygpath` (`bash_path`, `native_path`). Git Bash converts `HOME`, `TMPDIR` and `PATH` itself.
- **Line endings.** Every script a test writes for bash is written as LF bytes. The fake's redirect headers and its `gh` tag are written as bytes: Python on Windows prints `\r\n`, and `$(...)` keeps the `\r`.
- **The executable bit.** Git Bash's drives are mounted `noacl`, so `chmod 755` changes nothing. A file that starts with `#!` runs anyway. `--install`'s output is checked by mode 755 on POSIX, and by bash's own `test -x` on Windows.
- **A test's own PATH.** On Windows it holds `#!/bin/sh` wrappers. A symlink to a Git Bash program cannot load its DLL: with symlinks, `mktemp` failed and install.sh refused `could not make a temporary directory`.
- **An argument holding a newline** reaches Git Bash through the environment. On the Windows command line it was split in two: `v1\nforged` arrived as "two versions given". In Git Bash, bash hands bash the argument whole, so the test now does the same.
- **`ln -s`.** A new test makes a link with Git Bash's own `ln -s`, after dropping any `$MSYS` the caller set. By default that is a copy, which has no `openRepoShape.ref` beside it and so follows main. With `MSYS=winsymlinks:nativestrict` it is a real link, which reads the record.
- **Two anchors (fix round 2).**
  - The fetched-setup.sh test now also asserts that the parent of the stub's directory exists. Its "the directory is gone" line therefore cannot pass on a path Python cannot open. Both asserts read one `ran_dir = native_path(ran_in)` (fix round 3), so the existence check cannot be reverted to `Path(ran_in)` alone.
  - The `gh` shim test already could not pass with the shim unreachable: it asserts that the shim's own log holds exactly `auth status`, and only the shim writes that log. Probe 4 reverted its `os.pathsep` and it still passed: the shim stayed reachable either way, most likely through MSYS's conversion of PATH, which the log does not show.

**The workflow (fix round 4).**
- **The header** keeps #597's text and adds one sentence: "One exception, on Brett Heap's word at 2026-10-15T18:20:35Z, "Option 1: keep a small Git Bash job for #596": the `tests-git-bash` job below runs the launcher's and the installer's suites, and nothing else, on Windows under Git Bash."
- **`tests-git-bash`**: `runs-on: windows-latest`, `timeout-minutes: 15`, and no `if:`, so it runs on every trigger the workflow has: a push to main, a pull request, a manual run. Its steps:
  1. checkout and setup-python, at the ubuntu job's two commits, with `fetch-depth: 0`;
  2. `python -m pip install --disable-pip-version-check pytest==8.3.4`;
  3. the git identity, appended to `GITHUB_ENV` in PowerShell, as the old Windows job did;
  4. "Git Bash is the bash the launcher and installer suites run": fails the job when conftest finds no Git Bash, and prints the bash the suites will run;
  5. `python -m pytest -q -rA tests/test_openreposhape_command.py tests/test_install_sh.py --basetemp=${{ runner.temp }}\t`.
- **Its env** is the old Windows job's: `PYTHONUTF8=1`, `core.autocrlf=false` and `core.longpaths=true` through `GIT_CONFIG_COUNT`.
- **Its comment** says why it exists (#585, the one-line install on Windows), why it runs two suites only (#597), why its bash is Git's `usr\bin\bash.exe` (pointing to conftest), and why it runs on a push to main.

**The docs.**
- **README § "Windows, natively" and the handbook's Windows panel** say: PowerShell has no twin of the command; Git Bash runs it and the one-line install, tested in CI but for a typed `yes`, which the preflight the one-line install ends with cannot take there. #588's sentence about POSIX-quoted printed commands follows (fix round 6).
  - Why: install.sh:342 (:277 before #598) runs `exec </dev/null` under `curl … | bash`, and setup-project.py's `open_terminal()` has no Windows path.
  - Nothing installs without a typed `yes`, so consent holds.
  - Fix round 4: this sentence was left with nothing behind it once #597 removed the Windows job. `tests-git-bash` makes it true again, so its text is unchanged.
- **README's macOS sentence and the handbook's macOS panel (fix round 4).** #597 made them say "though CI runs on Linux only and no run of it checks them against bash 3.2". With this job that is false. They now say "though no CI run, on Linux or in Git Bash on Windows, checks them against bash 3.2". README rewraps inside the same four lines.
- **Step 3's sentence about the preflight's offers** (README) and **the consent rules** (handbook) add that in Git Bash it can take no `yes`. Since fix round 7, both clauses follow #598's `pipx`-or-`uv` text, and README's paragraph is reworded to fit (above).
  - The handbook's own step-3 install paragraph is `data-platform="linux mac wsl"`, so it is already scoped away from Windows, and is left.
- **The WSL2 sentence** says WSL2 "runs every bash entry point in Linux itself", instead of being "still how you run" them.
- **The handbook's masthead** lists Git Bash beside macOS, Linux and WSL2.
- **README keeps its line count**: 1449, the cap (1422 + 5 for `550.txt` + 22 for `564.txt`).
- **The seven handbook markers** for README.md § "Starting a project in a new organisation" record its new hash, `badbb6b273215042ba0e8ae167fd760dc25430ee` (fix round 7, after #598). Fix round 4's merge set them to `a88ac2d0…`, 5f001de moved them to `34723d9a…` for the macOS sentence, and fix round 6 to `278a1947…` after #588. Every hash was computed with `tests/test_repo_hygiene.py`'s own `readme_segments` and `readme_fingerprint`.

- **A separate small job, on Brett Heap's word.** At 2026-10-15T18:20:35Z he chose "Option 1: keep a small Git Bash job for #596". The option read: "A small Windows job for just these two suites. The probe runs took 2-3 minutes end to end, so it finishes well inside the 34-36 minute Linux job and adds no wall-clock time. The README claim stays true. It is an exception to #597's decision, so it needs your word." So the job runs these two suites and nothing else. It is not a matrix entry on the ubuntu job, whose actionlint download is a linux-amd64 binary.
- **Every trigger, a push to main included.** A squash lands a tree no pull-request run tested (#122), and these two suites are the only run of the one-line install on Windows. At about a minute and a half beside the ubuntu job (86 s on its first run), it adds no wall-clock time.
- **What the job keeps from the old Windows job, and why.**
  - `PYTHONUTF8=1`: the suites decode bash's output with `text=True`, which uses the locale's code page (cp1252) outside UTF-8 mode, and the launcher's `--help` prints em dashes (openRepoShape:108, :119-120).
  - `core.autocrlf=false` and `core.longpaths=true`: every Windows run of these suites had both, #585's four probes included, and no run without them exists.
  - `fetch-depth: 0`: `test_a_ref_the_doctor_would_check_out_is_validated` sets `OPENREPOSHAPE_REPO` to this checkout, so the launcher clones it, and every Windows run of it cloned a full one.
  - `--basetemp=${{ runner.temp }}\t`: the old job's short temp root, for MAX_PATH.
- **What it leaves out.**
  - **git-filter-repo.** These suites never reach a real one. The install suite's PATH holds only its own fakes, `git-filter-repo` and `pipx` among them. The launcher's machine report gets a fake `git-filter-repo` on the PATH `machine_path` builds. On Linux, with every PATH directory that held a `git-filter-repo` removed (`filter-repo=none` in the run log), the two suites gave **113 passed**. `test_it_installs_the_git_filter_repo_ci_runs` still finds the ubuntu job's pin.
  - **actionlint.** The ubuntu job lints this file.
- **`usr\bin\bash.exe`, not `bin\bash.exe`, and not PATH.** The probe-1 lines quoted above.
- **`git_bash_session` is needed, though the runner's own PATH hides it.**
  - The runner's PATH already holds Git's `bin`, `usr\bin` and `mingw64\bin`, with `bin` before System32 (probe-1 PATH dump). So reverting the fixture changes nothing there (probe 2: 113 passed).
  - A stock Git for Windows install puts only `Git\cmd` on PATH. Probe 3 ran the two files on exactly that PATH, where `which mktemp` is `None` and `which bash` is `C:\Windows\system32\bash.EXE`:
    - with the fixture: **113 passed**;
    - with it reverted: **59 failed, 15 passed, 39 errors**, for example `bash: line 199: mktemp: command not found` and `this machine has no mktemp, which the install needs`.
- **`bash -s` runs still take their script through a text-mode pipe.** That pipe hands bash CRLF on Windows, but Git Bash's bash drops a carriage return before a newline: a CRLF script piped to it printed `crlf-ok` and exited 0 (probe 1). Sending bytes instead changed no result (mutation `text-stdin`, 113 passed), so that change was dropped.
- **Python can make symlinks on the runner** (`os.symlink: ok`), so the three existing symlink tests run there unchanged.
- **The macOS sentence names Git Bash without a line.** "No CI run, on Linux or in Git Bash on Windows, checks them against bash 3.2" is true with this job, and fits README's four lines. A clause naming the two suites would have needed a fifth line and a cap raise.
- **Not claimed: that setup's own `yes` is asked in Git Bash.** Typed at a Git Bash prompt, `openRepoShape <Project>` reads its `yes` through native Windows Python. Whether that Python sees a console depends on Git Bash's pseudo-console support, and nobody has confirmed it on a Windows machine.

- **Linux, on the head 52524fe** (fix round 7, `dirty=0`):
  - the two suites: **113 passed** in 41.23s;
  - `tests/test_install_sh_filter_repo.py` (#598's, unchanged): **18 passed** in 7.32s;
  - `tests/test_repo_hygiene.py`, `tests/test_readme_sections.py`, `tests/test_cli_reference.py`, `tests/test_setup_by_hand_commands.py` and `tests/test_readme_windows_quoting_sentence.py`: **171 passed** in 25.43s.
  - `git grep -l 'install.sh' tests/` finds `conftest.py`, `readme-cap.d/564.txt`, `test_install_sh.py`, `test_install_sh_filter_repo.py` and `test_repo_hygiene.py`. Every test file among them was in these runs. No skips.
  - The merge, before its commit: `test_repo_hygiene.py`, `test_readme_sections.py` and `test_readme_windows_quoting_sentence.py`, 144 passed.
- **actionlint 1.7.12 on 52524fe**, on `tests.yml` and the two template workflows: exit 0, with shellcheck on PATH. `tests.yml` is the same blob as at 4c3b72a (5fc46de); #598 does not touch it.
- **Linux, on the head 4c3b72a** (fix round 6, `dirty=0`):
  - the two suites: **113 passed** in 28.57s;
  - `tests/test_repo_hygiene.py`, `tests/test_readme_sections.py`, `tests/test_cli_reference.py`, `tests/test_setup_by_hand_commands.py` and `tests/test_readme_windows_quoting_sentence.py`: **171 passed** in 17.09s.
  - Those cover every `tests/test_install_sh*.py` (there is one) and every test file `git grep -l 'install.sh' tests/` finds (`test_install_sh.py`, `test_repo_hygiene.py`; the other two hits are `conftest.py` and `readme-cap.d/564.txt`).
  - 3080ac0's merge, before its commit: `test_repo_hygiene.py`, `test_readme_sections.py` and `test_readme_windows_quoting_sentence.py`, 144 passed.
- **actionlint 1.7.12 on 4c3b72a**, on `tests.yml` and the two template workflows: exit 0, with shellcheck on PATH. The workflow keeps main's #593 `concurrency` block byte for byte (this PR's two `tests.yml` hunks are the header and the new job), the header's "The FULL suite runs on LINUX ONLY" with the exception sentence, and `tests-git-bash`.
- **Linux, on the head 5f001de** (`dirty=0`): the two suites, `tests/test_repo_hygiene.py`, `tests/test_readme_sections.py`, `tests/test_cli_reference.py` and `tests/test_setup_by_hand_commands.py`: **281 passed** in 61.24s.
- **Linux, fix round 4, before its commit:**
  - the two suites: 113 passed;
  - the two suites with no `git-filter-repo` on PATH: **113 passed**;
  - the four docs and hygiene files: 168 passed;
  - the merge alone, before its commit: `test_repo_hygiene.py` and `test_readme_sections.py`, 141 passed.
- **actionlint 1.7.12**, fetched and checksum-checked as the ubuntu job does, on `tests.yml` and the two template workflows: exit 0, with shellcheck on PATH.
- **The YAML parse.** Triggers: `push` to `main`, `pull_request`, `workflow_dispatch`. Jobs: `tests` (ubuntu-latest) and `tests-git-bash` (windows-latest, `timeout-minutes: 15`). Neither has an `if:`, so both run on every trigger.
- **Linux, earlier rounds:** on 01e8c2c, the same five files without `test_setup_by_hand_commands.py`, 276 passed. Lane 5's re-review got the same 276 there. On 649b089, with `tests/test_setup_by_hand_commands.py`, `tests/test_agents_md_codes.py` and `tests/test_gh_guard.py` as well, 334 passed.
- **Windows, `windows-latest`, through probe branches that ran only these suites:**
  - Probe 1 (run 38752845751): 109 passed, 2 failed, 0 skipped. The two failures were the newline arguments, fixed since.
  - Probe 2 (run 38754067543, tree ebba347): **113 passed, 0 skipped**, in 68.83s.
  - Probe 3 (run 38757083883, on 649b089): **113 passed** in 50.97s. On a stock Git for Windows PATH (only `Git\cmd`): 113 passed.
  - Probe 4 (`l5-585-probe-4`, on 01e8c2c): **113 passed** in 77.92s (run 38769166462).
- **The same two files as they were at 5191f0e, with only `WINDOWS_SKIP` lifted** (probe 1): 88 failed, 23 passed. Two causes: the WSL launcher, and the real network through `Git\bin\bash.EXE`.
- **Windows on 5f001de, the PR's own run of `tests-git-bash`:** **113 passed** in 50.28s, with the whole job taking 1 min 26 s (18:37:00Z to 18:38:26Z, check run 116601758252).
  - The guard step printed `C:\Program Files\Git\usr\bin\bash.exe`.
  - Its steps and env are probe 4's, less the git-filter-repo install, and with `-q -rA` and a 15-minute job timeout.

Probe 2 ran these on **ebba347f**, the tree before the squash, which still had a `run_piped` helper; the `text-stdin` row is that helper. The squashed head 649b089 was then re-measured whole by probe 3 (113 passed), and fix round 2's head 01e8c2c by probe 4. Each row reverts one Windows hunk and runs the two files again.

| Hunk reverted | Result |
|---|---|
| `BASH` is the bare word `bash` | 108 failed |
| paths keep Windows' spelling | 25 failed |
| scripts written with `write_text` (CRLF) | 1 failed: `test_a_release_whose_command_records_no_ref_exits_one`, whose launcher's first line became `#!/usr/bin/env bash\r` and was refused as not the command |
| the fake prints its headers and tag in text mode | 22 failed |
| mode 755 checked on Windows too | 2 failed |
| symlinks on a test's own PATH | 28 failed |
| newline arguments on the command line | 2 failed |
| no `git_bash_session` | 113 passed on the runner's PATH; on a stock Git for Windows PATH (probe 3) 59 failed and 39 errors, against 113 passed with it |
| `bash -s` script sent as bytes (`run_piped`) | 113 passed either way; this hunk was dropped |

Fix round 2's anchors (probe 4, on 01e8c2c): 

| Run | Result |
|---|---|
| the fetched-setup.sh test, `native_path(ran_in)` reverted to `Path(ran_in)` in both asserts | exit 1: 1 failed. The new parent-directory anchor catches it. Since fix round 3 both asserts read one `ran_dir`, so this is the only way to revert it. |
| the `gh` shim test, its PATH joined with `:` instead of `os.pathsep` | exit 0: 1 passed. The shim stays reachable, so the hunk is not load-bearing on this runner. The log shows only that outcome; MSYS's conversion of PATH is the likely reason. The test is still not vacuous: it asserts that the shim's own log holds exactly `auth status`, and only the shim writes that log, so an unreachable shim would fail it. |
| both `ln -s` cases with `MSYS=winsymlinks:nativestrict` exported, head | exit 0: 2 passed |
| the same, with the `MSYS` drop reverted | exit 1: 1 failed (`ln-default` made a link), 1 passed |

#590 adds four cases to these two suites, written while they skipped on Windows:
- `test_a_command_that_is_a_symlink_is_refused_before_any_request[live|dangling]`;
- `test_install_refuses_a_target_that_is_a_symlink[live|dangling]`.

Each compares a path bash printed with `str(path)`. Each also compares `os.readlink`'s answer with the target, and on Windows that answer typically starts `\\?\` (Python's `os.readlink` documentation, since 3.8; the probe's failures stop at the first assert, so no log shows it). Linux passes on the merged tree (117 passed), but under Git Bash those four cases fail, and `tests-git-bash` runs them.

**Whichever of #596 and #590 lands second applies these hunks** to the merged tree. Probe 4 (run 38769166462) applied exactly these.

```diff
--- a/tests/test_install_sh.py
+++ b/tests/test_install_sh.py
@@ -668,12 +668,14 @@ def test_a_command_that_is_a_symlink_is_refused_before_any_request(
     result = net.run()
     assert result.returncode == 2, result.stdout + result.stderr
     said = " ".join(result.stderr.split())
-    assert f"{link} is a symbolic link, so nothing was installed" in said, said
+    assert f"{bash_path(link)} is a symbolic link, so nothing was installed" \
+        in said, said
     assert "pipx upgrade openreposhape" in said
     assert "pipx uninstall openreposhape" in said
     assert net.calls() == [], net.calls()
     assert link.is_symlink()
-    assert link.readlink() == packaged
+    # Windows reads a link back with `\\?\` ahead of its target.
+    assert str(link.readlink()).removeprefix("\\\\?\\") == str(packaged)
     if not dangling:
         assert packaged.read_bytes() == original
     assert sorted(path.name for path in net.bin.iterdir()) == \
--- a/tests/test_openreposhape_command.py
+++ b/tests/test_openreposhape_command.py
@@ -407,17 +407,18 @@ def test_install_refuses_a_target_that_is_a_symlink(tmp_path, dangling):
     link = bin_dir / "openRepoShape"
     link.symlink_to(packaged)
     result = run_cmd("--install", home=tmp_path,
-                     env={"OPENREPOSHAPE_BIN_DIR": str(bin_dir),
+                     env={"OPENREPOSHAPE_BIN_DIR": bash_path(bin_dir),
                           "OPENREPOSHAPE_REF": "v9.9.9",
                           "OPENREPOSHAPE_RECORD_REF": "1"})
     assert result.returncode == 2, result.stdout + result.stderr
     said = " ".join(result.stderr.split())
-    assert f"{link} is a symbolic link, so --install will not copy this " \
-        "command through it" in said, said
+    assert f"{bash_path(link)} is a symbolic link, so --install will not " \
+        "copy this command through it" in said, said
     assert "pipx upgrade openreposhape" in said
     assert "pipx uninstall openreposhape" in said
     assert link.is_symlink()
-    assert os.readlink(link) == str(packaged)
+    # Windows reads a link back with `\\?\` ahead of its target.
+    assert os.readlink(link).removeprefix("\\\\?\\") == str(packaged)
     if not dangling:
         assert packaged.read_bytes() == original
     assert not packaged.with_name("openRepoShape.ref").exists()
```

Both files already import `bash_path` once #596 is in. On Linux, the merge of 649b089 with d090f0c, with these hunks: **117 passed**. Fix round 7, on the merge of 52524fe with #590's head 1c8a708, with fix round 6's resolved `tests.yml` (both inputs unchanged since; the result is blob 266c62c): the hunks apply (`git apply --check` exit 0). The two suites (117 by collection), `tests/test_install_sh_filter_repo.py` (18), `tests/test_openreposhape_package.py` and `tests/test_repo_hygiene.py` give **270 passed** in 48.69s. Fix round 6: on the merge of 4c3b72a with #590's current head 1c8a708, with `tests.yml` resolved as below, the hunks apply (`git apply --check` exit 0) and the two suites give **117 passed**; with `tests/test_openreposhape_package.py` and `tests/test_repo_hygiene.py`, 252 passed. Probe 4 runs the merge of 01e8c2c with `l3-563-pypi-package` on Windows: the four cases without the hunks, then both suites with them. In run 38769166462, on Windows:
- **Without the hunks**: exit 1, `4 failed, 113 deselected`. These are exactly the four cases. The launcher pair failed on `REFUSED: D:\a\_temp\…\bin/openRepoShape is a symbol…`, which confirms rv585's inferred launcher failures; the install pair failed on `REFUSED: /d/a/_temp/…/.local/bin/openRepoShape …`.
- **With the hunks**: they apply (exit 0), and both suites give **117 passed** in 75.13s.

**`tests.yml` conflicts with #590 (fix round 6; unchanged in fix round 7, where #590's head is still 1c8a708 and this PR's `tests.yml` is the same blob).** #590 now targets `main` too: at fix round 6 its merge ref's first parent was main 77bb5f0. Its head 1c8a708 has merged #587's final head and main, so it holds #597, and it merges with main cleanly. Both PRs add to the same two places in `tests.yml`, so against 4c3b72a there are three regions:
- **The header.** Keep this PR's "The FULL suite runs on LINUX ONLY" and its exception sentence, then #590's four lines about the `package` job.
- **The end of the file, two regions.** git splits the two new jobs where they share the checkout and setup-python lines. Keep both jobs whole: `tests-git-bash`, then #590's `package`.
- **Checked.** I built that file: this PR's `tests.yml` with #590's header paragraph and its `package` job added. Against #590's file it differs only in this PR's two hunks, and against this PR's file only in #590's two. `yaml.safe_load` gives the jobs `['tests', 'tests-git-bash', 'package']`, and actionlint 1.7.12 exits 0 on it. #590's `package` comment now says "the other jobs" and "on Linux like the rest of the suite since #597", which stay true beside `tests-git-bash`. Fix round 4's advice to drop `tests-windows` and `tests-macos` and to reword "three jobs" no longer applies: #590 has done both.

The open pull requests are the 17 with a `refs/pull/<n>/merge` ref in `git ls-remote origin 'refs/pull/*'`: 583, 590, 594, 596, 599, 614, 617-623 and 625-628. Each head was merged with 52524fe and, separately, with main 396c0ff. The ones not in the table merge cleanly with this head and touch none of its six files: #583, #614, #617, #618, #619, #621, #622, #625, #626 and #628.

| Against | Result |
|---|---|
| origin/main 396c0ff (#591, #572, #624, #598 3fb21d9, #570) | **clean**. The merge gives this head's own tree, 6d8ffea: the head holds all of main. |
| #590 1c8a708 (`l3-563-pypi-package`) | **conflicts** in `.github/workflows/tests.yml`, 3 regions: the header and the end of the jobs. #590 against main alone is clean, so this is the two PRs' own overlap. It also touches `tests/test_install_sh.py` and `tests/test_openreposhape_command.py`, which merge as text. See the #590 section above for the resolution, the four test cases and fix round 7's 270 passed. |
| #599 b0182e2 (lane 3, stacked on #590's old head d090f0c) | **conflicts** in `.github/workflows/tests.yml`, 2 regions. #599 also conflicts with main alone, in six files (`tests.yml`, README.md, the handbook, `install.sh`, `openRepoShape`, `tests/test_install_sh.py`). It is #597's conflict, through #599's stale base, not this job's. |
| #627 fc0656b (`fix/606-readme-followups`, #606; new since fix round 6) | **conflicts** in docs/handbook.html only: 7 regions, each one § Starting marker line (this PR's badbb6b2 against #627's 51702a2b). Its README hunks (the second-run refusal in § Starting, and `CONTRACTS_DIR` and the `single-repository.yaml` question in § Adopting) merge as text with this PR's. #627 against main alone is clean. **Whoever lands second** sets the seven markers to the merged README's hash. On this head plus fc0656b that is `fe0d932b031e4a08fc885f93d6cd978df0d5a25c`. README is then 1456 lines: 1449 plus #627's own `readme-cap.d/606.txt` (+7). |
| #594 be808a7, #623 1e30fa7 | **clean**. Both touch `tests/conftest.py`, which merges as text. |
| #620 4c049a8 | **conflicts** in `adopt-project.py`, and so does #620 against main alone: its own conflict with main, not an overlap with this PR. |

#598 landed as 3fb21d9 and is in the main row; fix round 7 is that merge. Every lane-5 PR and every other PR that has landed is in the main row.

- **`tests/test_setup_sh.py`, and `test_repo_hygiene.py`'s `bash -n` test, still skip on Windows,** and since #597 nothing runs them on Windows at all. #585 names the launcher's and the installer's suites, and Brett Heap's word keeps a job for those two only. The same `BASH` would serve both.
- **Any typed `yes`.** CI has no terminal.
- **`tests/test_install_sh_filter_repo.py` in `tests-git-bash` (fix round 7).** #598's file is not added to the job; that is the coordinator's call. See checklist candidate 8.
- **`docs/release-notes/v1.0.0.md` and `docs/release-notes/v1.0.1.md`** are records of those releases, and #597 left them unedited too. v1.0.1's notes say #585 runs the suites under Git Bash; that stays true. The v1.1.0 text is below.
- **A required check.** This PR does not make `tests-git-bash` a required status check. That is a ruleset setting, and Brett Heap's call.

No v1.1.0 notes file exists on main yet, so this PR writes none. Proposed text, a section of its own:

> **Git Bash on Windows (#585).** Git Bash, the bash that Git for Windows installs, runs the one-line install and the `openRepoShape` command. CI's `tests-git-bash` job runs their suites under Git for Windows' own bash, with the PATH Git Bash makes, and fails if it cannot find that bash. That job is an exception to #597's Linux-only CI, made on Brett Heap's word. In PowerShell the way in is unchanged: `py setup-project.py`. Two things differ from macOS and Linux. `ln -s` in Git Bash makes a COPY unless `MSYS=winsymlinks:nativestrict` is set; a copy has no `openRepoShape.ref` beside it, so it follows main, and `openRepoShape --version` says so. Link it with that setting (Windows lets a non-administrator make a symlink only in Developer Mode), or put `~/.local/bin` on `PATH` instead. And `chmod` changes nothing on Git Bash's drives; a file that starts with `#!` runs as a command anyway. Not tested there: `setup.sh` run on its own (its suite still skips on Windows), and any typed `yes`, for which CI has no terminal. In Git Bash the preflight that the one-line install ends with cannot take a `yes` at all, so it installs nothing for you.

1. **The machine report's `python3` line on Windows.** `--install`'s `machine:` block probes only `python3`. Where Python came from python.org, Git Bash has `python` and no `python3`, or only the Microsoft Store stub. The block then says `python3 MISSING`, or "present" for a stub, while `--preflight` passes with `python`.
2. **A drive-letter `$OPENREPOSHAPE_REPO`.** `shape_clone_url` treats `C:/src/openRepoShape` as an owner/name and builds `https://github.com/C:/src/openRepoShape.git` from it. Git Bash and git itself both accept the drive-letter spelling.
3. **A Windows-spelled `$OPENREPOSHAPE_BIN_DIR`.** `--install` then prints `export PATH="D:\...:$PATH"`, which bash cannot use: the drive's colon splits it.
4. **A stale docstring.** `test_a_ref_the_doctor_would_check_out_is_validated` says the refusal arrives before any clone. `shape_checkout` clones first, then checks the ref.
5. **On Windows, the preflight a piped install ends with cannot take a typed `yes`.**
   - Under `curl … | bash`, install.sh:342 (:277 before #598) hands its children /dev/null for stdin.
   - setup-project.py's `open_terminal()` returns None when `os.name == "nt"`, so it has no Windows path to the person's terminal. Opening `CONIN$`, where POSIX opens `/dev/tty`, might be the analogue.
   - Found by rv585; it is the root of this PR's README wording.
6. **The handbook's step 3 has no Git Bash path.** Its install block and paragraph are `data-platform="linux mac wsl"` (docs/handbook.html:775-784 on 01e8c2c), so a reader on the Windows tab never sees the one-line install that the Windows panel says Git Bash runs. Adding it to the Windows tab is a docs decision.
7. **In Git Bash, the host-quoted lines are still quoted for PowerShell.** `openRepoShape --doctor`, a family `setup-project.py` run and `family.py`, started from Git Bash, run under native Windows Python, where `os.name` is `nt`. So `quote_arg`, `shell_quoted`, `shell_line` and `shell_word` (for example setup-project.py:2331, `if os.name == "nt" and not posix`) quote for PowerShell, even though the person is at a bash prompt. #588's sentence stays true ("on Windows"). But someone who follows "Git Bash runs that command" and pastes such a line back into Git Bash gets the failure that `tests/test_readme_windows_quoting_sentence.py`'s docstring names: `git -C C:\work\Orbit` reads as `C:workOrbit`. Choosing `sh`'s dialect under Git Bash (for example on `MSYSTEM`) would be a #532 decision.
8. **#598's installer cases do not run under Git Bash.** `tests-git-bash` runs `tests/test_install_sh.py`, and since fix round 7 that suite reaches #598's code in two ways: every run now prints `adoption:` and `done:`, and `no_pipx` expects uv's printed command. But the uv install, the readinesses' wording and the pasted next steps are tested in `tests/test_install_sh_filter_repo.py` only, on Linux. README says Git Bash runs step 3's one line, "tested in CI".
   - **What the file uses.** It inherits `test_install_sh.pytestmark` (`BASH_SKIP` and `git_bash_session`), and drives every run through `Net`: `fake` (LF bytes through `write_script`), `run` (`BASH`, `bash_path(INSTALLER)`), `serve`, `installed` and `calls`. Its own `uv` fake writes LF bytes, like `Net.fake`, and names the interpreter as `shlex.quote(sys.executable)`, as `Net.fake` does in the job today. No assert compares a path bash printed with a Python path; each checks a line's start or end.
   - **Under Git Bash it would likely pass.** Its programs see `PATH=net.path` alone, so the runner's own pipx or uv cannot leak in. The launcher's `--install` probes `python3` with `command -v` and never runs it, so the logging `python3`/`python` fakes stay silent. Its `paste()` sends a script through a text-mode pipe, as `Net.run(piped=True)` already does in the job.
   - **Not run on Windows.** On Linux: 18 passed in 7.32s. Adding it to the job is one more path on the job's pytest line.
9. **README still names `pip install git-filter-repo` for adopting.** README:768 (§ Adopting) says "`git filter-repo` is a **hard dependency** — `pip install git-filter-repo`, or the same name from apt or brew." #595 found that `pip install` is refused on an externally managed Python (Debian 12, Ubuntu 23.04, Arch; PEP 668), and #598's install.sh no longer prints it. Neither #598 nor this PR touches that line.

Review: rv585 found 0 blockers, 2 should-fix and 8 nits on 649b089 (CHANGES NEEDED), fixed or answered in 01e8c2c; the re-review rv585b found 5 nits on 01e8c2c (APPROVE WITH NITS), fixed or answered in 3095a9c; rv585c found 4 nits on 5f001de (APPROVE WITH NITS), fixed or answered in fe58d66. CI (Brett Heap's ruling 27, Linux-only since #597): H0 is the head 52524fe, a hand-resolved merge of main 396c0ff, green on tests (Linux), tests-git-bash and SonarCloud; main since then (#625 8f453d0, #583 de428f5) touched none of this PR's files, and the local gate passed on the predicted squash over de428f5: the launcher's, the installer's and install.sh's filter-repo suites, 131 passed; and the hygiene, README-section, CLI-reference, setup-by-hand and Windows-quoting sets, 171 passed. The tests-git-bash job is on Brett Heap's word of 2026-10-15T18:20:35Z, "Option 1: keep a small Git Bash job for #596". Landed on Brett Heap's word of 2026-10-15T22:24:36Z, "Merge #596 when its turn comes".

Closes #585

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap merged commit 7ba64f0 into main Oct 16, 2026
4 checks passed
@brettheap
brettheap deleted the fix/530-adopt-nested-gitattributes branch October 16, 2026 00:38
brettheap pushed a commit that referenced this pull request Oct 16, 2026
main gained #583 (#520), #596 (#585), #614 (#527), #619 (#530), #594
(#526), #617 (#525) and #618 (#418). Three files conflicted:

- .github/workflows/tests.yml: main's file (with #596's tests-git-bash
  job) plus this branch's three additions, byte-identical to theirs: the
  header paragraph on the `package` job (#590), after main's Git Bash
  paragraph; #599's actionlint line for release.yml; and #590's `package`
  job, now after tests-git-bash. actionlint 1.7.12 finds nothing in it.
- README.md, the native Windows block's closing paragraph: #596 says the
  two-liner IS the Windows way in from PowerShell, with no twin of the
  `openRepoShape` command, and that Git Bash runs that command and the one
  line, tested in CI but for a typed `yes`; this branch says the two-liner
  is the way in that installs nothing and pipx's `openRepoShape` runs the
  same flow. The merge keeps both facts in main's seven lines: the two-liner
  installs nothing, pipx's command runs the same flow, and Git Bash runs
  step 3's bash command and its one line, tested in CI but for a typed `yes`.
- docs/handbook.html: the same two sentences in the masthead's second-way
  paragraph and the Windows panel, each combined the same way, and the
  seven `Starting a project in a new organisation` markers, which now read
  the merged README (62737be2). No other section's marker moves.

README.md is 1477 lines, its cap (1449 on main, +28 for #563).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 16, 2026
…ests, 57 of 82 milestone issues closed), with #583, #596, #614, #619, #594, #617 and #618 in their areas, #596's Git Bash facts for its placeholders, release.yml's step 1 whole, and every pull request cited beside its issue (RV630 SHOULD 3, 5, 6, nits 7, 8)

- SHOULD 5: step 1 matches release.yml's header: "always on a
  `release/v<X.Y>` branch, which no push run tests", "A `release/v<X.Y>`
  branch serves while it is still at that commit", and that a tag's
  dispatch running at the tag's commit is GitHub's documented behaviour,
  not yet seen here.
- SHOULD 6: #598 beside #595, #592 beside #593, #460 on #557's line, and
  #589's line says "where that clone is still on the machine and is no
  partial clone" again. The counts and the open-issue list are re-derived
  from `git log --first-parent bfed143..origin/main` (49) and the
  milestone (25 open, 57 closed: #342, #392, #393, #421, #423, #426,
  #432, #433, #434, #443, #444, #454, #455 and #629 joined it). New lines
  under adopt-project.py for #583 (#520, #420), #619 (#530), #618 (#418),
  #614 (#527), #594 (#526) and #617 (#525); #596 (#585) under the one-line
  install, CI, native Windows, the docs and the known limits, where the
  two placeholders for it were.
- SHOULD 3: the known limit about "the installed doctor's temporary
  clone" is gone; the README, the handbook and AGENTS.md no longer say it.
- SHOULD 1: the filter-repo line says install.sh's order, apt and dnf
  included.
- nit 7: the release process's preconditions are said as the process
  this release follows; the workflow checks the environments, and nothing
  checks the rehearsal.
- nit 8: the known limits are wrapped.
- "the first on native Windows, where the one line cannot run" says
  PowerShell cannot run it, since Git Bash does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 16, 2026
brettheap pushed a commit that referenced this pull request Oct 20, 2026
…rk directory, with the source's object format and no hook of this machine's, a path git refuses is the finding `plan-path-git-refuses`, and a repository git cannot make or ask there is `adopt-git-unasked`, never `git-failed` (#910)

`check` asks this machine's git what the split will make of the source in
throwaway repositories: the split rehearsal (#654), the attributes probe
(#619, #530), `_stored_as_written` (#530 D5(a)) and the symlink probe
(#183). The first three were made by a plain `git init` in TMPDIR. So a
SHA-256 source, or `GIT_DEFAULT_HASH=sha256` over a SHA-1 one, stopped
`check` with `git-failed` at the rehearsal's `read-tree`; a path git refuses
stopped it the same way; the answers were about TMPDIR's disk, not the work
directory where `execute` builds the split; and every hook this machine's
config names ran in them, 12 times in one `check`.

- L1-90: each throwaway repository is made with `git init
  --object-format=<the source's>`, which `Source.object_format` reads once
  with `git rev-parse --show-object-format`. Before anything else, the
  rehearsal asks which paths of the source commit git refuses as a path
  (`..`, `.Git/x`, `notes/.git/x`). It writes them with `update-index
  --index-info`, which leaves out each one it refuses, and reads back
  `ls-files`. Each such path is named in the new finding
  `plan-path-git-refuses`. The finding says that each clone `execute` makes
  stops at the path, after both leg repositories were created, whatever the
  plan answers. Its remedy is to rename or remove the path in the source and
  run `plan` again, because no answer in the plan changes it, `drop`
  included. `execute` refuses it before any leg
  (`_refuse_what_check_finds`). The rehearsal asks nothing more of such a
  tree, and `check` asks the path question once.
- L1-92: they are made in the work directory, `--work-dir` or its default,
  not in TMPDIR, and are removed. That directory is where `execute` builds
  the split, and `check --work-dir` already says to give it the one
  `execute` will be given, so the answer is about the disk and the
  directory the split meets. Saying where they were made, the other choice,
  would still answer about another disk.
- L1-94: every git command in them runs with `-c
  core.hooksPath=<an empty directory beside the repository>`, which outranks
  the config files and `GIT_CONFIG_COUNT`, and `git init` is given that
  directory as `--template`.
- L1-32: when the symlink probe's `git init` fails without an `OSError` (a
  `CommandFailed` or a `Refusal`), the probe takes git to check symlinks out
  as symlinks, so a finding about a symlink says what such a disk makes of
  it, and one NOTE gives git's words. It was "a git or gh command failed".
- `adopt-git-unasked`: where git cannot make or ask a throwaway repository
  in the work directory, `check` gives one finding that says why in git's
  words, and `execute` refuses it before any leg. An example is
  `init.defaultBranch = a..b`, which the split's own clone refuses too.
  Where no directory can be made there at all, `adopt-work-dir-unwritable`
  (#524) stays the one finding, and a NOTE says what went unasked.

Measured with `check` against a fresh source (stub `gh`), first on
c7a3ff1, then on this commit:

- SHA-256 source: exit 2, `git-failed` at `read-tree`; then exit 0,
  `plan ok`.
- `GIT_DEFAULT_HASH=sha256` over a SHA-1 source: exit 2, `git-failed`;
  then exit 0, `plan ok`.
- `..` in the source commit: exit 2, `git-failed: ... error: invalid path
  '..'`; then exit 1, `plan-path-git-refuses`. `.Git/x` behaves the
  same. With the rehearsal patched out, as before #654, `execute` created
  both legs and then stopped, `REFUSED` extracting the spec leg (clone exit
  128, invalid path).
- A counting hook named by `core.hooksPath` in `GIT_CONFIG_GLOBAL`: 12
  runs (2 in the attributes probe, 10 in the rehearsal); then 0. The same
  hook runs once for a plain `git add` in the control.
- Where: before, three in TMPDIR (attributes probe x2, rehearsal x1); now,
  all of them in the work directory, which is empty again afterwards.
- `GIT_DEFAULT_HASH=bogus` with `.github -> ../outside-ci`: exit 2, "a git
  or gh command failed ... unknown hash algorithm 'bogus'"; then exit 1,
  `plan-root-symlink` and the probe's NOTE.
- `init.defaultBranch = a..b`: exit 2, `REFUSED git-failed`; then exit 1,
  `adopt-git-unasked`. The split's own clone there exits 128.
- A work directory nobody can write in: before, `adopt-work-dir-unwritable`
  with the rehearsal asked in TMPDIR; now `adopt-work-dir-unwritable` and
  one NOTE.

tests/test_adopt_check_throwaway_repos.py has 28 rows, covering each item
and its controls. On c7a3ff1's adopt-project.py, 22 of them fail and 6
pass, and the 6 are controls.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 21, 2026
…rk directory with the source's object format and no hook of this machine's; a path git refuses is `plan-path-git-refuses`, a repository git cannot make there `adopt-git-unasked` (#910) (#1031)

## What

`adopt-project.py check` asks this machine's git what the split will make of
the source in throwaway repositories: the split rehearsal (#654), the
attributes probe (#619, #530), `_stored_as_written` (#530 D5(a)) and the
symlink probe (#183). After this change they answer as the split will, or
`check` says why they cannot. They no longer stop with `git-failed`, and
they no longer answer about another disk or another config.

| Item | Change |
| --- | --- |
| L1-90 | Every throwaway repository except the symlink probe's is made with `git init --object-format=<the source's>`. `Source.object_format` reads that format once, with `git rev-parse --show-object-format`. The symlink probe keeps its plain `git init` and asks only `core.symlinks` (see L1-32). A path of the source commit that git refuses as a path (`..`, `.Git/x`, `notes/.git/x`) gets the new finding `plan-path-git-refuses`. The finding names the paths and says that each clone the split makes would stop at them, so `execute` refuses them before it makes either leg. Its remedy is to rename or remove the path in the source and run `plan` again; no plan answer changes it, `drop` included. Beside such a path, `check` asks nothing more of the source's files: `plan-file-reencoded` is asked once the path is gone. |
| L1-92 | **The work directory**: `--work-dir`, or the default one `check` makes in TMPDIR, but never TMPDIR itself. That is where `execute` builds the split, and `check --work-dir` already asks for the one `execute` will be given, so the answer is about the disk the split meets. The other choice, saying where they were made, would still answer about another disk. The repositories are removed afterwards. |
| L1-94 | Every git command in the rehearsal, the attributes probe and `_stored_as_written` runs with `-c core.hooksPath=<an empty directory beside the repository>`, and their `git init` is given `--template=<that directory>`. A command-line `-c` outranks the config files and `GIT_CONFIG_COUNT`. The symlink probe's `git init` and `git config --get` run no hook. |
| L1-32 | When the symlink probe's `git init` fails without an `OSError` (a `CommandFailed` or a `Refusal`), the probe takes git to check symlinks out as symlinks, as on a disk that holds them, and prints one NOTE with git's words. It used to print "a git or gh command failed". |
| new | `adopt-git-unasked`: where git cannot make or ask a throwaway repository in the work directory, because a `git init` or a step of the rehearsal fails there, there is one finding that gives git's reason, and `execute` refuses it before any leg. Each throwaway `git init` names its branch (`-b`), as the split's own `git init`s do, so an `init.defaultBranch` git will not take (`a..b`) stops none of the questions asked in its throwaway repositories. Where no directory can be made there at all, `adopt-work-dir-unwritable` (#524) stays the only finding, and a NOTE says what went unasked. |

## Measured

`check` against a fresh source, with a stub `gh`, first on `c7a3ff16`
(before) and then on this branch (after):

| Case | Before | After |
| --- | --- | --- |
| SHA-256 source | exit 2, `REFUSED git-failed` at the rehearsal's `read-tree` | exit 0, `plan ok` |
| `GIT_DEFAULT_HASH=sha256`, SHA-1 source | exit 2, `REFUSED git-failed` (`Not a valid object name`) | exit 0, `plan ok` |
| `..` in the source commit | exit 2, `REFUSED git-failed: ... error: invalid path '..'` | exit 1, `FINDING plan-path-git-refuses` |
| `.Git/x` in the source commit | exit 2, `REFUSED git-failed: ... error: invalid path` | exit 1, `FINDING plan-path-git-refuses` |
| counting hook named by `core.hooksPath` in `GIT_CONFIG_GLOBAL` | 12 runs (attributes probe 2, rehearsal 10) | 0 runs (control: a plain `git add` runs it once) |
| where the repositories are made | TMPDIR (attributes probe x2, rehearsal x1) | the work directory, which is empty again afterwards |
| `GIT_DEFAULT_HASH=bogus`, `.github -> ../outside-ci` | exit 2, "a git or gh command failed ... unknown hash algorithm 'bogus'" | exit 1, `FINDING plan-root-symlink` and the probe's NOTE |
| `init.defaultBranch = a..b` | exit 2, `REFUSED git-failed` | exit 0, `plan ok`: every question is asked (on git 2.43, `execute` then stops after both legs; see the follow-ups) |
| a work directory nobody can write in | `adopt-work-dir-unwritable`, with the rehearsal asked in TMPDIR | `adopt-work-dir-unwritable` and one NOTE |

Why `plan-path-git-refuses` refuses before either leg: with the rehearsal
patched out, as before #654, `execute` on a source holding `..` created both
legs and then stopped with `REFUSED` while extracting the spec leg (its clone
exited 128, `invalid path`). On this branch `execute` refuses
`plan-path-git-refuses` before any leg exists
(`test_execute_refuses_a_path_git_refuses_before_any_leg`).

Why `init.defaultBranch = a..b` stops none of the questions asked in its
throwaway repositories: the split's clone of the source does not stop at it
on git 2.55, which CI runs. An earlier head of this PR read it at every
throwaway `git init`, and so refused, as `adopt-git-unasked`, a split that
clones there (CI run 39404718064). On git 2.43 `git clone` does stop at it;
see the follow-ups.

## Tests

- `tests/test_adopt_check_throwaway_repos.py`, new: 29 rows, covering each
  item and its controls. On `c7a3ff16`'s `adopt-project.py`, 24 fail and 5
  pass, and the 5 that pass are all controls (measured by the review,
  r1031).
- The rows that need a throwaway `git init` to fail use a stub `git` first on
  PATH, which refuses only a `git init` given a `--template`. Which setting
  stops a `git init` depends on git's version, so these rows do not rely on
  one. They are skipped on Windows.
- The head is 74c1770. `git merge-tree --write-tree 74c1770 origin/main`
  is clean against main 5e539a8.
- CI's full suite on 74c1770 (Linux, git 2.55.0): 19,000 passed, 372
  skipped.
- Gates in trial merges of 74c1770 with main, run on named files only, with
  one basetemp, a stub `gh`, and at most 100 tests per pytest call. The list
  is 125 test files: every one that loads `adopt-project.py` or names a
  function or NOTE this PR touches. A usage checkpoint stopped each run part
  of the way through the list:
  - a2d8547e (main eee089c): 33 files and part of a 34th, 2,100 passed, 2
    skipped, 0 failed;
  - 559acb12 (main 00f6d7f): 78 files and part of a 79th, 5,493 passed, 9
    skipped, 0 failed;
  - fca6ad84 (main 6c86163): 47 files and part of a 48th, 3,362 passed, 3
    skipped, 0 failed.
  The last 14 files of the list, from `test_scaffold_manifest_values.py` on
  (`test_shape_doctor.py` among them), ran in none of these merges. CI's
  full suite ran them on 74c1770.
- The review, r1031, ran its own gate on its trial merge 3559f637 (main
  6c86163): 1,302 passed, 0 failed.
- The landing gate adds its own counts over the main it lands on.

## Not in this PR (follow-ups, filed as #1083)

- **This PR's trade on git 2.43.** On a git whose `git clone` reads
  `init.defaultBranch` (2.43 here), an invalid one now passes `check`, and
  `execute` stops after both legs exist. Before, `check` refused it before
  any leg. On git 2.55, which CI runs, the split's clone of the source does
  not stop at it. Asking the split's own clone (`git clone --no-checkout
  --shared` in the work directory) would answer on every version; that
  follow-up is filed as #1083. Measured by the review, r1031, on git 2.43
  with `[init] defaultBranch = a..b` in the global config:

  | Head | `check --work-dir` | `execute --local-remote-dir` |
  | --- | --- | --- |
  | c7a3ff1 | exit 2, `REFUSED git-failed` | exit 2, `REFUSED git-failed`; no leg exists |
  | b83d9c0 | exit 1, `FINDING adopt-git-unasked` | exit 2, `REFUSED adopt-git-unasked`; no leg exists |
  | 74c1770 | exit 0, `plan ok` | exit 2: `REFUSED extracting the spec leg` (its clone exits 128, `fatal: invalid branch name: init.defaultBranch = a..b`), after `Northwind-code.git` and `Northwind-spec.git` exist; nothing is rolled back |

- Also filed as #1083: `_resolved` and `_answers` still make their repositories in TMPDIR, run this machine's hooks, and use a plain `git init`, so for a source with a submodule `init.defaultBranch = a..b` or `GIT_DEFAULT_HASH=bogus` still stops `check` with `a git or gh command failed`, exit 2; the docstring of `tests/test_adopt_reencoded_files.py::test_a_link_the_checkout_writes_is_never_written_through` still says TMPDIR; and `update-index --index-info` with `core.ignorecase` case twins is untested on a case-insensitive disk.

It lands by fast refresh (Brett Heap, 2026-10-15T22:48:35Z). Its reviewed head `74c17705` is green on Linux and SonarCloud. The branch merged main once and was never rebased; the last merge is update-branch's, clean, giving 32bc7ed, which is green on SonarCloud and Git Bash. Lane 1's gate passed on its tree: the 28 test files that run `check`'s throwaway repositories, the split, its refusals and the source scanners, and the doctor's, 2,763 passed and 3 skipped.

Closes #910

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

adopt-project.py: a nested .gitattributes setting eol or -text for a pinned copy outranks #471's root LF block, so Windows writes CRLF

1 participant