use anyhow::{anyhow, Result};
use std::path::PathBuf;
use std::process::Command;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MergeResult {
Success,
Conflict { files: Vec<String> },
NothingToCommit,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorktreeInfo {
pub main_path: PathBuf,
pub worktree_path: PathBuf,
pub branch: String,
}
#[derive(Debug)]
struct WorktreeEntry {
path: PathBuf,
branch: Option<String>,
}
fn parse_worktree_list(output: &str) -> Vec<WorktreeEntry> {
let mut entries = Vec::new();
let mut current_path: Option<PathBuf> = None;
let mut current_branch: Option<String> = None;
for line in output.lines() {
if let Some(path) = line.strip_prefix("worktree ") {
if let Some(path) = current_path.take() {
entries.push(WorktreeEntry {
path,
branch: current_branch.take(),
});
}
current_path = Some(PathBuf::from(path));
current_branch = None;
} else if let Some(branch_ref) = line.strip_prefix("branch ") {
current_branch = Some(
branch_ref
.strip_prefix("refs/heads/")
.unwrap_or(branch_ref)
.to_string(),
);
}
}
if let Some(path) = current_path {
entries.push(WorktreeEntry {
path,
branch: current_branch,
});
}
entries
}
pub fn detect_worktree(cwd: &std::path::Path) -> Result<Option<WorktreeInfo>> {
let output = Command::new("git")
.args(["worktree", "list", "--porcelain"])
.current_dir(cwd)
.output();
let output = match output {
Ok(o) => o,
Err(_) => return Ok(None), };
if !output.status.success() {
return Ok(None);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let entries = parse_worktree_list(&stdout);
if entries.is_empty() {
return Ok(None);
}
let main_entry = &entries[0];
let main_path = &main_entry.path;
let mut current_entry: Option<&WorktreeEntry> = None;
for entry in &entries {
if cwd.starts_with(&entry.path) {
match current_entry {
None => current_entry = Some(entry),
Some(prev) if entry.path.as_os_str().len() > prev.path.as_os_str().len() => {
current_entry = Some(entry)
}
_ => {}
}
}
}
let current_entry = match current_entry {
Some(e) => e,
None => return Ok(None), };
if current_entry.path == *main_path {
return Ok(None);
}
Ok(Some(WorktreeInfo {
main_path: main_path.clone(),
worktree_path: current_entry.path.clone(),
branch: current_entry.branch.clone().unwrap_or_default(),
}))
}
pub fn commit_worktree_paths(
cwd: &std::path::Path,
message: &str,
paths: &[String],
) -> Result<bool> {
commit_worktree_paths_preserve_index(cwd, message, paths)
}
pub fn commit_worktree_paths_preserve_index(
cwd: &std::path::Path,
message: &str,
paths: &[String],
) -> Result<bool> {
if paths.is_empty() {
return Ok(false);
}
let repo_root = git_stdout(cwd, &["rev-parse", "--show-toplevel"])?;
let index_path = std::env::temp_dir().join(format!(
"mana-targeted-index-{}-{}",
std::process::id(),
unique_suffix()
));
let result = commit_with_temp_index(cwd, PathBuf::from(repo_root), &index_path, message, paths);
cleanup_temp_index(&index_path);
result
}
fn commit_with_temp_index(
cwd: &std::path::Path,
repo_root: PathBuf,
index_path: &std::path::Path,
message: &str,
paths: &[String],
) -> Result<bool> {
let index = index_path.to_string_lossy().to_string();
git_status(
cwd,
&["read-tree", "HEAD"],
Some((&index, repo_root.as_path())),
"git read-tree failed",
)?;
let add_output = git_command_with_env(cwd, Some((&index, repo_root.as_path())))
.arg("add")
.arg("-A")
.arg("--")
.args(paths)
.output()?;
if !add_output.status.success() {
return Err(anyhow!(
"git add failed: {}",
String::from_utf8_lossy(&add_output.stderr)
));
}
let tree = git_stdout_with_env(
cwd,
&["write-tree"],
Some((&index, repo_root.as_path())),
"git write-tree failed",
)?;
let head_tree = git_stdout(cwd, &["rev-parse", "HEAD^{tree}"])?;
if tree == head_tree {
return Ok(false);
}
let commit_output = Command::new("git")
.arg("commit-tree")
.arg(&tree)
.arg("-p")
.arg("HEAD")
.arg("-m")
.arg(message)
.current_dir(cwd)
.output()?;
if !commit_output.status.success() {
return Err(anyhow!(
"git commit-tree failed: {}",
String::from_utf8_lossy(&commit_output.stderr)
));
}
let new_head = String::from_utf8_lossy(&commit_output.stdout)
.trim()
.to_string();
if new_head.is_empty() {
return Err(anyhow!("git commit-tree produced an empty commit id"));
}
git_status(
cwd,
&[
"update-ref",
"-m",
&format!("commit: {message}"),
"HEAD",
&new_head,
],
None,
"git update-ref failed",
)?;
Ok(true)
}
fn git_command_with_env(
cwd: &std::path::Path,
temp_index: Option<(&str, &std::path::Path)>,
) -> Command {
let mut command = Command::new("git");
command.current_dir(cwd);
if let Some((index, work_tree)) = temp_index {
command
.env("GIT_INDEX_FILE", index)
.env("GIT_WORK_TREE", work_tree);
}
command
}
fn git_status(
cwd: &std::path::Path,
args: &[&str],
temp_index: Option<(&str, &std::path::Path)>,
context: &str,
) -> Result<()> {
let output = git_command_with_env(cwd, temp_index).args(args).output()?;
if output.status.success() {
return Ok(());
}
Err(anyhow!(
"{}: {}",
context,
String::from_utf8_lossy(&output.stderr)
))
}
fn git_stdout(cwd: &std::path::Path, args: &[&str]) -> Result<String> {
git_stdout_with_env(cwd, args, None, "git command failed")
}
fn git_stdout_with_env(
cwd: &std::path::Path,
args: &[&str],
temp_index: Option<(&str, &std::path::Path)>,
context: &str,
) -> Result<String> {
let output = git_command_with_env(cwd, temp_index).args(args).output()?;
if !output.status.success() {
return Err(anyhow!(
"{}: {}",
context,
String::from_utf8_lossy(&output.stderr)
));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn cleanup_temp_index(path: &std::path::Path) {
let _ = std::fs::remove_file(path);
let _ = std::fs::remove_file(path.with_extension("lock"));
}
fn unique_suffix() -> u128 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0)
}
pub fn commit_worktree_changes(cwd: &std::path::Path, message: &str) -> Result<bool> {
let add_output = Command::new("git")
.args(["add", "-A"])
.current_dir(cwd)
.output()?;
if !add_output.status.success() {
return Err(anyhow!(
"git add failed: {}",
String::from_utf8_lossy(&add_output.stderr)
));
}
commit_staged_changes(cwd, message)
}
fn commit_staged_changes(cwd: &std::path::Path, message: &str) -> Result<bool> {
let commit_output = Command::new("git")
.args(["commit", "-m", message])
.current_dir(cwd)
.output()?;
if commit_output.status.success() {
return Ok(true);
}
let stderr = String::from_utf8_lossy(&commit_output.stderr);
let stdout = String::from_utf8_lossy(&commit_output.stdout);
if stderr.contains("nothing to commit")
|| stdout.contains("nothing to commit")
|| stderr.contains("no changes added")
|| stdout.contains("no changes added")
{
return Ok(false);
}
Err(anyhow!("git commit failed: {}", stderr))
}
pub fn merge_to_main(info: &WorktreeInfo, unit_id: &str) -> Result<MergeResult> {
let main_path = &info.main_path;
let branch = &info.branch;
if branch.is_empty() {
return Err(anyhow!("Worktree has no branch (detached HEAD?)"));
}
let merge_message = format!("Merge branch '{}' (unit {})", branch, unit_id);
let merge_output = Command::new("git")
.args(["-C", main_path.to_str().unwrap_or(".")])
.args(["merge", branch, "--no-ff", "-m", &merge_message])
.output()?;
if merge_output.status.success() {
return Ok(MergeResult::Success);
}
let stderr = String::from_utf8_lossy(&merge_output.stderr);
let stdout = String::from_utf8_lossy(&merge_output.stdout);
if stdout.contains("Already up to date") || stderr.contains("Already up to date") {
return Ok(MergeResult::NothingToCommit);
}
if stdout.contains("CONFLICT") || stderr.contains("CONFLICT") {
let conflicts = parse_conflict_files(&stdout, &stderr);
let _ = Command::new("git")
.args(["-C", main_path.to_str().unwrap_or(".")])
.args(["merge", "--abort"])
.output();
return Ok(MergeResult::Conflict { files: conflicts });
}
Err(anyhow!("git merge failed: {}", stderr))
}
fn parse_conflict_files(stdout: &str, stderr: &str) -> Vec<String> {
let combined = format!("{}\n{}", stdout, stderr);
let mut files = Vec::new();
for line in combined.lines() {
if let Some(idx) = line.find("Merge conflict in ") {
let file = line[idx + "Merge conflict in ".len()..].trim();
files.push(file.to_string());
}
else if line.starts_with("CONFLICT") {
if let Some(colon_idx) = line.find("):") {
let rest = &line[colon_idx + 2..].trim();
if let Some(word) = rest.split_whitespace().next() {
if !word.is_empty() && word != "Merge" && !files.contains(&word.to_string()) {
files.push(word.to_string());
}
}
}
}
}
files
}
pub fn cleanup_worktree(info: &WorktreeInfo) -> Result<()> {
let main_path = &info.main_path;
let worktree_path = &info.worktree_path;
let branch = &info.branch;
let remove_output = Command::new("git")
.args(["-C", main_path.to_str().unwrap_or(".")])
.args(["worktree", "remove", worktree_path.to_str().unwrap_or(".")])
.output()?;
if !remove_output.status.success() {
let force_output = Command::new("git")
.args(["-C", main_path.to_str().unwrap_or(".")])
.args([
"worktree",
"remove",
"--force",
worktree_path.to_str().unwrap_or("."),
])
.output()?;
if !force_output.status.success() {
return Err(anyhow!(
"Failed to remove worktree: {}",
String::from_utf8_lossy(&force_output.stderr)
));
}
}
if !branch.is_empty() {
let delete_output = Command::new("git")
.args(["-C", main_path.to_str().unwrap_or(".")])
.args(["branch", "-d", branch])
.output()?;
if !delete_output.status.success() {
let force_delete = Command::new("git")
.args(["-C", main_path.to_str().unwrap_or(".")])
.args(["branch", "-D", branch])
.output()?;
if !force_delete.status.success() {
return Err(anyhow!(
"Failed to delete branch '{}': {}",
branch,
String::from_utf8_lossy(&force_delete.stderr)
));
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_worktree_list_single() {
let output = "worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n";
let entries = parse_worktree_list(output);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].path, PathBuf::from("/home/user/project"));
assert_eq!(entries[0].branch, Some("main".to_string()));
}
#[test]
fn test_parse_worktree_list_multiple() {
let output = r#"worktree /home/user/project
HEAD abc123
branch refs/heads/main
worktree /home/user/project-feature
HEAD def456
branch refs/heads/feature-x
"#;
let entries = parse_worktree_list(output);
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].path, PathBuf::from("/home/user/project"));
assert_eq!(entries[0].branch, Some("main".to_string()));
assert_eq!(entries[1].path, PathBuf::from("/home/user/project-feature"));
assert_eq!(entries[1].branch, Some("feature-x".to_string()));
}
#[test]
fn test_parse_worktree_list_detached_head() {
let output = "worktree /home/user/project\nHEAD abc123\ndetached\n";
let entries = parse_worktree_list(output);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].path, PathBuf::from("/home/user/project"));
assert_eq!(entries[0].branch, None);
}
#[test]
fn detect_worktree_runs_without_panic() {
let cwd = std::env::current_dir().unwrap();
let result = detect_worktree(&cwd);
assert!(result.is_ok());
}
mod merge {
use super::*;
#[test]
fn test_merge_result_variants() {
let success = MergeResult::Success;
let conflict = MergeResult::Conflict {
files: vec!["file1.txt".to_string(), "file2.txt".to_string()],
};
let nothing = MergeResult::NothingToCommit;
assert_eq!(success, MergeResult::Success);
assert_eq!(nothing, MergeResult::NothingToCommit);
if let MergeResult::Conflict { files } = conflict {
assert_eq!(files.len(), 2);
assert!(files.contains(&"file1.txt".to_string()));
} else {
unreachable!("Expected Conflict variant");
}
}
#[test]
fn test_parse_conflict_files_content_conflict() {
let stdout =
"Auto-merging src/lib.rs\nCONFLICT (content): Merge conflict in src/lib.rs\n";
let stderr = "";
let files = parse_conflict_files(stdout, stderr);
assert_eq!(files, vec!["src/lib.rs"]);
}
#[test]
fn test_parse_conflict_files_multiple() {
let stdout = r#"Auto-merging file1.txt
CONFLICT (content): Merge conflict in file1.txt
Auto-merging file2.txt
CONFLICT (content): Merge conflict in file2.txt
"#;
let files = parse_conflict_files(stdout, "");
assert_eq!(files.len(), 2);
assert!(files.contains(&"file1.txt".to_string()));
assert!(files.contains(&"file2.txt".to_string()));
}
#[test]
fn test_parse_conflict_files_empty() {
let files = parse_conflict_files("", "");
assert!(files.is_empty());
}
#[test]
fn test_parse_conflict_files_no_conflicts() {
let stdout = "Already up to date.\n";
let files = parse_conflict_files(stdout, "");
assert!(files.is_empty());
}
#[test]
fn test_worktree_info_for_merge() {
let info = WorktreeInfo {
main_path: PathBuf::from("/home/user/project"),
worktree_path: PathBuf::from("/home/user/project-feature"),
branch: "feature-branch".to_string(),
};
assert_eq!(info.branch, "feature-branch");
assert_eq!(info.main_path, PathBuf::from("/home/user/project"));
assert_eq!(
info.worktree_path,
PathBuf::from("/home/user/project-feature")
);
}
#[test]
fn test_merge_to_main_requires_branch() {
let info = WorktreeInfo {
main_path: PathBuf::from("/tmp/nonexistent"),
worktree_path: PathBuf::from("/tmp/nonexistent-wt"),
branch: String::new(), };
let result = merge_to_main(&info, "test-unit");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("no branch"));
}
#[test]
fn test_commit_worktree_changes_type_signature() {
let cwd = std::env::current_dir().unwrap();
let result = commit_worktree_changes(&cwd, "test message");
let _ = result;
}
#[test]
fn test_cleanup_worktree_type_signature() {
let info = WorktreeInfo {
main_path: PathBuf::from("/tmp/nonexistent-main"),
worktree_path: PathBuf::from("/tmp/nonexistent-wt"),
branch: "test-branch".to_string(),
};
let result = cleanup_worktree(&info);
assert!(result.is_err()); }
}
}