Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
91d504c
Switched from interfacing to git2 to gitoxide for basic repostiory
philocalyst Dec 7, 2025
41d70f0
Switched to using gitoxide and converted logic to work within that in…
philocalyst Dec 7, 2025
dd14870
Switched the integrations tests to make use of gitoxide
philocalyst Dec 7, 2025
7c488dd
Added gix dependency
philocalyst Dec 7, 2025
9534ce9
yo hi gix
philocalyst Feb 4, 2026
451614d
removed vendored openssl
philocalyst Feb 4, 2026
9107ecd
gix update
philocalyst Feb 4, 2026
a72c600
added scoping here
philocalyst Feb 21, 2026
f5a758f
added testing for scoping
philocalyst Feb 21, 2026
168645e
git2 for testing
philocalyst Feb 21, 2026
c2aeff3
ci(deps): update actions/cache action to v5 (#166)
renovate[bot] Dec 15, 2025
1bc60bb
ci(deps): update codecov/codecov-action action to v5.5.2 (#165)
renovate[bot] Dec 15, 2025
4999896
ci(deps): update actions/checkout action to v6.0.1 (#163)
renovate[bot] Dec 15, 2025
4f0b43f
test: fix rexpect windows failures, more in ci (#168)
cococonscious Dec 15, 2025
ebab035
feat: replace git2 with gix (#174)
philocalyst Feb 18, 2026
14cdf12
ci: replace release-plz with release-please (#176)
cococonscious Feb 20, 2026
2143b9f
ci(release-please): don't include component in tag (#178)
cococonscious Feb 20, 2026
2e766da
added scoping here
philocalyst Feb 21, 2026
5c01a20
feat: add force_scope and allow_empty_scope options
philocalyst Feb 28, 2026
ebdec6e
test: cleanup and add integration tests
philocalyst Feb 28, 2026
82f611d
deps: added thiserror
philocalyst Mar 11, 2026
06c162c
questions: added much better error handling, and prompt config diagno…
philocalyst Mar 11, 2026
71a8863
Added integration testing for the advacned scope configuration
philocalyst May 1, 2026
f2b9808
Added new file for the complex regex/ast-grep implementaiton here
philocalyst May 1, 2026
2e4de45
Updated configuration to accept the special scopes
philocalyst May 1, 2026
69e2db7
Linked scope in
philocalyst May 1, 2026
34e522d
Full hinting within questions
philocalyst May 1, 2026
0f3b7a5
Updated README
philocalyst May 1, 2026
b7c7db7
added deps
philocalyst May 1, 2026
72b5eb9
Passing scope through
philocalyst May 1, 2026
65dcce4
Created a simple custom type to clean up the crazy return type
philocalyst May 1, 2026
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
451 changes: 451 additions & 0 deletions Cargo.lock

Large diffs are not rendered by default.

9 changes: 7 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ authors = [
"Finley Thomalla <finley@thomalla.ch>",
"Danny Tatom <its.danny@hey.com>",
]
description = "An interactive CLI for creating conventional commits."
documentation = "https://docs.rs/koji"
description = "An interactive CLI for creating conventional commits."
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's up with this reordering?

repository = "https://github.com/cococonscious/koji"
license = "MIT"

Expand All @@ -26,19 +26,24 @@ cocogitto = { version = "6.5", default-features = false }
conventional_commit_parser = "0.9"
dirs = "6.0"
emojis = "0.8"
globset = "0.4"
indexmap = "2.10"
serde = { version = "1.0", features = ["derive"] }
inquire = "0.9"
regex = "1.12"
thiserror = "2"
clap_complete_command = { version = "0.6", features = ["nushell"]}
config = { version = "0.15", features = ["toml"] }
xdg = "3.0"
gix = "0.80.0"
ast-grep-config = { version = "0.42.1" }
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please put these features behind a feature flag. I don't mind if it's in the default features, but I want to be able to exclude it if necessary.

ast-grep-language = { version = "0.42.1" }

[dev-dependencies]
git2 = "0.20.4"
assert_cmd = "2.0.16"
predicates = "3.1.2"
tempfile = "3.12.0"
git2 = "0.20.3"

[target.'cfg(not(windows))'.dev-dependencies]
rexpect = "0.6.2"
Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,3 +191,27 @@ emoji = true
issues = true
```

#### `scope-patterns`
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make sure to document all new configuration parameters.


- Type: `table`
- Optional: `true`
- Description: Pre-assign commit scopes from staged paths. Patterns are matched against repo-relative paths prefixed with `/`, and each entry can be a single regex/glob string or a list of patterns.
```toml
[scope_patterns]
flakes = "/flake\\.nix$"
core = "/crates/core/**/*.rs"
build = ["^/build\\.rs$", "/justfile"]
```

#### Ast-grep Scope Config

- Location: In the normal Koji config file alongside the rest of the scope config
- Optional: `true`
- Description: Ast-grep rules can pre-assign commit scopes from structural code matches.
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it'd be great if the pre-assigned scope, when an ast-grep rule matches, would show up in the prompt somehow, I think nicest would be something like this:

> What type of change are you committing? feat
> What's the scope of this change? foo      <---- This line should have been printed automatically instead of being omitted.
> Write a short, imperative tense description of the change: bar

If this is not possible, for example if the prompting library doesn't allow for it, print it in some other fitting way, I just don't like that it's invisible.

```toml
[[scope_ast_grep]]
scope = "test"
language = "Rust"
files = ["**/*.rs"]
rule = { kind = "function_item", has = { stopBy = "end", pattern = "#[test]" } }
```
2 changes: 2 additions & 0 deletions meta/config/default.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ breaking_changes = true
issues = true
emoji = false
sign = false
force_scope = false
allow_empty_scope = true

[[commit_types]]
name = "feat"
Expand Down
6 changes: 5 additions & 1 deletion src/bin/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use koji::answers::{get_extracted_answers, ExtractedAnswers};
use koji::commit::{commit, generate_commit_msg, write_commit_msg};
use koji::config::{Config, ConfigArgs};
use koji::questions::{create_prompt, prompt_confirm};
use koji::scope::detect_scope_matches;
use koji::status::{check_staging, StagingStatus};

#[derive(Parser, Debug)]
Expand Down Expand Up @@ -193,10 +194,13 @@ fn main() -> Result<()> {
sign,
_user_config_path: None,
_current_dir: Some(current_dir.clone()),
..Default::default()
}))?;

let scope_matches = detect_scope_matches(&repo, &config)?;
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the all parameter is passed, newly staged files (that were previously not staged) will not be recognized.


// Get answers from interactive prompt
let answers = create_prompt(commit_message, &config)?;
let answers = create_prompt(commit_message, &config, &scope_matches)?;

// Get data necessary for a conventional commit
let ExtractedAnswers {
Expand Down
183 changes: 179 additions & 4 deletions src/lib/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use dirs::config_dir;
use indexmap::IndexMap;
use serde::Deserialize;
use std::env::current_dir;
use std::fmt;
use std::path::PathBuf;
#[cfg(any(unix, target_os = "redox"))]
use xdg::BaseDirectories;
Expand All @@ -13,10 +14,15 @@ pub struct Config {
pub autocomplete: bool,
pub breaking_changes: bool,
pub commit_types: IndexMap<String, CommitType>,
pub commit_scopes: IndexMap<String, CommitScope>,
pub emoji: bool,
pub issues: bool,
pub sign: bool,
pub force_scope: bool,
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm afraid I will have to insist on renaming this to force_config_scopes or similar. It must be clear that the scopes defined in the config are being forced, not that it's similar to allow_empty_scope.

pub allow_empty_scope: bool,
pub workdir: PathBuf,
pub scope_patterns: IndexMap<String, ScopePatternValue>,
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if we should rather have the patterns be a property in the commit_scopes entries, it seems more fitting, otherwise you configure the same scope in multiple places.

pub scope_ast_grep: Vec<ScopeAstGrepRule>,
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably the same here, should maybe be a property on the commit_scopes entries.

}

#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
Expand All @@ -26,15 +32,60 @@ pub struct CommitType {
pub name: String,
}

#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
pub struct CommitScope {
pub name: String,
pub description: Option<String>,
}

#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
#[serde(untagged)]
pub enum ScopePatternValue {
One(String),
Many(Vec<String>),
}

impl ScopePatternValue {
pub fn iter(&self) -> Box<dyn Iterator<Item = &str> + '_> {
match self {
Self::One(pattern) => Box::new(std::iter::once(pattern.as_str())),
Self::Many(patterns) => Box::new(patterns.iter().map(String::as_str)),
}
}
}

#[derive(Clone, Deserialize)]
pub struct ScopeAstGrepRule {
pub scope: String,
#[serde(flatten)]
pub rule: ast_grep_config::SerializableRuleConfig<ast_grep_language::SupportLang>,
}

impl fmt::Debug for ScopeAstGrepRule {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ScopeAstGrepRule")
.field("scope", &self.scope)
.finish_non_exhaustive()
}
}

#[derive(Clone, Debug, Deserialize)]
struct ConfigTOML {
pub autocomplete: bool,
pub breaking_changes: bool,
#[serde(default)]
commit_types: Vec<CommitType>,
#[serde(default)]
commit_scopes: Vec<CommitScope>,
pub emoji: bool,
pub issues: bool,
pub sign: bool,
pub force_scope: bool,
pub allow_empty_scope: bool,
#[serde(default)]
scope_patterns: IndexMap<String, ScopePatternValue>,
#[serde(default)]
scope_ast_grep: Vec<ScopeAstGrepRule>,
}

#[derive(Default)]
Expand All @@ -45,6 +96,8 @@ pub struct ConfigArgs {
pub emoji: Option<bool>,
pub issues: Option<bool>,
pub sign: Option<bool>,
pub force_scope: Option<bool>,
pub allow_empty_scope: Option<bool>,
pub _user_config_path: Option<PathBuf>,
pub _current_dir: Option<PathBuf>,
}
Expand All @@ -59,6 +112,8 @@ impl Config {
emoji,
issues,
sign,
force_scope,
allow_empty_scope,
_user_config_path,
_current_dir,
} = args.unwrap_or_default();
Expand All @@ -75,7 +130,7 @@ impl Config {
let mut config_dirs = vec![config_dir()];
#[cfg(any(unix, target_os = "redox"))]
config_dirs.push(BaseDirectories::new().get_config_home());
config_dirs.push(_user_config_path);
config_dirs.push(_user_config_path.clone());

settings = config_dirs
.into_iter()
Expand All @@ -89,7 +144,7 @@ impl Config {
settings = settings.add_source(config::File::from(working_dir_path).required(false));

// Try to get config from passed directory
if let Some(path) = path {
if let Some(path) = path.clone() {
settings = settings.add_source(config::File::from(path).required(false));
}

Expand All @@ -101,15 +156,47 @@ impl Config {
commit_types.insert(commit_type.name.clone(), commit_type.to_owned());
}

Ok(Config {
// Gather up commit scopes
let mut commit_scopes = IndexMap::new();
for commit_scope in config.commit_scopes.iter() {
commit_scopes.insert(commit_scope.name.clone(), commit_scope.to_owned());
}
for scope in config.scope_patterns.keys() {
commit_scopes
.entry(scope.clone())
.or_insert_with(|| CommitScope {
name: scope.clone(),
description: None,
});
}
for rule in &config.scope_ast_grep {
commit_scopes
.entry(rule.scope.clone())
.or_insert_with(|| CommitScope {
name: rule.scope.clone(),
description: None,
});
}

let config = Config {
autocomplete: autocomplete.unwrap_or(config.autocomplete),
breaking_changes: breaking_changes.unwrap_or(config.breaking_changes),
commit_types,
commit_scopes,
emoji: emoji.unwrap_or(config.emoji),
issues: issues.unwrap_or(config.issues),
sign: sign.unwrap_or(config.sign),
force_scope: force_scope.unwrap_or(config.force_scope),
allow_empty_scope: allow_empty_scope.unwrap_or(config.allow_empty_scope),
workdir,
})
scope_patterns: config.scope_patterns,
scope_ast_grep: config.scope_ast_grep,
};

crate::scope::validate_scope_patterns(&config)?;
crate::scope::validate_ast_grep_rules(&config)?;

Ok(config)
}
}

Expand Down Expand Up @@ -285,4 +372,92 @@ mod tests {

Ok(())
}

#[test]
fn test_commit_scopes() -> Result<(), Box<dyn Error>> {
let tempdir = tempfile::tempdir()?;
std::fs::write(
tempdir.path().join(".koji.toml"),
"[[commit_scopes]]\nname=\"app\"\ndescription=\"Application code\"",
)?;
let config = Config::new(Some(ConfigArgs {
_current_dir: Some(tempdir.path().to_path_buf()),
..Default::default()
}))?;
assert!(config.commit_scopes.get("app").is_some());
assert_eq!(
config.commit_scopes.get("app"),
Some(&CommitScope {
name: "app".into(),
description: Some("Application code".into())
})
);
tempdir.close()?;
Ok(())
}
#[test]
fn test_commit_scopes_from_config() -> Result<(), Box<dyn Error>> {
let tempdir_config = tempfile::tempdir()?;
std::fs::create_dir(tempdir_config.path().join("koji"))?;
std::fs::write(
tempdir_config.path().join("koji").join("config.toml"),
"[[commit_scopes]]\nname=\"server\"\ndescription=\"Server code\"\n[[commit_scopes]]\nname=\"shared\"",
)?;
let tempdir_current = tempfile::tempdir()?;
let config = Config::new(Some(ConfigArgs {
_user_config_path: Some(tempdir_config.path().to_path_buf()),
_current_dir: Some(tempdir_current.path().to_path_buf()),
..Default::default()
}))?;
assert!(config.commit_scopes.get("server").is_some());
assert!(config.commit_scopes.get("shared").is_some());
assert_eq!(config.commit_scopes.len(), 2);
tempdir_current.close()?;
tempdir_config.close()?;
Ok(())
}

#[test]
fn test_scope_patterns_add_scopes() -> Result<(), Box<dyn Error>> {
let tempdir = tempfile::tempdir()?;
std::fs::write(
tempdir.path().join(".koji.toml"),
"[scope_patterns]\ncore = \"/crates/core/**/*.rs\"\nbuild = [\"^/build\\\\.rs$\", \"/justfile\"]",
)?;

let config = Config::new(Some(ConfigArgs {
_current_dir: Some(tempdir.path().to_path_buf()),
..Default::default()
}))?;

assert!(config.commit_scopes.contains_key("core"));
assert!(config.commit_scopes.contains_key("build"));
assert_eq!(
config.scope_patterns.get("core"),
Some(&ScopePatternValue::One("/crates/core/**/*.rs".into()))
);

tempdir.close()?;
Ok(())
}

#[test]
fn test_scope_ast_grep_adds_scopes() -> Result<(), Box<dyn Error>> {
let tempdir = tempfile::tempdir()?;
std::fs::write(
tempdir.path().join(".koji.toml"),
"[[scope_ast_grep]]\nscope = \"test\"\nlanguage = \"Rust\"\nrule = { kind = \"function_item\", has = { stopBy = \"end\", pattern = \"#[test]\" } }\nfiles = [\"**/*.rs\"]",
)?;

let config = Config::new(Some(ConfigArgs {
_current_dir: Some(tempdir.path().to_path_buf()),
..Default::default()
}))?;

assert!(config.commit_scopes.contains_key("test"));
assert_eq!(config.scope_ast_grep.len(), 1);

tempdir.close()?;
Ok(())
}
}
1 change: 1 addition & 0 deletions src/lib/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ pub mod commit;
pub mod config;
pub mod emoji;
pub mod questions;
pub mod scope;
pub mod status;
Loading
Loading