1use anyhow::{Context, Result};
41use std::fs;
42use std::path::Path;
43use std::process::Command;
44
45use crate::spec::{split_frontmatter, SpecFrontmatter, SpecStatus};
46
47#[derive(Debug, Clone)]
49pub struct ParsedSpec {
50 pub frontmatter_yaml: String,
52 pub frontmatter: SpecFrontmatter,
54 pub body: String,
56}
57
58pub fn parse_spec_file(content: &str) -> Result<ParsedSpec> {
60 let (frontmatter_opt, body) = split_frontmatter(content);
61
62 let frontmatter_yaml = frontmatter_opt.unwrap_or_default();
63 let frontmatter: SpecFrontmatter = if !frontmatter_yaml.is_empty() {
64 serde_yaml::from_str(&frontmatter_yaml).context("Failed to parse frontmatter")?
65 } else {
66 SpecFrontmatter::default()
67 };
68
69 Ok(ParsedSpec {
70 frontmatter_yaml,
71 frontmatter,
72 body: body.to_string(),
73 })
74}
75
76pub fn merge_frontmatter(
84 base: &SpecFrontmatter,
85 ours: &SpecFrontmatter,
86 theirs: &SpecFrontmatter,
87) -> SpecFrontmatter {
88 let mut result = ours.clone();
89
90 result.status = merge_status(&base.status, &ours.status, &theirs.status);
92
93 if result.completed_at.is_none() && theirs.completed_at.is_some() {
95 result.completed_at = theirs.completed_at.clone();
96 }
97
98 if result.model.is_none() && theirs.model.is_some() {
100 result.model = theirs.model.clone();
101 }
102
103 result.commits = merge_commits(&base.commits, &ours.commits, &theirs.commits);
105
106 if result.branch.is_none() && theirs.branch.is_some() {
108 result.branch = theirs.branch.clone();
109 }
110
111 result.labels = merge_string_lists(&base.labels, &ours.labels, &theirs.labels);
113
114 result.target_files =
116 merge_string_lists(&base.target_files, &ours.target_files, &theirs.target_files);
117
118 result.context = merge_string_lists(&base.context, &ours.context, &theirs.context);
120
121 result.members = merge_string_lists(&base.members, &ours.members, &theirs.members);
123
124 if result.last_verified.is_none() && theirs.last_verified.is_some() {
126 result.last_verified = theirs.last_verified.clone();
127 }
128 if result.verification_status.is_none() && theirs.verification_status.is_some() {
129 result.verification_status = theirs.verification_status.clone();
130 }
131 if result.verification_failures.is_none() && theirs.verification_failures.is_some() {
132 result.verification_failures = theirs.verification_failures.clone();
133 }
134
135 if result.replayed_at.is_none() && theirs.replayed_at.is_some() {
137 result.replayed_at = theirs.replayed_at.clone();
138 }
139 if result.replay_count.is_none() && theirs.replay_count.is_some() {
140 result.replay_count = theirs.replay_count;
141 }
142 if result.original_completed_at.is_none() && theirs.original_completed_at.is_some() {
143 result.original_completed_at = theirs.original_completed_at.clone();
144 }
145
146 result
147}
148
149fn merge_status(_base: &SpecStatus, ours: &SpecStatus, theirs: &SpecStatus) -> SpecStatus {
151 let priority = |s: &SpecStatus| -> u8 {
154 match s {
155 SpecStatus::Cancelled => 0,
156 SpecStatus::Failed => 1,
157 SpecStatus::NeedsAttention => 2,
158 SpecStatus::Blocked => 3,
159 SpecStatus::Pending => 4,
160 SpecStatus::Ready => 5,
161 SpecStatus::Paused => 6,
162 SpecStatus::InProgress => 7,
163 SpecStatus::Completed => 8,
164 }
165 };
166
167 let ours_priority = priority(ours);
168 let theirs_priority = priority(theirs);
169
170 if ours_priority >= theirs_priority {
172 ours.clone()
173 } else {
174 theirs.clone()
175 }
176}
177
178fn merge_commits(
180 _base: &Option<Vec<String>>,
181 ours: &Option<Vec<String>>,
182 theirs: &Option<Vec<String>>,
183) -> Option<Vec<String>> {
184 match (ours, theirs) {
185 (Some(o), Some(t)) => {
186 let mut result: Vec<String> = o.clone();
187 for commit in t {
188 if !result.contains(commit) {
189 result.push(commit.clone());
190 }
191 }
192 if result.is_empty() {
193 None
194 } else {
195 Some(result)
196 }
197 }
198 (Some(o), None) => Some(o.clone()),
199 (None, Some(t)) => Some(t.clone()),
200 (None, None) => None,
201 }
202}
203
204fn merge_string_lists(
206 _base: &Option<Vec<String>>,
207 ours: &Option<Vec<String>>,
208 theirs: &Option<Vec<String>>,
209) -> Option<Vec<String>> {
210 match (ours, theirs) {
211 (Some(o), Some(t)) => {
212 let mut result: Vec<String> = o.clone();
213 for item in t {
214 if !result.contains(item) {
215 result.push(item.clone());
216 }
217 }
218 if result.is_empty() {
219 None
220 } else {
221 Some(result)
222 }
223 }
224 (Some(o), None) => Some(o.clone()),
225 (None, Some(t)) => Some(t.clone()),
226 (None, None) => None,
227 }
228}
229
230pub fn merge_body(base: &str, ours: &str, theirs: &str) -> Result<String> {
234 if base.trim() == ours.trim() {
236 return Ok(theirs.to_string());
237 }
238 if base.trim() == theirs.trim() {
240 return Ok(ours.to_string());
241 }
242 if ours.trim() == theirs.trim() {
244 return Ok(ours.to_string());
245 }
246
247 let temp_dir = tempfile::tempdir().context("Failed to create temp directory")?;
249 let base_path = temp_dir.path().join("base");
250 let ours_path = temp_dir.path().join("ours");
251 let theirs_path = temp_dir.path().join("theirs");
252
253 fs::write(&base_path, base).context("Failed to write base file")?;
254 fs::write(&ours_path, ours).context("Failed to write ours file")?;
255 fs::write(&theirs_path, theirs).context("Failed to write theirs file")?;
256
257 let output = Command::new("git")
259 .args([
260 "merge-file",
261 "-p", ours_path.to_str().unwrap(),
263 base_path.to_str().unwrap(),
264 theirs_path.to_str().unwrap(),
265 ])
266 .output()
267 .context("Failed to run git merge-file")?;
268
269 let merged = String::from_utf8_lossy(&output.stdout).to_string();
270
271 Ok(merged)
274}
275
276pub fn serialize_frontmatter(frontmatter: &SpecFrontmatter) -> Result<String> {
278 serde_yaml::to_string(frontmatter).context("Failed to serialize frontmatter")
279}
280
281pub fn assemble_spec(frontmatter: &SpecFrontmatter, body: &str) -> Result<String> {
283 let frontmatter_yaml = serialize_frontmatter(frontmatter)?;
284 Ok(format!("---\n{}---\n{}", frontmatter_yaml, body))
285}
286
287pub fn run_merge_driver(base_path: &Path, ours_path: &Path, theirs_path: &Path) -> Result<bool> {
299 let base_content = fs::read_to_string(base_path)
301 .with_context(|| format!("Failed to read base file: {}", base_path.display()))?;
302 let ours_content = fs::read_to_string(ours_path)
303 .with_context(|| format!("Failed to read ours file: {}", ours_path.display()))?;
304 let theirs_content = fs::read_to_string(theirs_path)
305 .with_context(|| format!("Failed to read theirs file: {}", theirs_path.display()))?;
306
307 let base = parse_spec_file(&base_content)?;
309 let ours = parse_spec_file(&ours_content)?;
310 let theirs = parse_spec_file(&theirs_content)?;
311
312 let merged_frontmatter =
314 merge_frontmatter(&base.frontmatter, &ours.frontmatter, &theirs.frontmatter);
315
316 let merged_body = merge_body(&base.body, &ours.body, &theirs.body)?;
318
319 let has_conflicts = merged_body.contains("<<<<<<<")
321 || merged_body.contains("=======")
322 || merged_body.contains(">>>>>>>");
323
324 let result = assemble_spec(&merged_frontmatter, &merged_body)?;
326
327 fs::write(ours_path, result)
329 .with_context(|| format!("Failed to write result to: {}", ours_path.display()))?;
330
331 Ok(!has_conflicts)
332}
333
334pub fn get_setup_instructions() -> String {
336 r#"# Chant Spec Merge Driver Setup
337
338## Step 1: Add .gitattributes entry
339
340Add to your `.gitattributes` file (or create one):
341
342```
343.chant/specs/*.md merge=chant-spec
344```
345
346## Step 2: Configure git merge driver
347
348Run these commands in your repository:
349
350```bash
351# Configure the merge driver
352git config merge.chant-spec.name "Chant spec merge driver"
353git config merge.chant-spec.driver "chant merge-driver %O %A %B"
354```
355
356Or add to your `.git/config`:
357
358```ini
359[merge "chant-spec"]
360 name = Chant spec merge driver
361 driver = chant merge-driver %O %A %B
362```
363
364## How it works
365
366The merge driver intelligently handles spec file merges by:
367
3681. **Frontmatter conflicts**: Automatically resolved
369 - `status`: Prefers more "advanced" status (completed > in_progress > pending)
370 - `completed_at`, `model`: Takes values from either side
371 - `commits`: Merges both lists, deduplicates
372
3732. **Body conflicts**: Uses standard 3-way merge
374 - Shows conflict markers if both sides changed same section
375
376This prevents the common issue where `git checkout --theirs` discards
377implementation code while keeping wrong metadata.
378"#
379 .to_string()
380}
381
382#[derive(Debug, Clone)]
384pub struct MergeDriverSetupResult {
385 pub git_config_set: bool,
387 pub gitattributes_updated: bool,
389 pub warning: Option<String>,
391}
392
393pub fn setup_merge_driver() -> Result<MergeDriverSetupResult> {
401 let mut result = MergeDriverSetupResult {
402 git_config_set: false,
403 gitattributes_updated: false,
404 warning: None,
405 };
406
407 let in_git_repo = Command::new("git")
409 .args(["rev-parse", "--git-dir"])
410 .output()
411 .map(|o| o.status.success())
412 .unwrap_or(false);
413
414 if in_git_repo {
415 let name_result = Command::new("git")
417 .args(["config", "merge.chant-spec.name", "Chant spec merge driver"])
418 .output();
419
420 let driver_result = Command::new("git")
422 .args([
423 "config",
424 "merge.chant-spec.driver",
425 "chant merge-driver %O %A %B",
426 ])
427 .output();
428
429 match (name_result, driver_result) {
431 (Ok(name_out), Ok(driver_out))
432 if name_out.status.success() && driver_out.status.success() =>
433 {
434 result.git_config_set = true;
435 }
436 _ => {
437 result.warning = Some("Failed to configure git merge driver".to_string());
438 }
439 }
440 } else {
441 result.warning = Some("Not in a git repository - merge driver config skipped".to_string());
442 }
443
444 let gitattributes_path = std::path::Path::new(".gitattributes");
446 let merge_pattern = ".chant/specs/*.md merge=chant-spec";
447
448 if gitattributes_path.exists() {
449 let content =
451 fs::read_to_string(gitattributes_path).context("Failed to read .gitattributes")?;
452
453 if !content.contains(merge_pattern) {
454 let mut new_content = content;
456 if !new_content.ends_with('\n') && !new_content.is_empty() {
457 new_content.push('\n');
458 }
459 new_content.push_str("\n# Chant spec files use a custom merge driver for intelligent conflict resolution\n");
460 new_content.push_str(merge_pattern);
461 new_content.push('\n');
462 fs::write(gitattributes_path, new_content)
463 .context("Failed to update .gitattributes")?;
464 result.gitattributes_updated = true;
465 }
466 } else {
468 let content = format!(
470 "# 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",
471 merge_pattern
472 );
473 fs::write(gitattributes_path, content).context("Failed to create .gitattributes")?;
474 result.gitattributes_updated = true;
475 }
476
477 Ok(result)
478}
479
480#[cfg(test)]
481mod tests {
482 use super::*;
483
484 #[test]
485 fn test_parse_spec_file_basic() {
486 let content = r#"---
487type: code
488status: pending
489---
490# Test Spec
491
492Body content here.
493"#;
494 let result = parse_spec_file(content).unwrap();
495 assert_eq!(result.frontmatter.status, SpecStatus::Pending);
496 assert!(result.body.contains("# Test Spec"));
497 assert!(result.body.contains("Body content here."));
498 }
499
500 #[test]
501 fn test_parse_spec_file_with_all_fields() {
502 let content = r#"---
503type: code
504status: completed
505commits:
506 - abc123
507 - def456
508completed_at: 2026-01-27T10:00:00Z
509model: claude-opus-4-5
510---
511# Completed Spec
512
513Implementation details.
514"#;
515 let result = parse_spec_file(content).unwrap();
516 assert_eq!(result.frontmatter.status, SpecStatus::Completed);
517 assert_eq!(
518 result.frontmatter.model,
519 Some("claude-opus-4-5".to_string())
520 );
521 assert_eq!(
522 result.frontmatter.commits,
523 Some(vec!["abc123".to_string(), "def456".to_string()])
524 );
525 }
526
527 #[test]
528 fn test_merge_status_prefers_completed() {
529 let base = SpecStatus::Pending;
530 let ours = SpecStatus::InProgress;
531 let theirs = SpecStatus::Completed;
532
533 let result = merge_status(&base, &ours, &theirs);
534 assert_eq!(result, SpecStatus::Completed);
535 }
536
537 #[test]
538 fn test_merge_status_prefers_in_progress_over_pending() {
539 let base = SpecStatus::Pending;
540 let ours = SpecStatus::InProgress;
541 let theirs = SpecStatus::Pending;
542
543 let result = merge_status(&base, &ours, &theirs);
544 assert_eq!(result, SpecStatus::InProgress);
545 }
546
547 #[test]
548 fn test_merge_commits_deduplicates() {
549 let base = Some(vec!["abc".to_string()]);
550 let ours = Some(vec!["abc".to_string(), "def".to_string()]);
551 let theirs = Some(vec!["abc".to_string(), "ghi".to_string()]);
552
553 let result = merge_commits(&base, &ours, &theirs);
554 let result = result.unwrap();
555 assert_eq!(result.len(), 3);
556 assert!(result.contains(&"abc".to_string()));
557 assert!(result.contains(&"def".to_string()));
558 assert!(result.contains(&"ghi".to_string()));
559 }
560
561 #[test]
562 fn test_merge_frontmatter_takes_completed_at_from_theirs() {
563 let base = SpecFrontmatter::default();
564 let ours = SpecFrontmatter {
565 status: SpecStatus::InProgress,
566 ..Default::default()
567 };
568 let theirs = SpecFrontmatter {
569 status: SpecStatus::Completed,
570 completed_at: Some("2026-01-27T10:00:00Z".to_string()),
571 model: Some("claude-opus-4-5".to_string()),
572 ..Default::default()
573 };
574
575 let result = merge_frontmatter(&base, &ours, &theirs);
576 assert_eq!(result.status, SpecStatus::Completed);
577 assert_eq!(
578 result.completed_at,
579 Some("2026-01-27T10:00:00Z".to_string())
580 );
581 assert_eq!(result.model, Some("claude-opus-4-5".to_string()));
582 }
583
584 #[test]
585 fn test_merge_body_takes_ours_when_theirs_unchanged() {
586 let base = "Original content";
587 let ours = "Modified content";
588 let theirs = "Original content";
589
590 let result = merge_body(base, ours, theirs).unwrap();
591 assert_eq!(result, "Modified content");
592 }
593
594 #[test]
595 fn test_merge_body_takes_theirs_when_ours_unchanged() {
596 let base = "Original content";
597 let ours = "Original content";
598 let theirs = "Modified content";
599
600 let result = merge_body(base, ours, theirs).unwrap();
601 assert_eq!(result, "Modified content");
602 }
603
604 #[test]
605 fn test_assemble_spec() {
606 let frontmatter = SpecFrontmatter {
607 status: SpecStatus::Completed,
608 ..Default::default()
609 };
610 let body = "# Test\n\nContent here.";
611
612 let result = assemble_spec(&frontmatter, body).unwrap();
613 assert!(result.starts_with("---\n"));
614 assert!(result.contains("status: completed"));
615 assert!(result.contains("---\n# Test"));
616 assert!(result.contains("Content here."));
617 }
618
619 #[test]
620 fn test_merge_string_lists_deduplicates() {
621 let base: Option<Vec<String>> = None;
622 let ours = Some(vec!["a".to_string(), "b".to_string()]);
623 let theirs = Some(vec!["b".to_string(), "c".to_string()]);
624
625 let result = merge_string_lists(&base, &ours, &theirs).unwrap();
626 assert_eq!(result.len(), 3);
627 assert!(result.contains(&"a".to_string()));
628 assert!(result.contains(&"b".to_string()));
629 assert!(result.contains(&"c".to_string()));
630 }
631
632 #[test]
633 fn test_real_world_scenario() {
634 let base_content = r#"---
640type: code
641status: pending
642---
643# Implement feature X
644
645## Problem
646
647Description of the problem.
648
649## Acceptance Criteria
650
651- [ ] Feature X implemented
652- [ ] Tests passing
653"#;
654
655 let ours_content = r#"---
656type: code
657status: in_progress
658commits:
659 - abc123
660---
661# Implement feature X
662
663## Problem
664
665Description of the problem.
666
667## Solution
668
669Here's how we solved it...
670
671## Acceptance Criteria
672
673- [x] Feature X implemented
674- [x] Tests passing
675"#;
676
677 let theirs_content = r#"---
678type: code
679status: completed
680completed_at: 2026-01-27T15:00:00Z
681model: claude-opus-4-5
682commits:
683 - def456
684---
685# Implement feature X
686
687## Problem
688
689Description of the problem.
690
691## Acceptance Criteria
692
693- [ ] Feature X implemented
694- [ ] Tests passing
695"#;
696
697 let base = parse_spec_file(base_content).unwrap();
698 let ours = parse_spec_file(ours_content).unwrap();
699 let theirs = parse_spec_file(theirs_content).unwrap();
700
701 let merged_fm =
703 merge_frontmatter(&base.frontmatter, &ours.frontmatter, &theirs.frontmatter);
704
705 assert_eq!(merged_fm.status, SpecStatus::Completed);
707 assert_eq!(
709 merged_fm.completed_at,
710 Some("2026-01-27T15:00:00Z".to_string())
711 );
712 assert_eq!(merged_fm.model, Some("claude-opus-4-5".to_string()));
714 let commits = merged_fm.commits.unwrap();
716 assert!(commits.contains(&"abc123".to_string()));
717 assert!(commits.contains(&"def456".to_string()));
718
719 let merged_body = merge_body(&base.body, &ours.body, &theirs.body).unwrap();
721
722 assert!(
724 merged_body.contains("## Solution") || merged_body.contains("Here's how we solved it")
725 );
726 }
728}