simit 0.17.7

Semver-aware git commit helper for Rust projects
Documentation
use std::ffi::OsString;

use anyhow::{Context, Result, anyhow, bail};
use semver::Version;

use crate::cargo::{self, BumpSpec, Package};
use crate::changelog;
use crate::cli::{ReleaseAction, ReleaseCommand, ReleaseSecretsAction, ReleaseTrustAction};
use crate::config::ProjectConfig;
use crate::git;
use crate::registry;
use crate::release_trust::{self, TrustOverrides};

pub fn run(command: ReleaseCommand) -> Result<()> {
    if command.action == ReleaseAction::Verify {
        return crate::commands::release_verify::run(command);
    }
    if command.action == ReleaseAction::Plan {
        return crate::commands::release_plan::run(command);
    }
    if command.action == ReleaseAction::Trust {
        return trust(command);
    }
    if command.action == ReleaseAction::Secrets {
        return secrets(command);
    }
    if command.trust_action.is_some() || command.trust_key.is_some() || command.trust_root.is_some()
    {
        bail!("release trust arguments are only valid with `simit release trust`");
    }
    reject_secrets_flags(&command)?;
    if command.action == ReleaseAction::SyncUp {
        return sync_up(command);
    }

    let metadata = cargo::metadata_for_current_dir()?;
    let workspace_root = metadata.workspace_root.as_std_path();
    let packages = cargo::select_packages(&metadata, &command.packages, command.workspace)?;
    if command.push {
        bail!("--push is only valid with `simit release sync-up`");
    }
    if command.remote != "origin" {
        bail!("--remote is only valid with `simit release sync-up`");
    }
    reject_verify_flags(&command)?;
    let bump = BumpSpec::new(
        command.action.bump_kind().expect("release bump action"),
        command.pre,
    )?;
    let plans = cargo::plan_versions(packages, &bump)?;
    let new_version = cargo::common_new_version(&plans)?;
    let create_tag = !command.no_tag;
    let sign_tag = !command.no_sign;
    let message = command
        .message
        .clone()
        .ok_or_else(|| anyhow!("release commit message is required; pass -m <message>"))?;
    let git_args = vec![OsString::from("-m"), OsString::from(message)];
    let changelog_path = workspace_root.join(changelog::DEFAULT_PATH);

    if command.dry_run {
        println!("simit release dry-run");
        for plan in &plans {
            println!(
                "package {}: {} -> {}",
                plan.package.name, plan.old_version, plan.new_version
            );
        }
        println!("would run cargo test and cargo clippy");
        if !command.no_changelog && changelog_path.exists() {
            println!("would promote CHANGELOG.md [Unreleased] to {new_version}");
        }
        println!("would run git commit with {:?}", git_args);
        if create_tag {
            println!("would create tag {new_version}");
        }
        return Ok(());
    }

    git::release_preflight(workspace_root, create_tag, sign_tag, &new_version)?;
    let changelog_update = if !command.no_changelog && changelog_path.exists() {
        Some(changelog::release_content(
            &std::fs::read_to_string(&changelog_path)?,
            &new_version,
            changelog::today_utc()?,
            None,
            &changelog_path,
            Some(workspace_root),
        )?)
    } else {
        None
    };
    git::run_project_checks(workspace_root)?;
    for plan in &plans {
        cargo::update_manifest_version(
            plan.package.manifest_path.as_std_path(),
            &plan.new_version,
        )?;
    }
    if workspace_root.join("Cargo.lock").exists() {
        cargo::update_lockfile(workspace_root, &plans)?;
    }
    if let Some(changelog_update) = changelog_update {
        std::fs::write(&changelog_path, changelog_update)?;
    }

    let mut paths = git::version_paths(workspace_root, &plans);
    if changelog_path.exists() && !command.no_changelog {
        paths.push(changelog_path);
    }
    paths.sort();
    paths.dedup();
    git::stage_paths(workspace_root, &paths)?;
    git::commit(workspace_root, &git_args)?;
    if create_tag {
        git::tag(workspace_root, &new_version, sign_tag)?;
    }

    registry::refresh_current_project_or_warn();
    Ok(())
}

