Skip to content

v0.30.0

Choose a tag to compare

@smm-h smm-h released this 07 Oct 13:20
· 84 commits to main since this release

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 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 push or safegit hook run wrote that text onto safegit's stdout, ahead of the --json envelope, 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 run exits 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 rule push uses: 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 push or safegit hook run no 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. A SIGINT or SIGTERM now 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 example warning: 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 gets SIGTERM and the cap minus 6 seconds to exit, then SIGKILL, and a process still alive at the cap (one in uninterruptible sleep outlives SIGKILL) is named as not stoppable, with its process state on Linux, recorded with killed false, and left running while safegit exits. The cap is 60 seconds by default; --hook-kill-cap-s on push and hook run sets it in whole seconds from 11 to 1800, read from the environment variable SAFEGIT_HOOK_KILL_CAP_S when the flag is not given, and any other value is refused before a hook runs. Under --json the interrupted run still emits its payload: every hook run so far, the interrupted one with exit_code null and the processes it left, and for push an empty refs list.
  • safegit push --verbose says a timed-out hook timed out. The per-hook line printed exit=21 for a hook the timeout killed, as if the hook had exited with safegit's own timeout code; it now reads hook <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 as failed (exit 1), an exit status it never had. safegit push and safegit hook run now say hook <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/env naming 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 list stuck 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, with logs a gitignored symlink that replaced a tracked directory, failed with git's raw The following paths are ignored by one of your .gitignore files and 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 example file 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/, with logs a symlink leading outside the repository, was refused only as file 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 --untrack arguments), and, unless the link is gitignored, offers committing the link itself: logs without 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@v0 downloads included the .rlsbl, .strictcli, stricttools, and todo directories and the root CLAUDE.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 the todo, stricttools, .rlsbl, and .strictcli directories and every CLAUDE.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 commit given a path below a nested repository, a gitlink, or a submodule said staging it left the tree unchanged, failed with git's raw is in submodule or a git check-ignore fatal, 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 --moved side inside another repository is refused with exit 19.
  • safegit mv into a submodule or gitlink no longer deletes the gitlink. safegit mv 'top -> sub/top', with sub a submodule or a recorded gitlink, exited 0 and committed a tree in which a directory holding top replaced 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.