From a528006f019e94d3780fdb1f917d52e939e21947 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Mon, 12 Oct 2026 22:19:45 +0000 Subject: [PATCH 01/13] tests.yml: the Windows job installs git-filter-repo 2.47.0, so the 23 test files that skip without it run there and an adoption is executed on Windows for the first time, and the comments and docstrings that said that job installs none now say why it does (#471) v1.0.0 says that every AGENTS.md procedure, adopt included, works end to end on Linux, macOS and Windows. `tests-windows` left `git-filter-repo` out on purpose, as a job for the entry point and the materializer alone, so every test that executes an adoption skipped there and the Windows half of that sentence was a claim no runner had checked. - `tests-windows` installs `git-filter-repo` at the ubuntu job's pin, 2.47.0, on ONE line: PowerShell is that runner's default shell, and the ubuntu job's `\` continuation is bash's. pip puts `git-filter-repo.exe` in Python's `Scripts` directory, which setup-python puts on PATH; probe run 38312924968 found it there and `git filter-repo --version` answered. - The suite still runs whole, as before. - The comment that explained leaving it out now says why it is installed; the job header no longer names it as a reason for a second job, and the macOS job's step no longer says "unlike the windows job". - Seven test docstrings said the Windows job installs none, as the reason their filter-repo-free tests run there. They now say a machine that has none runs them, which stays true. `tests/test_shape_doctor.py`'s `no_adoption_offered` said the missing-tool case was "the Windows CI job, by design"; no CI runner lacks the tool now, and its assertion holds either way. The 23 files are the 21 that spell `shutil.which("git-filter-repo")`, `test_adopt_filter_repo_failure.py` (`shutil.which(FILTER_REPO_TOOL)`) and `test_adopt_git_reads.py`, which imports `needs_filter_repo`. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 --- .github/workflows/tests.yml | 23 +++++++++++++++-------- tests/test_adopt_clone_template.py | 2 +- tests/test_adopt_entry_paths.py | 4 ++-- tests/test_adopt_ignored_shape_files.py | 4 ++-- tests/test_adopt_mount_ignored.py | 4 ++-- tests/test_adopt_submodules.py | 2 +- tests/test_adopt_verify.py | 2 +- tests/test_shape_doctor.py | 11 ++++++----- 8 files changed, 30 insertions(+), 22 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8242e3d1..e5e47255 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -113,8 +113,8 @@ jobs: # THE SAME SUITE ON WINDOWS, because `setup-project.py` exists for a machine # nobody here runs. A SECOND JOB rather than a matrix on the first one: that - # job also installs `git-filter-repo` and downloads a linux-amd64 actionlint - # binary, and neither belongs on a Windows runner. + # job downloads a linux-amd64 actionlint binary, which belongs on no Windows + # runner. tests-windows: runs-on: windows-latest # SKIPPED on a push to main: a squash-interaction regression is not @@ -168,11 +168,18 @@ jobs: - name: install the test runner run: python -m pip install --disable-pip-version-check pytest==8.3.4 - # NO git-filter-repo, on purpose. It is what `adopt-project.py` extracts - # a leg's history with, and the adoption suites SKIP without it — which - # is the honest outcome here: what this job is for is the entry point and - # the materializer on Windows, not a second platform's worth of history - # rewriting. The ubuntu job installs it and runs those tests. + # git-filter-repo IS installed here now, at the ubuntu job's pin (#471). + # It is what `adopt-project.py` extracts a leg's history with, and the + # adoption suites SKIP without it. This job used to leave it out on + # purpose, as a job for the entry point and the materializer alone. But + # v1.0.0 says that adopt works end to end on Windows too, and while no + # Windows runner ever executed an adoption, that sentence was a claim: + # the same claim the macOS job's step below exists to stop making. + # + # ONE LINE: PowerShell is this runner's default shell, and the ubuntu + # job's `\` continuation is bash's, not PowerShell's. + - name: install git-filter-repo + run: python -m pip install --disable-pip-version-check git-filter-repo==2.47.0 # Per-invocation identity, never `git config --global`. PowerShell is the # default shell on this runner, so the append is `>> $env:GITHUB_ENV` @@ -232,7 +239,7 @@ jobs: - name: install the test runner run: python3 -m pip install --disable-pip-version-check pytest==8.3.4 - # git-filter-repo IS installed here, unlike the windows job. The + # git-filter-repo IS installed here, as on the other two runners. The # adoption suites SKIP without it, and `adopt-project.py` is run on a # Mac by whoever adopts a repository from one — a claim that stays a # claim while the only runner that extracts history is Linux. Same pin. diff --git a/tests/test_adopt_clone_template.py b/tests/test_adopt_clone_template.py index aa1c2716..02f373fe 100644 --- a/tests/test_adopt_clone_template.py +++ b/tests/test_adopt_clone_template.py @@ -41,7 +41,7 @@ swapped after the mount. Only the end-to-end tests need `git filter-repo`. The verification tests build -their legs by hand, so the Windows job, which installs none, runs them. Every +their legs by hand, so a machine that has none still runs them. Every repository and remote is local; no network is used, and the operator's configuration reaches git through `GIT_CONFIG_GLOBAL`, a file each test writes, so nothing here reads or writes the global configuration of whoever diff --git a/tests/test_adopt_entry_paths.py b/tests/test_adopt_entry_paths.py index 45298bef..292ecd01 100644 --- a/tests/test_adopt_entry_paths.py +++ b/tests/test_adopt_entry_paths.py @@ -17,8 +17,8 @@ `check` to refuse that answer. NO NETWORK AND NO GITHUB. `git filter-repo` is needed only by the end-to-end -test at the bottom, which skips without it as `test_adopt_e2e.py` does; the -Windows job installs none, so there it is the tests above it that run. They +test at the bottom, which skips without it as `test_adopt_e2e.py` does; on a +machine that has none, it is the tests above it that run. They load `adopt-project.py` through importlib and stub its probe for the tool, as `test_adopt_execute_refusals.py` does: every refusal under test is raised before the tool would be used. diff --git a/tests/test_adopt_ignored_shape_files.py b/tests/test_adopt_ignored_shape_files.py index 10c4381a..5dfcd1a8 100644 --- a/tests/test_adopt_ignored_shape_files.py +++ b/tests/test_adopt_ignored_shape_files.py @@ -16,8 +16,8 @@ put it, and the check finds a pinned file through the split tree's own links, so it agrees with the first bootstrap. All repositories and remotes are local fixtures; no network is used. The end-to-end tests need `git filter-repo`; the -others do not, so they run on the Windows job, which installs none -- except -the ones that make a symlink on the DISK, which skip there. +others do not, so they run on a machine that has none too. The ones that make +a symlink on the DISK skip on Windows. """ from __future__ import annotations diff --git a/tests/test_adopt_mount_ignored.py b/tests/test_adopt_mount_ignored.py index a9751ef3..9d1d93e1 100644 --- a/tests/test_adopt_mount_ignored.py +++ b/tests/test_adopt_mount_ignored.py @@ -31,8 +31,8 @@ what its remediation says of the run, and that its next step adopts. The mount tests call `_mount_the_legs` on hand-made repositories, so they need -no `git filter-repo` and run on the Windows job, which installs none -- except -the two that make a symlink on the DISK, which skip there. The end-to-end +no `git filter-repo` and run on a machine that has none too; the two that +make a symlink on the DISK skip on Windows. The end-to-end tests need `git filter-repo`. All repositories and remotes are local fixtures; no network is used. """ diff --git a/tests/test_adopt_submodules.py b/tests/test_adopt_submodules.py index 15c665cf..b8736170 100644 --- a/tests/test_adopt_submodules.py +++ b/tests/test_adopt_submodules.py @@ -28,7 +28,7 @@ #: What the end-to-end test needs and the mount tests below do not: they call #: `_mount_the_legs` on hand-made repositories, so they run on a machine with -#: no `git filter-repo` (the Windows CI job installs none). +#: no `git filter-repo`. needs_filter_repo = pytest.mark.skipif( shutil.which("git-filter-repo") is None, reason="git filter-repo is not installed: `pip install git-filter-repo`") diff --git a/tests/test_adopt_verify.py b/tests/test_adopt_verify.py index 65725e6e..8821ad1a 100644 --- a/tests/test_adopt_verify.py +++ b/tests/test_adopt_verify.py @@ -12,7 +12,7 @@ EVERY TEST BUT THE LAST TWO CALLS `_verify` DIRECTLY on hand-made commits, their gitlinks written with `git update-index --cacheinfo 160000,...`. No `git -filter-repo` is involved, so the Windows job, which installs none, runs all of +filter-repo` is involved, so a machine that has none still runs all of those. The last two run `execute`, so they need `git filter-repo` and are skipped without it, as every `execute` test is: one proves `cmd_execute` hands `_verify` the PLAN'S mount paths and that the assembly clones and bootstraps diff --git a/tests/test_shape_doctor.py b/tests/test_shape_doctor.py index fc1c74fc..7eac15a5 100644 --- a/tests/test_shape_doctor.py +++ b/tests/test_shape_doctor.py @@ -1542,11 +1542,12 @@ def no_adoption_offered(text: str = "", rows: dict | None = None, THE `machine` ROW IS THE ONE PLACE THE NAME MAY APPEAR, and it is UNCHANGED by #205 (`machine_row_unchanged`): on a workstation without - `git-filter-repo` -- the Windows CI job, by design -- it says that only - `adopt-project.py` needs that tool, in a COMPLIANT report exactly as - here. That is a sentence about the machine and never an offer to adopt - anything, so the text report is read around that row's one line, and the - row itself may name the tool only while the tool is missing. Everything + `git-filter-repo` -- never a CI runner since #471 installed it on all + three -- it says that only `adopt-project.py` needs that tool, in a + COMPLIANT report exactly as here. That is a sentence about the machine + and never an offer to adopt anything, so the text report is read around + that row's one line, and the row itself may name the tool only while the + tool is missing. Everything else -- the kind line, every reason, every NEXT, every `detail`, the verdict -- names it not at all, `plan` or no `plan`. """ From a8b506421bf38915601a79da8693653cc598fdf8 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Mon, 12 Oct 2026 22:52:10 +0000 Subject: [PATCH 02/13] tests/test_adopt_ignored_shape_files.py: the bootstrap of the source that keeps a `.gitattributes` of its own is a strict xfail on Windows, because the shape's `eol=lf` rule lands in `shape/` and a Windows checkout writes CRLF into every pinned copy at the root, until the fix that appends that rule to the source's own file lands (#471) Probe run 38312924968 ran the adoption suites on Windows for the first time: 1696 passed, 34 skipped, and this one case failed. The source tracks `* text=auto` at its root, so `execute` writes the shape's `* text=auto eol=lf` beside it at `shape/.gitattributes`, where it governs only `shape/`. The nine copies `contracts/shape-pin.yaml` pins at their own paths are then governed by the source's rule alone, and git's checkout on Windows (`core.eol=native`, or Git for Windows' default `core.autocrlf=true`) writes them with CRLF. `validate-pins.py` digests the bytes on disk (#51), so the first bootstrap reports shape-copy-drift on all nine. Each digest Windows reported is the sha256 of the CRLF form of the pinned LF bytes, and the same nine findings, byte for byte, come back on Linux under `core.eol=crlf` or `core.autocrlf=true`. Brett Heap ruled at 2026-10-12T22:48Z that the split appends a marked block of `/ text eol=lf` lines to the source's own root `.gitattributes`. That fix is a separate pull request on #471. Until it lands this case is a STRICT xfail on Windows only, applied to this parameter alone through `request.applymarker`: the other test of the same fixture parameter passes there, and a mark on the fixture's param would make it a strict XPASS. The fix turns the xfail red, so it removes the mark. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 --- tests/test_adopt_ignored_shape_files.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/tests/test_adopt_ignored_shape_files.py b/tests/test_adopt_ignored_shape_files.py index 5dfcd1a8..b02a89ae 100644 --- a/tests/test_adopt_ignored_shape_files.py +++ b/tests/test_adopt_ignored_shape_files.py @@ -534,9 +534,24 @@ def test_the_split_lands_the_files_the_pin_names(adopted): cwd=adopted.source).stdout == OWN_ATTRIBUTES +#: KNOWN ON WINDOWS, AND STRICT, for the source with attributes of its own +#: (#471): the shape's `* text=auto eol=lf` lands at `shape/.gitattributes`, +#: where it governs only `shape/`, so a Windows checkout writes CRLF into the +#: pinned copies at the root and the first bootstrap reports shape-copy-drift +#: on every one. Strict, so the fix that appends the shape's rule to the +#: source's own `.gitattributes` turns this red until the mark is removed. +WINDOWS_CRLF_COPIES = pytest.mark.xfail( + sys.platform == "win32", strict=True, + reason="#471: the source's own .gitattributes keeps the shape's eol=lf " + "rule off the root's pinned copies, which a Windows checkout " + "writes with CRLF, so bootstrap reports shape-copy-drift") + + @needs_filter_repo def test_the_assembly_bootstraps_where_it_failed_with_shape_copy_missing( - adopted, tmp_path): + adopted, tmp_path, request): + if adopted.own: + request.applymarker(WINDOWS_CRLF_COPIES) clone = tmp_path / PROJECT git(*FILE_PROTOCOL, "clone", "-q", "--recurse-submodules", "-b", BRANCH, str(adopted.source), str(clone), cwd=tmp_path) From 9c6a228c2e88239f3860ced45d721d00f9a0b22d Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Mon, 12 Oct 2026 23:09:57 +0000 Subject: [PATCH 03/13] adopt-project.py: a source that keeps its own root `.gitattributes` gets one marked block of `/ text eol=lf` lines appended to it in the split, so a Windows clone writes the pinned copies at the root with the LF bytes the pin records and the first bootstrap no longer reports shape-copy-drift on every one, the verification counts that file as the source's bytes followed by exactly that block, and `update-shape.py apply --add` extends it (#471) Adoption's first run on a Windows runner (probe run 38312924968) failed one ordinary case. The source tracked `* text=auto` in its own root `.gitattributes`, so `execute` wrote the shape's `* text=auto eol=lf` beside it at `shape/.gitattributes`. Git reads attributes for a path from the `.gitattributes` files of its own directory and those above it, so that copy governs `shape/` alone, and the nine copies the pin names at their own paths (`Makefile`, `AGENTS-shape.md`, five under `scripts/`, `contracts/repository-naming.yaml`, the workflow) answered to the source's rule: a Windows checkout wrote them with CRLF, and `validate-pins.py`, which digests the bytes on disk (#51), reported shape-copy-drift on each. Under Git for Windows' default `core.autocrlf=true` any root `.gitattributes` but the shape's does the same, `*.png binary` included. A root-level file takes attributes from the root `.gitattributes` alone, so no file beside the source's can fix it. Brett Heap ruled at 2026-10-12T22:48Z that the split appends a marked block to the source's own file, accepting that this overrides "never written over" for that one file, that the verification gets an exception for it, and that `update-shape --add` extends the block. - `scripts/shape_materialize.py` spells the block once: `EOL_BLOCK_HEAD`, a comment naming openRepoShape and #471, then `eol_line` for each pinned copy outside `shape/` (`eol_block_paths`), in the pin's order, anchored and literal; a path git would read as a pattern is refused (`materialize-attributes-path`). `with_eol_block` appends it after the source's bytes, adding only a final newline the file lacked. `materialize_assembly_root` appends it when the shape's `.gitattributes` collided, and records the copies in `Materialized.eol_block`. A `.gitattributes` that is a symlink is not written through. - The `.gitattributes` follow-up says what the block protects, that a Windows clone reports shape-copy-drift without it, that none of the source's lines changed, and that merging the rest of `shape/.gitattributes` is optional. - `adopt-project.py` prints the append, lists every line it added in the split commit's message under "APPENDED TO .gitattributes", and `_land_the_eol_block` counts the source's file as landed in the root when its blob there is exactly `with_eol_block(source bytes, copies outside shape/)`, recomputed from the split's own pin. Anything else, a source line changed or the block short of a copy or with one too many, is still `adopt-lost`. - `update-shape.py apply --add` names each added copy in the block, through the ledger so a refusal puts it back, and commits the file with the copies. A root with no block, or whose `.gitattributes` is a symlink, is left alone. The block is found in a CRLF checkout of the file too. - A source with no root `.gitattributes`, or one the plan sends to a leg, keeps today's behaviour: the shape's own file is the root's. Tests: `tests/test_adopt_attributes_eol_block.py` (new, no `git filter-repo`) holds the block's text, the materializer's append, the follow-up, the split message and the verification with six mutants of the file; `tests/test_adopt_ignored_shape_files.py` now asserts the source's bytes come first and the block after them, and clones the adopted assembly under `core.autocrlf=true` and under `core.eol=crlf`, with the source's own `README.md` checked out CRLF as the control, and bootstraps it; `tests/test_update_shape_add.py` extends, leaves alone and rolls back the block. The two `test_adopt_filter_repo_failure.py` tests whose stub `git-filter-repo` is a `#!` script lose `WINDOWS_SKIP`: git starts that stub, and Git for Windows runs a `#!` script with its own `sh`. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 70 ++++- scripts/shape_materialize.py | 167 +++++++++- tests/test_adopt_attributes_eol_block.py | 368 +++++++++++++++++++++++ tests/test_adopt_filter_repo_failure.py | 19 +- tests/test_adopt_ignored_shape_files.py | 82 ++++- tests/test_update_shape_add.py | 71 +++++ update-shape.py | 40 ++- 7 files changed, 798 insertions(+), 19 deletions(-) create mode 100644 tests/test_adopt_attributes_eol_block.py diff --git a/adopt-project.py b/adopt-project.py index ebf7e3f0..7d7a7d95 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -151,8 +151,9 @@ tree_digest, ) from shape_materialize import ( # noqa: E402 - ADOPT_MAKEFILE_BLOCK, SHAPE_REPOSITORY, + ADOPT_MAKEFILE_BLOCK, ATTRIBUTES, SHAPE_REPOSITORY, CommandFailed, Materialized, assembly_root_paths, collision_follow_up, + eol_block_paths, eol_line, with_eol_block, copy_tree, default_reference, election_date, env_commit, escape_what_stdout_cannot_encode, git_init_commit, materialize_assembly_root, naming_block, run, write_lf, @@ -6443,11 +6444,17 @@ def _commit_the_split(plan: Plan, source: Source, assembly: Path, for intended, actual in materialized.collisions: print(f" beside {actual} (the source already has {intended}; " "nothing was overwritten)") + if materialized.eol_block: + print(f" appended to {ATTRIBUTES}: a `text eol=lf` line for " + f"each of {len(materialized.eol_block)} pinned copies, so " + "every checkout keeps their bytes (#471); none of its own " + "lines was changed") follow_ups = _plan_follow_ups(plan) message = _split_message(names, paths_for, leg_commits, spec_path, code_path, follow_ups, - materialized.collisions, seeded) + materialized.collisions, seeded, + appended=materialized.eol_block) _stage_the_split(assembly, materialized) env_commit(assembly, message) split_commit = git_out(["rev-parse", "HEAD"], cwd=assembly).lower() @@ -6791,7 +6798,7 @@ def _template_table(plan: Plan, names, repositories, urls, spec_path, def _split_message(names, paths_for, leg_commits, spec_path, code_path, - follow_ups, collisions, seeded=()) -> str: + follow_ups, collisions, seeded=(), *, appended=()) -> str: def none_line(role: str) -> list[str]: if role not in seeded: return [" (none)"] @@ -6830,6 +6837,14 @@ def none_line(role: str) -> list[str]: "first):"] lines += [f" {actual} <- the shape's {intended}" for intended, actual in collisions] + if appended: + # The one source file the split writes into (#471): said with every + # line it added, so nobody has to diff the file to see what changed. + lines += ["", f"APPENDED TO {ATTRIBUTES}, after this repository's own " + "lines, which are unchanged (each pinned copy outside " + f"{COLLISION_DIR}/ checks out with LF, as the pin records " + "it):"] + lines += [f" {eol_line(path)}" for path in appended] if follow_ups: lines += ["", "FOLLOW-UPS, which this commit does NOT do:"] lines += [f" - {item}" for item in follow_ups] @@ -6935,6 +6950,7 @@ def _accounted_for(source: Source, assembly: Path, work_root: Path, names, root_findings = _land_the_root_tree(after, split_tree, before, mounts) root_findings += _mount_commit_findings( split_tree, mounts, _pushed_commits(work_root, names)) + block = _land_the_eol_block(assembly, split_tree, before, after) counts, findings = _account_for(before, after, paths_for["drop"]) findings += _shape_file_findings(assembly, split_commit) @@ -6945,9 +6961,57 @@ def _accounted_for(source: Source, assembly: Path, work_root: Path, names, f"paths{note}") print(f" added {len(added):>5} new paths (manifest, pins, shape files" + (", seeded leg" if seeded else "") + ")") + if block: + print(f" root {ATTRIBUTES} is the source's own bytes, then the " + f"shape's block of {block} `text eol=lf` lines (#471)") return findings + root_findings +def _land_the_eol_block(assembly: Path, tree: list, before: dict, + after: dict) -> int: + """Count the source's `.gitattributes` as landed in the root when the + root holds its bytes followed by exactly the shape's block; return how + many lines that block has, or 0 when there is none to count. + + THE ONE FILE THE SPLIT WRITES INTO (#471, ruled by Brett Heap at + 2026-10-12T22:48Z), so the one exception to "by blob sha": its blob in the + root is not the source's, and without this it was `adopt-lost`. It is + allowed exactly one shape, recomputed here rather than trusted from the + run that wrote it: the source's bytes, untouched, then + `shape_materialize.with_eol_block` over the pinned copies outside + `shape/`, read out of the split's own pin. Anything else -- a source line + changed, the block short of a copy or holding one too many -- leaves the + source's blob in no place, and `_account_for` reports it as before. + + The pin is the shape's: under `shape/` when the source kept its own + `contracts/shape-pin.yaml`, which nothing else can put there + (`plan-root-blocks-shape-copy`). An unreadable one counts nothing, and + `_shape_file_findings` reports it. + """ + source_oid = before.get(ATTRIBUTES) + landed = after.get(ATTRIBUTES, []) + root = [where for where in landed if where.startswith("root:")] + if source_oid is None or len(root) != 1 \ + or root[0] == f"root:{source_oid}": + return 0 + blobs = {path: oid for path, mode, oid, _ in tree if mode in FILE_MODES} + pin = blobs.get(f"{COLLISION_DIR}/{SHAPE_PIN}") or blobs.get(SHAPE_PIN) + pinned = _pinned_shape_paths(assembly, pin) if pin else None + if pinned is None or blobs.get(ATTRIBUTES) != root[0].split(":", 1)[1]: + return 0 + paths = eol_block_paths(pinned, COLLISION_DIR) + expected = with_eol_block(_blob_bytes(assembly, source_oid), paths) + if _blob_bytes(assembly, blobs[ATTRIBUTES]) != expected: + return 0 + after[ATTRIBUTES] = [f"root:{source_oid}" if where == root[0] else where + for where in landed] + return len(paths) + + +def _blob_bytes(repo: Path, oid: str) -> bytes: + return git_out([GIT_CAT_FILE, "blob", oid], cwd=repo, binary=True) + + def _left_by_the_split(source: Source, urls: dict, names: dict, work_root: Path, branch: str, *, interrupted: bool = False) -> str: diff --git a/scripts/shape_materialize.py b/scripts/shape_materialize.py index e138c253..c927ff89 100644 --- a/scripts/shape_materialize.py +++ b/scripts/shape_materialize.py @@ -25,6 +25,11 @@ `follow_ups:`. The human merges it; the tool never guesses. A copy whose own path is taken -- the source keeps a `shape/README.md` of its own -- is REFUSED, never written over (`copy_in_the_way`, #262). + +ONE EXCEPTION, `.gitattributes` (#471, ruled by Brett Heap 2026-10-12T22:48Z): +the shape's copy still goes beside, and one marked block of line-ending rules +for the pinned copies is APPENDED to the source's own file, because no file +beside it can reach them. "The shape's line endings" below says why and how. """ from __future__ import annotations @@ -661,6 +666,139 @@ def readme_trailing_shape_line(text: str) -> "re.Match | None": SHAPE_MAKE_TARGETS = ("bootstrap", "validate", "pins", "park", "resume") +# --------------------------------------------------------------------------- +# The shape's line endings, in a source's OWN `.gitattributes` (#471) +# --------------------------------------------------------------------------- +# +# THE ONE FILE OF THE SOURCE'S THAT AN ADOPTION WRITES INTO, by Brett Heap's +# ruling of 2026-10-12T22:48Z. Every other collision is written BESIDE, under +# `shape/`, and left to a human to merge. `.gitattributes` cannot be: git +# reads attributes for a path from the `.gitattributes` files of its own +# directory and those above it, so `shape/.gitattributes` governs `shape/` +# alone, and a pinned copy at the root -- `Makefile`, `AGENTS-shape.md`, +# `scripts/bootstrap.py` -- answers to the source's file and nothing else. A +# source with `* text=auto` there, or with any `.gitattributes` at all under +# Git for Windows' default `core.autocrlf=true`, then checked every such copy +# out with CRLF on Windows, and the first `make bootstrap` reported +# shape-copy-drift on each: `validate-pins.py` digests the bytes on disk +# (#51). Adoption's first run on a Windows runner found it. +# +# So the split APPENDS one marked block to the source's file: a comment line, +# then `/ text eol=lf` for each pinned copy outside `shape/`. Later lines +# win in a `.gitattributes`, and each line names one file, so the block +# settles those copies' line endings and nothing else of the source's. None of +# the source's own lines is changed; a file that does not end in a newline +# gets one before the block, which is the only byte written that is not the +# block's. `adopt-project.py`'s verification counts the file as the source's +# bytes followed by exactly this block, and `update-shape.py apply --add` +# extends it when the standard adds a copy at the root later. + +#: The `.gitattributes` a source may already hold at its root. +ATTRIBUTES = ".gitattributes" + +#: The comment line that opens the block, and that `extend_eol_block` finds it +#: by. Spelled once: three tools read it. +EOL_BLOCK_HEAD = ("# openRepoShape (#471): the pinned shape copies below check " + "out with LF everywhere, as contracts/shape-pin.yaml " + "records their bytes") + +#: A path the block can name as a literal, anchored pattern. Every copy the +#: shape pins is spelled this way; a name with a space, a glob character or a +#: backslash would be read by git as something else, and is refused rather +#: than written. +EOL_PATH_RE = re.compile(r"[A-Za-z0-9._-]+(?:/[A-Za-z0-9._-]+)*") + + +def eol_line(path: str) -> str: + """The block's line for the pinned copy at `path`.""" + if not EOL_PATH_RE.fullmatch(path): + raise Refusal( + "materialize-attributes-path", + f"{path!r} cannot be named as a literal path in a " + f"`{ATTRIBUTES}` line", + f"Remediation: this is a defect in {SHAPE_REPOSITORY}: every " + "pinned copy has a plain name. Report it there.") + return f"/{path} text eol=lf" + + +def eol_block_paths(shape_files, collision_dir: str) -> list[str]: + """The pinned copies a source's own root `.gitattributes` governs: each + one not under `collision_dir`, where the shape's own copy of the file + governs, in the pin's order.""" + return [path for path in shape_files + if not path.startswith(f"{collision_dir}/")] + + +def eol_block(paths) -> str: + """The block for `paths`: its head, then one line per copy.""" + return "".join(f"{line}\n" + for line in (EOL_BLOCK_HEAD, *map(eol_line, paths))) + + +def with_eol_block(existing: bytes, paths) -> bytes: + """`existing`, a source's `.gitattributes`, with the block after it. + + Its bytes are kept as they are. One that does not end in a newline gets + one first, or the head would be read as part of its last line.""" + gap = b"\n" if existing and not existing.endswith(b"\n") else b"" + return existing + gap + eol_block(paths).encode("utf-8") + + +def extend_eol_block(existing: bytes, paths) -> bytes | None: + """`existing` with a line for each of `paths` the block lacks, put after + its last line; None when `existing` holds no block. + + The block is the head line and the `/ text eol=lf` lines straight + after it. Lines a human wrote after the block stay after the new ones. + + A WINDOWS CHECKOUT OF THE FILE MAY BE CRLF: the source's own rules + govern `.gitattributes` too, and `* text=auto` there, or Git for + Windows' `core.autocrlf=true`, writes it with CRLF. So lines are matched + without their ending, and a new line takes the ending the block's last + line has; git normalises the file when it is committed either way.""" + lines = existing.splitlines(keepends=True) + bare = [line.rstrip(b"\r\n") for line in lines] + try: + start = bare.index(EOL_BLOCK_HEAD.encode("utf-8")) + except ValueError: + return None + end, named = start + 1, set() + while end < len(lines): + match = re.fullmatch(rb"/(\S+) text eol=lf", bare[end]) + if not match: + break + named.add(match.group(1).decode("utf-8", "surrogateescape")) + end += 1 + newline = b"\r\n" if lines[end - 1].endswith(b"\r\n") else b"\n" + if not lines[end - 1].endswith(b"\n"): + lines[end - 1] += newline + new = [] + for path in paths: + if path not in named: + named.add(path) + new.append(eol_line(path).encode("utf-8") + newline) + return b"".join(lines[:end] + new + lines[end:]) + + +def _append_eol_block(target: Path, result: "Materialized", + collision_dir: str) -> None: + """Append the block to the source's own `.gitattributes`, when the + shape's copy of that file had to be written beside it. + + A SYMLINK IS NOT WRITTEN THROUGH: what it leads to may be another file of + the source's, or nothing in this repository, and git reads no + `.gitattributes` through a link anyway. It is left as it is, and the + follow-up names the lines to add where it leads.""" + if (ATTRIBUTES, f"{collision_dir}/{ATTRIBUTES}") not in result.collisions: + return + path = target / ATTRIBUTES + if path.is_symlink() or not path.is_file(): + return + paths = eol_block_paths(result.shape_files, collision_dir) + path.write_bytes(with_eol_block(path.read_bytes(), paths)) + result.eol_block = paths + + def collision_follow_up(intended: str, actual: str, beside: dict | None = None) -> str: """The follow-up for ONE collision, in one place. @@ -680,6 +818,21 @@ def collision_follow_up(intended: str, actual: str, runs the source's script and exits 0, so the follow-up names no command, and says both merges come first (#493's review, F2). """ + if intended == ATTRIBUTES: + return ( + f"keep the block at the end of {intended} that opens with " + f"`{EOL_BLOCK_HEAD}`: the source repository already has a " + f"{intended}, so the shape's copy was written beside it as " + f"{actual}, which governs only its own directory, and the split " + f"appends one `text eol=lf` line to {intended} for each pinned " + "copy outside it. That block is what makes every checkout, " + "Windows included, write those copies with the bytes " + "contracts/shape-pin.yaml records; without it a Windows clone " + "reports shape-copy-drift on each one. None of the source's own " + f"lines is changed. Merge the rest of {actual} into {intended} " + "only if the whole project should check out with LF. A " + f"{intended} that is a symlink is not written through: add the " + "block's lines where it leads") if intended in ASSISTANT_INSTRUCTIONS: return ( f"add the line `{SHAPE_POINTER_LINE}` to the existing {intended} " @@ -1922,12 +2075,18 @@ class Materialized: `shape_files` is the ordered list of paths the shape pin digests, at the paths they ACTUALLY landed on — a pin that names a path nothing is at is a refusal in `validate-pins.py`, and rightly. + + `eol_block` is the copies named in the block appended to the source's own + `.gitattributes` (#471), and empty when none was: no source file of that + name, one the plan did not keep in the root, or one that is a symlink. + That file is the source's, so it is in neither list above. """ def __init__(self) -> None: self.written: list[str] = [] self.collisions: list[tuple[str, str]] = [] self.shape_files: list[str] = [] + self.eol_block: list[str] = [] def follow_ups(self) -> list[str]: beside = dict(self.collisions) @@ -1952,12 +2111,18 @@ def materialize_assembly_root(shape_root: Path, target: Path, `neutral_pins` maps a neutral product name to the values for its pin file, each rendered from `contracts/neutral-product-pin.yaml`. + + With a `collision_dir`, a `.gitattributes` the target already held gets + the shape's line-ending block appended (#471, `_append_eol_block`). """ - return _materialize( + result = _materialize( shape_root, shape_root / "templates" / "assembly-root", target, values, templated=TEMPLATED, verbatim=COPIED_VERBATIM, from_shape=COPIED_FROM_SHAPE, executable=EXECUTABLE, collision_dir=collision_dir, append=append, neutral_pins=neutral_pins) + if collision_dir is not None: + _append_eol_block(target, result, collision_dir) + return result def assembly_root_paths(neutral_pins=()) -> tuple[str, ...]: diff --git a/tests/test_adopt_attributes_eol_block.py b/tests/test_adopt_attributes_eol_block.py new file mode 100644 index 00000000..e2aa7737 --- /dev/null +++ b/tests/test_adopt_attributes_eol_block.py @@ -0,0 +1,368 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The shape's line endings, appended to a source's own `.gitattributes` (#471). + +Adoption's first run on a Windows runner failed one ordinary case: a source +that tracks its own root `.gitattributes`. The shape's copy, `* text=auto +eol=lf`, is written beside it under `shape/`, and git reads attributes for a +path only from the `.gitattributes` files of its own directory and those above +it, so the copy governs `shape/` alone. Every pinned copy at the root answered +to the source's file, a checkout that writes CRLF (Git for Windows' default) +wrote them with CRLF, and `validate-pins.py`, which digests the bytes on disk, +reported shape-copy-drift on each. + +Brett Heap ruled at 2026-10-12T22:48Z that the split APPENDS one marked block +to the source's own file: a comment line, then `/ text eol=lf` for each +pinned copy outside `shape/`. This module holds the parts of that to their +contract on hand-made directories and repositories: the block's text, the +materializer's append, the follow-up's words, the split commit's message, and +the verification that counts the file as the source's bytes followed by +exactly that block and nothing else. It needs no `git filter-repo` and no +network, so every CI job runs it. `tests/test_adopt_ignored_shape_files.py` +adopts such a source end to end and clones it with a checkout that writes +CRLF; `tests/test_update_shape_add.py` extends the block with `--add`. +""" + +from __future__ import annotations + +import importlib.util +import os +import sys +from pathlib import Path + +import pytest + +from conftest import ADOPT, REPO, git + +sys.path.insert(0, str(REPO / "scripts")) +from repo_shape import Refusal # noqa: E402 +from shape_materialize import ( # noqa: E402 + ATTRIBUTES, EOL_BLOCK_HEAD, PLACEHOLDER_RE, collision_follow_up, + eol_block, eol_block_paths, eol_line, extend_eol_block, + materialize_assembly_root, with_eol_block, +) + +UTF8 = "utf-8" +COLLISION_DIR = "shape" +#: A source's own attributes, as a repository commonly has them. +OWN = b"* text=auto\n*.png binary\n" +#: Three pinned copies at the root, in a pin's order. +COPIES = ["scripts/validate-pins.py", "Makefile", + ".github/workflows/validate.yml"] +#: The block's lines for `COPIES`, spelled out rather than computed. +COPIES_BLOCK = (f"{EOL_BLOCK_HEAD}\n" + "/scripts/validate-pins.py text eol=lf\n" + "/Makefile text eol=lf\n" + "/.github/workflows/validate.yml text eol=lf\n").encode(UTF8) +SHAPE_PIN = "contracts/shape-pin.yaml" +TEMPLATE = REPO / "templates" / "assembly-root" + + +@pytest.fixture(scope="module") +def adopter(): + spec = importlib.util.spec_from_file_location("adopt_attributes_eol_block", + ADOPT) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +# --------------------------------------------------------------------------- +# The block's text +# --------------------------------------------------------------------------- + + +def test_the_block_is_its_head_and_one_anchored_line_per_copy(): + """A comment naming openRepoShape and #471, then each copy as a literal + path anchored at the root, in the order given.""" + assert "openRepoShape" in EOL_BLOCK_HEAD and "#471" in EOL_BLOCK_HEAD + assert EOL_BLOCK_HEAD.startswith("# ") + assert eol_block(COPIES).encode(UTF8) == COPIES_BLOCK + + +def test_the_sources_bytes_come_first_and_are_kept(): + appended = with_eol_block(OWN, COPIES) + assert appended == OWN + COPIES_BLOCK + + +@pytest.mark.parametrize(("existing", "expected"), [ + (b"* text=auto", b"* text=auto\n" + COPIES_BLOCK), + (b"", COPIES_BLOCK), + (b"* text=auto\r\n", b"* text=auto\r\n" + COPIES_BLOCK), +], ids=["no-final-newline", "empty", "crlf-lines-kept"]) +def test_a_final_newline_is_the_only_byte_added_outside_the_block(existing, + expected): + """A file that does not end in a newline would have the head read as part + of its last line, so it gets one; an empty one gets nothing; a CRLF one + keeps its own lines exactly.""" + assert with_eol_block(existing, COPIES) == expected + + +def test_copies_under_shape_are_left_to_the_shapes_own_file(): + """`shape/.gitattributes` governs `shape/`, so the block names only what + is outside it, keeping the pin's order.""" + pinned = ["shape/.gitattributes", "scripts/bootstrap.py", "shape/Makefile", + "Makefile", "shape-notes/x.md"] + assert eol_block_paths(pinned, COLLISION_DIR) == [ + "scripts/bootstrap.py", "Makefile", "shape-notes/x.md"] + + +@pytest.mark.parametrize("path", ["notes and more.md", "scripts/*.py", + "docs/[a].md", "dir\\file", "", "/Makefile", + "a//b", "!x"]) +def test_a_path_git_would_read_as_a_pattern_is_refused(path): + """Each line must name ONE file: a space ends a pattern, a glob character + matches others, a backslash escapes. No pinned copy is spelled so, so one + that is would be the standard's defect, and is refused rather than + written.""" + with pytest.raises(Refusal) as caught: + eol_line(path) + assert caught.value.code == "materialize-attributes-path" + + +# --------------------------------------------------------------------------- +# Extending it (`update-shape.py apply --add`) +# --------------------------------------------------------------------------- + + +def test_a_new_copy_joins_the_block_before_the_lines_after_it(): + """The new line goes after the block's last line, so a line a human + wrote after the block still comes last; a copy already named is not + named twice.""" + after = b"*.jpg binary\n" + extended = extend_eol_block(OWN + COPIES_BLOCK + after, + ["AGENTS-x.md", "Makefile"]) + assert extended == (OWN + COPIES_BLOCK + b"/AGENTS-x.md text eol=lf\n" + + after) + + +def test_extending_with_copies_already_named_changes_nothing(): + data = OWN + COPIES_BLOCK + assert extend_eol_block(data, COPIES) == data + + +def test_a_file_with_no_block_is_not_extended(): + """A scaffolded root's `.gitattributes` is the shape's own, and one + adopted before #471 has no block: neither is written.""" + assert extend_eol_block(OWN, ["AGENTS-x.md"]) is None + assert extend_eol_block(b"", ["AGENTS-x.md"]) is None + + +def test_a_block_that_ends_the_file_with_no_newline_is_extended_cleanly(): + data = OWN + COPIES_BLOCK.rstrip(b"\n") + assert extend_eol_block(data, ["AGENTS-x.md", "AGENTS-x.md"]) == ( + OWN + COPIES_BLOCK + b"/AGENTS-x.md text eol=lf\n") + + +def test_a_crlf_checkout_of_the_file_is_extended_in_its_own_endings(): + """On Windows the source's rules govern `.gitattributes` itself, so a + checkout can hold it with CRLF: the block is still found, and the new + line ends as the block's lines do.""" + crlf = (OWN + COPIES_BLOCK).replace(b"\n", b"\r\n") + assert extend_eol_block(crlf, ["AGENTS-x.md"]) == ( + crlf + b"/AGENTS-x.md text eol=lf\r\n") + + +# --------------------------------------------------------------------------- +# The materializer appends it, where and only where the shape's copy collided +# --------------------------------------------------------------------------- + + +def template_values() -> dict: + return {name[2:-2]: "x" for path in TEMPLATE.rglob("*") if path.is_file() + for name in PLACEHOLDER_RE.findall(path.read_text(UTF8))} + + +def target_holding(tmp_path: Path, files: dict) -> Path: + target = tmp_path / "assembly" + for name, body in files.items(): + (target / name).parent.mkdir(parents=True, exist_ok=True) + (target / name).write_bytes(body) + return target + + +def test_a_source_attributes_file_gets_the_block_for_the_copies_outside_shape( + tmp_path): + target = target_holding(tmp_path, {ATTRIBUTES: OWN, + "README.md": b"# Thing\n"}) + result = materialize_assembly_root(REPO, target, template_values(), + collision_dir=COLLISION_DIR) + assert (ATTRIBUTES, f"{COLLISION_DIR}/{ATTRIBUTES}") in result.collisions + outside = eol_block_paths(result.shape_files, COLLISION_DIR) + assert result.eol_block == outside + assert {"Makefile", "AGENTS-shape.md", "scripts/bootstrap.py", + ".github/workflows/validate.yml"} <= set(outside) + assert f"{COLLISION_DIR}/{ATTRIBUTES}" in result.shape_files + assert (target / ATTRIBUTES).read_bytes() == with_eol_block(OWN, outside) + # The shape's own copy is still the template's, beside the source's. + assert (target / COLLISION_DIR / ATTRIBUTES).read_bytes() == \ + (TEMPLATE / ATTRIBUTES).read_bytes() + # The source's file is in neither list of what the shape wrote. + assert ATTRIBUTES not in result.written + assert ATTRIBUTES not in result.shape_files + + +def test_with_no_attributes_of_its_own_nothing_is_appended(tmp_path): + """Today's behaviour, unchanged: the shape's own file is the root's.""" + target = target_holding(tmp_path, {"README.md": b"# Thing\n"}) + result = materialize_assembly_root(REPO, target, template_values(), + collision_dir=COLLISION_DIR) + assert result.eol_block == [] + assert (target / ATTRIBUTES).read_bytes() == \ + (TEMPLATE / ATTRIBUTES).read_bytes() + + +def test_an_attributes_symlink_is_not_written_through(tmp_path): + """What a link leads to may be another of the source's files, and git + reads no `.gitattributes` through a link anyway: it is left alone.""" + target = target_holding(tmp_path, {"config/attributes": OWN}) + try: + os.symlink("config/attributes", target / ATTRIBUTES) + except (NotImplementedError, OSError) as exc: + pytest.skip(f"this platform cannot create a symlink here: {exc}") + result = materialize_assembly_root(REPO, target, template_values(), + collision_dir=COLLISION_DIR) + assert (ATTRIBUTES, f"{COLLISION_DIR}/{ATTRIBUTES}") in result.collisions + assert result.eol_block == [] + assert (target / "config" / "attributes").read_bytes() == OWN + + +# --------------------------------------------------------------------------- +# What the human reads: the follow-up and the split commit's message +# --------------------------------------------------------------------------- + + +def test_the_follow_up_says_what_the_block_protects(): + said = collision_follow_up(ATTRIBUTES, f"{COLLISION_DIR}/{ATTRIBUTES}") + assert not said.startswith("merge "), said + assert f"`{EOL_BLOCK_HEAD}`" in said + assert "shape-copy-drift" in said and "Windows" in said + assert "None of the source's own lines is changed" in said + assert "symlink" in said + # Every other collision keeps its own wording. + assert collision_follow_up("Makefile", "shape/Makefile").startswith( + "merge shape/Makefile into Makefile") + + +def split_message(adopter, appended=()) -> str: + return adopter._split_message( + {"assembly": "Northwind", "spec": "Northwind-spec", + "code": "Northwind-code"}, + {"spec": ["specs/"], "code": ["src/"], "drop": [], "root": [ATTRIBUTES]}, + {"spec": "a" * 40, "code": "b" * 40}, "spec", "code", [], + [(ATTRIBUTES, f"{COLLISION_DIR}/{ATTRIBUTES}")], appended=appended) + + +def test_the_split_message_lists_every_line_it_appended(adopter): + message = split_message(adopter, COPIES) + assert f"APPENDED TO {ATTRIBUTES}, after this repository's own lines, " \ + "which are unchanged" in message + for path in COPIES: + assert f"\n {eol_line(path)}\n" in message, path + assert "APPENDED TO" not in split_message(adopter) + + +# --------------------------------------------------------------------------- +# The verification counts the source's file, and only in that one shape +# --------------------------------------------------------------------------- + + +def pin_of(paths) -> bytes: + rows = "".join(f" - path: {path}\n sha256: \"{'0' * 64}\"\n" + for path in paths) + return (f"schema_version: 1\nkind: pinned_contract_manifest\nfiles:\n" + f"{rows}").encode(UTF8) + + +#: The split's pin: the shape's `.gitattributes` beside the source's, and two +#: copies at the root. +PINNED = [f"{COLLISION_DIR}/{ATTRIBUTES}", "Makefile", + "scripts/validate-pins.py"] +ROOT_COPIES = ["Makefile", "scripts/validate-pins.py"] + + +def commit_files(repo: Path, files: dict, message: str) -> str: + for name, body in files.items(): + (repo / name).parent.mkdir(parents=True, exist_ok=True) + (repo / name).write_bytes(body) + git("add", "-A", "--", ".", cwd=repo) + git("-c", "user.name=T", "-c", "user.email=t@invalid.example", "commit", + "-qm", message, cwd=repo) + return git("rev-parse", "HEAD", cwd=repo).stdout.strip() + + +def accounted(adopter, tmp_path: Path, attributes: bytes, + pin_path: str = SHAPE_PIN) -> tuple: + """A source commit holding `OWN`, and a split commit over it whose root + `.gitattributes` is `attributes`: `(lines counted, findings, counts, + the source's oid, after)` from the verification's own steps.""" + repo = tmp_path / "repo" + repo.mkdir() + git("init", "-q", "-b", "main", ".", cwd=repo) + source = commit_files(repo, {ATTRIBUTES: OWN, "README.md": b"# Thing\n"}, + "The source") + split = commit_files(repo, { + ATTRIBUTES: attributes, pin_path: pin_of(PINNED), + f"{COLLISION_DIR}/{ATTRIBUTES}": (TEMPLATE / ATTRIBUTES).read_bytes(), + "Makefile": b"all:\n", "scripts/validate-pins.py": b"print()\n"}, + "The split") + before = {path: oid for path, _, oid, _ in adopter._tree_of(repo, source)} + tree = adopter._tree_of(repo, split) + after: dict = {} + assert adopter._land_the_root_tree(after, tree, before, {}) == [] + lines = adopter._land_the_eol_block(repo, tree, before, after) + counts, findings = adopter._account_for(before, after, []) + return lines, findings, counts, before[ATTRIBUTES], after + + +def test_the_sources_bytes_then_the_block_is_the_source_landed_in_the_root( + adopter, tmp_path): + lines, findings, counts, oid, after = accounted( + adopter, tmp_path, with_eol_block(OWN, ROOT_COPIES)) + assert lines == 2 + assert after[ATTRIBUTES] == [f"root:{oid}"] + assert findings == [] + assert counts["root"] == 2 + + +def test_the_shapes_pin_is_read_from_shape_when_the_source_kept_its_own( + adopter, tmp_path): + """The source's own `contracts/shape-pin.yaml` kept in the root sends the + shape's to `shape/`, and that one says which copies the block names.""" + lines, findings, _, _, _ = accounted( + adopter, tmp_path, with_eol_block(OWN, ROOT_COPIES), + pin_path=f"{COLLISION_DIR}/{SHAPE_PIN}") + assert (lines, findings) == (2, []) + + +def test_the_sources_own_bytes_untouched_need_no_exception(adopter, tmp_path): + """No block (no collision, or a symlink): the blob is the source's, and + the accounting is the ordinary one.""" + lines, findings, counts, _, _ = accounted(adopter, tmp_path, OWN) + assert (lines, findings, counts["root"]) == (0, [], 2) + + +#: Every way the root's file can differ from the source's and the block, and +#: each one leaves the source's bytes in no place: `adopt-lost`. +NOT_THE_BLOCK = [ + pytest.param(with_eol_block(OWN, ROOT_COPIES[:1]), id="a-copy-missing"), + pytest.param(with_eol_block(OWN, [*ROOT_COPIES, "README.md"]), + id="a-line-too-many"), + pytest.param(with_eol_block(OWN, PINNED), id="a-shape-copy-named"), + pytest.param(with_eol_block(OWN, ROOT_COPIES) + b"*.jpg binary\n", + id="a-line-after-it"), + pytest.param(with_eol_block(b"* text=auto eol=crlf\n*.png binary\n", + ROOT_COPIES), id="a-source-line-changed"), + pytest.param(eol_block(ROOT_COPIES).encode(UTF8), id="the-source-gone"), +] + + +@pytest.mark.parametrize("attributes", NOT_THE_BLOCK) +def test_anything_but_exactly_the_block_is_the_source_lost(adopter, tmp_path, + attributes): + lines, findings, _, oid, after = accounted(adopter, tmp_path, attributes) + assert lines == 0 + assert f"root:{oid}" not in after[ATTRIBUTES] + assert len(findings) == 1, findings + assert findings[0].startswith( + f"FINDING adopt-lost: `{ATTRIBUTES}` ({oid[:12]}) is in no leg, not " + "in the root tree"), findings diff --git a/tests/test_adopt_filter_repo_failure.py b/tests/test_adopt_filter_repo_failure.py index a81d959b..5f16410b 100644 --- a/tests/test_adopt_filter_repo_failure.py +++ b/tests/test_adopt_filter_repo_failure.py @@ -16,9 +16,12 @@ The unit tests need no `git filter-repo`: they stub the leg builders, or run a real leg builder against a remote that does not exist (the extraction skips only the filter itself), and call `_build_the_legs` in this process. -The stub is a `#!/bin/sh` script, so the tests that put it on PATH skip on -Windows; the one that then follows the notice into a real adoption needs the -real tool as well. +The stub is a `#!/bin/sh` script, and it is GIT that runs it: `git +filter-repo` launches `git-filter-repo` from PATH, and Git for Windows reads +a `#!` line itself and runs the script with its own `sh`, so these run on +Windows too (#471), where a stub `gh` started by `subprocess` cannot. The +one that then follows the notice into a real adoption needs the real tool as +well. NO NETWORK AND NO GITHUB: every remote is a bare repository on disk, or no repository at all. @@ -35,7 +38,7 @@ import pytest -from conftest import (ADOPT, WINDOWS_SKIP, git, make_source_repo, rmtree, +from conftest import (ADOPT, git, make_source_repo, rmtree, run_script, spelled, write_plan) SPEC, CODE = "spec", "code" @@ -314,8 +317,10 @@ def failing_filter_repo(tmp_path) -> dict: `git ` looks in git's own exec-path before PATH, so a tool installed THERE would shadow the stub and extract for real; the test is skipped rather than allowed to pass or fail for that reason. A `#!` line - is a POSIX kernel convention, so the tests that use it carry - `WINDOWS_SKIP`, as `tests/test_free_plan_hint.py`'s stub `gh` does. + is a POSIX kernel convention, but GIT is what starts this stub, and Git + for Windows reads the line itself and runs it with its own `sh`; so, + unlike `tests/test_free_plan_hint.py`'s stub `gh`, which `subprocess` + starts, its tests are not `WINDOWS_SKIP` (#471). """ exec_path = Path(git("--exec-path", cwd=tmp_path).stdout.strip()) if (exec_path / FILTER_REPO_TOOL).exists(): @@ -347,7 +352,6 @@ def execute(plan: Path, remotes: Path, work: Path, env: dict | None = None): str(work), env=env) -@WINDOWS_SKIP def test_a_failed_git_filter_repo_names_itself_on_stderr( tmp_path, failing_filter_repo): """The issue, end to end: `check` passes, both leg remotes are created, @@ -376,7 +380,6 @@ def test_a_failed_git_filter_repo_names_itself_on_stderr( assert "neither leg is pushed" in err -@WINDOWS_SKIP @needs_filter_repo def test_following_the_notice_after_a_failed_leg_ends_adoption_verified( tmp_path, failing_filter_repo): diff --git a/tests/test_adopt_ignored_shape_files.py b/tests/test_adopt_ignored_shape_files.py index 10c4381a..acd86456 100644 --- a/tests/test_adopt_ignored_shape_files.py +++ b/tests/test_adopt_ignored_shape_files.py @@ -9,6 +9,13 @@ never for the ones the shape added. The assembly then failed its first bootstrap with `shape-copy-missing`. +The source that tracks its OWN `.gitattributes` is also where Windows failed +(#471): the shape's `* text=auto eol=lf` goes beside it, under `shape/`, and +governs nothing at the root, so a checkout that writes CRLF -- Git for +Windows' default -- broke every pinned copy's digest there. The split now +appends one block of `text eol=lf` lines for those copies to the source's +own file, and the end-to-end tests clone it with a checkout that writes CRLF. + Two halves, so two groups of tests: the split now FORCES the shape's own paths (and only those), and the verification now FAILS when a pinned file is not in the split commit. Both read a path the way a checkout does, through any @@ -38,7 +45,9 @@ sys.path.insert(0, str(REPO / "scripts")) from repo_shape import parse_yaml # noqa: E402 -from shape_materialize import Materialized, root_key # noqa: E402 +from shape_materialize import ( # noqa: E402 + EOL_BLOCK_HEAD, Materialized, eol_block, root_key, +) #: What the end-to-end tests need and the rest do not: they call the two new #: helpers on hand-made repositories, so they run on a machine with no @@ -529,9 +538,21 @@ def test_the_split_lands_the_files_the_pin_names(adopted): assert {adopted.attributes, WORKFLOW} <= rows assert rows <= tree, f"pinned but not in the split: {sorted(rows - tree)}" if adopted.own: - # Beside, not over: the source's own bytes are where they were. - assert git("show", f"{BRANCH}:{ATTRIBUTES}", - cwd=adopted.source).stdout == OWN_ATTRIBUTES + # Beside, and ONE BLOCK AFTER (#471, Brett Heap 2026-10-12T22:48Z): + # the source's own bytes are where they were and come first, and all + # that follows them is a `text eol=lf` line for each pinned copy + # outside `shape/`, in the pin's order. Nothing under `shape/`, which + # the shape's own copy governs. + outside = [row["path"] for row in pin["files"] + if not row["path"].startswith("shape/")] + text = git("show", f"{BRANCH}:{ATTRIBUTES}", + cwd=adopted.source).stdout + assert text == OWN_ATTRIBUTES + eol_block(outside), text + assert text.startswith(f"{OWN_ATTRIBUTES}{EOL_BLOCK_HEAD}\n") + for path in (MAKEFILE, WORKFLOW, "AGENTS-shape.md", + "scripts/bootstrap.py", "scripts/validate-pins.py"): + assert f"\n/{path} text eol=lf\n" in text, path + assert "/shape/" not in text and README not in text @needs_filter_repo @@ -546,6 +567,59 @@ def test_the_assembly_bootstraps_where_it_failed_with_shape_copy_missing( assert bootstrapped.returncode == 0, output +#: Two ways a checkout writes CRLF: Git for Windows' installer default, and +#: the line ending a `text` file takes on Windows when its attributes say +#: `text=auto` and no `eol` (`core.eol=native`). Either is what a Windows +#: colleague's first clone of the assembly does (#471); CI's Windows job has +#: the second, with `core.autocrlf=false`. +CRLF_CHECKOUTS = [pytest.param("core.autocrlf", "true", id="autocrlf"), + pytest.param("core.eol", "crlf", id="eol-crlf")] + + +def command_scope(key: str, value: str) -> dict: + """`key=value` in git's command scope for every git a run starts, + APPENDED to what the environment holds: the Windows job sets + `core.autocrlf=false` there, which outranks any config file, so a + clone's own `--config` would not reach its checkout. The last entry of a + key is the one git reads.""" + held = int(os.environ.get("GIT_CONFIG_COUNT") or 0) + return {**os.environ, "GIT_CONFIG_COUNT": str(held + 1), + f"GIT_CONFIG_KEY_{held}": key, f"GIT_CONFIG_VALUE_{held}": value} + + +@needs_filter_repo +@pytest.mark.parametrize(("key", "value"), CRLF_CHECKOUTS) +def test_a_checkout_that_writes_crlf_still_bootstraps(adopted, tmp_path, key, + value): + """The assembly cloned the way Windows clones it: every pinned copy is + on the disk with the bytes the pin records, and the first bootstrap + passes. Before #471 the source with attributes of its own failed here + with shape-copy-drift on all nine copies at the root. + + THE CONTROL IS IN THE SAME CLONE: the source's own `README.md` is not a + shape copy, its `* text=auto` alone governs it, and it IS checked out + with CRLF, so the setting reached the checkout and the pass means what + it says.""" + clone = tmp_path / PROJECT + cloned = subprocess.run( + ["git", *FILE_PROTOCOL, "clone", "-q", "--recurse-submodules", "-b", + BRANCH, str(adopted.source), str(clone)], cwd=str(tmp_path), + env=command_scope(key, value), capture_output=True, text=True, + check=False) + assert cloned.returncode == 0, cloned.stderr + if adopted.own: + assert b"\r\n" in (clone / README).read_bytes(), \ + "this clone did not write CRLF, so it tests nothing" + pin = parse_yaml((clone / PIN).read_text(encoding="utf-8")) + crlf = [row["path"] for row in pin["files"] + if b"\r\n" in (clone / row["path"]).read_bytes()] + assert crlf == [], f"pinned copies checked out with CRLF: {crlf}" + bootstrapped = run_script(clone / "scripts" / "bootstrap.py", cwd=clone) + output = bootstrapped.stderr + bootstrapped.stdout + assert "shape-copy-drift" not in output, output + assert bootstrapped.returncode == 0, output + + def execute_in_process(adopter, tmp_path: Path, monkeypatch) -> tuple: """`execute` run in THIS process, so a test can patch one step of it: `(source, exit code)`. What `run_script` blanks for a child is blanked diff --git a/tests/test_update_shape_add.py b/tests/test_update_shape_add.py index 29695f0a..fc8af568 100644 --- a/tests/test_update_shape_add.py +++ b/tests/test_update_shape_add.py @@ -46,6 +46,7 @@ sys.path.insert(0, str(REPO / "scripts")) from repo_shape import file_sha256, load_yaml # noqa: E402 +from shape_materialize import with_eol_block # noqa: E402 UPDATE = REPO / "update-shape.py" SCAFFOLD_ARGS = ("--elected-by", "Test Human", "--elected-on", "2026-09-02") @@ -453,6 +454,76 @@ def test_a_red_validator_rolls_the_added_file_back_out(root, standard): assert pin(root)["commit"].lower() == standard["c1"] +# --- an adopted root that keeps its OWN `.gitattributes` (#471) -------------- +# +# Its source had a `.gitattributes`, so the shape's went to `shape/`, where it +# governs that directory alone, and `adopt-project.py` appended a block of +# `text eol=lf` lines for the pinned copies at the root (Brett Heap, +# 2026-10-12T22:48Z). A file `--add` writes is one more such copy, and a +# Windows clone reports shape-copy-drift on any copy the block does not name. + +#: The source's own line, which the block follows. +OWN_ATTRIBUTES = b"* text=auto\n" + + +def keep_own_attributes(root, data: bytes) -> bytes: + """Give the root `data` as its own committed `.gitattributes`.""" + (root / ATTRIBUTES).write_bytes(data) + git("add", "--", ATTRIBUTES, cwd=root) + git("commit", "-qm", "Keep our own attributes", "--", ATTRIBUTES, + cwd=root) + return data + + +def test_add_extends_the_block_in_a_root_that_keeps_its_own_attributes( + root, standard): + """Each new copy gets its line inside the block, after the lines already + there and before a line the project wrote after it; the commit carries + the file with the copies.""" + keep_own_attributes(root, with_eol_block(OWN_ATTRIBUTES, [PINNED]) + + b"*.jpg binary\n") + result = apply(root, standard, "--add", ADDED, "--add", ADDED_SCRIPT, + "--branch", "shape/add-test") + assert result.returncode == 0, result.stdout + result.stderr + assert (root / ATTRIBUTES).read_bytes() == with_eol_block( + OWN_ATTRIBUTES, [PINNED, ADDED, ADDED_SCRIPT]) + b"*.jpg binary\n" + committed = set(git("show", "--name-only", "--format=", "HEAD", + cwd=root).stdout.split()) + assert committed == {ADDED, ADDED_SCRIPT, ATTRIBUTES, + "contracts/shape-pin.yaml", "project.yaml"} + validators_are_green(root) + + +def test_add_leaves_a_root_without_the_block_alone(root, standard): + """A `.gitattributes` with no block is not this tool's to write: a + scaffolded root's is the shape's own, and one adopted before #471 never + had a block.""" + keep_own_attributes(root, OWN_ATTRIBUTES) + result = apply(root, standard, "--add", ADDED, + "--branch", "shape/add-test") + assert result.returncode == 0, result.stdout + result.stderr + assert (root / ATTRIBUTES).read_bytes() == OWN_ATTRIBUTES + committed = set(git("show", "--name-only", "--format=", "HEAD", + cwd=root).stdout.split()) + assert ATTRIBUTES not in committed + + +def test_a_refused_apply_puts_the_block_back(root, standard): + """The block's new line is in the same transaction as the copy it names: + a red validator puts both back.""" + before = keep_own_attributes( + root, with_eol_block(OWN_ATTRIBUTES, [PINNED])) + spec = root / "contracts" / "spec-pin.yaml" + spec.write_text(spec.read_text(encoding="utf-8") + .replace('commit: "', 'commit: "' + "0" * 40 + '" # ', 1), + encoding="utf-8") + result = apply(root, standard, "--add", ADDED) + assert result.returncode == 2, result.stdout + assert "update-validators-red" in result.stderr + assert (root / ATTRIBUTES).read_bytes() == before + assert not (root / ADDED).exists() + + # --- the family holder ------------------------------------------------------- def test_a_family_holder_takes_its_own_addition(holder, standard): diff --git a/update-shape.py b/update-shape.py index cfc0f676..385d0848 100755 --- a/update-shape.py +++ b/update-shape.py @@ -137,9 +137,10 @@ tree_digest, ) from shape_materialize import ( # noqa: E402 - COPIED_FROM_SHAPE, COPIED_VERBATIM, FAMILY_COPIED_FROM_SHAPE, + ATTRIBUTES, COPIED_FROM_SHAPE, COPIED_VERBATIM, FAMILY_COPIED_FROM_SHAPE, FAMILY_COPIED_VERBATIM, SHAPE_REPOSITORY, CommandFailed, check_program, - commit_trailers, escape_what_stdout_cannot_encode, force_stage, + commit_trailers, escape_what_stdout_cannot_encode, extend_eol_block, + force_stage, inherited_child_environment, lane_trailer_argument, readme_shape_lines, is_nested_key, readme_trailing_shape_line, rewrite_fault, run, trailer_line, with_scalar, @@ -2664,6 +2665,37 @@ def _write_copies(root: Path, rows: list[Row], taking: list[Addition], return copied +def _extend_the_eol_block(root: Path, taking: list[Addition], + ledger: Rollback) -> list[str]: + """Name each `--add` file in the line-ending block of the root's own + `.gitattributes`; the paths this wrote, for the commit (#471). + + An ADOPTED root whose source kept its own `.gitattributes` carries the + shape's `* text=auto eol=lf` under `shape/`, where it governs that + directory alone, and a block that `adopt-project.py` appended to its own + file names each pinned copy outside it, so a Windows checkout keeps their + bytes. An `--add` file is written at its own path, never under `shape/`, + so it is one more such copy, and without its line a Windows clone reports + shape-copy-drift on it. A root with no block -- a scaffolded one, whose + `.gitattributes` IS the shape's, or one adopted before #471 -- is left + alone, as is a `.gitattributes` that is a symlink: this tool never + writes through one. Through the ledger, so a refusal after it puts the + file back. + """ + path = root / ATTRIBUTES + if not taking or symlink_on_the_way(root, ATTRIBUTES) is not None \ + or not path.is_file(): + return [] + before = path.read_bytes() + after = extend_eol_block(before, [add.path for add in taking]) + if after is None or after == before: + return [] + ledger.write(path, after) + print(f" extended {ATTRIBUTES}' line-ending block with the --add " + "files, so every checkout keeps their bytes") + return [ATTRIBUTES] + + def _repin(root: Path, kind: Kind, ledger: Rollback, rows: list[Row], pinned: str, target: str, target_tree: str) -> None: """Rewrite `contracts/shape-pin.yaml` and the kind's manifest onto the @@ -2835,6 +2867,8 @@ def cmd_apply(args) -> int: readme = _read_readme(root, upstream, target) with ledger.writing("writing the copies and the --add files"): copied = _write_copies(root, rows, taking, ledger) + with ledger.writing(f"extending {ATTRIBUTES}' line-ending block"): + attributes = _extend_the_eol_block(root, taking, ledger) with ledger.writing("re-pinning contracts/shape-pin.yaml and " f"{kind.manifest}"): _repin(root, kind, ledger, rows, pinned, target, target_tree) @@ -2842,7 +2876,7 @@ def cmd_apply(args) -> int: _refuse_red_validators(root, kind) added = [add.path for add in taking] paths = sorted({row.path for row in copied} | set(added) - | {SHAPE_PIN, kind.manifest} + | {SHAPE_PIN, kind.manifest} | set(attributes) | ({README} if readme.rewritten else set())) _land(root, args, paths, added, target, upstream, kind, len(copied), ledger) From 7c1073409fc5548a995caba9abb958ad41138cfe Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Mon, 12 Oct 2026 23:11:39 +0000 Subject: [PATCH 04/13] tests/test_update_shape_add.py: the fixture's check that no copy list still names a file it stripped reads the list entries and not every string in `scripts/shape_materialize.py`, because the materializer now spells `.gitattributes` once outside the lists, for the block an adoption appends, and that broke every test in the module (#471) `strip_the_real_additions` removes `.gitattributes` from the copy lists of a cloned upstream with `unname_everywhere`, which strips the whole line ` ".gitattributes",`, and then asserted that the text `".gitattributes"` appeared nowhere in the module, as a proxy for "no list names it". `ATTRIBUTES = ".gitattributes"` is not a list entry and no list reads it, but it failed that proxy, so the module fixture raised and every test that uses it errored. The check now looks for what was stripped: a list entry line. Found by the mutation run for this change, whose `no-extend` mutant met the fixture error instead of the test. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 --- tests/test_update_shape_add.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_update_shape_add.py b/tests/test_update_shape_add.py index fc8af568..086920dd 100644 --- a/tests/test_update_shape_add.py +++ b/tests/test_update_shape_add.py @@ -152,7 +152,12 @@ def strip_the_real_additions(upstream) -> list: (upstream / rel).unlink() touched.append(rel) text = unname_everywhere(text, entry) - assert f'"{entry}"' not in text, ( + # A LIST ENTRY, the line `unname_everywhere` strips, and not every + # string in the module: `ATTRIBUTES` spells `.gitattributes` once + # outside the lists (#471), for the block an adoption appends to a + # source's own file, and no list reads it. + assert not re.search(r'^\s+"%s",?\r?$' % re.escape(entry), text, + flags=re.MULTILINE), ( f"the copy lists still name {entry}, which this fixture just " "removed") if text != source.read_text(encoding="utf-8"): From e2922e840f9099eb8cf039e8004152ca9cd2d180 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 13 Oct 2026 11:07:37 +0000 Subject: [PATCH 05/13] tests: the notice-following test compares the leg repositories it reads out of the notice with their `spelled` form, because the notice escapes each backslash of a Windows path, and the attributes-symlink test skips on Windows as the suites' other disk-symlink tests do, so both pass where #471's second Windows probe failed them (#471) Probe run 38321799510 (Windows, this change at b2c0924 with the workflow's `git-filter-repo` install) passed 1754 and failed two tests, both the tests' own: - `test_following_the_notice_after_a_failed_leg_ends_adoption_verified`, one of the two `git-filter-repo` stub tests this change lets run on Windows. The notice names the leg repositories `_spelled` (#212): between backticks, with a backslash escaped, so a Windows path reads `D:\\a\\...` there, as `conftest.spelled` documents. The test compared what it read with the raw `str()` of the path, which holds on POSIX alone. It now compares with `spelled()`, as its next assertion already did, and deletes the same two paths. The other stub test passed on Windows: Git for Windows does run the `#!` stub. - `test_an_attributes_symlink_is_not_written_through`. `os.symlink` made a link on the runner, but one whose target is spelled `config/attributes` leads nowhere on Windows, so the materializer found the path free and wrote through it. It now carries `needs_disk_symlinks`, as the nine other disk-symlink tests of the adoption suites do. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 --- tests/test_adopt_attributes_eol_block.py | 10 ++++++++++ tests/test_adopt_filter_repo_failure.py | 13 +++++++++---- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/tests/test_adopt_attributes_eol_block.py b/tests/test_adopt_attributes_eol_block.py index e2aa7737..e2ec9e9e 100644 --- a/tests/test_adopt_attributes_eol_block.py +++ b/tests/test_adopt_attributes_eol_block.py @@ -56,6 +56,15 @@ SHAPE_PIN = "contracts/shape-pin.yaml" TEMPLATE = REPO / "templates" / "assembly-root" +#: A symlink made ON THE DISK, as the other symlink tests of the adoption +#: suites skip one on Windows: there a runner may make one only in Developer +#: Mode or elevated, and one whose target is spelled `config/attributes` +#: leads nowhere, so the materializer took the path for free and wrote +#: through the link (#471's probe 2). +needs_disk_symlinks = pytest.mark.skipif( + sys.platform == "win32", + reason="a symlink needs a privilege Windows does not grant by default") + @pytest.fixture(scope="module") def adopter(): @@ -211,6 +220,7 @@ def test_with_no_attributes_of_its_own_nothing_is_appended(tmp_path): (TEMPLATE / ATTRIBUTES).read_bytes() +@needs_disk_symlinks def test_an_attributes_symlink_is_not_written_through(tmp_path): """What a link leads to may be another of the source's files, and git reads no `.gitattributes` through a link anyway: it is left alone.""" diff --git a/tests/test_adopt_filter_repo_failure.py b/tests/test_adopt_filter_repo_failure.py index 5f16410b..3aee0a04 100644 --- a/tests/test_adopt_filter_repo_failure.py +++ b/tests/test_adopt_filter_repo_failure.py @@ -397,15 +397,20 @@ def test_following_the_notice_after_a_failed_leg_ends_adoption_verified( if line.startswith(ROLLED_BACK)) named = NAMED.search(notice) assert named, notice - assert [named["spec"], named["code"]] == [ - str(remotes / f"{ASSEMBLY}-{role}.git") for role in UNIT_ROLES] + # AS THE NOTICE SPELLS THEM (#212): between backticks, a backslash + # escaped, so a Windows path reads `D:\\a\\...` there. Compared with + # the raw path, this held on POSIX alone, and the Windows run of #471's + # probe 2 failed here on the doubled separators, which are the spelling. + legs = [remotes / f"{ASSEMBLY}-{role}.git" for role in UNIT_ROLES] + assert [f"`{named['spec']}`", f"`{named['code']}`"] == list( + map(spelled, legs)) assert (f"Use a fresh --work-dir as well, or none: {spelled(work)} holds " f"{spelled(f'{ASSEMBLY}-{SPEC}')} from this run") in notice # What it says: delete the two repositories it names, and a fresh # --work-dir. The --local-remote-dir is the same one. - for role in UNIT_ROLES: - rmtree(Path(named[role])) + for leg in legs: + rmtree(leg) rerun = execute(plan, remotes, tmp_path / "fresh work") assert rerun.returncode == 0, rerun.stderr + rerun.stdout From 8f8cf6fafca1b7bfa86e4c284408338b8c63abda Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 13 Oct 2026 11:39:17 +0000 Subject: [PATCH 06/13] README.md, AGENTS.md and the handbook: a root `.gitattributes` the source keeps gets the split's line-ending block appended and is verified as the source's bytes plus that block, said where they promised that nothing is overwritten and that every source path verifies by blob sha (#471) Brett Heap ruled at 2026-10-13T11:30Z that each lane-4 fix carries its own doc lines; #483, which they had been handed to, closed through #505 before this change was on main. Each file keeps its line count (README.md 1422, AGENTS.md 490, both at their caps): - README.md, "Ambiguous is an answer, and no source line is overwritten" (was "nothing is overwritten"): the collision paragraph says a kept root `.gitattributes` gets a block of `/ text eol=lf` lines appended, and the verification counts that file as the source's bytes plus the block. `.gitignore` leaves the example list and "after the split" the root tree, to keep the paragraph at seven lines. - README.md, "Why `.gitattributes` is one of the copies": in an adopted root that kept its own file, the block appended to it is what carries the rule. - AGENTS.md step 7 states the same exception for the verification table; the no-code variant's item 2 no longer restates "by blob sha" and defers to step 7, which it already names, so the file keeps 490 lines. - docs/handbook.html mirrors both README paragraphs, its footer names #471, and its README.md blob is recomputed. "The only change to its history is ONE split commit" is unchanged: the block is in that commit, so it still holds. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 22 +++++++++++----------- README.md | 24 ++++++++++++------------ docs/handbook.html | 20 +++++++++++++------- 3 files changed, 36 insertions(+), 30 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4b94a0a7..e5093651 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -141,15 +141,16 @@ steps, in order, and step 4 is the one you must not skip. `execute` does not make them. Do not pass `--yes` on your own initiative; it is the human's word, not your convenience. 7. **Read the verification table back to them.** It accounts for every source - path at the source commit, by blob sha, in exactly one of {spec leg, code - leg, root tree} or as `drop`. If it exits non-zero, STOP and relay it, and - take the way out THAT exit prints. Exit 1, a failed verification: the - source is untouched — nothing is deleted, `main` is where it was, the split - is on a branch — so fix the plan, delete the split branch, and re-run into - fresh legs and a fresh `--work-dir`, or none. Exit 2 saying `IS pushed` - (GitHub only: `gh pr create` or the topic refused) comes after both legs - and the split are pushed and verified: finish with the command it prints - and never re-run into fresh legs, which abandons that pair. + path at the source commit, by blob sha (a kept root `.gitattributes` as the + source's bytes plus the line-ending block the split appends), in exactly one + of {spec leg, code leg, root tree} or as `drop`. If it exits non-zero, STOP + and relay it, and take the way out THAT exit prints. Exit 1, a failed + verification: the source is untouched — nothing is deleted, `main` is where + it was, the split is on a branch — so fix the plan, delete the split branch, + and re-run into fresh legs and a fresh `--work-dir`, or none. Exit 2 saying + `IS pushed` (GitHub only: `gh pr create` or the topic refused) comes after + both legs and the split are pushed and verified: finish with the command it + prints and never re-run into fresh legs, which abandons that pair. `execute` also records the plan's answers about the source's submodules in the split's `project.yaml`, under `submodules:` — per submodule a leg keeps, @@ -177,8 +178,7 @@ passes, because a spec-only repository is a legitimate thing to adopt. repository that has no code. Do not pass it because the tool asked for it; pass it because the human confirmed the repository genuinely has none. 2. Everything else is unchanged, step 7 included: the verification table's - `code` row reads `0 of N source paths (seeded from template)`, and the table - still accounts for every source path by blob sha. + `code` row reads `0 of N source paths (seeded from template)`. ## Scaffolding a project that DECLARES descent diff --git a/README.md b/README.md index 1417b9ec..9a763a96 100644 --- a/README.md +++ b/README.md @@ -769,7 +769,7 @@ the ten identical assistant stubs stay at the root because an assistant reading `CLAUDE.md` from inside the code leg would be told about specifications it cannot see. -### Ambiguous is an answer, and nothing is overwritten +### Ambiguous is an answer, and no source line is overwritten `contracts/path-classification.yaml` maps path globs to `spec | code | root | ambiguous`, first match wins, each with a reason. The fourth class is not a @@ -778,13 +778,13 @@ about half the time and silent about it. Those entries get `leg: null`, `review_required: true` and the QUESTION; `execute` refuses while any remain, because an unanswered question is never an implicit `root`. -A shape file whose name the source already holds — `README.md`, `Makefile`, -`.gitignore` — is written under `shape/` and the collision becomes a follow-up -for a human to merge. Then `execute` VERIFIES: every source path at the source -commit is in exactly one of {spec leg, code leg, root tree after the split} -**by blob sha**, or listed as `drop`. Counting paths would pass a split that -truncated a file; blob shas cannot, and a path in two places is as much a -finding as a path in none. +A shape file whose name the source already holds — `README.md`, `Makefile` — is +written under `shape/` as a follow-up to merge, and a kept root `.gitattributes` +gets a block of `/ text eol=lf` lines appended. Then `execute` VERIFIES: +every source path at the source commit is in exactly one of {spec leg, code leg, +root tree} **by blob sha** (that file as the source's bytes plus the block), or +as `drop`. Counting paths would pass a split that truncated a file; blob shas +cannot, and a path in two places is as much a finding as a path in none. An empty repository is not a live one: `scaffold-project.py --reuse-empty-repo` uses a zero-commit `/` as the assembly root, and refuses one with @@ -1006,10 +1006,10 @@ with the exit named (carry it upstream; do not update the digest). **Why `.gitattributes` is one of the copies.** The pin digests the bytes ON DISK, and Git for Windows' installer default is `core.autocrlf=true` — so -without this file a colleague who did nothing but clone the finished project -gets CRLF in the worktree, and every pinned row is false for them alone. -`* text=auto eol=lf` puts the answer in the project, where the digests are, so -no clone-time setting is needed on any machine (#51, 2026-09-05). +without this file a colleague who just clones the project gets CRLF in the +worktree, and every pinned row is false for them alone. `* text=auto eol=lf` (or +the block appended to an adopted root's own file, #471) puts the answer where +the digests are, so no clone-time setting is needed (#51, 2026-09-05). **Why an agent file is one of the copies.** An agent working in a project learns the shape's rules — advance a leg in ONE commit, never edit a pinned diff --git a/docs/handbook.html b/docs/handbook.html index ee0b9cc7..f60ba49c 100644 --- a/docs/handbook.html +++ b/docs/handbook.html @@ -1181,7 +1181,7 @@

Adopting an existing repository

pip install git-filter-repo, or the same name from apt or brew. It is deliberately not vendored: history extraction has one correct implementation, and a copy is a second one that drifts.

-

Ambiguous is an answer, and nothing is overwritten. +

Ambiguous is an answer, and no source line is overwritten. contracts/path-classification.yaml maps path globs to spec | code | root | ambiguous, first match wins, each with a reason. The fourth class is not a failure: those entries get leg: null, @@ -1189,9 +1189,12 @@

Adopting an existing repository

any remain, because an unanswered question is never an implicit root. A shape file whose name the source already holds — README.md, Makefile, .gitignore — is written under shape/ and - the collision becomes a follow-up for a human to merge. Then execute VERIFIES: + the collision becomes a follow-up for a human to merge, and a root + .gitattributes the source keeps gets a block of + /<copy> text eol=lf lines appended. Then execute VERIFIES: every source path at the source commit is in exactly one of {spec leg, code leg, root tree - after the split} by blob sha, or listed as drop.

+ after the split} by blob sha (that file as the source’s bytes plus the + block), or listed as drop.

