diff --git a/README.md b/README.md index 5e311f17..c09879d1 100644 --- a/README.md +++ b/README.md @@ -1117,22 +1117,22 @@ directory first — which is what makes the verdict offline. For an assembly root the rows are **naming** (this standard's own `contracts/repository-naming.yaml`), **manifest** and **pins** (the project's OWN `scripts/validate-manifest.py` and `scripts/validate-pins.py` when it -carries them — they are the pinned copies, which is the point — else the -template copies, and the row says which answered), **manifest kinds** (every -`kind:`-bearing manifest in the root against a registry, so a `contracts/*.yaml` -nothing validates is a finding by name rather than a silence, and so is a -`family.yaml` beside `project.yaml`), **shape currency** (`update-shape.py -check`'s per-file verdicts, summarised), **legs** (each one mounted, and its -checked-out commit against the pin, with no fetch), **leg shape files** (each -leg's `AGENTS.md`, `CLAUDE.md` and `.gitignore` against `templates/-root/` -— a comparison and not a gate: no leg file is pinned anywhere), **placement** -(every tracked path of every leg, against the path policy), **agent files**, and -**machine**. A FAMILY holder gets **family**, **manifest kinds**, **shape -currency**, **placement** (`n/a` — a holder has no legs of its own), **members** -and **machine**; a directory that is neither gets what it is CALLED under the -naming policy, what IS in it, and the way in — and a repository the advisory is -not silent about gets the **Triad** row, status `advisory`: the preference said -once and stepped over by the verdict (a leg gets its assembly root). +carries them — the pinned copies — else the template copies, and the row says +which answered), **manifest kinds** (every `kind:`-bearing manifest in the +root against a registry, so a `contracts/*.yaml` nothing validates, or a +`family.yaml` beside `project.yaml`, is a finding by name), **shape currency** +(`update-shape.py check`'s per-file verdicts, summarised), **legs** (each one +mounted, and its checked-out commit against the pin, with no fetch), **leg +shape files** (each leg's `AGENTS.md`, `CLAUDE.md` and `.gitignore` against +`templates/-root/`, a comparison: no leg file is pinned), **placement** +(every tracked path of every leg, against the path policy), **agent files**, +and **machine**. A FAMILY holder gets **family**, **manifest kinds**, **shape +currency**, **placement** (`n/a`: no legs of its own), **agent files**, **members** +and **machine**. Either, mid-merge, gets **merge** first: a `FINDING` naming the +unmerged paths, or a `note` if none is left. A directory that is neither gets what +it is CALLED under the naming policy, what IS in it, the way in and **machine** — +and a repository the advisory is not silent about gets the **Triad** row, status +`advisory`: said once and stepped over by the verdict (a leg gets its root). Every row is `ok`, `note`, `FINDING` or `n/a` — or `advisory`, the Triad row alone — and every one that is not `ok` names the exact command that fixes it, @@ -1151,22 +1151,22 @@ verdict, and its exit code: | `COMPLIANT` | 0 | every row ok, and the pin names this standard's commit | | `COMPLIANT, SHAPE BEHIND (n upstream-changed, m upstream-added)` | 1 | valid, and the standard has moved on | | `DRIFTED (…)` | 1 | a copy was edited here, or a leg is off its pin | -| `MISPLACED (n paths)` | 1 | a tracked path sits in a leg the path policy puts elsewhere | -| `INVALID ()` | 1 | a validator went red | +| `MISPLACED (n paths)` | 1 | a tracked path sits in a leg the path policy puts elsewhere, or a submodule in a leg other than the one its `submodules:` record names | +| `INVALID ()` | 1 | a validator went red, or (`merge`) the index holds an unmerged path | | `NOT A SHAPE ROOT` | 2 | neither manifest is there — adopt, or scaffold | Exit 3 is usage or environment — nothing about it is a statement about the tree you pointed at: no such path, a `shape-doctor.py` not sitting in a checkout of the standard, or a pin naming a commit **this** checkout does not -carry (`CANNOT ANSWER`). **Drift outranks a red validator** -on the verdict line, deliberately — an edited shape copy is exactly what makes -`validate-pins.py` red, so answering `INVALID (pins)` would send the reader at -the symptom while the fix is `update-shape.py`. The pins row is printed either -way, with its own next command. `MISPLACED` sits between the two: a path in -the wrong leg is a fact about the shape that no validator, pin or manifest -here can see, while a red validator names itself in the table whether or not -it also names the verdict line. `--json` is the same report as one object, -each row carrying its `id`. +carry (`CANNOT ANSWER`). **Drift outranks a red validator** on the verdict +line, deliberately — an edited shape copy is exactly what makes +`validate-pins.py` red, so `INVALID (pins)` would send the reader at the +symptom while the fix is `update-shape.py`; the pins row still prints its own +next command. `MISPLACED` sits between the two: a path in the wrong leg is a +fact about the shape no validator, pin or manifest here can see. **An +unfinished merge outranks all three**, as `INVALID (merge)`: the conflict is +what turns the other rows red, and no fix commits until it is finished. +`--json` is the same report as one object, each row carrying its `id`. **`placement` is the row that reads what is *inside* the legs.** Brett Heap, 2026-09-10: *"we have to look for code in spec and spec in code"*. It runs the diff --git a/docs/handbook.html b/docs/handbook.html index f3b51681..fdcf2c1b 100644 --- a/docs/handbook.html +++ b/docs/handbook.html @@ -1452,10 +1452,13 @@

