Status & Tracking
Feature Definition
Section titled “Feature Definition”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 Implementation
Section titled “Aider Implementation”Aider uses GitPython for all status operations, with a few raw git commands for edge cases.
Exception Handling Foundation
Section titled “Exception Handling Foundation”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.
Dirty Detection
Section titled “Dirty Detection”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.
Dirty File Enumeration
Section titled “Dirty File Enumeration”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_filesThis 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.
Tracked File Listing
Section titled “Tracked File Listing”get_tracked_files() (repo.py:450-497) is central to Aider’s operation — it determines which files the agent can see:
- Get HEAD commit: Retrieves the commit object, caching results per commit SHA to avoid repeated tree traversals.
- Tree traversal: Calls
commit.tree.traverse()filtering forblob.type == "blob"(excludes subtrees/directories). - Staged file addition: Adds files from index entries that may not be in the tree yet (newly staged files).
- 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.
File Watcher
Section titled “File Watcher”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
.gitignorepatterns 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).
What Aider Does Not Have
Section titled “What Aider Does Not Have”- 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 statuscall.
Codex Implementation
Section titled “Codex Implementation”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.
Quick Dirty Check
Section titled “Quick Dirty Check”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.
Full Porcelain v2 Parser
Section titled “Full Porcelain v2 Parser”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 byte | Record type | Fields before path | Handling |
|---|---|---|---|
? | Untracked file | 1 (just the ? marker) | Added to untracked.files after ignore/size filtering |
! | Ignored file | 1 (just the ! marker) | Added to untracked.ignored |
1 | Ordinary changed entry | 8 (XY, sub, mH, mI, mW, hH, hI, path) | Path added to tracked_paths |
2 | Renamed/copied entry | 9 (adds score field) | Path added to tracked_paths; sets expect_rename_source = true for next record |
u | Unmerged entry | 10 (adds stage info) | Path added to tracked_paths |
Porcelain v2 field meanings for type 1 (ordinary) entries:
| Field | Position | Meaning |
|---|---|---|
| XY | 1 | Two-character status: X = index status, Y = worktree status |
| sub | 2 | Submodule state (N... for non-submodule) |
| mH | 3 | Octal file mode in HEAD |
| mI | 4 | Octal file mode in index |
| mW | 5 | Octal file mode in worktree |
| hH | 6 | Object name (SHA) in HEAD |
| hI | 7 | Object name (SHA) in index |
| path | 8 | File 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:
- When a type
2record is encountered, setexpect_rename_source = trueand record the destination path. - The next NUL-delimited record is the original (source) path. Add it to
tracked_paths. - 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.
Untracked File Filtering
Section titled “Untracked File Filtering”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.
StatusSnapshot Structure
Section titled “StatusSnapshot Structure”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.
No File Watching
Section titled “No File Watching”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 Implementation
Section titled “OpenCode Implementation”OpenCode combines shell-based status queries with a real-time file watcher for live updates.
Status Queries
Section titled “Status Queries”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: @parcel/watcher
Section titled “File Watcher: @parcel/watcher”file/watcher.ts implements a real-time file change detection system using @parcel/watcher.
Platform backends (watcher.ts:52-56):
- Windows:
windowsbackend - macOS:
fs-eventsbackend - Linux:
inotifybackend
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):
-
Project directory (behind
OPENCODE_EXPERIMENTAL_FILEWATCHERflag): WatchesInstance.directorywith ignore patterns fromFileIgnore.PATTERNSplus user-configuredwatcher.ignorepatterns. This scope is experimental because of the inotify limit risk on Linux. -
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.
VCS State: Branch + Status
Section titled “VCS State: Branch + Status”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 Patterns
Section titled “File Ignore Patterns”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.
Pitfalls & Hard Lessons
Section titled “Pitfalls & Hard Lessons”Porcelain v1 vs v2
Section titled “Porcelain v1 vs v2”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.
NUL Delimiter Is Essential
Section titled “NUL Delimiter Is Essential”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.
--untracked-files=all Can Be Expensive
Section titled “--untracked-files=all Can Be Expensive”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.
File Watcher Overhead
Section titled “File Watcher Overhead”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() Hides Details
Section titled “GitPython’s is_dirty() Hides Details”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.
Status Queries and the Index Lock
Section titled “Status Queries and the Index Lock”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.
Pipe Buffer Deadlock
Section titled “Pipe Buffer Deadlock”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.
Symlink Handling
Section titled “Symlink Handling”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.
OpenOxide Blueprint
Section titled “OpenOxide Blueprint”Architecture: Layered Status System
Section titled “Architecture: Layered Status System”Three layers, from fast to comprehensive:
-
Quick check (
has_changes() -> bool): Singlegit status --porcelaincall, check if output is non-empty. Used for TUI dirty indicator. 5-second timeout. -
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. -
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.
Porcelain v2 Parser
Section titled “Porcelain v2 Parser”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.
File Watcher
Section titled “File Watcher”Use the notify crate scoped to .git/HEAD and .git/index only:
.git/HEADchanges: Trigger branch change events (see branch management blueprint)..git/indexchanges: 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).
Git Subprocess Wrapper
Section titled “Git Subprocess Wrapper”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).
Untracked File Filtering
Section titled “Untracked File Filtering”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 MiBApply filtering after the git command returns, not before (git cannot filter by size or directory file count natively).
Crates
Section titled “Crates”| Crate | Purpose |
|---|---|
openoxide-git | Status parsing, dirty detection, file listing |
notify | File watching for .git/HEAD and .git/index |
tokio | Async subprocess with timeout |
Key Design Decisions
Section titled “Key Design Decisions”- Use porcelain v2 with
-zexclusively. Do not implement v1 parsing — it is strictly less capable and has ambiguous rename handling. - Single
git statuscall per query. Never split into multiple commands (staged vs unstaged vs untracked) — a single porcelain v2 call provides all information atomically. - Watch
.git/selectively, not the project directory. The cost/benefit of project-wide watching is poor for most repositories. - 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.
- Serialize git operations to avoid index lock contention. Use a
tokio::sync::Mutexaround all git subprocess calls, or use--no-optional-lockswhere git supports it. - Set
GIT_OPTIONAL_LOCKS=0on all read-only git commands. This prevents unnecessary index lock acquisition during status queries and other non-mutating operations. - 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.