Skip to content

feat(commit): run pre-commit and commit-msg hooks on commit and amend - #84

Merged
cheat2001 merged 11 commits into
cheat2001:mainfrom
ChrisLauinger77:feat/commit-hooks
Nov 1, 2026
Merged

cheat2001 merged 11 commits into
cheat2001:mainfrom
ChrisLauinger77:feat/commit-hooks

Conversation

@ChrisLauinger77

@ChrisLauinger77 ChrisLauinger77 commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

What & why

libgit2 never executes Git hooks, so every commit made in AngKorGit behaved like git commit --no-verify. This is part 1 of #82: commit and amend now run pre-commit and commit-msg through the Git CLI.

In scope

  • Detection: pre-commit and commit-msg are found in .git/hooks or in core.hooksPath, which counts from the worktree root and expands ~. Hooks defined in config (hook.<name>.event, Git 2.54+) are detected too. *.sample and non-executable files never count.
  • Runner: hooks run through git hook run --ignore-missing, which needs Git 2.36 or newer.
    • Git is found on the same augmented PATH the AI CLIs use (G16) and started through proc::hidden (G31).
    • Hooks run from the worktree root with the environment git commit -m gives them: GIT_INDEX_FILE, GIT_EDITOR=:, and the commit's author in GIT_AUTHOR_NAME, GIT_AUTHOR_EMAIL and GIT_AUTHOR_DATE (git's raw @<seconds> <±hhmm> form). Amend passes the original author it keeps. Inherited GIT_DIR-style variables are cleared first.
    • The generic hooks::run helper is what pre-push and the post-hooks will reuse.
  • No hooks: when no hook exists, no Git process starts, so nothing changes for those repos.
  • pre-commit: a failure aborts the commit. After it passes, the index is reloaded from disk, so formatter hooks that re-stage files get their changes committed.
  • commit-msg: gets <gitdir>/COMMIT_EDITMSG, which is per worktree. An edited message is committed after Git's whitespace cleanup, an unchanged one stays byte for byte, and an emptied one aborts the commit.
  • Amend: runs the same steps. Without a new message, commit-msg sees the current message.
  • Git missing or older than 2.36: hooks are skipped, the commit goes through, and the UI shows a warning toast saying why.
  • UI:
    • A Skip hooks checkbox next to Amend (--no-verify). It only appears when the repository has commit hooks and clears after each commit.
    • The Commit button reads "Running hooks…" while hooks run (not while "Commit without hooks" runs).
    • When a hook fails, a dialog names the hook and exit code and shows its output with the colour codes stripped. It offers Copy output, Commit without hooks, and Close, and keeps the message you typed.
    • A successful commit clears the message only if it is unchanged, so text typed during a long hook stays.
  • A commit stays with its repository: a commit carries its repository, message and amend flag from start to finish, so switching tabs while a hook runs is safe.
    • The failure dialog only opens in the repository that started the commit, and "Commit without hooks" re-runs that same commit.
    • A failure that arrives after you switched tabs shows a toast ("Nothing was committed in …") and waits: its dialog appears when you go back to that repository.
    • The running commit, the Skip hooks choice and any failure live in small stores keyed by repository, outside the commit box. Selecting a graph commit while a hook runs keeps Commit busy (no second run on the same index), and the failure dialog still appears.
    • Status and graph refresh only while that repository is still shown.
    • The committing state is tracked per repository, so another tab never shows "Running hooks…" and can commit meanwhile.
  • Responsiveness: hooks run off the UI thread, with a 10-minute safety timeout that stops the hook's whole process tree (its own process group on unix, taskkill /F /T on Windows), not just git.

Deferred

  • pre-push (next PR)
  • prepare-commit-msg, post-commit, post-checkout, post-merge, post-rewrite
  • Hooks for commits the app makes elsewhere: reword, pull/merge, revert, cherry-pick, rebase
  • Live hook output and a cancel button.
  • Group pull and post-merge (Start page: group context menu with fetch all and fast-forward-only pull #69): group_pull_matches_toolbar_pull_with_active_hooks is unchanged and still asserts that post-hooks do not run.

Tests

  • 13 engine tests in tests/git_engine.rs:

    • a passing and a failing pre-commit
    • a pre-commit that re-stages a file (this test fails when the index reload is removed)
    • commit-msg that edits the message, leaves it untouched, fails, or empties it
    • Skip hooks on commit and amend
    • amend runs both hooks
    • core.hooksPath wins over .git/hooks; samples and non-executable files are ignored
    • hooks defined in config are detected
    • commit-msg in a linked worktree
    • both hooks see the author variables, and amend exports the original author after the identity changed

    They skip with a note when Git 2.36 or newer is not on PATH.

  • 6 module tests: Git version parsing (including the Apple and Windows forms), the author date format, message cleanup, output trimming, the hook_failed error payload, and a timed-out hook's whole process tree being killed (unix; it fails when only the child is killed).

  • 5 e2e tests on demo mode (angkorgit-demo-hooks, angkorgit-demo-hooks-delay): the failure dialog and Skip hooks, no checkbox without hooks, a hook that fails after a tab switch waiting in its repository, a running hook surviving a graph-commit selection with its failure still shown, and "Commit without hooks" never showing "Running hooks…" while text typed during the run is kept. The Playwright browsers are not installed on my machine, so I checked these flows by hand in demo mode; CI runs the tests. The first three also passed on Linux at 28f70a6, see below.

Tested on Linux

Debian forky/sid, Git 2.53.0, Node 24.21, rustc 1.97.1, at 28f70a6:

  • pnpm typecheck passes, and pnpm test passes all 287 unit tests.
  • cargo fmt --check and cargo clippy --all-targets -- -D warnings are clean.
  • cargo test passes all 87 module tests and 137 engine tests. The 13 hook tests ran against Git 2.53, and none skipped.
  • pnpm test:e2e passes all 124 tests.
  • The same three sign.rs module tests fail with the global commit.gpgsign=true on that machine, and pass with a clean HOME.

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 …)

libgit2 never executes Git hooks, so every commit made in the app behaved
like git commit --no-verify. Commit and amend now run pre-commit and
commit-msg through `git hook run` (Git 2.36 or newer), found on the same
augmented PATH as the AI CLIs and started through proc::hidden.

- Hooks come from core.hooksPath or the common hooks folder, plus hooks
  defined in config; *.sample and non-executable files never count. With
  no hook present no git process starts.
- The index is reloaded after pre-commit so re-staged files are committed.
- commit-msg edits COMMIT_EDITMSG in the worktree's git dir and the edited
  message is committed.
- Without a usable git the commit goes through with a warning.
- The commit box gains a Skip hooks checkbox (only when the repository has
  hooks) and a dialog that shows a failed hook's output.

pre-push, the post-hooks and hooks for other in-app commits come later.

Refs cheat2001#82
The dialog's own X button is also named Close, so the unscoped locator
matched two buttons and Playwright's strict mode failed the test.
git commit exports GIT_AUTHOR_NAME, GIT_AUTHOR_EMAIL and GIT_AUTHOR_DATE
before pre-commit runs, so a hook that validates the author failed in
AngKorGit while the same native commit passed. Both commit hooks now get
the commit's author, and amend passes the original author it keeps. The
date uses git's raw "@<seconds> <+hhmm>" form.
…commit

A hook that failed after switching tabs opened its dialog in the other
repository, and "Commit without hooks" then committed that repository's
draft with verification off. The finished commit also refreshed the
graph with the old repository's history.

A commit now carries its repository, message and amend flag from start to
finish. The failure dialog keeps that request and its retry re-runs it,
a failure for a repository that is no longer shown becomes a toast with
no retry, status and graph refresh only while that repository is current,
and the committing state is tracked per repository.

@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.

Thanks Chris, I read through the engine side and it holds up: index reload after pre-commit, COMMIT_EDITMSG in the worktree gitdir, signing untouched, amend byte for byte when there are no hooks. The tests are great.

One thing before I merge. The in-flight commit and the hook failure live in WorkingCopyPanel state, and the inspector swaps that panel for CommitDetails as soon as a commit is selected in the graph. So if I click Commit, lint-staged runs for 20 seconds and I click a commit meanwhile, a failing hook shows nothing at all. Coming back, Commit is enabled again and I can start a second hook run on the same index. The tab switch handling doesn't catch it because the panel stays mounted there. Could you move the committing set and the pending failure into a small store keyed by repo path, the way features/ai/workStore.ts does for reviews? An e2e that selects a graph commit while the hook runs would pin it.

Smaller things, fine as follow-ups:

  • a successful commit clears the draft unconditionally, so anything typed during a long hook is lost. Clearing only when the draft still equals the sent message fixes it.
  • after "Commit without hooks" the button still says "Running hooks…".
  • the timeout kills git but not the hook it started, so lint-staged can keep writing the index after we've reported a failure.
  • CLAUDE.md says Git for Windows ignores X_OK, but its is_executable actually looks for a #! line or .exe.

Upstream added its own G54 (WebKit focus), so the hooks gotcha is now G55.
Both changelog entries and both sets of e2e coverage are kept.
The hook timeout killed git but not the hook git had started, so a hook
such as lint-staged could keep writing the index after AngKorGit had
already reported the failure.

