shipit 1.0.2

A CLI for managing git releases
Documentation
use std::path::{Path, PathBuf};

use crate::cli::{B2tApplyArgs, B2tPlanArgs};
use crate::git::{categorize_commits, generate_summary, next_version, TetheredGit};
use crate::context::Context;
use crate::error::ShipItError;

/// Entry point for `shipit b2t plan`.
///
/// Constructs a [`TetheredGit`] for the target branch, then delegates all logic
/// to [`run_plan`]. Splitting construction from logic keeps the inner function
/// unit-testable without a real remote.
pub async fn plan(ctx: &Context, args: B2tPlanArgs) -> Result<(), ShipItError> {
    let path = args.dir.as_deref().map(PathBuf::from).unwrap_or_else(|| PathBuf::from("."));
    let tethered_git = TetheredGit::new(
        &path,
        &args.remote,
        &args.branch,
        &ctx.settings.platform.domain,
        &ctx.settings.platform.token,
        args.allow_dirty,
        args.yes,
    ).await?;
    run_plan(tethered_git, args, &path).await
}

/// Core logic for `shipit b2t plan`.
///
/// Resolves the latest tag to compare against (via `--latest-tag` or [`TetheredGit::get_latest_tag`]),
/// collects commits since that tag, generates (or accepts a user-provided) tag name and release
/// notes, then serializes the result as a [`TagPlan`] YAML file under `<path>/.shipit/plans/`.
///
/// Commit messages are skipped when `--description` is provided. When `--conventional-commits`
/// is set the notes are grouped by commit type; otherwise each message becomes a raw bullet-list entry.
pub(crate) async fn run_plan(tethered_git: TetheredGit, args: B2tPlanArgs, path: &Path) -> Result<(), ShipItError> {
    // resolve the latest tag to compare against
    let latest_tag = if let Some(ref t) = args.latest_tag {
        t.clone()
    } else {
        tethered_git.get_latest_tag()?
    };

    let notes_from_user = args.description.is_some();
    let msgs = if args.description.is_some() {
        vec![]
    } else {
        let mut msgs = tethered_git.collect_messages_since_tag(&latest_tag, args.only_merges)?;
        let sp = crate::output::start_spinner("Enriching commit messages...");
        msgs = tethered_git.platform.enrich_messages(&msgs).await;
        sp.finish_and_clear();
        msgs
    };

    let refs: Vec<&str> = msgs.iter().map(|s| s.as_str()).collect();
    let categorized = categorize_commits(&refs);

    let mut notes = if let Some(provided) = args.description {
        provided
    } else {
        if msgs.is_empty() {
            tracing::warn!("No commits found since tag '{}'. Nothing to do.", latest_tag);
            return Ok(());
        }

        if args.conventional_commits {
            generate_summary(&categorized)
        } else {
            msgs.iter().map(|m| format!("- {}", m)).collect::<Vec<_>>().join("\n")
        }
    };
    crate::output::print_content("The tag notes are:", &notes);
    if !args.no_sign {
        notes += "\n\n\n*This tag was generated by [Shipit](https://gitshipit.net)* 🚢";
    }

    let tag_from_user = args.tag.is_some();
    let tag_name = if let Some(provided) = args.tag {
        provided
    } else {
        let version = next_version(&categorized, &latest_tag);
        let suggested = version.as_deref();
        if args.yes {
            suggested
                .ok_or_else(|| ShipItError::Error(format!("Could not compute next version from tag '{}'", latest_tag)))?
                .to_string()
        } else {
            crate::output::prompt_tag_name(suggested)
                .map_err(|e| ShipItError::Error(format!("Failed to read input: {}", e)))?
        }
    };
    crate::output::print_content("The tag name is:", &tag_name);

    let notes_source = if notes_from_user {
        "user".to_string()
    } else if args.conventional_commits {
        "conventional-commits".to_string()
    } else {
        "raw".to_string()
    };

    let generated_at = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
    let tag_plan = crate::plan::TagPlan {
        shipit_version: env!("CARGO_PKG_VERSION").to_string(),
        generated_at,
        branch: args.branch.clone(),
        tag_name: crate::plan::FieldWithSource {
            value: tag_name,
            generated_by: if tag_from_user { "user".to_string() } else { "default".to_string() },
        },
        notes: crate::plan::FieldWithSource {
            value: notes,
            generated_by: notes_source,
        },
        commits: msgs,
    };
    let plan_path = crate::plan::write_tag_plan(&tag_plan, path)?;
    crate::output::print_plan_saved(&plan_path);

    if args.yaml {
        let filename = plan_path.file_name()
            .map(|n| n.to_string_lossy().to_string())
            .unwrap_or_default();
        let yaml = crate::output::build_plan_yaml(&tag_plan, &filename)?;
        print!("{}", yaml);
    }

    Ok(())
}

