Skip to content

but setup displaces hooks owned by hook managers (prek, pre-commit, husky) without detecting them, and provides no escape hatch #12748

@ichoosetoaccept

Description

@ichoosetoaccept

Version

0.19.5

Operating System

macOS

Distribution Method

dmg (Mac OS - Apple Silicon)

Describe the issue

Summary

but setup unconditionally overwrites pre-commit and post-checkout hooks without checking whether a hook manager (prek, pre-commit, husky, lefthook, etc.) already owns them. It renames existing hooks with a -user suffix and installs GITBUTLER_MANAGED_HOOK_V1 wrappers in their place. While but setup does print a note that it is "Installing Git hooks", it does not name the affected files, does not warn that existing hooks will be displaced, and does not detect pre-existing hook managers.

The resulting state is fragile in several distinct ways.

Failure modes

1. Insufficient warning on but setup

but setup does print a note:

Installing Git hooks to help manage commits on the workspace branch

However it does not:

  • Name which hook files will be affected (pre-commit, post-checkout)
  • Mention that existing hooks will be renamed to a -user suffix
  • Detect whether a hook manager (prek, pre-commit, husky) is already installed and owns those files
  • Offer a way to skip hook installation (--no-hooks) for projects that manage hooks independently

A developer running but setup in a project where prek already owns pre-commit gets no indication that their hook runner has just been demoted to pre-commit-user and wrapped by a GitButler shim.

2. Inconsistent coverage — pre-push is left untouched

GitButler wraps pre-commit and post-checkout, but ignores pre-push entirely. This means pre-push hooks continue running through the hook manager's full pipeline (including stash-before-hooks). When combined with a stale gitbutler/workspace HEAD commit, this causes linters to run against stale file content — see the stale-workspace-HEAD issue filed separately (#12750).

3. prek install after but setup silently breaks workspace guard

Running prek install a second time (e.g., after git clone on a new machine following the project's README) produces:

Hook already exists at `.git/hooks/pre-commit`, moved it to `.git/hooks/pre-commit.legacy`
Migration mode: prek will also run legacy hook `.git/hooks/pre-commit.legacy`
prek installed at `.git/hooks/pre-commit`

Result:

pre-commit         → fresh prek runner
pre-commit.legacy  → GitButler wrapper  ← prek calls this in migration mode, which calls...
pre-commit-user    → old prek runner    ← ...this orphaned duplicate: prek runs TWICE
post-checkout      → fresh prek runner  ← GitButler cleanup logic GONE
post-checkout-user → prek runner (orphaned)

In prek's default migration mode, the GitButler workspace guard still runs (via .legacy), but prek is invoked twice — once directly, once via the wrapper calling pre-commit-user. Running prek install --overwrite removes .legacy entirely, at which point the workspace guard is silently gone with no error or warning.

4. No escape hatch

There is no but setup --no-hooks flag, no config option to disable hook management, and no way to tell GitButler "I already manage hooks through prek — provide your logic as a hook I can call."

Environment

  • GitButler CLI: but (latest)
  • OS: macOS 15 / macOS 26 beta
  • Hook manager tested: prek v0.3+ (pre-commit compatible)
  • Also affects: pre-commit (uses .git/hooks/), Husky (both .git/hooks/ for v4 and .husky/ for v5+ — same displacement pattern regardless of core.hooksPath)

How to reproduce (Optional)

Reproduction

With prek (hooks in .git/hooks/)

# Fresh repo with prek already managing hooks
git init /tmp/hook-test
git commit --allow-empty -m "init"
cat > /tmp/hook-test/prek.toml << 'EOF'
minimum_prek_version = "0.3.3"
default_install_hook_types = ["pre-commit", "pre-push", "post-checkout"]
[[repos]]
repo = "builtin"
hooks = [{ id = "trailing-whitespace" }]
EOF
prek install -C /tmp/hook-test

Hooks before but setup:

.git/hooks/pre-commit    → prek runner
.git/hooks/pre-push      → prek runner
.git/hooks/post-checkout → prek runner
but setup   # run in /tmp/hook-test

Hooks after but setup:

.git/hooks/pre-commit         → GITBUTLER_MANAGED_HOOK_V1 wrapper  ← replaced, no file-level warning
.git/hooks/pre-commit-user    → prek runner (demoted, non-standard filename)
.git/hooks/pre-push           → prek runner                         ← untouched (inconsistent!)
.git/hooks/post-checkout      → GITBUTLER_MANAGED_HOOK_V1 wrapper  ← replaced, no file-level warning
.git/hooks/post-checkout-user → prek runner (demoted, non-standard filename)

With Husky (hooks in .husky/, core.hooksPath = .husky)

but setup is aware of core.hooksPath — when set to .husky, it correctly installs its wrappers into .husky/ rather than .git/hooks/. However, the same displacement pattern applies:

# Fresh repo with Husky-style hooks
git init /tmp/husky-test && git commit --allow-empty -m "init"
mkdir /tmp/husky-test/.husky
echo '#!/bin/sh\necho "husky: running pre-commit"' > /tmp/husky-test/.husky/pre-commit
chmod +x /tmp/husky-test/.husky/pre-commit
git -C /tmp/husky-test config core.hooksPath .husky
but setup   # run in /tmp/husky-test

Result — .husky/ after but setup:

.husky/pre-commit      → GITBUTLER_MANAGED_HOOK_V1 wrapper  ← original displaced
.husky/pre-commit-user → original Husky hook (50 bytes)
.husky/post-checkout   → GitButler post-checkout handler    ← injected
.git/hooks/            → empty (correctly unaffected)

The GitButler GUI setting "Enable Husky hooks" is orthogonal to this — it controls whether GitButler's internal hook execution calls .husky/ scripts, not how but setup behaves.

Expected behavior (Optional)

Expected behaviour

but setup should:

  1. Detect existing hook managers before overwriting. If pre-commit, post-checkout, or pre-push are already owned by a recognised hook manager (prek, pre-commit, husky, etc.), print a warning and instructions rather than silently renaming.

  2. Provide composable hook scripts — ship the workspace guard and post-checkout cleanup as standalone scripts (e.g., installed to .git/gitbutler/hooks/) that can be called from any hook manager config.

  3. Support --no-hooks (or a config flag) to skip all hook installation for teams that manage hooks independently.

  4. Handle pre-push consistently — either wrap it the same way as pre-commit/post-checkout, or don't wrap any hooks at all and rely on the composable scripts approach.

Suggested integration pattern

The correct integration for a prek-managed project looks like this (currently requires manual setup with no official guidance):

# prek.toml
[[repos]]
repo = "local"
hooks = [
    { id = "gitbutler-workspace-guard",
      name = "GitButler Workspace Guard",
      language = "system",
      entry = "nu .git/gitbutler/hooks/workspace-guard.nu",   # hypothetical
      pass_filenames = false,
      always_run = true,
      stages = ["pre-commit"] },
]

GitButler should ship these scripts and document this pattern as the recommended integration for projects using hook managers.

Relevant log output (Optional)

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workinghelp wantedWe would love you to get involved.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions