Concurrent commits in linked worktrees destroy each other's unstaged changes: the backup patch and stash are shared per common git dir
Description
The backup lefthook takes before hiding unstaged changes is stored in two places that are shared by every linked worktree of a repository, so two worktrees committing at the same time overwrite and delete each other's backup. Unstaged work is destroyed silently, and both commits report success.
The two shared resources:
.git/info/lefthook-unstaged.patch—InfoPathcomes fromgit rev-parse --git-path infoininternal/git/paths.go, which resolves to the common git dir, not the per-worktree one. Every worktree writes the same file.- The
lefthook auto backupstash —refs/stashis a single shared ref, anddropUnstagedStashdrops every entry whose message matches, not just the one this process stored.
Interleaving two pre-commit runs therefore gives, deterministically:
| timing | result |
|---|---|
| B finishes while A is still running its jobs | B applies its own patch, removes the shared patch file, and drops A's stash. A then finds no patch and returns early, so A's unstaged changes are gone, with no stash left. |
| A wakes while B's patch is still on disk | A applies B's patch into A's worktree (A gains an edit it never made), then removes it and drops both stashes. B finds nothing to restore, so both worktrees lose their unstaged changes. |
Recovery is only through git fsck --unreachable, until gc.
This needs no formatter and no stage_fixed: the guard engages for any pre-commit with a partially staged file (controller.go, !opts.NoStageFixed && config.HookUsesStagedFiles(hook.Name)). The sleep in the repro only makes the window deterministic; in practice the window is however long the jobs take, and a monorepo where several worktrees are worked on at once hits it by accident.
Related but distinct: #1480 is a single-repo patch-restore failure, and #1265 is about inherited GIT_DIR. Neither covers concurrent worktrees.
lefthook.yml
pre-commit:
jobs:
- name: slow
run: sh -c 'sleep ${LH_SLEEP:-0}'Commands to reproduce
#!/bin/sh
# Two linked worktrees commit a partially staged file at the same time.
set -u
LH="${LH:-lefthook}"
T=$(mktemp -d); R=$T/repo; A=$T/wtA; B=$T/wtB
git init -q -b main "$R"
git -C "$R" config user.email [email protected]; git -C "$R" config user.name T
cat > "$R/lefthook.yml" <<'YML'
pre-commit:
jobs:
- name: slow
run: sh -c 'sleep ${LH_SLEEP:-0}'
YML
printf 'a\n' > "$R/fileA.txt"; printf 'b\n' > "$R/fileB.txt"
(cd "$R" && "$LH" install -f >/dev/null 2>&1)
git -C "$R" add -A >/dev/null; git -C "$R" commit -qm seed
git -C "$R" worktree add -q "$A" -b brA; git -C "$R" worktree add -q "$B" -b brB
# Each worktree: a staged edit plus a further unstaged edit in the same file.
for pair in "$A fileA.txt A" "$B fileB.txt B"; do
set -- $pair
printf '%s-staged\n' "$3" >> "$1/$2"; git -C "$1" add "$2"
printf '%s-UNSTAGED-PRECIOUS\n' "$3" >> "$1/$2"
done
LH_SLEEP=8 git -C "$A" commit -qm A >/dev/null 2>&1 &
sleep 2
LH_SLEEP=1 git -C "$B" commit -qm B >/dev/null 2>&1 &
wait
echo "worktree A, unstaged line: $(grep -c 'A-UNSTAGED-PRECIOUS' "$A/fileA.txt") (expected 1)"
echo "worktree B, unstaged line: $(grep -c 'B-UNSTAGED-PRECIOUS' "$B/fileB.txt") (expected 1)"
echo "stash entries left: $(git -C "$R" stash list | wc -l | tr -d ' ')"Observed:
worktree A, unstaged line: 0 (expected 1)
worktree B, unstaged line: 1 (expected 1)
stash entries left: 0Swap the two LH_SLEEP values (4 for A, 8 for B) and both lines report 0, with B's edit landing in worktree A's fileB.txt.
Possible fix
Scope both resources to the worktree: put the patch under the per-worktree git dir (git rev-parse --git-path gives the shared info, but --git-dir in a linked worktree gives .git/worktrees/<name>), and include a worktree identifier in the stash message so dropUnstagedStash only drops its own entry.
Lefthook version
Reproduced on 2.1.9. internal/git/repo.go and internal/git/paths.go are byte-identical at v2.1.12 (the only change to guard.go in between is error joining), so it should still apply; I have not run the repro against 2.1.12 itself.
Source: evilmartians/lefthook