Skip to content

Commit 4da5697

Browse files
authored
Merge pull request #450 from eduralph/fix/419-reviewer-target-git-writable
Materialize a git-writable reviewer target for the red→green re-run
2 parents bc7deaf + a1c9d7a commit 4da5697

5 files changed

Lines changed: 368 additions & 8 deletions

File tree

template/src/pdca_harness/leaves.py

Lines changed: 111 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,13 @@
3636

3737
import contextlib
3838
import hashlib
39+
import io
3940
import json
4041
import os
4142
import shutil
4243
import subprocess
4344
import sys
45+
import tarfile
4446
import tempfile
4547
import time
4648
from pathlib import Path
@@ -1641,7 +1643,12 @@ def reviewer_input_paths(d: Path) -> list[Path]:
16411643
"allows), observe, and report; only where it genuinely can't be driven, hand the human "
16421644
"concrete runnable steps, not a bare 'needs manual check'. If a verdict turns on an "
16431645
"investigation, run it and show the result directly — don't ask whether to investigate. "
1644-
"Ground every cited path:line on the target source at $PDCA_TARGET (read-only); "
1646+
"Ground every cited path:line on the target source at $PDCA_TARGET. When the bundle "
1647+
"carries a patch, $PDCA_TARGET is a DISPOSABLE git-self-contained copy — the base as "
1648+
"one local commit, patch.diff applied uncommitted on top — so the independent "
1649+
"red→green re-run is executable in place: `git stash` restores the pre-fix tree, "
1650+
"`git stash pop` re-applies the patch, and no write of yours can reach the real "
1651+
"checkout. Otherwise treat $PDCA_TARGET as read-only. "
16451652
"if $PDCA_TARGET is unset, ground against patch.diff alone — do NOT search other "
16461653
"checkouts on the machine. If $PDCA_TARGET is SET yet stale or unreadable (its base "
16471654
"lags what the patch was built/verified against — a dependent/stacked cycle's base "
@@ -1686,6 +1693,85 @@ def _reviewer_target(d: Path, cfg: Config) -> Path | None:
16861693
return None
16871694

16881695

