Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/configuration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ The `-local` config can be used without a main config file. This is useful when
- [`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.

- [`fail_on_changes`](./fail_on_changes.md)
- [`fail_on_changes_diff`](./fail_on_changes_diff.md)
- [`exclude_tags`](./exclude_tags.md)
Expand Down
4 changes: 4 additions & 0 deletions docs/configuration/stage_fixed.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ title: "stage_fixed"
Works **only** for the `pre-commit` hook.
:::

::: callout info Note
If [`stash_unstaged_changes`](./stash_unstaged_changes.md) is set to `false` on `pre-commit`, `stage_fixed` is disabled and lefthook will not auto-stage files after the job runs.
:::

When set to `true` lefthook will automatically call `git add` on files after running the command or script. For a command if [`files`](./files.md) option was specified, the specified command will be used to retrieve files for `git add`. For scripts and commands without [`files`](./files.md) option `{staged_files}` template will be used. All filters ([`glob`](./glob.md), [`exclude`](./exclude.md)) will be applied if specified.

#### Example
Expand Down
33 changes: 33 additions & 0 deletions docs/configuration/stash_unstaged_changes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
title: "stash_unstaged_changes"
---

# `stash_unstaged_changes`

**Default: `true` for `pre-commit`**

Controls whether lefthook hides unstaged hunks from partially staged files before running the `pre-commit` hook.

When set to `false`, lefthook keeps the current on-disk contents of partially staged files visible during `pre-commit` execution. File selection does not change: `pre-commit` still uses the staged file set, and explicit templates like [`{staged_files}`](./run.md#staged_files) keep their usual meaning.

::: callout info Note
Works only for the `pre-commit` hook.
:::

::: callout info Note
When this is `false`, [`stage_fixed`](./stage_fixed.md) is disabled for `pre-commit` jobs.
:::

If you need to customize which files are passed to a job, use the hook-level [`files`](./files-global.md) option or job-level [`files`](./files.md) option.

#### Example

```yml
# lefthook.yml

pre-commit:
stash_unstaged_changes: false
commands:
lint:
run: yarn eslint {staged_files}
```
4 changes: 4 additions & 0 deletions internal/command/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ func (l *Lefthook) Validate(_ctx context.Context, args ValidateArgs) error {
return errors.New("validation failed for secondary config")
}

if _, err := config.Unmarshal(main, secondary); err != nil {
return err
}

log.Info("All good")
return nil
}
Expand Down
6 changes: 4 additions & 2 deletions internal/config/available_hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ const ChecksumFileName = "lefthook.checksum"
// GhostHookName - the hook which logs are not shown and which is used for synchronizing hooks.
const GhostHookName = "prepare-commit-msg"

const PreCommitHookName = "pre-commit"

// AvailableHooks - list of hooks taken from https://git-scm.com/docs/githooks.
// Keep the order of the hooks same here for easy syncing.
var AvailableHooks = map[string]struct{}{
"applypatch-msg": {},
"pre-applypatch": {},
"post-applypatch": {},
"pre-commit": {},
PreCommitHookName: {},
"pre-merge-commit": {},
"prepare-commit-msg": {},
"commit-msg": {},
Expand Down Expand Up @@ -40,7 +42,7 @@ var AvailableHooks = map[string]struct{}{
}

func HookUsesStagedFiles(hook string) bool {
return hook == "pre-commit"
return hook == PreCommitHookName
Comment on lines 9 to +45

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!

}

func HookUsesPushFiles(hook string) bool {
Expand Down
31 changes: 20 additions & 11 deletions internal/config/hook.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,18 @@ package config
const CMD = "{cmd}"

type Hook struct {
Name string `json:"-" jsonschema:"-" koanf:"-" mapstructure:"-" toml:"-" yaml:"-"`
Parallel bool `json:"parallel,omitempty" mapstructure:"parallel" toml:"parallel,omitempty" yaml:",omitempty"`
Piped bool `json:"piped,omitempty" mapstructure:"piped" toml:"piped,omitempty" yaml:",omitempty"`
Follow bool `json:"follow,omitempty" mapstructure:"follow" toml:"follow,omitempty" yaml:",omitempty"`
FailOnChanges string `json:"fail_on_changes,omitempty" jsonschema:"enum=true,enum=1,enum=0,enum=false,enum=never,enum=always,enum=ci,enum=non-ci" koanf:"fail_on_changes" mapstructure:"fail_on_changes" toml:"fail_on_changes,omitempty" yaml:"fail_on_changes,omitempty"`
FailOnChangesDiff *bool `json:"fail_on_changes_diff,omitempty" koanf:"fail_on_changes_diff" mapstructure:"fail_on_changes_diff" toml:"fail_on_changes_diff,omitempty" yaml:"fail_on_changes_diff,omitempty"`
Files string `json:"files,omitempty" mapstructure:"files" toml:"files,omitempty" yaml:",omitempty"`
ExcludeTags []string `json:"exclude_tags,omitempty" koanf:"exclude_tags" mapstructure:"exclude_tags" toml:"exclude_tags,omitempty" yaml:"exclude_tags,omitempty"`
Exclude []string `json:"exclude,omitempty" koanf:"exclude" mapstructure:"exclude" toml:"exclude,omitempty" yaml:"exclude,omitempty"`
Skip any `json:"skip,omitempty" jsonschema:"oneof_type=boolean;array" mapstructure:"skip" toml:"skip,omitempty,inline" yaml:",omitempty"`
Only any `json:"only,omitempty" jsonschema:"oneof_type=boolean;array" mapstructure:"only" toml:"only,omitempty,inline" yaml:",omitempty"`
Name string `json:"-" jsonschema:"-" koanf:"-" mapstructure:"-" toml:"-" yaml:"-"`
Parallel bool `json:"parallel,omitempty" mapstructure:"parallel" toml:"parallel,omitempty" yaml:",omitempty"`
Piped bool `json:"piped,omitempty" mapstructure:"piped" toml:"piped,omitempty" yaml:",omitempty"`
Follow bool `json:"follow,omitempty" mapstructure:"follow" toml:"follow,omitempty" yaml:",omitempty"`
StashUnstagedChanges *bool `json:"stash_unstaged_changes,omitempty" koanf:"stash_unstaged_changes" mapstructure:"stash_unstaged_changes" toml:"stash_unstaged_changes,omitempty" yaml:"stash_unstaged_changes,omitempty"`
FailOnChanges string `json:"fail_on_changes,omitempty" jsonschema:"enum=true,enum=1,enum=0,enum=false,enum=never,enum=always,enum=ci,enum=non-ci" koanf:"fail_on_changes" mapstructure:"fail_on_changes" toml:"fail_on_changes,omitempty" yaml:"fail_on_changes,omitempty"`
FailOnChangesDiff *bool `json:"fail_on_changes_diff,omitempty" koanf:"fail_on_changes_diff" mapstructure:"fail_on_changes_diff" toml:"fail_on_changes_diff,omitempty" yaml:"fail_on_changes_diff,omitempty"`
Files string `json:"files,omitempty" mapstructure:"files" toml:"files,omitempty" yaml:",omitempty"`
ExcludeTags []string `json:"exclude_tags,omitempty" koanf:"exclude_tags" mapstructure:"exclude_tags" toml:"exclude_tags,omitempty" yaml:"exclude_tags,omitempty"`
Exclude []string `json:"exclude,omitempty" koanf:"exclude" mapstructure:"exclude" toml:"exclude,omitempty" yaml:"exclude,omitempty"`
Skip any `json:"skip,omitempty" jsonschema:"oneof_type=boolean;array" mapstructure:"skip" toml:"skip,omitempty,inline" yaml:",omitempty"`
Only any `json:"only,omitempty" jsonschema:"oneof_type=boolean;array" mapstructure:"only" toml:"only,omitempty,inline" yaml:",omitempty"`

Setup []*SetupInstruction `json:"setup,omitempty" mapstructure:"setup" toml:"setup,omitempty" yaml:",omitempty"`
Jobs []*Job `json:"jobs,omitempty" mapstructure:"jobs" toml:"jobs,omitempty" yaml:",omitempty"`
Expand All @@ -22,6 +23,14 @@ type Hook struct {
Scripts map[string]*Script `json:"scripts,omitempty" mapstructure:"-" toml:"scripts,omitempty" yaml:",omitempty"`
}

func (hook *Hook) StashesUnstagedChanges() bool {
if hook.Name != PreCommitHookName {
return false
}

return hook.StashUnstagedChanges == nil || *hook.StashUnstagedChanges
}

type SetupInstruction struct {
Run string `json:"run,omitempty" jsonschema:"oneof_required=Run a command" mapstructure:"run" toml:"run,omitempty" yaml:",omitempty"`
}
8 changes: 7 additions & 1 deletion internal/config/jsonschema.json
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,9 @@
"follow": {
"type": "boolean"
},
"stash_unstaged_changes": {
"type": "boolean"
},
"fail_on_changes": {
"type": "string",
"enum": [
Expand Down Expand Up @@ -497,7 +500,7 @@
"type": "object"
}
},
"$comment": "Last updated on 2026.02.28.",
"$comment": "Last updated on 2026.05.15.",
"properties": {
"min_version": {
"type": "string",
Expand Down Expand Up @@ -691,6 +694,9 @@
"follow": {
"type": "boolean"
},
"stash_unstaged_changes": {
"type": "boolean"
},
"fail_on_changes": {
"type": "string",
"enum": [
Expand Down
12 changes: 12 additions & 0 deletions internal/config/load.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,14 @@ func Unmarshal(main *koanf.Koanf, secondary *koanf.Koanf) (*Config, error) {
return &config, nil
}

func validateHook(hook *Hook) error {
if hook.StashUnstagedChanges != nil && hook.Name != PreCommitHookName {
return fmt.Errorf("hook %s: stash_unstaged_changes is only supported for pre-commit", hook.Name)
}

return nil
}

// loadRemotes merges remote configs to the current one.
func loadRemotes(k *koanf.Koanf, filesystem afero.Fs, repo *git.Repository, remotes []*Remote) error {
for _, remote := range remotes {
Expand Down Expand Up @@ -438,6 +446,10 @@ func addHook(name string, main, secondary *koanf.Koanf, c *Config) error {
hook.ExcludeTags = append(hook.ExcludeTags, strings.Split(tags, ",")...)
}

if err := validateHook(&hook); err != nil {
return err
}

c.Hooks[name] = &hook
return nil
}
Expand Down
59 changes: 59 additions & 0 deletions internal/config/load_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1113,6 +1113,47 @@ run = "echo 1"
},
},
},
{
name: "stash_unstaged_changes configs",
yaml: `
pre-commit:
stash_unstaged_changes: false
commands:
echo:
run: echo 1
`,
json: `
{
"pre-commit": {
"stash_unstaged_changes": false,
"commands": {
"echo": { "run": "echo 1" }
}
}
}`,
toml: `
[pre-commit]
stash_unstaged_changes = false

[pre-commit.commands.echo]
run = "echo 1"
`,
result: &Config{
SourceDir: DefaultSourceDir,
SourceDirLocal: DefaultSourceDirLocal,
Hooks: map[string]*Hook{
"pre-commit": {
Name: "pre-commit",
StashUnstagedChanges: new(false),
Commands: map[string]*Command{
"echo": {
Run: "echo 1",
},
},
},
},
},
},
} {
fs := afero.Afero{Fs: afero.NewMemMapFs()}
repo := gittest.NewRepositoryBuilder().Root(root).Fs(fs).Build()
Expand Down Expand Up @@ -1152,6 +1193,24 @@ run = "echo 1"
})
}

t.Run("stash_unstaged_changes outside pre-commit", func(t *testing.T) {
assert := assert.New(t)
fs := afero.Afero{Fs: afero.NewMemMapFs()}
repo := gittest.NewRepositoryBuilder().Root(root).Fs(fs).Build()

configPath := filepath.Join(root, "lefthook.yml")
assert.NoError(fs.WriteFile(configPath, []byte(`
pre-push:
stash_unstaged_changes: false
commands:
echo:
run: echo 1
`), 0o644))

_, err := Load(fs.Fs, repo)
assert.EqualError(err, "hook pre-push: stash_unstaged_changes is only supported for pre-commit")
})

type remote struct {
RemoteConfigPath string
Content string
Expand Down
2 changes: 1 addition & 1 deletion internal/run/controller/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ func (c *Controller) RunHook(ctx context.Context, opts Options, hook *config.Hoo
defer log.StopSpinner()
}

guard := newGuard(c.git, !opts.NoStageFixed && config.HookUsesStagedFiles(hook.Name), opts.FailOnChanges, opts.FailOnChangesDiff)
guard := newGuard(c.git, !opts.NoStageFixed && hook.StashesUnstagedChanges(), opts.FailOnChanges, opts.FailOnChangesDiff)
scope := newScope(hook, opts)
err := guard.wrap(func() {
if hook.Parallel {
Expand Down
17 changes: 17 additions & 0 deletions internal/run/controller/controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,23 @@ func TestRunAll(t *testing.T) {
"git add --force -- .*scripts.*script.sh",
},
},
"with pre-commit and stash_unstaged_changes=false": {
hookName: "pre-commit",
existingFiles: []string{
filepath.Join(root, "README.md"),
},
hook: configtest.ParseHook(`
stash_unstaged_changes: false
jobs:
- name: ok
run: success
stage_fixed: true
`),
success: []result.Result{succeeded("ok")},
gitCommands: []string{
"git diff --name-only --cached --diff-filter=ACMR",
},
},
"with pre-push skip": {
hookName: "pre-push",
existingFiles: []string{
Expand Down
2 changes: 1 addition & 1 deletion internal/run/controller/job.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ func (c *Controller) runSingleJob(ctx context.Context, scope *scope, id string,
return result.Failure(name, job.FailText, executionTime)
}

if config.HookUsesStagedFiles(scope.hookName) && job.StageFixed && !scope.opts.NoStageFixed {
if config.HookUsesStagedFiles(scope.hookName) && scope.stashUnstagedChanges && job.StageFixed && !scope.opts.NoStageFixed {
if len(files) == 0 {
var err error
files, err = c.git.StagedFiles()
Expand Down
38 changes: 20 additions & 18 deletions internal/run/controller/scope.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,18 @@ import (
type scope struct {
follow bool

glob []string
tags []string
excludeTags []string // Consider removing this setting
names []string
fileTypes []string
excludeFiles []string
env map[string]string
root string
hookName string
filesCmd string
opts Options
glob []string
tags []string
excludeTags []string // Consider removing this setting
names []string
fileTypes []string
excludeFiles []string
env map[string]string
root string
hookName string
filesCmd string
stashUnstagedChanges bool
opts Options
}

func newScope(hook *config.Hook, opts Options) *scope {
Expand All @@ -38,13 +39,14 @@ func newScope(hook *config.Hook, opts Options) *scope {
}

return &scope{
hookName: hook.Name,
follow: hook.Follow,
filesCmd: hook.Files,
excludeTags: hook.ExcludeTags,
excludeFiles: excludeFiles,
env: make(map[string]string),
opts: opts,
hookName: hook.Name,
follow: hook.Follow,
filesCmd: hook.Files,
stashUnstagedChanges: hook.StashesUnstagedChanges(),
excludeTags: hook.ExcludeTags,
excludeFiles: excludeFiles,
env: make(map[string]string),
opts: opts,
}
}

Expand Down
8 changes: 7 additions & 1 deletion schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,9 @@
"follow": {
"type": "boolean"
},
"stash_unstaged_changes": {
"type": "boolean"
},
"fail_on_changes": {
"type": "string",
"enum": [
Expand Down Expand Up @@ -497,7 +500,7 @@
"type": "object"
}
},
"$comment": "Last updated on 2026.02.28.",
"$comment": "Last updated on 2026.05.15.",
"properties": {
"min_version": {
"type": "string",
Expand Down Expand Up @@ -691,6 +694,9 @@
"follow": {
"type": "boolean"
},
"stash_unstaged_changes": {
"type": "boolean"
},
"fail_on_changes": {
"type": "string",
"enum": [
Expand Down
Loading