11use crate :: error:: { GitError , RailError , RailResult , ResultExt } ;
22use std:: collections:: HashMap ;
33use std:: path:: Path ;
4+ use std:: thread;
5+ use std:: time:: Duration ;
46
57/// Commit mapping store using git-notes
68/// Maps commits between monorepo and split repos
@@ -163,19 +165,22 @@ impl MappingStore {
163165
164166 println ! ( " Pushing git-notes to remote '{}'..." , remote) ;
165167
166- let output = Command :: new ( "git" )
167- . current_dir ( repo_path)
168- . args ( [ "push" , remote, & notes_ref] )
169- . output ( )
170- . context ( "Failed to push git-notes" ) ?;
171-
172- if !output. status . success ( ) {
173- let stderr = String :: from_utf8_lossy ( & output. stderr ) ;
174- return Err ( RailError :: Git ( GitError :: CommandFailed {
175- command : "git push notes" . to_string ( ) ,
176- stderr : stderr. to_string ( ) ,
177- } ) ) ;
178- }
168+ retry_operation ( || {
169+ let output = Command :: new ( "git" )
170+ . current_dir ( repo_path)
171+ . args ( [ "push" , remote, & notes_ref] )
172+ . output ( )
173+ . context ( "Failed to push git-notes" ) ?;
174+
175+ if !output. status . success ( ) {
176+ let stderr = String :: from_utf8_lossy ( & output. stderr ) ;
177+ return Err ( RailError :: Git ( GitError :: CommandFailed {
178+ command : "git push notes" . to_string ( ) ,
179+ stderr : stderr. to_string ( ) ,
180+ } ) ) ;
181+ }
182+ Ok ( ( ) )
183+ } ) ?;
179184
180185 println ! ( " ✅ Pushed git-notes" ) ;
181186 Ok ( ( ) )
@@ -190,91 +195,154 @@ impl MappingStore {
190195
191196 println ! ( " Fetching git-notes from remote '{}'..." , remote) ;
192197
193- let output = Command :: new ( "git" )
194- . current_dir ( repo_path)
195- . args ( [ "fetch" , remote, & refspec] )
196- . output ( )
197- . context ( "Failed to fetch git-notes" ) ?;
198+ // Retry the fetch operation
199+ let result = retry_operation ( || {
200+ let output = Command :: new ( "git" )
201+ . current_dir ( repo_path)
202+ . args ( [ "fetch" , remote, & refspec] )
203+ . output ( )
204+ . context ( "Failed to fetch git-notes" ) ?;
205+
206+ if !output. status . success ( ) {
207+ let stderr = String :: from_utf8_lossy ( & output. stderr ) ;
198208
199- if !output. status . success ( ) {
200- let stderr = String :: from_utf8_lossy ( & output. stderr ) ;
209+ // Ignore "couldn't find remote ref" - notes may not exist yet
210+ if stderr. contains ( "couldn't find remote ref" ) {
211+ return Ok ( ( ) ) ; // Treat as success (no notes yet)
212+ }
201213
202- // Ignore "couldn't find remote ref" - notes may not exist yet
203- if stderr . contains ( "couldn't find remote ref" ) {
204- println ! ( " ℹ️ No remote git-notes found yet (this is normal for first sync)" ) ;
205- return Ok ( ( ) ) ;
214+ return Err ( RailError :: Git ( GitError :: CommandFailed {
215+ command : "git fetch notes" . to_string ( ) ,
216+ stderr : stderr . to_string ( ) ,
217+ } ) ) ;
206218 }
219+ Ok ( ( ) )
220+ } ) ;
207221
208- // Handle non-fast-forward (notes conflict)
209- if stderr. contains ( "non-fast-forward" ) || stderr. contains ( "rejected" ) {
210- println ! ( " ⚠️ Git-notes conflict detected (local and remote notes diverged)" ) ;
211- println ! ( " 🔄 Attempting automatic merge with union strategy..." ) ;
212-
213- // Fetch to FETCH_HEAD without updating the ref
214- let fetch_output = Command :: new ( "git" )
215- . current_dir ( repo_path)
216- . args ( [ "fetch" , remote, & notes_ref] )
217- . output ( )
218- . context ( "Failed to fetch notes to FETCH_HEAD" ) ?;
219-
220- if !fetch_output. status . success ( ) {
221- let fetch_stderr = String :: from_utf8_lossy ( & fetch_output. stderr ) ;
222- if !fetch_stderr. contains ( "couldn't find remote ref" ) {
223- return Err ( RailError :: Git ( GitError :: CommandFailed {
224- command : "git fetch notes to FETCH_HEAD" . to_string ( ) ,
225- stderr : fetch_stderr. to_string ( ) ,
226- } ) ) ;
222+ match result {
223+ Ok ( _) => {
224+ println ! ( " ✅ Fetched git-notes" ) ;
225+ Ok ( ( ) )
226+ }
227+ Err ( e) => {
228+ // Check if it's a conflict (non-fast-forward)
229+ let is_conflict = if let RailError :: Git ( GitError :: CommandFailed { stderr, .. } ) = & e {
230+ stderr. contains ( "non-fast-forward" ) || stderr. contains ( "rejected" )
231+ } else {
232+ false
233+ } ;
234+
235+ if is_conflict {
236+ println ! ( " ⚠️ Git-notes conflict detected (local and remote notes diverged)" ) ;
237+ println ! ( " 🔄 Attempting automatic merge with union strategy..." ) ;
238+
239+ // Fetch to FETCH_HEAD without updating the ref
240+ let fetch_output = Command :: new ( "git" )
241+ . current_dir ( repo_path)
242+ . args ( [ "fetch" , remote, & notes_ref] )
243+ . output ( )
244+ . context ( "Failed to fetch notes to FETCH_HEAD" ) ?;
245+
246+ if !fetch_output. status . success ( ) {
247+ let fetch_stderr = String :: from_utf8_lossy ( & fetch_output. stderr ) ;
248+ if !fetch_stderr. contains ( "couldn't find remote ref" ) {
249+ return Err ( RailError :: Git ( GitError :: CommandFailed {
250+ command : "git fetch notes to FETCH_HEAD" . to_string ( ) ,
251+ stderr : fetch_stderr. to_string ( ) ,
252+ } ) ) ;
253+ }
254+ return Ok ( ( ) ) ; // No remote notes
227255 }
228- return Ok ( ( ) ) ; // No remote notes
256+
257+ // Merge notes using union strategy (combines both without conflict)
258+ let merge_output = Command :: new ( "git" )
259+ . current_dir ( repo_path)
260+ . args ( [ "notes" , "--ref" , & notes_ref, "merge" , "--strategy=union" , "FETCH_HEAD" ] )
261+ . output ( )
262+ . context ( "Failed to merge git-notes" ) ?;
263+
264+ if !merge_output. status . success ( ) {
265+ let merge_stderr = String :: from_utf8_lossy ( & merge_output. stderr ) ;
266+
267+ // If union merge fails, provide clear guidance
268+ eprintln ! ( " ❌ Automatic git-notes merge failed" ) ;
269+ eprintln ! ( " 📋 Manual resolution required:" ) ;
270+ eprintln ! ( " 1. cd {}" , repo_path. display( ) ) ;
271+ eprintln ! ( " 2. git notes --ref={} merge FETCH_HEAD" , notes_ref) ;
272+ eprintln ! ( " 3. Resolve conflicts manually" ) ;
273+ eprintln ! ( " 4. git notes --ref={} merge --commit" , notes_ref) ;
274+ eprintln ! ( ) ;
275+ return Err ( RailError :: with_help (
276+ format ! ( "git notes merge failed: {}" , merge_stderr) ,
277+ format ! (
278+ "This usually happens when the same commit has different mappings on different machines.\n \
279+ Manual resolution steps:\n \
280+ 1. cd {}\n \
281+ 2. git notes --ref={} merge FETCH_HEAD\n \
282+ 3. Resolve conflicts manually\n \
283+ 4. git notes --ref={} merge --commit",
284+ repo_path. display( ) ,
285+ notes_ref,
286+ notes_ref
287+ ) ,
288+ ) ) ;
289+ }
290+
291+ println ! ( " ✅ Git-notes merged successfully (union strategy)" ) ;
292+ Ok ( ( ) )
293+ } else {
294+ // Propagate other errors
295+ Err ( e)
296+ }
297+ }
298+ }
299+ }
300+ }
301+
302+ /// Retry an operation with exponential backoff
303+ fn retry_operation < F , T > ( mut operation : F ) -> RailResult < T >
304+ where
305+ F : FnMut ( ) -> RailResult < T > ,
306+ {
307+ let max_retries = 3 ;
308+ let mut attempt = 0 ;
309+ let mut delay = Duration :: from_millis ( 500 ) ;
310+
311+ loop {
312+ match operation ( ) {
313+ Ok ( result) => return Ok ( result) ,
314+ Err ( e) => {
315+ attempt += 1 ;
316+ if attempt > max_retries {
317+ return Err ( e) ;
229318 }
230319
231- // Merge notes using union strategy (combines both without conflict)
232- let merge_output = Command :: new ( "git" )
233- . current_dir ( repo_path)
234- . args ( [ "notes" , "--ref" , & notes_ref, "merge" , "--strategy=union" , "FETCH_HEAD" ] )
235- . output ( )
236- . context ( "Failed to merge git-notes" ) ?;
237-
238- if !merge_output. status . success ( ) {
239- let merge_stderr = String :: from_utf8_lossy ( & merge_output. stderr ) ;
240-
241- // If union merge fails, provide clear guidance
242- eprintln ! ( " ❌ Automatic git-notes merge failed" ) ;
243- eprintln ! ( " 📋 Manual resolution required:" ) ;
244- eprintln ! ( " 1. cd {}" , repo_path. display( ) ) ;
245- eprintln ! ( " 2. git notes --ref={} merge FETCH_HEAD" , notes_ref) ;
246- eprintln ! ( " 3. Resolve conflicts manually" ) ;
247- eprintln ! ( " 4. git notes --ref={} merge --commit" , notes_ref) ;
248- eprintln ! ( ) ;
249- return Err ( RailError :: with_help (
250- format ! ( "git notes merge failed: {}" , merge_stderr) ,
251- format ! (
252- "This usually happens when the same commit has different mappings on different machines.\n \
253- Manual resolution steps:\n \
254- 1. cd {}\n \
255- 2. git notes --ref={} merge FETCH_HEAD\n \
256- 3. Resolve conflicts manually\n \
257- 4. git notes --ref={} merge --commit",
258- repo_path. display( ) ,
259- notes_ref,
260- notes_ref
261- ) ,
262- ) ) ;
320+ // Only retry on network/lock errors, not logical errors
321+ // For now, we assume most git command failures in this context are transient or lock-related
322+ // unless it's a conflict which we handle separately in fetch_notes
323+ let should_retry = match & e {
324+ RailError :: Git ( GitError :: CommandFailed { stderr, .. } ) => {
325+ stderr. contains ( "lock" )
326+ || stderr. contains ( "temporarily unavailable" )
327+ || stderr. contains ( "Connection timed out" )
328+ || stderr. contains ( "Could not resolve host" )
329+ || stderr. contains ( "Failed to connect" )
330+ }
331+ _ => false ,
332+ } ;
333+
334+ if !should_retry {
335+ return Err ( e) ;
263336 }
264337
265- println ! ( " ✅ Git-notes merged successfully (union strategy)" ) ;
266- return Ok ( ( ) ) ;
338+ println ! (
339+ " ⚠️ Operation failed. Retrying in {:?}... (Attempt {}/{})" ,
340+ delay, attempt, max_retries
341+ ) ;
342+ thread:: sleep ( delay) ;
343+ delay *= 2 ;
267344 }
268-
269- // Unknown error
270- return Err ( RailError :: Git ( GitError :: CommandFailed {
271- command : "git fetch notes" . to_string ( ) ,
272- stderr : stderr. to_string ( ) ,
273- } ) ) ;
274345 }
275-
276- println ! ( " ✅ Fetched git-notes" ) ;
277- Ok ( ( ) )
278346 }
279347}
280348
0 commit comments