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;
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
}
pub(crate) async fn run_plan(tethered_git: TetheredGit, args: B2tPlanArgs, path: &Path) -> Result<(), ShipItError> {
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:", ¬es);
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(())
}
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
}
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");
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);
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");
}
}