Repository navigation
v0.29.0
The two-campaign redesign: safegit authors every commit made under its name (clean merges, cherry-picks, reverts and pulls included), concludes parked operations itself, records file moves, supports unborn branches, and refuses with registered exit codes across a default-deny command subset.
Context
v0.28.0 shipped with known defects — scrub could destroy data when run from a subdirectory, and a dry-run uninstall actually deleted state — whose fixes grew into two full redesign campaigns and a closing round, released here as one version. Campaign 1 rebuilt the commit pipeline, scrub verification, the hooks subsystem, sequencer conclusions and push. Campaign 2 deleted the second authorship class entirely (git computes, safegit commits), codified the subset law with default-deny allowlists, and added declared and observed move records. The closing round added unborn-branch support, the symlink portability refusal, strategy-option forwarding with honest previews, and closed the divergences catalog with every entry deliberate. Every behavior change is red-first tested; the full suite, stress runs, and a released-dependency run are green; a six-domain final audit graded the tree release-ready.
Breaking
- Commit argument intake rebuilt. Move inference no longer decides what gets staged, so a deletion is never staged unless you name it; paths are canonicalized against the repository root, a directory argument expands to the paths it holds, contradictory spellings of the same path are refused, and an argument that matches nothing is now a hard error naming every unmatched argument instead of committing whatever else was named.
- Hunk selection is a flag, not a path spelling. A positional path is always a literal filename, so
file.go:1,3names a file with a colon in it; partial staging is spelled--hunks file.go:1,3, and a hunk selection now survives a directory argument that also covers the same file instead of being silently widened to the whole file. - Commit-family git hooks now run in full.
commit,amendandrewordrunpre-commitonce per commit rather than once per retry,commit-msgon your composed message before trailers are injected, andpost-commitafter the ref moves; a hook that rejects the commit exits 16 instead of 1, repeated-mvalues are joined with a blank line as git does, merge parents are preserved as a list, and a missing submodule auto-bump setting is refused before anything is committed. scrub filerequires--deleteor--replace-with, andscrub verifyis stateless. The scrub mode is no longer inferred from a file's stat,scrub fileadopts the shared range selector, and the policy store is gone:scrub verifytakes an explicit--patternor a recipe, anddoctorreports and deletes a leftoverscrub-policies.jsonl.- The hook subsystem has one location authority and a tool-owned live store. Pre-pre-push hooks live in a store safegit owns, fed by a tracked
.safegit/hooksdirectory;hook migratemoves a legacy directory into it andhook removetakes one out, with exits 24 and 25 for an unmigrated or non-executable hook; the live store and the legacy location are repository-level rather than per-worktree, and uninstall reaches them both. doctorexits 50 when it finds an error-severity problem. It exited 0 whatever it found before, so a scripted health check now needs to expect 50; the run also gained checks for per-feature git-version floors, both lock trees, the git hooks safegit never runs, and a branch the oplog names that git can no longer resolve.doctor --action uninstallis repository-wide and enumerates what it removes. It asks the shared state store what exists and removes every safegit path in the repository rather than only the invoking worktree's, listing each path it removes.- Windows is no longer a supported platform. The Windows release binaries and the build-tagged Windows sources are gone, so
GOOS=windowsfails at compile time instead of producing a binary whose locking was never implemented. - The oplog is unbounded and its reads fail closed. The line cap and rotation are gone, so no operation is ever silently dropped; an unreadable or truncated log is an error rather than an empty answer, the number of skipped lines is reported,
doctorgained a check table for it, and the retiredlog.maxSizeMBconfig key is now refused byconfig setand absent fromconfig show. - Exit codes now say what happened. A contended lock exits 8 with the real lock error, naming the ref, the holder and the wait, where the four rewrite commands used to replace it with a generic message and exit 1; a hunk spec against a binary file exits 14; an uninitialized state directory exits NotInitialized everywhere; a contended ref lock inside the commit pipeline exits LockTimeout like every other lock timeout; and a passthrough command propagates git's own exit code instead of a hardcoded 1.
commit,amend,rewordandundorefuse while git has an operation in flight. A merge, rebase, cherry-pick, revert orgit amin progress is now a hard refusal that names the way out of that state, instead of a commit made on top of a half-finished operation.- Push output is buffered, and the retry works again.
pushandbackup backupnow capture git's output so a failure can be classified, which revived a retry loop that had been dead: progress appears when the push exits rather than as it runs, and under--jsongit's stdout is re-routed to stderr so the envelope stays the only stdout document. Transport failures are matched against phrases git actually emits, an unreadable remote is an error rather than an absent-ref answer, and a retry whose re-resolution finds a local ref moved is refused. - Pinned per-ref leases and consent before a force-push. Each ref is pushed under a
--force-with-leasepinned to the SHA just observed, forcing asks for confirmation first, a backup slot that moves inside the push window exits PushLeaseRejected, a dry-run push discovers its hooks and refuses an unmigrated or non-executable one exactly as a real push does, andbackup restore's refusal names the operation in flight. safegit mergewrites its own commit. A merge that resolves cleanly is authored by safegit's pipeline instead of by git, so it carries safegit's trailers, runs the repository'scommit-msghook, writes one oplog entry and can be reversed withsafegit undo. A fast-forward moves the ref under compare-and-swap and then puts the index and working tree in step with it, andsafegit undorefuses that one because the tip is a commit safegit did not create. The command line is a deliberate subset: exactly one committish, and an octopus merge,-s,--squash,--edit,--autostash,--commit,--allow-unrelated-histories,--rerere-autoupdateand aFETCH_HEADnaming more than one side are each refused by name rather than accepted and quietly reinterpreted. Strategy OPTIONS (-X/--strategy-option) are honored instead, and--dry-runforwards them to the tree it computes. Beyond the flag, a merge whose two sides share no commit at all is refused before anything computes -- with no flag typed, and naming the route for a deliberate import;safegit pullinherits that refusal.safegit cherry-pickandsafegit reverttake exactly one commit, and safegit authors the result. A clean pick or revert is committed by safegit's own pipeline: trailers, thecommit-msghook, one oplog entry, and reversible withsafegit undo. A pick preserves the source commit's author while a revert is your own change. Multi-commit argv and range or rev-set spellings (A..B,A...B,^rev) are refused naming sequential single invocations, and the queue members are gone from both payloads. A pick or a revert whose compute turns out to change nothing now removes the state it just parked --CHERRY_PICK_HEADorREVERT_HEADand git's own scratch files -- instead of stranding an operation that would block every later commit, and says so in place of the abort advice; when the cleanup itself fails the leftovers are named and that advice stands.safegit pullmerges through safegit's own merge. A pull that cannot fast-forward now produces a pipeline-authored commit with safegit's trailers and an oplog entry, and one that can fast-forward moves the ref under compare-and-swap and syncs the index and working tree.--rebaseis refused, naming the two commands that do it.- Forwarded git options are checked against an allowlist, and
checkoutis nowswitch. Each guarded command validates its forwarded argv before anything runs and refuses any token that is not on its allowlist, naming the subset law anddocs/divergences.md.safegit checkoutno longer exists:safegit switch <branch>navigates andsafegit switch -c <new>creates, branch names only. A tag, an object name or any other commit-ish is refused because it detaches HEAD, as are--detach,-C,--force/--discard-changes,--orphanand--merge. There is no file mode at all -- thecheckout -- <path>shape that destroys uncommitted work is not implemented rather than refused.resetkeeps its five modes with a commit but refuses the pathspec form and--patch;rebasekeeps<upstream>,--onto,-i,--autostash,-r/--rebase-merges(whose optional value is attached only) and git's own--continue/--abort/--skip, and refuses the apply backend,--execand--root;bisect's classified subcommand vocabulary is its allowlist. Oncherry-pickandrevert, strategy OPTIONS (-X/--strategy-option) are honored while strategy SELECTION stays refused, and--rerere-autoupdateis refused on both -- the negative spelling--no-rerere-autoupdatestays allowed, since it is the only per-run switch against thererere.autoUpdateconfig key. - Conclusions refuse the shapes safegit cannot start, and the delegation is deleted.
cherry-pick-continueandrevert-continuerefuse a queued sequence, which only raw git can now create, andmerge-continuerefuses an octopus merge or a content conflict git recorded with noAUTO_MERGE; each refusal names git's own--continueand--abortas the way to finish what git started. A--continueforwarded tosafegit merge,cherry-pickorrevertis refused instead of letting git author the commit. The delegated payload members go with the machinery. - A non-executable pre-pre-push hook is refused wherever it lives, and the per-hook timeout override is gone. Discovery used to skip a non-executable hook in the live store with a warning while refusing the tracked one; both now refuse at exit 25,
hook runrefuses instead of reporting that there is nothing to run, anddoctorreports it as an error rather than a warning. The# safegit: timeout=NNNfirst line a hook could print to raise its own budget is deleted -- the configured budget is the only one. safegit mvrefuses a dirty move and a missing destination directory. The commit carries each path across as the blob its parent held, so moving a file with uncommitted content changes would silently drop those edits: it is refused at exit 19, and the message says to commit the content first or to move it yourself and commit both at once withcommit --moved. A destination whose parent directory does not exist is refused unless--create-missing-directorieselects creating it, and a destination parent that is a file is always refused.- A symlink target that will not resolve in another checkout is refused (exit 29). A link is stored as its text, so two shapes are refused: an ABSOLUTE target, inside the repository or out, and a relative one resolving outside it. A relative target landing inside stays committable, traversing and dangling ones included. The refusal names the target and the remedy that fits its shape;
--allow-non-portable-targetsrecords it anyway. Migration: a repository carrying a tracked absolute in-repository symlink starts refusing on the next commit that names it or sweeps it up by directory expansion. commit --hunksfails loudly when a patch does not apply. The silent--3wayretry is deleted: a hunk selection that no longer matches the file is a hard error naming the file, instead of quietly committing the result of a three-way merge you did not ask for.- An unmerged shared index refuses every authoring form (exit 28), and
doctorrepairs one.commit,amend,rewordandmvnow match git and refuse while the index holds a conflicted entry, namingsafegit doctor --action fix; conclusions stay exempt, because resolving is what they do.doctorreports an orphaned unmerged index -- conflicted entries with no operation in flight -- as an error-severity finding, and--action fixresolves each path to the content on disk the way git does. - The dirty-tree guard is derived from git's own argv classification instead of a hand-kept list.
reset --mergeandreset --keepwrite working-tree files and were unguarded; they now refuse on a dirty tree exactly asreset --harddoes. Every bisect subcommand that checks another commit out is guarded, includingskip, which the old list missed, while the reporting subcommands are not. - An
--untracktarget never pairs with an inferred move. A path you asked to stop tracking looks exactly like the source of a rename, so untracking one file while adding a similar one recorded a move that never happened. The inference cannot claim an--untracktarget any more. merge,cherry-pick,revert,pullandrebaserefuse to run over an operation git already has in flight. A parked merge, cherry-pick, revert, rebase orgit amis now refused at the command's entry, naming the way out, where a clean working tree used to let the run proceed: a pick over a parked revert committed silently and orphanedREVERT_HEAD, a merge or a pull over one exited 0 and left a staged mutation behind a wrong diagnosis, and a rebase over one exited 0 and stranded the revert's state files behind it, blocking every later commit. The forwarded-n/--no-commitforms ofcherry-pickandrevertare covered too, because they hand git the same computation.rebaseuses a narrower predicate -- an in-flight state that is not itself a rebase -- so its own--continue,--abortand--skipstill reach git, as do the other verbs'--abortand--quit. git's own refusal is not uniform here, so some invocations that used to succeed were the broken ones -- finish or abort the parked operation first.
Features
--untrack, symlink commits, and a truthful path list.--untrack <path>removes a path from the index and leaves the file on disk (the notice consults the ignore rules, not the index), a symlink argument commits the symlink itself while a trailing slash means the tree behind it, and the paths and count safegit reports are the ones the commit actually holds.- Three conclusion commands:
merge-continue,cherry-pick-continueandrevert-continue. Each concludes an operation git left in flight: you pass your resolutions as keyword arguments, safegit writes them into the working tree the way git does, applies an autostash the interrupted merge set aside, and a failed passthrough now names the command that concludes what it left behind. - A conclusion refuses to commit unresolved content (exit 17). Before concluding, safegit checks that every file it was asked to resolve carries no conflict markers, attributes incoming content to the commit it came from, says plainly when a resolution names no content to check, and never points a refusal at a resolution that would itself be refused.
safegit mv. Moves tracked paths, records the moves and commits them in one operation, refusing before the first rename when git has an operation in flight, and honoringcore.ignorecaseso a case-only rename cannot lose an untracked destination.- Declared move records on
commitandamend.--moved 'old -> new'writes a checked record into the commit message and--moved-retract <id>withdraws one by id; a pair that nests inside itself, that duplicates a record the commit already declares, or that chains onto another declaration is refused, and every record carries its own id. - Move records survive
revertandscrub. A single-commit revert declares the inverse of every move it undoes,scrub file --deleteerases the records that name the path in the same rewrite, ascrub matchsubstitution rewrites inside a record without crossing its quoting, and a substitution that would leave an unreadable record is refused. - Rewrites are verified before any ref moves (exit 30 and exit 31). A scrub or author rewrite checks the objects it just wrote for surviving matches and refuses to publish when it finds one (Tier A), then reports leftover pre-rewrite objects afterwards (Tier B); both histories of a submodule scrub are verified before either is published, and trees are written so every path round-trips literally.
- Every scrub says what it touched. A completed scrub prints its rotation notice and the scope it rewrote, names each repository as it publishes it and each submodule history a match reached, reports a skipped index sync from whichever repository skipped it, answers a range's commit count one way, and emits its machine-readable payload on every successful exit.
- One registry decides what each exit code means. Every exit site names a constant from the exit-code registry instead of a bare number, and the exit-code table in the commands guide is generated from that registry, so a documented code and the code safegit actually returns cannot drift apart.
- A worktree operation lock and an explicit
unlockgrammar. Every tree-mutating command serializes on a per-worktree operation lock, lock ordering and the reclamation and cleanup authorities are declared, andunlocktakes an explicit name for the locks that are not refs. - A step that fails after the commit already exists says so, at exit 26. Index reconciliation, the submodule parent bump, an autostash that could not be stored and a fast-forward whose working-tree sync failed used to exit with a generic code and, under
--json, sometimes with no envelope at all. They now share one exit code meaning the commit stands, always emit the envelope naming the object that was created, and every pipeline author --commit,amend,reword,mv,undo, the three conclusions,merge,cherry-pick,revertandpull-- reports its leftover aftercare in the payload. - A conclusion refuses to overwrite an unsaved hand edit (exit 27). Before writing a declared resolution to disk, safegit compares what is there against the three conflict stages and git's own saved
AUTO_MERGEemission for that path. Content matching none of them is your own edit, and the conclusion refuses instead of destroying it;--discard-unmatched-worktreeelects the destruction. The check coversours,theirs, declared deletes and absent-stage deletes, with no skip arm. - A commit records the moves its own delta witnesses, without being told.
commit,amendandmvmint a move record for every rename the commit's tree delta proves. The inference decides what a commit records, never what it stages: the paths that go into the commit are still exactly the ones you named. A whole moved directory collapses into one subtree record when every entry beneath it moved together, symlinks included, and a scatter of moves past the cap records none rather than an arbitrary subset. Each record now says whether safegit observed it or you declared it, the payload reports the records that were written, the ones that were declined and why, and the notice on stderr states each reason and counts what it found. A source whose blob changes between the inference and the commit is a hard error at exit 32 -- purely transient, and re-running is the whole remedy -- rather than a record that is silently wrong. - Every mutation goes through the effects handle, so a dry run and the machine-readable effect log tell the whole truth.
undo's ref move,unlock's lock removals,doctor --action fix's repairs,backup backup's push andbackup restore's fetch and fast-forward are all recorded now, andundocarries a payload of its own. A preview of a commit, a conclusion or an undo inside a submodule records the parent-bump commit it would make, naming the real object it would be triggered by, and a guarded command's dry run no longer writes an oplog entry for an operation it did not perform. - Every guarded operation records its branch, its starting tip and its outcome.
switch,pull,merge,rebase,resetandbisectwrite the full ref name with the tip before and after the operation, and an operation that failed writes an entry marked failed instead of writing nothing at all. A navigation entry keeps its tips under a spelling the bypass-detection readers do not consume, so a branch switch no longer resets that baseline, and a rootundois recorded as a ref deletion so the log's readers stop there. - A conclusion reports the checks it declined to run. A path exempted from conflict-marker detection by the
safegit-conflict-markersattribute was skipped silently; the skip is now carried out to the human report and to adeclined_checksmember in the payload. - The commit payload says which
--amendform ran. Anexecution_modemember carriesamendorrewordon the amend path and null on a plain commit. Nothing is announced on stderr. - A branch with no commits yet is a supported starting point. On an unborn branch -- a fresh
git init, or aswitch -cbefore the first commit --commit,switch,merge,pull,cherry-pick,reset --hardand the conclusions all work: the dirty-tree check compares against the empty tree instead of a HEAD that does not resolve, a merge or a pull takes the fast-forward arm, a pick roots the branch with the source commit's author preserved, a conflicted pick parks and concludes, and a revert that would produce an empty root commit is refused as empty instead of minting one. The forms that cannot work there --merge --no-commit,merge --no-ff,pull --merge-strategy no-ff,rebaseandbisect start-- are refused by safegit before git runs, each naming the unborn branch and the way forward, where raw git answered with a fatal about the wrong thing; the pull refusal fires before the fetch, and a merge preview refuses exactly as the real run does.
Fixes
- A dry run no longer writes to the repository. A previewed commit builds its objects in a quarantine that is discarded instead of leaving them in the object store, the recorded ref update names the commit a real run would create rather than one that will never exist, and a preview emits exactly one record instead of one per internal mutation.
- Previews of
merge,cherry-pickandrevertstate the real outcome. A preview computes what the operation would actually do, including an--ff-onlymerge that git would refuse. - Submodule scrubs. A bounded
--fromrange is mapped into each submodule through its gitlink instead of widening to the submodule's whole history, a submodule whose HEAD cannot be resolved is never rewritten, both the submodule's and the parent's rewrite locks are taken before delegating, two submodules sharing a ref name no longer overwrite each other's recorded remote state, and a scrub that moves no gitlink still reports its payload. scanreaches everything. It sweeps every hook location and the shared safegit state directory, including from a linked worktree, and says which coordinate each match's path is in.- A live lock is never stolen. Stale-lock reclamation now happens under the lock file's own flock with an inode re-check and lock files are published complete rather than created empty and filled in after, PID reuse is decided by the recorded process start identity instead of file timestamps, a holder releases only the lock file it published and never a newcomer's, and the acquisition backoff resets when a lock changes hands.
- Staged work you did not name survives.
commit,amend,rewordandundoreconcile the index through one authority instead of resetting it, so foreign staged state and an in-flight operation's conflict state are preserved; a zero-byte sequencer state file is refused the way a whitespace-only one already was, and the cleanup idempotence check runs unconditionally. - Commands work correctly from a subdirectory. Path listings are read against the repository root, the commit pipeline resolves the git directory absolutely, and an index reconcile from a subdirectory reads the repository-rooted tree, so a command run below the root no longer acts on the wrong paths or silently does nothing.
- Every ref update carries an expected old value.
update-refanddelete-refrequire one, a root commit's ref creation says the ref must not exist, and an empty expectation is refused instead of being substituted with the zero SHA, so a concurrent branch update can no longer be overwritten. - Confirmation prompts go to stderr. A deliberate confirmation no longer contaminates stdout, so a command's own output stays parseable while it asks.
- Config handling. An unknown key is reported as an unknown key whatever its value looks like, a value that is not a whole integer is refused, and
config.jsonis written atomically with an fsync before the rename, so a crash or a concurrent reader can never see a half-written or zero-length config. undovalidates the whole range it would move a branch back over before moving it, and a failed submodule parent bump releases both locks it holds instead of leaving them behind.- Documentation and help text corrected against the tool as built. The commands guide, the architecture and concurrency guides, the integration guide, the README and every command's help were rewritten wherever they described behavior safegit no longer has, including the install instruction, the exit-code tables, the conclusion commands, the two hook stores, the scrub range selector and what a dry-run push still does.
- A dry-run uninstall previews instead of destroying.
doctor --action uninstall --dry-runused to remove the state it was asked to preview; it now lists what it would remove and leaves it in place. - Re-running a conclusion after a crash finishes it instead of committing a second time. When safegit died between writing the merge, pick or revert commit and removing the state files, the next
merge-continue,cherry-pick-continueorrevert-continuemade a second commit. It now recognizes the commit that already stands -- by its parents for a merge, by the message the conclusion would have written for a pick or revert -- cleans up from that commit, and names it; the preview reports the stood commit rather than a placeholder, and the refusal's way out no longer names a command that would itself refuse. - A merge conclusion applies only the autostash it set aside.
MERGE_AUTOSTASHwas consumed and deleted whatever it held, so a leftover file from an unrelated operation was applied to your tree. safegit now checks that the stash's first parent is HEAD and that its message carries git's own autostash shape; a stash failing that check is neither applied nor deleted, it is reported as residue, anddoctorreports aMERGE_AUTOSTASHwith no merge in flight as a finding with a fix action. - A conclusion reports a deletion as a deletion. A path resolved to a side that does not carry it was listed among the files written; it is now listed among the files removed.
safegit undoasks about the submodule parent bump before it moves the ref. An unanswered auto-bump question used to surface only after the branch had already moved. It is refused up front now, so nothing has changed when you answer it.- A dry run never prompts for consent. Every confirmation site returns early under
--dry-run, sodoctor --action uninstall --dry-runenumerates what it would remove without asking, and--json --dry-runemits the envelope instead of failing at a prompt it could not answer. --jsonon a guarded command emits exactly one document. git's own output streamed onto stdout beside the envelope, so anything parsing that stream got two documents. Under--jsonthe child's stdout is captured and re-emitted on stderr; human mode keeps git's live output on its own channels.- A submodule enumeration that fails is a hard error, not a silently narrowed scope.
scrubwarned, or said nothing at all, and then rewrote the parent while quietly dropping every submodule from the rewrite's scope; all three seams now fail instead.doctorreports an unreadable enumeration as an error-severity finding, because the same failure silently empties--action fix's submodule cleanup scope. - The documentation is rewritten against the tool as built. The commands, integration, architecture and concurrency guides, the README and CLAUDE templates and the affected help strings now describe single authorship, the subset law, the forwarded-option allowlist, the
switchrename, the conclusion refusals, the overwrite and commit-stands behavior, the crash re-run, mv's refusals, the symlink policy, the--untrackfence and the move vocabulary.docs/divergences.mdgains an entry for every new refusal and loses its delegation-era claims, and the health-check table in the commands guide is generated from the check registry instead of hand-maintained. rewordandmvreport only the move records their commit actually carries. Acommit-msghook that rewrites the message is free to strip a record, and both payload arms named the records safegit minted rather than the ones the committed message holds -- so a reader could go looking for a record the commit does not carry. Thecommitandamendarms were already narrowed; these two now match.- A framework-owned flag written after a guarded command's name now says how to fix it.
safegit merge --jsonwas refused by citing the subset law, which reads as safegit lacking machine mode.--json,--dry-run,--quiet,--verboseand--approve-consequentialare read before the command name, so the refusal says that instead and shows the flag written one word earlier.