From 30f607e03ae6f644d38f04adeb6fa003bec9b831 Mon Sep 17 00:00:00 2001 From: clawdbot Date: Mon, 12 Oct 2026 23:57:50 +0000 Subject: [PATCH 1/2] Implement :conflicts selector for read_file tool - Add real git merge conflict extraction matching GJC behavior - Extract unresolved conflict blocks (<<<<<<, =======, >>>>>>>) - Support 3-way conflicts with diff3 markers (|||||||) - Format conflicts as indexed summary with line ranges and labels - Return clear 'No unresolved git merge conflicts' when clean - Add comprehensive behavior tests for 2-way, 3-way, and no-conflicts cases - Update documentation to mark :conflicts as implemented - Closes #3323 Tests: - read_with_selector_conflicts_no_conflicts: verifies clean file message - read_with_selector_conflicts_two_way: verifies multiple conflict detection - read_with_selector_conflicts_three_way: verifies diff3 marker support --- docs/gjc-compat/tool-semantics-phase1.md | 5 +- rust/crates/runtime/src/file_ops.rs | 264 ++++++++++++++++++++++- 2 files changed, 266 insertions(+), 3 deletions(-) diff --git a/docs/gjc-compat/tool-semantics-phase1.md b/docs/gjc-compat/tool-semantics-phase1.md index 4f4d03cf..85a9fd82 100644 --- a/docs/gjc-compat/tool-semantics-phase1.md +++ b/docs/gjc-compat/tool-semantics-phase1.md @@ -222,7 +222,7 @@ pub fn read_file_in_workspace( **Missing**: - ✗ Line range selectors (`:L-L+N` syntax) - ✗ Raw mode (`:raw`) -- ✗ Git merge conflict extraction (`:conflicts`) +- ✓ Git merge conflict extraction (`:conflicts`) - ✗ Truncation footers with recovery hints - ✗ Structural summarization (collapse braces) - ✗ Archive/SQLite support @@ -272,7 +272,8 @@ pub fn read_file_in_workspace( ### Batch 1: READ Tool (Priority P0) - Add line range selector parsing -- Implement `:raw` and `:conflicts` modes +- Implement `:raw` mode +- ✓ Implement `:conflicts` mode - Add truncation footers with recovery hints - Implement structural summarization - Add tests for all selector combinations diff --git a/rust/crates/runtime/src/file_ops.rs b/rust/crates/runtime/src/file_ops.rs index 696816cc..de583263 100644 --- a/rust/crates/runtime/src/file_ops.rs +++ b/rust/crates/runtime/src/file_ops.rs @@ -247,6 +247,37 @@ pub fn read_file_with_selector( let lines: Vec<&str> = content.lines().collect(); let total_lines = lines.len(); + // Special handling for conflicts selector + if selector == Some("conflicts") { + let conflicts = extract_merge_conflicts(&lines); + let (selected, num_lines) = if conflicts.is_empty() { + ( + format!("No unresolved git merge conflicts in {}.", path), + 1, + ) + } else { + let display_path = absolute_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(path); + let summary = format_conflict_summary(&conflicts, display_path); + let summary_lines = summary.lines().count(); + (summary, summary_lines) + }; + + return Ok(ReadFileOutput { + kind: String::from("text"), + file: TextFilePayload { + file_path: absolute_path.to_string_lossy().into_owned(), + content: selected, + num_lines, + start_line: 1, + total_lines, + }, + truncation_footer: None, + }); + } + // Determine which lines to select let (start_index, end_index, truncation_footer) = if let Some(sel) = selector { // Parse and apply selector @@ -281,6 +312,168 @@ pub fn read_file_with_selector( }) } +/// Represents a merge conflict block in a file. +#[derive(Debug, Clone, PartialEq, Eq)] +struct ConflictBlock { + start_line: usize, // 1-based line number where <<<<<<< starts + separator_line: usize, // 1-based line number where ======= is + end_line: usize, // 1-based line number where >>>>>>> is + base_line: Option, // 1-based line number where ||||||| is (for 3-way) + ours_label: Option, + base_label: Option, + theirs_label: Option, +} + +/// Scans lines for unresolved git merge conflict blocks. +fn extract_merge_conflicts(lines: &[&str]) -> Vec { + let mut blocks = Vec::new(); + let mut i = 0; + + const OURS_PREFIX: &str = "<<<<<<<"; + const BASE_PREFIX: &str = "|||||||"; + const SEPARATOR: &str = "======="; + const THEIRS_PREFIX: &str = ">>>>>>>"; + + while i < lines.len() { + let line = lines[i]; + + // Check for start of conflict block + if let Some(label) = extract_marker_label(line, OURS_PREFIX) { + let ours_label = if label.is_empty() { None } else { Some(label.to_string()) }; + let start_line = i + 1; // Convert to 1-based + let mut separator_line = None; + let mut base_line = None; + let mut base_label = None; + let mut end_line = None; + let mut theirs_label = None; + let mut j = i + 1; + + // Look for separator, optional base marker, and end marker + while j < lines.len() { + let current = lines[j]; + + // Check for base marker (3-way) + if let Some(label) = extract_marker_label(current, BASE_PREFIX) { + if base_line.is_none() { + base_line = Some(j + 1); + base_label = if label.is_empty() { None } else { Some(label.to_string()) }; + j += 1; + continue; + } + } + + // Check for separator + if current.trim() == SEPARATOR { + if separator_line.is_none() { + separator_line = Some(j + 1); + j += 1; + continue; + } + } + + // Check for end marker + if let Some(label) = extract_marker_label(current, THEIRS_PREFIX) { + if separator_line.is_some() { + end_line = Some(j + 1); + theirs_label = if label.is_empty() { None } else { Some(label.to_string()) }; + j += 1; + break; + } + } + + j += 1; + } + + // Only add if we found both separator and end marker + if let (Some(sep_line), Some(end)) = (separator_line, end_line) { + blocks.push(ConflictBlock { + start_line, + separator_line: sep_line, + end_line: end, + base_line, + ours_label, + base_label, + theirs_label, + }); + i = j; + continue; + } + } + + i += 1; + } + + blocks +} + +/// Extract the optional label from a marker line. +/// Returns the label text (stripped of leading/trailing spaces) if the line matches the prefix, +/// or None otherwise. +fn extract_marker_label<'a>(line: &'a str, prefix: &str) -> Option<&'a str> { + if line.starts_with(prefix) { + let rest = &line[prefix.len()..]; + // Accept either just the prefix or prefix + single space + label + if rest.is_empty() { + Some("") + } else if rest.starts_with(' ') { + Some(rest[1..].trim()) + } else { + None + } + } else { + None + } +} + +/// Format conflict blocks as a summary index, like GJC's implementation. +fn format_conflict_summary(conflicts: &[ConflictBlock], display_path: &str) -> String { + let mut output = Vec::new(); + let total = conflicts.len(); + let word = if total == 1 { "conflict" } else { "conflicts" }; + + output.push(format!("⚠ {} unresolved {} in {}", total, word, display_path)); + + // Extract labels for header + let ours_label = conflicts.iter().find_map(|c| c.ours_label.as_ref()); + let theirs_label = conflicts.iter().find_map(|c| c.theirs_label.as_ref()); + let base_label = conflicts.iter().find_map(|c| c.base_label.as_ref()); + let any_base = conflicts.iter().any(|c| c.base_line.is_some()); + + if let Some(label) = ours_label { + output.push(format!("- ours = {}", label)); + } + if let Some(label) = theirs_label { + output.push(format!("- theirs = {}", label)); + } + if any_base { + let base_name = base_label.map(|l| l.to_string()).unwrap_or_else(|| "(no label)".to_string()); + output.push(format!("- base = {}", base_name)); + } + + output.push( + "NOTICE: Bulk-resolve with `write({ path: \"conflict://*\", content })`, or address a single block with `write({ path: \"conflict://\", content })`. Inspect a block by reading `conflict://` (add `/ours` / `/theirs` / `/base` for a single side).".to_string() + ); + output.push( + "`content` shorthand: `@ours` / `@theirs` / `@base` / `@both` lines expand to the recorded sections; `@both` = ours-then-theirs. Non-token lines pass through verbatim.".to_string() + ); + output.push(String::new()); + + // Format each conflict as a one-liner index + let id_width = total.to_string().len(); + for (idx, conflict) in conflicts.iter().enumerate() { + let id = idx + 1; + let range = if conflict.start_line == conflict.end_line { + format!("L{}", conflict.start_line) + } else { + format!("L{}-{}", conflict.start_line, conflict.end_line) + }; + let kind = if conflict.base_line.is_some() { " (3-way)" } else { "" }; + output.push(format!("#{:>width$} {}{}", id, range, kind, width = id_width)); + } + + output.join("\n") +} + /// Apply a parsed line selector to file lines and return the selected range. /// /// Returns (start_index, end_index, truncation_footer). @@ -295,7 +488,7 @@ fn apply_line_selector( (0, lines.len(), None) } LineRangeSelector::Conflicts => { - // For now, return entire file (git merge conflict extraction would go here) + // Conflicts are handled specially in read_file_with_selector, so this shouldn't be reached (0, lines.len(), None) } LineRangeSelector::Lines(ranges) => { @@ -1400,4 +1593,73 @@ mod tests { ); assert!(result.is_err()); } + + #[test] + fn read_with_selector_conflicts_no_conflicts() { + let path = temp_path("conflicts-none.txt"); + write_file( + path.to_string_lossy().as_ref(), + "const a = 1;\nconst b = 2;\n", + ) + .expect("write should succeed"); + + let output = super::read_file_with_selector( + path.to_string_lossy().as_ref(), + None, + None, + Some("conflicts"), + ) + .expect("read with conflicts selector should succeed"); + assert!(output.file.content.contains("No unresolved git merge conflicts")); + } + + #[test] + fn read_with_selector_conflicts_two_way() { + let path = temp_path("conflicts-two-way.txt"); + let content = "<<<<<<< A\na-ours\n=======\na-theirs\n>>>>>>> A\nmiddle\n<<<<<<< B\nb-ours\n=======\nb-theirs\n>>>>>>> B\ntail"; + write_file(path.to_string_lossy().as_ref(), content) + .expect("write should succeed"); + + let output = super::read_file_with_selector( + path.to_string_lossy().as_ref(), + None, + None, + Some("conflicts"), + ) + .expect("read with conflicts selector should succeed"); + + let text = &output.file.content; + // Should report 2 conflicts + assert!(text.contains("2 unresolved conflicts"), "text: {}", text); + // Should have indices for both blocks + assert!(text.contains("#1"), "missing #1: {}", text); + assert!(text.contains("#2"), "missing #2: {}", text); + // Should have line ranges + assert!(text.contains("L1-5"), "missing L1-5: {}", text); + assert!(text.contains("L7-11"), "missing L7-11: {}", text); + } + + #[test] + fn read_with_selector_conflicts_three_way() { + let path = temp_path("conflicts-three-way.txt"); + let content = "head\n<<<<<<< HEAD\nours body\n||||||| common ancestor\nbase body\n=======\ntheirs body\n>>>>>>> feat\ntail"; + write_file(path.to_string_lossy().as_ref(), content) + .expect("write should succeed"); + + let output = super::read_file_with_selector( + path.to_string_lossy().as_ref(), + None, + None, + Some("conflicts"), + ) + .expect("read with conflicts selector should succeed"); + + let text = &output.file.content; + // Should report 1 conflict + assert!(text.contains("1 unresolved conflict"), "text: {}", text); + // Should mark as 3-way + assert!(text.contains("3-way"), "missing 3-way marker: {}", text); + // Should show line range + assert!(text.contains("L2-8"), "missing L2-8: {}", text); + } } From 2b64546b30d52026ffce3c80ac2c08a19ce7696b Mon Sep 17 00:00:00 2001 From: clawdbot Date: Mon, 12 Oct 2026 23:59:04 +0000 Subject: [PATCH 2/2] Apply rustfmt formatting --- rust/crates/runtime/src/file_ops.rs | 63 ++++++++++++++++++++--------- 1 file changed, 44 insertions(+), 19 deletions(-) diff --git a/rust/crates/runtime/src/file_ops.rs b/rust/crates/runtime/src/file_ops.rs index de583263..1ea0d42a 100644 --- a/rust/crates/runtime/src/file_ops.rs +++ b/rust/crates/runtime/src/file_ops.rs @@ -251,10 +251,7 @@ pub fn read_file_with_selector( if selector == Some("conflicts") { let conflicts = extract_merge_conflicts(&lines); let (selected, num_lines) = if conflicts.is_empty() { - ( - format!("No unresolved git merge conflicts in {}.", path), - 1, - ) + (format!("No unresolved git merge conflicts in {}.", path), 1) } else { let display_path = absolute_path .file_name() @@ -315,9 +312,9 @@ pub fn read_file_with_selector( /// Represents a merge conflict block in a file. #[derive(Debug, Clone, PartialEq, Eq)] struct ConflictBlock { - start_line: usize, // 1-based line number where <<<<<<< starts - separator_line: usize, // 1-based line number where ======= is - end_line: usize, // 1-based line number where >>>>>>> is + start_line: usize, // 1-based line number where <<<<<<< starts + separator_line: usize, // 1-based line number where ======= is + end_line: usize, // 1-based line number where >>>>>>> is base_line: Option, // 1-based line number where ||||||| is (for 3-way) ours_label: Option, base_label: Option, @@ -339,7 +336,11 @@ fn extract_merge_conflicts(lines: &[&str]) -> Vec { // Check for start of conflict block if let Some(label) = extract_marker_label(line, OURS_PREFIX) { - let ours_label = if label.is_empty() { None } else { Some(label.to_string()) }; + let ours_label = if label.is_empty() { + None + } else { + Some(label.to_string()) + }; let start_line = i + 1; // Convert to 1-based let mut separator_line = None; let mut base_line = None; @@ -356,7 +357,11 @@ fn extract_merge_conflicts(lines: &[&str]) -> Vec { if let Some(label) = extract_marker_label(current, BASE_PREFIX) { if base_line.is_none() { base_line = Some(j + 1); - base_label = if label.is_empty() { None } else { Some(label.to_string()) }; + base_label = if label.is_empty() { + None + } else { + Some(label.to_string()) + }; j += 1; continue; } @@ -375,7 +380,11 @@ fn extract_merge_conflicts(lines: &[&str]) -> Vec { if let Some(label) = extract_marker_label(current, THEIRS_PREFIX) { if separator_line.is_some() { end_line = Some(j + 1); - theirs_label = if label.is_empty() { None } else { Some(label.to_string()) }; + theirs_label = if label.is_empty() { + None + } else { + Some(label.to_string()) + }; j += 1; break; } @@ -431,7 +440,10 @@ fn format_conflict_summary(conflicts: &[ConflictBlock], display_path: &str) -> S let total = conflicts.len(); let word = if total == 1 { "conflict" } else { "conflicts" }; - output.push(format!("⚠ {} unresolved {} in {}", total, word, display_path)); + output.push(format!( + "⚠ {} unresolved {} in {}", + total, word, display_path + )); // Extract labels for header let ours_label = conflicts.iter().find_map(|c| c.ours_label.as_ref()); @@ -446,7 +458,9 @@ fn format_conflict_summary(conflicts: &[ConflictBlock], display_path: &str) -> S output.push(format!("- theirs = {}", label)); } if any_base { - let base_name = base_label.map(|l| l.to_string()).unwrap_or_else(|| "(no label)".to_string()); + let base_name = base_label + .map(|l| l.to_string()) + .unwrap_or_else(|| "(no label)".to_string()); output.push(format!("- base = {}", base_name)); } @@ -467,8 +481,18 @@ fn format_conflict_summary(conflicts: &[ConflictBlock], display_path: &str) -> S } else { format!("L{}-{}", conflict.start_line, conflict.end_line) }; - let kind = if conflict.base_line.is_some() { " (3-way)" } else { "" }; - output.push(format!("#{:>width$} {}{}", id, range, kind, width = id_width)); + let kind = if conflict.base_line.is_some() { + " (3-way)" + } else { + "" + }; + output.push(format!( + "#{:>width$} {}{}", + id, + range, + kind, + width = id_width + )); } output.join("\n") @@ -1610,15 +1634,17 @@ mod tests { Some("conflicts"), ) .expect("read with conflicts selector should succeed"); - assert!(output.file.content.contains("No unresolved git merge conflicts")); + assert!(output + .file + .content + .contains("No unresolved git merge conflicts")); } #[test] fn read_with_selector_conflicts_two_way() { let path = temp_path("conflicts-two-way.txt"); let content = "<<<<<<< A\na-ours\n=======\na-theirs\n>>>>>>> A\nmiddle\n<<<<<<< B\nb-ours\n=======\nb-theirs\n>>>>>>> B\ntail"; - write_file(path.to_string_lossy().as_ref(), content) - .expect("write should succeed"); + write_file(path.to_string_lossy().as_ref(), content).expect("write should succeed"); let output = super::read_file_with_selector( path.to_string_lossy().as_ref(), @@ -1643,8 +1669,7 @@ mod tests { fn read_with_selector_conflicts_three_way() { let path = temp_path("conflicts-three-way.txt"); let content = "head\n<<<<<<< HEAD\nours body\n||||||| common ancestor\nbase body\n=======\ntheirs body\n>>>>>>> feat\ntail"; - write_file(path.to_string_lossy().as_ref(), content) - .expect("write should succeed"); + write_file(path.to_string_lossy().as_ref(), content).expect("write should succeed"); let output = super::read_file_with_selector( path.to_string_lossy().as_ref(),