On this page
How safegit enables multiple AI agent sessions to share a single git worktree without corrupting each other's commits or leaking files.
#Concurrency Guide
This guide explains safegit's concurrency model: what problems arise when multiple AI sessions share a git worktree, how safegit solves them, and what guarantees it provides.
#The problem: multiple agents, one worktree
When multiple Claude Code sessions (or any concurrent processes) work in the same git repository, standard git commands race on the shared .git/index file. The index is a single mutable staging area that every git add and git commit reads and writes. Two sessions running these commands at the same time can produce commits containing files from both sessions, silently leaking one session's work into another's commit.
This is not a theoretical concern. AI agent orchestration systems routinely run multiple sessions against the same checkout, and the race window is wide enough that it triggers regularly under normal workloads.
#Two-phase commit pipeline
safegit splits the commit operation into two distinct phases to achieve both parallelism and correctness. Phase A builds the commit object using a private temporary index, fully isolated from other sessions. Phase B acquires a per-branch lock and updates the ref via compare-and-swap.
#internal/commit
Amend and Reword implement tip-commit rewriting with CAS safety.
#ExitCASExhausted
const ExitCASExhausted = 7Exit codes for commit-specific errors.
#ExitWriteTree
const ExitWriteTree = 9#ExitCommitTree
const ExitCommitTree = 10#AmendRequest
type AmendRequest structAmendRequest holds inputs for an amend operation.
#AmendResult
type AmendResult structAmendResult is the JSON-serializable output of a successful amend.
#RewordRequest
type RewordRequest structRewordRequest holds inputs for a reword operation.
#RewordResult
type RewordResult structRewordResult is the JSON-serializable output of a successful reword.
#CommitError
type CommitError structCommitError carries a structured exit code alongside the error message.
#Pipeline
type Pipeline structPipeline orchestrates the full commit flow.
#FileSpec
type FileSpec structFileSpec describes a file with optional hunk selection for staging.
#CommitRequest
type CommitRequest structCommitRequest holds all inputs for a single commit operation.
#CommitResult
type CommitResult structCommitResult is the JSON-serializable output of a successful commit.
#Pipeline.Amend
func (p *Pipeline) Amend(ctx context.Context, req AmendRequest) (*AmendResult, error)Amend rewrites the tip of the current branch with new files staged. Uses tmp index seeded from HEAD, stages files, builds a new commit with parent = HEAD^ and lock-and-CAS updates the ref.
#Pipeline.Reword
func (p *Pipeline) Reword(ctx context.Context, req RewordRequest) (*RewordResult, error)Reword rewrites only the commit message of the tip of the current branch. Tree and parent remain unchanged. Retries on CAS miss.
#CommitError.Error
func (e *CommitError) Error() string { return e.Message }Error returns the error message.
#Pipeline.Execute
func (p *Pipeline) Execute(ctx context.Context, req CommitRequest) (*CommitResult, error)Execute runs the full two-phase commit pipeline. On CAS miss it retries from Phase A up to Config.Commit.CASMaxAttempts times.
#Phase A: parallel-safe object construction
Every safegit commit invocation creates its own temporary index file in a unique directory under .git/safegit/tmp/, completely isolated from the shared .git/index and from every other concurrent invocation, so multiple sessions can stage files simultaneously without interference.
#internal/index
Package index manages per-invocation temporary git indexes so each safegit invocation stages into its own index seeded from HEAD, avoiding contention. No safegit operation writes to the shared .git/index; all staging goes through temporary indexes created here.
#TmpIndex
type TmpIndex structTmpIndex represents a per-invocation temporary index directory.
#New
func New(ctx context.Context, safegitDir string, treeish string) (*TmpIndex, error)New creates a temporary index directory and seeds the index from the given treeish. The directory name is
#NewEmpty
func NewEmpty(safegitDir string) (*TmpIndex, error)NewEmpty creates a temporary index directory with an empty index (no tree). Used for root commits in repos with no prior commits.
#GarbageCollect
func GarbageCollect(safegitDir string) (removed int, err error)GarbageCollect removes tmp directories whose owning PID is no longer alive. Returns the count of directories removed.
#GarbageCollectDryRun
func GarbageCollectDryRun(safegitDir string) ([]string, error)GarbageCollectDryRun reports orphan tmp directories without removing them. Returns the directory names that would be cleaned.
#TmpIndex.Cleanup
func (t *TmpIndex) Cleanup() errorCleanup removes the temporary index directory.
- Create a private temporary index. A directory is created at
.git/safegit/tmp/<pid>-<random>/containing its ownindexfile. The<pid>prefix enables garbage collection of leaked directories from crashed processes. The random suffix (4 bytes ofcrypto/rand) prevents collisions when the same PID is reused.
- Seed from the branch tip. The temporary index is populated from the current branch tip via
git read-tree, giving the invocation a snapshot of the committed state. All subsequent staging happens against this private copy.
- Stage only the specified files. Files listed after
--are staged into the temporary index. Untracked files are added; deleted files are removed. No other files can leak in because no other process writes to this index.
- Build the tree and commit objects.
git write-treeproduces a tree SHA from the temporary index, andgit commit-treecreates a commit object pointing to that tree with the resolved parent. Both are content-addressed and idempotent -- multiple processes creating the same objects simultaneously is harmless.
At the end of Phase A, a valid commit object exists in the object store, but no ref points to it. If the process crashes here, the commit is unreachable and will eventually be garbage collected. Nothing is corrupted.
#Phase B: serialized ref update
Phase B acquires a per-branch lock file using atomic exclusive creation, verifies the branch tip has not moved since Phase A via compare-and-swap, and atomically updates the ref to point at the new commit object.
#internal/lock
go:build !windows
#RefLock
type RefLock structRefLock represents an acquired lock on a git ref.
#Acquire
func Acquire(locksBaseDir, safegitDir, ref, op string, timeout time.Duration) (*RefLock, error)Acquire attempts to acquire a lock on the given ref. locksBaseDir is the safegit directory whose "locks/" subtree holds lock files; for worktrees this should be the shared (common) safegit dir so that all worktrees serialize on the same lock. safegitDir is the worktree-local safegit dir used for oplog writes (stale-lock recovery events). It uses O_CREAT|O_EXCL for atomic creation. If the lock is held by a dead process, it is automatically replaced. Uses exponential backoff polling bounded by timeout.
#IsStale
func IsStale(path string) (bool, error)IsStale checks whether the process that holds the lock file is dead. Returns (true, nil) if the lock is stale and can be reclaimed. A corrupt or zero-length lock file (no parseable PID) is treated as stale.
Hardening checks beyond simple PID liveness: - If the lock contains a host= field that differs from the local hostname, refuse to reclaim (the PID belongs to a different machine's namespace). - On Linux, if the process started after the lock was created, the PID was reused by a new process and the lock is stale.
#ForceRelease
func ForceRelease(locksBaseDir, ref string) errorForceRelease unconditionally removes the lock file for a ref. locksBaseDir is the safegit directory whose "locks/" subtree holds lock files; for worktrees this should be the shared (common) safegit dir.
#ParsePID
func ParsePID(lockPath string) (int, error)ParsePID reads the lock file and extracts the pid= value.
#RefLock.Release
func (l *RefLock) Release() errorRelease removes the lock file.
- Acquire the ref lock. An exclusive lock file is created at
.git/safegit/locks/refs/heads/<branch>.lockusingO_CREAT|O_EXCL(atomic exclusive creation). Only one process can hold this lock at a time.
- CAS check. With the lock held, the branch tip is re-resolved. If it matches the parent used in Phase A, the commit is valid. If it has moved (another session committed between Phase A and Phase B), the commit is stale -- this is a CAS (compare-and-swap) miss.
- Update the ref.
git update-refadvances the branch to the new commit, passing the expected old value for a git-level CAS as belt-and-suspenders protection.
- Release the lock and record the operation. The lock file is removed, and the operation is appended to the oplog.
#CAS retry on miss
When the branch tip moves between Phase A and Phase B, the entire pipeline retries from Phase A: a new temporary index is seeded from the updated branch tip, files are re-staged, and new tree and commit objects are built. This retry loop runs up to commit.casMaxAttempts times (default 5, configurable up to 200). Random jitter (1-10ms) is injected between retries to break thundering-herd stampedes.
The stress tests verify that 100 parallel commits to the same branch all succeed with linear history and no lost files.
#Locking strategy
safegit uses per-ref file locks, not a global repository lock. This means commits to different branches proceed in parallel with zero contention -- each branch has its own lock file under .git/safegit/locks/refs/heads/.
#Lock file format
Each lock file is a plain text file recording the holder's identity with PID, timestamp, operation type, and hostname fields that enable liveness checks and diagnostics when a lock appears stale or is held longer than expected:
pid=12345
ts=2026-04-26T11:39:42.123Z
op=commit
host=myhostThe pid and host fields enable liveness checks. The op field is informational for diagnostics.
#Stale lock recovery
When a process crashes while holding a lock (killed by the OS, power failure, or OOM), the lock file persists on disk and blocks all other sessions from committing to that branch. safegit detects stale locks and recovers from them automatically using PID liveness checks, host verification, and PID reuse detection:
- PID liveness check. On each poll iteration, the lock holder's PID is checked via
kill(pid, 0). If the process is dead, the lock is stale.
- Host check. If the lock file contains a
host=field that differs from the local hostname, the PID check is skipped -- the PID belongs to a different machine's namespace (relevant for NFS/shared filesystems).
- PID reuse detection (Linux). On Linux, if
/proc/<pid>was created after the lock file, the PID was recycled by the kernel and the lock is stale despite the PID appearing alive.
- Corrupt lock files. A zero-length or unparseable lock file (from a crash mid-write) is treated as stale.
Stale lock recovery is logged to the oplog as a lock_recovered event.
#Polling and backoff
Waiters use exponential backoff polling: 10ms, 20ms, 50ms, 100ms, 200ms, 500ms, capped at 1s. The total wait is bounded by lock.acquireTimeoutSeconds (default 30s). Past the timeout, safegit commit exits with an error identifying the lock holder.
#Signal handling
Lock files are registered for cleanup on SIGINT and SIGTERM. If safegit is interrupted while holding a lock, the signal handler removes the lock file before exiting. This prevents the most common source of stale locks in interactive use.
#What makes it safe vs regular git
| Concern | Regular git commit | safegit commit |
|---|---|---|
| Index isolation | All sessions share .git/index | Each invocation gets a private temporary index |
| File leakage | Session A's staged files appear in Session B's commit | Impossible -- staging is isolated per-invocation |
| Branch tip race | git commit reads HEAD, stages, commits non-atomically | Two-phase pipeline with per-ref lock and CAS verification |
| Concurrent same-branch commits | Undefined behavior, potential corruption | Serialized via lock + CAS retry with guaranteed linear history |
| Crash recovery | .git/index.lock left behind, requires manual rm | Stale locks auto-recovered via PID liveness checks |
| Concurrent different-branch commits | Possible but fragile (index is shared) | Fully parallel -- separate lock files per branch |
| Untracked file handling | Requires git add (mutates shared index) | Files listed after -- are staged atomically in the private index |
| Index lock contention | git takes .git/index.lock for many operations | --no-optional-locks flag prevents git from taking advisory index locks |
#The --no-optional-locks flag
Every git command safegit invokes is prefixed with --no-optional-locks, which prevents git from refreshing the shared .git/index as a side effect of read-only operations like git status or git diff. Without this flag, even read operations can contend on .git/index.lock with concurrent writers.
#internal/git
Package git wraps os/exec calls to the git binary and is the sole interface through which safegit interacts with git plumbing commands. All functions shell out to git and return structured results; no other package may invoke git directly.
#ErrDetachedHead
var ErrDetachedHead = fmt.Errorf("HEAD is detached (not on a branch); check out a branch first or use --branch")ErrDetachedHead is returned when HEAD is not on a branch.
#AuthorInfo
type AuthorInfo structAuthorInfo holds the name, email, and raw git date for an author or committer.
#CommitInfo
type CommitInfo structCommitInfo holds the parsed contents of a git commit object.
#TreeEntry
type TreeEntry structTreeEntry represents an entry from git ls-tree (blob, tree, or other object).
#ObjectEntry
type ObjectEntry structObjectEntry holds one object read from a git cat-file --batch stream.
#ObjectIterator
type ObjectIterator structObjectIterator streams objects from a long-running git cat-file process.
#WithDir
func WithDir(ctx context.Context, gitDir, workTree string) context.ContextWithDir returns a context that carries git directory overrides. All git functions that receive this context will automatically set GIT_DIR, GIT_WORK_TREE, and cmd.Dir on the subprocess, targeting the specified repo regardless of the process's current working directory.
#Run
func Run(ctx context.Context, args ...string) (stdout, stderr string, err error)Run executes a git command and returns stdout, stderr, and any error.
#RunWithEnv
func RunWithEnv(ctx context.Context, env []string, args ...string) (stdout, stderr string, err error)RunWithEnv executes a git command with additional environment variables.
#RunWithEnvStdin
func RunWithEnvStdin(ctx context.Context, env []string, stdin []byte, args ...string) (stdout, stderr string, err error)RunWithEnvStdin executes a git command with environment variables and stdin data.
#RepoRoot
func RepoRoot(ctx context.Context) (string, error)RepoRoot returns the absolute path to the repository root.
#GitDir
func GitDir(ctx context.Context) (string, error)GitDir returns the path to the .git directory.
#HeadRef
func HeadRef(ctx context.Context) (string, error)HeadRef returns the current branch ref (e.g. "refs/heads/main"). Returns ErrDetachedHead if HEAD is not on a branch.
#RevParse
func RevParse(ctx context.Context, rev string) (string, error)RevParse resolves a revision to a full SHA.
#ReadTree
func ReadTree(ctx context.Context, indexPath, treeish string) errorReadTree populates a temporary index from a treeish (commit/tree SHA or ref).
#WriteTree
func WriteTree(ctx context.Context, indexPath string) (string, error)WriteTree writes the index content as a tree object, returns the tree SHA.
#CommitTree
func CommitTree(ctx context.Context, treeSHA, parentSHA, message string) (string, error)CommitTree creates a commit object from a tree SHA and parent, returns commit SHA. If parentSHA is empty, creates a root commit.
#UpdateRef
func UpdateRef(ctx context.Context, ref, newSHA, oldSHA string) errorUpdateRef atomically updates a ref using compare-and-swap. oldSHA is the expected current value; if empty, the ref must not exist.
#DeleteRef
func DeleteRef(ctx context.Context, ref, oldSHA string) errorDeleteRef atomically deletes a ref using compare-and-swap. oldSHA is the expected current value of the ref.
#AddFile
func AddFile(ctx context.Context, indexPath, filePath string) errorAddFile stages a file into a custom index.
#RmCached
func RmCached(ctx context.Context, indexPath, filePath string) errorRmCached removes a file or directory from a custom index without touching the working tree.
#IsTracked
func IsTracked(ctx context.Context, filePath string) (bool, error)IsTracked checks whether a file is tracked by git (present in HEAD tree). Uses cat-file instead of ls-files because safegit never writes to the main index -- files committed via safegit exist in HEAD but not in .git/index.
#ListSkipWorktreeFiles
func ListSkipWorktreeFiles(ctx context.Context) ([]string, error)ListSkipWorktreeFiles returns the paths of all files with the skip-worktree flag set in the main index. It parses git ls-files -v output, selecting lines that start with "S " (the skip-worktree indicator).
#ListTrackedIgnoredFiles
func ListTrackedIgnoredFiles(ctx context.Context) ([]string, error)ListTrackedIgnoredFiles returns the paths of all files that are tracked in the index but ignored by .gitignore rules. These are files that were once committed and later gitignored -- read-tree --reset -u would overwrite them, destroying local modifications (e.g., config files with secrets).
#SyncMainIndex
func SyncMainIndex(ctx context.Context, treeish string) errorSyncMainIndex updates the main .git/index to match the given treeish. This makes git status/diff reflect the committed state after safegit commits. Skip-worktree flags are preserved across the read-tree rebuild.
#SyncMainIndexWithWorktree
func SyncMainIndexWithWorktree(ctx context.Context, treeish string) ([]string, error)SyncMainIndexWithWorktree updates the main .git/index AND the working tree to match the given treeish. Uses --reset -u, so the working tree must be clean before calling. Needed after history rewrites (scrub) where committed blobs have changed and the working tree must reflect the new content.
Tracked+gitignored files (committed then later gitignored, e.g., config files with secrets) are protected: skip-worktree is set before read-tree so --reset -u does not overwrite them. Pre-existing skip-worktree flags are also preserved.
Returns the list of protected tracked+gitignored paths (empty if none).
#RunPassthrough
func RunPassthrough(ctx context.Context, args ...string) errorRunPassthrough executes a git command with stdin/stdout/stderr wired to the terminal (os.Stdin, os.Stdout, os.Stderr). It prepends --no-optional-locks like Run, but does not capture output -- suitable for interactive/pager commands.
#CommonGitDir
func CommonGitDir(ctx context.Context) (string, error)CommonGitDir returns the path to the shared .git directory. For normal repos this equals GitDir(); for worktrees it returns the main .git dir that is shared across all worktrees. Lock files should live here so that worktrees committing to the same branch serialize correctly.
#CommonGitDirOf
func CommonGitDirOf(ctx context.Context, gitDir string) (string, error)CommonGitDirOf returns the common git directory for a given gitDir. Unlike CommonGitDir, this does not depend on the process working directory; it sets GIT_DIR explicitly so the result is always relative to gitDir.
#IsIgnored
func IsIgnored(ctx context.Context, filePath string) (bool, error)IsIgnored checks whether a file matches a gitignore rule.
#IsAncestorOf
func IsAncestorOf(ctx context.Context, commitSHA, descendantSHA string) (bool, error)IsAncestorOf checks whether commitSHA is an ancestor of (or equal to) descendantSHA. Uses git merge-base --is-ancestor which exits 0 if true, 1 if false, and other codes on error.
#CommitMessage
func CommitMessage(ctx context.Context, rev string) (string, error)CommitMessage returns the full commit message of the given revision.
#ParseCommit
func ParseCommit(ctx context.Context, sha string) (CommitInfo, error)ParseCommit reads and parses a commit object by SHA using git cat-file.
#CommitTreeWithAuthor
func CommitTreeWithAuthor(ctx context.Context, treeSHA string, parentSHAs []string, message string, author, committer AuthorInfo) (string, error)CommitTreeWithAuthor creates a commit object with explicit author and committer identity, returning the new commit SHA.
#LsTreeAll
func LsTreeAll(ctx context.Context, treeish string) ([]TreeEntry, error)LsTreeAll returns all blob entries in the given treeish, recursively. Empty trees return an empty slice, not an error.
#LsTree
func LsTree(ctx context.Context, treeish string) ([]TreeEntry, error)LsTree returns all entries (blobs and subtrees) at one level of the given treeish, without recursing into subtrees. Each entry includes Mode and ObjectType so callers can distinguish blobs from trees.
#HashObject
func HashObject(ctx context.Context, path string) (string, error)HashObject returns the blob SHA for a file without writing to the object store.
#HashObjectWrite
func HashObjectWrite(ctx context.Context, path string) (string, error)HashObjectWrite hashes a file and writes the blob to the object store, returning the blob SHA.
#HashObjectWriteBytes
func HashObjectWriteBytes(ctx context.Context, data []byte) (string, error)HashObjectWriteBytes writes in-memory bytes as a blob to the object store via git hash-object -w --stdin, returning the blob SHA.
#CatFileBlob
func CatFileBlob(ctx context.Context, sha string) ([]byte, error)CatFileBlob reads blob content by SHA via git cat-file -p.
#MkTree
func MkTree(ctx context.Context, entries []TreeEntry) (string, error)MkTree creates a tree object from a slice of TreeEntry values and returns the tree SHA. Each entry must have Mode, ObjectType, SHA, and Path populated. Input is piped to git mktree as "
#CatFileBatchAll
func CatFileBatchAll(ctx context.Context) (*ObjectIterator, error)CatFileBatchAll starts a git cat-file --batch-all-objects --batch subprocess and returns an ObjectIterator for streaming the results. The caller must call Close() when done. Respects WithDir context overrides.
#CatFileBatchSHAs
func CatFileBatchSHAs(ctx context.Context, shas []string) (*ObjectIterator, error)CatFileBatchSHAs starts a git cat-file --batch subprocess that reads only the specified SHAs, and returns an ObjectIterator for streaming the results. Unlike CatFileBatchAll (which enumerates all objects), this feeds specific SHAs via stdin using bytes.NewReader to avoid pipe deadlock: if output exceeds the OS pipe buffer (~64KB), git blocks on stdout write while the caller is still writing to stdin. With bytes.NewReader, git reads stdin from memory at its own pace. The caller must call Close() when done.
#RunWithGitDir
func RunWithGitDir(ctx context.Context, gitDir string, workTree string, args ...string) (stdout, stderr string, err error)RunWithGitDir executes a git command against a specific git directory and work tree, rather than relying on cwd-based discovery. Sets GIT_DIR, GIT_WORK_TREE, and cmd.Dir so both git and cwd-relative paths resolve against the target repo.
#CatFileBatchAllWithDir
func CatFileBatchAllWithDir(ctx context.Context, gitDir string) (*ObjectIterator, error)CatFileBatchAllWithDir starts a git cat-file --batch-all-objects --batch subprocess targeting a specific git directory. Returns an ObjectIterator for streaming the results. The caller must call Close() when done.
#CatFileBatchSHAsWithDir
func CatFileBatchSHAsWithDir(ctx context.Context, gitDir string, shas []string) (*ObjectIterator, error)CatFileBatchSHAsWithDir starts a git cat-file --batch subprocess targeting a specific git directory, reading only the specified SHAs. Sets GIT_DIR so git resolves objects from the target repo rather than the cwd repo. The caller must call Close() when done.
#SplitNonEmpty
func SplitNonEmpty(s string) []stringSplitNonEmpty splits s by newlines and returns only non-empty lines.
#ForEachRef
func ForEachRef(ctx context.Context, format string, prefixes ...string) ([]string, error)ForEachRef runs git for-each-ref with the given format and optional ref prefixes (e.g. "refs/heads/", "refs/tags/"). Returns one line per ref.
#LsRemoteBulk
func LsRemoteBulk(ctx context.Context, remote, pattern string) (map[string]string, error)LsRemoteBulk runs git ls-remote against a remote with a pattern and returns a map of refname to SHA. The output format of git ls-remote is "
#ObjectIterator.Next
func (it *ObjectIterator) Next() (*ObjectEntry, error)Next reads the next non-tree object from the stream. Trees are silently skipped. Returns io.EOF when the stream ends.
#ObjectIterator.Close
func (it *ObjectIterator) Close() errorClose kills the subprocess if it is still running and waits for it to exit.
#Operation log and undo
Every mutating operation is recorded in the oplog at .git/safegit/log, an append-only JSONL file. Writes use O_APPEND with flock-guarded appends, and each entry is kept under 4096 bytes to preserve POSIX atomic append guarantees. This means concurrent oplog writes from parallel commits never produce corrupted or interleaved lines.
#internal/oplog
Package oplog implements the append-only JSONL operation log that records every mutating operation for undo support and audit trail purposes. Each entry appends one JSON line to .git/safegit/log via O_APPEND (lines must be < 4096 bytes for POSIX atomicity).
#Entry
type Entry structEntry represents a single operation log entry.
#Append
func Append(safegitDir string, entry Entry) errorAppend writes a single entry to the log file atomically. The entry is serialized as a single JSON line. Lines exceeding 4096 bytes are rejected to preserve atomic write guarantees.
#Read
func Read(safegitDir string) ([]Entry, error)Read returns all entries from the log file.
#LogSize
func LogSize(safegitDir string) (int64, error)LogSize returns the size of the log file in bytes. Returns 0 if not found.
#Rotate
func Rotate(safegitDir string, maxSizeMB int) (bool, error)Rotate renames the current log to log.1 (overwriting any existing log.1) and creates a fresh empty log file. Returns true if rotation happened.
#LastRefUpdate
func LastRefUpdate(safegitDir, ref string) (*Entry, error)LastRefUpdate finds the most recent oplog entry for a given ref that records a new tip SHA. It accepts any op type and tries multiple extra keys ("sha", "to", "result") since different ops store the new tip under different names. Returns nil if no matching entry is found.
#LastRefUpdateForSession
func LastRefUpdateForSession(safegitDir, ref, sessionID string) (*Entry, error)LastRefUpdateForSession finds the most recent oplog entry for a given ref and session ID that records a new tip SHA. Same logic as LastRefUpdate but with an additional session ID filter. Returns nil if no matching entry is found.
#TipSHA
func TipSHA(extra map[string]interface{}) stringTipSHA extracts the new-tip SHA from an oplog entry's extra map. It checks "sha", "to", and "result" in order. Returns "" if none found.
The oplog enables:
- Session-scoped undo.
safegit undorolls back the last commit, amend, or reword by reading the oplog and restoring the previous ref value. Undo is scoped to the current session (identified byCLAUDE_CODE_SESSION_ID), so one session's undo never affects another's commits.
- Bypass detection.
safegit doctorcompares the oplog's last known ref state against the actual branch tip. If they diverge, someone committed via rawgit commit, bypassing safegit's isolation guarantees.
- Audit trail. Every commit, amend, undo, and lock recovery is timestamped and attributed to a PID and session.
#Coordination guards for tree-mutating operations
Not all git operations can be safely parallelized. Commands that mutate the working tree -- checkout, merge, rebase, reset, pull -- can clobber uncommitted work from other sessions. safegit wraps these commands with a coordination guard that checks whether the working tree is clean before proceeding.
#internal/coord
Package coord implements the coordination layer that prevents concurrent agents from corrupting the working tree by guarding tree-mutating operations. It checks whether the working tree is clean before allowing checkout, merge, rebase, reset, and pull to proceed.
#DirtyState
type DirtyState structDirtyState describes why the working tree is not clean.
#Check
func Check(ctx context.Context, safegitDir string) (*DirtyState, error)Check inspects the working tree. Returns nil if clean.
#DirtyState.Refuse
func (d *DirtyState) Refuse(operation string) stringRefuse formats a refusal message from a DirtyState.
If any tracked file is modified or any untracked file exists, the guarded command is refused with exit code 5 and a suggestion to commit the outstanding changes first. This prevents one session from running safegit checkout other-branch while another session has uncommitted edits in the working tree.
The guard uses git diff HEAD (not git status, which depends on the potentially stale main index) to detect modifications, ensuring accuracy even when the shared index is out of sync with the actual committed state.
#Session attribution via trailers
Each commit created by safegit includes a Claude-Code-Session-Id trailer (when the environment variable is set), enabling post-hoc attribution of which session created which commit. This is not a concurrency mechanism -- it is an audit trail that makes it possible to trace commit ownership in multi-session repositories.
#internal/trailer
Package trailer injects git trailers (key-value metadata lines) into commit messages for AI agent traceability and session attribution.
#SessionKey
const SessionKey = "Claude-Code-Session-Id"SessionKey is the git trailer key used to record the Claude Code session ID.
#Inject
func Inject(message string) stringInject reads CLAUDE_CODE_SESSION_ID from the environment and appends a Claude-Code-Session-Id trailer to the commit message if present. For amend: deduplicates if the same session ID already exists as a trailer; keeps both if a different session's trailer is present.
#AppendCustom
func AppendCustom(message string, trailers []string) stringAppendCustom appends user-provided trailers to the commit message. Each trailer should be in "Key: Value" format. If trailers is empty, the message is returned unchanged. Follows the same format as Inject: appends to an existing trailer block, or adds a blank line separator first.
#SplitBodyTrailers
func SplitBodyTrailers(message string) (body, trailerBlock string)SplitBodyTrailers splits a commit message into the body (everything before the trailer block) and the trailer block (trailing Key: Value lines preceded by a blank line). Continuation lines (indented lines following a trailer) are included in the trailer block.
If the message has no trailers, body is the entire message and trailerBlock is empty. If the entire message consists of trailer- format lines with no blank-line separator, body is empty and trailerBlock is the entire message.
#ReplaceIdentity
func ReplaceIdentity(message, oldName, newName, oldEmail, newEmail string) stringReplaceIdentity replaces author identity in identity-bearing trailers (lines whose key ends in "-by", such as Signed-off-by, Co-authored-by, Reviewed-by, Acked-by). Within those trailer lines, it replaces "oldName
#Worktree support
Git worktrees allow multiple checkouts of the same repository. safegit handles this by placing lock files under the common .git directory (the one shared by all worktrees), not under each worktree's local .git file. This ensures that commits to the same branch from different worktrees are properly serialized.
The SharedSafegitDir function resolves the common git directory at runtime, so lock files always land in the shared location regardless of which worktree initiated the commit.
#internal/repo
Package repo manages the .git/safegit/ data directory including initialization, configuration loading, validation, and path helpers for all state files.
#Config
type Config structConfig holds safegit configuration persisted in config.json.
#CommitConfig
type CommitConfig structCommitConfig holds commit-related settings.
#LockConfig
type LockConfig structLockConfig holds ref-lock acquisition settings.
#HooksConfig
type HooksConfig structHooksConfig holds hook-related settings.
#PrePrePushConfig
type PrePrePushConfig structPrePrePushConfig holds pre-pre-push hook timeout settings.
#PushConfig
type PushConfig structPushConfig holds push retry settings.
#LogConfig
type LogConfig structLogConfig holds operation log size settings.
#DefaultConfig
func DefaultConfig() ConfigDefaultConfig returns the default safegit configuration.
#SafegitDir
func SafegitDir(gitDir string) stringSafegitDir returns the path to .git/safegit/ given a .git directory path.
#SharedSafegitDir
func SharedSafegitDir(ctx context.Context, gitDir string) stringSharedSafegitDir returns the safegit directory under the common .git dir. For normal repos this is identical to SafegitDir(gitDir). For worktrees it returns
The parameter accepts either the git directory (.git) or the safegit directory (.git/safegit); callers use both forms.
#IsInitialized
func IsInitialized(gitDir string) boolIsInitialized checks whether the .git/safegit/ directory exists.
#Init
func Init(gitDir string) errorInit creates the .git/safegit/ directory structure and writes default config.json. Idempotent: returns nil if already initialized.
#EnsureInitialized
func EnsureInitialized(gitDir string) errorEnsureInitialized auto-initializes .git/safegit/ if it doesn't exist yet.
#Uninstall
func Uninstall(gitDir string) errorUninstall removes the .git/safegit/ directory entirely. In worktree setups, also cleans up the shared lock directory.
#LoadConfig
func LoadConfig(gitDir string) (*Config, error)LoadConfig reads and parses config.json from the safegit directory.
#LoadConfigFrom
func LoadConfigFrom(path string) (*Config, error)LoadConfigFrom reads and parses config from an arbitrary path.
#MarshalConfig
func MarshalConfig(cfg *Config) ([]byte, error)MarshalConfig renders the config exactly as SaveConfig would write it. It is split out so callers can mint the write as an effect instead of performing it here, which is what lets --dry-run record a config change without making one.
#ConfigPath
func ConfigPath(gitDir string) stringConfigPath is the path SaveConfig writes to for the given git dir.
#SaveConfigTo
func SaveConfigTo(path string, cfg *Config) errorSaveConfigTo writes config to an arbitrary path.
#SaveConfig
func SaveConfig(gitDir string, cfg *Config) errorSaveConfig writes config back to config.json.
#GetConfigValue
func GetConfigValue(cfg *Config, key string) (interface{}, error)GetConfigValue returns the value for a dot-separated config key.
#SetConfigValue
func SetConfigValue(cfg *Config, key, value string) errorSetConfigValue sets a dot-separated config key to the given string value.
#ValidConfigKeys
func ValidConfigKeys() []stringValidConfigKeys returns the list of supported config keys.
#Config.Validate
func (c *Config) Validate() errorValidate checks that all config values are within acceptable ranges.
#The scrub system
History rewriting (safegit scrub) is an inherently non-concurrent operation -- it rewrites every commit in a range, changing SHAs throughout the history. safegit handles this with a dedicated rewrite lock and a crash-safe record trail.
#Rewrite lock
Scrub operations acquire a repository-wide coordination lock on safegit/rewrite (not a per-ref lock like commits use) before modifying any refs. This prevents two scrub operations from running simultaneously and producing inconsistent history, since history rewriting changes every commit SHA downstream of the rewrite point.
#Crash-safe rewrite maps
Every scrub persists a three-phase record to .git/safegit/rewrite-maps.jsonl, a flock-guarded JSONL file that enables crash recovery and post-scrub orchestration by recording the full old-to-new commit SHA mapping, tag rewrites, and cleanup status:
- **
startrecord.** Written before any refs move. Contains the full old-to-new commit SHA mapping and the pre-rewrite state of all remote-tracking refs. If the process crashes after this point, the mapping is recoverable.
- **
refsrecord.** Written after refs and tags have been updated. Contains all tag rewrite records.
- **
completerecord.** Written after cleanup (reflog expiry, object pruning) and HEAD resolution. Contains the new HEAD and cleanup status.
These three phases ensure that no matter when a crash occurs, an orchestrator (like rlsbl release scrub) can determine exactly what state the repository is in and resume or roll back appropriately.
#Rewrites in release-managed repositories
A history rewrite invalidates metadata that lives outside the commit graph: changelog entries that name commit hashes, remote tags, and the forge releases attached to them. safegit does not try to prevent that by refusing the rewrite. It performs the rewrite and records the full old-to-new mapping in the journal, and the release tooling repairs the damage afterwards: its changelog hash-resolution check fails loudly on the dangling hashes, rlsbl changelog remap --from-journal rewrites them from this journal, and rlsbl release reconcile re-pushes the moved tags and recreates their GitHub Releases. Detection and repair are the contract; the journal is the interface.
#The rewrite walk
The scrub walker processes commits in topological order (parents before children), applying a transform function to each commit. Parent SHAs are remapped through the growing old-to-new map, so descendant commits automatically inherit rewritten parents. When the transform changes a commit's tree, message, or author, a new commit object is created; otherwise the original SHA is preserved as an identity mapping.
After the walk, the shared finalization pipeline updates all branch and tag refs to point at rewritten commits, syncs the main index with the rewritten HEAD, expires tainted reflog entries, and prunes old objects.
#Common concurrent workflows
#Multiple sessions editing different files on the same branch
This is the most common case. Each session runs safegit commit -m "message" -- file1 file2 with its own files. The commits are serialized by the per-branch lock, and CAS retry ensures each commit builds on the latest branch tip. All commits land in linear order with no lost files.
#Multiple sessions working on different branches
Commits to different branches proceed in full parallel with zero lock contention, since each branch has its own independent lock file under .git/safegit/locks/refs/heads/. This is the ideal workflow for multi-agent orchestration where each session can be assigned its own feature branch for maximum throughput.
#Cross-branch commits
A session can commit to a branch other than the one currently checked out using --branch <name>. This does not move HEAD or modify the working tree -- it only updates the target branch's ref. The commit uses the target branch's tip as its parent and acquires the target branch's lock, so it serializes correctly with other commits to that branch.
#Amend while another session is committing
safegit commit --amend uses the same two-phase pipeline and per-ref lock as regular commits. The amended commit replaces the branch tip atomically. If another session commits between the amend's Phase A and Phase B, the CAS check catches the conflict and retries.
#Cleanup after crashes
safegit doctor --fix performs three cleanup tasks relevant to concurrency: removing orphan temporary index directories left by crashed processes via PID liveness checks, releasing stale lock files whose owning processes are no longer alive, and detecting raw git commits that bypassed safegit's isolation guarantees by comparing the oplog against actual branch ref state:
- Orphan tmp directories. Temporary index directories from crashed processes are identified by checking PID liveness and removed.
- Stale lock files. Lock files held by dead processes are removed.
- Bypass detection. Commits made via raw
git commit(bypassing safegit) are detected by comparing the oplog against actual ref state.
#Temporary index garbage collection
Each invocation cleans up its own temporary index directory via defer on normal exit. When a process is killed, the directory leaks. The index garbage collector scans .git/safegit/tmp/ for directories whose owning PID (encoded in the directory name) is no longer alive, and removes them.
#internal/index
Package index manages per-invocation temporary git indexes so each safegit invocation stages into its own index seeded from HEAD, avoiding contention. No safegit operation writes to the shared .git/index; all staging goes through temporary indexes created here.
#TmpIndex
type TmpIndex structTmpIndex represents a per-invocation temporary index directory.
#New
func New(ctx context.Context, safegitDir string, treeish string) (*TmpIndex, error)New creates a temporary index directory and seeds the index from the given treeish. The directory name is
#NewEmpty
func NewEmpty(safegitDir string) (*TmpIndex, error)NewEmpty creates a temporary index directory with an empty index (no tree). Used for root commits in repos with no prior commits.
#GarbageCollect
func GarbageCollect(safegitDir string) (removed int, err error)GarbageCollect removes tmp directories whose owning PID is no longer alive. Returns the count of directories removed.
#GarbageCollectDryRun
func GarbageCollectDryRun(safegitDir string) ([]string, error)GarbageCollectDryRun reports orphan tmp directories without removing them. Returns the directory names that would be cleaned.
#TmpIndex.Cleanup
func (t *TmpIndex) Cleanup() errorCleanup removes the temporary index directory.
This runs automatically during safegit doctor --fix and can also be triggered manually. The garbage collector never removes directories belonging to live processes, so it is safe to run while other sessions are actively committing.