Skip to content

Commit cd5292c

Browse files
authored
fix: clone all batches before processing commits to avoid broken range [CM-1200] (#4138)
Signed-off-by: Mouad BANI <mouad-mb@outlook.com>
1 parent 0e22e13 commit cd5292c

7 files changed

Lines changed: 379 additions & 446 deletions

File tree

services/apps/git_integration/src/crowdgit/models/clone_batch.py

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,6 @@ class CloneBatchInfo(BaseModel):
1111
latest_commit_in_repo: str | None = Field(
1212
None, description="Hash of the latest commit in repo"
1313
)
14-
edge_commit: str | None = Field(
15-
default=None,
16-
description="The oldest commit in the current batch, used to track progress during incremental processing.",
17-
)
18-
prev_batch_edge_commit: str | None = Field(
19-
default=None,
20-
description="The edge commit from the previous batch, used to track progress during incremental processing.",
21-
)
2214
clone_with_batches: bool = Field(
2315
default=True, description="Whether repo is cloned with batches"
2416
)

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

Lines changed: 71 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import tempfile
44
import time
55
from collections.abc import AsyncIterator
6+
from datetime import datetime, timezone
67
from decimal import Decimal
78

89
import aiofiles
@@ -22,6 +23,7 @@
2223
NetworkError,
2324
RateLimitError,
2425
RemoteServerError,
26+
ReOnboardingRequiredError,
2527
)
2628
from crowdgit.models import CloneBatchInfo, Repository, ServiceExecution
2729
from crowdgit.services.base.base_service import BaseService
@@ -31,6 +33,10 @@
3133
get_repo_name,
3234
run_shell_command,
3335
)
36+
from crowdgit.settings import (
37+
STUCK_ONBOARDING_REPO_TIMEOUT_HOURS,
38+
STUCK_RECURRENT_REPO_TIMEOUT_HOURS,
39+
)
3440

3541
DEFAULT_STORAGE_OPTIMIZATION_THRESHOLD_MB = 10000
3642
INITIAL_BATCH_DEEPEN = 50
@@ -70,19 +76,59 @@ async def _configure_global_git_client(self, path: str) -> None:
7076
["git", "config", "--global", "fetch.recurseSubmodules", "false"], cwd=path
7177
)
7278

