git-gen Dev Log #1

Cover for 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.

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.

build.rs
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:

commitgen.toml
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:

src/providers/mod.rs
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:

src/providers/gemini.rs
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:

src/main.rs
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 or git 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, and q to quit.

I’ll also add a few helper commands:

  • git gen init to create the GITGEN.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.

Topics