Skip to content

Commit 32284c2

Browse files
authored
fix: prevent missing commits during shallow clones (#4127)
Signed-off-by: Mouad BANI <mouad-mb@outlook.com>
1 parent 2ad18b1 commit 32284c2

6 files changed

Lines changed: 95 additions & 44 deletions

File tree

services/apps/git_integration/src/crowdgit/database/crud.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from crowdgit.models.repository import Repository
99
from crowdgit.models.service_execution import ServiceExecution
1010
from crowdgit.settings import (
11+
FAILED_RETRY_INTERVAL_HOURS,
1112
MAX_CONCURRENT_ONBOARDINGS,
1213
MAX_INTEGRATION_RESULTS,
1314
REPOSITORY_UPDATE_INTERVAL_HOURS,
@@ -143,7 +144,9 @@ async def acquire_recurrent_repo() -> Repository | None:
143144
WHERE NOT (rp.state = ANY($2))
144145
AND rp."lockedAt" IS NULL
145146
AND r."deletedAt" IS NULL
146-
AND rp."lastProcessedAt" < NOW() - INTERVAL '1 hour' * $3
147+
AND rp."lastProcessedAt" < NOW() - INTERVAL '1 hour' * (
148+
CASE WHEN rp.state = 'failed' THEN $4::numeric ELSE $3::numeric END
149+
)
147150
AND NOT (
148151
r.url LIKE '%gerrit.automotivelinux.org%'
149152
AND EXISTS (SELECT 1 FROM automotivelinux_processing)
@@ -170,7 +173,12 @@ async def acquire_recurrent_repo() -> Repository | None:
170173
)
171174
return await acquire_repository(
172175
recurrent_repo_sql_query,
173-
(RepositoryState.PROCESSING, states_to_exclude, REPOSITORY_UPDATE_INTERVAL_HOURS),
176+
(
177+
RepositoryState.PROCESSING,
178+
states_to_exclude,
179+
REPOSITORY_UPDATE_INTERVAL_HOURS,
180+
FAILED_RETRY_INTERVAL_HOURS,
181+
),
174182
)
175183

176184

services/apps/git_integration/src/crowdgit/errors.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ class NetworkError(CrowdGitError):
4747

4848

4949
@dataclass
50-
class PermissionError(CrowdGitError):
50+
class RepoPermissionError(CrowdGitError):
5151
error_message: str = "Permission denied"
5252
error_code: ErrorCode = ErrorCode.PERMISSION_ERROR
5353

services/apps/git_integration/src/crowdgit/services/clone/clone_service.py

Lines changed: 73 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@
3333
)
3434

3535
DEFAULT_STORAGE_OPTIMIZATION_THRESHOLD_MB = 10000
36+
INITIAL_BATCH_DEEPEN = 50
37+
MAX_BATCH_DEEPEN = 10000
38+
BATCH_DEEPEN_MULTIPLIER = 2
3639

3740
RETRYABLE_CLONE_ERRORS = (RateLimitError, NetworkError, RemoteServerError)
3841

@@ -57,6 +60,16 @@ def __init__(self):
5760
def _is_gerrit_remote(remote: str) -> bool:
5861
return any(pattern in remote for pattern in GERRIT_PATTERNS)
5962

63+
async def _configure_global_git_client(self, path: str) -> None:
64+
# Increase post buffer to reduce RPC failures on large repos
65+
await run_shell_command(
66+
["git", "config", "--global", "http.postBuffer", "524288000"], cwd=path
67+
)
68+
# Disable recursive submodule fetches
69+
await run_shell_command(
70+
["git", "config", "--global", "fetch.recurseSubmodules", "false"], cwd=path
71+
)
72+
6073
async def _check_if_final_batch(self, path: str, target_commit_hash: str | None) -> bool:
6174
"""
6275
Final batch is determined if:
@@ -74,20 +87,62 @@ async def _check_if_final_batch(self, path: str, target_commit_hash: str | None)
7487
await run_shell_command(
7588
["git", "rev-parse", "--verify", f"{target_commit_hash}^{{commit}}"], cwd=path
7689
)
90+
has_boundary_in_required_range = await self._has_boundary_in_required_range(
91+
path, target_commit_hash
92+
)
93+
if has_boundary_in_required_range:
94+
self.logger.info(
95+
f"Target commit {target_commit_hash} reached, but shallow boundary still intersects required range. Continuing deepen."
96+
)
97+
return False
7798
self.logger.info(f"Target commit {target_commit_hash} reached")
7899
return True
79100
except CommandExecutionError:
80101
return False
81102

103+
async def _read_shallow_file(self, repo_path: str) -> list[str]:
104+
"""
105+
Read commit hashes from .git/shallow.
106+
Returns an empty list when the file does not exist (full clone).
107+
"""
108+
shallow_file = os.path.join(repo_path, ".git", "shallow")
109+
try:
110+
async with aiofiles.open(shallow_file, "r", encoding="utf-8") as f:
111+
return [line.strip() for line in await f.readlines() if line.strip()]
112+
except FileNotFoundError:
113+
return []
114+
115+
async def _get_shallow_boundary_commits(self, repo_path: str) -> set[str]:
116+
"""
117+
Return all shallow boundary commits from .git/shallow.
118+
"""
119+
return set(await self._read_shallow_file(repo_path))
120+
121+
async def _has_boundary_in_required_range(
122+
self, repo_path: str, target_commit_hash: str
123+
) -> bool:
124+
"""
125+
Check if any shallow boundary commit is inside the current required range.
126+
127+
If true, the required range is still truncated and the clone should be deepened.
128+
"""
129+
shallow_boundaries = await self._get_shallow_boundary_commits(repo_path)
130+
if not shallow_boundaries:
131+
return False
132+
133+
required_commits_output = await run_shell_command(
134+
["git", "rev-list", f"{target_commit_hash}..HEAD"],
135+
cwd=repo_path,
136+
)
137+
required_commits = {line.strip() for line in required_commits_output.splitlines() if line}
138+
problematic_boundaries = (required_commits & shallow_boundaries) - {target_commit_hash}
139+
return bool(problematic_boundaries)
140+
82141
@retry_on_clone_error
83142
async def _perform_minimal_clone(self, path: str, remote: str) -> None:
84143
"""
85144
Perform minimal clone of depth=1
86145
"""
87-
# increasing post buffer to avoid RPC failed error
88-
await run_shell_command(
89-
["git", "config", "--global", "http.postBuffer", "524288000"], cwd=path
90-
)
91146
self.logger.info("Initializing minimal clone")
92147
await run_shell_command(
93148
["git", "clone", "--depth=1", "--no-tags", "--single-branch", remote, "."], cwd=path
@@ -196,21 +251,19 @@ async def _update_batch_info(
196251
batch_info.is_final_batch = await self._check_if_final_batch(repo_path, target_commit_hash)
197252
batch_info.edge_commit = await self._get_edge_commit(repo_path)
198253

199-
async def _get_edge_commit(self, repo_path: str):
254+
async def _get_edge_commit(self, repo_path: str) -> str | None:
200255
"""
201256
Returns the edge commit of a shallow clone by reading the .git/shallow file,
202257
which contains the boundary commit(s) when history is truncated.
203258
204259
If the full history has been cloned, the .git/shallow file does not exist.
205260
"""
206-
shallow_file = os.path.join(repo_path, ".git", "shallow")
207-
try:
208-
async with aiofiles.open(shallow_file, "r", encoding="utf-8") as f:
209-
oldest_commit = (await f.readline()).strip()
210-
self.logger.info(f"Edge commit: {oldest_commit}")
211-
return oldest_commit
212-
except FileNotFoundError:
261+
boundaries = await self._read_shallow_file(repo_path)
262+
if not boundaries:
213263
return None
264+
oldest_commit = boundaries[0]
265+
self.logger.info(f"Edge commit: {oldest_commit}")
266+
return oldest_commit
214267

215268
async def _cleanup_temp_directory(self, temp_repo_path: str, repo_id: str) -> None:
216269
"""
@@ -288,26 +341,6 @@ async def _cleanup_working_directory(self, repo_path: str) -> None:
288341

289342
self.logger.info("Working directory cleanup completed")
290343

291-
async def _calculate_batch_depth(self, repo_path: str, remote: str) -> int:
292-
calculated_depth = None
293-
total_branches_tags = await run_shell_command(
294-
["git", "ls-remote", "--heads", "--tags", remote], cwd=repo_path
295-
)
296-
total_branches_tags = len(total_branches_tags.splitlines())
297-
if total_branches_tags <= 200:
298-
# Small repo, get a decent amount of history
299-
calculated_depth = 100
300-
elif total_branches_tags <= 1000:
301-
# Medium repo, get a moderate amount of history
302-
calculated_depth = 50
303-
else:
304-
# Large repo, get less history
305-
calculated_depth = 5
306-
self.logger.info(
307-
f"total_branches_tags={total_branches_tags}, calculated_depth={calculated_depth}"
308-
)
309-
return calculated_depth
310-
311344
@retry_on_clone_error
312345
async def _perform_full_clone(self, repo_path: str, remote: str):
313346
"""Perform full repository clone"""
@@ -372,6 +405,7 @@ async def determine_clone_strategy(
372405
self.logger.info(
373406
f"Starting clone decision for {remote} (branch: {branch}, last_commit: {last_processed_commit})"
374407
)
408+
await self._configure_global_git_client(repo_path)
375409

376410
default_branch_changed = await self.has_default_branch_changed(remote, branch)
377411

@@ -405,6 +439,7 @@ async def clone_batches_generator(
405439
error_code = None
406440
error_message = None
407441
total_execution_time = 0.0
442+
batch_depth = INITIAL_BATCH_DEEPEN
408443
remote = repository.url.removesuffix(".git")
409444

410445
batch_info = CloneBatchInfo(
@@ -420,8 +455,6 @@ async def clone_batches_generator(
420455
clone_with_batches = await self.determine_clone_strategy(
421456
temp_repo_path, remote, repository.branch, repository.last_processed_commit
422457
)
423-
if clone_with_batches:
424-
batch_depth = await self._calculate_batch_depth(temp_repo_path, remote)
425458
await self._update_batch_info(
426459
batch_info, temp_repo_path, repository.last_processed_commit, clone_with_batches
427460
)
@@ -447,6 +480,12 @@ async def clone_batches_generator(
447480
total_execution_time += round(batch_end_time - batch_start_time, 2)
448481

449482
yield batch_info
483+
if not batch_info.is_final_batch:
484+
# exponential deepen increment to speed up fetching
485+
batch_depth = min(
486+
batch_depth * BATCH_DEEPEN_MULTIPLIER,
487+
MAX_BATCH_DEEPEN,
488+
)
450489

451490
except Exception as e:
452491
# Handle both CrowdGitError and generic Exception

services/apps/git_integration/src/crowdgit/services/utils.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@
99
EmptyRepoError,
1010
ForbiddenError,
1111
NetworkError,
12-
PermissionError,
1312
RateLimitError,
1413
RemoteServerError,
1514
RepoAuthRequiredError,
15+
RepoPermissionError,
1616
ValidationError,
1717
)
1818
from crowdgit.logger import logger
@@ -118,8 +118,11 @@ async def get_remote_default_branch(remote_url: str) -> str | None:
118118
# Fallback: if symbolic ref not available, try common branches
119119
for branch in ["main", "master"]:
120120
try:
121-
await run_shell_command(["git", "ls-remote", "--heads", remote_url, branch])
122-
return branch
121+
output = await run_shell_command(
122+
["git", "ls-remote", "--heads", remote_url, branch]
123+
)
124+
if output.strip():
125+
return branch
123126
except CommandExecutionError:
124127
continue
125128

@@ -171,7 +174,7 @@ async def get_default_branch(repo_path: str) -> str:
171174
# (stderr_patterns, exception_class)
172175
({"No space left on device"}, DiskSpaceError),
173176
({"Network is unreachable", "Connection refused", "Connection timed out"}, NetworkError),
174-
({"Permission denied"}, PermissionError),
177+
({"Permission denied"}, RepoPermissionError),
175178
({"The requested URL returned error: 403"}, ForbiddenError),
176179
({"The requested URL returned error: 429"}, RateLimitError),
177180
({"The requested URL returned error: 5"}, RemoteServerError),
@@ -231,7 +234,7 @@ async def run_shell_command(
231234
CommandTimeoutError: When command times out
232235
DiskSpaceError: When disk space is insufficient
233236
NetworkError: When network connectivity issues occur
234-
PermissionError: When permission is denied
237+
RepoPermissionError: When permission is denied
235238
CommandExecutionError: For other command failures
236239
"""
237240
process = None

services/apps/git_integration/src/crowdgit/settings.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ def load_env_var(key: str, required=True, default=None):
99

1010

1111
CROWD_DB_WRITE_HOST = load_env_var("CROWD_DB_WRITE_HOST")
12-
CROWD_DB_PORT = load_env_var("CROWD_DB_PORT")
12+
CROWD_DB_PORT = int(load_env_var("CROWD_DB_PORT"))
1313
CROWD_DB_USERNAME = load_env_var("CROWD_DB_USERNAME")
1414
CROWD_DB_PASSWORD = load_env_var("CROWD_DB_PASSWORD")
1515
CROWD_DB_DATABASE = load_env_var("CROWD_DB_DATABASE")
@@ -18,6 +18,7 @@ def load_env_var(key: str, required=True, default=None):
1818
REPOSITORY_UPDATE_INTERVAL_HOURS = int(
1919
load_env_var("REPOSITORY_UPDATE_INTERVAL_HOURS", default=24)
2020
)
21+
FAILED_RETRY_INTERVAL_HOURS = int(load_env_var("FAILED_RETRY_INTERVAL_HOURS", default="6"))
2122
DEFAULT_TENANT_ID = load_env_var(
2223
"CROWD_SSO_LF_TENANT_ID", default="875c38bd-2b1b-4e91-ad07-0cfbabb4c49f"
2324
)

services/apps/git_integration/src/crowdgit/worker/repository_worker.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ async def _process_repositories(self):
145145
)
146146
finally:
147147
if available_repo_to_process:
148-
logger.info("releasing repo: ", available_repo_to_process.url)
148+
logger.info(f"releasing repo: {available_repo_to_process.url}")
149149
await release_repo(available_repo_to_process.id)
150150
logger.info(f"Repo {available_repo_to_process.url} released!")
151151

0 commit comments

Comments
 (0)