diff --git a/scripts/bump-leg.py b/scripts/bump-leg.py index 706b0506..6a4e3316 100755 --- a/scripts/bump-leg.py +++ b/scripts/bump-leg.py @@ -48,6 +48,18 @@ * a validator that goes red after the rewrite. Every byte is rolled back first — the pin file, the workflow files, the leg's checkout and the index — so a refusal leaves the tree exactly as it was found. + * any other failure once the leg has been asked to move, up to and + including a commit git refuses (a signing key that cannot sign, a + `commit-msg` hook) — a workflow file it cannot write, a stdout that + cannot encode what a validator printed, an interrupt — is rolled back + the same way and then reported: as a refusal naming the exception for an + `OSError` or a `UnicodeError`, and as itself for anything else, an + interrupt included, after a line saying whether the tree is back. No + report claims more than the rollback did: a step it could not do is + named, and so is a leg whose `git status` no longer reads as it did + before the bump (a checkout cut off part way leaves the files it wrote), + and the report says THE ROLLBACK WAS NOT COMPLETE. A failure after the + commit has landed rolls nothing back, and says so. EXIT CODES: 0 the commit was made, or a `--dry-run` printed a move it would make · 1 NOTHING TO DO — every fact already names that commit, under @@ -62,6 +74,7 @@ import re import subprocess import sys +from functools import partial from pathlib import Path SHAPE_ROOT = Path(__file__).resolve().parents[1] @@ -694,11 +707,183 @@ def _print_trailers(trailers: list[str]) -> None: print("trailers none") +#: What a report says when the rollback put every byte back, and what it says +#: when it did not. ONE SPELLING EACH: a refusal written before the rollback +#: ran (`bump-leg-validators-red`) carries `ROLLED_BACK`, and the handler +#: replaces exactly that sentence when the rollback did not earn it. +ROLLED_BACK = ("Every byte this command wrote has been rolled back — the " + "pin, the workflow files, the leg's checkout and the index — " + "and the tree is exactly as it was.") +NOT_ROLLED_BACK = ("THE ROLLBACK WAS NOT COMPLETE: what it could not put " + "back is named above, so read `git status` before " + "anything else.") + + +def _restore_file(target: Path, original: bytes) -> None: + """Write `original` back, but only over a file that no longer holds it. + + A write that never opened the file — a read-only attribute, a permission + the operator does not have — changed nothing, and writing the same bytes + back would meet the same refusal and report a rollback failure for a file + that was never touched. + """ + if target.read_bytes() != original: + target.write_bytes(original) + + +class LegNotAsFound(Exception): + """The leg's `git status` does not read as it did before the bump.""" + + +def _leg_status(submodule: Path) -> bytes: + """The leg's `git status`, untracked files one by one, as raw bytes. + + `-z` and bytes, so nothing strips the leading space of ` M `, and + `--untracked-files=all`, so a file written into a directory that already + held an untracked file of the operator's is a line of its own. + """ + return git_out(["status", "--porcelain", "-z", "--untracked-files=all"], + cwd=submodule, binary=True) + + +def _put_back(what: str, undo) -> bool: + """Attempt ONE step of a rollback; say so, and carry on, if it fails. + + Each step is attempted whatever the steps before it did, and none of them + raises: a rollback that stopped at the first byte it could not restore + would leave the leg moved and the index staged behind a file the operator + cannot write. Returns whether the step succeeded. + + AN INTERRUPT IS CAUGHT HERE TOO. A second Ctrl-C while the rollback checks + a large leg out again is the likeliest next keystroke, and let through it + skipped every step after the one it landed in, leaving the new pin, the + new workflow and the new gitlink staged with nothing naming the index. + Caught per step rather than by a signal handler, which a library function + has no business installing: the step it landed in is named, the steps + after it (a file, the index: short ones) still run, and the first failure + is still the one re-raised. + """ + try: + undo() + except CommandFailed as exc: + print(exc.loudly("rolling the bump back"), file=sys.stderr) + except KeyboardInterrupt: + print(f"REFUSED rolling the bump back. {what} could not be put back: " + "interrupted", file=sys.stderr) + except LegNotAsFound as exc: + print(f"REFUSED rolling the bump back. {what} could not be put back: " + f"{exc}", file=sys.stderr) + except Exception as exc: # noqa: BLE001 - nothing may escape a rollback + print(f"REFUSED rolling the bump back. {what} could not be put back: " + f"{type(exc).__name__}: {exc}", file=sys.stderr) + else: + return True + return False + + +def _restore_leg_if_moved(submodule: Path, placement: str | None, + was: str, status: bytes) -> None: + """Put the leg back where it stood, if it no longer stands there, and + fail the step if its `git status` no longer reads as `status` did. + + DECIDED FROM THE LEG, NOT FROM WHETHER THE CHECKOUT RETURNED. git moves + the leg's HEAD before it runs `post-checkout`, so a hook that exits + non-zero (git-lfs's does, with no `git-lfs` on PATH) fails a checkout + that has already moved the leg, and an interrupt can land while the + checkout is still being waited on after git has moved it. Either way the + leg is on the new commit and the checkout never returned. A leg that + still stands where it stood, on the same branch at the same commit, is + not checked out again, so a checkout that never moved it does not go + through a refusing hook a second time. + + AND FROM THE LEG'S `git status`, NOT ITS BRANCH AND HEAD ALONE. git + writes the new commit's files before it moves the leg's index and HEAD, + so a checkout cut off between the two (a terminal's Ctrl-C reaches git + and this tool together) leaves the leg on its branch at its commit with + those files in it. Nothing is removed here: an untracked file of the + operator's own is allowed in the leg, and only the operator can tell it + from one the checkout wrote, so the step fails and says where to look. + """ + if not (leg_placement(submodule) == placement + and git_out(["rev-parse", "HEAD"], + cwd=submodule).lower() == was.lower()): + restore_leg(submodule, placement, was) + if _leg_status(submodule) != status: + raise LegNotAsFound( + "its `git status` no longer reads as it did before the bump, " + "which is what a checkout cut off part way leaves behind. Read " + f"`git -C {submodule} status` before anything else, and remove " + "only what you did not put there yourself.") + + +def _roll_back(root: Path, submodule: Path, placement: str | None, was: str, + status: bytes, staged: list[str], + written: dict[Path, bytes]) -> bool: + """Put back every byte `_apply_rewrite` changed: the files it wrote, the + leg's checkout (when the leg no longer stands where it stood, and checked + against the leg's `git status` from before the bump) and the index, in + that order. Returns whether ALL of it went back. + + THE ROLLBACK NEVER RAISES OVER THE FAILURE THAT STARTED IT. Whatever it + cannot put back is printed and the original failure is still the one the + caller re-raises: a rollback failure that replaced the validator's finding + would leave a person with a changed tree and no idea what the gate + objected to. + """ + steps = [(target.relative_to(root).as_posix(), + partial(_restore_file, target, original)) + for target, original in written.items()] + steps.append((f"the checkout of {submodule.name}/", + partial(_restore_leg_if_moved, submodule, placement, was, + status))) + steps.append(("the index", partial( + run, ["git", "reset", "-q", "HEAD", "--", *staged], cwd=root))) + return all([_put_back(what, undo) for what, undo in steps]) + + +def _head_guard(root: Path, head_before: str | None) -> str | None: + """None when the rollback is to run; otherwise the sentence the report + carries instead, saying why it must not. + + THE ROOT'S HEAD DECIDES, NOT WHICH STATEMENT RAISED. Until the commit is + asked for (`head_before` is None) nothing can have been committed. Once + it has been, an unmoved HEAD means git refused the commit, and every + byte goes back as for any other failure. A moved HEAD means the bump IS + a commit and the failure came after it (the `git rev-parse` that reads + it back, an interrupt); putting the files, the leg and the index back + under that commit would leave a tree that contradicts it. A HEAD that + cannot be read is treated the same way, since a rollback over a commit + that landed is the worse of the two mistakes. + """ + if head_before is None: + return None + try: + head = git_out(["rev-parse", "HEAD"], cwd=root).lower() + except BaseException as exc: # noqa: BLE001 - an interrupt included + return ("Whether the commit landed could not be read " + f"({type(exc).__name__}), so nothing was rolled back: read " + "`git log -1` and `git status` before anything else.") + if head == head_before: + return None + return (f"The bump was committed as {head[:12]} before this failed, so " + "nothing was rolled back: that commit holds the whole bump, and " + "`git log -1` shows it.") + + +def _with_verdict(detail: str, verdict: str) -> str: + """A refusal's `detail`, saying what the rollback actually did: in place + of the `ROLLED_BACK` it was written with, or else after it.""" + if ROLLED_BACK in detail: + return detail.replace(ROLLED_BACK, verdict, 1) + return f"{detail}\n{verdict}" + + def _apply_rewrite(root: Path, submodule: Path, commit: str, was: str, pin_path: Path, pin_text: str, digest: str, plans: list, - staged: list[str]) -> None: - """Rewrite the pin and every workflow file, stage them and validate — - or roll every byte back and re-raise. + staged: list[str], message: str, + trailers: list[str]) -> str: + """Rewrite the pin and every workflow file, stage them, validate them and + commit them — or roll every byte back and re-raise. Returns the commit. Split from `cmd_bump` for #136: the whole rewrite-or-roll-back sequence is one question, asked once, instead of being the tail half of a @@ -706,19 +891,44 @@ def _apply_rewrite(root: Path, submodule: Path, commit: str, was: str, its order are unchanged: the leg is checked out detached BEFORE any file is written, the rewritten files are staged AFTER they are all written and BEFORE the validators run (`recorded_gitlink` reads the - INDEX), and a caught `Refusal` or `CommandFailed` rolls every byte back - before it is re-raised, never swallowed. + INDEX), and a failure of ANY kind rolls every byte back before it is + re-raised, never swallowed (#253: the handler named `Refusal` and + `CommandFailed` alone, so a `PermissionError` on a workflow file, a + `UnicodeError` from the validator echo or an interrupt left the pin + rewritten, the leg on the new commit and the index changed). + + THE COMMIT IS INSIDE IT TOO, as the last statement. A commit git refuses + (a signing key that cannot sign, a `commit-msg` hook) is a failure after + the leg moved like any other; made after this returned, it left the bump + staged and the leg on the new commit, and the operator's next act, the + same command once the signer was fixed, was refused as a dirty root. One + `git rev-parse HEAD` is read just before it, and `_head_guard` compares + the root's HEAD with that after any failure: a failure after the commit + landed is not rolled back. + + WHAT IT REPORTS never claims more than the rollback did: every byte back + (`ROLLED_BACK`), THE ROLLBACK WAS NOT COMPLETE, or the commit landed and + nothing was rolled back. A `Refusal` carries that in its own text. An + `OSError` or a `UnicodeError` that nothing committed leaves as a + `Refusal` naming it — the repository's own answer to a write that could + not happen, `update-shape.py`'s `update-readme-unwritable`, never a + traceback. A `CommandFailed`, an interrupt and anything else leave as + themselves, after one line on stderr saying it, because neither + `main()`'s report of a git command nor a traceback says what became of + the tree. """ written: dict[Path, bytes] = {} placement = leg_placement(submodule) - moved_leg = False + # Read BEFORE the checkout, as `placement` is: the rollback compares the + # leg with it (`_restore_leg_if_moved`). + status = _leg_status(submodule) + head_before: str | None = None try: # A BUMP LEAVES THE LEG AT THE COMMIT, detached, and does not move any # branch. `bootstrap.py` is what places a leg ON its tracking branch — # at the pin — and a bump that also moved `main` in the leg would be # this tool pushing somebody's branch around from inside a submodule. run(["git", "checkout", "-q", "--detach", commit], cwd=submodule) - moved_leg = True for target, data in [(pin_path, rewrite_pin(pin_text, commit, digest)) ] + [(wf_path, text) for wf_path, text, _ in plans]: written[target] = target.read_bytes() @@ -742,29 +952,48 @@ def _apply_rewrite(root: Path, submodule: Path, commit: str, was: str, code, output = run_validator(root, script) sys.stdout.write(output) if code != 0: + # `ROLLED_BACK` is written before the rollback has run, and + # the handler below replaces it when the rollback did not + # earn it. raise Refusal( "bump-leg-validators-red", - f"{script} exited {code} after the rewrite. Every byte " - "this command wrote has been rolled back — the pin, the " - "workflow files, the leg's checkout and the index — and " - "the tree is exactly as it was.\n--- its output ---\n" + f"{script} exited {code} after the rewrite. " + f"{ROLLED_BACK}\n--- its output ---\n" + output.rstrip() + "\n--- end output ---", "Remediation: read the finding above. A tool that left a " "project red would have moved the cost from this command " "to somebody else's pull request.") - except (Refusal, CommandFailed): - # THE ROLLBACK NEVER RAISES OVER THE REFUSAL. Whatever it cannot put - # back is printed and the original exit is re-raised: a rollback - # failure that replaced the validator's finding would leave a person - # with a changed tree and no idea what the gate objected to. - for target, original in written.items(): - target.write_bytes(original) - try: - if moved_leg: - restore_leg(submodule, placement, was) - run(["git", "reset", "-q", "HEAD", "--", *staged], cwd=root) - except CommandFailed as undo: - print(undo.loudly("rolling the bump back"), file=sys.stderr) + + head_before = git_out(["rev-parse", "HEAD"], cwd=root).lower() + return commit_once(root, message, staged, trailers=trailers) + except BaseException as failure: + # `BaseException`, NOT `Exception`: an interrupt during the checkout + # or the validators is the failure a person causes on purpose, and it + # leaves the same half-applied bump as any other. + landed = _head_guard(root, head_before) + if landed is None: + verdict = (ROLLED_BACK if _roll_back(root, submodule, placement, + was, status, staged, written) + else NOT_ROLLED_BACK) + else: + verdict = landed + if isinstance(failure, Refusal): + raise Refusal(failure.code, _with_verdict(failure.detail, verdict), + failure.remediation) from failure + if landed is None and isinstance(failure, (OSError, UnicodeError)): + raise Refusal( + "bump-leg-rewrite-failed", + f"{type(failure).__name__} while the bump was being applied: " + f"{failure}. {verdict}", + "Remediation: fix what the exception names — a file this " + "command cannot write, a stdout that cannot encode what a " + "validator printed — and run the same command again. " + "Nothing was committed.") from failure + lead = ("The command below failed" + if isinstance(failure, CommandFailed) + else type(failure).__name__) + print(f"{lead} while the bump was being applied. {verdict}", + file=sys.stderr) raise @@ -812,12 +1041,10 @@ def cmd_bump(args) -> int: "untouched.") return 0 - # ---- the rewrite, remembered byte for byte so a red validator undoes it + # ---- the rewrite and the commit, remembered byte for byte so a failure + # anywhere up to the commit undoes them staged: list[str] = [path, pin_rel] + [ wf_path.relative_to(root).as_posix() for wf_path, _, _ in plans] - _apply_rewrite(root, submodule, commit, was, pin_path, pin_text, digest, - plans, staged) - body = ( f"{was[:12]} -> {commit[:12]}, tree {digest}.\n\n" + (f"{sum(count for _, _, count in plans)} workflow `@` " @@ -832,9 +1059,11 @@ def cmd_bump(args) -> int: "request, and on somebody else's change.\n\nWritten by " "openRepoShape's `scripts/bump-leg.py`; the digest is recomputed " "from the leg's own objects, not adjusted.\n") - head = commit_once( - root, f"Bump {role} leg to {commit[:12]} in {project.display_name}\n\n" - + body, staged, trailers=args.trailer) + head = _apply_rewrite( + root, submodule, commit, was, pin_path, pin_text, digest, plans, + staged, + f"Bump {role} leg to {commit[:12]} in {project.display_name}\n\n" + + body, args.trailer) print(f"\n committed {head[:12]}: " + ", ".join(staged)) print(f" {path}/ is left DETACHED at {commit[:12]}; `make bootstrap` in " "the root re-places it on its tracking branch AT the new pin") diff --git a/tests/test_bump_leg_rollback.py b/tests/test_bump_leg_rollback.py new file mode 100644 index 00000000..f46c42f4 --- /dev/null +++ b/tests/test_bump_leg_rollback.py @@ -0,0 +1,664 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`scripts/bump-leg.py`: a bump that fails for ANY reason once its first byte +has moved puts every byte back (#253). + +`_apply_rewrite` checks the leg out, writes the pin and each workflow file, +stages them, runs the validators and commits. Its rollback used to be a +handler for `Refusal` and `CommandFailed` alone, so a `PermissionError` on a +workflow file, a `UnicodeError` from echoing a validator's output or an +interrupt left the pin rewritten, the leg on the new commit and the index +changed, behind a traceback. `tests/test_bump_leg.py` holds the red-validator +rollback; this file holds the failures that rollback never saw: those, a +commit git refuses, a forward checkout that moved the leg and then failed or +was cut off before it moved it, and a rollback step that fails or is +interrupted itself. It also holds the one failure that must NOT be rolled +back: one after the commit landed. + +A REPORT NEVER CLAIMS A TREE THE ROLLBACK DID NOT RESTORE. Every failure +says, as a refusal's own text or as a line before the traceback, either that +every byte is back or that THE ROLLBACK WAS NOT COMPLETE. + +EVERY TEST COMPARES THE WHOLE TREE, not the one thing its failure touched: +the root's HEAD, the pin's bytes, every workflow's bytes, the gitlink the +index records, `git diff --cached --quiet`, the leg's HEAD and the branch the +leg stood on. A rollback that put the pin back and forgot the index is the +bug, so a test that read only the pin could not fail for it. + +The failure that needs the operating system (a read-only file) runs +`bump-leg.py` as a subprocess. The ones that need a failure at an exact step +(a write that dies half way, a validator's output that cannot be decoded or +echoed, an interrupt, a rollback step that itself fails) call `main` +in-process with ONE module attribute replaced, and everything else, git +included, real. + +THE ECHO IS A STREAM THAT RAISES, NOT A CODEC SETTING. A real `strict` stdout +(`PYTHONIOENCODING=cp1252`) is what #253 was found with, but +`escape_what_stdout_cannot_encode` (#214) reconfigures exactly that stream to +`backslashreplace` at the top of `main`, so a test built on it would stop +meaning anything the day #214 lands. A stream that raises +`UnicodeEncodeError` from `write` stays a failure in the echo whichever of +those fixes are in. +""" + +from __future__ import annotations + +import errno +import io +import os +import stat +import subprocess +import sys +from pathlib import Path + +import pytest + +from conftest import PROJECT, git +# A fixture is used by being named in this module's namespace, which is what +# the import of `bump_leg_module` is for. +from test_bump_leg import ( # noqa: F401 + branched, bump, bump_leg_module, reference_both_legs, +) + +#: Where the advanced spec commit is pushed. Not `test_bump_leg.py`'s branch: +#: both modules share the session's bare remotes, and a second, different +#: commit pushed to the same branch name would be refused as non-fast-forward. +ADVANCE_BRANCH = "bump-leg-rollback-advance" + +#: The two things a report may say about the tree, spelled here rather than +#: imported, so a test reads what the operator reads. +ROLLED_BACK = "and the tree is exactly as it was" +NOT_COMPLETE = "THE ROLLBACK WAS NOT COMPLETE" + + +@pytest.fixture(scope="module") +def advanced(scaffolded, tmp_path_factory) -> str: + """A real new commit on the spec leg's bare remote; returns its sha.""" + work = tmp_path_factory.mktemp("spec-advance-rollback") / f"{PROJECT}-spec" + proc = subprocess.run( + ["git", "clone", "-q", + str(scaffolded["remotes"] / f"{PROJECT}-spec.git"), str(work)], + capture_output=True, text=True, check=False) + assert proc.returncode == 0, proc.stderr + (work / "ROLLBACK-CONTRACT.md").write_text("# a contract the leg gained\n") + git("add", "--", "ROLLBACK-CONTRACT.md", cwd=work) + git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", + "Advance the spec leg for the rollback tests", cwd=work) + git("push", "-q", "origin", f"HEAD:refs/heads/{ADVANCE_BRANCH}", cwd=work) + return git("rev-parse", "HEAD", cwd=work).stdout.strip() + + +def snapshot(project: Path) -> dict: + """Everything a bump may change and a rollback has to put back.""" + leg = project / "spec" + workflows = sorted((project / ".github" / "workflows").glob("*.y*ml")) + return { + "head": git("rev-parse", "HEAD", cwd=project).stdout.strip(), + "pin": (project / "contracts" / "spec-pin.yaml").read_bytes(), + "workflows": {path.name: path.read_bytes() for path in workflows}, + "gitlink": git("ls-files", "-s", "--", "spec", cwd=project).stdout, + "status": git("status", "--porcelain", cwd=project).stdout, + "leg_head": git("rev-parse", "HEAD", cwd=leg).stdout.strip(), + "leg_branch": git("symbolic-ref", "--quiet", "HEAD", cwd=leg, + check=False).stdout.strip(), + } + + +def assert_nothing_changed(project: Path, before: dict) -> None: + after = snapshot(project) + for key, value in before.items(): + assert after[key] == value, f"{key} is not as it was found" + assert git("diff", "--cached", "--quiet", cwd=project, + check=False).returncode == 0, "the index carries a staged change" + + +def prepared(project: Path) -> dict: + """A bump-ready root: a workflow naming both legs, committed; the spec leg + ON its branch, as `bootstrap.py` leaves it, so a rollback that restored the + commit but left the leg detached is a failure too; a bump branch.""" + reference_both_legs(project) + git("checkout", "-q", "-B", "main", cwd=project / "spec") + branched(project) + return snapshot(project) + + +def run_main(module, project: Path, scaffolded: dict, to: str) -> int: + return module.main(["--root", str(project), "--leg", "spec", "--to", to, + "--local-remote-dir", str(scaffolded["remotes"])]) + + +def raiser(exc: BaseException): + def raise_it(*_args, **_kwargs): + raise exc + return raise_it + + +class CannotEncode(io.StringIO): + """A stdout that holds what cp1252 holds and raises on anything else, as a + redirected Windows stdout does: `…` is fine, U+FFFD is not.""" + + def write(self, text): + text.encode("cp1252") + return super().write(text) + + +# --- the operating system's failures, run for real -------------------------- + +def test_a_workflow_file_the_operator_cannot_write_is_refused_and_put_back( + project, scaffolded, advanced): + """THE ISSUE'S OWN REPRODUCTION: a tracked workflow file with a + read-only attribute. The leg was moved and the pin written before the + write failed; the file itself was never opened, so it is the one thing + the rollback must NOT report as unrestorable.""" + before = prepared(project) + legs = project / ".github" / "workflows" / "legs.yml" + legs.chmod(stat.S_IREAD) + try: + if os.access(legs, os.W_OK): + pytest.skip("this account can write a read-only file (root)") + result = bump(project, scaffolded, advanced) + finally: + legs.chmod(stat.S_IREAD | stat.S_IWRITE) + + assert result.returncode == 2, result.stderr + result.stdout + assert "REFUSED bump-leg-rewrite-failed" in result.stderr + assert "PermissionError" in result.stderr + assert "Traceback" not in result.stderr, "a refusal, never a traceback" + assert "rolled back" in result.stderr + assert "rolling the bump back" not in result.stderr, ( + "the file was never changed, so there was nothing it could not put " + "back") + assert_nothing_changed(project, before) + + +# --- a failure at an exact step, called in-process -------------------------- + +def test_a_write_that_dies_half_way_leaves_the_file_as_it_was( + project, scaffolded, advanced, bump_leg_module, monkeypatch, capsys): + """A full disk after the file was truncated and part of it written: the + bytes on disk are neither the old nor the new ones, and only the bytes + kept before the write can put the file back.""" + before = prepared(project) + real_write = bump_leg_module.write_lf + + def disk_full(path, text): + if path.name != "legs.yml": + return real_write(path, text) + path.write_bytes(text.encode("utf-8")[:20]) + raise OSError(errno.ENOSPC, os.strerror(errno.ENOSPC), str(path)) + + monkeypatch.setattr(bump_leg_module, "write_lf", disk_full) + code = run_main(bump_leg_module, project, scaffolded, advanced) + + err = capsys.readouterr().err + assert code == 2 + assert "REFUSED bump-leg-rewrite-failed" in err + assert os.strerror(errno.ENOSPC) in err + assert_nothing_changed(project, before) + + +@pytest.mark.parametrize("what", ["decoded", "echoed"]) +def test_a_validator_output_that_cannot_be_decoded_or_echoed_is_refused_and_put_back( + what, project, scaffolded, advanced, bump_leg_module, monkeypatch, + capsys): + """The statement after the leg moved, the files were written and the + index was staged: reading the validator's output (the decode error #230 + turns into a replacement character) and writing it to a stdout that + cannot hold that character (the crash it moves to).""" + before = prepared(project) + if what == "decoded": + monkeypatch.setattr(bump_leg_module, "run_validator", raiser( + UnicodeDecodeError("utf-8", b"\x85", 0, 1, "invalid start byte"))) + expected = "UnicodeDecodeError" + else: + monkeypatch.setattr( + bump_leg_module, "run_validator", + lambda root, script: (0, "validator says: \ufffd\n")) + monkeypatch.setattr(sys, "stdout", CannotEncode()) + expected = "UnicodeEncodeError" + + code = run_main(bump_leg_module, project, scaffolded, advanced) + + err = capsys.readouterr().err + assert code == 2 + assert "REFUSED bump-leg-rewrite-failed" in err + assert expected in err + assert "rolled back" in err + assert_nothing_changed(project, before) + + +@pytest.mark.parametrize("step, failure", [ + ("write_lf", KeyboardInterrupt()), + ("run_validator", KeyboardInterrupt()), + ("run_validator", RuntimeError("a defect in this tool")), +], ids=["interrupt-writing", "interrupt-validating", "defect-validating"]) +def test_any_other_failure_puts_every_byte_back_and_is_re_raised_as_itself( + step, failure, project, scaffolded, advanced, bump_leg_module, + monkeypatch, capsys): + """An interrupt, or a defect that is not the operator's to fix, is not + made into a refusal: it reaches the interpreter as what it is, AFTER the + tree is back. `write_lf` fails on the SECOND file, with the pin already + rewritten; `run_validator` fails with the index staged. + + The traceback alone does not say whether the tree is back, so one line + before it does: an operator who pressed Ctrl-C should not have to run + `git status` to learn that nothing is left half-applied.""" + before = prepared(project) + if step == "write_lf": + real_write = bump_leg_module.write_lf + + def second_write_fails(path, text): + if path.name == "legs.yml": + raise failure + return real_write(path, text) + monkeypatch.setattr(bump_leg_module, "write_lf", second_write_fails) + else: + monkeypatch.setattr(bump_leg_module, "run_validator", raiser(failure)) + + with pytest.raises(type(failure)) as raised: + run_main(bump_leg_module, project, scaffolded, advanced) + + assert raised.value is failure + assert_nothing_changed(project, before) + err = capsys.readouterr().err + assert (f"{type(failure).__name__} while the bump was being applied" + in err), err + assert ROLLED_BACK in err, "the line says the tree is back" + assert NOT_COMPLETE not in err + + +@pytest.mark.parametrize("failure", + ["validators-red", "unwritable", "command-failed"]) +def test_a_rollback_step_that_fails_is_named_and_the_others_still_run( + failure, project, scaffolded, advanced, bump_leg_module, monkeypatch, + capsys): + """THE ROLLBACK NEVER RAISES OVER THE FAILURE: with the leg's checkout the + one step that cannot be put back, the files and the index still are, the + step is named, and the failure the person sees is the original one. + + AND NOTHING CLAIMS THE TREE IS BACK. A red validator's refusal, the + refusal an `OSError` becomes and the line printed above a git command + that failed all say THE ROLLBACK WAS NOT COMPLETE, and none of them says + the tree is exactly as it was.""" + before = prepared(project) + monkeypatch.setattr(bump_leg_module, "restore_leg", raiser( + OSError(errno.EIO, "the leg's checkout is gone"))) + if failure == "validators-red": + monkeypatch.setattr(bump_leg_module, "run_validator", + lambda root, script: (1, "pin-digest-mismatch\n")) + original = "REFUSED bump-leg-validators-red" + elif failure == "unwritable": + real_write = bump_leg_module.write_lf + + def refused(path, text): + if path.name == "legs.yml": + raise PermissionError(errno.EACCES, "Permission denied", + str(path)) + return real_write(path, text) + monkeypatch.setattr(bump_leg_module, "write_lf", refused) + original = "REFUSED bump-leg-rewrite-failed" + else: + real_run = bump_leg_module.run + + def add_fails(args, *rest, **kwargs): + if args[:2] == ["git", "add"]: + raise bump_leg_module.CommandFailed( + args, project, 128, "fatal: the index is locked") + return real_run(args, *rest, **kwargs) + monkeypatch.setattr(bump_leg_module, "run", add_fails) + original = "REFUSED advancing a leg" + + code = run_main(bump_leg_module, project, scaffolded, advanced) + + err = capsys.readouterr().err + assert code == 2 + assert original in err, "the original failure is the one reported" + assert "the checkout of spec/ could not be put back" in err + assert "the leg's checkout is gone" in err + assert NOT_COMPLETE in err, ( + "a refusal does not claim a tree it did not restore") + assert ROLLED_BACK not in err, ( + "and nothing says the tree is exactly as it was") + after = snapshot(project) + for key in ("head", "pin", "workflows", "gitlink"): + assert after[key] == before[key], f"{key} is not as it was found" + assert git("diff", "--cached", "--quiet", cwd=project, + check=False).returncode == 0, ( + "the index was still put back, though the leg's step failed before it") + + +def test_an_interrupt_inside_a_rollback_step_is_named_and_the_steps_after_it_still_run( + project, scaffolded, advanced, bump_leg_module, monkeypatch, capsys): + """A SECOND Ctrl-C, while the rollback checks the leg out again — the + likeliest next keystroke when a large leg is slow to check out. The step + it lands in is named as not put back; the index step after it still + runs, so the new pin, workflow and gitlink are not left staged; and the + failure reported is still the first one, saying the rollback was not + complete.""" + before = prepared(project) + monkeypatch.setattr(bump_leg_module, "run_validator", + lambda root, script: (1, "pin-digest-mismatch\n")) + monkeypatch.setattr(bump_leg_module, "restore_leg", + raiser(KeyboardInterrupt())) + + try: + code = run_main(bump_leg_module, project, scaffolded, advanced) + except KeyboardInterrupt: + pytest.fail("the interrupt in a rollback step escaped the rollback, " + "so no step after it ran") + + err = capsys.readouterr().err + assert code == 2 + assert "REFUSED bump-leg-validators-red" in err + assert "the checkout of spec/ could not be put back: interrupted" in err + assert NOT_COMPLETE in err + assert ROLLED_BACK not in err + after = snapshot(project) + for key in ("head", "pin", "workflows", "gitlink"): + assert after[key] == before[key], f"{key} is not as it was found" + assert git("diff", "--cached", "--quiet", cwd=project, + check=False).returncode == 0, ( + "the index step ran after the interrupted one") + + +# --- the leg, decided from the leg ------------------------------------------ + +def hook(repository: Path, hooks: Path, name: str, body: bytes) -> None: + """A hook for `repository`, set the way `test_update_shape_add_ignored.py` + sets one: `core.hooksPath` in the repository's own config, so a global + one cannot replace it; a shebang script, which Git for Windows runs + through its own `sh`; and a directory outside the tree, so `git status` + never sees it.""" + hooks.mkdir(exist_ok=True) + script = hooks / name + script.write_bytes(b"#!/bin/sh\n" + body) + script.chmod(0o755) + git("config", "core.hooksPath", hooks.as_posix(), cwd=repository) + + +@pytest.mark.parametrize("how", ["interrupted", "hook-failed"]) +def test_a_forward_checkout_that_moved_the_leg_and_then_failed_puts_it_back( + how, project, scaffolded, advanced, bump_leg_module, monkeypatch, + tmp_path): + """git moves the leg's HEAD BEFORE it runs `post-checkout`, so a checkout + can fail after the move: a hook that exits non-zero (git-lfs's, with no + `git-lfs` on PATH), or an interrupt that lands while the tool is still + waiting on git. Whether the leg is put back is read off the leg, not off + whether the checkout returned. The hook here refuses the NEW commit only, + so the checkout that puts the leg back is not refused too.""" + before = prepared(project) + if how == "interrupted": + real_run = bump_leg_module.run + + def moved_then_interrupted(args, *rest, **kwargs): + out = real_run(args, *rest, **kwargs) + if args[:4] == ["git", "checkout", "-q", "--detach"] and \ + args[-1] == advanced: + raise KeyboardInterrupt() + return out + monkeypatch.setattr(bump_leg_module, "run", moved_then_interrupted) + with pytest.raises(KeyboardInterrupt): + run_main(bump_leg_module, project, scaffolded, advanced) + assert_nothing_changed(project, before) + return + + hook(project / "spec", tmp_path / "leg-hooks", "post-checkout", + b'[ "$2" = "' + advanced.encode("ascii") + b'" ] || exit 0\n' + b"echo 'post-checkout: git-lfs was not found on your path' >&2\n" + b"exit 1\n") + result = bump(project, scaffolded, advanced) + + assert_nothing_changed(project, before) + assert result.returncode == 2, result.stderr + result.stdout + assert "REFUSED advancing a leg" in result.stderr + assert f"checkout -q --detach {advanced}" in result.stderr + assert ROLLED_BACK in result.stderr, result.stderr + + +@pytest.mark.parametrize("how", [ + "cut-off-interrupted", "cut-off-command-failed", "moved-then-interrupted"]) +def test_a_forward_checkout_that_left_files_in_the_leg_is_not_called_put_back( + how, project, scaffolded, advanced, bump_leg_module, monkeypatch, + capsys): + """git writes the new commit's files BEFORE it moves the leg's index and + HEAD, and a terminal's Ctrl-C reaches git and this tool together, so a + large checkout can be cut off with the leg's branch and HEAD unmoved and + the files it wrote left in the leg. Read off the branch and the HEAD + alone, that leg looked put back, the report said the tree was exactly as + it was, and the re-run then failed on `untracked working tree files would + be overwritten`. The `cut-off` cases are that checkout, interrupted or + failed; `moved-then-interrupted` is one that did move the leg and left a + file the checkout back cannot remove, so the comparison is made after a + restore as well. + + The leg's `git status` is read before the checkout and compared in the + rollback: the step is named, the operator is sent to `git -C + status`, and the report says THE ROLLBACK WAS NOT COMPLETE. NOTHING IN + THE LEG IS REMOVED: an untracked file of the operator's own is allowed + there, and the rollback cannot tell it from one the checkout wrote.""" + prepared(project) + leg = project / "spec" + mine = leg / "OPERATOR-NOTES.md" + mine.write_bytes(b"the operator's own untracked file\n") + before = snapshot(project) + real_run = bump_leg_module.run + + def cut_off(args, *rest, **kwargs): + if args == ["git", "checkout", "-q", "--detach", advanced]: + if how == "moved-then-interrupted": + real_run(args, *rest, **kwargs) + (leg / "PARTIAL.md").write_bytes(b"# written before the cut\n") + if how == "cut-off-command-failed": + raise bump_leg_module.CommandFailed(args, leg, 130, "") + raise KeyboardInterrupt() + return real_run(args, *rest, **kwargs) + monkeypatch.setattr(bump_leg_module, "run", cut_off) + + if how != "cut-off-command-failed": + with pytest.raises(KeyboardInterrupt): + run_main(bump_leg_module, project, scaffolded, advanced) + lead = "KeyboardInterrupt" + else: + assert run_main(bump_leg_module, project, scaffolded, advanced) == 2 + lead = "The command below failed" + + err = capsys.readouterr().err + assert (f"{lead} while the bump was being applied. {NOT_COMPLETE}" + in err), err + assert ROLLED_BACK not in err, "nothing says the tree is exactly as it was" + assert "the checkout of spec/ could not be put back: its `git status`" \ + in err, "the step is named" + assert "spec status` before anything else" in err, err + after = snapshot(project) + for key in ("head", "pin", "workflows", "gitlink", "leg_head", + "leg_branch"): + assert after[key] == before[key], f"{key} is not as it was found" + assert git("diff", "--cached", "--quiet", cwd=project, + check=False).returncode == 0, "the index was put back" + assert mine.read_bytes() == b"the operator's own untracked file\n" + assert (leg / "PARTIAL.md").is_file(), ( + "the rollback removes nothing from the leg: only the operator can " + "tell a file of theirs from one the checkout wrote") + + +def test_an_untracked_file_the_operator_keeps_in_the_leg_is_left_alone_and_not_counted( + project, scaffolded, advanced, bump_leg_module, monkeypatch, capsys): + """The other side of the comparison above: an untracked file that was in + the leg before the bump is in its `git status` before and after, so a + rollback that put everything back still says so, and the file is not + touched.""" + prepared(project) + mine = project / "spec" / "OPERATOR-NOTES.md" + mine.write_bytes(b"the operator's own untracked file\n") + before = snapshot(project) + monkeypatch.setattr(bump_leg_module, "run_validator", + lambda root, script: (1, "pin-digest-mismatch\n")) + + code = run_main(bump_leg_module, project, scaffolded, advanced) + + err = capsys.readouterr().err + assert code == 2 + assert "REFUSED bump-leg-validators-red" in err + assert ROLLED_BACK in err, err + assert NOT_COMPLETE not in err + assert_nothing_changed(project, before) + assert mine.read_bytes() == b"the operator's own untracked file\n" + + +def test_a_leg_detached_at_the_commit_it_stood_on_is_put_back_on_its_branch( + project, scaffolded, bump_leg_module, monkeypatch, capsys): + """`--to` the commit already pinned, with a workflow reference left + behind by a bump made by hand: not `nothing to do`, and the forward + checkout detaches the leg AT THE COMMIT IT STOOD ON. Its HEAD is where it + was and its branch is not, so a rollback that compared the HEAD alone + would leave the leg detached, which `bootstrap.py` never leaves it.""" + prepared(project) + pinned = git("rev-parse", "HEAD:spec", cwd=project).stdout.strip() + legs = project / ".github" / "workflows" / "legs.yml" + spec_ref = f"{PROJECT}-spec/.github/workflows/x.yml@" + text = legs.read_text(encoding="utf-8") + assert text.count(spec_ref + pinned) == 1 + legs.write_text(text.replace(spec_ref + pinned, spec_ref + "0" * 40), + encoding="utf-8") + git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", + "A workflow reference the last bump missed", "--", + ".github/workflows/legs.yml", cwd=project) + before = snapshot(project) + assert before["leg_branch"] == "refs/heads/main" + monkeypatch.setattr(bump_leg_module, "run_validator", + lambda root, script: (1, "pin-digest-mismatch\n")) + + code = run_main(bump_leg_module, project, scaffolded, pinned) + + err = capsys.readouterr().err + assert code == 2 + assert "REFUSED bump-leg-validators-red" in err + assert ROLLED_BACK in err, err + assert_nothing_changed(project, before) + + +# --- the commit, inside the rollback's reach -------------------------------- + +@pytest.mark.parametrize("refusal", ["signing", "commit-msg"]) +def test_a_commit_git_refuses_puts_every_byte_back( + refusal, project, scaffolded, advanced, tmp_path): + """The validators were green, so everything was written, staged and + checked; then git refused the commit itself. A signing key that cannot + sign and a `commit-msg` hook are both common on a developer's machine, + and the operator's next act is to fix that and run the same command + again, which a root left with the bump staged refuses as dirty. The + signer is a program that does not exist, which needs no hook and runs + the same on every platform.""" + before = prepared(project) + if refusal == "signing": + git("config", "commit.gpgsign", "true", cwd=project) + git("config", "gpg.program", (tmp_path / "no-signer").as_posix(), + cwd=project) + else: + hook(project, tmp_path / "root-hooks", "commit-msg", + b"echo 'commit-msg: not this message' >&2\nexit 1\n") + + result = bump(project, scaffolded, advanced) + + assert_nothing_changed(project, before) + assert result.returncode == 2, result.stderr + result.stdout + assert "REFUSED advancing a leg" in result.stderr + assert "git commit" in result.stderr, "the refusal names the commit" + assert ROLLED_BACK in result.stderr, result.stderr + assert NOT_COMPLETE not in result.stderr + + +def assert_the_commit_landed_and_nothing_was_rolled_back( + project: Path, before: dict, advanced: str) -> str: + head = git("rev-parse", "HEAD", cwd=project).stdout.strip() + assert head != before["head"], "the commit landed" + assert git("rev-parse", "HEAD:spec", cwd=project).stdout.strip() == \ + advanced + assert git("status", "--porcelain", cwd=project).stdout == "", ( + "the tree agrees with the commit: nothing was rolled back under it") + assert git("rev-parse", "HEAD", cwd=project / "spec").stdout.strip() == \ + advanced + return head + + +@pytest.mark.parametrize("after", ["interrupt", "refusal", "oserror"]) +def test_a_failure_after_the_commit_landed_rolls_nothing_back( + after, project, scaffolded, advanced, bump_leg_module, monkeypatch, + capsys): + """THE HEAD GUARD. Once the root's HEAD has moved, the bump IS a commit, + and putting the files, the leg and the index back under it would leave a + tree that contradicts the commit just made. An interrupt after `git + commit` returned, the `git rev-parse HEAD` that reads it back failing, or + an `OSError` once it landed rolls nothing back, and says the commit + landed. The `OSError` is not made into `bump-leg-rewrite-failed`, whose + remediation says nothing was committed.""" + before = prepared(project) + real_commit = bump_leg_module.commit_once + failure = { + "interrupt": KeyboardInterrupt(), + "refusal": bump_leg_module.Refusal("git-failed", + "`git rev-parse HEAD` exited 128"), + "oserror": OSError(errno.EIO, "the disk went away after the commit"), + }[after] + + def lands_then_fails(*args, **kwargs): + real_commit(*args, **kwargs) + raise failure + monkeypatch.setattr(bump_leg_module, "commit_once", lands_then_fails) + + if after == "refusal": + assert run_main(bump_leg_module, project, scaffolded, advanced) == 2 + else: + with pytest.raises(type(failure)) as raised: + run_main(bump_leg_module, project, scaffolded, advanced) + assert raised.value is failure + + head = assert_the_commit_landed_and_nothing_was_rolled_back( + project, before, advanced) + err = capsys.readouterr().err + assert f"committed as {head[:12]}" in err, err + assert ROLLED_BACK not in err + if after == "refusal": + assert "REFUSED git-failed" in err + if after == "oserror": + assert "OSError while the bump was being applied" in err, err + assert "bump-leg-rewrite-failed" not in err + assert "Nothing was committed" not in err + + +def test_a_head_that_cannot_be_read_after_the_commit_rolls_nothing_back( + project, scaffolded, advanced, bump_leg_module, monkeypatch, capsys): + """THE HEAD GUARD'S THIRD ANSWER. Once the commit has been asked for, + every `git rev-parse HEAD` in the root fails: the one `commit_once` + reads the new commit back with, and the guard's own. Whether the commit + landed is then unknown, and a rollback under a commit that did land is + the worse mistake, so nothing is rolled back and the report says it + could not be read.""" + before = prepared(project) + asked = [] + real_check = bump_leg_module.check_program + real_git_out = bump_leg_module.git_out + + def notes_the_commit(args): + if args[:2] == ["git", "commit"]: + asked.append(args) + return real_check(args) + + def head_unreadable_once_asked(args, cwd, binary=False): + if asked and args == ["rev-parse", "HEAD"]: + raise bump_leg_module.Refusal( + "git-failed", "`git rev-parse HEAD` exited 128: bad HEAD") + return real_git_out(args, cwd=cwd, binary=binary) + monkeypatch.setattr(bump_leg_module, "check_program", notes_the_commit) + monkeypatch.setattr(bump_leg_module, "git_out", head_unreadable_once_asked) + + code = run_main(bump_leg_module, project, scaffolded, advanced) + + assert asked, "the commit was asked for" + assert code == 2 + assert_the_commit_landed_and_nothing_was_rolled_back( + project, before, advanced) + err = capsys.readouterr().err + assert "REFUSED git-failed" in err + assert "Whether the commit landed could not be read" in err, err + assert ROLLED_BACK not in err + assert NOT_COMPLETE not in err