opencodecommit 1.6.0

AI-powered git commit message generator that delegates to terminal AI agents
Documentation
mod common;

use std::process::Command;
use std::time::Duration;

use common::{FixtureRepo, TUI_BACKENDS, load_env, occ_bin};
use expectrl::session::OsSession;
use expectrl::{ControlCode, Eof, Expect, Regex, Session};

fn backend_label(backend: &str) -> &'static str {
    match backend {
        "opencode" => "OpenCode CLI",
        "claude" => "Claude Code CLI",
        "codex" => "Codex CLI",
        "gemini" => "Gemini CLI",
        "openai-api" => "OpenAI API",
        "anthropic-api" => "Anthropic API",
        "gemini-api" => "Gemini API",
        "openrouter-api" => "OpenRouter API",
        "opencode-api" => "OpenCode Zen API",
        "ollama-api" => "Ollama API",
        "lm-studio-api" => "LM Studio API",
        "custom-api" => "Custom API",
        other => panic!("unknown backend: {other}"),
    }
}

fn tui_expect_timeout(mode: &str) -> Duration {
    if mode == "staging" {
        Duration::from_secs(300)
    } else {
        Duration::from_secs(180)
    }
}

fn spawn_tui(
    repo: &FixtureRepo,
    config_path: &std::path::Path,
    expect_timeout: Duration,
) -> OsSession {
    let mut command = Command::new(occ_bin());
    command
        .current_dir(&repo.path)
        .arg("tui")
        .arg("--config")
        .arg(config_path)
        .env("NO_COLOR", "1")
        .env_remove("CLICOLOR")
        .env_remove("CLICOLOR_FORCE");

    if let Ok(backend) = std::env::var("OCC_E2E_TUI_BACKEND_OVERRIDE") {
        let backend = backend.trim();
        if !backend.is_empty() {
            command.arg("--backend").arg(backend);
        }
    }

    let mut session = Session::spawn(command).expect("spawn tui session");
    session
        .get_process_mut()
        .set_window_size(120, 30)
        .expect("resize tui session");
    session.set_expect_timeout(Some(expect_timeout));
    session
}

fn menu_backends(mode: &str, active_backends: &[String]) -> Vec<(&'static str, char)> {
    if mode == "staging" {
        return TUI_BACKENDS.to_vec();
    }

    TUI_BACKENDS
        .iter()
        .copied()
        .filter(|(backend, _)| active_backends.iter().any(|value| value == backend))
        .collect()
}

fn targeted_single_backend(mode: &str, active_backends: &[String]) -> bool {
    mode != "staging" && active_backends.len() == 1
}

#[test]
fn tui_core_buttons_and_sidebar_work_in_a_real_pty() {
    let Some(env) = load_env() else { return };
    let repo = FixtureRepo::new("e2e-tui-core");
    let mut session = spawn_tui(&repo, &env.config_path, tui_expect_timeout(&env.mode));
    let single_backend = targeted_single_backend(&env.mode, &env.active_backends);

    session.expect("OpenCodeCommit").unwrap();
    session.expect("1 Commit").unwrap();
    session.expect("7 PR").unwrap();

    for _ in 0..8 {
        session.send(ControlCode::HT).unwrap();
    }
    session.send("j").unwrap();
    session.send(" ").unwrap();
    session.expect(Regex("(Staged|Unstaged) .*\\.")).unwrap();

    session.send("1").unwrap();
    session.expect("Generated commit message").unwrap();
    if !single_backend {
        session.send("s").unwrap();
        session.expect("Shortened commit message").unwrap();
    }
    session.send("c").unwrap();
    session.expect("Committed:").unwrap();

    session.send("2").unwrap();
    session.expect("BRANCH NAME PREVIEW").unwrap();
    session.send("c").unwrap();
    session.expect("Switched to new branch").unwrap();

    session.send("3").unwrap();
    session.expect("Generated PR preview").unwrap();
    if !single_backend {
        session.send("p").unwrap();
        session
            .expect(Regex("PR copied to clipboard\\.|Clipboard copy failed"))
            .unwrap();
        session.send("r").unwrap();
        session.expect("Generated PR preview").unwrap();
    }
    session.send(ControlCode::ESC).unwrap();

    session.send("4").unwrap();
    session.expect("SAFETY SETTINGS").unwrap();
    session.send("i").unwrap();
    session.expect("[y Yes]").unwrap();
    session.send("y").unwrap();
    session.expect("installed prepare-commit-msg hook").unwrap();

    session.send("4").unwrap();
    session.expect("SAFETY SETTINGS").unwrap();
    session.send("u").unwrap();
    session.expect("[y Yes]").unwrap();
    session.send("y").unwrap();
    session
        .expect("uninstalled prepare-commit-msg hook")
        .unwrap();

    session.send("4").unwrap();
    session.expect("SAFETY SETTINGS").unwrap();
    session.send("h").unwrap();
    session.expect("Applied human sensitive profile").unwrap();

    session.send("4").unwrap();
    session.expect("SAFETY SETTINGS").unwrap();
    session.send("a").unwrap();
    session
        .expect("Applied strict-agent sensitive profile")
        .unwrap();

    session.send("q").unwrap();
    session.expect(Eof).unwrap();
}

