Files
orchestrator/tests/test_git_worktree.py
Dev Agent 1ebe8afc23 feat(worktree): git worktree per task to isolate shared /repos (ORCH-2 / S-4)
- add src/git_worktree.py: ensure/remove/get_worktree_path
- config: worktrees_dir=/repos/_wt
- launcher: agent runs in per-branch worktree; task-file + commit/push in worktree; no shared checkout
- qg/checks: read artifacts + run make test from worktree (branch arg, backward-compatible)
- webhooks/plane: pass branch into QG dispatch; review fallback from worktree
- webhooks/gitea: keep read-only branch --contains in main clone (documented)
- tests: test_git_worktree.py (isolation) + update test_launcher write-task-file
- docs: ARCHITECTURE worktree section + BUGFIXES_2026-06-02_ORCH2

Preserves B-1/B-2/S-1/S-5 fixes (paths now point at worktree).
2026-06-02 21:12:06 +03:00

153 lines
6.1 KiB
Python

"""Tests for src/git_worktree (ORCH-2 / S-4): isolated worktree per task/branch.
Uses real local git repos in tmp (a bare 'origin' + a working main clone) so that
`git fetch origin`, `git worktree add`, branch creation from origin/main, reuse and
removal are all exercised without network access.
"""
import os
import subprocess
import tempfile
import pytest
# Env must be set before importing app modules (same convention as the other suites).
_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_wt.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 (
_safe,
get_worktree_path,
ensure_worktree,
remove_worktree,
)
def _git(cwd, *args):
return subprocess.run(["git", "-C", cwd, *args], capture_output=True, text=True)
@pytest.fixture
def repos(tmp_path, monkeypatch):
"""Build a bare 'origin' with main + a feature branch, plus a main clone at repos_dir/<repo>.
Returns the repo name. settings.repos_dir / worktrees_dir are pointed at tmp.
"""
repo = "enduro-trails"
repos_dir = tmp_path / "repos"
wt_dir = tmp_path / "repos" / "_wt"
repos_dir.mkdir(parents=True)
monkeypatch.setattr(git_worktree.settings, "repos_dir", str(repos_dir))
monkeypatch.setattr(git_worktree.settings, "worktrees_dir", str(wt_dir))
# Bare origin
origin = tmp_path / "origin.git"
subprocess.run(["git", "init", "--bare", "-b", "main", str(origin)], capture_output=True)
# Seed repo
seed = tmp_path / "seed"
seed.mkdir()
_git(str(seed), "init", "-b", "main")
_git(str(seed), "config", "user.email", "t@t")
_git(str(seed), "config", "user.name", "t")
(seed / "README.md").write_text("# seed\n")
_git(str(seed), "add", ".")
_git(str(seed), "commit", "-m", "init")
_git(str(seed), "remote", "add", "origin", str(origin))
_git(str(seed), "push", "origin", "main")
# An existing feature branch on origin
_git(str(seed), "checkout", "-b", "feature/existing")
(seed / "f.txt").write_text("feature\n")
_git(str(seed), "add", ".")
_git(str(seed), "commit", "-m", "feat")
_git(str(seed), "push", "origin", "feature/existing")
# Main clone at repos_dir/<repo>
main_clone = repos_dir / repo
subprocess.run(["git", "clone", str(origin), str(main_clone)], capture_output=True)
_git(str(main_clone), "config", "user.email", "t@t")
_git(str(main_clone), "config", "user.name", "t")
return repo
# ---------------------------------------------------------------------------
# _safe / get_worktree_path
# ---------------------------------------------------------------------------
class TestSafeAndPath:
def test_safe_replaces_slashes_and_specials(self):
assert _safe("feature/ET-001-x") == "feature_ET-001-x"
assert _safe("a b/c:d") == "a_b_c_d"
assert _safe("keep.dots-and_underscores") == "keep.dots-and_underscores"
def test_get_worktree_path(self, monkeypatch):
monkeypatch.setattr(git_worktree.settings, "worktrees_dir", "/repos/_wt")
assert get_worktree_path("repo", "feature/x") == "/repos/_wt/repo/feature_x"
# ---------------------------------------------------------------------------
# ensure_worktree
# ---------------------------------------------------------------------------
class TestEnsureWorktree:
def test_missing_main_repo_raises(self, tmp_path, monkeypatch):
monkeypatch.setattr(git_worktree.settings, "repos_dir", str(tmp_path / "nope"))
monkeypatch.setattr(git_worktree.settings, "worktrees_dir", str(tmp_path / "_wt"))
with pytest.raises(FileNotFoundError):
ensure_worktree("enduro-trails", "main")
def test_creates_worktree_for_existing_branch(self, repos):
wt = ensure_worktree(repos, "feature/existing")
assert os.path.isdir(wt)
assert wt == get_worktree_path(repos, "feature/existing")
# On the right branch
cur = _git(wt, "branch", "--show-current").stdout.strip()
assert cur == "feature/existing"
# Feature file from that branch is present (proves correct checkout)
assert os.path.isfile(os.path.join(wt, "f.txt"))
def test_creates_new_branch_from_origin_main(self, repos):
wt = ensure_worktree(repos, "feature/brand-new")
assert os.path.isdir(wt)
cur = _git(wt, "branch", "--show-current").stdout.strip()
assert cur == "feature/brand-new"
# Based on main -> README present, no feature file
assert os.path.isfile(os.path.join(wt, "README.md"))
assert not os.path.isfile(os.path.join(wt, "f.txt"))
def test_reuse_returns_same_path(self, repos):
wt1 = ensure_worktree(repos, "feature/existing")
wt2 = ensure_worktree(repos, "feature/existing")
assert wt1 == wt2
assert os.path.isdir(wt2)
def test_two_branches_are_isolated(self, repos):
a = ensure_worktree(repos, "feature/wt-A")
b = ensure_worktree(repos, "feature/wt-B")
assert a != b
ba = _git(a, "branch", "--show-current").stdout.strip()
bb = _git(b, "branch", "--show-current").stdout.strip()
assert ba == "feature/wt-A"
assert bb == "feature/wt-B"
# Writing in A must not affect B
with open(os.path.join(a, "only-a.txt"), "w") as f:
f.write("a")
assert not os.path.isfile(os.path.join(b, "only-a.txt"))
# ---------------------------------------------------------------------------
# remove_worktree
# ---------------------------------------------------------------------------
class TestRemoveWorktree:
def test_remove_deletes_worktree_dir(self, repos):
wt = ensure_worktree(repos, "feature/to-remove")
assert os.path.isdir(wt)
remove_worktree(repos, "feature/to-remove")
assert not os.path.isdir(wt)
def test_remove_nonexistent_is_noop(self, repos):
# Should not raise even if the worktree was never created.
remove_worktree(repos, "feature/never-made")