Is this repository compliant? openRepoShape --doctor

policy), agent files and machine. A family holder gets family, manifest kinds, shape currency, placement - (n/a — a holder has no legs of its own), members and - machine; a directory that is neither gets what it is CALLED under the naming - policy, what IS in it, and the way in — and a repository the advisory is not silent - about gets the Triad row, status advisory: the + (n/a — a holder has no legs of its own), agent files, + members and machine. Either one in the middle of a merge + gets merge first: a FINDING naming the unmerged paths, or a + note if nothing is left unmerged. A directory that is neither gets what it is + CALLED under the naming policy, what IS in it, the way in and machine + — and a repository the advisory is not silent about gets the + Triad row, status advisory: the preference said once and stepped over by the verdict (a leg gets its assembly root).

Every row is ok, note, FINDING or @@ -1474,8 +1477,8 @@

Is this repository compliant? openRepoShape --doctor

COMPLIANT0every row ok, and the pin names this standard’s commit COMPLIANT, SHAPE BEHIND (n upstream-changed, m upstream-added)1valid, and the standard has moved on DRIFTED (…)1a copy was edited here, or a leg is off its pin - MISPLACED (n paths)1a tracked path sits in a leg the path policy puts elsewhere - INVALID (<rows>)1a validator went red + MISPLACED (n paths)1a tracked path sits in a leg the path policy puts elsewhere, or a submodule in a leg other than the one its submodules: record names + INVALID (<rows>)1a validator went red, or (merge) the index holds an unmerged path NOT A SHAPE ROOT2neither manifest is there — adopt, or scaffold @@ -1489,6 +1492,8 @@

Is this repository compliant? openRepoShape --doctor

either way, with its own next command. MISPLACED sits between the two: a path in the wrong leg is a fact about the shape that no validator, pin or manifest here can see, while a red validator names itself in the table whether or not it also names the verdict line. + An unfinished merge outranks all three, as INVALID (merge): the + conflict is what turns the other rows red, and no fix commits until it is finished. --json is the same report as one object, each row carrying its id.

@@ -1822,9 +1827,10 @@

For AI assistants

