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_load_main_branch_default() {
453        let config = make_config(DefaultsConfig::default());
454        let branch = load_main_branch(&config);
455        assert_eq!(branch, "main");
456    }
457
458    #[test]
459    fn test_load_main_branch_custom() {
460        let config = make_config(DefaultsConfig {
461            main_branch: "master".to_string(),
462            ..Default::default()
463        });
464        let branch = load_main_branch(&config);
465        assert_eq!(branch, "master");
466    }
467
468    #[test]
469    fn test_get_specs_to_merge_all() {
470        let specs = vec![
471            make_spec("spec1", SpecStatus::Completed),
472            make_spec("spec2", SpecStatus::Pending),
473            make_spec("spec3", SpecStatus::Completed),
474        ];
475
476        let result = get_specs_to_merge(&[], true, &specs).unwrap();
477        assert_eq!(result.len(), 2);
478        assert_eq!(result[0].0, "spec1");
479        assert_eq!(result[1].0, "spec3");
480    }
481
482    #[test]
483    fn test_get_specs_to_merge_specific() {
484        let specs = vec![
485            make_spec("spec1", SpecStatus::Completed),
486            make_spec("spec2", SpecStatus::Completed),
487        ];
488
489        let result = get_specs_to_merge(&["spec1".to_string()], false, &specs).unwrap();
490        assert_eq!(result.len(), 1);
491        assert_eq!(result[0].0, "spec1");
492    }
493
494    #[test]
495    fn test_get_specs_to_merge_not_found() {
496        let specs = vec![make_spec("spec1", SpecStatus::Pending)];
497
498        let result = get_specs_to_merge(&["nonexistent".to_string()], false, &specs);
499        assert!(result.is_err());
500        assert!(result.unwrap_err().to_string().contains("Spec not found"));
501    }
502
503    #[test]
504    fn test_validate_spec_can_merge_completed() {
505        let spec = make_spec("spec1", SpecStatus::Completed);
506        let result = validate_spec_can_merge(&spec, true);
507        assert!(result.is_ok());
508    }
509
510    #[test]
511    fn test_validate_spec_can_merge_pending_fails() {
512        let spec = make_spec("spec1", SpecStatus::Pending);
513        let result = validate_spec_can_merge(&spec, true);
514        assert!(result.is_err());
515        let err_msg = result.unwrap_err().to_string();
516        assert!(err_msg.contains("Cannot merge spec spec1"));
517        assert!(err_msg.contains("Pending"));
518        assert!(err_msg.contains("Next Steps"));
519    }
520
521    #[test]
522    fn test_validate_spec_can_merge_in_progress_fails() {
523        let spec = make_spec("spec1", SpecStatus::InProgress);
524        let result = validate_spec_can_merge(&spec, true);
525        assert!(result.is_err());
526        let err_msg = result.unwrap_err().to_string();
527        assert!(err_msg.contains("Cannot merge spec spec1"));
528        assert!(err_msg.contains("InProgress"));
529        assert!(err_msg.contains("Next Steps"));
530    }
531
532    #[test]
533    fn test_validate_spec_can_merge_failed_fails() {
534        let spec = make_spec("spec1", SpecStatus::Failed);
535        let result = validate_spec_can_merge(&spec, true);
536        assert!(result.is_err());
537        let err_msg = result.unwrap_err().to_string();
538        assert!(err_msg.contains("Cannot merge spec spec1"));
539        assert!(err_msg.contains("Failed"));
540        assert!(err_msg.contains("Next Steps"));
541    }
542
543    #[test]
544    fn test_validate_spec_can_merge_no_branch() {
545        let spec = make_spec("spec1", SpecStatus::Completed);
546        let result = validate_spec_can_merge(&spec, false);
547        assert!(result.is_err());
548        assert!(result.unwrap_err().to_string().contains("No branch found"));
549    }
550
551    #[test]
552    fn test_collect_member_specs() {
553        let driver = Spec {
554            id: "driver".to_string(),
555            frontmatter: crate::spec::SpecFrontmatter::default(),
556            title: Some("Driver".to_string()),
557            body: "Driver".to_string(),
558        };
559
560        let member1 = Spec {
561            id: "driver.1".to_string(),
562            frontmatter: crate::spec::SpecFrontmatter::default(),
563            title: Some("Member 1".to_string()),
564            body: "Member 1".to_string(),
565        };
566
567        let member2 = Spec {
568            id: "driver.2".to_string(),
569            frontmatter: crate::spec::SpecFrontmatter::default(),
570            title: Some("Member 2".to_string()),
571            body: "Member 2".to_string(),
572        };
573
574        let member3 = Spec {
575            id: "driver.3".to_string(),
576            frontmatter: crate::spec::SpecFrontmatter::default(),
577            title: Some("Member 3".to_string()),
578            body: "Member 3".to_string(),
579        };
580
581        let all_specs = vec![
582            driver.clone(),
583            member3.clone(),
584            member1.clone(),
585            member2.clone(),
586        ];
587
588        let result = collect_member_specs(&driver, &all_specs);
589        assert_eq!(result.len(), 3);
590        assert_eq!(result[0].id, "driver.1");
591        assert_eq!(result[1].id, "driver.2");
592        assert_eq!(result[2].id, "driver.3");
593    }
594
595    #[test]
596    fn test_collect_member_specs_with_nested() {
597        let driver = Spec {
598            id: "driver".to_string(),
599            frontmatter: crate::spec::SpecFrontmatter::default(),
600            title: Some("Driver".to_string()),
601            body: "Driver".to_string(),
602        };
603
604        let member1 = Spec {
605            id: "driver.1".to_string(),
606            frontmatter: crate::spec::SpecFrontmatter::default(),
607            title: Some("Member 1".to_string()),
608            body: "Member 1".to_string(),
609        };
610
611        let member2 = Spec {
612            id: "driver.2".to_string(),
613            frontmatter: crate::spec::SpecFrontmatter::default(),
614            title: Some("Member 2".to_string()),
615            body: "Member 2".to_string(),
616        };
617
618        let all_specs = vec![driver.clone(), member1.clone(), member2.clone()];
619
620        let result = collect_member_specs(&driver, &all_specs);
621        assert_eq!(result.len(), 2);
622        assert_eq!(result[0].id, "driver.1");
623        assert_eq!(result[1].id, "driver.2");
624    }
625
626    #[test]
627    fn test_collect_member_specs_empty() {
628        let driver = make_spec("driver", SpecStatus::Pending);
629        let other = make_spec("other", SpecStatus::Pending);
630        let all_specs = vec![driver.clone(), other];
631        let result = collect_member_specs(&driver, &all_specs);
632        assert_eq!(result.len(), 0);
633    }
634
635    #[test]
636    fn test_is_driver_spec_with_members() {
637        let driver = make_spec("driver", SpecStatus::Pending);
638        let member1 = make_spec("driver.1", SpecStatus::Pending);
639        let all_specs = vec![driver.clone(), member1];
640        let result = is_driver_spec(&driver, &all_specs);
641        assert!(result);
642    }
643
644    #[test]
645    fn test_is_driver_spec_without_members() {
646        let driver = make_spec("driver", SpecStatus::Pending);
647        let other = make_spec("other", SpecStatus::Pending);
648        let all_specs = vec![driver.clone(), other];
649        let result = is_driver_spec(&driver, &all_specs);
650        assert!(!result);
651    }
652
653    #[test]
654    #[serial_test::serial]
655    fn test_merge_driver_spec_all_members_completed() {
656        let driver = Spec {
657            id: "driver".to_string(),
658            frontmatter: crate::spec::SpecFrontmatter {
659                status: SpecStatus::Completed,
660                ..Default::default()
661            },
662            title: Some("Driver".to_string()),
663            body: "Driver".to_string(),
664        };
665
666        let member1 = Spec {
667            id: "driver.1".to_string(),
668            frontmatter: crate::spec::SpecFrontmatter {
669                status: SpecStatus::Completed,
670                ..Default::default()
671            },
672            title: Some("Member 1".to_string()),
673            body: "Member 1".to_string(),
674        };
675
676        let member2 = Spec {
677            id: "driver.2".to_string(),
678            frontmatter: crate::spec::SpecFrontmatter {
679                status: SpecStatus::Completed,
680                ..Default::default()
681            },
682            title: Some("Member 2".to_string()),
683            body: "Member 2".to_string(),
684        };
685
686        let member3 = Spec {
687            id: "driver.3".to_string(),
688            frontmatter: crate::spec::SpecFrontmatter {
689                status: SpecStatus::Completed,
690                ..Default::default()
691            },
692            title: Some("Member 3".to_string()),
693            body: "Member 3".to_string(),
694        };
695
696        let all_specs = vec![driver.clone(), member1, member2, member3];
697
698        // In dry-run mode, this should succeed (no branch validation)
699        let result = merge_driver_spec(&driver, &all_specs, "spec-", "main", false, true);
700        assert!(result.is_ok());
701        let results = result.unwrap();
702        // Should have 4 results: 3 members + 1 driver
703        assert_eq!(results.len(), 4);
704        // All should be in dry-run mode
705        assert!(results.iter().all(|r| r.dry_run));
706    }
707
708    #[test]
709    fn test_merge_driver_spec_member_pending() {
710        let driver = Spec {
711            id: "driver".to_string(),
712            frontmatter: crate::spec::SpecFrontmatter {
713                status: SpecStatus::Completed,
714                ..Default::default()
715            },
716            title: Some("Driver".to_string()),
717            body: "Driver".to_string(),
718        };
719
720        let member1 = Spec {
721            id: "driver.1".to_string(),
722            frontmatter: crate::spec::SpecFrontmatter {
723                status: SpecStatus::Completed,
724                ..Default::default()
725            },
726            title: Some("Member 1".to_string()),
727            body: "Member 1".to_string(),
728        };
729
730        let member2 = Spec {
731            id: "driver.2".to_string(),
732            frontmatter: crate::spec::SpecFrontmatter {
733                status: SpecStatus::Pending,
734                ..Default::default()
735            },
736            title: Some("Member 2".to_string()),
737            body: "Member 2".to_string(),
738        };
739
740        let all_specs = vec![driver.clone(), member1, member2];
741
742        let result = merge_driver_spec(&driver, &all_specs, "spec-", "main", false, true);
743        assert!(result.is_err());
744        let error = result.unwrap_err().to_string();
745        assert!(error.contains("Cannot merge driver spec"));
746        assert!(error.contains("driver.2"));
747        assert!(error.contains("incomplete"));
748    }
749
750    #[test]
751    #[serial_test::serial]
752    fn test_merge_driver_spec_multiple_members_in_order() {
753        let driver = Spec {
754            id: "2026-01-24-01y-73b".to_string(),
755            frontmatter: crate::spec::SpecFrontmatter {
756                status: SpecStatus::Completed,
757                ..Default::default()
758            },
759            title: Some("Driver".to_string()),
760            body: "Driver".to_string(),
761        };
762
763        let member1 = Spec {
764            id: "2026-01-24-01y-73b.1".to_string(),
765            frontmatter: crate::spec::SpecFrontmatter {
766                status: SpecStatus::Completed,
767                ..Default::default()
768            },
769            title: Some("Member 1".to_string()),
770            body: "Member 1".to_string(),
771        };
772
773        let member2 = Spec {
774            id: "2026-01-24-01y-73b.2".to_string(),
775            frontmatter: crate::spec::SpecFrontmatter {
776                status: SpecStatus::Completed,
777                ..Default::default()
778            },
779            title: Some("Member 2".to_string()),
780            body: "Member 2".to_string(),
781        };
782
783        let member3 = Spec {
784            id: "2026-01-24-01y-73b.3".to_string(),
785            frontmatter: crate::spec::SpecFrontmatter {
786                status: SpecStatus::Completed,
787                ..Default::default()
788            },
789            title: Some("Member 3".to_string()),
790            body: "Member 3".to_string(),
791        };
792
793        let all_specs = vec![driver.clone(), member3, member1, member2];
794
795        let result = merge_driver_spec(&driver, &all_specs, "spec-", "main", false, true);
796        assert!(result.is_ok());
797        let results = result.unwrap();
798        // Should have 4 results in correct order: .1, .2, .3, driver
799        assert_eq!(results.len(), 4);
800        assert_eq!(results[0].spec_id, "2026-01-24-01y-73b.1");
801        assert_eq!(results[1].spec_id, "2026-01-24-01y-73b.2");
802        assert_eq!(results[2].spec_id, "2026-01-24-01y-73b.3");
803        assert_eq!(results[3].spec_id, "2026-01-24-01y-73b");
804    }
805
806    #[test]
807    #[serial_test::serial]
808    fn test_merge_driver_spec_dry_run_shows_all_merges() {
809        let driver = Spec {
810            id: "driver".to_string(),
811            frontmatter: crate::spec::SpecFrontmatter {
812                status: SpecStatus::Completed,
813                ..Default::default()
814            },
815            title: Some("Driver".to_string()),
816            body: "Driver".to_string(),
817        };
818
819        let member1 = Spec {
820            id: "driver.1".to_string(),
821            frontmatter: crate::spec::SpecFrontmatter {
822                status: SpecStatus::Completed,
823                ..Default::default()
824            },
825            title: Some("Member 1".to_string()),
826            body: "Member 1".to_string(),
827        };
828
829        let member2 = Spec {
830            id: "driver.2".to_string(),
831            frontmatter: crate::spec::SpecFrontmatter {
832                status: SpecStatus::Completed,
833                ..Default::default()
834            },
835            title: Some("Member 2".to_string()),
836            body: "Member 2".to_string(),
837        };
838
839        let all_specs = vec![driver.clone(), member1, member2];
840
841        let result = merge_driver_spec(&driver, &all_specs, "spec-", "main", false, true);
842        assert!(result.is_ok());
843        let results = result.unwrap();
844        // 3 merges: member1, member2, driver
845        assert_eq!(results.len(), 3);
846        // All should be in dry-run mode
847        assert!(results.iter().all(|r| r.dry_run));
848        // All should be marked as success in dry-run
849        assert!(results.iter().all(|r| r.success));
850    }
851}