├── .gitignore ├── Cargo.toml ├── LICENSE ├── README.md └── src ├── lib.rs └── main.rs /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | debug/ 4 | target/ 5 | 6 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 7 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 8 | Cargo.lock 9 | 10 | # These are backup files generated by rustfmt 11 | **/*.rs.bk 12 | 13 | # MSVC Windows builds of rustc generate these, which store debugging information 14 | *.pdb 15 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "commit" 3 | license = "MIT" 4 | edition = "2021" 5 | version = "0.1.0" 6 | readme = "README.md" 7 | categories = ["command-line-utilities"] 8 | homepage = "https://github.com/0xble/commit" 9 | repository = "https://github.com/0xble/commit" 10 | authors = ["Brian Le "] 11 | description = "Generate commit messages using GPT-3 based on your changes and commit history." 12 | 13 | [dependencies] 14 | clap = { version = "4.0.29", features = ["derive"] } 15 | dialoguer = "0.10.2" 16 | colored = "2.0.0" 17 | reqwest = { version = "0.11.13", default-features = false, features = ["json", "blocking", "native-tls-crate", "default-tls", "hyper-tls", "__tls"] } 18 | serde_json = { version = "1.0.89", default-features = false } 19 | spinners = "4.1.0" 20 | bat = { version = "0.22.1", default-features = false, features = ["regex-onig"] } 21 | words-count = "0.1" 22 | regex = "1" 23 | clipboard = "0.5.0" -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Brian Le 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Commit 2 | 3 | ![commit](https://user-images.githubusercontent.com/20782088/209482689-4b4b1a2c-9ae4-4ed0-ac62-ba0dceef5c44.png) 4 | 5 | Generate commit messages using GPT-3 based on your changes and commit history. 6 | 7 | ## Install 8 | 9 | You need Rust and Cargo installed on your machine. See the installation guide 10 | [here](https://doc.rust-lang.org/cargo/getting-started/installation.html). 11 | 12 | Then clone the repo and install the CLI globally like this: 13 | 14 | ```sh 15 | cargo install --path . 16 | ``` 17 | 18 | ## Usage 19 | 20 | `commit` uses [GPT-3](https://beta.openai.com/). To use it, you'll need to grab an API key from [your dashboard](https://beta.openai.com/), and save it to `OPENAI_API_KEY` as follows (you can also save it in your bash/zsh profile for persistance between sessions). 21 | 22 | ```bash 23 | export OPENAI_API_KEY='sk-XXXXXXXX' 24 | ``` 25 | 26 | Once you have configured your environment, run `commit` in any Git repository with staged changes. 27 | 28 | To get a full overview of all available options, run `commit --help` 29 | 30 | ```sh 31 | $ commit --help 32 | Generate commit messages using GPT-3 based on your changes and commit history. 33 | 34 | Usage: commit [OPTIONS] [FILES]... 35 | 36 | Arguments: 37 | [FILES]... Files to stage and commit 38 | 39 | Options: 40 | -c, --commits Number of commits in history to use generating message [default: 50] 41 | -t, --max-tokens Maximum number of tokens to use generating message [default: 2000] 42 | --copy Copy the commit message to clipboard instead of committing 43 | --no-commit Don't commit, just print the commit message to stdout 44 | -y, --yes Answert "Yes" to prompts 45 | -h, --help Print help information 46 | -V, --version Print version information 47 | ``` 48 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | use colored::*; 2 | use reqwest::blocking::Client; 3 | use serde_json::json; 4 | use spinners::{Spinner, Spinners}; 5 | use std::process; 6 | 7 | pub fn generate_commit_message( 8 | commit_messages: &str, 9 | mut diff: String, 10 | api_key: &str, 11 | max_tokens: usize, 12 | negative_matches: &str, 13 | ) -> (String, String) { 14 | let client = Client::new(); 15 | 16 | let mut data = None; 17 | 18 | // Loop at most twice to retry with summary of changes if diff is too long 19 | for _ in 0..2 { 20 | let mut spinner = 21 | Spinner::new(Spinners::BouncingBar, "Generating commit message...".into()); 22 | 23 | // Create prompt 24 | let mut prompt = String::new(); 25 | 26 | if !commit_messages.is_empty() { 27 | prompt = format!("Using the same format, convention, and style of these examples:\n{commit_messages}\n\n"); 28 | }; 29 | 30 | prompt = match negative_matches.len() { 31 | 0 => format!("{prompt}Write a commit message no longer than seventy-two characters describing the changes, ignoring todo comments:\n{diff}\n\nShow me just the commit message."), 32 | _ => format!("{prompt}Write a commit message no longer than seventy-two characters describing the changes, ignoring todo comments:\n{diff}\n\nAvoid generating these messages:\n{negative_matches}\n\nShow me just the commit message."), 33 | }; 34 | 35 | // Send request to OpenAI API 36 | let response = client 37 | .post("https://api.openai.com/v1/completions") 38 | .json(&json!({ 39 | "top_p": 1, 40 | "temperature": 0, 41 | "max_tokens": max_tokens, 42 | "presence_penalty": 0, 43 | "frequency_penalty": 0, 44 | "model": "text-davinci-003", 45 | "prompt": prompt, 46 | })) 47 | .header("Authorization", format!("Bearer {}", api_key)) 48 | .send() 49 | .unwrap_or_else(|_| { 50 | spinner.stop_and_persist( 51 | "✖".red().to_string().as_str(), 52 | "Failed to get a response. Have you set the OPENAI_API_KEY variable?" 53 | .red() 54 | .to_string(), 55 | ); 56 | std::process::exit(1); 57 | }); 58 | 59 | data = Some(response.json::().unwrap()); 60 | 61 | // Check for error 62 | let error = &data.as_ref().unwrap()["error"]["message"]; 63 | if error.is_string() && error.to_string().contains("Please reduce your prompt") { 64 | // If the diff is too long, generate message from stat summary of diff instead 65 | let new_diff = process::Command::new("git") 66 | .arg("diff") 67 | .arg("--staged") 68 | .arg("--stat") 69 | .arg("--summary") 70 | .output() 71 | .expect("Failed to execute `git diff`"); 72 | 73 | spinner.stop_with_message( 74 | "Exceeds max tokens, using summary of changes instead..." 75 | .yellow() 76 | .to_string(), 77 | ); 78 | 79 | diff = String::from_utf8(new_diff.stdout).unwrap(); 80 | } else { 81 | spinner.stop_and_persist( 82 | "✔".green().to_string().as_str(), 83 | "Got commit message!".green().to_string(), 84 | ); 85 | 86 | break; 87 | } 88 | } 89 | 90 | // Get commit message from response 91 | let commit; 92 | match data { 93 | Some(value) => { 94 | commit = value["choices"][0]["text"] 95 | .as_str() 96 | .unwrap_or_else(|| { 97 | if value["error"]["message"].is_string() { 98 | println!( 99 | "{}", 100 | format!("{}", value["error"]["message"].to_string()).red() 101 | ); 102 | std::process::exit(1); 103 | } else { 104 | println!("{}", "Nothing returned from GPT-3.".red()); 105 | std::process::exit(1); 106 | } 107 | }) 108 | .trim() 109 | .trim_start_matches("\"") 110 | .trim_end_matches("\"") 111 | .to_string(); 112 | } 113 | None => { 114 | println!("{}", "Failed to generate commit message.".red()); 115 | std::process::exit(1); 116 | } 117 | } 118 | 119 | if commit.is_empty() { 120 | println!("{}", "Nothing returned from GPT-3.".red()); 121 | std::process::exit(1); 122 | } 123 | 124 | (commit, diff) 125 | } 126 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use bat::PrettyPrinter; 2 | use clap::Parser; 3 | use clipboard::{ClipboardContext, ClipboardProvider}; 4 | use colored::*; 5 | use commit::generate_commit_message; 6 | use dialoguer::{theme::ColorfulTheme, Editor, Select}; 7 | use std::{env, process}; 8 | use words_count::count; 9 | 10 | #[derive(Parser, Debug)] 11 | #[command(author, version, about)] 12 | struct Cli { 13 | #[clap(help = "Files to stage and commit")] 14 | files: Vec, 15 | #[clap( 16 | short, 17 | long, 18 | default_value = "50", 19 | help = "Number of commits in history to use generating message" 20 | )] 21 | commits: usize, 22 | #[clap( 23 | short = 't', 24 | long, 25 | default_value = "2000", 26 | help = "Maximum number of tokens to use generating message" 27 | )] 28 | max_tokens: usize, 29 | #[clap( 30 | long, 31 | help = "Copy the commit message to clipboard instead of committing" 32 | )] 33 | copy: bool, 34 | #[clap(long, help = "Don't commit, just print the commit message to stdout")] 35 | no_commit: bool, 36 | #[clap(short, long, help = "Answert \"Yes\" to prompts")] 37 | yes: bool, 38 | } 39 | 40 | fn main() { 41 | // Parse the command line arguments 42 | let Cli { 43 | files, 44 | commits, 45 | max_tokens, 46 | copy, 47 | no_commit, 48 | yes, 49 | } = Cli::parse(); 50 | 51 | // Check if the OpenAI API key is set 52 | let api_key = env::var("OPENAI_API_KEY").unwrap_or_else(|_| { 53 | println!("{}", "This program requires an OpenAI API key to run. Please set the OPENAI_API_KEY environment variable.".red()); 54 | std::process::exit(1); 55 | }); 56 | 57 | // Stage the files if any are provided 58 | if !files.is_empty() { 59 | process::Command::new("git") 60 | .arg("add") 61 | .args(&files) 62 | .output() 63 | .expect("Failed to execute `git add`"); 64 | } 65 | 66 | // Get the diff of the staged files 67 | let diff = process::Command::new("git") 68 | .arg("diff") 69 | .arg("--staged") 70 | .arg("--minimal") 71 | .output() 72 | .expect("Failed to execute `git diff`"); 73 | let mut diff = String::from_utf8(diff.stdout).unwrap(); 74 | 75 | let count = count(&diff); 76 | if count.characters - count.whitespaces == 0 { 77 | println!( 78 | "{}", 79 | "Nothing to commit. Did you stage your changes with \"git add\"?".red() 80 | ); 81 | std::process::exit(1); 82 | } 83 | 84 | // Get the commit messages from the last n commits 85 | let commit_messages = process::Command::new("git") 86 | .arg("log") 87 | .arg(format!("-{}", commits)) 88 | .arg("--pretty=format:%s") 89 | .output() 90 | .expect("Failed to execute `git log`"); 91 | let mut commit_messages = String::from_utf8(commit_messages.stdout) 92 | .unwrap() 93 | .lines() 94 | .filter(|line| !line.starts_with("Merge")) 95 | .map(|line| format!("- {line}")) 96 | .collect::>() 97 | .join("\n"); 98 | // Remove PR numbers from squashed commit messages 99 | let re = regex::Regex::new(r"\(#\d+\)\n").unwrap(); 100 | commit_messages = re.replace_all(&commit_messages, "\n").to_string(); 101 | 102 | // Generate the commit message using GPT-3 103 | let mut commit; 104 | let mut negative_matches = vec![]; 105 | loop { 106 | let prompt = match (copy, no_commit) { 107 | (true, false) | (true, true) => "Copy commit message to clipboard?", 108 | (false, true) => "Print commit message to stdout?", 109 | (false, false) => "Commit changes with message?", 110 | }; 111 | 112 | (commit, diff) = generate_commit_message( 113 | &commit_messages, 114 | diff.clone(), 115 | &api_key, 116 | max_tokens, 117 | negative_matches.join("\n").as_str(), 118 | ); 119 | 120 | // If user has provided the --yes flag, confirm without prompting 121 | if yes { 122 | break; 123 | } 124 | 125 | // Print commit message 126 | PrettyPrinter::new() 127 | .input_from_bytes(commit.as_bytes()) 128 | .grid(true) 129 | .colored_output(false) 130 | .print() 131 | .unwrap(); 132 | 133 | let user_option = Select::with_theme(&ColorfulTheme::default()) 134 | .with_prompt(prompt) 135 | .default(0) 136 | .item("Yes") 137 | .item("No") 138 | .item("Edit") 139 | .item("Redo") 140 | .interact() 141 | .unwrap(); 142 | 143 | // Handle user selection 144 | match user_option { 145 | // Proceed if user selects "Yes" 146 | 0 => break, 147 | // Quit if user selects "No" 148 | 1 => std::process::exit(0), 149 | // Edit the commit message if user selects "Edit" 150 | 2 => { 151 | if let Some(new_commit) = Editor::new().edit(&commit).unwrap() { 152 | if new_commit.is_empty() { 153 | println!("{}", "Commit message cannot be empty.".red()); 154 | std::process::exit(1); 155 | } 156 | commit = new_commit; 157 | break; 158 | } else { 159 | std::process::exit(0); 160 | } 161 | } 162 | // Redo the commit message if user selects "Redo" 163 | 3 => { 164 | negative_matches.push(format!("- {}", commit)); 165 | } 166 | // Unrecognized selection 167 | _ => { 168 | println!("{}", "Unrecognized selection.".red()); 169 | std::process::exit(1); 170 | } 171 | } 172 | } 173 | if copy { 174 | // Copy the header to clipboard. 175 | let mut ctx: ClipboardContext = ClipboardProvider::new().unwrap(); 176 | ctx.set_contents(commit.clone()).unwrap(); 177 | } else if no_commit { 178 | // Print the commit message to stdout 179 | println!("{}", commit); 180 | } else { 181 | // Commit changes with generated commit message 182 | let commit = process::Command::new("git") 183 | .arg("commit") 184 | .arg("-m") 185 | .arg(commit) 186 | .output() 187 | .expect("Failed to execute `git commit`"); 188 | let commit = String::from_utf8(commit.stdout).unwrap(); 189 | let commit = commit.trim(); 190 | println!("{}", commit); 191 | } 192 | } 193 | --------------------------------------------------------------------------------