On this page
#Changelog
#0.29.2
Documentation frontmatter converted to TOML for the current selfdoc; no user-facing change.
#Infrastructure
- Documentation frontmatter converted to TOML for the current selfdoc; no user-facing change.
#0.29.1
Fixes to commit --moved, current go-toml-edit and strictcli dependencies with unknown recipe keys now refused, a consistent self-description, and the documentation base at the unified site.
#Fixes
- **
safegit commit --movedno longer commits half a rename.** A declared move was checked against the working tree only, so a destination the commit did not stage -- one not named at all, or one a directory expansion passed over as gitignored -- produced a commit carrying the old path's deletion, no addition, and a record pointing at a path the commit does not hold. The same hole on the other side committed a copy while declaring a move, and a reword could write a record about a tree that bears out neither side. The declaration is now checked against the tree the commit writes, on all three arms (commit, amend, reword): a commit that would not carry both sides of the move is refused (exit 19) naming the path to add to the file list, instead of committing silently. - The project describes itself consistently in its README, package documentation and registries. The README opening line, the root package doc comment, the documentation index and
selfdoc.jsoneach said something different about what safegit is, andselfdoc.jsoncarried no description at all. - A key safegit's TOML schema does not declare is now refused by name. A scrub recipe or a conclusion
--resolve-filecarrying a misspelled or unrecognized key (paternforpattern,choiseforchoice) used to parse successfully with that key silently dropped, so an operation or a resolution the file meant to declare never ran. Both files now report the key, its table and its line --4:1: operations[0].unknown_key: unknown key "unknown_key"-- and key matching is exact, so a key differing only in case is unknown too. TOML syntax diagnostics are also reworded:expected value, got Newlinenow readsexpected a value, got newline.
#0.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.
#0.28.0
Migrate onto go-strictcli v0.33.0: presence declared on every flag and argument, push and doctor take a required choice, scrub match and scrub run take member-spelled selectors, and author rewrite declares its constraints.
Context
The declaration-regime campaign's Phase F for safegit. strictcli v0.33.0 refuses a declaration whose presence is unstated, and refuses a value default on any flag or positional arg of a mutating command -- on a mutating command a value the framework picked is a value the framework writes. Together with the deletion of MutexGroup and CoRequired, that turns four hand-rolled selections and one hand-written guard into declarations the parser enforces and --help renders.
Two CLI spellings change and one does not, and the split is deliberate. push's four mode bools and doctor's three collapse into one required choices flag each, because a bool member could be negated into a state that elected nothing -- which is exactly how --no-only-tags used to push HEAD. scrub match's --replace/--mangle and scrub match/run's --from/--entire-history become member-spelled selectors, which keeps every flag an operator types unchanged and reproduces the old refusal sentences byte for byte; the tools that drive those commands need no change.
Exit codes move at every converted site: a hand guard returned safegit's own usage code (2, or 70 for the unreachable-state refusals) and a framework parse error exits 1.
#Breaking
- The CLI declares what it accepts; three hand-written guards are gone, and two spellings changed.
safegit push --only-head|--only-branches|--only-tags|--both-branches-and-tagsbecomessafegit push --refs head|branches|tags|both, andsafegit doctor --diagnose|--fix|--uninstallbecomessafegit doctor --action diagnose|fix|uninstall— neither has a default, and the negation that made--no-only-tagspush HEAD and--no-fixrun the diagnose path no longer exists at all.scrub match's--replace/--mangleandscrub match/scrub run's--from/--entire-historykeep their exact spellings and their exact refusal messages, now as declared selections rather than mutex groups.author rewritedeclares its two pairs and its at-least-one rule as constraints, rendered in--help; a missing or half-typed pair is refused by the parser and exits 1 instead of 2, and the two statespushandscrub matchused to refuse with exit 70 are unrepresentable. Every flag and positional argument now declares its presence, so--helpmarks each one[required],[optional]or[default: v];--amend,--allow-empty,--pre-push-hook,--force-with-lease,--bypass-session,--count,--diff,--limit,--overwrite-remote-backupand--allow-public-remotecarry no default value any more and name their fallback in their own help text, behaving exactly as before when omitted. - **The machine envelope declares
interface_version: 2.** Machine mode (--json) is the CLI framework's, and the framework's envelope contract advanced with the release safegit now builds against: the document gains awritesmember (alwaysnullhere — safegit declares no update command) and the version it reports is 2. A consumer that pinsinterface_version == 1must be updated before it can read safegit's output.
#Fixes
- Released binaries report their real version again. The goreleaser build stamped
main.Version, a variable that does not exist; every published binary fell back to the module pseudo-version, sosafegit versionandsafegit --versionreported something like0.27.1-0.20260814034848-8737c897423ainstead of the release tag.
#0.27.0
safegit adopts the framework's machine-output envelope: --json now emits the strictcli envelope as the sole stdout document with a command's own data as its payload, and machine mode no longer forces --quiet. The four history rewrites mint real effects, so a dry-run preview finally lists what it would do. Three dry-run bugs are fixed: a preview no longer writes under .git/, and it no longer refuses a dirty working tree.
Context
The dependency on the CLI framework moved off a local workspace and onto the released strictcli go v0.32.0, which publishes the machine-mode envelope API this work is written against. That release also reworded the refusal a consequential command gives when stdin is not a terminal, so safegit's pinned copy of that string is updated to match.
This is the version rlsbl's scrub path requires: rlsbl pins SAFEGIT_MIN_VERSION = 0.27.0 for the rewrite journal and the JSONL hash remapping it drives from it.
#Breaking
- **
--jsonis now the framework's machine mode, and its output shape changed.** stdout carries exactly one document -- the strictcli envelope (interface_version,app,command,exit_code,payload,dry_run,preview,preview_error,diagnostics) -- and a command's own data is itspayloadmember rather than the whole stream.--jsonis no longer a safegit flag (it is framework-owned and recognized anywhere in argv), it no longer implies--quiet(the envelope is exempt from quiet, so--json --quietemits the complete document), a failing command answers with its exit code and stderr instead of a{"error": ...}object, and a dry run's recorded effects ride the envelope'spreviewmember instead of a would-do log printed after the JSON. Every payload-producing command declares a JSON Schema the framework validates at emission and--dump-schemapublishes verbatim. Anything parsing safegit's JSON must read the payload. - The refusal a consequential command gives when there is no terminal is reworded.
scrub file,scrub match,scrub runandauthor rewriterefused a non-interactive run witherror: stdin is not interactive; pass --approve-consequential to confirm; against the released strictcli v0.32.0 that line now readserror: stdin is not interactive; a consequential command must be confirmed at a terminal. The behaviour is unchanged -- the four commands still refuse without a terminal, and--approve-consequentialstill consents -- but a script or hook matching on the old text will no longer recognize it. safegit's own refusals for the conditions the framework cannot see (doctor --uninstall, abackup backupto a public remote) are unchanged and still name their consent flag.
#Fixes
- Dry-run scrub no longer writes to disk or refuses a dirty working tree.
safegit --dry-run scrub file|match|runcreated.git/safegit/(config.json, the operation log, lock directories) before previewing anything, and refused to run at all when the working tree had uncommitted changes -- the state a preview is most useful in. A preview now leaves the repository byte-for-byte untouched and runs on a dirty tree; every execute path still requires a clean one. - A dry-run commit no longer writes to disk, and a half-initialized repository repairs itself.
safegit --dry-run commitcreated its per-invocation temporary index under.git/safegit/tmp/and left that directory behind, which made the repository read as initialized whileconfig.jsonwas absent -- after which every safegit command there failed withreading config.json: no such file or directoryuntil.git/safegitwas deleted by hand. A preview now stages into an OS temporary directory and touches nothing under.git/, and a.git/safegitwithoutconfig.jsonis completed on the next executing command instead of being trusted as complete. - **A dry-run
author rewriteworks on a dirty working tree.**safegit --dry-run author rewriterefused when the working tree had uncommitted changes -- exactly the state a preview is wanted in -- because the clean-tree requirement ran before the preview branch. The preview now runs on a dirty tree; an executing rewrite still requires a clean one. - A dry-run history rewrite finally says what it would do, and both renderings agree.
scrub file,scrub match,scrub runandauthor rewriteminted no effects, so--dry-runprinted the would-do log's header over an empty body -- reading as "this would change nothing" about a preview of an irreversible rewrite. The rewrite is now recorded through the effects handle, so the log lists the ref move and the reflog/repack/prune cleanup that follows it (and the same records ride the envelope'spreviewin machine mode). The human and machine outputs are also one computation now:estimated_commitsused to exist only in the machine branch, and the human "in N objects" count was a different denominator from the machine field it read like.scrub matchreports both, asobjects_matchedandobjects_scanned.
#0.26.0
Each confirmation now owns its consent flag, hook run stops pretending to have a dry run, and the command surface is pinned by tests.
Context
This release closes the residue the strictcli effects-regime migration left behind. Two confirmation layers had ended up in series: the framework asks before a consequential command runs, and safegit asked again behind it. A human answered twice; a script that passed --approve-consequential had every safegit-side question answered for it, including the one that flag says nothing about -- whether the remote a backup is about to land on is public. Each question now carries its own flag. --allow-public-remote is the only answer to the exposure question, which restores the property that a non-interactive run cannot publish a branch to a public repository without having said so, and the four history rewrites lost their duplicate prompt in favour of a notice.
hook run was found to ignore --dry-run entirely and execute every installed script for real. It now declares that it has no dry-run mode, with the reason, rather than inventing a preview of a program safegit did not write.
Under the hood, main() was split so that a test can hold the registered app: every command's classification, its consequential and dry-run declarations and the group tree are now pinned by a table, and the app description's command count is checked against the number of commands actually registered -- it had been advertising twenty against thirty-one. The generated selfdoc manifest and content hashes are tracked in the repo, matching the rest of the fleet.
#Breaking
- **Breaking:
--approve-consequentialno longer consents to backing up to a public remote;--allow-public-remotedoes.** Two confirmations sat in series: the framework asks before a consequential command runs, and safegit asked again behind it. An operator at a terminal answered twice, and a script that passed--approve-consequentialhad every safegit-side question answered for it -- including the one that flag says nothing about, whether the remote a backup lands on is public. Each question now owns its flag.safegit backup backupgains--allow-public-remote, which is the only thing that consents to a public or unclassifiable target, so a non-interactive run can no longer publish a branch without having said so. The four history rewrites (scrub file/match/run,author rewrite) lost their second prompt: the framework already took consent, so their commit counts and scope are printed as a notice instead of asked again.doctor --uninstallis unchanged.
#Fixes
- **
safegit --dry-run hook runno longer runs the hooks.** The command ignored--dry-runoutright, so what was meant as a preview executed every installed pre-pre-push script for real. There is nothing honest to preview here -- a hook is a script you supplied, and safegit cannot know what it does -- sohook runnow declares that it has no dry-run mode and the flag is refused with that reason.safegit hook listshows which scripts a push would run. - The README no longer documents a flag that does not exist. Its note on PID reuse told you to run
safegit unlock --force, which has never been a valid invocation --unlocktakes a ref and no flags -- so anyone following it got a parse error. It now describes whatunlockdoes, including that it refuses to clear a lock whose holder is still alive. The commands and integration guides were updated for the new per-condition consent flags, andhook rungained a section on why it refuses--dry-run.
#0.25.1
Publishes 0.25.0's platform binaries and container image, which a new source-wide secret scan had blocked
Context
0.25.0 shipped as a Go module and nothing else. The re-scaffolded publish workflow scans the whole source tree with gitleaks before it builds anything, and safegit's scrub and scan tests plant strings that look precisely like leaked credentials -- SECRET_XYZ_123, sk_live_abc123 and friends -- because a scrubber that is never given anything to scrub is not being tested. The scan found five, both publish jobs failed, and the 0.25.0 Release ended up with no binaries attached and no image in the container registry.
The repository simply had no .gitleaks.toml; the scan step is new. The allowlist added here names the four fixture literals rather than exempting the test files wholesale, so if a real credential is ever pasted into one of those files the scan still catches it.
This is a fix-forward rather than a retag: the tag on 0.25.0 sits on a tree the scan will reject on every re-run, so there is no dispatch that could make it publish. Nothing about 0.25.0 is withdrawn -- the Go module proxy has served it since it was tagged, and this release changes no code.
#Fixes
- 0.25.0's binaries and container image are published. The publish workflow gained a gitleaks scan of the source tree, and safegit's own scrub and scan tests plant synthetic credential strings in throwaway repositories -- which is exactly what makes them useful fixtures. With no allowlist the scan found five and failed both publish jobs, so the 0.25.0 GitHub Release carried no platform binaries and no image was pushed to the container registry. A
.gitleaks.tomlnow names those four fixture literals, so the scan passes without exempting the files a real credential could land in. The Go module was unaffected --go install github.com/smm-h/safegit@v0.25.0has worked all along.
#0.25.0
safegit backup keeps a per-branch history snapshot on a remote, --dry-run is honest in every command that accepts it, only the four history-rewriting commands confirm (via --approve-consequential), and the rlsbl rewrite handshake is gone
Context
Three things happened here, and they are connected.
The first is safegit backup. The tool has always been careful about the working tree and the commit path, but the one thing it could not do was give you somewhere to put a branch before you did something frightening to it. The new group keeps exactly one slot per branch under refs/backups/<branch> on a remote: backup backup refuses to overwrite a slot holding commits your history does not contain, pins the push with a lease to the SHA it just observed so a concurrent backup from another machine is rejected rather than clobbered, and asks first when the remote is public or cannot be proven private. Every slot is plain-git restorable, which is the point -- a backup you need safegit to read is not much of a backup.
The second is that --dry-run now means something. It used to be a per-command courtesy: eleven commands accepted the flag and mutated anyway, so safegit --dry-run push really pushed, and a dry-run commit inside a submodule created a real commit in the parent repository. The mutating seams now route through the CLI framework's effects handle, so a dry run records what it would do and prints it instead of doing it. That is a guarantee rather than a habit, because there is now one place it could break instead of thirty.
The third is the confirmation story, and it is where the breaking changes are. Confirmation used to be inferred from "this command mutates something", which caught roughly two thirds of the commands in the tool -- commit, push, pull, undo among them -- and a prompt that fires on two thirds of invocations trains people to dismiss it, which is the opposite of what a confirmation is for. Consequence is now declared per command, and exactly four declare it: scrub file, scrub match, scrub run and author rewrite, the ones that rewrite history irreversibly. Everything else runs bare again, with no flag and no prompt. The flag that consents is --approve-consequential, deliberately unwieldy so it cannot decay into muscle memory the way -y did, and self-documenting wherever it appears in a script. --yes is gone, and so are -q, -n and -y: the reserved quartet is framework-owned and has no short forms, in exchange for being accepted anywhere on the command line. --json no longer answers a confirmation on your behalf, and a declined confirmation exits 1 instead of 0, so a script cannot read a refusal as success.
Also gone: the guard that killed scrub and author rewrite in any repository containing .rlsbl/ unless RLSBL_SCRUB_ORCHESTRATED=1 was exported. That was prevention by handshake -- two tools had to agree on an environment variable before either could do its job, and the failure mode was a hard stop on a legitimate rewrite. Every rewrite already writes a journal at .git/safegit/rewrite-maps.jsonl; the release tooling reads that journal to detect the metadata a rewrite invalidated and repair it. Detecting and healing after the fact turned out to be both simpler and stricter than refusing beforehand.
#Breaking
- Destructive history rewrites are no longer blocked in release-managed repositories.
scrub file,scrub match,scrub runandauthor rewriteused to die in repos containing.rlsbl/unlessRLSBL_SCRUB_ORCHESTRATED=1was set; that handshake is gone and the rewrites run like anywhere else. Every rewrite still writes the journal at.git/safegit/rewrite-maps.jsonl, which the release tooling reads to detect and repair the metadata a rewrite invalidates. - **
--jsonno longer confirms destructive operations for you.** A--jsonrun used to imply consent, sosafegit --json scrub match ...,--json author rewriteand--json doctor --uninstallwent ahead without asking. They now refuse and name--approve-consequentialas the flag that consents deliberately;--jsonstill means machine-readable output. Previews are unaffected, andscrub file --dry-runno longer asks for permission to rewrite nothing. - **Breaking:
--quiet,--verbose,--dry-runand--approve-consequentialare framework-owned, and the short forms are gone.** Write them in full ---q,-nand-yno longer exist. In exchange all four are recognized anywhere on the command line, sosafegit push --dry-runworks as well assafegit --dry-run push. Every command is now classified read-only or mutating, which is what decides whether--dry-runrecords the command's effects instead of performing them; read-only commands (version,scan,config show/get,author list/check,backup list,hook list,scrub verify) have nothing to record. - Only history-rewriting commands ask for confirmation now.
safegit commit,push,pull,undo,config setand the guarded passthroughs run bare again -- no flag, no prompt. Confirmation is reserved for the four commands that rewrite history irreversibly (scrub file,scrub match,scrub run,author rewrite), and the flag that skips it is now--approve-consequential;--yesis gone.
#Features
- **
CLAUDE_CODE_SESSION_IDis now documented insafegit --help.** The session handshake variable that scopessafegit undoand stamps commit trailers is declared to the CLI framework and listed under Infrastructure. - **New
safegit backupcommand group.**backup backup,backup listandbackup restorekeep one backup slot per branch underrefs/backups/<branch>on a remote: the backup refuses to overwrite a slot containing work missing from your history, leases the push to the SHA it just observed, warns before pushing to a public remote, and every slot stays restorable with plain git. - Commands guide documents the backup group. The guide covers
backup backup/list/restore, the divergence refusal and lease, the public-remote confirmation, and the plain-git commands each subcommand is equivalent to. - Three new documentation guides, and a README and CLAUDE.md generated from templates. The docs site gains a commands guide covering every subcommand with its flags, exit codes and plain-git equivalent; a concurrency guide explaining the locking model and how concurrent sessions interact; and an integration guide for wiring safegit into other tools.
README.mdandCLAUDE.mdare now generated by selfdoc fromdocs/_README.mdanddocs/_CLAUDE.md, so they stay in step with the rest of the documentation.
#Fixes
- **
author rewrite --dry-runno longer contends for the rewrite lock.** The read-only preview previously loaded config and acquired the repo-wide rewrite lock, so it could block and fail while an unrelated scrub was running. - **
--dry-runin a submodule no longer commits in the parent repository.** Submodule auto-bump ran on the dry-run path too, so previewing a commit, amend or reword inside a submodule could create a real parent commit; the preview now leaves the parent untouched. - **
--jsonno longer bypasses the backup public-remote confirmation.**safegit --json backup backup <remote>used to push a full branch snapshot to a public or unclassifiable remote without asking, because--jsonimplied consent. It now refuses and names--approve-consequentialas the flag that consents deliberately. - **
backup backup --dry-runno longer contacts the remote.** The preview is built from local state, so it works against an unreachable remote and never asks the public-remote question. The slot's SHA, the ancestry check and the lease are resolved when the backup actually runs. - **
--dry-runis honest everywhere now.**push,pull,checkout,merge,rebase,reset,bisect,cherry-pick,revert,config setandhook installall ignored--dry-runand mutated anyway --safegit --dry-run pushreally pushed. Every one of them now records what it would do in a would-do log on stdout and changes nothing. - **
commit --dry-runno longer reports a commit it did not make.** The preview printed<n> file(s) committed; it now sayswould be committedand lists the ref update it would perform. - A declined confirmation now exits nonzero. Answering
n(or feeding EOF) todoctor --uninstall, a public-remotebackup backup, or any scrub/rewrite prompt printedAborted.and exited 0, so a script or agent could read a refusal as success. Every declined confirmation now exits 1.
#0.24.0
Breaking: --config renamed to --config-file (strictcli reserves the global name 'config'); go-strictcli 0.20.0.
#Breaking
- Breaking.
--configrenamed to--config-file(strictcli now reserves the global nameconfig).
#0.23.5
Fix Windows process-alive detection (locks and GC now work correctly on Windows).
#Fixes
- Windows process-alive check now uses OpenProcess API instead of broken Signal(0), fixing lock contention and premature tmp directory cleanup on Windows.
#0.23.4
Fix flaky TestCleanRepo: SharedSafegitDir resolves git-common-dir from the correct repository.
#Fixes
- Fix. SharedSafegitDir now resolves git-common-dir from the correct repository, fixing a race where parallel tests modified the host project's .git/safegit/ instead of the test repo's.
#0.23.3
Fix Windows cross-compilation in goreleaser builds.
#Fixes
- Fix. Windows cross-compilation now succeeds (extracted platform-specific syscalls into shared filelock, procutil, and hooks packages with build constraints).
#0.23.2
Fix CI workflow missing protocol.file.allow env
Context
The ci-go.yml workflow was missing GIT_CONFIG env vars that ci.yml already had, causing submodule tests to fail on that runner.
#Fixes
- Fix. Add missing protocol.file.allow environment to ci-go.yml workflow, completing the CI submodule test fix.
#0.23.1
Fix CI submodule test failures on GitHub Actions
#Fixes
- Fix. CI submodule tests no longer fail on GitHub Actions runners with restricted file transport protocol.
#0.23.0
Undo overhaul: --count N, redo removal, root commit fix
Context
The redo command is removed in favor of undo --count N, which provides a simpler mental model for multi-step undo. Root commit undo is fixed by introducing DeleteRef and ReadTreeEmpty to properly handle the branch-less state after undoing a first commit.
#Breaking
- Breaking: removed redo command. Redo is replaced by undo --count N. To recover from over-undo, re-commit instead of redo.
#Features
- New: undo --count N. Undo multiple commits in a single invocation by specifying a count. Walk-back logic replays N undo steps sequentially.
#Fixes
- Improved CLI help text. App and scrub group help text now includes numeric data points for documentation quality compliance.
- Fixed undo on root commits. Undoing the first commit in a repository no longer crashes; the branch ref is deleted and the index cleared to match git-init state.
#0.22.0
Release orchestration integration: crash-safe rewrite maps, rlsbl orchestration guard on all destructive rewrites, and in-history SHA remapping.
Context
This release makes safegit's history rewrites safe to drive from release tooling. Every scrub now persists crash-safe rewrite records to .git/safegit/rewrite-maps.jsonl before, during, and after refs move -- including the edge case where only a tag annotation is rewritten -- so an orchestrator (or a crash recovery) can always reconstruct what moved where. Destructive scrubs and author rewrites in rlsbl-managed repositories now refuse to run outside release orchestration, closing the gap where an agent could rewrite history behind the release pipeline's back. The new --remap-shas-in flag keeps hash-referencing files such as JSONL changelogs self-consistent at every commit of the rewritten history.
#Features
- Rewrite-map persistence. Every scrub now records crash-safe rewrite maps in
.git/safegit/rewrite-maps.jsonl(commit mappings written before refs move, all tag rewrites, pre-rewrite remote-tracking state, cleanup status), and scrub JSON output gainspre_rewrite_remotes,cleanup_ok, andcleanup_errorskeys for release orchestrators. - Orchestration guard. Destructive
scrub file/scrub match/scrub runoperations in rlsbl-managed repositories now fail with a hard error directing users torlsbl release scrub; dry-run and--diffpreviews remain available. - **
--remap-shas-in <glob>.** New repeatable flag onscrub file,scrub match, andscrub runthat rewrites full 40-character commit hashes inside glob-matched files to the post-scrub SHAs during the walk, keeping hash-referencing files like JSONL changelogs self-consistent at every commit; pre-range references are left untouched and stale hashes are reported non-fatally. - **Orchestration guard covers
author rewrite.** Destructive author rewrites in rlsbl-managed repositories now fail with a hard error directing users to coordinate the rewrite through the release tooling, matching the scrub guard;--dry-runpreviews remain available.
#Fixes
- Tag-annotation-only rewrites are now recorded. When a scrub's secret lived only in a tag annotation body (no commit changed), the tag ref moved without a rewrite-maps record and the old secret-bearing tag object survived cleanup, failing post-scrub verification. Such rewrites now persist the full start/refs/complete record sequence and prune the old tag object; pure no-op scrubs stay recordless.
#0.21.1
Internal cleanup: SEO, hook migration, DRY extraction, test modernization
Context
Infrastructure-only release. Expanded CLI help texts for selfdoc SEO compliance. Migrated pre-release hooks to config-driven format. Extracted duplicated commit count estimation into shared helper. Migrated tree_ops_test.go from os.Chdir to git.WithDir for test isolation.
#Infrastructure
- Internal cleanup: SEO, hook migration, DRY extraction, test modernization
#0.21.0
Revert tracked policy file — scrub patterns are no longer committed to the repo
Context
The v0.20.0 decision to track .safegit/scrub-policies.jsonl in the working tree was a security mistake: the policy file contains the literal regex patterns used to find secrets, which re-introduces the scrubbed content into the repo. Reverted to .git/safegit/ (untracked). Deleted ~250 lines of complexity: auto-commit logic, policy blob exclusion sets, migration from old to new location. Policies are now local to the machine where the scrub was performed.
#Breaking
- Breaking. Scrub policy storage reverted from tracked
.safegit/to untracked.git/safegit/. Tracked policies committed the literal patterns being scrubbed, re-introducing secrets into the repo. Policies are now local-only and never committed.
#Features
- Improvement.
scrub verifyprints the policy file path and explains that policies are local to the machine when no policies are found.
#0.20.2
Fix per-operation recipe scope enforcement and dry-run lock acquisition
Context
Per-operation scope fields in recipe TOML files were recorded in policies but not enforced during blob filtering. scrub match and scrub file dry-run modes unnecessarily acquired the exclusive rewrite lock, blocking concurrent operations during read-only preview.
#Fixes
- Bug fix. Per-operation
scopein recipe TOML files is now enforced during execution and dry-run. Previously, recipe scopes were recorded in policies but not used for blob filtering. - Bug fix.
scrub match --dry-runno longer acquires the exclusive rewrite lock, allowing concurrent operations during preview. - Bug fix.
scrub file --dry-runno longer acquires the exclusive rewrite lock.
#0.20.1
Fix dry-run and preview modes that were writing objects to the git store
Context
Three dry-run/preview code paths were writing real objects to the git object store, contradicting their documented read-only behavior. scrub run --diff wrote replacement blobs via HashObjectWriteBytes. scrub file --dry-run wrote the replacement blob before the dry-run gate. Both are now truly read-only. Additionally, scrub run --dry-run is a new mode showing per-operation match counts without any writes or lock acquisition.
#Features
- New feature.
scrub run --dry-runshows per-operation match counts and affected files without writing objects or acquiring the rewrite lock.
#Fixes
- Bug fix.
scrub run --diffno longer writes blob objects to the git store. Preview is now truly read-only. - Bug fix.
scrub file --dry-runno longer writes the replacement blob to the git store. SHA is computed without persisting.
#0.20.0
Scan DRY consolidation, unified scrub execution, os.Chdir elimination, tracked policy files
Context
Internal architecture improvements and one breaking change.
The scan package was consolidated from 6 nearly-identical functions to a unified ScanOpts-based API. scrub match now delegates to the shared recipe execution engine (executeScrubRecipe), eliminating ~300 lines of duplicated orchestration. scrub verify scans the object store once for all policies instead of once per policy.
All 24 os.Chdir calls in submodule processing were replaced with context-scoped git directories (git.WithDir). Git commands now target the correct repository via context values, not process-wide CWD mutation.
Scrub policy files moved from .git/safegit/ (hidden, per-worktree) to .safegit/ (tracked, committed, portable across clones). Existing repos auto-migrate on first read. This is a breaking change for tooling that reads the old location directly.
strictcli upgraded from v0.9.0 to v0.16.2. JSON-producing commands are tagged in the CLI schema.
#Breaking
- Breaking. Scrub policy file moved from
.git/safegit/scrub-policies.jsonlto.safegit/scrub-policies.jsonl(tracked in the working tree). Policies are now committed, portable across clones, and visible in PRs. Existing repos auto-migrate on first read.
#Features
- Improvement. JSON-producing commands are now tagged in the CLI schema, making it machine-discoverable which commands support
--json. - Internal. Consolidated the scan package from 6 functions to a unified
ScanObjects(ctx, pattern, opts)API withScanOptsstruct. - Performance.
safegit scrub verifynow scans the object store once for all policies instead of once per policy. - Internal.
scrub matchnow delegates execution to the shared recipe engine, eliminating ~300 lines of duplicated orchestration code. - Internal. Replaced all 24
os.Chdircalls in submodule processing with context-scoped git directories. Git commands now target the correct repository via context, not process-wide CWD mutation.
#0.19.0
New scan, author, and recipe commands; scrub verification; breaking rename of rewrite-author
Context
This release adds five new commands and restructures the CLI around an author group.
safegit scan is a top-level read-only search across git history with --target filtering (blobs, commits, tags, trailers, files). The author group provides safegit author list (identity auditing), safegit author check (deviation detection), and safegit author rewrite (renamed from the top-level rewrite-author, which now prints a deprecation error).
safegit scrub run executes multi-operation scrub recipes from TOML files with independent and chained operations, per-operation scope/target filters, and --diff preview. safegit scrub verify reads a persistent policy file (auto-populated after scrubs) to continuously verify that removed secrets stay gone.
Internally, all three history-rewriting commands now share a RewriteResult pipeline (Finalize method) that handles ref updates, index sync, cleanup, oplog, and push hints in one place. rewrite-author gains previously-missing index sync and object cleanup. Push hints now detect .rlsbl-managed repos. Tree caching reduces git plumbing calls during scrubs. Multi-line regex patterns now work correctly in scan/dry-run mode.
#Breaking
- Renamed.
safegit rewrite-authoris nowsafegit author rewrite. The old name prints a deprecation error.
#Features
- New command.
safegit scansearches git history for regex patterns across blobs, commit messages, tag annotations, trailers, and working tree files. Supports--targetfiltering,--scopeglob,--from/--entire-historyrange, and--jsonoutput. - New commands.
safegit author listenumerates all author/committer identities with commit counts.safegit author checkcompares identities against expected values and suggests rewrite commands for mismatches. - New command.
safegit scrub runexecutes multi-operation scrub recipes from TOML files. Supports independent and chained operations (depends_on), per-operationscopeandtargetfilters,--diffpreview mode, and overlap detection. - New command.
safegit scrub verifychecks that previously scrubbed patterns remain absent from git history. Reads from a persistent policy file auto-populated after scrub operations. Supports hand-written policies for preventive scanning. - Unified post-rewrite pipeline. All history-rewriting commands (
scrub file,scrub match,author rewrite) now share aRewriteResult.Finalizemethod that handles index sync, working tree update, object pruning, oplog, verification, and push hints. This replaces per-command post-rewrite logic with a single consistent pipeline. - Performance. Scrub operations now cache tree objects during the rewrite walk, avoiding redundant
git ls-treeandgit mktreecalls for unchanged subtrees.
#Fixes
- Bug fix.
author rewritenow updates identity-bearing trailers (Co-authored-by,Signed-off-by,Reviewed-by,Acked-by) alongside commit header fields. - Bug fix.
author rewritenow syncs the working tree index and prunes old objects after rewriting, matching the behavior of scrub commands. Previously the working tree could be stale and old objects would linger. - Bug fix. Pattern scanning now matches on full blob content instead of line-by-line, fixing multi-line regex patterns that previously produced zero matches in dry-run mode.
#0.18.4
Fix commit --amend on root commits.
Context
amend.go rejected root commits (no parent) even though reword and CommitTree already supported them. One-line fix matching the existing reword pattern.
#Fixes
- **
commit --amendnow works on root commits.** Previously failed with 'is a root commit (no parent)'. The fix matches the existing pattern used byreword.
#0.18.3
Fix rewrite-author --quiet, add ScopeFilter to dry-run JSON, submodule-aware range scanning infrastructure.
Context
Loose ends from v0.18.2: rewrite-author summary had unconverted fmt.Printf calls, dry-run JSON lost the --scope glob value, and ScanObjectsInRangeWithDir was added for future submodule range-scoping.
#Features
- Submodule-aware range-scoped scanning. Added
ScanObjectsInRangeWithDirandCatFileBatchSHAsWithDirfor future submodule range-scoped dry-run support.
#Fixes
- **
rewrite-authorrespects--quiet.** Summary output is suppressed when--quietis passed.
#0.18.2
Add rewrite-author --json, range-scoped dry-run scanning, JSON error safety net, splitNonEmpty consolidation.
Context
Range-scoped dry-run uses git rev-list --objects to scan only range-reachable objects instead of the entire store. rewrite-author now supports --json for both execute and dry-run modes. die() emits JSON error objects when --json is active, and mustGitDir routes through die() for coverage. splitNonEmpty consolidated from two implementations into a single exported git.SplitNonEmpty.
#Features
- Scrub match dry-run scans only range-reachable objects. When
--fromis specified, the dry-run now scans only objects reachable from the commit range instead of the entire object store. Faster and more accurate for scoped rewrites. - **
rewrite-author --jsonemits machine-readable output.** JSON includes old-to-new commit SHA mapping, tag rewrites, counters, and author details. Dry-run mode also emits JSON.
#Fixes
- **Consolidated
splitNonEmptyinto a single exportedgit.SplitNonEmptyfunction.** Eliminates duplication between the main package and internal/git versions. - **
--jsonerrors now emit structured JSON.** When--jsonis active and a fatal error occurs,die()emits{"error": "..."}to stdout before exiting. Also fixesmustGitDirto route throughdie()so the JSON safety net covers early exits.
#0.18.1
Fix push test CI failure on ubuntu-latest.
Context
Bare remotes in tests used system default branch (master on CI) while local repos used main. Fixed by setting --initial-branch=main on all bare inits.
#Fixes
- Fixed CI test failure for push commands. Tests now use explicit branch names for bare remote verification, fixing failures on systems where the default branch is not
main.
#0.18.0
Add global --json flag, scrub JSON output, explicit push mode flags.
Context
Push now requires an explicit mode flag (--only-head, --only-branches, --only-tags, --both-branches-and-tags) instead of implicit current-branch default and refspec positional args. Scrub match and scrub file support --json for machine-readable output (commit SHA mapping, tag rewrites). This unblocks rlsbl release scrub (upstream consumer). Scrub commands now respect --quiet. All post-rewrite suggestions route through safegit push.
#Breaking
- **Push now requires explicit mode:
--only-head,--only-branches,--only-tags, or--both-branches-and-tags.** Replaces the implicit current-branch default and refspec positional args. Each mode enumerates refs explicitly rather than passing--all/--tagsto git.
#Features
- **Scrub commands respect
--quiet.** All informational output is suppressed when--quietis passed. - **
scrub match --jsonemits machine-readable output.** JSON includes old-to-new commit SHA mapping, tag rewrites, and counters. Dry-run mode emits scan results. Blockedrlsbl release scrub(upstream consumer). - **
scrub file --jsonemits machine-readable output.** Same JSON schema as scrub match, including dry-run mode.
#0.17.2
Sync submodule working trees after scrub history rewrites.
Context
v0.17.1 added working-tree sync for the parent repo but missed submodule child repos. Submodule files on disk could still contain pre-scrub secrets.
#Fixes
- Scrub now syncs submodule working trees after rewriting history. Previously only the parent repo's working tree was updated; submodule files on disk could still contain pre-scrub content.
#0.17.1
Fix scrub data loss for tracked+gitignored files, fix doctor dry-run submodule reporting, add unlock --dry-run.
Context
The v0.16.0 working-tree sync (read-tree --reset -u) could overwrite tracked+gitignored files (e.g., config files with runtime secrets that were committed by mistake then gitignored). SyncMainIndexWithWorktree now saves and restores their content, then untracks them. Also fixed doctor --fix --dry-run not reporting submodule state, and added --dry-run support to unlock.
#Features
- **
unlocknow supports--dry-run.** Shows what would be released without actually removing the lock.
#Fixes
- **
doctor --fix --dry-runnow reports submodule state.** Previously the dry-run path returned early before the submodule loop, so submodule stale locks and orphan tmp dirs were never reported in dry-run mode. - Scrub preserves tracked+gitignored files. When a committed-then-gitignored file is scrubbed, the on-disk content is preserved and the file is automatically untracked. Prevents data loss for config files with runtime secrets.
#0.17.0
Rename --force-push to --force-with-lease, remove --push from rewrite-author, fix doctor stale lock cleanup.
Context
Loose ends from the v0.16.0 --force removal: the --force-push flag name was misleading (it triggers --force-with-lease), rewrite-author --push bypassed safegit's push pipeline, and doctor --fix didn't clean stale locks in the main repo. Also added integration tests for unlock (previously untested) and doctor stale lock cleanup.
#Breaking
- **Renamed
--force-pushto--force-with-leaseonpush.** The flag name now matches what it actually does. - **Removed
--pushflag fromrewrite-author.** After rewriting, safegit now prints the git commands to push manually instead of pushing directly.
#Fixes
- Removed stash suggestion from dirty-tree error.
requireCleanTreeno longer suggestsgit stashas a workaround. - **
doctor --fixnow cleans stale locks in the main repo.** Previously only cleaned submodule stale locks. The diagnostic check also now scans the full lock tree recursively instead of justrefs/heads/.
#0.16.1
Fix macOS symlink bug in submodule detection.
Context
DetectParent compared symlink-resolved paths from git with unresolved paths from os.Getwd(), causing filepath.Rel to produce incorrect relative paths on macOS where /var -> /private/var. This broke autobump and push-hook-cascade on macOS since v0.15.0.
#Fixes
- Fixed macOS symlink bug in submodule detection.
DetectParentnow resolves symlinks on bothparentWorkTreeandcwd, fixing autobump and push-hook-cascade on macOS where/varis a symlink to/private/var.
#0.16.0
Remove global --force flag, add --yes and --mangle, sync working tree after scrub, use --force-with-lease for pushes.
Context
The global --force flag was overloaded across 10+ use sites with 5+ distinct meanings. This release replaces it with explicit per-purpose mechanisms: --yes for confirmation prompts, unconditional dirty-tree rejection, no coordination guard bypass, no hook skip, no gitignore override. Push now uses --force-with-lease (safe force push) instead of --force (destructive). Scrub operations now sync the working tree after rewriting history, so secrets no longer linger on disk. New --mangle flag for scrub match replaces matched content with crypto-random printable ASCII of the same length.
#Breaking
- **Removed global
--forceflag.** Replaced with explicit per-purpose mechanisms:--yesfor confirmation prompts, unconditional dirty-tree rejection, no coordination guard bypass, no hook skip, no gitignore override. - **Push uses
--force-with-lease.**safegit push --force-pushandrewrite-author --pushnow use--force-with-leaseinstead of--force, preventing accidental overwrites of others' pushes.
#Features
- Working tree sync after scrub.
scrub matchandscrub filenow update working tree files to match rewritten history, so secrets no longer linger on disk after scrubbing. - **New
--mangleflag forscrub match.** Replaces matched content with crypto-random printable ASCII of the same length, as a mutually exclusive alternative to--replace. - **New
--yes/-yglobal flag.** Auto-confirms interactive prompts for scrub, rewrite-author, and doctor commands.
#0.15.3
Fix macOS symlink path resolution in submodule discovery.
#Fixes
- Fix: submodule path resolution now handles macOS
/tmpsymlinks correctly.
#0.15.2
Fix macOS CI: use portable GIT_CONFIG env vars instead of --global for file transport allow.
#Fixes
- Fix: submodule tests now pass on macOS CI (portable git config via environment variables).
#0.15.1
Fix submodule test infrastructure for CI environments with restricted file transport.
#Fixes
- Fix: submodule integration tests now pass on CI environments that restrict
protocol.file.allow.
#0.15.0
Full submodule support: commit/undo/redo inside submodules, auto-bump parent pointer, scrub auto-recurse, push hook cascade, doctor cleanup.
Context
safegit previously refused to operate in repos with submodules. This release removes that limitation and adds deep submodule integration across all commands.
#Features
- Submodule support. safegit now works in repos with submodules. Commit, undo, redo, and amend all operate correctly when cwd is inside a submodule, with lock isolation between parent and submodule repos.
- Auto-bump parent pointer. After committing in a submodule, safegit automatically commits the pointer update in the parent repo. Controlled by
commit.autoBumpParentconfig (mandatory boolean). Triggers on commit, amend, reword, undo, and redo. - Scrub auto-recurse into submodules.
scrub matchandscrub fileautomatically scan and rewrite secrets inside submodules, updating parent gitlink pointers. Supports--scopefiltering by submodule path. - Push hook cascade. When pushing from a submodule, parent repo's pre-pre-push hooks are discovered and run first.
- Doctor cleans submodule state.
doctor --fixnow finds and cleans orphan tmp dirs and stale locks in all submodule safegit directories.
#Fixes
- Fix: committing a submodule pointer update (
safegit commit -- mysub) no longer fails due to move detection trying to hash a directory.
#0.14.2
Improved documentation descriptions for AI discoverability.
#Fixes
- Improved documentation descriptions. Expanded CLI help strings and package doc comments for better AI discoverability.
#0.14.1
Rewrite-author lock, scrub match --scope flag, CLAUDE.md update.
#Features
- New flag: scrub match --scope. Glob pattern limiting which file paths are searched (e.g.
--scope '*.env'). Blobs at paths outside scope are left untouched.
#Fixes
- Rewrite-author now uses the repo-wide rewrite lock. Prevents concurrent rewrite-author and scrub operations from corrupting history.
#0.14.0
New scrub match command for pattern-based secret removal with surgical cleanup.
Context
Major release driven by a real secret leak incident. A user ran scrub on leaked API keys and found secrets persisted in reflog entries and unreachable objects.
New: safegit scrub match --pattern
Breaking: safegit scrub is now a command group. The old 'safegit scrub
Also: shared walkAndRewrite extraction, ParseCommit dedup in verification, branch ref verification, streaming object enumeration, new scan package.
#Breaking
- Breaking: scrub is now a command group.
safegit scrubbecomessafegit scrub file(existing behavior) andsafegit scrub match(new). The oldsafegit scrub <file> --from <commit>syntax no longer works. - Breaking: TreeEntry.BlobSHA renamed to TreeEntry.SHA. Reflects that the field stores both blob and tree SHAs. All callers updated.
#Features
- New command: scrub match. Pattern-based secret removal across all git objects — blobs, commit messages, and tag annotations. Use
--dry-runto scan without rewriting. - Surgical post-rewrite cleanup. Scrub now expires tainted reflog entries, prunes unreachable objects, and re-scans to verify secrets are gone. Hard-errors if any matches survive.
- Refactor: extracted shared commit walker.
walkAndRewriteeliminates code duplication between scrub file, rewrite-author, and scrub match. - New git plumbing. Streaming object enumeration (
CatFileBatchAll), in-memory blob writing (HashObjectWriteBytes), blob reading (CatFileBlob), and SHA-identity tree replacement (replaceInTreeByBlobMap).
#Fixes
- Scrub verification: deduplicated ParseCommit calls. 4x fewer git subprocess calls during post-rewrite verification.
- Scrub verification: branch ref checks. New check 7 verifies branch refs were correctly remapped after rewrite.
#0.13.0
Breaking: --from is now inclusive. Scrub safety improvements.
Context
Three scrub improvements:
- --from is now inclusive: the commit you point to IS rewritten, not just everything after it. An ancestry guard rejects non-ancestral --from commits.
- Dirty-tree guard prevents scrub from clobbering staged changes (use --force to override). SyncMainIndex after rewriting keeps git status clean.
- Post-rewrite verification now detects tags that still point to pre-rewrite commits.
#Breaking
- Breaking: --from is now inclusive.
safegit scrub --from Xnow includes commit X in the rewrite. Previously it started after X (exclusive). An ancestry guard now rejects --from commits not ancestral to HEAD.
#Fixes
- Scrub safety: dirty-tree guard and index sync. Scrub now checks for a clean working tree before starting (use --force to override) and syncs the main index after rewriting, preventing phantom
git statusoutput. - Scrub verification: stale tag detection. Post-rewrite verification now catches tags that still point to pre-rewrite commits, detecting
updateRefsfailures.
#0.12.0
Session-scoped undo, redo command, and scrub command for surgical history rewriting.
Context
Three new features aimed at multi-session safety and sensitive content cleanup:
- safegit undo is now session-scoped by default (requires CLAUDE_CODE_SESSION_ID), preventing accidental cross-session rollbacks. --bypass-session restores old behavior.
- safegit redo explicitly restores what undo removed, with one-shot design to prevent oscillation.
- safegit scrub
--from --reason surgically replaces or removes a file's blob across history, with post-rewrite verification, annotated tag rewriting, and confirmation prompt.
#Features
- New command: scrub.
safegit scrub <file> --from <commit> --reason <text>surgically replaces or removes a file's blob across all commits from a starting point to HEAD, with post-rewrite verification and annotated tag rewriting. - New command: redo.
safegit redorestores whatsafegit undoremoved. One-shot by design -- prevents infinite undo/redo oscillation. - Session-scoped undo.
safegit undonow only undoes the current session's operations by default, preventing accidental cross-session rollbacks. Use--bypass-sessionfor the old behavior.
#0.11.1
Fix. CI matrix updated to Go 1.25+1.26 to match go.mod requirement.
#Fixes
- Fix. CI matrix updated to Go 1.25+1.26 to match go.mod requirement.
- Fix. Dockerfile updated to Go 1.25 to match go.mod requirement.
#0.11.0
New feature. Auto-detect file moves on commit. When committing a file that was moved (via mv), safegit now automatically stages the deletion of the old path, so the commit records a proper rename. Works with both commit and commit --amend. Uses exact content matching (move+edit requires listing both paths explicitly).
#Features
- New feature. Auto-detect file moves on commit. When committing a file that was moved (via
mv), safegit now automatically stages the deletion of the old path, so the commit records a proper rename. Works with bothcommitandcommit --amend. Uses exact content matching (move+edit requires listing both paths explicitly).
#0.10.3
Fix: docs deploy in post-release hook. Environment variables are now exported so selfdoc/wrangler can read Cloudflare credentials.
#Fixes
- Fix: docs deploy in post-release hook. Environment variables are now exported so selfdoc/wrangler can read Cloudflare credentials.
#0.10.2
No user-facing changes.
- No user-facing changes.
#0.10.1
Fix: use published strictcli dependency. Removed local replace directive so go install and CI work correctly.
#Fixes
- Fix: use published strictcli dependency. Removed local replace directive so
go installand CI work correctly.
#0.10.0
Breaking: CLI restructured with strictcli. Commands now use structured parsing with auto-generated help. config and hook are subcommand groups (config set key value, hook list). pull requires --ff-only, --ff, or --no-ff. doctor requires --diagnose, --fix, or --uninstall. push --force renamed to --force-push.
#Breaking
- Breaking: CLI restructured with strictcli. Commands now use structured parsing with auto-generated help.
configandhookare subcommand groups (config set key value,hook list).pullrequires--ff-only,--ff, or--no-ff.doctorrequires--diagnose,--fix, or--uninstall.push --forcerenamed to--force-push.
#Features
- **New:
--trailerflag.**safegit commit --trailer "Key: Value"appends git trailers to commit messages. Repeatable, works with commit, amend, and reword.
#Fixes
- **Fix:
--allow-emptywithout files.** Previously rejected with "no files specified" even when--allow-emptywas set.