opencodecommit 1.6.0

AI-powered git commit message generator that delegates to terminal AI agents
Documentation
#![allow(dead_code)]
use std::env;
use std::fs::{self, OpenOptions};
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use std::time::{SystemTime, UNIX_EPOCH};

use regex::Regex;

#[derive(Debug, Clone)]
pub struct E2eEnv {
    pub mode: String,
    pub config_path: PathBuf,
    pub active_backends: Vec<String>,
}

pub fn load_env() -> Option<E2eEnv> {
    let mode = env::var("OCC_E2E_MODE").ok()?;
    let config_path = PathBuf::from(env::var("OCC_E2E_CONFIG_PATH").ok()?);
    let active_backends = env::var("OCC_E2E_ACTIVE_BACKENDS")
        .unwrap_or_default()
        .split(',')
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned)
        .collect::<Vec<_>>();

    Some(E2eEnv {
        mode,
        config_path,
        active_backends,
    })
}

pub fn repo_root() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .and_then(Path::parent)
        .expect("workspace root")
        .to_path_buf()
}

pub fn occ_bin() -> PathBuf {
    env::var_os("CARGO_BIN_EXE_occ")
        .map(PathBuf::from)
        .unwrap_or_else(|| repo_root().join("target/debug/occ"))
}

fn unique_path(prefix: &str) -> PathBuf {
    let ts = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("clock")
        .as_nanos();
    env::temp_dir().join(format!("occ-{prefix}-{}-{ts}", std::process::id()))
}

fn run_git(repo: &Path, args: &[&str]) {
    let status = Command::new("git")
        .args(args)
        .current_dir(repo)
        .env("GIT_AUTHOR_NAME", "OpenCodeCommit E2E")
        .env("GIT_AUTHOR_EMAIL", "e2e@example.com")
        .env("GIT_COMMITTER_NAME", "OpenCodeCommit E2E")
        .env("GIT_COMMITTER_EMAIL", "e2e@example.com")
        .status()
        .expect("git command to run");
    assert!(status.success(), "git {:?} failed", args);
}

fn git_stdout(repo: &Path, args: &[&str]) -> String {
    let output = Command::new("git")
        .args(args)
        .current_dir(repo)
        .output()
        .expect("git command to run");
    assert!(output.status.success(), "git {:?} failed", args);
    String::from_utf8_lossy(&output.stdout).trim().to_owned()
}

#[derive(Debug)]
pub struct FixtureRepo {
    pub path: PathBuf,
}

impl FixtureRepo {
    pub fn new(name: &str) -> Self {
        let path = unique_path(name);
        fs::create_dir_all(path.join("src")).expect("fixture src dir");
        fs::create_dir_all(path.join("docs")).expect("fixture docs dir");

        run_git(&path, &["init", "-q"]);
        run_git(&path, &["config", "user.name", "OpenCodeCommit E2E"]);
        run_git(&path, &["config", "user.email", "e2e@example.com"]);

        fs::write(
            path.join("src/app.ts"),
            "export function add(left: number, right: number): number {\n  return left + right\n}\n",
        )
        .expect("seed app.ts");
        fs::write(path.join("README.md"), "# OpenCodeCommit E2E Fixture\n").expect("seed README");

        run_git(&path, &["add", "README.md", "src/app.ts"]);
        run_git(&path, &["commit", "-q", "-m", "chore: seed e2e fixture"]);
        run_git(&path, &["checkout", "-q", "-b", "feature/e2e-coverage"]);

        fs::write(
            path.join("src/app.ts"),
            "export function add(left: number, right: number): number {\n  return left + right\n}\n\nexport function subtract(left: number, right: number): number {\n  return left - right\n}\n",
        )
        .expect("staged app.ts");
        fs::write(
            path.join("docs/notes.md"),
            "- add subtract helper\n- document staging verification\n",
        )
        .expect("staged notes.md");
        run_git(&path, &["add", "src/app.ts", "docs/notes.md"]);

        fs::write(
            path.join("src/app.ts"),
            "export function add(left: number, right: number): number {\n  return left + right\n}\n\nexport function subtract(left: number, right: number): number {\n  return left - right\n}\n\nexport function multiply(left: number, right: number): number {\n  return left * right\n}\n",
        )
        .expect("unstaged app.ts");

        Self { path }
    }

    pub fn staged_diff(&self) -> String {
        git_stdout(&self.path, &["diff", "--cached"])
    }

