@@ -8,7 +8,9 @@ use homeboy_core::git;
88pub ( super ) struct CheckoutRestoreEvidence {
99 pub ( super ) original_head : String ,
1010 pub ( super ) temporary_head : String ,
11- pub ( super ) final_head : String ,
11+ pub ( super ) final_head : Option < String > ,
12+ pub ( super ) restored : bool ,
13+ pub ( super ) error : Option < String > ,
1214}
1315
1416#[ derive( Debug , Clone ) ]
@@ -56,31 +58,85 @@ impl ReleaseCheckoutGuard {
5658 }
5759
5860 pub ( super ) fn restore_after_failure ( & self ) -> Result < CheckoutRestoreEvidence > {
61+ self . restore_after_failure_with_hook ( || { } )
62+ }
63+
64+ fn restore_after_failure_with_hook (
65+ & self ,
66+ after_capture : impl FnOnce ( ) ,
67+ ) -> Result < CheckoutRestoreEvidence > {
5968 let temporary_head = git_stdout ( & self . path , & [ "rev-parse" , "HEAD" ] ) ?;
69+ after_capture ( ) ;
70+ let mut errors = Vec :: new ( ) ;
6071 abort_in_progress_operations ( & self . path ) ;
61- run_git_checked ( & self . path , & [ "reset" , "--hard" ] ) ?;
62- remove_new_untracked ( & self . path , & self . original_untracked ) ?;
72+ record_cleanup_error (
73+ & mut errors,
74+ run_git_checked ( & self . path , & [ "reset" , "--hard" ] ) ,
75+ ) ;
76+ record_cleanup_error (
77+ & mut errors,
78+ remove_new_untracked ( & self . path , & self . original_untracked ) ,
79+ ) ;
6380
64- match & self . original_ref {
65- OriginalRef :: Branch ( branch) => {
66- run_git_checked ( & self . path , & [ "checkout" , "-q" , branch] ) ?
67- }
81+ let checkout_result = match & self . original_ref {
82+ OriginalRef :: Branch ( branch) => run_git_checked ( & self . path , & [ "checkout" , "-q" , branch] ) ,
6883 OriginalRef :: Detached => {
69- run_git_checked ( & self . path , & [ "checkout" , "-q" , & self . original_head ] ) ?
84+ run_git_checked ( & self . path , & [ "checkout" , "-q" , & self . original_head ] )
7085 }
86+ } ;
87+ record_cleanup_error ( & mut errors, checkout_result) ;
88+
89+ let before_restore = git_stdout ( & self . path , & [ "rev-parse" , "HEAD" ] ) ;
90+ match before_restore {
91+ Ok ( head) if head == self . original_head => { }
92+ Ok ( head) if head == temporary_head => record_cleanup_error (
93+ & mut errors,
94+ run_git_checked ( & self . path , & [ "reset" , "--hard" , & self . original_head ] ) ,
95+ ) ,
96+ Ok ( head) => errors. push ( format ! (
97+ "checkout HEAD moved concurrently to {head}; expected release commit {temporary_head} or original HEAD {}" ,
98+ self . original_head
99+ ) ) ,
100+ Err ( error) => errors. push ( format ! ( "failed to inspect HEAD before restore: {error}" ) ) ,
71101 }
72102
73- run_git_checked ( & self . path , & [ "reset" , "--hard" , & self . original_head ] ) ?;
74- remove_new_untracked ( & self . path , & self . original_untracked ) ?;
75- let final_head = git_stdout ( & self . path , & [ "rev-parse" , "HEAD" ] ) ?;
103+ record_cleanup_error (
104+ & mut errors,
105+ remove_new_untracked ( & self . path , & self . original_untracked ) ,
106+ ) ;
107+ // This read is deliberately independent of the cleanup commands above.
108+ // Rollback evidence must describe the checkout that actually remains.
109+ let final_head = match git_stdout ( & self . path , & [ "rev-parse" , "HEAD" ] ) {
110+ Ok ( head) => Some ( head) ,
111+ Err ( error) => {
112+ errors. push ( format ! ( "failed to verify final HEAD: {error}" ) ) ;
113+ None
114+ }
115+ } ;
116+ let restored = errors. is_empty ( ) && final_head. as_deref ( ) == Some ( & self . original_head ) ;
117+ if errors. is_empty ( ) && !restored {
118+ errors. push ( format ! (
119+ "rollback verification mismatch: expected {}, observed {}" ,
120+ self . original_head,
121+ final_head. as_deref( ) . unwrap_or( "unavailable" )
122+ ) ) ;
123+ }
76124 Ok ( CheckoutRestoreEvidence {
77125 original_head : self . original_head . clone ( ) ,
78126 temporary_head,
79127 final_head,
128+ restored,
129+ error : ( !errors. is_empty ( ) ) . then ( || errors. join ( "; " ) ) ,
80130 } )
81131 }
82132}
83133
134+ fn record_cleanup_error ( errors : & mut Vec < String > , result : Result < ( ) > ) {
135+ if let Err ( error) = result {
136+ errors. push ( error. to_string ( ) ) ;
137+ }
138+ }
139+
84140fn current_ref ( path : & str ) -> Result < OriginalRef > {
85141 let output = git_output ( path, & [ "symbolic-ref" , "--short" , "HEAD" ] ) ?;
86142 if output. status . success ( ) {
@@ -237,7 +293,12 @@ mod tests {
237293 ) ;
238294 assert_eq ! ( rollback. original_head, original_head) ;
239295 assert_ne ! ( rollback. temporary_head, rollback. original_head) ;
240- assert_eq ! ( rollback. final_head, rollback. original_head) ;
296+ assert ! ( rollback. restored) ;
297+ assert_eq ! (
298+ rollback. final_head. as_deref( ) ,
299+ Some ( rollback. original_head. as_str( ) )
300+ ) ;
301+ assert_eq ! ( rollback. error, None ) ;
241302 assert_eq ! ( git_stdout_for_test( dir, & [ "status" , "--porcelain=v1" ] ) , "" ) ;
242303 assert ! ( !dir. join( "generated.txt" ) . exists( ) ) ;
243304 assert_eq ! (
@@ -297,6 +358,46 @@ mod tests {
297358 assert ! ( err. message. contains( "Uncommitted tracked changes" ) ) ;
298359 }
299360
361+ #[ test]
362+ fn concurrent_head_movement_is_reported_without_overwriting_it ( ) {
363+ let temp = init_repo ( ) ;
364+ let dir = temp. path ( ) ;
365+ let original_head = git_stdout_for_test ( dir, & [ "rev-parse" , "HEAD" ] ) ;
366+ let guard = ReleaseCheckoutGuard :: capture ( & component ( dir) )
367+ . expect ( "capture" )
368+ . expect ( "git repo" ) ;
369+
370+ std:: fs:: write ( dir. join ( "file.txt" ) , "release\n " ) . expect ( "write release change" ) ;
371+ run_git ( dir, & [ "add" , "." ] ) ;
372+ run_git ( dir, & [ "commit" , "-q" , "-m" , "release: v1.0.0" ] ) ;
373+ let release_commit = git_stdout_for_test ( dir, & [ "rev-parse" , "HEAD" ] ) ;
374+
375+ let rollback = guard
376+ . restore_after_failure_with_hook ( || {
377+ std:: fs:: write ( dir. join ( "file.txt" ) , "concurrent\n " )
378+ . expect ( "write concurrent change" ) ;
379+ run_git ( dir, & [ "add" , "." ] ) ;
380+ run_git ( dir, & [ "commit" , "-q" , "-m" , "fix: concurrent movement" ] ) ;
381+ } )
382+ . expect ( "rollback evidence" ) ;
383+ let concurrent_head = git_stdout_for_test ( dir, & [ "rev-parse" , "HEAD" ] ) ;
384+
385+ assert ! ( !rollback. restored) ;
386+ assert_eq ! ( rollback. original_head, original_head) ;
387+ assert_eq ! ( rollback. temporary_head, release_commit) ;
388+ assert_eq ! (
389+ rollback. final_head. as_deref( ) ,
390+ Some ( concurrent_head. as_str( ) )
391+ ) ;
392+ assert ! ( rollback
393+ . error
394+ . as_deref( )
395+ . unwrap( )
396+ . contains( "moved concurrently" ) ) ;
397+ assert_ne ! ( concurrent_head, original_head) ;
398+ assert_ne ! ( concurrent_head, release_commit) ;
399+ }
400+
300401 fn git_stdout_for_test ( dir : & std:: path:: Path , args : & [ & str ] ) -> String {
301402 let output = std:: process:: Command :: new ( "git" )
302403 . args ( args)
0 commit comments