Skip to content
Concurrency Guide
Edit
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

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

Go go
const IndexBaseParentTree IndexBase = iota

IndexBaseParentTree 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

Go go
const IndexBaseSharedIndex

IndexBaseSharedIndex 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

Go go
const IndexEditBlob IndexEditKind = iota

IndexEditBlob places Mode and SHA at stage 0, replacing every slot the path holds.

#IndexEditWorktree

Go go
const IndexEditWorktree

IndexEditWorktree stages the working-tree file at Path, whatever it now holds, replacing every slot the path holds.

#IndexEditRemove

Go go
const IndexEditRemove

IndexEditRemove removes every slot the path holds, so the commit does not contain it. The working-tree file is not touched.

#ErrTreeUnchanged

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

Go go
type AmendRequest struct

AmendRequest holds inputs for an amend operation.

#AmendResult

Go go
type AmendResult struct

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

#RewordRequest

Go go
type RewordRequest struct

RewordRequest holds inputs for a reword operation.

#RewordResult

Go go
type RewordResult struct

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

#CommitError

Go go
type CommitError struct

CommitError carries a structured exit code alongside the error message.

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

Go go
type PartialError struct

PartialError 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

Go go
type Pipeline struct

Pipeline orchestrates the full commit flow.

#FileSpec

Go go
type FileSpec struct

FileSpec describes a file with optional hunk selection for staging.

#CommitRequest

Go go
type CommitRequest struct

CommitRequest holds all inputs for a single commit operation.

#IndexBase

Go go
type IndexBase int

IndexBase 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

Go go
type CommitResult struct

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

#IndexEditKind

Go go
type IndexEditKind int

IndexEditKind names what one edit does to the temporary index.

#IndexEdit

Go go
type IndexEdit struct

IndexEdit 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

Go go
type RefusedMove struct

RefusedMove 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

Go go
type RefUpdate interface

RefUpdate 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

Go go
func NativeHooks() []string

NativeHooks 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

Go go
func ApplyIndexEditsTo(ctx context.Context, indexPath string, edits []IndexEdit) error

ApplyIndexEditsTo 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

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

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

Go go
func (p *Pipeline) Amend(ctx context.Context, req AmendRequest) (*AmendResult, error)

Amend rewrites the tip of the current branch with new files staged. Uses tmp index seeded from HEAD, stages files, builds a new commit with parent = HEAD^ and lock-and-CAS updates the ref.

#Pipeline.Reword

Go go
func (p *Pipeline) Reword(ctx context.Context, req RewordRequest) (*RewordResult, error)

Reword rewrites only the commit message of the tip of the current branch. Tree and parent remain unchanged. Retries on CAS miss.

#CommitError.Error

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

Error returns the error message.

#CommitError.Unwrap

Go go
func (e *CommitError) Unwrap() error { return e.Err }

Unwrap exposes the underlying cause to errors.Is/errors.As.

#PartialError.Error

Go go
func (e *PartialError) Error() string

Error states the outcome in the order it has to be read: the commit first, the failure second.

#PartialError.Unwrap

Go go
func (e *PartialError) Unwrap() error { return e.Err }

Unwrap exposes the cause to errors.Is/errors.As.

#Pipeline.Execute

Go go
func (p *Pipeline) Execute(ctx context.Context, req CommitRequest) (*CommitResult, error)

Execute runs the full two-phase commit pipeline. On CAS miss it retries from Phase A up to Config.Commit.CASMaxAttempts times.

#Phase A: parallel-safe object construction

Every safegit commit invocation creates its own temporary index file in a unique directory under .git/safegit/tmp/, completely isolated from the shared .git/index and from every other concurrent invocation, so multiple sessions can stage files simultaneously without interference.

#internal/index

Package index manages per-invocation temporary git indexes so each safegit invocation stages into its own index seeded from HEAD, avoiding contention. No safegit operation writes to the shared .git/index; all staging goes through temporary indexes created here.

#TmpIndex

Go go
type TmpIndex struct

TmpIndex represents a per-invocation temporary index directory.

#New

Go go
func New(ctx context.Context, baseDir string, treeish string) (*TmpIndex, error)

New creates a temporary index directory under baseDir/tmp/ and seeds the index from the given treeish.

#NewEmpty

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

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

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

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

Cleanup removes the temporary index directory.

  1. 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.
  1. Create a private temporary index. A directory is created at .git/safegit/tmp/<pid>-<random>/ containing its own index file. The <pid> prefix enables garbage collection of leaked directories from crashed processes. The random suffix (4 bytes of crypto/rand) prevents collisions when the same PID is reused. A dry run creates it inside a throwaway preview area outside the repository instead, so .git/safegit is never touched -- not even created.
  1. 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.)
  1. Stage only the specified files. Files listed after -- are staged into the temporary index. Untracked files are added; deleted files are removed. No other files can leak in because no other process writes to this index.
  1. **Run the repository's pre-commit hook** 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 letting git commit do it. The hook runs once per operation, not once per CAS attempt.
  1. Build the tree and commit objects. git write-tree produces a tree SHA from the temporary index; git diff-tree against 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 -- the commit-msg hook runs and git commit-tree creates the commit object. write-tree and commit-tree are 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

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

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

Go go
type RefLock struct

RefLock represents an acquired lock on a git ref.

#TimeoutError

Go go
type TimeoutError struct

TimeoutError 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

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

Go go
func IsTimeout(err error) bool

IsTimeout reports whether err is, or wraps, a lock acquisition timeout.

#Path

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

Go go
func NameFromPath(locksBaseDir, path string) string

NameFromPath 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

Go go
func Acquire(locksBaseDir, safegitDir, ref, op string, timeout time.Duration) (*RefLock, error)

Acquire attempts to acquire a lock on the given ref. locksBaseDir is the safegit directory whose "locks/" subtree holds lock files; for worktrees this should be the shared (common) safegit dir so that all worktrees serialize on the same lock. safegitDir is the worktree-local safegit dir used for oplog writes (stale-lock recovery events). 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

Go go
func IsLockFile(name string) bool

IsLockFile 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

Go go
func IsPublicationTemp(name string) bool

IsPublicationTemp reports whether name (a bare file name, not a path) is a lock-publication temporary sibling rather than a lock.

#IsStale

Go go
func IsStale(path string) bool

IsStale 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

Go go
func ForceRelease(locksBaseDir, ref string, remove func(string) error) error

ForceRelease unconditionally removes the lock file for a ref. locksBaseDir is the safegit directory whose "locks/" subtree holds lock files; for worktrees this should be the shared (common) safegit dir.

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

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

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

#ReclaimIfStale

Go go
func ReclaimIfStale(path string, remove func(string) error) bool

ReclaimIfStale 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

Go go
func (e *TimeoutError) Error() string

#RefLock.Release

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

Release removes the lock file this process published, and nothing else.

  1. Acquire the ref lock. A lock file is published at .git/safegit/locks/refs/heads/<branch>.lock by writing the holder's record into a temporary sibling and link(2)-ing it into place. link fails with EEXIST when 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.
  1. CAS check. With the lock held, the branch tip is re-resolved. If it matches the parent used in Phase A, the commit is valid. If it has moved (another session committed between Phase A and Phase B), the commit is stale -- this is a CAS (compare-and-swap) miss.
  1. Update the ref. git update-ref advances the branch to the new commit, passing the expected old value for a git-level CAS as belt-and-braces protection. A root commit passes the all-zero SHA, so creating a branch is conditional too.
  1. 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 undo can reverse. Then the shared index is reconciled (only when committing to the checked-out branch), post-commit runs, 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.120Z

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

  1. 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: a host= 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 the start identity 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 (no start field, or a platform that cannot report start times) the check fails closed and the lock is left alone.
  1. 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.
  1. 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 working flock(2)**, and where it does not work, contenders simply time out (exit 8) and safegit 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

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

#The --no-optional-locks flag

Every git command safegit invokes is prefixed with --no-optional-locks, which prevents git from refreshing the shared .git/index as a side effect of read-only operations like git status or git diff. Without this flag, even read operations can contend on .git/index.lock with concurrent writers.

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

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

Go go
const StyleMerge ConflictStyle = "merge"

StyleMerge is git's default: the two sides separated by "=======".

#StyleDiff3

Go go
const StyleDiff3 ConflictStyle = "diff3"

StyleDiff3 adds the merge base between them, under "|||||||".

#StyleZdiff3

