Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 49 additions & 3 deletions scripts/check-merge-sequence.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@
from __future__ import annotations

import argparse
import os
import re
import subprocess
import sys
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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.

Expand All @@ -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:
Expand All @@ -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()}")
Expand Down
65 changes: 65 additions & 0 deletions tests/test_check_merge_sequence.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading