Repository navigation
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 exited quickly could lose its stdout, because safegit stopped reading when the hook process exited. safegit now reads a hook's output to its end.
- Hook output no longer corrupts machine-mode JSON. A pre-pre-push hook that printed to its stdout during
safegit pushorsafegit hook runwrote that text onto safegit's stdout, ahead of the--jsonenvelope, so the output no longer parsed as one JSON document. A hook's stdout now goes to safegit's stderr, as the documentation already stated. safegit hook runexits 21 when a hook times out. Run without a hook name, it exited 20 even when the hook that stopped it had timed out. It now uses the rulepushuses: the first hook that does not pass decides the code, 21 for a timeout and 20 for a nonzero exit or a process left running, and that hook's verdict is written to stderr.- Interrupting
safegit pushorsafegit hook runno longer leaves the hook running. A pre-pre-push hook runs in its own process group, so Ctrl-C at a terminal reached safegit alone: safegit exited, and the hook, with everything it started, kept running. ASIGINTorSIGTERMnow stops the running hook and what it started the same way the timeout does, names what the hook left behind, and exits 128 + the signal number (130 or 143); the hooks after it are not started. The stop is announced first, in a warning, for examplewarning: stopping hook 20-test and the processes it started; this can take up to 60s, and a second Ctrl-C is ignored until it is done. That figure is the cap on stopping a hook, which bounds every stop -- at the timeout, on an interruption, and for the processes a hook left when it ended: every process getsSIGTERMand the cap minus 6 seconds to exit, thenSIGKILL, and a process still alive at the cap (one in uninterruptible sleep outlivesSIGKILL) is named as not stoppable, with its process state on Linux, recorded withkilledfalse, and left running while safegit exits. The cap is 60 seconds by default;--hook-kill-cap-sonpushandhook runsets it in whole seconds from 11 to 1800, read from the environment variableSAFEGIT_HOOK_KILL_CAP_Swhen the flag is not given, and any other value is refused before a hook runs. Under--jsonthe interrupted run still emits its payload: every hook run so far, the interrupted one withexit_codenull and the processes it left, and forpushan emptyrefslist. safegit push --verbosesays a timed-out hook timed out. The per-hook line printedexit=21for a hook the timeout killed, as if the hook had exited with safegit's own timeout code; it now readshook <name>: timed out (<duration>).- A hook that cannot be started says why. A pre-pre-push hook that exec refuses to start -- a
#!line naming an interpreter that does not exist, a file that is neither a script nor a program -- was reported asfailed (exit 1), an exit status it never had.safegit pushandsafegit hook runnow sayhook <name> could not be started: exec: no such file or directory(or the reason exec gave) and still exit 20. A hook that starts and exits nonzero,#!/usr/bin/envnaming a missing program included, is reported by its exit status as before. - A SIGINT or SIGTERM stops the git safegit is waiting on. Every git subprocess now runs under the CLI framework's cancellation, so an interrupted command stops its git at once and exits 128 + the signal number instead of waiting for git to finish (for example a
backup liststuck on an unresponsive remote). - A gitignored path named on the command line is refused with safegit's own error, naming the rule.
safegit commit -- logs, withlogsa gitignored symlink that replaced a tracked directory, failed with git's rawThe following paths are ignored by one of your .gitignore filesand its hint to use-f, which safegit does not have. Every named ignored path is now refused by safegit before git runs, naming the ignore file, the line, and the rule, for examplefile debug.log is gitignored, through line 1 of .gitignore (`*.log`), with the two ways forward: narrow the rule, or leave the path out of the commit. Below a glob, when no directory above the path is excluded, it gives the negation line that re-includes the path, such as!/debug.log. - A trailing slash that follows a symlink out of the repository says what to commit instead.
safegit commit -- logs/, withlogsa symlink leading outside the repository, was refused only asfile logs/ is outside the repository. The refusal now names the link and its target, lists the paths the parent commit tracks under the link (naming them instead records their deletion; for--untrack logs/they are offered as--untrackarguments), and, unless the link is gitignored, offers committing the link itself:logswithout the slash, with--allow-non-portable-targets, because its target will not resolve in another checkout. - The published Go module no longer carries repository-internal files. The module zip that
go install github.com/stricttools/safegit@v0downloads included the.rlsbl,.strictcli,stricttools, andtododirectories and the rootCLAUDE.md; they are now left out. - The Docker image no longer carries repository-internal files. The image built its binary from a copy of the whole checkout in
/src, which included thetodo,stricttools,.rlsbl, and.strictclidirectories and everyCLAUDE.md; they are now left out of the build context. - A path inside another git repository is refused, with the command that commits it there.
safegit commitgiven a path below a nested repository, a gitlink, or a submodule said staging it left the tree unchanged, failed with git's rawis in submoduleor agit check-ignorefatal, or (under--hunks) said the file had no changes. Every such path, whether positional, in--hunks, in--untrack, or under--amend, is now refused with exit 11 before anything is staged, grouped by the repository it belongs to, naming that repository's shape and printing(cd <repository> && safegit commit -m <message> -- <paths>); a--movedside inside another repository is refused with exit 19. safegit mvinto a submodule or gitlink no longer deletes the gitlink.safegit mv 'top -> sub/top', withsuba submodule or a recorded gitlink, exited 0 and committed a tree in which a directory holdingtopreplaced the gitlink; moving into an untracked nested repository wrote this repository's file into the other repository's working tree. A pair with a side inside another repository is now refused with exit 19, and nothing is moved or committed.