Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 13 additions & 11 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ include = ["/src", "LICENSE"]
[dependencies]
structopt = "0.3.26"
inquire = "0.7.5"
git2 = { version = "0.20.0", features = ["vendored-openssl"] }
thiserror = "2.0.12"
anyhow = "1.0.98"
sentry = "0.37.0"
git2 = { version = "0.20.2", features = ["vendored-openssl"] }
thiserror = "2.0.16"
anyhow = "1.0.99"
sentry = "0.42.0"
chrono = { version = "0.4.41", features = ["serde"] }
semver = "1.0.24"
regex = "1.11.1"
Expand All @@ -28,18 +28,20 @@ self_update = { version = "0.42.0", features = [
"archive-zip",
] }
colored = "3.0.0"
indicatif = "0.17.9"
indicatif = "0.18.0"
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
toml = "0.8.22"
serde_json = "1.0.143"
toml = "0.9.5"
dirs = "6.0.0"
reqwest = { version = "0.12.15", features = ["json", "blocking"] }
tokio = { version = "1.44.2", features = ["full"] }
reqwest = { version = "0.12.23", features = ["json", "blocking"] }
tokio = { version = "1.47.1", features = ["full"] }
once_cell = "1.21.3"
uuid = {version = "1.16.0", features = ["v4"]}
uuid = {version = "1.18.0", features = ["v4"]}

[dev-dependencies]
assert_cmd = "2.0.17"
predicates = "3.1.3"
tempfile = "3.15.0"
tempfile = "3.21.0"
mockall = "0.13.1"
once_cell = "1.21.3"
serial_test = "3.2.0"
64 changes: 64 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,70 @@ committy -s "change the api version"
committy -s "change the api version" amend
```

## ⚙️ CLI Reference & Advanced Usage

### Output format

- Use `--output json|text` on commands that support it.
- `lint --output json` prints `{ ok, count, issues }`.
- `tag --output json` (with `--dry-run`) prints `{ ok, new_tag }`.

### Verbosity

- `-v` or `--verbose`: increase log verbosity. Repeat for more details (`-vv`).
- `-q` or `--quiet`: only error logs.
- Defaults to `info` when neither is provided.

### Non-interactive mode

- `--non-interactive` disables prompts (ideal for CI).
- Also enabled when `COMMITTY_NONINTERACTIVE=1` or `CI=1`.

### Fetch control for tags

- `--fetch` / `--no-fetch` controls whether tags are fetched from remote before calculation.
- Default: fetch is enabled unless `--no-fetch` is provided.
- Example (no remote access):

```bash
committy --non-interactive tag --no-fetch --dry-run --not-publish --output json
```

### Stable lint exit codes (for CI)

- `0` = OK, no issues
- `3` = Lint issues found
- `1` = Error

Example:

```bash
committy --non-interactive lint --repo-path . --output json || {
code=$?; if [ $code -eq 3 ]; then echo "Lint issues"; else exit $code; fi
}
```

### Configurable version bump rules

Committy determines semantic version bumps using regex patterns loaded from `config.toml`.

- Default location: `~/.config/committy/config.toml`
- Override directory with `COMMITTY_CONFIG_DIR` (the file must be named `config.toml`).
- Keys:
- `major_regex`
- `minor_regex`
- `patch_regex`

Example `config.toml` (use single quotes for literal regex):

```toml
minor_regex = '(?im)^fix(?:\s*\([^)]*\))?:'
patch_regex = '(?im)^docs(?:\s*\([^)]*\))?:'
major_regex = '(?im)^(breaking change:|feat(?:\s*\([^)]*\))?!:)'
```

This example treats `fix:` commits as a minor bump (instead of patch) and moves `docs:` to patch.

## 📝 Commit Types

Committy supports the following commit types:
Expand Down
7 changes: 2 additions & 5 deletions build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,13 @@ fn main() {
let dest_path = std::path::Path::new(&out_dir).join("sentry_dsn.rs");
fs::write(
&dest_path,
format!(r#"pub const SENTRY_DSN: &str = "{}";"#, sentry_dsn),
format!(r#"pub const SENTRY_DSN: &str = "{sentry_dsn}";"#),
)
.unwrap();
let dest_path = std::path::Path::new(&out_dir).join("posthog_api_key.rs");
fs::write(
&dest_path,
format!(
r#"pub const POSTHOG_API_KEY: &str = "{}";"#,
posthog_api_key
),
format!(r#"pub const POSTHOG_API_KEY: &str = "{posthog_api_key}";"#),
)
.unwrap();
}
12 changes: 6 additions & 6 deletions src/cli/commands/branch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ impl Command for BranchCommand {

if let Some(name) = &self.name {
git::create_branch(name, self.force)?;
println!("Branch {} created successfully!", name);
println!("Branch {name} created successfully!");
} else {
if non_interactive {
return Err(CliError::InputError(
Expand All @@ -41,9 +41,9 @@ impl Command for BranchCommand {
let subject = input::input_subject()?;

let branch_name = if ticket.is_empty() {
format!("{}-{}", branch_type, subject)
format!("{branch_type}-{subject}")
} else {
format!("{}-{}-{}", branch_type, ticket, subject)
format!("{branch_type}-{ticket}-{subject}")
};

let validate = if !self.validate {
Expand All @@ -56,9 +56,9 @@ impl Command for BranchCommand {
return Ok(());
}
git::create_branch(&branch_name, self.force)?;
println!("Branch {} created successfully!", branch_name);
println!("Branch {branch_name} created successfully!");
git::checkout_branch(&branch_name)?;
println!("Switched to branch {}", branch_name);
println!("Switched to branch {branch_name}");
if let Err(e) =
tokio::runtime::Runtime::new()
.unwrap()
Expand All @@ -73,7 +73,7 @@ impl Command for BranchCommand {
]),
))
{
debug!("Telemetry error: {:?}", e);
debug!("Telemetry error: {e:?}");
}
}

Expand Down
16 changes: 5 additions & 11 deletions src/cli/commands/commit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,8 @@ impl Command for CommitCommand {
let commit_type = if let Some(commit_type) = &self.commit_type {
if let Some(suggested) = suggest_commit_type(commit_type) {
if suggested != commit_type {
info!(
"Auto-correcting commit type from '{}' to '{}'",
commit_type, suggested
);
debug!(
"Auto-corrected commit type from '{}' to '{}'",
commit_type, suggested
);
info!("Auto-correcting commit type from '{commit_type}' to '{suggested}'");
debug!("Auto-corrected commit type from '{commit_type}' to '{suggested}'");
}
suggested.to_string()
} else {
Expand Down Expand Up @@ -94,7 +88,7 @@ impl Command for CommitCommand {
// In non-interactive mode, apply corrections automatically
let corrected = auto_correct_scope(scope);
if corrected != *scope {
info!("Auto-correcting scope from '{}' to '{}'", scope, corrected);
info!("Auto-correcting scope from '{scope}' to '{corrected}'");
}
corrected
}
Expand Down Expand Up @@ -129,7 +123,7 @@ impl Command for CommitCommand {
&long_message,
);

debug!("Formatted commit message: {}", full_message);
debug!("Formatted commit message: {full_message}");
git::commit_changes(&full_message, self.amend)?;
// fire off telemetry without making this function async
if let Err(e) =
Expand Down Expand Up @@ -158,7 +152,7 @@ impl Command for CommitCommand {
]),
))
{
debug!("Telemetry error: {:?}", e);
debug!("Telemetry error: {e:?}");
}
info!("Changes committed successfully! 🎉");
Ok(())
Expand Down
30 changes: 26 additions & 4 deletions src/cli/commands/lint.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
use crate::cli::Command;
use crate::error::CliError;
use crate::linter::CommitLinter;
use serde::Serialize;
use structopt::StructOpt;

#[derive(Debug, StructOpt)]
pub struct LintCommand {
/// Path to the git repository (defaults to current directory)
#[structopt(long, default_value = ".")]
repo_path: String,

/// Output format: text or json
#[structopt(long, default_value = "text", possible_values = &["text", "json"])]
output: String,
}

impl Command for LintCommand {
Expand All @@ -17,20 +22,37 @@ impl Command for LintCommand {

match linter.check_commits_since_last_tag() {
Ok(issues) => {
if issues.is_empty() {
if self.output == "json" {
#[derive(Serialize)]
struct LintOutput<'a> {
ok: bool,
count: usize,
issues: &'a [crate::linter::CommitIssue],
}
let payload = LintOutput {
ok: issues.is_empty(),
count: issues.len(),
issues: &issues,
};
println!("{}", serde_json::to_string(&payload).unwrap());
} else if issues.is_empty() {
println!(
"✅ All commits since the last tag follow the conventional commit format!"
);
} else {
println!("❌ Found {} commit(s) with issues:", issues.len());
for issue in issues {
for issue in &issues {
println!("\nCommit: {}", issue.commit_id);
println!("Message: {}", issue.message);
println!("Issue: {}", issue.issue);
}
std::process::exit(1);
}
Ok(())

if issues.is_empty() {
Ok(())
} else {
Err(CliError::LintIssues(issues.len()))
}
}
Err(e) => Err(CliError::Generic(e.to_string())),
}
Expand Down
47 changes: 40 additions & 7 deletions src/cli/commands/tag.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ pub struct TagCommand {

#[structopt(flatten)]
tag_options: git::TagGeneratorOptions,

/// Output format: text or json
#[structopt(long, default_value = "text", possible_values = &["text", "json"])]
output: String,
}

impl Command for TagCommand {
Expand All @@ -39,14 +43,34 @@ impl Command for TagCommand {
let version_manager =
git::TagGenerator::new(self.tag_options.clone(), self.bump_config_files);
version_manager.create_and_push_tag(&version_manager.open_repository()?, name)?;
println!("Tag {} created successfully!", name);
} else {
if non_interactive {
return Err(CliError::InputError(
"Tag name is required in non-interactive mode".to_string(),
));
if self.output == "json" {
let payload = serde_json::json!({
"ok": true,
"new_tag": name,
});
println!("{}", serde_json::to_string(&payload).unwrap());
} else {
println!("Tag {name} created successfully!");
}
} else if non_interactive {
// In non-interactive mode, auto-calculate and act based on options
let mut version_manager =
git::TagGenerator::new(self.tag_options.clone(), self.bump_config_files);
version_manager.run()?;

// Print the calculated tag so callers/tests can consume it
if self.output == "json" {
let payload = serde_json::json!({
"ok": true,
"old_tag": version_manager.current_tag,
"new_tag": version_manager.new_tag,
"pre_release": version_manager.is_pre_release,
});
println!("{}", serde_json::to_string(&payload).unwrap());
} else {
println!("{}", version_manager.new_tag);
}
} else {
let validate = if !self.validate {
input::ask_want_create_new_tag()?
} else {
Expand All @@ -59,6 +83,15 @@ impl Command for TagCommand {
let mut version_manager =
git::TagGenerator::new(self.tag_options.clone(), self.bump_config_files);
version_manager.run()?;
if self.output == "json" {
let payload = serde_json::json!({
"ok": true,
"old_tag": version_manager.current_tag,
"new_tag": version_manager.new_tag,
"pre_release": version_manager.is_pre_release,
});
println!("{}", serde_json::to_string(&payload).unwrap());
}
if let Err(e) =
tokio::runtime::Runtime::new()
.unwrap()
Expand All @@ -75,7 +108,7 @@ impl Command for TagCommand {
]),
))
{
debug!("Telemetry error: {:?}", e);
debug!("Telemetry error: {e:?}");
}
}

Expand Down
Loading
Loading