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 all changes in the specified worktree directory.
162///
163/// Runs `git add -A` followed by `git commit -m <message>` in the given directory.
164/// Uses an explicit `cwd` to avoid relying on the process-global current directory.
165///
166/// Returns:
167/// - `Ok(true)` if a commit was made
168/// - `Ok(false)` if there was nothing to commit
169/// - `Err` if git commands fail
170pub fn commit_worktree_changes(cwd: &std::path::Path, message: &str) -> Result<bool> {
171    // Stage all changes
172    let add_output = Command::new("git")
173        .args(["add", "-A"])
174        .current_dir(cwd)
175        .output()?;
176
177    if !add_output.status.success() {
178        return Err(anyhow!(
179            "git add failed: {}",
180            String::from_utf8_lossy(&add_output.stderr)
181        ));
182    }
183
184    // Commit changes
185    let commit_output = Command::new("git")
186        .args(["commit", "-m", message])
187        .current_dir(cwd)
188        .output()?;
189
190    if commit_output.status.success() {
191        return Ok(true);
192    }
193
194    // Check if it failed because there was nothing to commit
195    let stderr = String::from_utf8_lossy(&commit_output.stderr);
196    let stdout = String::from_utf8_lossy(&commit_output.stdout);
197    if stderr.contains("nothing to commit")
198        || stdout.contains("nothing to commit")
199        || stderr.contains("no changes added")
200        || stdout.contains("no changes added")
201    {
202        return Ok(false);
203    }
204
205    Err(anyhow!("git commit failed: {}", stderr))
206}
207
208/// Merge the worktree branch to main.
209///
210/// Performs a no-fast-forward merge from the worktree's branch to the main branch.
211/// If there are conflicts, aborts the merge and returns the conflicting files.
212///
213/// # Arguments
214/// * `info` - Information about the worktree
215/// * `unit_id` - Unit ID to include in the commit message
216///
217/// Returns:
218/// - `Ok(MergeResult::Success)` if merge completed
219/// - `Ok(MergeResult::Conflict { files })` if there were conflicts
220/// - `Ok(MergeResult::NothingToCommit)` if branch is already merged
221/// - `Err` if git commands fail unexpectedly
222pub fn merge_to_main(info: &WorktreeInfo, unit_id: &str) -> Result<MergeResult> {
223    let main_path = &info.main_path;
224    let branch = &info.branch;
225
226    if branch.is_empty() {
227        return Err(anyhow!("Worktree has no branch (detached HEAD?)"));
228    }
229
230    // Perform the merge from the main worktree
231    let merge_message = format!("Merge branch '{}' (unit {})", branch, unit_id);
232    let merge_output = Command::new("git")
233        .args(["-C", main_path.to_str().unwrap_or(".")])
234        .args(["merge", branch, "--no-ff", "-m", &merge_message])
235        .output()?;
236
237    if merge_output.status.success() {
238        return Ok(MergeResult::Success);
239    }
240
241    let stderr = String::from_utf8_lossy(&merge_output.stderr);
242    let stdout = String::from_utf8_lossy(&merge_output.stdout);
243
244    // Check if already up-to-date
245    if stdout.contains("Already up to date") || stderr.contains("Already up to date") {
246        return Ok(MergeResult::NothingToCommit);
247    }
248
249    // Check for conflicts
250    if stdout.contains("CONFLICT") || stderr.contains("CONFLICT") {
251        // Get list of conflicting files
252        let conflicts = parse_conflict_files(&stdout, &stderr);
253
254        // Abort the merge
255        let _ = Command::new("git")
256            .args(["-C", main_path.to_str().unwrap_or(".")])
257            .args(["merge", "--abort"])
258            .output();
259
260        return Ok(MergeResult::Conflict { files: conflicts });
261    }
262
263    Err(anyhow!("git merge failed: {}", stderr))
264}
265
266/// Parse conflicting files from merge output.
267fn parse_conflict_files(stdout: &str, stderr: &str) -> Vec<String> {
268    let combined = format!("{}\n{}", stdout, stderr);
269    let mut files = Vec::new();
270
271    for line in combined.lines() {
272        // Match lines like "CONFLICT (content): Merge conflict in <file>"
273        if let Some(idx) = line.find("Merge conflict in ") {
274            let file = line[idx + "Merge conflict in ".len()..].trim();
275            files.push(file.to_string());
276        }
277        // Match lines like "CONFLICT (add/add): Merge conflict in <file>"
278        // or "CONFLICT (modify/delete): <file> deleted in ..."
279        else if line.starts_with("CONFLICT") {
280            // Try to extract filename from various CONFLICT formats
281            if let Some(colon_idx) = line.find("):") {
282                let rest = &line[colon_idx + 2..].trim();
283                // Get first word which might be the filename
284                if let Some(word) = rest.split_whitespace().next() {
285                    if !word.is_empty() && word != "Merge" && !files.contains(&word.to_string()) {
286                        files.push(word.to_string());
287                    }
288                }
289            }
290        }
291    }
292
293    files
294}
295
296/// Clean up a worktree and its branch.
297///
298/// Removes the worktree directory and deletes the associated branch.
299///
300/// # Arguments
301/// * `info` - Information about the worktree to clean up
302pub fn cleanup_worktree(info: &WorktreeInfo) -> Result<()> {
303    let main_path = &info.main_path;
304    let worktree_path = &info.worktree_path;
305    let branch = &info.branch;
306
307    // Remove the worktree
308    let remove_output = Command::new("git")
309        .args(["-C", main_path.to_str().unwrap_or(".")])
310        .args(["worktree", "remove", worktree_path.to_str().unwrap_or(".")])
311        .output()?;
312
313    if !remove_output.status.success() {
314        // Try force remove if normal remove fails
315        let force_output = Command::new("git")
316            .args(["-C", main_path.to_str().unwrap_or(".")])
317            .args([
318                "worktree",
319                "remove",
320                "--force",
321                worktree_path.to_str().unwrap_or("."),
322            ])
323            .output()?;
324
325        if !force_output.status.success() {
326            return Err(anyhow!(
327                "Failed to remove worktree: {}",
328                String::from_utf8_lossy(&force_output.stderr)
329            ));
330        }
331    }
332
333    // Delete the branch (only if we have a branch name)
334    if !branch.is_empty() {
335        let delete_output = Command::new("git")
336            .args(["-C", main_path.to_str().unwrap_or(".")])
337            .args(["branch", "-d", branch])
338            .output()?;
339
340        if !delete_output.status.success() {
341            // Try force delete if normal delete fails (branch not fully merged)
342            let force_delete = Command::new("git")
343                .args(["-C", main_path.to_str().unwrap_or(".")])
344                .args(["branch", "-D", branch])
345                .output()?;
346
347            if !force_delete.status.success() {
348                return Err(anyhow!(
349                    "Failed to delete branch '{}': {}",
350                    branch,
351                    String::from_utf8_lossy(&force_delete.stderr)
352                ));
353            }
354        }
355    }
356
357    Ok(())
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    #[test]
365    fn test_parse_worktree_list_single() {
366        let output = "worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n";
367        let entries = parse_worktree_list(output);
368
369        assert_eq!(entries.len(), 1);
370        assert_eq!(entries[0].path, PathBuf::from("/home/user/project"));
371        assert_eq!(entries[0].branch, Some("main".to_string()));
372    }
373
374    #[test]
375    fn test_parse_worktree_list_multiple() {
376        let output = r#"worktree /home/user/project
377HEAD abc123
378branch refs/heads/main
379
380worktree /home/user/project-feature
381HEAD def456
382branch refs/heads/feature-x
383"#;
384        let entries = parse_worktree_list(output);
385
386        assert_eq!(entries.len(), 2);
387        assert_eq!(entries[0].path, PathBuf::from("/home/user/project"));
388        assert_eq!(entries[0].branch, Some("main".to_string()));
389        assert_eq!(entries[1].path, PathBuf::from("/home/user/project-feature"));
390        assert_eq!(entries[1].branch, Some("feature-x".to_string()));
391    }
392
393    #[test]
394    fn test_parse_worktree_list_detached_head() {
395        let output = "worktree /home/user/project\nHEAD abc123\ndetached\n";
396        let entries = parse_worktree_list(output);
397
398        assert_eq!(entries.len(), 1);
399        assert_eq!(entries[0].path, PathBuf::from("/home/user/project"));
400        assert_eq!(entries[0].branch, None);
401    }
402
403    #[test]
404    fn detect_worktree_runs_without_panic() {
405        // This test just ensures the function doesn't panic
406        // The actual result depends on the environment
407        let cwd = std::env::current_dir().unwrap();
408        let result = detect_worktree(&cwd);
409        assert!(result.is_ok());
410    }
411
412    // Merge-related tests
413    mod merge {
414        use super::*;
415
416        #[test]
417        fn test_merge_result_variants() {
418            // Test that MergeResult variants can be constructed and compared
419            let success = MergeResult::Success;
420            let conflict = MergeResult::Conflict {
421                files: vec!["file1.txt".to_string(), "file2.txt".to_string()],
422            };
423            let nothing = MergeResult::NothingToCommit;
424
425            assert_eq!(success, MergeResult::Success);
426            assert_eq!(nothing, MergeResult::NothingToCommit);
427
428            if let MergeResult::Conflict { files } = conflict {
429                assert_eq!(files.len(), 2);
430                assert!(files.contains(&"file1.txt".to_string()));
431            } else {
432                unreachable!("Expected Conflict variant");
433            }
434        }
435
436        #[test]
437        fn test_parse_conflict_files_content_conflict() {
438            let stdout =
439                "Auto-merging src/lib.rs\nCONFLICT (content): Merge conflict in src/lib.rs\n";
440            let stderr = "";
441            let files = parse_conflict_files(stdout, stderr);
442            assert_eq!(files, vec!["src/lib.rs"]);
443        }
444
445        #[test]
446        fn test_parse_conflict_files_multiple() {
447            let stdout = r#"Auto-merging file1.txt
448CONFLICT (content): Merge conflict in file1.txt
449Auto-merging file2.txt
450CONFLICT (content): Merge conflict in file2.txt
451"#;
452            let files = parse_conflict_files(stdout, "");
453            assert_eq!(files.len(), 2);
454            assert!(files.contains(&"file1.txt".to_string()));
455            assert!(files.contains(&"file2.txt".to_string()));
456        }
457
458        #[test]
459        fn test_parse_conflict_files_empty() {
460            let files = parse_conflict_files("", "");
461            assert!(files.is_empty());
462        }
463
464        #[test]
465        fn test_parse_conflict_files_no_conflicts() {
466            let stdout = "Already up to date.\n";
467            let files = parse_conflict_files(stdout, "");
468            assert!(files.is_empty());
469        }
470
471        #[test]
472        fn test_worktree_info_for_merge() {
473            // Test that WorktreeInfo can be used with merge functions
474            let info = WorktreeInfo {
475                main_path: PathBuf::from("/home/user/project"),
476                worktree_path: PathBuf::from("/home/user/project-feature"),
477                branch: "feature-branch".to_string(),
478            };
479
480            assert_eq!(info.branch, "feature-branch");
481            assert_eq!(info.main_path, PathBuf::from("/home/user/project"));
482            assert_eq!(
483                info.worktree_path,
484                PathBuf::from("/home/user/project-feature")
485            );
486        }
487
488        #[test]
489        fn test_merge_to_main_requires_branch() {
490            // Test that merge_to_main fails with empty branch
491            let info = WorktreeInfo {
492                main_path: PathBuf::from("/tmp/nonexistent"),
493                worktree_path: PathBuf::from("/tmp/nonexistent-wt"),
494                branch: String::new(), // Empty branch
495            };
496
497            let result = merge_to_main(&info, "test-unit");
498            assert!(result.is_err());
499            let err = result.unwrap_err();
500            assert!(err.to_string().contains("no branch"));
501        }
502
503        #[test]
504        fn test_commit_worktree_changes_type_signature() {
505            // This test verifies the function signature and return type
506            // by calling it - it will likely fail (not in git repo) but
507            // shouldn't panic
508            let cwd = std::env::current_dir().unwrap();
509            let result = commit_worktree_changes(&cwd, "test message");
510            // Result should be Ok or Err, not panic
511            let _ = result;
512        }
513
514        #[test]
515        fn test_cleanup_worktree_type_signature() {
516            // This test verifies the function signature works correctly
517            let info = WorktreeInfo {
518                main_path: PathBuf::from("/tmp/nonexistent-main"),
519                worktree_path: PathBuf::from("/tmp/nonexistent-wt"),
520                branch: "test-branch".to_string(),
521            };
522
523            // This will fail because the paths don't exist, but shouldn't panic
524            let result = cleanup_worktree(&info);
525            assert!(result.is_err()); // Expected to fail with nonexistent paths
526        }
527    }
528}