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>
140 lines
5.2 KiB
Python
140 lines
5.2 KiB
Python
"""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()
|