Go go
const StyleZdiff3 ConflictStyle = "zdiff3"

StyleZdiff3 is diff3 with lines common to both sides hoisted out of the conflicted region.

#ZeroSHA

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

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

Go go
var ErrDetachedHead = fmt.Errorf("HEAD is detached (not on a branch); check out a branch first or use --branch")

ErrDetachedHead is returned when HEAD is not on a branch.

#ErrNoExpectedValue

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

Go go
type UnmergedEntry struct

UnmergedEntry 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

Go go
type ConflictStyle string

ConflictStyle is git's merge.conflictStyle vocabulary: which shape git writes a conflicted region in.

#MergeFileOptions

Go go
type MergeFileOptions struct

MergeFileOptions shapes one merge-file reconstruction.

#CommitIdentity

Go go
type CommitIdentity struct

CommitIdentity 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

Go go
type AuthorInfo struct

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

#CommitInfo

Go go
type CommitInfo struct

CommitInfo holds the parsed contents of a git commit object.

#TreeEntry

Go go
type TreeEntry struct

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

#ChangedPath

Go go
type ChangedPath struct

ChangedPath is one entry of a recursive raw diff between two trees.

#ObjectEntry

Go go
type ObjectEntry struct

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

#ObjectIterator

Go go
type ObjectIterator struct

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

#CommitMessage

Go go
type CommitMessage struct

CommitMessage is one commit and the whole message it carries.

#IndexStage0

Go go
type IndexStage0 struct

IndexStage0 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

Go go
type MergeTreeResult struct

MergeTreeResult is what one git merge-tree --write-tree computed.

#UnmergedStages

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

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

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

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

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

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

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

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

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

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

WithDir returns a context that carries git directory overrides. All git functions that receive this context will automatically set GIT_DIR, GIT_WORK_TREE, and cmd.Dir on the subprocess, targeting the specified repo regardless of the process's current working directory.

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

Go go
func WithRoot(ctx context.Context, root string) context.Context

WithRoot returns a context carrying the repository-root working-directory pin. See gitexec.WithRoot for what the pin is for.

#Version

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

Go go
func Run(ctx context.Context, args ...string) (stdout, stderr string, err error)

Run executes a git command and returns stdout, stderr, and any error.

#RunWithEnv

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

RunWithEnv executes a git command with additional environment variables.

#RunWithEnvStdin

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

RunWithEnvStdin executes a git command with environment variables and stdin data.

#AnchorRoot

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

Go go
func Anchor(root, repoRelative string) string

Anchor 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

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

RepoRoot returns the absolute path to the repository root.

#GitDir

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

GitDir returns the 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 /.git: a directory that does not exist. Every probe of it then reports "absent", which is the permissive answer in both places it is asked -- no operation in flight, and an empty index -- so a commit taken mid-merge from a subdirectory succeeded and dropped the merge's second parent.

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

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

Go go
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/) has no hooks/ of its own -- git runs the common git dir's hooks there. A caller joining paths itself would run nothing in the first case and nothing at all in the second, while git still ran the operator's hooks.

#HeadRef

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

HeadRef returns the current branch ref (e.g. "refs/heads/main"). Returns ErrDetachedHead if HEAD is not on a branch.

#RevParse

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

RevParse resolves a revision to a full SHA.

#EmptyTreeSHA

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

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

Go go
func HeadIsUnborn(ctx context.Context) bool

HeadIsUnborn 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

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

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

#WriteTree

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

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

#CommitTree

