Skip to content

Auto Commit

Source attribution: The git integration behavior described in this page is drawn from the official Aider documentation at aider.chat/docs/git.html. Implementation details are traced from references/aider/aider/repo.py and references/aider/aider/coders/base_coder.py at commit b9050e1d5faf8096eae7a46a9ecc05a86231384b.

When an AI agent edits files, those edits must land somewhere in the project’s version history. The naive approach — let the user commit manually after reviewing — sounds safe but breaks down in practice. Sessions generate dozens of incremental changes. If the user forgets to commit and then runs /undo, or the agent crashes mid-session, the edit history is gone. Worse, without discrete commits, the diff between “before AI assistance” and “after” is a single incomprehensible blob.

Auto-commit solves this by committing every accepted AI edit immediately after it’s applied. Each commit:

  1. Captures a precise snapshot of the AI’s contribution for future undo
  2. Generates a human-readable message describing what changed (not just “aider edit”)
  3. Attributes the change to the AI so reviewers know which commits were AI-authored
  4. Keeps user’s own uncommitted changes separate from AI changes

The hard parts are:

  • Dirty repo handling: The user may have staged or unstaged changes before asking the agent anything. Blindly committing will either commit the user’s in-progress work under an AI message or produce a confusing mixed commit.
  • Message quality: The commit message must be descriptive enough to be useful in git log without being so long it obscures the diff. Generating this from a raw diff requires a capable model and a well-crafted prompt.
  • Attribution: Team projects need to know which commits are AI-generated. Naive approaches (just commit as the current user) make it impossible to audit AI involvement post-hoc.
  • Undo safety: Auto-committed changes should be reversible with a single in-chat command. This requires tracking which commits the agent made in the current session.

Reference: references/aider/aider/repo.py, references/aider/aider/coders/base_coder.py, references/aider/aider/commands.py | Commit: b9050e1d5faf8096eae7a46a9ecc05a86231384b

After Aider applies edits to the filesystem, base_coder.py’s auto_commit() method fires immediately:

def auto_commit(self, edited, context=None):
if not self.repo or not self.auto_commits or self.dry_run:
return
res = self.repo.commit(fnames=edited, context=context, aider_edits=True, coder=self)
if res:
self.show_auto_commit_outcome(res)

The aider_edits=True flag is the key. It tells repo.commit() that this is an AI-generated change, which triggers the attribution logic. The context parameter passes recent chat history to the commit message generator so the message can reflect what the user asked for, not just what bytes changed.

On success, show_auto_commit_outcome() records the commit hash in two places:

def show_auto_commit_outcome(self, res):
commit_hash, commit_message = res
self.last_aider_commit_hash = commit_hash
self.aider_commit_hashes.add(commit_hash) # session-wide set for /undo tracking
self.last_aider_commit_message = commit_message

aider_commit_hashes is a set that grows throughout the session. The /undo command checks this set before resetting HEAD — if the last commit’s hash is not in the set, Aider refuses to undo it (it’s a user commit, not an AI commit).

The message is generated by repo.get_commit_message() in repo.py:

def get_commit_message(self, diffs, context, user_language=None):

The system prompt (from aider/prompts.py) instructs a “weak” model to produce a Conventional Commits-style one-liner:

You are an expert software engineer that generates concise, one-line Git commit messages
based on the provided diffs.
Review the provided context and diffs which are about to be committed to a git repo.
Generate a one-line commit message for those changes.
The commit message should be structured as follows: <type>: <description>
Use these for <type>: fix, feat, build, chore, ci, docs, style, refactor, perf, test
Ensure the commit message:
- Starts with the appropriate prefix.
- Is in the imperative mood (e.g., "add feature" not "added feature" or "adding feature").
- Does not exceed 72 characters.
Reply only with the one-line commit message, without any additional text, explanations, or line breaks.

Aider uses a “weak” model for this — typically gpt-3.5-turbo or a comparable cheap model — because the task is mechanical. There’s no need to burn Claude 3.5 Sonnet credits to generate a commit message. The implementation tries models in sequence until one succeeds within its token limit:

# From repo.py get_commit_message():
for model in commit_message_models:
try:
commit_message = model.simple_send_with_retries(messages, max_tokens=25)
if commit_message:
return commit_message.strip().strip('"').strip()
except Exception:
continue

The strip('"') handles the common case where models wrap their one-liner in quotation marks despite the instruction not to.

The diff preparation deserves attention. For repos with prior commits, Aider uses git diff HEAD rather than git diff --cached. This means the diff includes unstaged changes too — which is intentional, since the files were just written by the edit applier and may not be staged yet:

def get_diffs(self, fnames=None):
if not self.repo.head.is_valid():
# Empty repo: combine staged + unstaged
diffs = self.repo.git.diff("--cached")
diffs += self.repo.git.diff()
else:
diffs = self.repo.git.diff("HEAD", "--", *fnames)

New files (not yet tracked by git) are prefixed with "Added {fname}\n" in the diff, so the commit message generator understands a file was created from scratch rather than modified.

