Skip to content

Commit 456eb32

Browse files
authored
Add filelock for git operations (#28)
This PR wraps all git operations in `get_git_repository` with a per-repository `FileLock` to prevent concurrent tasks from potentially corrupting a shared local clone. Also, if fetching from the remote fails, the repository is automatically wiped and re-cloned to ensure a clean state.
1 parent 29250e0 commit 456eb32

3 files changed

Lines changed: 65 additions & 65 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ dependencies = [
2828
"httpx~=0.27",
2929
"uvicorn~=0.30",
3030
"valkey",
31+
"filelock",
3132
]
3233

3334
[project.urls]

src/tmt_web/utils/git_handler.py

Lines changed: 57 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import re
1111
from shutil import rmtree
1212

13+
from filelock import FileLock
1314
from tmt import Logger
1415
from tmt._compat.pathlib import Path
1516
from tmt.utils import Command, Common, GeneralError, RunError
@@ -21,7 +22,7 @@
2122
ROOT_DIR = Path(__file__).resolve().parents[2]
2223

2324

24-
def create_hash(text: str):
25+
def _create_hash(text: str):
2526
"""Create hash of the given text that is consistent across runs."""
2627
hashed_text = hashlib.new("sha1", usedforsecurity=False)
2728
hashed_text.update(text.encode())
@@ -35,9 +36,7 @@ def get_unique_clone_path(url: str) -> Path:
3536
:param url: Repository URL
3637
:return: Unique path for cloning
3738
"""
38-
url = url.rstrip("/").removesuffix(".git")
39-
clone_dir_name = create_hash(url)
40-
return ROOT_DIR / settings.CLONE_DIR_PATH / clone_dir_name
39+
return ROOT_DIR / settings.CLONE_DIR_PATH / _create_hash(url.rstrip("/").removesuffix(".git"))
4140

4241

4342
def clear_tmp_dir(logger: Logger) -> None:
@@ -59,13 +58,13 @@ def clear_tmp_dir(logger: Logger) -> None:
5958
raise GeneralError(f"Failed to clear repository clone directory '{path}'") from err
6059

6160

62-
def clone_repository(url: str, logger: Logger) -> Path:
61+
def clone_repository(url: str, destination: Path, logger: Logger) -> Path:
6362
"""
6463
Clone a Git repository to a unique path.
6564
6665
:param url: Repository URL
66+
:param destination: Path to where repository should be cloned
6767
:param logger: Logger instance
68-
:param ref: Optional ref to checkout
6968
:return: Path to the cloned repository
7069
:raises: GitUrlError if URL is invalid
7170
:raises: GeneralError if clone fails
@@ -74,9 +73,6 @@ def clone_repository(url: str, logger: Logger) -> Path:
7473
# Validate URL
7574
url = check_git_url(url, logger)
7675

77-
# Get unique path
78-
destination = get_unique_clone_path(url)
79-
8076
# Clone with retry logic
8177
git_clone(url=url, destination=destination, logger=logger)
8278

@@ -96,31 +92,42 @@ def get_git_repository(url: str, logger: Logger, ref: str | None = None) -> Path
9692
:raises: AttributeError if ref doesn't exist
9793
"""
9894
destination = get_unique_clone_path(url)
99-
if not destination.exists():
100-
clone_repository(url, logger)
95+
with FileLock(destination.with_name(f"{destination.name}.lock")):
96+
if not destination.exists():
97+
clone_repository(url, destination, logger)
98+
99+
common = Common(logger=logger)
100+
101+
try:
102+
# Fetch remote refs
103+
_fetch_remote(common, destination, logger)
104+
except GeneralError:
105+
logger.warning("Unable to fetch remote repository. Trying to clone again.")
106+
rmtree(destination, ignore_errors=True)
107+
clone_repository(url, destination, logger)
108+
109+
# If no ref is specified, the default branch is used
110+
ref = ref or _get_default_branch(common, destination, logger)
101111

102-
common = Common(logger=logger)
112+
# If the ref is a branch, ensure it's up to date
113+
if _is_branch(common, destination, ref):
114+
_reset_branch(common, destination, ref, logger)
103115

104-
# Fetch remote refs
105-
_fetch_remote(common, destination, logger)
116+
_checkout(common, destination, ref, logger)
117+
118+
return destination
106119

107-
# If no ref is specified, the default branch is used
108-
ref = ref or _get_default_branch(common, destination, logger)
109120

121+
def _checkout(common: Common, repo_path: Path, ref: str, logger: Logger):
122+
repo_status = _get_repository_status(common, repo_path)
110123
try:
111-
common.run(Command("git", "checkout", ref), cwd=destination)
124+
common.run(Command("git", "checkout", ref), cwd=repo_path)
112125
except RunError as err:
113126
logger.fail(f"Failed to checkout ref '{ref}': {err.stderr}")
127+
if repo_status:
128+
logger.fail(f"Previous repository status:\n{repo_status}")
114129
raise AttributeError(f"Failed to checkout ref '{ref}'") from err
115130

116-
_ensure_no_changes(common, destination, logger)
117-
118-
# If the ref is a branch, ensure it's up to date
119-
if _is_branch(common, destination, ref):
120-
_update_branch(common, destination, ref, logger)
121-
122-
return destination
123-
124131

125132
def _get_default_branch(common: Common, repo_path: Path, logger: Logger) -> str:
126133
"""Determine the default branch of a Git repository using a remote HEAD."""
@@ -147,6 +154,7 @@ def _get_default_branch(common: Common, repo_path: Path, logger: Logger) -> str:
147154

148155
def _fetch_remote(common: Common, repo_path: Path, logger: Logger) -> None:
149156
"""Fetch updates from the remote repository."""
157+
repo_status = _get_repository_status(common, repo_path)
150158
try:
151159
# Fetch all branches and tags, prune deleted ones
152160
common.run(
@@ -163,53 +171,24 @@ def _fetch_remote(common: Common, repo_path: Path, logger: Logger) -> None:
163171
)
164172
except RunError as err:
165173
logger.fail(f"Failed to fetch remote for repository '{repo_path}': {err.stderr}")
174+
if repo_status:
175+
logger.fail(f"Previous repository status:\n{repo_status}")
166176
raise GeneralError(f"Failed to fetch remote for repository '{repo_path}'") from err
167177

168178

169-
def _update_branch(common: Common, repo_path: Path, branch: str, logger: Logger) -> None:
179+
def _reset_branch(common: Common, repo_path: Path, branch: str, logger: Logger) -> None:
170180
"""Ensure the specified branch is up to date with its remote counterpart."""
181+
repo_status = _get_repository_status(common, repo_path)
171182
try:
172-
common.run(Command("git", "show-branch", f"origin/{branch}"), cwd=repo_path)
173-
except RunError as err:
174-
logger.fail(f"Branch '{branch}' does not exist in repository '{repo_path}': {err.stderr}")
175-
raise GeneralError(f"Branch {branch}' does not exist in repository '{repo_path}'") from err
176-
try:
177-
# Check if the branch is already up to date
178-
common.run(Command("git", "diff", "--quiet", branch, f"origin/{branch}"), cwd=repo_path)
179-
return
180-
except RunError:
181-
# Branch is not up to date, proceed with update
182-
try:
183-
common.run(Command("git", "reset", "--hard", f"origin/{branch}"), cwd=repo_path)
184-
except RunError as err:
185-
logger.fail(
186-
f"Failed to update branch '{branch}' for repository '{repo_path}': {err.stderr}"
187-
)
188-
raise GeneralError(
189-
f"Failed to update branch '{branch}' for repository '{repo_path}'"
190-
) from err
191-
192-
193-
def _ensure_no_changes(common: Common, repo_path: Path, logger: Logger) -> None:
194-
"""Ensure there are no changes in the repository."""
195-
try:
196-
output = common.run(Command("git", "status", "--porcelain"), cwd=repo_path)
197-
if not output.stdout or not output.stdout.strip():
198-
return
199-
logger.warning(f"Repository '{repo_path}' has changes:\n{output.stdout.strip()}")
200-
except RunError as err:
201-
logger.fail(f"Failed to check repository status for '{repo_path}': {err.stderr}")
202-
raise GeneralError(f"Failed to check repository status for '{repo_path}'") from err
203-
204-
try:
205-
common.run(Command("git", "restore", "."), cwd=repo_path)
206-
common.run(Command("git", "clean", "-fdx"), cwd=repo_path)
183+
common.run(Command("git", "reset", "--hard", f"origin/{branch}"), cwd=repo_path)
207184
except RunError as err:
208185
logger.fail(
209-
f"Repository '{repo_path}' has changes that could not be reverted: {err.stderr}"
186+
f"Failed to update branch '{branch}' for repository '{repo_path}': {err.stderr}"
210187
)
188+
if repo_status:
189+
logger.fail(f"Previous repository status:\n{repo_status}")
211190
raise GeneralError(
212-
f"Repository '{repo_path}' has changes that could not be reverted"
191+
f"Failed to update branch '{branch}' for repository '{repo_path}'"
213192
) from err
214193

215194

@@ -224,3 +203,18 @@ def _is_branch(common: Common, repo_path: Path, ref: str) -> bool:
224203
return True
225204
except RunError:
226205
return False
206+
207+
208+
def _get_repository_status(common: Common, repo_path: Path) -> str | None:
209+
"""Get the current status of a Git repository."""
210+
try:
211+
log_result = common.run(Command("git", "log", "-1"), cwd=repo_path)
212+
status_result = common.run(Command("git", "status"), cwd=repo_path)
213+
214+
outputs = [output.strip() for output in (log_result.stdout, status_result.stdout) if output]
215+
if outputs:
216+
return "\n".join(outputs)
217+
218+
return None
219+
except RunError:
220+
return None

tests/unit/test_git_handler.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,9 @@ def test_get_unique_clone_path_valid_url(self):
7474
def test_clone_repository_invalid_url(self, logger):
7575
"""Test cloning with invalid repository URL."""
7676
with pytest.raises(GitUrlError):
77-
git_handler.clone_repository(self.INVALID_REPO, logger)
77+
git_handler.clone_repository(
78+
self.INVALID_REPO, git_handler.get_unique_clone_path(self.INVALID_REPO), logger
79+
)
7880

7981
@pytest.mark.usefixtures("_clean_repo_dir")
8082
def test_get_git_repository_new(self, logger):
@@ -124,7 +126,10 @@ def test_get_git_repository_existing_checkout_error(self, mocker, logger):
124126
def side_effect(cmd, *args, **kwargs):
125127
if cmd._command == ["git", "checkout", "invalid-branch"]:
126128
raise RunError("Command failed", cmd, 1)
127-
return mocker.DEFAULT
129+
result = mocker.MagicMock()
130+
result.stdout = None
131+
result.stderr = None
132+
return result
128133

129134
mocker.patch("tmt.utils.Command.run", side_effect=side_effect, autospec=True)
130135

0 commit comments

Comments
 (0)