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.
#StepIndexReconcile
const StepIndexReconcile = "reconciling the shared index"StepIndexReconcile is the pipeline's own aftercare: putting the repository's shared index in step with the commit that was just made. It is the only step the pipeline itself performs after the ref update, so it is the only member of the vocabulary this package declares; the rest of the family's steps belong to the commands that run them.
#IndexBaseParentTree
const IndexBaseParentTree IndexBase = iotaIndexBaseParentTree seeds the temporary index from the tree of the commit being built on -- the branch tip, or an empty index on an unborn ref. It is the zero value and what every ordinary commit uses: the commit contains the parent's content with the named paths applied over it.
#IndexBaseSharedIndex
const IndexBaseSharedIndexIndexBaseSharedIndex seeds the temporary index from a copy of the repository's shared index (.git/index), so that whatever is staged there becomes the commit's content. It exists for the conclusion of an operation git has in flight, where the conflict resolution the operator staged lives in that index and nowhere else. The copy is a copy: the shared index is read and never written.
#IndexEditBlob
const IndexEditBlob IndexEditKind = iotaIndexEditBlob places Mode and SHA at stage 0, replacing every slot the path holds.
#IndexEditWorktree
const IndexEditWorktreeIndexEditWorktree stages the working-tree file at Path, whatever it now holds, replacing every slot the path holds.
#IndexEditRemove
const IndexEditRemoveIndexEditRemove removes every slot the path holds, so the commit does not contain it. The working-tree file is not touched.
#ErrTreeUnchanged
var ErrTreeUnchanged = errors.New("the commit's tree is identical to its parent's")ErrTreeUnchanged is the cause behind the empty-commit refusal, so a caller can recognize that particular refusal without matching on its text.
The refusal's own message names --allow-empty, which is the answer for safegit commit. It is not the answer for a caller that has no such flag: a conclusion command wraps this with the ways out that exist for IT rather than pointing at a flag it does not offer.
#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.
It reaches the caller wrapped as often as not -- a staging failure is annotated with the path it happened on before it leaves the pipeline -- so callers must find it with errors.As, never with a bare type assertion.
#PartialError
type PartialError structPartialError is the pipeline's commit-stands verdict: the ref MOVED, the commit object is the branch's tip, and a step that runs after the ref update did not finish.
It is a distinct type rather than a CommitError with a code because the two say opposite things about what happened. A CommitError is a refusal -- nothing was written, and re-running the command after fixing the cause is the remedy. This says the operation succeeded and something it owed afterwards did not, so re-running would make a SECOND commit. A caller that cannot tell them apart retries by default, which is the expensive guess.
The result value is returned ALONGSIDE it, not instead of it: a caller has to be able to report which commit stands, and that is the result's job.
#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.
#IndexBase
type IndexBase intIndexBase selects what a commit's temporary index starts from.
It is an explicit input rather than something inferred, because the two answers mean different things about where the commit's content came from: the parent tree plus the paths the caller named, or a resolution the operator already staged.
#CommitResult
type CommitResult structCommitResult is the JSON-serializable output of a successful commit.
#IndexEditKind
type IndexEditKind intIndexEditKind names what one edit does to the temporary index.
#IndexEdit
type IndexEdit structIndexEdit is one caller-decided change to the temporary index, applied after the index is seeded and before anything else is staged.
It carries no conflict vocabulary on purpose. Deciding that --resolve path=theirs means "the stage-3 blob" is the conclusion engine's job, and it is done once, against the shared index, before the pipeline runs; what arrives here is a mode, an object name and a path, which the pipeline applies without interpreting. That split is what keeps the pipeline free of any opinion about merges while still writing the objects inside a dry run's quarantine and re-applying every edit on a compare-and-swap retry.
#RefusedMove
type RefusedMove structRefusedMove is one move safegit's delta suggested and its fences declined to record: the paths on each side and why nothing was written.
A refusal is reported rather than dropped, because the caller who performed the move needs to know safegit did not record it.
They ride the commit payload itemized, and the aggregate stderr notice counts them by reason.
#RefUpdate
type RefUpdate interfaceRefUpdate performs the compare-and-swap that makes a commit the branch's tip -- the ONE mutation the commit pipeline makes on the world.
It is an input rather than a call into internal/git because that update is the seam a preview stops at: the caller's implementation mints it through the framework's effects handle, which performs it in an executing run and RECORDS it in a dry run, so a preview's would-do log states the move it would make instead of rendering an empty body. There is exactly one mint site, inside the compare-and-swap retry loop; a second one alongside it would fire twice per commit and record a move the loop had already made.
The interface is the pipeline's own rather than the framework's handle type because the handle's result carrier cannot be constructed outside the framework (its settled-ness is unexported and every accessor panics when unsettled), which would leave this package's own tests unable to supply one. Production has a single implementation and it IS the handle.
#NativeHooks
func NativeHooks() []stringNativeHooks returns the git hooks safegit itself executes, in the order a commit reaches them. It is exported because doctor reports the hooks safegit does NOT run, and that report is only true while it derives the set from here rather than restating it.
#ApplyIndexEditsTo
func ApplyIndexEditsTo(ctx context.Context, indexPath string, edits []IndexEdit) errorApplyIndexEditsTo applies the same edits to an index OUTSIDE the pipeline, resolving the repository root itself.
It exists for one caller: a conclusion, once its commit is real, has to put the shared index in the same state before reconciling it, because the reconciliation deliberately preserves unmerged stages and would otherwise preserve the very conflict the conclusion just resolved. An empty indexPath means the shared index.
#CanonicalRel
func CanonicalRel(repoRoot, arg string, followFinal bool) (string, error)CanonicalRel is canonicalRel for a caller outside this package.
It exists for safegit mv, whose arguments are paths a person typed at a shell prompt exactly as a positional path is, and which must therefore mean the same thing from a subdirectory as from the root. One canonicalizer, so a path named in a mv argument and the same path named anywhere else in the commit family resolve to the same repo-relative spelling.
#BeginPreview
func BeginPreview(ctx context.Context, dryRun bool) (previewCtx context.Context, area string, cleanup func(), err error)BeginPreview opens the throwaway area a dry run works in, and returns the context every git call of that run must be made with.
It is the SINGLE preview-area constructor for the whole tool. It lives here because the commit pipeline was the first command family to write objects in a preview, and it is exported because it is no longer the only one: the honest --dry-run of merge, cherry-pick and revert computes its answer with git merge-tree --write-tree, which writes real tree and blob objects and therefore needs exactly this quarantine. Two constructors would be two answers to "where does a preview put the objects it makes".
A preview computes real answers: it stages into an index, writes a tree and (for a commit or an amend) builds the commit object, because that is the only honest way to report which paths the operation would change. Every one of those steps writes objects. Pointing GIT_OBJECT_DIRECTORY at a directory inside the preview area is what keeps the arithmetic exact while leaving the repository's own object store untouched -- the tree SHA the preview reports is the tree SHA the real run would produce, and the objects behind it go away with the area.
Ordering, which is not incidental: the repository's object store is resolved BEFORE the quarantine is installed and the quarantine directory is created BEFORE it is named in an environment, because a GIT_OBJECT_DIRECTORY that points at a directory that does not exist makes git fail repository discovery outright ("not a git repository").
The area's lifetime is the whole operation, not one compare-and-swap attempt: the retry loop stages again from scratch each time, and an area per attempt would multiply directories for no gain. Not a dry run returns the context unchanged and an empty area, which is the signal to stage under the safegit directory as an executing run does.
#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.
#CommitError.Unwrap
func (e *CommitError) Unwrap() error { return e.Err }Unwrap exposes the underlying cause to errors.Is/errors.As.
#PartialError.Error
func (e *PartialError) Error() stringError states the outcome in the order it has to be read: the commit first, the failure second.
#PartialError.Unwrap
func (e *PartialError) Unwrap() error { return e.Err }Unwrap exposes the cause to errors.Is/errors.As.
#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, baseDir string, treeish string) (*TmpIndex, error)New creates a temporary index directory under baseDir/tmp/ and seeds the index from the given treeish.
#NewEmpty
func NewEmpty(baseDir 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. baseDir has the same meaning as in New.
#NewFromFile
func NewFromFile(baseDir, srcIndexPath string) (*TmpIndex, error)NewFromFile creates a temporary index directory whose index starts as a byte copy of an existing index file -- in practice the repository's shared .git/index, which is where git records a conflict resolution the operator has staged. Copying is what keeps safegit's promise never to write to that file: the copy is what gets staged into and written out as a tree, and the original is only ever read.
The copy carries whatever the source held, unmerged stage entries included; a caller that copies a conflicted index and then asks for a tree gets git's own refusal to write one, which is the honest answer.
#GarbageCollectPlan
func GarbageCollectPlan(safegitDir string) ([]string, error)GarbageCollectPlan reports the tmp directories whose owning PID is no longer alive, as full PATHS, and removes nothing.
It is the whole scanner: the caller decides what to do with what it found. That split is what lets safegit doctor diagnose with it, preview with it, and repair with it -- the repair removing each planned path through the effects handle, so a dry run records the removals it would make instead of making them, and machine mode carries them. A scanner that removed as it walked could not be asked what it would do without doing it.
#TmpIndex.Cleanup
func (t *TmpIndex) Cleanup() errorCleanup removes the temporary index directory.
- Resolve the parent first. The tip of the target ref is read BEFORE the index exists, so the tree and the parent always describe the same starting point. Resolving it afterwards is the ordering safegit deliberately rejects: another agent's commit landing in between would produce a commit whose tree is based on the old tip but whose parent is the new one, silently dropping that agent's files.
- 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. A dry run creates it inside a throwaway preview area outside the repository instead, so.git/safegitis never touched -- not even created.
- Seed from the resolved parent. The temporary index is populated from that commit via
git read-tree, giving the invocation a snapshot of the committed state. All subsequent staging happens against this private copy. (A conclusion of a merge, cherry-pick or revert asks for the SHARED index as its base instead, because the thing being committed IS that staged result. The choice is an explicit input, never inferred.)
- 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.
- **Run the repository's
pre-commithook** against that index, so it sees exactly what this commit stages -- safegit builds commits from plumbing, so it runs the commit family itself rather than lettinggit commitdo it. The hook runs once per operation, not once per CAS attempt.
- Build the tree and commit objects.
git write-treeproduces a tree SHA from the temporary index;git diff-treeagainst the parent's tree is then what safegit reports as the commit's contents (never the arguments); and after the refusals -- an argument that contributed nothing, an unchanged tree -- thecommit-msghook runs andgit commit-treecreates the commit object.write-treeandcommit-treeare content-addressed and idempotent, so 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 by atomic publication, 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. A dry run does none of it: it takes no lock, makes no re-read, and records the ref update instead of performing it.
#internal/lock
Package lock provides ref-lock primitives for concurrent ref updates using atomic lock file creation (link(2) of a fully-written temporary sibling) and exponential backoff polling. PID liveness checks detect and clean up stale locks left by crashed processes.
#RewriteRef
const RewriteRef = "safegit/rewrite"RewriteRef is the repository-wide history-rewrite lock. It lives in the SHARED safegit directory, so every worktree of the repository contends on the same file: a rewrite changes object names for all of them. Taken by scrub file, scrub match, scrub run and author rewrite.
#OperationRef
const OperationRef = "safegit/operation"OperationRef is the worktree operation lock. It lives in the WORKTREE-LOCAL safegit directory, so two worktrees of the same repository operate independently while two processes in one worktree serialize. Taken by every command that mutates this worktree: the guarded passthroughs (switch, pull, merge, rebase, reset, bisect, cherry-pick, revert) and the commit pipeline's three entry points plus undo.
It is what makes a commit's in-flight-operation check meaningful. Without it a passthrough could create sequencer state (a conflicted merge, a stopped cherry-pick) in the window between a commit reading that state and updating the ref, and the commit would build its tree against a repository git considers mid-operation.
#RefLock
type RefLock structRefLock represents an acquired lock on a git ref.
#TimeoutError
type TimeoutError structTimeoutError is what Acquire returns when the timeout expired with a live holder still owning the lock. It is a distinct type because the exit code safegit reports for that outcome (exitcode.LockTimeout) is distinct: a caller decides between "someone else is working here, wait or investigate" and every other reason a lock could not be taken by asking errors.As for this type, never by matching the message text.
The message names the ref and the holder record, so a caller that surfaces the error verbatim tells the operator which process to look at.
#ReleasePending
func ReleasePending()ReleasePending removes every lock this process still holds.
It exists for the exit paths that do not unwind. A deferred Release covers a function that returns; it does not cover os.Exit, which safegit's die() and several handlers reach on a refusal. Without this, a command that acquired a lock and then died on an unrelated error would leave its lock file behind for the next contender to wait out and for doctor to report -- recoverable, since the holder is dead and the lock is therefore stale, but noise the process itself can prevent.
It is safe to call when no lock is held, and safe to call twice.
Each removal is identity-checked exactly as RefLock.Release is: a lock this process was force-released out of, and which another process has since re-taken, is left alone rather than deleted out from under its new holder.
#IsTimeout
func IsTimeout(err error) boolIsTimeout reports whether err is, or wraps, a lock acquisition timeout.
#Path
func Path(locksBaseDir, ref string) string { return lockPath(locksBaseDir, ref) }Path returns where the lock file for ref lives under locksBaseDir. Callers that inspect or remove a lock by name -- safegit unlock, doctor -- resolve it here rather than rebuilding the path themselves.
#NameFromPath
func NameFromPath(locksBaseDir, path string) stringNameFromPath is Path's inverse: it turns an absolute lock-file path under locksBaseDir back into the ref (or pseudo-ref) it locks, for display. A path outside that subtree, or one that is not a lock file, yields "".
#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). Creation is atomic, so exactly one caller wins it. If the lock is held by a dead process, it is automatically replaced -- see the reclamation rules in reclaim.go, which are what keep two contenders facing the same stale lock from both deciding they reclaimed it. Uses exponential backoff polling bounded by timeout.
#IsLockFile
func IsLockFile(name string) boolIsLockFile reports whether name (a bare file name, not a path) names a lock. It is the predicate every scan of a locks/ subtree uses.
#IsPublicationTemp
func IsPublicationTemp(name string) boolIsPublicationTemp reports whether name (a bare file name, not a path) is a lock-publication temporary sibling rather than a lock.
#IsStale
func IsStale(path string) boolIsStale reports whether the process that holds the lock file is dead, which is the only condition under which the lock may be reclaimed. An unreadable, corrupt or zero-length lock file (no parseable PID) is stale: it is what a crash mid-create leaves behind.
There is no error return. Every condition this function can meet is already a verdict -- a lock it cannot read is stale, a comparison it cannot make fails closed and the lock is left alone -- so a caller has nothing to decide from an error that the boolean does not already say.
Reclaiming a lock whose holder is still running lets two operations mutate the same ref at once, so every check beyond plain PID liveness must have positive evidence before it declares a lock stale: - 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). - PID reuse is decided by comparing the start identity recorded at acquire time against the current start time of whatever now holds that PID: a mismatch means the recorded holder is gone and an unrelated process inherited its PID. Nothing else -- and in particular no file timestamp -- is evidence of reuse. When either side of the comparison is missing (no start= field, or a platform that cannot report start times) the check fails closed and the lock is left alone.
#ForceRelease
func ForceRelease(locksBaseDir, ref string, remove func(string) error) 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.
remove performs the unlink, and it is a parameter for the same reason ReclaimIfStale's is: safegit unlock mints the removal through the effects handle, so releasing a lock is visible in machine mode and a preview records it instead of performing it. A remover that treats a missing path as success turns "there was no lock" into "the lock was released", so the caller's remover must report the absence -- os.Remove does.
#ParsePID
func ParsePID(lockPath string) (int, error)ParsePID reads the lock file and extracts the pid= value.
#ReclaimIfStale
func ReclaimIfStale(path string, remove func(string) error) boolReclaimIfStale removes the lock file at path if, and only if, its holder is genuinely gone -- the same judgement Acquire makes, under the same flock and the same inode identity re-check. It reports whether the file was removed.
It is the authority for every unattended removal of a lock nobody asked about by name: safegit doctor --action fix sweeps the locks subtree through it rather than judging staleness and then calling os.Remove, because between those two steps another process can reclaim the same stale lock and publish its own live one at that path, and the bare remove would delete THAT.
A false result is never a verdict that the lock is live: it also covers "the path is already gone", "another contender holds the flock right now" and "this filesystem cannot flock". All of those mean leave it alone and look again later, which is the fail-closed direction.
remove is how the file is unlinked, and it is a parameter because doctor's sweep mints its removals through the effects handle -- so a preview records them and machine mode carries them -- while the acquire path unlinks directly. It MUST report a missing path as an error: reclaimLocked reads a nil error as "the stale lock was removed", so a remover with RemoveAll semantics would turn every vanished path into a reclamation this process claims to have performed.
#TimeoutError.Error
func (e *TimeoutError) Error() string#RefLock.Release
func (l *RefLock) Release() errorRelease removes the lock file this process published, and nothing else.
- Acquire the ref lock. A lock file is published at
.git/safegit/locks/refs/heads/<branch>.lockby writing the holder's record into a temporary sibling andlink(2)-ing it into place.linkfails withEEXISTwhen the path exists, so exactly one process wins -- the same one-winner property an exclusive create gives, plus one an exclusive create does not: the published file is already complete, so no contender can read a half-made lock and mistake it for a crashed holder's leftover.
- 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-braces protection. A root commit passes the all-zero SHA, so creating a branch is conditional too.
- Record the operation, still holding the lock. The oplog append happens under the lock and before the shared index is reconciled, so a failure of that reconcile still leaves a commit
safegit undocan reverse. Then the shared index is reconciled (only when committing to the checked-out branch),post-commitruns, and the lock is released last.
#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; any positive integer, with no upper bound). Random jitter (1-10ms) is injected between retries to break thundering-herd stampedes. The repository's hooks are NOT re-run per attempt, and the move records the commit declares keep the identifiers they were minted with, so every attempt writes the same commit message.
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, hostname, and process start time -- the 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
start=736936933
started=2026-04-26T11:39:42.120ZThe pid, host and start fields enable liveness checks. start is the holder's start time in clock ticks since boot, read from /proc/<pid>/stat; it distinguishes the holder from a later process that inherits the same PID. The op and started fields are informational for diagnostics (started is start rendered as wall-clock time and is never compared). On platforms that cannot report a process start time, start and started are absent.
#Atomic publication
A lock is not created empty and then filled in. The holder's record is written into a temporary sibling -- .<name>.lock.tmp-<random>, a dot-file, so no scan of the locks subtree mistakes it for a lock -- and that complete file is published with link(2), which fails with EEXIST when the path already exists.
That gives the same one-winner guarantee an exclusive create gives, and one it does not: no reader ever sees a half-made lock. Creating the file first and writing the record afterwards left it existing-but-empty for an instant, and the rule "a zero-length lock file is stale" then condemned a lock whose owner was very much alive. This is why lock acquisition requires hard-link support on the filesystem holding .git.
#Stale lock reclamation
When a process crashes while holding a lock (killed by the OS, power failure, or OOM), the lock file persists on disk and blocks other sessions. safegit reclaims such a lock automatically -- but judging a lock stale is not what authorizes removing it:
- The judgement. A lock is stale when its holder's PID is dead (
kill(pid, 0)), or when its file is zero-length, corrupt or unreadable. Two conditions withhold that verdict rather than granting it: ahost=that differs from the local hostname (the PID belongs to another machine's namespace) means the lock is never judged stale, and PID reuse is decided ONLY by comparing thestartidentity recorded at acquire time against the current start time of whatever holds that PID now -- a mismatch means the kernel recycled the PID, a match means the original holder is still running whatever the file timestamps say. When either side is unavailable (nostartfield, or a platform that cannot report start times) the check fails closed and the lock is left alone.
- The authorization. The judgement above is a cheap pre-filter that keeps the common contended case off the slow path. Removal happens only after the contender opens the lock file, takes an exclusive
flock(2)on it, re-stats the path and confirms it still names the exact inode it holds, and re-judges staleness from that descriptor rather than from a fresh read of the path. Without that, two contenders could both judge the same lock stale and both remove it -- the second deleting the fresh lock the first had already published, leaving two processes believing they held the same ref.
- What a failure means. Anything other than a clean verdict -- another contender mid-reclaim, a permission error, a filesystem without
flock-- leaves the lock alone. That is the fail-closed direction: **stale-lock reclamation requires a workingflock(2)**, and where it does not work, contenders simply time out (exit 8) andsafegit unlock <name>is the recovery path.
A successful reclamation is logged to the oplog as a lock_recovered event.
#Release is identity-checked too
A holder removes its lock file only while that path still names the exact file it published. The case that makes this necessary is reachable: an operator force-releases a lock this process still holds, a third process wins the free path and publishes its own lock there, and the original process then finishes -- a blind removal would delete the newcomer's live lock. A mismatch means our lock is already gone: there is nothing to remove, and nothing to report.
The identity is two halves, and the second is the one that decides. Comparing the stat -- device plus inode -- is not proof, because a filesystem may hand a freed inode number straight back out: ext4 recycles, btrfs never does, so the newcomer's lock can land on the very inode the previous holder had and the stat comparison then says "ours" about somebody else's live lock. The owner record is therefore compared too, byte-for-byte against the bytes this process wrote. A lock file is written before publication and never modified after, so the record is a stable identity that inode recycling cannot forge.
#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 exits 8 with an error identifying the lock holder.
A waiter that sees a DIFFERENT lock file at the path than it saw last poll -- a new inode, which every publication produces -- resets its backoff to the first step. Without that, every waiter escalates to the 1s cap within six polls and stays there, so a lock held for 30ms at a time sits idle most of every second and the queue drains at about one waiter per second however many are waiting; fifty concurrent commits then take fifty seconds and time out on a lock whose total work is under two. The escalation still does its job where it was meant to: a lock one process holds for minutes never changes hands.
#Signal handling
Lock files are registered for cleanup on SIGINT and SIGTERM: the handler releases what this process published before exiting, which prevents the most common source of stale locks in interactive use. Signal exits report 128 + the signal number, the Unix convention. A refusal that exits through safegit's own error path releases pending locks too -- os.Exit runs no deferred function, so a command that had taken a lock and then died on an unrelated error would otherwise leave its lock file behind.
#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.
That is a property of a single boundary rather than of discipline: every git subprocess safegit builds is constructed by one package (internal/gitexec), which prepends the flag and checks the subcommand against one classification table. Nothing else in the codebase shells out to git, and the arguments an OPERATOR types for a guarded passthrough travel through the same boundary.
#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.
#AttrUnspecified
const AttrUnspecified = "unspecified"AttrUnspecified is what check-attr answers for a path no attributes file says anything about. CheckAttr passes it through rather than dropping the entry, because "the path was asked about and nothing was set" and "the path was never asked about" are different facts.
#StyleMerge
const StyleMerge ConflictStyle = "merge"StyleMerge is git's default: the two sides separated by "=======".
#StyleDiff3
const StyleDiff3 ConflictStyle = "diff3"StyleDiff3 adds the merge base between them, under "|||||||".
#StyleZdiff3
const StyleZdiff3 ConflictStyle = "zdiff3"StyleZdiff3 is diff3 with lines common to both sides hoisted out of the conflicted region.
#ZeroSHA
const ZeroSHA = "0000000000000000000000000000000000000000"ZeroSHA is git's "this object must not exist" convention: the all-zero object name. Passed to update-ref as the expected old value it means "create only" -- git refuses with "reference already exists" when the ref is already there.
#ZeroMode
const ZeroMode = "000000"ZeroMode is how git's raw diff format spells the mode of a side that is not there: the addition's source, the deletion's destination.
#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.
#ErrNoExpectedValue
var ErrNoExpectedValue = errors.New("update-ref requires an expected old value; pass git.ZeroSHA to require that the ref does not exist yet")ErrNoExpectedValue is returned by UpdateRef and DeleteRef when the caller supplies no expected old value.
It used to mean "omit the old-value argument", which is git's spelling of an UNCONDITIONAL write: the ref moved to whatever the caller computed no matter what another process had done to it in the meantime. That is precisely the compare-and-swap safegit exists to provide, so the empty string is now a refusal rather than a mode. A caller that means "this ref must not exist yet" says so with ZeroSHA.
#UnmergedEntry
type UnmergedEntry structUnmergedEntry is one unmerged index entry: a path at one of the three merge stages. A conflicted path has up to three of them (1 = the merge base, 2 = ours, 3 = theirs), and a stage is ABSENT when the path did not exist on that side -- an add/add conflict has no stage 1, a delete/modify conflict has no stage 2 or no stage 3.
#ConflictStyle
type ConflictStyle stringConflictStyle is git's merge.conflictStyle vocabulary: which shape git writes a conflicted region in.
#MergeFileOptions
type MergeFileOptions structMergeFileOptions shapes one merge-file reconstruction.
#CommitIdentity
type CommitIdentity structCommitIdentity pins the author and committer a commit is written with, timestamps included. A caller that has no identity to impose passes nil to CommitTree and gets git's own configured identity and the current time, which is what every ordinary commit wants; the rewrite paths, which must reproduce an existing commit's identity exactly, pass one.
#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).
#ChangedPath
type ChangedPath structChangedPath is one entry of a recursive raw diff between two trees.
#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.
#CommitMessage
type CommitMessage structCommitMessage is one commit and the whole message it carries.
#IndexStage0
type IndexStage0 structIndexStage0 is one already-decided resolution applied to an index: every slot the path currently occupies is replaced by a single stage-0 entry naming Mode and SHA.
An empty Mode removes the path from the index entirely instead, which is what resolving a conflict by deleting the path means.
#MergeTreeResult
type MergeTreeResult structMergeTreeResult is what one git merge-tree --write-tree computed.
#UnmergedStages
func UnmergedStages(ctx context.Context, indexPath string) ([]UnmergedEntry, error)UnmergedStages lists the unmerged entries of an index, in git's own order.
indexPath names the index to read; an empty indexPath reads the repository's shared index. The listing is NUL-delimited, so a path holding a newline, a quote or a non-UTF-8 byte arrives exactly as it is stored.
#IndexEntries
func IndexEntries(ctx context.Context, indexPath string) ([]UnmergedEntry, error)IndexEntries lists EVERY entry of an index, at every stage, in git's own order. A quiet index answers entirely at stage 0; a conflicted one carries the unmerged path's stages 1/2/3 as well.
It exists for the marker verification, which has to read the content a conclusion is about to commit for paths that are NOT conflicted -- a path the operator resolved with git add before running the conclusion carries no stages at all, and is exactly where a forgotten marker hides.
#IndexPathsChangedFrom
func IndexPathsChangedFrom(ctx context.Context, treeish string) ([]string, error)IndexPathsChangedFrom lists the repo-relative paths whose entry in the shared index differs from the given tree-ish, unmerged paths included.
It is how the marker verification decides what a conclusion is about to RECORD: a path whose index entry already matches the first parent is not something the commit changes, and cannot introduce anything into it. The listing is NUL-delimited, so no path is C-quoted into something that names no file.
#AbbrevSHA
func AbbrevSHA(ctx context.Context, rev string) (string, error)AbbrevSHA returns the abbreviated object name git itself would print for a revision, honoring core.abbrev exactly as git's own conflict-marker labels do (git names the merge base on a diff3 marker line by this abbreviation, so a reconstruction that abbreviates differently is not byte-identical).
#CheckAttr
func CheckAttr(ctx context.Context, attrSource string, attrs []string, paths []string) (map[string]map[string]string, error)CheckAttr answers what the attributes files say about paths.
attrSource, when non-empty, is a tree-ish whose .gitattributes files are read INSTEAD of the working tree's (git's --attr-source). That is the whole reason this wrapper exists: during a conflicted merge the working tree's .gitattributes may itself be conflicted -- marker-laden and meaningless -- so an attribute that decides how safegit treats the conflict has to be read from a committed tree, where it necessarily predates the conflict.
The result is keyed by path, then by attribute name. A path git answers for is always present in the map; an attribute git says nothing about carries AttrUnspecified. A set-but-valueless attribute reads "set", an unset one "unset", exactly as git spells them.
Paths travel on stdin, so a path that looks like an option or holds a special byte is never re-interpreted.
#ParseConflictStyle
func ParseConflictStyle(value string) (ConflictStyle, error)ParseConflictStyle reads a merge.conflictStyle configuration value. An empty value is git's own default. An unrecognized value is an error rather than a silent fall back to the default: safegit would otherwise reconstruct a conflict in a shape git never wrote, and compare it against the real file.
#MergeFile
func MergeFile(ctx context.Context, ours, base, theirs []byte, opts MergeFileOptions) (merged []byte, conflicted bool, err error)MergeFile runs git's three-way file merge over three blob contents and returns the merged result, plus whether the merge conflicted.
This is how safegit reproduces the conflict-marked file git itself wrote into the working tree: given the index's stage 1/2/3 blobs and the attributes that were in force, merge-file emits the same bytes, because it is the same engine.
The three sides are written into a throwaway directory and merge-file is run with -p, so the result comes back on stdout and nothing in the repository or the working tree is touched. A missing side (an add/add conflict has no base) is passed as an empty file, which is what git's own merge does.
A conflicted merge is a NORMAL return, not an error: merge-file's exit status is the number of conflicts it left, and only a negative status (255 in practice) means it failed.
#StripComments
func StripComments(ctx context.Context, message string) (string, error)StripComments removes comment lines from a commit message the way git does when it commits one: it runs git stripspace --strip-comments, so the repository's own core.commentChar (or core.commentString) decides what a comment is, and blank-line collapsing matches git's.
The conclusion commands need it because the MERGE_MSG git leaves behind carries the "# Conflicts:" block, which is a comment in the draft and must not reach the commit object.
#ConfigGet
func ConfigGet(ctx context.Context, key string) (value string, set bool, err error)ConfigGet reads one git configuration value. set reports whether the key is configured at all: git exits 1 with no output for an absent key, which is an answer rather than a failure, and a caller that needs a default applies its own.
#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.
The override itself lives in internal/gitexec, the one place that builds a git subprocess; this is the plumbing interface's spelling of it.
#WithRoot
func WithRoot(ctx context.Context, root string) context.ContextWithRoot returns a context carrying the repository-root working-directory pin. See gitexec.WithRoot for what the pin is for.
#Version
func Version(ctx context.Context) (gitversion.Version, error)Version returns the version of the git binary safegit is running against, parsed. It is the one place a caller asks; a feature with a version floor compares this against its floor via gitversion.Require.
#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.
#AnchorRoot
func AnchorRoot(ctx context.Context) (string, error)AnchorRoot returns the directory that repo-relative paths reported by git in this context resolve against.
Pinning the git SUBPROCESS working directory does not change how Go resolves a relative path: an os.Lstat, os.ReadFile or os.WriteFile on a path git just listed still resolves against the PROCESS working directory. From a subdirectory the two disagree, and the file the syscall reaches is not the file git named -- which is how a protection that reads a git listing and then touches the filesystem silently protects nothing. Every filesystem syscall that consumes a git-listed path goes through Anchor(AnchorRoot(ctx), path).
The order is most-specific-first: a context targeting another repository anchors at that repository's work tree, a pinned context at the pin, and an unpinned context at whatever the repository root is from here.
#Anchor
func Anchor(root, repoRelative string) stringAnchor joins a repo-relative path onto root. An absolute path is returned unchanged: a caller that already resolved a path must not have it re-rooted.
#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 ABSOLUTE path of the repository's git directory.
Absolute because every consumer joins a state-file name onto it -- MERGE_HEAD, index, safegit/ -- and then reaches that path with a Go filesystem call, which resolves a relative path against the PROCESS working directory. Plain rev-parse --git-dir answers .git whenever git ran at the top of the work tree, and safegit's own context pins every git subprocess to the repository root, so from a subdirectory that answer names
It is git's own canonicalized answer (--absolute-git-dir) rather than a filepath.Abs of the relative one, for the same reason ObjectsDir and HooksDir ask git: a linked worktree, a redirected git directory and a GIT_DIR override all break any join a caller could do itself.
#ObjectsDir
func ObjectsDir(ctx context.Context) (string, error)ObjectsDir returns the ABSOLUTE path of the repository's object store.
Absolute because the answer is used as a GIT_ALTERNATE_OBJECT_DIRECTORIES entry, which git resolves against whatever directory the child process runs in -- and safegit's children run in several (the repository root under the pin, another repository entirely at the explicit-directory sites). A relative answer would name a different store depending on who read it.
It is git's own answer rather than a join onto the git dir, so a linked worktree (whose objects live in the common git dir) and a repository whose object store is redirected both report the store git will actually use.
#HooksDir
func HooksDir(ctx context.Context) (string, error)HooksDir returns the ABSOLUTE path of the directory git runs hooks from.
It is git's own answer rather than a join onto the git dir, and it is the one place anything in safegit asks. Two configurations make the join wrong, and both are silent when it is: core.hooksPath redirects the directory entirely, and a LINKED WORKTREE's git dir (.git/worktrees/
#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.
#EmptyTreeSHA
func EmptyTreeSHA(ctx context.Context) (string, error)EmptyTreeSHA is the object name of the tree with no entries, asked of git rather than spelled out.
The two well-known constants (sha1's 4b825dc6... and sha256's 6ef19b41...) are deliberately NOT hardcoded here. Hardcoding them would save one subprocess on paths that are already cold, in exchange for a table that has to be extended by hand the day git gains another hash algorithm -- and the failure then is silent, a name that resolves to nothing in a repository the table does not know. hash-object computes the name from the algorithm the repository actually uses, so it self-adapts.
It is hash-object -t tree on EMPTY STDIN rather than MkTree with no entries, which also yields the empty tree: mktree WRITES the object, which puts it in the class the preview quarantine exists for, while hash-object without -w computes the name and writes nothing at all. Empty stdin rather than /dev/null for the same reason every other hashing helper here takes bytes: safegit's hash-object callers hand git content, never a path (see the comment above HashObjectBytes), and /dev/null is not a path every platform has.
#HeadTreeish
func HeadTreeish(ctx context.Context) (string, error)HeadTreeish names the tree to compare the working tree, the index or a conclusion's first parent against: HEAD where it resolves, and the EMPTY TREE where it does not.
The second case is an UNBORN branch -- the state between git init and the first commit, and the state safegit undo of a root commit leaves behind. There is no HEAD there, and every git command that takes HEAD as a treeish is fatal, which is why the substitution is made here rather than left to each caller: a repository with no commits holds exactly the empty tree, so a diff against it reports precisely what a diff against HEAD reports on a born branch -- every staged addition, and nothing else.
The test is rev-parse --verify --quiet: without --quiet git prints its "ambiguous argument" advice and exits 128, so the cheap question would answer with noise on stderr in the ordinary case this function exists for.
#HeadIsUnborn
func HeadIsUnborn(ctx context.Context) boolHeadIsUnborn reports whether HEAD names a branch that does not exist yet.
It is the cheap question, asked with rev-parse --verify --quiet: without --quiet git prints its "ambiguous argument 'HEAD'" advice and exits 128, so the ordinary case this exists for would answer with noise on stderr.
A bool rather than (bool, error), because every caller is already inside a repository safegit resolved a git directory for, and the only other way this invocation fails is a repository nothing else in the process could read either.
#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 string, parents []string, message string, identity *CommitIdentity) (string, error)CommitTree creates a commit object from a tree SHA and its parents, in the order given, and returns the new commit SHA. An empty parents slice creates a root commit; more than one parent creates a merge commit, which is why the parameter is a slice rather than a single SHA -- a caller that rewrites a merge commit with one parent silently unmerges the branch.
identity, when non-nil, pins the author and committer (see CommitIdentity).
#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 and is MANDATORY. Pass ZeroSHA to require that the ref does not exist yet, which git enforces by refusing with "reference already exists".
#DeleteRef
func DeleteRef(ctx context.Context, ref, oldSHA string) errorDeleteRef atomically deletes a ref using compare-and-swap.
oldSHA is the expected current value and is MANDATORY, for the same reason it is on UpdateRef: without it git deletes whatever the ref points at now.
#AddFile
func AddFile(ctx context.Context, indexPath, filePath string) errorAddFile stages a file into a custom index.
An empty indexPath stages into the repository's shared index, the same convention UnmergedStages and SetIndexStage0 use.
#RmCached
func RmCached(ctx context.Context, indexPath, filePath string) errorRmCached removes a file or directory from a custom index without touching the working tree.
#DropFromIndex
func DropFromIndex(ctx context.Context, indexPath, repoRelPath string) errorDropFromIndex removes ONE exact path from a custom index, whether or not the file is still on disk and whatever its content is.
It is deliberately not RmCached. git rm --cached is a porcelain safety check as much as a removal: it refuses a path whose indexed content differs from both the working file and HEAD, and it reads HEAD -- the repository's real HEAD, which on a cross-branch operation is not the tree the index was seeded from. Untracking a file that is meant to STAY on disk, usually with content that has moved on since it was committed, is exactly the shape that check refuses. update-index --force-remove states the intent directly: drop this index entry, touch nothing else.
The path is repo-relative; git resolves it against the process working directory, which every safegit git call has pinned to the repository root.
#IsTracked
func IsTracked(ctx context.Context, rev, filePath string) (bool, error)IsTracked checks whether a file is tracked in the given revision's 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.
The revision is a PARAMETER rather than a hardcoded HEAD because the tree a path must be judged against is the tree the operation is built on, which is not always HEAD: a commit --branch other builds on other's tip, and an amend builds on the tip it replaces. Asking HEAD there decides the request against a tree the operation will never touch. An empty rev means there is no such tree yet (an unborn ref), where nothing is tracked.
#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 -z output, selecting records that start with "S " (the skip-worktree indicator).
The NUL-delimited form is what makes the answer usable: without -z git C-quotes any path that is not plain ASCII, and the quoted spelling names no index entry, so restoring the flag afterwards would fail on exactly the paths that most need it.
#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).
#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).
ONE substitution is made on the caller's treeish, and its scope is narrow on purpose: a literal "HEAD" on an UNBORN branch becomes the empty tree, because read-tree --reset -u HEAD is fatal there and what the caller means -- put the index and the working tree in step with the committed state -- is the empty tree in a repository that has no commits. It applies to nothing else. An unresolvable treeish that is NOT literal HEAD stays a hard error, and must: substituting the empty tree for a failed resolution generally would read-tree --reset -u every tracked file out of the working tree, which is the opposite of what a caller passing a real SHA (a merge's incoming tip) asked for.
#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.
This is the route for argv the OPERATOR wrote (cherry-pick, revert), so it carries the declared operator-cwd exemption from the repository-root pin: git must resolve the operator's own pathspecs in the operator's own directory. A context-carried WithDir override still applies.
#RunPassthroughWithEnv
func RunPassthroughWithEnv(ctx context.Context, env []string, args ...string) errorRunPassthroughWithEnv is RunPassthrough with extra environment entries, which is what lets a caller point git at an index file of safegit's own choosing (GIT_INDEX_FILE) while keeping safegit's promise never to write the shared one.
GIT_INDEX_FILE is deliberately NOT among the environment entries the boundary refuses (that list is GIT_DIR, GIT_WORK_TREE, GIT_COMMON_DIR and GIT_OBJECT_DIRECTORY): naming an index file does not retarget the repository, and internal/git already reaches every temporary index this way.
Streams, directory semantics and the declared exemption are identical to RunPassthrough's -- both are the same site, and a caller that adds an environment entry must not silently get different terminal or directory behavior.
#RunPassthroughTo
func RunPassthroughTo(ctx context.Context, env []string, stdout io.Writer, args ...string) errorRunPassthroughTo is RunPassthroughWithEnv with the child's STDOUT sink named by the caller.
It exists for machine mode: under --json safegit's stdout carries exactly one document, the framework's envelope, and a passthrough child writing its own progress there would put a second document beside it. The caller passes os.Stderr instead, which is where push already routes git's stdout for the same reason. Stderr and stdin are wired to the terminal either way.
#CommonGitDirOf
func CommonGitDirOf(ctx context.Context, gitDir string) (string, error)CommonGitDirOf returns the common git directory for a given gitDir: the one every worktree of a repository shares. For a normal repository it equals gitDir; for a linked worktree it is the main .git dir. Lock files live there, so that worktrees committing to the same branch serialize correctly.
The repository is an ARGUMENT rather than the process working directory, so the call goes through RunWithGitDir -- the declared explicit-directory exemption from the repository-root pin -- which sets GIT_DIR and runs git in that directory. An absolute gitDir therefore yields an absolute answer; a relative one yields an answer relative to gitDir itself, never to this process's working directory. There is deliberately no working-directory form: one existed, had no callers, and returned an answer whose meaning depended on where the process happened to stand.
#IsIgnored
func IsIgnored(ctx context.Context, filePath string) (bool, error)IsIgnored checks whether a file matches a gitignore rule.
This is git's own question, index included: a path that is in the index is TRACKED, and check-ignore answers "not ignored" for it whatever the ignore rules say. That is the right answer for "may this path be added", which is what the callers of this function ask -- a tracked file matching an ignore pattern must stay committable. A caller asking the other question, whether the ignore rules cover a path at all, wants MatchesIgnoreRules.
#MatchesIgnoreRules
func MatchesIgnoreRules(ctx context.Context, filePath string) (bool, error)MatchesIgnoreRules reports whether the ignore rules cover a path, with the index left out of the question entirely.
--no-index is the whole difference from IsIgnored, and it inverts the answer for exactly the paths that make the question worth asking: one that is still in the index. Plain check-ignore calls such a path not ignored because it is tracked, so asking it "is this path gitignored" about a path that is about to STOP being tracked yields the opposite of the truth. With --no-index only the patterns decide.
Exit 1 with nothing on stderr is check-ignore's "no pattern matches", which is an answer; any other failure is a real one and is returned.
#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.
#HaveMergeBase
func HaveMergeBase(ctx context.Context, a, b string) (have, ok bool)HaveMergeBase reports whether two commits share any merge base at all.
The three answers git's own merge-base gives are kept distinct, because only one of them means "these histories are unrelated": exit 0 with a base, exit 1 with none, and anything else -- an unresolvable argument, a broken object store -- which is not an answer to this question. ok is false for that third case, so a caller refuses on a FACT rather than on a failure that could mean anything.
#IsShallowRepository
func IsShallowRepository(ctx context.Context) boolIsShallowRepository reports whether this repository was fetched with a depth limit, so part of its history is simply absent from the object store.
It answers TRUE only on git's own "true": anything else -- "false", or a rev-parse that failed for any reason at all -- is read as not shallow, which is the safe direction for the one thing the answer is used for. It refines a refusal's WORDING, never the refusal itself, so a wrong "false" leaves the ordinary message rather than inventing a shallow one for a full clone.
#FirstParentRange
func FirstParentRange(ctx context.Context, from, to string) ([]string, error)FirstParentRange lists the commits a branch would LOSE by moving from to back to from, newest first. An empty from means the branch would lose everything reachable from to, which is what deleting the ref does.
The walk is FIRST-PARENT, and that is the whole definition rather than a detail of it. A merge commit's second parent is the side that was merged IN: those commits were never made by the branch, and moving the branch back to the merge's first parent does not undo them -- it undoes the merge. Walking every parent would report a whole merged-in branch as commits the move discards, which is a different and untrue statement.
#ConfiguredAuthor
func ConfiguredAuthor(ctx context.Context) (AuthorInfo, error)ConfiguredAuthor is the identity git itself would record as the AUTHOR of a commit created right now: git var GIT_AUTHOR_IDENT, which is git's own resolution of the environment, the repository config and the global config. Asking git is the point -- a reconstruction from config --get user.name would miss the GIT_AUTHOR_* environment and git's own fallbacks, and would therefore be able to disagree with the commit it claims to describe.
The timestamp git includes is dropped: the identity is asked for so a report can name who a commit records, and a time resolved here is not the time the commit will carry.
#ParseCommit
func ParseCommit(ctx context.Context, sha string) (CommitInfo, error)ParseCommit reads and parses a commit object by SHA using git cat-file.
#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.
--full-tree is not optional here. Without it git resolves a tree listing against the process working directory PREFIX: from a subdirectory, ls-tree <root-tree> returns that subdirectory's entries with the prefix stripped, and a caller that rebuilds a tree from the result promotes the subdirectory to the repository root and deletes everything outside it. Pinning the working directory alone does not fix this, because the *WithDir family and any future caller can still run somewhere else; the flag makes the listing repository-rooted no matter where the process stands.
#LsTreeRecursive
func LsTreeRecursive(ctx context.Context, treeish string) ([]TreeEntry, error)LsTreeRecursive returns EVERY entry in the given treeish, recursively: blobs, symlinks and gitlinks (submodule pointers, mode 160000, object type "commit"). LsTreeAll drops everything that is not a blob, which hides exactly the entries a caller that must not cross a submodule boundary needs to see.
ls-tree -r never descends INTO a gitlink, so a submodule's own contents can never appear here -- the gitlink is reported as one entry and the recursion stops there.
#LsTreePathsRecursive
func LsTreePathsRecursive(ctx context.Context, treeish string, paths []string) ([]TreeEntry, error)LsTreePathsRecursive returns the entries a treeish holds at EXACTLY the given repo-relative paths, recursively, and nothing else. A path the tree does not carry is simply absent from the answer, which is how a caller learns the tree does not hold it.
It is the path-limited form of LsTreeRecursive, for a caller that wants a handful of named paths out of a tree rather than all of it. --full-tree makes both the listing and the pathspecs repository-rooted, so the answer does not depend on where the process stands -- the same reason it is mandatory on the two listings above.
An empty path list returns nothing: ls-tree with no pathspec lists the whole tree, which is the opposite of what a caller asking about no paths means.
Every path goes out under the :(literal) pathspec magic, which is not optional: a path is a NAME here, never a pattern. Without it a path beginning with a colon is read as pathspec magic of its own -- :weird.txt matches NOTHING and git exits 0 -- and a caller that reads an absent answer as "the tree does not carry this path" would act on a silent miss. Wildcards in a name are the same class of error in the other direction.
#DiffTree
func DiffTree(ctx context.Context, fromTreeish, toTreeish string) ([]ChangedPath, error)DiffTree lists every path that differs between two trees, recursively.
It is the one place safegit asks git what a tree comparison contains, and the answer is what the commit pipeline reports as "the files in this commit": derived from the objects, never counted from the arguments a caller typed.
GIT'S rename detection is deliberately OFF, and the qualification is the point: what this returns is the RAW delta -- a deletion and an addition, with the modes and blob names on both sides -- which is what a reviewer of the published commit sees and what safegit's own move inference reads (internal/commit/infer_moves.go). That inference pairs a deletion with an addition only where the objects leave one answer possible; a similarity score is an interpretation of CONTENT, and safegit asks git for none, here or anywhere.
An empty fromTreeish means "compare against nothing": every path in the new tree is reported as an addition. That is the root-commit case, and it is spelled this way rather than with git's empty-tree constant so the function carries no assumption about the repository's hash algorithm.
#FilterIgnored
func FilterIgnored(ctx context.Context, paths []string) (map[string]bool, error)FilterIgnored returns the subset of the given repo-relative paths that git's ignore rules exclude, as a set. Directories may be passed too: an ignored directory answers for itself, so a caller walking a tree can stop there instead of asking about every file underneath it.
One check-ignore --stdin invocation answers for the whole batch. git exits 1 when nothing in the batch is ignored, which is an answer and not a failure.
#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. --full-tree is mandatory for the same reason it is on LsTreeAll: a listing resolved against the working-directory prefix is a listing of the wrong tree.
#HashObjectBytes
func HashObjectBytes(ctx context.Context, data []byte) (string, error)HashObjectBytes returns the blob SHA for in-memory bytes without writing anything to the object store -- the preview counterpart of HashObjectWriteBytes.
#HashObjectBytesAsPath
func HashObjectBytesAsPath(ctx context.Context, rel string, data []byte) (string, error)HashObjectBytesAsPath returns the blob SHA git would record for in-memory bytes if they lived at rel: the same answer as HashObjectBytes, except that git's clean filter and text attributes for rel are applied to the bytes first. It writes nothing.
This is NOT one of the path-taking helpers the comment above rules out. The content still comes from the caller, on stdin; rel is a repo-relative NAME git looks attributes up under and never opens, so nothing here depends on where a file happens to be or on which directory the child runs in. It is what makes a content comparison filter-aware: on a checkout where git converts line endings, the bytes on disk differ from the blob and the file is still clean, and a comparison that hashed them raw would call it changed.
#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.
#HashObjectWriteTag
func HashObjectWriteTag(ctx context.Context, content []byte) (string, error)HashObjectWriteTag writes in-memory bytes as a TAG object to the object store, returning the tag object SHA. A rewritten annotated tag is a new tag object, so every site that reconstructs one goes through here rather than spelling the -t tag argv again.
#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 -z as "ls-tree -z produces, which is where every entry safegit writes back came from.
The -z is not an optimization. Plain mktree input treats a path that starts with a double quote as a C-quoted string, so a repository holding a file whose name begins with one -- a legal name -- makes it refuse with "invalid quoting", and a path containing a backslash would be read as an escape. Under -z every path is taken literally, so the writer round-trips exactly what the reader parsed.
#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.
It is one of the declared explicit-directory exemptions from the repository-root pin: the repository is an argument, not a discovery.
#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.
#ReachableMessages
func ReachableMessages(ctx context.Context, rev string) ([]CommitMessage, error)ReachableMessages returns every commit reachable from rev, newest first, with its full message.
The delimiter is a NUL between commits (log -z), which is the only separator a commit message cannot contain: a message holds arbitrary text, blank lines and lines that look like whatever separator one might reach for, so any printable delimiter is a message somebody can write.
#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 "
#ReconcileMainIndex
func ReconcileMainIndex(ctx context.Context, beforeTip, afterTreeish string) errorReconcileMainIndex rebuilds the shared .git/index after a ref the working tree is on has moved, preserving everything the index holds that the pre-operation tip does not account for.
This is the SINGLE index-reconciliation authority: commit, amend, reword and undo all reconcile through this one function, so "what happens to the shared index when safegit moves a ref" has exactly one answer, and the continue commands that conclude an interrupted operation reconcile the same way.
beforeTip is the commit-ish the index was last reconciled against -- the ref's value BEFORE the operation. Empty means there was none (a root commit), so the whole index counts as delta. afterTreeish is the state to sync to; empty means clear the index entirely (undoing a root commit).
What survives the sync:
- foreign staged work: a stage-0 entry that differs from beforeTip (a staged modification or an addition beforeTip never had), and a path beforeTip has that the index has no slot for at all (a staged deletion, e.g. git rm --cached); - unmerged stage 1/2/3 entries, replayed intact, so a conflict another session is resolving is still a conflict afterwards; - skip-worktree flags, re-set on every flagged path still present at stage 0.
The whole delta is replayed in ONE git update-index --index-info batch. Within that batch an unmerged path is preceded by a zero-mode removal line, because the read-tree wrote a stage-0 entry for it and git refuses to hold stage 0 and a higher stage for the same path at once.
Every failure is HARD. A half-replayed index is a corrupted view of somebody else's staged work; reporting that as a warning and returning success is exactly how staged state disappears silently.
#SetIndexStage0
func SetIndexStage0(ctx context.Context, indexPath string, entries []IndexStage0) errorSetIndexStage0 applies resolutions to the index at indexPath, in one git update-index --index-info batch.
This is how a conflict is resolved in an index without going near the working tree: a conflicted path occupies stages 1, 2 and 3, and writing a stage-0 entry for it is what makes git write-tree accept it. Each path is preceded by a zero-mode removal line, because git will not hold stage 0 and a higher stage for one path at once, and because removing a path the index does not hold is a no-op -- so the same batch expresses both "resolve to this blob" and "remove this path".
indexPath names the index to write, and an empty indexPath writes the repository's shared index -- the same convention UnmergedStages reads by. The shared index has TWO writers, and both hold the worktree operation lock while they write: the conclusion's own reconciliation, through here, and safegit doctor --action fix's repair of an ORPHANED unmerged index, which re-stages the working tree's own content through the effects handle (so a preview records the invocations instead of performing them) and therefore does not come through this function. The lock is what keeps the two from interleaving.
#MergeTree
func MergeTree(ctx context.Context, base, ours, theirs string, extra ...string) (MergeTreeResult, error)MergeTree computes a three-way merge into the object store, touching neither the index nor the working tree.
It is the engine behind the honest --dry-run of merge, cherry-pick and revert: replaying the operation this way gives the REAL answer (clean or conflicted, and which paths) instead of a guess, and the objects it writes go into the preview's quarantine and away with it.
base is the merge base, and passing it is what makes a cherry-pick or a revert expressible as a merge -- the operation's whole difference from a branch merge is which commit stands as the base and which stands as the incoming side. Empty means "let git find the merge base itself", which is the branch-merge case.
extra are further merge-tree options, each its own element, inserted before the two sides. It carries the STRATEGY OPTIONS a previewed command line asked for, so the previewed tree is the one that command line really produces. Their own version floor is newer than this function's and belongs to the caller that knows whether any were asked for -- see previewRefusal.
The version floor is checked here rather than at each caller: --write-tree is git 2.38, and on an older git the flag does not exist at all, so an unchecked call would fail with git's usage text instead of a sentence naming the floor.
#StashApply
func StashApply(ctx context.Context, commit string) (output string, err error)StashApply applies a stash-shaped commit to the working tree and index, the way git stash apply <commit> does.
It returns git's own combined output, so a caller can show the operator what happened, and an error when the apply did not succeed -- most often because the stashed change conflicts with what the working tree now holds. A failed apply is NOT a no-op: git leaves the conflict in the working tree and the index, exactly as it does for a conflicting git stash apply an operator ran themselves.
#StashStore
func StashStore(ctx context.Context, commit, message string) errorStashStore records an already-existing stash-shaped commit as an entry on refs/stash, the way git stash store does, without touching the working tree or the index.
It is the recovery path for an apply that failed: the commit is real either way, but until it is on refs/stash the only name for it is a raw object name in a file that is about to be removed. Stored, it is stash@{0} and every ordinary stash command reaches it.
#RequireFeature
func RequireFeature(ctx context.Context, f gitversion.Feature) errorRequireFeature refuses when the installed git is older than the floor a declared feature carries, and returns nil otherwise.
It is the production entry point to internal/gitversion: a command that is about to run git syntax with a version floor calls this FIRST, so an operator on an older git is told which git feature safegit needs and which version introduced it -- rather than being handed git's own "unknown option" from somewhere in the middle of a conclusion.
The version is read once per process (see above), so a caller may call this on a hot path.
#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. Each append is made under an exclusive flock(2) held across the whole write, and that is what makes concurrent appends atomic -- not the POSIX PIPE_BUF guarantee, which only covers writes under 4096 bytes. Entries therefore have no size limit: an oversized one is written whole rather than refused. Concurrent oplog writes from parallel commits never produce corrupted or interleaved lines.
The log is never rotated or truncated, and there is no size setting: an audit trail that silently discards its oldest entries is not one. Reading it reports how many unparseable lines were skipped, and a consumer that needs a complete log -- undo, bypass detection -- fails closed on a nonzero count rather than acting on a partial reading; safegit doctor reports it as an error-severity finding.
#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 under an exclusive flock, which is what makes a concurrent append atomic; entries have no size limit.
#Entry
type Entry structEntry represents a single operation log entry.
#Path
func Path(safegitDir string) stringPath returns the path to the log file. It is exported so callers can name the file in an error a human has to go and inspect.
#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 of any length: the exclusive flock held across the whole write is the atomicity mechanism, so the 4096-byte POSIX O_APPEND guarantee is not what this file relies on and no line cap is needed. (Same reasoning as the scrub rewrite-map journal, which holds arbitrarily large commit maps under the same lock.)
#Read
func Read(safegitDir string) ([]Entry, int, error)Read returns all parseable entries from the log file, plus the number of non-empty lines it could not parse.
A nonzero skipped count means the log is incomplete: some operation was recorded but cannot be read back. Every caller whose correctness depends on the log being complete (undo arithmetic, bypass detection) must refuse rather than work from a partial history; callers that only summarize the log may report the count instead.
Lines are read with a bufio.Reader rather than a bufio.Scanner: entries have no size cap, and a Scanner would turn an over-long line into a read error for the whole file.
#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. It FAILS CLOSED on an incomplete log: bypass detection asks "is the tip the one safegit last wrote", and a log missing lines cannot answer that.
Two entry shapes are deliberately passed over rather than answered with:
- an entry carrying NO new tip. A guarded operation git refused records the ref it did not move and an empty new tip, so the position safegit really last left the branch at is still the one this returns. - an entry recording a ref DELETION (deleted: true), which stops the walk with no answer at all: safegit removed the ref on purpose, and everything older describes a ref that no longer exists.
#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 operation the commit pipeline AUTHORED -- a commit, amend, reword,mv, amerge,pull,cherry-pickorrevertthat ended in a commit, or one of the three conclusion commands -- by reading the oplog and restoring the previous ref value. What it will not roll back is decided by the entry rather than by the op name: an entry carrying anoutcomerecords what GIT did to the branch (a fast-forward, a parked or up-to-date operation, a refusal), the pipeline never writes that key, and undo reverses only the entries without one. Undo is scoped to the current session (identified byCLAUDE_CODE_SESSION_ID), so one session's undo never affects another's commits, and it refuses outright rather than rolling a branch back over a commit safegit did not create.
- 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 -- switch, pull, merge, rebase, reset, bisect, cherry-pick, revert -- 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 switch, merge, rebase, reset, and pull to proceed.
It also owns the other half of that coordination: what safegit does when git itself has an operation in flight. sequencer.Read reports the state and holds no policy; this package decides which commands may run against it (GuardInFlight) and what the operator is told when one may not (WayOutOf, RefuseInFlight). Both refusal paths -- the commit pipeline's and the passthrough guard's -- render their advice from here, so they cannot name different commands for the same state.
#DirtyState
type DirtyState structDirtyState describes why the working tree is not clean.
#SequencerContext
type SequencerContext structSequencerContext is a caller's DECLARATION that it is the conclusion path for an in-flight git operation.
Every ordinary caller passes nil, which means "refuse if anything is in flight" -- a commit, an amend, a reword or an undo taken while git is mid-merge or mid-cherry-pick builds its tree from a parent commit and hands commit-tree a single parent, silently discarding the operation's staged result and its second parent. A non-nil context means the caller IS the command that finishes the named operation and must be allowed to commit during exactly the state everyone else is refused for.
The declaration is checked, not trusted: a context naming an operation other than the one actually in flight is itself a refusal, as is a context supplied when nothing is in flight at all.
#WayOut
type WayOut structWayOut names the commands that end an in-flight operation: the one that concludes it, keeping the work, and the one that abandons it, throwing the work away.
It is the single authority for that advice. Every refusal safegit prints while an operation is in flight renders it from here, so no two refusals can name different commands for the same state.
#InFlightError
type InFlightError structInFlightError is the refusal a command owes an operator when git has an operation in flight that the command cannot run against. Its message states what is in flight, factually, and the way out.
#Check
func Check(ctx context.Context, gitDir string) (*DirtyState, error)Check inspects the working tree of the repository whose git directory is gitDir. Returns nil if clean.
#WayOutOf
func WayOutOf(s sequencer.State) WayOutWayOutOf returns the way out of the state s reports.
Where safegit owns the conclusion it names its own command; where it does not it names git's, and it never names git's rebase commands for a git am or the other way round -- the two share a state directory and an operator sent to the wrong one gets a refusal, not a conclusion.
#RefuseInFlight
func RefuseInFlight(operation string, s sequencer.State) stringRefuseInFlight renders the refusal text for one operation against one state.
#GuardInFlight
func GuardInFlight(gitDir, operation string, declared *SequencerContext) errorGuardInFlight is the one check that decides whether operation may run against whatever git has in flight in gitDir. It returns nil when it may, an *InFlightError when the state forbids it, and a plain error when the state could not be read at all -- which is also a refusal, because a state file safegit cannot parse is not evidence that nothing is in flight.
declared is the caller's SequencerContext: nil for every ordinary caller.
It is filesystem-only (sequencer.Read starts no subprocess), so putting it on the hot path of commit costs a handful of stat calls.
#DirtyState.Refuse
func (d *DirtyState) Refuse(operation string) stringRefuse formats a refusal message from a DirtyState.
The advice depends on WHY the tree is dirty. Ordinarily the dirt is the operator's own uncommitted work and committing it is the way forward. While git has an operation in flight the same dirt is the operation's conflict markers and staged result: committing it is exactly what safegit refuses to do (it would drop the operation's other parent and everything the pathspec does not name), so the message names the operation and the command that ends it instead of advice no one can follow.
#InFlightError.Error
func (e *InFlightError) Error() string { return RefuseInFlight(e.Operation, e.State) }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 switch other-branch while another session has uncommitted edits in the working tree.
Two commands narrow the check to the forms that actually write to the working tree: reset runs it for --hard, --merge and --keep, and bisect for its stepping subcommands. Neither narrowing is re-derived at the call site -- both read internal/gitexec's classification table, the single authority over what a git invocation does, and an argv the table does not declare is refused rather than assumed harmless. Neither narrowing touches the operation lock, which every invocation of both takes unconditionally.
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. On an UNBORN branch -- a repository between git init and its first commit, where git diff HEAD is fatal -- the comparison is against the EMPTY TREE instead, which is exactly what such a repository holds, so a staged addition is reported there in the same shape.
Where git has an operation in flight, the same dirt is that operation's conflict markers and staged result, and "commit your work" is advice nobody can follow -- safegit commit is pathspec-only and refuses mid-merge. So the refusal names the operation and the command that ends it instead, rendered from one authority so no two refusals can name different commands for the same state. An in-flight operation does NOT by itself refuse a guarded command through THESE two guards: they are how an operator reaches rebase --continue and merge --abort, and refusing on state alone would refuse the way out.
Some of the guarded commands add a third check that IS about the state, and it is scoped so the way out still works. Every form that COMPUTES an operation -- merge, pull, and cherry-pick and revert in their own form as well as in the forwarded -n/--no-commit form, which asks git for the same computation -- refuses over anything git has in flight. rebase refuses over an in-flight state that is not itself a REBASE: it authors nothing of safegit's, but a rebase over a parked revert on a clean tree exits 0 and strands that revert's state files behind it, blocking every later commit. Scoping that predicate to the kind is what keeps rebase --continue, --abort and --skip working: mid-rebase state reports the rebase kind, so they pass without an exemption list of their own. --abort and --quit on the other verbs are never refused over the state they exist to clear.
#The worktree operation lock
The dirty-tree guard answers "is it safe to start?" at one instant. The operation lock answers "is anyone else already working here?" for the whole operation, and it is what makes the first answer worth anything: without it, another process could put the repository mid-merge in the window between one process's check and its ref update.
Every command that mutates a worktree takes it first: the guarded commands (switch, pull, merge, rebase, reset, bisect, cherry-pick, revert), commit (including --amend and reword), mv, the three conclusion commands (merge-continue, cherry-pick-continue, revert-continue), and undo. mv is the one whose ORDER inside the lock is worth stating: it checks for an in-flight git operation inside the lock and before its first move, because reaching the commit pipeline's own check afterwards would have moved every file and then refused to commit them. It lives at safegit/operation in the worktree-local safegit directory, so two worktrees of one repository work independently while two processes in one worktree serialize.
Lock ordering is fixed: the operation lock is outermost, and the per-ref locks the commit pipeline and undo take are acquired inside it. Nothing takes them the other way round, which is the whole deadlock argument. One acquisition crosses repositories without closing a cycle: the submodule auto-bump spawns safegit commit in the parent worktree while still holding the submodule's own operation lock, an edge that only ever runs from child to parent -- the observable consequence being that two sibling submodules bumping one parent serialize on the parent's operation lock, and the one that waits out lock.acquireTimeoutSeconds fails its bump with exit 8 after its own commit has already been made.
A guarded command holds the lock for the full duration of the git command it wraps -- including an interactive rebase -i's editor session. A second safegit process in that worktree waits lock.acquireTimeoutSeconds and then exits 8, naming the holder; it never runs concurrently. Two notes on the interactive case, both measured rather than assumed (testdata/experiments/exp-passthrough-editor-stdin.sh):
- The editor does run. A terminal editor (vim, nano,
emacs -nw) opens/dev/ttyand works normally. switch,pull,merge,rebase,resetandbisectreach git through the effects handle, which gives the child no stdin: anything that reads standard input sees EOF immediately.cherry-pickandrevertexec git directly and inherit stdin whole.
A dry run takes no lock: it performs no mutation, and acquiring one would mean a command that promises to change nothing writing a file into .git/safegit.
safegit unlock safegit/operation releases a stale operation lock left by a crashed process, and safegit doctor reports it by name.
#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 reads and writes the key-value metadata lines at the end of a commit message: the session attribution safegit injects, and the move records a commit declares.
Three things live here, in three layers:
- the trailer BLOCK: finding it in a message (SplitBodyTrailers), appending to it (Inject, AppendCustom) and reading it as key-value pairs (Trailers); - the move-record FORMAT: one encoder and one decoder for the old -> new pair grammar, shared by the record writer, the --moved validator and safegit mv (moved.go, cquote.go, ulid.go); - the PROJECTION that reads records back against the trees the repository holds (project.go), where the trees, not the records, have the last word.
#MovedKey
const MovedKey = "Moved"MovedKey is the trailer key one move record is written under.
#MovedRetractKey
const MovedRetractKey = "Moved-Retract"MovedRetractKey is the trailer key a retraction is written under. Its value is the id of the record being retracted and nothing else.
Retraction is the ONLY correction: a record already written is never edited, because a record only exists on a commit and editing that commit rewrites history. A replacement is a retraction plus a new record in one commit.
#OriginDeclared
const OriginDeclared Origin = ""OriginDeclared is a claim a person made. It is the zero value and it is written as NO token at all, which is what makes every record ever written a declared one without anything being migrated.
#OriginObserved
const OriginObserved Origin = "observed"OriginObserved is a claim safegit derived from a commit's delta.
#NoOverlap
const NoOverlap OverlapKind = iotaNoOverlap: the two moves are about different paths entirely.
#SameSource
const SameSourceSameSource: their source paths nest, so they state two fates for one file.
#SameDestination
const SameDestinationSameDestination: their destination paths nest, so they describe a result no move produces.
#Chained
const ChainedChained: one path is both a destination and a source, so the outcome would depend on which move was performed first.
#SessionKey
const SessionKey = "Claude-Code-Session-Id"SessionKey is the git trailer key used to record the Claude Code session ID.
#Origin
type Origin stringOrigin says how a record's claim was established.
It is written as the token immediately after the id, in the slot the grammar reserves for it (cquote.go), and ABSENCE IS A VALUE: a record with no token is a DECLARED one -- a person stating a move, which is what every record written before the token existed is and what every --moved, safegit mv and revert-inverse record still is.
observed is the one token written today. It means safegit DERIVED the claim from what a commit's own delta witnesses -- objects it read, not intent anybody stated -- so a reader who wants to know whether a person vouched for the move has the answer without asking anyone. The remaining reserved words (declared, derived) stay refused in that slot until something gives them a meaning.
#Record
type Record structRecord is one move record.
Old and New are canonical repo-relative paths. A trailing slash on both marks the SUBTREE form, which claims a move of everything under the prefix rather than of one file: the per-file answers are derived when the record is read and validated against the trees then, so a record written for a directory stays one line however many files the directory holds and however many of them a later reader finds.
Origin says who established the claim; see Origin. It changes nothing about what the record CLAIMS -- the trees remain the arbiter of every record, whatever its origin (project.go).
#Moves
type Moves structMoves is everything one commit message declares about moves.
Malformed carries the values under the two keys that do not parse, verbatim. They are neither dropped in silence nor turned into a hard error: a reader asking about a path must not be stopped by an unrelated commit somebody's tool mangled, and a tool auditing the repository must be able to find it.
#KV
type KV structKV is one trailer line: its key and everything after the "Key: ".
A continuation line -- an indented line following a trailer -- is appended to the preceding value with its newline and its indentation preserved, which is how git reads one too. safegit's own structured trailers never produce one (a path holding a newline is escaped, not wrapped), so a continuation in practice comes from somebody else's tool and is carried rather than interpreted.
#Tree
type Tree interfaceTree is the arbiter: the set of paths one commit holds.
#PathSet
type PathSet map[string]struct{}PathSet is a Tree over an explicit set of paths.
#Commit
type Commit structCommit is one commit as a projection reads it: what it declared, and the trees that decide whether the declarations hold.
Parents is every parent's tree, in parent order, and it is empty for a root commit -- which is why a root commit's records can never apply: nothing preceded it for anything to move from. Tree is the commit's own.
#Hop
type Hop structHop is one record the projection applied.
#Projection
type Projection structProjection is the answer for one followed path.
#Pair
type Pair structPair is one declared move's two paths, in either the file form or the subtree form. The trailing slash is trimmed wherever these paths are compared, so a caller may hand over whichever form it holds.
#OverlapKind
type OverlapKind intOverlapKind names how two declared moves speak about each other's paths.
#RecordTransformError
type RecordTransformError structRecordTransformError reports a transform that would turn a readable move record into one the decoder refuses.
Re-encoding through the one encoder keeps the QUOTING readable whatever the substitution did, but the pair itself still has to be a move: two different paths, both or neither naming a subtree, neither of them empty. A replacement can map both sides onto one path, eat a subtree marker on one side only, or empty a token -- and the line that would be written is then a claim nobody can read, sitting inert in history where the rewrite meant to correct it.
So the transform refuses rather than writing it, and the caller turns the refusal into a rewrite that never starts. The record is never edited into half a claim and never dropped in silence either: a record is a whole statement, and the only ways out are a replacement that keeps it one, erasing the path outright, or retracting the record in a commit of its own.
#EncodePair
func EncodePair(old, new string) stringEncodePair renders one "old -> new" token pair: the grammar --moved takes, the grammar safegit mv takes, and the tail of every written record. One encoder, so the three cannot drift apart.
#ParsePair
func ParsePair(s string) (old, new string, err error)ParsePair reads one "old -> new" token pair.
The separator is found OUTSIDE quoted regions, so a path that holds the arrow's own shape parses correctly once it is quoted -- and a value carrying two unquoted separators is refused rather than split at a guess.
An unquoted token is taken verbatim after its surrounding spaces are trimmed, which is what lets a person type --moved 'src/a.go -> src/b.go' (and even a non-ASCII path) without quoting anything. A token that really does carry a space, a quote, a backslash or a control byte has to be quoted, because nothing else could tell the two sides apart. One consequence worth stating: an unquoted token containing a lone double quote opens a quoted region that never closes, and the refusal says so rather than guessing.
#ValidatePair
func ValidatePair(old, new string) errorValidatePair applies the grammar's own rules to a decoded pair: the rules that hold wherever the pair came from, as opposed to the repository-dependent ones (is the old path tracked, is the new one there) that only a caller holding a tree can answer.
#EncodeRecord
func EncodeRecord(r Record) stringEncodeRecord renders the VALUE of one Moved: trailer -- the id, the origin token where there is one, then the pair.
A declared record writes no token, which is exactly the shape records had before the token existed: nothing in a repository has to be rewritten, and a reader that has never heard of origins reads every one of them the way it always did.
#RecordLine
func RecordLine(r Record) stringRecordLine renders a whole Moved: trailer line, without its newline.
#RetractLine
func RetractLine(id string) stringRetractLine renders a whole Moved-Retract: trailer line, without its newline.
#ParseRecord
func ParseRecord(value string) (Record, error)ParseRecord reads the value of one Moved: trailer.
#NewRecord
func NewRecord(old, new string, origin Origin) (Record, error)NewRecord mints an id and returns the record for one pair. The pair is validated first, so a record never exists for a pair the grammar refuses.
The origin is a PARAMETER rather than a default: every mint site knows whether it is writing down what a person said or what safegit read off a delta, and a default would let a site that never thought about it write the wrong answer in silence.
#ReadMoves
func ReadMoves(message string) MovesReadMoves parses one commit message's move declarations.
#MovedLines
func MovedLines(message string) []stringMovedLines returns the message's Moved: and Moved-Retract: trailer lines exactly as they are written, continuation lines included.
It is what an amend or a reword re-appends when a new -m replaces the message: dropping a record is a RETRACTION, never the side effect of rewording the commit that carries it, so the lines are carried across verbatim rather than re-encoded from a parse (which would silently normalize -- or lose -- a line this version does not understand).
#Trailers
func Trailers(message string) []KVTrailers parses the trailer block of a commit message. A message with no trailer block yields nothing.
#ParseTrailerBlock
func ParseTrailerBlock(block string) []KVParseTrailerBlock parses an already-located trailer block.
#NewPathSet
func NewPathSet(paths ...string) PathSetNewPathSet builds a PathSet from a list of paths.
#Forward
func Forward(path string, chain []Commit) ProjectionForward follows path through a chain of commits given oldest first, and answers what it is called at the end.
The chain is the caller's: this package walks no history and resolves no revision. A caller with a linear range hands over that range; a caller following a first-parent line hands over that line, with each commit's OTHER parents still listed in Parents so a record made on the side that was merged in is recognized as applying to something that existed.
#RetractedIDs
func RetractedIDs(chain []Commit) map[string]boolRetractedIDs collects every record id the chain retracts.
Folding is over the WHOLE chain rather than forward from each retraction: an id names exactly one record, so where in the chain the retraction sits cannot change which record it names. A retraction naming an id the chain does not carry is inert -- the record it retracts may simply be outside the range the caller handed over.
#Nests
func Nests(a, b string) boolNests reports whether two paths are the same path or one is inside the other. It is the one answer to "do these two declarations speak about each other's paths", and Overlap is what both callers of that question -- the --moved refusal and safegit mv -- ask it through.
#Overlap
func Overlap(a, b Pair) (kind OverlapKind, x, y string)Overlap reports whether two declared moves can stand as one statement, and it is the ONE implementation of that question: safegit mv asks it of the pairs it is about to rename, and --moved asks it of the records a commit or an amend is about to write. The two spellings are the same declaration, so a command line either of them refuses is refused by both.
Three ways two moves collide:
- NESTING ON THE SOURCE SIDE: src/ -> lib/ alongside src/one.txt -> x says two different things about one file. A reader could resolve that by longest match; a writer guessing which the caller meant would be the silent precedence rule this tool does not have. - NESTING ON THE DESTINATION SIDE: two moves landing inside one another describe a result no move produces. - CHAINING: one path that is both a destination and a source, as in a -> b beside b -> c. The result would depend on the order the moves happened to be performed in, which is not something a caller stated.
The two returned paths are the ones that nest, for a refusal to name.
#RemoveMovedRecordsNaming
func RemoveMovedRecordsNaming(message, path string) (string, bool)RemoveMovedRecordsNaming drops every Moved: record whose pair names path, returning the new message and whether anything was dropped.
It is what a rewrite that ERASES a path from history does to the records that reference it: the path is being removed from every tree, so a record still pointing at it is one more reference to the thing being erased. The record is removed rather than edited, because a record is a whole claim -- half of a move is not a smaller move, it is a malformed one.
A retraction naming a removed record's id is left where it is. It names an id nothing carries any more, which the projection already treats as inert, and deleting it would be a second edit to a message for no gain.
#RewriteMessage
func RewriteMessage(message string, transform func(string) string) (string, error)RewriteMessage applies a text transform to a commit message without breaking the quoting grammar of a move record.
The body is transformed verbatim, which is what a pattern substitution has always done to a whole message. Inside a Moved: line only the DECODED path tokens are transformed, and the result is re-encoded through the one encoder, so the record that comes out parses however aggressively the transform rewrote the paths. The record's ID is never transformed: it is a generated name, not content, and a rewritten id names nothing.
Every other trailer -- including a Moved: line that does not parse, which is somebody else's malformed line and not ours to normalize -- is transformed verbatim, exactly as before.
One consequence worth stating: a pattern that matches only the ESCAPED spelling of a path (\101 rather than A) matches nothing here, because the transform never sees the escaped form. The rewrite's own verification is what notices that the pattern survived, and it refuses the rewrite -- which is the honest outcome, and the alternative was a corrupt record.
A transform whose result is no longer a MOVE is refused: the returned message is empty and the error is a *RecordTransformError, which the caller turns into a rewrite that never starts. See that type for why the record is neither written broken nor dropped in silence.
#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
#NewID
func NewID() (string, error)NewID mints a fresh record id. It fails only when the system's entropy source does, which is not a condition to paper over: an id drawn from a degraded source could collide with another record's, and a retraction naming it would then retract the wrong record.
#ValidID
func ValidID(s string) boolValidID reports whether s is a well-formed record id.
It is strict about case: the encoder emits upper case, and accepting lower case would make two spellings of one id, which is exactly what a retraction must not have to guess about.
#Origin.Name
func (o Origin) Name() stringName is the origin's word, for a reader or a payload that needs one where the encoding writes nothing. An absent token is still an answer, and its answer is "declared".
#Record.Subtree
func (r Record) Subtree() bool { return strings.HasSuffix(r.Old, "/") }Subtree reports whether this record claims a whole prefix rather than one path.
#Record.OldPrefix
func (r Record) OldPrefix() string { return strings.TrimSuffix(r.Old, "/") }OldPrefix is Old without the subtree form's trailing slash.
#Record.NewPrefix
func (r Record) NewPrefix() string { return strings.TrimSuffix(r.New, "/") }NewPrefix is New without the subtree form's trailing slash.
#PathSet.Has
func (s PathSet) Has(path string) boolHas reports whether the set holds this exact path.
#PathSet.HasUnder
func (s PathSet) HasUnder(prefix string) boolHasUnder reports whether the set holds any path under this prefix.
#Record.Names
func (r Record) Names(path string) boolNames reports whether this record's pair names path.
For the file form that is the two paths themselves and nothing else -- a file-form record says nothing about a descendant, which is the same rule the projection applies. For the subtree form it is anything at or under either prefix, on both sides: a record claiming src/ -> lib/ references lib/deep/one.txt as surely as it references lib itself.
#RecordTransformError.Error
func (e *RecordTransformError) Error() string#RecordTransformError.Unwrap
func (e *RecordTransformError) Unwrap() error { return e.Err }#Worktree support
Git worktrees allow multiple checkouts of the same repository. safegit places ref locks and the repository-wide rewrite lock 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 those lock files always land in the shared location regardless of which worktree initiated the commit.
The operation lock is the deliberate exception: it lives in the worktree-local safegit directory, because it protects one worktree's tree rather than a ref every worktree shares. Two worktrees therefore switch, merge and rebase independently, while two processes in one worktree serialize. safegit doctor scans both trees.
#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.
A key this struct no longer declares (log.maxSizeMB, removed with oplog rotation) still LOADS from an existing config.json: encoding/json ignores unknown members. Writing one does not: GetConfigValue and SetConfigValue answer "unknown config key" for anything outside ValidConfigKeys.
#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.
#UninstallTarget
type UninstallTarget structUninstallTarget is one path a repository-wide uninstall removes.
#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.
#SharedGitDir
func SharedGitDir(ctx context.Context, gitDir string) stringSharedGitDir returns the COMMON git directory: the one every worktree of a repository shares. For a normal repository it is gitDir itself; for a linked worktree, whose git dir is
It is the anchor for everything that is repository-level policy rather than checkout state -- the ref locks, and safegit's live hook store -- so that two worktrees can never disagree about it. Git's own hook directory is common too, which is why the pre-migration hook location is resolved from here.
The parameter accepts either the git directory (.git) or the safegit directory (.git/safegit); callers use both forms.
The answer never depends on the process working directory. CommonGitDirOf runs git in the git directory it is handed, so an answer that comes back relative is relative to THAT directory and is anchored there -- filepath.Abs, which would resolve it against this process's own directory, is exactly the wrong anchor and is not used.
#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
It takes the same parameter forms as SharedGitDir, whose resolution it is.
#IsInitialized
func IsInitialized(gitDir string) boolIsInitialized reports whether this repository has a usable safegit data directory, which is decided by config.json rather than by the directory alone. A directory that exists without config.json is half-initialized -- an interrupted Init, or any stray subdirectory created under it -- and reporting that as initialized would make EnsureInitialized a no-op and leave every command failing on the missing config.json. Reporting it as uninitialized lets Init complete it (Init is idempotent over the directories it creates).
#Init
func Init(ctx context.Context, gitDir string) errorInit creates the .git/safegit/ directory structure and writes default config.json. Idempotent: returns nil if already initialized.
The context is the dispatch's own: the worktree check below asks git where the common git directory is, and that call belongs on the same context as every other git call the invocation makes.
#EnsureInitialized
func EnsureInitialized(ctx context.Context, gitDir string) errorEnsureInitialized auto-initializes .git/safegit/ if it doesn't exist yet.
#UninstallPlan
func UninstallPlan(ctx context.Context, gitDir string) ([]UninstallTarget, error)UninstallPlan enumerates every path a repository-wide uninstall removes, without removing any of them. It returns an error when there is nothing to remove, which is the "safegit is not initialized" refusal.
Enumerating and removing are deliberately separate: the plan is what the command prints before it asks for consent, and the removal itself goes through the caller's effects handle so that a dry run records it instead of performing it. There is no companion function that both plans and removes -- one existed, only its own tests called it, and it could not be previewed.
What the plan covers: safegit's state directory entirely -- which since the hook store moved there takes the installed hooks with it -- plus the repository-level state under the common git dir in worktree setups (the shared locks and the live hook store, which is the one pushes actually run), plus the two safegit-owned names that may still be sitting in git's own hook directory from before the move. Leaving any of those behind would keep an uninstalled tool's checks running on every push with no state directory left to explain where they came from.
The hook store the CHECKOUT provides (.safegit/hooks in the work tree) is deliberately absent from the plan: it is part of the repository's content, shared with everyone who cloned it, and removing it would be an uncommitted deletion of somebody else's file.
Uninstalling is a REPOSITORY operation, not a per-checkout one. A repository with linked worktrees holds safegit state in one directory per worktree git dir, plus the shared store under the common git dir where the locks and the hooks every push runs live. An uninstall that took only the invoking worktree's directory left the rest of it in place while reporting the tool uninstalled -- config, oplog and all.
The set of state directories is read off disk rather than from git worktree list. A worktree's git dir is always
#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 config.json's exact bytes. Rendering is split from writing so callers mint the write as an effect instead of performing it here, which is what lets --dry-run record a config change without making one.
This package therefore writes config.json in exactly one place -- Init, whose write goes through writeFileAtomic. config set renders here and hands the bytes to the effects handle. A save helper that plain-writes the file would be a third, non-atomic writer of the path every command reads.
#ConfigPath
func ConfigPath(gitDir string) stringConfigPath is where config.json lives for the given git dir.
#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.
The key is resolved BEFORE the value is parsed, so an unknown key is always reported as an unknown key whatever its value looks like: config set log.maxSizeMB abc names the retired key, not the shape of "abc".
#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. In one worktree the commits are serialized twice over -- by the worktree operation lock and then by the per-branch lock -- and CAS retry ensures each commit builds on the latest branch tip. All commits arrive in linear order with no lost files.
Same-worktree commits therefore do not overlap with each other, which is broader than the race strictly requires (only commit-vs-passthrough exclusion is necessary). It is measured as fine -- roughly 30 sequential commits a second -- and a reader-writer design that let commits proceed in parallel is deliberately left as future work, contingent on a measured demonstration of real contention.
#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 --action fix performs the cleanup tasks relevant to concurrency, over both lock trees -- the shared one and this worktree's own -- and over each submodule's safegit directory:
- Orphan tmp directories. Temporary index directories from crashed processes are identified by checking PID liveness and removed.
- Stale lock files. Locks whose holder is genuinely gone are reclaimed through the same flock-and-identity path a contender uses, never a bare judge-then-remove: doctor sweeps unattended and by the hundred, which is exactly where a blind removal would delete a live lock that had been published in the meantime.
- Orphaned publication temporaries. A kill between writing a lock's record and publishing it leaves a
.<name>.lock.tmp-*file. It is not a lock and blocks nothing, but it is swept. - 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, baseDir string, treeish string) (*TmpIndex, error)New creates a temporary index directory under baseDir/tmp/ and seeds the index from the given treeish.
#NewEmpty
func NewEmpty(baseDir 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. baseDir has the same meaning as in New.
#NewFromFile
func NewFromFile(baseDir, srcIndexPath string) (*TmpIndex, error)NewFromFile creates a temporary index directory whose index starts as a byte copy of an existing index file -- in practice the repository's shared .git/index, which is where git records a conflict resolution the operator has staged. Copying is what keeps safegit's promise never to write to that file: the copy is what gets staged into and written out as a tree, and the original is only ever read.
The copy carries whatever the source held, unmerged stage entries included; a caller that copies a conflicted index and then asks for a tree gets git's own refusal to write one, which is the honest answer.
#GarbageCollectPlan
func GarbageCollectPlan(safegitDir string) ([]string, error)GarbageCollectPlan reports the tmp directories whose owning PID is no longer alive, as full PATHS, and removes nothing.
It is the whole scanner: the caller decides what to do with what it found. That split is what lets safegit doctor diagnose with it, preview with it, and repair with it -- the repair removing each planned path through the effects handle, so a dry run records the removals it would make instead of making them, and machine mode carries them. A scanner that removed as it walked could not be asked what it would do without doing it.
#TmpIndex.Cleanup
func (t *TmpIndex) Cleanup() errorCleanup removes the temporary index directory.
safegit doctor --action fix is what runs it: there is no separate garbage-collection command, and nothing sweeps in the background. --dry-run reports what it would remove. The collector never removes a directory belonging to a live process, so it is safe to run while other sessions are actively committing, and it sweeps each submodule's safegit directory the same way.