stax 0.86.1

Fast stacked Git branches and PRs
Documentation
mod common;

use common::TestRepo;
use std::fs;
use std::process::{Command, Output};

const CHANGELOG_TEMPLATE: &str = r#"# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

<!-- next-header -->
## [Unreleased] - ReleaseDate

_placeholder_

## [0.1.0] - 2026-04-01

### Added
- Initial release.

<!-- next-url -->
[Unreleased]: https://github.com/cesarferreira/stax/compare/v0.1.0...HEAD
[0.1.0]: https://github.com/cesarferreira/stax/compare/v0.0.0...v0.1.0
"#;

fn run_release_prep(repo: &TestRepo) -> Output {
    run_release_prep_with_args(repo, &[])
}

fn run_release_prep_with_args(repo: &TestRepo, args: &[&str]) -> Output {
    let mut command = Command::new("python3");
    command
        .arg(format!(
            "{}/scripts/prepare_release.py",
            env!("CARGO_MANIFEST_DIR")
        ))
        .args(["--repo", repo.path().to_str().unwrap()])
        .args(args);
    command.output().expect("failed to run prepare_release.py")
}

fn run_release_prep_with_env(repo: &TestRepo, args: &[&str], env: &[(&str, &str)]) -> Output {
    let mut command = Command::new("python3");
    command
        .arg(format!(
            "{}/scripts/prepare_release.py",
            env!("CARGO_MANIFEST_DIR")
        ))
        .args(["--repo", repo.path().to_str().unwrap()])
        .args(args);

    for (key, value) in env {
        command.env(key, value);
    }

    command
        .output()
        .expect("failed to run prepare_release.py with env")
}

#[test]
fn test_prepare_release_generates_categorized_unreleased_notes() {
    let repo = TestRepo::new();
    repo.create_file("CHANGELOG.md", CHANGELOG_TEMPLATE);
    repo.commit("chore: add changelog");
    repo.git(&["tag", "v0.1.0"]);

    repo.create_file("feature.txt", "feature");
    repo.commit("feat(parser): add release generator (#12)");
    repo.create_file("fix.txt", "fix");
    repo.commit("fix: handle empty changelog (#13)");
    repo.create_file("docs.md", "docs");
    repo.commit("docs: document release automation");
    repo.create_file("refactor.txt", "refactor");
    repo.commit("refactor: simplify release pipeline");

    let output = run_release_prep(&repo);
    assert!(
        output.status.success(),
        "prepare_release.py failed: {}",
        TestRepo::stderr(&output)
    );

    let changelog = fs::read_to_string(repo.path().join("CHANGELOG.md")).expect("read changelog");

    assert!(changelog.contains("## [Unreleased] - ReleaseDate\n\n### Added"));
    assert!(changelog.contains("- Add release generator (#12)"));
    assert!(changelog.contains("### Changed\n- Simplify release pipeline"));
    assert!(changelog.contains("### Fixed\n- Handle empty changelog (#13)"));
    assert!(changelog.contains("### Documentation\n- Document release automation"));
    assert!(!changelog.contains("_placeholder_"));
}

#[test]
fn test_prepare_release_replaces_existing_unreleased_body() {
    let repo = TestRepo::new();
    repo.create_file("CHANGELOG.md", CHANGELOG_TEMPLATE);
    repo.commit("chore: add changelog");
    repo.git(&["tag", "v0.1.0"]);

    repo.create_file("bugfix.txt", "fix");
    repo.commit("fix(ui): keep release notes current");

    let output = run_release_prep(&repo);
    assert!(
        output.status.success(),
        "prepare_release.py failed: {}",
        TestRepo::stderr(&output)
    );

    let changelog = fs::read_to_string(repo.path().join("CHANGELOG.md")).expect("read changelog");
    assert!(!changelog.contains("_placeholder_"));
    assert!(changelog.contains("### Fixed\n- Keep release notes current"));
}

#[test]
fn test_prepare_release_fails_when_no_commits_since_last_tag() {
    let repo = TestRepo::new();
    repo.create_file("CHANGELOG.md", CHANGELOG_TEMPLATE);
    repo.commit("chore: add changelog");
    repo.git(&["tag", "v0.1.0"]);

    let before = fs::read_to_string(repo.path().join("CHANGELOG.md")).expect("read changelog");

    let output = run_release_prep(&repo);
    assert!(
        !output.status.success(),
        "prepare_release.py unexpectedly succeeded"
    );

    let stderr = TestRepo::stderr(&output);
    assert!(stderr.contains("No commits found since last tag"));

    let after = fs::read_to_string(repo.path().join("CHANGELOG.md")).expect("read changelog");
    assert_eq!(before, after);
}

#[test]
fn test_prepare_release_hook_mode_writes_released_entry_and_refreshes_links() {
    let repo = TestRepo::new();
    repo.create_file("CHANGELOG.md", CHANGELOG_TEMPLATE);
    repo.commit("chore: add changelog");
    repo.git(&["tag", "v0.1.0"]);

    repo.create_file("feature.txt", "feature");
    repo.commit("feat(parser): add release generator (#12)");
    repo.create_file("fix.txt", "fix");
    repo.commit("fix: handle empty changelog (#13)");

    let output = run_release_prep_with_env(
        &repo,
        &[
            "--release-version",
            "0.2.0",
            "--previous-version",
            "0.1.0",
            "--release-date",
            "2026-04-21",
        ],
        &[("DRY_RUN", "false")],
    );
    assert!(
        output.status.success(),
        "prepare_release.py hook mode failed: {}",
        TestRepo::stderr(&output)
    );

    let changelog = fs::read_to_string(repo.path().join("CHANGELOG.md")).expect("read changelog");

    assert!(changelog.contains("## [Unreleased] - ReleaseDate\n\n## [0.2.0] - 2026-04-21"));
    assert!(changelog.contains("### Added\n- Add release generator (#12)"));
    assert!(changelog.contains("### Fixed\n- Handle empty changelog (#13)"));
    assert!(changelog
        .contains("[Unreleased]: https://github.com/cesarferreira/stax/compare/v0.2.0...HEAD"));
    assert!(changelog
        .contains("[0.2.0]: https://github.com/cesarferreira/stax/compare/v0.1.0...v0.2.0"));
    assert!(!changelog.contains("_placeholder_"));
}

#[test]
fn test_prepare_release_hook_mode_skips_changes_during_dry_run() {
    let repo = TestRepo::new();
    repo.create_file("CHANGELOG.md", CHANGELOG_TEMPLATE);
    repo.commit("chore: add changelog");
    repo.git(&["tag", "v0.1.0"]);

    repo.create_file("feature.txt", "feature");
    repo.commit("feat(parser): add release generator (#12)");

    let before = fs::read_to_string(repo.path().join("CHANGELOG.md")).expect("read changelog");

    let output = run_release_prep_with_env(
        &repo,
        &[
            "--release-version",
            "0.2.0",
            "--previous-version",
            "0.1.0",
            "--release-date",
            "2026-04-21",
        ],
        &[("DRY_RUN", "true")],
    );
    assert!(
        output.status.success(),
        "prepare_release.py dry-run hook mode failed: {}",
        TestRepo::stderr(&output)
    );

    let after = fs::read_to_string(repo.path().join("CHANGELOG.md")).expect("read changelog");
    assert_eq!(before, after);
}