The Git CLI that is safe to hand to your AI Agents: no sharp edges by design, commit concurrency built-in, Git-compatible because it uses Git plumbing under the hood
When multiple AI agent sessions share a single git repository, they race on
.git/index. Two agents staging files at the same time produce mixed commits
-- files from one agent leak into another's commit, or writes are silently
lost. Standard git has no built-in isolation for this scenario.
safegit wraps git plumbing commands behind a two-phase commit pipeline that keeps every invocation isolated. Per-invocation temporary index files prevent staging races. Ref updates use per-ref locks with compare-and-swap (CAS) retry, so concurrent commits to the same branch serialize correctly. An append-only operation log records every mutation. The output is standard git commits -- teammates, CI, and code review tools see nothing unusual.
safegit does not promise full git support and never will. It implements a small, opinionated subset of git's functionality, chosen for agent-heavy workflows. When a git feature, command, flag, or edge case is judged actively harmful or irrelevant for that workflow, safegit deliberately omits it and never looks back. Every such omission is recorded in .stricttools/docs/divergences.md, unapologetically. safegit is for agents, not for all humans.
From source (requires the Go version go.mod declares -- currently 1.25.7):
go install github.com/smm-h/safegit@v0
@v0, not @latest: safegit issues no 1.x tags, so @latest cannot resolve to
a real release. Pin an exact version (@v0.28.0) when you need one.
Pre-built binaries are available on GitHub Releases via goreleaser.
cd your-repo
safegit commit -m "add feature X" -- src/foo.go src/bar.go
safegit push --refs head
safegit auto-initializes on first use (creates .git/safegit/); a --dry-run
deliberately does not, so previewing in a fresh repository writes nothing at
all. Use safegit doctor --action uninstall to remove safegit from a
repository -- a repository-wide operation that lists every path it will remove,
including the state of worktrees other than the one you are standing in, before
asking you to confirm.
| Command | Description |
|---|---|
commit |
stage and commit specified files in a single atomic operation |
mv |
move tracked paths and commit the moves with their records in one operation |
merge-continue |
conclude a merge git stopped before committing. Every conflicted path is named with --resolve (or in a --resolve-file), and safegit writes the merge commit itself: HEAD plus the MERGE_HEAD line as parents; the merge's whole staged result as its tree, so a path the merge staged cleanly is never dropped; git's own message draft with its comment block stripped, or -m; the repository's commit-msg hook run and safegit's trailers injected; and the merge's whole state-file set removed afterwards, so a later commit is not refused. An empty merge needs no flag -- a merge commit records its parents whether or not the tree changed. Two merge shapes only raw git can start are REFUSED, each naming git's own 'merge --continue' and '--abort': an OCTOPUS, because every check safegit makes over a merge is written against two sides, and a content conflict git recorded with no AUTO_MERGE, which is a non-default strategy's signature and leaves the marker verification nothing to read |
cherry-pick-continue |
conclude a cherry-pick git stopped before committing. Every conflicted path is named with --resolve (or in a --resolve-file), and safegit writes the commit itself: one parent, the AUTHOR preserved from the commit being applied while the committer is you, git's own message draft with its comment block stripped or -m, the repository's commit-msg hook run, and the cherry-pick's state files removed afterwards. A QUEUED sequence -- git cherry-pick <a> <b>, which only raw git can start, since safegit's cherry-pick applies one commit -- is REFUSED, naming git's own 'cherry-pick --continue' and '--abort': the queue is part of the state a conclusion removes, so finishing one step of it would throw the rest away |
revert-continue |
conclude a revert git stopped before committing. Every conflicted path is named with --resolve (or in a --resolve-file). For a SINGLE revert safegit writes the commit itself: one parent, YOU as both author and committer -- a revert is your own new change, not the reverted commit author's, which is git's own division and the opposite of what a cherry-pick does -- git's own message draft with its comment block stripped or -m, the repository's commit-msg hook run, and the revert's state files removed afterwards. Note the stage keywords: a revert applies an INVERSE patch, so theirs is what the reverted commit's parent held -- resolving to theirs keeps the revert, resolving to ours keeps the commit being reverted. A QUEUED sequence -- git revert <a> <b>, which only raw git can start, since safegit's revert undoes one commit -- is REFUSED, naming git's own 'revert --continue' and '--abort': the queue is part of the state a conclusion removes, so finishing one step of it would throw the rest away |
switch |
switch to another BRANCH, guarded twice before git runs: the worktree operation lock, held for the whole command, and then a check for uncommitted work. The command line is a deliberate subset of git's: an existing branch name, or -c to create one, and nothing else. A tag, an object name or any other commit-ish is refused, because switching onto one detaches HEAD -- the state safegit's commit, conclusion and undo paths all refuse -- and the refusal names the raw-git command for the rare deliberate case. --detach, -C, --force/--discard-changes, --orphan and --merge are refused, each naming why. There is NO file mode and no 'safegit checkout': git's checkout of files over the working tree destroys uncommitted work with no record anywhere, so safegit does not implement it at all |
merge |
merge one branch into the current one, and author the result: safegit decides the fast-forward itself and moves the ref under compare-and-swap, or runs git's merge machinery with --no-ff --no-commit and commits the staged result through its own pipeline -- so a merge safegit performed carries safegit's trailers, ran the repository's commit-msg hook and is reversible with 'safegit undo'. A merge git stops on a conflict parks, and 'safegit merge-continue' concludes it; 'safegit merge --continue' is refused and names that command. The command line is a deliberate subset of git's: exactly one branch (no octopus), no strategy selection, no --squash, no --edit and no --autostash. --no-commit computes the merge and leaves it parked even when it is clean. On an UNBORN branch -- one with no commits yet -- a merge can only be a fast-forward, so --no-ff and --no-commit are both refused there before git runs, with the reason |
rebase |
rebase the current branch onto upstream, guarded before git runs: the worktree operation lock -- held for the whole rebase, an interactive one's editor session included, so a second safegit process in this worktree waits that long -- then a check for uncommitted work, and then a refusal to rebase over ANOTHER operation git already has in flight (a rebase over a parked revert exits 0 and strands that revert's state files behind it, blocking every later commit). That last check is kind-scoped: it refuses an in-flight state that is not a REBASE, so a rebase's own --continue, --abort and --skip pass by construction. Those three stay git's either way: safegit has no verb that finishes a rebase. A rebase on an UNBORN branch -- one with no commits yet, so with nothing to replay -- is refused before git runs too, by a separate check beside that one. The command line is a deliberate subset of git's: exactly one upstream, --onto, -i, --autostash and --rebase-merges (whose optional value is attached only). The apply backend and its patch options, --exec and --root are refused, each naming why |
reset |
reset HEAD with guards that prevent accidental data loss. The worktree operation lock is taken for EVERY reset, because every reset moves HEAD; the uncommitted-work check applies to the modes that WRITE working-tree files -- --hard, --merge and --keep -- while --soft and --mixed move only the ref and the index. Which is which is derived from safegit's git classification table, never re-read from the argument list here. The command line is a deliberate subset of git's: one of the five modes with a commit. The PATHSPEC form is refused -- it writes the shared index entry by entry, which is the one file safegit's design keeps out of -- and so is --patch |
bisect |
binary search through commits to find a bug. The worktree operation lock is taken for EVERY invocation; the uncommitted-work check applies to the STEPPING subcommands (start, good, bad, old, new, skip, run, replay, reset), each of which checks another commit out, and not to the reporting ones (terms, log, view). Which is which is derived from safegit's git classification table, never kept as a list here -- and so is which subcommands may be typed at all: a word outside that vocabulary is refused before git runs, as is every option. 'bisect start' on an UNBORN branch is refused before git runs as well: there is no range of commits to search there |
push |
push refs to remote with pre-pre-push hooks and automatic retry |
pull |
fetch from a remote and merge what was fetched, with the merge strategy stated explicitly: --merge-strategy is required and has no default, so a pull never depends on git's own configuration to decide whether it may create a merge commit. The merge step is safegit's own -- the same one 'safegit merge' performs -- so a pull that cannot fast-forward produces a commit carrying safegit's trailers, run through the repository's commit-msg hook and reversible with 'safegit undo'; a pull that can fast-forward moves the ref under compare-and-swap and puts the index and the working tree in step with it. A merge git stops on a conflict parks, and 'safegit merge-continue' concludes it. --rebase is refused and names the two commands that do it |
doctor |
run diagnostic health checks on the repository and optionally repair issues |
cherry-pick |
apply ONE commit onto the current branch, and author the result: git computes the pick with --no-commit and safegit commits the staged result through its own pipeline -- so a pick safegit performed carries safegit's trailers, ran the repository's commit-msg hook and is reversible with 'safegit undo'. The AUTHOR is preserved from the commit being applied and the committer is you, which is git's own division. A pick git stops on a conflict parks -- safegit writes CHERRY_PICK_HEAD itself, because 'git cherry-pick --no-commit' does not -- and 'safegit cherry-pick-continue' concludes it; 'safegit cherry-pick --continue' is refused and names that command. The command line is a deliberate subset of git's: exactly one commit named as a commit (a range or any other revision set is refused, because a range hands the operation to git's sequencer even when it holds one commit), no --edit, no --ff, no --commit, no --cleanup, no signing and no empty-commit flags. --abort and --quit stay plain passthroughs, because they author nothing and are the way out of a state safegit must not refuse over; --no-commit is forwarded to git too, but it COMPUTES, so it takes the same refusal the restructured form does over an operation git already has in flight |
revert |
revert ONE commit by applying its inverse patch, and author the result: git computes the inverse with --no-commit and safegit commits the staged result through its own pipeline -- so a revert safegit performed carries safegit's trailers, ran the repository's commit-msg hook, is reversible with 'safegit undo', and declares the INVERSE of every move record the reverted commit declared. YOU are recorded as both author and committer, because a revert is your own new change rather than the reverted author's; that is git's own division and the opposite of what a cherry-pick does. A revert git stops on a conflict parks, and 'safegit revert-continue' concludes it; 'safegit revert --continue' is refused and names that command. The command line is a deliberate subset of git's: exactly one commit named as a commit (a range or any other revision set is refused, because a range hands the operation to git's sequencer even when it holds one commit), no --edit, no --commit, no --cleanup and no signing. --abort and --quit stay plain passthroughs, because they author nothing and are the way out of a state safegit must not refuse over; --no-commit is forwarded to git too, but it COMPUTES, so it takes the same refusal the restructured form does over an operation git already has in flight |
undo |
reverse the last safegit-authored operation using the oplog -- a commit, an mv, an amend, a reword, a merge, pull, cherry-pick or revert safegit's own commit pipeline authored, or a conclusion (merge-continue, cherry-pick-continue, revert-continue). A fast-forward is REFUSED rather than reversed: the tip it moved onto is a commit git created and safegit never rolls a branch back over one. It moves a REF and never the working tree |
unlock |
release one of safegit's OWN lock files -- a per-ref lock, this worktree's operation lock, or the repository-wide rewrite lock -- left behind by a safegit process that was killed while holding it. It has nothing to do with git's .git/index.lock or any other lock git takes for itself. A lock whose holder is still alive is refused; ordinarily nothing needs this command, because a stale lock is reclaimed automatically by the next contender and 'safegit doctor --action fix' sweeps them, so it is the last-resort path for a filesystem where that reclamation cannot work |
scan |
search git history for regex pattern matches across all objects and working tree files, scanning blobs, commit messages, tag annotations, and trailers with optional scope filtering and commit range selection |
version |
print safegit version, Go runtime version, and git version |
| backup | push, list, and restore per-branch history backups held in the tool-owned refs/backups namespace on a remote, so uncommitted-to-the-world work survives a lost machine without ever touching refs/heads |
backup backup |
push the current branch to its backup slot refs/backups/ on the remote, after fetching that slot and refusing when it holds commits your history does not contain; the push is pinned with --force-with-lease to the exact SHA that was just observed (or to "this ref must not exist" for a first backup), so a concurrent backup from another machine is rejected rather than clobbered; plain git equivalent: git push --force-with-lease=refs/backups/: HEAD:refs/backups/ |
backup list |
list every backup slot present on the remote with the branch name and the commit each slot points at, so you can see which branches are backed up from which machine before restoring one; plain git equivalent: git ls-remote 'refs/backups/*' |
backup restore |
fetch the current branch's backup slot from the remote and fast-forward the branch onto it, refusing when the local branch carries commits the backup does not contain so no local work is ever discarded; plain git equivalent: git fetch refs/backups/ && git merge --ff-only FETCH_HEAD |
| config | show, get, or set safegit configuration key-value pairs |
config show |
show all configuration values currently in effect for this repository, including built-in defaults and any user overrides from the .git/safegit/config.json file, printed as key-value pairs to stdout for inspection and debugging purposes |
config get |
get the current value of a single configuration key from the .git/safegit/config.json file, printing the raw value to stdout so it can be captured by scripts or used in automation pipelines |
config set |
set a configuration key to a new value in the .git/safegit/config.json file, creating the file if it does not exist yet, and persisting the change for all future safegit invocations in this repository |
| hook | manage pre-pre-push hook scripts that run before every push |
hook list |
list every pre-pre-push hook location safegit knows about, with its origin, its path and whether it is executable, so you can audit which checks run before every push. Three origins are shown: local, the tool-owned live store under the repository's common .git/safegit/hooks that hook install writes to and every worktree shares; tracked, the hooks the CHECKOUT provides in .safegit/hooks, which run because they are in that directory whether or not git tracks them, so cloning a repository and pushing from that checkout runs the repository's scripts; and legacy, the pre-migration location in git's own .git/hooks, which nothing runs any more and safegit hook migrate relocates. Non-executable and non-hook entries are listed too, because the hook an operator is asking about is usually the one that is NOT running |
hook run |
run all installed pre-pre-push hooks (or a single named hook) immediately without performing an actual push, so you can verify that all configured hooks pass before committing to a real push operation. Discovery's own verdicts about the checkout reach here too: exit 24 while a hook is still in the pre-migration .git/hooks location, and exit 25 when a discovered hook is not executable, in EITHER store -- never a silent skip and never a 'no hooks to run', because a command whose whole purpose is to say whether the checks pass must not exit 0 because a check was passed over |
hook install |
install a pre-pre-push hook by copying a script file into the live store under the repository's common .git/safegit/hooks directory and making it executable, so that safegit push runs it before any network I/O occurs. The store is keyed on the common git dir, so a hook installed from a linked worktree is the same hook every worktree of the repository runs. An existing destination is refused rather than overwritten |
hook remove |
remove one hook from the tool-owned live store under the repository's common .git/safegit/hooks directory by name, naming either the store-relative path such as pre-pre-push.d/20-lint or just the base name, so a hook can be retired or replaced without deleting files by hand; a name that resolves only to a hook the checkout provides in .safegit/hooks is refused, because removing that one means deleting the file and committing that, and a name carried by both stores removes the live one and says the other still runs |
hook migrate |
move safegit's hooks out of git's own .git/hooks directory into the tool-owned .git/safegit/hooks store, relocating the pre-pre-push file and the pre-pre-push.d directory unconditionally because those two names are the only ones safegit ever wrote there, and reporting success with an explanation when there is nothing to move. Both ends are under the repository's COMMON git dir, which is git's own hook directory in every worktree, so migration run from a linked worktree relocates the repository's hooks |
| author | audit and rewrite commit author/committer identity — list all identities, check against expected values, and rewrite name or email across history |
author list |
list all distinct author and committer identities across the entire commit history, showing name, email, role, and commit count for each unique identity — useful for auditing repositories with multiple contributors or detecting unwanted identity variations such as typos, old email addresses, or bot accounts that should be consolidated before a rewrite |
author check |
check that all commits use the expected author and committer identity by scanning every commit in the repository history, reporting any deviations with the exact commit hashes and mismatched fields, and suggesting the corresponding safegit author rewrite command to fix each deviation found |
author rewrite |
rewrite author and committer name or email across all commit history using git filter-branch style rewriting, replacing every occurrence of the old identity with the new one in both author and committer fields while preserving timestamps, commit messages, tree contents, and parent relationships so the rewritten history is otherwise identical to the original |
| scrub | surgically rewrite git history to remove or replace sensitive content: file and match rewrite the commits, trees and blobs of a range the caller selects (--from or --entire-history), run applies a recipe of such operations in one coordinated pass, and verify only reads -- it confirms that the patterns named on its command line are absent from the whole object store |
scrub file |
replace or remove a specific file across every commit in a SELECTED RANGE of history -- --from or --entire-history, one of which is required -- rewriting each affected commit tree to either substitute the file's contents with those of a sanitized file or delete it entirely from every snapshot in that range. A --delete also removes the move records naming that path, whole, since the path they refer to is being erased; a --replace-with edits no message, because the path still exists and a record naming it is still true |
scrub match |
replace every occurrence of a regex pattern in the blobs, commit messages and tag annotations of a SELECTED RANGE of history -- --from or --entire-history, one of which is required -- rewriting commit trees so that sensitive values like secrets and credentials are removed from every snapshot in that range. A move record is rewritten as a record rather than as text: the substitution applies to the decoded paths and the pair is re-encoded, so the output always parses, a pattern written against the escaped spelling matches nothing, and a substitution whose result would no longer be a move is refused before any ref moves |
scrub run |
execute a multi-operation scrub recipe from a TOML file, applying all pattern replacements and file removals across history in a single coordinated pass with topological commit ordering, overlap detection between operations, and automatic verification that no matched content survives in the rewritten object store — use --diff to preview all changes as unified diffs before committing to the rewrite |
scrub verify |
confirm that the patterns named on the command line -- repeatable --pattern regexes, the operations of a scrub recipe file, or both -- are absent from every object in the git object store, scanning blobs, commit messages, and tag annotations and reporting detailed per-pattern pass or fail results with match locations for any violations found |
Tree-mutating commands (switch, pull, merge, rebase, reset, bisect,
cherry-pick, revert) run behind two coordination guards -- the worktree
operation lock, then the uncommitted-work check -- and each validates its
command line against an explicit allowlist first, so an option safegit has not
considered is refused rather than passed to git. Some add a third check on top:
the forms that COMPUTE an operation (merge, pull, and cherry-pick and
revert in both their own and their forwarded --no-commit shape) refuse over
any operation git already has in flight, and rebase refuses over one that is
not itself a rebase -- so a rebase's own --continue still reaches git.
Every commit safegit makes is safegit's own. merge, cherry-pick,
revert and pull use git only to compute a result and then write the commit
through safegit's pipeline: trailered, commit-msg-hooked, recorded, and
reversible with safegit undo. Each of them applies ONE thing, and a
multi-commit or revision-range command line is refused naming the sequential
form. safegit rebase is the one declared exception, where git replays and
authors, always.
Branch navigation is safegit switch (a branch name, or -c to make one).
There is no safegit checkout and no file-restoration mode at all: git's
checkout -- <path> destroys uncommitted work with no record anywhere, so it is
inexpressible here rather than refused.
When git parks a merge, cherry-pick or revert on a conflict, safegit finishes it
rather than git: merge-continue, cherry-pick-continue and revert-continue
take one --resolve 'path=ours|theirs|worktree|delete' per conflicted path and
write the commit themselves, refusing a declaration that does not match the
conflict, content that still holds a conflict block, or a working-tree write
that would destroy a hand edit no side of the conflict accounts for.
The commit pipeline has two phases. Phase A (parallel-safe) resolves the tip of
the target branch, creates a temporary index seeded from it, stages the
requested files, and builds the tree and commit objects -- all without touching
the shared .git/index. Phase B acquires a per-ref lock, re-reads the tip to
confirm it has not moved, and updates the ref with a compare-and-swap. If it
did move, the pipeline retries from Phase A against the new tip (re-parenting
the commit) with random jitter to avoid thundering-herd stampedes under heavy
concurrency.
The parent is resolved BEFORE the index rather than after the tree: the other order lets another session's commit land in between and produces a commit whose tree is based on the old tip but whose parent is the new one, silently dropping that session's files.
See .stricttools/docs/architecture.md for the full architecture specification.
Run safegit config show to view every setting, safegit config get <key> to
read one, and safegit config set <key> <value> to change one.
| Key | Default | Description |
|---|---|---|
commit.casMaxAttempts |
5 | Max CAS retry attempts for ref updates |
commit.autoBumpParent |
(unset, and an unset one is a refusal) | Whether a commit in a submodule also commits the parent's moved gitlink |
lock.acquireTimeoutSeconds |
30 | Timeout waiting for a lock |
hooks.preprepush.timeoutSeconds |
1800 | Timeout for pre-pre-push hook execution |
push.retryAttempts |
3 | Number of push retry attempts |
Those five are the whole key set: anything else is an unknown-key error. There is no oplog size or rotation setting -- the operation log is append-only and complete by design, and nothing truncates it.
Configuration is stored in .git/safegit/config.json. Remove the entire
.git/safegit/ directory to return to vanilla git -- or run safegit doctor --action uninstall, which does it for the whole repository (every worktree's
state directory plus the shared store) and enumerates every path before it asks
for confirmation.
- Same-machine concurrency only. Lock staleness detection uses PID liveness
checks and hostname comparison. On network filesystems (NFS, CIFS),
safegit doctorwarns about reduced lock atomicity guarantees. Cross-machine lock reclaim is refused when the hostname doesn't match. - The filesystem must support hard links and
flock(2). A lock is published by writing its record to a temporary sibling andlink(2)-ing it into place, so the published file is complete the instant it exists; and a stale lock is reclaimed only under an exclusiveflockon the lock file, with an inode identity re-check, so two contenders can never both "reclaim" the same lock. Whereflockdoes not work, nothing is reclaimed at all: contenders time out andsafegit unlock <name>is the recovery path. - PID reuse. On Linux, safegit records the holder's process start identity
from
/procin the lock file and compares it against the current start time of whatever holds that PID, so a recycled PID is detected and a live holder is never mistaken for one. On other platforms the comparison is unavailable and fails closed: a reused PID keeps an orphan lock looking alive, andsafegit unlockrefuses to clear a lock whose holder appears alive, so such a lock has to be removed by hand from.git/safegit/locks/. Where the holder really is gone,safegit unlock refs/heads/mainclears it. - Linux and macOS only. Windows is not supported and is not built: the release binaries cover linux and darwin on amd64 and arm64, and safegit uses Unix-only syscalls for locking, signals and process management. WSL (Windows Subsystem for Linux) works, since it runs the Linux binary natively.
MIT