1use 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#[derive(Debug, Clone, PartialEq)]
42pub enum MergeStatus {
43 Success(Hash),
45 FastForward(Hash),
47 UpToDate,
49 Conflicts(Vec<PathBuf>),
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum FastForwardMode {
56 Auto,
58 Only,
60 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum MergeStrategy {
77 Recursive,
79 Ours,
81 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#[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 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 pub fn with_fast_forward(mut self, mode: FastForwardMode) -> Self {
117 self.fast_forward = mode;
118 self
119 }
120
121 pub fn with_strategy(mut self, strategy: MergeStrategy) -> Self {
123 self.strategy = Some(strategy);
124 self
125 }
126
127 pub fn with_message(mut self, message: String) -> Self {
129 self.commit_message = Some(message);
130 self
131 }
132
133 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
146pub 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 let ff_option = options.fast_forward.as_str();
156 if !ff_option.is_empty() {
157 args.push(ff_option);
158 }
159
160 if let Some(strategy) = options.strategy {
162 args.push("-s");
163 args.push(strategy.as_str());
164 }
165
166 if options.no_commit {
168 args.push("--no-commit");
169 }
170
171 if let Some(ref message) = options.commit_message {
173 args.push("-m");
174 args.push(message);
175 }
176
177 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 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 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 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 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 let conflicts = extract_conflicted_files(repo_path.as_ref())?;
214 Ok(MergeStatus::Conflicts(conflicts))
215 } else {
216 Err(GitError::CommandFailed(format!(
218 "git {} failed: stdout='{}' stderr='{}'",
219 args.join(" "),
220 stdout,
221 stderr
222 )))
223 }
224}
225
226fn 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
242pub 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
249pub 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 pub fn merge(&self, branch: &str) -> Result<MergeStatus> {
283 Self::ensure_git()?;
284 merge(self.repo_path(), branch, &MergeOptions::new())
285 }
286
287 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 pub fn merge_in_progress(&self) -> Result<bool> {
327 Self::ensure_git()?;
328 merge_in_progress(self.repo_path())
329 }
330
331 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 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 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_file_and_commit(&repo, &temp_dir, "file1.txt", "content1", "Initial commit");
434
435 repo.checkout_new("feature", None).unwrap();
437
438 create_file_and_commit(&repo, &temp_dir, "file2.txt", "content2", "Feature commit");
440
441 let branches = repo.branches().unwrap();
443 let master_branch = branches.find("master").unwrap();
444 repo.checkout(master_branch).unwrap();
445
446 let status = repo.merge("feature").unwrap();
448
449 match status {
450 MergeStatus::FastForward(_) => {
451 assert!(temp_dir.join("file2.txt").exists());
453 }
454 _ => panic!("Expected fast-forward merge, got: {:?}", status),
455 }
456
457 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_file_and_commit(&repo, &temp_dir, "file1.txt", "content1", "Initial commit");
467
468 repo.checkout_new("feature", None).unwrap();
470
471 create_file_and_commit(&repo, &temp_dir, "file2.txt", "content2", "Feature commit");
473
474 let branches = repo.branches().unwrap();
476 let master_branch = branches.find("master").unwrap();
477 repo.checkout(master_branch).unwrap();
478
479 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 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 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_file_and_commit(&repo, &temp_dir, "file1.txt", "content1", "Initial commit");
502
503 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 let status = repo.merge("feature").unwrap();
511
512 assert_eq!(status, MergeStatus::UpToDate);
513
514 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_file_and_commit(&repo, &temp_dir, "file1.txt", "content1", "Initial commit");
524
525 assert!(!repo.merge_in_progress().unwrap());
527
528 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_file_and_commit(
538 &repo,
539 &temp_dir,
540 "file1.txt",
541 "line1\nline2\nline3",
542 "Initial commit",
543 );
544
545 repo.checkout_new("feature", None).unwrap();
547
548 create_file_and_commit(
550 &repo,
551 &temp_dir,
552 "file1.txt",
553 "line1\nfeature_line\nline3",
554 "Feature changes",
555 );
556
557 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 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 assert!(repo.merge_in_progress().unwrap());
579
580 repo.abort_merge().unwrap();
582
583 assert!(!repo.merge_in_progress().unwrap());
585 }
586 _ => panic!("Expected conflicts, got: {:?}", status),
587 }
588
589 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_file_and_commit(&repo, &temp_dir, "file1.txt", "content1", "Initial commit");
599
600 repo.checkout_new("feature", None).unwrap();
602
603 create_file_and_commit(&repo, &temp_dir, "file2.txt", "content2", "Feature commit");
605
606 let branches = repo.branches().unwrap();
608 let master_branch = branches.find("master").unwrap();
609 repo.checkout(master_branch).unwrap();
610
611 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 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 fs::remove_dir_all(&temp_dir).unwrap();
635 }
636}