- 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).
153 lines
6.1 KiB
Python
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")
|