Skip to main content

mars_agents/merge/
mod.rs

1//! Three-way merge using `git merge-file` CLI.
2//!
3//! Wraps `git merge-file -p` to produce git-standard conflict markers
4//! that IDEs (VS Code, JetBrains) recognize and provide "Accept Current/
5//! Incoming/Both" UI for.
6//!
7//! Uses `git merge-file` via subprocess for consistent merge behavior.
8//! Since mars is inherently a git-based tool, `git` being in PATH is a safe
9//! assumption.
10
11use std::io::Write;
12use std::path::Path;
13
14use crate::error::MarsError;
15
16/// Result of a three-way merge via `git merge-file`.
17#[derive(Debug, Clone)]
18pub struct MergeResult {
19    /// The merged content (may contain conflict markers).
20    pub content: Vec<u8>,
21    /// Whether the merge produced conflict markers.
22    pub has_conflicts: bool,
23    /// Number of conflict regions (approximate — counts `<<<<<<<` markers).
24    pub conflict_count: usize,
25}
26
27/// Labels for the three sides of a merge.
28#[derive(Debug, Clone)]
29pub struct MergeLabels {
30    /// e.g., "base (mars installed)"
31    pub base: String,
32    /// e.g., "local"
33    pub local: String,
34    /// e.g., "meridian-base@v0.6.0"
35    pub theirs: String,
36}
37
38/// Perform three-way merge using `git merge-file`.
39///
40/// Inputs:
41/// - `base`: what mars installed last time (from cache)
42/// - `local`: current file on disk (user's copy)
43/// - `theirs`: new source content (upstream update)
44///
45/// Output: merged content, possibly with git conflict markers.
46///
47/// `git merge-file` exit codes:
48/// - 0 = clean merge
49/// - positive = number of conflicts
50/// - negative = error
51pub fn merge_content(
52    base: &[u8],
53    local: &[u8],
54    theirs: &[u8],
55    labels: &MergeLabels,
56) -> Result<MergeResult, MarsError> {
57    let dir = tempfile::TempDir::new()?;
58
59    let base_path = dir.path().join("base");
60    let local_path = dir.path().join("local");
61    let theirs_path = dir.path().join("theirs");
62
63    write_file(&base_path, base)?;
64    write_file(&local_path, local)?;
65    write_file(&theirs_path, theirs)?;
66
67    // git merge-file -p -L <local-label> -L <base-label> -L <theirs-label>
68    //   <local-file> <base-file> <theirs-file>
69    //
70    // Note: label order is local, base, theirs (matching file order).
71    // The -p flag writes merged output to stdout instead of modifying the file.
72    let local_path_str = local_path.to_string_lossy();
73    let base_path_str = base_path.to_string_lossy();
74    let theirs_path_str = theirs_path.to_string_lossy();
75    let output = crate::platform::process::run_git_raw(
76        &[
77            "merge-file",
78            "-p",
79            "-L",
80            &labels.local,
81            "-L",
82            &labels.base,
83            "-L",
84            &labels.theirs,
85            &local_path_str,
86            &base_path_str,
87            &theirs_path_str,
88        ],
89        dir.path(),
90        "three-way merge",
91    )?;
92
93    let exit_code = output.status.code().unwrap_or(-1);
94
95    // Negative exit code = error (not a conflict)
96    if exit_code < 0 {
97        return Err(MarsError::Source {
98            source_name: "merge".to_string(),
99            message: format!(
100                "git merge-file failed (exit {}): {}",
101                exit_code,
102                String::from_utf8_lossy(&output.stderr)
103            ),
104        });
105    }
106
107    let content = output.stdout;
108    let has_conflicts = exit_code > 0;
109    let conflict_count = count_conflict_markers(&content);
110
111    Ok(MergeResult {
112        content,
113        has_conflicts,
114        conflict_count,
115    })
116}
117
118/// Check if file content contains unresolved conflict markers.
119///
120/// Scans for `<<<<<<<` markers that indicate an unresolved merge conflict.
121pub fn has_conflict_markers(content: &[u8]) -> bool {
122    // Look for "<<<<<<< " at the start of a line
123    if content.starts_with(b"<<<<<<<") {
124        return true;
125    }
126    content
127        .windows(8)
128        .any(|w| w[0] == b'\n' && &w[1..] == b"<<<<<<<")
129}
130
131/// Check whether a file on disk contains unresolved conflict markers.
132pub fn file_has_conflict_markers(path: &Path) -> bool {
133    // Intentionally treat unreadable files as "no markers" so list/resolve views stay conservative.
134    std::fs::read(path)
135        .map(|content| has_conflict_markers(&content))
136        .unwrap_or(false)
137}
138
139/// Count conflict marker regions in content.
140fn count_conflict_markers(content: &[u8]) -> usize {
141    let mut count = 0;
142
143    // Check if content starts with a marker
144    if content.len() >= 7 && &content[..7] == b"<<<<<<<" {
145        count += 1;
146    }
147
148    // Count occurrences of "\n<<<<<<<" (marker at start of line)
149    for window in content.windows(8) {
150        if window[0] == b'\n' && &window[1..] == b"<<<<<<<" {
151            count += 1;
152        }
153    }
154
155    count
156}
157
158/// Helper to write bytes to a file.
159fn write_file(path: &std::path::Path, content: &[u8]) -> Result<(), MarsError> {
160    let mut file = std::fs::File::create(path)?;
161    file.write_all(content)?;
162    Ok(())
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    fn labels() -> MergeLabels {
170        MergeLabels {
171            base: "base (last sync)".to_string(),
172            local: "local".to_string(),
173            theirs: "meridian-base@v0.6.0".to_string(),
174        }
175    }
176
177    // === Clean merge tests ===
178
179    #[test]
180    fn all_three_identical() {
181        let content = b"line 1\nline 2\nline 3\n";
182        let result = merge_content(content, content, content, &labels()).unwrap();
183        assert!(!result.has_conflicts);
184        assert_eq!(result.conflict_count, 0);
185        assert_eq!(result.content, content);
186    }
187
188    #[test]
189    fn theirs_changed_local_same_as_base() {
190        let base = b"line 1\nline 2\nline 3\n";
191        let local = b"line 1\nline 2\nline 3\n";
192        let theirs = b"line 1\nline 2 modified\nline 3\n";
193
194        let result = merge_content(base, local, theirs, &labels()).unwrap();
195        assert!(!result.has_conflicts);
196        assert_eq!(result.content, theirs);
197    }
198
199    #[test]
200    fn local_changed_theirs_same_as_base() {
201        let base = b"line 1\nline 2\nline 3\n";
202        let local = b"line 1\nline 2 local edit\nline 3\n";
203        let theirs = b"line 1\nline 2\nline 3\n";
204
205        let result = merge_content(base, local, theirs, &labels()).unwrap();
206        assert!(!result.has_conflicts);
207        assert_eq!(result.content, local);
208    }
209
210    #[test]
211    fn non_overlapping_changes_merge_cleanly() {
212        let base = b"line 1\nline 2\nline 3\nline 4\nline 5\n";
213        let local = b"line 1 local\nline 2\nline 3\nline 4\nline 5\n";
214        let theirs = b"line 1\nline 2\nline 3\nline 4\nline 5 theirs\n";
215
216        let result = merge_content(base, local, theirs, &labels()).unwrap();
217        assert!(!result.has_conflicts);
218        let merged = String::from_utf8(result.content).unwrap();
219        assert!(merged.contains("line 1 local"));
220        assert!(merged.contains("line 5 theirs"));
221    }
222
223    // === Conflict tests ===
224
225    #[test]
226    fn overlapping_changes_produce_conflict() {
227        let base = b"line 1\nline 2\nline 3\n";
228        let local = b"line 1\nlocal change\nline 3\n";
229        let theirs = b"line 1\ntheirs change\nline 3\n";
230
231        let result = merge_content(base, local, theirs, &labels()).unwrap();
232        assert!(result.has_conflicts);
233        assert!(result.conflict_count >= 1);
234    }
235
236    #[test]
237    fn conflict_markers_match_git_format() {
238        let base = b"same\nconflict line\nsame\n";
239        let local = b"same\nlocal version\nsame\n";
240        let theirs = b"same\ntheirs version\nsame\n";
241
242        let result = merge_content(base, local, theirs, &labels()).unwrap();
243        assert!(result.has_conflicts);
244
245        let merged = String::from_utf8(result.content).unwrap();
246        assert!(merged.contains("<<<<<<<"), "should have opening marker");
247        assert!(merged.contains("======="), "should have separator");
248        assert!(merged.contains(">>>>>>>"), "should have closing marker");
249    }
250
251    #[test]
252    fn labels_appear_in_conflict_markers() {
253        let base = b"conflict\n";
254        let local = b"local version\n";
255        let theirs = b"theirs version\n";
256
257        let result = merge_content(base, local, theirs, &labels()).unwrap();
258        let merged = String::from_utf8(result.content).unwrap();
259        assert!(
260            merged.contains("local"),
261            "local label should appear: {merged}"
262        );
263        assert!(
264            merged.contains("meridian-base@v0.6.0"),
265            "theirs label should appear: {merged}"
266        );
267    }
268
269    #[test]
270    fn multiple_conflict_regions() {
271        // Use more spacing between conflicting regions so git treats them separately
272        let base = b"a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n";
273        let local = b"a-local\nb\nc\nd\ne\nf\ng\nh\ni-local\nj\n";
274        let theirs = b"a-theirs\nb\nc\nd\ne\nf\ng\nh\ni-theirs\nj\n";
275
276        let result = merge_content(base, local, theirs, &labels()).unwrap();
277        assert!(result.has_conflicts);
278        assert!(
279            result.conflict_count >= 2,
280            "should have at least 2 conflicts, got {}",
281            result.conflict_count
282        );
283    }
284
285    // === Edge cases ===
286
287    #[test]
288    fn empty_base_with_different_content() {
289        let base = b"";
290        let local = b"local content\n";
291        let theirs = b"theirs content\n";
292
293        // Empty base with both sides adding content → conflict
294        let result = merge_content(base, local, theirs, &labels()).unwrap();
295        // Both added content from empty base — this is a conflict
296        assert!(result.has_conflicts);
297    }
298
299    #[test]
300    fn empty_base_same_additions() {
301        let base = b"";
302        let local = b"same content\n";
303        let theirs = b"same content\n";
304
305        let result = merge_content(base, local, theirs, &labels()).unwrap();
306        assert!(!result.has_conflicts);
307        assert_eq!(result.content, b"same content\n");
308    }
309
310    #[test]
311    fn all_empty() {
312        let result = merge_content(b"", b"", b"", &labels()).unwrap();
313        assert!(!result.has_conflicts);
314        assert!(result.content.is_empty());
315    }
316
317    // === has_conflict_markers tests ===
318
319    #[test]
320    fn has_conflict_markers_detects_markers() {
321        let content = b"before\n<<<<<<< local\nlocal\n=======\ntheirs\n>>>>>>> theirs\nafter\n";
322        assert!(has_conflict_markers(content));
323    }
324
325    #[test]
326    fn has_conflict_markers_at_start_of_file() {
327        let content = b"<<<<<<< local\nlocal\n=======\ntheirs\n>>>>>>> theirs\n";
328        assert!(has_conflict_markers(content));
329    }
330
331    #[test]
332    fn has_conflict_markers_no_markers() {
333        let content = b"normal content\nno conflicts here\n";
334        assert!(!has_conflict_markers(content));
335    }
336
337    #[test]
338    fn has_conflict_markers_partial_marker_not_detected() {
339        // "<<<<<<" (6 chars) shouldn't be detected — needs 7 (`<<<<<<<`)
340        let content = b"some <<<<<< stuff\n";
341        assert!(!has_conflict_markers(content));
342    }
343
344    #[test]
345    fn has_conflict_markers_in_middle_of_line_not_detected() {
346        // Marker must be at start of line
347        let content = b"text <<<<<<< not a real marker\n";
348        assert!(!has_conflict_markers(content));
349    }
350
351    // === count_conflict_markers tests ===
352
353    #[test]
354    fn count_zero_conflicts() {
355        assert_eq!(count_conflict_markers(b"no conflicts"), 0);
356    }
357
358    #[test]
359    fn count_one_conflict() {
360        let content = b"before\n<<<<<<< local\nlocal\n=======\ntheirs\n>>>>>>> theirs\nafter\n";
361        assert_eq!(count_conflict_markers(content), 1);
362    }
363
364    #[test]
365    fn count_multiple_conflicts() {
366        let content =
367            b"<<<<<<< a\nx\n=======\ny\n>>>>>>> b\nok\n<<<<<<< a\np\n=======\nq\n>>>>>>> b\n";
368        assert_eq!(count_conflict_markers(content), 2);
369    }
370}