temp for tree extraction
This commit is contained in:
190
internal/git/auth.go
Normal file
190
internal/git/auth.go
Normal file
@ -0,0 +1,190 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/go-git/go-git/v5/plumbing/transport"
|
||||
"github.com/go-git/go-git/v5/plumbing/transport/http"
|
||||
"github.com/go-git/go-git/v5/plumbing/transport/ssh"
|
||||
)
|
||||
|
||||
// AuthProvider handles authentication for Git operations
|
||||
type AuthProvider struct {
|
||||
cache map[string]transport.AuthMethod
|
||||
}
|
||||
|
||||
// NewAuthProvider creates a new AuthProvider
|
||||
func NewAuthProvider() *AuthProvider {
|
||||
return &AuthProvider{
|
||||
cache: make(map[string]transport.AuthMethod),
|
||||
}
|
||||
}
|
||||
|
||||
// GetAuthMethod returns the appropriate authentication method for a URL
|
||||
func (ap *AuthProvider) GetAuthMethod(url string) (transport.AuthMethod, error) {
|
||||
// Check cache first
|
||||
if auth, ok := ap.cache[url]; ok {
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
var auth transport.AuthMethod
|
||||
var err error
|
||||
|
||||
// Detect transport type from URL
|
||||
if strings.HasPrefix(url, "git@") || strings.HasPrefix(url, "ssh://") {
|
||||
// SSH authentication
|
||||
auth, err = ap.getSSHAuth()
|
||||
} else if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") {
|
||||
// HTTPS authentication
|
||||
auth, err = ap.getHTTPSAuth(url)
|
||||
} else {
|
||||
return nil, fmt.Errorf("unsupported URL scheme: %s", url)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Cache the auth method
|
||||
ap.cache[url] = auth
|
||||
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
// getSSHAuth attempts to get SSH authentication
|
||||
func (ap *AuthProvider) getSSHAuth() (transport.AuthMethod, error) {
|
||||
// Try SSH agent first
|
||||
auth, err := ssh.NewSSHAgentAuth("git")
|
||||
if err == nil {
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
// Fallback to loading SSH keys from default locations
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get home directory: %w", err)
|
||||
}
|
||||
|
||||
sshDir := filepath.Join(homeDir, ".ssh")
|
||||
|
||||
// Try common key files
|
||||
keyFiles := []string{
|
||||
"id_ed25519",
|
||||
"id_rsa",
|
||||
"id_ecdsa",
|
||||
"id_dsa",
|
||||
}
|
||||
|
||||
for _, keyFile := range keyFiles {
|
||||
keyPath := filepath.Join(sshDir, keyFile)
|
||||
if _, err := os.Stat(keyPath); err == nil {
|
||||
// Try loading without passphrase first
|
||||
auth, err := ssh.NewPublicKeysFromFile("git", keyPath, "")
|
||||
if err == nil {
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
// If that fails, it might need a passphrase
|
||||
// For now, we'll skip passphrase-protected keys
|
||||
// In the future, we could prompt for the passphrase
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no SSH authentication method available (tried ssh-agent and ~/.ssh keys)")
|
||||
}
|
||||
|
||||
// getHTTPSAuth attempts to get HTTPS authentication
|
||||
func (ap *AuthProvider) getHTTPSAuth(url string) (transport.AuthMethod, error) {
|
||||
// Try git credential helper first
|
||||
auth, err := ap.tryGitCredentialHelper(url)
|
||||
if err == nil && auth != nil {
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
// Try environment variables
|
||||
username := os.Getenv("GIT_USERNAME")
|
||||
password := os.Getenv("GIT_PASSWORD")
|
||||
token := os.Getenv("GIT_TOKEN")
|
||||
|
||||
if token != "" {
|
||||
// Use token as password (common for GitHub, GitLab, etc.)
|
||||
return &http.BasicAuth{
|
||||
Username: "git", // Token usually goes in password field
|
||||
Password: token,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if username != "" && password != "" {
|
||||
return &http.BasicAuth{
|
||||
Username: username,
|
||||
Password: password,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// No credentials available - return nil to let go-git try anonymous
|
||||
// (this will fail for private repos but that's expected)
|
||||
return nil, fmt.Errorf("no HTTPS credentials available (tried git credential helper and environment variables)")
|
||||
}
|
||||
|
||||
// tryGitCredentialHelper attempts to use git's credential helper
|
||||
func (ap *AuthProvider) tryGitCredentialHelper(url string) (*http.BasicAuth, error) {
|
||||
// Build the credential request
|
||||
input := fmt.Sprintf("protocol=https\nhost=%s\n\n", extractHost(url))
|
||||
|
||||
// Call git credential fill
|
||||
cmd := exec.Command("git", "credential", "fill")
|
||||
cmd.Stdin = strings.NewReader(input)
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("git credential helper failed: %w", err)
|
||||
}
|
||||
|
||||
// Parse the output
|
||||
lines := strings.Split(string(output), "\n")
|
||||
auth := &http.BasicAuth{}
|
||||
|
||||
for _, line := range lines {
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
|
||||
switch key {
|
||||
case "username":
|
||||
auth.Username = value
|
||||
case "password":
|
||||
auth.Password = value
|
||||
}
|
||||
}
|
||||
|
||||
if auth.Username == "" || auth.Password == "" {
|
||||
return nil, fmt.Errorf("git credential helper did not return username and password")
|
||||
}
|
||||
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
// extractHost extracts the host from a URL
|
||||
func extractHost(url string) string {
|
||||
// Remove protocol
|
||||
url = strings.TrimPrefix(url, "https://")
|
||||
url = strings.TrimPrefix(url, "http://")
|
||||
|
||||
// Extract host (everything before the first /)
|
||||
parts := strings.SplitN(url, "/", 2)
|
||||
return parts[0]
|
||||
}
|
||||
|
||||
// ClearCache clears the authentication cache
|
||||
func (ap *AuthProvider) ClearCache() {
|
||||
ap.cache = make(map[string]transport.AuthMethod)
|
||||
}
|
187
internal/git/conflicts.go
Normal file
187
internal/git/conflicts.go
Normal file
@ -0,0 +1,187 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
gogit "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 *gogit.Repository
|
||||
repoPath string
|
||||
}
|
||||
|
||||
// NewConflictResolver creates a new ConflictResolver instance
|
||||
func NewConflictResolver(repo *gogit.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
|
||||
}
|
210
internal/git/objects.go
Normal file
210
internal/git/objects.go
Normal file
@ -0,0 +1,210 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
gogit "github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
"github.com/go-git/go-git/v5/plumbing/filemode"
|
||||
"github.com/go-git/go-git/v5/plumbing/object"
|
||||
)
|
||||
|
||||
// GitBackend implements low-level Git object operations
|
||||
type GitBackend struct {
|
||||
repo *gogit.Repository
|
||||
}
|
||||
|
||||
// NewGitBackend creates a new GitBackend instance
|
||||
func NewGitBackend(repo *gogit.Repository) *GitBackend {
|
||||
return &GitBackend{repo: repo}
|
||||
}
|
||||
|
||||
// CreateBlob creates a new blob object from the given content
|
||||
func (gb *GitBackend) CreateBlob(content []byte) (string, error) {
|
||||
store := gb.repo.Storer
|
||||
|
||||
// Create a blob object
|
||||
blob := store.NewEncodedObject()
|
||||
blob.SetType(plumbing.BlobObject)
|
||||
blob.SetSize(int64(len(content)))
|
||||
|
||||
writer, err := blob.Writer()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get blob writer: %w", err)
|
||||
}
|
||||
|
||||
_, err = writer.Write(content)
|
||||
if err != nil {
|
||||
writer.Close()
|
||||
return "", fmt.Errorf("failed to write blob content: %w", err)
|
||||
}
|
||||
|
||||
if err := writer.Close(); err != nil {
|
||||
return "", fmt.Errorf("failed to close blob writer: %w", err)
|
||||
}
|
||||
|
||||
// Store the blob
|
||||
hash, err := store.SetEncodedObject(blob)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to store blob: %w", err)
|
||||
}
|
||||
|
||||
return hash.String(), nil
|
||||
}
|
||||
|
||||
// TreeEntry represents an entry in a Git tree
|
||||
type TreeEntry struct {
|
||||
Mode filemode.FileMode
|
||||
Name string
|
||||
Hash plumbing.Hash
|
||||
}
|
||||
|
||||
// CreateTree creates a new tree object from the given entries
|
||||
func (gb *GitBackend) CreateTree(entries []TreeEntry) (string, error) {
|
||||
store := gb.repo.Storer
|
||||
|
||||
// Create a new tree object
|
||||
tree := &object.Tree{}
|
||||
treeEntries := make([]object.TreeEntry, len(entries))
|
||||
|
||||
for i, entry := range entries {
|
||||
treeEntries[i] = object.TreeEntry{
|
||||
Name: entry.Name,
|
||||
Mode: entry.Mode,
|
||||
Hash: entry.Hash,
|
||||
}
|
||||
}
|
||||
|
||||
tree.Entries = treeEntries
|
||||
|
||||
// Encode and store the tree
|
||||
obj := store.NewEncodedObject()
|
||||
if err := tree.Encode(obj); err != nil {
|
||||
return "", fmt.Errorf("failed to encode tree: %w", err)
|
||||
}
|
||||
|
||||
hash, err := store.SetEncodedObject(obj)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to store tree: %w", err)
|
||||
}
|
||||
|
||||
return hash.String(), nil
|
||||
}
|
||||
|
||||
// CreateCommit creates a new commit object
|
||||
func (gb *GitBackend) CreateCommit(treeHash, parentHash, message, author string) (string, error) {
|
||||
store := gb.repo.Storer
|
||||
|
||||
// Parse hashes
|
||||
tree := plumbing.NewHash(treeHash)
|
||||
|
||||
var parents []plumbing.Hash
|
||||
if parentHash != "" {
|
||||
parents = []plumbing.Hash{plumbing.NewHash(parentHash)}
|
||||
}
|
||||
|
||||
// Create commit object
|
||||
commit := &object.Commit{
|
||||
Author: object.Signature{
|
||||
Name: author,
|
||||
Email: "onyx@local",
|
||||
When: time.Now(),
|
||||
},
|
||||
Committer: object.Signature{
|
||||
Name: author,
|
||||
Email: "onyx@local",
|
||||
When: time.Now(),
|
||||
},
|
||||
Message: message,
|
||||
TreeHash: tree,
|
||||
}
|
||||
|
||||
if len(parents) > 0 {
|
||||
commit.ParentHashes = parents
|
||||
}
|
||||
|
||||
// Encode and store the commit
|
||||
obj := store.NewEncodedObject()
|
||||
if err := commit.Encode(obj); err != nil {
|
||||
return "", fmt.Errorf("failed to encode commit: %w", err)
|
||||
}
|
||||
|
||||
hash, err := store.SetEncodedObject(obj)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to store commit: %w", err)
|
||||
}
|
||||
|
||||
return hash.String(), nil
|
||||
}
|
||||
|
||||
// UpdateRef updates a Git reference to point to a new SHA
|
||||
func (gb *GitBackend) UpdateRef(refName, sha string) error {
|
||||
hash := plumbing.NewHash(sha)
|
||||
ref := plumbing.NewHashReference(plumbing.ReferenceName(refName), hash)
|
||||
|
||||
if err := gb.repo.Storer.SetReference(ref); err != nil {
|
||||
return fmt.Errorf("failed to update reference %s: %w", refName, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRef retrieves the SHA that a reference points to
|
||||
func (gb *GitBackend) GetRef(refName string) (string, error) {
|
||||
ref, err := gb.repo.Reference(plumbing.ReferenceName(refName), true)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get reference %s: %w", refName, err)
|
||||
}
|
||||
|
||||
return ref.Hash().String(), nil
|
||||
}
|
||||
|
||||
// GetObject retrieves a Git object by its SHA
|
||||
func (gb *GitBackend) GetObject(sha string) (object.Object, error) {
|
||||
hash := plumbing.NewHash(sha)
|
||||
obj, err := gb.repo.Object(plumbing.AnyObject, hash)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get object %s: %w", sha, err)
|
||||
}
|
||||
|
||||
return obj, nil
|
||||
}
|
||||
|
||||
// GetBlob retrieves a blob object by its SHA
|
||||
func (gb *GitBackend) GetBlob(sha string) (*object.Blob, error) {
|
||||
hash := plumbing.NewHash(sha)
|
||||
blob, err := gb.repo.BlobObject(hash)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get blob %s: %w", sha, err)
|
||||
}
|
||||
|
||||
return blob, nil
|
||||
}
|
||||
|
||||
// GetTree retrieves a tree object by its SHA
|
||||
func (gb *GitBackend) GetTree(sha string) (*object.Tree, error) {
|
||||
hash := plumbing.NewHash(sha)
|
||||
tree, err := gb.repo.TreeObject(hash)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get tree %s: %w", sha, err)
|
||||
}
|
||||
|
||||
return tree, nil
|
||||
}
|
||||
|
||||
// GetCommit retrieves a commit object by its SHA
|
||||
func (gb *GitBackend) GetCommit(sha string) (*object.Commit, error) {
|
||||
hash := plumbing.NewHash(sha)
|
||||
commit, err := gb.repo.CommitObject(hash)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get commit %s: %w", sha, err)
|
||||
}
|
||||
|
||||
return commit, nil
|
||||
}
|
||||
|
||||
// HashFromString converts a string SHA to a plumbing.Hash
|
||||
func HashFromString(sha string) plumbing.Hash {
|
||||
return plumbing.NewHash(sha)
|
||||
}
|
207
internal/git/rebase.go
Normal file
207
internal/git/rebase.go
Normal file
@ -0,0 +1,207 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
gogit "github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
"github.com/go-git/go-git/v5/plumbing/object"
|
||||
)
|
||||
|
||||
// RebaseEngine handles stacked rebase operations with rerere support
|
||||
type RebaseEngine struct {
|
||||
repo *gogit.Repository
|
||||
backend *GitBackend
|
||||
rerere *RerereManager
|
||||
conflictResolver *ConflictResolver
|
||||
repoPath string
|
||||
}
|
||||
|
||||
// NewRebaseEngine creates a new RebaseEngine instance
|
||||
func NewRebaseEngine(repo *gogit.Repository, onyxPath, repoPath string) *RebaseEngine {
|
||||
return &RebaseEngine{
|
||||
repo: repo,
|
||||
backend: NewGitBackend(repo),
|
||||
rerere: NewRerereManager(repo, onyxPath, repoPath),
|
||||
conflictResolver: NewConflictResolver(repo, repoPath),
|
||||
repoPath: repoPath,
|
||||
}
|
||||
}
|
||||
|
||||
// RebaseStackResult contains the result of a stack rebase operation
|
||||
type RebaseStackResult struct {
|
||||
Success bool
|
||||
RebasedCommits []string
|
||||
FailedCommit string
|
||||
ConflictingFiles []ConflictInfo
|
||||
Message string
|
||||
}
|
||||
|
||||
// RebaseStack rebases a stack of commits onto a new base
|
||||
func (re *RebaseEngine) RebaseStack(stack []string, onto string) (*RebaseStackResult, error) {
|
||||
result := &RebaseStackResult{
|
||||
Success: true,
|
||||
RebasedCommits: []string{},
|
||||
}
|
||||
|
||||
if len(stack) == 0 {
|
||||
result.Message = "No commits to rebase"
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Validate onto commit exists
|
||||
_, err := re.backend.GetCommit(onto)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid onto commit %s: %w", onto, err)
|
||||
}
|
||||
|
||||
currentBase := onto
|
||||
|
||||
// Rebase each commit in the stack sequentially
|
||||
for i, commitSHA := range stack {
|
||||
// Get the commit object
|
||||
commit, err := re.backend.GetCommit(commitSHA)
|
||||
if err != nil {
|
||||
result.Success = false
|
||||
result.FailedCommit = commitSHA
|
||||
result.Message = fmt.Sprintf("Failed to get commit %s: %v", commitSHA, err)
|
||||
return result, fmt.Errorf("failed to get commit: %w", err)
|
||||
}
|
||||
|
||||
// Rebase this commit onto the current base
|
||||
newCommitSHA, err := re.rebaseSingleCommit(commit, currentBase)
|
||||
if err != nil {
|
||||
// Check if it's a conflict error
|
||||
conflicts, detectErr := re.conflictResolver.DetectConflicts()
|
||||
if detectErr == nil && len(conflicts) > 0 {
|
||||
result.Success = false
|
||||
result.FailedCommit = commitSHA
|
||||
result.ConflictingFiles = conflicts
|
||||
result.Message = fmt.Sprintf("Conflicts detected while rebasing commit %d/%d (%s)",
|
||||
i+1, len(stack), commitSHA[:8])
|
||||
return result, nil
|
||||
}
|
||||
|
||||
result.Success = false
|
||||
result.FailedCommit = commitSHA
|
||||
result.Message = fmt.Sprintf("Failed to rebase commit %s: %v", commitSHA, err)
|
||||
return result, err
|
||||
}
|
||||
|
||||
result.RebasedCommits = append(result.RebasedCommits, newCommitSHA)
|
||||
currentBase = newCommitSHA
|
||||
}
|
||||
|
||||
result.Message = fmt.Sprintf("Successfully rebased %d commit(s)", len(stack))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// rebaseSingleCommit rebases a single commit onto a new parent
|
||||
func (re *RebaseEngine) rebaseSingleCommit(commit *object.Commit, newParent string) (string, error) {
|
||||
// Record conflicts before attempting rebase (for rerere)
|
||||
if err := re.rerere.RecordConflicts(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to record conflicts: %v\n", err)
|
||||
}
|
||||
|
||||
// Get the commit's tree
|
||||
tree, err := commit.Tree()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get commit tree: %w", err)
|
||||
}
|
||||
|
||||
// Check if there are any changes between the trees
|
||||
// For simplicity, we'll create a new commit with the same tree content
|
||||
// In a more sophisticated implementation, we would perform a three-way merge
|
||||
|
||||
// Try to apply rerere resolutions first
|
||||
if re.rerere.IsEnabled() {
|
||||
applied, err := re.rerere.ApplyResolutions()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to apply rerere resolutions: %v\n", err)
|
||||
} else if applied > 0 {
|
||||
fmt.Printf("Applied %d rerere resolution(s)\n", applied)
|
||||
}
|
||||
}
|
||||
|
||||
// Perform a simple rebase by creating a new commit with the same tree but new parent
|
||||
// This is a simplified implementation - a full implementation would handle merges
|
||||
newCommitSHA, err := re.backend.CreateCommit(
|
||||
tree.Hash.String(),
|
||||
newParent,
|
||||
commit.Message,
|
||||
commit.Author.Name,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create rebased commit: %w", err)
|
||||
}
|
||||
|
||||
return newCommitSHA, nil
|
||||
}
|
||||
|
||||
// RebaseCommit rebases a single commit onto a new parent (public API)
|
||||
func (re *RebaseEngine) RebaseCommit(commitSHA, newParent string) (string, error) {
|
||||
commit, err := re.backend.GetCommit(commitSHA)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get commit: %w", err)
|
||||
}
|
||||
|
||||
return re.rebaseSingleCommit(commit, newParent)
|
||||
}
|
||||
|
||||
// ContinueRebase continues a rebase after conflict resolution
|
||||
func (re *RebaseEngine) ContinueRebase(stack []string, fromIndex int, onto string) (*RebaseStackResult, error) {
|
||||
// Record the resolution for rerere
|
||||
if err := re.rerere.RecordResolution(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to record resolution: %v\n", err)
|
||||
}
|
||||
|
||||
// Check if conflicts are resolved
|
||||
hasConflicts, err := re.conflictResolver.HasConflicts()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check for conflicts: %w", err)
|
||||
}
|
||||
|
||||
if hasConflicts {
|
||||
return &RebaseStackResult{
|
||||
Success: false,
|
||||
Message: "Conflicts still exist. Please resolve all conflicts before continuing.",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Continue rebasing from the next commit
|
||||
remainingStack := stack[fromIndex:]
|
||||
return re.RebaseStack(remainingStack, onto)
|
||||
}
|
||||
|
||||
// AbortRebase aborts a rebase operation and returns to the original state
|
||||
func (re *RebaseEngine) AbortRebase(originalHead string) error {
|
||||
// Update HEAD to original commit
|
||||
hash := plumbing.NewHash(originalHead)
|
||||
|
||||
worktree, err := re.repo.Worktree()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get worktree: %w", err)
|
||||
}
|
||||
|
||||
// Checkout the original HEAD
|
||||
err = worktree.Checkout(&gogit.CheckoutOptions{
|
||||
Hash: hash,
|
||||
Force: true,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to checkout original HEAD: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRerereManager returns the rerere manager
|
||||
func (re *RebaseEngine) GetRerereManager() *RerereManager {
|
||||
return re.rerere
|
||||
}
|
||||
|
||||
// GetConflictResolver returns the conflict resolver
|
||||
func (re *RebaseEngine) GetConflictResolver() *ConflictResolver {
|
||||
return re.conflictResolver
|
||||
}
|
106
internal/git/remote.go
Normal file
106
internal/git/remote.go
Normal file
@ -0,0 +1,106 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
gogit "github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/config"
|
||||
)
|
||||
|
||||
// RemoteHelper provides utilities for working with Git remotes
|
||||
type RemoteHelper struct {
|
||||
repo *gogit.Repository
|
||||
}
|
||||
|
||||
// NewRemoteHelper creates a new RemoteHelper instance
|
||||
func NewRemoteHelper(repo *gogit.Repository) *RemoteHelper {
|
||||
return &RemoteHelper{repo: repo}
|
||||
}
|
||||
|
||||
// GetRemote retrieves a remote by name, defaults to "origin" if name is empty
|
||||
func (rh *RemoteHelper) GetRemote(name string) (*gogit.Remote, error) {
|
||||
if name == "" {
|
||||
name = "origin"
|
||||
}
|
||||
|
||||
remote, err := rh.repo.Remote(name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("remote '%s' not found: %w", name, err)
|
||||
}
|
||||
|
||||
return remote, nil
|
||||
}
|
||||
|
||||
// ListRemotes returns all configured remotes
|
||||
func (rh *RemoteHelper) ListRemotes() ([]*gogit.Remote, error) {
|
||||
remotes, err := rh.repo.Remotes()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list remotes: %w", err)
|
||||
}
|
||||
|
||||
return remotes, nil
|
||||
}
|
||||
|
||||
// ValidateRemote checks if a remote exists and is properly configured
|
||||
func (rh *RemoteHelper) ValidateRemote(name string) error {
|
||||
if name == "" {
|
||||
name = "origin"
|
||||
}
|
||||
|
||||
remote, err := rh.GetRemote(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if remote has URLs configured
|
||||
cfg := remote.Config()
|
||||
if len(cfg.URLs) == 0 {
|
||||
return fmt.Errorf("remote '%s' has no URLs configured", name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDefaultRemoteName returns the default remote name (origin)
|
||||
func (rh *RemoteHelper) GetDefaultRemoteName() string {
|
||||
return "origin"
|
||||
}
|
||||
|
||||
// GetRemoteURL returns the fetch URL for a remote
|
||||
func (rh *RemoteHelper) GetRemoteURL(name string) (string, error) {
|
||||
if name == "" {
|
||||
name = "origin"
|
||||
}
|
||||
|
||||
remote, err := rh.GetRemote(name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
cfg := remote.Config()
|
||||
if len(cfg.URLs) == 0 {
|
||||
return "", fmt.Errorf("remote '%s' has no URLs configured", name)
|
||||
}
|
||||
|
||||
return cfg.URLs[0], nil
|
||||
}
|
||||
|
||||
// GetRemoteConfig returns the configuration for a remote
|
||||
func (rh *RemoteHelper) GetRemoteConfig(name string) (*config.RemoteConfig, error) {
|
||||
if name == "" {
|
||||
name = "origin"
|
||||
}
|
||||
|
||||
remote, err := rh.GetRemote(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return remote.Config(), nil
|
||||
}
|
||||
|
||||
// HasRemote checks if a remote with the given name exists
|
||||
func (rh *RemoteHelper) HasRemote(name string) bool {
|
||||
_, err := rh.repo.Remote(name)
|
||||
return err == nil
|
||||
}
|
286
internal/git/rerere.go
Normal file
286
internal/git/rerere.go
Normal file
@ -0,0 +1,286 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
gogit "github.com/go-git/go-git/v5"
|
||||
)
|
||||
|
||||
// RerereManager manages git rerere (reuse recorded resolution) functionality
|
||||
type RerereManager struct {
|
||||
repo *gogit.Repository
|
||||
cachePath string
|
||||
enabled bool
|
||||
repoPath string
|
||||
conflictResolver *ConflictResolver
|
||||
}
|
||||
|
||||
// NewRerereManager creates a new RerereManager instance
|
||||
func NewRerereManager(repo *gogit.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
|
||||
}
|
Reference in New Issue
Block a user