diff --git a/shape-doctor.py b/shape-doctor.py index 3dc65fc9..cf0b8822 100755 --- a/shape-doctor.py +++ b/shape-doctor.py @@ -3601,9 +3601,10 @@ def pin_at(ctx: Context, rev: str, pin: str = SHAPE_PIN, puts back is what the row then reads. THE ROWS, NOT A YES, for the shape pin (review of PR #358): `rev` may - be an index stage, `:2` or `:3`, and the rows of the side of a merge - `pin_restore` takes whole are the copies that side's tool wrote, so - they are the conflicted files its `why` gives a person to merge (#485). + be an index stage, `:2` or `:3`, and the rows of each side of a merge + are the copies that side's tool wrote, so they are the conflicted files + `pin_restore`'s `why` takes with the pin or gives a person to merge + (#485, #687). """ blob = git_answer(["cat-file", "blob", f"{rev}:./{pin}"], ctx.root, binary=True) @@ -3644,11 +3645,12 @@ def pin_restore(ctx: Context, detail: dict | None = None, edit of the project, kept by `apply --accept-local` on one side, and taken whole the other side's copy dropped it, the hunks git merged cleanly with it, and the doctor then - said COMPLIANT. The copies are the files the taken side's + said COMPLIANT. The copies are the files EITHER side's pin has `files:` rows for, their collision copies included -- those rows and not this standard's copy - lists, since a file the pin has no row for is not a shape - copy, though the lists name its path (review of PR #358). + lists, since a file neither pin has a row for is not a + shape copy, though the lists name its path (review of PR + #358). Only the taken side's can be taken (#687). PROVE AND TAKE (Brett Heap, 2026-10-14T23:54:30Z, #547): a conflicted copy whose two stages are each, byte for byte, the standard's own file at the commit THAT side's @@ -3680,6 +3682,29 @@ def pin_restore(ctx: Context, detail: dict | None = None, 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. + A COPY ONLY THE OTHER SIDE'S PIN HAS A ROW FOR (#687) -- + a path that side's `apply --add` took from the standard + and the side taken here wrote as its own, say -- is + never taken either: `as_the_standard_wrote_it` proves + nothing of a side whose pin has no row for it. Nor is it + left out of the `why`, though the pin taken here makes it + the project's own file once the merge is finished: its + conflict holds the other side's `apply` output, and the + `merge` row lists it unmerged all the same. So the `why` + says that the other side's pin calls it a copy and the + pin taken here does not, and that a person merges it by + hand, keeping the hunks git merged. Where the pin taken + here does not read, it says that instead, and nothing of + whose file the copy becomes; where one side deleted the + copy, it says a person keeps or deletes it, as there are + no hunks to keep (`other_sides_copies`, review of PR + #736). The `--accept-local` + sentence is not for it, and is said only where a copy the + kept pin has a row for is left: `apply` refuses the flag + on a path the pin has no row for. In the `manifest kinds` + row such a copy under `contracts/` is `unmerged` while + the pin is conflicted, as every file the `why` gives a + person is, and `project` once the pin taken here reads. 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 @@ -3688,7 +3713,10 @@ def pin_restore(ctx: Context, detail: dict | None = None, says a person merges it. `detail["left_for_a_person"]` lists every file the `why` gives a person. A file of the project's own is never - named: its merge is theirs. + named: its merge is theirs. Both pins decide which file + that is, not the pin taken here alone: one NEITHER + side's pin has a row for. A file only the other side's + pin calls a copy is named, as above (#687). uncommitted HEAD holds a pin that reads, and the tree or the index differs from it. `checkout HEAD`, which drops a change no tool of this standard leaves uncommitted. @@ -3795,13 +3823,20 @@ def pin_restore(ctx: Context, detail: dict | None = None, f"{what} whole" if b"3" in pin_stages and side == "--ours" else f"only that side of the merge holds a {what}. Take " "it whole") - lines, copies, taken, partial = [], [], [], False + lines, copies, other_side, taken = [], [], [], [] + partial, kept_reads = False, True if pin == SHAPE_PIN: lines = sorted(name for name in (kind.manifest, us.README) if name in stages) - copies = sorted(name for name in set( - pin_at(ctx, f":{stage.decode()}") or ()) - if name in stages) + # Both pins say what is a copy, and the kept one what can be + # taken: a copy only the other side's is never taken (#687). + held = pin_at(ctx, f":{stage.decode()}") + kept_reads, kept = held is not None, set(held or ()) + other = set(pin_at(ctx, ":3" if stage == b"2" else ":2") + or ()) + copies = sorted(name for name in kept if name in stages) + other_side = sorted(name for name in other - kept + if name in stages) # A read of a partial clone can fetch: prove nothing there. partial = bool(copies) and partial_clone(ctx.shape) if not partial: @@ -3821,9 +3856,10 @@ def pin_restore(ctx: Context, detail: dict | None = None, + ("them" if len(taken) > 1 else "it") + " at the " "commit that side's pin names, so neither side holds an " "edit of the project") - if lines or copies: + if lines or copies or other_side: if detail is not None: - detail["left_for_a_person"] = sorted(lines + copies) + detail["left_for_a_person"] = sorted(lines + copies + + other_side) if lines: said.append( f"{' and '.join(lines)} " @@ -3854,6 +3890,7 @@ def pin_restore(ctx: Context, detail: dict | None = None, "check calls it both, as update-conflict, and " "either refusal asks for --accept-local on that " "path") + said += other_sides_copies(other_side, stages, kept_reads) if said: then = ". " + ". ".join(said) + ". Then finish" return "unmerged", ( @@ -3912,6 +3949,52 @@ def pin_restore(ctx: Context, detail: dict | None = None, return "none", None, None +def other_sides_copies(names: list, stages: dict, kept_reads: bool) -> list: + """The sentences of a conflicted shape pin's `why` for `names`, the + files conflicted beside it that only the side NOT taken has `files:` + rows for (#687): never taken, and a person's to resolve. `stages` is + `{path: {stage: mode}}`, as `pin_restore` reads `ls-files -u`, and + `kept_reads` whether the pin taken here reads as one. + + WHAT IS KNOWN, AND NOTHING ELSE (review of PR #736). Where the pin + taken here reads, it has no row for them, so once the merge is finished + each is the project's own file, and the sentence says so. Where it does + not read, nothing says whose file each becomes, so the sentence says + only that it does not read. + + ONE SENTENCE FOR THE FILES BOTH SIDES HOLD, ANOTHER FOR THOSE ONE SIDE + DELETED. Both sides' stages are there for a content conflict, an + add/add included, and a person merges the file by hand, keeping the + hunks git merged. A delete/modify conflict holds one of them alone, and + has no hunks to keep: a person keeps the file or deletes it. + """ + said = [] + for deleted in (False, True): + group = [name for name in names + if deleted != ({b"2", b"3"} <= set(stages.get(name, ())))] + if not group: + continue + many = len(group) > 1 + each = "each" if many else "it" + sentence = ( + f"{' and '.join(group)} {'are' if many else 'is'} conflicted " + "too, and the other side's pin calls " + + ("them copies" if many else "it a copy") + + (" where the pin taken here does not" if kept_reads + else " while the pin taken here does not read as a pin") + + (f", and one side deleted {each}" if deleted else "") + + ", so neither side is taken whole: ") + if kept_reads: + sentence += (f"once the merge is finished {each} is the " + "project's own file" + + (" if kept" if deleted else "") + ", and ") + said.append(sentence + ( + f"a person keeps or deletes {each} by hand" if deleted + else f"a person merges {each} by hand, keeping the hunks git " + "merged")) + return said + + def taken_with_the_pin(ctx: Context, kind, copies: list, stages: dict) -> list: """Which of `copies`, conflicted beside the shape pin, `pin_restore` @@ -4227,12 +4310,16 @@ def unreadable_next(ctx: Context, unreadable: list, WHAT THE SHAPE PIN'S MERGE LEAVES TO A PERSON IS SAID HERE TOO (#485). While the shape pin does not read, no row says what is a copy, so a - copy conflicted beside it is not `tool_written`. The side of the merge - `pin_restore` takes does say, and its `why` gives each such copy to a - person. That copy's case is `unmerged`, the one it has once the pin - reads, and not `project`, and `detail` gets the `left_for_a_person` - the `shape currency` row's has, so both rows say the same of the same - command. A copy that command takes whole with the pin, proved tool + copy conflicted beside it is not `tool_written`. The two sides of the + merge do say, and `pin_restore`'s `why` gives each such copy to a + person. That copy's case is `unmerged`, and not `project`, and + `detail` gets the `left_for_a_person` the `shape currency` row's has, + so both rows say the same of the same command. Where the pin taken + here has a row for the copy, `unmerged` is the case it has once the + pin reads. A copy under `contracts/` that only the other side's pin + lists (#687) is `project` then, as the pin taken here has no row for + it: `unmerged` is its case only while the pin is conflicted and the + `why` names it. A copy the NEXT takes whole with the pin, proved tool output on both sides (#547), is `unmerged` too, and `detail` gets the `taken_with_the_pin` that row's has: the command overwrites it, and `project` would say git was not asked about a file the NEXT puts back. diff --git a/tests/test_doctor_pin_restore_merges.py b/tests/test_doctor_pin_restore_merges.py index ddc1cdff..baa7e9ed 100644 --- a/tests/test_doctor_pin_restore_merges.py +++ b/tests/test_doctor_pin_restore_merges.py @@ -16,6 +16,12 @@ one it leaves to a person. A copy whose mode one side changed, one a side deleted, one the other side's pin has no row for, and every copy where the standard's checkout is a partial clone, stay a person's (review of PR #586). +A copy only the other side's pin has a row for -- a row the kept pin lost, +or an add/add where one side's `apply --add` took a file the standard added +and the other wrote its own -- is never taken either, and the `why` and +`left_for_a_person` name it for a person (#687). Beside a kept pin that +does not read, or where one side deleted it, the `why` says only what is +known: no owner, and no hunks to keep (review of PR #736). L2-9. A slip amended into `bump-leg.py`'s own commit leaves the commit before it holding the pin for the OLD leg commit. Putting that pin back is @@ -49,6 +55,7 @@ commit_in, compliant_again, doctor, pasted, pin_with, project, rows_of, run_pasted, scaffolded_here, standard, ) +from test_update_shape_add import MATERIALIZER, name_in_list #: The two copies these merges conflict, and the standard's file each is a #: verbatim copy of. @@ -104,13 +111,15 @@ def the_standard_moved(standard: Path, tmp_path: Path, *sources: str): return moved, c2, c3, frozen -def apply_on(moved: Path, root: Path, commit: str, branch: str) -> None: +def apply_on(moved: Path, root: Path, commit: str, branch: str, + *extra: str) -> None: """The real `update-shape.py apply --at --yes --branch - `, from `main`.""" + `, and `extra`, from `main`.""" git("checkout", "-q", "main", cwd=root) applied = run_script(moved / "update-shape.py", "apply", "--root", str(root), "--upstream", str(moved), "--at", commit, - "--yes", "--branch", branch, env=GIT_IDENTITY) + "--yes", "--branch", branch, *extra, + env=GIT_IDENTITY) assert applied.returncode == 0, applied.stdout + applied.stderr @@ -175,6 +184,8 @@ def assert_both_rows_name_it(rows: dict) -> dict: assert kinds["detail"]["restore"] == {SHAPE_PIN: "unmerged"}, kinds assert kinds["next"] == currency["next"], (kinds, currency) assert kinds["why"] == currency["why"], (kinds, currency) + assert kinds["detail"].get("left_for_a_person") \ + == currency["detail"].get("left_for_a_person"), (kinds, currency) assert_a_plain_next(currency) return currency @@ -594,17 +605,22 @@ def restaged(root: Path, rel: str, stages: dict) -> None: cwd=str(root), input=data, capture_output=True, check=True) -def assert_only_bootstrap_is_taken(rows: dict) -> dict: +#: What the `why` says of the Makefile where the kept side's pin has its row. +A_COPY_LEFT = f"{MAKEFILE} is a copy {LEFT}" + + +def assert_only_bootstrap_is_taken(rows: dict, said: str = A_COPY_LEFT + ) -> dict: """Both rows name the pin and `scripts/bootstrap.py`, taken; the - Makefile goes to a person with `project.yaml`. The `shape currency` - row.""" + Makefile goes to a person with `project.yaml`, and the `why` says + `said` of it. The `shape currency` row.""" currency = assert_both_rows_name_it(rows) assert pasted(currency["next"])[3:] == [ "checkout", "--ours", "--", SHAPE_PIN, BOOTSTRAP], currency["next"] detail = currency["detail"] assert detail["taken_with_the_pin"] == [BOOTSTRAP], detail assert detail["left_for_a_person"] == [MAKEFILE, "project.yaml"], detail - assert f"{MAKEFILE} is a copy {LEFT}" in currency["why"], currency["why"] + assert said in currency["why"], currency["why"] return currency @@ -647,15 +663,63 @@ def test_a_copy_whose_mode_one_side_changed_is_left_to_a_person( assert_only_bootstrap_is_taken(rows) +def a_pin_restaged(root: Path, number: int, changed) -> None: + """Stage `number` of the conflicted shape pin restaged as `changed` + makes it of the bytes the merge left there.""" + stages = index_stages(root, SHAPE_PIN) + mode, oid = stages[number] + blob = subprocess.run(["git", "cat-file", "blob", oid], cwd=str(root), + capture_output=True, check=True).stdout + held = changed(blob) + written = subprocess.run(["git", "hash-object", "-w", "--stdin"], + cwd=str(root), input=held, + capture_output=True, check=True) + stages[number] = (mode, written.stdout.decode("ascii").strip()) + restaged(root, SHAPE_PIN, stages) + assert stage_of(root, number, SHAPE_PIN) == held + + +def a_pin_without_a_row(root: Path, number: int, rel: str) -> None: + """Stage `number` of the conflicted shape pin restaged without `rel`'s + `files:` row, and every other byte of it as the merge left it.""" + row = (rb'(?m)^ - path: ' + re.escape(rel.encode()) + + rb'\n sha256: "[0-9a-f]{64}"\n') + + def without(blob: bytes) -> bytes: + held, count = re.subn(row, b"", blob) + assert count == 1, blob + return held + + a_pin_restaged(root, number, without) + + +#: What the `why` says of the Makefile one side deleted where only the +#: other side's pin has its row (review of PR #736): no hunks to keep. +OTHER_SIDES_DELETED = ( + f"{MAKEFILE} is conflicted too, and the other side's pin calls it a " + "copy where the pin taken here does not, and one side deleted it, so " + "neither side is taken whole: once the merge is finished it is the " + "project's own file if kept, and a person keeps or deletes it by hand. " + "Then finish") + + +@pytest.mark.parametrize("kept_row", [ + pytest.param(True, id="the-kept-pin-has-its-row"), + pytest.param(False, id="only-the-other-sides-pin-has-its-row"), +]) def test_a_copy_one_side_deleted_is_left_to_a_person(standard, project, - tmp_path): + tmp_path, kept_row): """shape/a re-pins to C2 and then deletes its Makefile; shape/b re-pins to C3. Merged, the Makefile is a delete/modify conflict: stages 1 and 2, no stage 3, and shape/a's pin still has its row. No side's absence proves anything, so the Makefile is a person's -- taken whole as ours, the NEXT would drop the other side's deletion -- and bootstrap.py is still taken. Run, the NEXT leaves the Makefile - unmerged, as the merge left it.""" + unmerged, as the merge left it. + + With the kept side's pin restaged without the Makefile's row, only the + other side's pin calls it a copy (#687), and the `why` says a person + keeps or deletes it, since a deletion leaves no hunks to keep.""" root = under_an_awkward_name(project, tmp_path) moved, c2, c3, _frozen = the_standard_moved( standard, tmp_path, UPSTREAM_TARGET, BOOTSTRAP_SOURCE) @@ -667,8 +731,11 @@ def test_a_copy_one_side_deleted_is_left_to_a_person(standard, project, assert f"UD {MAKEFILE}" in status, status assert f"UU {SHAPE_PIN}" in status, status assert sorted(index_stages(root, MAKEFILE)) == [1, 2] + if not kept_row: + a_pin_without_a_row(root, 2, MAKEFILE) _verdict, rows = rows_from(moved, root) - currency = assert_only_bootstrap_is_taken(rows) + currency = assert_only_bootstrap_is_taken( + rows, A_COPY_LEFT if kept_row else OTHER_SIDES_DELETED) run_pasted(currency["next"]) assert sorted(index_stages(root, MAKEFILE)) == [1, 2] @@ -681,24 +748,171 @@ def test_a_copy_the_other_sides_pin_has_no_row_for_is_left_to_a_person( question, though its bytes are the standard's: the Makefile is left, and bootstrap.py, which both pins name, is still taken.""" root = both_pure["root"] - stages = index_stages(root, SHAPE_PIN) - mode, oid = stages[3] - blob = subprocess.run(["git", "cat-file", "blob", oid], cwd=str(root), - capture_output=True, check=True).stdout - row = (rb'(?m)^ - path: ' + re.escape(MAKEFILE.encode()) - + rb'\n sha256: "[0-9a-f]{64}"\n') - without, count = re.subn(row, b"", blob) - assert count == 1, blob - written = subprocess.run(["git", "hash-object", "-w", "--stdin"], - cwd=str(root), input=without, - capture_output=True, check=True) - stages[3] = (mode, written.stdout.decode("ascii").strip()) - restaged(root, SHAPE_PIN, stages) - assert stage_of(root, 3, SHAPE_PIN) == without + a_pin_without_a_row(root, 3, MAKEFILE) _verdict, rows = rows_from(both_pure["moved"], root) assert_only_bootstrap_is_taken(rows) +#: What the `why` says of a conflicted file only the side NOT taken has a +#: `files:` row for (#687). +OTHER_SIDES = (f"{MAKEFILE} is conflicted too, and the other side's pin " + "calls it a copy where the pin taken here does not, so " + "neither side is taken whole: once the merge is finished it " + "is the project's own file, and a person merges it by hand, " + "keeping the hunks git merged") + + +def test_a_copy_only_the_other_sides_pin_has_a_row_for_is_named(both_pure): + """The mirror of the test above (#687): (a)'s merge with the KEPT + side's pin, stage 2, the one `checkout --ours` takes, restaged without + the Makefile's `files:` row. That pin says the Makefile is not a shape + copy, so nothing proves it unedited and the NEXT never takes it. The + other side's pin calls it one, and its conflict holds that side's + `apply` output, so it is a person's to merge and the `why` and + `left_for_a_person` say so, where they said nothing of it before. No + copy the kept pin has a row for is left, so the `why` sends nobody to + `--accept-local`, which refuses a path that pin has no row for. + bootstrap.py, which both pins name, is still taken. Run, the NEXT + leaves the Makefile unmerged, as the merge left it.""" + root = both_pure["root"] + a_pin_without_a_row(root, 2, MAKEFILE) + _verdict, rows = rows_from(both_pure["moved"], root) + currency = assert_only_bootstrap_is_taken(rows, OTHER_SIDES) + assert "--accept-local" not in currency["why"], currency["why"] + held = (root / MAKEFILE).read_bytes() + run_pasted(currency["next"]) + assert sorted(index_stages(root, MAKEFILE)) == [1, 2, 3] + assert (root / MAKEFILE).read_bytes() == held + + +#: What the `why` says of the two copies, by what the kept side's pin says +#: of them: no rows for either, or nothing, as it does not read (review of +#: PR #736), where nothing says whose file each becomes. +TOGETHER = { + "rows-gone": "where the pin taken here does not, so neither side is " + "taken whole: once the merge is finished each is the " + "project's own file, and a person merges each by hand, " + "keeping the hunks git merged. Then finish", + "unreadable": "while the pin taken here does not read as a pin, so " + "neither side is taken whole: a person merges each by " + "hand, keeping the hunks git merged. Then finish", +} + + +@pytest.mark.parametrize("kept", sorted(TOGETHER)) +def test_copies_only_the_other_sides_pin_has_rows_for_are_named_together( + both_pure, kept): + """The mirror with both copies' rows gone from the kept side's pin, or + with that pin restaged as `files: [`, which does not read, while the + other side's reads: nothing is taken with the pin, and the `why` names + the two in one sentence, which says whose file each becomes only where + the pin taken here reads.""" + root = both_pure["root"] + if kept == "unreadable": + a_pin_restaged(root, 2, lambda _blob: b"files: [\n") + else: + for rel in (MAKEFILE, BOOTSTRAP): + a_pin_without_a_row(root, 2, rel) + _verdict, rows = rows_from(both_pure["moved"], root) + currency = assert_both_rows_name_it(rows) + assert pasted(currency["next"])[3:] == [ + "checkout", "--ours", "--", SHAPE_PIN], currency["next"] + assert currency["detail"]["left_for_a_person"] == [ + MAKEFILE, "project.yaml", BOOTSTRAP], currency["detail"] + assert f"{MAKEFILE} and {BOOTSTRAP} are conflicted too, and the other " \ + f"side's pin calls them copies {TOGETHER[kept]}" in currency["why"], \ + currency["why"] + + +def test_a_naming_copy_only_the_other_sides_pin_has_a_row_for_is_named( + standard, project, tmp_path): + """(h)'s merge, with the kept side's pin restaged without the naming + copy's row (#687). Nothing takes the copy, both rows give it to a + person, and the `manifest kinds` row calls it `unmerged` while the + shape pin is conflicted, as it calls every file the `why` gives a + person. Run, the NEXT leaves the copy as the merge left it, and once + the pin taken here reads, with no row for it, the copy is the project's + own file there, `project`, as the `why` said it would be.""" + root = under_an_awkward_name(project, tmp_path) + moved, c2, c3, _frozen = the_standard_moved(standard, tmp_path, + NAMING_COPY) + status = two_applies_merged(moved, root, c2, c3) + assert f"UU {NAMING_COPY}" in status, status + a_pin_without_a_row(root, 2, NAMING_COPY) + _verdict, rows = rows_from(moved, root) + currency, kinds = rows["shape-currency"], rows["manifest-kinds"] + assert pasted(currency["next"])[3:] == [ + "checkout", "--ours", "--", SHAPE_PIN], currency["next"] + assert kinds["why"] == currency["why"], (kinds, currency) + assert f"{NAMING_COPY} is conflicted too, and the other side's pin " \ + "calls it a copy" in currency["why"], currency["why"] + assert kinds["detail"]["left_for_a_person"] == [ + NAMING_COPY, "project.yaml"], kinds["detail"] + assert kinds["detail"]["restore"] == {NAMING_COPY: "unmerged", + SHAPE_PIN: "unmerged"}, kinds + held = (root / NAMING_COPY).read_bytes() + run_pasted(currency["next"]) + assert (root / NAMING_COPY).read_bytes() == held + _verdict, rows = rows_from(moved, root) + assert rows["manifest-kinds"]["detail"]["restore"] == { + NAMING_COPY: "project"}, rows["manifest-kinds"] + + +#: A file the standard adds to the assembly root's copy list, which one +#: side takes with `apply --add` and the other writes as its own. +ADDED = "SECURITY.md" + + +def test_an_add_add_only_one_sides_pin_calls_a_copy_is_named( + standard, project, tmp_path): + """#687's state by a natural flow. At C2 the standard adds SECURITY.md + to the assembly root's copy list, as a pull request adding a template + file does, and at C3 its Makefile gains a line. shape/a re-pins to C2 + and takes the new copy with `apply --add`; shape/b re-pins to C3 + without it, since `apply` adds nothing unasked, and writes a + SECURITY.md of its own. Merged, SECURITY.md is an add/add conflict that + only the other side's pin calls a copy: the NEXT takes the pin alone, + and the `why` gives SECURITY.md to a person. Run, the NEXT leaves it + unmerged; a person keeps the project's own, and once the merge is + committed the doctor says COMPLIANT, the file the project's own, as the + `why` said.""" + root = under_an_awkward_name(project, tmp_path) + moved = tmp_path / "moved" / "openRepoShape" + shutil.copytree(standard, moved, symlinks=True) + (moved / "templates" / "assembly-root" / ADDED).write_bytes( + b"# The standard's security policy.\n") + source = moved / MATERIALIZER + source.write_text(name_in_list(source.read_text(encoding="utf-8"), + "COPIED_VERBATIM", ADDED), + encoding="utf-8") + commit_all(moved, f"c2: the standard adds {ADDED}") + c2 = git("rev-parse", "HEAD", cwd=moved).stdout.strip() + c3 = moved_on(moved, "c3", UPSTREAM_TARGET) + apply_on(moved, root, c2, "shape/a", "--add", ADDED) + apply_on(moved, root, c3, "shape/b") + (root / ADDED).write_bytes(b"# This project's own security policy.\n") + git("add", "--", ADDED, cwd=root) + commit_in(root, f"the project's own {ADDED}") + status = merge_a_into_b(root) + assert f"AA {ADDED}" in status, status + assert f"UU {SHAPE_PIN}" in status, status + _verdict, rows = rows_from(moved, root) + currency = assert_both_rows_name_it(rows) + assert pasted(currency["next"])[3:] == [ + "checkout", "--ours", "--", SHAPE_PIN], currency["next"] + assert currency["detail"]["left_for_a_person"] == [ + ADDED, "project.yaml"], currency["detail"] + assert OTHER_SIDES.replace(MAKEFILE, ADDED, 1) in currency["why"], \ + currency["why"] + run_pasted(currency["next"]) + assert sorted(index_stages(root, ADDED)) == [2, 3] + (root / ADDED).write_bytes(stage_of(root, 2, ADDED)) + merge_by_ours(root, tmp_path, "project.yaml") + git("add", "--", SHAPE_PIN, ADDED, "project.yaml", cwd=root) + commit_in(root, "merge shape/a") + compliant_again(moved, root) + + def test_a_partial_clone_of_the_standard_proves_nothing_and_fetches_nothing( both_pure, tmp_path): """(a)'s merge, asked of a `--filter=blob:none` clone of the standard,