Skip to content

Releases: stricttools/safegit

v0.32.0

Choose a tag to compare

@smm-h smm-h released this 09 Oct 15:17

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 run renames paths. A recipe operation with target = "paths" renames every file and directory name its pattern matches, in every commit of the range, with its replace text; a rename onto another entry's name is refused, and the preview, both verification tiers, and scrub verify check path names for it.

Fixes

  • A recipe operation's target limits what it rewrites: an operation targeting commits or tags no longer rewrites file contents too, and an unknown target is refused.

v0.31.1

Choose a tag to compare

@smm-h smm-h released this 09 Oct 13:04

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, and author rewrite read and write objects through long-running git processes instead of one git process per object, --remap-shas-in reads 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-history scrub match --remap-shas-in over 8,764 commits went from 22 minutes to 34 seconds.

Fixes

  • A --remap-shas-in hash whose object type cannot be read stops the rewrite instead of being counted as a stale hash and left in place.

v0.31.0

Choose a tag to compare

@smm-h smm-h released this 08 Oct 15:13

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 --amend and 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, --first through --last with 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

Choose a tag to compare

@smm-h smm-h released this 07 Oct 13:20

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 push and safegit hook run exit 20 (21 when the hook timed out), and each such process is named on stderr, for example hook release-check left process 48213 (node) running after it ended; it was killed. On Linux safegit stops every such process, including one detached with setsid or 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 push records every hook run, and emits its payload when a hook stops the push. The payload gains a hooks list, 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 as exec: permission denied, or exec: no such file or directory for a #! line naming a missing interpreter; null for a hook that started), timed_out, duration_ms, leftover_processes (pid, command, killed), unidentified_leftovers, and leftover_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 because lsof is missing on macOS). The pre_pre_push_hooks_run member is removed: the length of hooks is the count, and pre_pre_push_hooks_skipped still says why none ran. A push a hook stopped (exit 20, or 21 for a timeout) used to answer with payload: null; it now emits the payload with refs empty, force_with_lease and atomic describing the push it set out to make, and the hooks list 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, so github.com/smm-h/safegit is no longer this module's path: install with go install github.com/stricttools/safegit@v0, and a program importing its packages changes its imports and require to the new path.
  • The commit message flags are --message and --message-file, safegit version is the framework's, and help --json replaces --dump-schema. The single-letter long spellings --m and --F on commit, mv, merge-continue, cherry-pick-continue and revert-continue are gone (the short forms -m and -F are unchanged): write --message and --message-file. safegit version prints one line, safegit <version>, and under --json the document {"name", "version"}; the Go runtime and git version lines and the version payload are gone (git --version reports git's). safegit help --json prints the help document on stdout and writes no file, and --dump-schema is 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 +2 or --count 02 is refused with expected integer.
  • Under --json everything safegit says is in the document, which is interface_version 3. The document gains an output member, after payload, carrying the text a command answers with at a terminal (a config get value, doctor findings, the hook list listing; null when there is none), and every progress line, warning, note and error is a diagnostics entry with its level instead of a stderr line or nothing. A consumer that validates the key set must accept output, and one that read errors from stderr reads the last error diagnostic. At a terminal, notices print as warning: ... lines, and --verbose detail 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 --json writes the document. Every refusal now ends through the CLI framework, so under --json stdout carries the document with the refusal's exit_code, payload: null, and the reason as the last error diagnostic, where commit and 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 saying not a git repository (or git is not installed). --help shows the requirement.
  • The cap on stopping a hook is the --hook-kill-cap-s flag on push and hook run. It takes whole seconds from 11 to 1800, means 60 when omitted, and reads SAFEGIT_HOOK_KILL_CAP_S when the flag is not given. A value with a sign or leading zeros, such as +30 or 030, 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@v0 or from the release archives instead.

Features

  • safegit --json hook run emits a payload. Both the single-hook and the all-hooks form answer with {"hooks": [...]}, each entry recording what push records, 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, and leftover_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 with payload: null.
  • Each leftover process in a hook payload records its state. Every leftover_processes entry of push and hook run under --json carries state: the process state letter /proc reported when safegit found the process (S, D, Z, ...), and null where there is no /proc, as on macOS.

Fixes

  • A tracked directory replaced by a symlink commits as the link. safegit commit -- logs/.gitignore logs refused logs/.gitignore as outside the repository, and safegit commit -- logs failed with git check-ignore ... beyond a symbolic link (or, for a dangling link, committed the deletions without the link). Both now record the deletion and the logs symlink 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/ with logs a link deletes what the parent commit tracks under logs/sub instead of committing the link target's sub directory, and is refused with exit 11 when nothing is tracked there. A --moved declaration is read as spelled too: safegit commit --moved 'logs/ -> x/' with logs a 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, as safegit mv does.
  • safegit mv refuses 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, as git mv does, 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/' with logs a 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...
Read more

v0.29.3

Choose a tag to compare

@smm-h smm-h released this 17 Sep 17:53

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 --help header, 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 commit left the shared index still holding the deletion -- git status reported 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 mv and then edited no longer stays pending after being committed. safegit commit recorded the edited bytes at the new path and then put the rename's pre-edit blob back into the shared index, so git status reported MM on 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

Choose a tag to compare

@smm-h smm-h released this 14 Sep 17:50

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.

v0.29.1

Choose a tag to compare

@smm-h smm-h released this 14 Sep 17:01

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.

v0.29.0

Choose a tag to compare

@smm-h smm-h released this 27 Aug 19:34

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...
Read more

v0.28.0

Choose a tag to compare

@smm-h smm-h released this 17 Aug 13:04

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.

v0.27.0

Choose a tag to compare

@smm-h smm-h released this 14 Aug 03:48

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.