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>
287 lines
7.4 KiB
Go
287 lines
7.4 KiB
Go
package git
|
|
|
|
import (
|
|
"crypto/sha1"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/go-git/go-git/v5"
|
|
)
|
|
|
|
// RerereManager manages git rerere (reuse recorded resolution) functionality
|
|
type RerereManager struct {
|
|
repo *git.Repository
|
|
cachePath string
|
|
enabled bool
|
|
repoPath string
|
|
conflictResolver *ConflictResolver
|
|
}
|
|
|
|
// NewRerereManager creates a new RerereManager instance
|
|
func NewRerereManager(repo *git.Repository, onyxPath, repoPath string) *RerereManager {
|
|
cachePath := filepath.Join(onyxPath, "rerere_cache")
|
|
|
|
return &RerereManager{
|
|
repo: repo,
|
|
cachePath: cachePath,
|
|
enabled: true,
|
|
repoPath: repoPath,
|
|
conflictResolver: NewConflictResolver(repo, repoPath),
|
|
}
|
|
}
|
|
|
|
// Enable enables rerere functionality
|
|
func (rm *RerereManager) Enable() {
|
|
rm.enabled = true
|
|
}
|
|
|
|
// Disable disables rerere functionality
|
|
func (rm *RerereManager) Disable() {
|
|
rm.enabled = false
|
|
}
|
|
|
|
// IsEnabled returns whether rerere is enabled
|
|
func (rm *RerereManager) IsEnabled() bool {
|
|
return rm.enabled
|
|
}
|
|
|
|
// RecordConflicts records current conflicts for future resolution
|
|
func (rm *RerereManager) RecordConflicts() error {
|
|
if !rm.enabled {
|
|
return nil
|
|
}
|
|
|
|
conflicts, err := rm.conflictResolver.DetectConflicts()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to detect conflicts: %w", err)
|
|
}
|
|
|
|
if len(conflicts) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// For each conflict, create a cache entry
|
|
for _, conflict := range conflicts {
|
|
if err := rm.recordConflict(conflict); err != nil {
|
|
// Log error but continue with other conflicts
|
|
fmt.Fprintf(os.Stderr, "Warning: failed to record conflict for %s: %v\n", conflict.FilePath, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// recordConflict records a single conflict
|
|
func (rm *RerereManager) recordConflict(conflict ConflictInfo) error {
|
|
// Read the conflicted file
|
|
fullPath := filepath.Join(rm.repoPath, conflict.FilePath)
|
|
content, err := os.ReadFile(fullPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read conflicted file: %w", err)
|
|
}
|
|
|
|
// Generate a unique ID for this conflict pattern
|
|
conflictID := rm.generateConflictID(content)
|
|
|
|
// Create cache directory for this conflict
|
|
conflictDir := filepath.Join(rm.cachePath, conflictID)
|
|
if err := os.MkdirAll(conflictDir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create conflict cache directory: %w", err)
|
|
}
|
|
|
|
// Save the preimage (conflict state)
|
|
preimagePath := filepath.Join(conflictDir, "preimage")
|
|
if err := os.WriteFile(preimagePath, content, 0644); err != nil {
|
|
return fmt.Errorf("failed to write preimage: %w", err)
|
|
}
|
|
|
|
// Save metadata
|
|
metadataPath := filepath.Join(conflictDir, "metadata")
|
|
metadata := fmt.Sprintf("file=%s\nbase=%s\nours=%s\ntheirs=%s\n",
|
|
conflict.FilePath, conflict.BaseHash, conflict.OursHash, conflict.TheirsHash)
|
|
if err := os.WriteFile(metadataPath, []byte(metadata), 0644); err != nil {
|
|
return fmt.Errorf("failed to write metadata: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// RecordResolution records the resolution for previously recorded conflicts
|
|
func (rm *RerereManager) RecordResolution() error {
|
|
if !rm.enabled {
|
|
return nil
|
|
}
|
|
|
|
// Find all recorded conflicts
|
|
entries, err := os.ReadDir(rm.cachePath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("failed to read rerere cache: %w", err)
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() {
|
|
continue
|
|
}
|
|
|
|
conflictID := entry.Name()
|
|
if err := rm.recordResolutionForConflict(conflictID); err != nil {
|
|
// Log error but continue
|
|
fmt.Fprintf(os.Stderr, "Warning: failed to record resolution for %s: %v\n", conflictID, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// recordResolutionForConflict records the resolution for a specific conflict
|
|
func (rm *RerereManager) recordResolutionForConflict(conflictID string) error {
|
|
conflictDir := filepath.Join(rm.cachePath, conflictID)
|
|
|
|
// Read metadata to get file path
|
|
metadataPath := filepath.Join(conflictDir, "metadata")
|
|
metadataContent, err := os.ReadFile(metadataPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read metadata: %w", err)
|
|
}
|
|
|
|
// Parse file path from metadata
|
|
filePath := ""
|
|
for _, line := range strings.Split(string(metadataContent), "\n") {
|
|
if strings.HasPrefix(line, "file=") {
|
|
filePath = strings.TrimPrefix(line, "file=")
|
|
break
|
|
}
|
|
}
|
|
|
|
if filePath == "" {
|
|
return fmt.Errorf("file path not found in metadata")
|
|
}
|
|
|
|
// Check if file still has conflicts
|
|
fullPath := filepath.Join(rm.repoPath, filePath)
|
|
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
|
|
// File was deleted or doesn't exist, skip
|
|
return nil
|
|
}
|
|
|
|
hasConflicts, err := rm.conflictResolver.IsFileConflicted(filePath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check if file is conflicted: %w", err)
|
|
}
|
|
|
|
if hasConflicts {
|
|
// Still has conflicts, not resolved yet
|
|
return nil
|
|
}
|
|
|
|
// Read the resolved content
|
|
resolvedContent, err := os.ReadFile(fullPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read resolved file: %w", err)
|
|
}
|
|
|
|
// Save the postimage (resolved state)
|
|
postimagePath := filepath.Join(conflictDir, "postimage")
|
|
if err := os.WriteFile(postimagePath, resolvedContent, 0644); err != nil {
|
|
return fmt.Errorf("failed to write postimage: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ApplyResolutions applies previously recorded resolutions to current conflicts
|
|
func (rm *RerereManager) ApplyResolutions() (int, error) {
|
|
if !rm.enabled {
|
|
return 0, nil
|
|
}
|
|
|
|
conflicts, err := rm.conflictResolver.DetectConflicts()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to detect conflicts: %w", err)
|
|
}
|
|
|
|
appliedCount := 0
|
|
|
|
for _, conflict := range conflicts {
|
|
// Read the conflicted file
|
|
fullPath := filepath.Join(rm.repoPath, conflict.FilePath)
|
|
content, err := os.ReadFile(fullPath)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
// Generate conflict ID
|
|
conflictID := rm.generateConflictID(content)
|
|
|
|
// Check if we have a resolution for this conflict
|
|
postimagePath := filepath.Join(rm.cachePath, conflictID, "postimage")
|
|
if _, err := os.Stat(postimagePath); os.IsNotExist(err) {
|
|
continue
|
|
}
|
|
|
|
// Apply the resolution
|
|
resolvedContent, err := os.ReadFile(postimagePath)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
if err := os.WriteFile(fullPath, resolvedContent, 0644); err != nil {
|
|
continue
|
|
}
|
|
|
|
appliedCount++
|
|
}
|
|
|
|
return appliedCount, nil
|
|
}
|
|
|
|
// generateConflictID generates a unique ID for a conflict pattern
|
|
func (rm *RerereManager) generateConflictID(content []byte) string {
|
|
// Normalize conflict content by removing variable parts
|
|
normalized := rm.normalizeConflict(content)
|
|
|
|
// Generate SHA1 hash
|
|
hash := sha1.New()
|
|
io.WriteString(hash, normalized)
|
|
return hex.EncodeToString(hash.Sum(nil))
|
|
}
|
|
|
|
// normalizeConflict normalizes conflict content for matching
|
|
func (rm *RerereManager) normalizeConflict(content []byte) string {
|
|
// Convert to string
|
|
str := string(content)
|
|
|
|
// Remove commit hashes from conflict markers (they vary)
|
|
lines := strings.Split(str, "\n")
|
|
var normalized []string
|
|
|
|
for _, line := range lines {
|
|
if strings.HasPrefix(line, "<<<<<<<") {
|
|
normalized = append(normalized, "<<<<<<<")
|
|
} else if strings.HasPrefix(line, ">>>>>>>") {
|
|
normalized = append(normalized, ">>>>>>>")
|
|
} else {
|
|
normalized = append(normalized, line)
|
|
}
|
|
}
|
|
|
|
return strings.Join(normalized, "\n")
|
|
}
|
|
|
|
// ClearCache clears the rerere cache
|
|
func (rm *RerereManager) ClearCache() error {
|
|
return os.RemoveAll(rm.cachePath)
|
|
}
|
|
|
|
// GetCachePath returns the path to the rerere cache
|
|
func (rm *RerereManager) GetCachePath() string {
|
|
return rm.cachePath
|
|
}
|