Skip to content

Unstaged changes lost when patch restore fails after formatter rewrites #1369

Description

@jjurm

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:

lefthook.yml

pre-commit:
  commands:
    fmt:
      glob: "*.py"
      run: pixi run ruff format {staged_files}
      stage_fixed: true

Commands 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

  1. mrexox commented on May 19, 2026

    @mrexox
    Member

    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

  2. jjurm commented on May 19, 2026

    @jjurm
    Author

    Thanks for the fix! I confirm that this issue is resolved, i.e. unstaged changes are not lost anymore.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions