1use anyhow::{Context, Result};
9
10pub use crate::git_ops::{
12 branch_exists, can_fast_forward_merge, checkout_branch, count_commits, delete_branch,
13 get_commit_changed_files, get_commit_files_with_status, get_commits_for_path,
14 get_commits_in_range, get_conflicting_files, get_current_branch, get_file_at_commit,
15 get_file_at_parent, get_git_config, get_git_user_info, get_recent_commits, is_branch_behind,
16 is_branch_merged, rebase_abort, rebase_branch, rebase_continue, stage_file, CommitInfo,
17 ConflictType, MergeAttemptResult, RebaseResult,
18};
19
20pub fn ensure_on_main_branch(main_branch: &str) -> Result<()> {
27 let current = get_current_branch()?;
28
29 if current != main_branch {
30 let output = std::process::Command::new("git")
31 .args(["checkout", main_branch])
32 .output()
33 .context("Failed to checkout main branch")?;
34
35 if !output.status.success() {
36 let stderr = String::from_utf8_lossy(&output.stderr);
37 eprintln!("Warning: Could not return to {}: {}", main_branch, stderr);
39 }
40 }
41
42 Ok(())
43}
44
45pub fn merge_single_spec(
57 spec_id: &str,
58 spec_branch: &str,
59 main_branch: &str,
60 should_delete_branch: bool,
61 dry_run: bool,
62) -> Result<MergeResult> {
63 if dry_run {
65 let original_branch = get_current_branch().unwrap_or_default();
66 return Ok(MergeResult {
67 spec_id: spec_id.to_string(),
68 success: true,
69 original_branch,
70 merged_to: main_branch.to_string(),
71 branch_deleted: should_delete_branch,
72 branch_delete_warning: None,
73 dry_run: true,
74 });
75 }
76
77 let original_branch = get_current_branch()?;
79
80 if !dry_run && !branch_exists(main_branch)? {
82 anyhow::bail!(
83 "{}",
84 crate::merge_errors::main_branch_not_found(main_branch)
85 );
86 }
87
88 if !dry_run && !branch_exists(spec_branch)? {
90 anyhow::bail!(
91 "{}",
92 crate::merge_errors::branch_not_found(spec_id, spec_branch)
93 );
94 }
95
96 if let Err(e) = checkout_branch(main_branch, dry_run) {
98 let _ = checkout_branch(&original_branch, false);
100 return Err(e);
101 }
102
103 let merge_result = match crate::git_ops::merge_branch_ff_only(spec_branch, dry_run) {
105 Ok(result) => result,
106 Err(e) => {
107 let _ = checkout_branch(&original_branch, false);
109 return Err(e);
110 }
111 };
112
113 if !merge_result.success && !dry_run {
114 let _ = checkout_branch(&original_branch, false);
116
117 let conflict_type = merge_result.conflict_type.unwrap_or(ConflictType::Unknown);
119
120 anyhow::bail!(
121 "{}",
122 crate::merge_errors::merge_conflict_detailed(
123 spec_id,
124 spec_branch,
125 main_branch,
126 conflict_type,
127 &merge_result.conflicting_files
128 )
129 );
130 }
131
132 let merge_success = merge_result.success;
133
134 let mut branch_delete_warning: Option<String> = None;
136 let mut branch_actually_deleted = false;
137 if should_delete_branch && merge_success {
138 if let Err(e) = delete_branch(spec_branch, dry_run) {
139 branch_delete_warning = Some(format!("Warning: Failed to delete branch: {}", e));
141 } else {
142 branch_actually_deleted = true;
143 }
144 }
145
146 if merge_success && !dry_run {
148 use crate::worktree::git_ops::{get_active_worktree, remove_worktree};
149
150 if let Ok(config) = crate::config::Config::load() {
152 let project_name = Some(config.project.name.as_str());
153 if let Some(worktree_path) = get_active_worktree(spec_id, project_name) {
154 if let Err(e) = remove_worktree(&worktree_path) {
155 eprintln!(
157 "Warning: Failed to clean up worktree at {:?}: {}",
158 worktree_path, e
159 );
160 }
161 }
162 }
163 }
164
165 let should_checkout_original = original_branch != main_branch
169 && !(branch_actually_deleted && original_branch == spec_branch);
170
171 if should_checkout_original {
172 if let Err(e) = checkout_branch(&original_branch, false) {
173 eprintln!(
176 "Warning: Could not return to original branch '{}': {}. Staying on {}.",
177 original_branch, e, main_branch
178 );
179 }
180 }
181
182 Ok(MergeResult {
183 spec_id: spec_id.to_string(),
184 success: merge_success,
185 original_branch,
186 merged_to: main_branch.to_string(),
187 branch_deleted: should_delete_branch && merge_success,
188 branch_delete_warning,
189 dry_run,
190 })
191}
192
193#[derive(Debug, Clone)]
195pub struct MergeResult {
196 pub spec_id: String,
197 pub success: bool,
198 pub original_branch: String,
199 pub merged_to: String,
200 pub branch_deleted: bool,
201 pub branch_delete_warning: Option<String>,
202 pub dry_run: bool,
203}
204
205pub fn format_merge_summary(result: &MergeResult) -> String {
207 let mut output = String::new();
208
209 if result.dry_run {
210 output.push_str("[DRY RUN] ");
211 }
212
213 if result.success {
214 output.push_str(&format!(
215 "✓ Successfully merged {} to {}",
216 result.spec_id, result.merged_to
217 ));
218 if result.branch_deleted {
219 output.push_str(&format!(" and deleted branch {}", result.spec_id));
220 }
221 } else {
222 output.push_str(&format!(
223 "✗ Failed to merge {} to {}",
224 result.spec_id, result.merged_to
225 ));
226 }
227
228 if let Some(warning) = &result.branch_delete_warning {
229 output.push_str(&format!("\n {}", warning));
230 }
231
232 output.push_str(&format!("\nReturned to branch: {}", result.original_branch));
233
234 output
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240 use std::fs;
241 use std::process::Command;
242 use tempfile::TempDir;
243
244 fn setup_test_repo() -> Result<TempDir> {
246 let temp_dir = TempDir::new()?;
247 let repo_path = temp_dir.path();
248
249 Command::new("git")
251 .arg("init")
252 .current_dir(repo_path)
253 .output()?;
254
255 Command::new("git")
257 .args(["config", "user.email", "test@example.com"])
258 .current_dir(repo_path)
259 .output()?;
260
261 Command::new("git")
262 .args(["config", "user.name", "Test User"])
263 .current_dir(repo_path)
264 .output()?;
265
266 let file_path = repo_path.join("test.txt");
268 fs::write(&file_path, "test content")?;
269 Command::new("git")
270 .args(["add", "test.txt"])
271 .current_dir(repo_path)
272 .output()?;
273
274 Command::new("git")
275 .args(["commit", "-m", "Initial commit"])
276 .current_dir(repo_path)
277 .output()?;
278
279 Command::new("git")
281 .args(["branch", "main"])
282 .current_dir(repo_path)
283 .output()?;
284
285 Command::new("git")
286 .args(["checkout", "main"])
287 .current_dir(repo_path)
288 .output()?;
289
290 Ok(temp_dir)
291 }
292
293 #[test]
294 #[serial_test::serial]
295 fn test_merge_single_spec_successful_dry_run() -> Result<()> {
296 let temp_dir = setup_test_repo()?;
297 let repo_path = temp_dir.path();
298 let original_dir = std::env::current_dir()?;
299
300 std::env::set_current_dir(repo_path)?;
301
302 Command::new("git")
304 .args(["checkout", "-b", "spec-001"])
305 .output()?;
306
307 let file_path = repo_path.join("spec-file.txt");
309 fs::write(&file_path, "spec content")?;
310 Command::new("git")
311 .args(["add", "spec-file.txt"])
312 .output()?;
313 Command::new("git")
314 .args(["commit", "-m", "Add spec-file"])
315 .output()?;
316
317 Command::new("git").args(["checkout", "main"]).output()?;
319
320 let result = merge_single_spec("spec-001", "spec-001", "main", false, true)?;
322
323 assert!(result.success);
324 assert!(result.dry_run);
325 assert_eq!(result.spec_id, "spec-001");
326 assert_eq!(result.merged_to, "main");
327 assert_eq!(result.original_branch, "main");
328
329 let current = get_current_branch()?;
331 assert_eq!(current, "main");
332
333 assert!(branch_exists("spec-001")?);
335
336 std::env::set_current_dir(original_dir)?;
337 Ok(())
338 }
339
340 #[test]
341 #[serial_test::serial]
342 fn test_merge_single_spec_successful_with_delete() -> Result<()> {
343 let temp_dir = setup_test_repo()?;
344 let repo_path = temp_dir.path();
345 let original_dir = std::env::current_dir()?;
346
347 std::env::set_current_dir(repo_path)?;
348
349 Command::new("git")
351 .args(["checkout", "-b", "spec-002"])
352 .output()?;
353
354 let file_path = repo_path.join("spec-file2.txt");
356 fs::write(&file_path, "spec content 2")?;
357 Command::new("git")
358 .args(["add", "spec-file2.txt"])
359 .output()?;
360 Command::new("git")
361 .args(["commit", "-m", "Add spec-file2"])
362 .output()?;
363
364 Command::new("git").args(["checkout", "main"]).output()?;
366
367 let result = merge_single_spec("spec-002", "spec-002", "main", true, false)?;
369
370 assert!(result.success);
371 assert!(!result.dry_run);
372 assert!(result.branch_deleted);
373
374 assert!(!branch_exists("spec-002")?);
376
377 let current = get_current_branch()?;
379 assert_eq!(current, "main");
380
381 std::env::set_current_dir(original_dir)?;
382 Ok(())
383 }
384
385 #[test]
386 #[serial_test::serial]
387 fn test_merge_single_spec_nonexistent_main_branch() -> Result<()> {
388 let temp_dir = setup_test_repo()?;
389 let repo_path = temp_dir.path();
390 let original_dir = std::env::current_dir()?;
391
392 std::env::set_current_dir(repo_path)?;
393
394 Command::new("git")
396 .args(["checkout", "-b", "spec-003"])
397 .output()?;
398
399 let file_path = repo_path.join("spec-file3.txt");
401 fs::write(&file_path, "spec content 3")?;
402 Command::new("git")
403 .args(["add", "spec-file3.txt"])
404 .output()?;
405 Command::new("git")
406 .args(["commit", "-m", "Add spec-file3"])
407 .output()?;
408
409 let result = merge_single_spec("spec-003", "spec-003", "nonexistent", false, false);
411
412 assert!(result.is_err());
413 assert!(result.unwrap_err().to_string().contains("does not exist"));
414
415 let current = get_current_branch()?;
417 assert_eq!(current, "spec-003");
418
419 std::env::set_current_dir(original_dir)?;
420 Ok(())
421 }
422
423 #[test]
424 #[serial_test::serial]
425 fn test_merge_single_spec_nonexistent_spec_branch() -> Result<()> {
426 let temp_dir = setup_test_repo()?;
427 let repo_path = temp_dir.path();
428 let original_dir = std::env::current_dir()?;
429
430 std::env::set_current_dir(repo_path)?;
431
432 let result = merge_single_spec("nonexistent", "nonexistent", "main", false, false);
434
435 assert!(result.is_err());
436 assert!(result.unwrap_err().to_string().contains("not found"));
437
438 let current = get_current_branch()?;
440 assert_eq!(current, "main");
441
442 std::env::set_current_dir(original_dir)?;
443 Ok(())
444 }
445
446 #[test]
447 fn test_format_merge_summary_success() {
448 let result = MergeResult {
449 spec_id: "spec-001".to_string(),
450 success: true,
451 original_branch: "main".to_string(),
452 merged_to: "main".to_string(),
453 branch_deleted: false,
454 branch_delete_warning: None,
455 dry_run: false,
456 };
457
458 let summary = format_merge_summary(&result);
459 assert!(summary.contains("✓"));
460 assert!(summary.contains("spec-001"));
461 assert!(summary.contains("Returned to branch: main"));
462 }
463
464 #[test]
465 fn test_format_merge_summary_with_delete() {
466 let result = MergeResult {
467 spec_id: "spec-002".to_string(),
468 success: true,
469 original_branch: "main".to_string(),
470 merged_to: "main".to_string(),
471 branch_deleted: true,
472 branch_delete_warning: None,
473 dry_run: false,
474 };
475
476 let summary = format_merge_summary(&result);
477 assert!(summary.contains("✓"));
478 assert!(summary.contains("deleted branch spec-002"));
479 }
480
481 #[test]
482 fn test_format_merge_summary_dry_run() {
483 let result = MergeResult {
484 spec_id: "spec-003".to_string(),
485 success: true,
486 original_branch: "main".to_string(),
487 merged_to: "main".to_string(),
488 branch_deleted: false,
489 branch_delete_warning: None,
490 dry_run: true,
491 };
492
493 let summary = format_merge_summary(&result);
494 assert!(summary.contains("[DRY RUN]"));
495 }
496
497 #[test]
498 fn test_format_merge_summary_with_warning() {
499 let result = MergeResult {
500 spec_id: "spec-004".to_string(),
501 success: true,
502 original_branch: "main".to_string(),
503 merged_to: "main".to_string(),
504 branch_deleted: false,
505 branch_delete_warning: Some("Warning: Could not delete branch".to_string()),
506 dry_run: false,
507 };
508
509 let summary = format_merge_summary(&result);
510 assert!(summary.contains("Warning"));
511 }
512
513 #[test]
514 fn test_format_merge_summary_failure() {
515 let result = MergeResult {
516 spec_id: "spec-005".to_string(),
517 success: false,
518 original_branch: "main".to_string(),
519 merged_to: "main".to_string(),
520 branch_deleted: false,
521 branch_delete_warning: None,
522 dry_run: false,
523 };
524
525 let summary = format_merge_summary(&result);
526 assert!(summary.contains("✗"));
527 assert!(summary.contains("Failed to merge"));
528 }
529
530 #[test]
531 #[serial_test::serial]
532 fn test_merge_single_spec_with_diverged_branches() -> Result<()> {
533 let temp_dir = setup_test_repo()?;
534 let repo_path = temp_dir.path();
535 let original_dir = std::env::current_dir()?;
536
537 std::env::set_current_dir(repo_path)?;
538
539 Command::new("git")
541 .args(["checkout", "-b", "spec-diverged"])
542 .output()?;
543
544 let file_path = repo_path.join("spec-change.txt");
546 fs::write(&file_path, "spec content")?;
547 Command::new("git")
548 .args(["add", "spec-change.txt"])
549 .output()?;
550 Command::new("git")
551 .args(["commit", "-m", "Add spec-change"])
552 .output()?;
553
554 Command::new("git").args(["checkout", "main"]).output()?;
556 let main_file = repo_path.join("main-change.txt");
557 fs::write(&main_file, "main content")?;
558 Command::new("git")
559 .args(["add", "main-change.txt"])
560 .output()?;
561 Command::new("git")
562 .args(["commit", "-m", "Add main-change"])
563 .output()?;
564
565 let result = merge_single_spec("spec-diverged", "spec-diverged", "main", false, false)?;
567
568 assert!(result.success, "Merge should succeed with --no-ff");
569 assert_eq!(result.spec_id, "spec-diverged");
570 assert_eq!(result.merged_to, "main");
571
572 let current = get_current_branch()?;
574 assert_eq!(current, "main");
575
576 std::env::set_current_dir(original_dir)?;
577 Ok(())
578 }
579
580 #[test]
581 #[serial_test::serial]
582 fn test_ensure_on_main_branch() -> Result<()> {
583 let temp_dir = setup_test_repo()?;
584 let repo_path = temp_dir.path();
585 let original_dir = std::env::current_dir()?;
586
587 std::env::set_current_dir(repo_path)?;
588
589 Command::new("git")
591 .args(["checkout", "-b", "spec-test"])
592 .output()?;
593
594 let current = get_current_branch()?;
596 assert_eq!(current, "spec-test");
597
598 ensure_on_main_branch("main")?;
600
601 let current = get_current_branch()?;
603 assert_eq!(current, "main");
604
605 std::env::set_current_dir(original_dir)?;
606 Ok(())
607 }
608
609 #[test]
610 #[serial_test::serial]
611 fn test_ensure_on_main_branch_already_on_main() -> Result<()> {
612 let temp_dir = setup_test_repo()?;
613 let repo_path = temp_dir.path();
614 let original_dir = std::env::current_dir()?;
615
616 std::env::set_current_dir(repo_path)?;
617
618 let current = get_current_branch()?;
620 assert_eq!(current, "main");
621
622 ensure_on_main_branch("main")?;
624
625 let current = get_current_branch()?;
627 assert_eq!(current, "main");
628
629 std::env::set_current_dir(original_dir)?;
630 Ok(())
631 }
632}