#1529·lefthook

Concurrent commits in linked worktrees destroy each other's unstaged changes: the backup patch and stash are shared per common git dir

Author: jdadam-iscompaniesCreated Sep 4, 2026Updated Sep 4, 2026

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.patchInfoPath comes from git rev-parse --git-path info in internal/git/paths.go, which resolves to the common git dir, not the per-worktree one. Every worktree writes the same file.
  • The lefthook auto backup stash — refs/stash is a single shared ref, and dropUnstagedStash drops 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

yaml
pre-commit:
  jobs:
    - name: slow
      run: sh -c 'sleep ${LH_SLEEP:-0}'

Commands to reproduce

bash
#!/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:        0

Swap 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.