fn secrets(command: ReleaseCommand) -> Result<()> {
    if !command.packages.is_empty() {
        bail!("--package is not valid with `simit release secrets`");
    }
    if command.workspace {
        bail!("--workspace is not valid with `simit release secrets`");
    }
    if command.no_tag {
        bail!("--no-tag is not valid with `simit release secrets`");
    }
    if command.no_sign {
        bail!("--no-sign is not valid with `simit release secrets`");
    }
    if command.dry_run {
        bail!("--dry-run is not valid with `simit release secrets`");
    }
    if command.pre.is_some() {
        bail!("--pre is not valid with `simit release secrets`");
    }
    if command.message.is_some() {
        bail!("-m/--message is not valid with `simit release secrets`");
    }
    if command.no_changelog {
        bail!("--no-changelog is not valid with `simit release secrets`");
    }
    if command.push {
        bail!("--push is not valid with `simit release secrets`");
    }
    if command.remote != "origin" {
        bail!("--remote is not valid with `simit release secrets`");
    }
    if command.trust_key.is_some() || command.trust_root.is_some() {
        bail!("release trust arguments are only valid with `simit release trust`");
    }
    let contract_action = command.secrets_action == Some(ReleaseSecretsAction::Contract)
        || command.trust_action == Some(ReleaseTrustAction::Contract);
    if !(contract_action && command.json) {
        reject_verify_flags(&command)?;
    }

    let action = match (command.secrets_action, command.trust_action) {
        (Some(action), None) => action,
        (None, Some(ReleaseTrustAction::Init)) => ReleaseSecretsAction::Init,
        (None, Some(ReleaseTrustAction::Check)) => ReleaseSecretsAction::Check,
        (None, Some(ReleaseTrustAction::InspectMinisignInput)) => {
            ReleaseSecretsAction::InspectMinisignInput
        }
        (None, Some(ReleaseTrustAction::Contract)) => ReleaseSecretsAction::Contract,
        (None, Some(ReleaseTrustAction::Status)) => {
            bail!("release secrets action must be init, check, contract, or inspect-minisign-input")
        }
        (None, None) => {
            bail!(
                "release secrets action is required: init, check, contract, or inspect-minisign-input"
            )
        }
        (Some(_), Some(_)) => bail!("release secrets action specified more than once"),
    };
    match action {
        ReleaseSecretsAction::Init => crate::commands::release_secrets::init(command),
        ReleaseSecretsAction::Check => crate::commands::release_secrets::check(command),
        ReleaseSecretsAction::Contract => crate::commands::release_secrets::contract(command),
        ReleaseSecretsAction::InspectMinisignInput => {
            crate::commands::release_secrets::inspect_minisign_input(command)
        }
    }
}

fn trust(command: ReleaseCommand) -> Result<()> {
    if !command.packages.is_empty() {
        bail!("--package is not valid with `simit release trust`");
    }
    if command.workspace {
        bail!("--workspace is not valid with `simit release trust`");
    }
    if command.no_tag {
        bail!("--no-tag is not valid with `simit release trust`");
    }
    if command.no_sign {
        bail!("--no-sign is not valid with `simit release trust`");
    }
    if command.dry_run {
        bail!("--dry-run is not valid with `simit release trust`");
    }
    if command.pre.is_some() {
        bail!("--pre is not valid with `simit release trust`");
    }
    if command.message.is_some() {
        bail!("-m/--message is not valid with `simit release trust`");
    }
    if command.no_changelog {
        bail!("--no-changelog is not valid with `simit release trust`");
    }
    if command.push {
        bail!("--push is not valid with `simit release trust`");
    }
    if command.remote != "origin" {
        bail!("--remote is not valid with `simit release trust`");
    }
    reject_verify_flags(&command)?;

    let action = command
        .trust_action
        .ok_or_else(|| anyhow!("release trust action is required: status, init, or check"))?;
    let metadata = cargo::metadata_for_current_dir()?;
    let workspace_root = metadata.workspace_root.as_std_path();
    let config = ProjectConfig::load(workspace_root)?;
    let overrides = TrustOverrides {
        key: command.trust_key,
        trust_root: command.trust_root,
    };

    match action {
        ReleaseTrustAction::Status => release_trust::status(workspace_root, &config, &overrides),
        ReleaseTrustAction::Init => release_trust::init(workspace_root, &config, &overrides),
        ReleaseTrustAction::Check => release_trust::check(workspace_root, &config, &overrides),
        ReleaseTrustAction::InspectMinisignInput | ReleaseTrustAction::Contract => {
            bail!("release trust action must be status, init, or check")
        }
    }
}

fn sync_up(command: ReleaseCommand) -> Result<()> {
    if command.no_tag {
        bail!("--no-tag is not valid with `simit release sync-up`");
    }
    if command.pre.is_some() {
        bail!("--pre is not valid with `simit release sync-up`");
    }
    if command.message.is_some() {
        bail!("-m/--message is not valid with `simit release sync-up`");
    }
    if command.no_changelog {
        bail!("--no-changelog is not valid with `simit release sync-up`");
    }
    reject_verify_flags(&command)?;

    let metadata = cargo::metadata_for_current_dir()?;
    let workspace_root = metadata.workspace_root.as_std_path();
    let packages = cargo::select_packages(&metadata, &command.packages, command.workspace)?;
    let version = common_current_version(&packages)?;
    let sign_tag = !command.no_sign;
    git::tag_ref_object(workspace_root, &version)?;
    let old_tag_target = git::tag_target_commit(workspace_root, &version)?;
    let head = git::head_commit(workspace_root)?;

    if command.dry_run {
        println!("simit release sync-up dry-run");
        println!("version: {version}");
        println!("tag {version} currently points to {old_tag_target}");
        println!("would run cargo test and cargo clippy");
        if old_tag_target == head {
            println!("tag {version} already points to HEAD");
        } else {
            println!("would move tag {version} to HEAD {head}");
        }
        if command.push {
            println!("would push tag {version} to {}", command.remote);
        } else {
            println!("would not push tag {version}");
        }
        return Ok(());
    }

    git::sync_up_preflight(workspace_root, sign_tag)?;
    git::run_project_checks(workspace_root)?;

    if old_tag_target == head {
        println!("tag {version} already points to HEAD");
    } else {
        git::move_tag(workspace_root, &version, sign_tag)?;
        println!("moved tag {version} to HEAD {head}");
    }

    if command.push {
        let expected_remote_object =
            git::remote_tag_ref_object(workspace_root, &command.remote, &version)?;
        git::push_moved_tag(
            workspace_root,
            &command.remote,
            &version,
            &expected_remote_object,
        )?;
    }

    registry::refresh_current_project_or_warn();
    Ok(())
}

fn reject_verify_flags(command: &ReleaseCommand) -> Result<()> {
    if command.json {
        bail!("--json is only valid with `simit release verify`");
    }
    if command.verify_version.is_some() {
        bail!("--version is only valid with `simit release verify`");
    }
    if command.push_target.is_some() {
        bail!("--push-target is only valid with `simit release verify`");
    }
    Ok(())
}

fn reject_secrets_flags(command: &ReleaseCommand) -> Result<()> {
    if command.secrets_action.is_some()
        || command.secrets_repo.is_some()
        || command.secrets_token_file.is_some()
        || !command.assumed_account_secrets.is_empty()
        || command.minisign_secret_key_file.is_some()
        || command.minisign_password_file.is_some()
        || command.rotate_minisign
        || command.minisign_public_key.as_str() != "keys/minisign.pub"
        || command.secrets_api_base != "https://codeberg.org/api/v1"
    {
        bail!("release secrets arguments are only valid with `simit release secrets`");
    }
    Ok(())
}

fn common_current_version(packages: &[Package]) -> Result<Version> {
    let Some(first) = packages.first() else {
        bail!("no packages selected");
    };
    let first_version = Version::parse(&first.version)
        .with_context(|| format!("parsing version {}", first.version))?;

    let mut divergent = Vec::new();
    for package in packages.iter().skip(1) {
        let version = Version::parse(&package.version)
            .with_context(|| format!("parsing version {}", package.version))?;
        if version != first_version {
            divergent.push(format!("{} -> {}", package.name, package.version));
        }
    }

    if !divergent.is_empty() {
        let mut details = vec![format!("{} -> {}", first.name, first.version)];
        details.extend(divergent);
        bail!(
            "selected packages do not have one current release version:\n{}",
            details.join("\n")
        );
    }

    Ok(first_version)
}