From 1ab1b46db108a4df5e1f63b459e06b0020ea7868 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Fri, 30 Oct 2026 01:26:41 +0800 Subject: [PATCH] emrg: a probe for an index that restores an earlier commit's tree --- Agent.md | 16 +- scripts/check-staged-inverse.py | 290 ++++++++++++++++++ ...est_a_tree_reading_guard_names_its_tree.py | 93 +++--- tests/test_check_staged_inverse.py | 252 +++++++++++++++ tests/test_guard_report.py | 1 + 5 files changed, 609 insertions(+), 43 deletions(-) create mode 100644 scripts/check-staged-inverse.py create mode 100644 tests/test_check_staged_inverse.py diff --git a/Agent.md b/Agent.md index cf0bd0bf..6bb129e9 100644 --- a/Agent.md +++ b/Agent.md @@ -1,6 +1,6 @@ # EMRG — Agent.md -> **Principles and conventions only** — what to obey and how to decide. History belongs in the scripts' docstrings and `--help`, in commit messages and in the evolution records, not here. +> **Principles and conventions only** — what to obey and how to decide. History belongs in the scripts' docstrings and `--help`, in commit messages and evolution records, not here. ## Project Overview @@ -8,7 +8,7 @@ EMRG is a self-evolving AI agent architecture experiment: a **micro-kernel** — ## Architecture -**The server is the living core; a client is only the interface.** Task understanding, tool scheduling, capability growth and self-evolution live in the server; one server serves many clients and outlives any of them. +**The server is the living core; a client is only the interface.** Task understanding, tool scheduling, capability growth and self-evolution live there; one server serves many clients and outlives any of them. - `emrg/` — core: `__main__.py` (CLI), `protocol.py`, `config.py`, `connect.py` (WebSocket over loopback, token auth) - `emrg/server/` — the daemon: `daemon.py` (messages, the background thread running evolution, the tool loop), `llm.py`, `tool_types.py`, task templates @@ -25,14 +25,14 @@ EMRG is a self-evolving AI agent architecture experiment: a **micro-kernel** — - A client auto-detects the server and starts it if absent - `README.md` and `Agent.md` are English; `README.cn.md` is the Chinese README - Commits: `emrg: `, English, one intent per commit -- A derived number is never written where a guard can measure it; a rule that can be mechanised is mechanised, because prose decays +- A derived number is never written where a guard can measure it; a mechanisable rule is mechanised, because prose decays - Prefer inspectable state: everything the agent knows is a file ## Terminology The execution model, in this repo's own terms (`emrg/server/daemon.py`): -- **Tool loop** — the whole process triggered by one user message: LLM requests and tool calls repeat until a round produces no new tool calls (`_run_tool_loop`). +- **Tool loop** — the whole process triggered by one user message: LLM requests and tool calls repeat until a round brings no new tool calls (`_run_tool_loop`). - **Round** — one iteration inside a tool loop: one LLM request + zero or more tool calls + their execution (`round_num`, bounded by `max_tool_rounds`). - **Evolution cycle** — one full self-evolution run (Preparation → Look → Think → Act → Record). Never call it a "round". @@ -43,8 +43,8 @@ What EMRG does today — capability statements, not a changelog. - **TUI client** — markdown/diffs, slash commands, autocomplete, pickers, clipboard images (`vision` per model), ESC interrupt, CJK-aware input - **Electron GUI** — streaming chat, tool-status cards, sessions, models, settings, onboarding, reconnect with daemon respawn; React + TypeScript; vitest - **Scheduled tasks** — `TaskHandler` for any registered project: builtin and custom types, CRUD, hot reload, `when` early wake -- **Rant-driven evolution** — host feedback through `/rant` drives the self-improvement cycles -- **Memory** — project and session memory (YAML frontmatter + a Markdown index), compact/clear, a capped prompt footprint +- **Rant-driven evolution** — host feedback through `/rant` drives the cycles +- **Memory** — project and session memory (YAML frontmatter + a Markdown index), compact/clear, capped prompt footprint - **Skills** — `.emrg/skills/` with progressive disclosure + an installable-skill catalog - **Operational** — config hot-reload, project tracking, headless GitHub auth - **Inspectable by design** — state, memory and logs are files; git is the state store @@ -59,13 +59,13 @@ Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (754: 5 snapsh - CI: pytest on ubuntu **and windows-2025**, the GUI suites, and an actionlint gate over `.github/workflows/`; re-trigger a dropped push with `scripts/re-trigger-ci.sh `, never an empty commit - Merge gates, run before merging — each asks what no other gate asks: `uv run --no-sync python3 scripts/check-vote-count.py` (approvals still valid) · `scripts/check-pr-base.py` (base reaches master) · `scripts/check-merge-freshness.py` (that green CI is about the landing tree) · `scripts/check-merge-order.py` (dirt and what it lands) · `scripts/check-merge-landing-diff.py` (what the landing changes) · `scripts/check-merge-pairs.py` / `scripts/check-merge-sequence.py` (a pair, or a whole order) · `scripts/check-merge-tree-health.py` (the merged tree passes one guard) · `scripts/check-merge-plan-suite.py` (the plan's final tree passes the suite) — all under `uv run --no-sync python3` - Post-merge audit: `uv run --no-sync python3 scripts/check-merge-landed.py ` (did the merge land a tree its reviews named) -- Other tools: `uv run --no-sync python3 scripts/check-node-test-count.py --write` (the Node totals), `scripts/check-memory-index.py` (a MEMORY.md against the index rule), `scripts/check-patch-files.py` (a rebuilt patch keeps its predecessor's files), `scripts/check-memory-rotation.py` (whose memory moved), `scripts/cast-vote.py` (a counted vote), `scripts/feishu-gate.py` (the third-party send's gate), `bump-version.py`, `classify-conflict.py`, `sync-master-from-api.py`, `push-branch-from-api.py` +- Other tools: `uv run --no-sync python3 scripts/check-node-test-count.py --write` (the Node totals), `scripts/check-memory-index.py` (a MEMORY.md vs the index rule), `scripts/check-patch-files.py` (a rebuilt patch keeps its files), `scripts/check-staged-inverse.py` (an index holding an earlier commit's tree), `scripts/check-memory-rotation.py` (whose memory moved), `scripts/cast-vote.py` (a counted vote), `scripts/feishu-gate.py` (the third-party send gate), `bump-version.py`, `classify-conflict.py`, `sync-master-from-api.py`, `push-branch-from-api.py` Every guard that reads a working tree names it before its verdict; a question it cannot answer is reported unmeasurable, never as a pass. ## Releasing -1. **Bump** — `python3 scripts/bump-version.py ` rewrites every version declaration at once; `tests/test_version_sync.py` proves they agree. PR it, take three approvals from different cycles, squash merge — never self-merge a release PR. +1. **Bump** — `python3 scripts/bump-version.py ` rewrites every version declaration; `tests/test_version_sync.py` proves they agree. PR it, take three approvals from different cycles, squash merge — never self-merge a release PR. 2. **Tag** — `git tag -a v -m "emrg v" && git push origin v`. The tag is the **only** trigger of `build-release.yml`; Test never signs or notarizes. 3. **Verify and confirm** — `scripts/check-release-published.py v`: green across the platform matrix, published as Latest (not draft, not prerelease) with the full asset set. diff --git a/scripts/check-staged-inverse.py b/scripts/check-staged-inverse.py new file mode 100644 index 00000000..555e4d64 --- /dev/null +++ b/scripts/check-staged-inverse.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +"""Does the staged index restore the content of a commit this history already holds? + +Usage +----- + uv run --no-sync python3 scripts/check-staged-inverse.py + uv run --no-sync python3 scripts/check-staged-inverse.py --repo + +The class this exists for +------------------------- +`git status` reports a staged path as a staged *modification*. It never reports +the modification's **direction**, and the direction is the whole danger: an index +left holding the content of an older commit is the inverse of every landing that +came after it, so `git commit` in that state silently undoes work that has +already been reviewed, voted on and merged — while `git status` shows a tidy list +of staged files and says nothing against it. + +Measured on this host, 2026-10-30 (`cyc20261030-002325`): + + index tree 783fba29937fd049864f75db4e8c341b74ff8083 + b0a6f7f^{tree} 783fba29937fd049864f75db4e8c341b74ff8083 + HEAD 79eb0211 tree 5fc083ac + staged 11 paths, 9 insertions(+), 115 deletions(-) + +— the exact inverse of three commits that had landed hours earlier. Nothing in +this tree read the index at all: `scripts/` carries a guard per merge gate, and +not one of them looks at what is staged. Every occurrence so far was caught +because a cycle happened to compare two trees by hand. + +What the question reduces to +---------------------------- +`git write-tree` names the index's tree. A commit's tree is stored as a tree +object, so "would this index restore an earlier commit's content?" is exactly +"is the index's tree equal to the tree of a commit this repository holds?" — a +question about trees, answerable from the objects alone, with no working-tree +inspection and no diffing. + +The naive form of this probe answers "clean" on a repository it could not read +------------------------------------------------------------------------------ +The obvious implementation, `git write-tree` intersected with +`git log --all --format=%T`, is a false negative wherever a ref's history has a +missing object, and this repository was such a place: + + $ git log --all --format=%T > /dev/null ; echo $? + 128 + $ git log --all --format=%T | wc -l + 152 + +`git log --all` walks every ref, and one unhappy ref makes it exit 128 **after** +it has already printed whatever it reached. A probe that reads stdout without +reading the status takes a truncated history for the whole one and reports no +inverse. That is why the standard here is not "answer" but "answer or refuse": +a silence this tool did not measure is the one output it may not produce. + +So the whole-history walk is tried first, as one process. If it fails, its +stdout is **discarded** rather than trusted, and each ref is walked on its own: +the refs that answer are measured, and the ones that do not are named in the +refusal. The two refs that were broken here — +`refs/heads/80789e97` and `refs/remotes/origin/80789e97`, two of 1028 — are what +that fallback exists for. (Their missing parent was fetched through the API and +the one-shot walk now answers in 0.25 s, which is why the fallback costs 87 s +here only on a host that really is broken.) + +What it prints, and what it does not +------------------------------------ +The tree it read, first, before any verdict — every guard here names its subject. +Then one of four readings: + +* the index is `HEAD`'s tree → nothing is staged, exit 0; +* no commit's tree matches → the index holds content of its own, exit 0; +* a commit's tree matches → **INVERSE**, naming that commit and the staged + paths, exit 1; +* a ref could not be walked and no match was found → **could not measure**, + naming the refs, exit 2 — never the words "no inverse". + +Exit codes: `0` clean, `1` inverse found, `2` could not measure. A match found +through the refs that did answer is still reported as a match; the unreadable +refs are then named as a caveat rather than used to withhold the finding. +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + +CLEAN = 0 +INVERSE = 1 +CANNOT_MEASURE = 2 + + +def _git(repo: str | Path, *args: str) -> subprocess.CompletedProcess[str]: + """One git command in `repo`, never raising: the caller reads `returncode`.""" + return subprocess.run( + ["git", *args], + cwd=str(repo), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + + +def _pairs(text: str) -> dict[str, str]: + """` ` lines → {sha: tree}, ignoring anything not in that shape.""" + pairs: dict[str, str] = {} + for line in text.splitlines(): + parts = line.split() + if len(parts) == 2 and len(parts[0]) == 40 and len(parts[1]) == 40: + pairs[parts[0]] = parts[1] + return pairs + + +def refs_of(repo: str | Path) -> list[str]: + """Every ref name, or an empty list when the repository has none.""" + proc = _git(repo, "for-each-ref", "--format=%(refname)") + if proc.returncode != 0: + return [] + return [line.strip() for line in proc.stdout.splitlines() if line.strip()] + + +def commit_trees(repo: str | Path) -> tuple[dict[str, str], list[str], int]: + """{commit: tree} reachable from any ref, the refs that would not walk, and refs read. + + The one-process walk is used when it succeeds — it is the cheap reading + (`0.25 s` measured over 3372 commits here). When it fails its stdout is + discarded, because a failed `git log --all` has already printed the part of + the history it reached and that output looks exactly like a whole one. The + fallback walks each ref separately so that the unreadable ones can be named + instead of the question being abandoned. + """ + proc = _git(repo, "log", "--all", "--format=%H %T") + if proc.returncode == 0: + pairs = _pairs(proc.stdout) + return pairs, [], len(refs_of(repo)) + + pairs = {} + broken: list[str] = [] + every = refs_of(repo) + for ref in every: + one = _git(repo, "log", ref, "--format=%H %T") + if one.returncode != 0: + broken.append(ref) + continue + pairs.update(_pairs(one.stdout)) + return pairs, broken, len(every) - len(broken) + + +def staged_paths(repo: str | Path) -> list[str]: + """The paths the index differs from HEAD in, as `git diff --cached` spells them.""" + proc = _git(repo, "diff", "--cached", "--name-status") + if proc.returncode != 0: + return [] + return [line.rstrip() for line in proc.stdout.splitlines() if line.strip()] + + +def subjects(repo: str | Path, shas: list[str]) -> dict[str, str]: + """`{full sha: " "}`, so a finding names a commit a reader can recognise. + + Keyed by the full sha because that is what `commit_trees` hands back: keying by + the abbreviated form would make every lookup miss and print the bare sha, which + is the one output that looks like the subject never arrived. + """ + if not shas: + return {} + proc = _git(repo, "log", "--no-walk", "--format=%H %s", *shas) + if proc.returncode != 0: + return {} + found = {} + for line in proc.stdout.splitlines(): + full, _, subject = line.partition(" ") + if full: + found[full] = f"{full[:12]} {subject}".strip() + return found + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description=( + "Check whether the staged index holds the tree of a commit already in " + "this history - the shape that makes a commit undo landed work" + ) + ) + parser.add_argument( + "--repo", + default=None, + help="the working tree to read (default: the directory this shell stands in)", + ) + args = parser.parse_args(argv) + + # This tool prints a `tree:` line and then may write to stderr: unbuffered, a + # piped reader sees the refusal before the subject it is about. + sys.stdout.reconfigure(line_buffering=True) + + start = Path(args.repo).resolve() if args.repo else Path.cwd() + if not start.is_dir(): + print( + f"could not measure: {start} is not a directory, so there is no working tree " + "to read an index from", + file=sys.stderr, + ) + return CANNOT_MEASURE + top_proc = _git(start, "rev-parse", "--show-toplevel") + if top_proc.returncode != 0: + detail = (top_proc.stderr or top_proc.stdout).strip().splitlines() + print( + f"could not measure: {start} is not a git working tree" + + (f" ({detail[0]})" if detail else ""), + file=sys.stderr, + ) + return CANNOT_MEASURE + top = Path(top_proc.stdout.strip()) + + # Before anything else: which tree answered. + print(f"tree: {top}") + + tree_proc = _git(top, "write-tree") + if tree_proc.returncode != 0: + detail = (tree_proc.stderr or "").strip().splitlines() + print( + "could not measure: `git write-tree` failed, so the index's tree is unknown" + + (f" ({detail[0]})" if detail else ""), + file=sys.stderr, + ) + return CANNOT_MEASURE + index_tree = tree_proc.stdout.strip() + + head_proc = _git(top, "rev-parse", "HEAD^{tree}") + head_tree = head_proc.stdout.strip() if head_proc.returncode == 0 else None + + if head_tree is not None and index_tree == head_tree: + print( + f"index tree {index_tree[:12]} is HEAD's tree - nothing is staged " + "(a working tree that differs from HEAD without being staged is not this check's subject)" + ) + return CLEAN + + pairs, broken, refs_read = commit_trees(top) + matches = sorted(sha for sha, tree in pairs.items() if tree == index_tree) + + if matches: + named = subjects(top, matches) + paths = staged_paths(top) + print("INVERSE: the index holds the tree of a commit in this history") + for sha in matches: + print(f" {named.get(sha, sha)}") + print(f" index tree {index_tree}") + if paths: + print(f"staged paths ({len(paths)}):") + for line in paths: + print(f" {line}") + print( + "Committing this index would restore that content, undoing every landing " + "between it and HEAD. If that is not intended:" + ) + print(" git reset # unstages it; the working tree keeps the content") + print(" git reset --hard HEAD # unstages it and discards the working tree as well") + if broken: + print( + f"note: {len(broken)} ref(s) could not be walked, so a twin reachable only " + f"through them would not be listed: {', '.join(broken)}", + file=sys.stderr, + ) + return INVERSE + + if broken: + for ref in broken: + print( + f"could not measure: {ref} could not be walked - its history names an " + "object this clone does not have", + file=sys.stderr, + ) + print( + f"could not measure: {len(broken)} of {len(broken) + refs_read} ref(s) could not be " + "walked, so a twin reachable only through them would go unseen; " + "'no inverse' would be a claim about a history this reading never saw", + file=sys.stderr, + ) + return CANNOT_MEASURE + + print( + f"index tree {index_tree[:12]} matches no commit's tree among {len(pairs)} " + f"commit(s) reachable from {refs_read} ref(s) - the index holds content of its own" + ) + return CLEAN + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_a_tree_reading_guard_names_its_tree.py b/tests/test_a_tree_reading_guard_names_its_tree.py index d0f73d97..86369db3 100644 --- a/tests/test_a_tree_reading_guard_names_its_tree.py +++ b/tests/test_a_tree_reading_guard_names_its_tree.py @@ -135,14 +135,22 @@ def _carries(subjects: tuple[str, ...]) -> bool: """Whether this checkout holds any of these subject paths.""" return any(next(REPO_ROOT.glob(pattern), None) is not None for pattern in subjects) -#: The one guard run here that names a *given* tree rather than its own checkout, so it -#: is asked a different question below: its line must follow its argument. -TAKES_A_TREE_ARGUMENT = "check-citation-resolves.py" +#: The guards whose tree is the caller's to choose rather than this checkout, keyed by the +#: argument that points each at one (`()` = the tree is the positional subject). Their line +#: must follow that argument, and the two legs below ask them that instead of the +#: own-checkout question `RUN_HERE` members are asked. The bucket is decided by the +#: **default**, not by convenience: a guard whose default root is the tree the caller stands +#: in cannot be in `RUN_HERE`, because the leg there requires it to name this checkout +#: whatever the cwd — which for such a guard would be a line that follows nothing. +TAKES_A_TREE_ARGUMENT = { + "check-citation-resolves.py": (), + "check-staged-inverse.py": ("--repo",), +} #: The guards that can be pointed at a tree of the caller's choosing, and the argument #: that points each at one — the shape the empty-tree leg below needs and cannot derive, #: because "accepts a root" is not visible in the source the way a `tree: ` line is. -#: `TAKES_A_TREE_ARGUMENT` is here as the member whose tree *is* its argument; +#: `TAKES_A_TREE_ARGUMENT`'s members are here as the guards whose tree *is* their argument; #: `check_unbound_reads.py` takes the same kind of input behind an optional flag, which #: is why it is also in `RUN_HERE` (with no flag it names its own checkout); #: `check-workflows.py` takes one behind `--root` and is classified in the @@ -163,6 +171,7 @@ def _carries(subjects: tuple[str, ...]) -> bool: #: `--root` and was not listed, so nothing pointed it at a tree. POINTABLE_AT_A_TREE = { "check-citation-resolves.py": (), + "check-staged-inverse.py": ("--repo",), "check-retry-bounds.py": ("--root",), "check_unbound_reads.py": ("--root",), "check-undefined-names.py": ("--root",), @@ -393,7 +402,7 @@ def _classified() -> set[str]: """ return ( set(RUN_HERE) - | {TAKES_A_TREE_ARGUMENT} + | set(TAKES_A_TREE_ARGUMENT) | set(NAMES_ITS_TREE_BUT_IS_NOT_RUN_HERE) | set(NOT_TREE_READERS) ) @@ -409,7 +418,7 @@ def _tree_reading_buckets() -> set[str]: disk is classified"), which is a different one (reviewed 2026-10-06, `cyc20261006-230355`). """ - return set(RUN_HERE) | {TAKES_A_TREE_ARGUMENT} | set(NAMES_ITS_TREE_BUT_IS_NOT_RUN_HERE) + return set(RUN_HERE) | set(TAKES_A_TREE_ARGUMENT) | set(NAMES_ITS_TREE_BUT_IS_NOT_RUN_HERE) def _first_line(proc: subprocess.CompletedProcess) -> str: @@ -610,33 +619,43 @@ def test_a_tree_reading_guard_names_its_own_checkout_whatever_the_cwd(tmp_path, ) +def _git_init(path: Path) -> None: + """A working tree with no commit: enough for a guard to name it, and nothing to invert.""" + subprocess.run(["git", "init", "-q", str(path)], check=True, capture_output=True) + + def test_the_guard_that_takes_a_tree_names_the_tree_it_was_given(tmp_path) -> None: - """The citation guard's line follows its **argument** — the leg a constant dies on. + """A guard whose tree is its argument names **that** tree — the leg a constant dies on. - This guard is the one whose tree is chosen by its caller, so its subject is not this - repository: pointed at a directory a test built, it must say so. A line hardcoded to - the repository root would pass every other assertion in this file and fail here. + These guards' subject is chosen by their caller, so it is not this repository: pointed + at a tree a test built, each must say so. A line hardcoded to the repository root would + pass every other assertion in this file and fail here. + + The subject is a real working tree, because a guard answering about an index needs one + to answer about at all; it holds no commit, so neither guard can report a finding about + it — the subject of this leg is the line, not the verdict. """ - script = SCRIPTS_DIR / TAKES_A_TREE_ARGUMENT subject = tmp_path / "some-tree" subject.mkdir() + _git_init(subject) - proc = _run([str(script), str(subject)], cwd=REPO_ROOT) + for name, flag in TAKES_A_TREE_ARGUMENT.items(): + proc = _run([str(SCRIPTS_DIR / name), *flag, str(subject)], cwd=REPO_ROOT) - named = _named_root(proc) - assert named, ( - f"{TAKES_A_TREE_ARGUMENT}: no `tree: ` line in its report " - f"(rc={proc.returncode}, stdout={proc.stdout[:200]!r})" - ) - assert Path(named).resolve() == subject.resolve(), ( - f"{TAKES_A_TREE_ARGUMENT}: it was given {subject} and named {named} — the line " - "must name the tree that answered, or it is a constant wearing the " - "convention's clothes" - ) - assert Path(named).resolve() != REPO_ROOT, ( - "the control is only discriminating while the tree it was given is not this " - "repository — otherwise both halves of this file's control name the same path" - ) + named = _named_root(proc) + assert named, ( + f"{name}: no `tree: ` line in its report " + f"(rc={proc.returncode}, stdout={proc.stdout[:200]!r})" + ) + assert Path(named).resolve() == subject.resolve(), ( + f"{name}: it was given {subject} and named {named} — the line " + "must name the tree that answered, or it is a constant wearing the " + "convention's clothes" + ) + assert Path(named).resolve() != REPO_ROOT, ( + "the control is only discriminating while the tree it was given is not this " + "repository — otherwise both halves of this file's control name the same path" + ) def _refusal(proc: subprocess.CompletedProcess) -> str: @@ -687,21 +706,25 @@ def test_a_guard_pointed_at_a_tree_it_read_nothing_from_refuses_to_pass(tmp_path ) -def test_the_citation_guards_line_is_not_merely_the_default(tmp_path) -> None: - """Run with no argument, that guard names the tree it defaults to (`.`), not this one. +def test_a_guard_that_takes_a_tree_defaults_to_the_tree_it_stands_in(tmp_path) -> None: + """Run with no argument, each names the tree it defaults to (`.`), not this one. The default is what a cycle gets when it runs the guard by hand from a shell, so the line has to be right there too — and it is the run that shows the guard's subject is "the tree you are standing in" rather than "the repository the file lives in". """ - script = SCRIPTS_DIR / TAKES_A_TREE_ARGUMENT - proc = _run([str(script)], cwd=tmp_path) + subject = tmp_path / "standing-in" + subject.mkdir() + _git_init(subject) - named = _named_root(proc) - assert Path(named).resolve() == tmp_path.resolve(), ( - f"{TAKES_A_TREE_ARGUMENT}: run from {tmp_path} with no argument it named " - f"{named!r} — its default root is `.`, and that is the tree it read" - ) + for name in TAKES_A_TREE_ARGUMENT: + proc = _run([str(SCRIPTS_DIR / name)], cwd=subject) + + named = _named_root(proc) + assert Path(named).resolve() == subject.resolve(), ( + f"{name}: run from {subject} with no argument it named " + f"{named!r} — its default root is `.`, and that is the tree it read" + ) def test_the_refusal_reader_takes_only_a_line_that_starts_with_the_spelling() -> None: diff --git a/tests/test_check_staged_inverse.py b/tests/test_check_staged_inverse.py new file mode 100644 index 00000000..fd857b68 --- /dev/null +++ b/tests/test_check_staged_inverse.py @@ -0,0 +1,252 @@ +"""Tests for scripts/check-staged-inverse.py - an index holding an earlier commit's tree. + +The class the check exists for: `git status` reports a staged path as a staged +*modification* and never its direction, so an index left holding the content of an +older commit reads as a tidy list of staged files while a commit made from it +undoes everything that landed since. Measured on this host 2026-10-30: an index +whose `git write-tree` was `783fba29` - byte for byte the tree of `b0a6f7f`, a +commit already on master - against a HEAD of `79eb021` (`5fc083ac`), 11 paths and +115 deletions staged. + +Both directions are pinned here, because a guard that answers `0` for the reason +it was written to catch is worse than no guard: + +* an index holding an ancestor's tree is an **inverse** (`1`); +* an index holding content of its own - a staged file no commit has ever had - is + **not** (`0`), and this is the arm that fails if the check ever degrades into + "the index differs from HEAD", which would fire on every honest staging; +* an index that only *looks* clean because a ref could not be walked is + **could not measure** (`2`), never `0`. + +The last of those is the arm the whole design turns on. `git log --all` walks +every ref and exits `128` **after** printing whatever it reached, so a version +that read its stdout without reading its status would take a truncated history +for the whole one and report no inverse. Two refs of this checkout's 1028 were +in exactly that state, and `git log --all` answered 128 over 152 printed lines. +`test_a_failed_walk_does_not_contribute_what_it_printed` pins the discard, and +`test_a_broken_ref_with_no_match_refuses_to_answer` pins the refusal - the words +"no inverse" must not appear in it. +""" + +from __future__ import annotations + +import importlib.util +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent +SCRIPT = REPO_ROOT / "scripts" / "check-staged-inverse.py" + + +def _load(): + spec = importlib.util.spec_from_file_location("check_staged_inverse", SCRIPT) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +@pytest.fixture(scope="module") +def mod(): + return _load() + + +def _git(cwd: Path, *args: str) -> str: + proc = subprocess.run( + ["git", *args], cwd=str(cwd), capture_output=True, text=True, encoding="utf-8" + ) + assert proc.returncode == 0, f"git {' '.join(args)} failed: {proc.stderr}" + return proc.stdout.strip() + + +def _init_repo(path: Path) -> None: + path.mkdir(parents=True, exist_ok=True) + _git(path, "init", "-q", "-b", "master") + _git(path, "config", "user.email", "t@example.com") + _git(path, "config", "user.name", "t") + _git(path, "config", "commit.gpgsign", "false") + + +def _commit(repo: Path, relpath: str, body: str, message: str) -> str: + target = repo / relpath + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(body, encoding="utf-8") + _git(repo, "add", "-A") + _git(repo, "commit", "-q", "-m", message) + return _git(repo, "rev-parse", "HEAD") + + +def _two_commits(tmp_path: Path) -> tuple[Path, str, str]: + """A repo at its second commit, plus the first commit's sha and tree. + + The first commit's tree is the shape an index falls back to, so a test that + needs an inverse stages it with `git read-tree ` - which writes the + index only and leaves the working tree alone. + """ + repo = tmp_path / "repo" + _init_repo(repo) + first = _commit(repo, "a.txt", "one\n", "the first landing") + _commit(repo, "a.txt", "two\n", "the second landing") + return repo, first, _git(repo, "rev-parse", f"{first}^{{tree}}") + + +def _break_a_ref(repo: Path, name: str = "broken") -> str: + """A ref whose object is not present, the state that makes `git log --all` exit 128. + + Written as a ref file directly: `git update-ref` refuses an object it cannot + find, so the only way to reproduce this shape is to place the file, which is + also how the two broken refs in this checkout came to exist. + """ + ref = repo / ".git" / "refs" / "heads" / name + ref.parent.mkdir(parents=True, exist_ok=True) + ref.write_text("0123456789012345678901234567890123456789\n", encoding="ascii") + return f"refs/heads/{name}" + + +class TestTheTwoDirections: + def test_a_clean_index_is_clean(self, mod, tmp_path, capsys) -> None: + repo, _first, _tree = _two_commits(tmp_path) + code = mod.main(["--repo", str(repo)]) + out = capsys.readouterr().out + assert code == 0, out + assert "nothing is staged" in out + + def test_an_index_holding_an_ancestors_tree_is_an_inverse( + self, mod, tmp_path, capsys + ) -> None: + repo, first, tree = _two_commits(tmp_path) + _git(repo, "read-tree", first) + assert _git(repo, "write-tree") == tree, "the fixture did not stage the ancestor" + + code = mod.main(["--repo", str(repo)]) + out = capsys.readouterr().out + + assert code == 1, out + assert "INVERSE" in out + # The finding has to name a commit a reader recognises: the subject, not + # only the sha, is what makes the line actionable. + assert "the first landing" in out + assert tree in out + # And the paths it would undo, so the reader can see the size of it. + assert "a.txt" in out + + def test_an_index_holding_its_own_content_is_not_an_inverse( + self, mod, tmp_path, capsys + ) -> None: + """The arm that keeps the check from firing on every honest staging.""" + repo, _first, _tree = _two_commits(tmp_path) + (repo / "new.txt").write_text("a file no commit has had\n", encoding="utf-8") + _git(repo, "add", "new.txt") + assert _git(repo, "write-tree") != _git(repo, "rev-parse", "HEAD^{tree}") + + code = mod.main(["--repo", str(repo)]) + out = capsys.readouterr().out + + assert code == 0, out + assert "matches no commit's tree" in out + assert "INVERSE" not in out + + +class TestTheRefusal: + """An unmeasured silence is the one output the check may not produce.""" + + def test_a_broken_ref_with_no_match_refuses_to_answer( + self, mod, tmp_path, capsys + ) -> None: + repo, _first, _tree = _two_commits(tmp_path) + broken = _break_a_ref(repo) + (repo / "new.txt").write_text("of its own\n", encoding="utf-8") + _git(repo, "add", "new.txt") + + code = mod.main(["--repo", str(repo)]) + captured = capsys.readouterr() + out, err = captured.out, captured.err + + assert code == 2, out + err + assert "could not measure" in err + assert broken in err + # The exact words that would turn a partial walk into a verdict. + combined = out + err + assert "matches no commit's tree" not in combined + assert "INVERSE" not in combined + + def test_a_match_found_through_the_refs_that_answer_is_still_reported( + self, mod, tmp_path, capsys + ) -> None: + """A broken ref is a caveat on a finding, not a reason to withhold it.""" + repo, first, _tree = _two_commits(tmp_path) + broken = _break_a_ref(repo) + _git(repo, "read-tree", first) + + code = mod.main(["--repo", str(repo)]) + captured = capsys.readouterr() + + assert code == 1, captured.out + captured.err + assert "INVERSE" in captured.out + assert broken in captured.err + + def test_a_failed_walk_does_not_contribute_what_it_printed( + self, mod, tmp_path, monkeypatch, capsys + ) -> None: + """A failed `git log --all` has already printed history, and it looks whole. + + The fake walk fails *and* prints a pair whose tree is the index's own, naming + a commit that does not exist. Trusting that stdout would report an inverse + naming a phantom commit; discarding it leaves the per-ref walk, which finds + no match and answers clean. + """ + repo, _first, _tree = _two_commits(tmp_path) + (repo / "new.txt").write_text("of its own\n", encoding="utf-8") + _git(repo, "add", "new.txt") + index_tree = _git(repo, "write-tree") + phantom = "dead" * 10 + real_git = mod._git + + def fake_git(where, *args): + if args[:2] == ("log", "--all"): + return subprocess.CompletedProcess( + args, 128, stdout=f"{phantom} {index_tree}\n", stderr="fatal: bad object" + ) + return real_git(where, *args) + + monkeypatch.setattr(mod, "_git", fake_git) + code = mod.main(["--repo", str(repo)]) + captured = capsys.readouterr() + + assert code == 0, captured.out + captured.err + assert phantom not in captured.out + assert "INVERSE" not in captured.out + + def test_a_directory_that_is_not_a_working_tree_refuses(self, mod, tmp_path, capsys) -> None: + plain = tmp_path / "plain" + plain.mkdir() + code = mod.main(["--repo", str(plain)]) + err = capsys.readouterr().err + assert code == 2, err + assert "not a git working tree" in err + + +class TestWhatItNamesBeforeTheVerdict: + def test_the_working_tree_is_named_before_anything_else(self, mod, tmp_path, capsys) -> None: + """Every guard here names its subject; a verdict without one is unreadable.""" + repo, _first, _tree = _two_commits(tmp_path) + mod.main(["--repo", str(repo)]) + out = capsys.readouterr().out + assert out.splitlines()[0].startswith("tree: ") + assert "repo" in out.splitlines()[0] + + def test_it_reads_the_named_tree_not_the_directory_the_caller_stands_in( + self, mod, tmp_path, capsys, monkeypatch + ) -> None: + """`--repo` from outside the tree must still measure that tree.""" + repo, first, _tree = _two_commits(tmp_path) + _git(repo, "read-tree", first) + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + monkeypatch.chdir(elsewhere) + + code = mod.main(["--repo", str(repo)]) + out = capsys.readouterr().out + assert code == 1, out + assert str(repo.resolve()) in out.splitlines()[0] diff --git a/tests/test_guard_report.py b/tests/test_guard_report.py index 1681ff5f..4976afaf 100644 --- a/tests/test_guard_report.py +++ b/tests/test_guard_report.py @@ -101,6 +101,7 @@ "check-rant-citations.py", "check-release-prompt.py", "check-retry-bounds.py", + "check-staged-inverse.py", "check-undefined-names.py", "check-workflows.py", "check_nonlocal.py",