Skip to content

Add pre-commit.stash_unstaged_changes to control stashing#1412

Open
AlexanderLarionovQC wants to merge 3 commits into
evilmartians:masterfrom
AlexanderLarionovQC:copilot/stash-unstaged-config
Open

Add pre-commit.stash_unstaged_changes to control stashing#1412
AlexanderLarionovQC wants to merge 3 commits into
evilmartians:masterfrom
AlexanderLarionovQC:copilot/stash-unstaged-config

Conversation

@AlexanderLarionovQC

Copy link
Copy Markdown

Context

  • Lefthook currently hides unstaged hunks from partially staged files during pre-commit, runs hooks against the staged snapshot, and then restores the hidden changes.
  • That flow is awkward for partial-commit workflows and especially for AI-assisted edit loops, where repeated formatter or linter runs can keep bouncing against a restore step that rewrites the worktree underneath the agent.
  • Users have also reported code-loss style failures around this stash/restore path:
    • In #1369, a formatter rewrote nearby lines so the saved unstaged patch could no longer be applied, and unstaged changes disappeared from the working tree.
    • In #1400, users reported staged or staged-untracked changes being lost when hooks failed or were interrupted, especially with longer-running hooks and stage_fixed: true.

Essentially, this PR enables an alternative Lefthook workflow where it us up to the user to more manually manage staging of formatting changes implemented by linters and formatters but this avoids any 'magic' from interfering for both agents and potential loss of data.

Changes

  • Adds a new hook-level pre-commit.stash_unstaged_changes option.
  • Keeps the default pre-commit behavior unchanged when the option is omitted.
  • When set to false, skips the automatic save, stash, revert, and restore flow for partially staged files.
  • Keeps staged-file selection semantics unchanged, including explicit {staged_files} behavior and files:-based overrides.
  • Suppresses stage_fixed auto-staging in this mode so Lefthook does not re-stage files while partial hunks remain visible in the worktree.
  • Updates schema, docs, and test coverage for both the default behavior and the new opt-out mode.

AI Usage Transparency

This change was drafted and largely implemented with GitHub Copilot using GPT-5.4 as an AI coding agent.
The resulting changes were then manually reviewed and validated with the repository's test, integration-test, and lint commands.

@AlexanderLarionovQC

Copy link
Copy Markdown
Author

I appreciate AI slop can be a real thing, however it has been a while since I have worked in go so it was substantially faster to use an agent. I have tried to make this conceptually a lean modification.

Thank you for your review!

@AlexanderLarionovQC AlexanderLarionovQC changed the title Add pre-commit option to disable partial stashing Add pre-commit.stash_unstaged_changes to control stashing May 15, 2026
@mrexox

mrexox commented May 18, 2026

Copy link
Copy Markdown
Member

Hey @AlexanderLarionovQC,

Thank you for preparing a PR! Adding a new option make sense, but I am also trying to fix the issue here: #1416. It should fix the both cases you've mentioned and I hope this option won't be necessary. I believe that it's preferred that stash restoration just works out of the box.

WDYT?

@jjurm

jjurm commented May 19, 2026

Copy link
Copy Markdown

I'd like to advocate for the non-stashing workflow, too. (doesn't have to be the default, but having this option would be nice). Partial staging + conflicting linter changes is something I deal with daily. I see two ways to address it:

A. Let lefthook operate on staged changes only (current)

  • If linter changes are compatible with unstaged changes, we are in the ideal scenario: linting passes, unstaged changes are preserved.
  • If linter changes are incompatible with unstaged changes, fail. Here, fix: always restore unstaged changes #1416 now correctly restores unstaged changes, but does not provide any solution to the developer other than the advice to "Stage all changes and try again".

B. Let lefthook operate on all changes regardless of git index (proposed by this PR)

  • If linter passes, lefthook also passes, there's no failed-to-restore scenario.

If one doesn't use partial staging at all (always committing all changes), there are no cons for either of A or B. Otherwise, these are the cons I see in each approach:

Cons of A:

  • to proceed from the failed-to-restore scenario, I have to discard my git index (by staging all files) or skip linting altogether (both less convenient if lefthook is part of a commit hook)

