use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone)]
pub struct WorktreeInfo {
pub branch: String,
pub worktree_path: PathBuf,
pub main_repo_path: PathBuf,
}
pub fn detect() -> Option<WorktreeInfo> {
let output = Command::new("git")
.args(["rev-parse", "--git-common-dir"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let common_dir = String::from_utf8_lossy(&output.stdout).trim().to_string();
let output = Command::new("git")
.args(["rev-parse", "--git-dir"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let git_dir = String::from_utf8_lossy(&output.stdout).trim().to_string();
let worktree_path: PathBuf = Path::new(&git_dir).canonicalize().ok()?;
if common_dir == git_dir {
return None;
}
let main_repo_path: PathBuf = Path::new(&common_dir).parent().map(|p| p.to_path_buf())?;
let main_repo_path = main_repo_path.canonicalize().ok()?;
let branch = current_branch().unwrap_or_default();
Some(WorktreeInfo {
branch,
worktree_path: worktree_path
.parent()
.map(|p| p.to_path_buf())
.unwrap_or(worktree_path),
main_repo_path,
})
}
pub fn current_branch() -> Option<String> {
let output = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
if branch == "HEAD" { None } else { Some(branch) }
}
pub fn default_branch(repo_path: &Path) -> Option<String> {
for name in &["main", "master"] {
let output = Command::new("git")
.arg("-C")
.arg(repo_path)
.args(["rev-parse", "--verify", name])
.output()
.ok();
if let Some(out) = output
&& out.status.success()
{
return Some(name.to_string());
}
}
None
}
fn validate_branch_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("branch name must not be empty".to_string());
}
if name.starts_with('-') {
return Err(format!(
"branch name {name:?} must not start with '-' (looks like a git flag)"
));
}
if name == "HEAD" || name == "@" {
return Err(format!("branch name {name:?} is a reserved git ref"));
}
if name.contains("..") {
return Err(format!(
"branch name {name:?} must not contain '..' (git ref-name rule)"
));
}
for bad in ['~', ':', '^', '?', '*', '['] {
if name.contains(bad) {
return Err(format!(
"branch name {name:?} must not contain '{bad}' (git ref-name rule)"
));
}
}
if name
.chars()
.any(|c| c == '\0' || (c.is_control() && c != '\t'))
{
return Err(format!(
"branch name {name:?} must not contain null bytes or control characters"
));
}
Ok(())
}
pub fn create(name: &str) -> Result<(PathBuf, WorktreeInfo), String> {
validate_branch_name(name)?;
let target = format!("../{}", name);
let output = Command::new("git")
.args(["worktree", "add", "-b", name, "--", &target])
.output()
.map_err(|e| format!("failed to run git: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("git worktree add failed: {}", stderr.trim()));
}
let cleanup = |reason: String| -> String {
let _ = Command::new("git")
.args(["worktree", "remove", "--force", "--", &target])
.output();
reason
};
let wt_path = match PathBuf::from(&target).canonicalize() {
Ok(p) => p,
Err(e) => return Err(cleanup(format!("failed to resolve worktree path: {}", e))),
};
let main_repo = match std::env::current_dir() {
Ok(p) => p,
Err(e) => return Err(cleanup(format!("failed to get current dir: {}", e))),
};
Ok((
wt_path.clone(),
WorktreeInfo {
branch: name.to_string(),
worktree_path: wt_path,
main_repo_path: main_repo,
},
))
}
fn git_in(repo: &Path, args: &[&str]) -> Result<String, String> {
let output = Command::new("git")
.current_dir(repo)
.arg("-C")
.arg(repo)
.args(args)
.output()
.map_err(|e| format!("failed to run git: {e}"))?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
}
}
fn is_dirty(repo: &Path) -> Result<bool, String> {
Ok(!git_in(repo, &["status", "--porcelain"])?.is_empty())
}
pub fn merge_worktree(info: &WorktreeInfo, target: &str) -> Result<(), String> {
validate_branch_name(target)?;
validate_branch_name(&info.branch)?;
let main = info.main_repo_path.as_path();
if is_dirty(&info.worktree_path)? {
return Err(format!(
"worktree '{}' has uncommitted changes — commit or discard them before merging",
info.branch
));
}
if is_dirty(main)? {
return Err(format!(
"main repo at {} has uncommitted changes — commit or stash them before merging",
main.display()
));
}
git_in(main, &["switch", target])
.or_else(|_| git_in(main, &["checkout", target]))
.map_err(|e| format!("failed to switch main repo to '{target}': {e}"))?;
if let Err(e) = git_in(main, &["merge", "--no-ff", &info.branch]) {
let _ = git_in(main, &["merge", "--abort"]);
return Err(format!(
"merge of '{}' into '{target}' could not complete cleanly and was aborted — \
nothing was changed. Resolve it manually in {} (git merge {}). Details: {e}",
info.branch,
main.display(),
info.branch
));
}
Ok(())
}
pub fn remove_worktree(main_repo: &Path, worktree_path: &Path) -> Result<(), String> {
git_in(
main_repo,
&["worktree", "remove", "--", &worktree_path.to_string_lossy()],
)
.map(|_| ())
}
pub fn repo_name(path: &Path) -> String {
path.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_string())
}
#[cfg(test)]
mod merge_tests {
use super::*;
fn git(dir: &Path, args: &[&str]) -> String {
let mut full = vec![
"-c",
"user.email=test@dirge",
"-c",
"user.name=dirge",
"-c",
"commit.gpgsign=false",
"-c",
"init.defaultBranch=main",
];
full.extend_from_slice(args);
let out = Command::new("git")
.current_dir(dir)
.arg("-C")
.arg(dir)
.args(&full)
.output()
.expect("git runs");
assert!(
out.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
fn write(p: &Path, contents: &str) {
std::fs::write(p, contents).unwrap();
}
fn setup() -> (WorktreeInfo, PathBuf) {
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let root =
std::env::temp_dir().join(format!("dirge-wt-merge-test-{}-{}", std::process::id(), n));
let _ = std::fs::remove_dir_all(&root);
let main = root.join("repo");
std::fs::create_dir_all(&main).unwrap();
git(&main, &["init"]);
git(&main, &["config", "user.email", "test@dirge.local"]);
git(&main, &["config", "user.name", "dirge-test"]);
git(&main, &["config", "commit.gpgsign", "false"]);
write(&main.join("file.txt"), "base\n");
git(&main, &["add", "."]);
git(&main, &["commit", "-m", "base"]);
let wt = root.join("feature");
git(
&main,
&[
"worktree",
"add",
"-b",
"feature",
"--",
wt.to_str().unwrap(),
],
);
let info = WorktreeInfo {
branch: "feature".to_string(),
worktree_path: wt,
main_repo_path: main,
};
(info, root)
}
#[test]
fn clean_merge_lands_feature_in_main() {
let (info, root) = setup();
write(&info.worktree_path.join("new.txt"), "from feature\n");
git(&info.worktree_path, &["add", "."]);
git(&info.worktree_path, &["commit", "-m", "feature work"]);
merge_worktree(&info, "main").expect("clean merge should succeed");
assert_eq!(
git(&info.main_repo_path, &["rev-parse", "--abbrev-ref", "HEAD"]),
"main"
);
assert!(
info.main_repo_path.join("new.txt").exists(),
"merged file present in main"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn conflicting_merge_aborts_and_leaves_repo_clean() {
let (info, root) = setup();
write(&info.main_repo_path.join("file.txt"), "main change\n");
git(&info.main_repo_path, &["commit", "-am", "main edit"]);
write(&info.worktree_path.join("file.txt"), "feature change\n");
git(&info.worktree_path, &["commit", "-am", "feature edit"]);
let err = merge_worktree(&info, "main").expect_err("conflicting merge must fail");
assert!(
err.contains("aborted"),
"error should say it aborted: {err}"
);
assert!(
!info.main_repo_path.join(".git/MERGE_HEAD").exists(),
"merge must be aborted (no MERGE_HEAD)"
);
assert!(
git(&info.main_repo_path, &["status", "--porcelain"]).is_empty(),
"main working tree must be clean after abort"
);
assert_eq!(
std::fs::read_to_string(info.main_repo_path.join("file.txt")).unwrap(),
"main change\n",
"main's content is untouched by the aborted merge"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn dirty_worktree_is_refused() {
let (info, root) = setup();
write(&info.worktree_path.join("file.txt"), "uncommitted\n");
let err = merge_worktree(&info, "main").expect_err("dirty worktree must be refused");
assert!(
err.contains("uncommitted"),
"error names the dirty state: {err}"
);
let _ = std::fs::remove_dir_all(&root);
}
}