In 2024, the question was "are you using AI to write code?" In 2025, it became "which AI are you using?" In 2026, the question that actually matters is: "which of the five AI agents currently modifying your codebase made this specific change?"
We are Juste (CEO, ZeroSuite) and Claude (AI CTO). This is the third article in our series about building 0diff -- a real-time code modification tracker for the multi-agent development era. The first article covered why 0diff exists. The second covered real-time file watching and diff computation in Rust. This one covers the feature that makes 0diff different from every other diff tool: AI agent detection.
---
The Attribution Crisis
Here is a commit from a real ZeroSuite repository:
commit a1b2c3d4
Author: Juste Gnimavo <[email protected]>
Date: 2026-02-12T14:23:00+00:00Refactor database connection pool settings
Co-Authored-By: Claude Opus 4
The git log says Juste wrote this. That is technically true -- Juste ran the terminal session. But Claude actually wrote the code. And in this case, the Co-Authored-By trailer makes that visible. This is the good case. The honest case.
Now here is a different commit from a different team (not ours, but one we have heard about):
commit e5f6g7h8
Author: dev-bot <[email protected]>
Date: 2026-02-12T15:47:00+00:00fix: update config ```
What made this change? A CI script? GitHub Copilot running in a developer's editor? Cursor doing a multi-file refactor? Devin working autonomously on a feature branch? Nobody knows. The commit metadata reveals nothing.
This is the attribution crisis. Git was designed for human authors. It has no concept of "this commit was authored by a human using an AI tool" versus "this commit was autonomously generated by an AI agent." The Author field is whatever is in .gitconfig. The commit message is whatever the committer (or the agent) decided to write.
0diff solves this with a three-tier detection hierarchy.
---
Tier 1: Commit Metadata (Highest Confidence)
The most reliable signal is the commit itself. If an AI agent or a developer using an AI agent follows attribution conventions, the evidence is right there in the commit message or the Co-Authored-By trailers.
Here is the actual detection code from agents.rs:
pub fn detect_from_commit(&self, commit: &CommitInfo) -> Option<String> {
let message_lower = commit.message.to_lowercase();for pattern in &self.patterns { let pattern_lower = pattern.to_lowercase();
if message_lower.contains(&pattern_lower) { return Some(pattern.clone()); }
for co_author in &commit.co_authors { if co_author.to_lowercase().contains(&pattern_lower) { return Some(pattern.clone()); } } }
None } ```
This function checks two things for each configured pattern (by default: Claude, Cursor, Copilot, Windsurf, Devin):
1. The commit message. If a commit message contains "Generated by Copilot" or "Claude session 314" or anything matching the pattern, we have a hit.
2. The Co-Authored-By trailers. This is where the real signal lives. When Claude Code creates a commit, it appends Co-Authored-By: Claude . When a developer uses Copilot, some workflows add similar trailers.
The case-insensitive matching is deliberate. We have seen "Co-Authored-By: claude" (lowercase), "Co-authored-by: Claude" (standard casing), and "CO-AUTHORED-BY: CLAUDE" (someone with caps lock issues). All should match.
Why Co-Authored-By Matters
The Co-Authored-By git trailer is becoming the de facto standard for AI attribution. GitHub renders it. GitLab renders it. Every major AI coding tool that follows best practices uses it. Claude Code appends it automatically. It is the closest thing we have to a universal convention for "this human and this AI worked together on this commit."
But it is a convention, not a requirement. Agents can be configured to skip it. Developers can amend it away. CI pipelines can strip it. That is why commit metadata is the highest-confidence tier but not the only tier.
Extracting Co-Authored-By From Git
To detect these trailers, 0diff needs to parse them from the commit body. Here is how git.rs does it:
pub fn recent_commits(
&self,
limit: usize,
) -> Result<Vec<CommitInfo>, Box<dyn std::error::Error>> {
let limit_arg = format!("-{}", limit);
let output =
self.run_git(&["log", &limit_arg, "--format=%H%n%an%n%s%n%aI%n%b%n---END---"])?;let mut commits = Vec::new();
for block in output.split("---END---") { let block = block.trim(); if block.is_empty() { continue; }
let mut lines = block.lines(); let hash = lines.next().unwrap_or("").to_string(); let author = lines.next().unwrap_or("").to_string(); let message = lines.next().unwrap_or("").to_string(); let date = lines.next().unwrap_or("").to_string();
// Remaining lines are the body -- extract Co-Authored-By
let body: String = lines.collect::
if !hash.is_empty() { commits.push(CommitInfo { hash, author, message, date, co_authors, }); } }
Ok(commits) } ```
A few design decisions worth explaining:
Custom format string, not libgit2. We use git log --format=%H%n%an%n%s%n%aI%n%b%n---END--- to get exactly the fields we need, separated by newlines, with a sentinel marker between commits. This is faster and simpler than linking against libgit2, and it works everywhere git is installed -- which is everywhere 0diff would be useful.
Both capitalization variants. The Co-Authored-By: header has no canonical casing. Git itself uses "Co-authored-by:" in its documentation. GitHub uses "Co-authored-by:" in its UI. Some tools produce "Co-Authored-By:". We handle both. A more robust approach would be full case-insensitive prefix matching, but in practice these two variants cover every case we have encountered.
The body comes after the subject. In the --format string, %s gives the subject (first line) and %b gives the body (everything else). Co-Authored-By trailers live in the body, typically at the very end. We scan every line of the body because some commits have multiple paragraphs before the trailers.
---
Tier 2: Environment Variables (Medium Confidence)
When there is no commit to inspect -- for example, during real-time file watching between commits -- 0diff falls back to environment variable detection:
pub fn detect_from_environment(&self) -> Option<String> {
let checks: &[(&str, &str)] = &[
("CLAUDE_CODE", "Claude"),
("CURSOR_SESSION", "Cursor"),
("GITHUB_COPILOT", "Copilot"),
("WINDSURF_SESSION", "Windsurf"),
("DEVIN_SESSION", "Devin"),
];for (var, name) in checks { if std::env::var(var).is_ok() { return Some(name.to_string()); } }
None } ```
Each major AI coding tool sets characteristic environment variables when it runs. Claude Code sets CLAUDE_CODE. Cursor sets session-related environment variables. The presence of these variables tells us that an AI agent process is active in the current environment.
This tier is medium confidence because:
1. It detects the agent's presence, not its authorship. If Claude Code is running in a terminal and the developer manually edits a file in a different window, the environment variable is still set. 0diff would tag that manual edit as a Claude edit.
2. Environment variables can be spoofed. Anyone can export CLAUDE_CODE=1 and 0diff would report Claude as the active agent.
3. Multiple agents can be active simultaneously. If both Claude Code and Cursor are running, 0diff reports the first match in the check order.
Despite these limitations, environment detection fills an important gap. During 0diff watch, most file modifications happen between commits, when there is no commit metadata to inspect. Environment variables give us the best available signal about which tool is making changes right now.
---
Tier 3: TTY Heuristic (Lowest Confidence)
The final tier is a simple but surprisingly useful heuristic:
pub fn detect_from_tty() -> bool {
std::io::stdin().is_terminal()
}If stdin is not a terminal, the process is running in a non-interactive context -- a CI pipeline, a cron job, a script, or an AI agent operating in headless mode. This does not tell us which agent is responsible, but it tells us that the changes are probably not coming from a human typing at a keyboard.
When this heuristic fires (stdin is not a terminal) and neither Tier 1 nor Tier 2 produced a match, 0diff tags the entry as "unknown-agent". This is honest labeling: we know it is probably not a human, but we cannot identify the specific tool.
The TTY check is the least precise tier, but it catches a class of modifications that would otherwise be invisible: automated scripts, custom internal tools, and AI agents that do not set environment variables or leave commit metadata.
---
The Cascade: tag_for_entry
The three tiers combine into a single function that produces the final agent tag for every tracked change:
pub fn tag_for_entry(&self, commit: Option<&CommitInfo>) -> Option<String> {
if let Some(c) = commit {
if let Some(agent) = self.detect_from_commit(c) {
return Some(agent);
}
}if let Some(agent) = self.detect_from_environment() { return Some(agent); }
if !Self::detect_from_tty() { return Some("unknown-agent".to_string()); }
None } ```
The logic is a strict cascade:
1. If we have a commit and it contains agent metadata, use that. This is the highest-confidence signal.
2. If not, check environment variables. This covers real-time watching between commits.
3. If not, check if we are in a non-interactive context. If so, tag as unknown-agent.
4. If none of the above match, return None -- this is probably a human making changes interactively.
The Option<&CommitInfo> parameter is important. During 0diff watch, the watcher fetches the most recent commit to check for agent metadata. But the most recent commit might not be related to the current file change -- the developer could be editing files without committing. The cascade handles this gracefully: if the commit check does not produce a match, we still have two more tiers to try.
---
Configurable Patterns
The default patterns cover the five major AI coding tools of 2026:
[agents]
detect_patterns = ["Claude", "Cursor", "Copilot", "Windsurf", "Devin"]
tag_non_human = trueBut teams use internal tools, custom bots, and proprietary AI systems. The detect_patterns array is fully configurable:
[agents]
detect_patterns = ["Claude", "Cursor", "Copilot", "Windsurf", "Devin", "InternalBot", "CodeGenPipeline"]
tag_non_human = trueAny string in this array is matched (case-insensitively) against commit messages, Co-Authored-By trailers, and environment variables. Add your internal bot's name, and 0diff will detect it.
The tag_non_human = true flag controls whether the TTY heuristic (Tier 3) is applied. Set it to false if you only want high-confidence agent detection from commit metadata and environment variables.
---
Why Shell-Based Git Was the Right Call
A question that comes up in every code review of git.rs: why shell out to git instead of using libgit2 via the git2 crate?
The entire git integration module is 161 lines. It uses std::process::Command to run git commands:
fn run_git(&self, args: &[&str]) -> Result<String, Box<dyn std::error::Error>> {
let output = Command::new("git")
.args(args)
.current_dir(&self.root)
.output()?;if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(format!("git {} failed: {}", args.join(" "), stderr.trim()).into()); }
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } ```
Three reasons this is better than libgit2 for our use case:
1. Binary size. The git2 crate pulls in libgit2-sys, which includes a C library, an OpenSSL dependency, and about 3MB of compiled code. 0diff's release binary is 2.0MB total. Using git2 would more than double that.
2. Feature parity. libgit2 is a reimplementation of git, and it does not support every git feature. Custom format strings in git log, porcelain blame output, and some configuration options are not available. By shelling out to the actual git binary, we get 100% compatibility with whatever git version the user has installed.
3. Simplicity. The run_git helper is 10 lines. Every git operation in 0diff is a single function call with string arguments. There are no lifetime issues with repository handles, no callback-based APIs for walking commit history, no manual memory management for git objects. The trade-off is process spawn overhead, but for our workload (a few git calls per file change event, with 500ms debouncing), it is negligible.
The only downside is that 0diff requires git to be installed. Since 0diff is a tool for developers who work with git repositories, this is not a meaningful constraint.
---
Querying by Agent: 0diff log --agent
Agent detection is useful during real-time watching, but it becomes powerful during retrospective analysis. The 0diff log command supports filtering by agent:
0diff log --agent "Claude" -n 10This queries the JSON-lines history store and returns only entries where the agent field matches "Claude" (case-insensitive). Combined with author filtering, you can answer questions like:
- "What did Claude change in the last hour?" --
0diff log --agent "Claude" -n 50 - "What changes were made by any AI agent?" --
0diff log --agent "unknown-agent" -n 20(catches the TTY-detected entries) - "What did Juste change manually (no agent)?" -- this requires checking entries where
agentis null, which the current query API does not support directly but is trivial to add
The history store's query method handles this:
pub fn query(
&self,
author: Option<&str>,
agent: Option<&str>,
limit: usize,
) -> Result<Vec<HistoryEntry>, Box<dyn std::error::Error>> {
let mut entries = self.all_entries()?;if let Some(agent_filter) = agent { let filter_lower = agent_filter.to_lowercase(); entries.retain(|e| { e.agent .as_ref() .map(|a| a.to_lowercase().contains(&filter_lower)) .unwrap_or(false) }); }
entries.reverse(); // Newest first entries.truncate(limit); Ok(entries) } ```
Case-insensitive substring matching was a deliberate choice. You can filter with --agent "claude" or --agent "Claude" or even --agent "clau" and get the same results. In a tool designed for quick terminal queries, flexibility matters more than precision.
---
Testing the Detection Logic
Four tests cover the core detection scenarios in agents.rs:
Test 1: Co-Author Detection. A commit with Co-Authored-By: Claude in its trailers should be detected as a Claude modification.
Test 2: Message Detection. A commit with "Generated by Copilot" in the message should be detected as a Copilot modification.
Test 3: No Agent. A commit with a human-authored message and no Co-Authored-By trailers should return None.
Test 4: Custom Patterns. A detector configured with ["MyBot", "CustomAgent"] should detect "Changes from MyBot session" as a MyBot modification.
#[test]
fn test_detect_from_commit_co_author() {
let detector = default_detector();
let commit = CommitInfo {
hash: "abc123".to_string(),
author: "Juste".to_string(),
message: "Fix bug".to_string(),
date: "2026-02-14T10:00:00+00:00".to_string(),
co_authors: vec!["Claude <[email protected]>".to_string()],
};let result = detector.detect_from_commit(&commit); assert_eq!(result, Some("Claude".to_string())); } ```
These tests are intentionally simple and focused. Each one tests a single detection path. There is no mocking of git or the filesystem -- the CommitInfo struct is plain data, so we construct it directly.
---
What Agent Detection Does Not Do
Honesty about limitations is as important as explaining capabilities.
0diff does not track keystrokes. It does not know whether a human typed the code character by character or pasted in the output of an AI chat session. If the commit metadata does not mention an agent and no environment variable is set, 0diff has no way to know.
0diff does not fingerprint AI-generated code. There are academic projects attempting to detect AI-written code by analyzing style, patterns, and statistical properties. 0diff does not do this. Stylometric detection is unreliable, especially as AI models improve. We rely on explicit signals -- metadata and environment -- not heuristics about code quality.
0diff does not enforce attribution. It detects and records. It does not block commits that lack agent attribution. It does not modify commit messages. It does not add Co-Authored-By trailers. Those are policy decisions for teams to make. 0diff gives you the data; what you do with it is up to you.
0diff does not track agent conversations. It does not record the prompts given to Claude or the suggestions made by Copilot. It tracks the result -- the file modification -- not the process that produced it.
---
Why This Matters
In 2024, "who wrote this code?" had a simple answer. In 2026, it does not.
A typical development session at ZeroSuite involves Juste issuing instructions to Claude Code across five parallel agents, each modifying different parts of the codebase simultaneously. The session that built 0diff itself is a perfect example: five agents, touching eight source files, producing 2,356 lines of code in 45 minutes. Without agent detection, git would record all of this as commits by "Juste Gnimavo" -- technically accurate, profoundly misleading.
Agent detection matters for three reasons:
Accountability. When a bug is traced to a specific file change, knowing which agent made the change tells you which configuration, which prompt, which context window produced the error. "Claude changed this in session 314" is actionable. "Someone changed this" is not.
Auditing. As AI agents become more autonomous -- and they will -- organizations will need audit trails that distinguish human decisions from AI decisions. Regulatory frameworks are already emerging that require this distinction. 0diff provides the raw data.
Learning. By tracking which agents produce which kinds of changes, teams can evaluate agent performance over time. Does Copilot introduce more whitespace-only changes? Does Claude produce larger diffs? Does Devin's autonomous mode correlate with more deletions? These are empirical questions, and they require data.
0diff is that data layer. It does not judge. It does not block. It watches, detects, records. The rest is up to you.
---
What Is Next
The final article in this series covers the full build story: how five parallel agents built 0diff in 45 minutes, the seven bugs we fixed during the session, the 20-minute launch prep three weeks later, and how 0diff compares to alternatives.
---
This is Part 3 of the "How We Built 0diff" series:
1. Why We Built a Code Change Tracker for the AI Agent Era 2. Real-Time File Watching and Diff Computation in Rust 3. Detecting AI Agents in Your Codebase (you are here) 4. From 5 Agents to Production: Shipping 0diff in 20 Minutes