Skip to main content

chant/
merge_driver.rs

1//! Git merge driver for spec files.
2//!
3//! This module implements a custom merge driver for `.chant/specs/*.md` files
4//! that intelligently resolves frontmatter conflicts while preserving body content.
5//!
6//! ## Problem
7//!
8//! When merging spec branches back to main, frontmatter conflicts occur because:
9//! - Main has `status: completed` (from finalize)
10//! - Feature branch has `status: in_progress`
11//! - Main may have `completed_at` and `model` fields
12//! - Feature branch may not have these fields yet
13//!
14//! ## Solution
15//!
16//! This merge driver:
17//! 1. Parses frontmatter from base, ours, and theirs versions
18//! 2. Intelligently merges frontmatter fields
19//! 3. Uses standard 3-way merge for body content
20//! 4. Produces a clean merge result or marks conflicts
21//!
22//! ## Git Configuration
23//!
24//! To use this merge driver, add to `.gitattributes`:
25//! ```text
26//! .chant/specs/*.md merge=chant-spec
27//! ```
28//!
29//! Then configure git:
30//! ```text
31//! git config merge.chant-spec.name "Chant spec merge driver"
32//! git config merge.chant-spec.driver "chant merge-driver %O %A %B"
33//! ```
34//!
35//! # Doc Audit
36//! - audited: 2026-01-27
37//! - docs: guides/recovery.md
38//! - ignore: false
39
40use anyhow::{Context, Result};
41use std::fs;
42use std::path::Path;
43use std::process::Command;
44
45use crate::spec::{split_frontmatter, SpecFrontmatter, SpecStatus};
46
47/// Result of parsing a spec file into frontmatter and body
48#[derive(Debug, Clone)]
49pub struct ParsedSpec {
50    /// Raw frontmatter YAML string (without ---\n markers)
51    pub frontmatter_yaml: String,
52    /// Parsed frontmatter structure
53    pub frontmatter: SpecFrontmatter,
54    /// Body content after frontmatter
55    pub body: String,
56}
57
58/// Parse a spec file into frontmatter and body components
59pub 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
76/// Merge frontmatter from base, ours, and theirs
77///
78/// Strategy:
79/// - `status`: Prefer the more "advanced" status (completed > in_progress > pending)
80/// - `completed_at`, `model`: Take from whichever side has them (prefer ours)
81/// - `commits`: Merge both lists, deduplicate
82/// - Other fields: Prefer ours (feature branch) as it's fresher for work-in-progress
83pub fn merge_frontmatter(
84    base: &SpecFrontmatter,
85    ours: &SpecFrontmatter,
86    theirs: &SpecFrontmatter,
87) -> SpecFrontmatter {
88    let mut result = ours.clone();
89
90    // Status: prefer the more "advanced" status
91    result.status = merge_status(&base.status, &ours.status, &theirs.status);
92
93    // completed_at: take from whichever side has it (prefer theirs as it's from finalize)
94    if result.completed_at.is_none() && theirs.completed_at.is_some() {
95        result.completed_at = theirs.completed_at.clone();
96    }
97
98    // model: take from whichever side has it (prefer theirs as it's from finalize)
99    if result.model.is_none() && theirs.model.is_some() {
100        result.model = theirs.model.clone();
101    }
102
103    // commits: merge both lists, deduplicate
104    result.commits = merge_commits(&base.commits, &ours.commits, &theirs.commits);
105
106    // branch: prefer ours (feature branch has the actual branch info)
107    if result.branch.is_none() && theirs.branch.is_some() {
108        result.branch = theirs.branch.clone();
109    }
110
111    // labels: merge both lists, deduplicate
112    result.labels = merge_string_lists(&base.labels, &ours.labels, &theirs.labels);
113
114    // target_files: merge both lists, deduplicate
115    result.target_files =
116        merge_string_lists(&base.target_files, &ours.target_files, &theirs.target_files);
117
118    // context: merge both lists, deduplicate
119    result.context = merge_string_lists(&base.context, &ours.context, &theirs.context);
120
121    // members: merge both lists, deduplicate
122    result.members = merge_string_lists(&base.members, &ours.members, &theirs.members);
123
124    // Verification fields: prefer theirs (from finalize) if present
125    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    // Replay fields: prefer ours
136    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
149/// Merge status fields, preferring the more "advanced" status
150fn merge_status(_base: &SpecStatus, ours: &SpecStatus, theirs: &SpecStatus) -> SpecStatus {
151    // Status priority (higher is more "advanced"):
152    // Cancelled < Failed < NeedsAttention < Blocked < Pending < Ready < InProgress < Completed
153    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 both changed from base, prefer the higher priority
171    if ours_priority >= theirs_priority {
172        ours.clone()
173    } else {
174        theirs.clone()
175    }
176}
177
178/// Merge commit lists, deduplicating entries
179fn 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
204/// Merge string lists, deduplicating entries
205fn 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
230/// Merge body content using git's 3-way merge
231///
232/// Returns Ok(merged_body) if merge succeeded, or Err with conflict markers
233pub fn merge_body(base: &str, ours: &str, theirs: &str) -> Result<String> {
234    // If base and ours are the same, take theirs
235    if base.trim() == ours.trim() {
236        return Ok(theirs.to_string());
237    }
238    // If base and theirs are the same, take ours
239    if base.trim() == theirs.trim() {
240        return Ok(ours.to_string());
241    }
242    // If ours and theirs are the same, take ours
243    if ours.trim() == theirs.trim() {
244        return Ok(ours.to_string());
245    }
246
247    // Write to temporary files and use git merge-file
248    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    // Run git merge-file
258    let output = Command::new("git")
259        .args([
260            "merge-file",
261            "-p", // Write to stdout instead of overwriting
262            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    // Exit code 0 = clean merge, >0 = conflicts (but content is still usable)
272    // We return the merged content either way, as it contains conflict markers if needed
273    Ok(merged)
274}
275
276/// Serialize frontmatter back to YAML string
277pub fn serialize_frontmatter(frontmatter: &SpecFrontmatter) -> Result<String> {
278    serde_yaml::to_string(frontmatter).context("Failed to serialize frontmatter")
279}
280
281/// Assemble a spec file from frontmatter and body
282pub 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
287/// Run the merge driver
288///
289/// This is the main entry point called by git.
290/// Arguments:
291/// - base_path: Path to the common ancestor version (%O)
292/// - ours_path: Path to our version (%A) - this is also where we write the result
293/// - theirs_path: Path to their version (%B)
294///
295/// Returns:
296/// - 0 (Ok) if merge succeeded
297/// - 1 (Err) if there are conflicts
298pub fn run_merge_driver(base_path: &Path, ours_path: &Path, theirs_path: &Path) -> Result<bool> {
299    // Read all three versions
300    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    // Parse all three
308    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    // Merge frontmatter
313    let merged_frontmatter =
314        merge_frontmatter(&base.frontmatter, &ours.frontmatter, &theirs.frontmatter);
315
316    // Merge body
317    let merged_body = merge_body(&base.body, &ours.body, &theirs.body)?;
318
319    // Check for conflict markers in body
320    let has_conflicts = merged_body.contains("<<<<<<<")
321        || merged_body.contains("=======")
322        || merged_body.contains(">>>>>>>");
323
324    // Assemble result
325    let result = assemble_spec(&merged_frontmatter, &merged_body)?;
326
327    // Write result to ours_path (git expects us to modify this file)
328    fs::write(ours_path, result)
329        .with_context(|| format!("Failed to write result to: {}", ours_path.display()))?;
330
331    Ok(!has_conflicts)
332}
333
334/// Generate git configuration instructions for the merge driver
335pub 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/// Result of setting up the merge driver
383#[derive(Debug, Clone)]
384pub struct MergeDriverSetupResult {
385    /// Whether the git config was set up (false if not in a git repo)
386    pub git_config_set: bool,
387    /// Whether .gitattributes was created/updated
388    pub gitattributes_updated: bool,
389    /// Any warning message (e.g., "not in a git repository")
390    pub warning: Option<String>,
391}
392
393/// Set up the merge driver for the current repository
394///
395/// This function:
396/// 1. Configures git with the merge driver settings (if in a git repo)
397/// 2. Creates/updates .gitattributes with the merge pattern
398///
399/// Returns a result indicating what was set up, or an error if something failed.
400pub 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    // Check if we're in a git repository
408    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        // Configure the merge driver name
416        let name_result = Command::new("git")
417            .args(["config", "merge.chant-spec.name", "Chant spec merge driver"])
418            .output();
419
420        // Configure the merge driver command
421        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        // Check if both commands succeeded
430        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    // Create/update .gitattributes (this works even without git)
445    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        // Check if the pattern already exists
450        let content =
451            fs::read_to_string(gitattributes_path).context("Failed to read .gitattributes")?;
452
453        if !content.contains(merge_pattern) {
454            // Append the pattern
455            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        // If pattern already exists, gitattributes_updated stays false (already configured)
467    } else {
468        // Create new .gitattributes file
469        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        // Simulate the exact conflict scenario from the spec:
635        // - Main has status: completed (from finalize)
636        // - Feature branch has status: in_progress
637        // - Main has completed_at and model fields
638
639        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        // Merge frontmatter
702        let merged_fm =
703            merge_frontmatter(&base.frontmatter, &ours.frontmatter, &theirs.frontmatter);
704
705        // Should get completed status (higher priority)
706        assert_eq!(merged_fm.status, SpecStatus::Completed);
707        // Should get completed_at from theirs
708        assert_eq!(
709            merged_fm.completed_at,
710            Some("2026-01-27T15:00:00Z".to_string())
711        );
712        // Should get model from theirs
713        assert_eq!(merged_fm.model, Some("claude-opus-4-5".to_string()));
714        // Should have both commits merged
715        let commits = merged_fm.commits.unwrap();
716        assert!(commits.contains(&"abc123".to_string()));
717        assert!(commits.contains(&"def456".to_string()));
718
719        // Merge body - ours has the implementation, so it should be preserved
720        let merged_body = merge_body(&base.body, &ours.body, &theirs.body).unwrap();
721
722        // The merged body should have our solution section
723        assert!(
724            merged_body.contains("## Solution") || merged_body.contains("Here's how we solved it")
725        );
726        // And our checked checkboxes (or at least not revert to unchecked without conflict)
727    }
728}