Skip to main content

chant/
git.rs

1//! Git operations for branch management and merging.
2//!
3//! # Doc Audit
4//! - audited: 2026-01-25
5//! - docs: reference/git.md
6//! - ignore: false
7
8use anyhow::{Context, Result};
9
10// Re-export low-level git operations for backward compatibility
11pub 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
20/// Ensure the main repo is on the main branch.
21///
22/// Call this at command boundaries to prevent branch drift.
23/// Uses config's main_branch setting (defaults to "main").
24///
25/// Warns but does not fail if checkout fails (e.g., dirty worktree).
26pub 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            // Don't fail hard - just warn
38            eprintln!("Warning: Could not return to {}: {}", main_branch, stderr);
39        }
40    }
41
42    Ok(())
43}
44
45/// Merge a single spec's branch into the main branch.
46///
47/// This function:
48/// 1. Saves the current branch
49/// 2. Checks if main branch exists
50/// 3. Checks out main branch
51/// 4. Merges spec branch with fast-forward only
52/// 5. Optionally deletes spec branch if requested
53/// 6. Returns to original branch
54///
55/// In dry-run mode, no actual git commands are executed.
56pub 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    // In dry_run mode, try to get current branch but don't fail if we're not in a repo
64    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    // Save current branch
78    let original_branch = get_current_branch()?;
79
80    // Check if main branch exists
81    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    // Check if spec branch exists
89    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    // Checkout main branch
97    if let Err(e) = checkout_branch(main_branch, dry_run) {
98        // Try to return to original branch before failing
99        let _ = checkout_branch(&original_branch, false);
100        return Err(e);
101    }
102
103    // Perform merge
104    let merge_result = match crate::git_ops::merge_branch_ff_only(spec_branch, dry_run) {
105        Ok(result) => result,
106        Err(e) => {
107            // Try to return to original branch before failing
108            let _ = checkout_branch(&original_branch, false);
109            return Err(e);
110        }
111    };
112
113    if !merge_result.success && !dry_run {
114        // Merge had conflicts - return to original branch
115        let _ = checkout_branch(&original_branch, false);
116
117        // Use detailed error message with conflict type and file list
118        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    // Delete branch if requested and merge was successful
135    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            // Log warning but don't fail overall
140            branch_delete_warning = Some(format!("Warning: Failed to delete branch: {}", e));
141        } else {
142            branch_actually_deleted = true;
143        }
144    }
145
146    // Clean up worktree after successful merge
147    if merge_success && !dry_run {
148        use crate::worktree::git_ops::{get_active_worktree, remove_worktree};
149
150        // Load config to get project name
151        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                    // Log warning but don't fail the merge
156                    eprintln!(
157                        "Warning: Failed to clean up worktree at {:?}: {}",
158                        worktree_path, e
159                    );
160                }
161            }
162        }
163    }
164
165    // Return to original branch, BUT not if:
166    // 1. We're already on main (no need to switch)
167    // 2. The original branch was the spec branch that we just deleted
168    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            // If we can't checkout the original branch, stay on main
174            // This can happen if the original branch was deleted elsewhere
175            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/// Result of a merge operation.
194#[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
205/// Format the merge result as a human-readable summary.
206pub 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    // Helper function to initialize a mock git repo for testing
245    fn setup_test_repo() -> Result<TempDir> {
246        let temp_dir = TempDir::new()?;
247        let repo_path = temp_dir.path();
248
249        // Initialize git repo
250        Command::new("git")
251            .arg("init")
252            .current_dir(repo_path)
253            .output()?;
254
255        // Configure git
256        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        // Create initial commit
267        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        // Create and checkout main branch
280        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        // Create a spec branch
303        Command::new("git")
304            .args(["checkout", "-b", "spec-001"])
305            .output()?;
306
307        // Make a change on spec branch
308        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        // Go back to main
318        Command::new("git").args(["checkout", "main"]).output()?;
319
320        // Test merge with dry-run
321        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        // Verify we're still on main
330        let current = get_current_branch()?;
331        assert_eq!(current, "main");
332
333        // Verify spec branch still exists (because of dry-run)
334        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        // Create a spec branch
350        Command::new("git")
351            .args(["checkout", "-b", "spec-002"])
352            .output()?;
353
354        // Make a change on spec branch
355        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        // Go back to main
365        Command::new("git").args(["checkout", "main"]).output()?;
366
367        // Test merge with delete
368        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        // Verify branch was deleted
375        assert!(!branch_exists("spec-002")?);
376
377        // Verify we're back on main
378        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        // Create a spec branch
395        Command::new("git")
396            .args(["checkout", "-b", "spec-003"])
397            .output()?;
398
399        // Make a change on spec branch
400        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        // Test merge with nonexistent main branch
410        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        // Verify we're still on spec-003
416        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        // Test merge with nonexistent spec branch
433        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        // Verify we're still on main
439        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        // Create a spec branch from main
540        Command::new("git")
541            .args(["checkout", "-b", "spec-diverged"])
542            .output()?;
543
544        // Make a change on spec branch
545        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        // Go back to main and make a different change
555        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        // Merge with diverged branches - should use --no-ff automatically
566        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        // Verify we're back on main
573        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        // Create a spec branch
590        Command::new("git")
591            .args(["checkout", "-b", "spec-test"])
592            .output()?;
593
594        // Verify we're on spec-test
595        let current = get_current_branch()?;
596        assert_eq!(current, "spec-test");
597
598        // Call ensure_on_main_branch - should switch back to main
599        ensure_on_main_branch("main")?;
600
601        // Verify we're back on main
602        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        // Verify we're on main
619        let current = get_current_branch()?;
620        assert_eq!(current, "main");
621
622        // Call ensure_on_main_branch - should be a no-op
623        ensure_on_main_branch("main")?;
624
625        // Verify we're still on main
626        let current = get_current_branch()?;
627        assert_eq!(current, "main");
628
629        std::env::set_current_dir(original_dir)?;
630        Ok(())
631    }
632}