worktrunk 0.35.1

A CLI for Git worktree management, designed for parallel AI agent workflows
Documentation
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;

// Re-export StageMode from config for use by CLI
pub use worktrunk::config::StageMode;

/// Options for committing current changes.
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> {
    /// Convenience constructor for the common case where untracked files should trigger a warning.
    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</>"
                ))
            );
        }
    }

    /// Commit staged changes in the given worktree.
    ///
    /// When `show_progress` is true, displays a progress message with diff stats
    /// before committing. Set to false for bulk operations where each worktree
    /// is handled individually (e.g., `step relocate --commit`).
    pub fn commit_staged_changes(
        &self,
        wt: &worktrunk::git::WorkingTree<'_>,
        show_progress: bool,
        show_no_squash_note: bool,
        stage_mode: StageMode,
    ) -> anyhow::Result<()> {
        // Fail early if nothing is staged (avoids confusing LLM prompt with empty diff)
        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 {
                // Gray parenthetical with separate cformat for closing paren (avoids optimizer)
                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(())
    }
}

/// Commit uncommitted changes with the shared commit pipeline.
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();

        // Show skip message
        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();

            // Run pre-commit hooks (user first, then project)
            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_filter: None,
                    display_path: crate::output::pre_hook_display_path(self.ctx.worktree_path),
                },
                HookFailureStrategy::FailFast,
            )
            .map_err(worktrunk::git::add_hook_skip_hint)?;
        }

        // Use the worktree path from context — this is the target worktree when
        // --branch is specified, or the current worktree otherwise.
        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)?;
        }

        // Stage changes based on mode
        match self.stage_mode {
            StageMode::All => {
                // Stage everything: tracked modifications + untracked files
                wt.run_command(&["add", "-A"])
                    .context("Failed to stage changes")?;
            }
            StageMode::Tracked => {
                // Stage tracked modifications only (no untracked files)
                wt.run_command(&["add", "-u"])
                    .context("Failed to stage tracked changes")?;
            }
            StageMode::None => {
                // Stage nothing - commit only what's already in the index
            }
        }

        let effective_config = self.ctx.commit_generation();
        CommitGenerator::new(&effective_config).commit_staged_changes(
            &wt,
            true, // show_progress
            self.show_no_squash_note,
            self.stage_mode,
        )?;

        // Spawn post-commit hooks in background (respects --no-hooks)
        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"), @"Simple commit message");
        assert_snapshot!(generator.format_message_for_display("First line\nSecond line\nThird line"), @"
        First line
        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, "");
    }
}