Skip to main content

chant/
merge.rs

1//! Spec merge logic and utilities.
2//!
3//! # Doc Audit
4//! - audited: 2026-01-25
5//! - docs: guides/recovery.md
6//! - ignore: false
7
8use crate::config::Config;
9use crate::git::MergeResult;
10use crate::spec::{Spec, SpecStatus};
11use crate::spec_group::{extract_member_number, is_member_of};
12use anyhow::Result;
13
14/// Status of a branch for selective merge operations
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum BranchStatus {
17    /// All criteria checked, can fast-forward merge
18    Ready,
19    /// Behind main, needs rebase but no conflicts expected
20    NeedsRebase,
21    /// Rebase would likely have conflicts
22    HasConflicts,
23    /// Spec criteria not all checked
24    Incomplete,
25    /// Branch exists but no commits
26    NoCommits,
27    /// Branch doesn't exist
28    NoBranch,
29}
30
31/// Information about a branch for selective merge display
32#[derive(Debug, Clone)]
33pub struct BranchInfo {
34    pub spec_id: String,
35    pub spec_title: Option<String>,
36    pub status: BranchStatus,
37    pub commit_count: usize,
38    pub criteria_checked: usize,
39    pub criteria_total: usize,
40}
41
42/// Load main_branch from config with fallback to "main"
43pub fn load_main_branch(config: &Config) -> String {
44    config.defaults.main_branch.clone()
45}
46
47/// Count checked and total acceptance criteria in a spec
48pub fn count_criteria(spec: &Spec) -> (usize, usize) {
49    let body = &spec.body;
50    let mut in_criteria_section = false;
51    let mut in_code_fence = false;
52    let mut checked = 0;
53    let mut total = 0;
54
55    for line in body.lines() {
56        let trimmed = line.trim();
57
58        // Track code fences
59        if trimmed.starts_with("```") {
60            in_code_fence = !in_code_fence;
61            continue;
62        }
63
64        // Skip lines in code fences
65        if in_code_fence {
66            continue;
67        }
68
69        // Check for acceptance criteria section
70        if trimmed.starts_with("## Acceptance Criteria") {
71            in_criteria_section = true;
72            continue;
73        }
74
75        // Exit criteria section on next heading
76        if in_criteria_section && trimmed.starts_with("## ") {
77            break;
78        }
79
80        // Count criteria checkboxes
81        if in_criteria_section {
82            if trimmed.starts_with("- [x]") || trimmed.starts_with("- [X]") {
83                checked += 1;
84                total += 1;
85            } else if trimmed.starts_with("- [ ]") {
86                total += 1;
87            }
88        }
89    }
90
91    (checked, total)
92}
93
94/// Detect the status of a branch for selective merge
95pub fn detect_branch_status(
96    spec: &Spec,
97    branch_name: &str,
98    main_branch: &str,
99) -> Result<BranchStatus> {
100    use crate::git;
101
102    // Check if branch exists
103    let exists = git::branch_exists(branch_name)?;
104    if !exists {
105        return Ok(BranchStatus::NoBranch);
106    }
107
108    // Count commits
109    let commit_count = git::count_commits(branch_name).unwrap_or(0);
110    if commit_count == 0 {
111        return Ok(BranchStatus::NoCommits);
112    }
113
114    // Check criteria
115    let (checked, total) = count_criteria(spec);
116    if total > 0 && checked < total {
117        return Ok(BranchStatus::Incomplete);
118    }
119
120    // Check if can fast-forward merge
121    let can_ff = git::can_fast_forward_merge(branch_name, main_branch)?;
122    if can_ff {
123        return Ok(BranchStatus::Ready);
124    }
125
126    // Check if behind main
127    let is_behind = git::is_branch_behind(branch_name, main_branch)?;
128    if is_behind {
129        // Could check for conflicts here, but for now assume needs rebase
130        return Ok(BranchStatus::NeedsRebase);
131    }
132
133    // Branch has diverged - likely has conflicts
134    Ok(BranchStatus::HasConflicts)
135}
136
137/// Get branch information for all completed specs
138pub fn get_branch_info_for_specs(
139    specs: &[Spec],
140    branch_prefix: &str,
141    main_branch: &str,
142) -> Result<Vec<BranchInfo>> {
143    let mut infos = Vec::new();
144
145    for spec in specs {
146        if spec.frontmatter.status != SpecStatus::Completed {
147            continue;
148        }
149
150        let branch_name = format!("{}{}", branch_prefix, spec.id);
151        let status = detect_branch_status(spec, &branch_name, main_branch)?;
152
153        // Only include specs with branches
154        if status == BranchStatus::NoBranch {
155            continue;
156        }
157
158        let (checked, total) = count_criteria(spec);
159        let commit_count = crate::git::count_commits(&branch_name).unwrap_or(0);
160
161        infos.push(BranchInfo {
162            spec_id: spec.id.clone(),
163            spec_title: spec.title.clone(),
164            status,
165            commit_count,
166            criteria_checked: checked,
167            criteria_total: total,
168        });
169    }
170
171    Ok(infos)
172}
173
174/// Get the list of specs to merge based on arguments
175/// Returns vector of (spec_id, Spec) tuples
176pub fn get_specs_to_merge(
177    args: &[String],
178    all: bool,
179    all_specs: &[Spec],
180) -> Result<Vec<(String, Spec)>> {
181    let mut result = Vec::new();
182
183    if all {
184        // Collect all specs with status == Completed that have branches
185        for spec in all_specs {
186            if spec.frontmatter.status == SpecStatus::Completed {
187                result.push((spec.id.clone(), spec.clone()));
188            }
189        }
190    } else {
191        // Resolve each ID using the same matching logic as resolve_spec:
192        // 1. Exact match
193        // 2. Suffix match (ends_with)
194        // 3. Contains match (partial_id anywhere in spec.id)
195        for partial_id in args {
196            // Try exact match first
197            if let Some(spec) = all_specs.iter().find(|s| s.id == *partial_id) {
198                result.push((spec.id.clone(), spec.clone()));
199                continue;
200            }
201
202            // Try suffix match
203            let suffix_matches: Vec<_> = all_specs
204                .iter()
205                .filter(|s| s.id.ends_with(partial_id))
206                .collect();
207            if suffix_matches.len() == 1 {
208                result.push((suffix_matches[0].id.clone(), suffix_matches[0].clone()));
209                continue;
210            }
211
212            // Try contains match
213            let contains_matches: Vec<_> = all_specs
214                .iter()
215                .filter(|s| s.id.contains(partial_id))
216                .collect();
217            if contains_matches.len() == 1 {
218                result.push((contains_matches[0].id.clone(), contains_matches[0].clone()));
219                continue;
220            }
221
222            if contains_matches.len() > 1 {
223                anyhow::bail!(
224                    "Ambiguous spec ID '{}'. Matches: {}",
225                    partial_id,
226                    contains_matches
227                        .iter()
228                        .map(|s| s.id.as_str())
229                        .collect::<Vec<_>>()
230                        .join(", ")
231                );
232            }
233
234            anyhow::bail!("Spec not found: {}", partial_id);
235        }
236    }
237
238    Ok(result)
239}
240
241/// Validate that a spec can be merged. Public API used in tests.
242pub fn validate_spec_can_merge(spec: &Spec, branch_exists: bool) -> Result<()> {
243    // Check status is Completed
244    match &spec.frontmatter.status {
245        SpecStatus::Completed => {}
246        other => {
247            let status_str = format!("{:?}", other);
248            anyhow::bail!(
249                "{}",
250                crate::merge_errors::spec_status_not_mergeable(&spec.id, &status_str)
251            );
252        }
253    }
254
255    // Check branch exists
256    if !branch_exists {
257        anyhow::bail!("{}", crate::merge_errors::no_branch_for_spec(&spec.id));
258    }
259
260    Ok(())
261}
262
263/// Check if a spec is a driver spec (has member specs)
264pub fn is_driver_spec(spec: &Spec, all_specs: &[Spec]) -> bool {
265    let members = collect_member_specs(spec, all_specs);
266    !members.is_empty()
267}
268
269/// Collect member specs of a driver spec in order (by sequence number)
270fn collect_member_specs(driver_spec: &Spec, all_specs: &[Spec]) -> Vec<Spec> {
271    let driver_id = &driver_spec.id;
272    let mut members: Vec<(u32, Spec)> = Vec::new();
273
274    for spec in all_specs {
275        if is_member_of(&spec.id, driver_id) {
276            // Extract sequence number from member ID
277            if let Some(seq_num) = extract_member_number(&spec.id) {
278                members.push((seq_num, spec.clone()));
279            }
280        }
281    }
282
283    // Sort by sequence number
284    members.sort_by_key(|m| m.0);
285
286    // Return just the specs
287    members.into_iter().map(|(_, spec)| spec).collect()
288}
289
290/// Merge a driver spec and all its members in order.
291///
292/// This function:
293/// 1. Collects all member specs in order
294/// 2. Validates all members are completed and branches exist
295/// 3. Merges each member spec in sequence
296/// 4. If any member merge fails, stops and reports which member failed
297/// 5. After all members succeed, merges the driver spec itself
298/// 6. Returns a list of all merge results (members + driver)
299///
300/// If any validation fails, returns an error with a clear listing of incomplete members.
301pub fn merge_driver_spec(
302    driver_spec: &Spec,
303    all_specs: &[Spec],
304    branch_prefix: &str,
305    main_branch: &str,
306    should_delete_branch: bool,
307    dry_run: bool,
308) -> Result<Vec<MergeResult>> {
309    use crate::git;
310
311    // Collect member specs in order
312    let members = collect_member_specs(driver_spec, all_specs);
313
314    // Check preconditions for all members
315    let mut incomplete_members = Vec::new();
316    for member in &members {
317        // Check status is Completed
318        if member.frontmatter.status != SpecStatus::Completed {
319            incomplete_members.push(format!(
320                "{} (status: {:?})",
321                member.id, member.frontmatter.status
322            ));
323        }
324    }
325
326    // Check all member branches exist (unless dry_run)
327    if !dry_run {
328        for member in &members {
329            let branch_name = format!("{}{}", branch_prefix, member.id);
330            match git::branch_exists(&branch_name) {
331                Ok(exists) => {
332                    if !exists {
333                        incomplete_members.push(format!("{} (branch not found)", member.id));
334                    }
335                }
336                Err(e) => {
337                    anyhow::bail!("Failed to check branch for {}: {}", member.id, e);
338                }
339            }
340        }
341    }
342
343    // If any preconditions failed, report them all
344    if !incomplete_members.is_empty() {
345        anyhow::bail!(
346            "{}",
347            crate::merge_errors::driver_members_incomplete(&driver_spec.id, &incomplete_members)
348        );
349    }
350
351    // Merge each member spec in order
352    let mut all_results = Vec::new();
353    for member in &members {
354        let branch_name = format!("{}{}", branch_prefix, member.id);
355        match git::merge_single_spec(
356            &member.id,
357            &branch_name,
358            main_branch,
359            should_delete_branch,
360            dry_run,
361        ) {
362            Ok(result) => {
363                if !result.success {
364                    anyhow::bail!(
365                        "{}",
366                        crate::merge_errors::member_merge_failed(
367                            &driver_spec.id,
368                            &member.id,
369                            &format!("Merge returned unsuccessful for {}", result.spec_id)
370                        )
371                    );
372                }
373                all_results.push(result);
374            }
375            Err(e) => {
376                anyhow::bail!(
377                    "{}",
378                    crate::merge_errors::member_merge_failed(
379                        &driver_spec.id,
380                        &member.id,
381                        &e.to_string()
382                    )
383                );
384            }
385        }
386    }
387
388    // After all members succeed, merge the driver spec itself
389    let driver_branch = format!("{}{}", branch_prefix, driver_spec.id);
390    match git::merge_single_spec(
391        &driver_spec.id,
392        &driver_branch,
393        main_branch,
394        should_delete_branch,
395        dry_run,
396    ) {
397        Ok(result) => {
398            all_results.push(result);
399            Ok(all_results)
400        }
401        Err(e) => {
402            anyhow::bail!(
403                "{}",
404                crate::merge_errors::member_merge_failed(
405                    &driver_spec.id,
406                    &driver_spec.id,
407                    &e.to_string()
408                )
409            );
410        }
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417    use crate::config::DefaultsConfig;
418
419    fn make_config(defaults: DefaultsConfig) -> Config {
420        Config {
421            project: crate::config::ProjectConfig {
422                name: "test".to_string(),
423                prefix: None,
424                silent: false,
425            },
426            defaults,
427            providers: crate::provider::ProviderConfig::default(),
428            parallel: crate::config::ParallelConfig::default(),
429            repos: vec![],
430            enterprise: crate::config::EnterpriseConfig::default(),
431            approval: crate::config::ApprovalConfig::default(),
432            validation: crate::config::OutputValidationConfig::default(),
433            site: crate::config::SiteConfig::default(),
434            lint: crate::config::LintConfig::default(),
435            watch: crate::config::WatchConfig::default(),
436        }
437    }
438
439    fn make_spec(id: &str, status: SpecStatus) -> Spec {
440        Spec {
441            id: id.to_string(),
442            frontmatter: crate::spec::SpecFrontmatter {
443                status,
444                ..Default::default()
445            },
446            title: Some(format!("Spec {}", id)),
447            body: format!("Body {}", id),
448        }
449    }
450
451    #[test]
452    fn test_count_criteria_no_criteria_section() {
453        let spec = make_spec("001", SpecStatus::Pending);
454        let (checked, total) = count_criteria(&spec);
455        assert_eq!(checked, 0);
456        assert_eq!(total, 0);
457    }
458
459    #[test]
460    fn test_count_criteria_all_unchecked() {
461        let spec = Spec {
462            id: "001".to_string(),
463            frontmatter: crate::spec::SpecFrontmatter {
464                status: SpecStatus::Pending,
465                ..Default::default()
466            },
467            title: Some("Test".to_string()),
468            body: r#"# Test
469
470## Acceptance Criteria
471
472- [ ] First criterion
473- [ ] Second criterion
474- [ ] Third criterion
475
476## Next Section
477"#
478            .to_string(),
479        };
480        let (checked, total) = count_criteria(&spec);
481        assert_eq!(checked, 0);
482        assert_eq!(total, 3);
483    }
484
485    #[test]
486    fn test_count_criteria_partially_checked() {
487        let spec = Spec {
488            id: "001".to_string(),
489            frontmatter: crate::spec::SpecFrontmatter::default(),
490            title: None,
491            body: r#"## Acceptance Criteria
492
493- [x] First criterion
494- [ ] Second criterion
495- [x] Third criterion
496"#
497            .to_string(),
498        };
499        let (checked, total) = count_criteria(&spec);
500        assert_eq!(checked, 2);
501        assert_eq!(total, 3);
502    }
503
504    #[test]
505    fn test_count_criteria_all_checked() {
506        let spec = Spec {
507            id: "001".to_string(),
508            frontmatter: crate::spec::SpecFrontmatter::default(),
509            title: None,
510            body: r#"## Acceptance Criteria
511
512- [x] First criterion
513- [x] Second criterion
514- [X] Third criterion (uppercase X)
515"#
516            .to_string(),
517        };
518        let (checked, total) = count_criteria(&spec);
519        assert_eq!(checked, 3);
520        assert_eq!(total, 3);
521    }
522
523    #[test]
524    fn test_count_criteria_ignores_code_fence() {
525        let spec = Spec {
526            id: "001".to_string(),
527            frontmatter: crate::spec::SpecFrontmatter::default(),
528            title: None,
529            body: r#"## Acceptance Criteria
530
531- [x] Real criterion
532
533```markdown
534- [ ] This should be ignored (in code fence)
535- [x] This too
536```
537
538- [ ] Another real criterion
539"#
540            .to_string(),
541        };
542        let (checked, total) = count_criteria(&spec);
543        assert_eq!(checked, 1);
544        assert_eq!(total, 2);
545    }
546
547    #[test]
548    fn test_count_criteria_stops_at_next_section() {
549        let spec = Spec {
550            id: "001".to_string(),
551            frontmatter: crate::spec::SpecFrontmatter::default(),
552            title: None,
553            body: r#"## Acceptance Criteria
554
555- [x] First criterion
556- [ ] Second criterion
557
558## Implementation Notes
559
560- [ ] This should not be counted (different section)
561"#
562            .to_string(),
563        };
564        let (checked, total) = count_criteria(&spec);
565        assert_eq!(checked, 1);
566        assert_eq!(total, 2);
567    }
568
569    #[test]
570    fn test_load_main_branch_default() {
571        let config = make_config(DefaultsConfig::default());
572        let branch = load_main_branch(&config);
573        assert_eq!(branch, "main");
574    }
575
576    #[test]
577    fn test_load_main_branch_custom() {
578        let config = make_config(DefaultsConfig {
579            main_branch: "master".to_string(),
580            ..Default::default()
581        });
582        let branch = load_main_branch(&config);
583        assert_eq!(branch, "master");
584    }
585
586    #[test]
587    fn test_get_specs_to_merge_all() {
588        let specs = vec![
589            make_spec("spec1", SpecStatus::Completed),
590            make_spec("spec2", SpecStatus::Pending),
591            make_spec("spec3", SpecStatus::Completed),
592        ];
593
594        let result = get_specs_to_merge(&[], true, &specs).unwrap();
595        assert_eq!(result.len(), 2);
596        assert_eq!(result[0].0, "spec1");
597        assert_eq!(result[1].0, "spec3");
598    }
599
600    #[test]
601    fn test_get_specs_to_merge_specific() {
602        let specs = vec![
603            make_spec("spec1", SpecStatus::Completed),
604            make_spec("spec2", SpecStatus::Completed),
605        ];
606
607        let result = get_specs_to_merge(&["spec1".to_string()], false, &specs).unwrap();
608        assert_eq!(result.len(), 1);
609        assert_eq!(result[0].0, "spec1");
610    }
611
612    #[test]
613    fn test_get_specs_to_merge_not_found() {
614        let specs = vec![make_spec("spec1", SpecStatus::Pending)];
615
616        let result = get_specs_to_merge(&["nonexistent".to_string()], false, &specs);
617        assert!(result.is_err());
618        assert!(result.unwrap_err().to_string().contains("Spec not found"));
619    }
620
621    #[test]
622    fn test_validate_spec_can_merge_completed() {
623        let spec = make_spec("spec1", SpecStatus::Completed);
624        let result = validate_spec_can_merge(&spec, true);
625        assert!(result.is_ok());
626    }
627
628    #[test]
629    fn test_validate_spec_can_merge_pending_fails() {
630        let spec = make_spec("spec1", SpecStatus::Pending);
631        let result = validate_spec_can_merge(&spec, true);
632        assert!(result.is_err());
633        let err_msg = result.unwrap_err().to_string();
634        assert!(err_msg.contains("Cannot merge spec spec1"));
635        assert!(err_msg.contains("Pending"));
636        assert!(err_msg.contains("Next Steps"));
637    }
638
639    #[test]
640    fn test_validate_spec_can_merge_in_progress_fails() {
641        let spec = make_spec("spec1", SpecStatus::InProgress);
642        let result = validate_spec_can_merge(&spec, true);
643        assert!(result.is_err());
644        let err_msg = result.unwrap_err().to_string();
645        assert!(err_msg.contains("Cannot merge spec spec1"));
646        assert!(err_msg.contains("InProgress"));
647        assert!(err_msg.contains("Next Steps"));
648    }
649
650    #[test]
651    fn test_validate_spec_can_merge_failed_fails() {
652        let spec = make_spec("spec1", SpecStatus::Failed);
653        let result = validate_spec_can_merge(&spec, true);
654        assert!(result.is_err());
655        let err_msg = result.unwrap_err().to_string();
656        assert!(err_msg.contains("Cannot merge spec spec1"));
657        assert!(err_msg.contains("Failed"));
658        assert!(err_msg.contains("Next Steps"));
659    }
660
661    #[test]
662    fn test_validate_spec_can_merge_no_branch() {
663        let spec = make_spec("spec1", SpecStatus::Completed);
664        let result = validate_spec_can_merge(&spec, false);
665        assert!(result.is_err());
666        assert!(result.unwrap_err().to_string().contains("No branch found"));
667    }
668
669    #[test]
670    fn test_collect_member_specs() {
671        let driver = Spec {
672            id: "driver".to_string(),
673            frontmatter: crate::spec::SpecFrontmatter::default(),
674            title: Some("Driver".to_string()),
675            body: "Driver".to_string(),
676        };
677
678        let member1 = Spec {
679            id: "driver.1".to_string(),
680            frontmatter: crate::spec::SpecFrontmatter::default(),
681            title: Some("Member 1".to_string()),
682            body: "Member 1".to_string(),
683        };
684
685        let member2 = Spec {
686            id: "driver.2".to_string(),
687            frontmatter: crate::spec::SpecFrontmatter::default(),
688            title: Some("Member 2".to_string()),
689            body: "Member 2".to_string(),
690        };
691
692        let member3 = Spec {
693            id: "driver.3".to_string(),
694            frontmatter: crate::spec::SpecFrontmatter::default(),
695            title: Some("Member 3".to_string()),
696            body: "Member 3".to_string(),
697        };
698
699        let all_specs = vec![
700            driver.clone(),
701            member3.clone(),
702            member1.clone(),
703            member2.clone(),
704        ];
705
706        let result = collect_member_specs(&driver, &all_specs);
707        assert_eq!(result.len(), 3);
708        assert_eq!(result[0].id, "driver.1");
709        assert_eq!(result[1].id, "driver.2");
710        assert_eq!(result[2].id, "driver.3");
711    }
712
713    #[test]
714    fn test_collect_member_specs_with_nested() {
715        let driver = Spec {
716            id: "driver".to_string(),
717            frontmatter: crate::spec::SpecFrontmatter::default(),
718            title: Some("Driver".to_string()),
719            body: "Driver".to_string(),
720        };
721
722        let member1 = Spec {
723            id: "driver.1".to_string(),
724            frontmatter: crate::spec::SpecFrontmatter::default(),
725            title: Some("Member 1".to_string()),
726            body: "Member 1".to_string(),
727        };
728
729        let member2 = Spec {
730            id: "driver.2".to_string(),
731            frontmatter: crate::spec::SpecFrontmatter::default(),
732            title: Some("Member 2".to_string()),
733            body: "Member 2".to_string(),
734        };
735
736        let all_specs = vec![driver.clone(), member1.clone(), member2.clone()];
737
738        let result = collect_member_specs(&driver, &all_specs);
739        assert_eq!(result.len(), 2);
740        assert_eq!(result[0].id, "driver.1");
741        assert_eq!(result[1].id, "driver.2");
742    }
743
744    #[test]
745    fn test_collect_member_specs_empty() {
746        let driver = make_spec("driver", SpecStatus::Pending);
747        let other = make_spec("other", SpecStatus::Pending);
748        let all_specs = vec![driver.clone(), other];
749        let result = collect_member_specs(&driver, &all_specs);
750        assert_eq!(result.len(), 0);
751    }
752
753    #[test]
754    fn test_is_driver_spec_with_members() {
755        let driver = make_spec("driver", SpecStatus::Pending);
756        let member1 = make_spec("driver.1", SpecStatus::Pending);
757        let all_specs = vec![driver.clone(), member1];
758        let result = is_driver_spec(&driver, &all_specs);
759        assert!(result);
760    }
761
762    #[test]
763    fn test_is_driver_spec_without_members() {
764        let driver = make_spec("driver", SpecStatus::Pending);
765        let other = make_spec("other", SpecStatus::Pending);
766        let all_specs = vec![driver.clone(), other];
767        let result = is_driver_spec(&driver, &all_specs);
768        assert!(!result);
769    }
770
771    #[test]
772    #[serial_test::serial]
773    fn test_merge_driver_spec_all_members_completed() {
774        let driver = Spec {
775            id: "driver".to_string(),
776            frontmatter: crate::spec::SpecFrontmatter {
777                status: SpecStatus::Completed,
778                ..Default::default()
779            },
780            title: Some("Driver".to_string()),
781            body: "Driver".to_string(),
782        };
783
784        let member1 = Spec {
785            id: "driver.1".to_string(),
786            frontmatter: crate::spec::SpecFrontmatter {
787                status: SpecStatus::Completed,
788                ..Default::default()
789            },
790            title: Some("Member 1".to_string()),
791            body: "Member 1".to_string(),
792        };
793
794        let member2 = Spec {
795            id: "driver.2".to_string(),
796            frontmatter: crate::spec::SpecFrontmatter {
797                status: SpecStatus::Completed,
798                ..Default::default()
799            },
800            title: Some("Member 2".to_string()),
801            body: "Member 2".to_string(),
802        };
803
804        let member3 = Spec {
805            id: "driver.3".to_string(),
806            frontmatter: crate::spec::SpecFrontmatter {
807                status: SpecStatus::Completed,
808                ..Default::default()
809            },
810            title: Some("Member 3".to_string()),
811            body: "Member 3".to_string(),
812        };
813
814        let all_specs = vec![driver.clone(), member1, member2, member3];
815
816        // In dry-run mode, this should succeed (no branch validation)
817        let result = merge_driver_spec(&driver, &all_specs, "spec-", "main", false, true);
818        assert!(result.is_ok());
819        let results = result.unwrap();
820        // Should have 4 results: 3 members + 1 driver
821        assert_eq!(results.len(), 4);
822        // All should be in dry-run mode
823        assert!(results.iter().all(|r| r.dry_run));
824    }
825
826    #[test]
827    fn test_merge_driver_spec_member_pending() {
828        let driver = Spec {
829            id: "driver".to_string(),
830            frontmatter: crate::spec::SpecFrontmatter {
831                status: SpecStatus::Completed,
832                ..Default::default()
833            },
834            title: Some("Driver".to_string()),
835            body: "Driver".to_string(),
836        };
837
838        let member1 = Spec {
839            id: "driver.1".to_string(),
840            frontmatter: crate::spec::SpecFrontmatter {
841                status: SpecStatus::Completed,
842                ..Default::default()
843            },
844            title: Some("Member 1".to_string()),
845            body: "Member 1".to_string(),
846        };
847
848        let member2 = Spec {
849            id: "driver.2".to_string(),
850            frontmatter: crate::spec::SpecFrontmatter {
851                status: SpecStatus::Pending,
852                ..Default::default()
853            },
854            title: Some("Member 2".to_string()),
855            body: "Member 2".to_string(),
856        };
857
858        let all_specs = vec![driver.clone(), member1, member2];
859
860        let result = merge_driver_spec(&driver, &all_specs, "spec-", "main", false, true);
861        assert!(result.is_err());
862        let error = result.unwrap_err().to_string();
863        assert!(error.contains("Cannot merge driver spec"));
864        assert!(error.contains("driver.2"));
865        assert!(error.contains("incomplete"));
866    }
867
868    #[test]
869    #[serial_test::serial]
870    fn test_merge_driver_spec_multiple_members_in_order() {
871        let driver = Spec {
872            id: "2026-01-24-01y-73b".to_string(),
873            frontmatter: crate::spec::SpecFrontmatter {
874                status: SpecStatus::Completed,
875                ..Default::default()
876            },
877            title: Some("Driver".to_string()),
878            body: "Driver".to_string(),
879        };
880
881        let member1 = Spec {
882            id: "2026-01-24-01y-73b.1".to_string(),
883            frontmatter: crate::spec::SpecFrontmatter {
884                status: SpecStatus::Completed,
885                ..Default::default()
886            },
887            title: Some("Member 1".to_string()),
888            body: "Member 1".to_string(),
889        };
890
891        let member2 = Spec {
892            id: "2026-01-24-01y-73b.2".to_string(),
893            frontmatter: crate::spec::SpecFrontmatter {
894                status: SpecStatus::Completed,
895                ..Default::default()
896            },
897            title: Some("Member 2".to_string()),
898            body: "Member 2".to_string(),
899        };
900
901        let member3 = Spec {
902            id: "2026-01-24-01y-73b.3".to_string(),
903            frontmatter: crate::spec::SpecFrontmatter {
904                status: SpecStatus::Completed,
905                ..Default::default()
906            },
907            title: Some("Member 3".to_string()),
908            body: "Member 3".to_string(),
909        };
910
911        let all_specs = vec![driver.clone(), member3, member1, member2];
912
913        let result = merge_driver_spec(&driver, &all_specs, "spec-", "main", false, true);
914        assert!(result.is_ok());
915        let results = result.unwrap();
916        // Should have 4 results in correct order: .1, .2, .3, driver
917        assert_eq!(results.len(), 4);
918        assert_eq!(results[0].spec_id, "2026-01-24-01y-73b.1");
919        assert_eq!(results[1].spec_id, "2026-01-24-01y-73b.2");
920        assert_eq!(results[2].spec_id, "2026-01-24-01y-73b.3");
921        assert_eq!(results[3].spec_id, "2026-01-24-01y-73b");
922    }
923
924    #[test]
925    #[serial_test::serial]
926    fn test_merge_driver_spec_dry_run_shows_all_merges() {
927        let driver = Spec {
928            id: "driver".to_string(),
929            frontmatter: crate::spec::SpecFrontmatter {
930                status: SpecStatus::Completed,
931                ..Default::default()
932            },
933            title: Some("Driver".to_string()),
934            body: "Driver".to_string(),
935        };
936
937        let member1 = Spec {
938            id: "driver.1".to_string(),
939            frontmatter: crate::spec::SpecFrontmatter {
940                status: SpecStatus::Completed,
941                ..Default::default()
942            },
943            title: Some("Member 1".to_string()),
944            body: "Member 1".to_string(),
945        };
946
947        let member2 = Spec {
948            id: "driver.2".to_string(),
949            frontmatter: crate::spec::SpecFrontmatter {
950                status: SpecStatus::Completed,
951                ..Default::default()
952            },
953            title: Some("Member 2".to_string()),
954            body: "Member 2".to_string(),
955        };
956
957        let all_specs = vec![driver.clone(), member1, member2];
958
959        let result = merge_driver_spec(&driver, &all_specs, "spec-", "main", false, true);
960        assert!(result.is_ok());
961        let results = result.unwrap();
962        // 3 merges: member1, member2, driver
963        assert_eq!(results.len(), 3);
964        // All should be in dry-run mode
965        assert!(results.iter().all(|r| r.dry_run));
966        // All should be marked as success in dry-run
967        assert!(results.iter().all(|r| r.success));
968    }
969}