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).
This commit is contained in:
152
tests/test_git_worktree.py
Normal file
152
tests/test_git_worktree.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user