Skip to content

Status & Tracking

AI coding agents need to know the current state of the repository at multiple points: before committing (are there dirty files?), before creating snapshots (what is tracked vs untracked?), during the agent loop (has the user changed anything externally?), and in the TUI (show live repo status). The core problem is parsing git status output reliably, handling all the edge cases (renames, unmerged files, ignored paths, large untracked directories), and deciding between polling and watching for changes. Codex has the most sophisticated implementation with full porcelain v2 parsing; Aider delegates to GitPython; OpenCode combines shell-based porcelain v1 with a real-time file watcher.

Aider uses GitPython for all status operations, with a few raw git commands for edge cases.

Before examining individual status functions, it is important to understand Aider’s error handling. The module-level ANY_GIT_ERROR tuple (repo.py:9-36) catches 15+ exception types:

ANY_GIT_ERROR = [
git.exc.ODBError, # Object database errors
git.exc.GitError, # Generic git errors
git.exc.InvalidGitRepositoryError,
git.exc.GitCommandNotFound,
OSError, # File I/O failures
IndexError, # Collection access
BufferError, # Memory buffer issues
TypeError, # Type mismatches (detached HEAD)
ValueError, # Invalid values
AttributeError, # Missing attributes
AssertionError, # Assertion failures
TimeoutError, # Command timeouts
]
ANY_GIT_ERROR = tuple(ANY_GIT_ERROR)

This broad catch is used throughout repo.py to handle the unpredictable ways git operations can fail. The inclusion of TypeError is specifically for detached HEAD states where self.repo.active_branch raises TypeError. The inclusion of system-level exceptions (OSError, BufferError) handles I/O failures on corrupt repositories or permission-denied scenarios.

is_dirty() (repo.py:597-601) is a thin wrapper around GitPython:

def is_dirty(self, path=None):
return self.repo.is_dirty(path=path)

GitPython’s is_dirty() runs git status --porcelain internally and checks whether the output is non-empty. Aider calls this in the commit flow (repo.py:201) to skip commits when there are no changes: if not fnames and not self.repo.is_dirty(): return.

get_dirty_files() (repo.py:580-595) lists files with uncommitted changes:

def get_dirty_files(self):
dirty_files = set()
# Staged changes
for item in self.repo.index.diff("HEAD"):
dirty_files.add(item.a_path)
# Unstaged changes
for item in self.repo.index.diff(None):
dirty_files.add(item.a_path)
# Untracked files
for path in self.repo.untracked_files:
dirty_files.add(path)
return dirty_files

This uses three separate GitPython calls: index.diff("HEAD") for staged changes (runs git diff-index HEAD), index.diff(None) for unstaged changes (runs git diff-files), and the untracked_files property for files not in the index (runs git ls-files --others --exclude-standard). Each call internally runs a separate git command, which is inefficient — a single git status --porcelain call could provide all three categories atomically.

get_tracked_files() (repo.py:450-497) is central to Aider’s operation — it determines which files the agent can see:

  1. Get HEAD commit: Retrieves the commit object, caching results per commit SHA to avoid repeated tree traversals.
  2. Tree traversal: Calls commit.tree.traverse() filtering for blob.type == "blob" (excludes subtrees/directories).
  3. Staged file addition: Adds files from index entries that may not be in the tree yet (newly staged files).
  4. Path normalization: Converts all paths to be relative to the repository root.

The subtree_only parameter restricts listing to a specific subdirectory, used when Aider operates on a subdirectory of a monorepo.

Aider does have a file watcher in watch.py (319 lines) that uses the watchfiles library:

def file_watcher_thread(self, stop_event):
for changes in watchfiles.watch(self.root, stop_event=stop_event):
for change_type, path in changes:
if self.filter_func(change_type, path):
self.changed_files.add(path)

The watcher runs in a daemon thread with a stop_event for clean shutdown. The filter_func checks several conditions:

  • Boundary validation: Path must be within the project root.
  • Gitignore patterns: Files matching .gitignore patterns are skipped.
  • Size limit: Files larger than 1 MB are skipped.
  • AI comment markers: Files containing specific AI-generated comment markers are flagged.

