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.
Environment
@sentry/dotagents: 3.1.0 (latest on npm); the code below is unchanged onmainpost-mergehook written bydotagents initSummary
When a merge runs in a linked worktree (
git worktree add), the post-merge hook'sdotagents --project installruns 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_DIRandDOTAGENTS_HOMEpoint inside it):Expected
HEAD stays on the new merge commit, the repository stays a full clone, and the working tree is clean.
Actual
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()inpackages/dotagents-lib/src/utils/exec.tsspreadsprocess.envinto every child process. So the git commandspackages/dotagents-lib/src/sources/cache.tsruns withcwdset to the cache clone act on the caller's repository instead:git fetch --force --depth=1 -- originfetches the caller'soriginat depth 1 and writes the caller's.git/shallow.git reset --hard FETCH_HEADmoves the caller's HEAD to the first line ofFETCH_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 thecwd, so the caller's files are checked out into the cache directory.GIT_REFLOG_ACTIONleaks as well, which is why the reflog entry claims to be the merge.git rev-parse HEADthen returns the caller's commit, which is written toagents.lockas the source'sresolved_commit. Because the cache now holds the caller's files, skill discovery can resolve the caller's own.agents/skillsas though they came from the configured source.