33import tempfile
44import time
55from collections .abc import AsyncIterator
6+ from datetime import datetime , timezone
67from decimal import Decimal
78
89import aiofiles
2223 NetworkError ,
2324 RateLimitError ,
2425 RemoteServerError ,
26+ ReOnboardingRequiredError ,
2527)
2628from crowdgit .models import CloneBatchInfo , Repository , ServiceExecution
2729from crowdgit .services .base .base_service import BaseService
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
3541DEFAULT_STORAGE_OPTIMIZATION_THRESHOLD_MB = 10000
3642INITIAL_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