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/AGENTS.md b/AGENTS.md index 8f4378b3..e579245c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -141,15 +141,16 @@ install command). Seven 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 ae817c5f..f3b378dc 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 @@ -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/adopt-project.py b/adopt-project.py index 9017cb0e..c1766d1e 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -189,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). @@ -2717,7 +2723,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)) @@ -2780,7 +2788,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 @@ -2800,12 +2809,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()] @@ -7493,11 +7507,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() @@ -7888,7 +7908,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)"] @@ -7927,6 +7947,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] @@ -8032,6 +8060,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_commit, split_tree, before, + after) counts, findings = _account_for(before, after, paths_for["drop"]) findings += _shape_file_findings(assembly, split_commit) @@ -8042,9 +8072,74 @@ 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, 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. + + 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`). 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, []) + 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 = _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 + 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 _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) + + def _left_by_the_split(source: Source, urls: dict, names: dict, work_root: Path, branch: str, *, interrupted: bool = False) -> str: diff --git a/docs/handbook.html b/docs/handbook.html index 5b02b5cb..4e01a52b 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 @@ -1387,8 +1390,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 @@ -1880,9 +1884,11 @@

For AI assistants

never the tracking branch, and printing the push of it, added (#487); the installed doctor keeping its clone of the standard, which its NEXT lines run from, added (#500); an adoption’s --project as the source’s own name, added (#499); the - merged-away verdict, whose row apply drops, added (#502) — a - page cannot name the commit that contains it, and the blob below is the fact a test checks. - README.md blob ae817c5fdb324ff60440b959a15f3ad3b2a18b43. Every command, flag, exit code + merged-away verdict, whose row apply drops, added (#502); 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 f3b378dc5704de79ee63972d0ea0be14dc693209. Every command, flag, exit code and file name on this page is quoted from that README and from AGENTS.md.

diff --git a/scripts/shape_materialize.py b/scripts/shape_materialize.py index 925cbe30..a82c70cc 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,8 +666,174 @@ 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. + + 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 + 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] + 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]) + 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:]) + + +#: 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 + 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` 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() \ + 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)) + result.eol_block = paths + + 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 @@ -670,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: @@ -685,7 +863,31 @@ 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) + 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, 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} " @@ -717,6 +919,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. @@ -1975,12 +2202,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) @@ -2010,13 +2243,19 @@ def materialize_assembly_root(shape_root: Path, target: Path, `first_and_more` takes one: between backticks when it is None, which is all the scaffold's own paths need. `adopt-project.py` passes its `_spelled`, because the source it adopts can hold a symlink to any name. + + 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, spell=spell) + 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..6314c56b --- /dev/null +++ b/tests/test_adopt_attributes_eol_block.py @@ -0,0 +1,488 @@ +# 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 subprocess +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" + +#: 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(): + 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_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 + + +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() + + +@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.""" + 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 + + +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.encode(UTF8), + capture_output=True, check=True) + git("update-index", "--add", "--cacheinfo", + 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): + """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 +# --------------------------------------------------------------------------- + + +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 + # 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. + 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 + # 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: + 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, + 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, 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. `links` + go into the split's tree only (`index_only_link`).""" + 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", 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, split, 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_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.""" + 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_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_filter_repo_failure.py b/tests/test_adopt_filter_repo_failure.py index a81d959b..3aee0a04 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): @@ -394,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 diff --git a/tests/test_adopt_ignored_shape_files.py b/tests/test_adopt_ignored_shape_files.py index 706eb480..034bc21d 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 @@ -16,14 +23,15 @@ 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 import importlib.util import os +import re import shutil import subprocess import sys @@ -38,7 +46,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 +539,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 +568,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 @@ -739,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=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") + 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 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_pins.py b/tests/test_adopt_pins.py index 044bd59f..77897adc 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"), diff --git a/tests/test_adopt_submodules.py b/tests/test_adopt_submodules.py index 8dffb47f..5a519fa6 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 42c182fa..7a420fdf 100644 --- a/tests/test_shape_doctor.py +++ b/tests/test_shape_doctor.py @@ -1576,11 +1576,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`. """ diff --git a/tests/test_update_shape_add.py b/tests/test_update_shape_add.py index 8467e476..1aaa97b6 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") @@ -151,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"): @@ -453,6 +459,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/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 diff --git a/update-shape.py b/update-shape.py index ccbd3333..68b33577 100755 --- a/update-shape.py +++ b/update-shape.py @@ -152,9 +152,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, @@ -3057,6 +3058,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 @@ -3237,6 +3269,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) @@ -3244,7 +3278,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())) # A DROPPED ROW IS NOT A PINNED PATH ANY MORE (#502): the pin this # run wrote has no row for it, so it is neither said in the commit