ralph_workflow/git_helpers/
rebase.rs

1//! Git rebase operations using libgit2.
2//!
3//! This module provides functionality to:
4//! - Perform rebase operations onto a specified upstream branch
5//! - Detect and report conflicts during rebase
6//! - Abort an in-progress rebase
7//! - Continue a rebase after conflict resolution
8//! - Get lists of conflicted files
9//!
10//! All operations use libgit2 directly (not git CLI) for consistency
11//! with the rest of the codebase.
12
13#![deny(unsafe_code)]
14
15use std::io;
16use std::path::Path;
17
18/// Convert git2 error to `io::Error`.
19fn git2_to_io_error(err: &git2::Error) -> io::Error {
20    io::Error::other(err.to_string())
21}
22
23/// Result of a rebase operation.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum RebaseResult {
26    /// Rebase completed successfully.
27    Success,
28    /// Rebase had conflicts that need resolution.
29    Conflicts(Vec<String>),
30    /// No rebase was needed (already up-to-date).
31    NoOp,
32}
33
34/// Perform a rebase onto the specified upstream branch.
35///
36/// This function rebases the current branch onto the specified upstream branch.
37/// It handles the full rebase process including conflict detection.
38///
39/// # Arguments
40///
41/// * `upstream_branch` - The branch to rebase onto (e.g., "main", "origin/main")
42///
43/// # Returns
44///
45/// Returns `Ok(RebaseResult)` indicating the outcome, or an error if:
46/// - The repository cannot be opened
47/// - The upstream branch cannot be found
48/// - The rebase operation fails
49///
50/// # Edge Cases Handled
51///
52/// - Empty repository (no commits) - Returns `Ok(RebaseResult::NoOp)`
53/// - Unborn branch - Returns `Ok(RebaseResult::NoOp)`
54/// - Already up-to-date - Returns `Ok(RebaseResult::NoOp)`
55/// - Unrelated branches (no shared ancestor) - Returns `Ok(RebaseResult::NoOp)`
56/// - Conflicts during rebase - Returns `Ok(RebaseResult::Conflicts)`
57///
58/// # Note
59///
60/// This function uses git CLI for rebase operations as libgit2's rebase API
61/// has limitations and complexity that make it unreliable for production use.
62/// The git CLI is more robust and better tested for rebase operations.
63pub fn rebase_onto(upstream_branch: &str) -> io::Result<RebaseResult> {
64    use std::process::Command;
65
66    // Check if we have any commits
67    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
68
69    match repo.head() {
70        Ok(_) => {}
71        Err(ref e) if e.code() == git2::ErrorCode::UnbornBranch => {
72            // No commits yet - nothing to rebase
73            return Ok(RebaseResult::NoOp);
74        }
75        Err(e) => return Err(git2_to_io_error(&e)),
76    }
77
78    // Get the upstream branch to ensure it exists
79    let upstream_object = repo.revparse_single(upstream_branch).map_err(|_| {
80        io::Error::new(
81            io::ErrorKind::NotFound,
82            format!("Upstream branch '{upstream_branch}' not found"),
83        )
84    })?;
85
86    let upstream_commit = upstream_object
87        .peel_to_commit()
88        .map_err(|e| git2_to_io_error(&e))?;
89
90    // Get our branch commit
91    let head = repo.head().map_err(|e| git2_to_io_error(&e))?;
92    let head_commit = head.peel_to_commit().map_err(|e| git2_to_io_error(&e))?;
93
94    // Check if we're already up-to-date
95    if repo
96        .graph_descendant_of(head_commit.id(), upstream_commit.id())
97        .map_err(|e| git2_to_io_error(&e))?
98    {
99        // Already up-to-date
100        return Ok(RebaseResult::NoOp);
101    }
102
103    // Check if branches share a common ancestor
104    // If merge_base fails with NotFound, the branches are unrelated
105    match repo.merge_base(head_commit.id(), upstream_commit.id()) {
106        Err(e)
107            if e.class() == git2::ErrorClass::Reference
108                && e.code() == git2::ErrorCode::NotFound =>
109        {
110            // Branches are unrelated - no shared history
111            return Ok(RebaseResult::NoOp);
112        }
113        Err(e) => return Err(git2_to_io_error(&e)),
114        Ok(_) => {}
115    }
116
117    // Check if we're on main/master
118    let branch_name = head.shorthand().ok_or_else(|| {
119        io::Error::new(
120            io::ErrorKind::NotFound,
121            "Could not determine branch name from HEAD",
122        )
123    })?;
124
125    if branch_name == "main" || branch_name == "master" {
126        return Ok(RebaseResult::NoOp);
127    }
128
129    // Use git CLI for rebase - more reliable than libgit2
130    let output = Command::new("git")
131        .args(["rebase", upstream_branch])
132        .output();
133
134    match output {
135        Ok(result) => {
136            if result.status.success() {
137                Ok(RebaseResult::Success)
138            } else {
139                let stderr = String::from_utf8_lossy(&result.stderr);
140                // Check if it's a conflict
141                if stderr.contains("Conflict")
142                    || stderr.contains("conflict")
143                    || stderr.contains("Resolve")
144                {
145                    // Return empty conflict list - user can check with git status
146                    Ok(RebaseResult::Conflicts(vec![]))
147                } else if stderr.contains("up to date") {
148                    Ok(RebaseResult::NoOp)
149                } else {
150                    Err(io::Error::other(format!("Rebase failed: {stderr}")))
151                }
152            }
153        }
154        Err(e) => Err(io::Error::other(format!(
155            "Failed to execute git rebase: {e}"
156        ))),
157    }
158}
159
160/// Abort the current rebase operation.
161///
162/// This cleans up the rebase state and returns the repository to its
163/// pre-rebase condition.
164///
165/// # Returns
166///
167/// Returns `Ok(())` if successful, or an error if:
168/// - No rebase is in progress
169/// - The abort operation fails
170pub fn abort_rebase() -> io::Result<()> {
171    use std::process::Command;
172
173    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
174
175    // Check if a rebase is in progress
176    let state = repo.state();
177    if state != git2::RepositoryState::Rebase
178        && state != git2::RepositoryState::RebaseMerge
179        && state != git2::RepositoryState::RebaseInteractive
180    {
181        return Err(io::Error::new(
182            io::ErrorKind::InvalidInput,
183            "No rebase in progress",
184        ));
185    }
186
187    // Use git CLI for abort
188    let output = Command::new("git").args(["rebase", "--abort"]).output();
189
190    match output {
191        Ok(result) => {
192            if result.status.success() {
193                Ok(())
194            } else {
195                let stderr = String::from_utf8_lossy(&result.stderr);
196                Err(io::Error::other(format!(
197                    "Failed to abort rebase: {stderr}"
198                )))
199            }
200        }
201        Err(e) => Err(io::Error::other(format!(
202            "Failed to execute git rebase --abort: {e}"
203        ))),
204    }
205}
206
207/// Get a list of files that have merge conflicts.
208///
209/// This function queries libgit2's index to find all files that are
210/// currently in a conflicted state.
211///
212/// # Returns
213///
214/// Returns `Ok(Vec<String>)` containing the paths of conflicted files,
215/// or an error if the repository cannot be accessed.
216pub fn get_conflicted_files() -> io::Result<Vec<String>> {
217    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
218    let index = repo.index().map_err(|e| git2_to_io_error(&e))?;
219
220    let mut conflicted_files = Vec::new();
221
222    // Check if there are any conflicts
223    if !index.has_conflicts() {
224        return Ok(conflicted_files);
225    }
226
227    // Get the list of conflicted files
228    let conflicts = index.conflicts().map_err(|e| git2_to_io_error(&e))?;
229
230    for conflict in conflicts {
231        let conflict = conflict.map_err(|e| git2_to_io_error(&e))?;
232        // The conflict's `our` entry (stage 2) will have the path
233        if let Some(our_entry) = conflict.our {
234            if let Ok(path) = std::str::from_utf8(&our_entry.path) {
235                let path_str = path.to_string();
236                if !conflicted_files.contains(&path_str) {
237                    conflicted_files.push(path_str);
238                }
239            }
240        }
241    }
242
243    Ok(conflicted_files)
244}
245
246/// Extract conflict markers from a file.
247///
248/// This function reads a file and returns the conflict sections,
249/// including both versions of the conflicted content.
250///
251/// # Arguments
252///
253/// * `path` - Path to the conflicted file (relative to repo root)
254///
255/// # Returns
256///
257/// Returns `Ok(String)` containing the conflict sections, or an error
258/// if the file cannot be read.
259pub fn get_conflict_markers_for_file(path: &Path) -> io::Result<String> {
260    use std::fs;
261    use std::io::Read;
262
263    let mut file = fs::File::open(path)?;
264    let mut content = String::new();
265    file.read_to_string(&mut content)?;
266
267    // Extract conflict markers and their content
268    let mut conflict_sections = Vec::new();
269    let lines: Vec<&str> = content.lines().collect();
270    let mut i = 0;
271
272    while i < lines.len() {
273        if lines[i].trim_start().starts_with("<<<<<<<") {
274            // Found conflict start
275            let mut section = Vec::new();
276            section.push(lines[i]);
277
278            i += 1;
279            // Collect "ours" version
280            while i < lines.len() && !lines[i].trim_start().starts_with("=======") {
281                section.push(lines[i]);
282                i += 1;
283            }
284
285            if i < lines.len() {
286                section.push(lines[i]); // Add the ======= line
287                i += 1;
288            }
289
290            // Collect "theirs" version
291            while i < lines.len() && !lines[i].trim_start().starts_with(">>>>>>>") {
292                section.push(lines[i]);
293                i += 1;
294            }
295
296            if i < lines.len() {
297                section.push(lines[i]); // Add the >>>>>>> line
298                i += 1;
299            }
300
301            conflict_sections.push(section.join("\n"));
302        } else {
303            i += 1;
304        }
305    }
306
307    if conflict_sections.is_empty() {
308        // No conflict markers found, return empty string
309        Ok(String::new())
310    } else {
311        Ok(conflict_sections.join("\n\n"))
312    }
313}
314
315/// Continue a rebase after conflict resolution.
316///
317/// This function continues a rebase that was paused due to conflicts.
318/// It should be called after all conflicts have been resolved and
319/// the resolved files have been staged with `git add`.
320///
321/// # Returns
322///
323/// Returns `Ok(())` if successful, or an error if:
324/// - No rebase is in progress
325/// - Conflicts remain unresolved
326/// - The continue operation fails
327pub fn continue_rebase() -> io::Result<()> {
328    use std::process::Command;
329
330    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
331
332    // Check if a rebase is in progress
333    let state = repo.state();
334    if state != git2::RepositoryState::Rebase
335        && state != git2::RepositoryState::RebaseMerge
336        && state != git2::RepositoryState::RebaseInteractive
337    {
338        return Err(io::Error::new(
339            io::ErrorKind::InvalidInput,
340            "No rebase in progress",
341        ));
342    }
343
344    // Check if there are still conflicts
345    let conflicted = get_conflicted_files()?;
346    if !conflicted.is_empty() {
347        return Err(io::Error::new(
348            io::ErrorKind::InvalidInput,
349            format!(
350                "Cannot continue rebase: {} file(s) still have conflicts",
351                conflicted.len()
352            ),
353        ));
354    }
355
356    // Use git CLI for continue
357    let output = Command::new("git").args(["rebase", "--continue"]).output();
358
359    match output {
360        Ok(result) => {
361            if result.status.success() {
362                Ok(())
363            } else {
364                let stderr = String::from_utf8_lossy(&result.stderr);
365                Err(io::Error::other(format!(
366                    "Failed to continue rebase: {stderr}"
367                )))
368            }
369        }
370        Err(e) => Err(io::Error::other(format!(
371            "Failed to execute git rebase --continue: {e}"
372        ))),
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    #[test]
381    fn test_rebase_result_variants_exist() {
382        // Test that RebaseResult has the expected variants
383        let _ = RebaseResult::Success;
384        let _ = RebaseResult::NoOp;
385        let _ = RebaseResult::Conflicts(vec![]);
386    }
387
388    #[test]
389    fn test_rebase_onto_returns_result() {
390        // Test that rebase_onto returns a Result
391        // We use a non-existent branch to test error handling
392        let result = rebase_onto("nonexistent_branch_that_does_not_exist");
393        // Should fail because the branch doesn't exist
394        assert!(result.is_err());
395    }
396
397    #[test]
398    fn test_get_conflicted_files_returns_result() {
399        // Test that get_conflicted_files returns a Result
400        let result = get_conflicted_files();
401        // Should succeed (returns Vec, not error)
402        assert!(result.is_ok());
403    }
404}