@@ -213,9 +213,35 @@ func PullWithStashTracking(rc *eos_io.RuntimeContext, repoDir, branch string) (c
213213 zap .String ("repo" , repoDir ),
214214 zap .String ("branch" , branch ))
215215
216+ // RESILIENCE: Check for and recover from existing merge conflicts FIRST
217+ // This prevents the "needs merge" error that blocks stash operations
218+ hasConflicts , conflictedFiles , err := HasMergeConflicts (rc , repoDir )
219+ if err != nil {
220+ return false , "" , fmt .Errorf ("failed to check for merge conflicts: %w" , err )
221+ }
222+
223+ if hasConflicts {
224+ logger .Warn ("Repository has existing merge conflicts, attempting auto-recovery" ,
225+ zap .Strings ("files" , conflictedFiles ))
226+
227+ if err := RecoverFromMergeConflicts (rc , repoDir ); err != nil {
228+ return false , "" , fmt .Errorf ("repository has unresolved merge conflicts: %w\n \n " +
229+ "Conflicted files: %v\n \n " +
230+ "Manual recovery required:\n " +
231+ " cd %s\n " +
232+ " git status # See conflict details\n " +
233+ " git merge --abort # Abort the merge\n " +
234+ " # OR: git reset --hard HEAD # Discard all changes\n " +
235+ " # Then re-run the update" ,
236+ err , conflictedFiles , repoDir )
237+ }
238+
239+ logger .Info ("Successfully recovered from merge conflicts, proceeding with update" )
240+ }
241+
216242 // SECURITY CHECK: Verify remote is trusted BEFORE pulling
217243 if err := VerifyTrustedRemote (rc , repoDir ); err != nil {
218- return false , "" , err // Error already includes detailed message
244+ return false , "" , err // Error already includes detailed message
219245 }
220246
221247 // Get commit before pull
@@ -412,6 +438,119 @@ func RestoreStash(rc *eos_io.RuntimeContext, repoDir, stashRef string) error {
412438 return nil
413439}
414440
441+ // HasMergeConflicts checks if the repository has unresolved merge conflicts
442+ // This detects the "needs merge" state that prevents stash/pull operations
443+ func HasMergeConflicts (rc * eos_io.RuntimeContext , repoDir string ) (bool , []string , error ) {
444+ logger := otelzap .Ctx (rc .Ctx )
445+
446+ // git status --porcelain shows merge conflicts as lines starting with "UU", "AA", "DD", etc.
447+ statusCmd := exec .Command ("git" , "-C" , repoDir , "status" , "--porcelain" )
448+ statusOutput , err := statusCmd .Output ()
449+ if err != nil {
450+ return false , nil , fmt .Errorf ("failed to check git status: %w" , err )
451+ }
452+
453+ var conflictedFiles []string
454+ lines := strings .Split (string (statusOutput ), "\n " )
455+ for _ , line := range lines {
456+ if len (line ) < 2 {
457+ continue
458+ }
459+ // Merge conflicts show as: UU, AA, DD, AU, UA, DU, UD
460+ // First two characters are the status codes
461+ x , y := line [0 ], line [1 ]
462+ isConflict := x == 'U' || y == 'U' || (x == 'A' && y == 'A' ) || (x == 'D' && y == 'D' )
463+ if isConflict && len (line ) > 3 {
464+ conflictedFiles = append (conflictedFiles , strings .TrimSpace (line [3 :]))
465+ }
466+ }
467+
468+ if len (conflictedFiles ) > 0 {
469+ logger .Warn ("Repository has merge conflicts" ,
470+ zap .Strings ("files" , conflictedFiles ))
471+ return true , conflictedFiles , nil
472+ }
473+
474+ return false , nil , nil
475+ }
476+
477+ // RecoverFromMergeConflicts attempts to automatically resolve merge conflicts
478+ // by resetting to HEAD (discarding the merge attempt)
479+ // Returns true if recovery was successful
480+ func RecoverFromMergeConflicts (rc * eos_io.RuntimeContext , repoDir string ) error {
481+ logger := otelzap .Ctx (rc .Ctx )
482+
483+ hasConflicts , files , err := HasMergeConflicts (rc , repoDir )
484+ if err != nil {
485+ return fmt .Errorf ("failed to check for conflicts: %w" , err )
486+ }
487+
488+ if ! hasConflicts {
489+ logger .Debug ("No merge conflicts to recover from" )
490+ return nil
491+ }
492+
493+ logger .Warn ("Attempting automatic recovery from merge conflicts" ,
494+ zap .Strings ("conflicted_files" , files ))
495+
496+ // Try to abort any in-progress merge
497+ mergeAbortCmd := exec .Command ("git" , "-C" , repoDir , "merge" , "--abort" )
498+ if output , err := mergeAbortCmd .CombinedOutput (); err != nil {
499+ logger .Debug ("git merge --abort failed (may not be in merge state)" ,
500+ zap .Error (err ),
501+ zap .String ("output" , string (output )))
502+ } else {
503+ logger .Info ("Successfully aborted in-progress merge" )
504+ return nil
505+ }
506+
507+ // If merge --abort didn't work, try reset --hard HEAD
508+ // This discards all uncommitted changes but resolves the conflict state
509+ logger .Warn ("Merge abort failed, attempting git reset --hard HEAD" )
510+ resetCmd := exec .Command ("git" , "-C" , repoDir , "reset" , "--hard" , "HEAD" )
511+ output , err := resetCmd .CombinedOutput ()
512+ if err != nil {
513+ return fmt .Errorf ("failed to reset repository: %w\n Output: %s\n \n " +
514+ "Manual recovery required:\n " +
515+ " cd %s\n " +
516+ " git status # See conflicted files\n " +
517+ " git merge --abort # Or: git reset --hard HEAD\n " +
518+ " # Then re-run install.sh or eos self update" ,
519+ err , strings .TrimSpace (string (output )), repoDir )
520+ }
521+
522+ logger .Info ("Repository reset to clean state" ,
523+ zap .String ("output" , strings .TrimSpace (string (output ))))
524+
525+ return nil
526+ }
527+
528+ // EnsureCleanState ensures the repository is in a clean state before operations
529+ // If conflicts are detected, attempts automatic recovery
530+ // If uncommitted changes exist (non-conflict), they are preserved
531+ func EnsureCleanState (rc * eos_io.RuntimeContext , repoDir string ) error {
532+ logger := otelzap .Ctx (rc .Ctx )
533+
534+ // First check for merge conflicts (blocking issue)
535+ hasConflicts , files , err := HasMergeConflicts (rc , repoDir )
536+ if err != nil {
537+ return err
538+ }
539+
540+ if hasConflicts {
541+ logger .Warn ("Repository has merge conflicts, attempting recovery" ,
542+ zap .Strings ("files" , files ))
543+
544+ if err := RecoverFromMergeConflicts (rc , repoDir ); err != nil {
545+ return fmt .Errorf ("repository has unresolved merge conflicts that could not be auto-resolved: %w" , err )
546+ }
547+
548+ logger .Info ("Successfully recovered from merge conflicts" )
549+ }
550+
551+ return nil
552+ }
553+
415554// ResetToCommit performs a git reset --hard to a specific commit
416555// DANGEROUS: Only use when safe (e.g., during rollback with proper checks)
417556func ResetToCommit (rc * eos_io.RuntimeContext , repoDir , commitHash string ) error {
0 commit comments