Skip to content

feat(hooks): run the pre-push hook on every push - #85

Open
ChrisLauinger77 wants to merge 2 commits into
cheat2001:mainfrom
ChrisLauinger77:feat/pre-push-hook
Open

ChrisLauinger77 wants to merge 2 commits into
cheat2001:mainfrom
ChrisLauinger77:feat/pre-push-hook

Conversation

@ChrisLauinger77

@ChrisLauinger77 ChrisLauinger77 commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Builds on #84, which is merged into main as f662f1e. This branch is rebased onto main and holds two commits: the pre-push feature and the fix that keeps the branch and the hook choice for retries and force push.

What & why

libgit2 never runs hooks, so every push from AngKorGit behaved like git push --no-verify. This is part 2 of #82: pre-push now runs before every push, using the hook runner from #84.

In scope

  • Every push path: toolbar Push, push with tags, push to another remote, force push (including from the "remote has moved on" dialog), command palette Push, graph "Push ", sidebar branch and tag push, and the push button in the Create PR dialog. In the engine these are remote::push and remote::push_tag, plus a general push_specs.
  • Same input as git:
    • Arguments: remote name and push URL (pushurl, else url, after insteadOf/pushInsteadOf).
    • One stdin line per ref: <local ref> <local sha> <remote ref> <remote sha>.
    • A new remote ref gets a zero remote SHA; a delete reads (delete) <zeros>; an annotated tag sends the tag object's SHA.
    • Refs that are already up to date are left out, and so is a non-forced non-fast-forward update to a commit we already have locally (git rejects those before the hook).
    • The hook also runs when nothing needs pushing, with empty stdin.
    • All of this was checked against git 2.56.
  • Remote SHAs come from the remote itself: the hook runs in libgit2's push_negotiation callback, which receives the remote's advertised refs and runs before any object or ref update is sent. A failing hook aborts the push there, so nothing reaches the remote.
  • Passing stdin to the hook: git hook run --to-stdin, which exists since Git 2.40 (absent from builtin/hook.c at v2.39.0, present at v2.40.0).
    • With Git 2.36–2.39 (Apple's Xcode 16 command line tools and Debian 12 ship 2.39), macOS and Linux run the hook file directly, with the same environment, working folder and PATH, through proc::hidden.
    • A hook without a shebang is valid for git, which retries it as /bin/sh <hook> <args> when the exec fails with ENOEXEC. The direct runner does the same, keeping arguments, stdin, environment and working folder. (On macOS Rust's posix_spawnp already retries through sh; Linux's does not.)
    • Windows skips the hook there with a non-blocking warning: running it directly would mean locating Git for Windows' sh.exe, which CI can't exercise.
  • No pre-push hook: the push takes exactly the same path as before, with no extra process.
  • UI:
    • The hook-failure dialog from feat(commit): run pre-commit and commit-msg hooks on commit and amend #84 is now shared by commit, amend and push: hook name, exit code and output, plus "Push without hooks".
    • A failure that arrives after a tab switch shows a toast and waits in its repository; its dialog appears when you go back there. It also appears while a graph commit is selected.
    • "Push without hooks" is also in the Push menu and the command palette when the repo has a pre-push hook.
    • A retry from the dialog pushes the same remote and branch as the original click, even if you switched branches meanwhile. After "Push without hooks" is rejected as non-fast-forward, Force push from that dialog also skips the hook.
  • Also changed: push_tag now goes through the same push routine, so a tag the server refuses fails the push instead of looking like it succeeded.

Deferred

Tests

  • 9 engine tests in tests/git_engine.rs, against a local bare repo:
    • a passing hook gets the remote name, URL and a new-branch line
    • a failing hook leaves the bare remote without the new commit and returns its output
    • a push with tags lists the branch update plus a lightweight and an annotated tag; repeating it sends empty stdin
    • push_tag runs the hook
    • a delete line
    • a rejected non-fast-forward is left out, and a force push carries the remote's current SHA
    • the hook runs with empty stdin when nothing needs pushing, and can fail that push
    • skipping hooks on push and on push_tag
    • core.hooksPath is respected
  • 5 module tests:
    • runner choice by Git version
    • the stdin line format
    • the direct runner passing arguments and stdin to the hook (unix)
    • a hook without a shebang still getting its arguments, stdin, environment and working folder through sh (unix). This one can only fail without the fix on Linux, since macOS retries through sh anyway; the Ubuntu CI job covers it.
    • only ENOEXEC triggers the sh retry
  • 2 e2e tests on demo mode:
    • the push dialog and the skip entry
    • Force push after a rejected "Push without hooks" still skips the hook and pushes the same branch

Screenshots

Taken on demo mode, dark theme; to be attached.

Checklist

  • pnpm typecheck && pnpm test pass
  • cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test pass (if Rust changed). Three sign.rs module tests fail locally with a global commit.gpgsign=true and fail on main too; everything passes with a clean HOME.
  • New engine functions have integration tests in tests/git_engine.rs
  • Mutating operations go through the undo tracked() wrapper where undoable
  • Colors use design tokens only (no hex in components)
  • CLAUDE.md updated if conventions/architecture changed
  • Commits carry no trailers from coding tools (Co-authored-by: <tool>, Generated with …)

@cheat2001 cheat2001 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read the pre-push part too. Running it in push_negotiation is a nice call, the remote SHAs come from the server and a failing hook aborts before anything is sent. #17 still holds. I checked --to-stdin against git's source, it's missing at v2.39.0 and there at v2.40.0, so the gate is right.

One thing I'd like fixed here. capture_child joins the stdout and stderr readers after git exits, so a hook that starts something in the background without redirecting it keeps the pipe open and the push never finishes. The commit hooks from #84 share it. Joining with a short grace period after exit and then moving on would do it.

I'm also going to try a slow pre-push over HTTPS. The hook runs between the info/refs GET and the receive-pack POST, and I'm not sure libgit2's keep-alive socket survives a two minute hook.

Later: a timed-out hook comes back as a plain error rather than hook_failed, so there's no "Push without hooks" button, and Force push after "Push without hooks" runs the hook again.

@ChrisLauinger77

Copy link
Copy Markdown
Contributor Author

I merged the reviewed #84 branch into this one (06b26aa):

  • Same failure handling as commits: push failures use the same per-repo failure store and dialog. A failing pre-push now also shows while a graph commit is selected, and after a tab switch it waits in its repo until you go back there.
  • Timeout stops the whole tree: both hook runners now start the hook in its own process group, so the timeout stops everything the hook started. That includes the direct runner for Git 2.36–2.39 and its sh fallback.
  • G55: the hooks gotcha is now G55, because upstream took G54.

Until #84 is merged, this PR also shows #84's commits. The pre-push work itself is 0aa8d2a and aaa68ad.

@cheat2001

Copy link
Copy Markdown
Owner

#84 is in (squashed as f662f1e), and the background process fix came with it, so that one's done. The push request change looks good too, keeping the branch and the hook choice from the click is the right call. Since #84 got squashed this branch now conflicts with main. Could you rebase it onto main so only the pre-push commits are left? I'll give it a last look in the installed app after that.

libgit2 never runs hooks, so every push from the app behaved like
git push --no-verify. Every push path now runs pre-push first, with the
same arguments and ref lines git gives it.

- The hook runs in libgit2's push_negotiation callback, after the
  remote's refs are read and before anything is sent, so it sees the
  remote's real SHAs and a failing hook sends nothing.
- Stdin follows git: up-to-date refs and rejected non-fast-forward
  updates are left out, deletes read "(delete)", annotated tags send the
  tag object, and the hook also runs when nothing needs pushing.
- Input goes through git hook run --to-stdin on Git 2.40 or newer. On
  Git 2.36 to 2.39 macOS and Linux run the hook file directly, in its
  own process group like the git runner; Windows skips it with a
  warning.
- The direct runner retries an ENOEXEC spawn as "/bin/sh <hook> <args>"
  with the same stdin, environment and working folder, as git's
  run-command does for a hook without a shebang.
- push, push_tag and the new push_specs share one push routine, so a
  refused tag now fails the push too.
- Hook failures from commit, amend and every push path share one dialog
  and the per-repository failure store, and the retry re-runs the
  original operation in its own repository. The Push menu and the
  palette offer "Push without hooks" when the repository has a pre-push
  hook.

Refs cheat2001#82
…push

Toolbar and palette pushes passed no branch, so the engine resolved HEAD
again on every call. If another branch was checked out while a pre-push
hook failed, "Push without hooks" published that other branch without
running the hook.

Toolbar pushes now build one request at click time with the branch, the
remote and the hook choice, and the hook retry re-runs it with the hook
skipped. A non-fast-forward rejection keeps the request, so the rejected
push dialog's Force push continues it instead of starting fresh, and a
"Push without hooks" no longer runs the hook again. The palette passes
the branch explicitly too.

Refs cheat2001#82
@ChrisLauinger77

ChrisLauinger77 commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor Author

Rebased onto main after all (0af1ddf). The branch now holds just two commits on top of f662f1e:

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants