Skip to content

Post-merge hook in a git worktree resets the repository's HEAD and makes it shallow #196

Description

@jfi

Environment

  • @sentry/dotagents: 3.1.0 (latest on npm); the code below is unchanged on main
  • git: 2.55.0
  • Node.js: 26.8.2
  • macOS 27.0
  • Hook: the post-merge hook written by dotagents init

Summary

When a merge runs in a linked worktree (git worktree add), the post-merge hook's dotagents --project install runs its skill-cache git commands against the repository being merged instead of the cache clone. It shallow-fetches into that repository and resets the worktree's HEAD to an unrelated commit.

The primary checkout is unaffected, which makes this easy to miss.

Steps to reproduce

This script keeps everything in a temporary directory (DOTAGENTS_STATE_DIR and DOTAGENTS_HOME point inside it):

#!/bin/bash
set -euo pipefail
R=$(mktemp -d)
export DOTAGENTS_STATE_DIR=$R/state DOTAGENTS_HOME=$R/home GIT_CONFIG_GLOBAL=/dev/null
export GIT_AUTHOR_NAME=repro GIT_AUTHOR_EMAIL=repro@example.com
export GIT_COMMITTER_NAME=repro GIT_COMMITTER_EMAIL=repro@example.com

# A remote with main, a feature branch, and an unrelated branch that sorts first.
git init -q -b main "$R/seed" && cd "$R/seed"
cat > agents.toml <<'TOML'
version = 1
agents = ["claude"]

[trust]
allow_all = true

[[skills]]
name = "dotagents"
source = "getsentry/dotagents"
TOML
printf 'agents.lock\n.agents/\n.claude/\n' > .gitignore
echo one > app.txt && git add . && git commit -qm "initial"
git checkout -q -b feature && echo feature > feature.txt && git add . && git commit -qm "feature"
git checkout -q --orphan '(unrelated' && git rm -rqf . && echo unrelated > unrelated.txt \
  && git add . && git commit -qm "unrelated branch"
git checkout -q main
git clone -q --bare "$R/seed" "$R/origin.git"
git clone -q "file://$R/origin.git" "$R/primary"

# The hook `dotagents init` installs.
cat > "$R/primary/.git/hooks/post-merge" <<'HOOK'
#!/bin/sh
# dotagents:post-merge
if command -v dotagents >/dev/null 2>&1; then
  dotagents --project install
elif command -v npx >/dev/null 2>&1; then
  npx --yes @sentry/dotagents --project install
fi
# dotagents:end
HOOK
chmod +x "$R/primary/.git/hooks/post-merge"
(cd "$R/primary" && npx --yes @sentry/dotagents --project install > /dev/null)

# Merge in a detached linked worktree.
git -C "$R/primary" worktree add -q --detach "$R/wt" origin/main
cd "$R/wt"
echo local >> app.txt && git commit -qam "local change"
git merge --no-edit -q origin/feature

echo "HEAD after merge : $(git log -1 --format='%h %s')"
echo "reflog           : $(git reflog -1)"
echo "shallow file     : $(ls "$R/primary/.git/shallow" 2>/dev/null || echo none)"
echo "status entries   : $(git status --porcelain | wc -l | tr -d ' ')"

Expected

HEAD stays on the new merge commit, the repository stays a full clone, and the working tree is clean.

Actual

Installed 1 skill(s): dotagents
HEAD after merge : 0825df2 unrelated branch
reflog           : 0825df2 HEAD@{0}: merge origin/feature: updating HEAD
shallow file     : /var/folders/.../primary/.git/shallow
status entries   : 5

The merge commit is gone from the worktree's HEAD, the repository is now shallow, and the worktree shows spurious changes. In a real repository the unrelated branch was a years-old commit and the worktree showed thousands of changes. On a branch with an upstream, the same reset moves the branch to its upstream, dropping the merge and any unpushed commits.

Cause

In a linked worktree, git exports an absolute GIT_DIR (<repo>/.git/worktrees/<name>) to hooks, as githooks(5) documents. It exports nothing in the primary checkout. exec() in packages/dotagents-lib/src/utils/exec.ts spreads process.env into every child process. So the git commands packages/dotagents-lib/src/sources/cache.ts runs with cwd set to the cache clone act on the caller's repository instead:

  • git fetch --force --depth=1 -- origin fetches the caller's origin at depth 1 and writes the caller's .git/shallow.
  • git reset --hard FETCH_HEAD moves the caller's HEAD to the first line of FETCH_HEAD. With a detached HEAD every line is not-for-merge, so that's the alphabetically first remote branch. The index follows HEAD, but the working tree used is the cwd, so the caller's files are checked out into the cache directory.
  • GIT_REFLOG_ACTION leaks as well, which is why the reflog entry claims to be the merge.
  • git rev-parse HEAD then returns the caller's commit, which is written to agents.lock as the source's resolved_commit. Because the cache now holds the caller's files, skill discovery can resolve the caller's own .agents/skills as though they came from the configured source.

Activity

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

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions