diff --git a/shape-doctor.py b/shape-doctor.py index bc03fa5d..9518ef83 100755 --- a/shape-doctor.py +++ b/shape-doctor.py @@ -2363,7 +2363,7 @@ def check_manifest_kinds(ctx: Context) -> Row: fixes.append(FAMILY_BESIDE_FIX) command = "; ".join(fixes) if unreadable: - restore, command = unreadable_next(ctx, unreadable) + restore, command = unreadable_next(ctx, unreadable, detail) detail.update({"restore": restore, "refusals": refusals}) return Row("manifest-kinds", LABEL_MANIFEST_KINDS, FINDING, "; ".join(parts) + aside, command, detail) @@ -2515,7 +2515,7 @@ def pin_at(ctx: Context, rev: str, pin: str = SHAPE_PIN): 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 what it names with the pin. + they are the conflicted files its note gives a person to merge (#485). """ blob = git_answer(["cat-file", "blob", f"{rev}:./{pin}"], ctx.root, binary=True) @@ -2544,23 +2544,43 @@ def pin_restore(ctx: Context, detail: dict | None = None, these that applies decides, as `detail["restore"]`: unmerged a merge left the pin conflicted. `checkout --ours`, or - `--theirs` where only that side holds a pin, of every - conflicted path the shape writes WHOLE -- the pin, and - the copies that side's pin has `files:` rows for, their - collision copies included -- because a merge of two - `update-shape.py apply` runs conflicts in them together, - and one side taken whole is one tool's output. Those rows - and not this standard's copy lists: a file the pin has no - row for is not a shape copy, though the lists name its - path (review of PR #358). - The manifest and README.md are never named, though `apply` - writes lines of them: every other line is the project's - own, and one side taken whole drops the other side's - edits, the hunks git merged cleanly included (review of - PR #267). Where either is conflicted the note says a - person merges it, and `detail["left_for_a_person"]` lists - it. A file of the project's own is never named: its merge - is theirs. + `--theirs` where only that side holds a pin, of THE PIN + ALONE, which only a tool writes, so one side taken whole + is one tool's output. + A copy is never named with it (#485), though a merge of + two `update-shape.py apply` runs conflicts in the two + together: a copy can hold an 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 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). Where one of them is + conflicted the note says a person merges it by hand, + 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. + 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 + side's edits, the hunks git merged cleanly included + (review of PR #267). Where either is conflicted the note + says a person merges it. + `detail["left_for_a_person"]` lists every file the note + gives a person. A file of the project's own is never + named: its merge is theirs. 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. @@ -2597,10 +2617,10 @@ def pin_restore(ctx: Context, detail: dict | None = None, declares, for a leg or a neutral product, or a copy the shape pin has a row for -- which is how the `manifest kinds` row answers each one that does not load. The note says `copy` where the file is not a pin, and - `pin_reads_at` says what reads. Only the shape pin takes more paths with - it in a merge -- the copies `apply` writes in the same commit -- and - only its note names a conflicted manifest or README.md for a person. - Any other file is named alone. A leg pin moves with its gitlink, but + `pin_reads_at` says what reads. Only the shape pin's note names other + paths of a merge, for a person to merge by hand: the copies `apply` + writes in the same commit, and a conflicted manifest or README.md. + Every pin is named alone. A leg pin moves with its gitlink, but `checkout --ours` on a conflicted gitlink exits 0 and leaves all three stages where they were, so naming it would be a command that changes nothing. Finishing the merge takes the gitlink, and the `pins` row then @@ -2640,7 +2660,6 @@ def pin_restore(ctx: Context, detail: dict | None = None, if xy in UNMERGED_STATUS: us = ctx.update_shape() kind = us.root_kind(root) - whole = {pin} # ` SP SP TAB `, one record per stage. stages: dict[str, set] = {} listing = git_answer(["ls-files", "-u", "-z"], root, binary=True) @@ -2654,33 +2673,53 @@ def pin_restore(ctx: Context, detail: dict | None = None, ("--theirs", b"3") if b"3" in pin_stages else (None, None)) if side: - if pin == SHAPE_PIN: - whole.update(pin_at(ctx, f":{stage.decode()}") or ()) - note = ("--theirs takes the other side. Take one side whole" - if b"3" in pin_stages and side == "--ours" + note = (f"--theirs takes the other side. Take one side of the " + 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") - named = sorted(name for name, held in stages.items() - if name in whole and stage in held) - left = (sorted(name for name in (kind.manifest, us.README) - if name in stages) if pin == SHAPE_PIN else []) - then = ", then finish" - if left: + lines, copies = [], [] + 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) + then, after = ", then finish", "" + if lines or copies: if detail is not None: - detail["left_for_a_person"] = left - says = ("are conflicted too and hold lines of the project, " - "so a person merges them" if len(left) > 1 else - "is conflicted too and holds lines of the project, " - "so a person merges it") - then = (f". {' and '.join(left)} {says}, keeping the lines " - "update-shape.py writes from the side taken here. " - "Then finish") + detail["left_for_a_person"] = sorted(lines + copies) + said = [] + if lines: + said.append( + f"{' and '.join(lines)} " + + ("are conflicted too and hold lines of the " + "project, so a person merges them" if len(lines) > 1 + else "is conflicted too and holds lines of the " + "project, so a person merges it") + + ", keeping the lines update-shape.py writes from " + "the side taken here") + if copies: + said.append( + f"{' and '.join(copies)} " + + ("are copies conflicted too" if len(copies) > 1 + else "is a copy conflicted too") + + f". {BY_HAND_WHY['copy']}, so neither side is " + "taken whole: a person merges " + + ("each" if len(copies) > 1 else "it") + + " by hand, keeping the hunks git merged") + 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") + then = ". " + ". ".join(said) + ". Then finish" return "unmerged", ( - f"{git_c} checkout {side} -- " - + " ".join(quote_arg(name) for name in named) - + f" # {note} for every path named and never a " - f"hand-merged {what}{then} the merge as git says and run " - "this doctor again") + f"{git_c} checkout {side} -- {quote_arg(pin)} # {note} and " + f"never a hand-merged {what}{then} the merge as git says and " + f"run this doctor again{after}") elif pin_reads_at(ctx, "HEAD", pin): if xy and shown: return "uncommitted", ( @@ -2853,7 +2892,8 @@ def tool_written(ctx: Context, rel: str) -> bool: "once they load") -def unreadable_next(ctx: Context, unreadable: list) -> tuple[dict, str]: +def unreadable_next(ctx: Context, unreadable: list, + detail: dict | None = None) -> tuple[dict, str]: """`(restore, command)` for the `manifest kinds` row's files that do not load (#291): each file's case, and the one command the row prints. @@ -2876,16 +2916,33 @@ def unreadable_next(ctx: Context, unreadable: list) -> tuple[dict, str]: the file, and its note says that what the file should say is theirs to decide. #205's `manifest` row answers a `project.yaml` that does not load the same way: the gate that refuses it, and that note. + + 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 note 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. """ restore: dict[str, str] = {} commands: dict[str, str] = {} + held: dict = {} for rel in unreadable: if tool_written(ctx, rel): - restore[rel], found = pin_restore(ctx, pin=rel) + restore[rel], found = pin_restore( + ctx, held if rel == SHAPE_PIN else None, pin=rel) if found: commands[rel] = found else: restore[rel] = "project" + left = held.get("left_for_a_person", []) + for rel in left: + if restore.get(rel) == "project": + restore[rel] = "unmerged" + if left and detail is not None: + detail["left_for_a_person"] = left command = commands.get(SHAPE_PIN) or next(iter(commands.values()), None) if command is None: command = (f"{PYTHON} {quote_arg(ctx.shape / 'shape-doctor.py')} " @@ -2922,7 +2979,7 @@ def unreadable_pin_row(ctx: Context, exc: Refusal) -> Row: THE NEXT PUTS BACK THE PIN A TOOL WROTE, NEVER A FETCH. `pin_restore` names the git command from the pin's own state, and run as printed it ends at `COMPLIANT` or at a row that reads the pin, but for what its - note leaves to a person: a conflicted manifest or README.md, or an index + note gives a person: a conflicted copy, manifest or README.md, or an index flag that hides the pin. Naming the tool that refused, `update-shape.py check`, was a dead end (review of PR #267): it reads the pin with the same `read_pin`, refuses it the same way, and its remediation is about diff --git a/tests/test_shape_doctor.py b/tests/test_shape_doctor.py index 3b60097e..e51fbc52 100644 --- a/tests/test_shape_doctor.py +++ b/tests/test_shape_doctor.py @@ -4056,16 +4056,20 @@ def test_a_pin_broken_in_a_commit_is_put_back_from_before_that_commit( compliant_again(standard, project) -def test_the_issues_own_merge_takes_one_side_of_the_shapes_files_whole( - standard, project): +def test_the_issues_own_merge_takes_only_the_pin_whole(standard, project): """The issue's reproduction: two branches rewrite `commit:`, then merge. The pin is `UU`, and so are `Makefile`, a shape copy, and a file of the - project's own. The NEXT takes one side whole of every conflicted file the - shape writes and of no other, so the project's own file is left to its - owner. Run, the pin reads again: the row then answers about the commit - it names, which in this fixture is no commit at all. - """ + project's own. The NEXT took one side whole of the pin and the + Makefile, as one tool's output. But a copy can hold an edit of the + project, kept by `apply --accept-local`, and taken whole the other + side's copy dropped it (#485). So the NEXT takes the pin alone, and its + note and `left_for_a_person` give the Makefile to a person to merge by + hand. The project's own file is left to its owner, unnamed. Run, the + pin reads again and the Makefile is as the merge left it: the row then + answers about the commit the pin names, which here is no commit at all. + """ + makefile = project / "Makefile" own = project / "notes.md" own.write_bytes(b"base\n") git("add", "--", "notes.md", cwd=project) @@ -4094,11 +4098,17 @@ def test_the_issues_own_merge_takes_one_side_of_the_shapes_files_whole( assert row["next"].startswith("git -C "), row["next"] assert "checkout --ours -- " in row["next"], row["next"] argv = pasted(row["next"]) - assert argv[3:] == ["checkout", "--ours", "--", "Makefile", SHAPE_PIN], \ - argv - assert "left_for_a_person" not in row["detail"], row["detail"] + assert argv[3:] == ["checkout", "--ours", "--", SHAPE_PIN], argv + assert row["detail"]["left_for_a_person"] == ["Makefile"], row["detail"] + note = row["next"].split(" #", 1)[1] + assert "Makefile is a copy conflicted too" in note, note + assert "merges it by hand, keeping the hunks git merged" in note, note + assert "notes.md" not in row["next"], row["next"] assert_a_plain_note(row["next"]) + held = makefile.read_bytes() + assert b"# edit-a" in held and b"# edit-b" in held, held[-200:] run_pasted(row["next"]) + assert makefile.read_bytes() == held, "the NEXT took the Makefile whole" again = rows_of(doctor(standard, project, "--json"))["shape-currency"] assert again["detail"]["error"] not in READ_PIN_CODES, again assert again["detail"]["error"] == "update-revision-unresolvable", again @@ -4178,9 +4188,12 @@ def test_the_issues_two_apply_merge_leaves_the_projects_manifest_to_a_person( paragraph, lines of its own far from any `apply` writes. Git merges both cleanly, and `project.yaml` is still `UU` over its `shape:` block. Taking `project.yaml` whole dropped the rename, and the doctor then said - COMPLIANT (review of PR #267). The NEXT takes the files the shape writes - whole, `project.yaml` is left to a person, and the rename survives the - NEXT, that person's merge and the merge commit. + COMPLIANT (review of PR #267). The NEXT takes the pin whole, and + `project.yaml` is left to a person, and the rename survives the NEXT, + that person's merge and the merge commit. So is the `UU` Makefile: it + was taken whole with the pin until a copy kept by `--accept-local` was + found to lose its edit that way (#485), and now the person merges it + too, here to the side the NEXT took, which is the pin's own copy. The row is `check_shape_currency`'s own, asked in process: without #232 a `project.yaml` that does not load is NOT A SHAPE ROOT and the @@ -4218,31 +4231,38 @@ def test_the_issues_two_apply_merge_leaves_the_projects_manifest_to_a_person( assert row["detail"]["error"] == "yaml-unparsable", row["detail"] assert row["detail"]["restore"] == "unmerged", row["detail"] assert pasted(row["next"])[3:] == [ - "checkout", "--ours", "--", "Makefile", SHAPE_PIN], row["next"] - assert row["detail"]["left_for_a_person"] == ["project.yaml"], \ + "checkout", "--ours", "--", SHAPE_PIN], row["next"] + assert row["detail"]["left_for_a_person"] == ["Makefile", + "project.yaml"], \ row["detail"] note = row["next"].split(" #", 1)[1] assert "project.yaml is conflicted too" in note, note + assert "Makefile is a copy conflicted too" in note, note assert_a_plain_note(row["next"]) + held = (project / "Makefile").read_bytes() run_pasted(row["next"]) assert b'name: "Atlas Renamed"' in manifest.read_bytes(), \ "shape/a's rename was dropped" assert b"only shape/a wrote" in readme.read_bytes() + assert (project / "Makefile").read_bytes() == held, \ + "the NEXT took the Makefile whole" again = module.check_shape_currency(module.Context(project)).as_dict() assert again["detail"].get("error") not in READ_PIN_CODES, again - # A person's merge of `project.yaml`: each conflicting hunk to the side - # the NEXT took, each clean one kept, over git's own three stages. - stages = [] - for number in (1, 2, 3): - held = subprocess.run(["git", "show", f":{number}:project.yaml"], - cwd=str(project), capture_output=True, - check=True).stdout - stages.append(tmp_path / f"project.yaml.{number}") - stages[-1].write_bytes(held) - subprocess.run(["git", "merge-file", "--ours", str(stages[1]), - str(stages[0]), str(stages[2])], - capture_output=True, check=True) - manifest.write_bytes(stages[1].read_bytes()) + # A person's merge of `project.yaml` and of the Makefile: each + # conflicting hunk to the side the NEXT took, each clean one kept, over + # git's own three stages. + for name in ("project.yaml", "Makefile"): + stages = [] + for number in (1, 2, 3): + held = subprocess.run(["git", "show", f":{number}:{name}"], + cwd=str(project), capture_output=True, + check=True).stdout + stages.append(tmp_path / f"{name}.{number}") + stages[-1].write_bytes(held) + subprocess.run(["git", "merge-file", "--ours", str(stages[1]), + str(stages[0]), str(stages[2])], + capture_output=True, check=True) + (project / name).write_bytes(stages[1].read_bytes()) git("add", "--", "project.yaml", "Makefile", SHAPE_PIN, cwd=project) commit_in(project, "merge shape/a") kept = git("show", "HEAD:project.yaml", cwd=project).stdout @@ -5486,6 +5506,296 @@ def test_a_neutral_product_pin_is_shown_never_put_back(standard, project, assert b"2" * 40 in before +# --- the shape pin and a copy conflicted together (#485) -------------------- +# +# Review of PR #358 (probes OD and OD2), filed as #485. Two branches that +# each re-pin the shape: on one a person edits a copy and keeps the edit +# with `update-shape.py apply --accept-local`, the exit the `shape currency` +# row names for it; on the other `apply` takes a newer standard that wrote +# the same end of that copy. Merged, the shape pin and the copy are both +# conflicted. The NEXT took the copy whole with the pin, as one tool's +# output, and with the update side as `--ours` the person's lines were gone, +# the hunk git had merged with them, and the doctor then said COMPLIANT. +# Now the NEXT takes the pin alone, and the copy is a person's to merge, as +# a copy conflicted on its own is. + +#: The project's own Makefile target, and the one the standard's newer +#: Makefile gains: both at the end of the file, so git cannot merge them. +OWN_TARGET = b"\n# the project's own target\nship:\n\t@echo shipping Atlas\n" +NEWER_TARGET = b"\n# a target the standard gained\ndoctor:\n\t@echo doctor\n" + + +def standard_appended(standard: Path, tmp_path: Path, rel: str, + tail: bytes) -> tuple: + """`(a copy of standard, S1, S2)`: S1 is the commit every project here + is scaffolded at, S2 one more commit appending `tail` to the standard's + `rel`, so `apply --at S2` re-copies the file made from it. A copy, with + its `.git`, so the session's `standard` is left as every test finds it. + """ + moved = tmp_path / "moved" / "openRepoShape" + shutil.copytree(standard, moved, symlinks=True) + s1 = git("rev-parse", "HEAD", cwd=moved).stdout.strip() + append_to(moved / rel, tail) + commit_all(moved, f"{rel} gains a line") + return moved, s1, git("rev-parse", "HEAD", cwd=moved).stdout.strip() + + +def apply_and_commit(moved: Path, root: Path, *args: str) -> None: + """The real `update-shape.py apply --yes`, and what it wrote committed.""" + applied = run_script(moved / "update-shape.py", "apply", "--root", + str(root), "--upstream", str(moved), "--yes", *args) + assert applied.returncode == 0, applied.stdout + applied.stderr + commit_all(root, "update-shape.py apply " + " ".join(args)) + + +def two_shape_updates_merged(moved: Path, s1: str, s2: str, root: Path, + copy: str, edit, ours: str) -> bytes: + """Probe OD's state, built with the real `update-shape.py`. + + Branch `local`: `edit` changes `copy`, committed, then kept with `apply + --at S1 --accept-local `, which re-pins it from the project's + bytes. Branch `upgrade`: `apply --at S2`, which re-copies it from the + newer standard and re-pins the shape. Both from HEAD, and `ours` + merges the other, which leaves the copy and the shape pin `UU`. The + copy's bytes as the merge left them. + """ + base = git("rev-parse", "HEAD", cwd=root).stdout.strip() + git("checkout", "-q", "-b", "local", base, cwd=root) + edit(root) + git("add", "--", copy, cwd=root) + commit_in(root, f"the project's own edit of {copy}") + apply_and_commit(moved, root, "--at", s1, "--accept-local", copy) + git("checkout", "-q", "-b", "upgrade", base, cwd=root) + apply_and_commit(moved, root, "--at", s2) + git("checkout", "-q", ours, cwd=root) + theirs = "local" if ours == "upgrade" else "upgrade" + merged = subprocess.run(["git", "merge", "-q", theirs], cwd=str(root), + capture_output=True, text=True, check=False, + env={**os.environ, **GIT_IDENTITY}) + assert merged.returncode != 0, merged.stdout + merged.stderr + status = git("status", "--porcelain=v1", cwd=root).stdout + assert f"UU {copy}" in status and f"UU {SHAPE_PIN}" in status, status + return (root / copy).read_bytes() + + +def verdict_of(standard: Path, root: Path) -> tuple[str, dict]: + result = doctor(standard, root, "--json") + return json.loads(result.stdout)["verdict"], rows_of(result) + + +def assert_the_merge_is_unfinished(verdict: str, rows: dict, + paths: list) -> None: + """#424's rung over #485's state: while the index holds the copy and + the shape pin unmerged, before the NEXT and after it, since `checkout + --ours` writes the tree and leaves the index as it was, the verdict is + `INVALID (merge)` and the `merge` row names both, with git's read-only + `status` as its NEXT. The `shape currency` and `manifest kinds` rows + still answer, and what they print is what the person finishing the + merge reads.""" + assert verdict == "INVALID (merge)", (verdict, rows) + merge = rows["merge"] + assert merge["status"] == "FINDING", merge + assert merge["detail"]["unmerged"] == sorted(paths), merge["detail"] + assert merge["detail"]["merging"] is True, merge["detail"] + argv = pasted(merge["next"]) + assert argv[:2] == ["git", "-C"] and argv[3:] == ["status"], merge + + +def assert_the_merge_is_finished(verdict: str, rows: dict, + state: str) -> None: + """Once the copy merged by hand is committed, the merge's own row is + gone, and it is the copy, kept unlike the pin, that keeps the root + from COMPLIANT: `state` is what `update-shape.py check` calls it.""" + assert "merge" not in rows, rows["merge"] + assert verdict == f"DRIFTED (1 {state})", (verdict, rows) + + +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.""" + assert currency["detail"]["restore"] == "unmerged", currency["detail"] + assert pasted(currency["next"])[3:] == [ + "checkout", "--ours", "--", SHAPE_PIN], currency["next"] + assert currency["detail"]["left_for_a_person"] == [copy], currency + assert kinds["next"] == currency["next"], (kinds, currency) + assert kinds["detail"]["left_for_a_person"] == [copy], kinds["detail"] + note = currency["next"].split(" #", 1)[1] + assert f"{copy} is a copy conflicted too" in note, note + assert "merges it by hand, keeping the hunks git merged" in note, note + assert "Take one side of the pin whole and never a hand-merged pin" \ + in note, note + 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"): + assert words in note, (words, note) + assert_a_plain_note(currency["next"]) + + +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 + 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 + ` re-pins the copy from the merge and the root is COMPLIANT. + """ + then = currency["next"].split(" #", 1)[1].split() + assert then[:2] == ["then", "apply"], currency["next"] + args = then[2:] + apply = (moved / "update-shape.py", "apply", "--root", str(root), + "--upstream", str(moved)) + if ours == "upgrade": + assert args[args.index("--accept-local") + 1] == copy, then + 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] + applied = run_script(*apply, *args) + assert applied.returncode == 0, applied.stdout + applied.stderr + compliant_again(moved, root) + + +@pytest.mark.parametrize("ours", [ + pytest.param("upgrade", id="the-update-side-ours"), + pytest.param("local", id="the-persons-side-ours"), +]) +def test_a_copy_conflicted_with_the_shape_pin_is_left_to_a_person( + standard, project, tmp_path, ours): + """Probe OD (#485): the naming copy edited and accepted on `local`, its + end rewritten by an upgrade on `upgrade`, merged either way. + + The NEXT was `checkout --ours -- contracts/shape-pin.yaml`. + With the update side as ours it dropped the project's line, which git + had merged, and its tail; the merge finished, the doctor said + COMPLIANT. With the person's side as ours it dropped the upgrade's. + + Now, run as pasted, it leaves the copy as the merge left it. The + `manifest kinds` row's `restore` calls the copy `unmerged`, the case + 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 + line of both sides, either way round. + + Until the merge is committed the verdict is #424's `INVALID (merge)`, + the index holding the copy and the pin unmerged before the NEXT and + after it. Once it is, the `merge` row is gone and the verdict is the + copy's drift, so COMPLIANT still waits on the copy, not on the merge. + """ + moved, s1, s2 = standard_appended(standard, tmp_path, NAMING_COPY, + SHAPE_TAIL) + + def person(r: Path) -> None: + insert_after_first_line(r / NAMING_COPY, PROJECT_LINE) + append_to(r / NAMING_COPY, PROJECT_TAIL) + + held = two_shape_updates_merged(moved, s1, s2, project, NAMING_COPY, + person, ours) + for line in (PROJECT_LINE, PROJECT_TAIL, SHAPE_TAIL): + assert line in held, line + verdict, rows = verdict_of(moved, project) + assert_the_merge_is_unfinished(verdict, rows, [NAMING_COPY, SHAPE_PIN]) + kinds, currency = rows["manifest-kinds"], rows["shape-currency"] + run_pasted(currency["next"]) + assert (project / NAMING_COPY).read_bytes() == held, \ + "the NEXT took one side of the copy whole" + assert_the_pin_alone_is_taken(currency, kinds, NAMING_COPY) + assert kinds["detail"]["restore"] == {NAMING_COPY: "unmerged", + SHAPE_PIN: "unmerged"}, kinds + verdict, rows = verdict_of(moved, project) + assert_the_merge_is_unfinished(verdict, rows, [NAMING_COPY, SHAPE_PIN]) + kinds = rows["manifest-kinds"] + assert kinds["detail"]["restore"] == {NAMING_COPY: "unmerged"}, kinds + assert pasted(kinds["next"])[3:] == ["diff", "--", NAMING_COPY], kinds + shown = run_read_only(moved, project, kinds) + assert PROJECT_TAIL.strip() in shown and SHAPE_TAIL.strip() in shown + merge_by_hand(project / NAMING_COPY) + git("add", "--", NAMING_COPY, SHAPE_PIN, cwd=project) + commit_in(project, "the copy merged by hand") + verdict, rows = verdict_of(moved, project) + assert_the_merge_is_finished( + verdict, rows, "locally-modified" if ours == "upgrade" else "both") + re_pinned_from_the_merge(moved, project, rows["shape-currency"], + NAMING_COPY, ours) + kept = git("show", f"HEAD:{NAMING_COPY}", cwd=project).stdout + for line in (PROJECT_LINE, PROJECT_TAIL, SHAPE_TAIL): + assert line.decode() in kept, line + + +@pytest.mark.parametrize("ours", [ + pytest.param("upgrade", id="the-update-side-ours"), + pytest.param("local", id="the-persons-side-ours"), +]) +def test_a_makefile_conflicted_with_the_shape_pin_keeps_the_projects_target( + standard, project, tmp_path, ours): + """Probe OD2 (#485), the copy a project most often edits. The project + appends its own target to the Makefile and keeps it with + `--accept-local`, and the standard's newer Makefile appends another. + Either branch merges the other (review of PR #496, N2). + + The NEXT named `Makefile` with the pin, and once the merge was finished + HEAD's Makefile had lost the other side's target and, with the update + side as ours, the doctor said COMPLIANT. Now the Makefile keeps both + sides through the NEXT, the doctor is not COMPLIANT until a person has + merged it and accepted the merge, and HEAD's Makefile then holds both + targets. Until the merge is committed the verdict is #424's `INVALID + (merge)`, and after it the Makefile's drift: `locally-modified` with + the update side's pin taken, `both` with the person's. + """ + moved, s1, s2 = standard_appended(standard, tmp_path, UPSTREAM_TARGET, + NEWER_TARGET) + held = two_shape_updates_merged( + moved, s1, s2, project, "Makefile", + lambda r: append_to(r / "Makefile", OWN_TARGET), ours) + assert b"ship:" in held and b"doctor:" in held, held[-400:] + verdict, rows = verdict_of(moved, project) + assert_the_merge_is_unfinished(verdict, rows, ["Makefile", SHAPE_PIN]) + kinds, currency = rows["manifest-kinds"], rows["shape-currency"] + run_pasted(currency["next"]) + assert (project / "Makefile").read_bytes() == held, \ + "the NEXT took one side of the Makefile whole" + assert_the_pin_alone_is_taken(currency, kinds, "Makefile") + assert kinds["detail"]["restore"] == {SHAPE_PIN: "unmerged"}, kinds + verdict, rows = verdict_of(moved, project) + assert_the_merge_is_unfinished(verdict, rows, ["Makefile", SHAPE_PIN]) + merge_by_hand(project / "Makefile") + git("add", "--", "Makefile", SHAPE_PIN, cwd=project) + commit_in(project, "the Makefile merged by hand") + verdict, rows = verdict_of(moved, project) + assert_the_merge_is_finished( + verdict, rows, "locally-modified" if ours == "upgrade" else "both") + re_pinned_from_the_merge(moved, project, rows["shape-currency"], + "Makefile", ours) + kept = git("show", "HEAD:Makefile", cwd=project).stdout + assert "ship:" in kept and "doctor:" in kept, kept[-400:] + + # --- placement: code in spec, spec in code (#99) ---------------------------- # # Brett Heap, 2026-09-10: "we have to look for code in spec and spec in code".