use anyhow::{Context, Result, anyhow};
use crate::{cmd, git};
use tracing::{debug, info};
use super::cleanup::{self, get_worktree_mode};
use super::context::WorkflowContext;
use super::types::MergeResult;
#[allow(clippy::too_many_arguments)]
pub fn merge(
name: &str,
into_branch: Option<&str>,
ignore_uncommitted: bool,
rebase: bool,
squash: bool,
keep: bool,
no_verify: bool,
no_hooks: bool,
notification: bool,
context: &WorkflowContext,
) -> Result<MergeResult> {
info!(
name = name,
into = into_branch,
ignore_uncommitted,
rebase,
squash,
keep,
no_verify,
no_hooks,
"merge:start"
);
context.chdir_to_main_worktree()?;
let (worktree_path, branch_to_merge) = git::find_worktree(name).map_err(|_| {
anyhow!(
"Worktree '{}' not found. Use 'workmux list' to see available worktrees.",
name
)
})?;
let handle = worktree_path
.file_name()
.and_then(std::ffi::OsStr::to_str)
.ok_or_else(|| {
anyhow!(
"Could not derive handle from worktree path: {}",
worktree_path.display()
)
})?;
let mode = get_worktree_mode(handle);
debug!(
name = name,
handle = handle,
branch = branch_to_merge,
path = %worktree_path.display(),
"merge:worktree resolved"
);
let detected_base: Option<String> = if into_branch.is_some() {
None } else {
match git::get_branch_base(&branch_to_merge) {
Ok(base) => {
if git::branch_exists(&base)? {
info!(
branch = %branch_to_merge,
base = %base,
"merge:auto-detected base branch"
);
Some(base)
} else {
info!(
branch = %branch_to_merge,
base = %base,
"merge:base branch not found, defaulting to main"
);
None
}
}
Err(_) => {
debug!(
branch = %branch_to_merge,
"merge:no base config found, defaulting to main"
);
None
}
}
};
let target_branch = into_branch
.map(|s| s.to_string())
.or(detected_base)
.unwrap_or_else(|| context.main_branch.clone());
let target_branch = target_branch.as_str();
let (target_worktree_path, target_window_name) = match git::get_worktree_path(target_branch) {
Ok(path) => {
if path == context.main_worktree_root {
(path, context.main_branch.clone())
} else {
let handle = path
.file_name()
.and_then(|s| s.to_str())
.ok_or_else(|| anyhow!("Invalid worktree path for target branch"))?
.to_string();
(path, handle)
}
}
Err(_) => {
debug!(
target = target_branch,
"merge:target branch has no worktree, using main worktree"
);
(
context.main_worktree_root.clone(),
context.main_branch.clone(),
)
}
};
let has_unstaged = !keep && git::has_unstaged_changes(&worktree_path)?;
let has_untracked = !keep && git::has_untracked_files(&worktree_path)?;
if (has_unstaged || has_untracked) && !ignore_uncommitted {
let mut issues = Vec::new();
if has_unstaged {
issues.push("unstaged changes");
}
if has_untracked {
issues.push("untracked files (will be lost)");
}
return Err(anyhow!(
"Worktree for '{}' has {}. Please stage or stash them, or use --ignore-uncommitted.",
branch_to_merge,
issues.join(" and ")
));
}
let had_staged_changes = git::has_staged_changes(&worktree_path)?;
if had_staged_changes && !ignore_uncommitted {
info!(path = %worktree_path.display(), "merge:committing staged changes");
git::commit_with_editor(&worktree_path).context("Failed to commit staged changes")?;
}
if branch_to_merge == target_branch {
return Err(anyhow!(
"Cannot merge branch '{}' into itself.",
branch_to_merge
));
}
debug!(
branch = %branch_to_merge,
target = target_branch,
"merge:target branch resolved"
);
if git::has_tracked_changes(&target_worktree_path)? {
return Err(anyhow!(
"Target worktree ({}) has uncommitted changes. Please commit or stash them before merging.",
target_worktree_path.display()
));
}
git::switch_branch_in_worktree(&target_worktree_path, target_branch)?;
if !no_verify
&& !no_hooks
&& let Some(hooks) = &context.config.pre_merge
&& !hooks.is_empty()
{
info!(count = hooks.len(), "merge:running pre-merge hooks");
let abs_worktree_path = worktree_path
.canonicalize()
.unwrap_or_else(|_| worktree_path.clone());
let abs_project_root = context
.main_worktree_root
.canonicalize()
.unwrap_or_else(|_| context.main_worktree_root.clone());
let worktree_path_str = abs_worktree_path.to_string_lossy();
let project_root_str = abs_project_root.to_string_lossy();
let hook_env = [
("WORKMUX_HANDLE", handle),
("WM_BRANCH_NAME", branch_to_merge.as_str()),
("WM_TARGET_BRANCH", target_branch),
("WM_WORKTREE_PATH", worktree_path_str.as_ref()),
("WM_PROJECT_ROOT", project_root_str.as_ref()),
("WM_HANDLE", handle),
];
for command in hooks {
cmd::shell_command_with_env(command, &worktree_path, &hook_env)
.with_context(|| format!("Pre-merge hook failed: '{}'", command))?;
}
}
let conflict_err = |branch: &str| -> anyhow::Error {
let retry_cmd = if into_branch.is_some() {
format!("workmux merge {} --into {}", branch, target_branch)
} else {
format!("workmux merge {}", branch)
};
anyhow!(
"Merge failed due to conflicts. Target worktree kept clean.\n\n\
To resolve, update your branch in worktree at {}:\n\
git rebase {} (recommended)\n\
Or:\n\
git merge {}\n\n\
After resolving conflicts, retry: {}",
worktree_path.display(),
target_branch,
target_branch,
retry_cmd
)
};
if rebase {
println!(
"Rebasing '{}' onto '{}'...",
&branch_to_merge, target_branch
);
info!(
branch = %branch_to_merge,
base = target_branch,
"merge:rebase start"
);
git::rebase_branch_onto_base(&worktree_path, target_branch).with_context(|| {
format!(
"Rebase failed, likely due to conflicts.\n\n\
Please resolve them manually inside the worktree at '{}'.\n\
Then, run 'git rebase --continue' to proceed or 'git rebase --abort' to cancel.",
worktree_path.display()
)
})?;
git::merge_in_worktree(&target_worktree_path, &branch_to_merge)
.context("Failed to merge rebased branch. This should have been a fast-forward.")?;
info!(branch = %branch_to_merge, "merge:fast-forward complete");
} else if squash {
if let Err(e) = git::merge_squash_in_worktree(&target_worktree_path, &branch_to_merge) {
info!(branch = %branch_to_merge, error = %e, "merge:squash merge failed, resetting target worktree");
let _ = git::reset_hard(&target_worktree_path);
return Err(conflict_err(&branch_to_merge));
}
println!("Staged squashed changes. Please provide a commit message in your editor.");
git::commit_with_editor(&target_worktree_path)
.context("Failed to commit squashed changes. You may need to commit them manually.")?;
info!(branch = %branch_to_merge, "merge:squash merge committed");
} else {
if let Err(e) = git::merge_in_worktree(&target_worktree_path, &branch_to_merge) {
info!(branch = %branch_to_merge, error = %e, "merge:standard merge failed, aborting merge in target worktree");
let _ = git::abort_merge_in_worktree(&target_worktree_path);
return Err(conflict_err(&branch_to_merge));
}
info!(branch = %branch_to_merge, "merge:standard merge complete");
}
if notification {
show_notification(&format!(
"Merged '{}' into '{}'",
branch_to_merge, target_branch
));
}
if keep {
info!(branch = %branch_to_merge, "merge:skipping cleanup (--keep)");
return Ok(MergeResult {
branch_merged: branch_to_merge,
main_branch: target_branch.to_string(),
had_staged_changes,
});
}
info!(branch = %branch_to_merge, "merge:cleanup start");
let cleanup_result = cleanup::cleanup(
context,
&branch_to_merge,
handle,
&worktree_path,
true,
false, no_hooks,
)?;
cleanup::navigate_to_target_and_close(
context.mux.as_ref(),
&context.prefix,
&target_window_name,
handle,
&cleanup_result,
mode,
)?;
Ok(MergeResult {
branch_merged: branch_to_merge,
main_branch: target_branch.to_string(),
had_staged_changes,
})
}
fn show_notification(message: &str) {
#[cfg(target_os = "macos")]
{
use mac_notification_sys::{Notification, set_application};
if let Err(e) = set_application("com.apple.Terminal") {
tracing::debug!("Failed to set notification application: {:?}", e);
}
if let Err(e) = Notification::default()
.title("workmux")
.message(message)
.send()
{
tracing::debug!("Failed to send notification: {:?}", e);
}
}
#[cfg(not(target_os = "macos"))]
{
if let Err(e) = notify_rust::Notification::new()
.summary("workmux")
.body(message)
.show()
{
tracing::debug!("Failed to send notification: {:?}", e);
}
}
}