Branch Management
Feature Definition
Section titled “Feature Definition”AI coding agents need branch awareness for several critical operations: computing diffs against a base branch for review prompts, detecting the remote default branch for PR creation, tracking upstream distance to warn about divergence, and listing local branches for user selection. None of the reference implementations actually switch or create branches programmatically — they read branch state and use it to inform context construction and git operations. The hard part is not reading git branch; it is building a reliable fallback chain that works across bare repos, detached HEAD states, repos with no remotes, and repos where the remote default branch is not main.
Aider Implementation
Section titled “Aider Implementation”Aider takes the simplest approach: it delegates entirely to GitPython.
Exception Handling
Section titled “Exception Handling”Before examining branch operations, it is important to understand Aider’s error handling foundation. The 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)The inclusion of TypeError is specifically for detached HEAD states where self.repo.active_branch raises TypeError. This broad catch is used throughout repo.py and commands.py to handle the unpredictable ways git operations can fail.
Branch Detection
Section titled “Branch Detection”In aider/repo.py, Aider accesses the current branch via self.repo.active_branch (GitPython’s Head object). This is used in two places:
- Diff generation (
repo.py:380-403): The code first attempts to read the active branch and check whether it has any commits:
try: active_branch = self.repo.active_branch try: commits = self.repo.iter_commits(active_branch) current_branch_has_commits = any(commits) except ANY_GIT_ERROR: passexcept (TypeError,) + ANY_GIT_ERROR: pass # Detached HEAD or branch errorIf the branch has commits, diffs run against HEAD (repo.py:399): args = ["HEAD", "--"] + list(fnames). If it is a new branch with no commits (initial commit scenario), Aider falls back to two-stage diffing:
# Staged changesindex_args = ["--cached"] + ["--"] + list(fnames)diffs += self.repo.git.diff(*index_args, ...).decode(...)# Working directory changeswd_args = ["--"] + list(fnames)diffs += self.repo.git.diff(*wd_args, ...).decode(...)- Push/pull detection (
commands.py:608-612): The/commitworkflow reads the current branch name and checks for a remote upstream:
local_head = self.coder.repo.repo.git.rev_parse("HEAD")current_branch = self.coder.repo.repo.active_branch.nametry: remote_head = self.coder.repo.repo.git.rev_parse(f"origin/{current_branch}") has_origin = Trueexcept ANY_GIT_ERROR: has_origin = FalseIf has_origin is true and local_head == remote_head, the commit has already been pushed and undo is no longer possible.
Commit SHA and Message
Section titled “Commit SHA and Message”get_head_commit_sha() (repo.py:609-615) retrieves the HEAD commit hash:
def get_head_commit_sha(self, short=False): commit = self.get_head_commit() # repo.head.commit if not commit: return if short: return commit.hexsha[:7] return commit.hexshaCommit message generation (repo.py:326-363) uses the LLM to produce messages from diffs. It builds a content string with the diff, tries each configured model in sequence (respecting token limits), and returns the first successful response.
Git Command Pass-Through
Section titled “Git Command Pass-Through”The /git command (commands.py:961-985) passes arbitrary git commands to a subprocess:
def cmd_git(self, args): args = "git " + args env = dict(subprocess.os.environ) env["GIT_EDITOR"] = "true" # Suppress editor prompts result = subprocess.run( args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env, shell=True, encoding=self.io.encoding, errors="replace", )GIT_EDITOR=true prevents interactive editors from opening during operations like git rebase. The shell=True parameter allows the user to use shell features (pipes, wildcards) in their git commands.
What Aider Does Not Have
Section titled “What Aider Does Not Have”- No default branch detection. Aider never resolves whether the remote default is
main,master, or something else. - No upstream distance tracking. It does not count ahead/behind commits.
- No merge-base computation. Diffs are always against
HEAD, not against a common ancestor with a base branch. - No branch listing for user selection. The
/gitcommand passes arbitrary git commands through to a subprocess. - No handling of detached HEAD state beyond catching
ANY_GIT_ERROR.
Codex Implementation
Section titled “Codex Implementation”Codex has the most comprehensive branch management across two files: codex-rs/core/src/git_info.rs (1246 lines) for async queries used by the TUI and agent loop, and codex-rs/utils/git/src/branch.rs (257 lines) for synchronous merge-base computation used by the review system.
Timeout Protection
Section titled “Timeout Protection”All git commands in git_info.rs go through run_git_command_with_timeout() (git_info.rs:264-277):
const GIT_COMMAND_TIMEOUT: TokioDuration = TokioDuration::from_secs(5);
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") .args(args) .current_dir(cwd) .kill_on_drop(true);
let result = timeout(GIT_COMMAND_TIMEOUT, command.output()).await; match result { Ok(Ok(output)) => Some(output), _ => None, }}The 5-second timeout (GIT_COMMAND_TIMEOUT at line 47) prevents the TUI from hanging on large repositories or unresponsive network operations. kill_on_drop(true) ensures the child process is terminated if the timeout fires. GIT_OPTIONAL_LOCKS=0 prevents unnecessary index lock acquisition during read-only operations.
Collecting Git Info
Section titled “Collecting Git Info”collect_git_info() (git_info.rs:59-111) runs three git commands in parallel via tokio::join!:
pub async fn collect_git_info(cwd: &Path) -> Option<GitInfo> { let is_git_repo = run_git_command_with_timeout(&["rev-parse", "--git-dir"], cwd) .await?.status.success(); if !is_git_repo { return None; }
let (commit_result, branch_result, url_result) = tokio::join!( run_git_command_with_timeout(&["rev-parse", "HEAD"], cwd), run_git_command_with_timeout(&["rev-parse", "--abbrev-ref", "HEAD"], cwd), run_git_command_with_timeout(&["remote", "get-url", "origin"], cwd) );
let mut git_info = GitInfo { commit_hash: None, branch: None, repository_url: None, };
// Process commit hash if let Some(output) = commit_result && output.status.success() && let Ok(hash) = String::from_utf8(output.stdout) { git_info.commit_hash = Some(hash.trim().to_string()); }
// Process branch — filter out "HEAD" (detached state) if let Some(output) = branch_result && output.status.success() && let Ok(branch) = String::from_utf8(output.stdout) { let branch = branch.trim(); if branch != "HEAD" { git_info.branch = Some(branch.to_string()); } }
// Process repository URL if let Some(output) = url_result && output.status.success() && let Ok(url) = String::from_utf8(output.stdout) { git_info.repository_url = Some(url.trim().to_string()); }
Some(git_info)}The parallel execution means the total time is bounded by the slowest of the three commands (max 5 seconds each), not their sum. The branch field is None when rev-parse --abbrev-ref HEAD returns the literal string "HEAD" (detached HEAD state). This is used by the TUI status line.
Remote URL Parsing
Section titled “Remote URL Parsing”get_git_remote_urls() (git_info.rs:114-135) parses git remote -v output:
fn parse_git_remote_urls(stdout: &str) -> Option<BTreeMap<String, String>> { let mut remotes = BTreeMap::new(); for line in stdout.lines() { let Some(fetch_line) = line.strip_suffix(" (fetch)") else { continue; // Only process fetch lines, not push }; let Some((name, url_part)) = fetch_line .split_once('\t') .or_else(|| fetch_line.split_once(' ')) else { continue; }; remotes.insert(name.to_string(), url_part.trim_start().to_string()); } if remotes.is_empty() { None } else { Some(remotes) }}The parser only processes (fetch) lines (not (push)) and handles both tab and space delimiters. Results are stored in a BTreeMap for consistent ordering.
Default Branch Detection: The Three-Level Fallback
Section titled “Default Branch Detection: The Three-Level Fallback”get_default_branch() (git_info.rs:302-345) implements a priority chain:
Level 1 — Symbolic ref (git_info.rs:307-323): For each remote (origin prioritized first via get_git_remotes() at line 289 which sorts origin to position 0), Codex runs:
git symbolic-ref --quiet refs/remotes/{remote}/HEADIf the output is refs/remotes/origin/main, it extracts the branch name after the last / via rsplit_once('/'). The --quiet flag suppresses errors for remotes that have not had their HEAD fetched.
Level 2 — Remote show (git_info.rs:326-340): If symbolic-ref fails for all remotes, Codex falls back to:
git remote show {remote}It parses the output line-by-line looking for strip_prefix("HEAD branch:") and extracts the branch name. This requires network access (it queries the remote) and is slower than symbolic-ref. The 5-second timeout is critical here — git remote show on an unreachable remote can hang indefinitely without it.
Level 3 — Local heuristic (get_default_branch_local() at git_info.rs:358-377): As a last resort, Codex checks whether main or master exists locally:
async fn get_default_branch_local(cwd: &Path) -> Option<String> { for candidate in ["main", "master"] { if let Some(verify) = run_git_command_with_timeout( &["rev-parse", "--verify", "--quiet", &format!("refs/heads/{candidate}")], cwd, ).await && verify.status.success() { return Some(candidate.to_string()); } } None}The first candidate that succeeds is returned. If neither exists, the function returns None.
Branch Ancestry: Building a Priority Vector
Section titled “Branch Ancestry: Building a Priority Vector”branch_ancestry() (git_info.rs:381-446) builds an ordered list of branches that are relevant to the current HEAD, used to find the closest remote branch for diff generation:
-
Get current branch via
git rev-parse --abbrev-ref HEAD(git_info.rs:383-393). Filters out the literal string"HEAD"(detached state). -
Get default branch via
get_default_branch()(git_info.rs:396). -
Build the ancestry vector with de-duplication via
HashSet<String>(git_info.rs:437):- Add current branch (if not detached).
- Add default branch (if different from current — checked via
!seen.contains(&db)). - For each remote (origin first), run
git for-each-ref --format=%(refname:short) --contains=HEAD refs/remotes/{remote}to find remote branches that contain the current HEAD commit. Strip the{remote}/prefix and add unique branches.
-
Return the vector even if empty — callers handle the empty case.
The --contains=HEAD flag in step 3 is important: it only returns branches that have the current HEAD in their history. This means if HEAD is on a feature branch that has been merged into main, both feature and main will appear. Branches that diverged before HEAD will not appear.
Finding the Closest Remote SHA
Section titled “Finding the Closest Remote SHA”git_diff_to_remote() (git_info.rs:249-261) orchestrates the full pipeline to find the best merge base for review:
- Verify git root exists.
- Get remotes.
- Get branch ancestry (the priority vector).
- Call
find_closest_sha()(git_info.rs:531-553) which iterates through the ancestry and callsbranch_remote_and_distance()for each branch, tracking the one with the minimum commit distance. - Call
diff_against_sha()with the winning SHA to produce the actual diff.
find_closest_sha() implements a minimum-distance selection:
async fn find_closest_sha( cwd: &Path, branches: &[String], remotes: &[String]) -> Option<GitSha> { let mut closest_sha: Option<(GitSha, usize)> = None;
for branch in branches { let Some((maybe_remote_sha, distance)) = branch_remote_and_distance(cwd, branch, remotes).await else { continue };
let Some(remote_sha) = maybe_remote_sha else { continue };
match &closest_sha { None => closest_sha = Some((remote_sha, distance)), Some((_, best_distance)) if distance < *best_distance => { closest_sha = Some((remote_sha, distance)); } _ => {} } }
closest_sha.map(|(sha, _)| sha)}branch_remote_and_distance() (git_info.rs:452-528) does two things for each branch:
-
Find remote SHA: For each remote, run
git rev-parse --verify --quiet refs/remotes/{remote}/{branch}. First match wins. If the remote ref does not exist, continue to the next remote. -
Count distance: Run
git rev-list --count {branch}..HEAD(preferred). If the local branch ref does not exist, fall back togit rev-list --count refs/remotes/{remote}/{branch}..HEAD. The distance is the number of commits HEAD is ahead of the reference. Lower distance means the branch is more relevant (closer to HEAD).
Diff Against SHA
Section titled “Diff Against SHA”diff_against_sha() (git_info.rs:555-608) produces the actual diff for review:
async fn diff_against_sha(cwd: &Path, sha: &GitSha) -> Option<String> { let output = run_git_command_with_timeout( &["diff", "--no-textconv", "--no-ext-diff", &sha.0], cwd ).await?;
// Exit codes: 0 = no diff, 1 = diff present, other = error let exit_ok = output.status.code().is_some_and(|c| c == 0 || c == 1); if !exit_ok { return None; }
let mut diff = String::from_utf8(output.stdout).ok()?;
// Append diffs for untracked files if let Some(untracked_output) = run_git_command_with_timeout( &["ls-files", "--others", "--exclude-standard"], cwd ).await && untracked_output.status.success() { let untracked: Vec<String> = /* parse output */;
// Parallel diffs for each untracked file let futures = untracked.into_iter().map(|file| async move { run_git_command_with_timeout( &["diff", "--no-textconv", "--no-ext-diff", "--binary", "--no-index", "--", null_device, &file], cwd ).await }); let results = join_all(futures).await; for extra in results.into_iter().flatten() { if let Ok(s) = String::from_utf8(extra.stdout) { diff.push_str(&s); } } }
Some(diff)}Key details: --no-textconv and --no-ext-diff suppress custom diff drivers for deterministic output. Exit code 1 is valid (means diff is non-empty). Untracked files are diffed against /dev/null (or NUL on Windows) using --no-index for files outside the index. The parallel join_all runs all untracked file diffs concurrently.
Merge-Base for Reviews
Section titled “Merge-Base for Reviews”branch.rs in the utils/git crate provides synchronous merge-base computation for the review prompt system.
merge_base_with_head() (branch.rs:15-48) implements a six-step flow:
- Ensure git repository (
ensure_git_repository()). - Resolve repository root.
- Resolve HEAD — return
Noneif no HEAD (empty repo). - Resolve branch ref via
resolve_branch_ref()(branch.rs:50-66) which runsgit rev-parse --verify {branch}. MapsGitCommanderrors toNone(branch does not exist), propagating other errors. - Upstream preference:
resolve_upstream_if_remote_ahead()(branch.rs:68-117) checks whether the remote tracking branch is ahead of the local branch:
fn resolve_upstream_if_remote_ahead( repo_root: &Path, branch: &str,) -> Result<Option<String>, GitToolingError> { // Get upstream name let upstream = run_git_for_stdout(repo_root, vec![ "rev-parse", "--abbrev-ref", "--symbolic-full-name", &format!("{branch}@{{upstream}}") ], None)?;
// Count ahead/behind with left-right let counts = run_git_for_stdout(repo_root, vec![ "rev-list", "--left-right", "--count", &format!("{branch}...{upstream}") ], None)?;
let mut parts = counts.split_whitespace(); let _left: i64 = parts.next().unwrap_or("0").parse().unwrap_or(0); let right: i64 = parts.next().unwrap_or("0").parse().unwrap_or(0);
// Return upstream only if remote is ahead if right > 0 { Ok(Some(upstream)) } else { Ok(None) }}The {branch}@{upstream} syntax resolves to the configured tracking branch (e.g., origin/main for a local main branch). The --left-right --count with three dots (...) gives bidirectional counts: left = local ahead, right = remote ahead. If the remote is ahead (right > 0), the upstream ref is preferred as the merge-base base, ensuring reviews compare against the latest remote state.
- Compute merge-base:
git merge-base HEAD {preferred_ref}.
This merge-base feeds into review_prompts.rs:39-68 where review_prompt() formats the diff into a prompt string. If merge-base fails, the fallback prompt instructs the LLM to compute the merge-base itself using inline git commands.
Utility Functions
Section titled “Utility Functions”-
local_git_branches()(git_info.rs:640-664): Lists branches viagit branch --format=%(refname:short), sorts alphabetically, then moves the default branch to position 0 for the TUI branch picker. The move is done by finding the position withiter().position(), removing withVec::remove(), and inserting at index 0. -
current_branch_name()(git_info.rs:667-676): Simple wrapper aroundgit branch --show-current. ReturnsNonefor empty output (detached HEAD). -
get_has_changes()(git_info.rs:153-160): Quick dirty check viagit status --porcelain, returnsSome(true)if output is non-empty,Noneon timeout or error.
OpenCode Implementation
Section titled “OpenCode Implementation”OpenCode takes an event-driven approach with two key files: project/vcs.ts for reactive branch tracking and util/git.ts for low-level git command execution.
Branch Detection
Section titled “Branch Detection”currentBranch() (vcs.ts:31-39) runs:
async function currentBranch() { return $`git rev-parse --abbrev-ref HEAD` .quiet() .nothrow() .cwd(Instance.worktree) .text() .then((x) => x.trim()) .catch(() => undefined)}.quiet() suppresses stdout/stderr output. .nothrow() prevents non-zero exit codes from throwing. .catch(() => undefined) returns undefined on any error (detached HEAD, not a repo, etc.).
Real-Time Branch Change Detection
Section titled “Real-Time Branch Change Detection”vcs.ts:41-67 sets up a reactive state via Instance.state():
const state = Instance.state( async () => { if (Instance.project.vcs !== "git") { return { branch: async () => undefined, unsubscribe: undefined } }
let current = await currentBranch() log.info("initialized", { branch: current })
const unsubscribe = Bus.subscribe( FileWatcher.Event.Updated, async (evt) => { if (!evt.properties.file.endsWith("HEAD")) return
const next = await currentBranch() if (next !== current) { log.info("branch changed", { from: current, to: next }) current = next Bus.publish(Event.BranchUpdated, { branch: next }) } } )
return { branch: async () => current, unsubscribe } }, async (state) => { state.unsubscribe?.() },)The implementation is selective about what it watches:
- The file watcher (
file/watcher.ts) uses@parcel/watcherwith platform-specific backends (inotify on Linux, fs-events on macOS, Windows API on Windows). - For the
.gitdirectory specifically, it subscribes to the git dir (resolved viagit rev-parse --git-dir), but explicitly ignores everything exceptHEADby building an ignore list fromreaddir(vcsDir)filtered to exclude"HEAD"(watcher.ts:99-100). - When a
HEADfile change is detected,currentBranch()is re-evaluated. If the branch name differs from the last known value, it publishesEvent.BranchUpdatedvia the bus system.
The Event.BranchUpdated event (vcs.ts:14-19) carries a Zod-validated schema: z.object({ branch: z.string().optional() }). The optional() allows undefined to propagate through for detached HEAD states.
Lazy initialization: The state is only created on first call to init() or branch(). The cleanup function unsubscribes from the file watcher when the state is destroyed.
Git Command Wrapper
Section titled “Git Command Wrapper”git() in util/git.ts:19-64 provides two execution paths:
export async function git( args: string[], opts: { cwd: string; env?: Record<string, string> }): Promise<GitResult> { if (Flag.OPENCODE_CLIENT === "acp") { const proc = Bun.spawn(["git", ...args], { stdin: "ignore", stdout: "pipe", stderr: "pipe", cwd: opts.cwd, }) 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 } }
const result = await $`git ${args}`.quiet().nothrow().cwd(opts.cwd) return { exitCode: result.exitCode, text: () => result.text(), stdout: result.stdout, stderr: result.stderr }}-
ACP client mode: Uses
Bun.spawn()withstdin: "ignore"(prevents a Windows deadlock where inherited stdin blocks the child process) andPromise.allfor parallel stdout/stderr reads (prevents pipe buffer deadlock). The concurrent read pattern is critical: if git fills the stdout buffer before stderr is read, the process blocks. -
Normal mode: Uses Bun’s
$shell with.quiet().nothrow()for simpler, faster execution.
Both paths return a GitResult with exitCode, text(), stdout, and stderr.
What OpenCode Does Not Have
Section titled “What OpenCode Does Not Have”- No default branch detection. There is no equivalent to Codex’s three-level fallback.
- No merge-base computation. OpenCode does not compute diffs against a base branch.
- No upstream distance tracking.
- No branch listing for selection (the TUI does not have a branch picker).
Pitfalls & Hard Lessons
Section titled “Pitfalls & Hard Lessons”Detached HEAD Is Everywhere
Section titled “Detached HEAD Is Everywhere”All three implementations must handle detached HEAD. Aider catches ANY_GIT_ERROR exceptions (specifically TypeError) from GitPython. Codex filters out the literal string "HEAD" from git rev-parse --abbrev-ref HEAD output (line 97 in git_info.rs). OpenCode returns undefined and propagates the absence through the bus system. Any branch-aware feature must treat detached HEAD as a first-class state, not an error.
git symbolic-ref Requires a Prior Fetch
Section titled “git symbolic-ref Requires a Prior Fetch”The fastest way to get the remote default branch is git symbolic-ref refs/remotes/origin/HEAD, but this only works after a git fetch or git clone has set up the remote HEAD reference. Codex handles this by falling back to git remote show (which queries the network) and then to local heuristics. Agents that run in CI or on fresh clones without --single-branch should not rely on symbolic-ref alone.
Upstream Distance Counting Is Asymmetric
Section titled “Upstream Distance Counting Is Asymmetric”git rev-list --count A..B counts commits reachable from B but not from A. This is not the same as git rev-list --left-right --count A...B (three dots) which gives both ahead and behind counts. Codex uses both forms depending on context: --count for simple distance in find_closest_sha(), --left-right --count for the upstream check in branch.rs. Using the wrong form gives misleading numbers when branches have diverged.
The difference: A..B (two dots) is an asymmetric range — only commits in B not in A. A...B (three dots) is a symmetric difference — commits in either A or B but not both. The --left-right flag partitions the symmetric difference into left (A’s unique commits) and right (B’s unique commits).
Timeout Protection Is Not Optional
Section titled “Timeout Protection Is Not Optional”Codex’s 5-second timeout on all git commands is critical. On repositories with millions of objects, operations like git remote show can take 30+ seconds because they query the network. Without a timeout, the TUI freezes. OpenCode uses .nothrow() for error suppression but has no explicit timeout — a slow network can hang the event loop.
File Watcher Selectivity Matters
Section titled “File Watcher Selectivity Matters”OpenCode’s approach of watching only the HEAD file inside .git/ is correct. Watching the entire .git/ directory would fire on every git status, git stash, index updates, and reflog writes — thousands of events that are irrelevant to branch detection. The ignore-everything-except-HEAD pattern (watcher.ts:100) is essential for performance.
Remote Prioritization
Section titled “Remote Prioritization”Codex always puts origin first in its remote list (git_info.rs:289-292). This matters because git remote show on a secondary remote can return a different default branch than origin. If a repository has both origin pointing to GitHub and upstream pointing to a fork, the default branch resolution differs. OpenOxide should make remote priority explicit and configurable.
git for-each-ref --contains Can Be Slow
Section titled “git for-each-ref --contains Can Be Slow”The --contains=HEAD flag in branch_ancestry() requires git to walk the commit graph from every remote branch back to check if it contains HEAD. On repositories with thousands of remote branches and deep history, this can exceed the 5-second timeout. Codex’s timeout protection handles this gracefully (returns None), but a smarter approach might limit the query to a configurable set of remotes.
Empty Repos Have No HEAD
Section titled “Empty Repos Have No HEAD”Aider handles empty repositories (no commits) by catching the error from iter_commits() and falling back to --cached diffing. Codex returns None from resolve_head() in branch.rs for empty repos. OpenCode’s rev-parse --abbrev-ref HEAD returns a non-zero exit code. Any branch operation must handle the “no commits yet” state as a valid, non-error scenario.
OpenOxide Blueprint
Section titled “OpenOxide Blueprint”Architecture: Git Info Service
Section titled “Architecture: Git Info Service”Branch management belongs in an openoxide-git crate that wraps all git CLI operations behind an async interface with timeout protection.
pub struct GitInfo { pub commit_hash: Option<String>, pub branch: Option<String>, // None if detached HEAD pub repository_url: Option<String>,}
pub struct BranchAncestry { pub branches: Vec<String>, // Ordered by priority pub default_branch: Option<String>,}
pub struct RemoteDistance { pub remote_sha: Option<String>, pub ahead: usize, pub behind: usize,}Default Branch Detection
Section titled “Default Branch Detection”Adopt Codex’s three-level fallback chain verbatim — it handles the most edge cases:
git symbolic-ref --quiet refs/remotes/{remote}/HEAD(fast, local)git remote show {remote}+ parseHEAD branch:line (slow, network)- Check for
refs/heads/mainthenrefs/heads/masterlocally (offline fallback)
Remote prioritization: configurable via OPENOXIDE_PRIMARY_REMOTE env var, defaulting to origin.
Merge-Base for Reviews
Section titled “Merge-Base for Reviews”Follow Codex’s branch.rs pattern with one improvement — cache the merge-base SHA per session so repeated review operations do not re-compute it:
pub async fn merge_base_with_head( repo_root: &Path, branch: &str,) -> Result<Option<String>, GitError> { // 1. resolve HEAD // 2. resolve branch ref // 3. check upstream: if remote is ahead, prefer upstream // 4. git merge-base HEAD {preferred_ref}}Branch Change Events
Section titled “Branch Change Events”Use the notify crate for cross-platform file watching, scoped to .git/HEAD only. Emit BranchChanged { old: Option<String>, new: Option<String> } events through a tokio::broadcast channel. Re-evaluate the current branch on each event using git rev-parse --abbrev-ref HEAD.
Diff Generation
Section titled “Diff Generation”Follow Codex’s pipeline: build branch ancestry, find closest SHA, diff against it. Include untracked file diffs via parallel git diff --no-index -- /dev/null {file} calls. Cache the closest SHA within a turn to avoid re-computation on repeated diff requests.
Timeout
Section titled “Timeout”All git subprocess calls go through a shared executor with a configurable timeout (default 5 seconds). Use tokio::time::timeout wrapping tokio::process::Command. Set GIT_OPTIONAL_LOCKS=0 and kill_on_drop(true) on all commands.
Crates
Section titled “Crates”| Crate | Purpose |
|---|---|
openoxide-git | All git CLI wrappers, branch info, merge-base |
notify | Cross-platform file watching for .git/HEAD |
tokio | Async subprocess execution with timeout |
Key Design Decisions
Section titled “Key Design Decisions”- Shell out to git, do not use
git2for branch operations. Thegit2crate (libgit2 bindings) does not support all ref formats and has historically lagged behind the git CLI for features like--porcelain=2. Shell execution also inherits the user’s git configuration (aliases, credential helpers). - Cache branch ancestry per turn, not per session. Branch state can change between turns if the user runs git commands in another terminal.
- Make merge-base failure non-fatal. If merge-base cannot be computed (no common ancestor, empty repo, no remotes), fall back to diffing against HEAD and warn the user.
- Expose branch info to the TUI status line via the same
tokio::broadcastchannel used for branch change events. - Run
collect_git_info()calls in parallel viatokio::join!following Codex’s pattern. Three sequential git commands with 5-second timeouts would be 15 seconds worst case; parallel execution caps at 5 seconds. - Prefer upstream when remote is ahead for merge-base computation, following Codex’s
resolve_upstream_if_remote_ahead()pattern. This ensures review diffs compare against the latest remote state rather than a stale local copy.