use anyhow::Context;
use color_print::cformat;
use worktrunk::HookType;
use worktrunk::config::UserConfig;
use worktrunk::git::Repository;
use worktrunk::styling::{
eprintln, format_with_gutter, hint_message, info_message, println, progress_message,
success_message,
};
use super::super::command_approval::approve_or_skip;
use super::super::command_executor::FailureStrategy;
use super::super::commit::{CommitGenerator, CommitOutcome, HookGate, StageMode};
use super::super::context::CommandEnv;
use super::super::hooks::{self, HookAnnouncer, execute_hook};
use super::super::repository_ext::RepositoryCliExt;
use super::super::template_vars::TemplateVars;
use super::shared::print_dry_run;
#[derive(Debug, Clone)]
pub enum SquashResult {
Squashed {
sha: String,
message: String,
stage_mode: StageMode,
},
NoCommitsAhead(String),
AlreadySingleCommit,
NoNetChanges,
}
pub fn handle_squash(
target: Option<&str>,
yes: bool,
hooks: HookGate,
stage: Option<StageMode>,
announcer: &mut HookAnnouncer<'_>,
) -> anyhow::Result<SquashResult> {
let mut config = UserConfig::load().context("Failed to load config")?;
let _ = crate::output::prompt_commit_generation(&mut config);
let env = CommandEnv::for_action(config)?;
let repo = &env.repo;
let current_branch = env.require_branch("squash")?.to_string();
let ctx = env.context(yes);
let resolved = env.resolved();
let generator = CommitGenerator::new(&resolved.commit_generation);
let stage_mode = stage.unwrap_or(resolved.commit.stage());
let project_config = repo.load_project_config()?;
let user_hooks = ctx.config.hooks(ctx.project_id().as_deref());
let (user_cfg, proj_cfg) =
hooks::lookup_hook_configs(&user_hooks, project_config.as_ref(), HookType::PreCommit);
let any_hooks_exist = user_cfg.is_some() || proj_cfg.is_some();
let hooks = match hooks {
HookGate::Run => {
if approve_or_skip(
&ctx,
&[HookType::PreCommit, HookType::PostCommit],
"Commands declined, squashing without hooks",
)? {
HookGate::Run
} else {
HookGate::Silent
}
}
HookGate::NoHooksFlag => {
if any_hooks_exist {
eprintln!("{}", info_message("Skipping pre-commit hooks (--no-hooks)"));
}
HookGate::NoHooksFlag
}
HookGate::Silent => HookGate::Silent,
};
let integration_target = repo.require_target_ref(target)?;
let template_vars = TemplateVars::new().with_target(&integration_target);
match stage_mode {
StageMode::All => {
repo.warn_if_auto_staging_untracked()?;
repo.run_command(&["add", "-A"])
.context("Failed to stage changes")?;
}
StageMode::Tracked => {
repo.run_command(&["add", "-u"])
.context("Failed to stage tracked changes")?;
}
StageMode::None => {
}
}
if hooks.run() {
execute_hook(
&ctx,
HookType::PreCommit,
&template_vars.as_extra_vars(),
FailureStrategy::FailFast,
crate::output::pre_hook_display_path(ctx.worktree_path),
)?;
}
let merge_base = repo
.merge_base("HEAD", &integration_target)?
.context("Cannot squash: no common ancestor with target branch")?;
let commit_count = repo.count_commits(&merge_base, "HEAD")?;
let wt = repo.current_worktree();
let has_staged = wt.has_staged_changes()?;
if commit_count == 0 && !has_staged {
return Ok(SquashResult::NoCommitsAhead(integration_target));
}
if commit_count == 0 && has_staged {
let CommitOutcome {
sha,
message,
stage_mode,
} = generator.commit_staged_changes(&wt, true, true, stage_mode)?;
return Ok(SquashResult::Squashed {
sha,
message,
stage_mode,
});
}
if commit_count == 1 && !has_staged {
return Ok(SquashResult::AlreadySingleCommit);
}
let range = format!("{}..HEAD", merge_base);
let commit_text = if commit_count == 1 {
"commit"
} else {
"commits"
};
let total_stats = if has_staged {
repo.diff_stats_summary(&["diff", "--shortstat", &merge_base, "--cached"])
} else {
repo.diff_stats_summary(&["diff", "--shortstat", &range])
};
let with_changes = if has_staged {
match stage_mode {
StageMode::Tracked => " & tracked changes",
_ => " & working tree changes",
}
} else {
""
};
let parts = total_stats;
let squash_progress = if parts.is_empty() {
format!("Squashing {commit_count} {commit_text}{with_changes} into a single commit...")
} else {
let parts_str = parts.join(", ");
let paren_close = cformat!("<bright-black>)</>");
cformat!(
"Squashing {commit_count} {commit_text}{with_changes} into a single commit <bright-black>({parts_str}</>{paren_close}..."
)
};
eprintln!("{}", progress_message(squash_progress));
if has_staged {
let backup_message = format!("{} → {} (squash)", current_branch, integration_target);
let sha = wt.create_safety_backup(&backup_message)?;
eprintln!("{}", hint_message(format!("Backup created @ {sha}")));
}
let subjects = repo.commit_subjects(&range)?;
eprintln!(
"{}",
progress_message("Generating squash commit message...")
);
generator.emit_hint_if_needed();
let repo_root = wt.root()?;
let repo_name = repo_root
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("repo");
let commit_message = crate::llm::generate_squash_message(
&integration_target,
&merge_base,
&subjects,
¤t_branch,
repo_name,
&resolved.commit_generation,
)?;
let formatted_message = generator.format_message_for_display(&commit_message);
eprintln!("{}", format_with_gutter(&formatted_message, None));
repo.run_command(&["reset", "--soft", &merge_base])
.context("Failed to reset to merge base")?;
if !wt.has_staged_changes()? {
eprintln!(
"{}",
info_message(format!(
"No changes after squashing {commit_count} {commit_text}"
))
);
return Ok(SquashResult::NoNetChanges);
}
repo.run_command(&["commit", "-m", &commit_message])
.context("Failed to create squash commit")?;
let commit_sha = repo.run_command(&["rev-parse", "HEAD"])?.trim().to_string();
let commit_hash = repo.short_sha(&commit_sha)?;
eprintln!(
"{}",
success_message(cformat!("Squashed @ <dim>{commit_hash}</>"))
);
if hooks.run() {
let extra_vars = template_vars.as_extra_vars();
announcer.register(&ctx, HookType::PostCommit, &extra_vars, None)?;
}
Ok(SquashResult::Squashed {
sha: commit_sha,
message: commit_message,
stage_mode,
})
}
pub fn step_show_squash_prompt(target: Option<&str>) -> anyhow::Result<()> {
preview_squash(target, false)
}
pub fn step_dry_run_squash(target: Option<&str>) -> anyhow::Result<()> {
preview_squash(target, true)
}
fn preview_squash(target: Option<&str>, dry_run: bool) -> anyhow::Result<()> {
let repo = Repository::current()?;
let config = UserConfig::load().context("Failed to load config")?;
let project_id = repo.project_identifier().ok();
let commit_config = config.commit_generation(project_id.as_deref());
let integration_target = repo.require_target_ref(target)?;
let wt = repo.current_worktree();
let current_branch = wt.branch()?.unwrap_or_else(|| "HEAD".to_string());
let merge_base = repo
.merge_base("HEAD", &integration_target)?
.context("Cannot generate squash message: no common ancestor with target branch")?;
let range = format!("{}..HEAD", merge_base);
let subjects = repo.commit_subjects(&range)?;
let repo_root = wt.root()?;
let repo_name = repo_root
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("repo");
let prompt = crate::llm::build_squash_prompt(
&integration_target,
&merge_base,
&subjects,
¤t_branch,
repo_name,
&commit_config,
)?;
if !dry_run {
println!("{}", prompt);
return Ok(());
}
let message = crate::llm::generate_squash_message(
&integration_target,
&merge_base,
&subjects,
¤t_branch,
repo_name,
&commit_config,
)?;
print_dry_run(&prompt, &commit_config, &message)
}