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
6use anyhow::{Context, Result};
7use std::fs;
8use std::path::Path;
9use std::process::Command;
10
11use crate::spec::{split_frontmatter, SpecFrontmatter, SpecStatus};
12
13/// Merge strategy for a frontmatter field
14#[derive(Debug, Clone, PartialEq)]
15pub enum MergeRule {
16    /// Prefer the more advanced value (for status)
17    AdvancedStatus,
18    /// Take from either side, preferring theirs (for completion metadata)
19    PreferTheirs,
20    /// Take from either side, preferring ours (for working branch metadata)
21    PreferOurs,
22    /// Merge both lists, deduplicate (for arrays)
23    Union,
24}
25
26/// Declarative merge rules for frontmatter fields
27const 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
45/// Get merge rule for a field (default to PreferOurs if not specified)
46pub 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/// Result of parsing a spec file into frontmatter and body
55#[derive(Debug, Clone)]
56pub struct ParsedSpec {
57    pub frontmatter_yaml: String,
58    pub frontmatter: SpecFrontmatter,
59    pub body: String,
60}
61
62/// Parse a spec file into frontmatter and body components
63pub 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
80/// Merge frontmatter from base, ours, and theirs using declarative rules
81pub 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
132/// Merge status fields, preferring the more "advanced" status
133fn 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
155/// Merge lists using union strategy (deduplicate)
156fn 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
181/// Merge body content using git's 3-way merge
182pub 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
217/// Serialize frontmatter back to YAML string
218pub fn serialize_frontmatter(frontmatter: &SpecFrontmatter) -> Result<String> {
219    serde_yaml::to_string(frontmatter).context("Failed to serialize frontmatter")
220}
221
222/// Assemble a spec file from frontmatter and body
223pub 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
228/// Run the merge driver
229pub 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
258/// Generate git configuration instructions for the merge driver
259pub 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/// Result of setting up the merge driver
295#[derive(Debug, Clone)]
296pub struct MergeDriverSetupResult {
297    pub git_config_set: bool,
298    pub gitattributes_updated: bool,
299    pub warning: Option<String>,
300}
301
302/// Set up the merge driver for the current repository
303pub 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}