branch and naming the branch the real run needs, added (#477); the one network read a project’s own validators can make, the gh api recheck of a neutral product’s pin, said where this page and the README had said “offline”, - added (#411) — a page cannot name the commit that contains it, and the blob below is - the fact a test checks. - README.md blob 5e311f176503d43a188d9974c7988b396dd0e0e1. Every command, flag, exit code + added (#411); the doctor’s merge row, and INVALID (merge) + above drift, added (#424) — a page cannot name the commit that contains it, and the + blob below is the fact a test checks. + README.md blob c09879d13f9a618c5de7afb6d3b555e59888df17. Every command, flag, exit code and file name on this page is quoted from that README and from AGENTS.md.

diff --git a/shape-doctor.py b/shape-doctor.py index 1f93561c..28d65dd2 100755 --- a/shape-doctor.py +++ b/shape-doctor.py @@ -57,6 +57,9 @@ THE ROWS, for an assembly root, in this order: + merge only in the middle of a merge: a FINDING naming the + index's unmerged paths, or a `note` once nothing is + left unmerged; no row at all otherwise (#424) naming the three leg names against the STANDARD's own `contracts/repository-naming.yaml` manifest the project's own `scripts/validate-manifest.py` when it @@ -85,9 +88,10 @@ machine whether this workstation can RUN the fixes named above (`setup-project.py --preflight`, plus `git-filter-repo`) -and for a FAMILY holder: `family` (its own `scripts/validate-family.py`), -`manifest kinds`, `shape currency`, `placement` (`n/a`: a holder has no legs -of its own), `members` and `machine`. A directory that +and for a FAMILY holder: `merge` likewise, `family` (its own +`scripts/validate-family.py`), `manifest kinds`, `shape currency`, +`placement` (`n/a`: a holder has no legs of its own), `agent files`, +`members` and `machine`. A directory that is NEITHER gets `naming` (what it is called, under the policy), `what is here`, `the way in` -- `adopt-project.py plan` for a repository that already exists, `setup.sh` for a new one -- and `machine`. @@ -105,6 +109,18 @@ and declares its kind still decides first, so no root that declared one reads differently. +A ROOT IN THE MIDDLE OF A MERGE IS NOT COMPLIANT (#424). The other rows read +the working tree, and a leg's commit from the index -- or from HEAD, for a +gitlink a conflicted merge holds only at stages 1, 2 and 3. That is right +for a validator and wrong for a verdict: a root whose index holds an +unmerged path is one `git commit` refuses, and it read COMPLIANT, exit 0, +once the conflict markers were out of the files those rows read. So the +`merge` row, FIRST on both kinds of root, asks git for the unmerged paths +and is a FINDING naming them, with `git -C status` as its NEXT, and +the verdict is `INVALID (merge)`, exit 1, on a rung above every other but +CANNOT ANSWER (`merge_verdict` says why). A merge with nothing left unmerged +is a `note`: git commits it as it stands. With neither, there is no row. + AND ONE MORE ROW, `Triad`, WHERE THE RULE SAYS SO. Brett Heap, 2026-10-06: "ratify 1249 as recommended" (openxFactory `prefer-triad-project-shape`): the Triad -- this shape, an assembly root with a spec leg and a code leg -- @@ -238,7 +254,9 @@ wrong leg is a fact about the SHAPE -- the subject of this whole standard -- that no validator, pin or manifest here can see, whereas a red validator names itself in the table either way and loses nothing by not being on the verdict -line. +line. An unfinished merge outranks all three, for the reason drift outranks a +red validator: the conflict it leaves is what turns the other rows red, and +none of their fixes can be committed until it is finished. STANDARD LIBRARY ONLY, like everything else shipped here. Printed text is ASCII: a verdict a cp1252 console cannot render is a verdict nobody reads, and @@ -723,11 +741,12 @@ class Check: takes, for the same reason: putting a repository back into shape rewrites somebody's tree. - `run` RETURNS ONE ROW, OR NONE FOR A CHECK THAT IS SILENT BY RULE. One - check is: the Triad advisory, which the ratified rule makes SILENT in an + `run` RETURNS ONE ROW, OR NONE FOR A CHECK THAT IS SILENT BY RULE. Two + checks are: the Triad advisory, which the ratified rule makes SILENT in an elected Triad, a family holder, a `-wip` workspace and a project that recorded staying single -- and silent means no row at all, not a row - saying so. `run_checks` skips a None. + saying so -- and `merge`, which speaks only while a merge is unfinished + (#424). `run_checks` skips a None. """ def __init__(self, check_id: str, label: str, applies_to: tuple, @@ -5696,12 +5715,187 @@ def _triad_advisory_row(ctx: Context) -> Row | None: return None +# --------------------------------------------------------------------------- +# A root in the middle of a merge +# --------------------------------------------------------------------------- + + +#: Named once: `check_merge`'s two rows and the `CHECKS` entry below must +#: print the same row name. +LABEL_MERGE = "merge" + +#: How many unmerged paths the `merge` row's sentence names before it counts +#: the rest. `--json` lists every one. +UNMERGED_NAMED = 5 + + +def unmerged_paths(root: Path) -> list[str] | None: + """Each path the root's index holds UNMERGED, once, in git's order, or + None where git gives no answer (#424). + + `git ls-files -u` lists one record per STAGE, ` + TAB `, so a path both sides changed is listed three times and is + kept once. `-z`, read as bytes, so a name is never C-quoted and a byte + that is not UTF-8 survives as itself. Relative to the root, as every + other row reads a path. `--no-optional-locks`, git's word for a reader + that must not write into the repository it reads. + """ + raw = git_answer(["--no-optional-locks", "ls-files", "-u", "-z"], root, + binary=True) + if raw is None: + return None + paths: dict[str, None] = {} + for record in raw.split(b"\0"): + _meta, tab, path = record.partition(b"\t") + if tab: + paths.setdefault(path.decode("utf-8", "surrogateescape"), None) + return list(paths) + + +def git_dir_holds(root: Path, name: str) -> bool: + """Whether the root's git directory holds `name` (#424). `--git-path` + names it in a linked worktree too, where git keeps it per worktree, and + a relative answer is relative to the root, where git was asked.""" + where = (git_answer(["rev-parse", "--git-path", name], root, + binary=True) or b"").rstrip(b"\r\n") + if not where: + return False + path = Path(os.fsdecode(where)) + return (path if path.is_absolute() else root / path).exists() + + +#: What a rebase in progress keeps in the git directory: the merge backend's +#: directory and the apply backend's, which `git am` shares. +REBASE_STATE = ("rebase-merge", "rebase-apply") + + +def merge_in_progress(root: Path) -> bool: + """Whether git records a MERGE in progress, and no rebase: a + `MERGE_HEAD` file, and neither of `REBASE_STATE` (#424). + + THE FILE, NOT `rev-parse --verify MERGE_HEAD`, which also resolves a + branch or a tag of that name. AND NOT A REBASE: `git rebase -r` stopped + on a merge it replays writes `MERGE_HEAD` too, and `git merge --abort` + there strands the rebase, so the row sends a rebase to `git status`, + which names its own `--continue` and `--abort`. + """ + return git_dir_holds(root, "MERGE_HEAD") and not any( + git_dir_holds(root, name) for name in REBASE_STATE) + + +def own_repository(root: Path) -> bool: + """Whether git answers about the root's OWN repository (#424). + + `git -C` DISCOVERS, so a plain directory inside a checkout in the middle + of a merge would be told about that checkout's merge -- the reason + `not_its_own_repository` declines for a leg. Asked here as bytes and + compared with `samefile`, so neither a toplevel that is not UTF-8 nor a + root spelled in another case or through a symlink reads as a stranger. + """ + top = (git_answer(["rev-parse", "--show-toplevel"], root, binary=True) + or b"").rstrip(b"\r\n") + if not top: + return False + try: + return os.path.samefile(os.fsdecode(top), root) + except OSError: + return False + + +def check_merge(ctx: Context) -> Row | None: + """Whether the root is in the middle of a merge git will not commit. + + THE DEFECT (#424). Nothing here asked git whether the index held an + unmerged path. The validators read the working tree, and + `recorded_gitlink` answers a gitlink a conflicted merge holds only at + stages 1, 2 and 3 from HEAD, which its own docstring argues is right for + a validator. So a root two `bump-leg.py` branches had been merged into + read `DRIFTED` while the conflict markers sat in its pin, and COMPLIANT, + exit 0, once `git checkout --ours` had taken them out -- with `UU` still + on the pin and the gitlink, `MERGE_HEAD` still there, and `git commit` + refusing. A conflicted file no row reads was COMPLIANT from the start. + + A FINDING, AND WHAT ASSERTS IT IS GIT. A `FINDING` is what something + ELSE asserts, and git refuses to commit an index that holds an unmerged + path. The row names the paths -- `UNMERGED_NAMED` of them, then a count + -- and its NEXT is `git -C status`, which lists every one and, + for a merge, a rebase or a cherry-pick alike, names the operation and + how to finish or abandon it. Which side each path takes is the merge's + owner's call, so no command here takes one. The words about finishing + are in the reason, not after the command: a NEXT is pasted whole (#277). + + NOT CANNOT ANSWER, AND NO ROW IS MADE TO WAIT ON IT. Exit 3 is THIS + CHECKOUT failing to ask, and an unfinished merge is a fact about their + repository. The other rows still answer about the tree in front of them, + and some of them say what to do with a file the merge left behind -- the + `shape currency` row's side to take of a conflicted shape pin (#237), + the project's own validator refusing a pin with conflict markers in it + -- which the person finishing the merge wants to read. + + A MERGE WITH NOTHING UNMERGED IS A `note`. `git merge --no-commit`, or a + conflict resolved and added: git commits it as it stands, and the other + rows read the files that commit is made from, so nothing asserts a + fault. The row says so, and the verdict steps over it. + + NO ROW AT ALL OTHERWISE, as the Triad row is silent where its rule says + so. A row saying "no merge" on every run would be a row about a state a + root is almost never in, and every report and every `--json` caller's + row set would change for it. Where git gives no answer -- no repository, + no `git` -- there is no row either: no commit is being refused, and the + rows that read git say so themselves. Nor where the root is not a + repository of its own (`own_repository`): the merge `git -C` would find + is the enclosing checkout's. + + ONLY THE ROOT'S OWN INDEX. A leg is a repository of its own, and a merge + left unfinished in one is work in progress there, like an uncommitted + edit, which no row reports: the root records the leg's commit, never its + index. + """ + root = ctx.root + if not own_repository(root): + return None + unmerged = unmerged_paths(root) + merging = merge_in_progress(root) + if not unmerged and not merging: + return None + if not unmerged: + return Row("merge", LABEL_MERGE, NOTE, + "a merge is in progress and nothing in it is unmerged: " + "`git commit` concludes it and `git merge --abort` " + "abandons it. A note, because git commits it as it " + "stands and the other rows read the files it is made " + "from", None, + {"unmerged": [], "count": 0, "merging": True}) + spelled = [repr(path) if UNWRITABLE_IN_A_PLAN.search(path) else path + for path in unmerged] + named = ", ".join(spelled[:UNMERGED_NAMED]) + if len(spelled) > UNMERGED_NAMED: + named += f" and {len(spelled) - UNMERGED_NAMED} more" + how = ("A merge is in progress: resolve each path (which side it takes " + "is the merge owner's call), `git add` it, then `git commit`, or " + "abandon the merge (`git merge --abort`)" if merging + else "`git status` says how to resolve each, and how to finish " + "or abandon what left them") + return Row("merge", LABEL_MERGE, FINDING, + f"{len(spelled)} path(s) unmerged in the index, so git " + f"commits nothing here until each is resolved: {named}. " + f"{how}. Until then every other row reads a tree no commit " + "holds", + f"git -C {quote_arg(root)} status", + {"unmerged": spelled, "count": len(spelled), + "merging": merging}) + + # --------------------------------------------------------------------------- # The registry # --------------------------------------------------------------------------- CHECKS = ( + # FIRST, ON BOTH KINDS OF ROOT, and a row only while a merge is + # unfinished: every row after it then reads a tree no commit holds, and + # the verdict names it above them all -- see `check_merge` (#424). + Check("merge", LABEL_MERGE, (PROJECT, FAMILY), check_merge), Check("naming", "naming", (PROJECT,), check_naming), Check("manifest", "manifest", (PROJECT,), check_manifest), Check("pins", "pins", (PROJECT,), check_pins), @@ -5822,6 +6016,38 @@ def drifted_reasons(by_id: dict, detail: dict) -> list: return reasons +def merge_verdict(by_id: dict) -> tuple | None: + """`INVALID (merge)` and its exit code while the root's index holds an + unmerged path, or None (#424). + + ABOVE DRIFTED, MISPLACED AND EVERY OTHER RUNG, for the reason drift + outranks a red validator: the verdict names the cause, not what it + causes. In the issue's merge of two leg bumps the conflict markers in + the pin turned `pins` and `manifest kinds` red and read as a leg not at + its pin, so the verdict said `DRIFTED (1 leg(s) not at the pin)` about a + leg that had not moved. And no row's fix can be committed while git + refuses to commit at all. + + BELOW CANNOT ANSWER, which is THIS CHECKOUT failing to ask: exit 3 says + nothing here is a statement about the tree, and the table still carries + this row. + + `INVALID` AND NOT A NEW VERDICT. AGENTS.md documents the verdicts and + their exits, and this is a gate somebody else owns gone red -- git's, + which commits nothing until the index is merged -- which is what + `INVALID` names everywhere else. + + THE LIST HAS TO BE NON-EMPTY, as `misplaced_verdict` requires of its + own: `run_checks` turns an exception out of a check into a FINDING with + an empty `detail`, which falls through to the catch-all instead. + """ + merge = by_id.get("merge") + if merge is not None and merge.status == FINDING \ + and merge.detail.get("unmerged"): + return f"{V_INVALID} (merge)", 1 + return None + + def misplaced_verdict(by_id: dict) -> tuple | None: """MISPLACED and its exit code, or None when nothing is in the wrong leg. @@ -5919,6 +6145,9 @@ def verdict_for(ctx: Context, rows: list[Row]) -> tuple[str, int]: currency = by_id.get("shape-currency") detail = currency.detail if currency else {} + unfinished = merge_verdict(by_id) + if unfinished is not None: + return unfinished reasons = drifted_reasons(by_id, detail) if reasons: return f"{V_DRIFTED} ({', '.join(reasons)})", 1 diff --git a/tests/test_doctor_unfinished_merge.py b/tests/test_doctor_unfinished_merge.py new file mode 100644 index 00000000..8150d582 --- /dev/null +++ b/tests/test_doctor_unfinished_merge.py @@ -0,0 +1,434 @@ +# SPDX-License-Identifier: Apache-2.0 +"""A root in the middle of a merge is not COMPLIANT (#424). + +THE DEFECT. Nothing in `shape-doctor.py` asked git whether the root's index +held an unmerged path. The validators read the working tree, and +`recorded_gitlink` answers a gitlink a conflicted merge holds only at stages +1, 2 and 3 from HEAD. So the merge of two `bump-leg.py` branches read +`DRIFTED (1 leg(s) not at the pin)` while the conflict markers sat in the +pin, and `COMPLIANT`, exit 0, once `git checkout --ours` had taken them out: +`UU` still on the pin and on the gitlink, `MERGE_HEAD` still there, and `git +commit` refusing. A conflicted file no row reads was `COMPLIANT` from the +start, and so was the same conflict left by a cherry-pick. + +WHAT IT DOES NOW. A `merge` row, first on both kinds of root, is a FINDING +naming the unmerged paths, with `git -C status` as its NEXT, and the +verdict is `INVALID (merge)`, exit 1, above every other rung but CANNOT +ANSWER. A merge with nothing left unmerged is a `note`, and the verdict steps +over it. A root with neither gets no row at all, which +`test_every_row_of_a_compliant_project_is_ok_or_na` and +`test_a_family_holder_gets_the_family_rows` in `tests/test_shape_doctor.py` +already hold for a clean root of each kind. + +The fixtures are `tests/test_shape_doctor.py`'s, imported rather than rebuilt +(the precedent is `tests/test_doctor_legs_members_next.py`). Every merge is a +real `git merge`, every bump a real `bump-leg.py`, and the remotes a test +pushes to are its own copy, so no other test sees what it pushed. +""" + +from __future__ import annotations + +import json +import os +import shlex +import shutil +import subprocess + +from pathlib import Path + +import pytest + +from conftest import FILE_PROTOCOL, git, run_script +from test_shape_doctor import ( # noqa: F401 + FAMILY_NAME, PROJECT, doctor, doctor_module, holder, powershell_tokens, + project, rows_of, scaffolded_here, standard, verdict_line, +) + +SPEC_PIN = "contracts/spec-pin.yaml" + + +# --- helpers ---------------------------------------------------------------- + +def pasted(line: str) -> list[str]: + """The argv the reader's own shell makes of a printed NEXT.""" + return powershell_tokens(line) if os.name == "nt" else shlex.split(line) + + +def merge(root: Path, *args: str) -> subprocess.CompletedProcess: + """`git merge`, which exits non-zero when it leaves a conflict.""" + return git("merge", "--no-edit", *args, cwd=root, check=False) + + +def unmerged_index(root: Path) -> str: + return git("ls-files", "-u", cwd=root).stdout + + +def what_git_holds(root: Path) -> tuple: + """The index's every stage and the merge git records, read-only, so a + test can say the doctor left the merge exactly as it found it.""" + 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 + return (git("ls-files", "--stage", cwd=root).stdout, + merge_head.read_bytes() if merge_head.is_file() else None) + + +def assert_invalid_for_the_merge(standard: Path, root: Path, + paths: list[str], merging: bool) -> dict: + """The verdict, the row and its NEXT for a root left unmerged; the row. + + The NEXT is pasted and run: `git status` exits 0 in a merge, and the + doctor leaves the index and `MERGE_HEAD` as it found them. + """ + before = what_git_holds(root) + result = doctor(standard, root, "--json") + assert result.returncode == 1, result.stdout + result.stderr + payload = json.loads(result.stdout) + assert payload["verdict"] == "INVALID (merge)", payload["verdict"] + assert payload["exit"] == 1 + row = rows_of(result)["merge"] + assert payload["rows"][0]["id"] == "merge", "the merge row reads first" + assert row["status"] == "FINDING", row + assert row["detail"] == {"unmerged": paths, "count": len(paths), + "merging": merging}, row["detail"] + assert f"{len(paths)} path(s) unmerged in the index" in row["reason"] + for path in paths: + assert path in row["reason"], (path, row["reason"]) + said = ("resolve each path (which side it takes is the merge owner's " + "call), `git add` it, then `git commit`, or abandon the merge " + "(`git merge --abort`)") + assert (said in row["reason"]) is merging, row["reason"] + argv = pasted(row["next"]) + assert argv[:2] == ["git", "-C"] and argv[3:] == ["status"], row["next"] + assert Path(argv[2]).resolve() == root.resolve(), argv + status = subprocess.run(argv, capture_output=True, check=False) + assert status.returncode == 0, status.stderr + assert what_git_holds(root) == before, "the doctor moved the merge" + plain = doctor(standard, root) + assert plain.returncode == 1, plain.stdout + plain.stderr + assert verdict_line(plain).startswith("INVALID (merge) (exit 1)"), \ + plain.stdout + return row + + +def assert_compliant_with_no_merge_row(standard: Path, root: Path) -> None: + result = doctor(standard, root, "--json") + assert result.returncode == 0, result.stdout + result.stderr + assert json.loads(result.stdout)["verdict"] == "COMPLIANT" + assert "merge" not in rows_of(result), rows_of(result)["merge"] + + +def spec_commits_on_branches(remotes: Path, tmp_path: Path) -> tuple: + """Two commits on the spec leg's remote, siblings of its pin, each on a + branch of its own, so a merge of two bumps conflicts on the gitlink.""" + work = tmp_path / "spec-work" + git(*FILE_PROTOCOL, "clone", "-q", str(remotes / f"{PROJECT}-spec.git"), + str(work), cwd=tmp_path) + base = git("rev-parse", "HEAD", cwd=work).stdout.strip() + made = [] + for side in ("a", "b"): + git("checkout", "-q", "-b", f"side-{side}", base, cwd=work) + (work / "specs").mkdir(exist_ok=True) + (work / "specs" / f"{side}.md").write_bytes(f"# {side}\n".encode()) + git("add", "--", f"specs/{side}.md", cwd=work) + git("commit", "-q", "-m", f"spec side {side}", cwd=work) + made.append(git("rev-parse", "HEAD", cwd=work).stdout.strip()) + git("push", "-q", "origin", "side-a", "side-b", cwd=work) + return made[0], made[1] + + +def bump_on(standard: Path, root: Path, remotes: Path, branch: str, + to: str) -> None: + """`branch`, cut from `main`, with the spec leg bumped `to` a commit by + the standard's own `bump-leg.py`.""" + git("checkout", "-q", "-b", branch, "main", cwd=root) + git(*FILE_PROTOCOL, "submodule", "update", "-q", cwd=root) + bumped = run_script(standard / "scripts" / "bump-leg.py", "--root", + str(root), "--leg", "spec", "--to", to, + "--local-remote-dir", str(remotes)) + assert bumped.returncode == 0, bumped.stdout + bumped.stderr + + +# --- the issue's merge ------------------------------------------------------ + +def test_two_leg_bumps_merged_are_invalid_until_the_merge_is_committed( + standard, project, scaffolded_here, tmp_path): + """The issue's reproduction, end to end. + + `bump/a` and `bump/b` each move the spec leg, to sibling commits, and + `bump/b` is merged into `bump/a`: the pin and the gitlink are both + `UU`. Main read that `DRIFTED (1 leg(s) not at the pin)`, about a leg + that had not moved, and `COMPLIANT`, exit 0, once `checkout --ours` had + taken the markers out of the pin. Both are `INVALID (merge)` now, and + once the merge is committed the root is `COMPLIANT` with no merge row. + """ + remotes = tmp_path / "remotes" + shutil.copytree(scaffolded_here["base"] / "remotes", remotes, + symlinks=True) + new1, new2 = spec_commits_on_branches(remotes, tmp_path) + bump_on(standard, project, remotes, "bump/b", new2) + bump_on(standard, project, remotes, "bump/a", new1) + git(*FILE_PROTOCOL, "submodule", "update", "-q", cwd=project) + assert_compliant_with_no_merge_row(standard, project) + merged = merge(project, "bump/b") + assert merged.returncode != 0, merged.stdout + merged.stderr + status = git("status", "--porcelain=v1", cwd=project).stdout + assert f"UU {SPEC_PIN}" in status and "UU spec" in status, status + assert b"<<<<<<<" in (project / SPEC_PIN).read_bytes() + + assert_invalid_for_the_merge(standard, project, [SPEC_PIN, "spec"], + merging=True) + + git("checkout", "--ours", "--", SPEC_PIN, cwd=project) + assert b"<<<<<<<" not in (project / SPEC_PIN).read_bytes() + assert f"UU {SPEC_PIN}" in git("status", "--porcelain=v1", + cwd=project).stdout + assert_invalid_for_the_merge(standard, project, [SPEC_PIN, "spec"], + merging=True) + # The defect, said by the rows: with the markers gone every other row is + # green, so the merge row is the only thing between this root and + # COMPLIANT. + rows = rows_of(doctor(standard, project, "--json")) + assert [name for name, row in rows.items() + if row["status"] == "FINDING"] == ["merge"], rows + + # Finished as one side, and committed: the merge row goes. + git("add", "--", SPEC_PIN, cwd=project) + git("update-index", "--cacheinfo", f"160000,{new1},spec", cwd=project) + git("commit", "-q", "--no-edit", cwd=project) + assert unmerged_index(project) == "" + assert_compliant_with_no_merge_row(standard, project) + + +def own_file_conflicted(root: Path, operation: str) -> None: + """`NOTES.md`, a file no row reads, left conflicted by `operation`: a + `merge` (git writes `MERGE_HEAD`) or a `cherry-pick` (it does not).""" + base = git("rev-parse", "HEAD", cwd=root).stdout.strip() + for side in ("one", "two"): + git("checkout", "-q", "-b", f"notes-{side}", base, cwd=root) + (root / "NOTES.md").write_bytes(f"{side}\n".encode()) + git("add", "--", "NOTES.md", cwd=root) + git("commit", "-q", "-m", f"notes {side}", cwd=root) + if operation == "merge": + done = merge(root, "notes-one") + else: + done = git("cherry-pick", "notes-one", cwd=root, check=False) + assert done.returncode != 0, done.stdout + done.stderr + assert "AA NOTES.md" in git("status", "--porcelain=v1", + cwd=root).stdout + + +def test_a_merge_left_conflicted_in_a_file_no_row_reads_is_invalid( + standard, project): + """`AA NOTES.md`: main was `COMPLIANT`, exit 0, before `checkout --ours` + and after it. Abandoned, the root is `COMPLIANT` again.""" + own_file_conflicted(project, "merge") + assert_invalid_for_the_merge(standard, project, ["NOTES.md"], + merging=True) + git("checkout", "--ours", "--", "NOTES.md", cwd=project) + assert_invalid_for_the_merge(standard, project, ["NOTES.md"], + merging=True) + git("merge", "--abort", cwd=project) + assert_compliant_with_no_merge_row(standard, project) + + +def test_a_cherry_pick_left_conflicted_is_invalid_and_sends_to_git_status( + standard, project): + """No `MERGE_HEAD`, and `git commit` refuses all the same. The row does + not tell a cherry-pick to `git merge --abort`: `git status` says how to + resolve each path and how to finish or abandon the cherry-pick.""" + own_file_conflicted(project, "cherry-pick") + row = assert_invalid_for_the_merge(standard, project, ["NOTES.md"], + merging=False) + assert "merge --abort" not in row["reason"], row["reason"] + assert ("`git status` says how to resolve each, and how to finish or " + "abandon what left them") in row["reason"], row["reason"] + + +def test_a_rebase_stopped_on_a_merge_it_replays_is_sent_to_git_status( + standard, project): + """`git rebase -r` replays a merge whose conflict was resolved by hand, + meets the conflict again and stops, with `MERGE_HEAD` beside + `rebase-merge`. `git merge --abort` there would strand the rebase, so + the row words it as it does a cherry-pick: `git status` names the + rebase's own `--continue` and `--abort`.""" + base = git("rev-parse", "HEAD", cwd=project).stdout.strip() + for side in ("x", "t"): + git("checkout", "-q", "-b", f"side-{side}", base, cwd=project) + (project / "NOTES.md").write_bytes(f"{side}\n".encode()) + git("add", "--", "NOTES.md", cwd=project) + git("commit", "-q", "-m", f"notes {side}", cwd=project) + assert merge(project, "side-x").returncode != 0 + (project / "NOTES.md").write_bytes(b"resolved by hand\n") + git("add", "--", "NOTES.md", cwd=project) + git("commit", "-q", "--no-edit", cwd=project) + git("checkout", "-q", "-b", "onto", base, cwd=project) + (project / "ONTO.md").write_bytes(b"onto\n") + git("add", "--", "ONTO.md", cwd=project) + git("commit", "-q", "-m", "onto", cwd=project) + rebased = git("rebase", "-r", "onto", "side-t", cwd=project, check=False) + assert rebased.returncode != 0, rebased.stdout + rebased.stderr + for name in ("MERGE_HEAD", "rebase-merge"): + held = Path(git("rev-parse", "--git-path", name, + cwd=project).stdout.strip()) + assert (held if held.is_absolute() else project / held).exists(), \ + f"fixture: the stopped rebase must leave {name}" + row = assert_invalid_for_the_merge(standard, project, ["NOTES.md"], + merging=False) + assert "merge --abort" not in row["reason"], row["reason"] + + +def test_a_directory_inside_another_repository_is_not_asked_its_merge( + standard, tmp_path): + """`git -C` walks up. A directory with no repository of its own, inside + a checkout in the middle of a merge, is not that checkout: no row, for + a conflict outside the directory (a `note` before) or inside it (a + FINDING before). Asked of the checkout itself, the row is there.""" + outer = tmp_path / "outer" + (outer / "inner").mkdir(parents=True) + (outer / "inner" / "seed.md").write_bytes(b"seed\n") + git("init", "-q", "-b", "main", cwd=outer) + git("add", "--", "inner/seed.md", cwd=outer) + git("commit", "-q", "-m", "seed", cwd=outer) + own_file_conflicted(outer, "merge") + module = doctor_module(standard) + inner = (outer / "inner").resolve() + assert module.check_merge(module.Context(inner)) is None + assert unmerged_in_the_index(outer, [b"inner/x.md"]) + assert module.check_merge(module.Context(inner)) is None + row = module.check_merge(module.Context(outer.resolve())) + assert row.detail == {"unmerged": ["NOTES.md", "inner/x.md"], + "count": 2, "merging": True}, row.detail + + +def test_a_merge_with_nothing_unmerged_is_a_note_and_stays_compliant( + standard, project): + """`git merge --no-commit`: `MERGE_HEAD` and nothing unmerged. Git + commits it as it stands, so nothing asserts a fault: a `note`, no NEXT, + and the verdict steps over it.""" + git("checkout", "-q", "-b", "plain", cwd=project) + (project / "X.md").write_bytes(b"x\n") + git("add", "--", "X.md", cwd=project) + git("commit", "-q", "-m", "x", cwd=project) + git("checkout", "-q", "main", cwd=project) + merged = merge(project, "--no-commit", "--no-ff", "plain") + assert merged.returncode == 0, merged.stdout + merged.stderr + assert unmerged_index(project) == "" + result = doctor(standard, project, "--json") + assert result.returncode == 0, result.stdout + result.stderr + assert json.loads(result.stdout)["verdict"] == "COMPLIANT" + row = rows_of(result)["merge"] + assert row["status"] == "note", row + assert row["next"] is None, row + assert row["detail"] == {"unmerged": [], "count": 0, + "merging": True}, row["detail"] + assert "nothing in it is unmerged" in row["reason"], row["reason"] + + +def test_a_family_holder_in_the_middle_of_a_merge_is_invalid_too( + standard, holder, tmp_path): + """The row is registered for both kinds of root, and answers alike.""" + root = tmp_path / FAMILY_NAME + shutil.copytree(holder["root"], root, symlinks=True) + assert_compliant_with_no_merge_row(standard, root) + own_file_conflicted(root, "merge") + assert_invalid_for_the_merge(standard, root, ["NOTES.md"], merging=True) + result = doctor(standard, root, "--json") + assert json.loads(result.stdout)["kind"] == "family" + + +# --- what the row says, read in process ------------------------------------- + +def unmerged_in_the_index(root: Path, names: list[bytes]) -> bool: + """Each of `names` at stages 1, 2 and 3 of `root`'s index, as a + conflicted merge leaves a path, written straight in (`update-index -z + --index-info`, bytes in) and never on the disk, so no file system has to + hold a name. `core.protectNTFS` is off, as in `test_adopt_entry_paths.py`: + on Windows it refuses a control character in a name the index holds all + the same. False where git will not record a name, which only git can + say.""" + blob = subprocess.run(["git", "hash-object", "-w", "--stdin"], + cwd=str(root), input=b"x\n", capture_output=True, + check=True).stdout.decode("ascii").strip() + records = b"".join(f"100644 {blob} {stage}\t".encode() + name + b"\0" + for name in names for stage in (1, 2, 3)) + written = subprocess.run(["git", "-c", "core.protectNTFS=false", + "update-index", "-z", "--index-info"], + cwd=str(root), input=records, + capture_output=True, check=False) + held = subprocess.run(["git", "ls-files", "-u", "-z"], cwd=str(root), + capture_output=True, check=True).stdout + return written.returncode == 0 and all( + b"\t" + name + b"\0" in held for name in names) + + +def merge_row(standard: Path, root: Path): + module = doctor_module(standard) + return module, module.check_merge(module.Context(root.resolve())) + + +def test_the_row_names_five_paths_then_counts_the_rest(standard, project): + """Seven paths unmerged and no operation git records: five named, then + `and 2 more`, each once however many stages it holds, and every one in + `detail`. The verdict is the merge's.""" + names = [f"{letter}.md".encode() for letter in "abcdefg"] + assert unmerged_in_the_index(project, names) + module, row = merge_row(standard, project) + spelled = [name.decode() for name in names] + assert row.status == module.FINDING, row.as_dict() + assert row.detail == {"unmerged": spelled, "count": 7, + "merging": False}, row.detail + assert ("7 path(s) unmerged in the index, so git commits nothing here " + "until each is resolved: a.md, b.md, c.md, d.md, e.md and 2 " + "more. ") in row.reason, row.reason + result = doctor(standard, project, "--json") + assert result.returncode == 1, result.stdout + result.stderr + assert json.loads(result.stdout)["verdict"] == "INVALID (merge)" + + +def test_an_awkward_unmerged_name_is_spelled_on_one_line(standard, project): + """A name holding a line break, and one holding a byte that is not + UTF-8, are spelled by `repr`, as the placement row spells one, so the + report stays one line per row and the name is still the name.""" + names = [b"line\nbreak.md", b"caf\xe9.md"] + if not unmerged_in_the_index(project, names): + pytest.skip("git does not hold these names in the index here") + module, row = merge_row(standard, project) + spelled = [repr("caf\udce9.md"), repr("line\nbreak.md")] + assert row.detail["unmerged"] == spelled, row.detail + assert ", ".join(spelled) in row.reason, row.reason + assert "\n" not in row.reason and "\n" not in row.next_command + text = doctor(standard, project) + assert text.returncode == 1, text.stdout + text.stderr + [line] = [line for line in text.stdout.splitlines() + if " merge " in line and "unmerged in the index" in line] + assert ", ".join(spelled) in line, line + + +def test_the_merge_rung_outranks_drift_and_asks_for_its_list(standard): + """The ladder, asked in process. An unmerged index outranks a leg off + its pin and a red validator: it is what made them so. But a `merge` + FINDING with an empty `detail` -- what `run_checks` makes of a check + that raised -- names no path, so it is not the merge verdict: the rungs + above the catch-all answer first, as they do for a placement row with + no list.""" + module = doctor_module(standard) + ctx = module.Context.__new__(module.Context) + ctx.kind = module.PROJECT + off_pin = module.Row("legs", "legs", module.FINDING, "off its pin", None, + {"legs": [{"role": "spec", "head": "1" * 40, + "pin": "2" * 40}]}) + red = module.Row("pins", "pins", module.FINDING, "red", None, {}) + unfinished = module.Row("merge", "merge", module.FINDING, "unmerged", + None, {"unmerged": ["spec"], "count": 1, + "merging": True}) + assert module.verdict_for(ctx, [off_pin, red]) == ( + "DRIFTED (1 leg(s) not at the pin)", 1) + assert module.verdict_for(ctx, [unfinished, off_pin, red]) == ( + "INVALID (merge)", 1) + raised = module.Row("merge", "merge", module.FINDING, "could not be run", + None, {}) + assert module.verdict_for(ctx, [raised, off_pin, red]) == ( + "DRIFTED (1 leg(s) not at the pin)", 1) + assert module.verdict_for(ctx, [raised, red]) == ("INVALID (pins)", 1)