safegit v0.26.0 /Concurrency Guide
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

Go go
const ExitCASExhausted  = 7

Exit codes for commit-specific errors.

#ExitWriteTree

Go go
const ExitWriteTree     = 9

#ExitCommitTree

Go go
const ExitCommitTree    = 10

#AmendRequest

Go go
type AmendRequest struct

AmendRequest holds inputs for an amend operation.

#AmendResult

Go go
type AmendResult struct

AmendResult is the JSON-serializable output of a successful amend.

#RewordRequest

Go go
type RewordRequest struct

RewordRequest holds inputs for a reword operation.

#RewordResult

Go go
type RewordResult struct

RewordResult is the JSON-serializable output of a successful reword.

#CommitError

Go go
type CommitError struct

CommitError carries a structured exit code alongside the error message.

#Pipeline

Go go
type Pipeline struct

Pipeline orchestrates the full commit flow.

#FileSpec

Go go
type FileSpec struct

FileSpec describes a file with optional hunk selection for staging.

#CommitRequest

Go go
type CommitRequest struct

CommitRequest holds all inputs for a single commit operation.

#CommitResult

Go go
type CommitResult struct

CommitResult is the JSON-serializable output of a successful commit.

#Pipeline.Amend

Go go
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

Go go
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

Go go
func (e *CommitError) Error() string { return e.Message }

Error returns the error message.

#Pipeline.Execute

Go go
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

Go go
type TmpIndex struct

TmpIndex represents a per-invocation temporary index directory.

#New

Go go
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 - where random is 4 bytes hex.

#NewEmpty

Go go
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

Go go
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

Go go
func GarbageCollectDryRun(safegitDir string) ([]string, error)

GarbageCollectDryRun reports orphan tmp directories without removing them. Returns the directory names that would be cleaned.

#TmpIndex.Cleanup

Go go
func (t *TmpIndex) Cleanup() error

Cleanup removes the temporary index directory.

  1. Create a private temporary index. A directory is created at .git/safegit/tmp/<pid>-<random>/ containing its own index file. The <pid> prefix enables garbage collection of leaked directories from crashed processes. The random suffix (4 bytes of crypto/rand) prevents collisions when the same PID is reused.
  1. 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.
  1. 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.
  1. Build the tree and commit objects. git write-tree produces a tree SHA from the temporary index, and git commit-tree creates 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

Go go
type RefLock struct

RefLock represents an acquired lock on a git ref.

#Acquire

Go go
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

Go go
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

Go go
func ForceRelease(locksBaseDir, ref string) error

ForceRelease 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

Go go
func ParsePID(lockPath string) (int, error)

ParsePID reads the lock file and extracts the pid= value.

#RefLock.Release

Go go
func (l *RefLock) Release() error

Release removes the lock file.

  1. Acquire the ref lock. An exclusive lock file is created at .git/safegit/locks/refs/heads/<branch>.lock using O_CREAT|O_EXCL (atomic exclusive creation). Only one process can hold this lock at a time.
  1. 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.
  1. Update the ref. git update-ref advances the branch to the new commit, passing the expected old value for a git-level CAS as belt-and-suspenders protection.
  1. 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=myhost

The 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:

  1. 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.
  1. 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).
  1. 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.
  1. 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

What makes it safe vs regular git
ConcernRegular git commitsafegit commit
Index isolationAll sessions share .git/indexEach invocation gets a private temporary index
File leakageSession A's staged files appear in Session B's commitImpossible -- staging is isolated per-invocation
Branch tip racegit commit reads HEAD, stages, commits non-atomicallyTwo-phase pipeline with per-ref lock and CAS verification
Concurrent same-branch commitsUndefined behavior, potential corruptionSerialized via lock + CAS retry with guaranteed linear history
Crash recovery.git/index.lock left behind, requires manual rmStale locks auto-recovered via PID liveness checks
Concurrent different-branch commitsPossible but fragile (index is shared)Fully parallel -- separate lock files per branch
Untracked file handlingRequires git add (mutates shared index)Files listed after -- are staged atomically in the private index
Index lock contentiongit 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

Go go
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

Go go
type AuthorInfo struct

AuthorInfo holds the name, email, and raw git date for an author or committer.

#CommitInfo

Go go
type CommitInfo struct

CommitInfo holds the parsed contents of a git commit object.

#TreeEntry

Go go
type TreeEntry struct

TreeEntry represents an entry from git ls-tree (blob, tree, or other object).

#ObjectEntry

Go go
type ObjectEntry struct

ObjectEntry holds one object read from a git cat-file --batch stream.

#ObjectIterator

Go go
type ObjectIterator struct

ObjectIterator streams objects from a long-running git cat-file process.

#WithDir

Go go
func WithDir(ctx context.Context, gitDir, workTree string) context.Context

WithDir 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

Go go
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

Go go
func RunWithEnv(ctx context.Context, env []string, args ...string) (stdout, stderr string, err error)

RunWithEnv executes a git command with additional environment variables.

#RunWithEnvStdin

Go go
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

Go go
func RepoRoot(ctx context.Context) (string, error)

RepoRoot returns the absolute path to the repository root.

#GitDir

Go go
func GitDir(ctx context.Context) (string, error)

GitDir returns the path to the .git directory.

#HeadRef

Go go
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

Go go
func RevParse(ctx context.Context, rev string) (string, error)

RevParse resolves a revision to a full SHA.

#ReadTree

Go go
func ReadTree(ctx context.Context, indexPath, treeish string) error

ReadTree populates a temporary index from a treeish (commit/tree SHA or ref).

#WriteTree

Go go
func WriteTree(ctx context.Context, indexPath string) (string, error)

WriteTree writes the index content as a tree object, returns the tree SHA.

#CommitTree

Go go
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

Go go
func UpdateRef(ctx context.Context, ref, newSHA, oldSHA string) error

UpdateRef atomically updates a ref using compare-and-swap. oldSHA is the expected current value; if empty, the ref must not exist.

#DeleteRef

Go go
func DeleteRef(ctx context.Context, ref, oldSHA string) error

DeleteRef atomically deletes a ref using compare-and-swap. oldSHA is the expected current value of the ref.

#AddFile

Go go
func AddFile(ctx context.Context, indexPath, filePath string) error

AddFile stages a file into a custom index.

#RmCached

Go go
func RmCached(ctx context.Context, indexPath, filePath string) error

RmCached removes a file or directory from a custom index without touching the working tree.

#IsTracked

Go go
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

Go go
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

Go go
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

Go go
func SyncMainIndex(ctx context.Context, treeish string) error

SyncMainIndex 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

Go go
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

Go go
func RunPassthrough(ctx context.Context, args ...string) error

RunPassthrough 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

Go go
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

Go go
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

Go go
func IsIgnored(ctx context.Context, filePath string) (bool, error)

IsIgnored checks whether a file matches a gitignore rule.

#IsAncestorOf

Go go
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

Go go
func CommitMessage(ctx context.Context, rev string) (string, error)

CommitMessage returns the full commit message of the given revision.

#ParseCommit

Go go
func ParseCommit(ctx context.Context, sha string) (CommitInfo, error)

ParseCommit reads and parses a commit object by SHA using git cat-file.

#CommitTreeWithAuthor

Go go
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

Go go
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

Go go
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

Go go
func HashObject(ctx context.Context, path string) (string, error)

HashObject returns the blob SHA for a file without writing to the object store.

#HashObjectWrite

Go go
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

Go go
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

Go go
func CatFileBlob(ctx context.Context, sha string) ([]byte, error)

CatFileBlob reads blob content by SHA via git cat-file -p.

#MkTree

Go go
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 " \t\n".

#CatFileBatchAll

Go go
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

Go go
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

Go go
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

Go go
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

Go go
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

Go go
func SplitNonEmpty(s string) []string

SplitNonEmpty splits s by newlines and returns only non-empty lines.

#ForEachRef

Go go
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

Go go
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 "\t" per line; the map key is the refname.

#ObjectIterator.Next

Go go
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

Go go
func (it *ObjectIterator) Close() error

Close 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

Go go
type Entry struct

Entry represents a single operation log entry.

#Append

Go go
func Append(safegitDir string, entry Entry) error

Append 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

Go go
func Read(safegitDir string) ([]Entry, error)

Read returns all entries from the log file.

#LogSize

Go go
func LogSize(safegitDir string) (int64, error)

LogSize returns the size of the log file in bytes. Returns 0 if not found.

#Rotate

Go go
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

Go go
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

Go go
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

Go go
func TipSHA(extra map[string]interface{}) string

TipSHA 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 undo rolls 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 by CLAUDE_CODE_SESSION_ID), so one session's undo never affects another's commits.
  • Bypass detection. safegit doctor compares the oplog's last known ref state against the actual branch tip. If they diverge, someone committed via raw git 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

Go go
type DirtyState struct

DirtyState describes why the working tree is not clean.

#Check

Go go
func Check(ctx context.Context, safegitDir string) (*DirtyState, error)

Check inspects the working tree. Returns nil if clean.

#DirtyState.Refuse

