1use anyhow::Result;
8use serde::{Deserialize, Serialize};
9use std::path::Path;
10use tokio::process::Command;
11use tracing::warn;
12
13use crate::worktree::config::WorktreeMergeDetectionConfig;
14use crate::worktree::status::MergeInfo;
15
16#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum MergeDetectionMethod {
19 Standard,
21 Squash,
23 GitHubPR,
25 FileContent,
27}
28
29impl MergeDetectionMethod {
30 pub fn as_str(&self) -> &'static str {
31 match self {
32 MergeDetectionMethod::Standard => "standard",
33 MergeDetectionMethod::Squash => "squash",
34 MergeDetectionMethod::GitHubPR => "github_pr",
35 MergeDetectionMethod::FileContent => "file_content",
36 }
37 }
38
39 pub fn from_str(s: &str) -> Option<Self> {
40 match s {
41 "standard" => Some(MergeDetectionMethod::Standard),
42 "squash" => Some(MergeDetectionMethod::Squash),
43 "github_pr" => Some(MergeDetectionMethod::GitHubPR),
44 "file_content" => Some(MergeDetectionMethod::FileContent),
45 _ => None,
46 }
47 }
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct MergeDetectionResult {
53 pub is_merged: bool,
55
56 pub detection_method: String,
58
59 pub confidence: f32,
61
62 pub details: Option<String>,
64
65 pub method_results: Vec<MethodResult>,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct MethodResult {
71 pub method: String,
72 pub is_merged: bool,
73 pub confidence: f32,
74 pub details: Option<String>,
75 pub error: Option<String>,
76}
77
78pub struct MergeDetector {
80 config: WorktreeMergeDetectionConfig,
81}
82
83impl MergeDetector {
84 pub fn new(config: WorktreeMergeDetectionConfig) -> Self {
85 Self { config }
86 }
87
88 pub async fn detect_merge(
90 &self,
91 worktree_path: &Path,
92 branch_name: &str,
93 ) -> Result<MergeDetectionResult> {
94 let mut method_results = Vec::new();
95
96 for method_name in &self.config.methods {
98 if let Some(method) = MergeDetectionMethod::from_str(method_name) {
99 let result = self
100 .try_detection_method(&method, worktree_path, branch_name)
101 .await;
102 method_results.push(result);
103 } else {
104 warn!("Unknown merge detection method: {}", method_name);
105 }
106 }
107
108 self.analyze_method_results(method_results)
110 }
111
112 async fn try_detection_method(
114 &self,
115 method: &MergeDetectionMethod,
116 worktree_path: &Path,
117 branch_name: &str,
118 ) -> MethodResult {
119 let method_name = method.as_str().to_string();
120
121 match method {
122 MergeDetectionMethod::Standard => {
123 match self.detect_standard_merge(worktree_path, branch_name).await {
124 Ok((is_merged, details)) => MethodResult {
125 method: method_name,
126 is_merged,
127 confidence: if is_merged { 0.95 } else { 0.8 },
128 details,
129 error: None,
130 },
131 Err(e) => MethodResult {
132 method: method_name,
133 is_merged: false,
134 confidence: 0.0,
135 details: None,
136 error: Some(e.to_string()),
137 },
138 }
139 }
140
141 MergeDetectionMethod::Squash => {
142 match self.detect_squash_merge(worktree_path, branch_name).await {
143 Ok((is_merged, confidence, details)) => MethodResult {
144 method: method_name,
145 is_merged,
146 confidence,
147 details,
148 error: None,
149 },
150 Err(e) => MethodResult {
151 method: method_name,
152 is_merged: false,
153 confidence: 0.0,
154 details: None,
155 error: Some(e.to_string()),
156 },
157 }
158 }
159
160 MergeDetectionMethod::GitHubPR => {
161 if !self.config.use_github_cli {
162 return MethodResult {
163 method: method_name,
164 is_merged: false,
165 confidence: 0.0,
166 details: Some("GitHub CLI integration disabled".to_string()),
167 error: None,
168 };
169 }
170
171 match self
172 .detect_github_pr_merge(worktree_path, branch_name)
173 .await
174 {
175 Ok((is_merged, details)) => MethodResult {
176 method: method_name,
177 is_merged,
178 confidence: if is_merged { 0.9 } else { 0.0 },
179 details,
180 error: None,
181 },
182 Err(e) => MethodResult {
183 method: method_name,
184 is_merged: false,
185 confidence: 0.0,
186 details: None,
187 error: Some(e.to_string()),
188 },
189 }
190 }
191
192 MergeDetectionMethod::FileContent => {
193 match self
194 .detect_file_content_merge(worktree_path, branch_name)
195 .await
196 {
197 Ok((is_merged, confidence, details)) => MethodResult {
198 method: method_name,
199 is_merged,
200 confidence,
201 details,
202 error: None,
203 },
204 Err(e) => MethodResult {
205 method: method_name,
206 is_merged: false,
207 confidence: 0.0,
208 details: None,
209 error: Some(e.to_string()),
210 },
211 }
212 }
213 }
214 }
215
216 async fn detect_standard_merge(
218 &self,
219 worktree_path: &Path,
220 branch_name: &str,
221 ) -> Result<(bool, Option<String>)> {
222 if !self.has_remote_tracking(worktree_path).await? {
225 return Ok((
226 false,
227 Some("Branch has no remote tracking - cannot determine merge status".to_string()),
228 ));
229 }
230
231 for main_branch in &self.config.main_branches {
233 let output = Command::new("git")
234 .args(&["branch", "--merged", main_branch])
235 .current_dir(worktree_path)
236 .output()
237 .await?;
238
239 if output.status.success() {
240 let output_str = String::from_utf8_lossy(&output.stdout);
241 for line in output_str.lines() {
242 let clean_line = line.trim().trim_start_matches('*').trim();
243 if clean_line == branch_name {
244 return Ok((true, Some(format!("merged into {}", main_branch))));
245 }
246 }
247 }
248 }
249
250 Ok((false, None))
251 }
252
253 async fn detect_squash_merge(
255 &self,
256 worktree_path: &Path,
257 branch_name: &str,
258 ) -> Result<(bool, f32, Option<String>)> {
259 if !self.has_remote_tracking(worktree_path).await? {
261 return Ok((
262 false,
263 0.0,
264 Some("Branch has no remote tracking - cannot determine merge status".to_string()),
265 ));
266 }
267
268 let main_branch = self.find_best_main_branch(worktree_path).await?;
270
271 let merge_base_output = Command::new("git")
273 .args(&["merge-base", &main_branch, branch_name])
274 .current_dir(worktree_path)
275 .output()
276 .await?;
277
278 if !merge_base_output.status.success() {
279 return Ok((false, 0.0, Some("Cannot find merge base".to_string())));
280 }
281
282 let merge_base = String::from_utf8_lossy(&merge_base_output.stdout)
283 .trim()
284 .to_string();
285
286 let diff_output = Command::new("git")
288 .args(&[
289 "diff",
290 "--exit-code",
291 &format!("{}..{}", merge_base, branch_name),
292 ])
293 .current_dir(worktree_path)
294 .output()
295 .await?;
296
297 if diff_output.status.success() {
298 return Ok((true, 0.6, Some("no unique changes".to_string())));
300 }
301
302 let commit_analysis = self
304 .analyze_main_branch_for_squash(worktree_path, &main_branch, branch_name, &merge_base)
305 .await?;
306
307 if commit_analysis.confidence > 0.5 {
308 return Ok((true, commit_analysis.confidence, commit_analysis.details));
309 }
310
311 let file_analysis = self
313 .compare_file_contents(worktree_path, &main_branch, branch_name, &merge_base)
314 .await?;
315
316 Ok((
317 file_analysis.is_merged,
318 file_analysis.confidence,
319 file_analysis.details,
320 ))
321 }
322
323 async fn detect_github_pr_merge(
325 &self,
326 worktree_path: &Path,
327 branch_name: &str,
328 ) -> Result<(bool, Option<String>)> {
329 if !self.has_remote_tracking(worktree_path).await? {
331 return Ok((
332 false,
333 Some("Branch has no remote tracking - cannot determine merge status".to_string()),
334 ));
335 }
336
337 let output = Command::new("gh")
339 .args(&[
340 "pr",
341 "list",
342 "--state",
343 "merged",
344 "--head",
345 branch_name,
346 "--json",
347 "number,title,mergedAt",
348 ])
349 .current_dir(worktree_path)
350 .output()
351 .await?;
352
353 if !output.status.success() {
354 let stderr = String::from_utf8_lossy(&output.stderr);
355 if stderr.contains("not found") || stderr.contains("No such file") {
356 return Err(anyhow::anyhow!("GitHub CLI not available"));
357 }
358 return Err(anyhow::anyhow!("GitHub CLI failed: {}", stderr));
359 }
360
361 let json_str = String::from_utf8_lossy(&output.stdout);
362 if json_str.trim().is_empty() || json_str.trim() == "[]" {
363 return Ok((false, None));
364 }
365
366 let prs: serde_json::Value = serde_json::from_str(&json_str)?;
368 if let Some(pr_array) = prs.as_array() {
369 if let Some(pr) = pr_array.first() {
370 if let Some(pr_number) = pr.get("number").and_then(|n| n.as_u64()) {
371 return Ok((true, Some(format!("PR #{} merged", pr_number))));
372 }
373 }
374 }
375
376 Ok((false, None))
377 }
378
379 async fn detect_file_content_merge(
381 &self,
382 worktree_path: &Path,
383 branch_name: &str,
384 ) -> Result<(bool, f32, Option<String>)> {
385 if !self.has_remote_tracking(worktree_path).await? {
387 return Ok((
388 false,
389 0.0,
390 Some("Branch has no remote tracking - cannot determine merge status".to_string()),
391 ));
392 }
393
394 let main_branch = self.find_best_main_branch(worktree_path).await?;
395 let merge_base = self
396 .get_merge_base(worktree_path, &main_branch, branch_name)
397 .await?;
398
399 let changed_files = self
401 .get_changed_files(worktree_path, &merge_base, branch_name)
402 .await?;
403
404 if changed_files.is_empty() {
405 return Ok((true, 0.8, Some("no file changes".to_string())));
406 }
407
408 let mut matching_files = 0;
410 let mut total_files = 0;
411
412 for file in &changed_files {
413 total_files += 1;
414
415 if self
416 .files_have_same_content(worktree_path, file, &main_branch, branch_name)
417 .await?
418 {
419 matching_files += 1;
420 }
421 }
422
423 let match_ratio = matching_files as f32 / total_files as f32;
424 let confidence = match_ratio * 0.7; let details = if match_ratio > 0.8 {
427 Some(format!(
428 "file contents match ({}/{})",
429 matching_files, total_files
430 ))
431 } else {
432 None
433 };
434
435 Ok((match_ratio > 0.8, confidence, details))
436 }
437
438 async fn find_best_main_branch(&self, worktree_path: &Path) -> Result<String> {
441 for branch in &self.config.main_branches {
442 let output = Command::new("git")
443 .args(&["rev-parse", "--verify", branch])
444 .current_dir(worktree_path)
445 .output()
446 .await?;
447
448 if output.status.success() {
449 return Ok(branch.clone());
450 }
451 }
452
453 Err(anyhow::anyhow!("No main branch found"))
454 }
455
456 async fn has_remote_tracking(&self, worktree_path: &Path) -> Result<bool> {
458 let upstream_result = Command::new("git")
459 .args(&["rev-parse", "--abbrev-ref", "@{u}"])
460 .current_dir(worktree_path)
461 .output()
462 .await?;
463
464 Ok(upstream_result.status.success())
465 }
466
467 async fn get_merge_base(
468 &self,
469 worktree_path: &Path,
470 main_branch: &str,
471 branch_name: &str,
472 ) -> Result<String> {
473 let output = Command::new("git")
474 .args(&["merge-base", main_branch, branch_name])
475 .current_dir(worktree_path)
476 .output()
477 .await?;
478
479 if output.status.success() {
480 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
481 } else {
482 Err(anyhow::anyhow!("Cannot find merge base"))
483 }
484 }
485
486 async fn get_changed_files(
487 &self,
488 worktree_path: &Path,
489 merge_base: &str,
490 branch_name: &str,
491 ) -> Result<Vec<String>> {
492 let output = Command::new("git")
493 .args(&[
494 "diff",
495 "--name-only",
496 &format!("{}..{}", merge_base, branch_name),
497 ])
498 .current_dir(worktree_path)
499 .output()
500 .await?;
501
502 if output.status.success() {
503 let files = String::from_utf8_lossy(&output.stdout)
504 .lines()
505 .map(|line| line.trim().to_string())
506 .filter(|line| !line.is_empty())
507 .collect();
508 Ok(files)
509 } else {
510 Ok(Vec::new())
511 }
512 }
513
514 async fn files_have_same_content(
515 &self,
516 worktree_path: &Path,
517 file_path: &str,
518 main_branch: &str,
519 branch_name: &str,
520 ) -> Result<bool> {
521 let main_content_result = Command::new("git")
523 .args(&["show", &format!("{}:{}", main_branch, file_path)])
524 .current_dir(worktree_path)
525 .output()
526 .await;
527
528 let branch_content_result = Command::new("git")
529 .args(&["show", &format!("{}:{}", branch_name, file_path)])
530 .current_dir(worktree_path)
531 .output()
532 .await;
533
534 match (main_content_result, branch_content_result) {
535 (Ok(main_output), Ok(branch_output)) => Ok(main_output.stdout == branch_output.stdout),
536 _ => Ok(false), }
538 }
539
540 async fn analyze_main_branch_for_squash(
541 &self,
542 worktree_path: &Path,
543 main_branch: &str,
544 branch_name: &str,
545 merge_base: &str,
546 ) -> Result<SquashAnalysis> {
547 let search_range = format!("{}..{}", merge_base, main_branch);
549
550 let output = Command::new("git")
552 .args(&[
553 "log",
554 "--oneline",
555 "--grep",
556 &format!("{}\\|#[0-9]+", branch_name),
557 &search_range,
558 ])
559 .current_dir(worktree_path)
560 .output()
561 .await?;
562
563 if output.status.success() && !output.stdout.is_empty() {
564 let commit_messages = String::from_utf8_lossy(&output.stdout);
565 let commit_count = commit_messages.lines().count();
566
567 if commit_count > 0 {
568 return Ok(SquashAnalysis {
569 is_merged: true,
570 confidence: 0.7,
571 details: Some(format!("found {} potential squash commits", commit_count)),
572 });
573 }
574 }
575
576 let branch_commit_times = self
578 .get_branch_commit_times(worktree_path, merge_base, branch_name)
579 .await?;
580 if !branch_commit_times.is_empty() {
581 let main_commits_in_timeframe = self
582 .get_main_commits_in_timeframe(
583 worktree_path,
584 main_branch,
585 merge_base,
586 &branch_commit_times,
587 )
588 .await?;
589
590 if !main_commits_in_timeframe.is_empty() {
591 return Ok(SquashAnalysis {
592 is_merged: true,
593 confidence: 0.5,
594 details: Some("commits with similar timing found".to_string()),
595 });
596 }
597 }
598
599 Ok(SquashAnalysis {
600 is_merged: false,
601 confidence: 0.0,
602 details: None,
603 })
604 }
605
606 async fn get_branch_commit_times(
607 &self,
608 worktree_path: &Path,
609 merge_base: &str,
610 branch_name: &str,
611 ) -> Result<Vec<i64>> {
612 let output = Command::new("git")
613 .args(&[
614 "log",
615 "--format=%ct",
616 &format!("{}..{}", merge_base, branch_name),
617 ])
618 .current_dir(worktree_path)
619 .output()
620 .await?;
621
622 if output.status.success() {
623 let times = String::from_utf8_lossy(&output.stdout)
624 .lines()
625 .filter_map(|line| line.parse::<i64>().ok())
626 .collect();
627 Ok(times)
628 } else {
629 Ok(Vec::new())
630 }
631 }
632
633 async fn get_main_commits_in_timeframe(
634 &self,
635 worktree_path: &Path,
636 main_branch: &str,
637 merge_base: &str,
638 timeframe: &[i64],
639 ) -> Result<Vec<String>> {
640 if timeframe.is_empty() {
641 return Ok(Vec::new());
642 }
643
644 let min_time = timeframe.iter().min().unwrap();
645 let max_time = timeframe.iter().max().unwrap();
646
647 let output = Command::new("git")
648 .args(&[
649 "log",
650 "--oneline",
651 &format!("--since={}", min_time - 3600), &format!("--until={}", max_time + 3600),
653 &format!("{}..{}", merge_base, main_branch),
654 ])
655 .current_dir(worktree_path)
656 .output()
657 .await?;
658
659 if output.status.success() {
660 let commits = String::from_utf8_lossy(&output.stdout)
661 .lines()
662 .map(|line| line.to_string())
663 .collect();
664 Ok(commits)
665 } else {
666 Ok(Vec::new())
667 }
668 }
669
670 async fn compare_file_contents(
671 &self,
672 worktree_path: &Path,
673 main_branch: &str,
674 branch_name: &str,
675 merge_base: &str,
676 ) -> Result<SquashAnalysis> {
677 let changed_files = self
679 .get_changed_files(worktree_path, merge_base, branch_name)
680 .await?;
681
682 if changed_files.is_empty() {
683 return Ok(SquashAnalysis {
684 is_merged: true,
685 confidence: 0.8,
686 details: Some("no file changes".to_string()),
687 });
688 }
689
690 let mut matching_files = 0;
692 let mut total_files = 0;
693
694 for file in &changed_files {
695 total_files += 1;
696
697 if self
698 .files_have_same_content(worktree_path, file, main_branch, branch_name)
699 .await?
700 {
701 matching_files += 1;
702 }
703 }
704
705 let match_ratio = matching_files as f32 / total_files as f32;
706 let confidence = match_ratio * 0.7; let details = if match_ratio > 0.8 {
709 Some(format!(
710 "file contents match ({}/{})",
711 matching_files, total_files
712 ))
713 } else {
714 Some(format!(
715 "partial file match ({}/{})",
716 matching_files, total_files
717 ))
718 };
719
720 Ok(SquashAnalysis {
721 is_merged: match_ratio > 0.8,
722 confidence,
723 details,
724 })
725 }
726
727 fn analyze_method_results(
728 &self,
729 method_results: Vec<MethodResult>,
730 ) -> Result<MergeDetectionResult> {
731 if method_results.is_empty() {
732 return Ok(MergeDetectionResult {
733 is_merged: false,
734 detection_method: "none".to_string(),
735 confidence: 0.0,
736 details: Some("No detection methods available".to_string()),
737 method_results,
738 });
739 }
740
741 let best_positive = method_results
743 .iter()
744 .filter(|r| r.is_merged)
745 .max_by(|a, b| a.confidence.partial_cmp(&b.confidence).unwrap());
746
747 if let Some(positive_result) = best_positive {
748 return Ok(MergeDetectionResult {
750 is_merged: true,
751 detection_method: positive_result.method.clone(),
752 confidence: positive_result.confidence,
753 details: positive_result.details.clone(),
754 method_results,
755 });
756 }
757
758 let best_negative = method_results
760 .iter()
761 .max_by(|a, b| a.confidence.partial_cmp(&b.confidence).unwrap());
762
763 if let Some(negative_result) = best_negative {
764 Ok(MergeDetectionResult {
765 is_merged: false,
766 detection_method: negative_result.method.clone(),
767 confidence: negative_result.confidence,
768 details: negative_result.details.clone(),
769 method_results,
770 })
771 } else {
772 Ok(MergeDetectionResult {
773 is_merged: false,
774 detection_method: "unknown".to_string(),
775 confidence: 0.0,
776 details: Some("All detection methods failed".to_string()),
777 method_results,
778 })
779 }
780 }
781}
782
783#[derive(Debug)]
784struct SquashAnalysis {
785 is_merged: bool,
786 confidence: f32,
787 details: Option<String>,
788}
789
790impl From<MergeDetectionResult> for MergeInfo {
791 fn from(result: MergeDetectionResult) -> Self {
792 MergeInfo {
793 is_merged: result.is_merged,
794 detection_method: result.detection_method,
795 details: result.details,
796 confidence: result.confidence,
797 }
798 }
799}
800
801pub async fn detect_worktree_merge_status(
803 worktree_path: &Path,
804 branch_name: &str,
805 config: &WorktreeMergeDetectionConfig,
806) -> Result<MergeInfo> {
807 let detector = MergeDetector::new(config.clone());
808 let result = detector.detect_merge(worktree_path, branch_name).await?;
809 Ok(result.into())
810}
811
812#[cfg(test)]
813mod tests {
814 use super::*;
815
816 #[test]
817 fn test_merge_detection_method_conversion() {
818 assert_eq!(
819 MergeDetectionMethod::from_str("standard"),
820 Some(MergeDetectionMethod::Standard)
821 );
822 assert_eq!(MergeDetectionMethod::from_str("invalid"), None);
823
824 assert_eq!(MergeDetectionMethod::Standard.as_str(), "standard");
825 }
826
827 #[tokio::test]
828 async fn test_merge_detector_creation() {
829 let config = WorktreeMergeDetectionConfig::default();
830 let detector = MergeDetector::new(config);
831
832 assert!(!detector.config.methods.is_empty());
834 }
835
836 #[test]
837 fn test_merge_detection_result_conversion() {
838 let result = MergeDetectionResult {
839 is_merged: true,
840 detection_method: "standard".to_string(),
841 confidence: 0.95,
842 details: Some("merged into main".to_string()),
843 method_results: vec![],
844 };
845
846 let merge_info: MergeInfo = result.into();
847 assert!(merge_info.is_merged);
848 assert_eq!(merge_info.detection_method, "standard");
849 assert_eq!(merge_info.confidence, 0.95);
850 assert_eq!(merge_info.details, Some("merged into main".to_string()));
851 }
852
853 #[test]
854 fn test_method_result_creation() {
855 let method_result = MethodResult {
856 method: "test".to_string(),
857 is_merged: true,
858 confidence: 0.8,
859 details: Some("test details".to_string()),
860 error: None,
861 };
862
863 assert_eq!(method_result.method, "test");
864 assert!(method_result.is_merged);
865 assert_eq!(method_result.confidence, 0.8);
866 assert!(method_result.error.is_none());
867 }
868
869 }