Skip to main content

mana_core/
worktree.rs

1//! Git worktree detection and merge utilities.
2//!
3//! This module provides functions to detect if the current directory is within
4//! a git worktree, and to merge changes back to the main branch.
5
6use anyhow::{anyhow, Result};
7use std::path::PathBuf;
8use std::process::Command;
9
10/// Result of a merge operation.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum MergeResult {
13    /// Merge completed successfully
14    Success,
15    /// Merge had conflicts that need resolution
16    Conflict { files: Vec<String> },
17    /// Nothing to commit (no changes)
18    NothingToCommit,
19}
20
21/// Information about a git worktree.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct WorktreeInfo {
24    /// Path to the main worktree
25    pub main_path: PathBuf,
26    /// Current worktree path
27    pub worktree_path: PathBuf,
28    /// Branch name of current worktree
29    pub branch: String,
30}
31
32/// Parsed worktree entry from `git worktree list --porcelain` output.
33#[derive(Debug)]
34struct WorktreeEntry {
35    path: PathBuf,
36    branch: Option<String>,
37}
38
39/// Parse the output of `git worktree list --porcelain`.
40///
41/// Format:
42/// ```text
43/// worktree /path/to/worktree
44/// HEAD abc123
45/// branch refs/heads/main
46///
47/// worktree /path/to/another
48/// HEAD def456
49/// branch refs/heads/feature
50/// ```
51fn parse_worktree_list(output: &str) -> Vec<WorktreeEntry> {
52    let mut entries = Vec::new();
53    let mut current_path: Option<PathBuf> = None;
54    let mut current_branch: Option<String> = None;
55
56    for line in output.lines() {
57        if let Some(path) = line.strip_prefix("worktree ") {
58            // Save previous entry if exists
59            if let Some(path) = current_path.take() {
60                entries.push(WorktreeEntry {
61                    path,
62                    branch: current_branch.take(),
63                });
64            }
65            current_path = Some(PathBuf::from(path));
66            current_branch = None;
67        } else if let Some(branch_ref) = line.strip_prefix("branch ") {
68            // Extract branch name from refs/heads/...
69            current_branch = Some(
70                branch_ref
71                    .strip_prefix("refs/heads/")
72                    .unwrap_or(branch_ref)
73                    .to_string(),
74            );
75        }
76        // Ignore HEAD and other lines
77    }
78
79    // Don't forget the last entry
80    if let Some(path) = current_path {
81        entries.push(WorktreeEntry {
82            path,
83            branch: current_branch,
84        });
85    }
86
87    entries
88}
89
90/// Detect if the given directory is within a git worktree.
91///
92/// Uses the provided `cwd` path to determine which worktree (if any)
93/// the directory belongs to. This avoids relying on process-global
94/// `std::env::current_dir()` which is unsafe in multi-threaded tests.
95///
96/// Returns:
97/// - `Ok(None)` if not in a git repo or in the main worktree
98/// - `Ok(Some(WorktreeInfo))` if in a secondary worktree
99/// - `Err` if there's an error running git commands
100pub fn detect_worktree(cwd: &std::path::Path) -> Result<Option<WorktreeInfo>> {
101    // Run git worktree list --porcelain from the given directory
102    let output = Command::new("git")
103        .args(["worktree", "list", "--porcelain"])
104        .current_dir(cwd)
105        .output();
106
107    let output = match output {
108        Ok(o) => o,
109        Err(_) => return Ok(None), // git not available
110    };
111
112    if !output.status.success() {
113        // Not in a git repo or git error
114        return Ok(None);
115    }
116
117    let stdout = String::from_utf8_lossy(&output.stdout);
118    let entries = parse_worktree_list(&stdout);
119
120    if entries.is_empty() {
121        return Ok(None);
122    }
123
124    // First entry is always the main worktree
125    let main_entry = &entries[0];
126    let main_path = &main_entry.path;
127
128    // Find which worktree we're in by checking if cwd starts with any worktree path
129    // We need to find the most specific match (longest path)
130    let mut current_entry: Option<&WorktreeEntry> = None;
131    for entry in &entries {
132        if cwd.starts_with(&entry.path) {
133            match current_entry {
134                None => current_entry = Some(entry),
135                Some(prev) if entry.path.as_os_str().len() > prev.path.as_os_str().len() => {
136                    current_entry = Some(entry)
137                }
138                _ => {}
139            }
140        }
141    }
142
143    let current_entry = match current_entry {
144        Some(e) => e,
145        None => return Ok(None), // Not in any known worktree
146    };
147
148    // If we're in the main worktree, return None
149    if current_entry.path == *main_path {
150        return Ok(None);
151    }
152
153    // We're in a secondary worktree
154    Ok(Some(WorktreeInfo {
155        main_path: main_path.clone(),
156        worktree_path: current_entry.path.clone(),
157        branch: current_entry.branch.clone().unwrap_or_default(),
158    }))
159}
160
161/// Commit the specified paths in the worktree directory.
162///
163/// Uses a temporary index seeded from `HEAD`, stages only the requested paths
164/// into that temporary index, creates a commit from that tree, and moves `HEAD`
165/// to the new commit. This records additions/modifications/deletions for the
166/// target paths without including or disturbing unrelated pre-staged changes in
167/// the real index. Paths must be relative to the repository root.
168pub fn commit_worktree_paths(
169    cwd: &std::path::Path,
170    message: &str,
171    paths: &[String],
172) -> Result<bool> {
173    commit_worktree_paths_preserve_index(cwd, message, paths)
174}
175
176/// Commit the specified paths without including or disturbing the real index.
177pub fn commit_worktree_paths_preserve_index(
178    cwd: &std::path::Path,
179    message: &str,
180    paths: &[String],
181) -> Result<bool> {
182    if paths.is_empty() {
183        return Ok(false);
184    }
185
186    let repo_root = git_stdout(cwd, &["rev-parse", "--show-toplevel"])?;
187    let index_path = std::env::temp_dir().join(format!(
188        "mana-targeted-index-{}-{}",
189        std::process::id(),
190        unique_suffix()
191    ));
192
193    let result = commit_with_temp_index(cwd, PathBuf::from(repo_root), &index_path, message, paths);
194    cleanup_temp_index(&index_path);
195    result
196}
197
198fn commit_with_temp_index(
199    cwd: &std::path::Path,
200    repo_root: PathBuf,
201    index_path: &std::path::Path,
202    message: &str,
203    paths: &[String],
204) -> Result<bool> {
205    let index = index_path.to_string_lossy().to_string();
206    git_status(
207        cwd,
208        &["read-tree", "HEAD"],
209        Some((&index, repo_root.as_path())),
210        "git read-tree failed",
211    )?;
212
213    let add_output = git_command_with_env(cwd, Some((&index, repo_root.as_path())))
214        .arg("add")
215        .arg("-A")
216        .arg("--")
217        .args(paths)
218        .output()?;
219    if !add_output.status.success() {
220        return Err(anyhow!(
221            "git add failed: {}",
222            String::from_utf8_lossy(&add_output.stderr)
223        ));
224    }
225
226    let tree = git_stdout_with_env(
227        cwd,
228        &["write-tree"],
229        Some((&index, repo_root.as_path())),
230        "git write-tree failed",
231    )?;
232    let head_tree = git_stdout(cwd, &["rev-parse", "HEAD^{tree}"])?;
233    if tree == head_tree {
234        return Ok(false);
235    }
236
237    let commit_output = Command::new("git")
238        .arg("commit-tree")
239        .arg(&tree)
240        .arg("-p")
241        .arg("HEAD")
242        .arg("-m")
243        .arg(message)
244        .current_dir(cwd)
245        .output()?;
246    if !commit_output.status.success() {
247        return Err(anyhow!(
248            "git commit-tree failed: {}",
249            String::from_utf8_lossy(&commit_output.stderr)
250        ));
251    }
252    let new_head = String::from_utf8_lossy(&commit_output.stdout)
253        .trim()
254        .to_string();
255    if new_head.is_empty() {
256        return Err(anyhow!("git commit-tree produced an empty commit id"));
257    }
258
259    git_status(
260        cwd,
261        &[
262            "update-ref",
263            "-m",
264            &format!("commit: {message}"),
265            "HEAD",
266            &new_head,
267        ],
268        None,
269        "git update-ref failed",
270    )?;
271
272    Ok(true)
273}
274
275fn git_command_with_env(
276    cwd: &std::path::Path,
277    temp_index: Option<(&str, &std::path::Path)>,
278) -> Command {
279    let mut command = Command::new("git");
280    command.current_dir(cwd);
281    if let Some((index, work_tree)) = temp_index {
282        command
283            .env("GIT_INDEX_FILE", index)
284            .env("GIT_WORK_TREE", work_tree);
285    }
286    command
287}
288
289fn git_status(
290    cwd: &std::path::Path,
291    args: &[&str],
292    temp_index: Option<(&str, &std::path::Path)>,
293    context: &str,
294) -> Result<()> {
295    let output = git_command_with_env(cwd, temp_index).args(args).output()?;
296    if output.status.success() {
297        return Ok(());
298    }
299    Err(anyhow!(
300        "{}: {}",
301        context,
302        String::from_utf8_lossy(&output.stderr)
303    ))
304}
305
306fn git_stdout(cwd: &std::path::Path, args: &[&str]) -> Result<String> {
307    git_stdout_with_env(cwd, args, None, "git command failed")
308}
309
310fn git_stdout_with_env(
311    cwd: &std::path::Path,
312    args: &[&str],
313    temp_index: Option<(&str, &std::path::Path)>,
314    context: &str,
315) -> Result<String> {
316    let output = git_command_with_env(cwd, temp_index).args(args).output()?;
317    if !output.status.success() {
318        return Err(anyhow!(
319            "{}: {}",
320            context,
321            String::from_utf8_lossy(&output.stderr)
322        ));
323    }
324    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
325}
326
327fn cleanup_temp_index(path: &std::path::Path) {
328    let _ = std::fs::remove_file(path);
329    let _ = std::fs::remove_file(path.with_extension("lock"));
330}
331
332fn unique_suffix() -> u128 {
333    std::time::SystemTime::now()
334        .duration_since(std::time::UNIX_EPOCH)
335        .map(|duration| duration.as_nanos())
336        .unwrap_or(0)
337}
338
339/// Commit all changes in the specified worktree directory.
340///
341/// Runs `git add -A` followed by `git commit -m <message>` in the given directory.
342/// Uses an explicit `cwd` to avoid relying on the process-global current directory.
343///
344/// Returns:
345/// - `Ok(true)` if a commit was made
346/// - `Ok(false)` if there was nothing to commit
347/// - `Err` if git commands fail
348pub fn commit_worktree_changes(cwd: &std::path::Path, message: &str) -> Result<bool> {
349    // Stage all changes
350    let add_output = Command::new("git")
351        .args(["add", "-A"])
352        .current_dir(cwd)
353        .output()?;
354
355    if !add_output.status.success() {
356        return Err(anyhow!(
357            "git add failed: {}",
358            String::from_utf8_lossy(&add_output.stderr)
359        ));
360    }
361
362    commit_staged_changes(cwd, message)
363}
364
365fn commit_staged_changes(cwd: &std::path::Path, message: &str) -> Result<bool> {
366    // Commit changes
367    let commit_output = Command::new("git")
368        .args(["commit", "-m", message])
369        .current_dir(cwd)
370        .output()?;
371
372    if commit_output.status.success() {
373        return Ok(true);
374    }
375
376    // Check if it failed because there was nothing to commit
377    let stderr = String::from_utf8_lossy(&commit_output.stderr);
378    let stdout = String::from_utf8_lossy(&commit_output.stdout);
379    if stderr.contains("nothing to commit")
380        || stdout.contains("nothing to commit")
381        || stderr.contains("no changes added")
382        || stdout.contains("no changes added")
383    {
384        return Ok(false);
385    }
386
387    Err(anyhow!("git commit failed: {}", stderr))
388}
389
390/// Merge the worktree branch to main.
391///
392/// Performs a no-fast-forward merge from the worktree's branch to the main branch.
393/// If there are conflicts, aborts the merge and returns the conflicting files.
394///
395/// # Arguments
396/// * `info` - Information about the worktree
397/// * `unit_id` - Unit ID to include in the commit message
398///
399/// Returns:
400/// - `Ok(MergeResult::Success)` if merge completed
401/// - `Ok(MergeResult::Conflict { files })` if there were conflicts
402/// - `Ok(MergeResult::NothingToCommit)` if branch is already merged
403/// - `Err` if git commands fail unexpectedly
404pub fn merge_to_main(info: &WorktreeInfo, unit_id: &str) -> Result<MergeResult> {
405    let main_path = &info.main_path;
406    let branch = &info.branch;
407
408    if branch.is_empty() {
409        return Err(anyhow!("Worktree has no branch (detached HEAD?)"));
410    }
411
412    // Perform the merge from the main worktree
413    let merge_message = format!("Merge branch '{}' (unit {})", branch, unit_id);
414    let merge_output = Command::new("git")
415        .args(["-C", main_path.to_str().unwrap_or(".")])
416        .args(["merge", branch, "--no-ff", "-m", &merge_message])
417        .output()?;
418
419    if merge_output.status.success() {
420        return Ok(MergeResult::Success);
421    }
422
423    let stderr = String::from_utf8_lossy(&merge_output.stderr);
424    let stdout = String::from_utf8_lossy(&merge_output.stdout);
425
426    // Check if already up-to-date
427    if stdout.contains("Already up to date") || stderr.contains("Already up to date") {
428        return Ok(MergeResult::NothingToCommit);
429    }
430
431    // Check for conflicts
432    if stdout.contains("CONFLICT") || stderr.contains("CONFLICT") {
433        // Get list of conflicting files
434        let conflicts = parse_conflict_files(&stdout, &stderr);
435
436        // Abort the merge
437        let _ = Command::new("git")
438            .args(["-C", main_path.to_str().unwrap_or(".")])
439            .args(["merge", "--abort"])
440            .output();
441
442        return Ok(MergeResult::Conflict { files: conflicts });
443    }
444
445    Err(anyhow!("git merge failed: {}", stderr))
446}
447
448/// Parse conflicting files from merge output.
449fn parse_conflict_files(stdout: &str, stderr: &str) -> Vec<String> {
450    let combined = format!("{}\n{}", stdout, stderr);
451    let mut files = Vec::new();
452
453    for line in combined.lines() {
454        // Match lines like "CONFLICT (content): Merge conflict in <file>"
455        if let Some(idx) = line.find("Merge conflict in ") {
456            let file = line[idx + "Merge conflict in ".len()..].trim();
457            files.push(file.to_string());
458        }
459        // Match lines like "CONFLICT (add/add): Merge conflict in <file>"
460        // or "CONFLICT (modify/delete): <file> deleted in ..."
461        else if line.starts_with("CONFLICT") {
462            // Try to extract filename from various CONFLICT formats
463            if let Some(colon_idx) = line.find("):") {
464                let rest = &line[colon_idx + 2..].trim();
465                // Get first word which might be the filename
466                if let Some(word) = rest.split_whitespace().next() {
467                    if !word.is_empty() && word != "Merge" && !files.contains(&word.to_string()) {
468                        files.push(word.to_string());
469                    }
470                }
471            }
472        }
473    }
474
475    files
476}
477
478/// Clean up a worktree and its branch.
479///
480/// Removes the worktree directory and deletes the associated branch.
481///
482/// # Arguments
483/// * `info` - Information about the worktree to clean up
484pub fn cleanup_worktree(info: &WorktreeInfo) -> Result<()> {
485    let main_path = &info.main_path;
486    let worktree_path = &info.worktree_path;
487    let branch = &info.branch;
488
489    // Remove the worktree
490    let remove_output = Command::new("git")
491        .args(["-C", main_path.to_str().unwrap_or(".")])
492        .args(["worktree", "remove", worktree_path.to_str().unwrap_or(".")])
493        .output()?;
494
495    if !remove_output.status.success() {
496        // Try force remove if normal remove fails
497        let force_output = Command::new("git")
498            .args(["-C", main_path.to_str().unwrap_or(".")])
499            .args([
500                "worktree",
501                "remove",
502                "--force",
503                worktree_path.to_str().unwrap_or("."),
504            ])
505            .output()?;
506
507        if !force_output.status.success() {
508            return Err(anyhow!(
509                "Failed to remove worktree: {}",
510                String::from_utf8_lossy(&force_output.stderr)
511            ));
512        }
513    }
514
515    // Delete the branch (only if we have a branch name)
516    if !branch.is_empty() {
517        let delete_output = Command::new("git")
518            .args(["-C", main_path.to_str().unwrap_or(".")])
519            .args(["branch", "-d", branch])
520            .output()?;
521
522        if !delete_output.status.success() {
523            // Try force delete if normal delete fails (branch not fully merged)
524            let force_delete = Command::new("git")
525                .args(["-C", main_path.to_str().unwrap_or(".")])
526                .args(["branch", "-D", branch])
527                .output()?;
528
529            if !force_delete.status.success() {
530                return Err(anyhow!(
531                    "Failed to delete branch '{}': {}",
532                    branch,
533                    String::from_utf8_lossy(&force_delete.stderr)
534                ));
535            }
536        }
537    }
538
539    Ok(())
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545
546    #[test]
547    fn test_parse_worktree_list_single() {
548        let output = "worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n";
549        let entries = parse_worktree_list(output);
550
551        assert_eq!(entries.len(), 1);
552        assert_eq!(entries[0].path, PathBuf::from("/home/user/project"));
553        assert_eq!(entries[0].branch, Some("main".to_string()));
554    }
555
556    #[test]
557    fn test_parse_worktree_list_multiple() {
558        let output = r#"worktree /home/user/project
559HEAD abc123
560branch refs/heads/main
561
562worktree /home/user/project-feature
563HEAD def456
564branch refs/heads/feature-x
565"#;
566        let entries = parse_worktree_list(output);
567
568        assert_eq!(entries.len(), 2);
569        assert_eq!(entries[0].path, PathBuf::from("/home/user/project"));
570        assert_eq!(entries[0].branch, Some("main".to_string()));
571        assert_eq!(entries[1].path, PathBuf::from("/home/user/project-feature"));
572        assert_eq!(entries[1].branch, Some("feature-x".to_string()));
573    }
574
575    #[test]
576    fn test_parse_worktree_list_detached_head() {
577        let output = "worktree /home/user/project\nHEAD abc123\ndetached\n";
578        let entries = parse_worktree_list(output);
579
580        assert_eq!(entries.len(), 1);
581        assert_eq!(entries[0].path, PathBuf::from("/home/user/project"));
582        assert_eq!(entries[0].branch, None);
583    }
584
585    #[test]
586    fn detect_worktree_runs_without_panic() {
587        // This test just ensures the function doesn't panic
588        // The actual result depends on the environment
589        let cwd = std::env::current_dir().unwrap();
590        let result = detect_worktree(&cwd);
591        assert!(result.is_ok());
592    }
593
594    // Merge-related tests
595    mod merge {
596        use super::*;
597
598        #[test]
599        fn test_merge_result_variants() {
600            // Test that MergeResult variants can be constructed and compared
601            let success = MergeResult::Success;
602            let conflict = MergeResult::Conflict {
603                files: vec!["file1.txt".to_string(), "file2.txt".to_string()],
604            };
605            let nothing = MergeResult::NothingToCommit;
606
607            assert_eq!(success, MergeResult::Success);
608            assert_eq!(nothing, MergeResult::NothingToCommit);
609
610            if let MergeResult::Conflict { files } = conflict {
611                assert_eq!(files.len(), 2);
612                assert!(files.contains(&"file1.txt".to_string()));
613            } else {
614                unreachable!("Expected Conflict variant");
615            }
616        }
617
618        #[test]
619        fn test_parse_conflict_files_content_conflict() {
620            let stdout =
621                "Auto-merging src/lib.rs\nCONFLICT (content): Merge conflict in src/lib.rs\n";
622            let stderr = "";
623            let files = parse_conflict_files(stdout, stderr);
624            assert_eq!(files, vec!["src/lib.rs"]);
625        }
626
627        #[test]
628        fn test_parse_conflict_files_multiple() {
629            let stdout = r#"Auto-merging file1.txt
630CONFLICT (content): Merge conflict in file1.txt
631Auto-merging file2.txt
632CONFLICT (content): Merge conflict in file2.txt
633"#;
634            let files = parse_conflict_files(stdout, "");
635            assert_eq!(files.len(), 2);
636            assert!(files.contains(&"file1.txt".to_string()));
637            assert!(files.contains(&"file2.txt".to_string()));
638        }
639
640        #[test]
641        fn test_parse_conflict_files_empty() {
642            let files = parse_conflict_files("", "");
643            assert!(files.is_empty());
644        }
645
646        #[test]
647        fn test_parse_conflict_files_no_conflicts() {
648            let stdout = "Already up to date.\n";
649            let files = parse_conflict_files(stdout, "");
650            assert!(files.is_empty());
651        }
652
653        #[test]
654        fn test_worktree_info_for_merge() {
655            // Test that WorktreeInfo can be used with merge functions
656            let info = WorktreeInfo {
657                main_path: PathBuf::from("/home/user/project"),
658                worktree_path: PathBuf::from("/home/user/project-feature"),
659                branch: "feature-branch".to_string(),
660            };
661
662            assert_eq!(info.branch, "feature-branch");
663            assert_eq!(info.main_path, PathBuf::from("/home/user/project"));
664            assert_eq!(
665                info.worktree_path,
666                PathBuf::from("/home/user/project-feature")
667            );
668        }
669
670        #[test]
671        fn test_merge_to_main_requires_branch() {
672            // Test that merge_to_main fails with empty branch
673            let info = WorktreeInfo {
674                main_path: PathBuf::from("/tmp/nonexistent"),
675                worktree_path: PathBuf::from("/tmp/nonexistent-wt"),
676                branch: String::new(), // Empty branch
677            };
678
679            let result = merge_to_main(&info, "test-unit");
680            assert!(result.is_err());
681            let err = result.unwrap_err();
682            assert!(err.to_string().contains("no branch"));
683        }
684
685        #[test]
686        fn test_commit_worktree_changes_type_signature() {
687            // This test verifies the function signature and return type
688            // by calling it - it will likely fail (not in git repo) but
689            // shouldn't panic
690            let cwd = std::env::current_dir().unwrap();
691            let result = commit_worktree_changes(&cwd, "test message");
692            // Result should be Ok or Err, not panic
693            let _ = result;
694        }
695
696        #[test]
697        fn test_cleanup_worktree_type_signature() {
698            // This test verifies the function signature works correctly
699            let info = WorktreeInfo {
700                main_path: PathBuf::from("/tmp/nonexistent-main"),
701                worktree_path: PathBuf::from("/tmp/nonexistent-wt"),
702                branch: "test-branch".to_string(),
703            };
704
705            // This will fail because the paths don't exist, but shouldn't panic
706            let result = cleanup_worktree(&info);
707            assert!(result.is_err()); // Expected to fail with nonexistent paths
708        }
709    }
710}