Coverage for little_loops / parallel / git_lock.py: 25%
55 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-03-18 16:18 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-03-18 16:18 -0500
1"""Thread-safe git operations with retry logic for parallel processing.
3Provides a shared lock for git operations on the main repository to prevent
4concurrent operations from conflicting over .git/index.lock.
6The index.lock race condition occurs when:
7- MergeCoordinator runs stash/merge/pull operations
8- WorkerPool captures baseline git status or cleans up leaked files
9- Both touch the main repo's git index simultaneously
11This module provides:
12- A threading lock to serialize git operations on the main repo
13- Retry logic with exponential backoff for transient index.lock errors
14"""
16from __future__ import annotations
18import subprocess
19import threading
20import time
21from pathlib import Path
22from typing import TYPE_CHECKING
24if TYPE_CHECKING:
25 from little_loops.logger import Logger
28class GitLock:
29 """Thread-safe wrapper for git operations on a repository.
31 Serializes all git operations through a single lock to prevent
32 index.lock conflicts. Provides automatic retry with exponential
33 backoff for transient lock errors.
35 Example:
36 >>> git_lock = GitLock(logger)
37 >>> # Use context manager for custom operations
38 >>> with git_lock:
39 ... subprocess.run(["git", "status"], cwd=repo_path)
40 >>> # Or use the run method for automatic retry
41 >>> result = git_lock.run(["status"], cwd=repo_path)
42 """
44 def __init__(
45 self,
46 logger: Logger | None = None,
47 max_retries: int = 3,
48 initial_backoff: float = 0.5,
49 max_backoff: float = 8.0,
50 ) -> None:
51 """Initialize the git lock.
53 Args:
54 logger: Optional logger for retry messages
55 max_retries: Maximum number of retries on index.lock error
56 initial_backoff: Initial backoff delay in seconds
57 max_backoff: Maximum backoff delay in seconds
58 """
59 # RLock allows same thread to acquire lock multiple times
60 # (needed for nested git operations within a single method)
61 self._lock = threading.RLock()
62 self._logger = logger
63 self.max_retries = max_retries
64 self.initial_backoff = initial_backoff
65 self.max_backoff = max_backoff
67 def __enter__(self) -> GitLock:
68 """Acquire the lock for manual git operations."""
69 self._lock.acquire()
70 return self
72 def __exit__(
73 self,
74 exc_type: type[BaseException] | None,
75 exc_val: BaseException | None,
76 exc_tb: object,
77 ) -> None:
78 """Release the lock."""
79 self._lock.release()
81 def run(
82 self,
83 args: list[str],
84 cwd: Path,
85 timeout: float = 30,
86 capture_output: bool = True,
87 text: bool = True,
88 ) -> subprocess.CompletedProcess[str]:
89 """Run a git command with lock and retry logic.
91 Args:
92 args: Git command arguments (without 'git' prefix)
93 cwd: Working directory for the command
94 timeout: Command timeout in seconds
95 capture_output: Whether to capture stdout/stderr
96 text: Whether to decode output as text
98 Returns:
99 CompletedProcess with command result
100 """
101 with self._lock:
102 return self._run_with_retry(
103 args=args,
104 cwd=cwd,
105 timeout=timeout,
106 capture_output=capture_output,
107 text=text,
108 )
110 def _run_with_retry(
111 self,
112 args: list[str],
113 cwd: Path,
114 timeout: float,
115 capture_output: bool,
116 text: bool,
117 ) -> subprocess.CompletedProcess[str]:
118 """Run git command with retry on index.lock errors.
120 Args:
121 args: Git command arguments (without 'git' prefix)
122 cwd: Working directory
123 timeout: Command timeout
124 capture_output: Whether to capture output
125 text: Whether to decode as text
127 Returns:
128 CompletedProcess with result
129 """
130 cmd = ["git"] + args
131 backoff = self.initial_backoff
132 last_result: subprocess.CompletedProcess[str] | None = None
134 for attempt in range(self.max_retries + 1):
135 try:
136 result = subprocess.run(
137 cmd,
138 cwd=cwd,
139 timeout=timeout,
140 capture_output=capture_output,
141 text=text,
142 )
144 # Check for index.lock error
145 if result.returncode != 0 and self._is_index_lock_error(result.stderr or ""):
146 last_result = result
147 if attempt < self.max_retries:
148 if self._logger:
149 self._logger.debug(
150 f"Git index.lock conflict, retrying in {backoff:.1f}s "
151 f"(attempt {attempt + 1}/{self.max_retries})"
152 )
153 time.sleep(backoff)
154 backoff = min(backoff * 2, self.max_backoff)
155 continue
157 return result
159 except subprocess.TimeoutExpired:
160 if attempt < self.max_retries:
161 if self._logger:
162 self._logger.debug(
163 f"Git command timed out, retrying in {backoff:.1f}s "
164 f"(attempt {attempt + 1}/{self.max_retries})"
165 )
166 time.sleep(backoff)
167 backoff = min(backoff * 2, self.max_backoff)
168 else:
169 raise
171 # Return last result if we exhausted retries
172 if last_result is not None:
173 if self._logger:
174 self._logger.warning(
175 f"Git command failed after {self.max_retries} retries: "
176 f"{last_result.stderr[:200] if last_result.stderr else 'unknown error'}"
177 )
178 return last_result
180 # Should not reach here, but provide fallback
181 raise RuntimeError(f"Git command failed after {self.max_retries} retries")
183 @staticmethod
184 def _is_index_lock_error(stderr: str) -> bool:
185 """Check if error is due to index.lock conflict.
187 Args:
188 stderr: Error output from git command
190 Returns:
191 True if this is an index.lock error that may be retried
192 """
193 if not stderr:
194 return False
196 indicators = [
197 "index.lock",
198 "Unable to create",
199 "Another git process seems to be running",
200 "File exists",
201 ]
203 return any(indicator in stderr for indicator in indicators)