diff --git a/AGENTS.md b/AGENTS.md
index 58bb0772..8f4378b3 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -283,10 +283,10 @@ git -C push -u origin shape/update- # then open a pull request
1. **`check` first, always.** It writes nothing and prints one verdict per
PINNED file: `unchanged`, `upstream-changed`, `locally-modified` (the project
- edited its own copy), `both`, `already-at-target`, `upstream-removed`,
- `unmapped` or `copy-missing`; `apply` refuses the last three. A file with no
- row in `contracts/shape-pin.yaml` is not a shape copy and is none of your
- business — an adopted project merged some of them away on purpose.
+ edited its own copy), `both`, `already-at-target`, `merged-away` (a `shape/`
+ copy merged into its own file and removed: `apply` drops its row),
+ `upstream-removed`, `unmapped` or `copy-missing`; `apply` refuses the last
+ three. A file with no row is not a shape copy and is none of your business.
2. **Show the human the verdicts and let them say yes.** Re-pinning records
which openRepoShape this project is a copy of. Do not pass `--yes` on your
own initiative.
@@ -294,7 +294,7 @@ git -C push -u origin shape/update- # then open a pull request
locally edited file FROM THE PROJECT'S OWN BYTES, which makes the drift
`validate-pins.py` reports today invisible. Whether the project keeps its
edit is the human's decision; the other two exits are to revert it or to
- carry it upstream.
+ carry it upstream. No `NEXT` line passes it: `apply` refuses, and asks.
4. **`both` is a merge, and the tool refuses it unless you say `--accept-local
` too.** Merge by hand, commit, then re-run with `--accept-local
` on the file you merged — that commit goes on the branch `apply` is
diff --git a/README.md b/README.md
index 6bf94d58..ae817c5f 100644
--- a/README.md
+++ b/README.md
@@ -1041,9 +1041,9 @@ HAND: re-copy, re-pin, re-validate. `update-shape.py` is that, as one command:
product: **`unchanged`**, **`upstream-changed`** (the bytes differ between the
pinned commit and the target), **`locally-modified`** (the project's bytes
differ from the pinned digest — drift it introduced) or **`both`**; also
-`already-at-target` (the root has the target's bytes) and `upstream-removed`,
-`unmapped` and `copy-missing`; `apply` refuses the last three. It exits 1 when
-there is anything to do, so it can be a scheduled job.
+`already-at-target` (the root has the target's bytes), `merged-away` (below),
+`upstream-removed`, `unmapped` and `copy-missing`; `apply` refuses the last
+three. It exits 1 when there is anything to do, so it can be a scheduled job.
`apply` copies the `upstream-changed` files, rewrites `contracts/shape-pin.yaml`
(`commit`, `digests.tree_sha256` recomputed over the upstream tree under the
@@ -1066,9 +1066,9 @@ copying the upstream bytes over that would delete it without saying so.
**Only the pin's own rows are RE-SYNCED.** The file list is read from
`contracts/shape-pin.yaml` and never re-derived from this repository's copy
lists. An in-place adoption collides on `Makefile`, `README.md` and
-`.gitignore`; the shape's copies land under `shape/`, a human merges them and
-usually drops the rows. A file with no row is not a shape copy, and re-deriving
-the list would resurrect one the project deliberately merged away.
+`.gitignore`; the shape's copies land under `shape/`. Once a human merges one
+and removes it, `check` calls its row `merged-away` and `apply` drops the row.
+A file with no row is no shape copy; re-deriving the list would resurrect it.
**A file the standard ADDS later is the other half, and it is named, never
assumed.** A pin can only record what existed the day it was written, so
diff --git a/docs/handbook.html b/docs/handbook.html
index 695a9cd1..5b02b5cb 100644
--- a/docs/handbook.html
+++ b/docs/handbook.html
@@ -1416,8 +1416,8 @@
Keeping a project’s shape current
check prints one verdict per file the pin names, and the verdict is the
product: unchanged, upstream-changed (the bytes differ between the
pinned commit and the target), locally-modified (the project’s bytes differ
- from the pinned digest — drift it introduced), both or
- already-at-target (the root already holds the target’s bytes) — or,
+ from the pinned digest — drift it introduced), both,
+ already-at-target (the root already holds the target’s bytes) or merged-away (below) — or,
where there is nothing to compare, upstream-removed (the standard no longer has
the file), unmapped (no file there corresponds to it) or
copy-missing (the root has no such file), each of which apply
@@ -1439,7 +1439,7 @@
Keeping a project’s shape current
Only the pin’s own rows are RE-SYNCED. The file list is read from
contracts/shape-pin.yaml and never re-derived from this repository’s copy
lists — re-deriving it would resurrect a file the project deliberately merged
- away.
+ away. Once a human merges a shape/ copy and removes it, check calls its row merged-away and apply drops the row.
A file the standard ADDS later is reported as upstream-added and counts as
something to do. apply writes NONE of them unless --add <path>
names one, per file; --add refuses a path that is already pinned, one the
@@ -1879,9 +1879,10 @@
For AI assistants
add, bump and remove committing on a branch made first,
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) — a
+ 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 6bf94d583bb15fc6196c1e0c5232281edc723885. Every command, flag, exit code
+ README.md blob ae817c5fdb324ff60440b959a15f3ad3b2a18b43. 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 fa548e83..cbf22478 100644
--- a/scripts/shape_materialize.py
+++ b/scripts/shape_materialize.py
@@ -679,6 +679,12 @@ def collision_follow_up(intended: str, actual: str,
Where the root's `scripts/bootstrap.py` is the source's own, that command
runs the source's script and exits 0, so the follow-up names no command,
and says both merges come first (#493's review, F2).
+
+ AND A PINNED COPY'S FOLLOW-UP SAYS WHAT HAPPENS TO ITS ROW (#502). The
+ shape pin has a row for a copied file at the path it landed on, so once
+ 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.
"""
if intended in ASSISTANT_INSTRUCTIONS:
return (
@@ -692,6 +698,10 @@ def collision_follow_up(intended: str, actual: str,
f"merge {actual} into {intended}: the source repository already has "
"that name, so the shape's copy was written beside it and NOTHING was "
"overwritten")
+ if intended in COPIED_VERBATIM or any(rel == intended
+ for _, rel in COPIED_FROM_SHAPE):
+ merge += (f". Then remove {actual}: `update-shape.py check` reports "
+ "its pin row `merged-away`, and `apply` drops the row")
if intended != "Makefile":
return merge
targets = ", ".join(f"`{target}`" for target in SHAPE_MAKE_TARGETS)
diff --git a/shape-doctor.py b/shape-doctor.py
index 54ea82e6..e78b2482 100755
--- a/shape-doctor.py
+++ b/shape-doctor.py
@@ -1753,7 +1753,9 @@ def check_pins(ctx: Context) -> Row:
output: list[str] = []
row = validator_row(ctx, "pins", "pins", *PINS_GATE,
["--root", str(ctx.root)], output=output)
- return with_gitlink_repairs(ctx, row, "".join(output))
+ whole = "".join(output)
+ return with_merged_away_rows(ctx, with_gitlink_repairs(ctx, row, whole),
+ whole)
def bump_leg_command(shape: Path, root: Path, role: str, commit: str,
@@ -1962,6 +1964,102 @@ def with_gitlink_repairs(ctx: Context, row: Row, output: str) -> Row:
return row
+#: How every `validate-pins.py` since v0 spells a pinned copy that is not
+#: there: the code, and the words after `: ` (#502).
+COPY_MISSING_CODE = "shape-copy-missing"
+COPY_MISSING_TAIL = " is pinned but absent"
+
+
+def merged_away_findings(ctx: Context, output: str) -> dict | None:
+ """`{pinned path: the file it was merged into}` when EVERY finding in
+ the gate's `output` is `shape-copy-missing` for a `shape/` collision
+ copy `update-shape.py` calls merged-away (#502); None when there is
+ none, or when any finding is anything else.
+
+ THE PATH IS READ OUT OF THE GATE'S OWN LINE, `FINDING
+ shape-copy-missing: contracts/shape-pin.yaml: is pinned but
+ absent`, and judged by `update-shape.py`'s `merged_into`, the function
+ `check` classifies the row with, so this row and the `shape currency`
+ row cannot disagree about which rows `apply` drops. A line this cannot
+ read that way is a finding it cannot vouch for, and the answer is None.
+ """
+ us = ctx.update_shape()
+ lead = f"{SHAPE_PIN}: "
+ merged: dict[str, str] = {}
+ for line in output.splitlines():
+ found = FINDING_LINE.match(line)
+ if found is None:
+ continue
+ code, rest = found.group("code", "rest")
+ if code != COPY_MISSING_CODE or not rest.startswith(lead) \
+ or not rest.endswith(COPY_MISSING_TAIL):
+ return None
+ path = rest[len(lead):-len(COPY_MISSING_TAIL)]
+ original = us.merged_into(ctx.root, path)
+ if original is None:
+ return None
+ merged[path] = original
+ return merged or None
+
+
+def with_merged_away_rows(ctx: Context, row: Row, output: str) -> Row:
+ """The `pins` row, naming `update-shape.py check` as its NEXT where every
+ finding the gate reports is a merged-away collision copy's row (#502).
+
+ THE DEFECT. An adopted root that did its follow-up, "merge shape/X into
+ X", and removed the merged copy kept the copy's pin row, so its own
+ `validate-pins.py` said `shape-copy-missing`, and this row's NEXT was
+ that gate, which names no repair. No tool removed the row, and the hand
+ edit of the pin that would is the one AGENTS.md forbids. Brett Heap
+ ruled that the row goes, with `check` naming it and `apply` dropping it,
+ so this row names the same step the `shape currency` row does: `check`,
+ which reports the row `merged-away` and prints the `apply` that drops
+ it behind the human's yes. The reason says so, and the command is the
+ whole NEXT.
+
+ ONLY WHERE THE STEP CLEARS EVERY FINDING, as `with_gitlink_repairs`
+ names `bump-leg.py` only where a bump clears them: any other finding, a
+ gate that refused or crashed, and the row is returned untouched, its
+ NEXT the gate. A NEW ROW rather than this one changed in place, so
+ nothing said about the gate's command stays beside a NEXT that is not
+ the gate.
+
+ AND ONLY WHERE `check` CAN READ THE ROOT (#286). A root that keeps its
+ pin or its manifest as a symlink, or under one, is refused by `check`
+ itself, `update-rewrite-symlink`, before it classifies a row: named
+ here, `check` exited 2 and reported nothing `merged-away`, and the
+ `shape currency` row named replacing the link, not `check`
+ (`linked_record_row`), so both of this row's sentences were false. The
+ same `linked_record` question that row asks keeps this one the gate
+ until the link is replaced; the doctor run again then names `check`.
+ """
+ if row.status != FINDING or row.detail.get("exit") != 1 \
+ or row.detail.get("crashed"):
+ return row
+ merged = merged_away_findings(ctx, output)
+ if merged is None:
+ return row
+ us = ctx.update_shape()
+ if us.linked_record(ctx.root, us.root_kind(ctx.root)) is not None:
+ return row
+ one = len(merged) == 1
+ copies = ", ".join(f"{path} (merged into {original})"
+ for path, original in merged.items())
+ reason = (f"{row.reason}. {copies} "
+ + ("is a collision copy" if one else "are collision copies")
+ + " the root merged away, so `update-shape.py check` reports "
+ + ("it" if one else "them") + " `merged-away`, and the "
+ "`apply` it prints drops "
+ + ("its pin row" if one else "their pin rows")
+ + " in the re-pin commit: the step the `shape currency` row "
+ "names too")
+ return Row(row.id, row.label, row.status, reason,
+ f"{PYTHON} {quote_arg(ctx.shape / 'update-shape.py')} check "
+ f"--root {quote_arg(ctx.root)} --upstream "
+ f"{quote_arg(ctx.shape)}",
+ {**row.detail, "merged_away": merged})
+
+
def check_family(ctx: Context) -> Row:
own, template = FAMILY_GATE
if ctx.manifest_refusal is not None:
@@ -2602,16 +2700,17 @@ def pin_restore(ctx: Context, detail: dict | None = None,
keeping the hunks git merged, as a copy conflicted on its
own is merged, and that `update-shape.py apply
--accept-local` on a copy merged unlike the pin re-pins
- it from the merge. The `shape currency` row names it
- where `check` calls the copy `locally-modified`, as it
- does with the update side's pin taken. With the
- person's side's pin taken, behind the standard the copy
- was merged from, `check` calls it `both`, and the row's
- `apply` is refused `update-conflict` and asks for it
- (review of PR #496). The row never names it for `both`:
- AGENTS.md's rule 3 is never to pass it to make a refusal
- go away, and nothing here tells a copy merged by hand
- from one nobody merged.
+ it from the merge. The `shape currency` row never names
+ it (#501), and the `apply` it names is refused and asks
+ for it: `update-local-drift` where `check` calls the
+ copy `locally-modified`, as it does with the update
+ side's pin taken, and `update-conflict` where it calls
+ it `both`, as it does with the person's side's pin
+ taken, behind the standard the copy was merged from
+ (review of PR #496). Whether the project keeps the merge
+ is the person's word: AGENTS.md's rule 3 is never to
+ pass the flag to make a refusal go away, and nothing
+ here tells a copy merged by hand from one nobody merged.
The manifest and README.md are never named either, though
`apply` writes lines of them: every other line is the
project's own, and one side taken whole drops the other
@@ -2680,8 +2779,9 @@ def pin_restore(ctx: Context, detail: dict | None = None,
~1` for `committed`, every change since the newest one
that reads. Mending the file, or merging it, is the person's, as the
`both` exit of `update-shape.py` says of a copy changed on both sides:
- a merge by hand, then `--accept-local` on it, which is what the `shape
- currency` row names for the same file.
+ a merge by hand, then `--accept-local` on it, on the person's word,
+ which `update-shape.py check` names in that file's row and the `shape
+ currency` row never pre-fills (#501).
"""
root = ctx.root
git_c = f"git -C {quote_arg(root)}"
@@ -2753,11 +2853,13 @@ def pin_restore(ctx: Context, detail: dict | None = None,
after = (". Where a copy as merged differs from the pin, "
"update-shape.py apply --accept-local on that "
"copy re-pins it from the merge. The shape "
- "currency row names it where update-shape.py "
- "check calls the copy locally-modified, and "
- "where check calls it both, the apply the row "
- "names is refused as update-conflict and asks "
- "for --accept-local on that path")
+ "currency row never names it. Where "
+ "update-shape.py check calls the copy "
+ "locally-modified, the apply the row names is "
+ "refused as update-local-drift, and where "
+ "check calls it both, as update-conflict, and "
+ "either refusal asks for --accept-local on that "
+ "path")
then = ". " + ". ".join(said) + ". Then finish"
return "unmerged", (
f"{git_c} checkout {side} -- {quote_arg(pin)}"
@@ -3110,10 +3212,19 @@ def check_shape_currency(ctx: Context) -> Row:
added = [add for add in additions if not add.present]
reported = list(rows) + added
counts = us.counted(reported)
+ # A MERGED-AWAY ROW IS COUNTED WHERE THERE IS ONE (#502, review of PR
+ # #512). The five states every root has a word for are always
+ # printed; without `merged-away` beside them the counts fell one
+ # short of the rows for each such row, and the path `named` below
+ # stood under no count, in the reason of the very row whose `apply`
+ # drops it. Zero is left unsaid, so a root with no such row reads
+ # as it always did.
+ states = (us.UNCHANGED, us.UPSTREAM_CHANGED, us.LOCALLY_MODIFIED,
+ us.BOTH, us.UPSTREAM_ADDED)
+ if counts.get(us.MERGED_AWAY):
+ states += (us.MERGED_AWAY,)
summary = " / ".join(f"{counts.get(state, 0)} {state}"
- for state in (us.UNCHANGED, us.UPSTREAM_CHANGED,
- us.LOCALLY_MODIFIED, us.BOTH,
- us.UPSTREAM_ADDED))
+ for state in states)
by_state: dict[str, list[str]] = {}
for row in reported:
by_state.setdefault(row.state, []).append(row.path)
@@ -3124,8 +3235,12 @@ def check_shape_currency(ctx: Context) -> Row:
"paths": by_state, "conflicts": conflicts,
"behind": bool(counts.get(us.UPSTREAM_CHANGED)
or counts.get(us.UPSTREAM_ADDED)),
+ # A MERGED-AWAY ROW IS DRIFT TOO (#502): the pin names a
+ # copy the tree does not hold, and `apply` drops the row.
"drifted": bool(counts.get(us.LOCALLY_MODIFIED)
- or counts.get(us.BOTH) or conflicts)}
+ or counts.get(us.BOTH)
+ or counts.get(us.MERGED_AWAY)
+ or conflicts)}
where = f"pinned {pinned[:12]}, this standard {target[:12]}"
note = readme_shape_note(ctx, us, upstream.repository, pinned, detail)
moved = [row for row in reported if row.state != us.UNCHANGED]
@@ -3172,23 +3287,29 @@ def check_shape_currency(ctx: Context) -> Row:
"--branch "
f"{quote_arg('shape/update-' + target[:12])}"
f"{lane_trailer_command()}")
- accept = "".join(f" --accept-local {quote_arg(row.path)}"
- for row in rows
- if row.state == us.LOCALLY_MODIFIED)
named = ", ".join(row.path for row in moved[:4])
if len(moved) > 4:
named += f", and {len(moved) - 4} more"
# The `check` is the NEXT, and the `apply` that follows the human's
- # yes is the row's `why`, `--accept-local` and the lane's trailer
- # included (#111): it is the second of two acts, never pasted with
- # the first, and the ` # ` that joined them is what #277 removed.
+ # yes is the row's `why`, the lane's trailer included (#111): it is
+ # the second of two acts, never pasted with the first, and the ` # `
+ # that joined them is what #277 removed.
+ #
+ # NO `--accept-local` ON THAT `apply` (#501). It carried one for
+ # every `locally-modified` row, and pasted, it re-pinned the
+ # project's edit as the standard: `update-local-drift`, the refusal
+ # that asks the human whether the project keeps it, never fired.
+ # Brett Heap ruled on 2026-10-13, "Stop pre-filling it", so the
+ # `apply` meets that refusal, whose remediation names the three
+ # exits, and `update-shape.py check` names the flag in the row's
+ # detail. `both` was never pre-filled: its hand merge comes first.
return Row("shape-currency", LABEL_SHAPE_CURRENCY, FINDING,
f"{summary} ({where}): {named}{note}",
f"{PYTHON} "
f"{quote_arg(ctx.shape / 'update-shape.py')} check --root "
f"{quote_arg(ctx.root)} --upstream {quote_arg(ctx.shape)}",
detail,
- why=f"then apply --at {quote_arg(target)} --yes{accept} "
+ why=f"then apply --at {quote_arg(target)} --yes "
f"--branch {quote_arg('shape/update-' + target[:12])}"
f"{lane_trailer_command()}")
finally:
@@ -6401,12 +6522,20 @@ def drifted_file_reasons(counts: dict) -> list:
A state is named only when its count is non-zero: a verdict line reading
`0 unmapped` would be a parenthetical contradicting the words above it.
+ `merged-away` IS NAMED LAST (#502): a `shape/` collision copy merged into
+ the file it collided with and removed, whose row `update-shape.py apply`
+ drops. Until it does, the pin names a copy the tree does not hold, which
+ is what makes the project's own `validate-pins.py` red, so the verdict
+ names the cause and not the `pins` row.
+
Split out of `verdict_for` for #134.
"""
+ merged = ("merged-away",)
edited = ("locally-modified", "both")
unmapped = ("upstream-removed", "unmapped", "copy-missing",
"copy-is-link")
return [f"{counts[state]} {state}" for state in edited + unmapped
+ + merged
if counts.get(state)]
diff --git a/tests/test_doctor_next_is_a_command.py b/tests/test_doctor_next_is_a_command.py
index 44fc7bd5..3a7f1b86 100644
--- a/tests/test_doctor_next_is_a_command.py
+++ b/tests/test_doctor_next_is_a_command.py
@@ -168,9 +168,10 @@ def _shape_currency_moved(standard, project, holder, tmp_path):
+ "\n# a local edit nobody carried upstream\n",
encoding="utf-8")
row = behind_row(ahead, project, LANE)
- # The `apply` -- its `--accept-local` and the lane's trailer with it --
- # is the row's `why`, and nothing of it is on the NEXT.
- assert f"--accept-local {DRIFT_TARGET}" in row["why"], row["why"]
+ # The `apply` -- the lane's trailer with it -- is the row's `why`, and
+ # nothing of it is on the NEXT. Neither carries `--accept-local`: the
+ # flag is the person's to add, never the row's (#501).
+ assert "--accept-local" not in row["why"], row["why"]
assert row["why"].endswith(f" --trailer 'Lane: {LANE}'"), row["why"]
for moved in ("apply", "--accept-local", "--trailer"):
assert moved not in row["next"], (moved, row["next"])
diff --git a/tests/test_shape_doctor.py b/tests/test_shape_doctor.py
index 4c1902be..42c182fa 100644
--- a/tests/test_shape_doctor.py
+++ b/tests/test_shape_doctor.py
@@ -262,6 +262,14 @@ def test_an_edited_shape_copy_is_drift_and_names_update_shape(standard,
FOR — and a verdict of `INVALID (pins)` would send the reader at the
symptom while the exit is `update-shape.py`. Both rows are printed, and
this asserts both.
+
+ THE FIX IS NAMED WITHOUT `--accept-local` (#501). This test pinned the
+ hand-over of that flag on purpose, on the reasoning that a reader would
+ know not to paste it; but AGENTS.md tells the reader to run the command
+ a row names, and pasted, it re-pinned the project's edit as the standard
+ before `update-local-drift` could ask the human whether the project keeps
+ it. Brett Heap ruled on 2026-10-13, "Stop pre-filling it", so the
+ assertion is now the opposite one, in every line the report prints.
"""
edited = project / DRIFT_TARGET
edited.write_text(edited.read_text(encoding="utf-8")
@@ -273,10 +281,10 @@ def test_an_edited_shape_copy_is_drift_and_names_update_shape(standard,
result.stdout
assert DRIFT_TARGET in result.stdout
assert "update-shape.py" in result.stdout
- assert f"--accept-local {DRIFT_TARGET}" in result.stdout, (
- "a `locally-modified` row must hand over the exact flag that re-pins "
- "it, which is the one AGENTS.md says never to pass on your own "
- "initiative")
+ assert "--accept-local" not in result.stdout, (
+ "a `locally-modified` row must not hand over the flag that re-pins "
+ "it: whether the project keeps its edit is the human's decision, and "
+ "the `apply` it names asks it by refusing `update-local-drift`")
# The pins row is red too, and is still printed with its own next step.
rows = rows_of(doctor(standard, project, "--json"))
assert rows["pins"]["status"] == "FINDING", rows["pins"]
@@ -4532,7 +4540,7 @@ def test_a_readme_conflicted_over_the_projects_own_lines_is_left_to_a_person(
README.md holds no `Shape:` line here, so nothing in it is the shape's,
and where it holds one every other line is still the project's own.
Taken whole, edit-a's paragraph went with it (review of PR #267). The
- NEXT takes the pin alone, and its note and `left_for_a_person` say that
+ NEXT takes the pin alone, and its `why` and `left_for_a_person` say that
README.md is a person's to merge.
"""
readme = project / "README.md"
@@ -5631,7 +5639,7 @@ def test_a_copy_conflicted_on_its_own_is_shown_and_merged_by_a_person(
def test_a_conflicted_copys_next_agrees_with_shape_currency_and_agents_md(
standard, project):
- """P10b. In the same report the `shape currency` row names `apply
+ """P10b. In the same report the `shape currency` row named `apply
--accept-local` on the copy, which keeps the local bytes, while the
`manifest kinds` row took one side whole. AGENTS.md says `both` is a
merge: by hand, commit, then `--accept-local` on the file merged.
@@ -5640,12 +5648,18 @@ def test_a_conflicted_copys_next_agrees_with_shape_currency_and_agents_md(
are one path: the merge by hand, then the `shape currency` row's own
NEXT, run as its note spells it, ends COMPLIANT with every line of
both sides in the copy and its pin.
+
+ THE FLAG IS THE PERSON'S, NOT THE ROW'S (#501). The `why`'s `apply`
+ carries no `--accept-local`: run as printed it is refused
+ `update-local-drift`, which asks whether the project keeps the merge,
+ and with the person's `--accept-local` on the copy it ends COMPLIANT.
"""
a_copy_conflicted_on_its_own(project)
kinds, currency = kinds_and_currency(standard, project)
assert "merge it by hand" in kinds["why"], kinds
assert "checkout" not in kinds["next"], kinds["next"]
- assert f"--accept-local {NAMING_COPY}" in currency["why"], currency
+ assert "--accept-local" not in currency["next"] + currency["why"], \
+ currency
merge_by_hand(project / NAMING_COPY)
git("add", "--", NAMING_COPY, cwd=project)
commit_in(project, "the naming copy merged by hand")
@@ -5655,10 +5669,15 @@ def test_a_conflicted_copys_next_agrees_with_shape_currency_and_agents_md(
assert Path(argv[1]).name == "update-shape.py" and argv[2] == "check", \
currency["next"]
then = currency["why"].split()
- assert then[:2] == ["then", "apply"], currency["next"]
- assert then[then.index("--accept-local") + 1] == NAMING_COPY, then
- applied = run_script(standard / "update-shape.py", "apply", "--root",
- str(project), "--upstream", str(standard), *then[2:])
+ assert then[:2] == ["then", "apply"], currency["why"]
+ assert "--accept-local" not in then, then
+ apply = (standard / "update-shape.py", "apply", "--root", str(project),
+ "--upstream", str(standard), *then[2:])
+ asked = run_script(*apply)
+ assert asked.returncode == 2, asked.stdout + asked.stderr
+ assert "REFUSED update-local-drift" in asked.stderr, asked.stderr
+ assert f"--accept-local {NAMING_COPY}" in asked.stderr, asked.stderr
+ applied = run_script(*apply, "--accept-local", NAMING_COPY)
assert applied.returncode == 0, applied.stdout + applied.stderr
compliant_again(standard, project)
kept = git("show", f"HEAD:{NAMING_COPY}", cwd=project).stdout
@@ -6022,7 +6041,7 @@ def assert_the_merge_is_finished(verdict: str, rows: dict,
def assert_the_pin_alone_is_taken(currency: dict, kinds: dict,
copy: str) -> None:
"""The shape pin's NEXT names the pin alone, both rows print it, and
- its note and `left_for_a_person` give the copy to a person."""
+ its `why` and `left_for_a_person` give the copy to a person."""
assert currency["detail"]["restore"] == "unmerged", currency["detail"]
assert pasted(currency["next"])[3:] == [
"checkout", "--ours", "--", SHAPE_PIN], currency["next"]
@@ -6038,52 +6057,54 @@ def assert_the_pin_alone_is_taken(currency: dict, kinds: dict,
assert "every path named" not in note, note
for words in ("update-shape.py apply --accept-local on that copy "
"re-pins it from the merge",
- "check calls the copy locally-modified",
- "where check calls it both, the apply the row names is "
- "refused as update-conflict and asks for --accept-local "
- "on that path"):
+ "The shape currency row never names it",
+ "check calls the copy locally-modified, the apply the row "
+ "names is refused as update-local-drift",
+ "where check calls it both, as update-conflict, and "
+ "either refusal asks for --accept-local on that path"):
assert words in note, (words, note)
assert_a_plain_next(currency)
def re_pinned_from_the_merge(moved: Path, root: Path, currency: dict,
copy: str, ours: str) -> None:
- """The note's last sentences, followed from the `shape currency` row
+ """The `why`'s last sentences, followed from the `shape currency` row
both ways round (review of PR #496, F1), to COMPLIANT.
- With the update side's pin taken, the copy as merged is
- `locally-modified`, and the row's own `apply` names `--accept-local
- `. With the person's side's pin taken, behind the standard the
- copy was merged from, it is `both`, and the row names no
- `--accept-local`, as AGENTS.md's rule 3 wants: its `apply`, run as
- spelled, is refused `update-conflict`, moves nothing, and asks for
- `--accept-local` on that path. Either way that `apply --accept-local
+ The row's `apply` names no `--accept-local` either way round, as
+ AGENTS.md's rule 3 wants (#501): run as spelled it moves nothing and
+ asks for the flag on that path. With the update side's pin taken, the
+ copy as merged is `locally-modified`, and the refusal is
+ `update-local-drift`; with the person's side's pin taken, behind the
+ standard the copy was merged from, it is `both`, and the refusal is
+ `update-conflict`. Either way the person's `apply --accept-local
` re-pins the copy from the merge and the root is COMPLIANT.
"""
then = currency["why"].split()
- assert then[:2] == ["then", "apply"], currency["next"]
+ assert then[:2] == ["then", "apply"], currency["why"]
args = then[2:]
apply = (moved / "update-shape.py", "apply", "--root", str(root),
"--upstream", str(moved))
+ assert "--accept-local" not in args, then
+
+ def held() -> tuple:
+ return (git("rev-parse", "HEAD", cwd=root).stdout,
+ git("branch", "--list", cwd=root).stdout,
+ git("status", "--porcelain=v1", cwd=root).stdout,
+ (root / copy).read_bytes())
+
+ before = held()
+ refused = run_script(*apply, *args)
+ said = refused.stdout + refused.stderr
+ assert refused.returncode == 2, said
if ours == "upgrade":
- assert args[args.index("--accept-local") + 1] == copy, then
+ assert "update-local-drift" in said, said
+ assert f"--accept-local {copy}" in said, said
else:
- assert "--accept-local" not in args, then
-
- def held() -> tuple:
- return (git("rev-parse", "HEAD", cwd=root).stdout,
- git("branch", "--list", cwd=root).stdout,
- git("status", "--porcelain=v1", cwd=root).stdout,
- (root / copy).read_bytes())
-
- before = held()
- refused = run_script(*apply, *args)
- said = refused.stdout + refused.stderr
- assert refused.returncode == 2, said
assert "update-conflict" in said and f" {copy}: " in said, said
assert "--accept-local on that path" in said, said
- assert held() == before, "the refused apply moved something"
- args = [*args, "--accept-local", copy]
+ assert held() == before, "the refused apply moved something"
+ args = [*args, "--accept-local", copy]
applied = run_script(*apply, *args)
assert applied.returncode == 0, applied.stdout + applied.stderr
compliant_again(moved, root)
@@ -6108,9 +6129,10 @@ def test_a_copy_conflicted_with_the_shape_pin_is_left_to_a_person(
it is in, where it said `project` while its NEXT overwrote the file.
The next run names the copy's read-only `diff`, and the doctor is not
COMPLIANT until a person has merged it and, where the merge differs
- from the pin, re-pinned it with `apply --accept-local`: the `shape
- currency` row names it for a `locally-modified` copy, and the row's
- refused `apply` asks for it on a `both` one. HEAD then holds every
+ from the pin, re-pinned it with `apply --accept-local` on their word:
+ the `shape currency` row's `apply` never names the flag (#501), and
+ refused, it asks for it, `update-local-drift` for a `locally-modified`
+ copy and `update-conflict` for a `both` one. HEAD then holds every
line of both sides, either way round.
Until the merge is committed the verdict is #424's `INVALID (merge)`,
diff --git a/tests/test_update_shape.py b/tests/test_update_shape.py
index 7dd69cb8..7cb6acab 100644
--- a/tests/test_update_shape.py
+++ b/tests/test_update_shape.py
@@ -781,6 +781,48 @@ def test_a_locally_modified_copy_is_refused_by_name(root,
"a refused apply writes nothing at all")
+def test_checks_next_leaves_a_local_edit_to_the_human(root,
+ upstream_and_project):
+ """#501. `check`'s NEXT carried `--accept-local ` for every
+ `locally-modified` row, so the line AGENTS.md says to run, pasted as
+ printed, re-pinned the project's edit as the standard: exit 0, a green
+ `validate-pins.py`, and no question to the human. Brett Heap ruled on
+ 2026-10-13, "Stop pre-filling it".
+
+ The NEXT names no such flag now. Pasted as printed it is refused
+ `update-local-drift`, whose remediation names the three exits, and the
+ row keeps the digest the pin recorded. The row's detail still names the
+ flag, as the human's word, and given, it re-pins from the local bytes.
+ The edit is committed first, as a project's is, so the paste's
+ `--branch` meets the drift refusal and not `update-branch-uncommitted`.
+ """
+ edit_locally(root, LOCAL)
+ git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m",
+ "The project edits its own copy", "--", LOCAL, cwd=root)
+ recorded = pin_rows(root)[LOCAL]
+ checked = check(root, upstream_and_project)
+ assert checked.returncode == 1, checked.stdout + checked.stderr
+ assert verdicts(checked.stdout)[LOCAL] == "locally-modified"
+ line = next_line_of(checked.stdout)
+ assert "--accept-local" not in line, line
+ assert ("the human's decision: revert it, carry it upstream, or, on "
+ f"their word, `apply --accept-local {LOCAL}`") in checked.stdout
+
+ argv = shlex.split(line[len("NEXT "):])
+ assert argv[1:3] == ["update-shape.py", "apply"], line
+ pasted = run_script(UPDATE, *argv[2:])
+ assert_refused_and_unpinned(pasted, root, upstream_and_project,
+ "update-local-drift", LOCAL,
+ "revert each edit", "carry it upstream",
+ f"--accept-local {LOCAL}")
+ assert pin_rows(root)[LOCAL] == recorded, (
+ "the pasted line must not recompute the row from the local bytes")
+
+ worded = run_script(UPDATE, *argv[2:], "--accept-local", LOCAL)
+ assert worded.returncode == 0, worded.stdout + worded.stderr
+ assert pin_rows(root)[LOCAL] == file_sha256(root / LOCAL)
+
+
def test_accept_local_repins_that_file_from_the_root_bytes(
root, upstream_and_project):
edit_locally(root, LOCAL)
diff --git a/tests/test_update_shape_merged_away.py b/tests/test_update_shape_merged_away.py
new file mode 100644
index 00000000..6532a88b
--- /dev/null
+++ b/tests/test_update_shape_merged_away.py
@@ -0,0 +1,391 @@
+# SPDX-License-Identifier: Apache-2.0
+"""A `shape/` collision copy merged into the file it collided with (#502).
+
+AN IN-PLACE ADOPTION KEEPS THE SOURCE'S OWN `.gitignore` AND `Makefile`, and
+writes the shape's copies of them beside the originals, under `shape/`,
+where the shape pin gives each a row. The plan's follow-up says to merge
+`shape/X` into `X`, and the natural last act of that merge is to remove the
+copy. Its row then named a file that is not there: the project's own
+`validate-pins.py`, and with it `bootstrap.py` and CI's `validate`, said
+`shape-copy-missing`; `update-shape.py` called the row `copy-missing` and
+refused `update-conflict` with a remedy, `--accept-local`, that it refused
+in the same words; and no tool removed the row, which left undoing the merge
+or the hand edit of the pin that AGENTS.md forbids.
+
+Brett Heap ruled that the row goes (2026-10-12), and how (2026-10-13):
+"check names, apply drops". So `check` reports such a row `merged-away`,
+`apply` drops it in its re-pin commit behind the same yes as every other
+row, the doctor's `shape currency` and `pins` rows name that step, the
+plan's follow-up says what happens to the row, and a pinned copy deleted
+anywhere else stays `copy-missing`, with a remediation of its own.
+
+ONE REAL ADOPTION, OUT OF THE STANDARD `tests/test_shape_doctor.py` BUILDS,
+into local bare remotes, so the pin, the copies and the doctor all name one
+commit. Every test works on a private copy of its clone. `execute` needs
+`git filter-repo`, so a machine without it skips this file, as
+`tests/test_adopt_e2e.py` does.
+"""
+
+from __future__ import annotations
+
+import json
+import shlex
+import shutil
+import sys
+from pathlib import Path
+
+import pytest
+
+from conftest import REPO, WINDOWS_SKIP, clone_just_pushed, git, \
+ make_source_repo, run_script
+from test_shape_doctor import ( # noqa: F401
+ commit_all, doctor, keep_as_committed_link, rows_of, standard,
+ verdict_line,
+)
+from test_update_shape import STATES, next_line_of
+
+sys.path.insert(0, str(REPO / "scripts"))
+from repo_shape import load_yaml # noqa: E402
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("git-filter-repo") is None,
+ reason="git filter-repo is not installed: `pip install git-filter-repo`")
+
+PROJECT = "Medx"
+SHAPE_PIN = "contracts/shape-pin.yaml"
+
+#: The issue's own source, with a `Makefile` beside its `.gitignore`: two
+#: names the shape copies too, so the adoption writes both of its copies
+#: under `shape/` and pins them there. `README.md` collides as well, and is
+#: rendered rather than copied, so its `shape/README.md` has no row.
+SOURCE_TREE = {
+ "specs/a.md": "# A\n",
+ "src/a.py": "print(1)\n",
+ "README.md": "# Medx\n",
+ ".gitignore": "build/\n*.log\n",
+ "Makefile": "build:\n\t@echo the source's own build\n",
+}
+
+#: The pinned collision copies, and the file each was cut for.
+MERGED = {"shape/.gitignore": ".gitignore", "shape/Makefile": "Makefile"}
+
+
+@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=())
+ plan = base / "adoption-plan.yaml"
+ adopt = standard / "adopt-project.py"
+ planned = run_script(adopt, "plan", "--source", str(source), "--project",
+ PROJECT, "--org", "testorg", "--elected-by",
+ "Test Human", "--elected-on", "2026-10-13",
+ "--out", str(plan))
+ assert planned.returncode == 0, planned.stdout + planned.stderr
+ checked = run_script(adopt, "check", "--plan", str(plan))
+ assert checked.returncode == 0, checked.stdout + checked.stderr
+ executed = run_script(adopt, "execute", "--plan", str(plan), "--yes",
+ "--local-remote-dir", str(base / "remotes"),
+ "--work-dir", str(base / "work"))
+ assert executed.returncode == 0, executed.stdout + executed.stderr
+ git("merge", "-q", "--ff-only", "adopt/three-repo-shape", cwd=source)
+ clone = base / "clone" / PROJECT
+ cloned = clone_just_pushed(source, clone)
+ assert cloned.returncode == 0, cloned.stderr
+ return {"plan": plan, "clone": clone}
+
+
+@pytest.fixture
+def root(adopted_here, tmp_path) -> Path:
+ """A private, mutable copy of the adopted clone."""
+ target = tmp_path / PROJECT
+ shutil.copytree(adopted_here["clone"], target, symlinks=True)
+ return target
+
+
+def pin_files(root: Path) -> list:
+ """`(path, sha256)` per `files:` row, in the order the pin holds them."""
+ return [(row["path"], str(row["sha256"]).lower())
+ for row in load_yaml(root / SHAPE_PIN)["files"]]
+
+
+def verdicts(stdout: str) -> dict:
+ """`{path: state}` out of `check`'s report, `merged-away` included."""
+ states = (*STATES, "merged-away")
+ out = {}
+ for line in stdout.splitlines():
+ parts = line.split()
+ if len(parts) == 2 and parts[0] in states and line.startswith(" "):
+ out[parts[1]] = parts[0]
+ return out
+
+
+def merge_away(root: Path, *names: str) -> None:
+ """The plan's follow-up, done as a person does it: each `shape/`
+ appended to ``, the copy removed, and the two committed."""
+ for name in names:
+ original = root / name
+ original.write_bytes(original.read_bytes()
+ + (root / "shape" / name).read_bytes())
+ git("rm", "-q", "--", f"shape/{name}", cwd=root)
+ git("add", "--", name, cwd=root)
+ git("commit", "-q", "-m", "Merge the shape's copies into our own",
+ cwd=root)
+
+
+def update_shape(standard: Path, root: Path, *args: str):
+ return run_script(standard / "update-shape.py", *args, "--root",
+ str(root), "--upstream", str(standard))
+
+
+def gate(root: Path, script: str):
+ return run_script(root / "scripts" / script, cwd=root)
+
+
+def test_a_merged_away_copys_row_goes_and_the_project_is_green_again(
+ standard, root):
+ """The issue's reproduction, then the ruling, end to end.
+
+ Both follow-ups done: the project's own `validate-pins.py` and
+ `bootstrap.py` are red. `check` calls both rows `merged-away`, with no
+ refusal ahead; the doctor says `DRIFTED (2 merged-away)`, and its `pins`
+ row names the `check` its `shape currency` row names. `check`'s NEXT,
+ pasted as printed, drops the two rows and moves nothing else in the pin,
+ and then the gates are green, the doctor says COMPLIANT and `check` has
+ nothing to do.
+ """
+ before = pin_files(root)
+ assert set(MERGED) <= {path for path, _ in before}, before
+ assert gate(root, "validate-pins.py").returncode == 0
+ merge_away(root, *MERGED.values())
+
+ pins = gate(root, "validate-pins.py")
+ assert pins.returncode == 1, pins.stdout + pins.stderr
+ for path in MERGED:
+ assert (f"FINDING shape-copy-missing: {SHAPE_PIN}: {path} is pinned "
+ "but absent") in pins.stdout + pins.stderr
+ booted = gate(root, "bootstrap.py")
+ assert booted.returncode == 1, booted.stdout + booted.stderr
+
+ checked = update_shape(standard, root, "check")
+ assert checked.returncode == 1, checked.stdout + checked.stderr
+ states = verdicts(checked.stdout)
+ assert {path: states.get(path) for path in MERGED} == {
+ path: "merged-away" for path in MERGED}, checked.stdout
+ assert "REFUSAL AHEAD" not in checked.stdout, checked.stdout
+ for path, original in MERGED.items():
+ assert f"merged into {original} and removed" in checked.stdout
+
+ result = doctor(standard, root, "--json")
+ assert result.returncode == 1, result.stdout + result.stderr
+ payload = json.loads(result.stdout)
+ assert payload["verdict"] == "DRIFTED (2 merged-away)", payload["verdict"]
+ rows = rows_of(result)
+ pins_row, currency = rows["pins"], rows["shape-currency"]
+ assert pins_row["status"] == "FINDING", pins_row
+ assert pins_row["detail"]["merged_away"] == MERGED, pins_row
+ assert "update-shape.py" in pins_row["next"], pins_row["next"]
+ assert currency["next"].startswith(pins_row["next"]), (pins_row, currency)
+ assert "merged-away" in pins_row["reason"], pins_row["reason"]
+
+ 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
+ for path in MERGED:
+ assert f" dropped {path} " in applied.stdout, applied.stdout
+
+ assert pin_files(root) == [row for row in before
+ if row[0] not in MERGED], (
+ "the two rows go, and nothing else in the pin moves")
+ pinned = load_yaml(root / SHAPE_PIN)
+ assert pinned["commit"].lower() == git(
+ "rev-parse", "HEAD", cwd=standard).stdout.strip()
+ committed = set(git("show", "--name-only", "--format=", "HEAD",
+ cwd=root).stdout.split())
+ assert SHAPE_PIN in committed and committed <= {SHAPE_PIN, "project.yaml"}, \
+ committed
+
+ for script in ("validate-pins.py", "bootstrap.py"):
+ ran = gate(root, script)
+ assert ran.returncode == 0, f"{script}:\n{ran.stdout}{ran.stderr}"
+ again = doctor(standard, root)
+ assert again.returncode == 0, again.stdout + again.stderr
+ assert verdict_line(again).startswith("COMPLIANT (exit 0)"), again.stdout
+ rechecked = update_shape(standard, root, "check")
+ assert rechecked.returncode == 0, rechecked.stdout + rechecked.stderr
+ assert "nothing to do" in rechecked.stdout
+
+
+def test_a_shape_copy_removed_with_its_original_stays_copy_missing(
+ standard, root):
+ """Nothing says a merge happened where the file the copy was cut for is
+ gone too, so the row stays `copy-missing` and is refused. The refusal's
+ remedy is that state's own, with or without `--accept-local`: the copy
+ goes back from history, never the re-run with `--accept-local` that was
+ refused in the same words. The doctor's `pins` row keeps the gate.
+
+ It no longer says this command never records a deletion (review of PR
+ #512, NIT-1): `apply` records one, a merged-away row's, so the sentence
+ says which deletion it never records."""
+ git("rm", "-q", "--", "shape/.gitignore", ".gitignore", cwd=root)
+ git("commit", "-q", "-m", "Remove both", cwd=root)
+ before = pin_files(root)
+
+ checked = update_shape(standard, root, "check")
+ assert verdicts(checked.stdout)["shape/.gitignore"] == "copy-missing"
+ assert "REFUSAL AHEAD" in checked.stdout, checked.stdout
+
+ for extra in ((), ("--accept-local", "shape/.gitignore")):
+ refused = update_shape(standard, root, "apply", "--yes", *extra)
+ assert refused.returncode == 2, refused.stdout + refused.stderr
+ assert "REFUSED update-conflict" in refused.stderr, refused.stderr
+ assert ("Remediation: shape/.gitignore is pinned and gone from the "
+ "root, so --accept-local has no bytes to keep, and this "
+ "command never records the deletion of a copy the project "
+ "still needs.") in refused.stderr, refused.stderr
+ assert "never records a deletion" not in refused.stderr
+ assert "re-run this with --accept-local" not in refused.stderr
+ assert pin_files(root) == before
+
+ rows = rows_of(doctor(standard, root, "--json"))
+ assert "merged_away" not in rows["pins"]["detail"], rows["pins"]
+ assert "update-shape.py" not in rows["pins"]["next"], rows["pins"]
+
+
+def test_a_pinned_copy_deleted_elsewhere_stays_copy_missing(standard, root):
+ """`.gitattributes` took its own name, so its deletion is no merge: it
+ stays `copy-missing` beside a merged-away `shape/.gitignore`, and the
+ refusal names it alone, with its own remedy. The `pins` row keeps the
+ gate, because the step it would name does not clear that finding."""
+ merge_away(root, ".gitignore")
+ git("rm", "-q", "--", ".gitattributes", cwd=root)
+ git("commit", "-q", "-m", "Remove a pinned copy", cwd=root)
+
+ checked = update_shape(standard, root, "check")
+ states = verdicts(checked.stdout)
+ assert states["shape/.gitignore"] == "merged-away", checked.stdout
+ assert states[".gitattributes"] == "copy-missing", checked.stdout
+ (ahead,) = [line for line in checked.stdout.splitlines()
+ if line.startswith("REFUSAL AHEAD")]
+ assert ahead.endswith(" .gitattributes"), ahead
+ assert "shape/.gitignore" not in ahead, ahead
+
+ refused = update_shape(standard, root, "apply", "--yes")
+ assert refused.returncode == 2, refused.stdout + refused.stderr
+ assert " .gitattributes: pinned but absent" in refused.stderr
+ assert " shape/.gitignore" not in refused.stderr, refused.stderr
+ assert "Remediation: .gitattributes is pinned and gone" in refused.stderr
+ assert "re-run this with --accept-local" not in refused.stderr
+
+ result = doctor(standard, root, "--json")
+ assert json.loads(result.stdout)["verdict"] == \
+ "DRIFTED (1 copy-missing, 1 merged-away)", result.stdout
+ pins_row = rows_of(result)["pins"]
+ assert "merged_away" not in pins_row["detail"], pins_row
+ assert "update-shape.py" not in pins_row["next"], pins_row
+
+
+@WINDOWS_SKIP
+@pytest.mark.parametrize("linked", [
+ pytest.param("contracts/shape-pin.yaml", id="pin-at-a-link"),
+ pytest.param("contracts", id="contracts-directory-link"),
+ pytest.param("project.yaml", id="manifest-at-a-link"),
+])
+def test_the_pins_row_keeps_the_gate_while_check_refuses_a_linked_record(
+ standard, root, tmp_path, linked):
+ """Both copies merged away in a root that keeps its pin, the
+ `contracts` directory above it, or its manifest as a committed symlink
+ (#286). `check` refuses such a root, `update-rewrite-symlink`, before it
+ classifies a row, so the `pins` row named a `check` that exits 2 and
+ said it was the step the `shape currency` row names, while that row
+ named replacing the link. The `pins` row keeps the gate; with the link
+ replaced and committed, the doctor run again names `check`."""
+ merge_away(root, *MERGED.values())
+ end = keep_as_committed_link(root, linked, tmp_path)
+
+ checked = update_shape(standard, root, "check")
+ assert checked.returncode == 2, checked.stdout + checked.stderr
+ assert "REFUSED update-rewrite-symlink" in checked.stderr, checked.stderr
+
+ rows = rows_of(doctor(standard, root, "--json"))
+ pins_row, currency = rows["pins"], rows["shape-currency"]
+ assert pins_row["status"] == "FINDING", pins_row
+ assert "merged_away" not in pins_row["detail"], pins_row
+ assert "update-shape.py" not in pins_row["next"], pins_row["next"]
+ assert "merged-away" not in pins_row["reason"], pins_row["reason"]
+ assert currency["detail"]["error"] == "update-rewrite-symlink", currency
+ assert currency["next"].startswith(f"replace {linked} "), currency["next"]
+
+ (root / linked).unlink()
+ shutil.move(str(end), str(root / linked))
+ commit_all(root, f"keep {linked} where it stands, not as a link")
+ pins_row = rows_of(doctor(standard, root, "--json"))["pins"]
+ assert pins_row["detail"]["merged_away"] == MERGED, pins_row
+ assert "update-shape.py" in pins_row["next"], pins_row["next"]
+
+
+def test_a_deletion_not_yet_committed_comes_back_from_head(standard, root):
+ """`rm`, then `apply`: the common way to meet `copy-missing` (review of
+ PR #512, NIT-2). No commit removed the copy, so the parent of the commit
+ that removed it is no exit, and `git log -1 -- ` names the commit
+ that ADDED it. The remediation names both exits, and the one for a
+ deletion not yet committed, run as it names it, puts the copy back:
+ `check` then has nothing to do, and nothing was written before."""
+ (root / ".gitattributes").unlink()
+ before = pin_files(root)
+ head = git("rev-parse", "HEAD", cwd=root).stdout
+ manifest = (root / "project.yaml").read_bytes()
+
+ refused = update_shape(standard, root, "apply", "--yes")
+ assert refused.returncode == 2, refused.stdout + refused.stderr
+ assert "REFUSED update-conflict" in refused.stderr, refused.stderr
+ assert " .gitattributes: pinned but absent" in refused.stderr
+ assert ("Put each copy back from this project's own history: a deletion "
+ "not yet committed comes back with `git checkout HEAD -- ` "
+ "in the project, and a committed one from the parent of the "
+ "commit that removed it, which `git log -1 -- ` names.") \
+ in refused.stderr, refused.stderr
+ after = (pin_files(root), git("rev-parse", "HEAD", cwd=root).stdout,
+ (root / "project.yaml").read_bytes())
+ assert after == (before, head, manifest), \
+ "the refused apply wrote something"
+
+ git("checkout", "HEAD", "--", ".gitattributes", cwd=root)
+ checked = update_shape(standard, root, "check")
+ assert checked.returncode == 0, checked.stdout + checked.stderr
+ assert "nothing to do" in checked.stdout, checked.stdout
+
+
+def test_the_currency_rows_counts_name_a_merged_away_row(standard, root):
+ """The `shape currency` row's reason counts a merged-away row (review of
+ PR #512, NIT-3): it counted the five original states alone, so one such
+ row read `10 unchanged / 0 upstream-changed / ...` over eleven rows and
+ named its path under no count, in the row whose `apply` drops it. Its
+ counts now sum to the rows."""
+ merge_away(root, ".gitignore")
+ currency = rows_of(doctor(standard, root, "--json"))["shape-currency"]
+ summary = currency["reason"].split(" (pinned ", 1)[0]
+ assert summary.endswith(" / 1 merged-away"), currency["reason"]
+ said = {state: int(count) for count, state in
+ (part.split(" ", 1) for part in summary.split(" / "))}
+ assert sum(said.values()) == sum(currency["detail"]["counts"].values()), \
+ (said, currency["detail"]["counts"])
+
+
+def test_the_plans_follow_up_says_what_happens_to_the_row(adopted_here):
+ """The follow-up for each pinned collision copy says that its row goes,
+ and by which step; `shape/README.md`, a rendered file with no row, is
+ told nothing about one."""
+ follow_ups = load_yaml(adopted_here["plan"])["follow_ups"]
+ for path, original in MERGED.items():
+ (said,) = [item for item in follow_ups
+ if item.startswith(f"merge {path} into {original}")]
+ assert (f"Then remove {path}: `update-shape.py check` reports its "
+ "pin row `merged-away`, and `apply` drops the row") in said, \
+ said
+ (readme,) = [item for item in follow_ups
+ if item.startswith("merge shape/README.md into README.md")]
+ assert "pin row" not in readme, readme
diff --git a/tests/test_update_shape_next_quoting.py b/tests/test_update_shape_next_quoting.py
index 54753b05..50330ec1 100644
--- a/tests/test_update_shape_next_quoting.py
+++ b/tests/test_update_shape_next_quoting.py
@@ -166,7 +166,8 @@ def check_run(project, root, *, lane: str = "", upstream=None):
def edit_local(root: Path) -> None:
"""Edit the project's own copy of a pinned file, which is what makes
- `check` name it as `locally-modified` and offer `--accept-local`."""
+ `check` name it as `locally-modified`, and which its NEXT offered
+ `--accept-local` for until #501."""
path = root / LOCAL
path.write_text(path.read_text(encoding="utf-8") + "\n# an edit\n",
encoding="utf-8")
@@ -175,25 +176,26 @@ def edit_local(root: Path) -> None:
# --- check's NEXT line ------------------------------------------------------
-@pytest.mark.parametrize("variant", ["plain", "lane", "accept-local"])
+@pytest.mark.parametrize("variant", ["plain", "lane", "local-edit"])
@pytest.mark.parametrize("name", AWKWARD)
def test_the_next_line_check_prints_is_one_argv(project, make_root, name,
variant):
"""The `apply` `check` tells a person to run, read back by the shell:
the root, the upstream and the branch each ONE word, with the lane's
- trailer and a locally edited path where there are any."""
+ trailer where there is one.
+
+ AND NO LOCALLY EDITED PATH (#501). The line carried `--accept-local
+ ` for one, and pasted, it re-pinned the edit; a project keeping
+ its edit is the human's word, which `update-local-drift` asks for."""
root = make_root(name)
- if variant == "accept-local":
+ if variant == "local-edit":
edit_local(root)
result = check_run(project, root,
lane=LANE if variant == "lane" else "")
expected = [PYTHON, "update-shape.py", "apply", "--root",
str(root.resolve()), "--upstream",
str(project["upstream"].resolve()), "--at", project["b"],
- "--yes"]
- if variant == "accept-local":
- expected += ["--accept-local", LOCAL]
- expected += ["--branch", f"shape/update-{project['b'][:12]}"]
+ "--yes", "--branch", f"shape/update-{project['b'][:12]}"]
if variant == "lane":
expected += ["--trailer", f"Lane: {LANE}"]
assert argv_of(next_line_of(result.stdout)) == expected
@@ -215,26 +217,6 @@ def test_the_upstream_check_names_is_one_argument_too(project, make_root,
assert argv[-2:] == ["--branch", f"shape/update-{project['b'][:12]}"]
-@pytest.mark.parametrize("path", [
- "docs/shape guide.md", "it's [x].md", "$HOME/file", "a;b.md",
- 'say "hi".md'])
-def test_a_path_check_offers_to_accept_is_one_argument(update_shape,
- monkeypatch, path):
- """`--accept-local ` comes out of the project's own pin, so it is
- quoted like the rest. No copy-list entry holds such a name today, which
- is why the line is built here and not by a run."""
- monkeypatch.setenv("LANES_LANE", "")
- root = Path("project root")
- target = "ab" * 20
- command = update_shape.apply_command(root, "owner/repo", target,
- ["scripts/a.py", path])
- assert shlex.split(command) == [
- PYTHON, "update-shape.py", "apply", "--root", "project root",
- "--upstream", "owner/repo", "--at", target, "--yes",
- "--accept-local", "scripts/a.py", "--accept-local", path,
- "--branch", f"shape/update-{target[:12]}"]
-
-
def test_the_lane_trailer_is_spelled_by_the_same_quoter(update_shape,
monkeypatch):
"""No double quotes written by hand: whatever `LANE_NAME_RE` admits goes
diff --git a/update-shape.py b/update-shape.py
index a4406ede..ccbd3333 100755
--- a/update-shape.py
+++ b/update-shape.py
@@ -35,7 +35,9 @@
Recomputing that row silently would turn the drift `validate-pins.py`
reports today into a digest that agrees with the fork — which is exactly
the "record the fork as the standard" failure `shape-pin.yaml`'s own
- header forbids.
+ header forbids. `check`'s NEXT line never passes the flag for them
+ (#501): the `apply` it hands over meets that refusal, which is where the
+ human is asked.
* A file changed on BOTH sides is refused outright, UNLESS the human passes
`--accept-local ` on it too — the same exception `locally-modified`
gets, above. Two edits to one file is a merge, and a merge is a human's
@@ -85,9 +87,13 @@
`contracts/shape-pin.yaml`, never re-derived from this repository's copy
lists. An in-place adoption collides on `Makefile`, `README.md` and
`.gitignore` — the shape's copies land under `shape/` and a human merges them,
-usually deleting the `shape/` copy and its pin row (MedxEHR did exactly that).
-A file with no row is not a shape copy and is invisible here; re-deriving the
-list would resurrect a file the project deliberately merged away.
+usually deleting the `shape/` copy. Its row then names a file that is not
+there; MedxEHR dropped such rows by hand, which is the hand edit of a pin
+this standard forbids. So `check` calls the row `merged-away` where the root
+holds the file the copy was merged into, and `apply` drops it in its re-pin
+commit (#502). A file with no row is not a shape copy and is invisible here;
+re-deriving the list would resurrect a file the project deliberately merged
+away.
WHAT A PIN CANNOT NAME IS THE FILE THE STANDARD ADDED AFTERWARDS (2026-09-04),
and "only the pin's own rows are RE-SYNCED" is a different claim from "only the
@@ -270,6 +276,15 @@ def root_kind(root: Path) -> Kind:
LOCALLY_MODIFIED = "locally-modified"
BOTH = "both"
ALREADY_AT_TARGET = "already-at-target"
+#: A `shape/` collision copy the project merged into the file it collided
+#: with, and removed (#502): the root holds `X` and nothing at `shape/X`. Its
+#: row is the one thing left of it, and `apply` DROPS that row in its re-pin
+#: commit, behind the same yes as every other row it moves. Named for the
+#: act the adoption's own follow-up asks for ("merge shape/X into X"), and
+#: spelled as the other states are, so a person reading `check` reads what
+#: happened to the copy rather than what is missing. Not a conflict: there
+#: is nothing to merge and no bytes to keep, so nothing here refuses it.
+MERGED_AWAY = "merged-away"
UPSTREAM_REMOVED = "upstream-removed"
UNMAPPED = "unmapped"
COPY_MISSING = "copy-missing"
@@ -284,6 +299,7 @@ def root_kind(root: Path) -> Kind:
#: Anything here is a merge or a decision, never a byte copy.
CONFLICTS = (BOTH, UPSTREAM_REMOVED, UNMAPPED, COPY_MISSING, COPY_IS_LINK)
ORDER = (UPSTREAM_CHANGED, UPSTREAM_ADDED, BOTH, LOCALLY_MODIFIED,
+ MERGED_AWAY,
ALREADY_AT_TARGET, UPSTREAM_REMOVED, UNMAPPED, COPY_MISSING,
COPY_IS_LINK, UNCHANGED)
@@ -566,6 +582,36 @@ def source_for(rel: str, upstream: Upstream, rev: str,
return known or None
+def merged_into(root: Path, rel: str) -> str | None:
+ """The file a pinned `shape/` collision copy was merged into, or None
+ when `rel` is not a merged-away copy (#502).
+
+ THREE FACTS, ALL READ FROM THE ROOT, AND ALL THREE HAVE TO HOLD. `rel`
+ lies under `COLLISION_DIR`, where only an in-place adoption puts a shape
+ copy, and only because the source already used its name; the root holds
+ NOTHING at `rel`, not even a dangling link (`lexists`: a FIFO or a
+ directory there is something, so either stays `copy-missing`); and the root
+ holds a FILE at the path the copy was cut for, `shape/X` -> `X`, which
+ is what the adoption's follow-up said to merge it into. Where `X` is
+ gone too, nothing says a merge happened, and the row stays
+ `copy-missing` with its own refusal, as does a pinned copy deleted
+ anywhere else.
+
+ WHAT MERGING MEANS IS NOT JUDGED. The row is dropped and nothing is
+ copied, so no byte of `X` is compared with the shape's: the follow-up is
+ the person's, and `X` was never a shape copy of this pin. Read by
+ `classify` and by the doctor's `pins` row, so the two cannot disagree
+ about which rows go.
+ """
+ prefix = f"{COLLISION_DIR}/"
+ if not rel.startswith(prefix):
+ return None
+ original = rel[len(prefix):]
+ if not original or os.path.lexists(root / rel):
+ return None
+ return original if (root / original).is_file() else None
+
+
def classify(root: Path, rows: list[Row], upstream: Upstream, pinned: str,
target: str, kind: Kind = PROJECT_KIND) -> None:
"""Fill in every row's verdict. Four byte-strings decide it:
@@ -580,8 +626,19 @@ def classify(root: Path, rows: list[Row], upstream: Upstream, pinned: str,
as an upstream change merely for being appended to. `locally-modified` is
sha256(R) != D, which is the same question `validate-pins.py` asks and the
same answer it gives.
+
+ A MERGED-AWAY `shape/` COPY IS ASKED ABOUT FIRST (#502), because none of
+ the four is there to decide it: R is absent and the row is going, so the
+ upstream is not read for it. `merged_into` says which rows those are.
"""
for row in rows:
+ original = merged_into(root, row.path)
+ if original is not None:
+ row.state = MERGED_AWAY
+ row.detail = (f"merged into {original} and removed; `apply` "
+ "drops this pin row in its re-pin commit and "
+ "copies nothing")
+ continue
_classify_row(root, row, upstream, pinned, target, kind)
@@ -1795,6 +1852,23 @@ def accept_local_exit(path: str) -> str:
f"{path}` so the row is recomputed from what you merged")
+def keep_local_exits(path: str) -> str:
+ """The sentence `check` appends to a `locally-modified` row's detail: the
+ three exits `update-local-drift` names, the human's word first (#501).
+
+ THE FLAG IS NAMED HERE, WHERE NO PASTE REACHES IT. `check`'s NEXT line
+ carried `--accept-local ` for every such row, beside `--yes`, so
+ the `apply` it handed over re-pinned the project's edit as the standard
+ and the refusal that asks the human never fired. Brett Heap ruled on
+ 2026-10-13, "Stop pre-filling it": the NEXT leaves the flag out, and the
+ row's detail may still name it, as `accept_local_exit` does for `both`.
+ Display only, for the reason that function gives.
+ """
+ return (". Whether the project keeps the edit is the human's decision: "
+ "revert it, carry it upstream, or, on their word, `apply "
+ f"--accept-local {path}`")
+
+
def report(root: Path, upstream: Upstream, pinned: str, target: str,
pin_tree: str, target_tree: str, rows: list[Row]) -> None:
print(f"root {root}")
@@ -1812,6 +1886,8 @@ def report(root: Path, upstream: Upstream, pinned: str, target: str,
detail = row.detail
if row.state == BOTH and detail:
detail += accept_local_exit(row.path)
+ elif row.state == LOCALLY_MODIFIED and detail:
+ detail += keep_local_exits(row.path)
line = f" {state:<{width}} {row.path}"
if detail:
line += f"\n {'':<{width}} ({detail})"
@@ -1860,8 +1936,7 @@ def prepare(args) -> tuple[Path, dict, list[Row], list[Addition], Upstream,
return root, pin, rows, additions, upstream, pinned, target, kind
-def apply_command(root: Path, label: str, target: str,
- accept: list[str] = ()) -> str:
+def apply_command(root: Path, label: str, target: str) -> str:
"""The `apply` that `check`'s NEXT line hands a person to paste: this
tool run again from the checkout of the standard, pointed at `root`.
@@ -1870,12 +1945,21 @@ def apply_command(root: Path, label: str, target: str,
`my projects/` or `it's here/` or `$HOME` printed bare was two
arguments, an unterminated quotation or an expansion in front of whoever
pasted the line, which `landing_failed`'s and the rollback's lines were
- already quoted against (#202, #204). So is each `--accept-local` path,
- which comes out of the project's own pin, and the branch. `shlex.join`
+ already quoted against (#202, #204). So is the branch. `shlex.join`
leaves a value it has nothing to say about exactly as it was, so a plain
root prints what it always printed. On POSIX: `_by_hand` says what a
Windows root, which always holds a backslash, prints instead.
+ NEVER AN `--accept-local` (#501). The line carried one for every
+ `locally-modified` row, and pasted, it re-pinned the project's edit FROM
+ ITS OWN BYTES: `update-local-drift`, the refusal that asks the human
+ whether the project keeps that edit, never fired, and AGENTS.md tells an
+ assistant both to run the line a row names and never to pass the flag
+ on its own initiative. So the `apply` meets that refusal, whose
+ remediation names the three exits, and `check` names the flag in the
+ row's detail (`keep_local_exits`). The `--yes` stays: it is the yes the
+ human gives after reading `check`, which the line follows.
+
THE LANE, WHEN THE ENVIRONMENT NAMES ONE (2026-09-11, #111). A run that
pastes this line then lands a commit carrying the `Lane:` trailer the
protocol asks for, instead of one that has to be amended afterwards —
@@ -1891,10 +1975,8 @@ def apply_command(root: Path, label: str, target: str,
currency` row has always printed it in.
"""
words = [PYTHON, Path(__file__).name, "apply", "--root", str(root),
- "--upstream", label, "--at", target, "--yes"]
- for path in accept:
- words += ["--accept-local", path]
- words += ["--branch", f"shape/update-{target[:12]}"]
+ "--upstream", label, "--at", target, "--yes",
+ "--branch", f"shape/update-{target[:12]}"]
return shlex.join(words) + lane_trailer_argument(shlex.quote)
@@ -1947,7 +2029,6 @@ def cmd_check(args) -> int:
else f"the pin and {README}'s Shape: line")
print(f"no copied file differs; `apply` would move {alone}")
conflicts = [row for row in rows if row.is_conflict]
- local = [row for row in rows if row.state == LOCALLY_MODIFIED]
if conflicts:
print("REFUSAL AHEAD: `apply` will refuse while these are "
"unresolved — " + ", ".join(row.path for row in conflicts))
@@ -1964,9 +2045,9 @@ def cmd_check(args) -> int:
"pin, and `apply` writes none of them unless it is told to, "
"one path at a time — "
+ " ".join(f"--add {add.path}" for add in offered))
- # QUOTED WHOLE, THE LANE'S TRAILER INCLUDED: see `apply_command`.
- print("NEXT " + apply_command(
- root, upstream.label, target, [row.path for row in local]))
+ # QUOTED WHOLE, THE LANE'S TRAILER INCLUDED, AND NEVER AN
+ # `--accept-local` (#501): see `apply_command`.
+ print("NEXT " + apply_command(root, upstream.label, target))
return 1
finally:
upstream.close()
@@ -1992,8 +2073,13 @@ def confirm(args, rows: list[Row], taking: list[Addition],
"they have read `check`.")
changed = [row.path for row in rows if row.state == UPSTREAM_CHANGED]
adding = (f", adds the {len(taking)} file(s) --add named" if taking else "")
+ # THE SAME YES COVERS A DROPPED ROW (#502): it is a row this re-pin
+ # moves, like any other, so it is said here before anybody answers.
+ merged = [row.path for row in rows if row.state == MERGED_AWAY]
+ dropping = (f", drops the pin row(s) of {', '.join(merged)} (merged "
+ "away)" if merged else "")
print(f"\nThis re-copies {len(changed)} file(s) from {target[:12]}"
- f"{adding} and re-pins this project onto it.")
+ f"{adding}{dropping} and re-pins this project onto it.")
try:
answered = input("Type yes to proceed: ").strip().lower()
except EOFError:
@@ -2158,7 +2244,7 @@ def pull_request_repository(manifest: dict | None,
def commit_on_branch(root: Path, branch: str, paths: list[str], target: str,
upstream: Upstream, count: int, kind: Kind,
added: list[str] = (), trailers: list[str] = (), *,
- ledger: Rollback) -> None:
+ ledger: Rollback, dropped: list[str] = ()) -> None:
"""A branch and ONE commit, with EXPLICIT PATHSPECS.
`git commit -- ` commits the working-tree state of exactly those
@@ -2247,6 +2333,11 @@ def commit_on_branch(root: Path, branch: str, paths: list[str], target: str,
ledger.step = f"committing the re-synced shape on {branch}"
gained = (f"; {len(added)} new upstream file(s) added because --add named "
"them" if added else "")
+ # `dropped` ARE THE MERGED-AWAY ROWS (#502): a pin change no copied byte
+ # explains, so the message says which rows went and why.
+ if dropped:
+ gained += (f"; the pin row(s) of {', '.join(dropped)} dropped, each "
+ "a collision copy merged into the file it collided with")
# READ OFF THE PATHSPECS RATHER THAN PASSED IN: `README.md` is in `paths`
# only when `_rewrite_readme` moved its sha, so the message names the one
# file in this commit that carries no pin row and would otherwise read as
@@ -2791,6 +2882,10 @@ def _refuse_linked_copies(rows: list[Row]) -> None:
def _refuse_conflicts(rows: list[Row], accepted: set) -> None:
"""The `update-conflict` refusal, split out of `cmd_apply` for #133.
+ ITS REMEDIATION IS `conflict_remediation`'s, per kind of row (#502): a
+ `copy-missing` row was told to re-run with `--accept-local`, which this
+ function never honours for it, so the re-run printed the same refusal.
+
`both` ALONE has an accepted exit: `--accept-local ` on it means the
human already did the merge this refusal asks for, so that row is excluded
here and handled with LOCALLY_MODIFIED further down — never copied over,
@@ -2811,10 +2906,65 @@ def _refuse_conflicts(rows: list[Row], accepted: set) -> None:
"a merge is a human's judgement rather than a byte copy:\n"
+ "\n".join(f" {row.path}: {row.detail or row.state}"
for row in conflicts),
- "Remediation: merge each one by hand (the upstream bytes are "
- "`git show :` in a shape clone), commit "
- "it, then re-run this with --accept-local on that path so the "
- "row is recomputed from what you merged.")
+ conflict_remediation(conflicts))
+
+
+#: What `update-conflict` tells a row it can name a merge for: `both`, and
+#: the two states that cannot be compared. The words it always printed, so a
+#: refusal naming no `copy-missing` row reads exactly as it did.
+MERGE_BY_HAND = (
+ "merge each one by hand (the upstream bytes are `git show "
+ ":` in a shape clone), commit it, then re-run this "
+ "with --accept-local on that path so the row is recomputed from what "
+ "you merged.")
+
+
+def conflict_remediation(conflicts: list[Row]) -> str:
+ """`update-conflict`'s remediation, a sentence per kind of row it names
+ (#502).
+
+ A `copy-missing` ROW HAS AN EXIT OF ITS OWN, AND IT IS NOT
+ `--accept-local`. The one remediation this refusal had told every row to
+ merge by hand and re-run with that flag, which `_refuse_conflicts`
+ honours on a `both` row alone, so for a pinned copy the root no longer
+ holds the re-run printed the same refusal. Such a copy has no bytes for
+ the flag to keep, and this command never records the deletion of a copy
+ the project still needs -- the only rows it drops are merged-away
+ copies', below -- so the copy goes back from the project's own history.
+ WHERE IN THAT HISTORY DEPENDS ON WHETHER THE DELETION WAS COMMITTED
+ (review of PR #512): an `rm` or `git rm` not yet committed leaves the
+ copy in HEAD, and `git checkout HEAD -- ` puts it back, as the
+ doctor's own line for a lost `repo_shape.py` does; a committed one is in
+ the parent of the commit that removed it, which `git log -1 -- `
+ names. Naming the parent alone sent the common case -- an `rm`, then
+ `apply` -- to a removing commit that does not exist, and that `git log`
+ names the commit that ADDED the copy instead. A `shape/` copy merged
+ into the file it collided with does not reach here while that file is
+ at the root: `classify` calls it `merged-away`, and `apply` drops its
+ row. The sentence says so, because a person who removed the copy
+ without the merge reads it here.
+
+ The other three states keep `MERGE_BY_HAND`, after it where a
+ `copy-missing` row is named too.
+ """
+ missing = [row.path for row in conflicts if row.state == COPY_MISSING]
+ if not missing:
+ return f"Remediation: {MERGE_BY_HAND}"
+ verb = "is" if len(missing) == 1 else "are"
+ said = (f"Remediation: {', '.join(missing)} {verb} pinned and gone from "
+ "the root, so --accept-local has no bytes to keep, and this "
+ "command never records the deletion of a copy the project "
+ "still needs. Put each copy back from this project's own "
+ "history: a deletion not yet committed comes back with "
+ "`git checkout HEAD -- ` in the project, and a committed "
+ "one from the parent of the commit that removed it, which "
+ "`git log -1 -- ` names. Then run this again. A `shape/` "
+ "copy merged into the file it collided with is not refused: "
+ "with that file at the root, `check` reports it merged-away and "
+ "`apply` drops its row.")
+ if len(missing) < len(conflicts):
+ said += f" For the others, {MERGE_BY_HAND}"
+ return said
def _refuse_local_drift(rows: list[Row], accepted: set) -> None:
@@ -2892,6 +3042,12 @@ def _write_copies(root: Path, rows: list[Row], taking: list[Addition],
elif row.state == ALREADY_AT_TARGET:
print(f" kept {row.path} (already the target's bytes; "
"the row was stale)")
+ elif row.state == MERGED_AWAY:
+ # NOTHING IS WRITTEN AT ITS PATH (#502): `_repin` leaves its row
+ # out of the pin, and that is the whole of what dropping it is.
+ original = row.path[len(COLLISION_DIR) + 1:]
+ print(f" dropped {row.path} (merged into {original} and "
+ "removed; its pin row goes, and nothing is copied)")
for add in taking:
ledger.write_added(root / add.path, add.target_bytes or b"",
add.executable)
@@ -2907,11 +3063,17 @@ def _repin(root: Path, kind: Kind, ledger: Rollback, rows: list[Row],
target, through the ledger so both roll back. Split out of `cmd_apply`
for #133; the two rewriters are where the refusals about an unrecognised
pin or manifest live.
+
+ A MERGED-AWAY ROW IS LEFT OUT OF THE PIN (#502), in this same rewrite and
+ behind the same yes: the row names a copy the project merged into the
+ file it collided with and removed, and Brett Heap ruled that its pin row
+ goes. Every other row is written as `classify` left it.
"""
pin_path = root / "contracts" / "shape-pin.yaml"
manifest_path = root / kind.manifest
+ kept = [row for row in rows if row.state != MERGED_AWAY]
ledger.write(pin_path, rewrite_shape_pin(
- pin_path.read_text(encoding="utf-8"), target, target_tree, rows
+ pin_path.read_text(encoding="utf-8"), target, target_tree, kept
).encode("utf-8"))
ledger.write(manifest_path, rewrite_manifest(
manifest_path.read_text(encoding="utf-8"), target, target_tree, kind
@@ -3002,10 +3164,11 @@ def _refuse_red_validators(root: Path, kind: Kind) -> None:
def _land(root: Path, args, paths: list[str], added: list[str], target: str,
upstream: Upstream, kind: Kind, count: int,
- ledger: Rollback) -> None:
+ ledger: Rollback, dropped: list[str] = ()) -> None:
"""The three optional acts after a clean rewrite — commit, push, open a
pull request — each done only because a flag asked for it. Split out of
- `cmd_apply` for #133.
+ `cmd_apply` for #133. `dropped` names the merged-away rows the re-pin
+ left out (#502), for the commit message.
Nested the way the flags are: `--push` and `--pr` have nothing to act on
without `--branch`, which `main` refuses before this is ever reached.
@@ -3024,7 +3187,8 @@ def _land(root: Path, args, paths: list[str], added: list[str], target: str,
create = (pull_request_line(root, args, target, upstream, kind)
if args.push or args.pr else [])
commit_on_branch(root, args.branch, paths, target, upstream,
- count, kind, added, args.trailer, ledger=ledger)
+ count, kind, added, args.trailer, ledger=ledger,
+ dropped=dropped)
print(f"\n committed on {args.branch}: " + ", ".join(paths))
if args.push:
push_branch(root, args, create)
@@ -3082,11 +3246,16 @@ def cmd_apply(args) -> int:
paths = sorted({row.path for row in copied} | set(added)
| {SHAPE_PIN, kind.manifest}
| ({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
+ # as re-copied nor asked about as a copy the commit leaves out.
+ dropped = [row.path for row in rows if row.state == MERGED_AWAY]
_land(root, args, paths, added, target, upstream, kind, len(copied),
- ledger)
+ ledger, dropped)
print()
next_line(root, args, paths, target, added, kind,
- pinned_paths=[row.path for row in rows])
+ pinned_paths=[row.path for row in rows
+ if row.state != MERGED_AWAY])
return 0
except (Refusal, CommandFailed) as exc:
return _rolled_back(exc, ledger)
@@ -3385,11 +3554,14 @@ def _refuse_writes_through_a_link(root: Path, rows: list[Row], kind: Kind,
nothing new about it. The name is the rewrite twin of `--add`'s
`update-add-symlink` (#239), for a file this run overwrites rather than
creates. Without `--branch` nothing is committed, and a write through
- a link is made as on main (#286).
+ a link is made as on main (#286). A `merged-away` row is not asked
+ about either (#502): its row is dropped, and nothing is read or written
+ at its path.
"""
pin = "contracts/shape-pin.yaml"
paths = [pin, kind.manifest] + [row.path for row in rows
- if row.state != UNCHANGED]
+ if row.state not in (UNCHANGED,
+ MERGED_AWAY)]
linked = []
for path in paths:
link = symlink_on_the_way(root, path)