use std::path::Path;
use assert_cmd::Command;
use predicates::prelude::*;
fn git(dir: &Path, args: &[&str]) -> String {
let output = std::process::Command::new("git")
.current_dir(dir)
.args(args)
.output()
.unwrap();
assert!(
output.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
fn tag_exists(dir: &Path, tag: &str) -> bool {
std::process::Command::new("git")
.current_dir(dir)
.args(["rev-parse", "--verify", &format!("refs/tags/{tag}")])
.output()
.unwrap()
.status
.success()
}
fn head_message(dir: &Path) -> String {
git(dir, &["log", "-1", "--format=%B"]).trim().to_string()
}
fn collect_tag_names(dir: &Path) -> Vec<String> {
let output = git(dir, &["tag", "-l"]);
if output.is_empty() {
vec![]
} else {
output.lines().map(|s| s.to_string()).collect()
}
}
fn init_bump_repo(dir: &Path) {
git(dir, &["init"]);
git(dir, &["config", "user.name", "Test"]);
git(dir, &["config", "user.email", "test@test.com"]);
std::fs::write(
dir.join("Cargo.toml"),
"[package]\nname = \"test-pkg\"\nversion = \"0.0.0\"\nedition = \"2021\"\n",
)
.unwrap();
git(dir, &["add", "Cargo.toml"]);
git(dir, &["commit", "-m", "chore: init"]);
}
fn add_commit(dir: &Path, filename: &str, message: &str) {
std::fs::write(dir.join(filename), message).unwrap();
git(dir, &["add", filename]);
git(dir, &["commit", "-m", message]);
}
fn create_tag(dir: &Path, name: &str) {
git(dir, &["tag", "-a", name, "-m", name]);
}
#[test]
fn bump_help_shows_flags() {
let assert = Command::cargo_bin("git-std")
.unwrap()
.args(["bump", "--help"])
.assert()
.success();
let stdout = String::from_utf8_lossy(&assert.get_output().stdout);
for flag in [
"--dry-run",
"--prerelease",
"--release-as",
"--first-release",
"--no-tag",
"--no-commit",
"--skip-changelog",
"--sign",
"--force",
"--stable",
"--minor",
"--push",
] {
assert!(stdout.contains(flag), "bump help should list '{flag}' flag");
}
}
#[test]
fn bump_dry_run_shows_plan() {
let dir = tempfile::tempdir().unwrap();
init_bump_repo(dir.path());
create_tag(dir.path(), "v0.1.0");
add_commit(dir.path(), "a.txt", "feat: add feature A");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump", "--dry-run"])
.current_dir(dir.path())
.assert()
.success()
.stderr(predicate::str::contains("0.1.0 → 0.1.1"))
.stderr(predicate::str::contains("Would commit"))
.stderr(predicate::str::contains("Would tag"));
}
#[test]
fn bump_dry_run_no_writes() {
let dir = tempfile::tempdir().unwrap();
init_bump_repo(dir.path());
create_tag(dir.path(), "v0.1.0");
add_commit(dir.path(), "a.txt", "feat: add feature A");
let before = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
Command::cargo_bin("git-std")
.unwrap()
.args(["bump", "--dry-run"])
.current_dir(dir.path())
.assert()
.success();
let after = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
assert_eq!(before, after);
assert!(!dir.path().join("CHANGELOG.md").exists());
}
#[test]
fn bump_performs_full_workflow() {
let dir = tempfile::tempdir().unwrap();
init_bump_repo(dir.path());
create_tag(dir.path(), "v1.0.0");
add_commit(dir.path(), "b.txt", "fix: handle edge case");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump"])
.current_dir(dir.path())
.assert()
.success()
.stderr(predicate::str::contains("1.0.0 → 1.0.1"))
.stderr(predicate::str::contains("Committed"))
.stderr(predicate::str::contains("Tagged"));
let cargo = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
assert!(cargo.contains("version = \"1.0.1\""));
assert!(dir.path().join("CHANGELOG.md").exists());
assert!(tag_exists(dir.path(), "v1.0.1"), "tag v1.0.1 should exist");
assert_eq!(head_message(dir.path()), "chore(release): 1.0.1");
}
#[test]
fn bump_no_bump_worthy_commits() {
let dir = tempfile::tempdir().unwrap();
init_bump_repo(dir.path());
create_tag(dir.path(), "v1.0.0");
add_commit(dir.path(), "c.txt", "chore: update deps");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump"])
.current_dir(dir.path())
.assert()
.success()
.stderr(predicate::str::contains("no bump-worthy commits"));
}
#[test]
fn bump_major_on_breaking_change() {
let dir = tempfile::tempdir().unwrap();
init_bump_repo(dir.path());
create_tag(dir.path(), "v1.2.3");
add_commit(dir.path(), "d.txt", "feat!: remove old API");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump"])
.current_dir(dir.path())
.assert()
.success()
.stderr(predicate::str::contains("1.2.3 → 2.0.0"));
}
#[test]
fn bump_no_commit_flag() {
let dir = tempfile::tempdir().unwrap();
init_bump_repo(dir.path());
create_tag(dir.path(), "v1.0.0");
add_commit(dir.path(), "e.txt", "feat: new thing");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump", "--no-commit"])
.current_dir(dir.path())
.assert()
.success();
let cargo = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
assert!(cargo.contains("version = \"1.1.0\""));
assert_ne!(head_message(dir.path()), "chore(release): 1.1.0");
assert!(!tag_exists(dir.path(), "v1.1.0"));
}
#[test]
fn bump_skip_changelog() {
let dir = tempfile::tempdir().unwrap();
init_bump_repo(dir.path());
create_tag(dir.path(), "v1.0.0");
add_commit(dir.path(), "f.txt", "fix: a fix");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump", "--skip-changelog"])
.current_dir(dir.path())
.assert()
.success();
assert!(!dir.path().join("CHANGELOG.md").exists());
}
#[test]
fn bump_release_as() {
let dir = tempfile::tempdir().unwrap();
init_bump_repo(dir.path());
create_tag(dir.path(), "v1.0.0");
add_commit(dir.path(), "g.txt", "fix: a fix");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump", "--release-as", "5.0.0"])
.current_dir(dir.path())
.assert()
.success()
.stderr(predicate::str::contains("1.0.0 → 5.0.0"));
let cargo = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
assert!(cargo.contains("version = \"5.0.0\""));
}
#[test]
fn bump_first_release() {
let dir = tempfile::tempdir().unwrap();
init_bump_repo(dir.path());
add_commit(dir.path(), "init.txt", "feat: initial feature");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump", "--first-release"])
.current_dir(dir.path())
.assert()
.success();
assert!(dir.path().join("CHANGELOG.md").exists());
let cargo = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
assert!(cargo.contains("version = \"0.0.0\""));
}
fn assert_calver_format(ver: &str) {
let parts: Vec<&str> = ver.split('.').collect();
assert_eq!(
parts.len(),
3,
"calver should have 3 dot-separated parts, got: {ver}"
);
let year: u32 = parts[0]
.parse()
.unwrap_or_else(|_| panic!("year should be numeric, got: {}", parts[0]));
assert!(
(2020..=2100).contains(&year),
"year should be a plausible 4-digit year, got: {year}"
);
let month: u32 = parts[1]
.parse()
.unwrap_or_else(|_| panic!("month should be numeric, got: {}", parts[1]));
assert!(
(1..=12).contains(&month),
"month should be 1-12, got: {month}"
);
let _patch: u32 = parts[2]
.parse()
.unwrap_or_else(|_| panic!("patch should be numeric, got: {}", parts[2]));
}
#[test]
fn bump_full_release_cycle() {
let dir = tempfile::tempdir().unwrap();
init_bump_repo(dir.path());
std::fs::write(dir.path().join(".git-std.toml"), "scheme = \"calver\"\n").unwrap();
add_commit(dir.path(), "a.txt", "feat: first feature");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump"])
.current_dir(dir.path())
.assert()
.success()
.stderr(predicate::str::contains("calver"))
.stderr(predicate::str::contains("Committed"))
.stderr(predicate::str::contains("Tagged"));
let tags = collect_tag_names(dir.path());
assert!(!tags.is_empty(), "at least one tag should exist after bump");
let tag_name = &tags[0];
assert!(
tag_name.starts_with('v'),
"tag should start with 'v', got: {tag_name}"
);
let ver = tag_name.strip_prefix('v').unwrap();
assert_calver_format(ver);
let parts: Vec<&str> = ver.split('.').collect();
assert_eq!(
parts[2], "0",
"first calver release should have patch 0, got: {}",
parts[2]
);
add_commit(dir.path(), "b.txt", "fix: a bugfix");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump"])
.current_dir(dir.path())
.assert()
.success()
.stderr(predicate::str::contains("calver"));
let tags2 = collect_tag_names(dir.path());
assert!(
tags2.len() >= 2,
"should have at least 2 tags after second bump, got: {}",
tags2.len()
);
for t in &tags2 {
let v = t.strip_prefix('v').unwrap_or(t);
assert_calver_format(v);
}
let first_ver = tags2[0].strip_prefix('v').unwrap();
let second_ver = tags2[1].strip_prefix('v').unwrap();
let first_parts: Vec<&str> = first_ver.split('.').collect();
let second_parts: Vec<&str> = second_ver.split('.').collect();
assert_eq!(
first_parts[0], second_parts[0],
"year should match between bumps: {} vs {}",
first_parts[0], second_parts[0]
);
assert_eq!(
first_parts[1], second_parts[1],
"month should match between bumps: {} vs {}",
first_parts[1], second_parts[1]
);
let patch: u32 = second_parts[2].parse().expect("patch should be numeric");
assert_eq!(
patch, 1,
"second calver bump should have patch 1, got: {patch}"
);
}
#[test]
fn bump_prerelease_cycle() {
let dir = tempfile::tempdir().unwrap();
init_bump_repo(dir.path());
create_tag(dir.path(), "v1.0.0");
add_commit(dir.path(), "a.txt", "feat: new feature");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump", "--prerelease"])
.current_dir(dir.path())
.assert()
.success()
.stderr(predicate::str::contains("1.0.0 → 1.1.0-rc.0"));
assert!(
tag_exists(dir.path(), "v1.1.0-rc.0"),
"tag v1.1.0-rc.0 should exist"
);
add_commit(dir.path(), "b.txt", "fix: a fix");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump", "--prerelease"])
.current_dir(dir.path())
.assert()
.success()
.stderr(predicate::str::contains("1.1.0-rc.0 → 1.1.0-rc.1"));
assert!(
tag_exists(dir.path(), "v1.1.0-rc.1"),
"tag v1.1.0-rc.1 should exist"
);
}
#[test]
fn bump_missing_tool_lock_file_warns_and_continues() {
let dir = tempfile::tempdir().unwrap();
init_bump_repo(dir.path());
create_tag(dir.path(), "v1.0.0");
std::fs::write(
dir.path().join("pyproject.toml"),
"[project]\nname = \"test\"\nversion = \"1.0.0\"\n",
)
.unwrap();
std::fs::write(
dir.path().join(".git-std.toml"),
"[[version_files]]\npath = \"pyproject.toml\"\nregex = 'version = \"([^\"]+)\"'\n",
)
.unwrap();
git(dir.path(), &["add", "."]);
git(dir.path(), &["commit", "-m", "chore: add pyproject"]);
add_commit(dir.path(), "a.txt", "fix: small fix");
std::fs::write(dir.path().join("uv.lock"), "# placeholder\n").unwrap();
let git_dir = std::process::Command::new("which")
.arg("git")
.output()
.map(|o| {
let p = String::from_utf8_lossy(&o.stdout).trim().to_string();
std::path::PathBuf::from(p)
.parent()
.unwrap()
.to_string_lossy()
.to_string()
})
.unwrap();
Command::cargo_bin("git-std")
.unwrap()
.args(["bump"])
.current_dir(dir.path())
.env("PATH", &git_dir)
.assert()
.success();
let cargo = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
assert!(cargo.contains("version = \"1.0.1\""));
}
#[test]
fn bump_dry_run_shows_would_sync() {
let dir = tempfile::tempdir().unwrap();
init_bump_repo(dir.path());
create_tag(dir.path(), "v1.0.0");
std::fs::write(
dir.path().join("pyproject.toml"),
"[project]\nname = \"test\"\nversion = \"1.0.0\"\n",
)
.unwrap();
std::fs::write(
dir.path().join(".git-std.toml"),
"[[version_files]]\npath = \"pyproject.toml\"\nregex = 'version = \"([^\"]+)\"'\n",
)
.unwrap();
git(dir.path(), &["add", "."]);
git(dir.path(), &["commit", "-m", "chore: add pyproject"]);
add_commit(dir.path(), "a.txt", "feat: new feature");
std::fs::write(dir.path().join("uv.lock"), "# placeholder\n").unwrap();
Command::cargo_bin("git-std")
.unwrap()
.args(["bump", "--dry-run"])
.current_dir(dir.path())
.assert()
.success()
.stderr(predicate::str::contains("Would sync"))
.stderr(predicate::str::contains("uv.lock"));
}
#[test]
fn bump_dry_run_skips_untriggered_lock_file() {
let dir = tempfile::tempdir().unwrap();
init_bump_repo(dir.path());
create_tag(dir.path(), "v1.0.0");
add_commit(dir.path(), "a.txt", "feat: new feature");
std::fs::write(dir.path().join("uv.lock"), "# placeholder\n").unwrap();
Command::cargo_bin("git-std")
.unwrap()
.args(["bump", "--dry-run"])
.current_dir(dir.path())
.assert()
.success()
.stderr(predicate::str::contains("Would sync: uv.lock").not());
}
#[test]
fn bump_pre1_breaking_bumps_minor() {
let dir = tempfile::tempdir().unwrap();
init_bump_repo(dir.path());
create_tag(dir.path(), "v0.10.2");
add_commit(dir.path(), "brk.txt", "feat!: remove old API");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump"])
.current_dir(dir.path())
.assert()
.success()
.stderr(predicate::str::contains("0.10.2 → 0.11.0"));
let cargo = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
assert!(cargo.contains("version = \"0.11.0\""));
assert!(tag_exists(dir.path(), "v0.11.0"));
}
#[test]
fn bump_pre1_feat_bumps_patch() {
let dir = tempfile::tempdir().unwrap();
init_bump_repo(dir.path());
create_tag(dir.path(), "v0.10.2");
add_commit(dir.path(), "ft.txt", "feat: add feature");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump"])
.current_dir(dir.path())
.assert()
.success()
.stderr(predicate::str::contains("0.10.2 → 0.10.3"));
let cargo = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
assert!(cargo.contains("version = \"0.10.3\""));
}
#[test]
fn bump_pre1_fix_bumps_patch() {
let dir = tempfile::tempdir().unwrap();
init_bump_repo(dir.path());
create_tag(dir.path(), "v0.10.2");
add_commit(dir.path(), "fx.txt", "fix: handle edge case");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump"])
.current_dir(dir.path())
.assert()
.success()
.stderr(predicate::str::contains("0.10.2 → 0.10.3"));
}
#[test]
fn bump_pre1_release_as_overrides() {
let dir = tempfile::tempdir().unwrap();
init_bump_repo(dir.path());
create_tag(dir.path(), "v0.10.2");
add_commit(dir.path(), "ra.txt", "feat: something");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump", "--release-as", "1.0.0"])
.current_dir(dir.path())
.assert()
.success()
.stderr(predicate::str::contains("0.10.2 → 1.0.0"));
let cargo = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
assert!(cargo.contains("version = \"1.0.0\""));
assert!(tag_exists(dir.path(), "v1.0.0"));
}
#[test]
fn bump_pre1_dry_run_shows_correct_plan() {
let dir = tempfile::tempdir().unwrap();
init_bump_repo(dir.path());
create_tag(dir.path(), "v0.10.2");
add_commit(dir.path(), "dr.txt", "feat!: breaking API change");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump", "--dry-run"])
.current_dir(dir.path())
.assert()
.success()
.stderr(predicate::str::contains("0.10.2 → 0.11.0"))
.stderr(predicate::str::contains("Would commit"))
.stderr(predicate::str::contains("Would tag"));
}
#[test]
fn bump_post1_breaking_bumps_major() {
let dir = tempfile::tempdir().unwrap();
init_bump_repo(dir.path());
create_tag(dir.path(), "v1.2.3");
add_commit(dir.path(), "brk2.txt", "feat!: remove old API");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump"])
.current_dir(dir.path())
.assert()
.success()
.stderr(predicate::str::contains("1.2.3 → 2.0.0"));
}
fn write_hooks_file(dir: &std::path::Path, hook_name: &str, content: &str) {
let hooks_dir = dir.join(".githooks");
std::fs::create_dir_all(&hooks_dir).unwrap();
std::fs::write(hooks_dir.join(format!("{hook_name}.hooks")), content).unwrap();
}
#[test]
fn bump_lifecycle_pre_bump_passes() {
let dir = tempfile::tempdir().unwrap();
init_bump_repo(dir.path());
create_tag(dir.path(), "v1.0.0");
add_commit(dir.path(), "a.txt", "fix: a fix");
write_hooks_file(dir.path(), "pre-bump", "! true\n");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump"])
.current_dir(dir.path())
.assert()
.success()
.stderr(predicate::str::contains("1.0.0 → 1.0.1"));
}
#[test]
fn bump_lifecycle_pre_bump_aborts_on_failure() {
let dir = tempfile::tempdir().unwrap();
init_bump_repo(dir.path());
create_tag(dir.path(), "v1.0.0");
add_commit(dir.path(), "a.txt", "fix: a fix");
write_hooks_file(dir.path(), "pre-bump", "! false\n");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump"])
.current_dir(dir.path())
.assert()
.failure();
let cargo = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
assert!(
!cargo.contains("version = \"1.0.1\""),
"Cargo.toml must not be updated when pre-bump fails"
);
assert!(!tag_exists(dir.path(), "v1.0.1"), "tag must not exist");
}
#[test]
fn bump_lifecycle_pre_bump_skipped_on_dry_run() {
let dir = tempfile::tempdir().unwrap();
init_bump_repo(dir.path());
create_tag(dir.path(), "v1.0.0");
add_commit(dir.path(), "a.txt", "fix: a fix");
write_hooks_file(dir.path(), "pre-bump", "! false\n");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump", "--dry-run"])
.current_dir(dir.path())
.assert()
.success();
}
#[test]
fn bump_lifecycle_post_version_receives_version() {
let dir = tempfile::tempdir().unwrap();
init_bump_repo(dir.path());
create_tag(dir.path(), "v1.0.0");
add_commit(dir.path(), "a.txt", "fix: a fix");
let sentinel = dir.path().join("post-version-arg.txt");
let sentinel_str = sentinel.to_string_lossy();
write_hooks_file(
dir.path(),
"post-version",
&format!("! echo $1 > {sentinel_str}\n"),
);
Command::cargo_bin("git-std")
.unwrap()
.args(["bump"])
.current_dir(dir.path())
.assert()
.success();
let written = std::fs::read_to_string(&sentinel)
.unwrap_or_default()
.trim()
.to_string();
assert_eq!(written, "1.0.1", "post-version should receive '1.0.1'");
}
#[test]
fn bump_lifecycle_post_version_aborts_on_failure() {
let dir = tempfile::tempdir().unwrap();
init_bump_repo(dir.path());
create_tag(dir.path(), "v1.0.0");
add_commit(dir.path(), "a.txt", "fix: a fix");
write_hooks_file(dir.path(), "post-version", "! false\n");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump"])
.current_dir(dir.path())
.assert()
.failure();
assert!(!tag_exists(dir.path(), "v1.0.1"), "tag must not exist");
}
#[test]
fn bump_lifecycle_post_changelog_runs() {
let dir = tempfile::tempdir().unwrap();
init_bump_repo(dir.path());
create_tag(dir.path(), "v1.0.0");
add_commit(dir.path(), "a.txt", "fix: a fix");
let sentinel = dir.path().join("post-changelog-ran.txt");
let sentinel_str = sentinel.to_string_lossy();
write_hooks_file(
dir.path(),
"post-changelog",
&format!("! touch {sentinel_str}\n"),
);
Command::cargo_bin("git-std")
.unwrap()
.args(["bump"])
.current_dir(dir.path())
.assert()
.success();
assert!(
sentinel.exists(),
"post-changelog sentinel file must exist after bump"
);
}
#[test]
fn bump_lifecycle_post_changelog_aborts_on_failure() {
let dir = tempfile::tempdir().unwrap();
init_bump_repo(dir.path());
create_tag(dir.path(), "v1.0.0");
add_commit(dir.path(), "a.txt", "fix: a fix");
write_hooks_file(dir.path(), "post-changelog", "! false\n");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump"])
.current_dir(dir.path())
.assert()
.failure();
assert_ne!(
head_message(dir.path()),
"chore(release): 1.0.1",
"release commit must not exist"
);
}
#[test]
fn bump_lifecycle_post_changelog_skipped_when_skip_changelog() {
let dir = tempfile::tempdir().unwrap();
init_bump_repo(dir.path());
create_tag(dir.path(), "v1.0.0");
add_commit(dir.path(), "a.txt", "fix: a fix");
write_hooks_file(dir.path(), "post-changelog", "! false\n");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump", "--skip-changelog"])
.current_dir(dir.path())
.assert()
.success();
}
#[test]
fn bump_lifecycle_post_bump_runs_after_tag() {
let dir = tempfile::tempdir().unwrap();
init_bump_repo(dir.path());
create_tag(dir.path(), "v1.0.0");
add_commit(dir.path(), "a.txt", "fix: a fix");
let sentinel = dir.path().join("post-bump-ran.txt");
let sentinel_str = sentinel.to_string_lossy();
write_hooks_file(
dir.path(),
"post-bump",
&format!("! touch {sentinel_str}\n"),
);
Command::cargo_bin("git-std")
.unwrap()
.args(["bump"])
.current_dir(dir.path())
.assert()
.success();
assert!(tag_exists(dir.path(), "v1.0.1"), "tag must exist");
assert!(
sentinel.exists(),
"post-bump sentinel file must exist after bump"
);
}
#[test]
fn bump_lifecycle_post_bump_advisory_tolerates_failure() {
let dir = tempfile::tempdir().unwrap();
init_bump_repo(dir.path());
create_tag(dir.path(), "v1.0.0");
add_commit(dir.path(), "a.txt", "fix: a fix");
write_hooks_file(dir.path(), "post-bump", "? false\n");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump"])
.current_dir(dir.path())
.assert()
.success();
assert!(tag_exists(dir.path(), "v1.0.1"), "tag must exist");
}
#[test]
fn bump_lifecycle_post_bump_skipped_on_no_commit() {
let dir = tempfile::tempdir().unwrap();
init_bump_repo(dir.path());
create_tag(dir.path(), "v1.0.0");
add_commit(dir.path(), "a.txt", "fix: a fix");
write_hooks_file(dir.path(), "post-bump", "! false\n");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump", "--no-commit"])
.current_dir(dir.path())
.assert()
.success();
}
#[test]
fn bump_lifecycle_skip_hooks_env_var() {
let dir = tempfile::tempdir().unwrap();
init_bump_repo(dir.path());
create_tag(dir.path(), "v1.0.0");
add_commit(dir.path(), "a.txt", "fix: a fix");
for hook in ["pre-bump", "post-version", "post-changelog", "post-bump"] {
write_hooks_file(dir.path(), hook, "! false\n");
}
Command::cargo_bin("git-std")
.unwrap()
.args(["bump"])
.current_dir(dir.path())
.env("GIT_STD_SKIP_HOOKS", "1")
.assert()
.success();
}
#[test]
fn bump_lifecycle_dry_run_skips_all_hooks() {
let dir = tempfile::tempdir().unwrap();
init_bump_repo(dir.path());
create_tag(dir.path(), "v1.0.0");
add_commit(dir.path(), "a.txt", "fix: a fix");
for hook in ["pre-bump", "post-version", "post-changelog", "post-bump"] {
write_hooks_file(dir.path(), hook, "! false\n");
}
Command::cargo_bin("git-std")
.unwrap()
.args(["bump", "--dry-run"])
.current_dir(dir.path())
.assert()
.success();
}
fn init_remote(local_dir: &Path, remote_dir: &Path, remote_name: &str) {
std::process::Command::new("git")
.args(["init", "--bare"])
.arg(remote_dir)
.output()
.unwrap();
git(
local_dir,
&[
"remote",
"add",
remote_name,
&remote_dir.display().to_string(),
],
);
}
fn remote_tag_exists(remote_dir: &Path, tag: &str) -> bool {
std::process::Command::new("git")
.current_dir(remote_dir)
.args(["rev-parse", "--verify", &format!("refs/tags/{tag}")])
.output()
.unwrap()
.status
.success()
}
#[test]
fn bump_push_default_remote() {
let local = tempfile::tempdir().unwrap();
let remote = tempfile::tempdir().unwrap();
init_bump_repo(local.path());
create_tag(local.path(), "v1.0.0");
add_commit(local.path(), "a.txt", "fix: a fix");
init_remote(local.path(), remote.path(), "origin");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump", "--push"])
.current_dir(local.path())
.assert()
.success()
.stderr(predicate::str::contains("Pushed to origin"));
assert!(
remote_tag_exists(remote.path(), "v1.0.1"),
"tag v1.0.1 should be pushed to remote"
);
}
#[test]
fn bump_push_named_remote() {
let local = tempfile::tempdir().unwrap();
let remote = tempfile::tempdir().unwrap();
init_bump_repo(local.path());
create_tag(local.path(), "v1.0.0");
add_commit(local.path(), "a.txt", "fix: a fix");
init_remote(local.path(), remote.path(), "upstream");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump", "--push", "upstream"])
.current_dir(local.path())
.assert()
.success()
.stderr(predicate::str::contains("Pushed to upstream"));
assert!(
remote_tag_exists(remote.path(), "v1.0.1"),
"tag v1.0.1 should be pushed to upstream remote"
);
}
#[test]
fn bump_dry_run_push_reports_would_push() {
let local = tempfile::tempdir().unwrap();
let remote = tempfile::tempdir().unwrap();
init_bump_repo(local.path());
create_tag(local.path(), "v1.0.0");
add_commit(local.path(), "a.txt", "fix: a fix");
init_remote(local.path(), remote.path(), "origin");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump", "--dry-run", "--push"])
.current_dir(local.path())
.assert()
.success()
.stderr(predicate::str::contains("Would push to origin"));
assert!(
!remote_tag_exists(remote.path(), "v1.0.1"),
"dry-run should not push anything"
);
}
#[test]
fn bump_dry_run_push_named_remote() {
let local = tempfile::tempdir().unwrap();
let remote = tempfile::tempdir().unwrap();
init_bump_repo(local.path());
create_tag(local.path(), "v1.0.0");
add_commit(local.path(), "a.txt", "fix: a fix");
init_remote(local.path(), remote.path(), "upstream");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump", "--dry-run", "--push", "upstream"])
.current_dir(local.path())
.assert()
.success()
.stderr(predicate::str::contains("Would push to upstream"));
}
#[test]
fn bump_push_failure_exits_nonzero() {
let local = tempfile::tempdir().unwrap();
init_bump_repo(local.path());
create_tag(local.path(), "v1.0.0");
add_commit(local.path(), "a.txt", "fix: a fix");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump", "--push", "nonexistent-remote"])
.current_dir(local.path())
.assert()
.failure();
}
#[test]
fn bump_cargo_lock_sync_via_custom_version_files() {
let dir = tempfile::tempdir().unwrap();
let workspace_toml = r#"[workspace]
members = ["app"]
resolver = "2"
"#;
std::fs::write(dir.path().join("Cargo.toml"), workspace_toml).unwrap();
std::fs::create_dir_all(dir.path().join("app/src")).unwrap();
std::fs::write(dir.path().join("app/src/main.rs"), "fn main() {}").unwrap();
std::fs::write(
dir.path().join("app/Cargo.toml"),
"[package]\nname = \"app\"\nversion = \"1.0.0\"\nedition = \"2021\"\n",
)
.unwrap();
std::fs::write(
dir.path().join(".git-std.toml"),
"[[version_files]]\npath = \"app/Cargo.toml\"\nregex = 'version = \"([^\"]+)\"'\n",
)
.unwrap();
git(dir.path(), &["init"]);
git(dir.path(), &["config", "user.email", "test@example.com"]);
git(dir.path(), &["config", "user.name", "Test"]);
std::process::Command::new("cargo")
.args(["generate-lockfile"])
.current_dir(dir.path())
.status()
.expect("cargo must be available");
git(dir.path(), &["add", "."]);
git(dir.path(), &["commit", "-m", "chore: initial"]);
create_tag(dir.path(), "v1.0.0");
add_commit(dir.path(), "x.txt", "fix: a bug");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump"])
.current_dir(dir.path())
.assert()
.success()
.stderr(predicate::str::contains("Synced:"));
let files_in_commit = git(
dir.path(),
&["diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"],
);
assert!(
files_in_commit.contains("Cargo.lock"),
"Cargo.lock should be staged in the bump commit, got: {files_in_commit}"
);
}
#[test]
fn bump_cargo_lock_sync_happy_path() {
let dir = tempfile::tempdir().unwrap();
init_bump_repo(dir.path());
create_tag(dir.path(), "v1.0.0");
std::fs::create_dir_all(dir.path().join("src")).unwrap();
std::fs::write(dir.path().join("src/lib.rs"), "").unwrap();
std::process::Command::new("cargo")
.args(["generate-lockfile"])
.current_dir(dir.path())
.status()
.expect("cargo must be available");
git(dir.path(), &["add", "."]);
git(dir.path(), &["commit", "-m", "chore: add lock"]);
add_commit(dir.path(), "a.txt", "fix: a bug");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump"])
.current_dir(dir.path())
.assert()
.success()
.stderr(predicate::str::contains("Synced:"));
let files_in_commit = git(
dir.path(),
&["diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"],
);
assert!(
files_in_commit.contains("Cargo.lock"),
"Cargo.lock should be staged in the bump commit, got: {files_in_commit}"
);
}
#[test]
fn bump_workspace_package_inheritance() {
let dir = tempfile::tempdir().unwrap();
let workspace_toml = r#"[workspace]
members = ["app"]
resolver = "2"
[workspace.package]
version = "1.0.0"
edition = "2021"
"#;
std::fs::write(dir.path().join("Cargo.toml"), workspace_toml).unwrap();
std::fs::create_dir_all(dir.path().join("app/src")).unwrap();
std::fs::write(dir.path().join("app/src/main.rs"), "fn main() {}").unwrap();
std::fs::write(
dir.path().join("app/Cargo.toml"),
"[package]\nname = \"app\"\nversion.workspace = true\nedition.workspace = true\n",
)
.unwrap();
git(dir.path(), &["init"]);
git(dir.path(), &["config", "user.email", "test@example.com"]);
git(dir.path(), &["config", "user.name", "Test"]);
std::process::Command::new("cargo")
.args(["generate-lockfile"])
.current_dir(dir.path())
.status()
.expect("cargo must be available");
git(dir.path(), &["add", "."]);
git(dir.path(), &["commit", "-m", "chore: initial"]);
create_tag(dir.path(), "v1.0.0");
add_commit(dir.path(), "x.txt", "fix: a bug");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump"])
.current_dir(dir.path())
.assert()
.success();
let root_content = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
assert!(
root_content.contains("version = \"1.0.1\""),
"root [workspace.package] version should be updated"
);
let member_content = std::fs::read_to_string(dir.path().join("app/Cargo.toml")).unwrap();
assert!(
member_content.contains("version.workspace = true"),
"member must not have its workspace inherit rewritten"
);
assert!(
!member_content.contains("version = \""),
"member must not gain a pinned version field"
);
}
#[test]
fn bump_workspace_pinned_members() {
let dir = tempfile::tempdir().unwrap();
let workspace_toml = "[workspace]\nmembers = [\"alpha\", \"beta\"]\nresolver = \"2\"\n";
std::fs::write(dir.path().join("Cargo.toml"), workspace_toml).unwrap();
for name in ["alpha", "beta"] {
std::fs::create_dir_all(dir.path().join(name).join("src")).unwrap();
std::fs::write(dir.path().join(name).join("src/lib.rs"), "").unwrap();
std::fs::write(
dir.path().join(name).join("Cargo.toml"),
format!("[package]\nname = \"{name}\"\nversion = \"2.0.0\"\nedition = \"2021\"\n"),
)
.unwrap();
}
git(dir.path(), &["init"]);
git(dir.path(), &["config", "user.email", "test@example.com"]);
git(dir.path(), &["config", "user.name", "Test"]);
std::process::Command::new("cargo")
.args(["generate-lockfile"])
.current_dir(dir.path())
.status()
.expect("cargo must be available");
git(dir.path(), &["add", "."]);
git(dir.path(), &["commit", "-m", "chore: initial"]);
create_tag(dir.path(), "v2.0.0");
add_commit(dir.path(), "x.txt", "fix: patch");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump"])
.current_dir(dir.path())
.assert()
.success()
.stderr(predicate::str::contains("Synced:"));
for name in ["alpha", "beta"] {
let content = std::fs::read_to_string(dir.path().join(name).join("Cargo.toml")).unwrap();
assert!(
content.contains("version = \"2.0.1\""),
"{name}/Cargo.toml should be updated to 2.0.1, got:\n{content}"
);
}
let files_in_commit = git(
dir.path(),
&["diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"],
);
assert!(
files_in_commit.contains("alpha/Cargo.toml"),
"alpha/Cargo.toml must be staged"
);
assert!(
files_in_commit.contains("beta/Cargo.toml"),
"beta/Cargo.toml must be staged"
);
}
#[test]
fn bump_workspace_deps_version_updated() {
let dir = tempfile::tempdir().unwrap();
let workspace_toml = r#"[workspace]
members = ["alpha", "beta"]
resolver = "2"
[workspace.package]
version = "1.0.0"
edition = "2021"
[workspace.dependencies]
alpha = { version = "1.0.0", path = "alpha" }
beta = { version = "1.0.0", path = "beta" }
serde = "1"
"#;
std::fs::write(dir.path().join("Cargo.toml"), workspace_toml).unwrap();
for name in ["alpha", "beta"] {
std::fs::create_dir_all(dir.path().join(name).join("src")).unwrap();
std::fs::write(dir.path().join(name).join("src/lib.rs"), "").unwrap();
std::fs::write(
dir.path().join(name).join("Cargo.toml"),
format!(
"[package]\nname = \"{name}\"\nversion.workspace = true\nedition.workspace = true\n"
),
)
.unwrap();
}
git(dir.path(), &["init"]);
git(dir.path(), &["config", "user.email", "test@example.com"]);
git(dir.path(), &["config", "user.name", "Test"]);
std::process::Command::new("cargo")
.args(["generate-lockfile"])
.current_dir(dir.path())
.status()
.expect("cargo must be available");
git(dir.path(), &["add", "."]);
git(dir.path(), &["commit", "-m", "chore: initial"]);
create_tag(dir.path(), "v1.0.0");
add_commit(dir.path(), "x.txt", "fix: a bug");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump"])
.current_dir(dir.path())
.assert()
.success();
let root = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
assert!(
root.contains("version = \"1.0.1\""),
"[workspace.package] version should be 1.0.1"
);
assert!(
root.contains("alpha = { version = \"1.0.1\", path = \"alpha\" }"),
"alpha workspace dep version should be 1.0.1, got:\n{root}"
);
assert!(
root.contains("beta = { version = \"1.0.1\", path = \"beta\" }"),
"beta workspace dep version should be 1.0.1, got:\n{root}"
);
assert!(
root.contains("serde = \"1\""),
"serde (non-path dep) must not be modified"
);
}
#[test]
fn release_as_patch_forces_patch_bump() {
let dir = tempfile::tempdir().unwrap();
init_bump_repo(dir.path());
create_tag(dir.path(), "v1.2.3");
add_commit(dir.path(), "a.txt", "feat: new feature");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump", "--dry-run", "--release-as", "patch"])
.current_dir(dir.path())
.assert()
.success()
.stderr(predicate::str::contains("1.2.3 → 1.2.4"));
}
#[test]
fn release_as_minor_forces_minor_bump() {
let dir = tempfile::tempdir().unwrap();
init_bump_repo(dir.path());
create_tag(dir.path(), "v1.2.3");
add_commit(dir.path(), "b.txt", "fix: small fix");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump", "--dry-run", "--release-as", "minor"])
.current_dir(dir.path())
.assert()
.success()
.stderr(predicate::str::contains("1.2.3 → 1.3.0"));
}
#[test]
fn release_as_major_forces_major_bump() {
let dir = tempfile::tempdir().unwrap();
init_bump_repo(dir.path());
create_tag(dir.path(), "v1.2.3");
add_commit(dir.path(), "c.txt", "fix: a fix");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump", "--dry-run", "--release-as", "major"])
.current_dir(dir.path())
.assert()
.success()
.stderr(predicate::str::contains("1.2.3 → 2.0.0"));
}
#[test]
fn release_as_level_respects_dry_run() {
let dir = tempfile::tempdir().unwrap();
init_bump_repo(dir.path());
create_tag(dir.path(), "v1.0.0");
add_commit(dir.path(), "d.txt", "fix: a fix");
let before = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
Command::cargo_bin("git-std")
.unwrap()
.args(["bump", "--dry-run", "--release-as", "minor"])
.current_dir(dir.path())
.assert()
.success()
.stderr(predicate::str::contains("1.0.0 → 1.1.0"))
.stderr(predicate::str::contains("Would commit"))
.stderr(predicate::str::contains("Would tag"));
let after = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
assert_eq!(before, after);
assert!(!dir.path().join("CHANGELOG.md").exists());
}
#[test]
fn release_as_minor_rejected_on_patch_scheme() {
let dir = tempfile::tempdir().unwrap();
init_bump_repo(dir.path());
create_tag(dir.path(), "v1.0.0");
std::fs::write(dir.path().join(".git-std.toml"), "scheme = \"patch\"\n").unwrap();
git(dir.path(), &["add", ".git-std.toml"]);
git(dir.path(), &["commit", "-m", "chore: add config"]);
add_commit(dir.path(), "e.txt", "fix: a fix");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump", "--dry-run", "--release-as", "minor"])
.current_dir(dir.path())
.assert()
.failure()
.stderr(predicate::str::contains(
"patch-only scheme does not support --release-as minor or --release-as major",
));
}
#[test]
fn release_as_major_rejected_on_patch_scheme() {
let dir = tempfile::tempdir().unwrap();
init_bump_repo(dir.path());
create_tag(dir.path(), "v1.0.0");
std::fs::write(dir.path().join(".git-std.toml"), "scheme = \"patch\"\n").unwrap();
git(dir.path(), &["add", ".git-std.toml"]);
git(dir.path(), &["commit", "-m", "chore: add config"]);
add_commit(dir.path(), "f.txt", "fix: a fix");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump", "--dry-run", "--release-as", "major"])
.current_dir(dir.path())
.assert()
.failure()
.stderr(predicate::str::contains(
"patch-only scheme does not support --release-as minor or --release-as major",
));
}
fn init_bump_repo_on_branch(dir: &std::path::Path, branch: &str) {
git(dir, &["init", "--initial-branch", branch]);
git(dir, &["config", "user.name", "Test"]);
git(dir, &["config", "user.email", "test@test.com"]);
std::fs::write(
dir.join("Cargo.toml"),
"[package]\nname = \"test-pkg\"\nversion = \"1.0.0\"\nedition = \"2021\"\n",
)
.unwrap();
git(dir, &["add", "Cargo.toml"]);
git(dir, &["commit", "-m", "chore: init"]);
}
#[test]
fn bump_on_non_main_branch_rejected_in_non_tty() {
let dir = tempfile::tempdir().unwrap();
init_bump_repo_on_branch(dir.path(), "feat/my-feature");
create_tag(dir.path(), "v1.0.0");
add_commit(dir.path(), "a.txt", "feat: add feature");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump", "--skip-changelog"])
.current_dir(dir.path())
.assert()
.failure()
.stderr(predicate::str::contains("feat/my-feature"))
.stderr(predicate::str::contains("main"));
}
#[test]
fn bump_on_non_main_branch_bypassed_with_yes_flag() {
let dir = tempfile::tempdir().unwrap();
init_bump_repo_on_branch(dir.path(), "feat/my-feature");
create_tag(dir.path(), "v1.0.0");
add_commit(dir.path(), "a.txt", "feat: add feature");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump", "--skip-changelog", "--yes"])
.current_dir(dir.path())
.assert()
.success();
}
#[test]
fn bump_on_non_main_branch_bypassed_with_env_var() {
let dir = tempfile::tempdir().unwrap();
init_bump_repo_on_branch(dir.path(), "feat/my-feature");
create_tag(dir.path(), "v1.0.0");
add_commit(dir.path(), "a.txt", "feat: add feature");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump", "--skip-changelog"])
.env("GIT_STD_YES", "1")
.current_dir(dir.path())
.assert()
.success();
}
#[test]
fn bump_dry_run_skips_branch_guard() {
let dir = tempfile::tempdir().unwrap();
init_bump_repo_on_branch(dir.path(), "feat/my-feature");
create_tag(dir.path(), "v1.0.0");
add_commit(dir.path(), "a.txt", "feat: add feature");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump", "--dry-run"])
.current_dir(dir.path())
.assert()
.success();
}
#[test]
fn bump_on_main_branch_no_prompt() {
let dir = tempfile::tempdir().unwrap();
init_bump_repo(dir.path()); create_tag(dir.path(), "v1.0.0");
std::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"test-pkg\"\nversion = \"1.0.0\"\nedition = \"2021\"\n",
)
.unwrap();
git(dir.path(), &["add", "Cargo.toml"]);
git(dir.path(), &["commit", "-m", "chore: set version"]);
add_commit(dir.path(), "a.txt", "feat: add feature");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump", "--skip-changelog"])
.current_dir(dir.path())
.assert()
.success();
}
#[test]
fn bump_release_branch_config_respected() {
let dir = tempfile::tempdir().unwrap();
init_bump_repo_on_branch(dir.path(), "release");
create_tag(dir.path(), "v1.0.0");
std::fs::write(
dir.path().join(".git-std.toml"),
"release_branch = \"release\"\n",
)
.unwrap();
git(dir.path(), &["add", ".git-std.toml"]);
git(dir.path(), &["commit", "-m", "chore: config"]);
add_commit(dir.path(), "a.txt", "feat: add feature");
Command::cargo_bin("git-std")
.unwrap()
.args(["bump", "--skip-changelog"])
.current_dir(dir.path())
.assert()
.success();
}