Skip to content

Commit 75b4d01

Browse files
authored
fix: serialize git worktree mutations (#111)
* fix: serialize git worktree mutations * fix: address worktree review nits
1 parent b6c966d commit 75b4d01

2 files changed

Lines changed: 145 additions & 48 deletions

File tree

Lines changed: 89 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,22 @@
11
from __future__ import annotations
22

3+
from contextlib import contextmanager
34
import re
45
import subprocess
6+
import threading
57
from pathlib import Path
68

79
from openvibecoding_orch.config import load_config
810

11+
try:
12+
import fcntl
13+
except Exception: # pragma: no cover - non-posix fallback
14+
fcntl = None
15+
16+
17+
_PROCESS_LOCK_GUARD = threading.Lock()
18+
_PROCESS_LOCKS: dict[str, threading.RLock] = {}
19+
920

1021
def _run_git(args: list[str], repo_root: Path) -> subprocess.CompletedProcess[str]:
1122
return subprocess.run(
@@ -27,6 +38,33 @@ def _repo_root() -> Path:
2738
return Path(result.stdout.strip())
2839

2940

41+
def _process_lock_for(path: Path) -> threading.RLock:
42+
key = str(path.resolve(strict=False))
43+
with _PROCESS_LOCK_GUARD:
44+
lock = _PROCESS_LOCKS.get(key)
45+
if lock is None:
46+
lock = threading.RLock()
47+
_PROCESS_LOCKS[key] = lock
48+
return lock
49+
50+
51+
@contextmanager
52+
def _worktree_file_lock(repo_root: Path):
53+
lock_path = repo_root / ".git" / "openvibecoding-worktrees.lock"
54+
lock_path.parent.mkdir(parents=True, exist_ok=True)
55+
process_lock = _process_lock_for(lock_path)
56+
with process_lock:
57+
if fcntl is None:
58+
yield None
59+
return
60+
with lock_path.open("a+", encoding="utf-8") as lock_file:
61+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
62+
try:
63+
yield lock_file
64+
finally:
65+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
66+
67+
3068
def _safe_segment(value: str) -> str:
3169
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip())
3270
return cleaned or "task"
@@ -45,66 +83,69 @@ def _is_stale_worktree_metadata_error(stderr: str) -> bool:
4583
def create_worktree(run_id: str, task_id: str, baseline_commit: str) -> Path:
4684
cfg = load_config()
4785
repo_root = _repo_root()
86+
with _worktree_file_lock(repo_root):
87+
prune = _run_git(["git", "worktree", "prune"], repo_root)
88+
_ensure_git_ok(prune, ["git", "worktree", "prune"])
4889

49-
prune = _run_git(["git", "worktree", "prune"], repo_root)
50-
_ensure_git_ok(prune, ["git", "worktree", "prune"])
90+
check = _run_git(["git", "cat-file", "-e", baseline_commit], repo_root)
91+
_ensure_git_ok(check, ["git", "cat-file", "-e", baseline_commit])
5192

52-
check = _run_git(["git", "cat-file", "-e", baseline_commit], repo_root)
53-
_ensure_git_ok(check, ["git", "cat-file", "-e", baseline_commit])
93+
safe_task = _safe_segment(task_id)
94+
worktree_path = cfg.worktree_root / run_id / safe_task
95+
worktree_path.parent.mkdir(parents=True, exist_ok=True)
96+
branch = _branch_name(run_id, task_id)
5497

55-
safe_task = _safe_segment(task_id)
56-
worktree_path = cfg.worktree_root / run_id / safe_task
57-
worktree_path.parent.mkdir(parents=True, exist_ok=True)
58-
branch = _branch_name(run_id, task_id)
59-
60-
cmd = [
61-
"git",
62-
"worktree",
63-
"add",
64-
"-b",
65-
branch,
66-
str(worktree_path),
67-
baseline_commit,
68-
]
69-
result = _run_git(cmd, repo_root)
70-
_ensure_git_ok(result, cmd)
71-
return worktree_path
98+
cmd = [
99+
"git",
100+
"worktree",
101+
"add",
102+
"-b",
103+
branch,
104+
str(worktree_path),
105+
baseline_commit,
106+
]
107+
result = _run_git(cmd, repo_root)
108+
_ensure_git_ok(result, cmd)
109+
return worktree_path
72110

73111

74112
def remove_worktree(run_id: str, task_id: str) -> None:
75113
cfg = load_config()
76114
repo_root = _repo_root()
77-
safe_task = _safe_segment(task_id)
78-
worktree_path = cfg.worktree_root / run_id / safe_task
79-
branch = _branch_name(run_id, task_id)
80-
81-
if worktree_path.exists():
82-
cmd_remove = ["git", "worktree", "remove", "--force", str(worktree_path)]
83-
result = _run_git(cmd_remove, repo_root)
84-
_ensure_git_ok(result, cmd_remove)
85-
86-
prune = _run_git(["git", "worktree", "prune"], repo_root)
87-
_ensure_git_ok(prune, ["git", "worktree", "prune"])
88-
89-
parent = worktree_path.parent
90-
if parent.exists() and parent.is_dir():
91-
try:
92-
parent.rmdir()
93-
except OSError:
94-
pass
95-
96-
cmd_branch = ["git", "branch", "-D", branch]
97-
result = _run_git(cmd_branch, repo_root)
98-
if result.returncode != 0 and _is_stale_worktree_metadata_error(result.stderr):
115+
with _worktree_file_lock(repo_root):
116+
safe_task = _safe_segment(task_id)
117+
worktree_path = cfg.worktree_root / run_id / safe_task
118+
branch = _branch_name(run_id, task_id)
119+
120+
if worktree_path.exists():
121+
cmd_remove = ["git", "worktree", "remove", "--force", str(worktree_path)]
122+
result = _run_git(cmd_remove, repo_root)
123+
_ensure_git_ok(result, cmd_remove)
124+
99125
prune = _run_git(["git", "worktree", "prune"], repo_root)
100126
_ensure_git_ok(prune, ["git", "worktree", "prune"])
127+
128+
parent = worktree_path.parent
129+
if parent.exists() and parent.is_dir():
130+
try:
131+
parent.rmdir()
132+
except OSError:
133+
# Best-effort cleanup only; parent may still hold other task worktrees.
134+
pass
135+
136+
cmd_branch = ["git", "branch", "-D", branch]
101137
result = _run_git(cmd_branch, repo_root)
102-
if result.returncode != 0 and "not found" not in result.stderr:
103-
_ensure_git_ok(result, cmd_branch)
138+
if result.returncode != 0 and _is_stale_worktree_metadata_error(result.stderr):
139+
prune = _run_git(["git", "worktree", "prune"], repo_root)
140+
_ensure_git_ok(prune, ["git", "worktree", "prune"])
141+
result = _run_git(cmd_branch, repo_root)
142+
if result.returncode != 0 and "not found" not in result.stderr:
143+
_ensure_git_ok(result, cmd_branch)
104144

105145

106146
def list_worktrees() -> list[str]:
107147
repo_root = _repo_root()
108-
result = _run_git(["git", "worktree", "list", "--porcelain"], repo_root)
109-
_ensure_git_ok(result, ["git", "worktree", "list", "--porcelain"])
110-
return [line for line in result.stdout.splitlines() if line.startswith("worktree ")]
148+
with _worktree_file_lock(repo_root):
149+
result = _run_git(["git", "worktree", "list", "--porcelain"], repo_root)
150+
_ensure_git_ok(result, ["git", "worktree", "list", "--porcelain"])
151+
return [line for line in result.stdout.splitlines() if line.startswith("worktree ")]

apps/orchestrator/tests/test_worktree_lock.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import os
22
import subprocess
3+
import threading
4+
import time
35
from pathlib import Path
46

57
from openvibecoding_orch.locks import locker
@@ -70,3 +72,57 @@ def test_locks_atomic(tmp_path: Path):
7072
os.environ["OPENVIBECODING_RUN_ID"] = "run_b"
7173
assert locker.acquire_lock(target) is True
7274
locker.release_lock(target)
75+
76+
77+
def test_worktree_create_serializes_parallel_mutations(tmp_path: Path, monkeypatch) -> None:
78+
repo = tmp_path / "repo_lock"
79+
repo.mkdir(parents=True)
80+
_init_repo(repo)
81+
(repo / "README.md").write_text("hello", encoding="utf-8")
82+
_git(["git", "add", "README.md"], cwd=repo)
83+
_git(["git", "commit", "-m", "init"], cwd=repo)
84+
baseline = _git_output(["git", "rev-parse", "HEAD"], cwd=repo).strip()
85+
86+
os.environ["OPENVIBECODING_WORKTREE_ROOT"] = str(tmp_path / "worktrees")
87+
cwd = Path.cwd()
88+
active_mutation_calls = 0
89+
overlap_detected = False
90+
call_guard = threading.Lock()
91+
92+
def _fake_run_git(args: list[str], repo_root: Path):
93+
nonlocal active_mutation_calls, overlap_detected
94+
if args[:3] == ["git", "rev-parse", "--show-toplevel"]:
95+
return subprocess.CompletedProcess(args, 0, str(repo) + "\n", "")
96+
if args[:3] == ["git", "cat-file", "-e"]:
97+
return subprocess.CompletedProcess(args, 0, "", "")
98+
if args[:3] == ["git", "worktree", "prune"] or args[:3] == ["git", "worktree", "add"]:
99+
with call_guard:
100+
active_mutation_calls += 1
101+
if active_mutation_calls > 1:
102+
overlap_detected = True
103+
try:
104+
time.sleep(0.05)
105+
if args[:3] == ["git", "worktree", "add"]:
106+
Path(args[5]).mkdir(parents=True, exist_ok=True)
107+
return subprocess.CompletedProcess(args, 0, "", "")
108+
finally:
109+
with call_guard:
110+
active_mutation_calls -= 1
111+
return subprocess.CompletedProcess(args, 0, "", "")
112+
113+
monkeypatch.setattr(manager, "_run_git", _fake_run_git)
114+
115+
try:
116+
os.chdir(repo)
117+
threads = [
118+
threading.Thread(target=manager.create_worktree, args=("run-a", "worker_core_01", baseline)),
119+
threading.Thread(target=manager.create_worktree, args=("run-b", "worker_frontend_01", baseline)),
120+
]
121+
for thread in threads:
122+
thread.start()
123+
for thread in threads:
124+
thread.join()
125+
finally:
126+
os.chdir(cwd)
127+
128+
assert overlap_detected is False

0 commit comments

Comments
 (0)