Git hook execution environment
Desktop Material runs a repository's own Git hooks
(pre-commit, commit-msg,
pre-push, …) through a proxy so a hook that expects a login
shell environment behaves the same way it does in a terminal. Git is
told to use a temporary core.hooksPath containing one small
process-proxy executable per intercepted hook; that proxy
relays the hook's arguments, environment, working directory, and
standard input back to the app over a loopback connection guarded by a
single-use token. The app then invokes the real hook with
git hook run in the user's configured shell
environment.
Behavior and configuration
Interception is controlled by Preferences → Git → Hooks: whether the hook environment is used at all, whether it is cached across operations in a repository, and which shell supplies it (Git Bash, PowerShell Core, Windows PowerShell, or Command Prompt). With interception disabled, Git runs hooks itself with whatever environment the app was launched with.
Only the hooks the current operation declares are proxied — a commit
proxies the commit-time hooks, a push proxies pre-push —
and only hook names Git actually recognizes. GIT_* and
GITHEAD_* variables reach the hook; the app's own
GIT_CONFIG_PARAMETERS, GIT_EXEC_PATH,
GIT_ASKPASS, GIT_SSH_COMMAND, and related
plumbing variables are stripped so nothing internal leaks into hook
scripts. GITHUB_DESKTOP=1 is set so a hook can tell it is
running from the app.
Hook standard input
Some hooks are fed data on standard input: pre-push
receives one line per ref being pushed, which is exactly what the stock
git lfs pre-push hook reads. The proxy must therefore hand
that payload to git hook run, which accepts it as
--to-stdin=<path> and opens <path>
itself.
The app used to pass the literal string /dev/stdin,
meaning "whatever this Git process received". That only works where Git
links a POSIX runtime. Git for Windows is a native Win32 program
whose open() special-cases only
/dev/null, so /dev/stdin was resolved
as an ordinary filesystem path, which does not exist. Every intercepted
hook run in such a repository died before the hook was even spawned:
fatal: could not open '/dev/stdin' for reading: No such file or directory
and the push failed with exit 128. Any repository carrying the stock Git LFS hooks hit it on every push; the same hook ran fine under a system Git, which made it look like a repository problem rather than an app problem.
The payload is now streamed to a real file in a private temporary
directory and that path is passed to --to-stdin. This fixes
every hook that reads standard input, not just pre-push,
and it additionally makes the hook script's own
< /dev/stdin redirections work under the bundled MSYS
shell: /dev/stdin is a symlink to
/proc/self/fd/0, and the MSYS runtime can re-open a
disk-file handle by name but cannot re-open an anonymous Windows pipe
inherited from a non-MSYS parent such as Electron.
The trade-off is deliberate: the hook now starts once its input has been received in full instead of incrementally. Every hook the app intercepts is written-then-closed by Git before Git waits for the hook to exit, so no interception loses interactivity. The spool is bounded at 64 MiB — orders of magnitude above any real payload — and a producer that exceeds it is refused rather than allowed to fill the temporary volume.
App-generated pushes and
--no-verify
One push does not run pre-push: the
Cheap LFS first-publish anchor. That push is generated by the app, not
authored by the user — it exists only to create the branch on the remote
so a Release tag has a commit to point at (see release-backed
Cheap LFS) — and it can only ever create a ref, never move
one. Running the user's pre-push hook for a publication
they never requested gives the hook no useful decision to make while
letting an unrelated hook failure block the entire large-file route. The
user's own reviewed push still runs every hook, unchanged.
--no-verify and hook interception move together: when
Git is told not to run the hook, the app does not install a proxy for it
either. Every other batched push keeps hooks enabled, including the
batch pushes that publish the user's actual commits.
Failure modes and recovery
A hook the app cannot start is reported rather than silently skipped.
A missing shell environment names the shell it looked for and points at
Preferences → Git → Hooks. A payload above the standard-input budget is
refused with its byte budget. A failing hook's output is captured and
surfaced, and for hooks whose exit code Git treats as advisory
(post-commit, post-checkout,
post-merge, pre-auto-gc,
post-applypatch, post-rewrite) a nonzero exit
does not raise an error dialog. For every other hook the user is offered
the choice to abort or ignore, and the decision is applied to the exit
code the proxy reports back to Git.
Temporary hook directories and spooled standard input are removed when the operation ends, including after a failure or an abort. Cleanup never throws into the operation it belongs to.
Security considerations
The proxy listens only on 127.0.0.1 on an ephemeral port
and accepts a connection only after it presents the operation's
single-use token. Spooled standard input is written into a per-operation
mkdtemp directory, contains only the ref data Git produced,
and is deleted immediately afterwards. Hook environments are derived
from the user's own shell; the app's credential trampoline variables are
excluded so a hook can never read them.
Verification
Unit coverage asserts that a payload is spooled verbatim and in
order, that an empty payload stays empty, that an oversized payload is
refused and its partial spool removed, that a non-positive budget is
rejected, and that disposal is idempotent and never throws. A real-Git
fixture repository with an executable pre-push hook that
reads its refs from standard input proves the hook runs and receives
both its arguments and its ref lines through the spooled path; a
companion Windows-only case proves the previous
--to-stdin=/dev/stdin invocation fails against the same
fixture, so the regression cannot return unnoticed.
Batching-adapter coverage asserts that the anchor push argv contains
--no-verify and that its execution options carry no hook
interception or hook callbacks, while the ordinary batch push argv still
contains no --no-verify and keeps
interceptHooks: ['pre-push'].