Skip to content

entire enable --force writes the hook wrapper through a symlink, clobbering the target file and creating a self-chaining hook #2410

Open

Description

@edgar-bestow

Summary

When a git hook path is a symlink (a common pattern for repo-managed hooks, where .git/hooks/pre-push is symlinked to a script checked into the repository) and a <hook>.pre-entire backup already exists from a previous install, entire enable --force writes its wrapper through the symlink instead of replacing it.

This has two consequences:

  1. Data loss — the file the symlink points at (a version-controlled script in the working tree) is overwritten with the wrapper's contents.
  2. Infinite recursion — <hook> and <hook>.pre-entire end up resolving to the same file, so the wrapper's "chain to pre-existing hook" call re-executes itself. Every subsequent commit/push forks shells without bound until the machine runs out of processes.

Verified on Entire CLI 0.10.6 (go1.26.6, darwin/arm64).

Reproduction

mkdir repro && cd repro && git init
mkdir scripts
printf '#!/bin/sh\necho "PROJECT HOOK RAN"\n' > scripts/post-commit.sh
chmod +x scripts/post-commit.sh

# Repo-managed hook, installed as a symlink
ln -nsf "$PWD/scripts/post-commit.sh" .git/hooks/post-commit

# 1st enable — correct behaviour
entire enable --force
# [entire] Backed up existing post-commit to post-commit.pre-entire
#   post-commit             -> regular file (the wrapper)
#   post-commit.pre-entire  -> symlink to scripts/post-commit.sh

# Anything that re-installs the repo's hooks now re-creates the symlink,
# while the .pre-entire backup is still present:
ln -nsf "$PWD/scripts/post-commit.sh" .git/hooks/post-commit

# 2nd enable — the bug
entire enable --force
# [entire] Warning: replacing post-commit (backup post-commit.pre-entire already exists from a previous install)

Actual result

.git/hooks/post-commit is still a symlink, and the wrapper was written straight through it:

$ cat scripts/post-commit.sh
#!/bin/sh
# Entire CLI hooks
# Post-commit hook: condense session data if commit has Entire-Checkpoint trailer
if command -v entire >/dev/null 2>&1; then entire hooks git post-commit 2>/dev/null || true; else :; fi
# Chain: run pre-existing hook
_entire_hook_dir="$(dirname "$0")"
if [ -x "$_entire_hook_dir/post-commit.pre-entire" ]; then
    "$_entire_hook_dir/post-commit.pre-entire" "$@"
fi

The original echo "PROJECT HOOK RAN" script is gone — overwritten in the working tree.

Both hook paths now resolve to the same inode:

resolved .git/hooks/post-commit:            2129733
resolved .git/hooks/post-commit.pre-entire: 2129733

So the wrapper's chain call invokes itself. A single git commit then recurses until the process limit is hit:

.git/hooks/post-commit.pre-entire: line 9: Terminated: 15  "$_entire_hook_dir/post-commit.pre-entire" "$@"
error: .git/hooks/post-commit died of signal 15

(Reproduced under ulimit -u to keep it bounded; without a limit it forks until the machine is unusable.)

Expected result

entire enable --force should replace the hook path itself rather than following it, and should never produce an installation where <hook> and <hook>.pre-entire are the same file.

Relationship to #2237 and #1349

This is the same InstallGitHook branch as #2237 — the backupExists path that
prints Warning: replacing <hook> (backup ... already exists from a previous install)
and overwrites the file at the hook name — but a different consequence, and it needs
the hook to be a symlink to reproduce.

  • Re-install discards another tool's newer hook and chains to a stale .pre-entire backup #2237: the hook is a regular file. The overwrite loses another tool's newer hook and
    the chain runs a stale backup. Nothing outside .git/ is touched.
  • This issue: the hook is a symlink. The overwrite follows it and lands on the
    symlink target in the working tree, destroying a version-controlled file, and
    leaves <hook> and <hook>.pre-entire pointing at the same inode, which turns the
    chain call into unbounded recursion.

Replacing the hook path rather than writing through it is independent of how #2237 is
resolved, and is worth applying on its own since it is what causes the data loss and
the recursion here.

Suggested fix

Never write through the hook path. unlink the target first, or write to a
temporary file and rename() it into place, so the install always replaces the hook
path itself rather than following it. That prevents the working-tree file from being
destroyed and stops <hook> and <hook>.pre-entire from ever resolving to the same
file.

How the backupExists case should behave beyond that — refreshing the backup,
refusing the install, surfacing it in entire doctor — overlaps #2237, so I've left
that to you rather than guessing at constraints I can't see from outside.

Activity

  1. KC1706 commented on Sep 19, 2026

    @KC1706
    Contributor

    Reproduces on 0.10.6 but not on current main: the hook install now writes via an atomic temp-file + rename (which replaces a symlink instead of following it) and classifies a symlinked hook as foreign, so neither the write-through data loss nor the same-inode recursion can occur.

    The exact symlink + existing-backup path had no regression test, so I opened #2534 adding one — it reproduces both documented consequences (target overwrite and the self-chaining <hook> / <hook>.pre-entire recursion) and fails against the old os.WriteFile write-through shape.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions