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