1696+
def _reviewer_repo(d: Path, target: Path, sandbox: Path) -> Path | None:
1697+
"""A DISPOSABLE, git-self-contained copy of ``target`` inside the reviewer sandbox —
1698+
the tree the reviewer may re-run the red→green on (issue #419).
1699+
1700+
The review contract asks the reviewer to independently re-verify C4 against
1701+
``$PDCA_TARGET``: restore the pre-fix state, run the bundle's test, re-apply
1702+
(``git stash`` / ``git stash pop``). The tree :func:`_reviewer_target` resolves cannot
1703+
host that inside the reviewer's confinement: a linked worktree's git metadata — its
1704+
index included — lives under the PRIMARY checkout's ``.git/worktrees/<name>/``
1705+
(its ``.git`` is an absolute pointer, ``worktree.py:14-16``), and stash writes objects
1706+
into the shared ``.git/objects`` — both outside the granted dir and read-only to the
1707+
leaf. So every index-writing git op failed and the C4 verification claim landed in §6
1708+
NEEDS-HUMAN on every cycle instead of being mechanically re-checked.
1709+
1710+
Shape: ``<sandbox>/target`` holding the target's base tree as ONE local commit with
1711+
the bundle's ``patch.diff`` applied UNCOMMITTED on top — exactly the state the
1712+
reviewer must stash away, and the state the lane worktree itself carries (base
1713+
checked out, patch applied uncommitted), so ``HEAD`` of the source IS the pre-fix
1714+
tree. The copy's whole ``.git`` lives inside the sandbox cwd, so the pre-fix restore
1715+
+ re-apply write nothing anywhere near the primary checkout's git metadata; the
1716+
source repo is only ever READ (``git archive`` / ``rev-parse``). Identity and signing
1717+
are pinned in the copy's local config so ``git stash`` (which commits) cannot depend
1718+
on the operator's global git config.
1719+
1720+
Only for a bundle WITH a patch: with nothing to stash there is no re-run, and
1721+
read-only grounding on the real checkout serves citations better (full history).
1722+
**Best-effort**, mirroring :func:`_seed_sandbox_gate_logs`: any failure — a non-git
1723+
target, an archive/extract/apply error — degrades to None with a stderr note and the
1724+
caller falls back to grounding on ``target`` directly; never an aborted Check.
1725+
"""
1726+
patch = d / "patch.diff"
1727+
try:
1728+
patch_text = patch.read_text(encoding="utf-8") if patch.is_file() else ""
1729+
except (OSError, UnicodeDecodeError):
1730+
patch_text = ""
1731+
if not patch_text.strip() or not (target / ".git").exists():
1732+
return None
1733+
dest = sandbox / "target"
1734+
1735+
def _run(repo: Path, *args: str) -> subprocess.CompletedProcess:
1736+
return subprocess.run(["git", "-C", str(repo), *args], capture_output=True)
1737+
1738+
try:
1739+
# READ-ONLY against the source: export the tree at HEAD (the pre-fix base — the
1740+
# patch sits uncommitted on top of it in the lane) without touching its index.
1741+
archive = _run(target, "archive", "--format=tar", "HEAD")
1742+
if archive.returncode != 0:
1743+
raise OSError(archive.stderr.decode(errors="replace").strip()
1744+
or "git archive failed")
1745+
base = _run(target, "rev-parse", "HEAD").stdout.decode(errors="replace").strip()
1746+
dest.mkdir()
1747+
with tarfile.open(fileobj=io.BytesIO(archive.stdout)) as tf:
1748+
try:
1749+
tf.extractall(dest, filter="data")
1750+
except TypeError: # Python 3.11.0–3.11.3: no filter= yet (PEP 706 backport)
1751+
tf.extractall(dest)
1752+
for args in (("init", "-q"),
1753+
# stash COMMITS: pin identity + signing in the copy's own config so
1754+
# the re-run cannot depend on the operator's global git config.
1755+
("config", "user.name", "pdca-reviewer"),
1756+
("config", "user.email", "pdca-reviewer@localhost"),
1757+
("config", "commit.gpgsign", "false"),
1758+
# -f: a tracked-but-gitignored file in the base must not drop out.
1759+
("add", "-A", "-f"),
1760+
("commit", "-q", "--allow-empty", "-m", f"pre-fix base {base}"),
1761+
("apply", str(patch.resolve()))):
1762+
done = _run(dest, *args)
1763+
if done.returncode != 0:
1764+
raise OSError(
1765+
f"git {args[0]}: {done.stderr.decode(errors='replace').strip()}")
1766+
return dest
1767+
except Exception as exc: # noqa: BLE001 — materialization is best-effort, never fatal
1768+
print(f"leaves: could not materialize a git-writable reviewer copy of {target} "
1769+
f"({exc}); the leaf grounds on the target read-only and the red→green "
1770+
"re-run may land in §6", file=sys.stderr)
1771+
shutil.rmtree(dest, ignore_errors=True)
1772+
return None
1773+
1774+
16891775
def run_review(d: Path, cfg: Config) -> None:
16901776
inputs = reviewer_input_paths(d)
16911777
assert (d / "build-notes.md") not in inputs, "independence contract violated"
@@ -2085,13 +2171,24 @@ def _run_review_sandboxed(d: Path, cfg: Config) -> None:
20852171
# earn an automated red→green at Check.
20862172
seeded = _seed_sandbox_settings(cfg, sandbox, profile)
20872173
# Ground citations on the brief's target checkout (#75): name it via $PDCA_TARGET
2088-
# so the reviewer doesn't wander into unrelated checkouts, and grant read access
2089-
# via the family's grounding flag (claude: --add-dir). Independence holds — the
2090-
# target is the upstream source, not build-notes.md.
2174+
# so the reviewer doesn't wander into unrelated checkouts. For a bundle WITH a
2175+
# patch, what is handed is a disposable git-self-contained copy INSIDE the
2176+
# sandbox (#419): the lane worktree's git index/objects live under the PRIMARY
2177+
# checkout's .git (worktree.py:14-16) — outside any granted dir and read-only to
2178+
# the leaf — so the contract's own pre-fix restore (`git stash`) could never run
2179+
# against it. The copy's .git is sandbox-local: stash/unstash work, and the
2180+
# primary checkout's git metadata sees no writes. Independence holds — the copy
2181+
# is materialized from the target source + patch.diff, never build-notes.md.
20912182
target = _reviewer_target(d, cfg)
2092-
env = {"PDCA_TARGET": str(target)} if target else None
2183+
repo = _reviewer_repo(d, target, sandbox) if target is not None else None
2184+
grounded = repo if repo is not None else target
2185+
env = {"PDCA_TARGET": str(grounded)} if grounded else None
2186+
# The grounding grant (claude: --add-dir) is only needed for a target OUTSIDE
2187+
# the sandbox cwd. When the sandbox-local copy is handed, granting the real
2188+
# checkout too would hand a read+write family (codex --add-dir,
2189+
# families.py:112-113) the shared lane worktree for no reviewer need.
20932190
extra_argv = ([profile.grounding_flag, str(target)]
2094-
if target and profile.grounding_flag else [])
2191+
if repo is None and target and profile.grounding_flag else [])
20952192
# The confinement flag rides on `seeded` (a file that is not there must not cost
20962193
# the leaf its ambient sandbox, #290); the codex network grant does not (#291).
20972194
extra_argv += _sandbox_argv(cfg, profile, seeded=seeded)
@@ -2418,10 +2515,16 @@ def _run_advisory_sandboxed(d: Path, cfg: Config, leaf: LeafConfig, spec: dict,
24182515
# (#261) — without it a loopback-socket runtime test can't bind, so it can never
24192516
# earn an automated red→green at Check.
24202517
seeded = _seed_sandbox_settings(cfg, sandbox, profile)
2518+
# Same #419 shape as _run_review_sandboxed: a bundle with a patch gets a
2519+
# disposable git-self-contained copy inside the sandbox (the lane worktree's git
2520+
# metadata is read-only to the leaf, so stash/unstash could never run there);
2521+
# the grounding grant is withheld for the sandbox-local copy.
24212522
target = _reviewer_target(d, cfg)
2422-
env = {"PDCA_TARGET": str(target)} if target else None
2523+
repo = _reviewer_repo(d, target, sandbox) if target is not None else None
2524+
grounded = repo if repo is not None else target
2525+
env = {"PDCA_TARGET": str(grounded)} if grounded else None
24232526
extra = ([profile.grounding_flag, str(target)]
2424-
if target and profile.grounding_flag else [])
2527+
if repo is None and target and profile.grounding_flag else [])
24252528
extra += _sandbox_argv(cfg, profile, seeded=seeded) # see _run_review_sandboxed
24262529
out = sandbox / f"check-advisory-{leaf_id}.md"
24272530
error_log = advisory_error_log(d, leaf_id)

template/tests/test_autoiterate.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,6 +722,17 @@ def test_a_gate_row_kind_comes_from_its_element_not_its_label(self) -> None:
722722
class ConfigPlumbing(unittest.TestCase):
723723
def setUp(self) -> None:
724724
self.tmp = Path(tempfile.mkdtemp())
725+
# Hermetic against the ambient environment (#419): Config.load honors PDCA_*
726+
# env overrides (PDCA_AUTO_ITERATE, config.py), and a project's T3 suite gate
727+
# runs this suite with the DRIVER's inherited env (gates._merged_env) — an
728+
# auto-iterate flow can carry PDCA_AUTO_ITERATE=1 there, flipping the
729+
# default-behavior assertions below to read the operator's shell instead of
730+
# the toml under test.
731+
env_guard = mock.patch.dict(os.environ)
732+
env_guard.start()
733+
self.addCleanup(env_guard.stop)
734+
for key in [k for k in os.environ if k.startswith("PDCA_")]:
735+
del os.environ[key]
725736

726737
def tearDown(self) -> None:
727738
shutil.rmtree(self.tmp, ignore_errors=True)

template/tests/test_flow_slice.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -787,6 +787,16 @@ class LaneParallelism(unittest.TestCase):
787787
def setUp(self) -> None:
788788
self.tmp = Path(tempfile.mkdtemp())
789789
self.cfg = _stub_config(self.tmp)
790+
# Hermetic against the ambient environment (#419): gate commands inherit the
791+
# driver's env (gates._merged_env is {**os.environ, **extra}), so when THIS
792+
# suite runs under a lane-parallel outer driver's T3 gate — which exports
793+
# PDCA_LANE for its own lane (gates.py) — the serial-path assertion below
794+
# would read the OUTER driver's lane, not this test's serial flow.
795+
env_guard = mock.patch.dict(os.environ)
796+
env_guard.start()
797+
self.addCleanup(env_guard.stop)
798+
for key in [k for k in os.environ if k.startswith("PDCA_")]:
799+
del os.environ[key]
790800

791801
def tearDown(self) -> None:
792802
shutil.rmtree(self.tmp, ignore_errors=True)

template/tests/test_manual_test.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from __future__ import annotations
1010

11+
import os
1112
import tempfile
1213
import unittest
1314
from pathlib import Path
@@ -39,6 +40,15 @@ class ManualTestLaunch(unittest.TestCase):
3940
def setUp(self) -> None:
4041
self.tmp = Path(tempfile.mkdtemp())
4142
self.cfg = _cfg(self.tmp)
43+
# Hermetic against the ambient environment (#419): launch() hands the app
44+
# {**os.environ, …} (manual_test.py), so when this suite runs under a
45+
# lane-parallel outer driver's T3 gate — whose env carries the OUTER
46+
# PDCA_LANE — the serial-path assertion below would see that ambient value.
47+
env_guard = mock.patch.dict(os.environ)
48+
env_guard.start()
49+
self.addCleanup(env_guard.stop)
50+
for key in [k for k in os.environ if k.startswith("PDCA_")]:
51+
del os.environ[key]
4252

4353
def tearDown(self) -> None:
4454
import shutil

0 commit comments

Comments
 (0)