Skip to content
Changelog
Edit
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 --moved no 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.json each said something different about what safegit is, and selfdoc.json carried 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-file carrying a misspelled or unrecognized key (patern for pattern, choise for choice) 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 Newline now reads expected 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,3 names 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, amend and reword run pre-commit once per commit rather than once per retry, commit-msg on your composed message before trailers are injected, and post-commit after the ref moves; a hook that rejects the commit exits 16 instead of 1, repeated -m values 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 file requires --delete or --replace-with, and scrub verify is stateless.** The scrub mode is no longer inferred from a file's stat, scrub file adopts the shared range selector, and the policy store is gone: scrub verify takes an explicit --pattern or a recipe, and doctor reports and deletes a leftover scrub-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/hooks directory; hook migrate moves a legacy directory into it and hook remove takes 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.
  • **doctor exits 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 uninstall is 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=windows fails 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, doctor gained a check table for it, and the retired log.maxSizeMB config key is now refused by config set and absent from config 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, reword and undo refuse while git has an operation in flight.** A merge, rebase, cherry-pick, revert or git am in 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. push and backup backup now 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 --json git'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-lease pinned 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, and backup restore's refusal names the operation in flight.
  • **safegit merge writes 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's commit-msg hook, writes one oplog entry and can be reversed with safegit undo. A fast-forward moves the ref under compare-and-swap and then puts the index and working tree in step with it, and safegit undo refuses 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-autoupdate and a FETCH_HEAD naming 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-run forwards 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 pull inherits that refusal.
  • **safegit cherry-pick and safegit revert take exactly one commit, and safegit authors the result.** A clean pick or revert is committed by safegit's own pipeline: trailers, the commit-msg hook, one oplog entry, and reversible with safegit 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_HEAD or REVERT_HEAD and 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 pull merges 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. --rebase is refused, naming the two commands that do it.
  • **Forwarded git options are checked against an allowlist, and checkout is now switch.** Each guarded command validates its forwarded argv before anything runs and refuses any token that is not on its allowlist, naming the subset law and docs/divergences.md. safegit checkout no longer exists: safegit switch <branch> navigates and safegit 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, --orphan and --merge. There is no file mode at all -- the checkout -- <path> shape that destroys uncommitted work is not implemented rather than refused. reset keeps its five modes with a commit but refuses the pathspec form and --patch; rebase keeps <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, --exec and --root; bisect's classified subcommand vocabulary is its allowlist. On cherry-pick and revert, strategy OPTIONS (-X/--strategy-option) are honored while strategy SELECTION stays refused, and --rerere-autoupdate is refused on both -- the negative spelling --no-rerere-autoupdate stays allowed, since it is the only per-run switch against the rerere.autoUpdate config key.
  • Conclusions refuse the shapes safegit cannot start, and the delegation is deleted. cherry-pick-continue and revert-continue refuse a queued sequence, which only raw git can now create, and merge-continue refuses an octopus merge or a content conflict git recorded with no AUTO_MERGE; each refusal names git's own --continue and --abort as the way to finish what git started. A --continue forwarded to safegit merge, cherry-pick or revert is 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 run refuses instead of reporting that there is nothing to run, and doctor reports it as an error rather than a warning. The # safegit: timeout=NNN first line a hook could print to raise its own budget is deleted -- the configured budget is the only one.
  • **safegit mv refuses 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 with commit --moved. A destination whose parent directory does not exist is refused unless --create-missing-directories elects 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-targets records 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 --hunks fails loudly when a patch does not apply.** The silent --3way retry 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 doctor repairs one.** commit, amend, reword and mv now match git and refuse while the index holds a conflicted entry, naming safegit doctor --action fix; conclusions stay exempt, because resolving is what they do. doctor reports an orphaned unmerged index -- conflicted entries with no operation in flight -- as an error-severity finding, and --action fix resolves 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 --merge and reset --keep write working-tree files and were unguarded; they now refuse on a dirty tree exactly as reset --hard does. Every bisect subcommand that checks another commit out is guarded, including skip, which the old list missed, while the reporting subcommands are not.
  • **An --untrack target 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 --untrack target any more.
  • **merge, cherry-pick, revert, pull and rebase refuse to run over an operation git already has in flight.** A parked merge, cherry-pick, revert, rebase or git am is 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 orphaned REVERT_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-commit forms of cherry-pick and revert are covered too, because they hand git the same computation. rebase uses a narrower predicate -- an in-flight state that is not itself a rebase -- so its own --continue, --abort and --skip still reach git, as do the other verbs' --abort and --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-continue and revert-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 honoring core.ignorecase so a case-only rename cannot lose an untracked destination.
  • **Declared move records on commit and amend.** --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 revert and scrub.** A single-commit revert declares the inverse of every move it undoes, scrub file --delete erases the records that name the path in the same rewrite, a scrub match substitution 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 unlock grammar.** Every tree-mutating command serializes on a per-worktree operation lock, lock ordering and the reclamation and cleanup authorities are declared, and unlock takes 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, revert and pull -- 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_MERGE emission for that path. Content matching none of them is your own edit, and the conclusion refuses instead of destroying it; --discard-unmatched-worktree elects the destruction. The check covers ours, theirs, declared deletes and absent-stage deletes, with no skip arm.
  • A commit records the moves its own delta witnesses, without being told. commit, amend and mv mint 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 and backup restore's fetch and fast-forward are all recorded now, and undo carries 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, reset and bisect write 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 root undo is 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-markers attribute was skipped silently; the skip is now carried out to the human report and to a declined_checks member in the payload.
  • **The commit payload says which --amend form ran.** An execution_mode member carries amend or reword on 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 a switch -c before the first commit -- commit, switch, merge, pull, cherry-pick, reset --hard and 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, rebase and bisect 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-pick and revert state the real outcome.** A preview computes what the operation would actually do, including an --ff-only merge that git would refuse.
  • Submodule scrubs. A bounded --from range 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.
  • **scan reaches 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, reword and undo reconcile 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-ref and delete-ref require 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.json is 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.
  • **undo validates 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-run used 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-continue or revert-continue made 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_AUTOSTASH was 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, and doctor reports a MERGE_AUTOSTASH with 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 undo asks 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, so doctor --action uninstall --dry-run enumerates what it would remove without asking, and --json --dry-run emits the envelope instead of failing at a prompt it could not answer.
  • **--json on 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 --json the 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. scrub warned, 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. doctor reports 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 switch rename, the conclusion refusals, the overwrite and commit-stands behavior, the crash re-run, mv's refusals, the symlink policy, the --untrack fence and the move vocabulary. docs/divergences.md gains 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.
  • **reword and mv report only the move records their commit actually carries.** A commit-msg hook 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. The commit and amend arms 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 --json was refused by citing the subset law, which reads as safegit lacking machine mode. --json, --dry-run, --quiet, --verbose and --approve-consequential are 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-tags becomes safegit push --refs head|branches|tags|both, and safegit doctor --diagnose|--fix|--uninstall becomes safegit doctor --action diagnose|fix|uninstall — neither has a default, and the negation that made --no-only-tags push HEAD and --no-fix run the diagnose path no longer exists at all. scrub match's --replace/--mangle and scrub match/scrub run's --from/--entire-history keep their exact spellings and their exact refusal messages, now as declared selections rather than mutex groups. author rewrite declares 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 states push and scrub match used to refuse with exit 70 are unrepresentable. Every flag and positional argument now declares its presence, so --help marks each one [required], [optional] or [default: v]; --amend, --allow-empty, --pre-push-hook, --force-with-lease, --bypass-session, --count, --diff, --limit, --overwrite-remote-backup and --allow-public-remote carry 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 a writes member (always null here — safegit declares no update command) and the version it reports is 2. A consumer that pins interface_version == 1 must 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, so safegit version and safegit --version reported something like 0.27.1-0.20260814034848-8737c897423a instead 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

  • **--json is 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 its payload member rather than the whole stream. --json is 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 --quiet emits 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's preview member 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-schema publishes 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 run and author rewrite refused a non-interactive run with error: stdin is not interactive; pass --approve-consequential to confirm; against the released strictcli v0.32.0 that line now reads error: 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-consequential still 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, a backup backup to 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|run created .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 commit created its per-invocation temporary index under .git/safegit/tmp/ and left that directory behind, which made the repository read as initialized while config.json was absent -- after which every safegit command there failed with reading config.json: no such file or directory until .git/safegit was deleted by hand. A preview now stages into an OS temporary directory and touches nothing under .git/, and a .git/safegit without config.json is completed on the next executing command instead of being trusted as complete.
  • **A dry-run author rewrite works on a dirty working tree.** safegit --dry-run author rewrite refused 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 run and author rewrite minted no effects, so --dry-run printed 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's preview in machine mode). The human and machine outputs are also one computation now: estimated_commits used 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 match reports both, as objects_matched and objects_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-consequential no longer consents to backing up to a public remote; --allow-public-remote does.** 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-consequential had 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 backup gains --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 --uninstall is unchanged.

#Fixes

  • **safegit --dry-run hook run no longer runs the hooks.** The command ignored --dry-run outright, 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 -- so hook run now declares that it has no dry-run mode and the flag is refused with that reason. safegit hook list shows 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 -- unlock takes a ref and no flags -- so anyone following it got a parse error. It now describes what unlock does, 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, and hook run gained 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.toml now 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.0 has 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 run and author rewrite used to die in repos containing .rlsbl/ unless RLSBL_SCRUB_ORCHESTRATED=1 was 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.
  • **--json no longer confirms destructive operations for you.** A --json run used to imply consent, so safegit --json scrub match ..., --json author rewrite and --json doctor --uninstall went ahead without asking. They now refuse and name --approve-consequential as the flag that consents deliberately; --json still means machine-readable output. Previews are unaffected, and scrub file --dry-run no longer asks for permission to rewrite nothing.
  • **Breaking: --quiet, --verbose, --dry-run and --approve-consequential are framework-owned, and the short forms are gone.** Write them in full -- -q, -n and -y no longer exist. In exchange all four are recognized anywhere on the command line, so safegit push --dry-run works as well as safegit --dry-run push. Every command is now classified read-only or mutating, which is what decides whether --dry-run records 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 set and 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; --yes is gone.

#Features

  • **CLAUDE_CODE_SESSION_ID is now documented in safegit --help.** The session handshake variable that scopes safegit undo and stamps commit trailers is declared to the CLI framework and listed under Infrastructure.
  • **New safegit backup command group.** backup backup, backup list and backup restore keep one backup slot per branch under refs/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.md and CLAUDE.md are now generated by selfdoc from docs/_README.md and docs/_CLAUDE.md, so they stay in step with the rest of the documentation.

#Fixes

  • **author rewrite --dry-run no 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-run in 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.
  • **--json no 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 --json implied consent. It now refuses and names --approve-consequential as the flag that consents deliberately.
  • **backup backup --dry-run no 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-run is honest everywhere now.** push, pull, checkout, merge, rebase, reset, bisect, cherry-pick, revert, config set and hook install all ignored --dry-run and mutated anyway -- safegit --dry-run push really pushed. Every one of them now records what it would do in a would-do log on stdout and changes nothing.
  • **commit --dry-run no longer reports a commit it did not make.** The preview printed <n> file(s) committed; it now says would be committed and lists the ref update it would perform.
  • A declined confirmation now exits nonzero. Answering n (or feeding EOF) to doctor --uninstall, a public-remote backup backup, or any scrub/rewrite prompt printed Aborted. 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. --config renamed to --config-file (strictcli now reserves the global name config).

#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 gains pre_rewrite_remotes, cleanup_ok, and cleanup_errors keys for release orchestrators.
  • Orchestration guard. Destructive scrub file/scrub match/scrub run operations in rlsbl-managed repositories now fail with a hard error directing users to rlsbl release scrub; dry-run and --diff previews remain available.
  • **--remap-shas-in <glob>.** New repeatable flag on scrub file, scrub match, and scrub run that 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-run previews 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 verify prints 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 scope in 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-run no longer acquires the exclusive rewrite lock, allowing concurrent operations during preview.
  • Bug fix. scrub file --dry-run no 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-run shows per-operation match counts and affected files without writing objects or acquiring the rewrite lock.

#Fixes

  • Bug fix. scrub run --diff no longer writes blob objects to the git store. Preview is now truly read-only.
  • Bug fix. scrub file --dry-run no 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.jsonl to .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 with ScanOpts struct.
  • Performance. safegit scrub verify now scans the object store once for all policies instead of once per policy.
  • Internal. scrub match now delegates execution to the shared recipe engine, eliminating ~300 lines of duplicated orchestration code.
  • Internal. Replaced all 24 os.Chdir calls 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-author is now safegit author rewrite. The old name prints a deprecation error.

#Features

  • New command. safegit scan searches git history for regex patterns across blobs, commit messages, tag annotations, trailers, and working tree files. Supports --target filtering, --scope glob, --from/--entire-history range, and --json output.
  • New commands. safegit author list enumerates all author/committer identities with commit counts. safegit author check compares identities against expected values and suggests rewrite commands for mismatches.
  • New command. safegit scrub run executes multi-operation scrub recipes from TOML files. Supports independent and chained operations (depends_on), per-operation scope and target filters, --diff preview mode, and overlap detection.
  • New command. safegit scrub verify checks 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 a RewriteResult.Finalize method 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-tree and git mktree calls for unchanged subtrees.

