1use anyhow::{Context, Result};
12use std::path::Path;
13use std::process::Command;
14
15use crate::id;
16use crate::spec::{Spec, SpecStatus};
17
18#[derive(Debug, Clone)]
20pub struct ConflictContext {
21 pub source_branch: String,
22 pub target_branch: String,
23 pub conflicting_files: Vec<String>,
24 pub source_spec_id: String,
25 pub source_spec_title: Option<String>,
26 pub diff_summary: String,
27}
28
29pub fn detect_conflicting_files() -> Result<Vec<String>> {
31 let output = Command::new("git")
32 .args(["status", "--porcelain"])
33 .output()
34 .context("Failed to run git status")?;
35
36 if !output.status.success() {
37 anyhow::bail!("Failed to get git status");
38 }
39
40 let status = String::from_utf8_lossy(&output.stdout);
41 let mut conflicting_files = Vec::new();
42
43 for line in status.lines() {
44 if line.len() > 3 {
46 let status_code = &line[..2];
47 match status_code {
48 "UU" | "AA" | "DD" | "AU" | "UD" | "UA" | "DU" => {
49 let file = line[3..].trim().to_string();
50 conflicting_files.push(file);
51 }
52 _ => {}
53 }
54 }
55 }
56
57 Ok(conflicting_files)
58}
59
60pub fn extract_spec_context(specs_dir: &Path, spec_id: &str) -> Result<(Option<String>, String)> {
62 match crate::spec::resolve_spec(specs_dir, spec_id) {
64 Ok(spec) => {
65 let title = spec.title.clone();
66 let body = spec.body.clone();
67 Ok((title, body))
68 }
69 Err(_) => {
70 Ok((None, String::new()))
72 }
73 }
74}
75
76pub fn get_diff_summary(source_branch: &str, target_branch: &str) -> Result<String> {
78 let output = Command::new("git")
79 .args([
80 "diff",
81 "--stat",
82 &format!("{}..{}", target_branch, source_branch),
83 ])
84 .output()
85 .context("Failed to get git diff")?;
86
87 if !output.status.success() {
88 return Ok("(unable to generate diff)".to_string());
89 }
90
91 Ok(String::from_utf8_lossy(&output.stdout)
92 .lines()
93 .take(10) .map(|s| s.to_string())
95 .collect::<Vec<_>>()
96 .join("\n"))
97}
98
99pub fn get_blocked_specs(conflicting_files: &[String], all_specs: &[Spec]) -> Vec<String> {
101 let mut blocked = Vec::new();
102
103 for spec in all_specs {
104 if spec.frontmatter.status == SpecStatus::Completed
106 || spec.frontmatter.status == SpecStatus::Failed
107 {
108 continue;
109 }
110
111 if let Some(target_files) = &spec.frontmatter.target_files {
113 for conflicting_file in conflicting_files {
114 if target_files.iter().any(|tf| {
115 tf == conflicting_file || conflicting_file.starts_with(&format!("{}/", tf))
117 }) {
118 blocked.push(spec.id.clone());
119 break;
120 }
121 }
122 }
123 }
124
125 blocked
126}
127
128pub fn create_conflict_spec(
130 specs_dir: &Path,
131 context: &ConflictContext,
132 blocked_specs: Vec<String>,
133) -> Result<String> {
134 let spec_id = id::generate_id(specs_dir)?;
136
137 let mut content = String::new();
139
140 let conflicting_files_yaml = context
142 .conflicting_files
143 .iter()
144 .map(|f| format!("- {}", f))
145 .collect::<Vec<_>>()
146 .join("\n");
147
148 let blocked_specs_yaml = if blocked_specs.is_empty() {
149 "blocked_specs: []".to_string()
150 } else {
151 let items = blocked_specs
152 .iter()
153 .map(|s| format!(" - {}", s))
154 .collect::<Vec<_>>()
155 .join("\n");
156 format!("blocked_specs:\n{}", items)
157 };
158
159 content.push_str(&format!(
160 r#"---
161type: conflict
162status: pending
163source_branch: {}
164target_branch: {}
165conflicting_files:
166{}
167{}
168original_spec: {}
169---
170"#,
171 context.source_branch,
172 context.target_branch,
173 conflicting_files_yaml,
174 blocked_specs_yaml,
175 context.source_spec_id
176 ));
177
178 content.push_str(&format!(
180 "# Resolve merge conflict: {} → {}\n\n",
181 context.source_branch, context.target_branch
182 ));
183
184 content.push_str("## Conflict Summary\n");
185 content.push_str(&format!("- **Source branch**: {}\n", context.source_branch));
186 content.push_str(&format!("- **Target branch**: {}\n", context.target_branch));
187 content.push_str(&format!(
188 "- **Conflicting files**: {}\n",
189 context
190 .conflicting_files
191 .iter()
192 .map(|f| format!("`{}`", f))
193 .collect::<Vec<_>>()
194 .join(", ")
195 ));
196
197 if !blocked_specs.is_empty() {
198 content.push_str(&format!(
199 "- **Blocked specs**: {}\n",
200 blocked_specs.join(", ")
201 ));
202 }
203
204 content.push('\n');
205
206 content.push_str("## Context from Original Spec\n\n");
208 if let Some(title) = &context.source_spec_title {
209 content.push_str(&format!("**Title**: {}\n\n", title));
210 }
211 content.push_str("```\n");
212 content.push_str(&context.diff_summary);
213 content.push_str("\n```\n\n");
214
215 content.push_str("## Resolution Instructions\n\n");
217 content.push_str("1. Examine the conflicting files listed above\n");
218 content.push_str("2. Resolve conflicts manually in your editor or using git tools\n");
219 content.push_str("3. Stage resolved files: `git add <files>`\n");
220 content.push_str("4. Complete the merge: `git commit`\n");
221 content.push_str("5. Update this spec with resolution details\n\n");
222
223 content.push_str("## Acceptance Criteria\n\n");
225 for file in &context.conflicting_files {
226 content.push_str(&format!("- [ ] Resolved conflicts in `{}`\n", file));
227 }
228 content.push_str("- [ ] Merge completed successfully\n");
229
230 let spec_path = specs_dir.join(format!("{}.md", spec_id));
232 std::fs::write(&spec_path, &content).context("Failed to write conflict spec file")?;
233
234 Ok(spec_id)
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240 use crate::spec::SpecFrontmatter;
241
242 #[test]
243 fn test_detect_conflicting_files_parses_status() {
244 let conflicting_files = ["src/config.rs".to_string(), "docs/guide.md".to_string()];
247 assert_eq!(conflicting_files.len(), 2);
248 }
249
250 #[test]
251 fn test_get_blocked_specs_empty_when_no_overlap() {
252 let spec1 = Spec {
253 id: "2026-01-25-001-abc".to_string(),
254 frontmatter: SpecFrontmatter {
255 target_files: Some(vec!["src/lib.rs".to_string()]),
256 ..Default::default()
257 },
258 title: None,
259 body: String::new(),
260 };
261
262 let conflicting_files = vec!["src/config.rs".to_string()];
263 let blocked = get_blocked_specs(&conflicting_files, &[spec1]);
264 assert!(blocked.is_empty());
265 }
266
267 #[test]
268 fn test_get_blocked_specs_finds_overlap() {
269 let spec1 = Spec {
270 id: "2026-01-25-001-abc".to_string(),
271 frontmatter: SpecFrontmatter {
272 status: SpecStatus::Pending,
273 target_files: Some(vec!["src/config.rs".to_string()]),
274 ..Default::default()
275 },
276 title: None,
277 body: String::new(),
278 };
279
280 let conflicting_files = vec!["src/config.rs".to_string()];
281 let blocked = get_blocked_specs(&conflicting_files, &[spec1]);
282 assert_eq!(blocked.len(), 1);
283 assert_eq!(blocked[0], "2026-01-25-001-abc");
284 }
285
286 #[test]
287 fn test_get_blocked_specs_ignores_completed() {
288 let spec1 = Spec {
289 id: "2026-01-25-001-abc".to_string(),
290 frontmatter: SpecFrontmatter {
291 status: SpecStatus::Completed,
292 target_files: Some(vec!["src/config.rs".to_string()]),
293 ..Default::default()
294 },
295 title: None,
296 body: String::new(),
297 };
298
299 let conflicting_files = vec!["src/config.rs".to_string()];
300 let blocked = get_blocked_specs(&conflicting_files, &[spec1]);
301 assert!(blocked.is_empty());
302 }
303}