Desktop Material

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'].