#Fixes

  • Bug fix. author rewrite now updates identity-bearing trailers (Co-authored-by, Signed-off-by, Reviewed-by, Acked-by) alongside commit header fields.
  • Bug fix. author rewrite now 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 --amend now works on root commits.** Previously failed with 'is a root commit (no parent)'. The fix matches the existing pattern used by reword.

#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 ScanObjectsInRangeWithDir and CatFileBatchSHAsWithDir for future submodule range-scoped dry-run support.

#Fixes

  • **rewrite-author respects --quiet.** Summary output is suppressed when --quiet is 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 --from is 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 --json emits 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 splitNonEmpty into a single exported git.SplitNonEmpty function.** Eliminates duplication between the main package and internal/git versions.
  • **--json errors now emit structured JSON.** When --json is active and a fatal error occurs, die() emits {"error": "..."} to stdout before exiting. Also fixes mustGitDir to route through die() 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/--tags to git.

#Features

  • **Scrub commands respect --quiet.** All informational output is suppressed when --quiet is passed.
  • **scrub match --json emits machine-readable output.** JSON includes old-to-new commit SHA mapping, tag rewrites, and counters. Dry-run mode emits scan results. Blocked rlsbl release scrub (upstream consumer).
  • **scrub file --json emits 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

  • **unlock now supports --dry-run.** Shows what would be released without actually removing the lock.

#Fixes

  • **doctor --fix --dry-run now 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-push to --force-with-lease on push.** The flag name now matches what it actually does.
  • **Removed --push flag from rewrite-author.** After rewriting, safegit now prints the git commands to push manually instead of pushing directly.

#Fixes

  • Removed stash suggestion from dirty-tree error. requireCleanTree no longer suggests git stash as a workaround.
  • **doctor --fix now 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 just refs/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. DetectParent now resolves symlinks on both parentWorkTree and cwd, fixing autobump and push-hook-cascade on macOS where /var is 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 --force flag.** Replaced with explicit per-purpose mechanisms: --yes for confirmation prompts, unconditional dirty-tree rejection, no coordination guard bypass, no hook skip, no gitignore override.
  • **Push uses --force-with-lease.** safegit push --force-push and rewrite-author --push now use --force-with-lease instead of --force, preventing accidental overwrites of others' pushes.

#Features

  • Working tree sync after scrub. scrub match and scrub file now update working tree files to match rewritten history, so secrets no longer linger on disk after scrubbing.
  • **New --mangle flag for scrub match.** Replaces matched content with crypto-random printable ASCII of the same length, as a mutually exclusive alternative to --replace.
  • **New --yes/-y global 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 /tmp symlinks 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.autoBumpParent config (mandatory boolean). Triggers on commit, amend, reword, undo, and redo.
  • Scrub auto-recurse into submodules. scrub match and scrub file automatically scan and rewrite secrets inside submodules, updating parent gitlink pointers. Supports --scope filtering 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 --fix now 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 --replace --reason --entire-history searches all git objects (blobs, commit messages, tag annotations) for a pattern and replaces matches. Includes surgical post-rewrite cleanup (tainted reflog entries expired, unreachable objects pruned) and re-scan verification that hard-errors if any matches survive.

Breaking: safegit scrub is now a command group. The old 'safegit scrub ' becomes 'safegit scrub file '. TreeEntry.BlobSHA renamed to TreeEntry.SHA.

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 scrub becomes safegit scrub file (existing behavior) and safegit scrub match (new). The old safegit 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-run to 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. walkAndRewrite eliminates 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 X now 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 status output.
  • Scrub verification: stale tag detection. Post-rewrite verification now catches tags that still point to pre-rewrite commits, detecting updateRefs failures.

#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 redo restores what safegit undo removed. One-shot by design -- prevents infinite undo/redo oscillation.
  • Session-scoped undo. safegit undo now only undoes the current session's operations by default, preventing accidental cross-session rollbacks. Use --bypass-session for 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 both commit and commit --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 install and 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. 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.

#Features

  • **New: --trailer flag.** safegit commit --trailer "Key: Value" appends git trailers to commit messages. Repeatable, works with commit, amend, and reword.

#Fixes

  • **Fix: --allow-empty without files.** Previously rejected with "no files specified" even when --allow-empty was set.

More tools from this site

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