use anyhow::Context;
use color_print::cformat;
use worktrunk::HookType;
use worktrunk::config::CommitGenerationConfig;
use worktrunk::styling::{
eprintln, format_with_gutter, hint_message, info_message, progress_message, success_message,
};
use super::command_executor::CommandContext;
use super::hooks::{
HookCommandSpec, HookFailureStrategy, prepare_background_hooks, spawn_hook_pipeline,
};
use super::repository_ext::warn_about_untracked_files;
pub use worktrunk::config::StageMode;
pub struct CommitOptions<'a> {
pub ctx: &'a CommandContext<'a>,
pub target_branch: Option<&'a str>,
pub verify: bool,
pub stage_mode: StageMode,
pub warn_about_untracked: bool,
pub show_no_squash_note: bool,
}
impl<'a> CommitOptions<'a> {
pub fn new(ctx: &'a CommandContext<'a>) -> Self {
Self {
ctx,
target_branch: None,
verify: true,
stage_mode: StageMode::All,
warn_about_untracked: true,
show_no_squash_note: false,
}
}
}
pub(crate) struct CommitGenerator<'a> {
config: &'a CommitGenerationConfig,
}
impl<'a> CommitGenerator<'a> {
pub fn new(config: &'a CommitGenerationConfig) -> Self {
Self { config }
}
pub fn format_message_for_display(&self, message: &str) -> String {
let lines: Vec<&str> = message.lines().collect();
if lines.is_empty() {
return String::new();
}
let mut result = cformat!("<bold>{}</>", lines[0]);
if lines.len() > 1 {
for line in &lines[1..] {
result.push('\n');
result.push_str(line);
}
}
result
}
pub fn emit_hint_if_needed(&self) {
if !self.config.is_configured() {
eprintln!(
"{}",
hint_message(cformat!(
"Using fallback commit message. For LLM setup guide, run <underline>wt config --help</>"
))
);
}
}
pub fn commit_staged_changes(
&self,
wt: &worktrunk::git::WorkingTree<'_>,
show_progress: bool,
show_no_squash_note: bool,
stage_mode: StageMode,
) -> anyhow::Result<()> {
if !wt.has_staged_changes()? {
anyhow::bail!("Nothing to commit");
}
if show_progress {
let stats_parts = wt
.repo()
.diff_stats_summary(&["diff", "--staged", "--shortstat"]);
let changes_type = match stage_mode {
StageMode::Tracked => "tracked changes",
_ => "changes",
};
let action = if self.config.is_configured() {
format!("Generating commit message and committing {changes_type}...")
} else {
format!("Committing {changes_type} with default message...")
};
let mut parts = vec![];
if !stats_parts.is_empty() {
parts.extend(stats_parts);
}
if show_no_squash_note {
parts.push("no squashing needed".to_string());
}
let full_progress_msg = if parts.is_empty() {
action
} else {
let parts_str = parts.join(", ");
let paren_close = cformat!("<bright-black>)</>");
cformat!("{action} <bright-black>({parts_str}</>{paren_close}")
};
eprintln!("{}", progress_message(full_progress_msg));
}
self.emit_hint_if_needed();
let commit_message = crate::llm::generate_commit_message(self.config)?;
let formatted_message = self.format_message_for_display(&commit_message);
eprintln!("{}", format_with_gutter(&formatted_message, None));
wt.run_command(&["commit", "-m", &commit_message])
.context("Failed to commit")?;
let commit_hash = wt
.run_command(&["rev-parse", "--short", "HEAD"])?
.trim()
.to_string();
eprintln!(
"{}",
success_message(cformat!("Committed changes @ <dim>{commit_hash}</>"))
);
Ok(())
}
}
impl CommitOptions<'_> {
pub fn commit(self) -> anyhow::Result<()> {
let project_config = self.ctx.repo.load_project_config()?;
let user_hooks = self.ctx.config.hooks(self.ctx.project_id().as_deref());
let (user_cfg, proj_cfg) = super::hooks::lookup_hook_configs(
&user_hooks,
project_config.as_ref(),
HookType::PreCommit,
);
let any_hooks_exist = user_cfg.is_some() || proj_cfg.is_some();
if !self.verify && any_hooks_exist {
eprintln!("{}", info_message("Skipping pre-commit hooks (--no-hooks)"));
}
if self.verify {
let extra_vars: Vec<(&str, &str)> = self
.target_branch
.into_iter()
.map(|target| ("target", target))
.collect();
super::hooks::run_hook_with_filter(
self.ctx,
HookCommandSpec {
user_config: user_cfg,
project_config: proj_cfg,
hook_type: HookType::PreCommit,
extra_vars: &extra_vars,
name_filters: &[],
display_path: crate::output::pre_hook_display_path(self.ctx.worktree_path),
},
HookFailureStrategy::FailFast,
)
.map_err(worktrunk::git::add_hook_skip_hint)?;
}
let wt = self.ctx.repo.worktree_at(self.ctx.worktree_path);
if self.warn_about_untracked && self.stage_mode == StageMode::All {
let status = wt
.run_command(&["status", "--porcelain", "-z"])
.context("Failed to get status")?;
warn_about_untracked_files(&status)?;
}
match self.stage_mode {
StageMode::All => {
wt.run_command(&["add", "-A"])
.context("Failed to stage changes")?;
}
StageMode::Tracked => {
wt.run_command(&["add", "-u"])
.context("Failed to stage tracked changes")?;
}
StageMode::None => {
}
}
let effective_config = self.ctx.commit_generation();
CommitGenerator::new(&effective_config).commit_staged_changes(
&wt,
true, self.show_no_squash_note,
self.stage_mode,
)?;
if self.verify {
let extra_vars: Vec<(&str, &str)> = self
.target_branch
.into_iter()
.map(|target| ("target", target))
.collect();
for steps in
prepare_background_hooks(self.ctx, HookType::PostCommit, &extra_vars, None)?
{
spawn_hook_pipeline(self.ctx, steps)?;
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_message_for_display() {
use insta::assert_snapshot;
let config = CommitGenerationConfig::default();
let generator = CommitGenerator::new(&config);
assert_snapshot!(generator.format_message_for_display("Simple commit message"), @"[1mSimple commit message[22m");
assert_snapshot!(generator.format_message_for_display("First line\nSecond line\nThird line"), @"
[1mFirst line[22m
Second line
Third line
");
}
#[test]
fn test_format_message_for_display_empty() {
let config = CommitGenerationConfig::default();
let generator = CommitGenerator::new(&config);
let result = generator.format_message_for_display("");
assert_eq!(result, "");
}
}