The user often has uncommitted work in their repo when they start an Aider session. Blindly committing after Aider’s edits would mix that uncommitted work into the AI commit. Aider’s solution is to commit dirty files before editing them:

def check_for_dirty_commit(self, path):
if not self.repo or not self.dirty_commits:
return
if not self.repo.is_dirty(path):
return
self.need_commit_before_edits.add(path)

This runs whenever Aider is about to edit a file. Files marked dirty accumulate in need_commit_before_edits. Before applying edits, dirty_commit() commits them:

def dirty_commit(self):
if not self.need_commit_before_edits:
return
self.repo.commit(fnames=self.need_commit_before_edits, coder=self)
# Note: aider_edits is NOT passed — this is a user-changes commit

The user’s in-progress work now lands in its own commit with an auto-generated message describing the user’s changes, and the AI’s edit will land in a separate subsequent commit. The resulting history is clean: user work, then AI work, clearly separated.

dirty_commits is forced to False when auto_commits=False — you can’t have pre-commit separation without post-commit as well:

if not auto_commits:
dirty_commits = False

Aider writes its authorship into git metadata using environment variables that git respects during commit:

@contextlib.contextmanager
def set_git_env(var_name, value, original_value):
os.environ[var_name] = value
try:
yield
finally:
# restore original value

The attribution logic depends on a set of constructor flags in GitRepo:

FlagDefaultEffect
attribute_authorTrueAppend (aider) to git author name
attribute_committerTrueAppend (aider) to git committer name
attribute_commit_message_authorFalsePrepend aider: to commit messages
attribute_commit_message_committerFalseAppend committer attribution to message
attribute_co_authored_byFalseAdd Co-authored-by: trailer to message

For a typical session with defaults, the commit shows:

Author: Alice <alice@example.com> (aider)
Commit: Alice <alice@example.com> (aider)
feat: add input validation to login form

When attribute_co_authored_by=True, Aider instead adds a Co-authored-by trailer and does not modify the author/committer names (unless the corresponding flags are explicitly set):

Author: Alice <alice@example.com>
Commit: Alice <alice@example.com>
feat: add input validation to login form
Co-authored-by: aider (claude-3-5-sonnet-20241022) <aider@aider.chat>

The model name is extracted from coder.main_model.name, so the trailer identifies exactly which model generated the change.

Aider exposes git operations as slash commands in the REPL, all handled in commands.py:

/commit — Commit staged user changes with an optional message:

def raw_cmd_commit(self, args=None):
commit_message = args.strip() if args else None
self.coder.repo.commit(message=commit_message, coder=self.coder)
# aider_edits not passed → user commit, no (aider) attribution

/undo — Revert the last Aider commit. Safety checks before resetting:

  1. Verify the HEAD commit hash is in aider_commit_hashes (won’t undo user commits)
  2. Verify the files have no uncommitted changes that would be lost
  3. Verify the commit hasn’t been pushed to a remote (warns if it has)

The actual undo is a targeted file restore + soft reset:

self.coder.repo.repo.git.checkout("HEAD~1", file_path) # restore each file
self.coder.repo.repo.git.reset("--soft", "HEAD~1") # rewind HEAD

/diff — Show changes since the last message boundary. Aider tracks a commit_before_message stack; each new user message pushes the current HEAD onto it. /diff compares the second-to-last entry against current HEAD to show exactly what the agent changed in response to the last request.

Aider respects pre-commit hooks by default. If --no-git-commit-verify is set, it passes --no-verify to skip hooks:

if not self.git_commit_verify:
cmd.append("--no-verify")

This is useful in repos where pre-commit hooks run linters or tests, and the user wants Aider to commit even when linters haven’t been satisfied yet.


Reference: references/codex/codex-rs/core/src/ | Commit: 4ab44e2c5cc54ed47e47a6729dfd8aa5a3dc2476

Codex does not have an auto-commit system. File edits are applied directly to the working tree without any automatic git staging or committing. The rationale is that Codex’s primary interface is a sandboxed environment where the agent applies a batch of changes and then presents a diff for user review before any commit happens.

This “show-before-commit” model avoids the dirty-commit complexity entirely: the user sees the diff, decides to accept, and then commits manually (or using Codex’s explicit commit tool). It sacrifices the granular per-change commit history that Aider produces in exchange for a cleaner user approval flow.

Codex does interact with git for reading project context — it detects the git root for AGENTS.md discovery and respects .gitignore for file search. But writes go to disk, not to git history.


Reference: references/opencode/packages/opencode/src/session/ | Commit: 7ed449974864361bad2c1f1405769fd2c2fcdf42

OpenCode also does not auto-commit. File edits from the edit tool are written to disk immediately and tracked in the VFS layer for undo purposes, but they are not committed to git. The session log provides an in-memory undo history (revert to the previous VFS snapshot) that doesn’t require git at all.

This makes OpenCode more portable to non-git environments (a user editing files in a plain directory rather than a repo) but means there’s no persistent undo history across sessions — closing the session discards the in-memory VFS state.

OpenCode does integrate with git for display purposes: it uses git diff output to render colorized diffs in the TUI after each edit, and it can stage files for review. But the commit decision is always left to the user.


