Skip to content
internal/exitcode
On this page

safegit's one registry of process exit codes: every number the tool returns is a named constant, and the guarded passthroughs' git codes stay outside.

#internal/exitcode

#internal/exitcode

Package exitcode is safegit's single registry of process exit codes.

Every numeric exit code safegit produces is a named constant here, and every exit site in the tool -- die(), os.Exit(), a handler's int return, a commit.CommitError's Code field -- names one of these constants rather than a bare literal. scripts/exit-inventory enumerates those sites mechanically from the AST; it is how the registry was derived and how a later reviewer re-derives it.

There are two carve-outs, both deliberate.

The first: the guarded passthroughs -- switch, pull, merge, rebase, reset, bisect, cherry-pick and revert -- exit with the wrapped git command's OWN exit code once git has run. Those codes are git's (1 for a conflicted merge, 128 or 129 for a fatal error), they are foreign to safegit, and they are deliberately NOT registered here: safegit reports git's verdict verbatim rather than translating it, and a registry row would claim ownership of a number safegit does not choose. A code from one of those commands is safegit's own only when the failure happened before git ran -- the coordination guard, an uninitialized repository, a rejected argument. docs/commands-guide.md states the same split above the generated table.

The second: a signal. When a SIGINT or a SIGTERM reaches a safegit process that holds a lock, internal/lock's handler releases the lock and exits 128 + the signal number -- 130 for SIGINT, 143 for SIGTERM -- which is the Unix shell convention every shell, supervisor and CI runner already reads that way. Those numbers are the convention's, not safegit's, and registering them would claim ownership of a number safegit does not choose; the exit-site guard does not see them either, because the status is computed rather than written as a literal. A signal exit says nothing about what the command was doing: it says the process was ended from outside, with its locks released.

#Standing rule for the redesign campaign

Every later campaign phase that introduces a new hard error registers its exit code HERE, in the same subphase that introduces it -- a new constant with a doc comment saying what the code means and which commands produce it, plus its row in All(). A phase that ships a new refusal without its registry entry is incomplete. exitcode_test.go enforces the half of this that a test can see: a constant declared and left out of All() (or the reverse) fails. The documentation table in docs/commands-guide.md is generated from All() by scripts/gen-exit-table, so it cannot drift from the registry.

#When a new code is warranted

A new code is registered only when the CALLER'S RECOVERY differs from every existing code's -- retry the same command as-is (a transient race), fix something and re-run (a refusal naming its cause), or do not retry at all (the operation stands and repeating it would double it). A new hard error whose recovery matches an existing code's JOINS that code's family, and the payload discriminates the detail; two codes for one recovery path teach a consumer nothing. This is why the commit-stands family covers both a standing commit and a standing fast-forward, while the transient-race abort has its own code: the recoveries differ, the situations within a code need not.

#What the framework owns

strictcli refuses a malformed command line before dispatch -- an unknown flag, an unknown command, a missing required flag, an out-of-choice value -- and those refusals exit 1, which is the framework's code and not safegit's to route. safegit's OWN argument validation, reached after a successful parse, exits Usage (2). The split is deliberate but not currently reconcilable: it awaits an upstream strictcli ruling on a usage-error code.

#DocBeginMarker

Go go
const DocBeginMarker = "<!-- BEGIN generated exit-code table (scripts/gen-exit-table) -->"

DocBeginMarker opens the generated region.

#DocEndMarker

Go go
const DocEndMarker = "<!-- END generated exit-code table -->"

DocEndMarker closes it.

#DocPath

Go go
const DocPath = "docs/commands-guide.md"

DocPath is the file that carries the generated table, relative to the repository root.

#OK

Go go
const OK = 0

OK is a successful run. Produced by every command, and by the per-command help output that --help and -h reach.

#General

Go go
const General = 1

General is an operation that failed for a reason with no more specific code: a git invocation that returned an error, a file that could not be read, a ref that would not resolve, a declined public-remote backup confirmation. Produced by every command except version, which reads nothing and cannot fail.

#Usage

Go go
const Usage = 2

Usage is a command line safegit itself rejects after strictcli has accepted it: mutually exclusive flags, a missing message, a hunk spec that does not parse, an empty file list, a non-positive --count, a malformed glob or --target value, a mv pair that does not parse or that speaks for a path another pair already claims. Produced by commit, mv, undo, scan, scrub file/match/run, author check, and the three guarded passthroughs that take a bare positional argument (switch, merge, rebase). Note that a refusal by the framework's own parser exits General (1) instead -- see the package comment.

#NoRepository

Go go
const NoRepository = 3

NoRepository means the working directory is not inside a git repository (or git is not installed). Produced by every command that resolves the git directory, at that point -- which is every command except version, author list and author check, none of which resolve it (they read git log, and a failure there is General).

#NotInitialized

Go go
const NotInitialized = 4

NotInitialized means safegit's own state directory could not be created or read. Produced by every command that needs .git/safegit: commit, push, undo, unlock, config, scan, hook, backup, scrub verify, the guarded passthroughs, and the four rewrite commands.

#CoordinationBusy

Go go
const CoordinationBusy = 5

CoordinationBusy means the coordination guard refused: another safegit operation, or an in-progress git sequencer state, owns the working tree. Produced by switch, pull, merge, rebase, reset, bisect, cherry-pick, revert and backup restore, and by commit (including its --amend and reword forms), mv and undo, which refuse outright while git has a merge, cherry-pick, revert, rebase or mailbox application in flight -- naming the operation and the command that ends it. mv asks the question before the first rename rather than leaving it to the commit pipeline, so a mv run against a sequencer state moves nothing at all.

merge, cherry-pick, revert and pull refuse outright there too, in the form that COMPUTES an operation (never --abort or --quit), before the compute and for pull before its fetch. That covers the forwarded --no-commit forms of cherry-pick and revert as well: they are handed to git, but what they ask git for is the same computation. They cannot inherit git's own refusal: they compute with git <verb> --no-commit, and git does not refuse that form the way it refuses a plain one.

rebase refuses there too, and its predicate is NARROWER than the rest: it refuses an in-flight state whose kind is not a REBASE. A rebase computes nothing of safegit's, but it runs over whatever state it finds -- over a parked revert on a clean tree it exits 0 and strands that revert's state files behind it, so every later commit refuses over a revert nobody is running. Scoping the predicate to the kind is what lets a rebase's own --continue, --abort and --skip through without a second exemption list: mid-rebase state reports the rebase kind.

The three conclusion commands -- merge-continue, cherry-pick-continue and revert-continue -- produce it from the other direction, for the two ways a conclusion can be asked for against the wrong state: run against an operation it does not conclude (merge-continue during a cherry-pick), and run when nothing is in flight at all. It is the same verdict as the declaration check inside the commit pipeline gives for the same two mismatches, so it is the same code.

#CASExhausted

Go go
const CASExhausted = 7

CASExhausted means the ref moved under every compare-and-swap attempt, so the commit could not converge. Produced by commit, including its --amend and reword forms.

#LockTimeout

Go go
const LockTimeout = 8

LockTimeout means a safegit lock could not be acquired within lock.acquireTimeoutSeconds because a live holder still owns it. It covers every safegit lock and every command that takes one, whichever lock and wherever in the command the acquisition happens:

- the repo-wide rewrite lock: scrub file, scrub match, scrub run, author rewrite; - the worktree operation lock: switch, pull, merge, rebase, reset, bisect, cherry-pick, revert, commit, commit --amend, reword, mv, undo, and the three conclusion commands -- merge-continue, cherry-pick-continue and revert-continue; - a per-ref CAS lock: undo, and the commit pipeline's own acquisition inside the operation lock, which commit, commit --amend and reword reach through pipelineExitCode.

The situation is one situation -- a live holder owns a lock this command needs -- and the remedy is one remedy: wait for the holder, or release the lock once it is provably gone. Which of safegit's locks it was, and how deep in the command the acquisition sat, does not change either, so it does not change the code.

#WriteTree

Go go
const WriteTree = 9

WriteTree means git write-tree failed against the per-invocation index -- most often a full disk. Produced by commit, including --amend.

#CommitTree

Go go
const CommitTree = 10

CommitTree means git commit-tree failed. Produced by commit, including its --amend and reword forms.

#PathMatchedNothing

Go go
const PathMatchedNothing = 11

PathMatchedNothing means a path or directory the caller named contributes nothing to the commit: it is absent from disk and untracked in the tree the commit is built on, it is a directory holding neither files on disk nor paths in that tree, it is a file whose content the commit would not change, or it is an --untrack target the commit's parent does not track, which leaves no index entry to remove. Naming a path is a statement about what the commit contains, so a path that cannot affect it is a refusal rather than a silent omission. Produced by commit, including --amend.

#BinaryHunkSpec

Go go
const BinaryHunkSpec = 14

BinaryHunkSpec means a hunk spec (--hunks file:1,3) was given for a file git reports as binary, where only whole-file staging exists. Produced by commit, including --amend.

#SymlinkHunkSpec

Go go
const SymlinkHunkSpec = 15

SymlinkHunkSpec means a hunk spec (--hunks link:1) named a symlink. A symlink's whole content is the path it points at -- one line the filesystem produces, with no hunks to choose between -- so the selection could only ever select nothing. It is a separate code from BinaryHunkSpec because the reason differs: a binary file HAS content git will not split, while a symlink has nothing to split at all. Produced by commit, including --amend.

#CommitHookRejected

Go go
const CommitHookRejected = 16

CommitHookRejected means one of the repository's own git hooks refused the commit: a pre-commit hook that exited nonzero against the staged content, or a commit-msg hook that exited nonzero on the message. Both are one situation -- the repository's own policy said no -- and the remedy is one remedy: satisfy the hook, or take it out of .git/hooks. No commit, amend or reword is created when it fires. Produced by commit, including its --amend and reword forms. The post-commit hook cannot produce it: it runs after the ref has moved and its exit status is ignored.

#ConclusionUnresolved

Go go
const ConclusionUnresolved = 17

ConclusionUnresolved means a conclusion command's declared resolutions do not match the conflict actually in the index: a conflicted path no --resolve or --resolve-file entry names, or an entry naming a path that is not conflicted. Both halves are one situation -- the set of paths the caller resolved is not the set of paths git left unmerged -- and the refusal lists the paths on whichever side is wrong. Nothing is committed; the operation is still in flight and the same command re-run with the missing (or without the surplus) entries concludes it. Produced by merge-continue, cherry-pick-continue and revert-continue -- and by safegit revert of a single commit, which reaches the same conclusion engine after computing the inverse patch, and therefore refuses on a foreign unmerged entry the same way.

#ConclusionMarkerSurvived

Go go
const ConclusionMarkerSurvived = 18

