stax 0.76.0

Fast stacked Git branches and PRs
Documentation
mod common;

use common::TestRepo;
use std::fs;
use std::path::{Path, PathBuf};
use tempfile::TempDir;

fn write_test_config_with_ai(home: &Path) {
    let config_dir = home.join(".config").join("stax");
    fs::create_dir_all(&config_dir).expect("create config dir");
    fs::write(
        config_dir.join("config.toml"),
        r#"
[ai.generate]
agent = "claude"
"#,
    )
    .expect("write config");
}

fn write_fake_claude(home: &Path, response: &str, prompt_log: &Path) -> PathBuf {
    let bin_dir = home.join("bin");
    fs::create_dir_all(&bin_dir).expect("create bin dir");

    let claude = bin_dir.join("claude");
    fs::write(
        &claude,
        format!(
            "#!/bin/sh\ncat > \"{}\"\nprintf '%s\\n' '{}'\n",
            prompt_log.display(),
            response.replace('\'', "'\"'\"'")
        ),
    )
    .expect("write fake claude");

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        fs::set_permissions(&claude, fs::Permissions::from_mode(0o755)).expect("chmod fake claude");
    }

    bin_dir
}

fn path_with_bin(bin_dir: &Path) -> String {
    let current = std::env::var("PATH").unwrap_or_default();
    if current.is_empty() {
        bin_dir.display().to_string()
    } else {
        format!("{}:{}", bin_dir.display(), current)
    }
}

fn ai_env(home: &Path, bin_dir: &Path) -> Vec<(String, String)> {
    vec![
        ("HOME".to_string(), home.display().to_string()),
        ("PATH".to_string(), path_with_bin(bin_dir)),
    ]
}

fn env_refs(env: &[(String, String)]) -> Vec<(&str, &str)> {
    env.iter()
        .map(|(key, value)| (key.as_str(), value.as_str()))
        .collect()
}

fn current_subject(repo: &TestRepo) -> String {
    let output = repo.git(&["log", "-1", "--format=%s"]);
    String::from_utf8_lossy(&output.stdout).trim().to_string()
}

#[test]
fn create_ai_all_yes_uses_generated_branch_and_commit_message() {
    let repo = TestRepo::new();
    repo.create_file("src/lib.rs", "pub fn answer() -> i32 { 42 }\n");

    let home = TempDir::new().expect("create home");
    write_test_config_with_ai(home.path());
    let prompt_log = home.path().join("prompt.txt");
    let bin_dir = write_fake_claude(
        home.path(),
        r#"{"branch":"add-answer-helper","message":"Add answer helper"}"#,
        &prompt_log,
    );
    let env = ai_env(home.path(), &bin_dir);
    let env = env_refs(&env);

    let output = repo.run_stax_with_env(&["create", "--ai", "-a", "--yes"], &env);

    assert!(
        output.status.success(),
        "create failed\nstdout: {}\nstderr: {}",
        TestRepo::stdout(&output),
        TestRepo::stderr(&output)
    );
    assert_eq!(repo.current_branch(), "add-answer-helper");
    assert_eq!(current_subject(&repo), "Add answer helper");

    let prompt = fs::read_to_string(prompt_log).expect("read prompt");
    assert!(prompt.contains("\"branch\" and \"message\""));
    assert!(prompt.contains("src/lib.rs"));
}

#[test]
fn create_named_ai_all_yes_uses_name_and_generated_commit_message() {
    let repo = TestRepo::new();
    repo.create_file("src/lib.rs", "pub fn feature() {}\n");

    let home = TempDir::new().expect("create home");
    write_test_config_with_ai(home.path());
    let prompt_log = home.path().join("prompt.txt");
    let bin_dir = write_fake_claude(
        home.path(),
        r#"{"message":"Add named feature"}"#,
        &prompt_log,
    );
    let env = ai_env(home.path(), &bin_dir);
    let env = env_refs(&env);

    let output = repo.run_stax_with_env(&["create", "manual-feature", "--ai", "-a", "--yes"], &env);

    assert!(
        output.status.success(),
        "create failed\nstdout: {}\nstderr: {}",
        TestRepo::stdout(&output),
        TestRepo::stderr(&output)
    );
    assert_eq!(repo.current_branch(), "manual-feature");
    assert_eq!(current_subject(&repo), "Add named feature");

    let prompt = fs::read_to_string(prompt_log).expect("read prompt");
    assert!(prompt.contains("\"message\""));
    assert!(!prompt.contains("\"branch\" and \"message\""));
}

#[test]
fn create_ai_manual_message_yes_uses_generated_branch_and_manual_commit_message() {
    let repo = TestRepo::new();
    repo.create_file("src/lib.rs", "pub fn manual_message() {}\n");

    let home = TempDir::new().expect("create home");
    write_test_config_with_ai(home.path());
    let prompt_log = home.path().join("prompt.txt");
    let bin_dir = write_fake_claude(
        home.path(),
        r#"{"branch":"manual-message-branch"}"#,
        &prompt_log,
    );
    let env = ai_env(home.path(), &bin_dir);
    let env = env_refs(&env);

    let output = repo.run_stax_with_env(
        &["create", "--ai", "-a", "-m", "Keep manual message", "--yes"],
        &env,
    );

    assert!(
        output.status.success(),
        "create failed\nstdout: {}\nstderr: {}",
        TestRepo::stdout(&output),
        TestRepo::stderr(&output)
    );
    assert_eq!(repo.current_branch(), "manual-message-branch");
    assert_eq!(current_subject(&repo), "Keep manual message");

    let prompt = fs::read_to_string(prompt_log).expect("read prompt");
    assert!(prompt.contains("\"branch\""));
    assert!(!prompt.contains("\"branch\" and \"message\""));
}

#[test]
fn create_ai_yes_without_staging_generates_branch_only() {
    let repo = TestRepo::new();
    let original_head = repo.head_sha();
    repo.create_file("src/lib.rs", "pub fn uncommitted() {}\n");

    let home = TempDir::new().expect("create home");
    write_test_config_with_ai(home.path());
    let prompt_log = home.path().join("prompt.txt");
    let bin_dir = write_fake_claude(home.path(), r#"{"branch":"branch-only-ai"}"#, &prompt_log);
    let env = ai_env(home.path(), &bin_dir);
    let env = env_refs(&env);

    let output = repo.run_stax_with_env(&["create", "--ai", "--yes"], &env);

    assert!(
        output.status.success(),
        "create failed\nstdout: {}\nstderr: {}",
        TestRepo::stdout(&output),
        TestRepo::stderr(&output)
    );
    assert_eq!(repo.current_branch(), "branch-only-ai");
    assert_eq!(repo.head_sha(), original_head);

    let status = repo.git(&["status", "--short", "--untracked-files=all"]);
    let status = String::from_utf8_lossy(&status.stdout);
    assert!(status.contains("src/lib.rs"));

    let prompt = fs::read_to_string(prompt_log).expect("read prompt");
    assert!(prompt.contains("\"branch\""));
    assert!(!prompt.contains("\"message\""));
}