This watcher is used for the /watch command which re-runs the agent when files change, not for live TUI updates (Aider uses a prompt-based interface, not a persistent TUI).

  • No porcelain v2 parsing (relies on GitPython’s abstraction over porcelain v1).
  • No handling of large untracked directories.
  • No distinction between untracked files and ignored files in status queries.
  • No real-time repo state tracking for the TUI.
  • Three separate git commands for dirty file enumeration instead of one atomic git status call.

Codex has two layers of status tracking: a quick async check in git_info.rs for TUI display, and a comprehensive porcelain v2 parser in ghost_commits.rs for snapshot creation.

get_has_changes() (git_info.rs:153-160) provides a fast boolean answer:

pub async fn get_has_changes(cwd: &Path) -> Option<bool> {
let output = run_git_command_with_timeout(&["status", "--porcelain"], cwd).await?;
if !output.status.success() {
return None;
}
Some(!output.stdout.is_empty())
}

This uses --porcelain (v1 format) and simply checks whether the output is empty. It runs through the 5-second timeout wrapper (run_git_command_with_timeout() at git_info.rs:264-277), returning None on timeout or git failure. Used by the TUI to show a dirty indicator without parsing individual files.

The timeout wrapper itself is critical:

async fn run_git_command_with_timeout(
args: &[&str],
cwd: &Path,
) -> Option<std::process::Output> {
let mut command = Command::new("git");
command
.env("GIT_OPTIONAL_LOCKS", "0") // Prevent lock contention
.args(args)
.current_dir(cwd)
.kill_on_drop(true); // Kill process on timeout
let result = timeout(GIT_COMMAND_TIMEOUT, command.output()).await;
match result {
Ok(Ok(output)) => Some(output),
_ => None,
}
}

GIT_OPTIONAL_LOCKS=0 (git_info.rs:270) tells git not to acquire the index lock for read-only operations, reducing contention when multiple git commands run concurrently. kill_on_drop(true) ensures the child process is terminated if the timeout fires or the future is dropped.

The comprehensive status parser lives in capture_status_snapshot() inside ghost_commits.rs:500-669. This function is called during ghost commit creation to determine exactly which files need to be included in the snapshot.

Command construction (ghost_commits.rs:524-533):

let mut args = vec![
OsString::from("status"),
OsString::from("--porcelain=2"),
OsString::from("-z"),
OsString::from("--untracked-files=all"),
];

Key flags:

  • --porcelain=2: Version 2 format with structured fields (more information than v1).
  • -z: NUL-delimited output (handles filenames with spaces, newlines, and special characters).
  • --untracked-files=all: Show individual untracked files, not just directories containing untracked files.
  • Optional -- {prefix} for subdirectory-scoped snapshots.

Record type dispatch (ghost_commits.rs:554-621): The parser splits on NUL bytes and dispatches on the first byte of each record:

First byteRecord typeFields before pathHandling
?Untracked file1 (just the ? marker)Added to untracked.files after ignore/size filtering
!Ignored file1 (just the ! marker)Added to untracked.ignored
1Ordinary changed entry8 (XY, sub, mH, mI, mW, hH, hI, path)Path added to tracked_paths
2Renamed/copied entry9 (adds score field)Path added to tracked_paths; sets expect_rename_source = true for next record
uUnmerged entry10 (adds stage info)Path added to tracked_paths

Porcelain v2 field meanings for type 1 (ordinary) entries:

FieldPositionMeaning
XY1Two-character status: X = index status, Y = worktree status
sub2Submodule state (N... for non-submodule)
mH3Octal file mode in HEAD
mI4Octal file mode in index
mW5Octal file mode in worktree
hH6Object name (SHA) in HEAD
hI7Object name (SHA) in index
path8File path

For type 2 (rename/copy) entries, field 9 is the rename/copy score (e.g., R100 for 100% rename), and the original path follows as a separate NUL-delimited record.