ConclusionMarkerSurvived means the content a conclusion was about to commit still holds a complete conflict region: the paths match the conflict exactly (that is code 17's question), but one of them carries the markers the resolution was supposed to remove. The refusal names each path and line. Nothing is committed and the operation is still in flight, so editing the file -- or resolving the path to a stage, whose content cannot carry a survived region -- and re-running concludes it. A path whose real content legitimately holds marker-shaped lines is declared with the safegit-conflict-markers attribute, read from the first parent's tree. Produced by merge-continue, cherry-pick-continue and revert-continue -- and by safegit revert of a single commit, whose staged result goes through the same verification before it is committed.

#MoveNotBorneOut

Go go
const MoveNotBorneOut = 19

MoveNotBorneOut means a claim about a move is contradicted by the repository.

A declared move (--moved) reaches it when the old path is not tracked in the tree the commit is built on, when the old path is still sitting on disk, or when the new path is neither on disk nor in that tree. A retraction (--moved-retract) reaches it when the id names no record in the history the commit is built on, or names one that is already retracted. Both are the same verdict: a record and a retraction are each a claim every later reader resolves against the repository, so writing one the repository already disagrees with would send those readers to a path -- or to a record -- that was never there. It is a separate code from PathMatchedNothing, which is about a named path CONTRIBUTING nothing to the commit's content: a declaration stages nothing and changes no content at all, and its failure is a claim the world does not support rather than an argument that had no effect. Nothing is committed when it fires. Produced by commit, including its --amend and reword forms.

mv produces it for the same class of verdict read the other way round: a pair whose source is untracked or absent from disk, whose destination is already occupied, or whose file/subtree spelling disagrees with what the path actually is. The refusal names EVERY pair that is wrong, and nothing has been moved or committed when it fires.

Two declarations that contradict EACH OTHER -- nested sources, nested destinations, one pair chaining into another -- exit Usage instead, which is where every other argument-against-argument contradiction in the commit family exits.

#PushHookFailed

Go go
const PushHookFailed = 20

PushHookFailed means a pre-pre-push hook exited nonzero, so no network I/O was attempted. Produced by push and by hook run.

#PushHookTimeout

Go go
const PushHookTimeout = 21

PushHookTimeout means a pre-pre-push hook exceeded hooks.preprepush.timeoutSeconds and was killed. Produced by push and by hook run.

#BackupDiverged

Go go
const BackupDiverged = 22

BackupDiverged means the remote backup slot holds commits the local history does not contain, so backing up would discard them. Produced by backup backup.

#BackupNoSlot

Go go
const BackupNoSlot = 23

BackupNoSlot means the current branch has no backup slot on the remote. Produced by backup restore.

#HooksNotMigrated

Go go
const HooksNotMigrated = 24

HooksNotMigrated means hook discovery found hooks still sitting in the pre-migration location -- the pre-pre-push file or the pre-pre-push.d/ directory inside git's own .git/hooks -- after safegit moved its live hook store to the tool-owned .git/safegit/hooks. Running them from there would make the store safegit executes from depend on where a file happened to be left, and skipping them would stop an operator's checks in silence, so discovery refuses. The remedy is one command: safegit hook migrate. Produced by push, hook run, hook list and hook remove -- remove reaches it when the named hook exists only in the legacy location, where it is not this command's to delete until migration has moved it.

#HookNotExecutable

Go go
const HookNotExecutable = 25

HookNotExecutable means a discovered pre-pre-push hook is not executable, in EITHER store: the tool-owned live one under .git/safegit/hooks, or the one the CHECKOUT provides in .safegit/hooks. A hook is disabled by REMOVING it -- deleting the file, and committing that deletion for the checkout-provided store -- never by dropping its mode, so a lost executable bit is treated as the accident it almost always is rather than as an intentional disabling that would stop the checks in silence. (Membership of either store is the directory itself, not git's tracking: an uncommitted file there runs too.) The remedy is chmod +x, plus a commit of the mode change where the store is the checkout's. Discovery answers for the whole set, so the healthy hooks beside the offender do not run either. Produced by push and hook run.

#CommitStands

Go go
const CommitStands = 26

CommitStands is the family code for every outcome in which the operation's REF MOVE IS REAL and a step that runs after it did not finish.

For the commit-authoring routes the move is safegit's own commit -- the object exists, it is the branch's tip, safegit undo can reverse it. For a FAST-FORWARD no commit is created at all: the branch stands on the incoming tip, and the index-and-working-tree sync that follows is the aftercare it owes. Both answer the same pair of questions the same way.

It exists because the two halves of such a run answer opposite questions, and a single General (1) answers neither. "Did the operation happen?" is yes; "did everything it owes finish?" is no. A caller that reads 1 has to guess which, and the guess that costs the most is the one a script makes by default -- retrying an operation that already succeeded.

The members of the family are the steps that can only run once the ref has moved: reconciling the shared index with the new tip, removing the concluded operation's state files, writing the declared resolutions into the working tree, putting a fast-forwarded branch's index and working tree in step with the tip it moved onto, bumping a parent repository's gitlink, and putting back the autostash git set aside before a merge. Every one of them leaves the move standing, and every one of them names in its own message what was left behind.

The whole family emits its report: under --json the envelope is emitted with the payload the run would have carried, so a machine consumer is never told nothing about a ref that moved. Produced by commit (including its --amend and reword forms), mv, undo, the three conclusion commands -- merge-continue, cherry-pick-continue and revert-continue -- and the commands that conclude an operation they started themselves: merge, pull, cherry-pick and revert.

#ConclusionWouldOverwrite

Go go
const ConclusionWouldOverwrite = 27

ConclusionWouldOverwrite means a conclusion's working-tree write would destroy content on disk that no side of the conflict accounts for.

Resolving a path to a stage REPLACES the file on disk (git's own checkout --ours) and resolving it to delete removes it, so a file an operator hand-edited between the conflict and the conclusion would be overwritten with content they never chose. safegit refuses instead: the accepted set for a path is the three index stages plus the blob git itself emitted into the working tree, and a file matching none of them is a hand edit.

Nothing is committed and the operation is still in flight, so the edit can be inspected, kept (by resolving that path to worktree) or thrown away. --discard-unmatched-worktree is the election that destroys it anyway. Produced by merge-continue, cherry-pick-continue and revert-continue.

#UnmergedIndex

Go go
const UnmergedIndex = 28

UnmergedIndex means the repository's shared index carries an unmerged entry, so the commit safegit was asked to make would be built beside a conflict nobody resolved. git refuses every commit in that state and so does safegit, naming the paths and safegit doctor --action fix, which re-stages the working tree's own content when no operation is in flight.

The conclusion commands are exempt by construction: an unmerged index is the state they exist to conclude, and their declared resolutions are what resolve it. Produced by commit (including its --amend and reword forms) and by mv, which reaches the same pipeline.

#NonPortableTarget

Go go
const NonPortableTarget = 29

NonPortableTarget means a commit named a symlink whose target text will not resolve in another checkout, and the caller did not elect to record it.

git records a symlink as the link TEXT and nothing else, so the only question is what a checkout somewhere else makes of that text. Two shapes fail it: an ABSOLUTE target, which resolves against the machine's filesystem rather than against the repository, and a RELATIVE target that climbs out of the repository. Both resolve to nothing in anyone else's checkout -- and, where they resolve at all, to a file the repository never carried. safegit refuses them rather than recording a reference only this checkout can honor, and the refusal names the literal target so the operator can see what the link says; offenders of both shapes in one commit are one refusal, grouped by shape, because the remedies differ. --allow-non-portable-targets is the election that commits it anyway, and restores the one-line notice the refusal replaced.

The judgment is made in the COMMIT FAMILY'S INTAKE and nowhere else: commit and its --amend form, over the paths that invocation stages -- the ones the caller named, a --moved commit's paths among them, and the ones a directory argument expands to. It runs before anything is staged, so nothing is written when it fires. Two other ways link content reaches a tree do not pass through it: safegit mv moving an existing tracked link of either shape carries the blob its parent held across and restages no link content at all, and a conclusion's --resolve path=worktree|ours|theirs stages the conflicted path's content directly. Produced by commit, including its --amend form.

#RewriteRefused

Go go
const RewriteRefused = 30

RewriteRefused means a history rewrite was refused by the verification that runs BEFORE any ref moves: the rewritten commits existed only as unreachable objects, and the check found the rewrite did not do what the operation declared it would (a commit changed a path no operation asked to change, a declared change is missing, the scrubbed content survived in the rewritten trees, the named file appears in no commit at all), or the working tree acquired foreign state while the rewrite was running. In every case NOTHING moved: no ref, no tag, no rewrite-journal record, and the original history is exactly as it was. Produced by scrub file, scrub match, scrub run and author rewrite.

#RewriteIncomplete

Go go
const RewriteIncomplete = 31

RewriteIncomplete means the rewrite itself STANDS -- refs moved, the journal is complete, the new history is the repository's history -- but something after it did not finish cleanly: an old object survived the prune, the scrubbed pattern is still reachable somewhere the rewrite does not cover (a stash, a note), a ref still points at a pre-rewrite SHA, or the working-tree sync was skipped because foreign staged state appeared while the rewrite ran. It is a separate code from RewriteRefused because the two ask for opposite things: RewriteRefused says nothing happened and the command can be re-run, this says the rewrite happened and the named residue is what still needs attention. Produced by scrub file, scrub match, scrub run and author rewrite.

#MoveWitnessChanged

Go go
const MoveWitnessChanged = 32

MoveWitnessChanged means a concurrent session moved the branch while a commit was being built and changed the moves that commit's own delta witnesses. The message was already composed -- with the records minted on the first attempt on it, past the repository's commit-msg hook -- so committing it would record moves the new delta no longer bears out, and safegit refuses instead. Nothing was written; the abort names the pair whose witness changed.

It is the one purely TRANSIENT refusal safegit produces: nothing is wrong with the command, the repository or the caller's intent, and running the same command again is the whole remedy. General would put it beside the failures that will happen again no matter how often they are retried, which in a tool built for concurrent sessions is the difference between an automatic re-run and a human being paged. It is NOT CASExhausted either: that one means the ref would not hold still long enough to converge, while this one converged and found a different world. Produced by commit, including its --amend form.

#PushFailed

Go go
const PushFailed = 40

PushFailed means the push did not get through. It covers git push itself failing after safegit's retry policy was exhausted, and the three ways the window around a push can defeat it:

- the remote could not be OBSERVED, before the first attempt or when re-reading it before a retry. safegit pins --force-with-lease to the SHA it observed, so an unreadable remote is not an answer it may substitute a guess for; - the re-read found no refs to push at all; - the re-read found a LOCAL ref at a SHA the pre-pre-push hooks never saw. safegit refuses rather than publish un-validated content, and it does not re-run the hooks mid-retry.

Produced by push and by backup backup.

#PushLeaseRejected

Go go
const PushLeaseRejected = 41

PushLeaseRejected means git refused the push because a --force-with-lease expectation did not match: between safegit observing the remote ref and the push reaching it, somebody else moved it. The lease did its job -- the other session's commits are still there -- so this is a verdict about the world, not a failure to retry: it is terminal, and the remedy is to fetch, look at what arrived, and decide again. It is a separate code from PushFailed because the two ask for different things: PushFailed says the push did not get through, this says it got through and was refused.

Produced by push and by backup backup, the two commands that pin leases from observations they took themselves. For a backup it means another machine wrote the same branch's slot inside that window. It is distinct from BackupDiverged, which is the ANCESTRY refusal: that one is decided from a slot safegit read and found to hold unfamiliar commits, and nothing is pushed at all.

#DoctorFindings

Go go
const DoctorFindings = 50

DoctorFindings means doctor ran its checks and at least one ERROR-severity check failed: safegit cannot work correctly in this repository until the named finding is dealt with. Warn-severity findings are advisory and never reach this code -- a repository whose only findings are warnings exits 0. Under --action fix the code reflects what the fix LEFT behind: a finding the fix repaired does not produce it, one it could not repair does. Produced by doctor.

#Internal

Go go
const Internal = 70

Internal marks an invariant safegit believes cannot be violated -- a switch over a closed set of framework-validated choices reaching its default arm. Produced by push. Seeing it is a bug report.

#Entry

Go go
type Entry struct

Entry is one registered code, as the generated documentation renders it.

#RenderDocument

Go go
func RenderDocument(doc string) (string, error)

RenderDocument returns doc with the region between the markers replaced by the current registry table. It is the whole generation step: the generator writes the result, and the freshness test compares the result against what is on disk, so both answer from one implementation.

A document missing either marker is an error rather than a silent no-op: there is nothing to keep in sync if the region is gone.

#All

Go go
func All() []Entry

All returns every registered code in ascending numeric order. It is the authority the documentation table is generated from.

#Defined

Go go
func Defined(code int) bool

Defined reports whether code is registered here.

#MarkdownTable

Go go
func MarkdownTable() string

MarkdownTable renders the registry as the two-column table the documentation carries. Both the generator and the test that checks the documentation for staleness call this, so there is one rendering and one authority.

Search