diff --git a/scripts/check-merge-sequence.py b/scripts/check-merge-sequence.py index ebddb2f8..7dbc7e54 100755 --- a/scripts/check-merge-sequence.py +++ b/scripts/check-merge-sequence.py @@ -189,6 +189,7 @@ from __future__ import annotations import argparse +import os import re import subprocess import sys @@ -216,22 +217,36 @@ COUNT_LINE_DOC = "Agent.md" RESOLVER = "uv run --no-sync python3 scripts/check-doc-count.py --resolve-conflict" +# The date carried by every synthetic merge commit - the same constant and the +# same reasoning as check-merge-plan-suite.py, so the two folds in this family +# cannot drift apart. Pinned rather than read from the clock: a commit's sha +# contains its committer date, so an unpinned fold is not a function of its +# inputs, and a caller comparing two runs of the same plan would be comparing two +# different shas for the same tree. +PLAN_COMMIT_DATE = "2000-01-01T00:00:00 +0000" + class MeasurementError(Exception): """The question could not be answered. Never a verdict.""" -def _run(argv: list[str], cwd: str | None = None) -> subprocess.CompletedProcess[str]: +def _run( + argv: list[str], cwd: str | None = None, env: dict[str, str] | None = None +) -> subprocess.CompletedProcess[str]: """Run a command with the decoding pinned. `encoding`/`errors` are pinned for the reason recorded in `check-doc-count.py`: a locale mismatch leaves `stdout` as `None` after the reader thread swallows the decode error, and the `None` surfaces later as a bare `TypeError` past every handler. + + `env` is only passed by the caller that creates the synthetic merge commits + (`_merge_commit`), for the reason recorded there. """ return subprocess.run( argv, cwd=cwd, + env=env, capture_output=True, text=True, encoding="utf-8", @@ -514,6 +529,35 @@ def _fetch_head(number: int) -> str: return _rev_parse(ref) +def _commit_env() -> dict[str, str]: + """Author/committer for the synthetic merge commits, independent of git config. + + Measured defect (cyc20260913-200715): with no ambient identity and + `user.useConfigOnly = true` (a real setting, and the default in hardened + environments), `git commit-tree` refuses - "Author identity unknown ... no + email was given and auto-detection is disabled" - so this tool raised + `MeasurementError` and reported that the *guard question could not be + answered*, in an environment where it can be answered. Its sibling + `check-merge-plan-suite.py` answered the same environment correctly, because it + pins identity; the tool that folded a plan was the more robust of the two. + + The date is pinned for the same reason as there: a commit's sha contains its + committer date, so an unpinned fold is not a function of its inputs. Here the + shas are only vehicles for the next step's merge (never printed, never compared + across runs), so the date half closes a latent trap rather than a measured + failure - the identity half is the measured one. + """ + return { + **os.environ, + "GIT_AUTHOR_NAME": "emrg-merge-sequence", + "GIT_AUTHOR_EMAIL": "merge-sequence@emrg.invalid", + "GIT_COMMITTER_NAME": "emrg-merge-sequence", + "GIT_COMMITTER_EMAIL": "merge-sequence@emrg.invalid", + "GIT_AUTHOR_DATE": PLAN_COMMIT_DATE, + "GIT_COMMITTER_DATE": PLAN_COMMIT_DATE, + } + + def _merge_commit(a: str, b: str) -> str | None: """Materialise the merge of commits `a` and `b` as a commit, or None if it conflicts. @@ -526,7 +570,8 @@ def _merge_commit(a: str, b: str) -> str | None: A non-zero/one exit is a measurement failure, never a conflict: reporting "conflict" for a git error would turn an unanswered question into a - reassuring one. + reassuring one. The identity/date of that synthetic commit are pinned by + `_commit_env`, so the fold does not depend on the machine's git config. """ proc = _run(["git", "merge-tree", "--write-tree", a, b]) if proc.returncode == 1: @@ -537,7 +582,8 @@ def _merge_commit(a: str, b: str) -> str | None: ) tree = proc.stdout.strip().splitlines()[0].strip() commit = _run( - ["git", "commit-tree", tree, "-p", a, "-p", b, "-m", f"merge {b[:8]} into {a[:8]}"] + ["git", "commit-tree", tree, "-p", a, "-p", b, "-m", f"merge {b[:8]} into {a[:8]}"], + env=_commit_env(), ) if commit.returncode != 0: raise MeasurementError(f"commit-tree failed: {commit.stderr.strip()}") diff --git a/tests/test_check_merge_sequence.py b/tests/test_check_merge_sequence.py index 672c2334..d0f6a61c 100644 --- a/tests/test_check_merge_sequence.py +++ b/tests/test_check_merge_sequence.py @@ -1087,3 +1087,68 @@ def test_the_base_question_does_not_read_an_unknown_report_as_no_count( ) monkeypatch.chdir(root) assert mod._base_states_a_count("HEAD") is None + + +def test_the_fold_does_not_need_an_ambient_git_identity(mod, tmp_path, monkeypatch): + """The fold must not depend on the machine's git config (measured defect). + + Measured (cyc20260913-200715): with no ambient identity and + `user.useConfigOnly = true`, `git commit-tree` refuses - "Author identity + unknown ... no email was given and auto-detection is disabled" - so + `_merge_commit` raised `MeasurementError` and this tool reported that the guard + question could not be answered, in an environment where it can be answered. + Its sibling `check-merge-plan-suite.py` answered that same environment fine, + because it pins identity: the tool that folds a *plan* was the more robust of + the two, which is backwards. + + Both halves are set up here on purpose, because either alone hides the defect: + `user.useConfigOnly` forbids the hostname fallback, and the global/system + configs are emptied so an identity configured on the machine running the tests + cannot answer in the tool's place (that is how a test of this could pass on a + developer's laptop and fail in a container). + """ + repo = tmp_path / "repo" + repo.mkdir() + identity = { + "GIT_AUTHOR_NAME": "fixture", "GIT_AUTHOR_EMAIL": "fixture@emrg.invalid", + "GIT_COMMITTER_NAME": "fixture", "GIT_COMMITTER_EMAIL": "fixture@emrg.invalid", + } + env = {**os.environ, **identity} + + def git(*argv: str) -> str: + proc = subprocess.run( + ["git", *argv], cwd=repo, env=env, check=True, + capture_output=True, text=True, encoding="utf-8", errors="replace", + ) + return proc.stdout.strip() + + git("init", "-q", "-b", "master", ".") + (repo / "base.txt").write_text("base\n", encoding="utf-8") + git("add", "-A") + git("commit", "-q", "-m", "base") + base = git("rev-parse", "HEAD") + git("checkout", "-q", "-b", "side", "master") + (repo / "side.txt").write_text("side\n", encoding="utf-8") + git("add", "-A") + git("commit", "-q", "-m", "side") + head = git("rev-parse", "HEAD") + git("checkout", "-q", "master") + + # The hostile environment the tool has to survive: this repo refuses an + # inferred identity, and no config file can supply one. + git("config", "user.useConfigOnly", "true") + monkeypatch.setenv("GIT_CONFIG_GLOBAL", os.devnull) + monkeypatch.setenv("GIT_CONFIG_SYSTEM", os.devnull) + monkeypatch.setenv("HOME", str(tmp_path / "home")) + monkeypatch.setenv("USERPROFILE", str(tmp_path / "home")) + for name in identity: + monkeypatch.delenv(name, raising=False) + monkeypatch.chdir(repo) + + commit = mod._merge_commit(base, head) + assert commit, ( + "the fold must not depend on the machine's git config - an unpinned " + "commit-tree turns a false 'could not measure' into the finding" + ) + # …and it is a function of its inputs, so a fold does not vary with the clock. + assert mod._merge_commit(base, head) == commit