Repository navigation
feat(repo): record public_at and guidance:rev history for leak checks #359
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
antonwolfy
wants to merge
9
commits into
intel:main
Choose a base branch
from
antonwolfy:feat/replay-leak-record
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+798
−47
Open
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
c21791a
feat(repo): record public_at and guidance:rev history for leak checks
antonwolfy d35b581
fix(repo): NUL-framed guidance history; count merges and link targets
antonwolfy 1b75473
fix(repo): pin git log config; count every link of a guidance chain
antonwolfy fd27a2b
Merge main into feat/replay-leak-record
antonwolfy f6a9cb9
fix(repo): refuse a shallow guidance history; keep CR and UTC dates
antonwolfy 3c1f93d
fix(replay): leave public_at out when a gold time is unusable
antonwolfy 06f64a6
fix(repo): leave eval_dir paths out of the guidance history
antonwolfy bcb96c1
perf(repo): match each history path once; note the read's cost
antonwolfy 3f2895a
refactor(repo): one isolated git env and one shallow check
antonwolfy File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
16 changes: 16 additions & 0 deletions
16
dokimi/changelog.d/20261101_120000_antonwolfy_replay-leak-record.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| ### Added | ||
|
|
||
| - **Replay tasks and `guidance:rev:` arms record what a leak check needs** — | ||
| `repo tasks replay` writes `public_at` into each `task.json`: the earliest | ||
| `createdAt` of a gold thread's first comment, left out (with a note) when | ||
| a gold thread has no time with an offset. A `guidance:rev:<rev>` cell's | ||
| `meta.json` `arm_rev` adds `guidance_latest` (the last commit to touch each | ||
| guidance file at `<rev>`, or a path a guidance link resolves through), | ||
| `guidance_commits` (the commits that touch a guidance path outside | ||
| `eval_dir` or a path a link | ||
| resolves through, a merge when its own result changes one, newest first by committer | ||
| date, at most 200) and `guidance_commits_truncated`, each commit with its | ||
| author and committer date (a UTC date as `Z`), so a reader can see whether | ||
| a guidance commit came after a task's review was public. A `guidance:rev:` | ||
| cell whose source is a shallow clone fails its preparation: the history | ||
| needs the full one (`git fetch --unshallow`). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,201 @@ | ||
| # Copyright (C) 2026 Intel Corporation | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| """The guidance history of a ``guidance:rev:<rev>`` arm, for its ``meta.json`` ``arm_rev`` (contract §3). | ||
|
|
||
| A replay task's answer is public from its ``public_at`` (§4.6); a guidance commit made after it may | ||
| encode that review. :func:`arm_rev_history` records what a reader needs to check that: the commit | ||
| that last touched each guidance file at the revision, and the newest commits that touch a guidance | ||
| path, each with its author and committer date. The flag reads the committer date: ``git commit | ||
| --amend`` and a rebase keep the author date (https://github.com/intel/dokimi/issues/290). A path | ||
| under the manifest's ``eval_dir`` is not guidance, as the arm installs none of them. | ||
|
|
||
| The history is one ``git log -z --raw`` over the revision, filtered with :func:`~.manifest.match_glob`. | ||
| ``-z`` ends every field with a NUL, and each file the commit touched is a ``:``-led status record | ||
| followed by its one path, so a subject or a path, whatever it holds, cannot pass for the other. | ||
| Git cuts a subject at a NUL (``git commit`` refuses one), so a subject is one field. The output is | ||
| split on NUL as bytes and each field then decoded as UTF-8 (``--encoding=UTF-8``; a byte that is not | ||
| UTF-8 becomes U+FFFD, as :mod:`dokimi.process` decodes), so a ``\\r`` in a subject or a path stays one. | ||
| A date is git's ``%aI`` / ``%cI`` with a UTC offset written ``Z``, which git 2.51 prints and older git | ||
| (2.43) prints as ``+00:00``. The history is read once per git dir, revision, globs, ``eval_dir`` and | ||
| links in a process, also when cells of the arm prepare at once: every cell asks the same. The read | ||
| covers every commit reachable from the revision and every path each one touched, so its cost grows | ||
| with the history: for 42,000 commits, about a second of ``git log`` (some 20-30 MB) and a fraction of | ||
| one to parse it, the globs matched once per distinct path; the other cells of the arm wait. A key of the | ||
| repository's own config that changes what the log prints (as checked with git 2.51) is overridden by | ||
| a flag (:data:`_PINNED`). A shallow git dir is refused: its history stops at the boundary commit, so | ||
| it would name a later commit as the last to touch a file. | ||
|
|
||
| The arm installs a guidance link (``AGENTS.md -> docs/current -> GUIDE.md``) as the bytes of the file | ||
| it resolves to, so a commit that touches any path of its chain, an intermediate link or that file, | ||
| counts for the link's path, whether or not a glob matches that path. | ||
|
|
||
| A merge counts when its own result changes a guidance path against every parent | ||
| (``--diff-merges=dense-combined``): a conflict resolution, an edit made in the merge, or a clean | ||
| merge that joins its parents' edits to different parts of one file. A merge that takes a file as | ||
| one parent has it does not: the commits it merged count. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import re | ||
| import threading | ||
| from collections.abc import Iterator | ||
| from dataclasses import dataclass | ||
| from datetime import datetime | ||
| from functools import cache, lru_cache | ||
| from pathlib import Path | ||
| from typing import Any | ||
|
|
||
| from dokimi import process | ||
|
|
||
| from .errors import PrepError | ||
| from .manifest import _is_within, match_glob | ||
| from .source import SourceError, is_shallow, isolated_git_env | ||
|
|
||
| #: At most this many commits in ``guidance_commits``; ``guidance_latest`` is not capped | ||
| GUIDANCE_COMMITS_CAP = 200 | ||
| _GIT_TIMEOUT_S = 1800.0 | ||
| _FORMAT = "--format=%H%x00%aI%x00%cI%x00%s" # with -z, a NUL ends the subject too | ||
| _COMMIT_ID = re.compile(r"[0-9a-f]{40}(?:[0-9a-f]{24})?") # SHA-1 or SHA-256 | ||
| #: Each overrides a key of the repository's config that changes the output (git 2.51) | ||
| _PINNED = ( | ||
| "--no-show-signature", # log.showSignature: verification text outside the fields | ||
| "--root", # log.showRoot=false: the root commit with no files | ||
| "--encoding=UTF-8", # i18n.logOutputEncoding: a subject re-encoded, in UTF-16 with NULs | ||
| "--ignore-submodules=none", # diff.ignoreSubmodules=all: no commit that adds or moves a gitlink | ||
| ) | ||
|
|
||
| _Links = tuple[tuple[str, tuple[str, ...]], ...] # (link path, the paths it resolves through), sorted | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class _Commit: | ||
| sha: str | ||
| authored: str | ||
| committed: str | ||
| subject: str | ||
| paths: tuple[str, ...] # the guidance paths it touched | ||
|
|
||
| def dates(self) -> dict[str, str]: | ||
| return {"sha": self.sha, "authored": self.authored, "committed": self.committed} | ||
|
|
||
|
|
||
| def arm_rev_history(git_dir: Path, globs: tuple[str, ...], eval_dir: str, sha: str, files: list[str], | ||
| links: dict[str, list[str]]) -> dict[str, Any]: | ||
| """``guidance_latest`` for each of ``files`` (the guidance at ``sha``; ``links`` maps those that | ||
| are links to every path they resolve through, the file they resolve to last), ``guidance_commits`` | ||
| (newest first by committer date, at most :data:`GUIDANCE_COMMITS_CAP`) and | ||
| ``guidance_commits_truncated``. A path under ``eval_dir`` is not guidance: the arm installs none.""" | ||
| commits = _guidance_commits(git_dir, sha, globs, eval_dir, tuple(sorted((k, tuple(v)) for k, v in links.items()))) | ||
| latest: dict[str, dict[str, str]] = {} | ||
| for c in commits: # newest first: the first commit naming a file is its latest | ||
| for path in c.paths: | ||
| latest.setdefault(path, c.dates()) | ||
| return {"guidance_latest": {p: latest[p] for p in sorted(files) if p in latest}, | ||
| "guidance_commits": [{**c.dates(), "subject": c.subject} for c in commits[:GUIDANCE_COMMITS_CAP]], | ||
| "guidance_commits_truncated": len(commits) > GUIDANCE_COMMITS_CAP} | ||
|
|
||
|
|
||
| _key_locks: dict[tuple[Any, ...], threading.Lock] = {} | ||
| _key_locks_guard = threading.Lock() | ||
|
|
||
|
|
||
| def _guidance_commits(git_dir: Path, sha: str, globs: tuple[str, ...], eval_dir: str, | ||
| links: _Links) -> tuple[_Commit, ...]: | ||
| """:func:`_read_guidance_commits`, one thread per key at a time: a cell that asks while another | ||
| cell reads the same history waits for that read and takes its answer from the cache.""" | ||
| key = (git_dir, sha, globs, eval_dir, links) | ||
| with _key_locks_guard: | ||
| lock = _key_locks.setdefault(key, threading.Lock()) | ||
| with lock: | ||
| return _read_guidance_commits(*key) | ||
|
|
||
|
|
||
| @lru_cache(maxsize=64) | ||
| def _read_guidance_commits(git_dir: Path, sha: str, globs: tuple[str, ...], eval_dir: str, | ||
| links: _Links) -> tuple[_Commit, ...]: | ||
| """The commits reachable from ``sha`` that touch a path ``globs`` match outside ``eval_dir`` or a | ||
| path a link of ``links`` resolves through, newest first by committer date.""" | ||
| linked: dict[str, list[str]] = {} | ||
| for link, chain in links: | ||
| for path in chain: | ||
| linked.setdefault(path, []).append(link) | ||
| _refuse_shallow(git_dir) | ||
| found = [] | ||
| # once per distinct path: a path recurs in many commits, and match_glob builds its pattern on every call | ||
| counts_for = cache(lambda p: _counts_for(p, globs, eval_dir, linked)) | ||
| for (commit, authored, committed, subject), touched in _parse_log(_git_log(git_dir, sha)): | ||
| paths = tuple(dict.fromkeys(g for p in touched for g in counts_for(p))) | ||
| if paths: | ||
| found.append(_Commit(commit, authored, committed, subject, paths)) | ||
| # stable: commits with one committer date keep git's order | ||
| return tuple(sorted(found, key=lambda c: datetime.fromisoformat(c.committed), reverse=True)) | ||
|
|
||
|
|
||
| def _counts_for(path: str, globs: tuple[str, ...], eval_dir: str, linked: dict[str, list[str]]) -> list[str]: | ||
| """The guidance paths a change to ``path`` counts for: itself when a glob matches it outside ``eval_dir`` | ||
| (as :func:`~.workspace._guidance_at` lists them), and each link through it (no chain reaches into | ||
| ``eval_dir``: :func:`~.workspace._link_chain` refuses one).""" | ||
| own = not _is_within(path, eval_dir) and any(match_glob(g, path) for g in globs) | ||
| return ([path] if own else []) + linked.get(path, []) | ||
|
|
||
|
|
||
| def _parse_log(out: bytes) -> list[tuple[tuple[str, str, str, str], list[str]]]: | ||
| """Each commit of :func:`_git_log`'s output as ``((sha, authored, committed, subject), paths)``. | ||
| A field that starts with ``:`` (after the newline that ends a header) is a status record, and the | ||
| field after it is its path; a path is never read as a header. Any other non-empty field starts a | ||
| header of four fields; an empty one ends a merge's header, and the output. Each field is decoded | ||
| on its own, after the split: no ``\\r`` is turned into a newline.""" | ||
| fields = (field.decode("utf-8", "replace") for field in out.split(b"\0")) | ||
| commits: list[tuple[tuple[str, str, str, str], list[str]]] = [] | ||
| for field in fields: | ||
| if field.lstrip("\n").startswith(":") and commits: | ||
| commits[-1][1].append(next(fields, "")) | ||
| elif field: | ||
| commits.append((_header(field, fields), [])) | ||
| return commits | ||
|
|
||
|
|
||
| def _header(sha: str, fields: Iterator[str]) -> tuple[str, str, str, str]: | ||
| authored, committed, subject = next(fields, ""), next(fields, ""), next(fields, "") | ||
| if not _COMMIT_ID.fullmatch(sha): | ||
| raise PrepError(f"git log: a commit starts with {sha[:60]!r}, not a commit id") | ||
| return sha, _utc_as_z(authored), _utc_as_z(committed), subject | ||
|
|
||
|
|
||
| def _utc_as_z(date: str) -> str: | ||
| """A strict ISO 8601 date of git's with a UTC offset as ``Z``, whichever form the git that printed it uses.""" | ||
| return date.removesuffix("+00:00") + "Z" if date.endswith("+00:00") else date | ||
|
|
||
|
|
||
| def _refuse_shallow(git_dir: Path) -> None: | ||
| try: # in the log's own environment: a caller's GIT_DIR would answer for another repository | ||
| shallow = is_shallow(git_dir, _env()) | ||
| except SourceError as exc: | ||
| raise PrepError(str(exc)) from exc | ||
| if shallow: | ||
| raise PrepError(f"{git_dir} is a shallow clone: the guidance history of a guidance:rev: arm needs its full " | ||
| "history (git fetch --unshallow)") | ||
|
|
||
|
|
||
| def _git_log(git_dir: Path, sha: str) -> bytes: | ||
| """Every commit reachable from ``sha`` with its dates, subject and the paths it touched; a merge | ||
| with the paths its result changes against every parent.""" | ||
| return _git(git_dir, f"log {sha[:12]}", "log", "-z", "--raw", "--no-abbrev", "--no-renames", | ||
| "--diff-merges=dense-combined", *_PINNED, _FORMAT, sha, "--") | ||
|
|
||
|
|
||
| def _git(git_dir: Path, what: str, *args: str) -> bytes: | ||
| """The stdout of ``git -C git_dir *args`` as bytes, with no system or global config; ``what`` names it in an error.""" | ||
| done = process.git("-C", str(git_dir), *args, timeout=_GIT_TIMEOUT_S, env=_env()) | ||
| if done.timed_out: | ||
| raise PrepError(f"git {what}: timed out after {_GIT_TIMEOUT_S:.0f} seconds") | ||
| if done.returncode: | ||
| raise PrepError(f"git {what} exited {done.returncode}: {process.git_failure(done)}") | ||
| return done.stdout_bytes | ||
|
|
||
|
|
||
| def _env() -> process.Env: | ||
| """git's environment for the history: :func:`~.source.isolated_git_env`, as the arm's own reads have it.""" | ||
| return process.Env.only((), isolated_git_env()) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.