Hooks now run in their own process group on unix and a timeout kills the
whole group; on Windows taskkill /F /T stops the tree. ai_cli gains
spawn_tree and capture_tree for this, plain capture is unchanged, and
libc is a unix-only dependency for killpg.

Refs cheat2001#82
…anel

The inspector swaps the commit box for the commit details as soon as a
graph commit is selected. The running commit and its hook failure lived
in the commit box, so a failure that landed meanwhile showed nothing and
Commit was enabled again for a second hook run on the same index.

A small store keyed by repository now holds the running commit and the
skip-hooks choice, and pending hook failures live in a store with one
dialog host mounted in App, so the dialog appears whatever the inspector
shows. A failure for a repository that is not on screen waits there.

Also from the review:
- a successful commit clears the draft only when it still equals the
  message that was sent, so text typed during a long hook survives
- "Running hooks…" shows only while the run actually verifies, so
  "Commit without hooks" no longer claims to run them
- CLAUDE.md now cites where Git for Windows decides a hook exists

Refs cheat2001#82
ChrisLauinger77 added a commit to ChrisLauinger77/angkorgit that referenced this pull request Nov 1, 2026
Brings in the cheat2001#84 review fixes and the upstream main merge:
- the hooks gotcha is G55 now (upstream took G54)
- hook failures from commit, amend and push share one store of pending
  failures keyed by repository; the commit box uses the commitWork store
- both hook runners spawn the hook in its own process group and a timeout
  kills the whole group, including the direct runner's sh fallback
@ChrisLauinger77

Copy link
Copy Markdown
Contributor Author

Thanks for the review! Everything is in 5404558 and ebfcce9. The branch is also merged with current main (affb089); upstream took G54 in the meantime, so the hooks gotcha is now G55.

  • Running commit and failures outside the panel: features/commit/commitWork.ts holds the running commit and the Skip hooks choice per repo, the same way ai/workStore.ts does for reviews. Hook failures go into a store keyed by repo, with one dialog host mounted in App.
    • Selecting a graph commit while a hook runs keeps Commit busy, so no second run can start on the same index.
    • The failure dialog appears over the commit details.
    • A failure for a repo that isn't on screen shows a toast and waits there; its dialog opens when you go back to that repo.
    • New e2e test: select a graph commit while the hook runs, come back, then get the failure while the graph commit is shown.
  • Draft: a successful commit now clears it only when it still equals the message that was sent.
  • "Running hooks…" after Commit without hooks: the label now follows the run itself, so a run without hooks no longer shows it. An e2e test covers this and also types into the description during the run.
  • Timeout: the hook now runs in its own process group on unix, and a timeout kills the whole group (killpg); Windows uses taskkill /F /T. The new module test fails when only the child is killed. CI compiles the Windows path, but I haven't run it on a real Windows machine.
  • Git for Windows: for hooks it really is an existence check. find_hook in hook.c calls access(path, X_OK), and mingw_access in compat/mingw.c strips X_OK ("X_OK is not supported by the MSVCRT version"). The #!-or-.exe check you mean is is_executable in run-command.c, which is used for the PATH lookup, not for hooks. I kept the behaviour and made the CLAUDE.md note cite the source.

#85 is updated on top of these changes.

Going back to the repository opens its waiting hook failure dialog at
once, and the modal dialog hides the rest of the page from role queries,
so the tab could not be found until the dialog was closed.
Checkout stays available while commit hooks run, and commit and amend
read HEAD and its parents only after the hooks, then wrote through
"HEAD". Checking out another branch at the same tip during a hook made
the commit land on that branch.

Commit and amend now record HEAD's branch, its commit and the repository
state before the hooks and refuse to write when any of them changed.

Refs cheat2001#82
The commit run checked that its repository was still on screen before
awaiting the status refresh, so a switch during that await still let
the graph reload with the old repository's history. It now checks again
right before the reload.

Refs cheat2001#82
The capture joined its stdout and stderr readers after the process
exited. A hook that starts a background process without redirecting it
keeps those pipes open, so the join waited for that process and Commit
or Push never finished.

The readers now stream into shared buffers, and after the process exits
they get at most one second before the capture returns what was read.

Refs cheat2001#82

@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.

Thanks Chris, that covers it. The new store, the dialog showing over the commit details and the draft only clearing when it's still the sent message all work, and the hook e2e tests pass in WebKit too. The background process fix is right as well, I ran your new test against the old wait and it sat there for the full 30 seconds. And you're right about Git for Windows, I mixed up is_executable with the hook lookup. Merging.

@cheat2001
cheat2001 merged commit f662f1e into cheat2001:main Nov 1, 2026
6 checks passed
@ChrisLauinger77
ChrisLauinger77 deleted the feat/commit-hooks branch November 1, 2026 13:10
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