-
Notifications
You must be signed in to change notification settings - Fork 15
Scopes #179
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Scopes #179
Changes from all commits
91d504c
41d70f0
dd14870
7c488dd
9534ce9
451614d
9107ecd
a72c600
f5a758f
168645e
c2aeff3
1bc60bb
4999896
4f0b43f
ebab035
14cdf12
2143b9f
2e766da
5c01a20
ebdec6e
82f611d
06c162c
71a8863
f2b9808
2e4de45
69e2db7
34e522d
0f3b7a5
b7c7db7
72b5eb9
65dcce4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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." | ||
| repository = "https://github.com/cococonscious/koji" | ||
| license = "MIT" | ||
|
|
||
|
|
@@ -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" } | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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" | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -191,3 +191,27 @@ emoji = true | |
| issues = true | ||
| ``` | ||
|
|
||
| #### `scope-patterns` | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: barIf 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]" } } | ||
| ``` | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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)] | ||
|
|
@@ -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)?; | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the |
||
|
|
||
| // 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 { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
|
@@ -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, | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm afraid I will have to insist on renaming this to |
||
| pub allow_empty_scope: bool, | ||
| pub workdir: PathBuf, | ||
| pub scope_patterns: IndexMap<String, ScopePatternValue>, | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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>, | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)] | ||
|
|
@@ -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)] | ||
|
|
@@ -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>, | ||
| } | ||
|
|
@@ -59,6 +112,8 @@ impl Config { | |
| emoji, | ||
| issues, | ||
| sign, | ||
| force_scope, | ||
| allow_empty_scope, | ||
| _user_config_path, | ||
| _current_dir, | ||
| } = args.unwrap_or_default(); | ||
|
|
@@ -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() | ||
|
|
@@ -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)); | ||
| } | ||
|
|
||
|
|
@@ -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) | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -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(()) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,4 +3,5 @@ pub mod commit; | |
| pub mod config; | ||
| pub mod emoji; | ||
| pub mod questions; | ||
| pub mod scope; | ||
| pub mod status; | ||
There was a problem hiding this comment.
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?