1use 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#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum BranchStatus {
17 Ready,
19 NeedsRebase,
21 HasConflicts,
23 Incomplete,
25 NoCommits,
27 NoBranch,
29}
30
31#[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
42pub fn load_main_branch(config: &Config) -> String {
44 config.defaults.main_branch.clone()
45}
46
47pub 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 if trimmed.starts_with("```") {
60 in_code_fence = !in_code_fence;
61 continue;
62 }
63
64 if in_code_fence {
66 continue;
67 }
68
69 if trimmed.starts_with("## Acceptance Criteria") {
71 in_criteria_section = true;
72 continue;
73 }
74
75 if in_criteria_section && trimmed.starts_with("## ") {
77 break;
78 }
79
80 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
94pub fn detect_branch_status(
96 spec: &Spec,
97 branch_name: &str,
98 main_branch: &str,
99) -> Result<BranchStatus> {
100 use crate::git;
101
102 let exists = git::branch_exists(branch_name)?;
104 if !exists {
105 return Ok(BranchStatus::NoBranch);
106 }
107
108 let commit_count = git::count_commits(branch_name).unwrap_or(0);
110 if commit_count == 0 {
111 return Ok(BranchStatus::NoCommits);
112 }
113
114 let (checked, total) = count_criteria(spec);
116 if total > 0 && checked < total {
117 return Ok(BranchStatus::Incomplete);
118 }
119
120 let can_ff = git::can_fast_forward_merge(branch_name, main_branch)?;
122 if can_ff {
123 return Ok(BranchStatus::Ready);
124 }
125
126 let is_behind = git::is_branch_behind(branch_name, main_branch)?;
128 if is_behind {
129 return Ok(BranchStatus::NeedsRebase);
131 }
132
133 Ok(BranchStatus::HasConflicts)
135}
136
137pub 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 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
174pub 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 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 for partial_id in args {
196 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 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 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
241pub fn validate_spec_can_merge(spec: &Spec, branch_exists: bool) -> Result<()> {
243 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 if !branch_exists {
257 anyhow::bail!("{}", crate::merge_errors::no_branch_for_spec(&spec.id));
258 }
259
260 Ok(())
261}
262
263pub 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
269fn 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 if let Some(seq_num) = extract_member_number(&spec.id) {
278 members.push((seq_num, spec.clone()));
279 }
280 }
281 }
282
283 members.sort_by_key(|m| m.0);
285
286 members.into_iter().map(|(_, spec)| spec).collect()
288}
289
290pub 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 let members = collect_member_specs(driver_spec, all_specs);
313
314 let mut incomplete_members = Vec::new();
316 for member in &members {
317 if member.frontmatter.status != SpecStatus::Completed {
319 incomplete_members.push(format!(
320 "{} (status: {:?})",
321 member.id, member.frontmatter.status
322 ));
323 }
324 }
325
326 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 !incomplete_members.is_empty() {
345 anyhow::bail!(
346 "{}",
347 crate::merge_errors::driver_members_incomplete(&driver_spec.id, &incomplete_members)
348 );
349 }
350
351 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 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 let result = merge_driver_spec(&driver, &all_specs, "spec-", "main", false, true);
700 assert!(result.is_ok());
701 let results = result.unwrap();
702 assert_eq!(results.len(), 4);
704 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 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 assert_eq!(results.len(), 3);
846 assert!(results.iter().all(|r| r.dry_run));
848 assert!(results.iter().all(|r| r.success));
850 }
851}