use std::path::PathBuf;
use anyhow::Context;
use color_print::cformat;
use worktrunk::git::{GitError, Repository};
use worktrunk::styling::{
eprintln, format_with_gutter, info_message, progress_message, success_message, warning_message,
};
use super::types::MergeOperations;
use crate::commands::repository_ext::{RepositoryCliExt, TargetWorktreeStash};
struct MergeContext {
repo: Repository,
target_branch: String,
target_worktree_path: Option<PathBuf>,
target_tip: String,
stash_guard: Option<TargetWorktreeStash>,
commit_count: usize,
stats_summary: Vec<String>,
}
impl MergeContext {
fn prepare(target: Option<&str>, operations: Option<MergeOperations>) -> anyhow::Result<Self> {
let repo = Repository::current()?;
let target_branch = repo.require_target_branch(target)?;
let target_worktree_path = repo.worktree_for_branch(&target_branch)?;
let target_ref = format!("refs/heads/{}", target_branch);
let target_tip = repo
.run_command(&["rev-parse", &target_ref])?
.trim()
.to_string();
if !repo.is_ancestor(&target_tip, "HEAD")? {
let commits_formatted = repo
.run_command(&[
"log",
"--color=always",
"--graph",
"--oneline",
&format!("HEAD..{}", target_tip),
])?
.trim()
.to_string();
return Err(GitError::NotFastForward {
target_branch: target_branch.clone(),
commits_formatted,
in_merge_context: operations.is_some(),
}
.into());
}
let stash_guard =
repo.prepare_target_worktree(target_worktree_path.as_ref(), &target_branch)?;
let commit_count = repo.count_commits(&target_branch, "HEAD")?;
let stats_summary = if commit_count > 0 {
repo.diff_stats_summary(&["diff", "--shortstat", &format!("{}..HEAD", target_branch)])
} else {
Vec::new()
};
Ok(Self {
repo,
target_branch,
target_worktree_path,
target_tip,
stash_guard,
commit_count,
stats_summary,
})
}
fn show_progress(
&self,
verb_ing: &str,
extra_note: &str,
operations: Option<MergeOperations>,
) -> anyhow::Result<()> {
if self.commit_count == 0 {
return Ok(());
}
let commit_text = if self.commit_count == 1 {
"commit"
} else {
"commits"
};
let head_sha = self.repo.run_command(&["rev-parse", "--short", "HEAD"])?;
let head_sha = head_sha.trim();
let operations_note = format_operations_note(operations);
eprintln!(
"{}",
progress_message(cformat!(
"{verb_ing} {} {commit_text} to <bold>{}</> @ <dim>{head_sha}</>{extra_note}{operations_note}",
self.commit_count,
self.target_branch,
))
);
let log_output = self.repo.run_command(&[
"log",
"--color=always",
"--graph",
"--oneline",
&format!("{}..HEAD", self.target_branch),
])?;
eprintln!("{}", format_with_gutter(&log_output, None));
crate::commands::show_diffstat(&self.repo, &format!("{}..HEAD", self.target_branch))?;
Ok(())
}
fn show_up_to_date_if_needed(&self, operations: Option<MergeOperations>) -> bool {
if self.commit_count > 0 {
return false;
}
let context = format_up_to_date_context(operations);
eprintln!(
"{}",
info_message(cformat!(
"Already up to date with <bold>{}</>{context}",
self.target_branch,
))
);
true
}
fn restore_stash(&mut self) {
if let Some(guard) = self.stash_guard.as_mut() {
guard.restore_now();
}
}
fn show_success(&self, verb: &str, sha_suffix: &str, extra_stats: &str) {
let mut summary_parts = vec![format!(
"{} commit{}",
self.commit_count,
if self.commit_count == 1 { "" } else { "s" }
)];
summary_parts.extend(self.stats_summary.clone());
let stats_str = summary_parts.join(", ");
let target_branch = &self.target_branch;
let paren_close = cformat!("<bright-black>)</>"); eprintln!(
"{}",
success_message(cformat!(
"{verb} <bold>{target_branch}</>{sha_suffix} <bright-black>({stats_str}{extra_stats}</>{paren_close}",
))
);
}
}
fn format_operations_note(operations: Option<MergeOperations>) -> String {
let Some(ops) = operations else {
return String::new();
};
let mut skipped = Vec::new();
if !ops.committed && !ops.squashed {
skipped.push("commit/squash");
}
if !ops.rebased {
skipped.push("rebase");
}
if skipped.is_empty() {
String::new()
} else {
format!(" (no {} needed)", skipped.join("/"))
}
}
fn format_up_to_date_context(operations: Option<MergeOperations>) -> String {
let Some(ops) = operations else {
return String::new();
};
let mut notes = Vec::new();
if !ops.committed && !ops.squashed {
notes.push("no new commits");
}
if !ops.rebased {
notes.push("no rebase needed");
}
if notes.is_empty() {
String::new()
} else {
format!(" ({})", notes.join(", "))
}
}
pub fn handle_push(
target: Option<&str>,
verb: &str,
operations: Option<MergeOperations>,
) -> anyhow::Result<()> {
let mut ctx = MergeContext::prepare(target, operations)?;
let verb_ing = if verb.starts_with("Merged") {
"Merging"
} else {
"Pushing"
};
ctx.show_progress(verb_ing, "", operations)?;
let git_common_dir = ctx.repo.git_common_dir();
let git_common_dir_str = git_common_dir.to_string_lossy();
let push_target = format!("HEAD:{}", ctx.target_branch);
ctx.repo
.run_command(&[
"push",
"--recurse-submodules=no",
"--receive-pack=git -c receive.denyCurrentBranch=updateInstead receive-pack",
git_common_dir_str.as_ref(),
&push_target,
])
.map_err(|e| GitError::PushFailed {
target_branch: ctx.target_branch.clone(),
error: e.to_string(),
})?;
ctx.restore_stash();
if ctx.commit_count > 0 {
ctx.show_success(verb, "", "");
} else {
ctx.show_up_to_date_if_needed(operations);
}
Ok(())
}
pub fn handle_no_ff_merge(
target: Option<&str>,
operations: Option<MergeOperations>,
feature_branch: &str,
) -> anyhow::Result<()> {
let mut ctx = MergeContext::prepare(target, operations)?;
ctx.show_progress("Merging", " (--no-ff)", operations)?;
if ctx.show_up_to_date_if_needed(operations) {
return Ok(());
}
let tree = ctx
.repo
.run_command(&["rev-parse", "HEAD^{tree}"])?
.trim()
.to_string();
let feature_tip = ctx
.repo
.run_command(&["rev-parse", "HEAD"])?
.trim()
.to_string();
let merge_message = format!(
"Merge branch '{}' into {}",
feature_branch, ctx.target_branch
);
let merge_sha = ctx
.repo
.run_command(&[
"commit-tree",
&tree,
"-p",
&ctx.target_tip,
"-p",
&feature_tip,
"-m",
&merge_message,
])
.context("Failed to create merge commit")?
.trim()
.to_string();
let target_ref = format!("refs/heads/{}", ctx.target_branch);
ctx.repo
.run_command(&["update-ref", &target_ref, &merge_sha, &ctx.target_tip])
.map_err(|e| GitError::PushFailed {
target_branch: ctx.target_branch.clone(),
error: format!("Failed to update ref: {e}"),
})?;
if let Some(wt_path) = &ctx.target_worktree_path
&& wt_path.exists()
{
let target_wt = ctx.repo.worktree_at(wt_path);
if let Err(e) =
target_wt.run_command(&["read-tree", "-m", "-u", &ctx.target_tip, &merge_sha])
{
eprintln!(
"{}",
warning_message(cformat!(
"Failed to sync target worktree; run <bold>git -C {} reset --hard HEAD</> manually",
worktrunk::path::format_path_for_display(wt_path)
))
);
log::warn!("Failed to sync target worktree: {e}");
}
}
ctx.restore_stash();
let merge_sha_short = &merge_sha[..merge_sha.len().min(7)];
let sha_suffix = cformat!(" @ <dim>{merge_sha_short}</>");
ctx.show_success("Merged to", &sha_suffix, ", --no-ff");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::worktree::types::MergeOperations;
#[test]
fn test_format_operations_note() {
assert_eq!(format_operations_note(None), "");
assert_eq!(
format_operations_note(Some(MergeOperations {
committed: true,
squashed: true,
rebased: true,
})),
""
);
assert_eq!(
format_operations_note(Some(MergeOperations {
committed: false,
squashed: false,
rebased: false,
})),
" (no commit/squash/rebase needed)"
);
assert_eq!(
format_operations_note(Some(MergeOperations {
committed: true,
squashed: false,
rebased: false,
})),
" (no rebase needed)"
);
assert_eq!(
format_operations_note(Some(MergeOperations {
committed: false,
squashed: false,
rebased: true,
})),
" (no commit/squash needed)"
);
}
#[test]
fn test_format_up_to_date_context() {
assert_eq!(format_up_to_date_context(None), "");
assert_eq!(
format_up_to_date_context(Some(MergeOperations {
committed: true,
squashed: true,
rebased: true,
})),
""
);
assert_eq!(
format_up_to_date_context(Some(MergeOperations {
committed: false,
squashed: false,
rebased: false,
})),
" (no new commits, no rebase needed)"
);
assert_eq!(
format_up_to_date_context(Some(MergeOperations {
committed: true,
squashed: false,
rebased: false,
})),
" (no rebase needed)"
);
assert_eq!(
format_up_to_date_context(Some(MergeOperations {
committed: false,
squashed: false,
rebased: true,
})),
" (no new commits)"
);
}
}