diff --git a/shape-doctor.py b/shape-doctor.py index e902aabd..b2a78b3b 100755 --- a/shape-doctor.py +++ b/shape-doctor.py @@ -4349,6 +4349,19 @@ def pin_restore(ctx: Context, detail: dict | None = None, path as renamed from the copy and gives it to a person (`renamed_copies`). It is never taken: neither side's pin has a row to prove it by. + A COPY RENAMED ON BOTH SIDES (#783) is `DD` at its old + name, stage 1 alone, and each new name is added by one + side alone, with no stage 1. It is no copy to merge by + hunks, as git merged none there, so it leaves `copies`, + and the `why` says it is gone on both sides and names + each new name a side's own commit holds the base's blob + of it at, or the bytes that commit's own pin row for it + records, at that one path of the commit, saying where a + side's is not proved (`copies_renamed_twice`, + `twice_renamed_copies`). Where the pin taken here still + lists a copy renamed on one side or both, the `why` says + this doctor reports it missing once the merge is + finished (`still_listed`). 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 @@ -4362,7 +4375,7 @@ def pin_restore(ctx: Context, detail: dict | None = None, side's pin has a row for, under its name or under the one the merge base held it at. A file only the other side's pin calls a copy is named, as above (#687), and - so is a copy's rename (#753). + so is a copy's rename, on one side or both (#753, #783). 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. @@ -4472,6 +4485,7 @@ def pin_restore(ctx: Context, detail: dict | None = None, if side: lines, copies, other_side, taken, partial = [], [], [], [], False renamed: dict[str, str] = {} + twice: dict[str, dict] = {} kept: set = set() if pin == SHAPE_PIN: lines = sorted(name for name in (kind.manifest, us.README) @@ -4484,9 +4498,24 @@ def pin_restore(ctx: Context, detail: dict | None = None, copies = sorted(name for name in kept if name in stages) other_side = sorted(name for name in other - kept if name in stages) - renamed = copies_renamed(ctx, kept | other, { - name: base for name, base in bases.items() - if name not in kept | other | {pin, *lines}}) + named = kept | other | {pin, *lines} + # A copy both sides renamed is `DD`, stage 1 alone (#783): + # never taken, and never a merge of hunks there are not. + gone = {name: bases[name] for name in copies + other_side + if set(stages[name]) == {b"1"}} + copies = [name for name in copies if name not in gone] + other_side = [name for name in other_side + if name not in gone] + unnamed = {name: base for name, base in bases.items() + if name not in named} + held = base_holders(ctx) if gone or unnamed else {} + renamed = copies_renamed(kept | other, unnamed, held) + # A path a pin has a row for is named as that, never as a + # copy's new name too (review of PR #789). + twice = copies_renamed_twice( + ctx, gone, {name: held_at for name, held_at + in stages.items() if name not in named}, + held) # A read of a partial clone can fetch: prove nothing there. partial = bool(copies) and partial_clone(ctx.shape) if not partial: @@ -4506,10 +4535,12 @@ 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 or other_side or renamed: + if lines or copies or other_side or renamed or twice: if detail is not None: detail["left_for_a_person"] = sorted( - lines + copies + other_side + list(renamed)) + {*lines, *copies, *other_side, *renamed, *twice, + *(path for went in twice.values() + for paths in went.values() for path in paths)}) if lines: said.append( f"{' and '.join(lines)} " @@ -4541,7 +4572,9 @@ def pin_restore(ctx: Context, detail: dict | None = None, "either refusal asks for --accept-local on that " "path") said += other_sides_copies(other_side, stages) - said += renamed_copies(renamed, stages, kept) + said += renamed_copies(renamed, stages) + said += twice_renamed_copies(twice, stage) + said += still_listed(set(renamed.values()) | set(twice), kept) if said: then = ". " + ". ".join(said) + ". Then finish" return "unmerged", ( @@ -4650,11 +4683,39 @@ def by_conflict(names: list, stages: dict) -> list: return pairs -def copies_renamed(ctx: Context, copies: set, bases: dict) -> dict: +def base_holders(ctx: Context) -> dict: + """`{blob: [paths]}`, every path of the merge base's whole tree under + the blob it holds (`tree_holders`), the base being `merge-base HEAD + MERGE_HEAD`, as `copies_renamed` (#753) and `copies_renamed_twice` + (#783) weigh a blob; `{}` where git cannot name the base, as for any + conflict but a merge's. + """ + base = git_answer(["merge-base", "HEAD", "MERGE_HEAD"], ctx.root) + return tree_holders(ctx, base) if base else {} + + +def tree_holders(ctx: Context, rev: str) -> dict: + """`{blob: [paths]}`, every path of the whole tree `rev` names under + the blob it holds, read from the root as `ls-tree -r` lists it, so each + path is named as `ls-files` names it there; `{}` where git cannot read + it. One read of the merge base (`base_holders`), and of each side's own + commit, for `copies_renamed_twice` (review of PR #789, round 3).""" + listing = git_answer(["ls-tree", "-r", "-z", rev], ctx.root, binary=True) + held: dict[bytes, list] = {} + for record in (listing or b"").split(b"\0"): + meta, _tab, path = record.partition(b"\t") + if path and len(meta.split()) == 3: + held.setdefault(meta.split()[2], []).append( + path.decode("utf-8", "replace")) + return held + + +def copies_renamed(copies: set, bases: dict, held: dict) -> dict: """`{path: copy}` for each path conflicted beside the shape pin whose stages came from a rename of `copy`, a path a side's pin has a `files:` row for (#753). `bases` is `{path: object}`, stage 1 of each conflicted - path no pin has a row for, the merge base's blob. + path no pin has a row for, the merge base's blob, and `held` the base's + whole tree by blob (`base_holders`). THE MERGE BASE'S OWN BLOB, AT A NAME THE BASE DID NOT HOLD. A rename on one side and an edit on the other conflict under the new name, and git @@ -4684,36 +4745,177 @@ def copies_renamed(ctx: Context, copies: set, bases: dict) -> dict: whose exact bytes the base also holds at another path, which is named no more than it was before #753; the `merge` row still lists it. """ - if not bases or not copies: - return {} - base = git_answer(["merge-base", "HEAD", "MERGE_HEAD"], ctx.root) - listing = base and git_answer(["ls-tree", "-r", "-z", base], ctx.root, - binary=True) - held: dict[bytes, list] = {} - for record in (listing or b"").split(b"\0"): - meta, _tab, path = record.partition(b"\t") - if path and len(meta.split()) == 3: - held.setdefault(meta.split()[2], []).append( - path.decode("utf-8", "replace")) present = {name for names in held.values() for name in names} return {name: held[blob][0] for name, blob in sorted(bases.items()) if name not in present and len(held.get(blob, ())) == 1 and held[blob][0] in copies} -def renamed_copies(renamed: dict, stages: dict, kept: set) -> list: +#: The commit each side of a merge records its own tree in, by the index +#: stage git gives that side's version of a path. +COMMIT_OF_STAGE = {b"2": "HEAD", b"3": "MERGE_HEAD"} + + +def copies_renamed_twice(ctx: Context, gone: dict, stages: dict, + held: dict) -> dict: + """`{copy: {stage: [paths]}}` for each copy both sides of the merge + renamed (#783): `gone` is `{copy: blob}`, the copies conflicted beside + the shape pin at stage 1 alone (`DD`) with the merge base's blob of + each, and each path is a new name of the copy on the side git gives + `stage`, b"2" for the side checked out, b"3" for the side merged in. + + BOTH SIDES RENAMED IT. git leaves the old name `DD` and each new one + added by one side alone (`AU`, `UA`), with no stage 1, so #781's rule, + which keys on a path's stage 1, sees neither. And it writes its merge + of the copy's three versions to both new names, so where a side edited + the copy no stage holds the base's blob. + + THE BYTES THE SIDE ITSELF COMMITTED ARE THE EVIDENCE (`side_moved_it`). + A path added by one side alone is that side's new name for the copy + where the commit that side records (`HEAD`, `MERGE_HEAD`) holds at that + path the merge base's blob of the copy, object for object, or the bytes + whose sha256 that same commit's own shape pin records in its row for + the copy (review of PR #789): where the standard moved the copy between + the two pins, each side's `apply` rewrote it, and neither commit holds + the base's blob. A side whose commit holds an edit there is not proved: + no similarity index is guessed at, and git's merged content, the same + at both names, is not taken for a pairing. + + NO OTHER PATH OF THE BASE HOLDS THOSE BYTES, as #781 weighs a blob + (`base_holders`): the base's blob of the copy is held there by the + copy alone, and bytes the base did not hold at the copy are held there + by no path at all. So a path of the base holding the very bytes a side + holds at its new name proves that name nothing, while a twin of the + copy's old bytes does not stand in the way of bytes the standard moved + it to. Where git cannot name the base, nothing is proved. + + AND NO OTHER PATH OF THE SIDE'S OWN COMMIT HOLDS THEM (`tree_holders`, + review of PR #789, round 3): #781's one-path rule, applied to the + side's tree. A project that keeps its own twin of the copy in step + with it, and renames the twin too, holds the copy's bytes at two new + names, and git pairs one of them with the twin: neither is proved, as + either may be the project's own file. The same holds for the base's + blob written at two paths. + + Only a path no pin has a row for is a candidate: `pin_restore` names + the others as what their rows say, never as a new name too (review of + PR #789). Several proved paths on one side were not reached in that + review: git pairs one new name per side with the rename, another path + a side copies the copy to is added cleanly, never conflicted, and two + proved paths would have to hold different bytes, the base's blob at + one and the bytes of the side's pin row at the other. The `[paths]` + keeps them all, should git ever give more. + """ + if not gone: + return {} + added = sorted(name for name, held_at in stages.items() + if set(held_at) in ({b"2"}, {b"3"})) + sides = {number: (commit, pinned_sha256(ctx, commit), + tree_holders(ctx, commit)) + for number, commit in COMMIT_OF_STAGE.items()} + twice: dict[str, dict] = {} + for copy, blob in sorted(gone.items()): + found: dict[bytes, list] = {} + for path in added: + number = next(iter(stages[path])) + commit, pinned, tree = sides[number] + if held and side_moved_it(ctx, commit, path, copy, blob, + pinned.get(copy), tree, held): + found.setdefault(number, []).append(path) + twice[copy] = found + return twice + + +def pinned_sha256(ctx: Context, rev: str) -> dict: + """`{path: sha256}` of the `files:` rows of the shape pin `rev` holds, + read by `pin_at` through `read_pin`'s checks, or `{}` where it does not + read; a row with no `sha256:` maps to ''.""" + read = pin_at(ctx, rev, whole=True) + return {row.path: row.recorded for row in read[1]} if read else {} + + +def side_moved_it(ctx: Context, commit: str, path: str, copy: str, + blob: bytes, recorded: str | None, tree: dict, + held: dict) -> bool: + """Whether `path` of `commit`, one side of the merge, holds the copy + `copy` moved there, by `copies_renamed_twice`'s evidence: bytes that + `tree`, that commit's whole tree by blob, holds at `path` alone, and + that are either the merge base's blob of the copy, `blob`, which the + base `held` holds at the copy alone, or bytes the base holds at no + path whose sha256 is `recorded`, that side's pin row for the copy.""" + spec = f"{commit}:./{path}" + committed = git_answer(["rev-parse", "-q", "--verify", spec], ctx.root) + if committed is None or tree.get(committed.encode("ascii")) != [path]: + return False + if committed.encode("ascii") == blob: + return held.get(blob) == [copy] + if not recorded or committed.encode("ascii") in held: + return False + data = git_answer(["cat-file", "blob", spec], ctx.root, binary=True) + return (data is not None + and ctx.update_shape().file_bytes_sha256(data) == recorded) + + +def twice_renamed_copies(twice: dict, stage: bytes) -> list: + """The sentences of a conflicted shape pin's `why` for `twice`, from + `copies_renamed_twice`, `stage` being the side taken here: one for each + copy both sides renamed (#783), naming the new names proved on each + side, and saying where a side's is not proved. Never the hunks git + merged, as git merged none at the old name, and never taken: a person + keeps one name, or both, or settles where the copy goes.""" + sides = {number: ("the side taken here" if number == stage + else "the other side") for number in (b"2", b"3")} + said = [] + for copy, found in sorted(twice.items()): + start = f"{copy} is gone on both sides of the merge" + if not found: + said.append(f"{start}, and where either side put it is not " + "proved here, so neither name is taken: a person " + "settles where it goes, by hand") + continue + renames = " and ".join( + f"to {' and '.join(found[number])} on {sides[number]}" + for number in sorted(found, key=lambda number: number != stage)) + new = ("name" if sum(len(paths) for paths in found.values()) == 1 + else "names") + if len(found) > 1: + said.append(f"{start}, renamed {renames}, and no pin lists the " + f"new {new}, so neither name is taken: a person " + "keeps one name, or both, by hand") + continue + unproved = next(number for number in sides if number not in found) + said.append(f"{start}, renamed {renames}, and no pin lists the new " + f"{new}; where {sides[unproved]} put it is not proved " + "here, so neither name is taken: a person settles where " + "it goes, by hand") + return said + + +def still_listed(gone: set, kept: set) -> list: + """The sentence a conflicted shape pin's `why` adds for the copies in + `gone`, renamed on one side or both (#753, #783), that `kept`, the rows + of the pin taken here, still lists (review of PR #781): no file is at + that name once the merge is finished unless it is renamed back, so + this doctor reports the copy missing. Said only of such a copy: where + the pin taken here has no row for it, nothing is missing.""" + listed = sorted(copy for copy in gone if copy in kept) + if not listed: + return [] + many = len(listed) > 1 + return [f"The pin taken here still lists {' and '.join(listed)}, so this " + f"doctor reports {'them' if many else 'it'} missing once the " + f"merge is finished, unless {'they are' if many else 'it is'} " + "renamed back"] + + +def renamed_copies(renamed: dict, stages: dict) -> list: """The sentences of a conflicted shape pin's `why` for `renamed`, `{path: copy}` from `copies_renamed`: paths conflicted beside the pin under a name no pin has a row for, each a rename of a copy (#753). Never taken, as a copy only the other side's pin lists is never taken (`other_sides_copies`), and a person's to resolve, its old name said so - the reader knows it for a copy. - - WHAT THE DOCTOR SAYS ONCE THE MERGE IS FINISHED, where `kept`, the rows - of the pin taken here, still lists the old name (review of PR #781): - no file is there unless it is renamed back, so this doctor reports the - copy missing. Said only of such a copy: where the pin taken here has no - row for it, nothing is missing. + the reader knows it for a copy; `still_listed` says what the pin taken + here still lists. """ said = [] for group, deleted in by_conflict(sorted(renamed), stages): @@ -4729,14 +4931,6 @@ def renamed_copies(renamed: dict, stages: dict, kept: set) -> list: + ", so neither side is taken whole: a person " + (f"keeps or deletes {each} by hand" if deleted else f"merges {each} by hand, keeping the hunks git merged")) - listed = sorted({copy for copy in renamed.values() if copy in kept}) - if listed: - many = len(listed) > 1 - said.append( - f"The pin taken here still lists {' and '.join(listed)}, so this " - f"doctor reports {'them' if many else 'it'} missing once the " - f"merge is finished, unless {'they are' if many else 'it is'} " - "renamed back") return said diff --git a/tests/test_doctor_pin_restore_merges.py b/tests/test_doctor_pin_restore_merges.py index eb4f84fd..9a122986 100644 --- a/tests/test_doctor_pin_restore_merges.py +++ b/tests/test_doctor_pin_restore_merges.py @@ -25,7 +25,11 @@ the NEXT takes the side whose pin reads, and where no side's pin reads, neither row names a NEXT (#752). A copy renamed on one side conflicts under a name no pin has a row for; the `why` names it as renamed from that -copy, by the merge base's blob, and gives it to a person (#753). +copy, by the merge base's blob, and gives it to a person (#753). A copy +renamed on both sides is `DD` at its old name, never a merge of hunks: +the `why` names each new name a side's own commit holds the base's blob +at, or the bytes that commit's own pin row for the copy records, and says +where a side's is not proved (#783, review of PR #789). 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 @@ -47,7 +51,9 @@ import re import shutil import subprocess +from collections.abc import Callable from pathlib import Path +from typing import NamedTuple import pytest @@ -1030,14 +1036,22 @@ def missing() -> set: RENAMED = "build.mk" -def renamed(old: str, new: str, line: bytes = b""): +def renamed(old: str, new: str, line: bytes = b"", row: bool = False): """An act on a branch: `old` renamed to `new`, `line` appended there, - committed.""" + and with `row` the shape pin's `files:` row for `old` moved to `new`, + its sha256 kept, all in one commit.""" def act(root: Path) -> None: git("mv", "--", old, new, cwd=root) if line: (root / new).write_bytes((root / new).read_bytes() + line) git("add", "--", new, cwd=root) + if row: + held = (root / SHAPE_PIN).read_bytes() + at = f" - path: {old}\n".encode() + assert held.count(at) == 1, held + (root / SHAPE_PIN).write_bytes( + held.replace(at, f" - path: {new}\n".encode())) + git("add", "--", SHAPE_PIN, cwd=root) commit_in(root, f"{old} renamed to {new}") return act @@ -1060,20 +1074,24 @@ def act(root: Path) -> None: def merged_after(standard: Path, project: Path, tmp_path: Path, acts: dict, - own: tuple | None = None) -> tuple: + own: tuple | None = None, + sources: tuple = (UPSTREAM_TARGET, BOOTSTRAP_SOURCE) + ) -> tuple: """`(root, the standard moved on)`: (a)'s two applies, each followed on its branch by `acts[branch]`, then shape/b merging shape/a. `own`, - `(path, content)`, is a file of the project's own that main gains - first: `content` where it is bytes, else the committed blob of the - copy it names, byte for byte.""" + `(path, content, *lines)`, is a file of the project's own that main + gains first: `content` where it is bytes, else the committed blob of + the copy it names, byte for byte, then `lines`. `sources` are the + standard's files C2 and C3 move on.""" root = under_an_awkward_name(project, tmp_path) - moved, c2, c3, _frozen = the_standard_moved( - standard, tmp_path, UPSTREAM_TARGET, BOOTSTRAP_SOURCE) + moved, c2, c3, _frozen = the_standard_moved(standard, tmp_path, + *sources) if own: - path, content = own + path, content, *lines = own (root / path).parent.mkdir(parents=True, exist_ok=True) - (root / path).write_bytes(content if isinstance(content, bytes) - else blob_at(root, f"HEAD:{content}")) + (root / path).write_bytes( + (content if isinstance(content, bytes) + else blob_at(root, f"HEAD:{content}")) + b"".join(lines)) git("add", "--", path, cwd=root) commit_in(root, f"the project's own {path}") for commit, branch in ((c2, "shape/a"), (c3, "shape/b")): @@ -1150,12 +1168,15 @@ def test_a_copy_renamed_on_one_side_is_named_for_a_person( == "DRIFTED (1 copy-missing)" -def finished_keeping(root: Path, tmp_path: Path, moved: Path, - name: str) -> str: +def finished_keeping(root: Path, tmp_path: Path, moved: Path, name: str, + dropped: tuple = ()) -> str: """The merge, its NEXT run, finished by a person who keeps `name` as - the side taken holds it, merges `project.yaml` and commits; the - doctor's verdict then, out of the standard moved on.""" + the side taken holds it, removes each of `dropped`, merges + `project.yaml` and commits; the doctor's verdict then, out of the + standard moved on.""" (root / name).write_bytes(stage_of(root, 2, name)) + if dropped: + git("rm", "-q", "--", *dropped, cwd=root) merge_by_ours(root, tmp_path, "project.yaml") git("add", "--", SHAPE_PIN, BOOTSTRAP, name, "project.yaml", cwd=root) commit_in(root, f"merge shape/a, {name} kept") @@ -1228,3 +1249,255 @@ def test_a_rename_of_no_copy_names_nothing(standard, project, tmp_path, assert new not in currency["detail"].get("left_for_a_person", []), \ currency["detail"] assert new not in currency["why"], currency["why"] + + +# --- a copy renamed on both sides (#783) ------------------------------------- + +#: The names each side gives the Makefile: the side `--ours` takes, and the +#: other side. +OURS_NAME, THEIRS_NAME = "a.mk", "b.mk" + +#: Both sides rename the Makefile, after `apply` runs that leave it as the +#: merge base holds it, unless the standard moves it too. +BOTH_RENAME = {"shape/a": renamed(MAKEFILE, THEIRS_NAME), + "shape/b": renamed(MAKEFILE, OURS_NAME)} + +#: Both, the Makefile edited on ours. +EDITED_ON_OURS = {**BOTH_RENAME, + "shape/b": renamed(MAKEFILE, OURS_NAME, OWN_LINE)} + +#: The standard's files C2 and C3 move on where the Makefile moves too, so +#: neither side's commit holds the merge base's blob of it. +THE_MAKEFILE_MOVES = (UPSTREAM_TARGET, BOOTSTRAP_SOURCE) + +#: A file of the project's own beside the Makefile: its twin, where main +#: gains it with the Makefile's bytes. +TWIN = "tools/Makefile.orig" + + +def in_step(path: str, like: str): + """An act on a branch: `path` given `like`'s bytes, committed, as a + project keeps its own twin of a copy in step with the copy.""" + def act(root: Path) -> None: + (root / path).write_bytes((root / like).read_bytes()) + git("add", "--", path, cwd=root) + commit_in(root, f"{path} in step with {like}") + return act + + +def in_turn(*acts): + """One act on a branch: each of `acts` in turn.""" + def act(root: Path) -> None: + for each in acts: + each(root) + return act + + +#: Both sides rename the Makefile and the project's own twin of it, ours +#: keeping the twin in step with its a.mk first, so ours' commit holds +#: a.mk's bytes at two new names (review of PR #789, round 3). +TWIN_KEPT_IN_STEP = { + "shape/a": in_turn(renamed(MAKEFILE, THEIRS_NAME), + renamed(TWIN, "tools/y.mk")), + "shape/b": in_turn(renamed(MAKEFILE, OURS_NAME), + in_step(TWIN, OURS_NAME), + renamed(TWIN, "tools/x.mk")), +} + + +def both_proved(taken_here: str, other: str) -> str: + """What the `why` says of the Makefile where each side's new name is + proved, the side taken here's first.""" + return (f"renamed to {taken_here} on the side taken here and to {other} " + "on the other side, and no pin lists the new names, so neither " + "name is taken: a person keeps one name, or both, by hand") + + +def one_proved(name: str, on: str, unproved: str) -> str: + """What the `why` says of the Makefile where one side's new name, + `name`, is proved, on the side `on`, and `unproved`'s is not.""" + return (f"renamed to {name} on {on}, and no pin lists the new name; " + f"where {unproved} put it is not proved here, so neither name is " + "taken: a person settles where it goes, by hand") + + +#: What the `why` says of the Makefile where neither side's name is proved. +NONE_PROVED = ("and where either side put it is not proved here, so neither " + "name is taken: a person settles where it goes, by hand") +TAKEN_HERE, OTHER_SIDE = "the side taken here", "the other side" + + +def ours_does_not_read(root: Path) -> None: + """Ours' stage of the shape pin as bytes that do not read, so the NEXT + takes `--theirs` (#752).""" + a_pin_restaged(root, 2, lambda _blob: UNREADABLE_PIN) + + +def the_kept_pin_lacks_its_row(root: Path) -> None: + """Ours' stage of the shape pin without the Makefile's row: only the + other side's pin calls it a copy (#687).""" + a_pin_without_a_row(root, 2, MAKEFILE) + + +def no_merge_head(root: Path) -> None: + """MERGE_HEAD gone, as a cherry-pick or a rebase leaves its conflicts: + git names no merge base. + + The FILE is deleted, at the path git itself gives, which is also a + linked worktree's own. `git update-ref -d MERGE_HEAD` is refused from + git 2.46 on ("refusing to update pseudoref"), and MERGE_HEAD stays a + file under both ref backends (#783, review of PR #789). It must be + there to go: this follows a real merge, so a missing file is a fixture + that stopped being one, never a tolerated state.""" + merge_head = Path(git("rev-parse", "--git-path", "MERGE_HEAD", + cwd=root).stdout.strip()) + if not merge_head.is_absolute(): + merge_head = root / merge_head + merge_head.unlink() + assert not merge_head.exists(), merge_head + + +class Twice(NamedTuple): + """A merge in which both sides rename the Makefile.""" + #: What each side does after its `apply`. + acts: dict + #: What the `why` says of the Makefile after "gone on both sides of the + #: merge, ". + said: str + #: What is left to a person. + left: list + #: A file of the project's own main gains first, as `merged_after` + #: takes it. + own: tuple | None = None + #: The standard's files C2 and C3 move on. + sources: tuple = (BOOTSTRAP_SOURCE,) + #: What is done to the merge before the doctor reads it. + then: Callable | None = None + #: The side the NEXT takes. + side: str = "--ours" + #: Whether the `why` says the pin taken here still lists the Makefile. + listed: bool = True + #: The verdict once the merge is finished keeping a.mk, where asked. + finished: str | None = "DRIFTED (1 copy-missing)" + + +#: Each merge in which both sides rename the Makefile, by what proves each +#: new name: the merge base's blob of it, or the bytes the side's own pin +#: row records (review of PR #789), at the side's own commit, and at one +#: path of it (round 3). +TWICE = { + "renamed-on-both-sides": Twice( + BOTH_RENAME, both_proved(OURS_NAME, THEIRS_NAME), + [MAKEFILE, OURS_NAME, THEIRS_NAME, "project.yaml"]), + "renamed-on-both-sides-edited-on-ours": Twice( + EDITED_ON_OURS, one_proved(THEIRS_NAME, OTHER_SIDE, TAKEN_HERE), + [MAKEFILE, THEIRS_NAME, "project.yaml"]), + "renamed-on-both-sides-beside-a-twin": Twice( + BOTH_RENAME, NONE_PROVED, [MAKEFILE, "project.yaml"], + own=(TWIN, MAKEFILE)), + "renamed-on-both-sides-a-twin-kept-in-step": Twice( + TWIN_KEPT_IN_STEP, one_proved(THEIRS_NAME, OTHER_SIDE, TAKEN_HERE), + [MAKEFILE, THEIRS_NAME, "project.yaml"], + own=(TWIN, MAKEFILE, OWN_LINE), finished=None), + "renamed-on-both-sides-theirs-moving-its-row": Twice( + {**BOTH_RENAME, "shape/a": renamed(MAKEFILE, THEIRS_NAME, row=True)}, + one_proved(OURS_NAME, TAKEN_HERE, OTHER_SIDE), + [MAKEFILE, OURS_NAME, THEIRS_NAME, "project.yaml"]), + "renamed-on-both-sides-theirs-taken": Twice( + BOTH_RENAME, both_proved(THEIRS_NAME, OURS_NAME), + [MAKEFILE, OURS_NAME, THEIRS_NAME, "project.yaml", BOOTSTRAP], + then=ours_does_not_read, side="--theirs", finished=None), + "renamed-on-both-sides-theirs-taken-edited-on-ours": Twice( + EDITED_ON_OURS, one_proved(THEIRS_NAME, TAKEN_HERE, OTHER_SIDE), + [MAKEFILE, THEIRS_NAME, "project.yaml", BOOTSTRAP], + then=ours_does_not_read, side="--theirs", finished=None), + "renamed-on-both-sides-the-kept-pin-without-its-row": Twice( + BOTH_RENAME, both_proved(OURS_NAME, THEIRS_NAME), + [MAKEFILE, OURS_NAME, THEIRS_NAME, "project.yaml"], + then=the_kept_pin_lacks_its_row, listed=False, + finished="COMPLIANT, SHAPE BEHIND (0 upstream-changed, " + "1 upstream-added)"), + "renamed-on-both-sides-the-standard-moved": Twice( + BOTH_RENAME, both_proved(OURS_NAME, THEIRS_NAME), + [MAKEFILE, OURS_NAME, THEIRS_NAME, "project.yaml"], + sources=THE_MAKEFILE_MOVES), + "renamed-on-both-sides-the-standard-moved-beside-a-twin": Twice( + BOTH_RENAME, both_proved(OURS_NAME, THEIRS_NAME), + [MAKEFILE, OURS_NAME, THEIRS_NAME, "project.yaml"], + own=(TWIN, MAKEFILE), sources=THE_MAKEFILE_MOVES), + "renamed-on-both-sides-the-standard-moved-a-twin-kept-in-step": Twice( + TWIN_KEPT_IN_STEP, one_proved(THEIRS_NAME, OTHER_SIDE, TAKEN_HERE), + [MAKEFILE, THEIRS_NAME, "project.yaml"], + own=(TWIN, MAKEFILE), sources=THE_MAKEFILE_MOVES, finished=None), + "renamed-on-both-sides-the-standard-moved-the-base-holding-ours": Twice( + BOTH_RENAME, one_proved(THEIRS_NAME, OTHER_SIDE, TAKEN_HERE), + [MAKEFILE, THEIRS_NAME, "project.yaml"], + own=("tools/Makefile.next", MAKEFILE, C2_LINE, C3_LINE), + sources=THE_MAKEFILE_MOVES), + "renamed-on-both-sides-the-standard-moved-no-merge-head": Twice( + BOTH_RENAME, NONE_PROVED, [MAKEFILE, "project.yaml"], + sources=THE_MAKEFILE_MOVES, then=no_merge_head, finished=None), +} + + +@pytest.mark.parametrize("merge", sorted(TWICE)) +def test_a_copy_renamed_on_both_sides_is_named_for_a_person( + standard, project, tmp_path, merge): + """Both sides rename the Makefile, to a.mk on the side `--ours` takes + and to b.mk on the other. git leaves the Makefile `DD`, stage 1 alone, + and each new name added by one side alone, with no stage 1, so #781's + rule saw neither, and the Makefile got the copies' sentence, keeping + "the hunks git merged" where git merged none (#783). + + Now the Makefile is gone on both sides, and each new name is named + where the commit of the side that made it holds there the merge base's + blob of the Makefile, or the bytes that commit's own pin row for the + Makefile records, and the base holds those bytes at no other path. + Edited on ours, git writes its merge to both names and ours' commit + holds the edit, so only b.mk is proved, and the `why` says ours' is + not; beside a twin of the Makefile neither is. Where the standard + moved the Makefile, neither commit holds the base's blob, and each + side's pin row proves its name, a twin of the base's bytes beside it + or not; a base holding ours' bytes at another path proves ours' name + nothing, and with no MERGE_HEAD no base is named, so nothing is proved + (review of PR #789). Where ours keeps its own twin of the Makefile in + step with a.mk and both sides rename the twin too, ours' commit holds + a.mk's bytes at two new names, and git pairs one with the twin: ours' + name is not proved, the standard moved or not (round 3). + + A name a pin has a row for is named by that row alone: theirs, moving + its row to b.mk, gets b.mk named once, as the other side's copy, and + ours' a.mk proved. Taken `--theirs`, b.mk is the side taken here's. + Where the kept pin has no row for the Makefile, both names are still + named, nothing is said to go missing, and the merge finished keeping + a.mk is no `copy-missing`; where it still lists the Makefile, the + `why` says so, and the finished merge is `copy-missing`. Every name + is said once, and only a copy left to a person is a merge of hunks.""" + case = TWICE[merge] + root, moved = merged_after(standard, project, tmp_path, case.acts, + case.own, case.sources) + if case.then: + case.then(root) + assert [sorted(index_stages(root, name)) + for name in (MAKEFILE, OURS_NAME, THEIRS_NAME)] \ + == [[1], [2], [3]] + _verdict, rows = rows_from(moved, root) + currency = assert_both_rows_name_it(rows) + why, left = currency["why"], case.left + left_whole = BOOTSTRAP in left + assert pasted(currency["next"])[3:] == [ + "checkout", case.side, "--", SHAPE_PIN, + *([] if left_whole else [BOOTSTRAP])], currency["next"] + assert currency["detail"]["left_for_a_person"] == left, \ + currency["detail"] + listed = f". {STILL_LISTED}" if case.listed else "" + assert f"{MAKEFILE} is gone on both sides of the merge, {case.said}" \ + f"{listed}. Then finish" in why, why + assert ("still lists" in why) == case.listed, why + assert [why.count(name) for name in left if name != MAKEFILE] \ + == [1] * (len(left) - 1), why + assert why.count("hunks") == int(left_whole), why + run_pasted(currency["next"]) + if case.finished: + assert finished_keeping(root, tmp_path, moved, OURS_NAME, + (MAKEFILE, THEIRS_NAME)) == case.finished