rustic_git/commands/
merge.rs

1//! Git merge operations
2//!
3//! This module provides functionality for merging branches and handling merge conflicts.
4//! It supports different merge strategies and fast-forward modes with comprehensive type safety.
5//!
6//! # Examples
7//!
8//! ```rust,no_run
9//! use rustic_git::{Repository, MergeOptions, MergeStatus, FastForwardMode};
10//!
11//! let repo = Repository::open(".")?;
12//!
13//! // Simple merge
14//! let status = repo.merge("feature-branch")?;
15//! match status {
16//!     MergeStatus::Success(hash) => println!("Merge commit: {}", hash),
17//!     MergeStatus::FastForward(hash) => println!("Fast-forwarded to: {}", hash),
18//!     MergeStatus::UpToDate => println!("Already up to date"),
19//!     MergeStatus::Conflicts(files) => {
20//!         println!("Conflicts in files: {:?}", files);
21//!         // Resolve conflicts manually, then commit
22//!     }
23//! }
24//!
25//! // Merge with options
26//! let options = MergeOptions::new()
27//!     .with_fast_forward(FastForwardMode::Never)
28//!     .with_message("Merge feature branch".to_string());
29//! let status = repo.merge_with_options("feature-branch", options)?;
30//!
31//! # Ok::<(), rustic_git::GitError>(())
32//! ```
33
34use crate::error::{GitError, Result};
35use crate::repository::Repository;
36use crate::types::Hash;
37use crate::utils::{git, git_raw};
38use std::path::{Path, PathBuf};
39
40/// The result of a merge operation
41#[derive(Debug, Clone, PartialEq)]
42pub enum MergeStatus {
43    /// Merge completed successfully with a new merge commit
44    Success(Hash),
45    /// Fast-forward merge completed (no merge commit created)
46    FastForward(Hash),
47    /// Already up to date, no changes needed
48    UpToDate,
49    /// Merge has conflicts that need manual resolution
50    Conflicts(Vec<PathBuf>),
51}
52
53/// Fast-forward merge behavior
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum FastForwardMode {
56    /// Allow fast-forward when possible (default)
57    Auto,
58    /// Only fast-forward, fail if merge commit would be needed
59    Only,
60    /// Never fast-forward, always create merge commit
61    Never,
62}
63
64impl FastForwardMode {
65    pub const fn as_str(&self) -> &'static str {
66        match self {
67            FastForwardMode::Auto => "",
68            FastForwardMode::Only => "--ff-only",
69            FastForwardMode::Never => "--no-ff",
70        }
71    }
72}
73
74/// Merge strategy options
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum MergeStrategy {
77    /// Default recursive strategy
78    Recursive,
79    /// Ours strategy (favor our changes)
80    Ours,
81    /// Theirs strategy (favor their changes)
82    Theirs,
83}
84
85impl MergeStrategy {
86    pub const fn as_str(&self) -> &'static str {
87        match self {
88            MergeStrategy::Recursive => "recursive",
89            MergeStrategy::Ours => "ours",
90            MergeStrategy::Theirs => "theirs",
91        }
92    }
93}
94
95/// Options for merge operations
96#[derive(Debug, Clone)]
97pub struct MergeOptions {
98    fast_forward: FastForwardMode,
99    strategy: Option<MergeStrategy>,
100    commit_message: Option<String>,
101    no_commit: bool,
102}
103
104impl MergeOptions {
105    /// Create new MergeOptions with default settings
106    pub fn new() -> Self {
107        Self {
108            fast_forward: FastForwardMode::Auto,
109            strategy: None,
110            commit_message: None,
111            no_commit: false,
112        }
113    }
114
115    /// Set the fast-forward mode
116    pub fn with_fast_forward(mut self, mode: FastForwardMode) -> Self {
117        self.fast_forward = mode;
118        self
119    }
120
121    /// Set the merge strategy
122    pub fn with_strategy(mut self, strategy: MergeStrategy) -> Self {
123        self.strategy = Some(strategy);
124        self
125    }
126
127    /// Set a custom commit message for the merge
128    pub fn with_message(mut self, message: String) -> Self {
129        self.commit_message = Some(message);
130        self
131    }
132
133    /// Perform merge but don't automatically commit
134    pub fn with_no_commit(mut self) -> Self {
135        self.no_commit = true;
136        self
137    }
138}
139
140impl Default for MergeOptions {
141    fn default() -> Self {
142        Self::new()
143    }
144}
145
146/// Perform a merge operation
147pub fn merge<P: AsRef<Path>>(
148    repo_path: P,
149    branch: &str,
150    options: &MergeOptions,
151) -> Result<MergeStatus> {
152    let mut args = vec!["merge"];
153
154    // Add fast-forward option if not auto
155    let ff_option = options.fast_forward.as_str();
156    if !ff_option.is_empty() {
157        args.push(ff_option);
158    }
159
160    // Add strategy if specified
161    if let Some(strategy) = options.strategy {
162        args.push("-s");
163        args.push(strategy.as_str());
164    }
165
166    // Add no-commit option if specified
167    if options.no_commit {
168        args.push("--no-commit");
169    }
170
171    // Add custom commit message if specified
172    if let Some(ref message) = options.commit_message {
173        args.push("-m");
174        args.push(message);
175    }
176
177    // Add the branch to merge
178    args.push(branch);
179
180    let output = git_raw(&args, Some(repo_path.as_ref()))?;
181    let stdout = String::from_utf8_lossy(&output.stdout);
182    let stderr = String::from_utf8_lossy(&output.stderr);
183
184    if output.status.success() {
185        // Parse the output to determine merge status
186        if stdout.contains("Already up to date") || stdout.contains("Already up-to-date") {
187            Ok(MergeStatus::UpToDate)
188        } else if stdout.contains("Fast-forward") {
189            // Extract the hash from fast-forward output
190            if let Some(hash_line) = stdout.lines().find(|line| line.contains(".."))
191                && let Some(hash_part) = hash_line.split("..").nth(1)
192                && let Some(hash_str) = hash_part.split_whitespace().next()
193            {
194                let hash = Hash::from(hash_str);
195                return Ok(MergeStatus::FastForward(hash));
196            }
197            // Fallback: get current HEAD
198            let head_output = git(&["rev-parse", "HEAD"], Some(repo_path.as_ref()))?;
199            let hash = Hash::from(head_output.trim());
200            Ok(MergeStatus::FastForward(hash))
201        } else {
202            // Regular merge success - get the merge commit hash
203            let head_output = git(&["rev-parse", "HEAD"], Some(repo_path.as_ref()))?;
204            let hash = Hash::from(head_output.trim());
205            Ok(MergeStatus::Success(hash))
206        }
207    } else if stderr.contains("CONFLICT")
208        || stderr.contains("Automatic merge failed")
209        || stdout.contains("CONFLICT")
210        || stdout.contains("Automatic merge failed")
211    {
212        // Merge has conflicts
213        let conflicts = extract_conflicted_files(repo_path.as_ref())?;
214        Ok(MergeStatus::Conflicts(conflicts))
215    } else {
216        // Other error
217        Err(GitError::CommandFailed(format!(
218            "git {} failed: stdout='{}' stderr='{}'",
219            args.join(" "),
220            stdout,
221            stderr
222        )))
223    }
224}
225
226/// Extract list of files with conflicts
227fn extract_conflicted_files<P: AsRef<Path>>(repo_path: P) -> Result<Vec<PathBuf>> {
228    let output = git(
229        &["diff", "--name-only", "--diff-filter=U"],
230        Some(repo_path.as_ref()),
231    )?;
232
233    let conflicts: Vec<PathBuf> = output
234        .lines()
235        .filter(|line| !line.trim().is_empty())
236        .map(|line| PathBuf::from(line.trim()))
237        .collect();
238
239    Ok(conflicts)
240}
241
242/// Check if a merge is currently in progress
243pub fn merge_in_progress<P: AsRef<Path>>(repo_path: P) -> Result<bool> {
244    let git_dir = repo_path.as_ref().join(".git");
245    let merge_head = git_dir.join("MERGE_HEAD");
246    Ok(merge_head.exists())
247}
248
249/// Abort an in-progress merge
250pub fn abort_merge<P: AsRef<Path>>(repo_path: P) -> Result<()> {
251    git(&["merge", "--abort"], Some(repo_path.as_ref()))?;
252    Ok(())
253}
254
255impl Repository {
256    /// Merge the specified branch into the current branch.
257    ///
258    /// Performs a merge using default options (allow fast-forward, no custom message).
259    ///
260    /// # Arguments
261    ///
262    /// * `branch` - The name of the branch to merge into the current branch
263    ///
264    /// # Returns
265    ///
266    /// A `Result` containing the `MergeStatus` which indicates the outcome of the merge.
267    ///
268    /// # Examples
269    ///
270    /// ```rust,no_run
271    /// use rustic_git::{Repository, MergeStatus};
272    ///
273    /// let repo = Repository::open(".")?;
274    /// match repo.merge("feature-branch")? {
275    ///     MergeStatus::Success(hash) => println!("Merge commit: {}", hash),
276    ///     MergeStatus::FastForward(hash) => println!("Fast-forwarded to: {}", hash),
277    ///     MergeStatus::UpToDate => println!("Already up to date"),
278    ///     MergeStatus::Conflicts(files) => println!("Conflicts in: {:?}", files),
279    /// }
280    /// # Ok::<(), rustic_git::GitError>(())
281    /// ```
282    pub fn merge(&self, branch: &str) -> Result<MergeStatus> {
283        Self::ensure_git()?;
284        merge(self.repo_path(), branch, &MergeOptions::new())
285    }
286
287    /// Merge the specified branch with custom options.
288    ///
289    /// Provides full control over merge behavior including fast-forward mode,
290    /// merge strategy, and commit message.
291    ///
292    /// # Arguments
293    ///
294    /// * `branch` - The name of the branch to merge into the current branch
295    /// * `options` - Merge options controlling the merge behavior
296    ///
297    /// # Returns
298    ///
299    /// A `Result` containing the `MergeStatus` which indicates the outcome of the merge.
300    ///
301    /// # Examples
302    ///
303    /// ```rust,no_run
304    /// use rustic_git::{Repository, MergeOptions, FastForwardMode};
305    ///
306    /// let repo = Repository::open(".")?;
307    /// let options = MergeOptions::new()
308    ///     .with_fast_forward(FastForwardMode::Never)
309    ///     .with_message("Merge feature into main".to_string());
310    ///
311    /// let status = repo.merge_with_options("feature-branch", options)?;
312    /// # Ok::<(), rustic_git::GitError>(())
313    /// ```
314    pub fn merge_with_options(&self, branch: &str, options: MergeOptions) -> Result<MergeStatus> {
315        Self::ensure_git()?;
316        merge(self.repo_path(), branch, &options)
317    }
318
319    /// Check if a merge is currently in progress.
320    ///
321    /// Returns `true` if there is an ongoing merge that needs to be completed or aborted.
322    ///
323    /// # Returns
324    ///
325    /// A `Result` containing a boolean indicating whether a merge is in progress.
326    pub fn merge_in_progress(&self) -> Result<bool> {
327        Self::ensure_git()?;
328        merge_in_progress(self.repo_path())
329    }
330
331    /// Abort an in-progress merge.
332    ///
333    /// Cancels the current merge operation and restores the repository to the state
334    /// before the merge was started. This is useful when merge conflicts occur and
335    /// you want to cancel the merge instead of resolving conflicts.
336    ///
337    /// # Returns
338    ///
339    /// A `Result` indicating success or failure of the abort operation.
340    ///
341    /// # Examples
342    ///
343    /// ```rust,no_run
344    /// use rustic_git::Repository;
345    ///
346    /// let repo = Repository::open(".")?;
347    /// if repo.merge_in_progress()? {
348    ///     repo.abort_merge()?;
349    ///     println!("Merge aborted");
350    /// }
351    /// # Ok::<(), rustic_git::GitError>(())
352    /// ```
353    pub fn abort_merge(&self) -> Result<()> {
354        Self::ensure_git()?;
355        abort_merge(self.repo_path())
356    }
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362    use crate::Repository;
363    use std::path::PathBuf;
364    use std::{env, fs};
365
366    fn create_test_repo(test_name: &str) -> (PathBuf, Repository) {
367        let temp_dir = env::temp_dir().join(format!("rustic_git_merge_test_{}", test_name));
368
369        // Clean up if exists
370        if temp_dir.exists() {
371            fs::remove_dir_all(&temp_dir).unwrap();
372        }
373
374        let repo = Repository::init(&temp_dir, false).unwrap();
375
376        // Configure git user for testing
377        repo.config()
378            .set_user("Test User", "test@example.com")
379            .unwrap();
380
381        (temp_dir, repo)
382    }
383
384    fn create_file_and_commit(
385        repo: &Repository,
386        temp_dir: &Path,
387        filename: &str,
388        content: &str,
389        message: &str,
390    ) -> String {
391        let file_path = temp_dir.join(filename);
392        fs::write(&file_path, content).unwrap();
393        repo.add(&[filename]).unwrap();
394        repo.commit(message).unwrap().to_string()
395    }
396
397    #[test]
398    fn test_fast_forward_mode_as_str() {
399        assert_eq!(FastForwardMode::Auto.as_str(), "");
400        assert_eq!(FastForwardMode::Only.as_str(), "--ff-only");
401        assert_eq!(FastForwardMode::Never.as_str(), "--no-ff");
402    }
403
404    #[test]
405    fn test_merge_strategy_as_str() {
406        assert_eq!(MergeStrategy::Recursive.as_str(), "recursive");
407        assert_eq!(MergeStrategy::Ours.as_str(), "ours");
408        assert_eq!(MergeStrategy::Theirs.as_str(), "theirs");
409    }
410
411    #[test]
412    fn test_merge_options_builder() {
413        let options = MergeOptions::new()
414            .with_fast_forward(FastForwardMode::Never)
415            .with_strategy(MergeStrategy::Ours)
416            .with_message("Custom merge message".to_string())
417            .with_no_commit();
418
419        assert_eq!(options.fast_forward, FastForwardMode::Never);
420        assert_eq!(options.strategy, Some(MergeStrategy::Ours));
421        assert_eq!(
422            options.commit_message,
423            Some("Custom merge message".to_string())
424        );
425        assert!(options.no_commit);
426    }
427
428    #[test]
429    fn test_merge_fast_forward() {
430        let (temp_dir, repo) = create_test_repo("merge_fast_forward");
431
432        // Create initial commit on master
433        create_file_and_commit(&repo, &temp_dir, "file1.txt", "content1", "Initial commit");
434
435        // Create and switch to feature branch
436        repo.checkout_new("feature", None).unwrap();
437
438        // Add commit to feature branch
439        create_file_and_commit(&repo, &temp_dir, "file2.txt", "content2", "Feature commit");
440
441        // Switch back to master
442        let branches = repo.branches().unwrap();
443        let master_branch = branches.find("master").unwrap();
444        repo.checkout(master_branch).unwrap();
445
446        // Merge feature branch (should fast-forward)
447        let status = repo.merge("feature").unwrap();
448
449        match status {
450            MergeStatus::FastForward(_) => {
451                // Verify file2.txt exists
452                assert!(temp_dir.join("file2.txt").exists());
453            }
454            _ => panic!("Expected fast-forward merge, got: {:?}", status),
455        }
456
457        // Clean up
458        fs::remove_dir_all(&temp_dir).unwrap();
459    }
460
461    #[test]
462    fn test_merge_no_fast_forward() {
463        let (temp_dir, repo) = create_test_repo("merge_no_ff");
464
465        // Create initial commit on master
466        create_file_and_commit(&repo, &temp_dir, "file1.txt", "content1", "Initial commit");
467
468        // Create and switch to feature branch
469        repo.checkout_new("feature", None).unwrap();
470
471        // Add commit to feature branch
472        create_file_and_commit(&repo, &temp_dir, "file2.txt", "content2", "Feature commit");
473
474        // Switch back to master
475        let branches = repo.branches().unwrap();
476        let master_branch = branches.find("master").unwrap();
477        repo.checkout(master_branch).unwrap();
478
479        // Merge feature branch with no fast-forward
480        let options = MergeOptions::new().with_fast_forward(FastForwardMode::Never);
481        let status = repo.merge_with_options("feature", options).unwrap();
482
483        match status {
484            MergeStatus::Success(_) => {
485                // Verify merge commit was created and both files exist
486                assert!(temp_dir.join("file1.txt").exists());
487                assert!(temp_dir.join("file2.txt").exists());
488            }
489            _ => panic!("Expected merge commit, got: {:?}", status),
490        }
491
492        // Clean up
493        fs::remove_dir_all(&temp_dir).unwrap();
494    }
495
496    #[test]
497    fn test_merge_up_to_date() {
498        let (temp_dir, repo) = create_test_repo("merge_up_to_date");
499
500        // Create initial commit
501        create_file_and_commit(&repo, &temp_dir, "file1.txt", "content1", "Initial commit");
502
503        // Create feature branch but don't add commits
504        repo.checkout_new("feature", None).unwrap();
505        let branches = repo.branches().unwrap();
506        let master_branch = branches.find("master").unwrap();
507        repo.checkout(master_branch).unwrap();
508
509        // Try to merge feature (should be up to date)
510        let status = repo.merge("feature").unwrap();
511
512        assert_eq!(status, MergeStatus::UpToDate);
513
514        // Clean up
515        fs::remove_dir_all(&temp_dir).unwrap();
516    }
517
518    #[test]
519    fn test_merge_in_progress_false() {
520        let (temp_dir, repo) = create_test_repo("merge_in_progress_false");
521
522        // Create initial commit
523        create_file_and_commit(&repo, &temp_dir, "file1.txt", "content1", "Initial commit");
524
525        // Check merge in progress (should be false)
526        assert!(!repo.merge_in_progress().unwrap());
527
528        // Clean up
529        fs::remove_dir_all(&temp_dir).unwrap();
530    }
531
532    #[test]
533    fn test_merge_conflicts() {
534        let (temp_dir, repo) = create_test_repo("merge_conflicts");
535
536        // Create initial commit
537        create_file_and_commit(
538            &repo,
539            &temp_dir,
540            "file1.txt",
541            "line1\nline2\nline3",
542            "Initial commit",
543        );
544
545        // Create and switch to feature branch
546        repo.checkout_new("feature", None).unwrap();
547
548        // Modify file in feature branch
549        create_file_and_commit(
550            &repo,
551            &temp_dir,
552            "file1.txt",
553            "line1\nfeature_line\nline3",
554            "Feature changes",
555        );
556
557        // Switch back to master and modify same file
558        let branches = repo.branches().unwrap();
559        let master_branch = branches.find("master").unwrap();
560        repo.checkout(master_branch).unwrap();
561        create_file_and_commit(
562            &repo,
563            &temp_dir,
564            "file1.txt",
565            "line1\nmaster_line\nline3",
566            "Master changes",
567        );
568
569        // Try to merge feature branch (should have conflicts)
570        let status = repo.merge("feature").unwrap();
571
572        match status {
573            MergeStatus::Conflicts(files) => {
574                assert!(!files.is_empty());
575                assert!(files.iter().any(|f| f.file_name().unwrap() == "file1.txt"));
576
577                // Verify merge is in progress
578                assert!(repo.merge_in_progress().unwrap());
579
580                // Abort the merge
581                repo.abort_merge().unwrap();
582
583                // Verify merge is no longer in progress
584                assert!(!repo.merge_in_progress().unwrap());
585            }
586            _ => panic!("Expected conflicts, got: {:?}", status),
587        }
588
589        // Clean up
590        fs::remove_dir_all(&temp_dir).unwrap();
591    }
592
593    #[test]
594    fn test_merge_with_custom_message() {
595        let (temp_dir, repo) = create_test_repo("merge_custom_message");
596
597        // Create initial commit on master
598        create_file_and_commit(&repo, &temp_dir, "file1.txt", "content1", "Initial commit");
599
600        // Create and switch to feature branch
601        repo.checkout_new("feature", None).unwrap();
602
603        // Add commit to feature branch
604        create_file_and_commit(&repo, &temp_dir, "file2.txt", "content2", "Feature commit");
605
606        // Switch back to master
607        let branches = repo.branches().unwrap();
608        let master_branch = branches.find("master").unwrap();
609        repo.checkout(master_branch).unwrap();
610
611        // Merge with custom message and no fast-forward
612        let options = MergeOptions::new()
613            .with_fast_forward(FastForwardMode::Never)
614            .with_message("Custom merge commit message".to_string());
615
616        let status = repo.merge_with_options("feature", options).unwrap();
617
618        match status {
619            MergeStatus::Success(_) => {
620                // Get the latest commit message
621                let commits = repo.recent_commits(1).unwrap();
622                let latest_commit = commits.iter().next().unwrap();
623                assert!(
624                    latest_commit
625                        .message
626                        .subject
627                        .contains("Custom merge commit message")
628                );
629            }
630            _ => panic!("Expected successful merge, got: {:?}", status),
631        }
632
633        // Clean up
634        fs::remove_dir_all(&temp_dir).unwrap();
635    }
636}