stax 0.50.2

Fast stacked Git branches and PRs
Documentation
mod common;

use common::{OutputAssertions, TestRepo};
use std::fs;
use std::path::PathBuf;

#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;

fn install_fake_codex(repo: &TestRepo, script: &str) -> (PathBuf, String) {
    let bin_dir = repo.path().join("fake-bin");
    fs::create_dir_all(&bin_dir).expect("Failed to create fake-bin directory");

    let codex_path = bin_dir.join("codex");
    fs::write(&codex_path, script).expect("Failed to write fake codex script");

    #[cfg(unix)]
    {
        let mut perms = fs::metadata(&codex_path)
            .expect("Failed to stat fake codex script")
            .permissions();
        perms.set_mode(0o755);
        fs::set_permissions(&codex_path, perms).expect("Failed to chmod fake codex script");
    }

    let old_path = std::env::var("PATH").unwrap_or_default();
    let path_env = format!("{}:{}", bin_dir.display(), old_path);
    (bin_dir, path_env)
}

fn setup_single_conflict(repo: &TestRepo) {
    repo.create_conflict_scenario();
    let _ = repo.run_stax(&["restack", "--yes", "--quiet"]);
    assert!(
        repo.has_rebase_in_progress(),
        "Expected an in-progress rebase conflict"
    );
}

fn setup_two_round_conflict(repo: &TestRepo) {
    repo.run_stax(&["bc", "multi-conflict"]).assert_success();
    let branch = repo.current_branch();

    repo.create_file("a.txt", "feature a\n");
    repo.commit("Feature adds a");
    repo.create_file("b.txt", "feature b\n");
    repo.commit("Feature adds b");

    repo.run_stax(&["t"]).assert_success();
    repo.create_file("a.txt", "main a\n");
    repo.create_file("b.txt", "main b\n");
    repo.commit("Main adds both files");

    repo.run_stax(&["checkout", &branch]).assert_success();
    let _ = repo.run_stax(&["restack", "--yes", "--quiet"]);
    assert!(
        repo.has_rebase_in_progress(),
        "Expected an in-progress rebase conflict"
    );
}

#[test]
fn test_resolve_no_rebase_in_progress() {
    let repo = TestRepo::new();
    repo.create_stack(&["feature-1"]);

    let output = repo.run_stax(&["resolve"]);
    output.assert_success();
    output.assert_stdout_contains("No rebase in progress.");
}

#[test]
fn test_resolve_success_single_round_with_fake_codex() {
    let repo = TestRepo::new();
    setup_single_conflict(&repo);

    let (_bin_dir, path_env) = install_fake_codex(
        &repo,
        r#"#!/bin/sh
cat >/dev/null
printf '%s' '{"resolutions":[{"path":"conflict.txt","content":"resolved content\nline 2\nline 3\n"}]}'
"#,
    );

    let output = repo.run_stax_with_env(
        &["resolve", "--agent", "codex", "--max-rounds", "3"],
        &[("PATH", path_env.as_str())],
    );
    output.assert_success();
    assert!(
        !repo.has_rebase_in_progress(),
        "Rebase should be completed after successful resolve"
    );
}

#[test]
fn test_resolve_invalid_json_fails_and_keeps_rebase_in_progress() {
    let repo = TestRepo::new();
    setup_single_conflict(&repo);

    let (_bin_dir, path_env) = install_fake_codex(
        &repo,
        r#"#!/bin/sh
cat >/dev/null
printf '%s' 'not-json'
"#,
    );

    let output = repo.run_stax_with_env(
        &["resolve", "--agent", "codex", "--max-rounds", "3"],
        &[("PATH", path_env.as_str())],
    );
    output.assert_failure();

    let stdout = TestRepo::stdout(&output);
    let stderr = TestRepo::stderr(&output);
    let combined = format!("{}{}", stdout, stderr);
    assert!(
        combined.contains("not valid JSON"),
        "Expected invalid JSON error, got: {}",
        combined
    );
    assert!(
        repo.has_rebase_in_progress(),
        "Rebase should still be in progress after JSON parse failure"
    );
}

#[test]
fn test_resolve_rejects_non_conflicted_file_paths() {
    let repo = TestRepo::new();
    setup_single_conflict(&repo);

    let (_bin_dir, path_env) = install_fake_codex(
        &repo,
        r#"#!/bin/sh
cat >/dev/null
printf '%s' '{"resolutions":[{"path":"not-conflicted.txt","content":"anything"}]}'
"#,
    );

    let output = repo.run_stax_with_env(
        &["resolve", "--agent", "codex", "--max-rounds", "3"],
        &[("PATH", path_env.as_str())],
    );
    output.assert_failure();

    let stdout = TestRepo::stdout(&output);
    let stderr = TestRepo::stderr(&output);
    let combined = format!("{}{}", stdout, stderr);
    assert!(
        combined.contains("non-conflicted file"),
        "Expected guardrail error, got: {}",
        combined
    );
    assert!(
        repo.has_rebase_in_progress(),
        "Rebase should still be in progress after guardrail failure"
    );
}

#[test]
fn test_resolve_rejects_unrelated_side_effect_file_changes() {
    let repo = TestRepo::new();
    setup_single_conflict(&repo);

    let (_bin_dir, path_env) = install_fake_codex(
        &repo,
        r#"#!/bin/sh
cat >/dev/null
echo 'side effect' > unexpected-side-effect.txt
printf '%s' '{"resolutions":[{"path":"conflict.txt","content":"resolved content\nline 2\nline 3\n"}]}'
"#,
    );

    let output = repo.run_stax_with_env(
        &["resolve", "--agent", "codex", "--max-rounds", "3"],
        &[("PATH", path_env.as_str())],
    );
    output.assert_failure();

    let stdout = TestRepo::stdout(&output);
    let stderr = TestRepo::stderr(&output);
    let combined = format!("{}{}", stdout, stderr);
    assert!(
        combined.contains("Detected edits outside conflicted files"),
        "Expected side-effect guardrail error, got: {}",
        combined
    );
    assert!(
        repo.has_rebase_in_progress(),
        "Rebase should still be in progress after side-effect guardrail failure"
    );
}

#[test]
fn test_resolve_max_rounds_exhaustion() {
    let repo = TestRepo::new();
    setup_two_round_conflict(&repo);

    let (_bin_dir, path_env) = install_fake_codex(
        &repo,
        r#"#!/bin/sh
INPUT="$(cat)"
FILE="$(printf '%s\n' "$INPUT" | sed -n 's/^FILE: //p' | head -n 1)"
if [ -z "$FILE" ]; then
  FILE="a.txt"
fi
printf '{"resolutions":[{"path":"%s","content":"resolved by fake agent\\n"}]}' "$FILE"
"#,
    );

    let output = repo.run_stax_with_env(
        &["resolve", "--agent", "codex", "--max-rounds", "1"],
        &[("PATH", path_env.as_str())],
    );
    output.assert_failure();

    let stdout = TestRepo::stdout(&output);
    let stderr = TestRepo::stderr(&output);
    let combined = format!("{}{}", stdout, stderr);
    assert!(
        combined.contains("Reached max rounds (1)"),
        "Expected max-rounds error, got: {}",
        combined
    );
    assert!(
        repo.has_rebase_in_progress(),
        "Rebase should still be in progress after max rounds exhaustion"
    );
}