This is the first dev log for my new project, git-commitgen
. I plan to write
these logs to keep track of the project’s progress and share the updates on this
site.
So far, I’ve built the basic structure and implemented the core functionality. Here’s a breakdown of what I’ve done.
Generating Man Pages
I ran into a problem when I tried to use the tool with git. Running
git commitgen --help
gave me an error. It turns out that for a command like
git-commitgen
to work properly with git --help
, it needs a man page named
git-commitgen.1
.
The easiest way to solve this was to generate it automatically from my clap
setup using clap_mangen
. This required a build.rs
script, but to make it
work, I had to access my Args
struct. To avoid compiler warnings, I moved the
argument parser out of src/main.rs
and into its own file, src/args.rs
.
use clap::{
ColorChoice,
Parser,
};
#[derive(Parser, Debug)]
#[command(
bin_name = "git commitgen",
color = ColorChoice::Auto,
version = env!("CARGO_PKG_VERSION"),
about = env!("CARGO_PKG_DESCRIPTION"),
long_about = env!("CARGO_PKG_DESCRIPTION"),
after_help = "Run 'git commitgen help' for more detailed information."
)]
pub struct Args {
/// Draft commit
#[arg(short, long)]
pub message: Option<String>,
}
With the struct separated, my build.rs
script can now import it cleanly and
generate the man page during the build process.
use clap::CommandFactory;
use std::env;
use std::fs;
use std::path::Path;
#[path = "src/args.rs"]
mod args;
use args::Args;
fn main() -> std::io::Result<()> {
println!("cargo:rerun-if-changed=src/args.rs");
let out_dir = env::var("OUT_DIR").expect("OUT_DIR not set");
let man_dir = Path::new(&out_dir).join("man");
fs::create_dir_all(&man_dir)?;
let cmd = Args::command();
let man = clap_mangen::Man::new(cmd.clone());
let mut buffer: Vec<u8> = Default::default();
man.render(&mut buffer)?;
let man_path = man_dir.join(format!("{}.1", cmd.get_name()));
fs::write(&man_path, buffer)?;
Ok(())
}
Parsing the Config
Next, I worked on the configuration. I’m using serde
and toml
to parse a
commitgen.toml
file. This file should be in the root of the project’s
repository.
Here is what it looks like:
provider = "gemini"
model = "gemini-1.5-flash-latest"
This lets the user choose which provider and model to use. Right now, I’ve only written the implementation for Gemini.
Adding a Provider Trait
To support different AI providers in the future, I used a trait to abstract the implementation. My goal is to make it easy to add new providers later on.
Here is how I defined the trait:
pub trait Provider {
/// Generates commit messages based on the given prompt and context
fn generate<C>(&self, prompt: &str, context: C) -> Result<Vec<String>>
where
C: envfmt::Context;
}
You might notice I’m using my own crate here called envfmt
. It’s a simple
crate that helps me expand variables in a template string.
I’m a bit biased, but I think envfmt
is great because I can pass any object as
context as long as it implements the envfmt::Context
trait. This makes it easy
to transform the git-gen
context into something that can fill out the prompt.
Implementing the Gemini Provider
Next, I implemented the first provider for Gemini. This was my first time using
the Gemini API, and I chose ureq
for the HTTP client.
Here is the implementation:
impl Provider for Gemini {
fn generate<C>(&self, template: &str, context: C) -> Result<Vec<String>>
where
C: envfmt::Context,
{
let api_key = env::var("GEMINI_API_KEY").map_err(|e| {
error!(
"failed to read GEMINI_API_KEY",
source: e,
help: "please make sure GEMINI_API_KEY is defined"
)
})?;
let prompt = envfmt::format_with(template, &context).map_err(|e| {
error!(
"failed to generate prompt from template and context",
source: e
)
})?;
let payload = Request {
contents: vec![Content {
parts: vec![Part { text: prompt }],
}],
};
// Try up to 5 times
const MAX_RETRIES: u32 = 5;
let mut attempts = 0;
let response = loop {
attempts += 1;
let result = ureq::post(&self.url)
.header("X-goog-api-key", &api_key)
.send_json(&payload);
match result {
Ok(res) => {
break res;
}
// 404 is a permanent error, no point in retrying
Err(ureq::Error::StatusCode(404)) => {
bail!(
"unknown model: {}", self.model,
help: "review your commitgen.toml and make sure its valid model name"
);
}
// A 5xx error is a server-side issue, so we can retry
Err(ureq::Error::StatusCode(code))
if (500..=599).contains(&code) =>
{
if attempts >= MAX_RETRIES {
bail!(
"Server error after {} attempts with status code: {}",
attempts,
code
);
}
// Calculate wait time with exponential backoff + jitter
let backoff_secs = 2u64.pow(attempts);
let jitter_ms = rand::random::<u16>() % 1000;
let wait_time = Duration::from_secs(backoff_secs)
+ Duration::from_millis(jitter_ms as u64);
eprintln!(
"Server error ({}), retrying in {:?} (attempt {}/{})",
code, wait_time, attempts, MAX_RETRIES
);
thread::sleep(wait_time);
}
// Any other error is treated as permanent
Err(err) => {
bail!(
"unexpected error when request model: {}", self.model,
source: err,
help: "review your commitgen.toml and make sure its valid model name"
);
}
}
};
let data =
response.into_json::<Response>().map_err(|err| {
error!("failed to deserialize Gemini API response",
source: err
)
})?;
let commits: Vec<String> = data
.candidates
.iter()
.flat_map(|c| &c.content.parts)
.flat_map(|p| p.text.split("\n---\n"))
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
Ok(commits)
}
}
As you might have noticed, the code is a bit messy because I added some duct
tape for a retry mechanism. I needed this because my tests run in parallel, and
the Gemini API has a strict rate limit that often returns a HTTP 503
error.
It’s safe to retry these requests, so I added the exponential backoff logic.
I’m already planning to extract this into a new ureq-retry
crate. I’ve written
the initial design, and I think it will be a much cleaner and more ergonomic
solution. I’ll publish it once it’s ready.
What’s Next?
The proof of concept is working, but there’s a lot more I want to do to polish
the git-commitgen
. Here’s my plan for the next iteration.
First, I’m going to rename the project from git-commitgen
to git-gen
. Typing
git commitgen
feels a bit long. git gen
is shorter and easier to remember. I
also think having a context file named GITGEN.md
in a repo would look pretty
cool, similar to CLAUDE.md
.
Next, I want to refactor some code to avoid .clone()
. For example, when
creating a new provider, I’m currently cloning the model string:
let provider = match &config.provider {
config::Provider::Gemini => Gemini::new(config.model.clone()),
_ => bail!("provider not implemented yet"),
};
While cloning a string isn’t expensive, it’s not ideal. The Gemini
struct is
designed to own the model string, which is correct. I should be able to move the
value directly instead of cloning it. This is a small thing, but thinking about
Rust’s ownership rules helps me write better code.
Then, I’ll focus on improving the user experience. I plan to use indicatif
to
show a loading spinner while generating commit suggestions. For picking a
suggestion, I’ll use dialoguer
to create an interactive selection menu.
Finally, I’ll implement the core features. The main interactive workflow will look like this:
- The user stages their changes with
git add
. - They run
git gen
orgit gen -m "draft message"
. - They see a list of 5 commit suggestions.
- They can press
r
to regenerate,e
to edit a suggestion, arrow keys to select,enter
to commit, andq
to quit.
I’ll also add a few helper commands:
git gen init
to create theGITGEN.md
context file through an interactive setup.git gen prompt
to print the final prompt without sending it to the API, which will be useful for debugging.git gen undo
to revert the last commit made by the tool.