Repository navigation
entire enable --force writes the hook wrapper through a symlink, clobbering the target file and creating a self-chaining hook #2410
Description
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:
- Data loss — the file the symlink points at (a version-controlled script in the working tree) is overwritten with the wrapper's contents.
- Infinite recursion —
<hook>and<hook>.pre-entireend 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-entirepointing 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
KC1706 commentedon Sep 19, 2026
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-entirerecursion) and fails against the oldos.WriteFilewrite-through shape.test(strategy): cover symlinked hook with an existing backup (entirei…