Skip to content

Restore commit hooks in the CLI - #15222

Open
ameyypawar wants to merge 5 commits into
gitbutlerapp:masterfrom
ameyypawar:fix/15209-cli-commit-hooks
Open

Restore commit hooks in the CLI#15222
ameyypawar wants to merge 5 commits into
gitbutlerapp:masterfrom
ameyypawar:fix/15209-cli-commit-hooks

Conversation

@ameyypawar

@ameyypawar ameyypawar commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

🧠 Changes

but commit runs the repository's pre-commit hook before writing the commit and post-commit after, with -n/--no-hooks (alias --no-verify) to bypass — restoring what 45a8387f40 ("feat(but): replace old commit command with commit2") dropped. The flag declaration, the failure wording and the "post-commit failures don't undo the commit" rule are taken from the code commit2 replaced. The TUI's commit runs them too.

A hook that rewrites files needs care. DiffSpec documents that hunk headers failing to match the worktree are dropped, and push_changes_from_uncommitted_area() expands every change into hunks — so even but commit -m with no ids carries headers a hook can invalidate, and committing the specs computed beforehand would carry less than was asked for without saying so. So:

  • with nothing singled out, the specs for those same paths are rebuilt from the worktree as the hook left it;
  • when files or hunks were singled out, the commit is refused and the worktree is left untouched;
  • only specs carrying hunk headers are watched, since additions, deletions, symlinks, binaries and too-large files are read whole while the commit is built and cannot go stale.

retired_syntax.rs listed --no-hooks among the retired forms with no modern equivalent, so but commit -c -m "msg" --no-hooks was refused rather than translated. Both grammars spell it identically, so it now carries over.

Separately, join_output tested stderr.is_ascii() where it meant is_empty(), so every ASCII-only hook message was discarded and reported as "hook produced no output". That one also affects the desktop and but push.

☕️ Reasoning

Before commit2, but commit ran all three commit hooks. The rewrite carried none across, so a repository whose gates live in pre-commit has had them silently skipped since 24 July — and the CLI is the surface the bundled agent skill directs agents to in preference to git.

PR #15139 documents the invariant a hook breaks: the changes handed to create_commit "must describe the pre-commit state — in practice, run this before any other operation that could change what the worktree is based on". A pre-commit hook is exactly such an operation, running between the specs being computed and the commit being made. Rebuilding restores that invariant; refusing is the answer when rebuilding would overrule a choice the user made.

On parity with git: there isn't any, and I would rather say so than imply it. git commits the index, so a hook's edits land only if the hook stages them. GitButler has no staging, and nothing here can tell a staging hook from one that merely writes, so the rule chosen is "the files already going into this commit, as the hook left them" — narrower than committing whatever the hook touched, wider than ignoring it. If you would rather it refuse in both cases, that is a smaller change than this one.

commit-msg is not restored here. commit2 creates the commit and then rewords it, because the editor shows the commit's own diff, and that reword runs inside a transaction that owns the Context which hooks::commit_msg needs. Restoring it means moving message resolution ahead of commit creation, and deciding what the hook is handed — a message read back from the commit has no trailing newline, so the common echo ... >> "$1" sign-off hook welds itself onto the subject line. That belongs in its own change.

Hooks default on, matching but push and git. The desktop's runCommitHooks defaults to false, but it is frontend-only localStorage with no Rust counterpart, so the CLI cannot read it; worth knowing the two surfaces differ.

A note on the test isolation fix

e71c886798 pins core.hooksPath per test repository. libgit2 only honours GIT_CONFIG_NOSYSTEM/GIT_CONFIG_GLOBAL when a repository is opened FROM_ENV, which but never does, so the sandbox's config isolation does not reach any hook path. On a machine with core.hooksPath set globally, the hook these tests write was skipped and the developer's own hook ran instead — I reproduced both halves. That is worth knowing beyond this PR: every libgit2-read config key escapes the sandbox today, and the general fix belongs in but-testsupport. Happy to send that separately. The failure mode was pointed out by @sensei-woo.

📌 Todos

Known and deliberately out of scope. None of these are introduced by this change - they are
pre-existing, and several are live in the desktop today. Listed so they are not mistaken for
oversights, and I am happy to take any of them on separately:

  • The desktop still has the underlying defect. It runs the same pre-hook specs through pre_commit_hook_diffspecs and then commits those same pre-hook changes, so a hook that reformats a file still drops the hunks it moved. This PR fixes the CLI only, which means the two surfaces now behave differently — arguably the more valuable follow-up, and it belongs in but_core rather than here.
  • Two hazards this makes reachable by default again, both predating commit2 and both live in the desktop: a hook that itself invokes but deadlocks, because git sets GIT_INDEX_FILE/GIT_EDITOR and git2-hooks does not, so the re-entry escape hatch never triggers; and abnormal termination during a hook leaves a stale index.gitbutler-hook-backup that blocks later commits until it is removed by hand.
  • Hook output on success is discarded, and post-commit failures only reach tracing. Both should go to stderr, as git does.
  • Change detection compares file bytes, where worktree_file_to_object would be filter-aware and constant-memory. Only files with hunk headers are read, so binaries and large files are already excluded, but it is the better primitive.
  • but amend, squash and the other commit-creating commands still run no hooks.

🎫 Affected issues

Addresses: #15209

`but commit` ran the commit hooks until 45a8387 replaced it with
commit2, which carried none of them across. A repository whose quality
gates live in `pre-commit` has had them silently skipped since, and the
CLI is the surface the bundled agent skill directs agents to.

Restores `pre-commit` and `post-commit` along with `-n`/`--no-hooks`,
keeping the flag declaration and failure wording of the code commit2
replaced. `commit-msg` needs the message resolved before the commit is
created, which commit2 inverted, so it is left for its own change.

Separately, `join_output` tested `stderr.is_ascii()` where it meant
`is_empty()`, discarding every ASCII-only hook message and reporting
"hook produced no output" instead.
Every diff spec carries hunk headers, even when no ids are given, and
`DiffSpec` drops headers that no longer match the worktree without
saying so. A `pre-commit` hook that reformats a file therefore moved the
headers computed before it ran, and the commit silently carried less
than was asked for.

With nothing singled out, the specs are rebuilt from the worktree as the
hook left it, which is the result `git` gives a hook that stages its own
edits. When files were singled out, the commit is refused rather than
guessed at, and the worktree is left untouched.
Watching every path in the commit refused commits that were never at
risk: additions and deletions carry no hunk headers, so the file is read
whole while the commit is built, and the same is true of symlinks,
binaries and files too large to diff. Only specs with hunk headers can
go stale, which also keeps the largest files out of the snapshot.

Rebuilding from the worktree also reached past what was being committed.
A hook is free to write elsewhere, and files it created or merely
dirtied were landing in the commit; `git` commits neither. The rebuild
is now limited to the paths that were already going in.

Adds the missing test for the refusal, which had none.
@sensei-woo

Copy link
Copy Markdown

Thanks for working on this. Could you please also restore the commit-msg hook? It was dropped alongside pre-commit and post-commit during the command rewrite, and we rely on it for Claude Code/Codex attribution. Without it, #15209 is only partially fixed.

@sensei-woo

Copy link
Copy Markdown

I put together the commit-msg follow-up as a stacked draft: ameyypawar#2

It runs the hook after provided/editor message resolution but before the workspace transaction commits, so hook edits are retained and rejection rolls everything back. Merging that PR into this branch will update #15222 directly.

libgit2 only honours GIT_CONFIG_NOSYSTEM and GIT_CONFIG_GLOBAL when the
repository is opened FROM_ENV, which `but` never does, so the sandbox's
config isolation does not reach any hook path. A developer with
`core.hooksPath` set globally therefore had the hook these tests write
skipped and their own hook run instead, and the tests failed.

Pinning `core.hooksPath` per repository keeps the hook under test the one
that runs. The wider leak - every libgit2-read config key escaping the
sandbox - belongs in but-testsupport rather than here.

The failure mode was pointed out by @sensei-woo.
The retired grammar spells the flag exactly as the modern one does, but
it was listed among the forms with no modern equivalent, so a command
carrying it was refused rather than translated. `but commit` runs hooks
again, so it carries over as it stands.

The unit test's refusal list and the CLI test both used this flag as
their example of something untranslatable; they now use `--message-file`,
which still is.
@ameyypawar
ameyypawar marked this pull request as ready for review August 9, 2026 08:45
@ameyypawar

Copy link
Copy Markdown
Contributor Author

Took the core.hooksPath fix — credited in e71c886798. Good catch: libgit2 ignores the sandbox's config isolation, so a global hooksPath had my own hook running during the test suite.

Holding off on the rest. message_raw() has no trailing newline, so a hook doing echo "Signed-off-by: …" >> "$1" produces add a thingSigned-off-by: … where git gives add a thing\nSigned-off-by: …. And no_hooks_skips_commit_msg_hook passes with the run_commit_msg_hook call removed — it only asserts the marker file is absent.

Both fixable. Worth sending as your own PR on top of this one.

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