Cons of B:

  • lefthook can interfere with unstaged changes
  • linter changes cannot be staged automatically (since we don't know which parts of the linter-induced diff belong to staged/unstaged changes)

So the preference for A vs B might depend on how often the failed-to-restore scenario happens. In my case, the cons of B are so negligible that I'd almost always prefer B (stash_unstaged_changes: false).

@AlexanderLarionovQC

Copy link
Copy Markdown
Author

Thanks @jjurm for a clear breakdown and highlighting the pros/cons.

The motivator for this PR is less about the existing open issues (I somewhat selfishly used them as justification) but rather just about not having a tool juggling state between stashes and the working directory automatically for the user.

I hope the config option offered in this PR also lets users who work with partial commits/hunk based workflows the option to not benefit from some of the convenience of A for more manual control and less confusion with B. As this would also have no impact on existing lefthook yamls (as the fallback is existing behaviour) I think it then becomes a question of how many people would find this useful, and if that justifies the burden of the extra code. Given it is <30 lines of code actual code changes I hope to convince you it's worth it 😄

On top of that, I think there is a compelling use-case for this option for agentic coding:

My personal workflow involves me using the staging to "checkpoint" the work of the agent, so I can always easily dropped all unstaged changes to get back to a version I like and also for easy incremental diff review of the cumulative changes the agent is making. I dont want the noise of many commits that I would have to squash later, especially for highly explorative changes, which I may totally bin after seeing the implementation.

However, with lefthook being opinionated on how I should work, this introduces a tension between retaining state by stashing, and applying new changes to state from the linter.
For example, when the agent changes something and tries to run my pre-commit hooks to verify it's changes are correct it can lose the linter induced changes and thus just keep running the pre-commit hooks over and over trying to lint it. This often causes it to start making totally whacky changes to otherwise good implementations as it tries to "guess" how to satisfy the linter 😆 .

@AlexanderLarionovQC

Copy link
Copy Markdown
Author

FWIW I agree with the sentiment that stashed change should work out of the box, and I appreciate the PR you have done in that direction @mrexox 🙇‍♂️

@mrexox

mrexox commented May 19, 2026

Copy link
Copy Markdown
Member

@AlexanderLarionovQC , just released a new version 2.1.8 with fixes for restoring the changes. I hope that they cover most of the cases. The main case covered: when hook jobs change files with unstaged changes, so they conflict, the hook changes get reverted, so you could stage the files and try again.

@jjurm

jjurm commented May 19, 2026

Copy link
Copy Markdown

Thanks, @mrexox , really appreciated!

Even though the now-closed #1369 might have inspired this PR, the proposal here stands on its own, independently: recent fixes only addressed approach A from above, they don't make approach B possible (which I think would be preferred).

@mrexox

mrexox commented May 20, 2026

Copy link
Copy Markdown
Member

Thank you @AlexanderLarionovQC and @jjurm, this makes sense. I will consider adding this option. This PR needs some effort to rebase, but I will probably prepare a new one with an option to avoid hiding changes.

Agree that this is more of a global switch of the behavior, so maybe it requires to introduce some global (not a hook-level) option. Will think about the API.

BTW, there is --no-stage-fixed flag for lefthook run command. You can consider it as a workaround for now (works only for explicit lefthook run calls).

@AlexanderLarionovQC

Copy link
Copy Markdown
Author

Thank you! If you would like me to rebase I can also do that. I think --no-stage-fixed is not quite a fix in the sense we would be looking for in that the workdir changes are still stashed prior to running; but it is good to know!

@AlexanderLarionovQC

Copy link
Copy Markdown
Author

Hey @mrexox - is there any update on this? I am still having to tell my AIs to just stop using lefthook to avoid infinite recursions

@mrexox mrexox left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My concern is that having both stash_unstaged_changes: false and stage_fixed: true brings confusion. The prior disables staging the changes and it's not obvious from a configuration file.

I agree that it's an implicit behavior that pre-commit hides unstaged changes. This behavior was added to support stage_fixed: true and avoid auto-fixing uncommitted changes with linter.

Could you please tell more about infinite recursion? How does it happen?

Comment on lines 9 to +45
@@ -40,7 +42,7 @@ var AvailableHooks = map[string]struct{}{
}

func HookUsesStagedFiles(hook string) bool {
return hook == "pre-commit"
return hook == PreCommitHookName

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please revert this change? I agree that this is a duplication, but it's more readable when using raw string values rather than constants

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will do!

- [`parallel`](./parallel.md)
- [`piped`](./piped.md)
- [`follow`](./follow.md)
- [`stash_unstaged_changes`](./stash_unstaged_changes.md)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regarding the naming: I would prefer some boolean that is false by default and you need to explicitly set it true to enable. That's the common approach for boolean values in lefthook configuration. Something like keep_unstaged_files or include_unstaged. But there's still a possible problem with stage_fixed conflict.

@AlexanderLarionovQC

Copy link
Copy Markdown
Author

My concern is that having both stash_unstaged_changes: false and stage_fixed: true brings confusion. The prior disables staging the changes and it's not obvious from a configuration file.

I agree this, and the naming issue you highlighted, both point that there needs to be a little more thought on the UX of this. I think your name suggestions seem good and will go with those. With regards the stage_fixed compatibility, how would you feel about just disallowing the combination? As they are essentially going to be used by two different subsets of users with mutually exclusive demands of lefthook?

Could you please tell more about infinite recursion? How does it happen?

Sorry, I should have been more clear. This is not a programmatic infinite recursion but rather one of an agent working on a codebase.

It is simply that if I have a partially staged file that raises a typing error, say, and the agent fixes this error, lefthook wont pick up the change until this is staged. The agent is unaware (and also I am not sure I want to have my agents partially staging files 🤔 ) and when it runs the lefthook again, it fails the typing again (as the fix was unstaged, and thus stashed) and it goes on an unsolvable quest to fix these issues and verify they were fixed by running the lefthook over and over 😆

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants