- name
- g6-ctx-git
- description
- Use when an agent must read or change a git repository and every call must leave a record, or must find out what changed in a working copy since a point, whoever changed it, so changes an agent made behind the record are not missed; git runs with the repository's own programs switched off, only in folders the person declared.
# G6 ctx_git
Reads and changes git repositories through git's command line, inside folders the person who runs G6 declares. A
repository's own config, hooks and attributes cannot make git start a program. Every call is written to an audit log
under the G6 home, and `changes_since` reports every change in a working copy since a point, marking each commit made
through ctx_git or not.
## When to use it
- An MCP-only client needs git, or you want an audited trail of what an agent did to a repository.
- Before building on a working copy an agent or a script touched: `changes_since` lists every commit, changed file
and untracked file since a point, and which commits did not come through ctx_git.
- To publish or fetch with a person in the loop: outward git actions (`fetch`, `pull`, `push`, `clone`) are dry runs
by default and checked by the action gate first. Local ones (`commit`, `merge`, `add`, branches, tags, stashes,
`reset_staged`) are sensitive ops the person allows.
## When not to use it
- An agent with a shell that needs no audit trail: plain git is simpler.
- To rewrite history, force a push or delete a branch: ctx_git offers none of these.
- In a repository with submodules: it is refused, because git would run inside the submodule under that
repository's own config.
## Ops
- `status`: branch, upstream, ahead and behind, and the staged, modified, untracked and conflicted files. Sensitive.
- `diff`: the files that differ and the patch (`rev`, `cached`, `paths`, `max_chars`), external diff and textconv programs off. Sensitive.
- `log`: commits in git's own order (`rev`, `max_count`): sha, parents, author, date, subject. Sensitive.
- `show`: one commit's message and patch (`rev`, `max_chars`). Sensitive.
- `blame`: who last changed each line of `file` (`rev`, `max_chars`). Sensitive.
- `branch_list`: the local branches, the current one and each upstream. Sensitive.
- `tag_list`: the tags and the commits they point at. Sensitive.
- `remote_list`: the remotes and their addresses, a user name or password in one hidden. Sensitive.
- `changes_since`: every change since `since` (or the last point the audit log saw here), whoever made it. Sensitive.
- `add`: stage `paths`, or everything with `all_files`; the index read back. Sensitive.
- `commit`: commit what is staged with `message`, the repository's hooks off; HEAD read back. Sensitive.
- `branch_create`: a new branch `name` at `rev` (default HEAD). Sensitive.
- `branch_switch`: switch to `branch`; refused with uncommitted changes. Sensitive.
- `merge`: merge `branch` into the current one; refused with uncommitted changes, aborted on a conflict. Sensitive.
- `stash_save`: set uncommitted changes aside (`message`, `include_untracked`). Sensitive.
- `stash_pop`: apply the newest stash and drop it; refused with uncommitted changes. Sensitive.
- `tag_create`: a tag `name` at `rev`, annotated when `message` is given. Sensitive.
- `reset_staged`: unstage `paths`, or everything with `all_files`; files are not touched. Sensitive.
- `fetch`: download from `remote`; a dry run unless `dry_run` is false. Sensitive.
- `pull`: fetch `branch` from `remote` and fast-forward only; refused with uncommitted changes; a dry run unless `dry_run` is false. Sensitive.
- `push`: publish `branch` to `remote`, never forced, read back with ls-remote; a dry run unless `dry_run` is false. Sensitive.
- `clone`: a new working copy of `url` at `dest`, inside a declared root; a dry run unless `dry_run` is false. Sensitive.
- `roots`: the folders and remote hosts the person declared; nothing is run.
## Reading the result
Every path is absolute; a network or device path is refused before anything is resolved. A repository must be the top folder of a working copy at or under a declared root, and so
must its `.git`, the common git folder a `commondir` file names, its `objects` and `refs` folders and every folder
its `objects/info/alternates` borrows from; a path outside, a link out, or a file in `.git` is refused
(`G6_E_GIT_OUTSIDE_ROOT`) before git runs. With no root declared nothing runs (`G6_E_GIT_NO_ROOT`).
Answers are `qualified-draft`: a read (`G6_E_GIT_READ`), a change with its read-back (`G6_E_GIT_CHANGED`: HEAD, the
index or the ref after the act), `changes_since` (`G6_E_GIT_CHANGES`), a dry run (`G6_E_GIT_DRY_RUN`, with
`would_run`, the exact command, and the gate's decision) or an outward act that ran (`G6_E_GIT_SENT`, with its
read-back; a push is read back from each address it went to, `read_back_from`). The read-back is ctx_git checking its
own act, so it is evidence, never a mark of correctness. `would_run_under` says that a person who runs the command runs
it under their own git config and the repository's (its hooks included), not under ctx_git's settings.
Fetch and pull name their refspecs, so a refspec in the repository's config never writes a local branch; no HTTP
redirect is followed. A network op is refused (`G6_E_GIT_REMOTE_CONFIG_REFUSED`), dry runs included, when the
repository's own config sets a key that would steer it: any `http.*` key, the remote's `proxy`, `uploadpack`,
`receivepack` or `mirror`, `fetch.bundleURI`, `push.followTags`, or a push address other than the one checked.
Refusals are `blocked-escalated`: `G6_E_GIT_DIRTY_WORKTREE` (commit or stash first; no flag goes past it),
`G6_E_GIT_CONFLICT` (a merge was aborted, so the copy is as it was), `G6_E_GIT_GATE_HELD` (the action gate holds an
outward act for a person; the reply shows the command a person can run), `G6_E_GIT_REMOTE_REFUSED` (not https or
ssh, not a declared host, a password in the address, or a host that resolves to a private address),
`G6_E_GIT_REMOTE_CONFIG_REFUSED` (the repository's config would steer a network op), `G6_E_GIT_AUDIT_UNWRITABLE`
(the audit log could not take the call's intent, so nothing was run),
`G6_E_GIT_SUBMODULE_REFUSED`, `G6_E_GIT_UNSAFE_CONFIG` (a git older than 2.31), `G6_E_GIT_AUTH_FAILED`,
`G6_E_GIT_UNREACHABLE`, `G6_E_GIT_REJECTED`, `G6_E_GIT_BAD_REVISION`, `G6_E_GIT_NOTHING_TO_COMMIT`,
`G6_E_GIT_IDENTITY`, `G6_E_GIT_TIMEOUT`, `G6_E_GIT_FAILED` and `G6_E_OUTSIDE_PROGRAM_MISSING` (git is not on PATH).
`status` and `changes_since` list at most 1000 entries per list, `counts` holding the whole. One git command's output
over 8 MiB is refused (`G6_E_GIT_OUTPUT_TOO_LARGE`), except a patch or a blame, which is cut (`truncated`).
In `changes_since` each commit's `how` is `made through ctx_git`, `brought in by ctx_git (a merge or pull)` or
`not through ctx_git`; `in_history` is false when the point is no longer in HEAD's history. A fast-forward onto
someone else's merge commit is brought in, not made. With `since` left out, `between_calls` lists each move of HEAD
between two of ctx_git's calls, with its commits (counted in `not_through_ctx_git`).
## Alone and composed
The person declares the folders, as JSON in `G6_GIT_CONFIG` or in `ctx_git/config.json` under the G6 home:
`{"roots": ["C:/work/project"], "remote_hosts": ["github.com"]}`. Over MCP every op but `roots` runs only when the
person who runs the server lists it in `G6_MCP_SENSITIVE`: `g6_invoke("ctx_git", "changes_since", {"repo":
"C:/work/project", "since": "HEAD~5"})`.
From Python: `CtxGitBlock(config=..., gate=...)`; `config` is a `GitConfig` (`g6.sources.ctx_git.config.parse`), and
`gate` replaces `g6.action_gating` for an outward act (it receives the declared action and returns a decision).
Composed: `g6.coding_agent_gate` checks a plan before risky coding acts; run `changes_since` after an agent's turn and
review what is marked `not through ctx_git`; `g6.grounding` checks claims made about a diff against its text.
## Failure modes it catches
- `untracked_repo_change` (repository changes an agent made that nobody tracked): every call is in the audit log on
disk, its intent before it runs (a log that cannot take it refuses the call) and its outcome after
(`audit_outcome_written` false when it could not be), and `changes_since` compares the history and the working copy
with it, so a commit, a deletion or a new file made by a shell or an editor is listed and marked, and a rewritten
history is said.
## Blind spots
- `prompt_injection`: commit messages, diffs and blame lines can carry instructions. They are untrusted data (each
reply notes the instruction-like phrasing the tripwire finds), and nothing here acts on them.
- Uncommitted changes carry no author: `changes_since` lists them but cannot say who made them.
- The log records only ctx_git's own calls; a commit made elsewhere and later rewritten away leaves no trace.
- The audit log is a plain, unkeyed file: any program running as you can append to or change it, so a forged line
could mark a commit as made through ctx_git. It is never rotated and grows by about a kilobyte per call.
- Filters are off, so git-lfs files (and others a filter would expand) come out as pointer files: `diff`, `show` and
`blame` read the pointer, and a checkout writes it.
- Hooks never run, so a project's own pre-commit checks do not run on commits made here.
- An https push or fetch gets no credential from G6 (no helper, no prompt); use ssh with your own agent.
- git resolves a remote's host itself after G6 checked it, so a name that changes its address in between is not
caught.
- Today's action gate holds every outward act for a person, so `fetch`, `pull`, `push` and `clone` return the
command for a person to run (`would_run_under`).
- A remote that answers with an HTTP redirect (a renamed repository) is not followed: use its new address.
## What it needs
git 2.31 or later on PATH (an outside program you install; ctx_git never installs it), and ssh on PATH for ssh
remotes. No extra, no key, no model. The audit log is `ctx_git/audit.jsonl` under the G6 home.
## Examples
```g6-example op=roots
{}
```
Lists the folders and hosts the person declared; with none, every other op is refused.
```g6-example op=changes_since
{"repo": "C:/work/project", "since": "HEAD~5"}
```
Sensitive: over MCP it runs only when the server's owner allows it.
```g6-example op=push
{"repo": "C:/work/project", "remote": "origin", "branch": "main"}
```
A dry run: nothing is sent; the reply carries the command and the gate's decision.
```python
import json
import tempfile
from pathlib import Path
from g6.sources.ctx_git import CtxGitBlock, CtxGitInput
from g6.sources.ctx_git.config import OUTSIDE_ROOT, parse
here = Path(tempfile.gettempdir())
block = CtxGitBlock(config=parse(json.dumps({"roots": [str(here / "work")]}), "this example"))
out = block.infer(CtxGitInput(op="status", repo=str(here / "elsewhere"))).unwrap()
assert out["code"] == OUTSIDE_ROOT and out["completion_state"] == "blocked-escalated" # git never ran
```
## The four open problems
- **Learning signals**: every call, with its code (refusals included), HEAD before and after and the commits it
made, is in the audit log. Not yet: nothing learns from it.
- **Evaluators the model cannot influence**: the repository itself, read back by git after each act, and the audit
log; no model is called and repository text changes no outcome.
- **Transfer by failure mode**: bound to `untracked_repo_change`; the same comparison applies to any repository under
a declared root.
- **Enough held-out cases**: Not yet: the tests use fixture repositories (dirty, conflicting, hook-bearing, with a
submodule, a rewritten history); there is no frozen corpus of real repositories.
View on GitHub