/// Entry point for `shipit b2t apply`.
///
/// Reads the plan file at `<dir>/.shipit/plans/<plan>`, constructs a [`TetheredGit`]
/// for the branch recorded in the plan, then delegates to [`run_apply`].
pub async fn apply(ctx: &Context, args: B2tApplyArgs) -> Result<(), ShipItError> {
    let dir = args.dir.as_deref().map(PathBuf::from).unwrap_or_else(|| PathBuf::from("."));
    let plan_path = dir.join(".shipit").join("plans").join(&args.plan);

    let content = std::fs::read_to_string(&plan_path)
        .map_err(|e| ShipItError::Error(format!("Failed to read plan file '{}': {}", plan_path.display(), e)))?;
    let tag_plan: crate::plan::TagPlan = serde_yaml::from_str(&content)
        .map_err(|e| ShipItError::Error(format!("Failed to parse plan file: {}", e)))?;

    crate::output::print_content("Applying plan:", &format!(
        "  tag: {}\n  branch: {}",
        tag_plan.tag_name.value, tag_plan.branch
    ));

    let tethered_git = TetheredGit::new(
        &dir,
        &args.remote,
        &tag_plan.branch,
        &ctx.settings.platform.domain,
        &ctx.settings.platform.token,
        args.allow_dirty,
        args.yes,
    ).await?;

    run_apply(tethered_git, tag_plan).await
}

/// Core logic for `shipit b2t apply`.
///
/// Resolves the HEAD OID of the branch recorded in `tag_plan`, creates an annotated
/// local tag at that commit, then pushes the tag ref to the remote.
pub(crate) async fn run_apply(tethered_git: TetheredGit, tag_plan: crate::plan::TagPlan) -> Result<(), ShipItError> {
    let branch_oid = tethered_git.repo
        .revparse_single(&tag_plan.branch)
        .map_err(ShipItError::Git)?
        .id();

    tethered_git.create_local_tag(&tag_plan.tag_name.value, branch_oid, &tag_plan.notes.value)?;
    tethered_git.push_tag(&tag_plan.tag_name.value)?;
    crate::output::print_success(&format!("Tag '{}' created and pushed.", tag_plan.tag_name.value));

    Ok(())
}

#[cfg(test)]
mod tests {
    use tempfile::TempDir;

    use crate::cli::B2tPlanArgs;
    use crate::git::test_helpers::{init_repo_with_remote, make_commit, make_tag, make_tethered_git};
    use crate::git::MockPlatform;
    use crate::plan::{FieldWithSource, TagPlan};

    fn make_b2t_plan_args(branch: &str) -> B2tPlanArgs {
        B2tPlanArgs {
            branch: branch.to_string(),
            tag: None,
            conventional_commits: false,
            dir: None,
            remote: "origin".to_string(),
            description: None,
            only_merges: false,
            latest_tag: None,
            no_sign: true,
            yes: true,
            yaml: false,
            allow_dirty: false,
        }
    }

    fn make_tag_plan(branch: &str, tag: &str) -> TagPlan {
        TagPlan {
            shipit_version: "0.0.0".to_string(),
            generated_at: "2024-01-01T00:00:00Z".to_string(),
            branch: branch.to_string(),
            tag_name: FieldWithSource {
                value: tag.to_string(),
                generated_by: "default".to_string(),
            },
            notes: FieldWithSource {
                value: "- feat: add feature".to_string(),
                generated_by: "raw".to_string(),
            },
            commits: vec!["feat: add feature".to_string()],
        }
    }

    #[tokio::test]
    async fn test_run_plan_explicit_description_and_tag() {
        let work_dir = TempDir::new().unwrap();
        let bare_dir = TempDir::new().unwrap();
        let repo = init_repo_with_remote(work_dir.path(), bare_dir.path());
        let base_oid = make_commit(&repo, "initial commit");
        make_tag(&repo, "v1.0.0", base_oid);
        make_commit(&repo, "feat: new feature");

        let mut mock = MockPlatform::new();
        mock.expect_enrich_messages().never();

        let tmp_out = TempDir::new().unwrap();
        let out_path = tmp_out.path().to_path_buf();

        let tethered = make_tethered_git(repo, work_dir.path().to_path_buf(), "master", Box::new(mock));

        let mut args = make_b2t_plan_args("master");
        args.tag = Some("v1.1.0".to_string());
        args.description = Some("My custom notes".to_string());
        args.latest_tag = Some("v1.0.0".to_string());

        let result = super::run_plan(tethered, args, &out_path).await;
        assert!(result.is_ok(), "run_plan failed: {:?}", result);

        let plans_dir = out_path.join(".shipit").join("plans");
        let entries: Vec<_> = std::fs::read_dir(&plans_dir).unwrap().collect();
        assert_eq!(entries.len(), 1);

        let content = std::fs::read_to_string(entries[0].as_ref().unwrap().path()).unwrap();
        assert!(content.contains("v1.1.0"));
        assert!(content.contains("My custom notes"));
        assert!(content.contains("generated_by: user"));
    }

    #[tokio::test]
    async fn test_run_plan_raw_format_bullet_list() {
        let work_dir = TempDir::new().unwrap();
        let bare_dir = TempDir::new().unwrap();
        let repo = init_repo_with_remote(work_dir.path(), bare_dir.path());
        let base_oid = make_commit(&repo, "initial commit");
        make_tag(&repo, "v1.0.0", base_oid);
        make_commit(&repo, "feat: add something");
        make_commit(&repo, "fix: repair something");

        let mut mock = MockPlatform::new();
        mock.expect_enrich_messages()
            .returning(|msgs| msgs.to_vec());

        let tmp_out = TempDir::new().unwrap();
        let out_path = tmp_out.path().to_path_buf();

        let tethered = make_tethered_git(repo, work_dir.path().to_path_buf(), "master", Box::new(mock));

        let mut args = make_b2t_plan_args("master");
        args.latest_tag = Some("v1.0.0".to_string());
        args.tag = Some("v1.1.0".to_string());

        let result = super::run_plan(tethered, args, &out_path).await;
        assert!(result.is_ok(), "run_plan failed: {:?}", result);

        let plans_dir = out_path.join(".shipit").join("plans");
        let entries: Vec<_> = std::fs::read_dir(&plans_dir).unwrap().collect();
        let content = std::fs::read_to_string(entries[0].as_ref().unwrap().path()).unwrap();
        assert!(content.contains("- "), "expected bullet list: {}", content);
        assert!(content.contains("generated_by: raw"));
    }

    #[tokio::test]
    async fn test_run_plan_conventional_commits() {
        let work_dir = TempDir::new().unwrap();
        let bare_dir = TempDir::new().unwrap();
        let repo = init_repo_with_remote(work_dir.path(), bare_dir.path());
        let base_oid = make_commit(&repo, "initial commit");
        make_tag(&repo, "v1.0.0", base_oid);
        make_commit(&repo, "feat: new capability");

        let mut mock = MockPlatform::new();
        mock.expect_enrich_messages()
            .returning(|msgs| msgs.to_vec());

        let tmp_out = TempDir::new().unwrap();
        let out_path = tmp_out.path().to_path_buf();

        let tethered = make_tethered_git(repo, work_dir.path().to_path_buf(), "master", Box::new(mock));

        let mut args = make_b2t_plan_args("master");
        args.conventional_commits = true;
        args.latest_tag = Some("v1.0.0".to_string());
        args.tag = Some("v1.1.0".to_string());

        let result = super::run_plan(tethered, args, &out_path).await;
        assert!(result.is_ok(), "run_plan failed: {:?}", result);

        let plans_dir = out_path.join(".shipit").join("plans");
        let entries: Vec<_> = std::fs::read_dir(&plans_dir).unwrap().collect();
        let content = std::fs::read_to_string(entries[0].as_ref().unwrap().path()).unwrap();
        assert!(content.contains("conventional-commits"));
    }

    #[tokio::test]
    async fn test_run_apply_creates_and_pushes_tag() {
        let work_dir = TempDir::new().unwrap();
        let bare_dir = TempDir::new().unwrap();
        let repo = init_repo_with_remote(work_dir.path(), bare_dir.path());
        make_commit(&repo, "initial commit");

        // Push the initial commit to origin so push_tag can succeed
        std::process::Command::new("git")
            .args(["push", "origin", "master"])
            .current_dir(work_dir.path())
            .output()
            .unwrap();

        let mock = MockPlatform::new();
        let work_path = work_dir.path().to_path_buf();
        let tethered = make_tethered_git(repo, work_path.clone(), "master", Box::new(mock));

        let tag_plan = make_tag_plan("master", "v2.0.0");
        let result = super::run_apply(tethered, tag_plan).await;
        assert!(result.is_ok(), "run_apply failed: {:?}", result);

        // Verify tag was created locally by reopening the repo
        let reopened = git2::Repository::open(&work_path).unwrap();
        let tag_ref = reopened.find_reference("refs/tags/v2.0.0");
        assert!(tag_ref.is_ok(), "tag v2.0.0 should exist after apply");
    }
}