Skip to main content

vibe_workspace/worktree/
merge_detection.rs

1//! Advanced merge detection algorithms for worktree branches
2//!
3//! This module implements multiple strategies for detecting when a branch
4//! has been merged into main, including regular merges, squash merges,
5//! and rebase merges that are difficult to detect with standard git commands.
6
7use 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/// Different methods available for merge detection
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum MergeDetectionMethod {
19    /// Standard git merge detection using `git branch --merged`
20    Standard,
21    /// Detect squash merges by analyzing commit content
22    Squash,
23    /// Use GitHub CLI to check PR merge status
24    GitHubPR,
25    /// Compare file contents between branch and main
26    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/// Result of merge detection analysis
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct MergeDetectionResult {
53    /// Whether the branch appears to be merged
54    pub is_merged: bool,
55
56    /// Method that detected the merge (or was most confident)
57    pub detection_method: String,
58
59    /// Confidence score from 0.0 (no confidence) to 1.0 (certain)
60    pub confidence: f32,
61
62    /// Additional details about the detection
63    pub details: Option<String>,
64
65    /// Results from all attempted methods
66    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
78/// Comprehensive merge detection engine
79pub struct MergeDetector {
80    config: WorktreeMergeDetectionConfig,
81}
82
83impl MergeDetector {
84    pub fn new(config: WorktreeMergeDetectionConfig) -> Self {
85        Self { config }
86    }
87
88    /// Detect if a branch has been merged using all configured methods
89    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        // Try each configured method in order of preference
97        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        // Analyze results to determine overall merge status
109        self.analyze_method_results(method_results)
110    }
111
112    /// Try a specific detection method
113    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    /// Standard git merge detection
217    async fn detect_standard_merge(
218        &self,
219        worktree_path: &Path,
220        branch_name: &str,
221    ) -> Result<(bool, Option<String>)> {
222        // First check if the branch has remote tracking
223        // Branches without remote tracking cannot be considered truly "merged"
224        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        // Try each main branch
232        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    /// Detect squash merges by analyzing commit content and diffs
254    async fn detect_squash_merge(
255        &self,
256        worktree_path: &Path,
257        branch_name: &str,
258    ) -> Result<(bool, f32, Option<String>)> {
259        // First check if the branch has remote tracking
260        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        // Find the best main branch to compare against
269        let main_branch = self.find_best_main_branch(worktree_path).await?;
270
271        // Get merge base
272        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        // Check if there are any changes between merge-base and branch tip
287        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            // No changes means branch is identical to merge-base (likely rebased or no commits)
299            return Ok((true, 0.6, Some("no unique changes".to_string())));
300        }
301
302        // Analyze commit patterns in main branch for squash evidence
303        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        // Compare file contents between branch and main
312        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    /// Detect merges using GitHub CLI PR information
324    async fn detect_github_pr_merge(
325        &self,
326        worktree_path: &Path,
327        branch_name: &str,
328    ) -> Result<(bool, Option<String>)> {
329        // First check if the branch has remote tracking
330        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        // Check if branch has an associated merged PR
338        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        // Parse JSON to get PR information
367        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    /// Detect merges by comparing file contents
380    async fn detect_file_content_merge(
381        &self,
382        worktree_path: &Path,
383        branch_name: &str,
384    ) -> Result<(bool, f32, Option<String>)> {
385        // First check if the branch has remote tracking
386        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        // Get list of files changed in the branch
400        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        // Compare each changed file between branch and main
409        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; // Conservative confidence for file content matching
425
426        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    // Helper methods
439
440    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    /// Check if the current worktree branch has remote tracking configured
457    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        // Compare file content between branch and main
522        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), // If we can't read either file, assume they're different
537        }
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        // Look for commits in main that might be squash merges of this branch
548        let search_range = format!("{}..{}", merge_base, main_branch);
549
550        // Search for commits that mention the branch name or PR numbers
551        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        // Look for commits with similar timing to branch development
577        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), // 1 hour buffer
652                &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        // Get files that changed in the branch
678        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        // Compare each file between branch and main
691        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; // Conservative confidence
707
708        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        // Find the most confident positive result
742        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            // We have a positive detection
749            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        // No positive results, find the most confident negative result
759        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
801/// Convenience function to detect merge status for a worktree
802pub 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        // Basic instantiation test
833        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    // Add more comprehensive tests for different merge scenarios
870    // These would require setting up git repositories with various merge states
871}