Backup stash is resolved by hash but dropped by index — a concurrent stash writer (e.g. a sibling git worktree) can have its entry destroyed

Author: HorseyjCreated Aug 30, 2026Updated Aug 31, 2026
LabelsNeeds more info

Description

GitWorkflow resolves its automatic backup stash by short hash but drops it by the numeric index it computed a moment earlier, and those two steps are not atomic against other writers to refs/stash:

  • getBackupStash (lib/gitWorkflow.js): git stash list --format="%h %s"findIndex(line => line.startsWith(ctx.backupHash))String(index)
  • cleanup: git stash drop --quiet <index>

refs/stash is one stack per repository — every linked worktree of a repo pushes to and pops from the same stack. If any other process pushes a stash entry between the stash list and the stash drop (the most realistic case: a second lint-staged run committing concurrently in a sibling git worktree, or a human running git stash push anywhere in the repo), every index shifts by one. The drop then lands on the other writer's entry — their stashed work is destroyed with no error anywhere — while lint-staged's own backup survives on the stack as a stale lint-staged automatic backup entry.

restoreOriginalState (stash apply <index>) and restoreUntrackedFiles have the same window, with "applies the wrong stash" instead of "drops the wrong stash".

We hit this for real running several agent/CI lanes as linked worktrees of one repository (each lane commits on its own branch, each commit runs lint-staged via a pre-commit hook).

Steps to reproduce

Deterministic replay (no timing luck needed — the "sibling" stash is pushed between resolve and drop, which is exactly what a concurrent writer does):

bash
git init -b main repro && cd repro
printf 'a\n' > f.txt && git add . && git commit -m init

# lane A: what lint-staged does in prepare()
printf 'b\n' > f.txt && git add f.txt
hash=$(git stash create) && git stash store -m 'lint-staged automatic backup' "$hash"
idx=0   # lane A resolves its backup: index 0 (getBackupStash)

# lane B: a sibling worktree / human stashes in the window
printf 'x\n' >> f.txt
git stash push -m 'lane-B parked work'

# lane A: cleanup() drops by the stale index
git stash drop "stash@{$idx}"

git stash list
# stash@{0}: lint-staged automatic backup   <- A's backup survives as silt
# lane B's 'lane-B parked work' is gone

Expected behavior

The drop should only ever remove the entry lint-staged created. Since git has no stash drop <hash>, the closest safe shapes seem to be:

  • re-resolve the index and verify git rev-parse "stash@{<idx>}" still equals ctx.backupHash immediately before (ideally in the same step as) the drop, and refuse otherwise — this shrinks the window from "whole task run" (the index is computed in getBackupStash, potentially long before cleanup) to a few milliseconds, or
  • git reflog delete --updateref --rewrite refs/stash@{<idx>} after the same re-verification.

Same for the stash apply call sites.

Environment

  • lint-staged: 17.3.0
  • git: 2.43
  • Node.js: 22
  • OS: Linux

(Downstream we've sidestepped it entirely with --no-stash, which in v17 still hides partially-staged edits via the patch file — but the default configuration can destroy another writer's stash, which seems worth fixing upstream.)