Skip to content

v0.29.0

Choose a tag to compare

@smm-h smm-h released this 27 Aug 19:34
· 403 commits to main since this release

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.