turbocommit 3.2.1

A CLI tool to create commit messages with OpenAI GPT-5.6 for Git and Jujutsu (JJ) repositories
use actor::Actor;
use colored::Colorize;
use config::Config;

use openai::Message;

use std::{env, process, time::Duration};

mod actor;
mod cli;
mod config;
mod debug_log;
mod git;
mod jj;
mod model;
mod openai;
mod spinner;
mod util;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let args: Vec<String> = env::args().collect();

    // Help and version must remain available even when an old config needs migration.
    if args
        .iter()
        .any(|arg| matches!(arg.as_str(), "-h" | "--help" | "--version"))
    {
        cli::Options::new(args.into_iter(), &Config::default());
        unreachable!();
    }

    // Find an explicit config before loading the default path so `-c` can recover
    // from an invalid or outdated ~/.turbocommit.yaml.
    let requested_config = args.windows(2).find_map(|pair| match pair[0].as_str() {
        "-c" | "--config" => Some(pair[1].clone()),
        _ => None,
    });

    let config = if let Some(config_path) = requested_config {
        Config::load_from_path(std::path::Path::new(&config_path))?
    } else {
        Config::load()?
    };

    let options = cli::Options::new(args.into_iter(), &config);

    if options.check_version_only {
        util::check_version().await;
        return Ok(());
    }

    let api_key = match &options.api_key {
        Some(ref key) => key.clone(),
        None => {
            let env_var = &config.api_key_env_var;
            if env_var.trim().is_empty() {
                // If env_var is empty, no API key is needed
                String::new()
            } else {
                // Only check environment variable if env_var is not empty
                match env::var(env_var) {
                    Ok(key) => key,
                    Err(_) => {
                        println!("{}", format!("No API key found. Either:").red());
                        println!("  1. Set the {} environment variable", env_var.purple());
                        println!("  2. Use the {} option", "--api-key <key>".purple());
                        println!("\n{}", "For API key safety best practices, see: https://help.openai.com/en/articles/5112595-best-practices-for-api-key-safety".bright_black());
                        process::exit(1);
                    }
                }
            }
        }
    };

    // Detect VCS type
    let vcs_type = jj::detect_vcs()?;

    // Print which VCS is being used
    match vcs_type {
        jj::VcsType::Git => println!("{}", "Using Git repository".bright_black()),
        jj::VcsType::Jujutsu => println!("{}", "Using Jujutsu repository".bright_black()),
    }

    let mut actor = Actor::new(
        options.clone(),
        api_key,
        options.api_endpoint.clone(),
        vcs_type.clone(),
    );

    let system_len =
        openai::count_token(options.system_msg.as_ref().unwrap_or(&config.system_msg)).unwrap_or(0);
    let extra_len = openai::count_token(&options.msg).unwrap_or(0);

    // GPT-5.6 treats developer messages as the application's instructions.
    actor.add_message(Message::developer(
        options.system_msg.unwrap_or(config.system_msg.clone()),
    ));

    // Handle different VCS types
    match vcs_type {
        jj::VcsType::Git => {
            let repo = git::get_repo()?;

            // Handle amend mode
            if options.amend {
                // When amending, we don't want any staged files
                if git::has_staged_changes(&repo)? {
                    println!("{}", "Error: You have staged changes.".red());
                    println!(
                        "{}",
                        "When using --amend, you should not have any staged changes."
                            .bright_black()
                    );
                    println!(
                        "{}",
                        "The --amend option only changes the commit message of the last commit."
                            .bright_black()
                    );
                    println!(
                        "{}",
                        "If you want to include new changes, either:".bright_black()
                    );
                    println!(
                        "{}",
                        "1. Commit them first normally, then amend that commit".bright_black()
                    );
                    println!(
                        "{}",
                        "2. Or use git commit --amend manually to include them".bright_black()
                    );
                    process::exit(1);
                }

                // Get the diff from the last commit
                let diff = git::get_last_commit_diff(&repo)?;
                if diff.is_empty() {
                    println!(
                        "{}",
                        "Error: Could not get changes from the last commit.".red()
                    );
                    println!(
                        "{}",
                        "Make sure you have at least one commit in your repository.".bright_black()
                    );
                    process::exit(1);
                }
                actor.add_message(Message::user(diff));
                actor.used_tokens = system_len + extra_len;
            } else {
                // Normal commit mode - get diff from staged changes
                let (diff, diff_tokens) = util::decide_diff(
                    &repo,
                    system_len + extra_len,
                    options.model.context_size(),
                    options.always_select_files,
                )?;
                actor.add_message(Message::user(diff));
                actor.used_tokens = system_len + extra_len + diff_tokens;
            }
        }
        jj::VcsType::Jujutsu => {
            // Check if there are changes for the specified revision (or working directory if none specified)
            if !jj::has_jj_changes_for_revision(options.jj_revision.as_deref())? {
                let revision_msg = if let Some(ref rev) = options.jj_revision {
                    format!("No changes detected in Jujutsu revision '{}'.", rev)
                } else {
                    "No changes detected in Jujutsu working directory.".to_string()
                };
                println!("{}", revision_msg.red());
                println!(
                    "{}",
                    "Please make some changes before running turbocommit.".bright_black()
                );
                process::exit(1);
            }

            // Validate revision ID if provided
            if let Some(ref rev) = options.jj_revision {
                jj::validate_revision_id(rev)?;
            }

            // Get the diff for the specified revision with file selection support
            let (diff, diff_tokens) = util::decide_diff_jj(
                system_len + extra_len,
                options.model.context_size(),
                options.always_select_files,
                options.jj_revision.as_deref(),
            )?;

            // If rewrite mode is enabled, include current description as hint
            if options.jj_rewrite {
                if let Some(current_desc) = jj::get_jj_description(options.jj_revision.as_deref())?
                {
                    let hint_msg = format!("Current description: {}", current_desc);
                    actor.add_message(Message::user(hint_msg));
                }
            }

            actor.add_message(Message::user(diff));
            actor.used_tokens = system_len + extra_len + diff_tokens;
        }
    }

    // Add any extra message from command line
    if !options.msg.is_empty() {
        actor.add_message(Message::user(options.msg));
    }

    if options.auto_commmit {
        let _ = actor.auto_commit().await?;
    } else {
        actor.start().await?;
    }

    // Only check for updates if not disabled in config or CLI
    if !options.disable_auto_update_check {
        util::check_version().await;
    }

    if util::check_config_age(Duration::from_secs(60 * 60 * 24 * 30 * 6)) {
        if !util::is_system_prompt_same_as_default(&config.system_msg) {
            println!(
                "\n{}\n{}\n{}",
                "Your developer instruction seems to be old.".yellow(),
                "There is a new recommended default. To apply it, delete the `system_msg` field in your config file.".bright_black(),
                "To get rid of this message, simply save your config file to change the last modified date.".bright_black()
            );
        }
    }

    Ok(())
}