This milestone adds comprehensive remote synchronization and stacked-diff publishing capabilities to Onyx. ## New Features ### Rebase Engine (internal/git/rebase.go) - Stacked rebase with sequential commit rebasing - Conflict detection and handling - Integration with rerere for automatic conflict resolution - Support for rebase continuation and abort operations ### Rerere Integration (internal/git/rerere.go) - Conflict resolution recording and replay - Cache location: .onx/rerere_cache - Automatic application of previous conflict resolutions - Normalized conflict pattern matching via SHA1 hashing ### Conflict Resolution UI (internal/git/conflicts.go) - Index-based conflict detection (stage 1/2/3) - Clear conflict presentation with file paths and hashes - User-friendly resolution guidance - Conflict marker extraction and analysis ### Remote Commands #### onx sync (internal/commands/sync.go) - Fetch latest changes from remote - Rebase workstream stack onto updated base branch - Automatic rerere conflict resolution - Transaction-based with full undo support - Progress reporting and clear error messages #### onx push (internal/commands/push.go) - Push all workstream branches to remote - Support for force push operations - Per-branch progress reporting - Clear summary of pushed branches ### Remote Helpers (internal/git/remote.go) - Remote validation and configuration - Support for multiple remotes with origin default - URL retrieval and remote existence checking ## Implementation Details - All operations wrapped in oplog transactions for undo support - Comprehensive error handling and user feedback - Integration with existing workstream management - CLI commands registered in cmd/onx/main.go ## Status Milestone 4 is now complete. All core synchronization and remote interaction features are implemented and tested. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
188 lines
5.0 KiB
Go
188 lines
5.0 KiB
Go
package git
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/go-git/go-git/v5"
|
|
"github.com/go-git/go-git/v5/plumbing/format/index"
|
|
)
|
|
|
|
// ConflictInfo represents information about a merge conflict
|
|
type ConflictInfo struct {
|
|
FilePath string
|
|
OursHash string
|
|
TheirsHash string
|
|
BaseHash string
|
|
HasConflict bool
|
|
}
|
|
|
|
// ConflictResolver handles conflict detection and resolution guidance
|
|
type ConflictResolver struct {
|
|
repo *git.Repository
|
|
repoPath string
|
|
}
|
|
|
|
// NewConflictResolver creates a new ConflictResolver instance
|
|
func NewConflictResolver(repo *git.Repository, repoPath string) *ConflictResolver {
|
|
return &ConflictResolver{
|
|
repo: repo,
|
|
repoPath: repoPath,
|
|
}
|
|
}
|
|
|
|
// DetectConflicts checks for merge conflicts in the working tree
|
|
func (cr *ConflictResolver) DetectConflicts() ([]ConflictInfo, error) {
|
|
idx, err := cr.repo.Storer.Index()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read index: %w", err)
|
|
}
|
|
|
|
conflicts := []ConflictInfo{}
|
|
|
|
// Check for conflicts in the index
|
|
for _, entry := range idx.Entries {
|
|
// Stage > 0 indicates a conflict
|
|
if entry.Stage != 0 {
|
|
// Find all stages for this file
|
|
conflict := cr.findConflictStages(idx, entry.Name)
|
|
if conflict.HasConflict {
|
|
conflicts = append(conflicts, conflict)
|
|
}
|
|
}
|
|
}
|
|
|
|
return conflicts, nil
|
|
}
|
|
|
|
// findConflictStages finds all conflict stages for a file
|
|
func (cr *ConflictResolver) findConflictStages(idx *index.Index, path string) ConflictInfo {
|
|
conflict := ConflictInfo{
|
|
FilePath: path,
|
|
HasConflict: false,
|
|
}
|
|
|
|
for _, entry := range idx.Entries {
|
|
if entry.Name == path {
|
|
switch entry.Stage {
|
|
case 1:
|
|
// Base/common ancestor
|
|
conflict.BaseHash = entry.Hash.String()
|
|
conflict.HasConflict = true
|
|
case 2:
|
|
// Ours (current branch)
|
|
conflict.OursHash = entry.Hash.String()
|
|
conflict.HasConflict = true
|
|
case 3:
|
|
// Theirs (incoming branch)
|
|
conflict.TheirsHash = entry.Hash.String()
|
|
conflict.HasConflict = true
|
|
}
|
|
}
|
|
}
|
|
|
|
return conflict
|
|
}
|
|
|
|
// HasConflicts checks if there are any conflicts in the working tree
|
|
func (cr *ConflictResolver) HasConflicts() (bool, error) {
|
|
conflicts, err := cr.DetectConflicts()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
return len(conflicts) > 0, nil
|
|
}
|
|
|
|
// PresentConflicts presents conflicts to the user with clear guidance
|
|
func (cr *ConflictResolver) PresentConflicts(conflicts []ConflictInfo) string {
|
|
if len(conflicts) == 0 {
|
|
return "No conflicts detected."
|
|
}
|
|
|
|
var sb strings.Builder
|
|
|
|
sb.WriteString(fmt.Sprintf("\n%s\n", strings.Repeat("=", 70)))
|
|
sb.WriteString(fmt.Sprintf(" MERGE CONFLICTS DETECTED (%d file(s))\n", len(conflicts)))
|
|
sb.WriteString(fmt.Sprintf("%s\n\n", strings.Repeat("=", 70)))
|
|
|
|
for i, conflict := range conflicts {
|
|
sb.WriteString(fmt.Sprintf("%d. %s\n", i+1, conflict.FilePath))
|
|
sb.WriteString(fmt.Sprintf(" Base: %s\n", conflict.BaseHash[:8]))
|
|
sb.WriteString(fmt.Sprintf(" Ours: %s\n", conflict.OursHash[:8]))
|
|
sb.WriteString(fmt.Sprintf(" Theirs: %s\n", conflict.TheirsHash[:8]))
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
sb.WriteString("To resolve conflicts:\n")
|
|
sb.WriteString(" 1. Edit the conflicting files to resolve conflicts\n")
|
|
sb.WriteString(" 2. Look for conflict markers: <<<<<<<, =======, >>>>>>>\n")
|
|
sb.WriteString(" 3. Remove the conflict markers after resolving\n")
|
|
sb.WriteString(" 4. Stage the resolved files: git add <file>\n")
|
|
sb.WriteString(" 5. Continue the rebase: git rebase --continue\n")
|
|
sb.WriteString(fmt.Sprintf("%s\n", strings.Repeat("=", 70)))
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
// GetConflictMarkers reads a file and extracts conflict marker sections
|
|
func (cr *ConflictResolver) GetConflictMarkers(filePath string) ([]ConflictMarker, error) {
|
|
fullPath := filepath.Join(cr.repoPath, filePath)
|
|
file, err := os.Open(fullPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open file: %w", err)
|
|
}
|
|
defer file.Close()
|
|
|
|
markers := []ConflictMarker{}
|
|
scanner := bufio.NewScanner(file)
|
|
lineNum := 0
|
|
var currentMarker *ConflictMarker
|
|
|
|
for scanner.Scan() {
|
|
lineNum++
|
|
line := scanner.Text()
|
|
|
|
if strings.HasPrefix(line, "<<<<<<<") {
|
|
// Start of conflict
|
|
currentMarker = &ConflictMarker{
|
|
FilePath: filePath,
|
|
StartLine: lineNum,
|
|
}
|
|
} else if strings.HasPrefix(line, "=======") && currentMarker != nil {
|
|
currentMarker.SeparatorLine = lineNum
|
|
} else if strings.HasPrefix(line, ">>>>>>>") && currentMarker != nil {
|
|
currentMarker.EndLine = lineNum
|
|
markers = append(markers, *currentMarker)
|
|
currentMarker = nil
|
|
}
|
|
}
|
|
|
|
if err := scanner.Err(); err != nil {
|
|
return nil, fmt.Errorf("error reading file: %w", err)
|
|
}
|
|
|
|
return markers, nil
|
|
}
|
|
|
|
// ConflictMarker represents a conflict marker section in a file
|
|
type ConflictMarker struct {
|
|
FilePath string
|
|
StartLine int
|
|
SeparatorLine int
|
|
EndLine int
|
|
}
|
|
|
|
// IsFileConflicted checks if a specific file has conflict markers
|
|
func (cr *ConflictResolver) IsFileConflicted(filePath string) (bool, error) {
|
|
markers, err := cr.GetConflictMarkers(filePath)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
return len(markers) > 0, nil
|
|
}
|