use crate::common;
use serde_json::Value;
use std::fs;
use std::path::Path;
use pretty_assertions::assert_eq;
fn as_str(output: &[u8]) -> String {
String::from_utf8_lossy(output).to_string()
}
fn stage_file(repo: &Path, name: &str, contents: &str) {
common::write_file(repo, name, contents);
common::git(repo, &["add", name]);
}
fn git_trim(repo: &Path, args: &[&str]) -> String {
common::git(repo, args).trim().to_string()
}
fn deterministic_env(path: &str) -> Vec<(&'static str, String)> {
vec![
("PATH", path.to_string()),
(
"GIT_AUTHOR_DATE",
"Thu, 01 Jan 1970 00:00:00 +0000".to_string(),
),
(
"GIT_COMMITTER_DATE",
"Thu, 01 Jan 1970 00:00:00 +0000".to_string(),
),
]
}
fn deterministic_env_with_pager(path: &str, pager: &str) -> Vec<(&'static str, String)> {
let mut env = deterministic_env(path);
env.push(("GIT_PAGER", pager.to_string()));
env
}
fn env_refs<'a>(envs: &'a [(&'static str, String)]) -> Vec<(&'static str, &'a str)> {
envs.iter()
.map(|(key, value)| (*key, value.as_str()))
.collect()
}
#[test]
fn commit_outside_git_repo_errors() {
let dir = tempfile::TempDir::new().expect("tempdir");
let output = common::run_semantic_commit_output(
dir.path(),
&["commit", "--message", "chore: test"],
&[],
None,
);
assert_eq!(output.status.code(), Some(1));
assert!(as_str(&output.stderr).contains("error: must run inside a git work tree"));
}
#[test]
fn commit_missing_git_dependency_exits_5() {
let dir = tempfile::TempDir::new().expect("tempdir");
let tool_dir = tempfile::TempDir::new().expect("tempdir");
let path_env = tool_dir.path().to_str().expect("tool dir path");
let envs_owned = deterministic_env(path_env);
let envs = env_refs(&envs_owned);
let output = common::run_semantic_commit_output(
dir.path(),
&["commit", "--message", "chore: test"],
&envs,
None,
);
assert_eq!(output.status.code(), Some(5));
assert!(as_str(&output.stderr).contains("error: git is required"));
}
#[test]
fn commit_help_flag_prints_usage() {
let dir = tempfile::TempDir::new().expect("tempdir");
let output = common::run_semantic_commit_output(dir.path(), &["commit", "--help"], &[], None);
assert_eq!(output.status.code(), Some(0));
assert!(
as_str(&output.stdout)
.contains("semantic-commit commit [--message <text>|--message-file <path>] [options]")
);
assert!(as_str(&output.stdout).contains("--amend"));
assert!(as_str(&output.stdout).contains("--message-only"));
assert!(as_str(&output.stdout).contains("--format <mode>"));
assert!(as_str(&output.stdout).contains("--body-bullet <text>"));
}
#[test]
fn commit_unknown_argument_errors_before_git_checks() {
let dir = tempfile::TempDir::new().expect("tempdir");
let output = common::run_semantic_commit_output(dir.path(), &["commit", "--bogus"], &[], None);
assert_eq!(output.status.code(), Some(1));
assert!(as_str(&output.stderr).contains("error: unknown argument: --bogus"));
}
#[test]
fn commit_message_flag_requires_value() {
let dir = tempfile::TempDir::new().expect("tempdir");
let output =
common::run_semantic_commit_output(dir.path(), &["commit", "--message"], &[], None);
assert_eq!(output.status.code(), Some(1));
assert!(as_str(&output.stderr).contains("error: --message requires a value"));
}
#[test]
fn commit_short_message_flag_requires_value() {
let dir = tempfile::TempDir::new().expect("tempdir");
let output = common::run_semantic_commit_output(dir.path(), &["commit", "-m"], &[], None);
assert_eq!(output.status.code(), Some(1));
assert!(as_str(&output.stderr).contains("error: -m requires a value"));
}
#[test]
fn commit_message_file_flag_requires_path() {
let dir = tempfile::TempDir::new().expect("tempdir");
let output =
common::run_semantic_commit_output(dir.path(), &["commit", "--message-file"], &[], None);
assert_eq!(output.status.code(), Some(1));
assert!(as_str(&output.stderr).contains("error: --message-file requires a path"));
}
#[test]
fn commit_no_staged_changes_exits_2() {
let repo = common::init_repo();
let output = common::run_semantic_commit_output(
repo.path(),
&["commit", "--message", "chore: test"],
&[],
None,
);
assert_eq!(output.status.code(), Some(2));
assert!(
as_str(&output.stderr)
.contains("error: no staged changes (stage files with git add first)")
);
}
#[test]
fn commit_invalid_header_format_is_hard_fail() {
let repo = common::init_repo();
stage_file(repo.path(), "a.txt", "hello\n");
let output = common::run_semantic_commit_output(
repo.path(),
&["commit", "--message", "Feat: bad"],
&[],
None,
);
assert_eq!(output.status.code(), Some(4));
assert!(as_str(&output.stderr).contains("error: invalid header format"));
let head = common::git_output(repo.path(), &["rev-parse", "--verify", "HEAD"]);
assert!(
!head.status.success(),
"expected no commit to have been created"
);
}
#[test]
fn commit_body_requires_blank_line() {
let repo = common::init_repo();
stage_file(repo.path(), "a.txt", "hello\n");
let message = "feat: test\n- Bad body without blank line\n";
let output = common::run_semantic_commit_output(repo.path(), &["commit"], &[], Some(message));
assert_eq!(output.status.code(), Some(4));
assert!(
as_str(&output.stderr)
.contains("error: commit body must be separated from header by a blank line")
);
}
#[test]
fn commit_body_line_requires_capitalized_bullet() {
let repo = common::init_repo();
stage_file(repo.path(), "a.txt", "hello\n");
let message = "feat: test\n\n- bad\n";
let output = common::run_semantic_commit_output(repo.path(), &["commit"], &[], Some(message));
assert_eq!(output.status.code(), Some(4));
assert!(
as_str(&output.stderr).contains(
"error: commit body line 3 must start with '- ' followed by uppercase letter"
),
"stderr was: {}",
as_str(&output.stderr)
);
}
#[test]
fn commit_body_accepts_two_space_continuation_lines() {
let repo = common::init_repo();
stage_file(repo.path(), "a.txt", "hello\n");
let message = "feat: test\n\n- First bullet wraps onto the next line because it is long.\n Continuation text describes the why in more detail.\n- Second bullet stands on its own.\n";
let output = common::run_semantic_commit_output(repo.path(), &["commit"], &[], Some(message));
assert_eq!(
output.status.code(),
Some(0),
"stderr was: {}",
as_str(&output.stderr)
);
}
#[test]
fn commit_body_rejects_continuation_without_preceding_bullet() {
let repo = common::init_repo();
stage_file(repo.path(), "a.txt", "hello\n");
let message = "feat: test\n\n Orphan continuation line without a leading bullet.\n";
let output = common::run_semantic_commit_output(repo.path(), &["commit"], &[], Some(message));
assert_eq!(output.status.code(), Some(4));
assert!(
as_str(&output.stderr).contains("commit body line 3 must start with '- '"),
"stderr was: {}",
as_str(&output.stderr)
);
}
#[test]
fn commit_message_and_message_file_are_mutually_exclusive() {
let dir = tempfile::TempDir::new().expect("tempdir");
let output = common::run_semantic_commit_output(
dir.path(),
&[
"commit",
"--message",
"chore: test",
"--message-file",
"message.txt",
],
&[],
None,
);
assert_eq!(output.status.code(), Some(1));
assert!(as_str(&output.stderr).contains("error: use only one of --message or --message-file"));
}
#[test]
fn commit_message_file_missing_errors() {
let repo = common::init_repo();
stage_file(repo.path(), "a.txt", "hello\n");
let output = common::run_semantic_commit_output(
repo.path(),
&["commit", "--message-file", "missing.txt"],
&[],
None,
);
assert_eq!(output.status.code(), Some(1));
assert!(as_str(&output.stderr).contains("error: message file not found: missing.txt"));
}
#[test]
fn commit_empty_stdin_message_errors_with_exit_3() {
let repo = common::init_repo();
stage_file(repo.path(), "a.txt", "hello\n");
let output = common::run_semantic_commit_output(repo.path(), &["commit"], &[], Some(""));
assert_eq!(output.status.code(), Some(3));
assert!(as_str(&output.stderr).contains("error: commit message is empty"));
}
#[test]
fn commit_whitespace_stdin_message_errors_with_exit_3() {
let repo = common::init_repo();
stage_file(repo.path(), "a.txt", "hello\n");
let output = common::run_semantic_commit_output(repo.path(), &["commit"], &[], Some(" \n\t\n"));
assert_eq!(output.status.code(), Some(3));
assert!(as_str(&output.stderr).contains("error: commit message is empty"));
}
#[test]
fn commit_automation_requires_message_flag_or_file() {
let repo = common::init_repo();
stage_file(repo.path(), "a.txt", "hello\n");
let output =
common::run_semantic_commit_output(repo.path(), &["commit", "--automation"], &[], Some(""));
assert_eq!(output.status.code(), Some(3));
assert!(
as_str(&output.stderr).contains("error: no commit message provided in automation mode")
);
}
#[test]
fn commit_validate_only_allows_outside_repo() {
let dir = tempfile::TempDir::new().expect("tempdir");
let output = common::run_semantic_commit_output(
dir.path(),
&[
"commit",
"--validate-only",
"--message",
"feat(core): add thing",
],
&[],
None,
);
assert_eq!(output.status.code(), Some(0));
}
#[test]
fn commit_validate_only_invalid_message_returns_4() {
let dir = tempfile::TempDir::new().expect("tempdir");
let output = common::run_semantic_commit_output(
dir.path(),
&[
"commit",
"--validate-only",
"--message",
"Feat(core): add thing",
],
&[],
None,
);
assert_eq!(output.status.code(), Some(4));
}
#[test]
fn commit_validate_only_rejects_claude_coauthor_trailer_in_message() {
let dir = tempfile::TempDir::new().expect("tempdir");
let output = common::run_semantic_commit_output(
dir.path(),
&[
"commit",
"--validate-only",
"--message",
"feat(core): add thing\n\nCo-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>",
],
&[],
None,
);
assert_eq!(output.status.code(), Some(4));
let stderr = as_str(&output.stderr);
assert!(
stderr.contains("blocked by rule `claude-coauthor-trailer`"),
"stderr was: {stderr}"
);
assert!(
stderr.contains("matched: Co-Authored-By: Claude"),
"stderr was: {stderr}"
);
}
#[test]
fn commit_validate_only_rejects_claude_coauthor_trailer_flag() {
let dir = tempfile::TempDir::new().expect("tempdir");
let output = common::run_semantic_commit_output(
dir.path(),
&[
"commit",
"--validate-only",
"--message",
"feat(core): add thing",
"--trailer",
"Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>",
],
&[],
None,
);
assert_eq!(output.status.code(), Some(4));
let stderr = as_str(&output.stderr);
assert!(
stderr.contains("source: --trailer #1"),
"stderr was: {stderr}"
);
assert!(
stderr.contains("blocked by rule `claude-coauthor-trailer`"),
"stderr was: {stderr}"
);
}
#[test]
fn commit_validate_only_rejects_claude_coauthor_equals_trailer_flag() {
let dir = tempfile::TempDir::new().expect("tempdir");
let output = common::run_semantic_commit_output(
dir.path(),
&[
"commit",
"--validate-only",
"--message",
"feat(core): add thing",
"--trailer",
"Co-Authored-By=Claude Haiku <noreply@anthropic.com>",
],
&[],
None,
);
assert_eq!(output.status.code(), Some(4));
let stderr = as_str(&output.stderr);
assert!(
stderr.contains("matched: Co-Authored-By: Claude ..."),
"stderr was: {stderr}"
);
}
#[test]
fn commit_validate_only_allows_non_claude_coauthor_trailers() {
let dir = tempfile::TempDir::new().expect("tempdir");
let output = common::run_semantic_commit_output(
dir.path(),
&[
"commit",
"--validate-only",
"--message",
"feat(core): add thing\n\nCo-Authored-By: Jane Dev <jane@example.com>",
"--trailer",
"Co-Authored-By: Build Bot <bot@example.com>",
],
&[],
None,
);
assert_eq!(
output.status.code(),
Some(0),
"stderr was: {}",
as_str(&output.stderr)
);
}
#[test]
fn commit_validate_only_allows_claude_as_part_of_longer_word() {
let dir = tempfile::TempDir::new().expect("tempdir");
let output = common::run_semantic_commit_output(
dir.path(),
&[
"commit",
"--validate-only",
"--message",
"feat(core): add thing\n\nCo-Authored-By: Claudette Dev <dev@example.com>",
],
&[],
None,
);
assert_eq!(
output.status.code(),
Some(0),
"stderr was: {}",
as_str(&output.stderr)
);
}
fn header_with_total_len(total_len: usize) -> String {
let prefix = "feat: ";
assert!(total_len > prefix.len());
format!("{prefix}{}", "a".repeat(total_len - prefix.len()))
}
#[test]
fn commit_max_header_width_flag_rejects_header_over_active_limit() {
let dir = tempfile::TempDir::new().expect("tempdir");
let message = header_with_total_len(73);
let output = common::run_semantic_commit_output(
dir.path(),
&[
"commit",
"--validate-only",
"--max-header-width",
"72",
"--message",
&message,
],
&[],
None,
);
assert_eq!(output.status.code(), Some(4));
assert!(
as_str(&output.stderr).contains("commit header exceeds 72 characters (max 72)"),
"stderr was: {}",
as_str(&output.stderr)
);
}
#[test]
fn commit_max_header_width_env_sets_default_when_flag_absent() {
let dir = tempfile::TempDir::new().expect("tempdir");
let message = header_with_total_len(81);
let output = common::run_semantic_commit_output(
dir.path(),
&["commit", "--validate-only", "--message", &message],
&[("SEMANTIC_COMMIT_HEADER_WIDTH", "80")],
None,
);
assert_eq!(output.status.code(), Some(4));
assert!(
as_str(&output.stderr).contains("commit header exceeds 80 characters (max 80)"),
"stderr was: {}",
as_str(&output.stderr)
);
}
#[test]
fn commit_max_header_width_flag_wins_over_env_default() {
let dir = tempfile::TempDir::new().expect("tempdir");
let message = header_with_total_len(79);
let output = common::run_semantic_commit_output(
dir.path(),
&[
"commit",
"--validate-only",
"--max-header-width",
"80",
"--message",
&message,
],
&[("SEMANTIC_COMMIT_HEADER_WIDTH", "72")],
None,
);
assert_eq!(
output.status.code(),
Some(0),
"stderr was: {}",
as_str(&output.stderr)
);
}
#[test]
fn commit_max_header_width_env_rejects_non_positive_values() {
let dir = tempfile::TempDir::new().expect("tempdir");
let output = common::run_semantic_commit_output(
dir.path(),
&[
"commit",
"--validate-only",
"--message",
"feat(core): add thing",
],
&[("SEMANTIC_COMMIT_HEADER_WIDTH", "0")],
None,
);
assert_eq!(output.status.code(), Some(1));
assert!(
as_str(&output.stderr).contains("SEMANTIC_COMMIT_HEADER_WIDTH must be a positive integer"),
"stderr was: {}",
as_str(&output.stderr)
);
}
#[test]
fn commit_dry_run_validates_and_checks_staged_without_committing() {
let repo = common::init_repo();
stage_file(repo.path(), "a.txt", "hello\n");
let output = common::run_semantic_commit_output(
repo.path(),
&["commit", "--dry-run", "--message", "feat(core): add thing"],
&[],
None,
);
assert_eq!(output.status.code(), Some(0));
let head = common::git_output(repo.path(), &["rev-parse", "--verify", "HEAD"]);
assert!(!head.status.success(), "expected no commit during dry-run");
}
#[test]
fn commit_json_outputs_result_metadata() {
let repo = common::init_repo();
stage_file(repo.path(), "a.txt", "hello\n");
let output = common::run_semantic_commit_output(
repo.path(),
&["commit", "--json", "--message", "feat(core): add thing"],
&[],
None,
);
assert_eq!(
output.status.code(),
Some(0),
"stderr was: {}",
as_str(&output.stderr)
);
let json: Value = serde_json::from_slice(&output.stdout).expect("commit json");
assert_eq!(json["schema_version"], "cli.semantic-commit.commit.v1");
assert_eq!(json["operation"], "commit");
assert_eq!(json["dry_run"], false);
assert_eq!(json["staged"]["file_count"], 1);
assert_eq!(json["staged"]["files"][0]["path"], "a.txt");
assert_eq!(
json["commit"]["sha"].as_str().expect("sha"),
git_trim(repo.path(), &["rev-parse", "HEAD"])
);
assert!(as_str(&output.stderr).trim().is_empty());
}
#[test]
fn commit_amend_no_edit_reuses_head_message() {
let repo = common::init_repo();
stage_file(repo.path(), "a.txt", "hello\n");
let first = common::run_semantic_commit_output(
repo.path(),
&[
"commit",
"--message",
"feat(core): add thing",
"--no-summary",
],
&[],
None,
);
assert_eq!(first.status.code(), Some(0));
stage_file(repo.path(), "b.txt", "world\n");
let amend = common::run_semantic_commit_output(
repo.path(),
&["commit", "--amend", "--no-edit", "--no-summary"],
&[],
None,
);
assert_eq!(
amend.status.code(),
Some(0),
"stderr was: {}",
as_str(&amend.stderr)
);
assert_eq!(git_trim(repo.path(), &["rev-list", "--count", "HEAD"]), "1");
assert_eq!(
git_trim(repo.path(), &["show", "-s", "--format=%s", "HEAD"]),
"feat(core): add thing"
);
assert_eq!(git_trim(repo.path(), &["show", "HEAD:b.txt"]), "world");
}
#[test]
fn commit_message_only_amend_updates_message_without_staged_changes() {
let repo = common::init_repo();
stage_file(repo.path(), "a.txt", "hello\n");
let first = common::run_semantic_commit_output(
repo.path(),
&[
"commit",
"--message",
"feat(core): add thing",
"--no-summary",
],
&[],
None,
);
assert_eq!(first.status.code(), Some(0));
let amend = common::run_semantic_commit_output(
repo.path(),
&[
"commit",
"--amend",
"--message-only",
"--message",
"fix(core): improve subject",
"--no-summary",
],
&[],
None,
);
assert_eq!(
amend.status.code(),
Some(0),
"stderr was: {}",
as_str(&amend.stderr)
);
assert_eq!(git_trim(repo.path(), &["rev-list", "--count", "HEAD"]), "1");
assert_eq!(
git_trim(repo.path(), &["show", "-s", "--format=%s", "HEAD"]),
"fix(core): improve subject"
);
}
#[test]
fn commit_message_only_rejects_staged_changes() {
let repo = common::init_repo();
stage_file(repo.path(), "a.txt", "hello\n");
let first = common::run_semantic_commit_output(
repo.path(),
&[
"commit",
"--message",
"feat(core): add thing",
"--no-summary",
],
&[],
None,
);
assert_eq!(first.status.code(), Some(0));
stage_file(repo.path(), "b.txt", "world\n");
let output = common::run_semantic_commit_output(
repo.path(),
&[
"commit",
"--amend",
"--message-only",
"--message",
"fix(core): improve subject",
],
&[],
None,
);
assert_eq!(output.status.code(), Some(1));
assert!(as_str(&output.stderr).contains("--message-only requires no staged changes"));
}
#[test]
fn commit_require_clean_rejects_unstaged_changes() {
let repo = common::init_repo();
stage_file(repo.path(), "a.txt", "hello\n");
common::write_file(repo.path(), "b.txt", "unstaged\n");
let output = common::run_semantic_commit_output(
repo.path(),
&[
"commit",
"--dry-run",
"--require-clean",
"--message",
"feat(core): add thing",
],
&[],
None,
);
assert_eq!(output.status.code(), Some(1));
assert!(as_str(&output.stderr).contains("unstaged or untracked changes present"));
}
#[test]
fn commit_expect_head_rejects_mismatch() {
let repo = common::init_repo();
stage_file(repo.path(), "a.txt", "hello\n");
let first = common::run_semantic_commit_output(
repo.path(),
&["commit", "--message", "feat(core): first", "--no-summary"],
&[],
None,
);
assert_eq!(first.status.code(), Some(0));
let first_sha = git_trim(repo.path(), &["rev-parse", "HEAD"]);
stage_file(repo.path(), "b.txt", "world\n");
let second = common::run_semantic_commit_output(
repo.path(),
&["commit", "--message", "feat(core): second", "--no-summary"],
&[],
None,
);
assert_eq!(second.status.code(), Some(0));
stage_file(repo.path(), "c.txt", "again\n");
let output = common::run_semantic_commit_output(
repo.path(),
&[
"commit",
"--dry-run",
"--expect-head",
&first_sha,
"--message",
"feat(core): third",
],
&[],
None,
);
assert_eq!(output.status.code(), Some(1));
assert!(as_str(&output.stderr).contains("HEAD mismatch"));
}
#[test]
fn commit_structured_message_builds_valid_commit_message() {
let repo = common::init_repo();
stage_file(repo.path(), "a.txt", "hello\n");
let output = common::run_semantic_commit_output(
repo.path(),
&[
"commit",
"--type",
"Feat",
"--scope",
"Core",
"--subject",
"add thing",
"--body-bullet",
"add supporting behavior",
"--no-summary",
],
&[],
None,
);
assert_eq!(
output.status.code(),
Some(0),
"stderr was: {}",
as_str(&output.stderr)
);
assert_eq!(
git_trim(repo.path(), &["show", "-s", "--format=%s", "HEAD"]),
"feat(core): add thing"
);
let body = common::git(repo.path(), &["show", "-s", "--format=%B", "HEAD"]);
assert!(body.contains("- Add supporting behavior"));
}
#[test]
fn commit_trailer_appends_git_trailer() {
let repo = common::init_repo();
stage_file(repo.path(), "a.txt", "hello\n");
let output = common::run_semantic_commit_output(
repo.path(),
&[
"commit",
"--message",
"feat(core): add thing",
"--trailer",
"Refs: #573",
"--no-summary",
],
&[],
None,
);
assert_eq!(
output.status.code(),
Some(0),
"stderr was: {}",
as_str(&output.stderr)
);
let body = common::git(repo.path(), &["show", "-s", "--format=%B", "HEAD"]);
assert!(body.contains("Refs: #573"));
}
#[test]
fn commit_signoff_appends_committer_signoff() {
let repo = common::init_repo();
stage_file(repo.path(), "a.txt", "hello\n");
let output = common::run_semantic_commit_output(
repo.path(),
&[
"commit",
"--message",
"feat(core): add thing",
"--signoff",
"--no-summary",
],
&[],
None,
);
assert_eq!(
output.status.code(),
Some(0),
"stderr was: {}",
as_str(&output.stderr)
);
let body = common::git(repo.path(), &["show", "-s", "--format=%B", "HEAD"]);
assert!(body.contains("Signed-off-by: Test User <test@example.com>"));
}
#[test]
fn commit_default_summary_falls_back_to_git_show_when_git_scope_missing() {
let repo = common::init_repo();
stage_file(repo.path(), "a.txt", "hello\n");
let envs_owned = deterministic_env("/usr/bin:/bin:/usr/sbin:/sbin");
let envs = env_refs(&envs_owned);
let output = common::run_semantic_commit_output(
repo.path(),
&[
"commit",
"--message",
"feat(core): add thing",
"--no-progress",
],
&envs,
None,
);
assert_eq!(output.status.code(), Some(0));
assert!(as_str(&output.stderr).contains("warning: git-scope summary unavailable"));
assert!(as_str(&output.stdout).contains("feat(core): add thing"));
}
#[test]
fn commit_no_summary_suppresses_summary_output() {
let repo = common::init_repo();
stage_file(repo.path(), "a.txt", "hello\n");
let envs_owned = deterministic_env("/usr/bin:/bin:/usr/sbin:/sbin");
let envs = env_refs(&envs_owned);
let output = common::run_semantic_commit_output(
repo.path(),
&[
"commit",
"-m",
"feat(core): add thing",
"--no-summary",
"--no-progress",
],
&envs,
None,
);
assert_eq!(output.status.code(), Some(0));
assert!(as_str(&output.stdout).trim().is_empty());
}
#[test]
fn commit_quiet_suppresses_progress_and_summary_output() {
let repo = common::init_repo();
stage_file(repo.path(), "a.txt", "hello\n");
let envs_owned = deterministic_env("/usr/bin:/bin:/usr/sbin:/sbin");
let envs = env_refs(&envs_owned);
let output = common::run_semantic_commit_output(
repo.path(),
&["commit", "--message", "feat(core): add thing", "--quiet"],
&envs,
None,
);
assert_eq!(output.status.code(), Some(0));
assert!(as_str(&output.stdout).trim().is_empty());
assert!(!as_str(&output.stderr).contains("semantic-commit"));
assert!(!as_str(&output.stderr).contains("git-scope summary unavailable"));
}
#[test]
fn commit_git_show_summary_overrides_git_pager() {
let env_without_pager = deterministic_env("/usr/bin:/bin:/usr/sbin:/sbin");
let env_without_pager = env_refs(&env_without_pager);
let without_pager =
run_git_show_summary_commit(&env_without_pager, "without-pager.txt", "hello\n");
let env_with_pager = deterministic_env_with_pager("/usr/bin:/bin:/usr/sbin:/sbin", "less");
let env_with_pager = env_refs(&env_with_pager);
let with_pager = run_git_show_summary_commit(&env_with_pager, "without-pager.txt", "hello\n");
assert_eq!(without_pager.status.code(), Some(0));
assert_eq!(with_pager.status.code(), Some(0));
assert_eq!(as_str(&with_pager.stdout), as_str(&without_pager.stdout));
}
#[test]
fn commit_git_show_summary_mode_works_without_git_scope() {
let repo = common::init_repo();
stage_file(repo.path(), "a.txt", "hello\n");
let envs_owned = deterministic_env("/usr/bin:/bin:/usr/sbin:/sbin");
let envs = env_refs(&envs_owned);
let output = common::run_semantic_commit_output(
repo.path(),
&[
"commit",
"--message",
"feat(core): add thing",
"--summary",
"git-show",
"--no-progress",
],
&envs,
None,
);
assert_eq!(output.status.code(), Some(0));
assert!(as_str(&output.stdout).contains("feat(core): add thing"));
assert!(!as_str(&output.stderr).contains("warning: git-scope"));
}
fn run_git_show_summary_commit(
envs: &[(&'static str, &str)],
file_name: &str,
contents: &str,
) -> std::process::Output {
let repo = common::init_repo();
stage_file(repo.path(), file_name, contents);
common::run_semantic_commit_output(
repo.path(),
&[
"commit",
"--message",
"feat(core): add thing",
"--summary",
"git-show",
"--no-progress",
],
envs,
None,
)
}
#[test]
fn commit_message_out_writes_recovery_message() {
let repo = common::init_repo();
stage_file(repo.path(), "a.txt", "hello\n");
let out_path = repo.path().join("commit-message.txt");
let output = common::run_semantic_commit_output(
repo.path(),
&[
"commit",
"--message",
"Feat(core): bad",
"--message-out",
"commit-message.txt",
],
&[],
None,
);
assert_eq!(output.status.code(), Some(4));
let saved = fs::read_to_string(out_path).expect("read message-out file");
assert_eq!(saved, "Feat(core): bad");
}
#[test]
fn commit_message_file_successfully_commits_with_git_scope() {
let repo = common::init_repo();
stage_file(repo.path(), "a.txt", "hello\n");
common::write_file(
repo.path(),
"message.txt",
"feat(core): add thing\n\n- Add thing\n",
);
let tool_dir = tempfile::TempDir::new().expect("tempdir");
common::write_executable(
tool_dir.path(),
"git-scope",
r#"#!/usr/bin/env bash
set -euo pipefail
if [[ "${1-}" != "commit" || "${2-}" != "HEAD" || "${3-}" != "--no-color" ]]; then
echo "unexpected args: $*" >&2
exit 2
fi
echo "GIT_SCOPE_OK"
"#,
);
let tool_dir = tool_dir.path().to_str().expect("tool dir str");
let path_env = format!("{tool_dir}:/usr/bin:/bin:/usr/sbin:/sbin");
let envs_owned = deterministic_env(&path_env);
let envs = env_refs(&envs_owned);
let output = common::run_semantic_commit_output(
repo.path(),
&["commit", "-F", "message.txt", "--no-progress"],
&envs,
None,
);
assert_eq!(output.status.code(), Some(0));
assert!(as_str(&output.stdout).contains("GIT_SCOPE_OK"));
}
#[cfg(unix)]
#[test]
fn commit_falls_back_when_git_scope_is_not_executable() {
use std::os::unix::fs::PermissionsExt;
let repo = common::init_repo();
stage_file(repo.path(), "a.txt", "hello\n");
let tool_dir = tempfile::TempDir::new().expect("tempdir");
let tool_path = tool_dir.path().join("git-scope");
fs::write(&tool_path, "#!/usr/bin/env bash\nexit 0\n").expect("write git-scope");
let mut perms = fs::metadata(&tool_path).expect("metadata").permissions();
perms.set_mode(0o644);
fs::set_permissions(&tool_path, perms).expect("set permissions");
let tool_dir = tool_dir.path().to_str().unwrap();
let path_env = format!("{tool_dir}:/usr/bin:/bin:/usr/sbin:/sbin");
let envs_owned = deterministic_env(&path_env);
let envs = env_refs(&envs_owned);
let output = common::run_semantic_commit_output(
repo.path(),
&[
"commit",
"--message",
"feat(core): add thing",
"--no-progress",
],
&envs,
None,
);
assert_eq!(output.status.code(), Some(0));
assert!(as_str(&output.stderr).contains("warning: git-scope summary unavailable"));
assert!(as_str(&output.stdout).contains("feat(core): add thing"));
}
#[test]
fn commit_auto_fix_wraps_overlength_body_line() {
let repo = common::init_repo();
stage_file(repo.path(), "a.txt", "hello\n");
let long_word_run = "word ".repeat(30);
let long_bullet = long_word_run.trim_end();
let message = format!("feat: test\n\n- {long_bullet}\n");
let output = common::run_semantic_commit_output(
repo.path(),
&["commit", "--auto-fix", "--validate-only"],
&[],
Some(&message),
);
assert_eq!(
output.status.code(),
Some(0),
"stderr was: {}",
as_str(&output.stderr)
);
}
#[test]
fn commit_auto_fix_lowercases_uppercase_type_and_bullet() {
let dir = tempfile::TempDir::new().expect("tempdir");
let output = common::run_semantic_commit_output(
dir.path(),
&[
"commit",
"--auto-fix",
"--validate-only",
"--message",
"Feat(Core): add thing",
],
&[],
None,
);
assert_eq!(
output.status.code(),
Some(0),
"stderr was: {}",
as_str(&output.stderr)
);
}
#[test]
fn commit_auto_fix_does_not_truncate_overlength_header() {
let dir = tempfile::TempDir::new().expect("tempdir");
let header = format!("feat: {}", "a".repeat(120));
let output = common::run_semantic_commit_output(
dir.path(),
&[
"commit",
"--auto-fix",
"--validate-only",
"--message",
&header,
],
&[],
None,
);
assert_eq!(output.status.code(), Some(4));
assert!(
as_str(&output.stderr).contains("commit header exceeds 100 characters"),
"stderr was: {}",
as_str(&output.stderr)
);
}
#[test]
fn commit_auto_fix_message_out_captures_normalized_message() {
let repo = common::init_repo();
stage_file(repo.path(), "a.txt", "hello\n");
let out_path = repo.path().join("commit-message.txt");
let output = common::run_semantic_commit_output(
repo.path(),
&[
"commit",
"--auto-fix",
"--dry-run",
"--message",
"Feat(Core): add thing",
"--message-out",
"commit-message.txt",
],
&[],
None,
);
assert_eq!(
output.status.code(),
Some(0),
"stderr was: {}",
as_str(&output.stderr)
);
let saved = fs::read_to_string(out_path).expect("read message-out file");
assert_eq!(saved, "feat(core): add thing");
}
#[test]
fn commit_repo_flag_commits_from_external_cwd() {
let outer = tempfile::TempDir::new().expect("tempdir");
let repo = common::init_repo();
stage_file(repo.path(), "a.txt", "hello\n");
let envs_owned = deterministic_env("/usr/bin:/bin:/usr/sbin:/sbin");
let envs = env_refs(&envs_owned);
let repo_path = repo.path().to_str().expect("repo path");
let output = common::run_semantic_commit_output(
outer.path(),
&[
"commit",
"--repo",
repo_path,
"--message",
"feat(core): add thing",
"--summary",
"none",
"--no-progress",
],
&envs,
None,
);
assert_eq!(output.status.code(), Some(0));
let head = common::git_output(repo.path(), &["rev-parse", "--verify", "HEAD"]);
assert!(
head.status.success(),
"expected commit to be created in --repo target"
);
}
#[test]
fn fixup_creates_fixup_commit_for_target() {
let repo = common::init_repo();
stage_file(repo.path(), "base.txt", "base\n");
let base = common::run_semantic_commit_output(
repo.path(),
&[
"commit",
"--message",
"feat(core): base change",
"--no-summary",
],
&[],
None,
);
assert_eq!(base.status.code(), Some(0));
stage_file(repo.path(), "fix.txt", "fix\n");
let output = common::run_semantic_commit_output(
repo.path(),
&["fixup", "--target", "HEAD", "--no-summary"],
&[],
None,
);
assert_eq!(
output.status.code(),
Some(0),
"stderr was: {}",
as_str(&output.stderr)
);
assert_eq!(
git_trim(repo.path(), &["show", "-s", "--format=%s", "HEAD"]),
"fixup! feat(core): base change"
);
}
#[test]
fn squash_json_dry_run_reports_target_without_committing() {
let repo = common::init_repo();
stage_file(repo.path(), "base.txt", "base\n");
let base = common::run_semantic_commit_output(
repo.path(),
&[
"commit",
"--message",
"feat(core): base change",
"--no-summary",
],
&[],
None,
);
assert_eq!(base.status.code(), Some(0));
let base_sha = git_trim(repo.path(), &["rev-parse", "HEAD"]);
stage_file(repo.path(), "squash.txt", "squash\n");
let output = common::run_semantic_commit_output(
repo.path(),
&["squash", "--target", "HEAD", "--json", "--dry-run"],
&[],
None,
);
assert_eq!(
output.status.code(),
Some(0),
"stderr was: {}",
as_str(&output.stderr)
);
let json: Value = serde_json::from_slice(&output.stdout).expect("squash json");
assert_eq!(json["operation"], "squash");
assert_eq!(json["dry_run"], true);
assert_eq!(json["target"]["sha"], base_sha);
assert_eq!(json["generated_subject"], "squash! feat(core): base change");
assert_eq!(git_trim(repo.path(), &["rev-parse", "HEAD"]), base_sha);
}
#[test]
fn squash_creates_squash_commit_without_opening_editor() {
let repo = common::init_repo();
stage_file(repo.path(), "base.txt", "base\n");
let base = common::run_semantic_commit_output(
repo.path(),
&[
"commit",
"--message",
"feat(core): base change",
"--no-summary",
],
&[],
None,
);
assert_eq!(base.status.code(), Some(0));
common::write_executable(repo.path(), "fail-editor.sh", "#!/bin/sh\nexit 42\n");
let editor = repo.path().join("fail-editor.sh");
stage_file(repo.path(), "squash.txt", "squash\n");
let output = common::run_semantic_commit_output(
repo.path(),
&["squash", "--target", "HEAD", "--json", "--no-summary"],
&[("GIT_EDITOR", editor.to_str().expect("editor path"))],
None,
);
assert_eq!(
output.status.code(),
Some(0),
"stderr was: {}",
as_str(&output.stderr)
);
let json: Value = serde_json::from_slice(&output.stdout).expect("squash json");
assert_eq!(json["operation"], "squash");
assert_eq!(
git_trim(repo.path(), &["show", "-s", "--format=%s", "HEAD"]),
"squash! feat(core): base change"
);
}
#[test]
fn fixup_invalid_target_fails_without_commit() {
let repo = common::init_repo();
stage_file(repo.path(), "base.txt", "base\n");
let base = common::run_semantic_commit_output(
repo.path(),
&[
"commit",
"--message",
"feat(core): base change",
"--no-summary",
],
&[],
None,
);
assert_eq!(base.status.code(), Some(0));
let base_sha = git_trim(repo.path(), &["rev-parse", "HEAD"]);
stage_file(repo.path(), "fix.txt", "fix\n");
let output = common::run_semantic_commit_output(
repo.path(),
&["fixup", "--target", "missing-target"],
&[],
None,
);
assert_eq!(output.status.code(), Some(1));
assert!(as_str(&output.stderr).contains("target revision not found"));
assert_eq!(git_trim(repo.path(), &["rev-parse", "HEAD"]), base_sha);
}