Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
a528006
tests.yml: the Windows job installs git-filter-repo 2.47.0, so the 23…
Oct 12, 2026
a8b5064
tests/test_adopt_ignored_shape_files.py: the bootstrap of the source …
Oct 12, 2026
6824181
Merge branch 'main' into ci/windows-adoption-suites
brettheap Oct 13, 2026
9c6a228
adopt-project.py: a source that keeps its own root `.gitattributes` g…
Oct 12, 2026
7c10734
tests/test_update_shape_add.py: the fixture's check that no copy list…
Oct 12, 2026
e2922e8
tests: the notice-following test compares the leg repositories it rea…
Oct 13, 2026
20a188e
Merge origin/main (47b1e06c) into fix/adopt-gitattributes-lf-block, s…
Oct 13, 2026
8f8cf6f
README.md, AGENTS.md and the handbook: a root `.gitattributes` the so…
Oct 13, 2026
ea9c209
adopt-project.py: a source `.gitattributes` git tracks as a symlink g…
Oct 13, 2026
4aba2bd
README.md: `update-shape.py apply --add` names the file it adds in a …
Oct 13, 2026
30922cd
Merge #497 (ci/windows-adoption-suites at 6824181) into fix/adopt-git…
Oct 13, 2026
cf2cfaf
tests: the two index-only-link helpers #471's review round added hand…
Oct 13, 2026
c349ab4
Merge origin/main (f391e58d) into fix/adopt-gitattributes-lf-block, s…
Oct 13, 2026
d131876
tests/test_adopt_pins.py: the module fixture deletes its copied repos…
Oct 13, 2026
cb5f5a7
Merge origin/main (d41cc29) into fix/adopt-gitattributes-lf-block: th…
Oct 13, 2026
c8cb46a
Merge origin/main (89c6a6cb) into fix/adopt-gitattributes-lf-block (#…
Oct 13, 2026
91ed4a5
shape_materialize.py: the `.gitattributes`-symlink paragraph of `coll…
Oct 13, 2026
000b0b0
Merge origin/main (8beb3d92, #512) into fix/adopt-gitattributes-lf-bl…
Oct 14, 2026
7924d75
Merge main b0826e02 (#504's squash for #183 and #178) into fix/adopt-…
Oct 14, 2026
8579d4f
shape_materialize.py: the follow-up for a source's own `.gitattribute…
Oct 14, 2026
9f28d90
adopt-project.py: #471's four imports, `ATTRIBUTES`, `eol_block_paths…
Oct 14, 2026
1ce4f45
Merge origin/main (31219459, #494) into fix/adopt-gitattributes-lf-bl…
Oct 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 15 additions & 8 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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.
Expand Down
22 changes: 11 additions & 11 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down
46 changes: 23 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 `/<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} **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 `<org>/<Project>` as the assembly root, and refuses one with
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <path>` names one, per file: `apply --at <commit> --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
<path>` 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 <path>` names one, per
file: `apply --at <commit> --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 <path>` 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.

Expand Down
105 changes: 100 additions & 5 deletions adopt-project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand All @@ -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()]


Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)"]
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand Down
Loading
Loading