git-gen Dev Log #1
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 genorgit gen -m "draft message". - They see a list of 5 commit suggestions.
- They can press
rto regenerate,eto edit a suggestion, arrow keys to select,enterto commit, andqto quit.
I’ll also add a few helper commands:
git gen initto create theGITGEN.mdcontext file through an interactive setup.git gen promptto print the final prompt without sending it to the API, which will be useful for debugging.git gen undoto revert the last commit made by the tool.
| Tags | rust , dev-log , git-gen |
|---|