A leg with nothing in it is SEEDED, not extracted. git filter-repo over an empty path list rewrites every commit to nothing and leaves an empty HISTORY, which is not the same thing as an empty repository — so a leg @@ -1385,8 +1388,9 @@

Bootstrap is COPIED into the project, not fetched

bytes ON DISK, and Git for Windows’ installer default is core.autocrlf=true — so without this file a colleague who did nothing but clone the finished project gets CRLF in the worktree, and every pinned row is false for them - alone. * text=auto eol=lf puts the answer in the project, where the digests are, - so no clone-time setting is needed on any machine.

+ alone. * text=auto eol=lf — or, in an adopted root that kept its own + .gitattributes, the block appended to that file — puts the answer in the + project, where the digests are, so no clone-time setting is needed on any machine.

Why an agent file is one of the copies. An agent working in a project learns the shape’s rules — advance a leg in ONE commit, never edit a pinned file, never --admin — from a file that travels WITH the shape rather than from @@ -1870,9 +1874,11 @@

For AI assistants

above drift, added (#424); the assistant’s route when its shell has no terminal, added (#476); the one-liner asking at the person’s own terminal, never reading the answer from the pipe, added (#492); the statements the v1.0.0 accuracy pass found false, - corrected here as in the README, added (#483) — a page cannot name the commit + corrected here as in the README, added (#483); the line-ending block an adoption appends + to a root .gitattributes the source keeps, and its verification as the + source’s bytes plus that block, added (#471) — a page cannot name the commit that contains it, and the blob below is the fact a test checks. - README.md blob 1417b9ec0235675da8f73ca023756411bc3fbe8a. Every command, flag, exit code + README.md blob 9a763a96877d8abd0c3d507b63f2442bd3bf9ceb. Every command, flag, exit code and file name on this page is quoted from that README and from AGENTS.md.

From ea9c209f939be7d854e193e75d890be486295eb3 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 13 Oct 2026 12:17:32 +0000 Subject: [PATCH 07/13] adopt-project.py: a source `.gitattributes` git tracks as a symlink gets no block even where `core.symlinks=false` checks it out as a plain file, its follow-up says nothing was appended and lists the lines a regular file needs, the verification finds the shape's pin through a root-kept `contracts` link, and `--add` extends the last block (#471) Review round 1 of #511 at 8f8cf6f (NOT MERGEABLE on one MUST): - MUST-1. `_append_eol_block` asked the disk whether `.gitattributes` was a link. Git for Windows' default `core.symlinks=false` checks a tracked link out as a plain file holding its target's path, so the block was appended into the link: the split kept mode 120000 over the old target followed by the block, `execute` said none of its own lines was changed, and it failed `adopt-lost: .gitattributes` after both legs were pushed, where main verifies the same source. `_tracked_as_link` now reads git's index (`ls-files --stage`, mode 120000) as well. Tested on every platform with a link in the index only, checked out under `core.symlinks=false`: in the materializer, and end to end with the setting in git's command scope for every git the run starts. - SHOULD-2. The `.gitattributes` follow-up described a block that is never appended to a link, and sent the person to add its lines where the link leads, which git never reads. `plan` now reads the link from the source tree's mode, and the follow-up for one says nothing was appended, that git reads no `.gitattributes` that is a symlink, that a regular file in its place is the remedy (or a clone's `.git/info/attributes`, for that clone alone), and lists the exact lines. - SHOULD-3. `_land_the_eol_block` looked for the shape's pin among the split's plain files, so a source keeping `contracts -> meta` in the root, whose pin the materializer writes through the link, ended `adopt-lost: .gitattributes` where main verifies it. It reads the pin through the split's links now (`_SplitTree`), as `_shape_file_findings` does, and the docstring says so. Tested on a split whose `contracts` link is in the tree only, and end to end with a disk link. - NIT-1. `extend_eol_block` extended the FIRST head line; a source that held a copy of it before its own later rules got `--add` lines that those rules overrode. It extends the last one, which is the block adoption appended. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 44 ++++++-- scripts/shape_materialize.py | 85 +++++++++++++-- tests/test_adopt_attributes_eol_block.py | 111 +++++++++++++++++-- tests/test_adopt_ignored_shape_files.py | 130 +++++++++++++++++++++++ 4 files changed, 345 insertions(+), 25 deletions(-) diff --git a/adopt-project.py b/adopt-project.py index 7d7a7d95..a2ddf51f 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -2691,7 +2691,9 @@ def cmd_plan(args) -> int: # the plan as written. A source with no such path plans as it did. ruled = walk(policy, [(path, size) for path, _, _, size in tree]) implied = ask_about_refused_paths(ruled, tree) - materialized = _predict_collisions(implied) + materialized = _predict_collisions( + implied, attributes_link=any(path == ATTRIBUTES and mode == SYMLINK_MODE + for path, mode, _, _ in tree)) follow_ups = follow_ups_for(source, implied, names, args.spec_path, args.code_path, materialized) seeded = seeded_legs(assigned_paths(implied)) @@ -2754,7 +2756,8 @@ def _print_summary(entries: list[Entry]) -> None: f"{row['byte_share']:>6.1%} of bytes") -def _predict_collisions(entries: list[Entry]) -> list[str]: +def _predict_collisions(entries: list[Entry], *, + attributes_link: bool = False) -> list[str]: """Which shape files will not be able to take their own names. Known BEFORE `execute` runs, because the plan says which paths survive in @@ -2774,12 +2777,17 @@ def _predict_collisions(entries: list[Entry]) -> list[str]: Each follow-up is told the WHOLE list: the Makefile's names what runs until it is merged, and that depends on whether `scripts/bootstrap.py` went aside too (#493's review, F2). + + `attributes_link` says the source's root `.gitattributes` is a symlink, + read from the source tree's mode as `execute` reads it from the index: + no block is appended to one, and its follow-up says so (#471). """ surviving = {e.path for e in entries if e.leg == "root"} beside = {path: f"{COLLISION_DIR}/{path}" for path in sorted(set(assembly_root_paths())) if path in surviving or f"{path.split('/')[0]}/" in surviving} - return [collision_follow_up(path, actual, beside) + return [collision_follow_up(path, actual, beside, + attributes_link=attributes_link) for path, actual in beside.items()] @@ -6950,7 +6958,8 @@ def _accounted_for(source: Source, assembly: Path, work_root: Path, names, root_findings = _land_the_root_tree(after, split_tree, before, mounts) root_findings += _mount_commit_findings( split_tree, mounts, _pushed_commits(work_root, names)) - block = _land_the_eol_block(assembly, split_tree, before, after) + block = _land_the_eol_block(assembly, split_commit, split_tree, before, + after) counts, findings = _account_for(before, after, paths_for["drop"]) findings += _shape_file_findings(assembly, split_commit) @@ -6967,8 +6976,8 @@ def _accounted_for(source: Source, assembly: Path, work_root: Path, names, return findings + root_findings -def _land_the_eol_block(assembly: Path, tree: list, before: dict, - after: dict) -> int: +def _land_the_eol_block(assembly: Path, split_commit: str, tree: list, + before: dict, after: dict) -> int: """Count the source's `.gitattributes` as landed in the root when the root holds its bytes followed by exactly the shape's block; return how many lines that block has, or 0 when there is none to count. @@ -6985,8 +6994,13 @@ def _land_the_eol_block(assembly: Path, tree: list, before: dict, The pin is the shape's: under `shape/` when the source kept its own `contracts/shape-pin.yaml`, which nothing else can put there - (`plan-root-blocks-shape-copy`). An unreadable one counts nothing, and - `_shape_file_findings` reports it. + (`plan-root-blocks-shape-copy`). It is read THROUGH the split's symlinks + (`_SplitTree`), as `_shape_file_findings` reads it: a source that keeps + `contracts -> meta` in the root has the pin written through the link, and + a lookup among the tree's plain files found none, so the appended file + was `adopt-lost` where main verified it (#471's review, SHOULD-3). A pin + neither can read counts nothing here, and `_shape_file_findings` reports + it. """ source_oid = before.get(ATTRIBUTES) landed = after.get(ATTRIBUTES, []) @@ -6995,7 +7009,7 @@ def _land_the_eol_block(assembly: Path, tree: list, before: dict, or root[0] == f"root:{source_oid}": return 0 blobs = {path: oid for path, mode, oid, _ in tree if mode in FILE_MODES} - pin = blobs.get(f"{COLLISION_DIR}/{SHAPE_PIN}") or blobs.get(SHAPE_PIN) + pin = _pin_through_links(_SplitTree(assembly, split_commit)) pinned = _pinned_shape_paths(assembly, pin) if pin else None if pinned is None or blobs.get(ATTRIBUTES) != root[0].split(":", 1)[1]: return 0 @@ -7008,6 +7022,18 @@ def _land_the_eol_block(assembly: Path, tree: list, before: dict, return len(paths) +def _pin_through_links(tree: "_SplitTree") -> str | None: + """The oid of the shape's pin in the split, under `shape/` first, each + path followed through the tree's links; None when neither leads to a + file.""" + for path in (f"{COLLISION_DIR}/{SHAPE_PIN}", SHAPE_PIN): + try: + return tree.blob_at(path) + except _NotInTheSplitError: + continue + return None + + def _blob_bytes(repo: Path, oid: str) -> bytes: return git_out([GIT_CAT_FILE, "blob", oid], cwd=repo, binary=True) diff --git a/scripts/shape_materialize.py b/scripts/shape_materialize.py index c927ff89..a549d57f 100644 --- a/scripts/shape_materialize.py +++ b/scripts/shape_materialize.py @@ -751,6 +751,12 @@ def extend_eol_block(existing: bytes, paths) -> bytes | None: The block is the head line and the `/ text eol=lf` lines straight after it. Lines a human wrote after the block stay after the new ones. + THE LAST HEAD IS THE BLOCK (#471's review, NIT-1). A source that already + held a copy of the head line keeps it, and the split appends its own + block after it, so the adoption's block is the last one; extending an + earlier copy would put the new lines before rules of the source's that + come after it, and a later `eol` rule would win over them. + A WINDOWS CHECKOUT OF THE FILE MAY BE CRLF: the source's own rules govern `.gitattributes` too, and `* text=auto` there, or Git for Windows' `core.autocrlf=true`, writes it with CRLF. So lines are matched @@ -758,10 +764,10 @@ def extend_eol_block(existing: bytes, paths) -> bytes | None: line has; git normalises the file when it is committed either way.""" lines = existing.splitlines(keepends=True) bare = [line.rstrip(b"\r\n") for line in lines] - try: - start = bare.index(EOL_BLOCK_HEAD.encode("utf-8")) - except ValueError: + head = EOL_BLOCK_HEAD.encode("utf-8") + if head not in bare: return None + start = len(bare) - 1 - bare[::-1].index(head) end, named = start + 1, set() while end < len(lines): match = re.fullmatch(rb"/(\S+) text eol=lf", bare[end]) @@ -780,6 +786,30 @@ def extend_eol_block(existing: bytes, paths) -> bytes | None: return b"".join(lines[:end] + new + lines[end:]) +#: The mode git records for a symlink. +LINK_MODE = "120000" + + +def _tracked_as_link(target: Path, rel: str) -> bool: + """Whether the git index in `target` records `rel` as a symlink. + + THE INDEX, NOT THE DISK (#471's review, MUST-1). Git for Windows' + default `core.symlinks=false` checks a tracked link out as a PLAIN FILE + holding the path it leads to, so `is_symlink()` says no there, and an + append wrote the block into the link itself: the split commit kept mode + 120000 over a blob that was now the old target followed by the block, + and the verification called the source's file lost after both legs were + pushed. A `target` that is no repository has no index to ask, and its + disk is all there is.""" + try: + listed = run(["git", "--literal-pathspecs", "-C", str(target), + "ls-files", "--stage", "-z", "--", rel]) + except CommandFailed: + return False + return any(entry.startswith(f"{LINK_MODE} ") + for entry in listed.split("\0")) + + def _append_eol_block(target: Path, result: "Materialized", collision_dir: str) -> None: """Append the block to the source's own `.gitattributes`, when the @@ -787,12 +817,14 @@ def _append_eol_block(target: Path, result: "Materialized", A SYMLINK IS NOT WRITTEN THROUGH: what it leads to may be another file of the source's, or nothing in this repository, and git reads no - `.gitattributes` through a link anyway. It is left as it is, and the - follow-up names the lines to add where it leads.""" + `.gitattributes` that is a link anyway. It is left as it is, and the + follow-up says so and lists the lines a regular file would need. A link + is one on the disk OR in git's index (`_tracked_as_link`).""" if (ATTRIBUTES, f"{collision_dir}/{ATTRIBUTES}") not in result.collisions: return path = target / ATTRIBUTES - if path.is_symlink() or not path.is_file(): + if path.is_symlink() or not path.is_file() \ + or _tracked_as_link(target, ATTRIBUTES): return paths = eol_block_paths(result.shape_files, collision_dir) path.write_bytes(with_eol_block(path.read_bytes(), paths)) @@ -800,7 +832,8 @@ def _append_eol_block(target: Path, result: "Materialized", def collision_follow_up(intended: str, actual: str, - beside: dict | None = None) -> str: + beside: dict | None = None, *, + attributes_link: bool = False) -> str: """The follow-up for ONE collision, in one place. Both callers say it: `adopt-project.py plan` PREDICTS the collisions from @@ -817,7 +850,16 @@ def collision_follow_up(intended: str, actual: str, Where the root's `scripts/bootstrap.py` is the source's own, that command runs the source's script and exits 0, so the follow-up names no command, and says both merges come first (#493's review, F2). + + A `.gitattributes` THAT IS A SYMLINK gets no block (`_append_eol_block`), + so `attributes_link` says which of two things happened, and the follow-up + for a link says that nothing was appended, that git reads no + `.gitattributes` that is a link, and the exact lines a regular file in + its place needs (#471's review, SHOULD-2). `plan` knows it from the + source tree's mode. """ + if intended == ATTRIBUTES and attributes_link: + return _attributes_link_follow_up(intended, actual, beside) if intended == ATTRIBUTES: return ( f"keep the block at the end of {intended} that opens with " @@ -830,9 +872,7 @@ def collision_follow_up(intended: str, actual: str, "contracts/shape-pin.yaml records; without it a Windows clone " "reports shape-copy-drift on each one. None of the source's own " f"lines is changed. Merge the rest of {actual} into {intended} " - "only if the whole project should check out with LF. A " - f"{intended} that is a symlink is not written through: add the " - "block's lines where it leads") + "only if the whole project should check out with LF") if intended in ASSISTANT_INSTRUCTIONS: return ( f"add the line `{SHAPE_POINTER_LINE}` to the existing {intended} " @@ -860,6 +900,31 @@ def collision_follow_up(intended: str, actual: str, "bootstrap before both merges are made") +def _attributes_link_follow_up(intended: str, actual: str, + beside: dict | None) -> str: + """The follow-up for a source `.gitattributes` that is a symlink: no + block was appended, nothing git reads gives the root's pinned copies a + line ending, and these are the lines a regular file needs. + + The copies are the pinned ones that kept their own names, in the pin's + order: every one `beside` does not send under the collision directory, + as `eol_block_paths` reads them out of what was written.""" + pinned = [*COPIED_VERBATIM, *(rel for _, rel in COPIED_FROM_SHAPE)] + lines = ", ".join(f"`{line}`" for line in eol_block( + [path for path in pinned if path not in (beside or {})]).splitlines()) + return ( + f"{intended} is a symlink, so the split appended nothing to it (it " + f"never writes through a link) and wrote the shape's copy beside it " + f"as {actual}, which governs only its own directory. Git reads no " + f"{intended} that is a symlink, so no line-ending rule reaches the " + "pinned copies outside it: a Windows clone writes them with CRLF and " + "reports shape-copy-drift on each one. To give every clone one, " + f"replace the link with a regular {intended} holding the lines of " + "the file it leads to that should still apply, then these, one per " + f"line: {lines}. Adding the same lines to a clone's " + "`.git/info/attributes` fixes that clone alone") + + def copy_in_the_way(target: Path, actual: str) -> str | None: """What stands where the shape's copy `actual` (`shape/README.md`) is to be written under `target`, in words; None when the way is free. diff --git a/tests/test_adopt_attributes_eol_block.py b/tests/test_adopt_attributes_eol_block.py index e2ec9e9e..0ee411cd 100644 --- a/tests/test_adopt_attributes_eol_block.py +++ b/tests/test_adopt_attributes_eol_block.py @@ -26,6 +26,7 @@ import importlib.util import os +import subprocess import sys from pathlib import Path @@ -144,6 +145,18 @@ def test_a_new_copy_joins_the_block_before_the_lines_after_it(): + after) +def test_the_last_block_is_extended_when_the_source_held_the_head_already(): + """A source that already held a copy of the head line gets the + adoption's block after it, so the LAST head is the block: a new line + goes there, after the source's later `* text eol=crlf`, which would + otherwise win over it (#471's review, NIT-1).""" + earlier = (f"{EOL_BLOCK_HEAD}\n/Makefile text eol=lf\n".encode(UTF8) + + b"* text eol=crlf\n") + data = earlier + COPIES_BLOCK + assert extend_eol_block(data, ["AGENTS-x.md"]) == ( + data + b"/AGENTS-x.md text eol=lf\n") + + def test_extending_with_copies_already_named_changes_nothing(): data = OWN + COPIES_BLOCK assert extend_eol_block(data, COPIES) == data @@ -236,6 +249,46 @@ def test_an_attributes_symlink_is_not_written_through(tmp_path): assert (target / "config" / "attributes").read_bytes() == OWN +def index_only_link(repo: Path, link: str, target: str) -> None: + """`link -> target` in git's INDEX only, mode 120000 with the target as + its blob, as a tree records it: no symlink on the disk, so no privilege + is needed on any platform.""" + blob = subprocess.run(["git", "hash-object", "-w", "--stdin"], + cwd=str(repo), input=target, capture_output=True, + text=True, check=True) + git("update-index", "--add", "--cacheinfo", + f"120000,{blob.stdout.strip()},{link}", cwd=repo) + + +def test_a_link_checked_out_as_a_plain_file_is_not_written_through(tmp_path): + """Git for Windows' default `core.symlinks=false` checks a tracked + symlink out as a PLAIN FILE holding the path it leads to, so the disk + says "not a link" there. The append used to land in the link itself: + the split kept mode 120000 over the old target followed by the block, + and the verification called the source's file lost after both legs were + pushed (#471's review, MUST-1). Git's index says what the file is, and + this test reads it the same way on every platform.""" + target = target_holding(tmp_path, {"config/attributes": OWN, + "README.md": b"# Thing\n"}) + git("init", "-q", "-b", "main", ".", cwd=target) + git("add", "-A", "--", ".", cwd=target) + index_only_link(target, ATTRIBUTES, "config/attributes") + git("commit", "-qm", "The source", cwd=target) + git("-c", "core.symlinks=false", "checkout", "--", ATTRIBUTES, cwd=target) + assert not (target / ATTRIBUTES).is_symlink() + assert (target / ATTRIBUTES).read_bytes() == b"config/attributes" + staged = git("ls-files", "--stage", "--", ATTRIBUTES, cwd=target).stdout + assert staged.startswith("120000 "), staged + result = materialize_assembly_root(REPO, target, template_values(), + collision_dir=COLLISION_DIR) + assert (ATTRIBUTES, f"{COLLISION_DIR}/{ATTRIBUTES}") in result.collisions + assert result.eol_block == [] + assert (target / ATTRIBUTES).read_bytes() == b"config/attributes" + assert (target / "config" / "attributes").read_bytes() == OWN + assert git("ls-files", "--stage", "--", ATTRIBUTES, + cwd=target).stdout == staged + + # --------------------------------------------------------------------------- # What the human reads: the follow-up and the split commit's message # --------------------------------------------------------------------------- @@ -247,12 +300,41 @@ def test_the_follow_up_says_what_the_block_protects(): assert f"`{EOL_BLOCK_HEAD}`" in said assert "shape-copy-drift" in said and "Windows" in said assert "None of the source's own lines is changed" in said - assert "symlink" in said + # A link gets the other follow-up, below. + assert "symlink" not in said # Every other collision keeps its own wording. assert collision_follow_up("Makefile", "shape/Makefile").startswith( "merge shape/Makefile into Makefile") +def test_the_follow_up_for_a_linked_attributes_file_lists_the_lines_it_needs( + tmp_path): + """A `.gitattributes` that is a symlink gets no block, and git reads + no `.gitattributes` that is one, so the follow-up says that nothing was + appended, gives the remedy that works, and lists the exact lines: the + ones a regular file would have had appended. It used to describe a + block that was never written and send the person to add its lines + where the link leads, which git never reads (#471's review, SHOULD-2).""" + target = target_holding(tmp_path, {ATTRIBUTES: OWN, "Makefile": b"all:\n"}) + result = materialize_assembly_root(REPO, target, template_values(), + collision_dir=COLLISION_DIR) + needed = eol_block(eol_block_paths(result.shape_files, + COLLISION_DIR)).splitlines() + beside = dict(result.collisions) + said = collision_follow_up(ATTRIBUTES, beside[ATTRIBUTES], beside, + attributes_link=True) + assert said.startswith(f"{ATTRIBUTES} is a symlink, so the split " + "appended nothing to it"), said + assert f"Git reads no {ATTRIBUTES} that is a symlink" in said + assert f"replace the link with a regular {ATTRIBUTES}" in said + assert ", ".join(f"`{line}`" for line in needed) in said + assert needed[0] == EOL_BLOCK_HEAD + assert "`/scripts/validate-pins.py text eol=lf`" in said + # The Makefile went beside the source's, under `shape/`. + assert "/Makefile text eol=lf" not in said + assert "keep the block" not in said + + def split_message(adopter, appended=()) -> str: return adopter._split_message( {"assembly": "Northwind", "spec": "Northwind-spec", @@ -290,21 +372,25 @@ def pin_of(paths) -> bytes: ROOT_COPIES = ["Makefile", "scripts/validate-pins.py"] -def commit_files(repo: Path, files: dict, message: str) -> str: +def commit_files(repo: Path, files: dict, message: str, + links: dict | None = None) -> str: for name, body in files.items(): (repo / name).parent.mkdir(parents=True, exist_ok=True) (repo / name).write_bytes(body) git("add", "-A", "--", ".", cwd=repo) + for link, target in (links or {}).items(): + index_only_link(repo, link, target) git("-c", "user.name=T", "-c", "user.email=t@invalid.example", "commit", "-qm", message, cwd=repo) return git("rev-parse", "HEAD", cwd=repo).stdout.strip() def accounted(adopter, tmp_path: Path, attributes: bytes, - pin_path: str = SHAPE_PIN) -> tuple: + pin_path: str = SHAPE_PIN, links: dict | None = None) -> tuple: """A source commit holding `OWN`, and a split commit over it whose root `.gitattributes` is `attributes`: `(lines counted, findings, counts, - the source's oid, after)` from the verification's own steps.""" + the source's oid, after)` from the verification's own steps. `links` + go into the split's tree only (`index_only_link`).""" repo = tmp_path / "repo" repo.mkdir() git("init", "-q", "-b", "main", ".", cwd=repo) @@ -314,12 +400,12 @@ def accounted(adopter, tmp_path: Path, attributes: bytes, ATTRIBUTES: attributes, pin_path: pin_of(PINNED), f"{COLLISION_DIR}/{ATTRIBUTES}": (TEMPLATE / ATTRIBUTES).read_bytes(), "Makefile": b"all:\n", "scripts/validate-pins.py": b"print()\n"}, - "The split") + "The split", links=links) before = {path: oid for path, _, oid, _ in adopter._tree_of(repo, source)} tree = adopter._tree_of(repo, split) after: dict = {} assert adopter._land_the_root_tree(after, tree, before, {}) == [] - lines = adopter._land_the_eol_block(repo, tree, before, after) + lines = adopter._land_the_eol_block(repo, split, tree, before, after) counts, findings = adopter._account_for(before, after, []) return lines, findings, counts, before[ATTRIBUTES], after @@ -344,6 +430,19 @@ def test_the_shapes_pin_is_read_from_shape_when_the_source_kept_its_own( assert (lines, findings) == (2, []) +def test_the_pin_is_read_through_a_link_the_root_kept(adopter, tmp_path): + """`contracts -> meta` kept in the root: the materializer wrote the pin + through the link, to `meta/shape-pin.yaml`, and `_shape_file_findings` + finds it by its pinned name. A lookup among the tree's plain files found + none, so the appended file was `adopt-lost` where main verified the + same source (#471's review, SHOULD-3).""" + lines, findings, _, oid, after = accounted( + adopter, tmp_path, with_eol_block(OWN, ROOT_COPIES), + pin_path="meta/shape-pin.yaml", links={"contracts": "meta"}) + assert (lines, findings) == (2, []) + assert after[ATTRIBUTES] == [f"root:{oid}"] + + def test_the_sources_own_bytes_untouched_need_no_exception(adopter, tmp_path): """No block (no collision, or a symlink): the blob is the source's, and the accounting is the ordinary one.""" diff --git a/tests/test_adopt_ignored_shape_files.py b/tests/test_adopt_ignored_shape_files.py index acd86456..384139f2 100644 --- a/tests/test_adopt_ignored_shape_files.py +++ b/tests/test_adopt_ignored_shape_files.py @@ -31,6 +31,7 @@ import importlib.util import os +import re import shutil import subprocess import sys @@ -813,3 +814,132 @@ def test_a_source_pin_kept_in_the_root_fails_verification_with_the_plan_answer( assert "fix the plan and re-run" in after assert after.rstrip().endswith(f"--delete {BRANCH}"), result.stderr assert git("show", f"{BRANCH}:{PIN}", cwd=source).stdout == own_pin + + +# --------------------------------------------------------------------------- +# #471's review: a linked `.gitattributes`, and a linked `contracts` +# --------------------------------------------------------------------------- + +#: A source whose root `.gitattributes` is a symlink to `config/attributes`. +LINKED_ATTRIBUTES_TREE = { + README: "# Thing\n", + IGNORE_FILE: "__pycache__/\n", + "specs/001-feature/spec.md": "# The feature\n", + "src/app/main.py": "import contracts_reader\n", + "config/attributes": OWN_ATTRIBUTES, +} + + +def planned_and_checked(source: Path, base: Path, env: dict | None = None, + keep: tuple = ()) -> Path: + """The plan for `source`, each path in `keep` answered `root`, checked.""" + plan = base / "adoption-plan.yaml" + written = run_script(ADOPT, "plan", "--source", str(source), "--project", + PROJECT, "--org", "testorg", "--elected-by", + "Test Human", "--elected-on", "2026-09-02", "--out", + str(plan), env=env) + assert written.returncode == 0, written.stderr + written.stdout + for path in keep: + text = plan.read_text(encoding="utf-8") + if f" - path: {path}\n leg: null\n" in text: + resolve(plan, path, "root", "the project's own, kept at the root") + continue + answered = re.search(rf"(?m)^ - path: {re.escape(path)}\n" + r" leg: (spec|code|drop)\n", text) + if answered: + with plan.open("w", encoding="utf-8", newline="\n") as handle: + handle.write(text.replace( + answered.group(0), + f" - path: {path}\n leg: root\n resolution: " + "\"the project's own, kept at the root\"\n", 1)) + assert f" - path: {path}\n leg: root\n" in \ + plan.read_text(encoding="utf-8"), f"{path} is not kept in the root" + checked = run_script(ADOPT, "check", "--plan", str(plan), env=env) + assert checked.returncode == 0, checked.stderr + checked.stdout + return plan + + +@needs_filter_repo +def test_a_linked_attributes_file_checked_out_as_a_plain_file_adopts( + tmp_path): + """Git for Windows' default `core.symlinks=false`, on every platform: the + link is in the source's TREE only (`update-index --cacheinfo`), and every + git the run starts checks it out as a plain file holding its target. + The split used to append the block into that file, so the link's target + became the old one followed by the block, and `execute` printed "none of + its own lines was changed" and then failed `adopt-lost: .gitattributes` + after both legs were pushed; main verified the same source (#471's + review, MUST-1). Now nothing is written through, the split holds the + source's link as it was, and the plan's follow-up says no block was + appended and lists the lines a regular file needs (SHOULD-2).""" + source = make_source_repo(tmp_path / "Thing", tree=LINKED_ATTRIBUTES_TREE) + blob = subprocess.run(["git", "hash-object", "-w", "--stdin"], + cwd=str(source), input="config/attributes", + capture_output=True, text=True, check=True) + link = blob.stdout.strip() + git("update-index", "--add", "--cacheinfo", + f"{SYMLINK},{link},{ATTRIBUTES}", cwd=source) + commit_index(source, "Link our attributes") + env = command_scope("core.symlinks", "false") + plan = planned_and_checked(source, tmp_path, env, + keep=(ATTRIBUTES, "config/")) + planned = plan.read_text(encoding="utf-8") + assert f"{ATTRIBUTES} is a symlink, so the split appended nothing" \ + in planned, planned + assert EOL_BLOCK_HEAD in planned + + result = run_script(ADOPT, "execute", "--plan", str(plan), "--yes", + "--local-remote-dir", str(tmp_path / "remotes"), + "--work-dir", str(tmp_path / "work"), env=env) + + output = result.stderr + result.stdout + assert result.returncode == 0, output + assert "adoption verified" in result.stdout, output + assert f"appended to {ATTRIBUTES}" not in result.stdout, output + entry = git("ls-tree", BRANCH, "--", ATTRIBUTES, cwd=source).stdout + assert entry.split("\t")[0].split() == [SYMLINK, "blob", link], entry + message = git("log", "-1", "--format=%B", BRANCH, cwd=source).stdout + assert f"APPENDED TO {ATTRIBUTES}" not in message + assert f"{ATTRIBUTES} is a symlink, so the split appended nothing" \ + in " ".join(message.split()), message + + +@needs_filter_repo +@needs_disk_symlinks +def test_own_attributes_and_a_linked_contracts_kept_in_the_root_adopt( + tmp_path): + """The source keeps its own `.gitattributes` AND `contracts -> meta` at + the root. The materializer writes the shape's pin through the link, to + `meta/shape-pin.yaml`, and the verification read the pin among the + split's plain files only, found none, and called the appended + `.gitattributes` lost where main verified the same source (#471's + review, SHOULD-3). It reads the pin through the links now, as the + shape-file check does.""" + source = make_source_repo( + tmp_path / "Thing", + tree={README: "# Thing\n", IGNORE_FILE: "__pycache__/\n", + "specs/001-feature/spec.md": "# The feature\n", + "src/app/main.py": "import contracts_reader\n", + "meta/README.md": "# Project metadata\n", + ATTRIBUTES: OWN_ATTRIBUTES}) + symlink(source / "contracts", "meta") + commit_all(source, "Link contracts to meta") + plan = planned_and_checked(source, tmp_path, + keep=("contracts", "meta/", ATTRIBUTES)) + + result = run_script(ADOPT, "execute", "--plan", str(plan), "--yes", + "--local-remote-dir", str(tmp_path / "remotes"), + "--work-dir", str(tmp_path / "work")) + + output = result.stderr + result.stdout + assert result.returncode == 0, output + assert "adoption verified" in result.stdout, output + assert f"root {ATTRIBUTES} is the source's own bytes, then the " \ + "shape's block" in result.stdout, output + modes = {line.split("\t", 1)[1]: line.split(" ", 1)[0] + for line in git("ls-tree", "-r", BRANCH, + cwd=source).stdout.splitlines()} + assert modes["contracts"] == SYMLINK + assert "meta/shape-pin.yaml" in modes + text = git("show", f"{BRANCH}:{ATTRIBUTES}", cwd=source).stdout + assert text.startswith(f"{OWN_ATTRIBUTES}{EOL_BLOCK_HEAD}\n"), text From 4aba2bd04932e81957f0b63f72a7acef8cb7fad9 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 13 Oct 2026 12:17:41 +0000 Subject: [PATCH 08/13] README.md: `update-shape.py apply --add` names the file it adds in a root `.gitattributes` block of #471, said where the README lists what `--add` writes, at an unchanged line count; the handbook's README blob is recomputed (#471) Review round 1 of #511, NIT-4: in an adopted root whose `.gitattributes` carries the block, `--add` also writes that file and commits it, and the README's list of what it writes was short by that act. The clause goes into the sentence that lists them, and lines 1078-1090 are re-wrapped to keep README.md at its cap of 1422. The handbook does not list `--add`'s acts, so only its `README.md blob` moves. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 --- README.md | 22 +++++++++++----------- docs/handbook.html | 2 +- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 9a763a96..c61ac374 100644 --- a/README.md +++ b/README.md @@ -1075,17 +1075,17 @@ assumed.** A pin can only record what existed the day it was written, so `AGENTS-shape.md` — added to both root templates after MedxEHR, MedxGlass, MedxScribe and the InkRouter members were cut — reached none of them. `check` therefore also reports **`upstream-added`**: a path the UPSTREAM's copy lists -name AT THE TARGET COMMIT (read out of that commit's own -`shape_materialize.py`, so the answer is the target's lists and not this -checkout's) that the pin has no row for and the root has no file at. It counts -as something to do, so `check` still exits 1. `apply` writes NONE of them -unless `--add ` names one, per file: `apply --at --yes --add -AGENTS-shape.md` copies it, chmods it if the materializer would, appends its -`sha256` row and moves the pin, all inside the same transaction and rolled back -whole if a validator goes red. Without `--add` it says `not added; pass --add -` and re-pins the rest. `--add` refuses a path that is already pinned, -one the target's lists do not name — and one the root ALREADY has a file at, -because two files with one name is a merge and this command copies bytes. A +name AT THE TARGET COMMIT (read out of that commit's own `shape_materialize.py`, +so the answer is the target's lists and not this checkout's) that the pin has no +row for and the root has no file at. It counts as something to do, so `check` +still exits 1. `apply` writes NONE of them unless `--add ` names one, per +file: `apply --at --yes --add AGENTS-shape.md` copies it, chmods it if +the materializer would, names it in a root `.gitattributes` block of #471, +appends its `sha256` row and moves the pin, all inside the same transaction and +rolled back whole if a validator goes red. Without `--add` it says `not added; +pass --add ` and re-pins the rest. `--add` refuses a path that is already +pinned, one the target's lists do not name — and one the root ALREADY has a file +at, because two files with one name is a merge and this command copies bytes. A project that declines an addition has no way to say so yet: it will be reported by every `check` until it is taken or a file arrives at that path. diff --git a/docs/handbook.html b/docs/handbook.html index f60ba49c..c9df89dc 100644 --- a/docs/handbook.html +++ b/docs/handbook.html @@ -1878,7 +1878,7 @@

For AI assistants

to a root .gitattributes the source keeps, and its verification as the source’s bytes plus that block, added (#471) — a page cannot name the commit that contains it, and the blob below is the fact a test checks. - README.md blob 9a763a96877d8abd0c3d507b63f2442bd3bf9ceb. Every command, flag, exit code + README.md blob c61ac374ee5257830f6bd3a0b37164f995d3ab0d. Every command, flag, exit code and file name on this page is quoted from that README and from AGENTS.md.

From cf2cfafc27b751ff1f234d170bec44fe2300ef0d Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 13 Oct 2026 12:32:08 +0000 Subject: [PATCH 09/13] tests: the two index-only-link helpers #471's review round added hand `git hash-object --stdin` the link target as bytes, not text, so no newline translation or locale codec can touch the blob on any platform (#471) #511's review, NIT-6: `index_only_link` in `tests/test_adopt_attributes_eol_block.py` and the source link in `test_a_linked_attributes_file_checked_out_as_a_plain_file_adopts` passed `input=` a str with `text=True`. The target is now encoded to UTF-8 bytes and the oid decoded from git's output. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 --- tests/test_adopt_attributes_eol_block.py | 6 +++--- tests/test_adopt_ignored_shape_files.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_adopt_attributes_eol_block.py b/tests/test_adopt_attributes_eol_block.py index 0ee411cd..d88d301d 100644 --- a/tests/test_adopt_attributes_eol_block.py +++ b/tests/test_adopt_attributes_eol_block.py @@ -254,10 +254,10 @@ def index_only_link(repo: Path, link: str, target: str) -> None: its blob, as a tree records it: no symlink on the disk, so no privilege is needed on any platform.""" blob = subprocess.run(["git", "hash-object", "-w", "--stdin"], - cwd=str(repo), input=target, capture_output=True, - text=True, check=True) + cwd=str(repo), input=target.encode(UTF8), + capture_output=True, check=True) git("update-index", "--add", "--cacheinfo", - f"120000,{blob.stdout.strip()},{link}", cwd=repo) + f"120000,{blob.stdout.decode(UTF8).strip()},{link}", cwd=repo) def test_a_link_checked_out_as_a_plain_file_is_not_written_through(tmp_path): diff --git a/tests/test_adopt_ignored_shape_files.py b/tests/test_adopt_ignored_shape_files.py index 66d3969a..46fddf44 100644 --- a/tests/test_adopt_ignored_shape_files.py +++ b/tests/test_adopt_ignored_shape_files.py @@ -874,9 +874,9 @@ def test_a_linked_attributes_file_checked_out_as_a_plain_file_adopts( appended and lists the lines a regular file needs (SHOULD-2).""" source = make_source_repo(tmp_path / "Thing", tree=LINKED_ATTRIBUTES_TREE) blob = subprocess.run(["git", "hash-object", "-w", "--stdin"], - cwd=str(source), input="config/attributes", - capture_output=True, text=True, check=True) - link = blob.stdout.strip() + cwd=str(source), input=b"config/attributes", + capture_output=True, check=True) + link = blob.stdout.decode("utf-8").strip() git("update-index", "--add", "--cacheinfo", f"{SYMLINK},{link},{ATTRIBUTES}", cwd=source) commit_index(source, "Link our attributes") From d131876fe25c1fb5341c5bc894565053cbaf0147 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 13 Oct 2026 15:02:56 +0000 Subject: [PATCH 10/13] tests/test_adopt_pins.py: the module fixture deletes its copied repository with conftest's `rmtree`, which clears the read-only bit git gives its objects, so the Windows job, which refuses to unlink them, no longer ends 12 adoption-pin tests in an error at setup (#471) #471 folds in #497's `git-filter-repo` install, so the adoption suites ran on a Windows runner for the first time, and the run was green but for 12 errors, all at the module fixture `adopted` of tests/test_adopt_pins.py. The fixture copies a repository with `shutil.copytree` to give the scaffold a pin source, and deletes the copy with `shutil.rmtree` before `check` so the digest is proved to have been read from it. Git writes its loose objects and packs READ-ONLY, and Windows refuses to unlink a read-only file, so the delete raised `PermissionError: [WinError 5]` there and succeeded everywhere else. `tests/conftest.py`'s `rmtree` clears the read-only bit and retries the one call that failed, and is what every other test that deletes a tree holding a `.git` already uses. The fixture now calls it. Test-only: no tool changes. The other `shutil.rmtree` calls under tests/ were each read and none needs the same: test_cli_reference.py:391 and test_setup_sh.py:496 pass `ignore_errors=True` over a scratch directory of generated text and over a checkout the Windows job never runs, test_family.py:1580 removes a submodule's working directory, which holds a `.git` FILE and no objects, and test_update_shape_landing.py:1284 removes a plain directory the test made. Checked against main's f391e58d (#435), which asks the keys of a plan file their types before anything is read: nothing this PR adds reads `source:`, `legs:`, `paths:`, `allow_empty_legs:` or `pins:` of a plan, and its one plan-writing change, the `.gitattributes` follow-up, is a string in the same `follow_ups:` list, which the new checks accept (`plan ok`). A plan holding `source: 5`, `legs: []`, `paths: {}` and `pins: true` is named by key by `check` (exit 1) and refused by `execute` (exit 2) before a work directory is made. Nothing changed for that. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Sonnet 5.5 --- tests/test_adopt_pins.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_adopt_pins.py b/tests/test_adopt_pins.py index c176bf12..d2729169 100644 --- a/tests/test_adopt_pins.py +++ b/tests/test_adopt_pins.py @@ -45,7 +45,8 @@ from conftest import (ADOPT, FILE_PROTOCOL, REPO, SCAFFOLD, SYNTHETIC_TREE, WINDOWS_SKIP, as_human, findings, git, make_source_repo, - planned, refused_before_any_leg, resolve, run_script) + planned, refused_before_any_leg, resolve, rmtree, + run_script) sys.path.insert(0, str(REPO / "scripts")) import shape_materialize # noqa: E402 @@ -223,7 +224,7 @@ def adopted(request, tmp_path_factory, glass): assert planned_run.returncode == 0, planned_run.stderr + planned_run.stdout for path, leg in ANSWERS: resolve(plan, path, leg) - shutil.rmtree(readable) + rmtree(readable) checked = run_script(ADOPT, "check", "--plan", str(plan), env=env) executed = run_script(ADOPT, "execute", "--plan", str(plan), "--yes", "--local-remote-dir", str(base / "remotes"), From 91ed4a571d88e239e54e6db16511b13ff6a06f7d Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 13 Oct 2026 23:04:52 +0000 Subject: [PATCH 11/13] shape_materialize.py: the `.gitattributes`-symlink paragraph of `collision_follow_up`'s docstring moves up beside the docstring's first paragraph, so the docstring's end, which #512 (#502) also extends, merges without a hand resolution (#471) This PR and #512 each appended a paragraph at the END of `collision_follow_up`'s docstring, so a merge of one into the other, once the first lands, conflicts in a .py file and needs a hand resolution. The paragraph about a `.gitattributes` that is a symlink now sits straight after the docstring's first paragraph, as its own paragraph with one blank line on each side, and the docstring ends, as it does on main, with "and says both merges come first (#493's review, F2)." Not one word changes and nothing else in any file: the sorted lines of the file are equal before and after. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Sonnet 5.5 --- scripts/shape_materialize.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/scripts/shape_materialize.py b/scripts/shape_materialize.py index d3f69959..222252ce 100644 --- a/scripts/shape_materialize.py +++ b/scripts/shape_materialize.py @@ -841,6 +841,13 @@ def collision_follow_up(intended: str, actual: str, actually happened. Two spellings of the same instruction is how the plan a human approves stops matching the commit message they read afterwards. + A `.gitattributes` THAT IS A SYMLINK gets no block (`_append_eol_block`), + so `attributes_link` says which of two things happened, and the follow-up + for a link says that nothing was appended, that git reads no + `.gitattributes` that is a link, and the exact lines a regular file in + its place needs (#471's review, SHOULD-2). `plan` knows it from the + source tree's mode. + THE MAKEFILE'S MERGE IS WHAT BRINGS THE SHAPE'S MAKE TARGETS (#480). Until it is made, `make bootstrap` in the root exits 2 with `No rule to make target 'bootstrap'`, so the follow-up says that, and what runs meanwhile: @@ -850,13 +857,6 @@ def collision_follow_up(intended: str, actual: str, Where the root's `scripts/bootstrap.py` is the source's own, that command runs the source's script and exits 0, so the follow-up names no command, and says both merges come first (#493's review, F2). - - A `.gitattributes` THAT IS A SYMLINK gets no block (`_append_eol_block`), - so `attributes_link` says which of two things happened, and the follow-up - for a link says that nothing was appended, that git reads no - `.gitattributes` that is a link, and the exact lines a regular file in - its place needs (#471's review, SHOULD-2). `plan` knows it from the - source tree's mode. """ if intended == ATTRIBUTES and attributes_link: return _attributes_link_follow_up(intended, actual, beside) From 8579d4f518dcc43a88f8f49db162599eadb5b08a Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Wed, 14 Oct 2026 01:16:09 +0000 Subject: [PATCH 12/13] shape_materialize.py: the follow-up for a source's own `.gitattributes` says what happens to the pin row of its copy, as #512 (#502) has every other pinned copy's say, so a person who merges the rest of `shape/.gitattributes` into the root file and removes it is told that `update-shape.py check` reports the row `merged-away` and `apply` drops it (#471) #512 (#502) ended the follow-up of every pinned collision copy with "Then remove shape/X: `update-shape.py check` reports its pin row `merged-away`, and `apply` drops the row". The `.gitattributes` branch of `collision_follow_up` returns before that sentence. Its follow-up said to merge the rest of `shape/.gitattributes` into the root file if the whole project should check out with LF, and said nothing of the row that removing the copy leaves, on which the project's own `validate-pins.py` is red until `apply` drops it. The sentence is added in #512's words, under the condition the merge already carries: "Merge the rest of shape/.gitattributes into .gitattributes only if the whole project should check out with LF, and only then remove shape/.gitattributes: ...". A copy that is kept, which is what the follow-up leaves by default, keeps its row, and that row stays `unchanged`. The follow-up for a `.gitattributes` that is a symlink does not get it. That follow-up sends nobody to merge or remove the copy, only to replace the link with a regular file holding the source's lines and the block's. The copy stays, pinned, so a row sentence there would describe a step nobody was given. tests/test_adopt_attributes_eol_block.py holds both follow-ups to that, and the regular one's new ending exactly. tests/test_update_shape_merged_away.py turns its fixture's body into one helper and adopts #502's own source a second time, with a root `.gitattributes` of its own. It then does the follow-up as a person does it: the copy is merged into the root file and removed, `check` calls its row `merged-away`, and its NEXT, pasted, drops that row and nothing else. The root file keeps the merge's bytes, block and all, and `validate-pins.py` and the doctor are green. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Sonnet 5.5 --- scripts/shape_materialize.py | 11 +++- tests/test_adopt_attributes_eol_block.py | 11 ++++ tests/test_update_shape_merged_away.py | 78 +++++++++++++++++++++++- 3 files changed, 97 insertions(+), 3 deletions(-) diff --git a/scripts/shape_materialize.py b/scripts/shape_materialize.py index eb3fcd88..a82c70cc 100644 --- a/scripts/shape_materialize.py +++ b/scripts/shape_materialize.py @@ -863,6 +863,13 @@ def collision_follow_up(intended: str, actual: str, the merged copy is removed that row names a file that is not there, and the project's own `validate-pins.py` is red until `update-shape.py apply` drops it. A rendered file, `README.md` among them, has no row. + `.gitattributes`'s follow-up says it too, under the condition its merge + already carries: the rest of the copy is merged only if the whole + project should check out with LF, so the copy is removed only then + (#471). It returned before that sentence, and a person who merged it + and removed it was told nothing of the row `check` then reports. The + follow-up for a `.gitattributes` that is a symlink does not say it: it + never sends anyone to merge or remove the copy, which stays, pinned. """ if intended == ATTRIBUTES and attributes_link: return _attributes_link_follow_up(intended, actual, beside) @@ -878,7 +885,9 @@ def collision_follow_up(intended: str, actual: str, "contracts/shape-pin.yaml records; without it a Windows clone " "reports shape-copy-drift on each one. None of the source's own " f"lines is changed. Merge the rest of {actual} into {intended} " - "only if the whole project should check out with LF") + "only if the whole project should check out with LF, and only " + f"then remove {actual}: `update-shape.py check` reports its pin " + "row `merged-away`, and `apply` drops the row") if intended in ASSISTANT_INSTRUCTIONS: return ( f"add the line `{SHAPE_POINTER_LINE}` to the existing {intended} " diff --git a/tests/test_adopt_attributes_eol_block.py b/tests/test_adopt_attributes_eol_block.py index d88d301d..6314c56b 100644 --- a/tests/test_adopt_attributes_eol_block.py +++ b/tests/test_adopt_attributes_eol_block.py @@ -300,6 +300,14 @@ def test_the_follow_up_says_what_the_block_protects(): assert f"`{EOL_BLOCK_HEAD}`" in said assert "shape-copy-drift" in said and "Windows" in said assert "None of the source's own lines is changed" in said + # The copy is merged only on that condition and removed only after it, + # and the row the removal leaves is said in #512's words for every other + # pinned copy (#502): it returned before them, and said nothing. + assert said.endswith( + f"Merge the rest of {COLLISION_DIR}/{ATTRIBUTES} into {ATTRIBUTES} " + "only if the whole project should check out with LF, and only then " + f"remove {COLLISION_DIR}/{ATTRIBUTES}: `update-shape.py check` " + "reports its pin row `merged-away`, and `apply` drops the row"), said # A link gets the other follow-up, below. assert "symlink" not in said # Every other collision keeps its own wording. @@ -333,6 +341,9 @@ def test_the_follow_up_for_a_linked_attributes_file_lists_the_lines_it_needs( # The Makefile went beside the source's, under `shape/`. assert "/Makefile text eol=lf" not in said assert "keep the block" not in said + # It never sends anyone to merge or remove the shape's copy, which stays + # pinned, so it says nothing of a row that removal would leave. + assert "merged-away" not in said and "remove shape/" not in said def split_message(adopter, appended=()) -> str: diff --git a/tests/test_update_shape_merged_away.py b/tests/test_update_shape_merged_away.py index 6532a88b..d6634f9b 100644 --- a/tests/test_update_shape_merged_away.py +++ b/tests/test_update_shape_merged_away.py @@ -69,13 +69,35 @@ #: The pinned collision copies, and the file each was cut for. MERGED = {"shape/.gitignore": ".gitignore", "shape/Makefile": "Makefile"} +#: The same source keeping a root `.gitattributes` of its own (#471): the +#: shape's copy is pinned beside it as `shape/.gitattributes`, and the split +#: appends its line-ending block to the source's file. A rule that changes +#: no line ending, so every platform checks the clone out as it was written. +ATTRIBUTES = ".gitattributes" +ATTRIBUTES_TREE = {**SOURCE_TREE, ATTRIBUTES: "*.png binary\n"} + @pytest.fixture(scope="module") def adopted_here(standard, tmp_path_factory) -> dict: """`SOURCE_TREE` adopted with the standard's own `adopt-project.py`, its pull request merged, and a recursive clone of it.""" - base = tmp_path_factory.mktemp("merged-away") - source = make_source_repo(base / PROJECT, tree=SOURCE_TREE, edits=()) + return adopt_and_clone(standard, tmp_path_factory.mktemp("merged-away"), + SOURCE_TREE) + + +@pytest.fixture(scope="module") +def adopted_with_attributes(standard, tmp_path_factory) -> dict: + """`ATTRIBUTES_TREE`, adopted and cloned as `adopted_here` is.""" + return adopt_and_clone( + standard, tmp_path_factory.mktemp("merged-away-attributes"), + ATTRIBUTES_TREE) + + +def adopt_and_clone(standard: Path, base: Path, tree: dict) -> dict: + """`tree` adopted with the standard's own `adopt-project.py` in `base`, + its pull request merged, and a recursive clone of it: the plan and the + clone.""" + source = make_source_repo(base / PROJECT, tree=tree, edits=()) plan = base / "adoption-plan.yaml" adopt = standard / "adopt-project.py" planned = run_script(adopt, "plan", "--source", str(source), "--project", @@ -389,3 +411,55 @@ def test_the_plans_follow_up_says_what_happens_to_the_row(adopted_here): (readme,) = [item for item in follow_ups if item.startswith("merge shape/README.md into README.md")] assert "pin row" not in readme, readme + + +def test_a_merged_away_attributes_copys_row_goes_and_the_root_file_stays( + standard, adopted_with_attributes, tmp_path): + """The `.gitattributes` copy, end to end (#471, #502). A source keeping + its own root `.gitattributes` gets the shape's copy pinned beside it as + `shape/.gitattributes`, and its follow-up said to merge the rest of that + copy only if the whole project should check out with LF, then returned + before the sentence every other pinned copy's follow-up carries, so + nothing said what removing the copy does to its row. It says it now, in + the same words, and they hold: the copy merged into the root file and + removed, `check` calls its row `merged-away`, its NEXT pasted drops that + row and nothing else, the root file stays as the merge left it, block + and all, and the gate and the doctor are green.""" + copy = f"shape/{ATTRIBUTES}" + follow_ups = load_yaml(adopted_with_attributes["plan"])["follow_ups"] + opening = f"keep the block at the end of {ATTRIBUTES}" + (said,) = [item for item in follow_ups if item.startswith(opening)] + assert said.endswith( + "should check out with LF, and only then remove " + f"{copy}: `update-shape.py check` reports its pin row `merged-away`, " + "and `apply` drops the row"), said + + root = tmp_path / PROJECT + shutil.copytree(adopted_with_attributes["clone"], root, symlinks=True) + before = pin_files(root) + assert copy in {path for path, _ in before}, before + assert ATTRIBUTES not in {path for path, _ in before}, before + merge_away(root, ATTRIBUTES) + merged = git("show", f"HEAD:{ATTRIBUTES}", cwd=root).stdout + + checked = update_shape(standard, root, "check") + assert checked.returncode == 1, checked.stdout + checked.stderr + assert verdicts(checked.stdout)[copy] == "merged-away", checked.stdout + assert "REFUSAL AHEAD" not in checked.stdout, checked.stdout + line = next_line_of(checked.stdout) + argv = shlex.split(line[len("NEXT "):]) + assert argv[1:3] == ["update-shape.py", "apply"], line + applied = run_script(standard / "update-shape.py", *argv[2:]) + assert applied.returncode == 0, applied.stdout + applied.stderr + assert f" dropped {copy} " in applied.stdout, applied.stdout + + assert pin_files(root) == [row for row in before if row[0] != copy], \ + "the copy's row goes, and nothing else in the pin moves" + committed = set(git("show", "--name-only", "--format=", "HEAD", + cwd=root).stdout.split()) + assert ATTRIBUTES not in committed, committed + assert git("show", f"HEAD:{ATTRIBUTES}", cwd=root).stdout == merged + ran = gate(root, "validate-pins.py") + assert ran.returncode == 0, ran.stdout + ran.stderr + again = doctor(standard, root) + assert verdict_line(again).startswith("COMPLIANT (exit 0)"), again.stdout From 9f28d9055f8430d82c4ee57a683111f5f3a0afd0 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Wed, 14 Oct 2026 12:45:15 +0000 Subject: [PATCH 13/13] adopt-project.py: #471's four imports, `ATTRIBUTES`, `eol_block_paths`, `eol_line` and `with_eol_block`, are a `from shape_materialize import` statement of their own, so a pull request that adds names to the shared list above it merges without a hand resolution (#471) The first `from shape_materialize import (...)` statement is the list every change to this file's imports edits: #504 added `SYMLINK_HOPS`, `TOO_MANY_SYMLINKS` and `template_files` to it, and #518 (#181) adds `PLACEHOLDER_RE`, `TEMPLATED` and `render`. Git treats two changes to adjacent lines of one block as one conflict, so #471's names on lines of that same list made #518 conflict there a second time, over names it never touched, and whoever merged it had to rewrite the list by hand. The four names now sit in a statement of their own, after the one that imports #172's shape half, with that statement and a comment between them and the shared list. The shared list reads as main holds it. Nothing else changes: the set of names `adopt-project.py` takes from `shape_materialize` is the same 48, parsed with `ast` before and after, and every other statement of the module is the same. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Sonnet 5.5 --- adopt-project.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/adopt-project.py b/adopt-project.py index 2ad36a7d..136a9a0c 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -165,12 +165,11 @@ tree_digest, ) from shape_materialize import ( # noqa: E402 - ADOPT_MAKEFILE_BLOCK, ATTRIBUTES, SHAPE_REPOSITORY, + ADOPT_MAKEFILE_BLOCK, SHAPE_REPOSITORY, SYMLINK_HOPS, TOO_MANY_SYMLINKS, CommandFailed, Materialized, assembly_root_paths, collision_follow_up, copy_tree, default_reference, election_date, env_commit, escape_what_stdout_cannot_encode, git_init_commit, - eol_block_paths, eol_line, with_eol_block, materialize_assembly_root, naming_block, run, template_files, write_lf, write_paths_listing, ) @@ -190,6 +189,12 @@ CASE_ASIDE as _CASE_ASIDE, MOVE_THE_LEG, first_and_more as _first_and_more, path_overlap as _overlap, shape_path_collisions, ) +# The line-ending block the split appends to a source's own root +# `.gitattributes` (#471): that file's name, which copies the block names, +# the line it writes for one, and the bytes it makes of the source's own. +from shape_materialize import ( # noqa: E402 + ATTRIBUTES, eol_block_paths, eol_line, with_eol_block, +) # What the project's own `validate-manifest.py` would say of the values written # into its manifest, and whether a repository's name can be a directory's: # asked of the plan's values by `plan`, `check` and `execute` (#261).