#[test]
fn tui_backend_selector_covers_the_expected_entries() {
    let Some(env) = load_env() else { return };
    let repo = FixtureRepo::new("e2e-tui-backend-selector");
    let mut session = spawn_tui(&repo, &env.config_path, tui_expect_timeout(&env.mode));

    session.expect("OpenCodeCommit").unwrap();
    let backends = menu_backends(&env.mode, &env.active_backends);

    for (index, (backend, key)) in backends.iter().enumerate() {
        session.send("5").unwrap();
        session.expect("BACKEND SELECTOR").unwrap();
        session.send(key.to_string()).unwrap();
        session
            .expect(format!("Backend set to {}.", backend_label(backend)))
            .unwrap();
        if index + 1 == backends.len() {
            break;
        }
    }

    session.send("q").unwrap();
    session.expect(Eof).unwrap();
}

#[test]
fn tui_one_shot_backend_menus_run_every_enabled_backend() {
    let Some(env) = load_env() else { return };
    let repo = FixtureRepo::new("e2e-tui-backend-menus");
    let mut session = spawn_tui(&repo, &env.config_path, tui_expect_timeout(&env.mode));

    session.expect("OpenCodeCommit").unwrap();
    let backends = menu_backends(&env.mode, &env.active_backends);

    if targeted_single_backend(&env.mode, &env.active_backends) {
        session.send("6").unwrap();
        session.expect("COMMIT BACKEND SELECTOR").unwrap();
        session.send(ControlCode::ESC).unwrap();

        session.send("7").unwrap();
        session.expect("PR BACKEND SELECTOR").unwrap();
        session.send(ControlCode::ESC).unwrap();

        session.send("q").unwrap();
        session.expect(Eof).unwrap();
        return;
    }

    for (_, key) in &backends {
        session.send("6").unwrap();
        session.expect("COMMIT BACKEND SELECTOR").unwrap();
        session.send(key.to_string()).unwrap();
        session.expect("Generated commit message").unwrap();
        session.send(ControlCode::ESC).unwrap();
    }

    for (_, key) in &backends {
        session.send("7").unwrap();
        session.expect("PR BACKEND SELECTOR").unwrap();
        session.send(key.to_string()).unwrap();
        session.expect("Generated PR preview").unwrap();
        session.send(ControlCode::ESC).unwrap();
    }

    session.send("q").unwrap();
    session.expect(Eof).unwrap();
}

#[test]
fn artifacts_tui_generation_smoke() {
    let Some(env) = load_env() else { return };
    let repo = FixtureRepo::new("e2e-tui-artifacts");
    let mut session = spawn_tui(&repo, &env.config_path, tui_expect_timeout(&env.mode));

    session.expect("OpenCodeCommit").unwrap();

    session.send("1").unwrap();
    session.expect("Generated commit message").unwrap();

    session.send("2").unwrap();
    session.expect("BRANCH NAME PREVIEW").unwrap();

    session.send("3").unwrap();
    session.expect("PR PREVIEW").unwrap();

    session.send("q").unwrap();
    session.expect(Eof).unwrap();
}

#[test]
fn tui_targeted_single_backend_smoke() {
    let Some(env) = load_env() else { return };
    if !targeted_single_backend(&env.mode, &env.active_backends) {
        return;
    }

    let repo = FixtureRepo::new("e2e-tui-targeted-single");
    let mut session = spawn_tui(&repo, &env.config_path, tui_expect_timeout(&env.mode));

    session.expect("OpenCodeCommit").unwrap();
    session.expect("1 Commit").unwrap();
    session.expect("2 Branch").unwrap();
    session.expect("3 PR").unwrap();
    session.expect("4 Safety Hook").unwrap();
    session.expect("5 Backend").unwrap();
    session.expect("6 Commit").unwrap();
    session.expect("7 PR").unwrap();

    session.send("1").unwrap();
    session.expect("Generated commit message").unwrap();

    session.send("2").unwrap();
    session.expect("BRANCH NAME PREVIEW").unwrap();

    session.send("3").unwrap();
    session.expect("PR PREVIEW").unwrap();

    session.send("4").unwrap();
    session.expect("SAFETY SETTINGS").unwrap();
    session.send(ControlCode::ESC).unwrap();

    session.send("5").unwrap();
    session.expect("BACKEND SELECTOR").unwrap();
    session.send(ControlCode::ESC).unwrap();

    session.send("q").unwrap();
    session.expect(Eof).unwrap();

    let repo = FixtureRepo::new("e2e-tui-targeted-single-commit-menu");
    let mut session = spawn_tui(&repo, &env.config_path, tui_expect_timeout(&env.mode));
    session.expect("OpenCodeCommit").unwrap();
    session.send("6").unwrap();
    session.expect("COMMIT BACKEND SELECTOR").unwrap();
    session.send(ControlCode::ESC).unwrap();
    session.send("q").unwrap();
    session.expect(Eof).unwrap();

    let repo = FixtureRepo::new("e2e-tui-targeted-single-pr-menu");
    let mut session = spawn_tui(&repo, &env.config_path, tui_expect_timeout(&env.mode));
    session.expect("OpenCodeCommit").unwrap();
    session.send("7").unwrap();
    session.expect("PR BACKEND SELECTOR").unwrap();
    session.send(ControlCode::ESC).unwrap();
    session.send("q").unwrap();
    session.expect(Eof).unwrap();
}