Rename state machine: The expect_rename_source flag (ghost_commits.rs:542, 547-551, 612) handles porcelain v2’s rename format: type 2 records are followed by a separate NUL-delimited record containing the original path. Both paths (source and destination) are added to tracked_paths. The state machine:

  1. When a type 2 record is encountered, set expect_rename_source = true and record the destination path.
  2. The next NUL-delimited record is the original (source) path. Add it to tracked_paths.
  3. Reset expect_rename_source = false.

Path extraction helper: extract_status_path_after_fields() (ghost_commits.rs:690-699) is a zero-allocation parser that counts space-separated fields and returns everything after the Nth field as the path:

fn extract_status_path_after_fields(record: &str, n_fields: usize) -> Option<&str> {
let mut spaces_seen = 0;
for (i, byte) in record.as_bytes().iter().enumerate() {
if *byte == b' ' {
spaces_seen += 1;
if spaces_seen == n_fields {
return Some(&record[i + 1..]);
}
}
}
None
}

This iterates bytes looking for spaces, avoiding any string splitting or allocation. For type 1 records, n_fields = 8; for type 2, n_fields = 9; for type u, n_fields = 10.

The snapshot system filters untracked files aggressively to prevent snapshot bloat.

Hardcoded directory ignore list (ghost_commits.rs:35-48):

const DEFAULT_IGNORED_DIR_NAMES: &[&str] = &[
"node_modules", ".venv", "venv", "env", ".env",
"dist", "build", ".pytest_cache", ".mypy_cache",
".cache", ".tox", "__pycache__",
];

should_ignore_for_snapshot() checks whether any path component matches this list using component-level iteration — a file at src/node_modules/foo.js is ignored because node_modules appears as a component.

Large directory detection (ghost_commits.rs:624-665): After collecting all untracked files, detect_large_untracked_dirs() groups files by their deepest containing directory and flags directories exceeding DEFAULT_IGNORE_LARGE_UNTRACKED_DIRS (200 files). Files within flagged directories are moved from the active snapshot to a separate ignored_large_untracked_dir_files list. This prevents a single large directory (like an unignored vendor/ or target/) from bloating the snapshot tree.

Large file filtering: Files exceeding DEFAULT_IGNORE_LARGE_UNTRACKED_FILES (10 MiB) are excluded from the snapshot tree but tracked in ignored_untracked_files for reporting. The size is read via fs::metadata() with capping at i64::MAX for robustness. These files are still treated as untracked for undo purposes (they will not be deleted by undo), but they are not included in the git tree object.

pub struct StatusSnapshot {
pub tracked_paths: Vec<PathBuf>,
pub untracked: UntrackedSnapshot,
}
pub struct UntrackedSnapshot {
pub files: Vec<PathBuf>, // Untracked files to include in snapshot
pub dirs: Vec<PathBuf>, // Untracked directories
pub untracked_files_for_index: Vec<PathBuf>, // Files to add to the index
pub ignored_untracked_files: Vec<IgnoredUntrackedFile>, // Too large or in ignored dirs
pub ignored_large_untracked_dirs: Vec<LargeUntrackedDir>, // Dirs exceeding 200 files
pub ignored_large_untracked_dir_files: Vec<PathBuf>, // Files within large dirs
}

This rich structure lets the snapshot system make fine-grained decisions about what to include in ghost commits versus what to skip (but still track for undo purposes). The separation between files (included) and ignored_untracked_files (excluded but tracked) is critical for the undo system: when reverting a snapshot, untracked files that were excluded from the tree should not be deleted.

Codex does not watch the filesystem for changes. Status is queried on demand: before each turn (ghost commit creation), and in the TUI status line via get_has_changes(). The 5-second timeout prevents status queries from blocking the event loop on large repos. This is a deliberate trade-off: polling on demand is simpler and avoids the complexity of file watcher resource management across platforms.

OpenCode combines shell-based status queries with a real-time file watcher for live updates.

util/git.ts wraps git commands via Bun’s shell with a dual-mode execution pattern:

export async function git(
args: string[],
opts: { cwd: string; env?: Record<string, string> }
): Promise<GitResult> {
if (Flag.OPENCODE_CLIENT === "acp") {
// ACP client mode: Bun.spawn with stdin: "ignore"
const proc = Bun.spawn(["git", ...args], {
stdin: "ignore", // Prevent pipe buffer deadlock
stdout: "pipe",
stderr: "pipe",
cwd: opts.cwd,
})
// Concurrent buffer reads to avoid blocking
const [exitCode, stdout, stderr] = await Promise.all([
proc.exited,
new Response(proc.stdout).arrayBuffer(),
new Response(proc.stderr).arrayBuffer(),
])
return { exitCode, text: () => Buffer.from(stdout).toString(), stdout, stderr }
}
// Normal mode: Bun's $ shell
const result = await $`git ${args}`.quiet().nothrow().cwd(opts.cwd)
return { exitCode: result.exitCode, text: () => result.text(), stdout: result.stdout, stderr: result.stderr }
}

The GitResult type provides a unified interface:

export interface GitResult {
exitCode: number
text(): string | Promise<string>
stdout: Buffer | ReadableStream<Uint8Array>
stderr: Buffer | ReadableStream<Uint8Array>
}

The ACP client mode (stdin: "ignore" at line 24) prevents a Windows deadlock where inherited stdin blocks the child process. The concurrent Promise.all read of stdout and stderr (lines 30-34) prevents pipe buffer deadlock: if git writes enough to fill the stdout buffer while stderr is not being read, the process blocks.

Status operations use git status --porcelain=v1 format (not v2). The parsing is straightforward: split on newlines, first two characters are the status code, remaining is the path. OpenCode uses status primarily for determining which files have been modified (for the revert/undo system), checking if the working tree is clean before operations, and providing file status information to the TUI.

file/watcher.ts implements a real-time file change detection system using @parcel/watcher.

Platform backends (watcher.ts:52-56):

  • Windows: windows backend
  • macOS: fs-events backend
  • Linux: inotify backend

Event mapping (watcher.ts:66-73): Parcel watcher events are mapped to bus events:

const eventMap = {
create: "add",
update: "change",
delete: "unlink",
}

These are published via Bus.publish(Event.Updated, { file, event }).

Two subscription scopes (watcher.ts:78-111):

  1. Project directory (behind OPENCODE_EXPERIMENTAL_FILEWATCHER flag): Watches Instance.directory with ignore patterns from FileIgnore.PATTERNS plus user-configured watcher.ignore patterns. This scope is experimental because of the inotify limit risk on Linux.

  2. Git directory (watcher.ts:91-111): Always active. The implementation:

const vcsDir = await $`git rev-parse --git-dir`
.quiet().nothrow().cwd(Instance.worktree)
.text().then((x) => path.resolve(Instance.worktree, x.trim()))
const gitDirContents = await readdir(vcsDir).catch(() => [])
const ignoreList = gitDirContents.filter((entry) => entry !== "HEAD")
const sub = await withTimeout(
w.subscribe(vcsDir, subscribe, { ignore: ignoreList, backend }),
SUBSCRIBE_TIMEOUT_MS
)

This watches the .git directory but ignores everything except HEAD by building an ignore list from all directory entries except "HEAD". This selective watching enables branch change detection (see branch management) without triggering on every index update, reflog write, or git stash operation.

Timeout protection (watcher.ts:83-88): Subscription has a 10-second timeout (SUBSCRIBE_TIMEOUT_MS). If the watcher fails to initialize within the timeout, the pending subscription is cleaned up to prevent resource leaks — the promise is caught and the subscription (when it eventually resolves) is immediately unsubscribed.

Cleanup (watcher.ts:115-118): On state disposal, all subscriptions are unsubscribed via Promise.all.

project/vcs.ts builds on the file watcher to provide reactive VCS state:

const state = Instance.state(
async () => {
let current = await currentBranch()
const unsubscribe = Bus.subscribe(
FileWatcher.Event.Updated,
async (evt) => {
if (evt.properties.file.endsWith("HEAD")) return
const next = await currentBranch()
if (next !== current) {
current = next
Bus.publish(Event.BranchUpdated, { branch: next })
}
}
)
return { branch: async () => current, unsubscribe }
},
async (state) => { state.unsubscribe?.() },
)

It subscribes to FileWatcher.Event.Updated, filters for HEAD file changes, re-evaluates the current branch name via git rev-parse --abbrev-ref HEAD, and publishes Event.BranchUpdated when the branch changes. The Event.BranchUpdated event carries a Zod-validated schema: z.object({ branch: z.string().optional() }).

file/ignore.ts defines hardcoded ignore patterns used by the file watcher. These are split into two categories:

Directory patterns (29 items):

export const FOLDER_PATTERNS = [
"node_modules", "vendor", "dist", "build",
".git", ".svn", ".hg",
".vscode", ".idea", ".fleet",
".next", ".nuxt", ".cache", ".turbo",
".pytest_cache", "__pycache__",
".mypy_cache", ".ruff_cache",
"target", "out", ".output",
".terraform", ".serverless",
"bower_components", "jspm_packages",
".yarn", ".pnp",
"eggs", "*.egg-info",
]

File patterns (13 glob patterns):

export const FILE_PATTERNS = [
"*.swp", "*.swo", "*.pyc",
".DS_Store", "Thumbs.db",
"logs/**", "coverage/**",
"*.log", "*.pid", "*.seed",
// ... additional patterns
]

The matching algorithm uses a whitelist override check first, then component-level set matching for directories, then glob matching for file patterns. This prevents the watcher from firing on dependency trees and build output that would generate thousands of irrelevant events.

Porcelain v1 (--porcelain) has a simpler format (two-character status + path) but loses information: renamed files show as R old -> new with the arrow as part of the output, requiring fragile string parsing. Porcelain v2 (--porcelain=2) uses structured fields with a fixed count per record type, making parsing deterministic. The v2 rename format (type 2 followed by a separate original-path record) is more complex but unambiguous. Codex’s choice of v2 is correct for production use.

Without -z, filenames containing spaces or newlines break line-based parsing. All three implementations handle this differently: Codex uses -z with NUL splitting, Aider relies on GitPython to handle it internally, OpenCode uses v1 format which cannot reliably handle pathological filenames. Any production implementation should use -z with porcelain v2.

On repositories with large untracked directories (a node_modules that is not gitignored, for example), --untracked-files=all forces git to enumerate every file individually. This can take seconds on a cold filesystem cache. Codex mitigates this with the large directory detection heuristic (skip directories with 200+ files) applied after the git command returns. An alternative is --untracked-files=normal which collapses directories, but this loses per-file granularity needed for snapshot creation.

OpenCode’s file watcher on the project directory is behind an experimental flag (OPENCODE_EXPERIMENTAL_FILEWATCHER) for a reason: on large repositories, inotify (Linux) can run out of watch descriptors. The default max_user_watches on many Linux distributions is 65536, which is easily exceeded by a monorepo. The git directory watcher is safe because .git/ has a small, predictable number of files.

GitPython’s is_dirty() returns a boolean but internally runs git status, discarding the structured output. When Aider later needs the list of dirty files, it runs three separate diff commands (get_dirty_files()). A single git status --porcelain call could provide both the boolean and the file list. This is a minor inefficiency but worth noting for OpenOxide’s design.

Multiple concurrent git status calls can contend on the .git/index.lock file. On macOS with APFS, this is usually fast. On NFS or other network filesystems, lock contention can cause git to fail with “Unable to create .git/index.lock”. Agents that query status from multiple threads (background compaction + main turn) should serialize git operations or use --no-optional-locks where supported. Codex sets GIT_OPTIONAL_LOCKS=0 in its timeout wrapper for exactly this reason.

OpenCode’s ACP client mode (git.ts:20-52) uses Promise.all to read stdout and stderr concurrently. Without concurrent reading, if git fills the stdout pipe buffer (typically 64KB on Linux) before stderr is read, the process deadlocks: git blocks on stdout write, the reader blocks waiting for the process to exit. The stdin: "ignore" setting prevents a similar deadlock from inherited stdin. Any subprocess wrapper should either use concurrent pipe reads or redirect unused streams.

Codex uses fs::symlink_metadata() instead of fs::metadata() when checking file existence during staging (apply.rs:325). Regular metadata() follows symlinks and reports the target’s metadata, which can fail with “No such file or directory” for broken symlinks. symlink_metadata() reads the symlink itself, correctly identifying that the path exists even when its target does not.

Three layers, from fast to comprehensive:

  1. Quick check (has_changes() -> bool): Single git status --porcelain call, check if output is non-empty. Used for TUI dirty indicator. 5-second timeout.

  2. File list (changed_files() -> Vec<StatusEntry>): Parse porcelain v2 output into structured entries with status codes. Used by the agent loop for context construction and permission checks.

  3. Full snapshot (capture_snapshot() -> StatusSnapshot): Full porcelain v2 parse with untracked file filtering, large directory detection, and size-based exclusion. Used by the ghost commit system before each turn.

pub enum StatusEntry {
Changed {
path: PathBuf,
index_status: char, // X in XY
worktree_status: char, // Y in XY
},
Renamed {
path: PathBuf,
original_path: PathBuf,
score: u8,
},
Unmerged {
path: PathBuf,
},
Untracked {
path: PathBuf,
},
Ignored {
path: PathBuf,
},
}

Parse with -z NUL delimiter. Handle the rename two-record format with a state machine (set expect_rename_source after type 2 records). Use extract_path_after_fields() to skip the fixed-count field prefix — port Codex’s zero-allocation byte iterator.

Use the notify crate scoped to .git/HEAD and .git/index only:

  • .git/HEAD changes: Trigger branch change events (see branch management blueprint).
  • .git/index changes: Trigger status refresh for the TUI dirty indicator.

Do not watch the project directory by default. Make it opt-in via configuration, with automatic inotify limit detection on Linux (/proc/sys/fs/inotify/max_user_watches).

pub async fn run_git(
args: &[&str],
cwd: &Path,
timeout_secs: u64,
) -> Option<GitOutput> {
let mut cmd = Command::new("git");
cmd.env("GIT_OPTIONAL_LOCKS", "0")
.args(args)
.current_dir(cwd)
.kill_on_drop(true);
let result = timeout(Duration::from_secs(timeout_secs), cmd.output()).await;
match result {
Ok(Ok(output)) => Some(GitOutput::from(output)),
_ => None,
}
}

All git subprocess calls use GIT_OPTIONAL_LOCKS=0 and kill_on_drop(true). The timeout is configurable per call (default 5 seconds for TUI operations, 30 seconds for snapshot creation).

Port Codex’s filtering logic:

const IGNORED_DIR_NAMES: &[&str] = &[
"node_modules", ".venv", "venv", "env",
"dist", "build", ".pytest_cache", "__pycache__",
];
const LARGE_DIR_THRESHOLD: usize = 200;
const LARGE_FILE_THRESHOLD: u64 = 10 * 1024 * 1024; // 10 MiB

Apply filtering after the git command returns, not before (git cannot filter by size or directory file count natively).

CratePurpose
openoxide-gitStatus parsing, dirty detection, file listing
notifyFile watching for .git/HEAD and .git/index
tokioAsync subprocess with timeout
  1. Use porcelain v2 with -z exclusively. Do not implement v1 parsing — it is strictly less capable and has ambiguous rename handling.
  2. Single git status call per query. Never split into multiple commands (staged vs unstaged vs untracked) — a single porcelain v2 call provides all information atomically.
  3. Watch .git/ selectively, not the project directory. The cost/benefit of project-wide watching is poor for most repositories.
  4. Cache the parsed snapshot within a turn. Status does not change during a turn unless the agent modifies files, in which case the snapshot is invalidated.
  5. Serialize git operations to avoid index lock contention. Use a tokio::sync::Mutex around all git subprocess calls, or use --no-optional-locks where git supports it.
  6. Set GIT_OPTIONAL_LOCKS=0 on all read-only git commands. This prevents unnecessary index lock acquisition during status queries and other non-mutating operations.
  7. Use kill_on_drop(true) on all subprocess commands. This ensures that timed-out git processes are killed rather than leaked, preventing zombie process accumulation during long agent sessions.