Repository navigation
Unstaged changes lost when patch restore fails after formatter rewrites #1369
Description
Description
Lefthook saves unstaged changes as a patch before running hooks. If a hook (e.g. a formatter) rewrites lines near the unstaged changes, the patch can no longer be applied and the unstaged changes are lost from the working tree.
The patch file (.git/info/lefthook-unstaged.patch) and a stash backup (lefthook auto backup) are preserved, so manual recovery is possible but tedious. Moreover, it's easy to overlook that changes were lost and continue working without them, leading to even more conflicts later on.
Related issues:
- Every time getting
Couldn't restore hidden unstaged files: exit status 1error #627 — same symptom, closed without a fix. The workaround there (deleting the leftover patch file) doesn't recover the lost changes. - Unstaged changes not restored when fail_on_changes is on #1216 — fixed the case where
fail_on_changesskipped restore entirely, but the author noted a second unfixed problem: "failure to revert+retry when restoring stashed fails", which is the case here.
lefthook.yml
pre-commit:
commands:
fmt:
glob: "*.py"
run: pixi run ruff format {staged_files}
stage_fixed: trueCommands to reproduce
#!/usr/bin/env bash
set -euo pipefail
WORK_DIR="$(mktemp -d)"
trap 'rm -rf "$WORK_DIR"' EXIT
cd "$WORK_DIR"
export LEFTHOOK_VERBOSE=true
git init
pixi init . && pixi add "lefthook==2.1.4" "ruff==0.15.8"
cat > lefthook.yml << 'EOF'
pre-commit:
commands:
fmt:
glob: "*.py"
run: pixi run ruff format {staged_files}
stage_fixed: true
EOF
# Initial commit: a multi-line list
printf 'x = [\n 1,\n 2,\n 3,\n]\n' > a.py
git add -A && pixi run lefthook run pre-commit && git commit -m "init"
# Stage: remove trailing comma on line 4.
printf 'x = [\n 1,\n 2,\n 3\n]\n' > a.py
git add a.py
# Unstaged: change element on line 2.
printf 'x = [\n 99,\n 2,\n 3\n]\n' > a.py
# Ruff's collapse rewrites line 2 too, making the unstaged patch impossible
# to apply. Lefthook fails to restore unstaged changes.
pixi run lefthook run pre-commit || true
# The unstaged change (1 -> 99) should still be in a.py, but it's gone
grep -q "99" a.py || echo "unstaged change LOST"Output, trimmed to the relevant run:
...
╭──────────────────────────────────────╮
│ 🥊 lefthook v2.1.4 hook: pre-commit │
╰──────────────────────────────────────╯
│ [lefthook] git: git status --short --porcelain -z
│ out: MM a.py
│ [lefthook] saving partially staged files
│ [lefthook] git: git diff --name-only --cached --diff-filter=ACMR
│ out: a.py
│ [lefthook] git: git diff --name-only --cached --diff-filter=ACMRD
│ out: a.py
│ [lefthook] git: git diff --binary --unified=0 --no-color --no-ext-diff --src-prefix=a/ --dst-prefix=b/ --patch --submodule=short --output /private/var/folders/7r/p7gcd7v571v90_3gkzpc7_7r0000gn/T/tmp.wWZudwkT7T/.git/info/lefthook-unstaged.patch -- a.py
│ [lefthook] git: git stash create
│ out: e41d90268ee47d4bef43ed26526f37ae84bc3ac4
│ [lefthook] git: git stash store --quiet --message lefthook auto backup e41d90268ee47d4bef43ed26526f37ae84bc3ac4
│ [lefthook] hide partially staged files: a.py
│ [lefthook] git: git checkout --force -- a.py
│ [lefthook] filtered [ ]: a.py
│ filtered [x]: a.py
│ [lefthook] files after escaping: a.py
│ [lefthook] job: pixi run ruff format a.py
│ [lefthook] run: pixi run ruff format a.py
┃ fmt ❯
1 file reformatted
│ [lefthook] git: git add --force -- a.py
│ [lefthook] git: git apply -v --whitespace=nowarn --recount --unidiff-zero -- /private/var/folders/7r/p7gcd7v571v90_3gkzpc7_7r0000gn/T/tmp.wWZudwkT7T/.git/info/lefthook-unstaged.patch
│ > git apply -v --whitespace=nowarn --recount --unidiff-zero -- /private/var/folders/7r/p7gcd7v571v90_3gkzpc7_7r0000gn/T/tmp.wWZudwkT7T/.git/info/lefthook-unstaged.patch
│ Checking patch a.py...
│ error: while searching for:
│ 1,
│ error: patch failed: a.py:2
│ error: a.py: patch does not apply
│
│ Couldn't restore unstaged files: couldn't apply the patch /private/var/folders/7r/p7gcd7v571v90_3gkzpc7_7r0000gn/T/tmp.wWZudwkT7T/.git/info/lefthook-unstaged.patch: exit status 1
│
────────────────────────────────────
summary: (done in 0.16 seconds)
✔️ fmt (0.06 seconds)
unstaged change LOST
Lefthook version
2.1.4
Possible solution
As suggested in #1216, when git apply fails, lefthook should roll back the formatter's working-tree changes (restoring the pre-format state), then re-apply the unstaged patch (which will now succeed since the file matches the original context). This is what pre-commit does: it prioritizes restoring unstaged work over keeping formatter output, since the formatter can always be re-run.
Activity
mrexox commentedon May 19, 2026
Hey! I have added new logic around reverting the unstaged changes. Lefthook will try to revert the previous state of unstaged changes. Check the lefthook version 2.1.7
jjurm commentedon May 19, 2026
Thanks for the fix! I confirm that this issue is resolved, i.e. unstaged changes are not lost anymore.
build(hooks): scope the per-edit lint hook and add a lefthook commit …