use crate::git;
use nils_common::git as common_git;
use nils_term::progress::{Progress, ProgressFinish, ProgressOptions};
use serde_json::json;
use std::fs::File;
use std::io::{BufRead, BufReader, IsTerminal, Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus, Output, Stdio};
const EXIT_ERROR: i32 = 1;
const EXIT_NO_STAGED_CHANGES: i32 = 2;
const EXIT_MESSAGE_REQUIRED: i32 = 3;
const EXIT_VALIDATION_FAILED: i32 = 4;
const EXIT_DEPENDENCY_ERROR: i32 = 5;
const CAT_PAGER_ENV: [(&str, &str); 2] = [("GIT_PAGER", "cat"), ("PAGER", "cat")];
const NONINTERACTIVE_COMMIT_ENV: [(&str, &str); 3] = [
("GIT_PAGER", "cat"),
("PAGER", "cat"),
("GIT_EDITOR", "true"),
];
const COMMIT_RESULT_SCHEMA_VERSION: &str = "cli.semantic-commit.commit.v1";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum SummaryMode {
GitScope,
GitShow,
None,
}
impl SummaryMode {
fn parse(value: &str) -> Option<Self> {
match value {
"git-scope" => Some(Self::GitScope),
"git-show" => Some(Self::GitShow),
"none" => Some(Self::None),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum OutputFormat {
Text,
Json,
}
impl OutputFormat {
fn parse(value: &str) -> Option<Self> {
match value {
"text" => Some(Self::Text),
"json" => Some(Self::Json),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum CommitOperation {
Create,
Amend,
MessageOnlyAmend,
}
impl CommitOperation {
fn as_str(self) -> &'static str {
match self {
Self::Create => "commit",
Self::Amend => "amend",
Self::MessageOnlyAmend => "message_only_amend",
}
}
fn progress_label(self) -> &'static str {
match self {
Self::Create => "git commit",
Self::Amend | Self::MessageOnlyAmend => "git commit --amend",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct StructuredMessage {
typ: Option<String>,
scope: Option<String>,
subject: Option<String>,
body_bullets: Vec<String>,
}
impl StructuredMessage {
fn has_any(&self) -> bool {
self.typ.is_some()
|| self.scope.is_some()
|| self.subject.is_some()
|| !self.body_bullets.is_empty()
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct CommitMetadata {
sha: String,
subject: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct StagedEntry {
status: String,
path: String,
old_path: Option<String>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum CleanupOperation {
Fixup,
Squash,
}
impl CleanupOperation {
fn as_str(self) -> &'static str {
match self {
Self::Fixup => "fixup",
Self::Squash => "squash",
}
}
fn git_flag(self) -> &'static str {
match self {
Self::Fixup => "--fixup",
Self::Squash => "--squash",
}
}
fn subject_prefix(self) -> &'static str {
match self {
Self::Fixup => "fixup!",
Self::Squash => "squash!",
}
}
}
#[derive(Debug)]
struct CleanupOptions {
target: Option<String>,
summary_mode: SummaryMode,
output_format: OutputFormat,
no_progress: bool,
quiet: bool,
dry_run: bool,
allow_empty: bool,
require_clean: bool,
expect_head: Option<String>,
repo: Option<PathBuf>,
}
impl CleanupOptions {
fn new(_operation: CleanupOperation) -> Self {
Self {
target: None,
summary_mode: SummaryMode::GitScope,
output_format: OutputFormat::Text,
no_progress: false,
quiet: false,
dry_run: false,
allow_empty: false,
require_clean: false,
expect_head: None,
repo: None,
}
}
}
#[derive(Debug)]
struct CommitOptions {
message: Option<String>,
message_file: Option<String>,
message_out: Option<PathBuf>,
summary_mode: SummaryMode,
output_format: OutputFormat,
no_progress: bool,
quiet: bool,
automation: bool,
validate_only: bool,
dry_run: bool,
auto_fix: bool,
amend: bool,
no_edit: bool,
message_only: bool,
allow_empty: bool,
require_clean: bool,
expect_head: Option<String>,
signoff: bool,
trailers: Vec<String>,
structured: StructuredMessage,
repo: Option<PathBuf>,
max_header_width: usize,
}
impl Default for CommitOptions {
fn default() -> Self {
Self {
message: None,
message_file: None,
message_out: None,
summary_mode: SummaryMode::GitScope,
output_format: OutputFormat::Text,
no_progress: false,
quiet: false,
automation: false,
validate_only: false,
dry_run: false,
auto_fix: false,
amend: false,
no_edit: false,
message_only: false,
allow_empty: false,
require_clean: false,
expect_head: None,
signoff: false,
trailers: Vec::new(),
structured: StructuredMessage {
typ: None,
scope: None,
subject: None,
body_bullets: Vec::new(),
},
repo: None,
max_header_width: DEFAULT_MAX_HEADER_WIDTH,
}
}
}
pub fn run(args: &[String]) -> i32 {
let mut options = match parse_args(args) {
Ok(options) => options,
Err(code) => return code,
};
let operation = commit_operation(&options);
if !git::command_exists("git") {
eprintln!("error: git is required (ensure it is installed and on PATH)");
return EXIT_DEPENDENCY_ERROR;
}
if options.output_format == OutputFormat::Json {
options.no_progress = true;
options.summary_mode = SummaryMode::None;
}
if options.quiet {
options.no_progress = true;
if options.output_format == OutputFormat::Text {
options.summary_mode = SummaryMode::None;
}
}
let mut message_contents = match read_message_contents(&options) {
Ok(contents) => contents,
Err(code) => return code,
};
if options.auto_fix {
message_contents = normalize_message(&message_contents);
}
if let Some(path) = options.message_out.as_deref()
&& let Err(err) = write_message_file(path, &message_contents)
{
eprintln!("error: failed to write --message-out file: {err}");
return EXIT_ERROR;
}
if let Err(code) = validate_blocked_message_rules(&message_contents, &options.trailers) {
return code;
}
let tmpfile = match tempfile::NamedTempFile::new() {
Ok(file) => file,
Err(_) => {
eprintln!("error: failed to create temp file for commit message");
return EXIT_ERROR;
}
};
if let Err(err) = write_message_file(tmpfile.path(), &message_contents) {
eprintln!("{err:#}");
return EXIT_ERROR;
}
if let Err(code) = validate_commit_message_with_width(tmpfile.path(), options.max_header_width)
{
return code;
}
if options.validate_only {
if options.output_format == OutputFormat::Json {
print_commit_json_result(operation, true, false, None, None, Vec::new());
}
return 0;
}
if !git::is_inside_work_tree(options.repo.as_deref()) {
eprintln!("error: must run inside a git work tree");
return EXIT_ERROR;
}
if let Some(expected) = options.expect_head.as_deref()
&& let Err(code) = ensure_expected_head(options.repo.as_deref(), expected)
{
return code;
}
if options.require_clean
&& let Err(code) = ensure_no_unstaged_or_untracked(options.repo.as_deref())
{
return code;
}
let has_staged_changes = match git::has_staged_changes(options.repo.as_deref()) {
Ok(value) => value,
Err(err) => {
eprintln!("{err:#}");
return EXIT_ERROR;
}
};
if operation == CommitOperation::MessageOnlyAmend && has_staged_changes {
eprintln!("error: --message-only requires no staged changes");
return EXIT_ERROR;
}
if !has_staged_changes && !options.allow_empty && operation != CommitOperation::MessageOnlyAmend
{
eprintln!("error: no staged changes (stage files with git add first)");
return EXIT_NO_STAGED_CHANGES;
}
let staged_entries = match staged_entries(options.repo.as_deref()) {
Ok(entries) => entries,
Err(err) => {
eprintln!("{err:#}");
return EXIT_ERROR;
}
};
if options.dry_run {
if options.output_format == OutputFormat::Json {
print_commit_json_result(
operation,
false,
true,
current_head_metadata(options.repo.as_deref()),
None,
staged_entries,
);
}
return 0;
}
let progress = if !options.no_progress {
Some(Progress::spinner(
ProgressOptions::default()
.with_prefix("semantic-commit ")
.with_finish(ProgressFinish::Clear),
))
} else {
None
};
if let Some(progress) = &progress {
progress.set_message(operation.progress_label());
progress.tick();
}
let status = git_commit(tmpfile.path(), &options, operation);
if let Some(progress) = &progress {
progress.finish_and_clear();
}
match status {
Ok(status) if status.success() => {}
Ok(status) => {
let rc = status.code().unwrap_or(EXIT_ERROR);
eprintln!("error: git commit failed (exit code: {rc})");
return rc;
}
Err(err) => {
eprintln!("{err:#}");
return EXIT_ERROR;
}
}
let commit = current_head_metadata(options.repo.as_deref());
if options.output_format == OutputFormat::Json {
print_commit_json_result(operation, false, false, commit, None, staged_entries);
return 0;
}
print_summary(options.summary_mode, options.repo.as_deref())
}
fn commit_operation(options: &CommitOptions) -> CommitOperation {
if options.message_only {
CommitOperation::MessageOnlyAmend
} else if options.amend {
CommitOperation::Amend
} else {
CommitOperation::Create
}
}
pub fn run_fixup(args: &[String]) -> i32 {
run_cleanup(args, CleanupOperation::Fixup)
}
pub fn run_squash(args: &[String]) -> i32 {
run_cleanup(args, CleanupOperation::Squash)
}
fn run_cleanup(args: &[String], operation: CleanupOperation) -> i32 {
let mut options = match parse_cleanup_args(args, operation) {
Ok(options) => options,
Err(code) => return code,
};
if !git::command_exists("git") {
eprintln!("error: git is required (ensure it is installed and on PATH)");
return EXIT_DEPENDENCY_ERROR;
}
if options.output_format == OutputFormat::Json {
options.no_progress = true;
options.summary_mode = SummaryMode::None;
}
if options.quiet {
options.no_progress = true;
if options.output_format == OutputFormat::Text {
options.summary_mode = SummaryMode::None;
}
}
if !git::is_inside_work_tree(options.repo.as_deref()) {
eprintln!("error: must run inside a git work tree");
return EXIT_ERROR;
}
let target = match resolve_target_metadata(options.repo.as_deref(), options.target.as_deref()) {
Ok(target) => target,
Err(code) => return code,
};
if let Some(expected) = options.expect_head.as_deref()
&& let Err(code) = ensure_expected_head(options.repo.as_deref(), expected)
{
return code;
}
if options.require_clean
&& let Err(code) = ensure_no_unstaged_or_untracked(options.repo.as_deref())
{
return code;
}
let has_staged_changes = match git::has_staged_changes(options.repo.as_deref()) {
Ok(value) => value,
Err(err) => {
eprintln!("{err:#}");
return EXIT_ERROR;
}
};
if !has_staged_changes && !options.allow_empty {
eprintln!("error: no staged changes (stage files with git add first)");
return EXIT_NO_STAGED_CHANGES;
}
let staged_entries = match staged_entries(options.repo.as_deref()) {
Ok(entries) => entries,
Err(err) => {
eprintln!("{err:#}");
return EXIT_ERROR;
}
};
if options.dry_run {
if options.output_format == OutputFormat::Json {
print_cleanup_json_result(operation, true, None, target, staged_entries);
}
return 0;
}
let progress = if !options.no_progress {
Some(Progress::spinner(
ProgressOptions::default()
.with_prefix("semantic-commit ")
.with_finish(ProgressFinish::Clear),
))
} else {
None
};
if let Some(progress) = &progress {
progress.set_message(format!("git commit --{}", operation.as_str()));
progress.tick();
}
let status = git_cleanup_commit(&options, operation, &target.sha);
if let Some(progress) = &progress {
progress.finish_and_clear();
}
match status {
Ok(status) if status.success() => {}
Ok(status) => {
let rc = status.code().unwrap_or(EXIT_ERROR);
eprintln!(
"error: git commit --{} failed (exit code: {rc})",
operation.as_str()
);
return rc;
}
Err(err) => {
eprintln!("{err:#}");
return EXIT_ERROR;
}
}
let commit = current_head_metadata(options.repo.as_deref());
if options.output_format == OutputFormat::Json {
print_cleanup_json_result(operation, false, commit, target, staged_entries);
return 0;
}
print_summary(options.summary_mode, options.repo.as_deref())
}
fn parse_args(args: &[String]) -> Result<CommitOptions, i32> {
let mut options = CommitOptions::default();
let mut max_header_width_from_flag = false;
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"-h" | "--help" => {
print_usage_stdout();
return Err(0);
}
"--message" | "-m" => {
let value = match args.get(i + 1) {
Some(value) => value.clone(),
None => {
eprintln!("error: {} requires a value", args[i]);
print_usage_stderr();
return Err(EXIT_ERROR);
}
};
options.message = Some(value);
i += 2;
}
"--message-file" | "-F" => {
let value = match args.get(i + 1) {
Some(value) => value.clone(),
None => {
eprintln!("error: {} requires a path", args[i]);
print_usage_stderr();
return Err(EXIT_ERROR);
}
};
options.message_file = Some(value);
i += 2;
}
"--message-out" => {
let value = match args.get(i + 1) {
Some(value) => value.clone(),
None => {
eprintln!("error: --message-out requires a path");
print_usage_stderr();
return Err(EXIT_ERROR);
}
};
options.message_out = Some(PathBuf::from(value));
i += 2;
}
"--summary" => {
let value = match args.get(i + 1) {
Some(value) => value,
None => {
eprintln!("error: --summary requires a value");
print_usage_stderr();
return Err(EXIT_ERROR);
}
};
let Some(mode) = SummaryMode::parse(value) else {
eprintln!(
"error: invalid --summary value: {value} (expected: git-scope, git-show, none)"
);
print_usage_stderr();
return Err(EXIT_ERROR);
};
options.summary_mode = mode;
i += 2;
}
"--no-summary" => {
options.summary_mode = SummaryMode::None;
i += 1;
}
"--format" => {
let value = match args.get(i + 1) {
Some(value) => value,
None => {
eprintln!("error: --format requires a value");
print_usage_stderr();
return Err(EXIT_ERROR);
}
};
let Some(format) = OutputFormat::parse(value) else {
eprintln!("error: invalid --format value: {value} (expected: text, json)");
print_usage_stderr();
return Err(EXIT_ERROR);
};
options.output_format = format;
i += 2;
}
"--json" => {
options.output_format = OutputFormat::Json;
i += 1;
}
"--no-progress" => {
options.no_progress = true;
i += 1;
}
"--quiet" => {
options.quiet = true;
i += 1;
}
"--automation" | "--non-interactive" => {
options.automation = true;
i += 1;
}
"--validate-only" => {
options.validate_only = true;
i += 1;
}
"--dry-run" => {
options.dry_run = true;
i += 1;
}
"--auto-fix" => {
options.auto_fix = true;
i += 1;
}
"--amend" => {
options.amend = true;
i += 1;
}
"--no-edit" => {
options.no_edit = true;
i += 1;
}
"--message-only" => {
options.message_only = true;
i += 1;
}
"--allow-empty" => {
options.allow_empty = true;
i += 1;
}
"--require-clean" | "--no-unstaged" => {
options.require_clean = true;
i += 1;
}
"--expect-head" => {
let value = match args.get(i + 1) {
Some(value) => value.clone(),
None => {
eprintln!("error: --expect-head requires a revision");
print_usage_stderr();
return Err(EXIT_ERROR);
}
};
options.expect_head = Some(value);
i += 2;
}
"--signoff" => {
options.signoff = true;
i += 1;
}
"--trailer" => {
let value = match args.get(i + 1) {
Some(value) => value.clone(),
None => {
eprintln!("error: --trailer requires a value");
print_usage_stderr();
return Err(EXIT_ERROR);
}
};
options.trailers.push(value);
i += 2;
}
"--type" => {
let value = match args.get(i + 1) {
Some(value) => value.clone(),
None => {
eprintln!("error: --type requires a value");
print_usage_stderr();
return Err(EXIT_ERROR);
}
};
options.structured.typ = Some(value);
i += 2;
}
"--scope" => {
let value = match args.get(i + 1) {
Some(value) => value.clone(),
None => {
eprintln!("error: --scope requires a value");
print_usage_stderr();
return Err(EXIT_ERROR);
}
};
options.structured.scope = Some(value);
i += 2;
}
"--subject" => {
let value = match args.get(i + 1) {
Some(value) => value.clone(),
None => {
eprintln!("error: --subject requires a value");
print_usage_stderr();
return Err(EXIT_ERROR);
}
};
options.structured.subject = Some(value);
i += 2;
}
"--body-bullet" | "--bullet" => {
let value = match args.get(i + 1) {
Some(value) => value.clone(),
None => {
eprintln!("error: {} requires a value", args[i]);
print_usage_stderr();
return Err(EXIT_ERROR);
}
};
options.structured.body_bullets.push(value);
i += 2;
}
"--repo" => {
let value = match args.get(i + 1) {
Some(value) => value.clone(),
None => {
eprintln!("error: --repo requires a path");
print_usage_stderr();
return Err(EXIT_ERROR);
}
};
options.repo = Some(PathBuf::from(value));
i += 2;
}
"--max-header-width" => {
let value = match args.get(i + 1) {
Some(value) => value,
None => {
eprintln!("error: --max-header-width requires a value");
print_usage_stderr();
return Err(EXIT_ERROR);
}
};
options.max_header_width = parse_header_width_flag(value)?;
max_header_width_from_flag = true;
i += 2;
}
other => {
eprintln!("error: unknown argument: {other}");
print_usage_stderr();
return Err(EXIT_ERROR);
}
}
}
if options.message.is_some() && options.message_file.is_some() {
eprintln!("error: use only one of --message or --message-file");
return Err(EXIT_ERROR);
}
if options.no_edit && !options.amend {
eprintln!("error: --no-edit requires --amend");
return Err(EXIT_ERROR);
}
if options.message_only && !options.amend {
eprintln!("error: --message-only requires --amend");
return Err(EXIT_ERROR);
}
if options.no_edit && options.message_only {
eprintln!("error: use only one of --no-edit or --message-only");
return Err(EXIT_ERROR);
}
if options.no_edit
&& (options.message.is_some()
|| options.message_file.is_some()
|| options.structured.has_any()
|| options.auto_fix)
{
eprintln!("error: --no-edit cannot be combined with message input or --auto-fix");
return Err(EXIT_ERROR);
}
if options.structured.has_any() && (options.message.is_some() || options.message_file.is_some())
{
eprintln!(
"error: structured message fields cannot be combined with --message or --message-file"
);
return Err(EXIT_ERROR);
}
if options.structured.has_any()
&& (options.structured.typ.is_none() || options.structured.subject.is_none())
{
eprintln!("error: structured message fields require --type and --subject");
return Err(EXIT_ERROR);
}
if let Err(message) = validate_trailers(&options.trailers) {
eprintln!("error: {message}");
return Err(EXIT_ERROR);
}
if !max_header_width_from_flag {
options.max_header_width = env_header_width()?;
}
Ok(options)
}
fn parse_cleanup_args(args: &[String], operation: CleanupOperation) -> Result<CleanupOptions, i32> {
let mut options = CleanupOptions::new(operation);
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"-h" | "--help" => {
print_cleanup_usage_stdout(operation);
return Err(0);
}
"--target" => {
let value = match args.get(i + 1) {
Some(value) => value.clone(),
None => {
eprintln!("error: --target requires a revision");
print_cleanup_usage_stderr(operation);
return Err(EXIT_ERROR);
}
};
options.target = Some(value);
i += 2;
}
"--summary" => {
let value = match args.get(i + 1) {
Some(value) => value,
None => {
eprintln!("error: --summary requires a value");
print_cleanup_usage_stderr(operation);
return Err(EXIT_ERROR);
}
};
let Some(mode) = SummaryMode::parse(value) else {
eprintln!(
"error: invalid --summary value: {value} (expected: git-scope, git-show, none)"
);
print_cleanup_usage_stderr(operation);
return Err(EXIT_ERROR);
};
options.summary_mode = mode;
i += 2;
}
"--no-summary" => {
options.summary_mode = SummaryMode::None;
i += 1;
}
"--format" => {
let value = match args.get(i + 1) {
Some(value) => value,
None => {
eprintln!("error: --format requires a value");
print_cleanup_usage_stderr(operation);
return Err(EXIT_ERROR);
}
};
let Some(format) = OutputFormat::parse(value) else {
eprintln!("error: invalid --format value: {value} (expected: text, json)");
print_cleanup_usage_stderr(operation);
return Err(EXIT_ERROR);
};
options.output_format = format;
i += 2;
}
"--json" => {
options.output_format = OutputFormat::Json;
i += 1;
}
"--dry-run" => {
options.dry_run = true;
i += 1;
}
"--allow-empty" => {
options.allow_empty = true;
i += 1;
}
"--require-clean" | "--no-unstaged" => {
options.require_clean = true;
i += 1;
}
"--expect-head" => {
let value = match args.get(i + 1) {
Some(value) => value.clone(),
None => {
eprintln!("error: --expect-head requires a revision");
print_cleanup_usage_stderr(operation);
return Err(EXIT_ERROR);
}
};
options.expect_head = Some(value);
i += 2;
}
"--repo" => {
let value = match args.get(i + 1) {
Some(value) => value.clone(),
None => {
eprintln!("error: --repo requires a path");
print_cleanup_usage_stderr(operation);
return Err(EXIT_ERROR);
}
};
options.repo = Some(PathBuf::from(value));
i += 2;
}
"--no-progress" => {
options.no_progress = true;
i += 1;
}
"--quiet" => {
options.quiet = true;
i += 1;
}
other => {
eprintln!("error: unknown argument: {other}");
print_cleanup_usage_stderr(operation);
return Err(EXIT_ERROR);
}
}
}
if options.target.is_none() {
eprintln!("error: --target is required");
print_cleanup_usage_stderr(operation);
return Err(EXIT_ERROR);
}
Ok(options)
}
fn env_header_width() -> Result<usize, i32> {
match std::env::var("SEMANTIC_COMMIT_HEADER_WIDTH") {
Ok(value) => parse_header_width_env(&value),
Err(std::env::VarError::NotPresent) => Ok(DEFAULT_MAX_HEADER_WIDTH),
Err(std::env::VarError::NotUnicode(_)) => {
eprintln!("error: SEMANTIC_COMMIT_HEADER_WIDTH must be valid UTF-8");
Err(EXIT_ERROR)
}
}
}
fn parse_header_width_flag(value: &str) -> Result<usize, i32> {
parse_positive_width(value, "--max-header-width")
}
fn parse_header_width_env(value: &str) -> Result<usize, i32> {
parse_positive_width(value, "SEMANTIC_COMMIT_HEADER_WIDTH")
}
fn parse_positive_width(value: &str, label: &str) -> Result<usize, i32> {
let Ok(parsed) = value.parse::<usize>() else {
eprintln!("error: {label} must be a positive integer");
return Err(EXIT_ERROR);
};
if parsed == 0 {
eprintln!("error: {label} must be a positive integer");
return Err(EXIT_ERROR);
}
Ok(parsed)
}
fn validate_trailers(trailers: &[String]) -> Result<(), String> {
for trailer in trailers {
if !is_valid_trailer_line(trailer) {
return Err(format!(
"invalid --trailer value: {trailer} (expected 'Token: value' or 'Token=value')"
));
}
}
Ok(())
}
fn build_structured_message(structured: &StructuredMessage) -> Result<String, i32> {
let typ = structured
.typ
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
eprintln!("error: --type must not be empty");
EXIT_MESSAGE_REQUIRED
})?;
let subject = structured
.subject
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
eprintln!("error: --subject must not be empty");
EXIT_MESSAGE_REQUIRED
})?;
let typ = typ.to_ascii_lowercase();
let header = match structured
.scope
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
Some(scope) => format!("{}({}): {subject}", typ, scope.to_ascii_lowercase()),
None => format!("{typ}: {subject}"),
};
let mut lines = vec![header];
if !structured.body_bullets.is_empty() {
lines.push(String::new());
for bullet in &structured.body_bullets {
let trimmed = bullet.trim();
if trimmed.is_empty() {
eprintln!("error: --body-bullet must not be empty");
return Err(EXIT_MESSAGE_REQUIRED);
}
let body = trimmed.strip_prefix("- ").unwrap_or(trimmed);
let line = capitalize_bullet_first_char(&format!("- {body}"));
lines.extend(wrap_body_line(&line, BODY_LINE_WIDTH));
}
}
Ok(lines.join("\n"))
}
fn read_message_contents(options: &CommitOptions) -> Result<String, i32> {
if options.no_edit {
let Some(contents) = commit_message_for_rev(options.repo.as_deref(), "HEAD")? else {
eprintln!("error: --no-edit requires an existing HEAD commit");
return Err(EXIT_ERROR);
};
return Ok(contents);
}
if options.structured.has_any() {
return build_structured_message(&options.structured);
}
let message_contents = match (&options.message, &options.message_file) {
(Some(text), None) => text.clone(),
(None, Some(path)) => match std::fs::read_to_string(path) {
Ok(contents) => contents,
Err(_) => {
eprintln!("error: message file not found: {path}");
return Err(EXIT_ERROR);
}
},
(None, None) => {
if options.automation {
eprintln!(
"error: no commit message provided in automation mode (use --message or --message-file)"
);
return Err(EXIT_MESSAGE_REQUIRED);
}
if std::io::stdin().is_terminal() {
eprintln!(
"error: no commit message provided (use stdin, --message, or --message-file)"
);
print_usage_stderr();
return Err(EXIT_MESSAGE_REQUIRED);
}
let mut buf = String::new();
if let Err(err) = std::io::stdin().read_to_string(&mut buf) {
eprintln!("{err:#}");
return Err(EXIT_ERROR);
}
buf
}
(Some(_), Some(_)) => unreachable!("validated above"),
};
if message_contents.trim().is_empty() {
eprintln!("error: commit message is empty");
return Err(EXIT_MESSAGE_REQUIRED);
}
Ok(message_contents)
}
fn run_git_output_with_pager(repo: Option<&Path>, args: &[&str]) -> std::io::Result<Output> {
match repo {
Some(repo) => common_git::run_output_in_with_env(repo, args, &CAT_PAGER_ENV),
None => common_git::run_output_with_env(args, &CAT_PAGER_ENV),
}
}
fn run_git_output_with_pager_owned(
repo: Option<&Path>,
args: &[String],
) -> std::io::Result<Output> {
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
run_git_output_with_pager(repo, &refs)
}
fn run_git_status_inherit_with_pager(
repo: Option<&Path>,
args: &[&str],
) -> std::io::Result<ExitStatus> {
match repo {
Some(repo) => common_git::run_status_inherit_in_with_env(repo, args, &CAT_PAGER_ENV),
None => common_git::run_status_inherit_with_env(args, &CAT_PAGER_ENV),
}
}
fn git_commit(
message_path: &Path,
options: &CommitOptions,
operation: CommitOperation,
) -> anyhow::Result<std::process::ExitStatus> {
let message_path = message_path.to_string_lossy();
let mut args = vec!["commit".to_string()];
if matches!(
operation,
CommitOperation::Amend | CommitOperation::MessageOnlyAmend
) {
args.push("--amend".to_string());
}
if options.allow_empty {
args.push("--allow-empty".to_string());
}
if options.signoff {
args.push("--signoff".to_string());
}
for trailer in &options.trailers {
args.push("--trailer".to_string());
args.push(trailer.clone());
}
if options.no_edit {
args.push("--no-edit".to_string());
} else {
args.push("-F".to_string());
args.push(message_path.to_string());
}
let output = run_git_output_with_pager_owned(options.repo.as_deref(), &args)?;
if !output.stderr.is_empty() {
std::io::stderr().write_all(&output.stderr)?;
}
Ok(output.status)
}
fn git_cleanup_commit(
options: &CleanupOptions,
operation: CleanupOperation,
target_sha: &str,
) -> anyhow::Result<std::process::ExitStatus> {
let mut args = vec!["commit".to_string()];
if options.allow_empty {
args.push("--allow-empty".to_string());
}
args.push(format!("{}={target_sha}", operation.git_flag()));
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
let output = match options.repo.as_deref() {
Some(repo) => common_git::run_output_in_with_env(repo, &refs, &NONINTERACTIVE_COMMIT_ENV),
None => common_git::run_output_with_env(&refs, &NONINTERACTIVE_COMMIT_ENV),
}?;
if !output.stderr.is_empty() {
std::io::stderr().write_all(&output.stderr)?;
}
Ok(output.status)
}
fn resolve_target_metadata(
repo: Option<&Path>,
target: Option<&str>,
) -> Result<CommitMetadata, i32> {
let target = target.expect("target checked by parser");
let rev = format!("{target}^{{commit}}");
let Some(sha) = git_stdout(repo, &["rev-parse", "--verify", &rev]) else {
eprintln!("error: target revision not found: {target}");
return Err(EXIT_ERROR);
};
let Some(subject) = git_stdout(repo, &["show", "-s", "--format=%s", &sha]) else {
eprintln!("error: failed to read target subject: {target}");
return Err(EXIT_ERROR);
};
Ok(CommitMetadata { sha, subject })
}
fn current_head_metadata(repo: Option<&Path>) -> Option<CommitMetadata> {
let sha = git_stdout(repo, &["rev-parse", "--verify", "HEAD"])?;
let subject = git_stdout(repo, &["show", "-s", "--format=%s", "HEAD"])?;
Some(CommitMetadata { sha, subject })
}
fn commit_message_for_rev(repo: Option<&Path>, rev: &str) -> Result<Option<String>, i32> {
let output =
run_git_output_with_pager(repo, &["log", "-1", "--format=%B", rev]).map_err(|err| {
eprintln!("error: failed to read {rev} commit message: {err}");
EXIT_ERROR
})?;
if output.status.success() {
Ok(Some(String::from_utf8_lossy(&output.stdout).to_string()))
} else {
Ok(None)
}
}
fn git_stdout(repo: Option<&Path>, args: &[&str]) -> Option<String> {
let output = run_git_output_with_pager(repo, args).ok()?;
if !output.status.success() {
return None;
}
let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
if value.is_empty() { None } else { Some(value) }
}
fn ensure_expected_head(repo: Option<&Path>, expected: &str) -> Result<(), i32> {
let Some(current) = git_stdout(repo, &["rev-parse", "--verify", "HEAD"]) else {
eprintln!("error: --expect-head requires an existing HEAD commit");
return Err(EXIT_ERROR);
};
let rev = format!("{expected}^{{commit}}");
let Some(expected_sha) = git_stdout(repo, &["rev-parse", "--verify", &rev]) else {
eprintln!("error: --expect-head revision not found: {expected}");
return Err(EXIT_ERROR);
};
if current != expected_sha {
eprintln!("error: HEAD mismatch (expected {expected_sha}, found {current})");
return Err(EXIT_ERROR);
}
Ok(())
}
fn ensure_no_unstaged_or_untracked(repo: Option<&Path>) -> Result<(), i32> {
if has_unstaged_or_untracked(repo)? {
eprintln!("error: unstaged or untracked changes present");
return Err(EXIT_ERROR);
}
Ok(())
}
fn has_unstaged_or_untracked(repo: Option<&Path>) -> Result<bool, i32> {
let output =
run_git_output_with_pager(repo, &["status", "--porcelain", "--untracked-files=all"])
.map_err(|err| {
eprintln!("error: failed to inspect worktree status: {err}");
EXIT_ERROR
})?;
if !output.status.success() {
eprintln!("error: failed to inspect worktree status");
return Err(EXIT_ERROR);
}
let status = String::from_utf8_lossy(&output.stdout);
Ok(status
.lines()
.any(|line| line.starts_with("??") || line.as_bytes().get(1).is_some_and(|b| *b != b' ')))
}
fn staged_entries(repo: Option<&Path>) -> anyhow::Result<Vec<StagedEntry>> {
let output = run_git_output_with_pager(
repo,
&[
"-c",
"core.quotepath=false",
"diff",
"--cached",
"--name-status",
"-z",
],
)?;
if !output.status.success() {
return Ok(Vec::new());
}
let entries =
common_git::parse_name_status_z(&output.stdout).map_err(|err| anyhow::anyhow!("{err}"))?;
Ok(entries
.into_iter()
.map(|entry| StagedEntry {
status: String::from_utf8_lossy(entry.status_raw).to_string(),
path: String::from_utf8_lossy(entry.path).to_string(),
old_path: entry
.old_path
.map(|path| String::from_utf8_lossy(path).to_string()),
})
.collect())
}
fn print_commit_json_result(
operation: CommitOperation,
validate_only: bool,
dry_run: bool,
commit: Option<CommitMetadata>,
target: Option<CommitMetadata>,
staged_entries: Vec<StagedEntry>,
) {
let files: Vec<_> = staged_entries
.iter()
.map(|entry| {
json!({
"status": entry.status,
"path": entry.path,
"old_path": entry.old_path,
})
})
.collect();
let commit_json = commit.map(|commit| {
json!({
"sha": commit.sha,
"subject": commit.subject,
})
});
let target_json = target.map(|target| {
json!({
"sha": target.sha,
"subject": target.subject,
})
});
let payload = json!({
"schema_version": COMMIT_RESULT_SCHEMA_VERSION,
"ok": true,
"operation": operation.as_str(),
"validate_only": validate_only,
"dry_run": dry_run,
"commit": commit_json,
"target": target_json,
"staged": {
"file_count": files.len(),
"files": files,
},
});
println!("{payload}");
}
fn print_cleanup_json_result(
operation: CleanupOperation,
dry_run: bool,
commit: Option<CommitMetadata>,
target: CommitMetadata,
staged_entries: Vec<StagedEntry>,
) {
let generated_subject = format!("{} {}", operation.subject_prefix(), target.subject);
let files: Vec<_> = staged_entries
.iter()
.map(|entry| {
json!({
"status": entry.status,
"path": entry.path,
"old_path": entry.old_path,
})
})
.collect();
let commit_json = commit.map(|commit| {
json!({
"sha": commit.sha,
"subject": commit.subject,
})
});
let payload = json!({
"schema_version": COMMIT_RESULT_SCHEMA_VERSION,
"ok": true,
"operation": operation.as_str(),
"validate_only": false,
"dry_run": dry_run,
"commit": commit_json,
"target": {
"sha": target.sha,
"subject": target.subject,
},
"generated_subject": generated_subject,
"staged": {
"file_count": files.len(),
"files": files,
},
});
println!("{payload}");
}
fn print_summary(summary_mode: SummaryMode, repo: Option<&Path>) -> i32 {
match summary_mode {
SummaryMode::None => 0,
SummaryMode::GitShow => print_git_show_summary(repo),
SummaryMode::GitScope => {
if run_git_scope_summary(repo) {
0
} else {
eprintln!(
"warning: git-scope summary unavailable; falling back to git show --name-status"
);
print_git_show_summary(repo)
}
}
}
}
fn run_git_scope_summary(repo: Option<&Path>) -> bool {
let mut command = Command::new("git-scope");
if let Some(repo) = repo {
command.current_dir(repo);
}
let status = command
.args(["commit", "HEAD", "--no-color"])
.env("GIT_PAGER", "cat")
.env("PAGER", "cat")
.stdin(Stdio::null())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status();
match status {
Ok(status) if status.success() => true,
Ok(status) => {
let rc = status.code().unwrap_or(EXIT_ERROR);
eprintln!("warning: git-scope commit failed (exit code: {rc})");
false
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
eprintln!("warning: git-scope not found on PATH");
false
}
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
eprintln!("warning: git-scope is not executable");
false
}
Err(err) => {
eprintln!("warning: git-scope commit failed: {err}");
false
}
}
}
fn print_git_show_summary(repo: Option<&Path>) -> i32 {
let status = run_git_status_inherit_with_pager(
repo,
&["show", "-1", "--name-status", "--oneline", "--no-color"],
);
match status {
Ok(status) if status.success() => 0,
Ok(status) => {
let rc = status.code().unwrap_or(EXIT_ERROR);
eprintln!("error: git show summary failed (exit code: {rc})");
rc
}
Err(err) => {
eprintln!("error: git show summary failed: {err}");
EXIT_ERROR
}
}
}
fn write_message_file(path: &Path, contents: &str) -> anyhow::Result<()> {
let mut file = File::create(path)?;
file.write_all(contents.as_bytes())?;
Ok(())
}
#[cfg(test)]
fn validate_commit_message(path: &Path) -> Result<(), i32> {
validate_commit_message_with_width(path, DEFAULT_MAX_HEADER_WIDTH)
}
fn validate_commit_message_with_width(path: &Path, max_header_width: usize) -> Result<(), i32> {
let file = File::open(path).map_err(|_| {
eprintln!("error: commit message validation failed");
EXIT_VALIDATION_FAILED
})?;
let reader = BufReader::new(file);
let mut lines: Vec<String> = Vec::new();
for line in reader.lines() {
match line {
Ok(line) => lines.push(line),
Err(_) => {
return fail_validation("commit message validation failed");
}
}
}
if lines.is_empty() {
return fail_validation("commit message is empty");
}
let header = &lines[0];
if header.is_empty() {
return fail_validation("commit header is empty");
}
if header.chars().count() > max_header_width {
return fail_validation(&format!(
"commit header exceeds {max_header_width} characters (max {max_header_width})"
));
}
if !is_valid_header(header) {
return fail_validation(
"invalid header format (expected 'type(scope): subject' or 'type: subject' with lowercase type)",
);
}
let body_exists = lines.iter().skip(1).any(|line| !line.is_empty());
if body_exists {
if lines.get(1).is_some_and(|line| !line.is_empty()) {
return fail_validation("commit body must be separated from header by a blank line");
}
let mut prev_was_body_line = false;
let mut trailer_mode = false;
for (idx, line) in lines.iter().enumerate().skip(2) {
let line_no = idx + 1;
if line.is_empty() {
if prev_was_body_line && !trailer_mode {
trailer_mode = true;
prev_was_body_line = false;
continue;
}
return fail_validation(&format!(
"commit body line {line_no} is empty; body lines must start with '- ' followed by uppercase letter, a trailer, or ' ' to continue the previous bullet"
));
}
if line.chars().count() > BODY_LINE_WIDTH {
return fail_validation(&format!(
"commit body line {line_no} exceeds {BODY_LINE_WIDTH} characters (max {BODY_LINE_WIDTH})"
));
}
if trailer_mode {
if !is_valid_trailer_line(line) {
return fail_validation(&format!(
"commit trailer line {line_no} must use 'Token: value' or 'Token=value'"
));
}
continue;
}
if !prev_was_body_line && is_valid_trailer_line(line) {
trailer_mode = true;
continue;
}
let is_bullet = line.starts_with("- ")
&& line.chars().nth(2).is_some_and(|c| c.is_ascii_uppercase());
let is_continuation = prev_was_body_line
&& line.starts_with(" ")
&& line.chars().nth(2).is_some_and(|c| !c.is_whitespace());
if !is_bullet && !is_continuation {
return fail_validation(&format!(
"commit body line {line_no} must start with '- ' followed by uppercase letter, a trailer, or ' ' to continue the previous bullet"
));
}
prev_was_body_line = true;
}
}
Ok(())
}
fn fail_validation(message: &str) -> Result<(), i32> {
eprintln!("error: {message}");
Err(EXIT_VALIDATION_FAILED)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct BlockedMessageRule {
id: &'static str,
reason: &'static str,
matched_hint: &'static str,
fix: &'static str,
patterns: &'static [BlockedMessagePattern],
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum BlockedMessagePattern {
TrailerValueStartsWith {
token: &'static str,
value_prefix: &'static str,
},
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum BlockedMessageSource {
MessageLine(usize),
TrailerFlag(usize),
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct BlockedMessageMatch {
source: BlockedMessageSource,
}
const CLAUDE_COAUTHOR_PATTERNS: &[BlockedMessagePattern] =
&[BlockedMessagePattern::TrailerValueStartsWith {
token: "Co-Authored-By",
value_prefix: "Claude",
}];
const BLOCKED_MESSAGE_RULES: &[BlockedMessageRule] = &[BlockedMessageRule {
id: "claude-coauthor-trailer",
reason: "do not attribute commits to any Claude model",
matched_hint: "Co-Authored-By: Claude ...",
fix: "remove the `Co-Authored-By: Claude ...` trailer",
patterns: CLAUDE_COAUTHOR_PATTERNS,
}];
fn validate_blocked_message_rules(message: &str, trailers: &[String]) -> Result<(), i32> {
for rule in BLOCKED_MESSAGE_RULES {
if let Some(blocked) = find_blocked_message_match(rule, message, trailers) {
return fail_validation(&format!(
"commit message is blocked by rule `{}`\n source: {}\n matched: {}\n rule: {}\n fix: {}",
rule.id,
blocked_message_source_label(blocked.source),
rule.matched_hint,
rule.reason,
rule.fix
));
}
}
Ok(())
}
fn find_blocked_message_match<'a>(
rule: &BlockedMessageRule,
message: &'a str,
trailers: &'a [String],
) -> Option<BlockedMessageMatch> {
for (idx, line) in message.lines().enumerate() {
if rule.patterns.iter().any(|pattern| pattern.matches(line)) {
return Some(BlockedMessageMatch {
source: BlockedMessageSource::MessageLine(idx + 1),
});
}
}
for (idx, trailer) in trailers.iter().enumerate() {
if rule.patterns.iter().any(|pattern| pattern.matches(trailer)) {
return Some(BlockedMessageMatch {
source: BlockedMessageSource::TrailerFlag(idx + 1),
});
}
}
None
}
fn blocked_message_source_label(source: BlockedMessageSource) -> String {
match source {
BlockedMessageSource::MessageLine(line) => format!("message line {line}"),
BlockedMessageSource::TrailerFlag(idx) => format!("--trailer #{idx}"),
}
}
impl BlockedMessagePattern {
fn matches(self, line: &str) -> bool {
match self {
Self::TrailerValueStartsWith {
token,
value_prefix,
} => trailer_value_starts_with(line, token, value_prefix),
}
}
}
fn trailer_value_starts_with(line: &str, expected_token: &str, value_prefix: &str) -> bool {
let Some((token, value)) = split_trailer(line.trim()) else {
return false;
};
token.eq_ignore_ascii_case(expected_token)
&& starts_with_ascii_word_ignore_case(value, value_prefix)
}
fn starts_with_ascii_word_ignore_case(value: &str, expected_word: &str) -> bool {
if expected_word.is_empty() {
return false;
}
let value = value.trim_start();
let Some(prefix) = value.get(..expected_word.len()) else {
return false;
};
if !prefix.eq_ignore_ascii_case(expected_word) {
return false;
}
value[expected_word.len()..]
.chars()
.next()
.is_none_or(|c| !(c.is_ascii_alphanumeric() || c == '_'))
}
fn is_valid_header(header: &str) -> bool {
let Some((prefix, subject)) = header.split_once(": ") else {
return false;
};
if subject.is_empty() {
return false;
}
let (typ, scope) = if let Some((t, rest)) = prefix.split_once('(') {
let Some(scope_end) = rest.strip_suffix(')') else {
return false;
};
(t, Some(scope_end))
} else {
(prefix, None)
};
let mut chars = typ.chars();
let Some(first) = chars.next() else {
return false;
};
if !first.is_ascii_lowercase() {
return false;
}
if !chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
return false;
}
if let Some(scope) = scope {
if scope.is_empty() {
return false;
}
if !scope.chars().all(|c| {
c.is_ascii_lowercase() || c.is_ascii_digit() || c == '.' || c == '_' || c == '-'
}) {
return false;
}
}
true
}
fn is_valid_trailer_line(line: &str) -> bool {
let Some((token, value)) = split_trailer(line) else {
return false;
};
!token.is_empty()
&& token.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
&& !value.trim().is_empty()
}
fn split_trailer(line: &str) -> Option<(&str, &str)> {
let colon = line.find(':');
let equals = line.find('=');
let idx = match (colon, equals) {
(Some(colon), Some(equals)) => colon.min(equals),
(Some(colon), None) => colon,
(None, Some(equals)) => equals,
(None, None) => return None,
};
let (token, rest) = line.split_at(idx);
Some((token.trim(), rest[1..].trim()))
}
const DEFAULT_MAX_HEADER_WIDTH: usize = 100;
const BODY_LINE_WIDTH: usize = 100;
fn normalize_message(input: &str) -> String {
let lines: Vec<&str> = input.lines().collect();
if lines.is_empty() {
return input.to_string();
}
let mut out: Vec<String> = Vec::with_capacity(lines.len() + 2);
out.push(normalize_header(lines[0]));
let body: Vec<&str> = lines
.iter()
.skip(1)
.filter(|line| !line.is_empty())
.copied()
.collect();
if !body.is_empty() {
out.push(String::new());
for line in body {
let cased = capitalize_bullet_first_char(line);
for wrapped in wrap_body_line(&cased, BODY_LINE_WIDTH) {
out.push(wrapped);
}
}
}
let mut result = out.join("\n");
if input.ends_with('\n') {
result.push('\n');
}
result
}
fn normalize_header(header: &str) -> String {
let Some((prefix, subject)) = header.split_once(": ") else {
return header.to_string();
};
let (typ, scope) = if let Some((t, rest)) = prefix.split_once('(') {
let Some(scope_inner) = rest.strip_suffix(')') else {
return header.to_string();
};
(t, Some(scope_inner))
} else {
(prefix, None)
};
let prefix_norm = match scope {
Some(s) => format!("{}({})", typ.to_ascii_lowercase(), s.to_ascii_lowercase()),
None => typ.to_ascii_lowercase(),
};
format!("{prefix_norm}: {subject}")
}
fn capitalize_bullet_first_char(line: &str) -> String {
if !line.starts_with("- ") {
return line.to_string();
}
let mut chars = line.chars();
let _ = chars.next();
let _ = chars.next();
match chars.next() {
Some(c) if c.is_ascii_lowercase() => {
let rest: String = chars.collect();
format!("- {}{rest}", c.to_ascii_uppercase())
}
_ => line.to_string(),
}
}
fn wrap_body_line(line: &str, max: usize) -> Vec<String> {
if line.chars().count() <= max {
return vec![line.to_string()];
}
let (first_prefix, cont_prefix, content): (&str, &str, String) = if line.starts_with("- ") {
("- ", " ", line.chars().skip(2).collect())
} else if line.starts_with(" ") {
(" ", " ", line.chars().skip(2).collect())
} else {
("", "", line.to_string())
};
let chars: Vec<char> = content.chars().collect();
let first_budget = max.saturating_sub(first_prefix.chars().count());
let cont_budget = max.saturating_sub(cont_prefix.chars().count());
if first_budget == 0 || cont_budget == 0 {
return vec![line.to_string()];
}
let mut result: Vec<String> = Vec::new();
let mut idx = 0;
let mut is_first = true;
while idx < chars.len() {
let budget = if is_first { first_budget } else { cont_budget };
let prefix = if is_first { first_prefix } else { cont_prefix };
let window_end = (idx + budget).min(chars.len());
let break_at = if window_end == chars.len() {
window_end
} else {
(idx..window_end)
.rev()
.find(|&i| chars[i] == ' ' && i > idx)
.unwrap_or(window_end)
};
let chunk: String = chars[idx..break_at].iter().collect();
result.push(format!("{prefix}{}", chunk.trim_end()));
idx = if break_at < chars.len() && chars[break_at] == ' ' {
let mut next = break_at + 1;
while next < chars.len() && chars[next] == ' ' {
next += 1;
}
next
} else {
break_at
};
is_first = false;
}
if result.is_empty() {
result.push(first_prefix.to_string());
}
result
}
fn print_usage_stdout() {
print_usage(false);
}
fn print_usage_stderr() {
print_usage(true);
}
fn print_usage(stderr: bool) {
let out: &mut dyn std::io::Write = if stderr {
&mut std::io::stderr()
} else {
&mut std::io::stdout()
};
let _ = writeln!(out, "Usage:");
let _ = writeln!(
out,
" semantic-commit commit [--message <text>|--message-file <path>] [options]"
);
let _ = writeln!(out);
let _ = writeln!(out, "Options:");
let _ = writeln!(out, " -m, --message <text> Commit message text");
let _ = writeln!(
out,
" -F, --message-file <path> Read commit message from file"
);
let _ = writeln!(
out,
" --message-out <path> Save prepared message for recovery"
);
let _ = writeln!(
out,
" --summary <mode> Summary mode: git-scope | git-show | none"
);
let _ = writeln!(
out,
" --no-summary Equivalent to --summary none"
);
let _ = writeln!(
out,
" --format <mode> Output mode: text | json"
);
let _ = writeln!(
out,
" --json Equivalent to --format json"
);
let _ = writeln!(
out,
" --repo <path> Run git commands against repo path"
);
let _ = writeln!(
out,
" --max-header-width <N> Override commit header width (default: {DEFAULT_MAX_HEADER_WIDTH}; env: SEMANTIC_COMMIT_HEADER_WIDTH)"
);
let _ = writeln!(
out,
" --automation Disallow stdin message fallback"
);
let _ = writeln!(
out,
" --validate-only Validate message format only"
);
let _ = writeln!(
out,
" --dry-run Validate + staged checks, skip git commit"
);
let _ = writeln!(
out,
" --auto-fix Normalize body wrap, bullet/type/scope case before validation"
);
let _ = writeln!(
out,
" --amend Amend HEAD instead of creating a new commit"
);
let _ = writeln!(
out,
" --no-edit Reuse HEAD message with --amend"
);
let _ = writeln!(
out,
" --message-only Amend only the HEAD message and require no staged changes"
);
let _ = writeln!(
out,
" --allow-empty Allow a commit operation without staged changes"
);
let _ = writeln!(
out,
" --require-clean Require no unstaged or untracked changes"
);
let _ = writeln!(
out,
" --no-unstaged Alias for --require-clean"
);
let _ = writeln!(
out,
" --expect-head <rev> Require HEAD to match rev before committing"
);
let _ = writeln!(
out,
" --signoff Pass --signoff to git commit"
);
let _ = writeln!(
out,
" --trailer <token: value> Add a git trailer (repeatable)"
);
let _ = writeln!(
out,
" --type <type> Structured message type"
);
let _ = writeln!(
out,
" --scope <scope> Structured message scope"
);
let _ = writeln!(
out,
" --subject <subject> Structured message subject"
);
let _ = writeln!(
out,
" --body-bullet <text> Structured message body bullet (repeatable)"
);
let _ = writeln!(
out,
" --no-progress Disable progress spinner"
);
let _ = writeln!(
out,
" --quiet Suppress progress and summary output"
);
let _ = writeln!(out);
let _ = writeln!(out, "Examples:");
let _ = writeln!(out, " cat <<'MSG' | semantic-commit commit");
let _ = writeln!(out, " feat(core): add thing");
let _ = writeln!(out);
let _ = writeln!(out, " - Add thing");
let _ = writeln!(out, " MSG");
let _ = writeln!(out);
let _ = writeln!(
out,
" semantic-commit commit -F ./message.txt --summary git-show"
);
}
fn print_cleanup_usage_stdout(operation: CleanupOperation) {
print_cleanup_usage(operation, false);
}
fn print_cleanup_usage_stderr(operation: CleanupOperation) {
print_cleanup_usage(operation, true);
}
fn print_cleanup_usage(operation: CleanupOperation, stderr: bool) {
let out: &mut dyn std::io::Write = if stderr {
&mut std::io::stderr()
} else {
&mut std::io::stdout()
};
let name = operation.as_str();
let _ = writeln!(out, "Usage:");
let _ = writeln!(out, " semantic-commit {name} --target <rev> [options]");
let _ = writeln!(out);
let _ = writeln!(
out,
"Create a {}! commit for staged changes.",
operation.as_str()
);
let _ = writeln!(out);
let _ = writeln!(out, "Options:");
let _ = writeln!(out, " --target <rev> Target commit revision");
let _ = writeln!(
out,
" --summary <mode> Summary mode: git-scope | git-show | none"
);
let _ = writeln!(
out,
" --no-summary Equivalent to --summary none"
);
let _ = writeln!(
out,
" --format <mode> Output mode: text | json"
);
let _ = writeln!(
out,
" --json Equivalent to --format json"
);
let _ = writeln!(
out,
" --dry-run Validate target and staged checks without committing"
);
let _ = writeln!(
out,
" --allow-empty Allow a cleanup commit without staged changes"
);
let _ = writeln!(
out,
" --require-clean Require no unstaged or untracked changes"
);
let _ = writeln!(
out,
" --no-unstaged Alias for --require-clean"
);
let _ = writeln!(
out,
" --expect-head <rev> Require HEAD to match rev before committing"
);
let _ = writeln!(
out,
" --repo <path> Run git commands against repo path"
);
let _ = writeln!(
out,
" --no-progress Disable progress spinner"
);
let _ = writeln!(
out,
" --quiet Suppress progress and summary output"
);
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn message_file(contents: &str) -> tempfile::NamedTempFile {
let mut file = tempfile::NamedTempFile::new().expect("create temp message file");
file.write_all(contents.as_bytes())
.expect("write temp message file");
file
}
#[test]
fn summary_mode_parse_supports_known_values() {
assert_eq!(SummaryMode::parse("git-scope"), Some(SummaryMode::GitScope));
assert_eq!(SummaryMode::parse("git-show"), Some(SummaryMode::GitShow));
assert_eq!(SummaryMode::parse("none"), Some(SummaryMode::None));
assert_eq!(SummaryMode::parse("other"), None);
}
#[test]
fn validate_commit_message_accepts_valid_header_without_body() {
let file = message_file("feat: add parser coverage\n");
assert!(validate_commit_message(file.path()).is_ok());
}
#[test]
fn validate_commit_message_accepts_valid_scoped_header_with_body() {
let file = message_file("fix(parser-2): handle edge case\n\n- Handle malformed input\n");
assert!(validate_commit_message(file.path()).is_ok());
}
#[test]
fn validate_commit_message_rejects_header_over_100_chars() {
let subject = "a".repeat(95);
let file = message_file(&format!("feat: {subject}\n"));
assert_eq!(
validate_commit_message(file.path()),
Err(EXIT_VALIDATION_FAILED)
);
}
#[test]
fn validate_commit_message_rejects_uppercase_scope() {
let file = message_file("feat(Core): add parser coverage\n");
assert_eq!(
validate_commit_message(file.path()),
Err(EXIT_VALIDATION_FAILED)
);
}
#[test]
fn validate_commit_message_rejects_empty_line_inside_body() {
let file = message_file("feat: add parser coverage\n\n- First line\n\n- Second line\n");
assert_eq!(
validate_commit_message(file.path()),
Err(EXIT_VALIDATION_FAILED)
);
}
#[test]
fn validate_commit_message_rejects_body_line_over_100_chars() {
let long_line = "A".repeat(99);
let file = message_file(&format!("feat: add parser coverage\n\n- {long_line}\n"));
assert_eq!(
validate_commit_message(file.path()),
Err(EXIT_VALIDATION_FAILED)
);
}
#[test]
fn is_valid_header_enforces_shape_rules() {
assert!(is_valid_header("fix(core_2): handle edge case"));
assert!(is_valid_header("chore: update fixtures"));
assert!(!is_valid_header("Fix: uppercase type"));
assert!(!is_valid_header("fix(core!): invalid scope character"));
assert!(!is_valid_header("fix(scope):"));
assert!(!is_valid_header("fix(scope): "));
assert!(!is_valid_header("fix(scope) missing colon"));
}
#[test]
fn normalize_header_lowercases_type_and_scope() {
assert_eq!(
normalize_header("Feat(Core): add thing"),
"feat(core): add thing"
);
assert_eq!(normalize_header("FIX: bug"), "fix: bug");
}
#[test]
fn normalize_header_preserves_subject_case() {
assert_eq!(
normalize_header("feat(core): Add THING with MixedCase"),
"feat(core): Add THING with MixedCase"
);
}
#[test]
fn normalize_header_passes_through_invalid_shape() {
assert_eq!(normalize_header("no colon here"), "no colon here");
assert_eq!(
normalize_header("feat(unclosed: subj"),
"feat(unclosed: subj"
);
}
#[test]
fn capitalize_bullet_first_char_upcases_lowercase_ascii() {
assert_eq!(capitalize_bullet_first_char("- add thing"), "- Add thing");
}
#[test]
fn capitalize_bullet_first_char_leaves_already_uppercase() {
assert_eq!(capitalize_bullet_first_char("- Add thing"), "- Add thing");
}
#[test]
fn capitalize_bullet_first_char_leaves_non_bullets() {
assert_eq!(capitalize_bullet_first_char(" cont"), " cont");
assert_eq!(capitalize_bullet_first_char("plain"), "plain");
assert_eq!(
capitalize_bullet_first_char("- 1.0 release"),
"- 1.0 release"
);
}
#[test]
fn wrap_body_line_short_unchanged() {
assert_eq!(
wrap_body_line("- Short bullet", 100),
vec!["- Short bullet"]
);
}
#[test]
fn wrap_body_line_breaks_bullet_at_last_space() {
let line = format!("- {}", "word ".repeat(30).trim_end());
let out = wrap_body_line(&line, 40);
assert!(out.len() > 1, "expected wrapping, got {out:?}");
assert!(out[0].starts_with("- "));
for cont in &out[1..] {
assert!(
cont.starts_with(" "),
"continuation must start with two spaces: {cont:?}"
);
assert!(
cont.chars().nth(2).is_some_and(|c| !c.is_whitespace()),
"continuation third char must be non-whitespace: {cont:?}"
);
}
for l in &out {
assert!(l.chars().count() <= 40, "line exceeds budget: {l:?}");
}
}
#[test]
fn wrap_body_line_continuation_keeps_two_space_prefix() {
let line = format!(" {}", "word ".repeat(30).trim_end());
let out = wrap_body_line(&line, 40);
for cont in &out {
assert!(
cont.starts_with(" "),
"expected two-space prefix: {cont:?}"
);
}
}
#[test]
fn wrap_body_line_hard_breaks_when_no_whitespace() {
let line = format!("- {}", "a".repeat(120));
let out = wrap_body_line(&line, 100);
assert!(out.len() >= 2, "expected hard break, got {out:?}");
for l in &out {
assert!(l.chars().count() <= 100, "line exceeds budget: {l:?}");
}
}
#[test]
fn wrap_body_line_handles_cjk_codepoint_break() {
let line = format!("- {}", "字".repeat(110));
let out = wrap_body_line(&line, 100);
assert!(out.len() >= 2, "expected wrap for long CJK, got {out:?}");
for l in &out {
assert!(l.chars().count() <= 100, "line exceeds budget: {l:?}");
}
}
#[test]
fn normalize_message_inserts_missing_blank_separator() {
let out = normalize_message("feat: add thing\n- Add thing\n");
assert_eq!(out, "feat: add thing\n\n- Add thing\n");
}
#[test]
fn normalize_message_drops_empty_body_lines() {
let out = normalize_message("feat: add thing\n\n- First\n\n- Second\n");
assert_eq!(out, "feat: add thing\n\n- First\n- Second\n");
}
#[test]
fn normalize_message_passes_through_already_valid() {
let input = "feat(core): add thing\n\n- Add thing\n";
assert_eq!(normalize_message(input), input);
}
#[test]
fn normalize_message_does_not_truncate_overlength_header() {
let header = format!("feat: {}", "a".repeat(120));
let input = format!("{header}\n");
let out = normalize_message(&input);
let header_line = out.lines().next().unwrap();
assert_eq!(header_line.chars().count(), header.chars().count());
}
#[test]
fn normalize_message_makes_invalid_input_pass_validator() {
let input = "Feat(Core): subject\n- lowercase bullet that is way too long because it has many words and exceeds the limit by a lot of chars\n";
let normalized = normalize_message(input);
let file = message_file(&normalized);
assert!(
validate_commit_message(file.path()).is_ok(),
"normalized message failed validation:\n{normalized}"
);
}
}