Skip to content

Commit 60c182e

Browse files
feat: add merge conflict auto-recovery and --force-clean flag for broken git repositories
- Add HasMergeConflicts() to detect "needs merge" state that blocks stash/pull operations - Add RecoverFromMergeConflicts() to auto-abort merges or reset to HEAD when conflicts detected - Add EnsureCleanState() helper to check and recover from conflicts before git operations - Add --force-clean flag to self update command for nuclear repository reset option - Modify PullWithStashTracking() to check for and recover
1 parent 9fd152e commit 60c182e

4 files changed

Lines changed: 264 additions & 7 deletions

File tree

cmd/self/self.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,13 @@ By default, system packages are updated. Use flags to control behavior.
3737
Flags:
3838
--system-packages Update system packages (default: true)
3939
--go-version Update Go compiler to latest version
40+
--force-clean Reset repository to clean state (nuclear option for broken repos)
4041
4142
Examples:
4243
eos self update # Update EOS binary + system packages (default)
4344
eos self update --system-packages=false # Update only EOS binary
4445
eos self update --go-version # Update EOS + system packages + Go compiler
46+
eos self update --force-clean # Reset repo to clean state, then update
4547
eos self update --system-packages=false --go-version # Update EOS + Go compiler only`,
4648
RunE: eos.Wrap(updateEos),
4749
}
@@ -58,6 +60,7 @@ from masterless mode to a fully managed node.`,
5860
updateSystemPackages bool
5961
updateGoVersion bool
6062
forcePackageErrors bool
63+
forceClean bool
6164
)
6265

6366
func init() {
@@ -70,6 +73,7 @@ func init() {
7073
UpdateCmd.Flags().BoolVar(&updateSystemPackages, "system-packages", true, "Update system packages (apt/yum/dnf/pacman)")
7174
UpdateCmd.Flags().BoolVar(&updateGoVersion, "go-version", false, "Update Go compiler to latest version")
7275
UpdateCmd.Flags().BoolVar(&forcePackageErrors, "force-package-errors", false, "Continue despite system package update errors (not recommended)")
76+
UpdateCmd.Flags().BoolVar(&forceClean, "force-clean", false, "Reset repository to clean state before update (discards local changes)")
7377

7478
// Setup EnrollCmd flags
7579
setupEnrollFlags()
@@ -225,6 +229,7 @@ func updateEos(rc *eos_io.RuntimeContext, cmd *cobra.Command, args []string) err
225229
UpdateSystemPackages: updateSystemPackages, // Update system packages if requested
226230
UpdateGoVersion: updateGoVersion, // Update Go version if requested
227231
ForcePackageErrors: forcePackageErrors, // Continue despite package errors (not recommended)
232+
ForceClean: forceClean, // Reset repository to clean state (nuclear option)
228233
}
229234

230235
updater := selfpkg.NewEnhancedEosUpdater(rc, config)

install.sh

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -581,19 +581,68 @@ update_from_git() {
581581
log INFO " Pulling latest changes from GitHub..."
582582
cd "$Eos_SRC_DIR"
583583

584-
# Save any local changes first
584+
# RESILIENCE: Check for and recover from merge conflicts FIRST
585+
# This is the most common cause of "needs merge" errors
586+
if git status --porcelain | grep -qE '^(U.|.U|AA|DD)'; then
587+
log WARN " Repository has unresolved merge conflicts, attempting auto-recovery..."
588+
589+
# Try to abort any in-progress merge
590+
if git merge --abort 2>/dev/null; then
591+
log INFO " Successfully aborted in-progress merge"
592+
else
593+
# If merge --abort didn't work, reset to HEAD
594+
log WARN " Merge abort failed, resetting to HEAD..."
595+
if git reset --hard HEAD; then
596+
log INFO " Repository reset to clean state"
597+
else
598+
log ERR " Failed to recover from merge conflicts"
599+
log ERR " Manual recovery required:"
600+
log ERR " cd $Eos_SRC_DIR"
601+
log ERR " git status"
602+
log ERR " git merge --abort # or: git reset --hard HEAD"
603+
log ERR " Then re-run install.sh"
604+
exit 1
605+
fi
606+
fi
607+
fi
608+
609+
# Save any local changes first (only if not in conflict state)
585610
if git diff --quiet && git diff --cached --quiet; then
586611
log INFO " No local changes detected"
587612
else
588613
log INFO " Stashing local changes before pull..."
589-
git stash push -m "install.sh auto-stash $(date +%Y%m%d-%H%M%S)"
614+
if ! git stash push -m "install.sh auto-stash $(date +%Y%m%d-%H%M%S)"; then
615+
log WARN " Failed to stash changes, attempting recovery..."
616+
# Stash might fail if there are still issues - try reset
617+
if git reset --hard HEAD; then
618+
log INFO " Repository reset to clean state (local changes discarded)"
619+
else
620+
log ERR " Failed to prepare repository for update"
621+
log ERR " Manual recovery required: git reset --hard HEAD"
622+
exit 1
623+
fi
624+
fi
590625
fi
591626

592-
# Pull latest
593-
if git pull origin main; then
594-
log INFO " Successfully pulled latest changes"
627+
# Pull latest with fast-forward only (safer, avoids merge conflicts)
628+
if git pull --ff-only origin main 2>/dev/null; then
629+
log INFO " Successfully pulled latest changes (fast-forward)"
595630
else
596-
log WARN " Git pull failed, continuing with existing code"
631+
# Fast-forward failed, try regular pull
632+
log INFO " Fast-forward not possible, attempting regular pull..."
633+
if git pull origin main; then
634+
log INFO " Successfully pulled latest changes"
635+
else
636+
log WARN " Git pull failed"
637+
# Check if we now have conflicts
638+
if git status --porcelain | grep -qE '^(U.|.U|AA|DD)'; then
639+
log WARN " Pull created merge conflicts, aborting merge..."
640+
git merge --abort 2>/dev/null || git reset --hard HEAD
641+
log INFO " Merge aborted, continuing with existing code"
642+
else
643+
log INFO " Continuing with existing code"
644+
fi
645+
fi
597646
fi
598647
else
599648
log INFO " Not a git repository, using existing code"

pkg/git/operations.go

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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\nOutput: %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)
417556
func ResetToCommit(rc *eos_io.RuntimeContext, repoDir, commitHash string) error {

pkg/self/updater_enhanced.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ type EnhancedUpdateConfig struct {
4949
UpdateSystemPackages bool // Run system package manager update (apt/yum/dnf)
5050
UpdateGoVersion bool // Check and update Go compiler if needed
5151
ForcePackageErrors bool // Continue despite package manager errors (not recommended)
52+
ForceClean bool // Reset repository to clean state before update (nuclear option)
5253
}
5354

5455
// EnhancedEosUpdater handles self-update with comprehensive error recovery
@@ -203,6 +204,15 @@ func (eeu *EnhancedEosUpdater) PreUpdateSafetyChecks() error {
203204
return err
204205
}
205206

207+
// 1b. Handle --force-clean: Reset repository to clean state (nuclear option)
208+
if eeu.enhancedConfig.ForceClean {
209+
eeu.logger.Warn("Force clean requested - resetting repository to clean state")
210+
if err := eeu.forceCleanRepository(); err != nil {
211+
return fmt.Errorf("force clean failed: %w", err)
212+
}
213+
eeu.logger.Info("Repository reset to clean state")
214+
}
215+
206216
// 2. Check git repository state
207217
if err := eeu.checkGitRepositoryState(); err != nil {
208218
return err
@@ -239,6 +249,60 @@ func (eeu *EnhancedEosUpdater) verifySourceDirectory() error {
239249
return git.VerifyRepository(eeu.rc, eeu.config.SourceDir)
240250
}
241251

252+
// forceCleanRepository resets the repository to a clean state
253+
// This is the "nuclear option" for recovering from broken git states
254+
func (eeu *EnhancedEosUpdater) forceCleanRepository() error {
255+
repoDir := eeu.config.SourceDir
256+
257+
// Step 1: Abort any in-progress merge
258+
eeu.logger.Info("Checking for in-progress merge...")
259+
mergeAbortCmd := exec.Command("git", "-C", repoDir, "merge", "--abort")
260+
if output, err := mergeAbortCmd.CombinedOutput(); err != nil {
261+
eeu.logger.Debug("No merge to abort (expected if not in merge state)",
262+
zap.String("output", string(output)))
263+
} else {
264+
eeu.logger.Info("Aborted in-progress merge")
265+
}
266+
267+
// Step 2: Drop all stashes (they're likely causing issues)
268+
eeu.logger.Info("Clearing stash...")
269+
stashClearCmd := exec.Command("git", "-C", repoDir, "stash", "clear")
270+
if output, err := stashClearCmd.CombinedOutput(); err != nil {
271+
eeu.logger.Warn("Failed to clear stash",
272+
zap.Error(err),
273+
zap.String("output", string(output)))
274+
}
275+
276+
// Step 3: Fetch latest from origin
277+
eeu.logger.Info("Fetching latest from origin...")
278+
fetchCmd := exec.Command("git", "-C", repoDir, "fetch", "origin")
279+
if output, err := fetchCmd.CombinedOutput(); err != nil {
280+
return fmt.Errorf("git fetch failed: %w\nOutput: %s", err, string(output))
281+
}
282+
283+
// Step 4: Reset hard to origin/main
284+
eeu.logger.Warn("Resetting repository to origin/main (discarding all local changes)...")
285+
resetCmd := exec.Command("git", "-C", repoDir, "reset", "--hard", "origin/"+eeu.config.GitBranch)
286+
output, err := resetCmd.CombinedOutput()
287+
if err != nil {
288+
return fmt.Errorf("git reset --hard failed: %w\nOutput: %s", err, string(output))
289+
}
290+
291+
eeu.logger.Info("Repository reset successfully",
292+
zap.String("output", strings.TrimSpace(string(output))))
293+
294+
// Step 5: Clean untracked files
295+
eeu.logger.Info("Cleaning untracked files...")
296+
cleanCmd := exec.Command("git", "-C", repoDir, "clean", "-fd")
297+
if output, err := cleanCmd.CombinedOutput(); err != nil {
298+
eeu.logger.Warn("git clean failed (non-fatal)",
299+
zap.Error(err),
300+
zap.String("output", string(output)))
301+
}
302+
303+
return nil
304+
}
305+
242306
// checkGitRepositoryState checks for uncommitted changes and prompts for informed consent
243307
// P0-3 FIX: Human-centric validation - don't proceed blindly with uncommitted changes
244308
func (eeu *EnhancedEosUpdater) checkGitRepositoryState() error {

0 commit comments

Comments
 (0)