feat(fs): legacy root-owned ownership detect + actionable worktree error (ORCH-057)

Follow-up ORCH-040: legacy root:root files in /repos broke worktree creation
under uid 1000 with a raw "Permission denied" (agent never started, no diagnosis).
Three additive, kill-switch-reversible layers; STAGE_TRANSITIONS / QG_CHECKS /
check_* / machine-verdict keys / DB schema are byte-for-byte unchanged.

- D1: ensure_worktree classifies the permission class and raises an actionable
  RuntimeError (cause + chown command + INFRA.md ref); non-permission errors keep
  the prior raw-stderr contract; kill-switch off -> contract 1:1 as before ORCH-057.
- D2: new never-raise leaf src/fs_normalize.py — scan_ownership (TTL-cached,
  early-exit per root), applies()-first scope (empty CSV -> self-hosting only),
  opt-in normalize() that chowns ONLY when privileged (no-op under uid 1000).
- D3: best-effort startup detect in main.lifespan (WARNING + Telegram on mismatch,
  never-fatal); read-only fs_ownership block in GET /queue; POST /fs-normalize/check.
  Claim is NOT blocked — the clear early outcome is delivered by D1 at launch.
- Docs/config: .env.example flags + CHANGELOG (architecture README / adr-0031 /
  INFRA.md procedure already landed on the branch).
- Tests: test_fs_normalize.py, test_git_worktree_perm.py,
  test_fs_normalize_startup.py, test_api_queue.py (TC-01..TC-12). Full suite green.

Refs: ORCH-057
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 02:53:15 +03:00
committed by orchestrator-deployer
parent 34778217fe
commit a98d605477
10 changed files with 1228 additions and 5 deletions

68
tests/test_api_queue.py Normal file
View File

@@ -0,0 +1,68 @@
"""ORCH-057 TC-12: GET /queue exposes the read-only fs_ownership block.
The block carries {enabled, target_uid, mismatch, roots_checked, roots_mismatch,
sample_path, checked_at, ...} and /queue must not 5xx whether the layer is on or off.
"""
import os
import tempfile
import pytest
_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_apiq.db")
os.environ["ORCH_DB_PATH"] = _test_db
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
os.environ["ORCH_GITEA_TOKEN"] = "test-token"
os.environ["ORCH_PLANE_API_TOKEN"] = "test-token"
os.environ["ORCH_PLANE_WEBHOOK_SECRET"] = ""
os.environ["ORCH_GITEA_WEBHOOK_SECRET"] = ""
from fastapi.testclient import TestClient
from src import fs_normalize
from src.main import app
from src.db import init_db
client = TestClient(app)
@pytest.fixture(autouse=True)
def _db():
if os.path.exists(_test_db):
os.unlink(_test_db)
init_db()
fs_normalize.reset_cache()
yield
if os.path.exists(_test_db):
os.unlink(_test_db)
def test_tc12_queue_exposes_fs_ownership_block(monkeypatch):
"""TC-12: GET /queue returns the fs_ownership block with the documented shape."""
monkeypatch.setattr(fs_normalize.settings, "fs_normalize_enabled", True)
r = client.get("/queue")
assert r.status_code == 200
body = r.json()
assert "fs_ownership" in body
block = body["fs_ownership"]
for k in ("enabled", "target_uid", "mismatch", "roots_checked",
"roots_mismatch", "sample_path", "checked_at"):
assert k in block
def test_tc12_queue_no_5xx_when_disabled(monkeypatch):
"""TC-12: with the kill-switch off /queue still returns 200 (no 5xx)."""
monkeypatch.setattr(fs_normalize.settings, "fs_normalize_enabled", False)
fs_normalize.reset_cache()
r = client.get("/queue")
assert r.status_code == 200
assert r.json()["fs_ownership"]["enabled"] is False
def test_fs_normalize_check_endpoint():
"""The optional POST /fs-normalize/check force-rescans and returns the snapshot."""
r = client.post("/fs-normalize/check")
assert r.status_code == 200
body = r.json()
assert body["ok"] is True
assert "scan" in body and "mismatch" in body["scan"]
assert "healing" in body

214
tests/test_fs_normalize.py Normal file
View File

@@ -0,0 +1,214 @@
"""ORCH-057 D2/D4/D6: ownership-detect leaf (src/fs_normalize.py) unit tests.
TC-03..TC-09 (04-test-plan.yaml). All FS-dependent tests use ``tmp_path`` and vary
``target_uid`` (a uid no tmp file actually has -> mismatch; the runner's own uid ->
clean) so NO real chown / privilege is needed. ``os.geteuid`` is monkeypatched for
the privilege-gated normalize test (TC-08). Never touches /repos.
"""
import os
import tempfile
import pytest
_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_fsn.db")
os.environ["ORCH_DB_PATH"] = _test_db
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
os.environ["ORCH_GITEA_TOKEN"] = "test-token"
os.environ["ORCH_PLANE_API_TOKEN"] = "test-token"
from src import fs_normalize
_NONEXISTENT_UID = 999999 # no tmp file is owned by this uid -> deterministic mismatch
@pytest.fixture(autouse=True)
def _reset(monkeypatch):
fs_normalize.reset_cache()
monkeypatch.setattr(fs_normalize.settings, "fs_normalize_enabled", True)
monkeypatch.setattr(fs_normalize.settings, "fs_normalize_repos", "")
monkeypatch.setattr(fs_normalize.settings, "fs_normalize_auto", False)
monkeypatch.setattr(fs_normalize.settings, "fs_scan_cache_ttl_s", 300)
yield
fs_normalize.reset_cache()
@pytest.fixture
def tree(tmp_path):
"""A small dir tree with a file, owned by the test runner's own uid."""
d = tmp_path / "root"
(d / "sub").mkdir(parents=True)
(d / "a.txt").write_text("a")
(d / "sub" / "b.txt").write_text("b")
return d
# ---------------------------------------------------------------------------
# TC-03 / TC-04 — scan verdict
# ---------------------------------------------------------------------------
def test_tc03_scan_detects_mismatch(tree):
"""TC-03: a tree whose files are not owned by target_uid -> mismatch=True with the
affected root listed and a sample path set."""
scan = fs_normalize.scan_ownership(roots=[str(tree)], target_uid=_NONEXISTENT_UID)
assert scan.mismatch is True
assert str(tree) in scan.roots_mismatch
assert scan.sample_path is not None
assert scan.target_uid == _NONEXISTENT_UID
def test_tc04_clean_tree_no_mismatch(tree):
"""TC-04: a clean tree (all files owned by target_uid == the runner) -> idempotent
mismatch=False no-op."""
scan = fs_normalize.scan_ownership(roots=[str(tree)], target_uid=os.getuid())
assert scan.mismatch is False
assert scan.roots_mismatch == []
assert scan.sample_path is None
# ---------------------------------------------------------------------------
# TC-05 — never-raise on bad/missing root
# ---------------------------------------------------------------------------
def test_tc05_never_raise_on_missing_root(tmp_path):
"""TC-05: a non-existent root degrades to mismatch=False, never raises."""
missing = str(tmp_path / "does-not-exist")
scan = fs_normalize.scan_ownership(roots=[missing], target_uid=_NONEXISTENT_UID)
assert scan.mismatch is False
assert scan.roots_checked == [] # the missing root is skipped
def test_tc05_never_raise_on_walk_error(tree, monkeypatch):
"""TC-05: an os.walk explosion mid-scan degrades to a conservative verdict."""
def boom(*a, **k):
raise OSError("simulated walk failure")
monkeypatch.setattr(fs_normalize.os, "walk", boom)
scan = fs_normalize.scan_ownership(roots=[str(tree)], target_uid=_NONEXISTENT_UID)
# The root dir itself is owned by the runner (not _NONEXISTENT_UID was checked via
# lstat which still works) -> walk error swallowed, no exception escapes.
assert isinstance(scan, fs_normalize.OwnershipScan)
# ---------------------------------------------------------------------------
# TC-06 — applies() scope
# ---------------------------------------------------------------------------
def test_tc06_applies_empty_csv_self_hosting_only(monkeypatch):
"""TC-06: empty ORCH_FS_NORMALIZE_REPOS -> True only for the self-hosting repo
(orchestrator), False for enduro-trails."""
monkeypatch.setattr(fs_normalize.settings, "fs_normalize_repos", "")
assert fs_normalize.applies("orchestrator") is True
assert fs_normalize.applies("enduro-trails") is False
def test_tc06_applies_explicit_csv(monkeypatch):
"""TC-06: a non-empty CSV scopes by list (case-insensitive)."""
monkeypatch.setattr(fs_normalize.settings, "fs_normalize_repos", "enduro-trails")
assert fs_normalize.applies("enduro-trails") is True
assert fs_normalize.applies("orchestrator") is False
# ---------------------------------------------------------------------------
# TC-07 — kill-switch
# ---------------------------------------------------------------------------
def test_tc07_killswitch_off_scan_inert(tree, monkeypatch):
"""TC-07: fs_normalize_enabled=False -> scan is inert (mismatch=False, enabled
flag exposes the off state); applies() False for everyone."""
monkeypatch.setattr(fs_normalize.settings, "fs_normalize_enabled", False)
scan = fs_normalize.scan_ownership(roots=[str(tree)], target_uid=_NONEXISTENT_UID)
assert scan.mismatch is False
assert scan.enabled is False
assert fs_normalize.applies("orchestrator") is False
def test_tc07_killswitch_off_normalize_inert(tree, monkeypatch):
"""TC-07: normalize is a documented no-op when the kill-switch is off."""
monkeypatch.setattr(fs_normalize.settings, "fs_normalize_enabled", False)
res = fs_normalize.normalize(roots=[str(tree)], target_uid=_NONEXISTENT_UID)
assert res["attempted"] is False
assert res["changed"] == 0
assert "disabled" in res["note"]
# ---------------------------------------------------------------------------
# TC-08 — normalize without privilege
# ---------------------------------------------------------------------------
def test_tc08_normalize_without_rights_is_noop_not_error(tree, monkeypatch):
"""TC-08: under a non-root euid with auto=True and foreign files, normalize is a
no-op + honest log ('operator procedure required'), NOT an exception."""
monkeypatch.setattr(fs_normalize.settings, "fs_normalize_auto", True)
monkeypatch.setattr(fs_normalize.os, "geteuid", lambda: 1000) # non-root
res = fs_normalize.normalize(roots=[str(tree)], target_uid=_NONEXISTENT_UID)
assert res["privileged"] is False
assert res["attempted"] is False
assert res["changed"] == 0
assert "INFRA.md" in res["note"]
# ---------------------------------------------------------------------------
# TC-09 — TTL cache
# ---------------------------------------------------------------------------
def test_tc09_ttl_cache_avoids_rescan(tree, monkeypatch):
"""TC-09: a repeat call inside the TTL window does NOT re-walk; force/reset
invalidates (mirrors preflight._cache)."""
calls = {"n": 0}
real_scan = fs_normalize._scan
def counting_scan(roots, target_uid):
calls["n"] += 1
return real_scan(roots, target_uid)
monkeypatch.setattr(fs_normalize, "_scan", counting_scan)
fs_normalize.scan_ownership(roots=[str(tree)], target_uid=_NONEXISTENT_UID)
fs_normalize.scan_ownership(roots=[str(tree)], target_uid=_NONEXISTENT_UID)
assert calls["n"] == 1 # second call served from cache
fs_normalize.scan_ownership(roots=[str(tree)], target_uid=_NONEXISTENT_UID, force=True)
assert calls["n"] == 2 # force bypasses the cache
fs_normalize.reset_cache()
fs_normalize.scan_ownership(roots=[str(tree)], target_uid=_NONEXISTENT_UID)
assert calls["n"] == 3 # reset invalidates
def test_tc09_cache_keyed_by_roots_and_uid(tree, monkeypatch):
"""A different (roots, target_uid) key is not served from another key's cache."""
calls = {"n": 0}
real_scan = fs_normalize._scan
def counting_scan(roots, target_uid):
calls["n"] += 1
return real_scan(roots, target_uid)
monkeypatch.setattr(fs_normalize, "_scan", counting_scan)
fs_normalize.scan_ownership(roots=[str(tree)], target_uid=_NONEXISTENT_UID)
fs_normalize.scan_ownership(roots=[str(tree)], target_uid=os.getuid()) # different uid
assert calls["n"] == 2
# ---------------------------------------------------------------------------
# classifier (pure) + snapshot
# ---------------------------------------------------------------------------
def test_classify_worktree_error_markers():
assert fs_normalize.classify_worktree_error("fatal: ...: Permission denied") is True
assert fs_normalize.classify_worktree_error("could not create leading directories") is True
assert fs_normalize.classify_worktree_error("insufficient permission for adding an object") is True
assert fs_normalize.classify_worktree_error("fatal: branch already checked out") is False
assert fs_normalize.classify_worktree_error("") is False
assert fs_normalize.classify_worktree_error(None) is False
def test_is_permission_failure_from_exc():
assert fs_normalize.is_permission_failure(exc=PermissionError(13, "denied")) is True
import errno as _errno
assert fs_normalize.is_permission_failure(exc=OSError(_errno.EACCES, "x")) is True
assert fs_normalize.is_permission_failure(exc=OSError(_errno.ENOENT, "x")) is False
def test_snapshot_shape(tree, monkeypatch):
"""snapshot() returns the additive fs_ownership block and never raises."""
monkeypatch.setattr(fs_normalize.settings, "fs_scan_roots", str(tree))
snap = fs_normalize.snapshot()
for k in ("enabled", "auto", "repos", "target_uid", "mismatch",
"roots_checked", "roots_mismatch", "sample_path", "checked_at"):
assert k in snap
assert snap["enabled"] is True

View File

@@ -0,0 +1,136 @@
"""ORCH-057 D3: startup-hook observability + the clear pre-launch outcome.
TC-10 / TC-11 (04-test-plan.yaml):
* TC-10 — the lifespan startup hook, on a detected mismatch, emits a WARNING and a
Telegram message; a detect error never crashes the start (never-fatal).
* TC-11 — the "clear, early" outcome on a permission failure is delivered by the
actionable ensure_worktree error (ADR-001 D3: claim is NOT blocked), i.e. the
launch surfaces an actionable diagnosis, never a raw git-fatal.
Background daemons are disabled via env so the lifespan is cheap and deterministic.
"""
import os
import tempfile
import pytest
_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_fsn_startup.db")
os.environ["ORCH_DB_PATH"] = _test_db
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
os.environ["ORCH_GITEA_TOKEN"] = "test-token"
os.environ["ORCH_PLANE_API_TOKEN"] = "test-token"
os.environ["ORCH_PLANE_WEBHOOK_SECRET"] = ""
os.environ["ORCH_GITEA_WEBHOOK_SECRET"] = ""
# Keep the lifespan light: no background daemons during the test.
os.environ["ORCH_RECONCILE_ENABLED"] = "false"
os.environ["ORCH_REAPER_ENABLED"] = "false"
os.environ["ORCH_DISK_MONITOR_ENABLED"] = "false"
os.environ["ORCH_BUILD_CACHE_PRUNE_ENABLED"] = "false"
os.environ["ORCH_FS_NORMALIZE_ENABLED"] = "true"
from fastapi.testclient import TestClient
from src import fs_normalize, git_worktree
from src.main import app
from src.db import init_db
@pytest.fixture(autouse=True)
def _db():
if os.path.exists(_test_db):
os.unlink(_test_db)
init_db()
fs_normalize.reset_cache()
yield
if os.path.exists(_test_db):
os.unlink(_test_db)
# ---------------------------------------------------------------------------
# TC-10 — startup observability
# ---------------------------------------------------------------------------
def test_tc10_startup_mismatch_warns_and_telegrams(monkeypatch, caplog):
"""TC-10: on a detected mismatch the startup hook logs a WARNING and sends a
Telegram message (mocked)."""
sent = []
monkeypatch.setattr(
"src.notifications.send_telegram", lambda *a, **k: sent.append(a[0] if a else "")
)
scan = fs_normalize.OwnershipScan(
mismatch=True, target_uid=1000, roots_checked=["/repos/_wt"],
roots_mismatch=["/repos/_wt"], sample_path="/repos/_wt/x", checked_at=1.0,
)
monkeypatch.setattr("src.fs_normalize.scan_ownership", lambda *a, **k: scan)
with caplog.at_level("WARNING"):
with TestClient(app):
pass
assert any("FS-ownership mismatch" in r.message for r in caplog.records)
# Filter for the fs-ownership message (the shared startup may emit other,
# unrelated Telegram traffic — e.g. a leftover task's tracker card).
fs_msgs = [m for m in sent if "legacy root-owned" in m.lower() or "chown" in m.lower()]
assert fs_msgs, "expected a Telegram message on mismatch"
def test_tc10_startup_detect_error_never_fatal(monkeypatch):
"""TC-10: a detect error must NOT crash the start (never-fatal)."""
def boom(*a, **k):
raise RuntimeError("simulated detect failure")
monkeypatch.setattr("src.fs_normalize.scan_ownership", boom)
# Entering/exiting the lifespan must not raise.
with TestClient(app):
pass
def test_tc10_startup_clean_no_telegram(monkeypatch):
"""A clean environment (no mismatch) sends no Telegram and does not warn."""
sent = []
monkeypatch.setattr(
"src.notifications.send_telegram", lambda *a, **k: sent.append(a[0] if a else "")
)
clean = fs_normalize.OwnershipScan(mismatch=False, target_uid=1000, checked_at=1.0)
monkeypatch.setattr("src.fs_normalize.scan_ownership", lambda *a, **k: clean)
with TestClient(app):
pass
# No fs-ownership message on a clean environment (unrelated startup Telegram
# traffic from a shared-DB leftover task is ignored).
fs_msgs = [m for m in sent if "legacy root-owned" in m.lower() or "обнаружены legacy" in m.lower()]
assert fs_msgs == []
# ---------------------------------------------------------------------------
# TC-11 — clear pre-launch outcome (D1, not a claim gate)
# ---------------------------------------------------------------------------
def test_tc11_launch_permission_failure_is_actionable_not_raw(tmp_path, monkeypatch):
"""TC-11: the launch-time worktree creation surfaces an actionable error (clear,
before the agent spends a token), not a raw git-fatal — the ADR-001 D3 "внятно и
заранее" outcome that replaces a blocking claim gate."""
repo = "orchestrator"
repos_dir = tmp_path / "repos"
(repos_dir / repo).mkdir(parents=True)
monkeypatch.setattr(git_worktree.settings, "repos_dir", str(repos_dir))
monkeypatch.setattr(git_worktree.settings, "worktrees_dir", str(repos_dir / "_wt"))
monkeypatch.setattr(git_worktree.settings, "fs_normalize_enabled", True)
class _R:
def __init__(self, rc, err=""):
self.returncode = rc
self.stderr = err
self.stdout = ""
def fake_run(cmd, *a, **k):
if "fetch" in cmd:
return _R(0)
if "worktree" in cmd and "add" in cmd:
return _R(128, "fatal: ...: Permission denied")
return _R(0)
monkeypatch.setattr(git_worktree.subprocess, "run", fake_run)
with pytest.raises(RuntimeError) as ei:
git_worktree.ensure_worktree(repo, "feature/x")
msg = str(ei.value)
assert "INFRA.md" in msg and "chown" in msg.lower()
assert "git worktree add failed" not in msg # not the raw passthrough

View File

@@ -0,0 +1,139 @@
"""ORCH-057 D1: actionable worktree error on a legacy-ownership permission failure.
TC-01 / TC-02 (04-test-plan.yaml): a permission-class ``git worktree add`` /
``os.makedirs`` failure must surface an actionable RuntimeError (cause + healing
command + INFRA.md ref), while a NON-permission failure keeps the prior raw-stderr
contract (no meaning substitution). No real chown / no writes to /repos — failures
are simulated via monkeypatched ``subprocess.run`` / ``os.makedirs``.
"""
import os
import tempfile
import pytest
_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_wt_perm.db")
os.environ["ORCH_DB_PATH"] = _test_db
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
os.environ["ORCH_GITEA_TOKEN"] = "test-token"
os.environ["ORCH_PLANE_API_TOKEN"] = "test-token"
from src import git_worktree
from src.git_worktree import ensure_worktree
class _R:
"""Minimal CompletedProcess stand-in."""
def __init__(self, returncode, stderr=""):
self.returncode = returncode
self.stderr = stderr
self.stdout = ""
@pytest.fixture
def main_repo(tmp_path, monkeypatch):
"""A bare-minimum main clone dir so ensure_worktree gets past the existence check.
repos_dir/<repo> must be a directory; worktrees_dir points at a fresh tmp path.
The actual git calls are monkeypatched per-test.
"""
repo = "orchestrator"
repos_dir = tmp_path / "repos"
(repos_dir / repo).mkdir(parents=True)
monkeypatch.setattr(git_worktree.settings, "repos_dir", str(repos_dir))
monkeypatch.setattr(git_worktree.settings, "worktrees_dir", str(tmp_path / "repos" / "_wt"))
monkeypatch.setattr(git_worktree.settings, "fs_normalize_enabled", True)
return repo
def test_tc01_permission_git_fatal_becomes_actionable(main_repo, monkeypatch):
"""TC-01: a git-fatal 'could not create leading directories / Permission denied'
raises an actionable RuntimeError (diagnosis + chown), not the raw git stderr."""
perm_stderr = (
"fatal: could not create leading directories of "
"'/repos/_wt/orchestrator/x': Permission denied"
)
def fake_run(cmd, *a, **k):
# fetch -> ok; worktree add (both forms) -> permission fatal.
if "fetch" in cmd:
return _R(0)
if "worktree" in cmd and "add" in cmd:
return _R(128, perm_stderr)
return _R(0)
monkeypatch.setattr(git_worktree.subprocess, "run", fake_run)
with pytest.raises(RuntimeError) as ei:
ensure_worktree(main_repo, "feature/x")
msg = str(ei.value)
# Actionable: names the cause + the healing command + the INFRA procedure...
assert "legacy root-owned" in msg.lower()
assert "chown" in msg.lower()
assert "INFRA.md" in msg
# ...and is NOT merely the raw "git worktree add failed" passthrough.
assert "git worktree add failed" not in msg
def test_tc01_makedirs_permission_error_becomes_actionable(main_repo, monkeypatch):
"""TC-01 (sibling path): a PermissionError from os.makedirs (creating the leading
worktree dir) is also turned into the actionable RuntimeError."""
def fake_run(cmd, *a, **k):
return _R(0)
monkeypatch.setattr(git_worktree.subprocess, "run", fake_run)
def boom(*a, **k):
raise PermissionError(13, "Permission denied")
monkeypatch.setattr(git_worktree.os, "makedirs", boom)
with pytest.raises(RuntimeError) as ei:
ensure_worktree(main_repo, "feature/x")
assert "chown" in str(ei.value).lower()
assert "legacy root-owned" in str(ei.value).lower()
def test_tc02_non_permission_error_keeps_prior_contract(main_repo, monkeypatch):
"""TC-02: a NON-permission failure (e.g. a real branch conflict) keeps the prior
raw-stderr 'git worktree add failed' message — no meaning substitution."""
conflict = "fatal: 'feature/x' is already checked out at '/repos/_wt/other'"
def fake_run(cmd, *a, **k):
if "fetch" in cmd:
return _R(0)
if "worktree" in cmd and "add" in cmd:
return _R(128, conflict)
return _R(0)
monkeypatch.setattr(git_worktree.subprocess, "run", fake_run)
with pytest.raises(RuntimeError) as ei:
ensure_worktree(main_repo, "feature/x")
msg = str(ei.value)
assert "git worktree add failed" in msg
assert "already checked out" in msg
# The actionable diagnosis must NOT be injected for a non-permission error.
assert "legacy root-owned" not in msg.lower()
def test_tc02_killswitch_off_keeps_raw_contract_even_for_permission(main_repo, monkeypatch):
"""Kill-switch off (fs_normalize_enabled=False) -> the error contract is byte-for-
byte as before ORCH-057 even for a permission failure (raw stderr passthrough)."""
monkeypatch.setattr(git_worktree.settings, "fs_normalize_enabled", False)
perm_stderr = "fatal: ...: Permission denied"
def fake_run(cmd, *a, **k):
if "fetch" in cmd:
return _R(0)
if "worktree" in cmd and "add" in cmd:
return _R(128, perm_stderr)
return _R(0)
monkeypatch.setattr(git_worktree.subprocess, "run", fake_run)
with pytest.raises(RuntimeError) as ei:
ensure_worktree(main_repo, "feature/x")
msg = str(ei.value)
assert "git worktree add failed" in msg
assert "legacy root-owned" not in msg.lower()