Repository navigation
Releases: stricttools/safegit
Release list
v0.32.0
scrub run renames file and directory names with a recipe operation targeting paths, and a recipe operation's target now limits what it rewrites.
Context
A history scrub that removes a name must remove it from path names too, in the same single rewrite as the contents and messages.
Features
scrub runrenames paths. A recipe operation withtarget = "paths"renames every file and directory name its pattern matches, in every commit of the range, with itsreplacetext; a rename onto another entry's name is refused, and the preview, both verification tiers, andscrub verifycheck path names for it.
Fixes
- A recipe operation's
targetlimits what it rewrites: an operation targetingcommitsortagsno longer rewrites file contents too, and an unknown target is refused.
v0.31.1
History rewrites run in time that grows with the commits they rewrite rather than with commits times directories: an entire-history scrub of a history of thousands of commits takes seconds instead of tens of minutes.
Context
An entire-history scrub of rlsbl's own history ran past the ten minutes rlsbl allowed it, because every object a rewrite read or wrote cost a git process.
Features
- Faster history rewrites.
scrub match,scrub file,scrub run,scrub squash, andauthor rewriteread and write objects through long-running git processes instead of one git process per object,--remap-shas-inreads only the directories its globs can reach, and scans skip objects holding none of a pattern's fixed strings and use every core: an entire-historyscrub match --remap-shas-inover 8,764 commits went from 22 minutes to 34 seconds.
Fixes
- A
--remap-shas-inhash whose object type cannot be read stops the rewrite instead of being counted as a stale hash and left in place.
v0.31.0
Commits are screened for confidential names against the lifecycle-and-license record and the machine-local confidential-name index, and safegit scrub squash folds a first-parent range of history into one commit.
Features
- Commits are screened for confidential names.
safegit commit(with--amendand the reword form) reads the repository's lifecycle-and-license record offline. In a confidential repository (a releasable with a proprietary license period in effect) the commit records the repository's names in the machine-local confidential-name index, keyed by the record's open releasable-name identities, so no origin remote is needed, and is not scanned. In a public repository, a repository without a record included, the commit removes that repository's own index entry and is refused when its message, an added or changed line, or a new path names a term the index protects, naming the file, line, column, and term. Index writes are recorded rather than made under--dry-run. safegit now builds with Go 1.26.3 or newer. safegit scrub squash. Folds one first-parent range of HEAD's history,--firstthrough--lastwith no merge commit inside, into a single commit carrying the tree, author, and committer of--last, the parents of--first, and the given--message. Later commits are rewritten onto it unchanged, a branch or tag pointing into the range moves to it, the rewrite is verified before any ref moves, and the rewrite journal records every folded commit against the squash commit. Like the other history rewrites it is consequential.
Fixes
- The commit screen in public repositories no longer refuses a confidential repository's releasable names, which are often common words, nor a term lying inside a URL or a dotted hostname; it still refuses registry names, repository names, codenames, and distinctive terms
- A commit in a confidential repository whose public-client declarations leave it no name to protect is no longer screened as a public repository's commit
v0.30.0
Built on strictcli 0.38.0: output, early exits and signals go through the framework, the message flags become --message and --message-file, the hook-stop cap becomes --hook-kill-cap-s, and git is a declared requirement
Context
safegit moves to strictcli's new module path, github.com/stricttools/strictcli/go, at v0.38.0, and adopts what that release makes the framework own. Every line safegit writes now goes through the framework's writers, so under --json the document (interface_version 3) carries a command's answer in its output member and every progress line, warning and error in diagnostics, and a refusal ends through the framework's exit step, which writes the document instead of exiting with nothing on stdout. Signals cancel the git subprocesses a command is waiting on. The framework's naming rule refuses single-letter long flags and reserves the version command, so the message flags are renamed and safegit's own version command is gone. The hook-stop cap moves from a raw environment read to a flag bound to the same variable, and git is declared as the runtime requirement it always was.
This release also carries the work of the 0.29.4 attempt, which was abandoned after its CI run failed: symlinks that replaced tracked directories commit as links, mv refuses paths beyond a symbolic link, and the hook containment, payload and interruption changes.
Breaking
- A pre-pre-push hook that leaves a process running now fails the push. A hook that ends -- by exiting, even with status 0, or at the timeout -- while a process it started is still running is a failed hook run:
safegit pushandsafegit hook runexit 20 (21 when the hook timed out), and each such process is named on stderr, for examplehook release-check left process 48213 (node) running after it ended; it was killed. On Linux safegit stops every such process, including one detached withsetsidor a double fork, and signals each through a pidfd, so an unrelated process that took over a leftover's pid is never signalled; this needs Linux 5.3 or later, and an older kernel refuses the hook run with exit 1. On macOS it stops what remains in the hook's process group and names, as still running, any process that left the group and still holds the hook's output; every process it names there, killed or not, carries the note that safegit cannot contain detached processes on macOS. A hook that starts a background server or watcher must now stop it before exiting. safegit --json pushrecords every hook run, and emits its payload when a hook stops the push. The payload gains ahookslist, one entry per pre-pre-push hook that ran, in run order:name,exit_code(null for a hook with no exit status of its own: one the timeout killed, or one that could not be started),start_error(why exec refused to start the hook, such asexec: permission denied, orexec: no such file or directoryfor a#!line naming a missing interpreter; null for a hook that started),timed_out,duration_ms,leftover_processes(pid,command,killed),unidentified_leftovers, andleftover_identification_error(true, with the reason, when a process still held the hook's output after it ended and safegit could not name it, for example becauselsofis missing on macOS). Thepre_pre_push_hooks_runmember is removed: the length ofhooksis the count, andpre_pre_push_hooks_skippedstill says why none ran. A push a hook stopped (exit 20, or 21 for a timeout) used to answer withpayload: null; it now emits the payload withrefsempty,force_with_leaseandatomicdescribing the push it set out to make, and thehookslist ending at the hook that stopped it. A push that exits 1 because safegit could not run a hook under containment emits it too, with every hook that ran before. Whether a hook passed is carried by the exit code and stderr, not by a payload member.- The Go module path moved to
github.com/stricttools/safegit. The repository lives in the stricttools organization, sogithub.com/smm-h/safegitis no longer this module's path: install withgo install github.com/stricttools/safegit@v0, and a program importing its packages changes its imports andrequireto the new path. - The commit message flags are
--messageand--message-file,safegit versionis the framework's, andhelp --jsonreplaces--dump-schema. The single-letter long spellings--mand--Foncommit,mv,merge-continue,cherry-pick-continueandrevert-continueare gone (the short forms-mand-Fare unchanged): write--messageand--message-file.safegit versionprints one line,safegit <version>, and under--jsonthe document{"name", "version"}; the Go runtime and git version lines and theversionpayload are gone (git --versionreports git's).safegit help --jsonprints the help document on stdout and writes no file, and--dump-schemais refused naming it. A flag that is not repeatable is now refused when given twice instead of keeping the last value, and an integer such as--count +2or--count 02is refused withexpected integer. - Under
--jsoneverything safegit says is in the document, which isinterface_version3. The document gains anoutputmember, afterpayload, carrying the text a command answers with at a terminal (aconfig getvalue,doctorfindings, thehook listlisting;nullwhen there is none), and every progress line, warning, note and error is adiagnosticsentry with its level instead of a stderr line or nothing. A consumer that validates the key set must acceptoutput, and one that read errors from stderr reads the lasterrordiagnostic. At a terminal, notices print aswarning: ...lines, and--verbosedetail and a few progress lines (a dry-run's skipped hooks, a parent bump,Applied autostash.) print on stdout, hidden by--quiet. - A refusal under
--jsonwrites the document. Every refusal now ends through the CLI framework, so under--jsonstdout carries the document with the refusal'sexit_code,payload: null, and the reason as the lasterrordiagnostic, wherecommitand other refusals used to exit with nothing on stdout and the reason on stderr. Deferred cleanup runs and the locks the command held are released before it exits. - git is a declared requirement of every command. A command run where git cannot be found is refused before it starts, at exit 1, with
command '<name>' needs git (...), which is not available: ...; install it: ..., where it used to exit 3 sayingnot a git repository (or git is not installed).--helpshows the requirement. - The cap on stopping a hook is the
--hook-kill-cap-sflag onpushandhook run. It takes whole seconds from 11 to 1800, means 60 when omitted, and readsSAFEGIT_HOOK_KILL_CAP_Swhen the flag is not given. A value with a sign or leading zeros, such as+30or030, is now refused as not an integer, and an out-of-range value names the flag or the variable it came from, with the remedy for that source. - safegit no longer publishes a Docker image. Releases from 0.30.0 on push no image to the GitHub Container Registry; images already published stay where they are. Install with
go install github.com/stricttools/safegit@v0or from the release archives instead.
Features
safegit --json hook runemits a payload. Both the single-hook and the all-hooks form answer with{"hooks": [...]}, each entry recording whatpushrecords, whether or not the hooks passed:name,exit_code(null for a hook that timed out or could not be started),start_error,timed_out,duration_ms,leftover_processes,unidentified_leftovers, andleftover_identification_error. A run that exits 1 because safegit could not run a hook under containment still records every hook that ran before it. It used to answer withpayload: null.- Each leftover process in a hook payload records its state. Every
leftover_processesentry ofpushandhook rununder--jsoncarriesstate: the process state letter/procreported when safegit found the process (S,D,Z, ...), andnullwhere there is no/proc, as on macOS.
Fixes
- A tracked directory replaced by a symlink commits as the link.
safegit commit -- logs/.gitignore logsrefusedlogs/.gitignoreas outside the repository, andsafegit commit -- logsfailed withgit check-ignore ... beyond a symbolic link(or, for a dangling link, committed the deletions without the link). Both now record the deletion and thelogssymlink in one commit; paths below the repository root are read as spelled rather than through links, and a path under a link is treated as absent from the working tree, as git treats it. A trailing slash follows only the final component:safegit commit -- logs/sub/withlogsa link deletes what the parent commit tracks underlogs/subinstead of committing the link target'ssubdirectory, and is refused with exit 11 when nothing is tracked there. A--moveddeclaration is read as spelled too:safegit commit --moved 'logs/ -> x/'withlogsa symlink declared a move out of the directory the link points at (or, for a link leading out of the repository, was refused as outside it), and a side beyond a link is now refused at exit 19 naming the path and the link, assafegit mvdoes. safegit mvrefuses a path beyond a symbolic link. A source or destination with a symlinked directory above it is refused at exit 19 naming the path and the link, asgit mvdoes, instead of being called outside the repository or moving the file that lives in the link's target. A subtree pair is read the same way:safegit mv 'logs/ -> x/'or'logs/sub/ -> x/'withlogsa symlink is refused rather than moving the directory the link points at.- Hook output is no longer silently dropped. A pre-pre-push hook that printed and exi...
v0.29.3
A commit no longer leaves a deleted-then-recreated path pending in the shared index.
Context
An archiving deletion tool stages the removal of a tracked file it takes away, so the next commit cannot resurrect it. When a new file was then written at that same path and committed, safegit recorded the new bytes and put the staged removal back into the shared index, because its reconciler reads "the pre-operation tip holds this path and the index has no slot for it" as another session's staged deletion worth preserving. It was the very path the commit had just answered, and the repository was left reporting it as deleted and untracked right after safegit reported it committed.
Preserving another session's staged work was stated over the whole index; it now holds over the paths the operation did not speak for. Only the DERIVED removal is suppressed for a path a commit or amend staged: where the index really holds a slot for such a path, two real blobs exist and choosing between them is still not the reconciler's to do.
Fixes
- The tool describes itself with one sentence everywhere, including
--help. The--helpheader, README, docs site and package metadata now carry the same line. - A file deleted-then-recreated at the same path no longer stays pending after being committed. When a deletion tool has staged the removal of a tracked file (
git rm --cached) and a new file is written at that same path,safegit commitleft the shared index still holding the deletion --git statusreported the path as both deleted and untracked right after safegit reported it committed, which was enough to block a release. Another session's staged work at a path the commit never named still outlives the commit unchanged. - A file renamed with
git mvand then edited no longer stays pending after being committed.safegit commitrecorded the edited bytes at the new path and then put the rename's pre-edit blob back into the shared index, sogit statusreportedMMon the file right after safegit reported it committed, and committing it again was refused as nothing to commit. The shared index now holds what the commit recorded on every path the commit staged; another session's staged work at a path the commit never named still outlives it unchanged.
v0.29.2
v0.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.
v0.29.0
The two-campaign redesign: safegit authors every commit made under its name (clean merges, cherry-picks, reverts and pulls included), concludes parked operations itself, records file moves, supports unborn branches, and refuses with registered exit codes across a default-deny command subset.
Context
v0.28.0 shipped with known defects — scrub could destroy data when run from a subdirectory, and a dry-run uninstall actually deleted state — whose fixes grew into two full redesign campaigns and a closing round, released here as one version. Campaign 1 rebuilt the commit pipeline, scrub verification, the hooks subsystem, sequencer conclusions and push. Campaign 2 deleted the second authorship class entirely (git computes, safegit commits), codified the subset law with default-deny allowlists, and added declared and observed move records. The closing round added unborn-branch support, the symlink portability refusal, strategy-option forwarding with honest previews, and closed the divergences catalog with every entry deliberate. Every behavior change is red-first tested; the full suite, stress runs, and a released-dependency run are green; a six-domain final audit graded the tree release-ready.
Breaking
- Commit argument intake rebuilt. Move inference no longer decides what gets staged, so a deletion is never staged unless you name it; paths are canonicalized against the repository root, a directory argument expands to the paths it holds, contradictory spellings of the same path are refused, and an argument that matches nothing is now a hard error naming every unmatched argument instead of committing whatever else was named.
- Hunk selection is a flag, not a path spelling. A positional path is always a literal filename, so
file.go:1,3names a file with a colon in it; partial staging is spelled--hunks file.go:1,3, and a hunk selection now survives a directory argument that also covers the same file instead of being silently widened to the whole file. - Commit-family git hooks now run in full.
commit,amendandrewordrunpre-commitonce per commit rather than once per retry,commit-msgon your composed message before trailers are injected, andpost-commitafter the ref moves; a hook that rejects the commit exits 16 instead of 1, repeated-mvalues are joined with a blank line as git does, merge parents are preserved as a list, and a missing submodule auto-bump setting is refused before anything is committed. scrub filerequires--deleteor--replace-with, andscrub verifyis stateless. The scrub mode is no longer inferred from a file's stat,scrub fileadopts the shared range selector, and the policy store is gone:scrub verifytakes an explicit--patternor a recipe, anddoctorreports and deletes a leftoverscrub-policies.jsonl.- The hook subsystem has one location authority and a tool-owned live store. Pre-pre-push hooks live in a store safegit owns, fed by a tracked
.safegit/hooksdirectory;hook migratemoves a legacy directory into it andhook removetakes one out, with exits 24 and 25 for an unmigrated or non-executable hook; the live store and the legacy location are repository-level rather than per-worktree, and uninstall reaches them both. doctorexits 50 when it finds an error-severity problem. It exited 0 whatever it found before, so a scripted health check now needs to expect 50; the run also gained checks for per-feature git-version floors, both lock trees, the git hooks safegit never runs, and a branch the oplog names that git can no longer resolve.doctor --action uninstallis repository-wide and enumerates what it removes. It asks the shared state store what exists and removes every safegit path in the repository rather than only the invoking worktree's, listing each path it removes.- Windows is no longer a supported platform. The Windows release binaries and the build-tagged Windows sources are gone, so
GOOS=windowsfails at compile time instead of producing a binary whose locking was never implemented. - The oplog is unbounded and its reads fail closed. The line cap and rotation are gone, so no operation is ever silently dropped; an unreadable or truncated log is an error rather than an empty answer, the number of skipped lines is reported,
doctorgained a check table for it, and the retiredlog.maxSizeMBconfig key is now refused byconfig setand absent fromconfig show. - Exit codes now say what happened. A contended lock exits 8 with the real lock error, naming the ref, the holder and the wait, where the four rewrite commands used to replace it with a generic message and exit 1; a hunk spec against a binary file exits 14; an uninitialized state directory exits NotInitialized everywhere; a contended ref lock inside the commit pipeline exits LockTimeout like every other lock timeout; and a passthrough command propagates git's own exit code instead of a hardcoded 1.
commit,amend,rewordandundorefuse while git has an operation in flight. A merge, rebase, cherry-pick, revert orgit amin progress is now a hard refusal that names the way out of that state, instead of a commit made on top of a half-finished operation.- Push output is buffered, and the retry works again.
pushandbackup backupnow capture git's output so a failure can be classified, which revived a retry loop that had been dead: progress appears when the push exits rather than as it runs, and under--jsongit's stdout is re-routed to stderr so the envelope stays the only stdout document. Transport failures are matched against phrases git actually emits, an unreadable remote is an error rather than an absent-ref answer, and a retry whose re-resolution finds a local ref moved is refused. - Pinned per-ref leases and consent before a force-push. Each ref is pushed under a
--force-with-leasepinned to the SHA just observed, forcing asks for confirmation first, a backup slot that moves inside the push window exits PushLeaseRejected, a dry-run push discovers its hooks and refuses an unmigrated or non-executable one exactly as a real push does, andbackup restore's refusal names the operation in flight. safegit mergewrites its own commit. A merge that resolves cleanly is authored by safegit's pipeline instead of by git, so it carries safegit's trailers, runs the repository'scommit-msghook, writes one oplog entry and can be reversed withsafegit undo. A fast-forward moves the ref under compare-and-swap and then puts the index and working tree in step with it, andsafegit undorefuses that one because the tip is a commit safegit did not create. The command line is a deliberate subset: exactly one committish, and an octopus merge,-s,--squash,--edit,--autostash,--commit,--allow-unrelated-histories,--rerere-autoupdateand aFETCH_HEADnaming more than one side are each refused by name rather than accepted and quietly reinterpreted. Strategy OPTIONS (-X/--strategy-option) are honored instead, and--dry-runforwards them to the tree it computes. Beyond the flag, a merge whose two sides share no commit at all is refused before anything computes -- with no flag typed, and naming the route for a deliberate import;safegit pullinherits that refusal.safegit cherry-pickandsafegit reverttake exactly one commit, and safegit authors the result. A clean pick or revert is committed by safegit's own pipeline: trailers, thecommit-msghook, one oplog entry, and reversible withsafegit undo. A pick preserves the source commit's author while a revert is your own change. Multi-commit argv and range or rev-set spellings (A..B,A...B,^rev) are refused naming sequential single invocations, and the queue members are gone from both payloads. A pick or a revert whose compute turns out to change nothing now removes the state it just parked --CHERRY_PICK_HEADorREVERT_HEADand git's own scratch files -- instead of stranding an operation that would block every later commit, and says so in place of the abort advice; when the cleanup itself fails the leftovers are named and that advice stands.safegit pullmerges through safegit's own merge. A pull that cannot fast-forward now produces a pipeline-authored commit with safegit's trailers and an oplog entry, and one that can fast-forward moves the ref under compare-and-swap and syncs the index and working tree.--rebaseis refused, naming the two commands that do it.- Forwarded git options are checked against an allowlist, and
checkoutis nowswitch. Each guarded command validates its forwarded argv before anything runs and refuses any token that is not on its allowlist, naming the subset law anddocs/divergences.md.safegit checkoutno longer exists:safegit switch <branch>navigates andsafegit switch -c <new>creates, branch names only. A tag, an object name or any other commit-ish is refused because it detaches HEAD, as are--detach,-C,--force/--discard-changes,--orphanand--merge. There is no file mode at all -- thecheckout -- <path>shape that destroys uncommitted work is not implemented rather than refused.resetkeeps its five modes with a commit but refuses the pathspec form and--patch;rebasekeeps<upstream>,--onto,-i,--autostash,-r/--rebase-merges(whose optional value is attached only) and git's own--continue/--abort/--skip, and refuses the apply backend,--execand--root;bisect's classified subcommand vocabulary is its allowlist. Oncherry-pickandrevert, strategy OPTIONS (-X/--strategy-option) are honored while strategy SELECTION stays refused, and--rerere-autoupdateis refused on both -- the negative spelling--no-rerere-autoupdatestays allowed, since it is the only per-run switch against thererere.autoUpdateconfig key. - **Conclusions refuse...
v0.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.
v0.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.