Go go
func CommitTree(ctx context.Context, treeSHA 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

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

UpdateRef atomically updates a ref using compare-and-swap.

oldSHA is the expected current value and is MANDATORY. Pass ZeroSHA to require that the ref does not exist yet, which git enforces by refusing with "reference already exists".

#DeleteRef

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

DeleteRef atomically deletes a ref using compare-and-swap.

oldSHA is the expected current value and is MANDATORY, for the same reason it is on UpdateRef: without it git deletes whatever the ref points at now.

#AddFile

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

AddFile 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

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

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

#DropFromIndex

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

DropFromIndex 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

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

Go go
func ListSkipWorktreeFiles(ctx context.Context) ([]string, error)

ListSkipWorktreeFiles returns the paths of all files with the skip-worktree flag set in the main index. It parses git ls-files -v -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

Go go
func ListTrackedIgnoredFiles(ctx context.Context) ([]string, error)

ListTrackedIgnoredFiles returns the paths of all files that are tracked in the index but ignored by .gitignore rules. These are files that were once committed and later gitignored -- read-tree --reset -u would overwrite them, destroying local modifications (e.g., config files with secrets).

#SyncMainIndexWithWorktree

Go go
func SyncMainIndexWithWorktree(ctx context.Context, treeish string) ([]string, error)

SyncMainIndexWithWorktree updates the main .git/index AND the working tree to match the given treeish. Uses --reset -u, so the working tree must be clean before calling. Needed after history rewrites (scrub) where committed blobs have changed and the working tree must reflect the new content.

Tracked+gitignored files (committed then later gitignored, e.g., config files with secrets) are protected: skip-worktree is set before read-tree so --reset -u does not overwrite them. Pre-existing skip-worktree flags are also preserved.

Returns the list of protected tracked+gitignored paths (empty if none).

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

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

RunPassthrough executes a git command with stdin/stdout/stderr wired to the terminal (os.Stdin, os.Stdout, os.Stderr). It prepends --no-optional-locks like Run, but does not capture output -- suitable for interactive/pager commands.

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

Go go
func RunPassthroughWithEnv(ctx context.Context, env []string, args ...string) error

RunPassthroughWithEnv 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

Go go
func RunPassthroughTo(ctx context.Context, env []string, stdout io.Writer, args ...string) error

RunPassthroughTo 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

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

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

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

Go go
func IsAncestorOf(ctx context.Context, commitSHA, descendantSHA string) (bool, error)

IsAncestorOf checks whether commitSHA is an ancestor of (or equal to) descendantSHA. Uses git merge-base --is-ancestor which exits 0 if true, 1 if false, and other codes on error.

#HaveMergeBase

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

Go go
func IsShallowRepository(ctx context.Context) bool

IsShallowRepository 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

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

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

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

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

#LsTreeAll

Go go
func LsTreeAll(ctx context.Context, treeish string) ([]TreeEntry, error)

LsTreeAll returns all blob entries in the given treeish, recursively. Empty trees return an empty slice, not an error.

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

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

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

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

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

Go go
func LsTree(ctx context.Context, treeish string) ([]TreeEntry, error)

LsTree returns all entries (blobs and subtrees) at one level of the given treeish, without recursing into subtrees. Each entry includes Mode and ObjectType so callers can distinguish blobs from trees. --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

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

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

Go go
func HashObjectWriteBytes(ctx context.Context, data []byte) (string, error)

HashObjectWriteBytes writes in-memory bytes as a blob to the object store via git hash-object -w --stdin, returning the blob SHA.

#HashObjectWriteTag

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

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

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

#MkTree

Go go
func MkTree(ctx context.Context, entries []TreeEntry) (string, error)

MkTree creates a tree object from a slice of TreeEntry values and returns the tree SHA. Each entry must have Mode, ObjectType, SHA, and Path populated. Input is piped to git mktree -z as " \t\0" -- the same NUL-terminated encoding 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

Go go
func CatFileBatchAll(ctx context.Context) (*ObjectIterator, error)

CatFileBatchAll starts a git cat-file --batch-all-objects --batch subprocess and returns an ObjectIterator for streaming the results. The caller must call Close() when done. Respects WithDir context overrides.

#CatFileBatchSHAs

Go go
func CatFileBatchSHAs(ctx context.Context, shas []string) (*ObjectIterator, error)

CatFileBatchSHAs starts a git cat-file --batch subprocess that reads only the specified SHAs, and returns an ObjectIterator for streaming the results. Unlike CatFileBatchAll (which enumerates all objects), this feeds specific SHAs via stdin using bytes.NewReader to avoid pipe deadlock: if output exceeds the OS pipe buffer (~64KB), git blocks on stdout write while the caller is still writing to stdin. With bytes.NewReader, git reads stdin from memory at its own pace. The caller must call Close() when done.

#RunWithGitDir

Go go
func RunWithGitDir(ctx context.Context, gitDir string, workTree string, args ...string) (stdout, stderr string, err error)

RunWithGitDir executes a git command against a specific git directory and work tree, rather than relying on cwd-based discovery. Sets GIT_DIR, GIT_WORK_TREE, and cmd.Dir so both git and cwd-relative paths resolve against the target repo.

It is one of the declared explicit-directory exemptions from the repository-root pin: the repository is an argument, not a discovery.

#CatFileBatchAllWithDir

Go go
func CatFileBatchAllWithDir(ctx context.Context, gitDir string) (*ObjectIterator, error)

CatFileBatchAllWithDir starts a git cat-file --batch-all-objects --batch subprocess targeting a specific git directory. Returns an ObjectIterator for streaming the results. The caller must call Close() when done.

#CatFileBatchSHAsWithDir

Go go
func CatFileBatchSHAsWithDir(ctx context.Context, gitDir string, shas []string) (*ObjectIterator, error)

CatFileBatchSHAsWithDir starts a git cat-file --batch subprocess targeting a specific git directory, reading only the specified SHAs. Sets GIT_DIR so git resolves objects from the target repo rather than the cwd repo. The caller must call Close() when done.

#SplitNonEmpty

Go go
func SplitNonEmpty(s string) []string

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

#ForEachRef

Go go
func ForEachRef(ctx context.Context, format string, prefixes ...string) ([]string, error)

ForEachRef runs git for-each-ref with the given format and optional ref prefixes (e.g. "refs/heads/", "refs/tags/"). Returns one line per ref.

#ReachableMessages

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

Go go
func LsRemoteBulk(ctx context.Context, remote, pattern string) (map[string]string, error)

LsRemoteBulk runs git ls-remote against a remote with a pattern and returns a map of refname to SHA. The output format of git ls-remote is "\t" per line; the map key is the refname.

#ReconcileMainIndex

Go go
func ReconcileMainIndex(ctx context.Context, beforeTip, afterTreeish string) error

ReconcileMainIndex 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

Go go
func SetIndexStage0(ctx context.Context, indexPath string, entries []IndexStage0) error

SetIndexStage0 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

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

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

Go go
func StashStore(ctx context.Context, commit, message string) error

StashStore 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

Go go
func RequireFeature(ctx context.Context, f gitversion.Feature) error

RequireFeature 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

Go go
func (it *ObjectIterator) Next() (*ObjectEntry, error)

Next reads the next non-tree object from the stream. Trees are silently skipped. Returns io.EOF when the stream ends.

#ObjectIterator.Close

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

Close kills the subprocess if it is still running and waits for it to exit.

#Operation log and undo

Every mutating operation is recorded in the oplog at .git/safegit/log, an append-only JSONL file. 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

Go go
type Entry struct

Entry represents a single operation log entry.

#Path

Go go
func Path(safegitDir string) string

Path 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

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

Append writes a single entry to the log file atomically. The entry is serialized as a single JSON line 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

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

Go go
func LastRefUpdate(safegitDir, ref string) (*Entry, error)

LastRefUpdate finds the most recent oplog entry for a given ref that records a new tip SHA. It accepts any op type and tries multiple extra keys ("sha", "to", "result") since different ops store the new tip under different names. Returns nil if no matching entry is found. 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

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

TipSHA extracts the new-tip SHA from an oplog entry's extra map. It checks "sha", "to", and "result" in order. Returns "" if none found.

The oplog enables:

  • Session-scoped undo. safegit undo rolls back the last operation the commit pipeline AUTHORED -- a commit, amend, reword, mv, a merge, pull, cherry-pick or revert that 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 an outcome records 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 by CLAUDE_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 doctor compares the oplog's last known ref state against the actual branch tip. If they diverge, someone committed via raw git commit, bypassing safegit's isolation guarantees.
  • Audit trail. Every commit, amend, undo, and lock recovery is timestamped and attributed to a PID and session.

#Coordination guards for tree-mutating operations

Not all git operations can be safely parallelized. Commands that mutate the working tree -- 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

Go go
type DirtyState struct

DirtyState describes why the working tree is not clean.

#SequencerContext

Go go
type SequencerContext struct

SequencerContext 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

Go go
type WayOut struct

WayOut 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

Go go
type InFlightError struct

InFlightError 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

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

Go go
func WayOutOf(s sequencer.State) WayOut

WayOutOf 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

Go go
func RefuseInFlight(operation string, s sequencer.State) string

RefuseInFlight renders the refusal text for one operation against one state.

#GuardInFlight

Go go
func GuardInFlight(gitDir, operation string, declared *SequencerContext) error

GuardInFlight 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

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

Refuse 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

Go go
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/tty and works normally.
  • switch, pull, merge, rebase, reset and bisect reach git through the effects handle, which gives the child no stdin: anything that reads standard input sees EOF immediately. cherry-pick and revert exec 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

Go go
const MovedKey = "Moved"

MovedKey is the trailer key one move record is written under.

#MovedRetractKey

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

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

Go go
const OriginObserved Origin = "observed"

OriginObserved is a claim safegit derived from a commit's delta.

#NoOverlap

Go go
const NoOverlap OverlapKind = iota

NoOverlap: the two moves are about different paths entirely.

#SameSource

Go go
const SameSource

SameSource: their source paths nest, so they state two fates for one file.

#SameDestination

Go go
const SameDestination

SameDestination: their destination paths nest, so they describe a result no move produces.

#Chained

Go go
const Chained

Chained: one path is both a destination and a source, so the outcome would depend on which move was performed first.

#SessionKey

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

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

#Origin

Go go
type Origin string

Origin 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

Go go
type Record struct

Record 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

Go go
type Moves struct

Moves 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

Go go
type KV struct

KV 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

Go go
type Tree interface

Tree is the arbiter: the set of paths one commit holds.

#PathSet

Go go
type PathSet map[string]struct{}

PathSet is a Tree over an explicit set of paths.

#Commit

Go go
type Commit struct

Commit 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

Go go
type Hop struct

Hop is one record the projection applied.

#Projection

Go go
type Projection struct

Projection is the answer for one followed path.

#Pair

Go go
type Pair struct

Pair 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

Go go
type OverlapKind int

OverlapKind names how two declared moves speak about each other's paths.

#RecordTransformError

Go go
type RecordTransformError struct

RecordTransformError 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

Go go
func EncodePair(old, new string) string

EncodePair 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

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

Go go
func ValidatePair(old, new string) error

ValidatePair 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

Go go
func EncodeRecord(r Record) string

EncodeRecord 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

Go go
func RecordLine(r Record) string

RecordLine renders a whole Moved: trailer line, without its newline.

#RetractLine

Go go
func RetractLine(id string) string

RetractLine renders a whole Moved-Retract: trailer line, without its newline.

#ParseRecord

Go go
func ParseRecord(value string) (Record, error)

ParseRecord reads the value of one Moved: trailer.

#NewRecord

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

Go go
func ReadMoves(message string) Moves

ReadMoves parses one commit message's move declarations.

#MovedLines

Go go
func MovedLines(message string) []string

MovedLines 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

Go go
func Trailers(message string) []KV

Trailers parses the trailer block of a commit message. A message with no trailer block yields nothing.

#ParseTrailerBlock

Go go
func ParseTrailerBlock(block string) []KV

ParseTrailerBlock parses an already-located trailer block.

#NewPathSet

Go go
func NewPathSet(paths ...string) PathSet

NewPathSet builds a PathSet from a list of paths.

#Forward

Go go
func Forward(path string, chain []Commit) Projection

Forward 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

Go go
func RetractedIDs(chain []Commit) map[string]bool

RetractedIDs 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

Go go
func Nests(a, b string) bool

Nests 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

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

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

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

Go go
func Inject(message string) string

Inject reads CLAUDE_CODE_SESSION_ID from the environment and appends a Claude-Code-Session-Id trailer to the commit message if present. For amend: deduplicates if the same session ID already exists as a trailer; keeps both if a different session's trailer is present.

#AppendCustom

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

AppendCustom appends user-provided trailers to the commit message. Each trailer should be in "Key: Value" format. If trailers is empty, the message is returned unchanged. Follows the same format as Inject: appends to an existing trailer block, or adds a blank line separator first.

#SplitBodyTrailers

Go go
func SplitBodyTrailers(message string) (body, trailerBlock string)

SplitBodyTrailers splits a commit message into the body (everything before the trailer block) and the trailer block (trailing Key: Value lines preceded by a blank line). Continuation lines (indented lines following a trailer) are included in the trailer block.

If the message has no trailers, body is the entire message and trailerBlock is empty. If the entire message consists of trailer- format lines with no blank-line separator, body is empty and trailerBlock is the entire message.

#ReplaceIdentity

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

ReplaceIdentity replaces author identity in identity-bearing trailers (lines whose key ends in "-by", such as Signed-off-by, Co-authored-by, Reviewed-by, Acked-by). Within those trailer lines, it replaces "oldName " with "newName ". When only one of name/email is changing (the other old value is empty), only the provided part is replaced. Non-identity trailers and the message body are never modified. If no changes are made, the original message is returned unchanged.

#NewID

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

Go go
func ValidID(s string) bool

ValidID 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

Go go
func (o Origin) Name() string

Name 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

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

Go go
func (r Record) OldPrefix() string { return strings.TrimSuffix(r.Old, "/") }

OldPrefix is Old without the subtree form's trailing slash.

#Record.NewPrefix

Go go
func (r Record) NewPrefix() string { return strings.TrimSuffix(r.New, "/") }

NewPrefix is New without the subtree form's trailing slash.

#PathSet.Has

Go go
func (s PathSet) Has(path string) bool

Has reports whether the set holds this exact path.

#PathSet.HasUnder

Go go
func (s PathSet) HasUnder(prefix string) bool

HasUnder reports whether the set holds any path under this prefix.

#Record.Names

Go go
func (r Record) Names(path string) bool

Names 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

Go go
func (e *RecordTransformError) Error() string

#RecordTransformError.Unwrap

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

Go go
type Config struct

Config 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

Go go
type CommitConfig struct

CommitConfig holds commit-related settings.

#LockConfig

Go go
type LockConfig struct

LockConfig holds ref-lock acquisition settings.

#HooksConfig

Go go
type HooksConfig struct

HooksConfig holds hook-related settings.

#PrePrePushConfig

Go go
type PrePrePushConfig struct

PrePrePushConfig holds pre-pre-push hook timeout settings.

#PushConfig

Go go
type PushConfig struct

PushConfig holds push retry settings.

#UninstallTarget

Go go
type UninstallTarget struct

UninstallTarget is one path a repository-wide uninstall removes.

#DefaultConfig

Go go
func DefaultConfig() Config

DefaultConfig returns the default safegit configuration.

#SafegitDir

Go go
func SafegitDir(gitDir string) string

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

#SharedGitDir

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

SharedGitDir 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 /worktrees/, it 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

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

SharedSafegitDir returns the safegit directory under the common .git dir. For normal repos this is identical to SafegitDir(gitDir). For worktrees it returns /safegit so that lock files and the live hook store are shared across all worktrees, ensuring proper serialization of ref updates and one repository-wide answer to which hooks run.

It takes the same parameter forms as SharedGitDir, whose resolution it is.

#IsInitialized

Go go
func IsInitialized(gitDir string) bool

IsInitialized 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

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

Init 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

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

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

#UninstallPlan

Go go
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 /worktrees/, so the listing is exact, and it still finds the state of a worktree whose checkout has been deleted but not yet pruned -- one git reports as prunable, and whose state an uninstall driven by that list would leave behind.

#LoadConfig

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

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

#LoadConfigFrom

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

LoadConfigFrom reads and parses config from an arbitrary path.

#MarshalConfig

Go go
func MarshalConfig(cfg *Config) ([]byte, error)

MarshalConfig renders 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

Go go
func ConfigPath(gitDir string) string

ConfigPath is where config.json lives for the given git dir.

#GetConfigValue

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

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

#SetConfigValue

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

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

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

Go go
func ValidConfigKeys() []string

ValidConfigKeys returns the list of supported config keys.

#Config.Validate

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

Validate checks that all config values are within acceptable ranges.

#The scrub system

History rewriting (safegit scrub) is an inherently non-concurrent operation -- it rewrites every commit in a range, changing SHAs throughout the history. safegit handles this with a dedicated rewrite lock and a crash-safe record trail.

#Rewrite lock

Scrub operations acquire a repository-wide coordination lock on safegit/rewrite (not a per-ref lock like commits use) before modifying any refs. This prevents two scrub operations from running simultaneously and producing inconsistent history, since history rewriting changes every commit SHA downstream of the rewrite point.

#Crash-safe rewrite maps

Every scrub persists a three-phase record to .git/safegit/rewrite-maps.jsonl, a flock-guarded JSONL file that enables crash recovery and post-scrub orchestration by recording the full old-to-new commit SHA mapping, tag rewrites, and cleanup status:

  1. **start record.** Written before any refs move. Contains the full old-to-new commit SHA mapping and the pre-rewrite state of all remote-tracking refs. If the process crashes after this point, the mapping is recoverable.
  1. **refs record.** Written after refs and tags have been updated. Contains all tag rewrite records.
  1. **complete record.** Written after cleanup (reflog expiry, object pruning) and HEAD resolution. Contains the new HEAD and cleanup status.

These three phases ensure that no matter when a crash occurs, an orchestrator (like rlsbl release scrub) can determine exactly what state the repository is in and resume or roll back appropriately.

#Rewrites in release-managed repositories

A history rewrite invalidates metadata that lives outside the commit graph: changelog entries that name commit hashes, remote tags, and the forge releases attached to them. safegit does not try to prevent that by refusing the rewrite. It performs the rewrite and records the full old-to-new mapping in the journal, and the release tooling repairs the damage afterwards: its changelog hash-resolution check fails loudly on the dangling hashes, rlsbl changelog remap --from-journal rewrites them from this journal, and rlsbl release reconcile re-pushes the moved tags and recreates their GitHub Releases. Detection and repair are the contract; the journal is the interface.

#The rewrite walk

The scrub walker processes commits in topological order (parents before children), applying a transform function to each commit. Parent SHAs are remapped through the growing old-to-new map, so descendant commits automatically inherit rewritten parents. When the transform changes a commit's tree, message, or author, a new commit object is created; otherwise the original SHA is preserved as an identity mapping.

After the walk, the shared finalization pipeline updates all branch and tag refs to point at rewritten commits, syncs the main index with the rewritten HEAD, expires tainted reflog entries, and prunes old objects.

#Common concurrent workflows

#Multiple sessions editing different files on the same branch

This is the most common case. Each session runs safegit commit -m "message" -- file1 file2 with its own files. 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

Go go
type TmpIndex struct

TmpIndex represents a per-invocation temporary index directory.

#New

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

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

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

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

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

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

More tools from this site

  • claudestream Drive Claude Code from Python: run it as a subprocess and read its output as typed events, with async and sync sessions, sandbox policies, and tools you define in Python
  • claudewheel A TUI Claude Code Launcher that lets you have more than one profile, manage sessions lifecycle, pick the exact CC version, model to use (even older unlisted ones), pick which GitHub account to use, etc.
  • dirstat Fast, single-binary directory statistics CLI: every file under a tree grouped by format, with counts, sizes, and lines of code, as a colored terminal table or as JSON
  • fastware A batteries-included ASGI framework: msgspec JSON, a managed Granian server, dependency injection, SSE, WebSockets, auth, and a test client
  • go-toml-edit Zero-dep TOML editing library for Go with comment preservation
  • howmuchleft The fastest Claude Code statusline: context window, 5-hour, and weekly limit usage as three customizable gradient bars, rendering in about 6 ms
  • orxtra
  • pgdesign
  • predraw Declarative rendering pipeline: describe a scene in JSON and get SVG, PNG and WebP out, with light and dark style tokens, reusable components and text converted to path outlines
  • reposummary Turn a git repository's history into a Markdown journal: pick a time window or revision range and get a readable digest of what changed, optionally narrated by an LLM
  • rlsbl Release orchestration and project scaffolding CLI that bumps versions, validates a structured JSONL changelog, tags only the commit CI verified, and publishes to npm, PyPI, Go and more
  • saferm Command-line replacement for rm that archives every deletion with a mandatory reason and the context it ran in, so deleted files can be listed, inspected and restored
  • selfdoc Static Site Generator that builds a project's documentation site directly from its source code, so the docs can never drift from the code they describe, with SEO/AEO, first-class blog, search, and cross-project linking built in
  • strictcli
  • stricttest An always-on test-isolation floor: a pytest plugin and a Go env-hygiene module that make a test suite structurally unable to reach real credentials, the real HOME, the network, or the development repository.
  • wesktop A Python framework that turns an ASGI web app into a desktop application, serving it from a local Granian server and displaying it in a native OS window via pywebview
Search