Go go
func (d *DirtyState) Refuse(operation string) string

Refuse 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

Go go
const SessionKey = "Claude-Code-Session-Id"

SessionKey is the git trailer key used to record the Claude Code session ID.

#Inject

Go go
func Inject(message string) string

Inject 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

Go go
func AppendCustom(message string, trailers []string) string

AppendCustom 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

Go go
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

Go go
func ReplaceIdentity(message, oldName, newName, oldEmail, newEmail string) string

ReplaceIdentity 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 " with "newName ". When only one of name/email is changing (the other old value is empty), only the provided part is replaced. Non-identity trailers and the message body are never modified. If no changes are made, the original message is returned unchanged.

#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

Go go
type Config struct

Config holds safegit configuration persisted in config.json.

#CommitConfig

Go go
type CommitConfig struct

CommitConfig holds commit-related settings.

#LockConfig

Go go
type LockConfig struct

LockConfig holds ref-lock acquisition settings.

#HooksConfig

Go go
type HooksConfig struct

HooksConfig holds hook-related settings.

#PrePrePushConfig

Go go
type PrePrePushConfig struct

PrePrePushConfig holds pre-pre-push hook timeout settings.

#PushConfig

Go go
type PushConfig struct

PushConfig holds push retry settings.

#LogConfig

Go go
type LogConfig struct

LogConfig holds operation log size settings.

#DefaultConfig

Go go
func DefaultConfig() Config

DefaultConfig returns the default safegit configuration.

#SafegitDir

Go go
func SafegitDir(gitDir string) string

SafegitDir returns the path to .git/safegit/ given a .git directory path.

#SharedSafegitDir

Go go
func SharedSafegitDir(ctx context.Context, gitDir string) string

SharedSafegitDir returns the safegit directory under the common .git dir. For normal repos this is identical to SafegitDir(gitDir). For worktrees it returns /safegit so that lock files are shared across all worktrees, ensuring proper serialization of ref updates.

The parameter accepts either the git directory (.git) or the safegit directory (.git/safegit); callers use both forms.

#IsInitialized

Go go
func IsInitialized(gitDir string) bool

IsInitialized checks whether the .git/safegit/ directory exists.

#Init

Go go
func Init(gitDir string) error

Init creates the .git/safegit/ directory structure and writes default config.json. Idempotent: returns nil if already initialized.

#EnsureInitialized

Go go
func EnsureInitialized(gitDir string) error

EnsureInitialized auto-initializes .git/safegit/ if it doesn't exist yet.

#Uninstall

Go go
func Uninstall(gitDir string) error

Uninstall removes the .git/safegit/ directory entirely. In worktree setups, also cleans up the shared lock directory.

#LoadConfig

Go go
func LoadConfig(gitDir string) (*Config, error)

LoadConfig reads and parses config.json from the safegit directory.

#LoadConfigFrom

Go go
func LoadConfigFrom(path string) (*Config, error)

LoadConfigFrom reads and parses config from an arbitrary path.

#MarshalConfig

Go go
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

Go go
func ConfigPath(gitDir string) string

ConfigPath is the path SaveConfig writes to for the given git dir.

#SaveConfigTo

Go go
func SaveConfigTo(path string, cfg *Config) error

SaveConfigTo writes config to an arbitrary path.

#SaveConfig

Go go
func SaveConfig(gitDir string, cfg *Config) error

SaveConfig writes config back to config.json.

#GetConfigValue

Go go
func GetConfigValue(cfg *Config, key string) (interface{}, error)

GetConfigValue returns the value for a dot-separated config key.

#SetConfigValue

Go go
func SetConfigValue(cfg *Config, key, value string) error

SetConfigValue sets a dot-separated config key to the given string value.

#ValidConfigKeys

Go go
func ValidConfigKeys() []string

ValidConfigKeys returns the list of supported config keys.

#Config.Validate

Go go
func (c *Config) Validate() error

Validate 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:

  1. **start record.** 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.
  1. **refs record.** Written after refs and tags have been updated. Contains all tag rewrite records.
  1. **complete record.** 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

Go go
type TmpIndex struct

TmpIndex represents a per-invocation temporary index directory.

#New

Go go
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 - where random is 4 bytes hex.

#NewEmpty

Go go
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

Go go
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

Go go
func GarbageCollectDryRun(safegitDir string) ([]string, error)

GarbageCollectDryRun reports orphan tmp directories without removing them. Returns the directory names that would be cleaned.

#TmpIndex.Cleanup

Go go
func (t *TmpIndex) Cleanup() error

Cleanup 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.

Search