Commit Message Models Must Be Fast and Cheap

Section titled “Commit Message Models Must Be Fast and Cheap”

Using the primary model to generate commit messages is wasteful. Generating “feat: add login validation” requires a 2,000-token diff as input and produces 10 tokens of output. GPT-4 charges per token; calling it for every commit doubles the cost of the session for no quality gain. Dedicate a cheap model to this task. If the cheap model hits its context limit (some very large diffs exceed 4k tokens), truncate the diff rather than upgrading models.

Auto-formatters that run as file-save hooks (Prettier, Black) can modify a file between the agent writing it and the commit capturing it. If Aider writes file.py, the save hook reformats it, and then the commit captures the reformatted version, the commit diff is now “agent edit + formatter cleanup” mixed together. The solution is to either stage the file immediately after writing (before any hooks can fire) or disable save hooks during AI edit sessions.

The “Aider Commit” Attribution is Not Git-Signed

Section titled “The “Aider Commit” Attribution is Not Git-Signed”

Appending (aider) to the author name is metadata only — it’s not cryptographically signed and can be faked by any user with repo access. For security-critical attribution (compliance, audit trails), the proper mechanism is GPG signing, but this requires the user to have a GPG key configured. Aider doesn’t attempt to GPG-sign AI commits; it’s an open issue. OpenOxide should document this limitation explicitly rather than implying the attribution is tamper-proof.

If the user pushes between Aider turns, the last AI commit is on the remote. Aider warns about this but still allows the undo locally. This creates a diverged history that requires a force push to resolve — which is destructive on shared branches. The safest behavior is to refuse undo after a push and tell the user to revert instead.

If the LLM produces an edit block that resolves to a no-op (the search text equals the replace text, or the “change” is only whitespace that the formatter already normalized), Aider may attempt to commit an empty diff. GitPython raises git.exc.GitCommandError: nothing to commit in this case. The commit call must wrap this case and silently skip the commit rather than surfacing the error to the user.


OpenOxide implements auto-commit as an opt-in mode, enabled by default for interactive sessions and disabled for batch/CI use. The implementation lives in openoxide-git.

pub struct GitContext {
pub repo: git2::Repository,
pub config: CommitConfig,
pub session_commits: HashSet<git2::Oid>, // for undo tracking
}
pub struct CommitConfig {
pub auto_commits: bool,
pub dirty_commits: bool,
pub attribution: AttributionConfig,
pub commit_verify: bool, // whether to run pre-commit hooks
}
pub struct AttributionConfig {
pub append_to_author: bool, // default true
pub append_to_committer: bool, // default true
pub co_authored_by: bool, // default false
pub message_prefix: bool, // prepend "openoxide: "
}

Commit messages are generated via a dedicated WeakModel call — a separate ModelHandle configured with a small, fast, cheap model:

pub async fn generate_commit_message(
model: &WeakModel,
diff: &str,
context: &str,
) -> anyhow::Result<String> {
let prompt = format!(
"Generate a conventional commit message (max 72 chars) for this diff:\n\n{diff}\n\nContext: {context}"
);
let msg = model.complete_simple(&prompt).await?;
Ok(msg.trim().trim_matches('"').to_string())
}

The WeakModel uses gpt-4o-mini or claude-haiku by default. If the diff exceeds 4k tokens, it is truncated to the first and last 1k tokens with a ... [truncated] ... separator.

pub async fn pre_edit_check(ctx: &GitContext, path: &Path) -> anyhow::Result<()> {
if !ctx.config.dirty_commits {
return Ok(());
}
if is_dirty(&ctx.repo, path)? {
commit_path(ctx, path, CommitKind::UserDirty).await?;
}
Ok(())
}

CommitKind::UserDirty generates a commit message from the diff without the AI attribution markers.

Attribution is set on the Signature passed to git2::Repository::commit():

pub fn make_signature(
config: &AttributionConfig,
model_name: &str,
base_name: &str,
base_email: &str,
kind: CommitKind,
) -> git2::Signature<'static> {
let name = if config.append_to_author && kind == CommitKind::AiderEdit {
format!("{base_name} (openoxide)")
} else {
base_name.to_string()
};
git2::Signature::now(&name, base_email).unwrap()
}

The Co-authored-by: trailer is appended to the commit message string before passing it to git2::Repository::commit().

Undo is implemented using git2::Repository::reset() with ResetType::Soft:

pub fn undo_last_ai_commit(ctx: &mut GitContext) -> anyhow::Result<()> {
let head = ctx.repo.head()?.peel_to_commit()?;
if !ctx.session_commits.contains(&head.id()) {
bail!("Last commit was not made by OpenOxide — refusing to undo");
}
let parent = head.parent(0)?;
ctx.repo.reset(parent.as_object(), git2::ResetType::Soft, None)?;
ctx.session_commits.remove(&head.id());
Ok(())
}

The session commit set is stored in memory only — it resets when OpenOxide exits. Cross-session undo requires reading the commit author metadata to identify OpenOxide commits, which is a future enhancement.