1use anyhow::{Context, Result};
7use std::fs;
8use std::path::Path;
9use std::process::Command;
10
11use crate::spec::{split_frontmatter, SpecFrontmatter, SpecStatus};
12
13#[derive(Debug, Clone, PartialEq)]
15pub enum MergeRule {
16 AdvancedStatus,
18 PreferTheirs,
20 PreferOurs,
22 Union,
24}
25
26const FIELD_RULES: &[(&str, MergeRule)] = &[
28 ("status", MergeRule::AdvancedStatus),
29 ("completed_at", MergeRule::PreferTheirs),
30 ("model", MergeRule::PreferTheirs),
31 ("commits", MergeRule::Union),
32 ("branch", MergeRule::PreferOurs),
33 ("labels", MergeRule::Union),
34 ("target_files", MergeRule::Union),
35 ("context", MergeRule::Union),
36 ("members", MergeRule::Union),
37 ("last_verified", MergeRule::PreferTheirs),
38 ("verification_status", MergeRule::PreferTheirs),
39 ("verification_failures", MergeRule::PreferTheirs),
40 ("replayed_at", MergeRule::PreferOurs),
41 ("replay_count", MergeRule::PreferOurs),
42 ("original_completed_at", MergeRule::PreferOurs),
43];
44
45pub fn get_merge_rule(field: &str) -> MergeRule {
47 FIELD_RULES
48 .iter()
49 .find(|(name, _)| *name == field)
50 .map(|(_, rule)| rule.clone())
51 .unwrap_or(MergeRule::PreferOurs)
52}
53
54#[derive(Debug, Clone)]
56pub struct ParsedSpec {
57 pub frontmatter_yaml: String,
58 pub frontmatter: SpecFrontmatter,
59 pub body: String,
60}
61
62pub fn parse_spec_file(content: &str) -> Result<ParsedSpec> {
64 let (frontmatter_opt, body) = split_frontmatter(content);
65
66 let frontmatter_yaml = frontmatter_opt.unwrap_or_default();
67 let frontmatter: SpecFrontmatter = if !frontmatter_yaml.is_empty() {
68 serde_yaml::from_str(&frontmatter_yaml).context("Failed to parse frontmatter")?
69 } else {
70 SpecFrontmatter::default()
71 };
72
73 Ok(ParsedSpec {
74 frontmatter_yaml,
75 frontmatter,
76 body: body.to_string(),
77 })
78}
79
80pub fn merge_frontmatter(
82 base: &SpecFrontmatter,
83 ours: &SpecFrontmatter,
84 theirs: &SpecFrontmatter,
85) -> SpecFrontmatter {
86 let mut result = ours.clone();
87
88 result.status = merge_status(&base.status, &ours.status, &theirs.status);
89
90 if result.completed_at.is_none() && theirs.completed_at.is_some() {
91 result.completed_at = theirs.completed_at.clone();
92 }
93
94 if result.model.is_none() && theirs.model.is_some() {
95 result.model = theirs.model.clone();
96 }
97
98 result.commits = merge_lists(&base.commits, &ours.commits, &theirs.commits);
99
100 if result.branch.is_none() && theirs.branch.is_some() {
101 result.branch = theirs.branch.clone();
102 }
103
104 result.labels = merge_lists(&base.labels, &ours.labels, &theirs.labels);
105 result.target_files = merge_lists(&base.target_files, &ours.target_files, &theirs.target_files);
106 result.context = merge_lists(&base.context, &ours.context, &theirs.context);
107 result.members = merge_lists(&base.members, &ours.members, &theirs.members);
108
109 if result.last_verified.is_none() && theirs.last_verified.is_some() {
110 result.last_verified = theirs.last_verified.clone();
111 }
112 if result.verification_status.is_none() && theirs.verification_status.is_some() {
113 result.verification_status = theirs.verification_status.clone();
114 }
115 if result.verification_failures.is_none() && theirs.verification_failures.is_some() {
116 result.verification_failures = theirs.verification_failures.clone();
117 }
118
119 if result.replayed_at.is_none() && theirs.replayed_at.is_some() {
120 result.replayed_at = theirs.replayed_at.clone();
121 }
122 if result.replay_count.is_none() && theirs.replay_count.is_some() {
123 result.replay_count = theirs.replay_count;
124 }
125 if result.original_completed_at.is_none() && theirs.original_completed_at.is_some() {
126 result.original_completed_at = theirs.original_completed_at.clone();
127 }
128
129 result
130}
131
132fn merge_status(_base: &SpecStatus, ours: &SpecStatus, theirs: &SpecStatus) -> SpecStatus {
134 let priority = |s: &SpecStatus| -> u8 {
135 match s {
136 SpecStatus::Cancelled => 0,
137 SpecStatus::Failed => 1,
138 SpecStatus::NeedsAttention => 2,
139 SpecStatus::Blocked => 3,
140 SpecStatus::Pending => 4,
141 SpecStatus::Ready => 5,
142 SpecStatus::Paused => 6,
143 SpecStatus::InProgress => 7,
144 SpecStatus::Completed => 8,
145 }
146 };
147
148 if priority(ours) >= priority(theirs) {
149 ours.clone()
150 } else {
151 theirs.clone()
152 }
153}
154
155fn merge_lists(
157 _base: &Option<Vec<String>>,
158 ours: &Option<Vec<String>>,
159 theirs: &Option<Vec<String>>,
160) -> Option<Vec<String>> {
161 match (ours, theirs) {
162 (Some(o), Some(t)) => {
163 let mut result: Vec<String> = o.clone();
164 for item in t {
165 if !result.contains(item) {
166 result.push(item.clone());
167 }
168 }
169 if result.is_empty() {
170 None
171 } else {
172 Some(result)
173 }
174 }
175 (Some(o), None) => Some(o.clone()),
176 (None, Some(t)) => Some(t.clone()),
177 (None, None) => None,
178 }
179}
180
181pub fn merge_body(base: &str, ours: &str, theirs: &str) -> Result<String> {
183 if base.trim() == ours.trim() {
184 return Ok(theirs.to_string());
185 }
186 if base.trim() == theirs.trim() {
187 return Ok(ours.to_string());
188 }
189 if ours.trim() == theirs.trim() {
190 return Ok(ours.to_string());
191 }
192
193 let temp_dir = tempfile::tempdir().context("Failed to create temp directory")?;
194 let base_path = temp_dir.path().join("base");
195 let ours_path = temp_dir.path().join("ours");
196 let theirs_path = temp_dir.path().join("theirs");
197
198 fs::write(&base_path, base).context("Failed to write base file")?;
199 fs::write(&ours_path, ours).context("Failed to write ours file")?;
200 fs::write(&theirs_path, theirs).context("Failed to write theirs file")?;
201
202 let output = Command::new("git")
203 .args([
204 "merge-file",
205 "-p",
206 ours_path.to_str().unwrap(),
207 base_path.to_str().unwrap(),
208 theirs_path.to_str().unwrap(),
209 ])
210 .output()
211 .context("Failed to run git merge-file")?;
212
213 let merged = String::from_utf8_lossy(&output.stdout).to_string();
214 Ok(merged)
215}
216
217pub fn serialize_frontmatter(frontmatter: &SpecFrontmatter) -> Result<String> {
219 serde_yaml::to_string(frontmatter).context("Failed to serialize frontmatter")
220}
221
222pub fn assemble_spec(frontmatter: &SpecFrontmatter, body: &str) -> Result<String> {
224 let frontmatter_yaml = serialize_frontmatter(frontmatter)?;
225 Ok(format!("---\n{}---\n{}", frontmatter_yaml, body))
226}
227
228pub fn run_merge_driver(base_path: &Path, ours_path: &Path, theirs_path: &Path) -> Result<bool> {
230 let base_content = fs::read_to_string(base_path)
231 .with_context(|| format!("Failed to read base file: {}", base_path.display()))?;
232 let ours_content = fs::read_to_string(ours_path)
233 .with_context(|| format!("Failed to read ours file: {}", ours_path.display()))?;
234 let theirs_content = fs::read_to_string(theirs_path)
235 .with_context(|| format!("Failed to read theirs file: {}", theirs_path.display()))?;
236
237 let base = parse_spec_file(&base_content)?;
238 let ours = parse_spec_file(&ours_content)?;
239 let theirs = parse_spec_file(&theirs_content)?;
240
241 let merged_frontmatter =
242 merge_frontmatter(&base.frontmatter, &ours.frontmatter, &theirs.frontmatter);
243
244 let merged_body = merge_body(&base.body, &ours.body, &theirs.body)?;
245
246 let has_conflicts = merged_body.contains("<<<<<<<")
247 || merged_body.contains("=======")
248 || merged_body.contains(">>>>>>>");
249
250 let result = assemble_spec(&merged_frontmatter, &merged_body)?;
251
252 fs::write(ours_path, result)
253 .with_context(|| format!("Failed to write result to: {}", ours_path.display()))?;
254
255 Ok(!has_conflicts)
256}
257
258pub fn get_setup_instructions() -> String {
260 r#"# Chant Spec Merge Driver Setup
261
262## Step 1: Add .gitattributes entry
263
264Add to your `.gitattributes` file (or create one):
265
266```
267.chant/specs/*.md merge=chant-spec
268```
269
270## Step 2: Configure git merge driver
271
272Run these commands in your repository:
273
274```bash
275git config merge.chant-spec.name "Chant spec merge driver"
276git config merge.chant-spec.driver "chant merge-driver %O %A %B"
277```
278
279## How it works
280
281The merge driver intelligently handles spec file merges by:
282
2831. **Frontmatter conflicts**: Automatically resolved using declarative rules
284 - `status`: Prefers more "advanced" status (completed > in_progress > pending)
285 - `completed_at`, `model`: Takes values from theirs (finalize metadata)
286 - `commits`, `labels`, `target_files`: Merges both lists, deduplicates
287
2882. **Body conflicts**: Uses standard 3-way merge
289 - Shows conflict markers if both sides changed same section
290"#
291 .to_string()
292}
293
294#[derive(Debug, Clone)]
296pub struct MergeDriverSetupResult {
297 pub git_config_set: bool,
298 pub gitattributes_updated: bool,
299 pub warning: Option<String>,
300}
301
302pub fn setup_merge_driver() -> Result<MergeDriverSetupResult> {
304 let mut result = MergeDriverSetupResult {
305 git_config_set: false,
306 gitattributes_updated: false,
307 warning: None,
308 };
309
310 let in_git_repo = Command::new("git")
311 .args(["rev-parse", "--git-dir"])
312 .output()
313 .map(|o| o.status.success())
314 .unwrap_or(false);
315
316 if in_git_repo {
317 let name_result = Command::new("git")
318 .args(["config", "merge.chant-spec.name", "Chant spec merge driver"])
319 .output();
320
321 let driver_result = Command::new("git")
322 .args([
323 "config",
324 "merge.chant-spec.driver",
325 "chant merge-driver %O %A %B",
326 ])
327 .output();
328
329 match (name_result, driver_result) {
330 (Ok(name_out), Ok(driver_out))
331 if name_out.status.success() && driver_out.status.success() =>
332 {
333 result.git_config_set = true;
334 }
335 _ => {
336 result.warning = Some("Failed to configure git merge driver".to_string());
337 }
338 }
339 } else {
340 result.warning = Some("Not in a git repository - merge driver config skipped".to_string());
341 }
342
343 let gitattributes_path = std::path::Path::new(".gitattributes");
344 let merge_pattern = ".chant/specs/*.md merge=chant-spec";
345
346 if gitattributes_path.exists() {
347 let content =
348 fs::read_to_string(gitattributes_path).context("Failed to read .gitattributes")?;
349
350 if !content.contains(merge_pattern) {
351 let mut new_content = content;
352 if !new_content.ends_with('\n') && !new_content.is_empty() {
353 new_content.push('\n');
354 }
355 new_content.push_str("\n# Chant spec files use a custom merge driver for intelligent conflict resolution\n");
356 new_content.push_str(merge_pattern);
357 new_content.push('\n');
358 fs::write(gitattributes_path, new_content)
359 .context("Failed to update .gitattributes")?;
360 result.gitattributes_updated = true;
361 }
362 } else {
363 let content = format!(
364 "# Chant spec files use a custom merge driver for intelligent conflict resolution\n# This driver automatically resolves frontmatter conflicts while preserving implementation content\n#\n# The merge driver is configured automatically by `chant init`\n{}\n",
365 merge_pattern
366 );
367 fs::write(gitattributes_path, content).context("Failed to create .gitattributes")?;
368 result.gitattributes_updated = true;
369 }
370
371 Ok(result)
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377
378 #[test]
379 fn test_parse_spec_file_basic() {
380 let content = r#"---
381type: code
382status: pending
383---
384# Test Spec
385
386Body content here.
387"#;
388 let result = parse_spec_file(content).unwrap();
389 assert_eq!(result.frontmatter.status, SpecStatus::Pending);
390 assert!(result.body.contains("# Test Spec"));
391 assert!(result.body.contains("Body content here."));
392 }
393
394 #[test]
395 fn test_merge_status_prefers_completed() {
396 let base = SpecStatus::Pending;
397 let ours = SpecStatus::InProgress;
398 let theirs = SpecStatus::Completed;
399
400 let result = merge_status(&base, &ours, &theirs);
401 assert_eq!(result, SpecStatus::Completed);
402 }
403
404 #[test]
405 fn test_merge_lists_deduplicates() {
406 let base = Some(vec!["abc".to_string()]);
407 let ours = Some(vec!["abc".to_string(), "def".to_string()]);
408 let theirs = Some(vec!["abc".to_string(), "ghi".to_string()]);
409
410 let result = merge_lists(&base, &ours, &theirs);
411 let result = result.unwrap();
412 assert_eq!(result.len(), 3);
413 assert!(result.contains(&"abc".to_string()));
414 assert!(result.contains(&"def".to_string()));
415 assert!(result.contains(&"ghi".to_string()));
416 }
417
418 #[test]
419 fn test_merge_frontmatter_takes_completed_at_from_theirs() {
420 let base = SpecFrontmatter::default();
421 let ours = SpecFrontmatter {
422 status: SpecStatus::InProgress,
423 ..Default::default()
424 };
425 let theirs = SpecFrontmatter {
426 status: SpecStatus::Completed,
427 completed_at: Some("2026-01-27T10:00:00Z".to_string()),
428 model: Some("claude-opus-4-5".to_string()),
429 ..Default::default()
430 };
431
432 let result = merge_frontmatter(&base, &ours, &theirs);
433 assert_eq!(result.status, SpecStatus::Completed);
434 assert_eq!(
435 result.completed_at,
436 Some("2026-01-27T10:00:00Z".to_string())
437 );
438 assert_eq!(result.model, Some("claude-opus-4-5".to_string()));
439 }
440
441 #[test]
442 fn test_merge_body_takes_ours_when_theirs_unchanged() {
443 let base = "Original content";
444 let ours = "Modified content";
445 let theirs = "Original content";
446
447 let result = merge_body(base, ours, theirs).unwrap();
448 assert_eq!(result, "Modified content");
449 }
450
451 #[test]
452 fn test_get_merge_rule() {
453 assert_eq!(get_merge_rule("status"), MergeRule::AdvancedStatus);
454 assert_eq!(get_merge_rule("completed_at"), MergeRule::PreferTheirs);
455 assert_eq!(get_merge_rule("branch"), MergeRule::PreferOurs);
456 assert_eq!(get_merge_rule("labels"), MergeRule::Union);
457 assert_eq!(get_merge_rule("unknown_field"), MergeRule::PreferOurs);
458 }
459
460 #[test]
461 fn test_real_world_scenario() {
462 let base_content = r#"---
463type: code
464status: pending
465---
466# Implement feature X
467
468## Problem
469
470Description of the problem.
471
472## Acceptance Criteria
473
474- [ ] Feature X implemented
475- [ ] Tests passing
476"#;
477
478 let ours_content = r#"---
479type: code
480status: in_progress
481commits:
482 - abc123
483---
484# Implement feature X
485
486## Problem
487
488Description of the problem.
489
490## Solution
491
492Here's how we solved it...
493
494## Acceptance Criteria
495
496- [x] Feature X implemented
497- [x] Tests passing
498"#;
499
500 let theirs_content = r#"---
501type: code
502status: completed
503completed_at: 2026-01-27T15:00:00Z
504model: claude-opus-4-5
505commits:
506 - def456
507---
508# Implement feature X
509
510## Problem
511
512Description of the problem.
513
514## Acceptance Criteria
515
516- [ ] Feature X implemented
517- [ ] Tests passing
518"#;
519
520 let base = parse_spec_file(base_content).unwrap();
521 let ours = parse_spec_file(ours_content).unwrap();
522 let theirs = parse_spec_file(theirs_content).unwrap();
523
524 let merged_fm =
525 merge_frontmatter(&base.frontmatter, &ours.frontmatter, &theirs.frontmatter);
526
527 assert_eq!(merged_fm.status, SpecStatus::Completed);
528 assert_eq!(
529 merged_fm.completed_at,
530 Some("2026-01-27T15:00:00Z".to_string())
531 );
532 assert_eq!(merged_fm.model, Some("claude-opus-4-5".to_string()));
533 let commits = merged_fm.commits.unwrap();
534 assert!(commits.contains(&"abc123".to_string()));
535 assert!(commits.contains(&"def456".to_string()));
536
537 let merged_body = merge_body(&base.body, &ours.body, &theirs.body).unwrap();
538 assert!(
539 merged_body.contains("## Solution") || merged_body.contains("Here's how we solved it")
540 );
541 }
542}