diff --git a/README.md b/README.md index 81086f49..3e26464c 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ features related to the conventional commit specification. Anything else shall b - [Cargo](#cargo) - [Archlinux](#archlinux) - [Binaries](#Binaries) + - [Shell completions](#Shell-completions) - [Coco Commits](#Coco-Commits) - [Breaking changes](#Breaking-changes) - [Cog commands](#Cog-commands) @@ -87,9 +88,35 @@ At the moment Cocogitto comes with two binaries `coco` and `cog`. - `cog` does everything else : check your repo history against the spec, edit malformed commit messages, generate changelog and bump versions etc. +### Shell completions + +Before getting started you might want to install shell completions for `cog` and `coco` commands. +Supported shells are `bash`, `elvish`, `fish` and `zsh`. + +Example installing completions: + +``` +# Bash +$ cog generate-completions bash > ~/.local/share/bash-completion/completions/cog +$ coco generate-completions bash > ~ bash > ~/.local/share/bash-completion/completions/coco + +# Bash (macOS/Homebrew) +$ cog generate-completions bash > ~ bash > $(brew --prefix)/etc/bash_completion.d/cog.bash-completion +$ coco generate-completions bash > ~ bash > $(brew --prefix)/etc/bash_completion.d/coco.bash-completion + +# Fish +$ mkdir -p ~/.config/fish/completions +$ cog generate-completions bash > ~ fish > ~/.config/fish/completions/cog.fish +$ coco generate-completions bash > ~ fish > ~/.config/fish/completions/coco.fish + +# Zsh +$ cog generate-completions bash > ~ zsh > ~/.zfunc/_cog +$ coco generate-completions bash > ~ zsh > ~/.zfunc/_coco +``` + ## Coco Commits -`coco` allows you to easily create commits respecting the conventional specification. It comes with a set of sub-commands +`coco` allows you to easily create commits respecting the conventional specification. It comes with a set of predefined arguments named after conventional commit types : `style`, `build`, `refactor`, `ci`, `fix`, `test`, `perf`, `chore`, `feat`, `revert`, `docs`. Conventional commits are structured as follow : @@ -102,14 +129,14 @@ Conventional commits are structured as follow : [optional footer(s)] ``` -All `coco` subcommands follows the same structure : +All `coco` commit commands follows the same structure : ``` coco {type} {message} [optional scope] [optional body] [optional footer] ``` The only difference you need to remember is that `coco` commit scope comes after the commit description. This allows -to use positional arguments instead of typing flags (ex: `coco -t {type} -s {scope} -m {message}... and so on`) +using positional arguments instead of typing flags (ex: `coco -t {type} -s {scope} -m {message}... and so on`) For instance if you want to create the following commit : `feat: add awesome feature` you would run this : @@ -236,7 +263,7 @@ cog log --author "Paul Delafosse" "Mike Lubinets" --type feat --scope cli --no-e ### Generate changelogs -There is two way to generate changelog with `cog` : +There are two way to generate changelog with `cog` : - To stdout with `cog changelog`. ``` @@ -259,7 +286,7 @@ There is two way to generate changelog with `cog` : d4aa61 - change config name to cog.toml - Paul Delafosse ``` -- To your repo `CHANGELOG.md` file with `cog bump` (see [Auto bump](#auto-bump)). +- To your repo `CHANGELOG.md` file with `cog bump`. ### Auto bump diff --git a/cog.toml b/cog.toml index 79b3273a..3bb4e711 100644 --- a/cog.toml +++ b/cog.toml @@ -41,4 +41,4 @@ post_bump_hooks = [ # An optional list of additional allowed commit type # `coco {commit_type}` commit command will be generated at runtime [commit_types] -ex = { changelog_title = "This is the markdown title for `ex` commit type", help_message = "This is the cli `--help` message for `ex` commits" } \ No newline at end of file +ex = { changelog_title = "This is the markdown title for `ex` commit type" } diff --git a/src/bin/coco.rs b/src/bin/coco.rs index a7d472f0..dbf84834 100644 --- a/src/bin/coco.rs +++ b/src/bin/coco.rs @@ -1,9 +1,10 @@ use anyhow::Result; -use clap::{App, AppSettings, Arg, SubCommand}; +use clap::{App, AppSettings, Arg, Shell, SubCommand}; use cocogitto::CocoGitto; const APP_SETTINGS: &[AppSettings] = &[ - AppSettings::SubcommandRequiredElseHelp, + AppSettings::ArgsNegateSubcommands, + AppSettings::SubcommandsNegateReqs, AppSettings::UnifiedHelpMessage, AppSettings::ColoredHelp, AppSettings::VersionlessSubcommands, @@ -17,52 +18,84 @@ const SUBCOMMAND_SETTINGS: &[AppSettings] = &[ AppSettings::DeriveDisplayOrder, ]; +const GENERATE_COMPLETIONS: &str = "generate-completions"; + fn main() -> Result<()> { let cocogitto = CocoGitto::get()?; - let matches = App::new("Cocogito") - .settings(APP_SETTINGS) - .version(env!("CARGO_PKG_VERSION")) - .author("Paul D. ") - .about("A conventional commit compliant, changelog and commit generator") - .long_about("Conventional Commit Git Terminal Overlord is a tool to help you use the conventional commit specification") - .subcommands(CocoGitto::get_commit_metadata() - .iter() - .map(|(commit_type, commit_config)| { - SubCommand::with_name(commit_type.get_key_str()) - .settings(SUBCOMMAND_SETTINGS) - .about(commit_config.help_message.as_str()) - .help(commit_config.help_message.as_str()) - .arg(Arg::with_name("message").help("The commit message")) - .arg(Arg::with_name("scope").help("The scope of the commit message")) - .arg(Arg::with_name("body").help("The body of the commit message")) - .arg(Arg::with_name("footer").help("The footer of the commit message")) - .arg( - Arg::with_name("breaking-change") - .help("BREAKING CHANGE commit") - .short("B") - .long("breaking-change"), - ) - }) - .collect::>() - ).get_matches(); + let matches = app().get_matches(); + + if let Some(subcommand) = matches.subcommand_matches(GENERATE_COMPLETIONS) { + let for_shell = match subcommand.value_of("type").unwrap() { + "bash" => Shell::Bash, + "elvish" => Shell::Elvish, + "fish" => Shell::Fish, + "zsh" => Shell::Zsh, + _ => unreachable!(), + }; + app().gen_completions_to("coco", for_shell, &mut std::io::stdout()); + } else { + let commit_type = matches.value_of("type").unwrap().to_string(); + let message = matches.value_of("message").unwrap().to_string(); + let scope = matches.value_of("scope").map(|scope| scope.to_string()); + let body = matches.value_of("body").map(|body| body.to_string()); + let footer = matches.value_of("footer").map(|footer| footer.to_string()); + let breaking_change = matches.is_present("breaking-change"); - if let Some(commit_subcommand) = matches.subcommand_name() { - if let Some(args) = matches.subcommand_matches(commit_subcommand) { - let message = args.value_of("message").unwrap().to_string(); - let scope = args.value_of("scope").map(|scope| scope.to_string()); - let body = args.value_of("body").map(|body| body.to_string()); - let footer = args.value_of("footer").map(|footer| footer.to_string()); - let breaking_change = args.is_present("breaking-change"); - cocogitto.conventional_commit( - commit_subcommand, - scope, - message, - body, - footer, - breaking_change, - )?; - } + cocogitto.conventional_commit( + &commit_type, + scope, + message, + body, + footer, + breaking_change, + )?; } + Ok(()) } + +fn app<'a, 'b>() -> App<'a, 'b> { + let keys = CocoGitto::get_commit_metadata() + .iter() + .map(|(commit_type, _)| commit_type.get_key_str()) + .collect::>(); + + App::new("Coco") + .settings(APP_SETTINGS) + .version(env!("CARGO_PKG_VERSION")) + .author("Paul D. ") + .about("A command line tool to create conventional commits") + .arg( + Arg::with_name("type") + .help("The type of the commit message") + .possible_values(keys.as_slice()) + .required(true), + ) + .arg( + Arg::with_name("message") + .help("The type of the commit message") + .required(true), + ) + .arg(Arg::with_name("scope").help("The scope of the commit message")) + .arg(Arg::with_name("body").help("The body of the commit message")) + .arg(Arg::with_name("footer").help("The footer of the commit message")) + .arg( + Arg::with_name("breaking-change") + .help("BREAKING CHANGE commit") + .short("B") + .long("breaking-change"), + ) + .subcommand( + SubCommand::with_name(GENERATE_COMPLETIONS) + .settings(SUBCOMMAND_SETTINGS) + .about("Generate shell completions") + .arg( + Arg::with_name("type") + .possible_values(&["bash", "elvish", "fish", "zsh"]) + .required(true) + .takes_value(true) + .help("Type of completions to generate"), + ), + ) +} diff --git a/src/bin/cog.rs b/src/bin/cog.rs index a3f1cd02..d7d03092 100644 --- a/src/bin/cog.rs +++ b/src/bin/cog.rs @@ -1,5 +1,5 @@ use anyhow::{Context, Result}; -use clap::{App, AppSettings, Arg, SubCommand}; +use clap::{App, AppSettings, Arg, Shell, SubCommand}; use cocogitto::commit::CommitType; use cocogitto::filter::{CommitFilter, CommitFilters}; use cocogitto::git_hooks::HookKind; @@ -31,8 +31,156 @@ const VERIFY: &str = "verify"; const CHANGELOG: &str = "changelog"; const INIT: &str = "init"; const INSTALL_GIT_HOOK: &str = "install-hook"; +const GENERATE_COMPLETIONS: &str = "generate-completions"; fn main() -> Result<()> { + let matches = app().get_matches(); + + if let Some(subcommand) = matches.subcommand_name() { + match subcommand { + BUMP => { + let cocogitto = CocoGitto::get()?; + let subcommand = matches.subcommand_matches(BUMP).unwrap(); + + let increment = if let Some(version) = subcommand.value_of("version") { + VersionIncrement::Manual(version.to_string()) + } else if subcommand.is_present("auto") { + VersionIncrement::Auto + } else if subcommand.is_present("major") { + VersionIncrement::Major + } else if subcommand.is_present("patch") { + VersionIncrement::Patch + } else if subcommand.is_present("minor") { + VersionIncrement::Minor + } else { + unreachable!() + }; + + let pre = subcommand.value_of("pre"); + + // TODO mode to cli + cocogitto.create_version(increment, WriterMode::Prepend, pre)? + } + VERIFY => { + let subcommand = matches.subcommand_matches(VERIFY).unwrap(); + let message = subcommand.value_of("message").unwrap(); + let author = CocoGitto::get() + .map(|cogito| cogito.get_committer().unwrap()) + .ok(); + + match cocogitto::verify(author, message) { + Ok(()) => exit(0), + Err(err) => { + eprintln!("{}", err); + exit(1); + } + } + } + + CHECK => { + let cocogitto = CocoGitto::get()?; + cocogitto.check()?; + } + EDIT => { + let cocogitto = CocoGitto::get()?; + cocogitto.check_and_edit()?; + } + LOG => { + let cocogitto = CocoGitto::get()?; + + let repo_tag_name = match cocogitto.get_repo_tag_name() { + Some(name) => name, + None => "cog log".to_string(), + }; + + let mut output = Output::builder() + .with_pager_from_env("PAGER") + .with_file_name(repo_tag_name) + .build()?; + + let subcommand = matches.subcommand_matches(LOG).unwrap(); + + let mut filters = vec![]; + if let Some(commit_types) = subcommand.values_of("type") { + commit_types.for_each(|commit_type| { + filters.push(CommitFilter::Type(CommitType::from(commit_type))); + }); + } + + if let Some(scopes) = subcommand.values_of("scope") { + scopes.for_each(|scope| { + filters.push(CommitFilter::Scope(scope.to_string())); + }); + } + + if let Some(authors) = subcommand.values_of("author") { + authors.for_each(|author| { + filters.push(CommitFilter::Author(author.to_string())); + }); + } + + if subcommand.is_present("breaking-change") { + filters.push(CommitFilter::BreakingChange); + } + + if subcommand.is_present("no-error") { + filters.push(CommitFilter::NoError); + } + + let filters = CommitFilters(filters); + + let content = cocogitto.get_log(filters)?; + output + .handle()? + .write_all(content.as_bytes()) + .context("failed to write log into the pager")?; + } + CHANGELOG => { + let cocogitto = CocoGitto::get()?; + let subcommand = matches.subcommand_matches(CHANGELOG).unwrap(); + let from = subcommand.value_of("from"); + let to = subcommand.value_of("to"); + let result = cocogitto.get_colored_changelog(from, to)?; + println!("{}", result); + } + + INIT => { + let subcommand = matches.subcommand_matches(INIT).unwrap(); + let init_path = subcommand.value_of("path").unwrap(); // safe unwrap via clap default value + cocogitto::init(init_path)?; + } + + INSTALL_GIT_HOOK => { + let subcommand = matches.subcommand_matches(INSTALL_GIT_HOOK).unwrap(); + let hook_type = subcommand.value_of("hook-type").unwrap(); // safe unwrap via clap default value + let cocogitto = CocoGitto::get()?; + match hook_type { + "pre-commit" => cocogitto.install_hook(HookKind::PrepareCommit)?, + "pre-push" => cocogitto.install_hook(HookKind::PrePush)?, + "all" => cocogitto.install_hook(HookKind::All)?, + _ => unreachable!(), + } + } + + GENERATE_COMPLETIONS => { + let generate_subcommand = matches.subcommand_matches(GENERATE_COMPLETIONS).unwrap(); + let for_shell = match generate_subcommand.value_of("type").unwrap() { + "bash" => Shell::Bash, + "elvish" => Shell::Elvish, + "fish" => Shell::Fish, + "zsh" => Shell::Zsh, + _ => unreachable!(), + }; + app().gen_completions_to("cog", for_shell, &mut std::io::stdout()); + } + + _ => unreachable!(), + } + } + Ok(()) +} + +fn app<'a, 'b>() -> App<'a, 'b> { let check_command = SubCommand::with_name(CHECK) .settings(SUBCOMMAND_SETTINGS) .about("Verify all commit message against the conventional commit specification") @@ -177,142 +325,31 @@ fn main() -> Result<()> { ) .display_order(7); - let matches = App::new("Cogitto") + App::new("Cog") .settings(APP_SETTINGS) .version(env!("CARGO_PKG_VERSION")) .author("Paul D. ") - .about("A conventional commit compliant, changelog and commit generator") - .long_about("Conventional Commit Git Terminal Overlord is a tool to help you use the conventional commit specification") - .subcommands(vec![verify_command, init_subcommand, check_command, edit_command, log_command, changelog_command, bump_command, install_git_hook]) - .get_matches(); - - if let Some(subcommand) = matches.subcommand_name() { - match subcommand { - BUMP => { - let cocogitto = CocoGitto::get()?; - let subcommand = matches.subcommand_matches(BUMP).unwrap(); - - let increment = if let Some(version) = subcommand.value_of("version") { - VersionIncrement::Manual(version.to_string()) - } else if subcommand.is_present("auto") { - VersionIncrement::Auto - } else if subcommand.is_present("major") { - VersionIncrement::Major - } else if subcommand.is_present("patch") { - VersionIncrement::Patch - } else if subcommand.is_present("minor") { - VersionIncrement::Minor - } else { - unreachable!() - }; - - let pre = subcommand.value_of("pre"); - - // TODO mode to cli - cocogitto.create_version(increment, WriterMode::Prepend, pre)? - } - VERIFY => { - let subcommand = matches.subcommand_matches(VERIFY).unwrap(); - let message = subcommand.value_of("message").unwrap(); - let author = CocoGitto::get() - .map(|cogito| cogito.get_committer().unwrap()) - .ok(); - - match cocogitto::verify(author, message) { - Ok(()) => exit(0), - Err(err) => { - eprintln!("{}", err); - exit(1); - } - } - } - - CHECK => { - let cocogitto = CocoGitto::get()?; - cocogitto.check()?; - } - EDIT => { - let cocogitto = CocoGitto::get()?; - cocogitto.check_and_edit()?; - } - LOG => { - let cocogitto = CocoGitto::get()?; - - let repo_tag_name = match cocogitto.get_repo_tag_name() { - Some(name) => name, - None => "cog log".to_string(), - }; - - let mut output = Output::builder() - .with_pager_from_env("PAGER") - .with_file_name(repo_tag_name) - .build()?; - - let subcommand = matches.subcommand_matches(LOG).unwrap(); - - let mut filters = vec![]; - if let Some(commit_types) = subcommand.values_of("type") { - commit_types.for_each(|commit_type| { - filters.push(CommitFilter::Type(CommitType::from(commit_type))); - }); - } - - if let Some(scopes) = subcommand.values_of("scope") { - scopes.for_each(|scope| { - filters.push(CommitFilter::Scope(scope.to_string())); - }); - } - - if let Some(authors) = subcommand.values_of("author") { - authors.for_each(|author| { - filters.push(CommitFilter::Author(author.to_string())); - }); - } - - if subcommand.is_present("breaking-change") { - filters.push(CommitFilter::BreakingChange); - } - - if subcommand.is_present("no-error") { - filters.push(CommitFilter::NoError); - } - - let filters = CommitFilters(filters); - - let content = cocogitto.get_log(filters)?; - output - .handle()? - .write_all(content.as_bytes()) - .context("failed to write log into the pager")?; - } - CHANGELOG => { - let cocogitto = CocoGitto::get()?; - let subcommand = matches.subcommand_matches(CHANGELOG).unwrap(); - let from = subcommand.value_of("from"); - let to = subcommand.value_of("to"); - let result = cocogitto.get_colored_changelog(from, to)?; - println!("{}", result); - } - - INIT => { - let subcommand = matches.subcommand_matches(INIT).unwrap(); - let init_path = subcommand.value_of("path").unwrap(); // safe unwrap via clap default value - cocogitto::init(init_path)?; - } - - INSTALL_GIT_HOOK => { - let subcommand = matches.subcommand_matches(INSTALL_GIT_HOOK).unwrap(); - let hook_type = subcommand.value_of("hook-type").unwrap(); // safe unwrap via clap default value - let cocogitto = CocoGitto::get()?; - match hook_type { - "pre-commit" => cocogitto.install_hook(HookKind::PrepareCommit)?, - "pre-push" => cocogitto.install_hook(HookKind::PrePush)?, - "all" => cocogitto.install_hook(HookKind::All)?, - _ => unreachable!(), - } - } - _ => unreachable!(), - } - } - Ok(()) + .about("A command line tool for the conventional commits and semver specifications") + .subcommands(vec![ + verify_command, + init_subcommand, + check_command, + edit_command, + log_command, + changelog_command, + bump_command, + install_git_hook, + ]) + .subcommand( + SubCommand::with_name(GENERATE_COMPLETIONS) + .settings(SUBCOMMAND_SETTINGS) + .about("Generate shell completions") + .arg( + Arg::with_name("type") + .possible_values(&["bash", "elvish", "fish", "zsh"]) + .required(true) + .takes_value(true) + .help("Type of completions to generate"), + ), + ) } diff --git a/src/commit.rs b/src/commit.rs index bb0af148..0fb7eae5 100644 --- a/src/commit.rs +++ b/src/commit.rs @@ -48,14 +48,12 @@ pub struct CommitMessage { #[derive(Debug, Deserialize, Serialize, Clone)] pub struct CommitConfig { pub changelog_title: String, - pub help_message: String, } impl CommitConfig { - pub(crate) fn new(changelog_title: &str, help_message: &str) -> Self { + pub(crate) fn new(changelog_title: &str) -> Self { CommitConfig { changelog_title: changelog_title.to_string(), - help_message: help_message.to_string(), } } } @@ -397,5 +395,8 @@ mod test { assert_eq!(commit.commit_type, CommitType::Feature); assert_eq!(commit.scope, Some("database".to_owned())); assert_eq!(commit.description, "add postgresql driver".to_owned()); + assert!(!commit.is_breaking_change); + assert!(commit.body.is_none()); + assert!(commit.footer.is_none()); } } diff --git a/src/settings.rs b/src/settings.rs index 3da365ab..fbb17145 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -84,55 +84,25 @@ impl Settings { fn get_default_commit_config() -> CommitsMetadata { let mut default_types = HashMap::new(); - default_types.insert( - CommitType::Feature, - CommitConfig::new("Features", "create a `feature` commit"), - ); - default_types.insert( - CommitType::BugFix, - CommitConfig::new("Bug Fixes", "create a `bug fix` commit"), - ); - default_types.insert( - CommitType::Chore, - CommitConfig::new("Miscellaneous Chores", "create a `chore` commit"), - ); - default_types.insert( - CommitType::Revert, - CommitConfig::new("Revert", "create a `revert` commit"), - ); + default_types.insert(CommitType::Feature, CommitConfig::new("Features")); + default_types.insert(CommitType::BugFix, CommitConfig::new("Bug Fixes")); + default_types.insert(CommitType::Chore, CommitConfig::new("Miscellaneous Chores")); + default_types.insert(CommitType::Revert, CommitConfig::new("Revert")); default_types.insert( CommitType::Performances, - CommitConfig::new("Performance Improvements", "create a `performance` commit"), + CommitConfig::new("Performance Improvements"), ); default_types.insert( CommitType::Documentation, - CommitConfig::new("Documentation", "create a `documentation` commit"), - ); - default_types.insert( - CommitType::Style, - CommitConfig::new("Style", "create a `style` commit"), - ); - default_types.insert( - CommitType::Refactoring, - CommitConfig::new("Refactoring", "create a `refactor` commit"), - ); - default_types.insert( - CommitType::Test, - CommitConfig::new("Tests", "create a `test` commit"), + CommitConfig::new("Documentation"), ); + default_types.insert(CommitType::Style, CommitConfig::new("Style")); + default_types.insert(CommitType::Refactoring, CommitConfig::new("Refactoring")); + default_types.insert(CommitType::Test, CommitConfig::new("Tests")); - default_types.insert( - CommitType::Build, - CommitConfig::new("Build system", "create a continuous `build` commit"), - ); + default_types.insert(CommitType::Build, CommitConfig::new("Build system")); - default_types.insert( - CommitType::Ci, - CommitConfig::new( - "Continuous Integration", - "create a `continuous integration` commit", - ), - ); + default_types.insert(CommitType::Ci, CommitConfig::new("Continuous Integration")); default_types }