Codex Handbook
core/tests/suite/cli_stream.rs 787 lines
use codex_git_utils::collect_git_info;use codex_login::CODEX_ACCESS_TOKEN_ENV_VAR;use codex_login::CODEX_API_KEY_ENV_VAR;use codex_protocol::protocol::GitInfo;use core_test_support::fs_wait;use core_test_support::responses;use core_test_support::skip_if_no_network;use pretty_assertions::assert_eq;use std::io;#[cfg(unix)]use std::os::unix::process::CommandExt as _;use std::process::Command;use std::process::Output;use std::process::Stdio;use std::sync::mpsc;use std::thread;use std::time::Duration;use tempfile::TempDir;use uuid::Uuid;use wiremock::Mock;use wiremock::MockServer;use wiremock::ResponseTemplate;use wiremock::matchers::header;use wiremock::matchers::method;use wiremock::matchers::path;const PERSONAL_ACCESS_TOKEN: &str = "at-cli-test";const PERSONAL_ACCESS_TOKEN_AUTHORIZATION: &str = "Bearer at-cli-test";const PERSONAL_ACCESS_TOKEN_ACCOUNT_ID: &str = "account-pat";const WHOAMI_PATH: &str = "/v1/user-auth-credential/whoami";const CLOUD_CONFIG_BUNDLE_PATH: &str = "/backend-api/wham/config/bundle";const CLI_TIMEOUT: Duration = Duration::from_secs(30);fn repo_root() -> std::path::PathBuf {    codex_utils_cargo_bin::repo_root().expect("failed to resolve repo root")}fn cli_sse_response() -> String {    responses::sse(vec![        responses::ev_response_created("resp-fixture"),        responses::ev_assistant_message("msg-fixture", "fixture hello"),        responses::ev_completed("resp-fixture"),    ])}async fn mount_personal_access_token_startup(server: &MockServer) {    Mock::given(method("GET"))        .and(path(WHOAMI_PATH))        .and(header("authorization", PERSONAL_ACCESS_TOKEN_AUTHORIZATION))        .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({            "email": "user@example.com",            "chatgpt_user_id": "user-pat",            "chatgpt_account_id": PERSONAL_ACCESS_TOKEN_ACCOUNT_ID,            "chatgpt_plan_type": "enterprise",            "chatgpt_account_is_fedramp": true,        })))        .expect(1..)        .mount(server)        .await;    Mock::given(method("GET"))        .and(path(CLOUD_CONFIG_BUNDLE_PATH))        .and(header("authorization", PERSONAL_ACCESS_TOKEN_AUTHORIZATION))        .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))        .expect(1)        .mount(server)        .await;}#[expect(clippy::unwrap_used)]fn personal_access_token_exec_command(server: &MockServer, home: &TempDir) -> Command {    let bin = codex_utils_cargo_bin::cargo_bin("codex").unwrap();    let mut cmd = Command::new(bin);    cmd.arg("exec")        .arg("--skip-git-repo-check")        .arg("-c")        .arg(format!("openai_base_url=\"{}/api/codex\"", server.uri()))        .arg("-c")        .arg(format!("chatgpt_base_url=\"{}/backend-api\"", server.uri()))        .arg("-C")        .arg(repo_root())        .arg("hello?");    cmd.env("CODEX_HOME", home.path())        .env(CODEX_ACCESS_TOKEN_ENV_VAR, PERSONAL_ACCESS_TOKEN)        .env("CODEX_AUTHAPI_BASE_URL", server.uri())        .env_remove(CODEX_API_KEY_ENV_VAR)        .env_remove("OPENAI_API_KEY");    cmd}struct ChildProcessCleanupGuard(u32);impl Drop for ChildProcessCleanupGuard {    fn drop(&mut self) {        #[cfg(unix)]        {            let _ = codex_utils_pty::process_group::kill_process_group(self.0);        }        #[cfg(windows)]        {            let _ = Command::new("taskkill")                .args(["/PID", &self.0.to_string(), "/T", "/F"])                .stdin(Stdio::null())                .stdout(Stdio::null())                .stderr(Stdio::null())                .status();        }        #[cfg(not(any(unix, windows)))]        {            let _ = self.0;        }    }}// Use this for new `codex exec` subprocess tests in this file. These commands// can spawn shell/Python grandchildren, so the timeout path must reap the whole// process group instead of only the direct CLI child.fn run_cli_command(command: &mut Command) -> io::Result<Output> {    #[cfg(unix)]    command.process_group(0);    command        .stdin(Stdio::null())        .stdout(Stdio::piped())        .stderr(Stdio::piped());    let child = command.spawn()?;    let _cleanup = ChildProcessCleanupGuard(child.id());    let (sender, receiver) = mpsc::sync_channel(1);    let _waiter = thread::spawn(move || {        let _ = sender.send(child.wait_with_output());    });    match receiver.recv_timeout(CLI_TIMEOUT) {        Ok(output) => output,        Err(mpsc::RecvTimeoutError::Timeout) => {            Err(io::Error::new(io::ErrorKind::TimedOut, "process timed out"))        }        Err(mpsc::RecvTimeoutError::Disconnected) => {            Err(io::Error::other("process output reader thread exited"))        }    }}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn responses_mode_stream_cli_supports_personal_access_tokens() {    skip_if_no_network!();    let server = MockServer::start().await;    mount_personal_access_token_startup(&server).await;    let resp_mock = responses::mount_sse_once(&server, cli_sse_response()).await;    let home = TempDir::new().unwrap();    let mut cmd = personal_access_token_exec_command(&server, &home);    let output = run_cli_command(&mut cmd).unwrap();    assert!(        output.status.success(),        "codex-cli exec failed: {}",        String::from_utf8_lossy(&output.stderr)    );    let request = resp_mock.single_request();    assert_eq!(request.path(), "/api/codex/responses");    assert_eq!(        request.header("authorization").as_deref(),        Some("Bearer at-cli-test")    );    assert_eq!(        request.header("chatgpt-account-id").as_deref(),        Some(PERSONAL_ACCESS_TOKEN_ACCOUNT_ID)    );    assert_eq!(request.header("x-openai-fedramp").as_deref(), Some("true"));    server.verify().await;}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn responses_mode_stream_cli_does_not_attempt_oauth_refresh_for_personal_access_tokens_after_401() {    skip_if_no_network!();    let server = MockServer::start().await;    mount_personal_access_token_startup(&server).await;    Mock::given(method("POST"))        .and(path("/api/codex/responses"))        .and(header("authorization", PERSONAL_ACCESS_TOKEN_AUTHORIZATION))        .and(header(            "chatgpt-account-id",            PERSONAL_ACCESS_TOKEN_ACCOUNT_ID,        ))        .and(header("x-openai-fedramp", "true"))        .respond_with(ResponseTemplate::new(401).set_body_string("unauthorized"))        .expect(1..)        .mount(&server)        .await;    Mock::given(method("POST"))        .and(path("/oauth/token"))        .respond_with(ResponseTemplate::new(500))        .expect(0)        .mount(&server)        .await;    let home = TempDir::new().unwrap();    let mut cmd = personal_access_token_exec_command(&server, &home);    let output = run_cli_command(&mut cmd).unwrap();    assert!(!output.status.success());    server.verify().await;}/// Tests streaming the Responses API through the CLI using a mock server.#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn responses_mode_stream_cli() {    skip_if_no_network!();    let server = MockServer::start().await;    let repo_root = repo_root();    let sse = responses::sse(vec![        responses::ev_response_created("resp-1"),        responses::ev_assistant_message("msg-1", "hi"),        responses::ev_completed("resp-1"),    ]);    let resp_mock = responses::mount_sse_once(&server, sse).await;    let home = TempDir::new().unwrap();    let provider_override = format!(        "model_providers.mock={{ name = \"mock\", base_url = \"{}/v1\", env_key = \"PATH\", wire_api = \"responses\" }}",        server.uri()    );    let bin = codex_utils_cargo_bin::cargo_bin("codex").unwrap();    let mut cmd = Command::new(bin);    cmd.arg("exec")        .arg("--skip-git-repo-check")        .arg("-c")        .arg(&provider_override)        .arg("-c")        .arg("model_provider=\"mock\"")        .arg("-C")        .arg(&repo_root)        .arg("hello?");    cmd.env("CODEX_HOME", home.path())        .env("OPENAI_API_KEY", "dummy");    let output = run_cli_command(&mut cmd).unwrap();    println!("Status: {}", output.status);    println!("Stdout:\n{}", String::from_utf8_lossy(&output.stdout));    println!("Stderr:\n{}", String::from_utf8_lossy(&output.stderr));    assert!(output.status.success());    let stdout = String::from_utf8_lossy(&output.stdout);    let hi_lines = stdout.lines().filter(|line| line.trim() == "hi").count();    assert_eq!(hi_lines, 1, "Expected exactly one line with 'hi'");    let request = resp_mock.single_request();    assert_eq!(request.path(), "/v1/responses");}/// Ensures `openai_base_url` config override routes built-in openai provider requests.#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn responses_mode_stream_cli_supports_openai_base_url_config_override() {    skip_if_no_network!();    let server = MockServer::start().await;    let repo_root = repo_root();    let sse = responses::sse(vec![        responses::ev_response_created("resp-1"),        responses::ev_assistant_message("msg-1", "hi"),        responses::ev_completed("resp-1"),    ]);    let resp_mock = responses::mount_sse_once(&server, sse).await;    let home = TempDir::new().unwrap();    let bin = codex_utils_cargo_bin::cargo_bin("codex").unwrap();    let mut cmd = Command::new(bin);    cmd.arg("exec")        .arg("--skip-git-repo-check")        .arg("-c")        .arg(format!("openai_base_url=\"{}/v1\"", server.uri()))        .arg("-C")        .arg(&repo_root)        .arg("hello?");    cmd.env("CODEX_HOME", home.path())        .env("OPENAI_API_KEY", "dummy");    let output = run_cli_command(&mut cmd).unwrap();    assert!(output.status.success());    let request = resp_mock.single_request();    assert_eq!(request.path(), "/v1/responses");}/// Verify that passing `-c model_instructions_file=...` to the CLI/// overrides the built-in base instructions by inspecting the request body/// received by a mock OpenAI Responses endpoint.#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn exec_cli_applies_model_instructions_file() {    skip_if_no_network!();    // Start mock server which will capture the request and return a minimal    // SSE stream for a single turn.    let server = MockServer::start().await;    let sse = concat!(        "data: {\"type\":\"response.created\",\"response\":{}}\n\n",        "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"r1\"}}\n\n"    );    let resp_mock = core_test_support::responses::mount_sse_once(&server, sse.to_string()).await;    // Create a temporary instructions file with a unique marker we can assert    // appears in the outbound request payload.    let custom = TempDir::new().unwrap();    let marker = "cli-model-instructions-file-marker";    let custom_path = custom.path().join("instr.md");    std::fs::write(&custom_path, marker).unwrap();    let custom_path_str = custom_path.to_string_lossy().replace('\\', "/");    // Build a provider override that points at the mock server and instructs    // Codex to use the Responses API with the dummy env var.    let provider_override = format!(        "model_providers.mock={{ name = \"mock\", base_url = \"{}/v1\", env_key = \"PATH\", wire_api = \"responses\" }}",        server.uri()    );    let home = TempDir::new().unwrap();    let repo_root = repo_root();    let bin = codex_utils_cargo_bin::cargo_bin("codex").unwrap();    let mut cmd = Command::new(bin);    cmd.arg("exec")        .arg("--skip-git-repo-check")        .arg("-c")        .arg(&provider_override)        .arg("-c")        .arg("model_provider=\"mock\"")        .arg("-c")        .arg(format!("model_instructions_file=\"{custom_path_str}\""))        .arg("-C")        .arg(&repo_root)        .arg("hello?\n");    cmd.env("CODEX_HOME", home.path())        .env("OPENAI_API_KEY", "dummy");    let output = run_cli_command(&mut cmd).unwrap();    println!("Status: {}", output.status);    println!("Stdout:\n{}", String::from_utf8_lossy(&output.stdout));    println!("Stderr:\n{}", String::from_utf8_lossy(&output.stderr));    assert!(output.status.success());    // Inspect the captured request and verify our custom base instructions were    // included in the `instructions` field.    let request = resp_mock.single_request();    let body = request.body_json();    let instructions = body        .get("instructions")        .and_then(|v| v.as_str())        .unwrap_or_default()        .to_string();    assert!(        instructions.contains(marker),        "instructions did not contain custom marker; got: {instructions}"    );}/// Verify that `codex exec --profile ...` preserves the active user config/// profile when it starts the in-process app-server thread, so the selected/// profile's `model_instructions_file` reaches the outbound request.#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn exec_cli_profile_applies_model_instructions_file() {    skip_if_no_network!();    let server = MockServer::start().await;    let sse = concat!(        "data: {\"type\":\"response.created\",\"response\":{}}\n\n",        "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"r1\"}}\n\n"    );    let resp_mock = core_test_support::responses::mount_sse_once(&server, sse.to_string()).await;    let custom = TempDir::new().unwrap();    let marker = "cli-profile-model-instructions-file-marker";    let custom_path = custom.path().join("instr.md");    std::fs::write(&custom_path, marker).unwrap();    let custom_path_str = custom_path.to_string_lossy().replace('\\', "/");    let provider_override = format!(        "model_providers.mock={{ name = \"mock\", base_url = \"{}/v1\", env_key = \"PATH\", wire_api = \"responses\" }}",        server.uri()    );    let home = TempDir::new().unwrap();    std::fs::write(        home.path().join("default.config.toml"),        format!("model_instructions_file = \"{custom_path_str}\"\n"),    )    .unwrap();    let repo_root = repo_root();    let bin = codex_utils_cargo_bin::cargo_bin("codex").unwrap();    let mut cmd = Command::new(bin);    cmd.arg("exec")        .arg("--skip-git-repo-check")        .arg("--profile")        .arg("default")        .arg("-c")        .arg(&provider_override)        .arg("-c")        .arg("model_provider=\"mock\"")        .arg("-C")        .arg(&repo_root)        .arg("hello?\n");    cmd.env("CODEX_HOME", home.path())        .env("OPENAI_API_KEY", "dummy");    let output = run_cli_command(&mut cmd).unwrap();    println!("Status: {}", output.status);    println!("Stdout:\n{}", String::from_utf8_lossy(&output.stdout));    println!("Stderr:\n{}", String::from_utf8_lossy(&output.stderr));    assert!(output.status.success());    let request = resp_mock.single_request();    let body = request.body_json();    let instructions = body        .get("instructions")        .and_then(|v| v.as_str())        .unwrap_or_default()        .to_string();    assert!(        instructions.contains(marker),        "instructions did not contain profile marker; got: {instructions}"    );}/// Tests streaming responses through the CLI using a local Responses API server.#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn responses_api_stream_cli() {    skip_if_no_network!();    let server = MockServer::start().await;    let resp_mock = responses::mount_sse_once(&server, cli_sse_response()).await;    let repo_root = repo_root();    let home = TempDir::new().unwrap();    let bin = codex_utils_cargo_bin::cargo_bin("codex").unwrap();    let mut cmd = Command::new(bin);    cmd.arg("exec")        .arg("--skip-git-repo-check")        .arg("-c")        .arg(format!("openai_base_url=\"{}/v1\"", server.uri()))        .arg("-C")        .arg(&repo_root)        .arg("hello?");    cmd.env("CODEX_HOME", home.path())        .env("OPENAI_API_KEY", "dummy");    let output = run_cli_command(&mut cmd).unwrap();    assert!(output.status.success());    let stdout = String::from_utf8_lossy(&output.stdout);    assert!(stdout.contains("fixture hello"));    let request = resp_mock.single_request();    assert_eq!(request.path(), "/v1/responses");}/// End-to-end: create a session (writes rollout), verify the file, then resume and confirm append.#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn integration_creates_and_checks_session_file() -> anyhow::Result<()> {    // Honor sandbox network restrictions for CI parity with the other tests.    skip_if_no_network!(Ok(()));    // 1. Temp home so we read/write isolated session files.    let home = TempDir::new()?;    // 2. Unique marker we'll look for in the session log.    let marker = format!("integration-test-{}", Uuid::new_v4());    let prompt = format!("echo {marker}");    // 3. Serve two hermetic SSE responses, one for the initial run and one for resume.    let server = MockServer::start().await;    let resp_mock =        responses::mount_sse_sequence(&server, vec![cli_sse_response(), cli_sse_response()]).await;    let repo_root = repo_root();    // 4. Run the codex CLI and invoke `exec`, which is what records a session.    let bin = codex_utils_cargo_bin::cargo_bin("codex").unwrap();    let mut cmd = Command::new(bin);    cmd.arg("exec")        .arg("--skip-git-repo-check")        .arg("-c")        .arg(format!("openai_base_url=\"{}/v1\"", server.uri()))        .arg("-C")        .arg(&repo_root)        .arg(&prompt);    cmd.env("CODEX_HOME", home.path())        .env(CODEX_API_KEY_ENV_VAR, "dummy");    let output = run_cli_command(&mut cmd).unwrap();    assert!(        output.status.success(),        "codex-cli exec failed: {}",        String::from_utf8_lossy(&output.stderr)    );    // Wait for sessions dir to appear.    let sessions_dir = home.path().join("sessions");    fs_wait::wait_for_path_exists(&sessions_dir, Duration::from_secs(5)).await?;    // Find the session file that contains `marker`.    let marker_clone = marker.clone();    let path = fs_wait::wait_for_matching_file(&sessions_dir, Duration::from_secs(10), move |p| {        if p.extension().and_then(|ext| ext.to_str()) != Some("jsonl") {            return false;        }        let Ok(content) = std::fs::read_to_string(p) else {            return false;        };        content.contains(&marker_clone)    })    .await?;    // Basic sanity checks on location and metadata.    let rel = path        .strip_prefix(&sessions_dir)        .expect("session file should live under sessions/");    let comps: Vec<String> = rel        .components()        .map(|c| c.as_os_str().to_string_lossy().into_owned())        .collect();    assert_eq!(        comps.len(),        4,        "Expected sessions/YYYY/MM/DD/<file>, got {rel:?}"    );    let year = &comps[0];    let month = &comps[1];    let day = &comps[2];    assert!(        year.len() == 4 && year.chars().all(|c| c.is_ascii_digit()),        "Year dir not 4-digit numeric: {year}"    );    assert!(        month.len() == 2 && month.chars().all(|c| c.is_ascii_digit()),        "Month dir not zero-padded 2-digit numeric: {month}"    );    assert!(        day.len() == 2 && day.chars().all(|c| c.is_ascii_digit()),        "Day dir not zero-padded 2-digit numeric: {day}"    );    if let Ok(m) = month.parse::<u8>() {        assert!((1..=12).contains(&m), "Month out of range: {m}");    }    if let Ok(d) = day.parse::<u8>() {        assert!((1..=31).contains(&d), "Day out of range: {d}");    }    let content = std::fs::read_to_string(&path).expect("failed to read session file");    let mut lines = content.lines();    let meta_line = lines        .next()        .ok_or("missing session meta line")        .expect("missing session meta line");    let meta: serde_json::Value =        serde_json::from_str(meta_line).expect("failed to parse session meta line as JSON");    assert_eq!(        meta.get("type").and_then(|v| v.as_str()),        Some("session_meta")    );    let payload = meta.get("payload").expect("Missing payload in meta line");    assert!(payload.get("id").is_some(), "SessionMeta missing id");    assert!(        payload.get("timestamp").is_some(),        "SessionMeta missing timestamp"    );    let mut found_message = false;    for line in lines {        if line.trim().is_empty() {            continue;        }        let Ok(item) = serde_json::from_str::<serde_json::Value>(line) else {            continue;        };        if item.get("type").and_then(|t| t.as_str()) == Some("response_item")            && let Some(payload) = item.get("payload")            && payload.get("type").and_then(|t| t.as_str()) == Some("message")            && let Some(c) = payload.get("content")            && c.to_string().contains(&marker)        {            found_message = true;            break;        }    }    assert!(        found_message,        "No message found in session file containing the marker"    );    // Second run: resume should update the existing file.    let marker2 = format!("integration-resume-{}", Uuid::new_v4());    let prompt2 = format!("echo {marker2}");    let bin2 = codex_utils_cargo_bin::cargo_bin("codex").unwrap();    let mut cmd2 = Command::new(bin2);    cmd2.arg("exec")        .arg("--skip-git-repo-check")        .arg("-c")        .arg(format!("openai_base_url=\"{}/v1\"", server.uri()))        .arg("-C")        .arg(&repo_root)        .arg(&prompt2)        .arg("resume")        .arg("--last");    cmd2.env("CODEX_HOME", home.path())        .env("OPENAI_API_KEY", "dummy");    let output2 = run_cli_command(&mut cmd2).unwrap();    assert!(output2.status.success(), "resume codex-cli run failed");    assert_eq!(resp_mock.requests().len(), 2);    // Find the new session file containing the resumed marker.    let marker2_clone = marker2.clone();    let resumed_path =        fs_wait::wait_for_matching_file(&sessions_dir, Duration::from_secs(10), move |p| {            if p.extension().and_then(|ext| ext.to_str()) != Some("jsonl") {                return false;            }            std::fs::read_to_string(p)                .map(|content| content.contains(&marker2_clone))                .unwrap_or(false)        })        .await?;    // Resume should write to the existing log file.    assert_eq!(        resumed_path, path,        "resume should create a new session file"    );    let resumed_content = std::fs::read_to_string(&resumed_path)?;    assert!(        resumed_content.contains(&marker),        "resumed file missing original marker"    );    assert!(        resumed_content.contains(&marker2),        "resumed file missing resumed marker"    );    Ok(())}/// Integration test to verify git info is collected and recorded in session files.#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn integration_git_info_unit_test() {    // This test verifies git info collection works independently    // without depending on the full CLI integration    // 1. Create temp directory for git repo    let temp_dir = TempDir::new().unwrap();    let git_repo = temp_dir.path().to_path_buf();    let envs = vec![        ("GIT_CONFIG_GLOBAL", "/dev/null"),        ("GIT_CONFIG_NOSYSTEM", "1"),    ];    // 2. Initialize a git repository with some content    let init_output = std::process::Command::new("git")        .envs(envs.clone())        .args(["init"])        .current_dir(&git_repo)        .output()        .unwrap();    assert!(init_output.status.success(), "git init failed");    // Configure git user (required for commits)    std::process::Command::new("git")        .envs(envs.clone())        .args(["config", "user.name", "Integration Test"])        .current_dir(&git_repo)        .output()        .unwrap();    std::process::Command::new("git")        .envs(envs.clone())        .args(["config", "user.email", "test@example.com"])        .current_dir(&git_repo)        .output()        .unwrap();    // Create a test file and commit it    let test_file = git_repo.join("test.txt");    std::fs::write(&test_file, "integration test content").unwrap();    std::process::Command::new("git")        .envs(envs.clone())        .args(["add", "."])        .current_dir(&git_repo)        .output()        .unwrap();    let commit_output = std::process::Command::new("git")        .envs(envs.clone())        .args(["commit", "-m", "Integration test commit"])        .current_dir(&git_repo)        .output()        .unwrap();    assert!(commit_output.status.success(), "git commit failed");    // Create a branch to test branch detection    std::process::Command::new("git")        .envs(envs.clone())        .args(["checkout", "-b", "integration-test-branch"])        .current_dir(&git_repo)        .output()        .unwrap();    // Add a remote to test repository URL detection    std::process::Command::new("git")        .envs(envs.clone())        .args([            "remote",            "add",            "origin",            "https://github.com/example/integration-test.git",        ])        .current_dir(&git_repo)        .output()        .unwrap();    // 3. Test git info collection directly    let git_info = collect_git_info(&git_repo).await;    // 4. Verify git info is present and contains expected data    assert!(git_info.is_some(), "Git info should be collected");    let git_info = git_info.unwrap();    // Check that we have a commit hash    assert!(        git_info.commit_hash.is_some(),        "Git info should contain commit_hash"    );    let commit_hash = &git_info.commit_hash.as_ref().unwrap().0;    assert_eq!(commit_hash.len(), 40, "Commit hash should be 40 characters");    assert!(        commit_hash.chars().all(|c| c.is_ascii_hexdigit()),        "Commit hash should be hexadecimal"    );    // Check that we have the correct branch    assert!(git_info.branch.is_some(), "Git info should contain branch");    let branch = git_info.branch.as_ref().unwrap();    assert_eq!(        branch, "integration-test-branch",        "Branch should match what we created"    );    // Check that we have the repository URL    assert!(        git_info.repository_url.is_some(),        "Git info should contain repository_url"    );    let repo_url = git_info.repository_url.as_ref().unwrap();    // Some hosts rewrite remotes (e.g., github.com → git@github.com), so assert against    // the actual remote reported by git instead of a static URL.    let expected_remote_url = std::process::Command::new("git")        .args(["remote", "get-url", "origin"])        .current_dir(&git_repo)        .output()        .unwrap();    let expected_remote_url = String::from_utf8(expected_remote_url.stdout)        .unwrap()        .trim()        .to_string();    assert_eq!(        repo_url, &expected_remote_url,        "Repository URL should match git remote get-url output"    );    println!("✅ Git info collection test passed!");    println!("   Commit: {commit_hash}");    println!("   Branch: {branch}");    println!("   Repo: {repo_url}");    // 5. Test serialization to ensure it works in SessionMeta    let serialized = serde_json::to_string(&git_info).unwrap();    let deserialized: GitInfo = serde_json::from_str(&serialized).unwrap();    assert_eq!(git_info.commit_hash, deserialized.commit_hash);    assert_eq!(git_info.branch, deserialized.branch);    assert_eq!(git_info.repository_url, deserialized.repository_url);    println!("✅ Git info serialization test passed!");}