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)