    pub fn hook_path(&self) -> PathBuf {
        self.path.join(".git/hooks/prepare-commit-msg")
    }
}

impl Drop for FixtureRepo {
    fn drop(&mut self) {
        let _ = fs::remove_dir_all(&self.path);
    }
}

pub fn run_occ(repo: &Path, args: &[&str], stdin: Option<&str>) -> Output {
    let mut command = Command::new(occ_bin());
    command
        .current_dir(repo)
        .args(args)
        .env("NO_COLOR", "1")
        .env_remove("CLICOLOR")
        .env_remove("CLICOLOR_FORCE")
        .stdout(Stdio::piped())
        .stderr(Stdio::piped());

    if stdin.is_some() {
        command.stdin(Stdio::piped());
    }

    let mut child = command.spawn().expect("spawn occ");
    if let Some(input) = stdin {
        use std::io::Write as _;
        child
            .stdin
            .as_mut()
            .expect("stdin pipe")
            .write_all(input.as_bytes())
            .expect("write stdin");
    }

    child.wait_with_output().expect("wait for occ")
}

pub fn stdout(output: &Output) -> String {
    String::from_utf8_lossy(&output.stdout).trim().to_owned()
}

pub fn stderr(output: &Output) -> String {
    String::from_utf8_lossy(&output.stderr).trim().to_owned()
}

pub fn append_response_log(
    platform: &str,
    test_case: &str,
    operation: &str,
    backend: &str,
    response: &str,
) {
    let Some(path) = env::var_os("OCC_E2E_RESPONSE_LOG").map(PathBuf::from) else {
        return;
    };

    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).expect("create e2e response log dir");
    }

    let mut file = OpenOptions::new()
        .create(true)
        .append(true)
        .open(&path)
        .expect("open e2e response log");

    writeln!(file, "=== AI Response ===").expect("write response log header");
    writeln!(file, "platform: {platform}").expect("write response log platform");
    writeln!(file, "test: {test_case}").expect("write response log test");
    writeln!(file, "operation: {operation}").expect("write response log operation");
    writeln!(file, "backend: {backend}").expect("write response log backend");
    writeln!(file, "response:").expect("write response log label");
    writeln!(file, "{}", response.trim()).expect("write response log body");
    writeln!(file).expect("write response log separator");
}

pub fn assert_commit_shape(message: &str, conventional: bool) {
    let trimmed = message.trim();
    assert!(!trimmed.is_empty(), "commit output was empty");
    let first_line = trimmed.lines().next().unwrap_or_default();
    assert!(first_line.len() <= 72, "subject too long: {first_line}");
    assert!(
        !first_line.ends_with('.'),
        "subject should not end with a period: {first_line}"
    );
    if conventional {
        let re = Regex::new(
            r"^(feat|fix|docs|style|refactor|test|chore|perf|security|revert)(\([^)]+\))?!?: .+",
        )
        .unwrap();
        assert!(
            re.is_match(first_line),
            "invalid conventional commit: {first_line}"
        );
    }
}

pub fn assert_branch_shape(name: &str) {
    let re = Regex::new(r"^[a-z0-9][a-z0-9/_-]{2,79}$").unwrap();
    assert!(re.is_match(name.trim()), "invalid branch name: {name}");
}

pub fn assert_pr_shape(output: &str) {
    let mut sections = output.trim().splitn(2, "\n\n");
    let title = sections.next().unwrap_or_default().trim();
    let body = sections.next().unwrap_or_default().trim();
    assert!(!title.is_empty(), "PR title was empty");
    assert!(title.len() <= 80, "PR title too long: {title}");
    assert!(body.len() >= 20, "PR body too short: {body}");
    assert!(
        body.contains("## "),
        "PR body missing markdown heading: {body}"
    );
}

pub fn assert_changelog_shape(output: &str) {
    let re = Regex::new(r"(?m)^(?:(?:##|###)\s+)?(Added|Changed|Fixed|Removed)\b").unwrap();
    assert!(
        re.is_match(output.trim()),
        "changelog missing section heading: {output}"
    );
}

pub const TUI_BACKENDS: [(&str, char); 12] = [
    ("opencode", '1'),
    ("claude", '2'),
    ("codex", '3'),
    ("gemini", '4'),
    ("openai-api", '5'),
    ("anthropic-api", '6'),
    ("gemini-api", '7'),
    ("openrouter-api", '8'),
    ("opencode-api", '9'),
    ("ollama-api", 'a'),
    ("lm-studio-api", 'b'),
    ("custom-api", 'c'),
];