73-
async def _check_if_final_batch(self, path: str, target_commit_hash: str | None) -> bool:
79+
async def _is_shallow_clone(self, path: str) -> bool:
80+
result = await run_shell_command(["git", "rev-parse", "--is-shallow-repository"], cwd=path)
81+
return "true" in result
82+
83+
def _is_timeout_reached(self, repository: Repository) -> bool:
84+
processing_duration_hours = (
85+
datetime.now(timezone.utc) - repository.locked_at.astimezone(timezone.utc)
86+
).total_seconds() / 3600
87+
is_onboarding = repository.last_processed_commit is None
88+
timeout_hours = (
89+
STUCK_ONBOARDING_REPO_TIMEOUT_HOURS
90+
if is_onboarding
91+
else STUCK_RECURRENT_REPO_TIMEOUT_HOURS
92+
)
93+
if processing_duration_hours >= timeout_hours:
94+
self.logger.warning(
95+
f"Repo {repository.url} is stuck for {processing_duration_hours:.1f} hours — queuing for re-onboarding"
96+
)
97+
return True
98+
return False
99+
100+
async def _check_if_final_batch(self, path: str, repository: Repository) -> bool:
74101
"""
75-
Final batch is determined if:
76-
- full history is cloned (no longer shallow_clone)
77-
- target commit reached
102+
Check whether the current batch is the final one.
103+
104+
Returns True when:
105+
- full history fetched (onboarding — no last_processed_commit to look for), or
106+
- all commits between last_processed_commit and HEAD are available (no shallow boundary in range).
107+
108+
Raises ReOnboardingRequiredError when:
109+
- force push detected (full clone but last_processed_commit missing), or
110+
- processing timeout exceeded while still deepening.
78111
"""
79-
is_shallow_clone = await run_shell_command(
80-
["git", "rev-parse", "--is-shallow-repository"], cwd=path
81-
)
82-
if "false" in is_shallow_clone:
112+
is_shallow_clone = await self._is_shallow_clone(path)
113+
is_full_clone = not is_shallow_clone
114+
115+
target_commit_hash = repository.last_processed_commit
116+
if is_full_clone and not target_commit_hash:
117+
# fully cloned at once
83118
return True
84-
if not target_commit_hash:
85-
return False
119+
120+
reached_target_commit = await self._has_required_commit_range(path, target_commit_hash)
121+
if reached_target_commit:
122+
return True
123+
124+
timeout_reached = self._is_timeout_reached(repository)
125+
force_push_detected = is_full_clone and not reached_target_commit
126+
if timeout_reached or force_push_detected:
127+
raise ReOnboardingRequiredError()
128+
129+
return False
130+
131+
async def _has_required_commit_range(self, path: str, target_commit_hash: str) -> bool:
86132
try:
87133
await run_shell_command(
88134
["git", "rev-parse", "--verify", f"{target_commit_hash}^{{commit}}"], cwd=path
@@ -225,13 +271,14 @@ async def _update_batch_info(
225271
self,
226272
batch_info: CloneBatchInfo,
227273
repo_path: str,
228-
target_commit_hash: str | None,
274+
repository: Repository,
229275
clone_with_batches: bool,
230276
) -> None:
231277
"""Update batch info with repo path and final batch status.
232278
233279
For full clones (clone_with_batches=False): Marks as final batch immediately.
234-
For batched clones (clone_with_batches=True): Checks if target commit reached or full history fetched.
280+
For batched clones (clone_with_batches=True): Delegates to _check_if_final_batch
281+
to determine if required commit range is available or timeout/force-push occurred.
235282
"""
236283
batch_info.repo_path = repo_path
237284
batch_info.clone_with_batches = clone_with_batches
@@ -248,22 +295,7 @@ async def _update_batch_info(
248295
batch_info.is_final_batch = True
249296
return
250297

251-
batch_info.is_final_batch = await self._check_if_final_batch(repo_path, target_commit_hash)
252-
batch_info.edge_commit = await self._get_edge_commit(repo_path)
253-
254-
async def _get_edge_commit(self, repo_path: str) -> str | None:
255-
"""
256-
Returns the edge commit of a shallow clone by reading the .git/shallow file,
257-
which contains the boundary commit(s) when history is truncated.
258-
259-
If the full history has been cloned, the .git/shallow file does not exist.
260-
"""
261-
boundaries = await self._read_shallow_file(repo_path)
262-
if not boundaries:
263-
return None
264-
oldest_commit = boundaries[0]
265-
self.logger.info(f"Edge commit: {oldest_commit}")
266-
return oldest_commit
298+
batch_info.is_final_batch = await self._check_if_final_batch(repo_path, repository)
267299

268300
async def _cleanup_temp_directory(self, temp_repo_path: str, repo_id: str) -> None:
269301
"""
@@ -456,37 +488,35 @@ async def clone_batches_generator(
456488
temp_repo_path, remote, repository.branch, repository.last_processed_commit
457489
)
458490
await self._update_batch_info(
459-
batch_info, temp_repo_path, repository.last_processed_commit, clone_with_batches
491+
batch_info, temp_repo_path, repository, clone_with_batches
460492
)
461-
batch_end_time = time.time()
462-
total_execution_time += round(batch_end_time - batch_start_time, 2)
493+
total_execution_time += round(time.time() - batch_start_time, 2)
463494

464495
yield batch_info
465496
if working_dir_cleanup:
466497
await self._cleanup_working_directory(temp_repo_path)
467498

468499
batch_info.is_first_batch = False
500+
if batch_info.is_final_batch:
501+
self.logger.info("Clone complete — no deepening needed, all commits available")
502+
return
503+
469504
while not batch_info.is_final_batch:
470505
batch_start_time = time.time()
471-
batch_info.prev_batch_edge_commit = await self._get_edge_commit(temp_repo_path)
472506
await self._clone_next_batch(temp_repo_path, batch_depth, remote)
473-
await self._update_batch_info(
474-
batch_info,
475-
temp_repo_path,
476-
repository.last_processed_commit,
477-
clone_with_batches,
507+
batch_info.is_final_batch = await self._check_if_final_batch(
508+
temp_repo_path, repository
478509
)
479-
batch_end_time = time.time()
480-
total_execution_time += round(batch_end_time - batch_start_time, 2)
510+
total_execution_time += round(time.time() - batch_start_time, 2)
481511

482-
yield batch_info
483512
if not batch_info.is_final_batch:
484-
# exponential deepen increment to speed up fetching
485513
batch_depth = min(
486514
batch_depth * BATCH_DEEPEN_MULTIPLIER,
487515
MAX_BATCH_DEEPEN,
488516
)
489517

518+
yield batch_info
519+
490520
except Exception as e:
491521
# Handle both CrowdGitError and generic Exception
492522
execution_status = ExecutionStatus.FAILURE

0 commit comments

Comments
 (0)