Skip to content

Commit 361f71f

Browse files
committed
feat: ability to show diff when failing on changes
Closes #1171
1 parent c951a26 commit 361f71f

File tree

12 files changed

+152
-59
lines changed

12 files changed

+152
-59
lines changed

cmd/run.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ func run() *cli.Command {
1717
Name: "fail-on-changes",
1818
Usage: "exit with 1 if some of the files were changed",
1919
}
20+
failOnChangesDiff := &cli.BoolWithInverseFlag{
21+
Name: "fail-on-changes-diff",
22+
Usage: "output a diff when failing on changes",
23+
}
2024

2125
return &cli.Command{
2226
Name: "run",
@@ -107,6 +111,10 @@ func run() *cli.Command {
107111
value := cmd.Bool("fail-on-changes")
108112
args.FailOnChanges = &value
109113
}
114+
if failOnChangesDiff.IsSet() {
115+
value := cmd.Bool("fail-on-changes-diff")
116+
args.FailOnChangesDiff = &value
117+
}
110118

111119
if cmd.Args().Len() < 1 {
112120
return errors.New("hook name missing")

docs/mdbook/SUMMARY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
- [`piped`](./configuration/piped.md)
6363
- [`follow`](./configuration/follow.md)
6464
- [`fail_on_changes`](./configuration/fail_on_changes.md)
65+
- [`fail_on_changes_diff`](./configuration/fail_on_changes_diff.md)
6566
- [`exclude_tags`](./configuration/exclude_tags.md)
6667
- [`exclude`](./configuration/exclude.md)
6768
- [`skip`](./configuration/skip.md)

docs/mdbook/configuration/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ The `-local` config can be used without a main config file. This is useful when
5454
- [`piped`](./piped.md)
5555
- [`follow`](./follow.md)
5656
- [`fail_on_changes`](./fail_on_changes.md)
57+
- [`fail_on_changes_diff`](./fail_on_changes_diff.md)
5758
- [`exclude_tags`](./exclude_tags.md)
5859
- [`exclude`](./exclude.md)
5960
- [`skip`](./skip.md)

docs/mdbook/configuration/fail_on_changes.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ The behaviour of lefthook when files (tracked by git) are modified can set by mo
77
- `ci`: exit with a non-zero status only when the `CI` environment variable is set. This can be useful when combined with `stage_fixed` to ensure a frictionless devX locally, and a robust CI.
88
- `non-ci`: exit with a non-zero status only when the `CI` environment variable is _not_ set. This can be useful in setups where the CI pipeline commits changes automatically, such as [autofix.ci](https://autofix.ci).
99

10+
See also [`fail_on_changes_diff`](./fail_on_changes_diff.md).
11+
1012
```yml
1113
# lefthook.yml
1214
pre-commit:
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# fail_on_changes_diff
2+
3+
When Lefthook exits with a non-zero status as a result of [`fail_on_changes`](./fail_on_changes.md) triggering, it can optionally output a diff of the detected changes.
4+
5+
The default behavior is to output the diff when run in a CI pipeline.
6+
The `fail_on_changes_diff` boolean configuration parameter can be used to override this.
7+
8+
```yml
9+
# lefthook.yml
10+
pre-commit:
11+
parallel: true
12+
fail_on_changes: "always"
13+
fail_on_changes_diff: true
14+
commands:
15+
lint:
16+
run: yarn lint
17+
test:
18+
run: yarn test
19+
```

internal/command/run.go

Lines changed: 44 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -26,22 +26,23 @@ const (
2626
var errPipedAndParallelSet = errors.New("conflicting options 'piped' and 'parallel' are set to 'true', remove one of this option from hook group")
2727

2828
type RunArgs struct {
29-
NoTTY bool
30-
AllFiles bool
31-
FilesFromStdin bool
32-
Force bool
33-
NoAutoInstall bool
34-
NoStageFixed bool
35-
SkipLFS bool
36-
Verbose bool
37-
FailOnChanges *bool
38-
Hook string
39-
Exclude []string
40-
Files []string
41-
RunOnlyCommands []string
42-
RunOnlyJobs []string
43-
RunOnlyTags []string
44-
GitArgs []string
29+
NoTTY bool
30+
AllFiles bool
31+
FilesFromStdin bool
32+
Force bool
33+
NoAutoInstall bool
34+
NoStageFixed bool
35+
SkipLFS bool
36+
Verbose bool
37+
FailOnChanges *bool
38+
FailOnChangesDiff *bool
39+
Hook string
40+
Exclude []string
41+
Files []string
42+
RunOnlyCommands []string
43+
RunOnlyJobs []string
44+
RunOnlyTags []string
45+
GitArgs []string
4546
}
4647

4748
func (l *Lefthook) Run(ctx context.Context, args RunArgs) error {
@@ -122,6 +123,7 @@ func (l *Lefthook) Run(ctx context.Context, args RunArgs) error {
122123
if err != nil {
123124
return err
124125
}
126+
failOnChangesDiff := shouldFailOnChangesDiff(args.FailOnChangesDiff, hook.FailOnChangesDiff)
125127

126128
// Convert Commands and Scripts into Jobs
127129
hook.Jobs = append(hook.Jobs, config.CommandsToJobs(hook.Commands)...)
@@ -131,19 +133,20 @@ func (l *Lefthook) Run(ctx context.Context, args RunArgs) error {
131133
args.RunOnlyJobs = append(args.RunOnlyJobs, args.RunOnlyCommands...)
132134

133135
return runHook(ctx, hook, l.repo, run.Options{
134-
DisableTTY: cfg.NoTTY || args.NoTTY,
135-
SkipLFS: cfg.SkipLFS || args.SkipLFS,
136-
Templates: cfg.Templates,
137-
GlobMatcher: cfg.GlobMatcher,
138-
GitArgs: args.GitArgs,
139-
ExcludeFiles: args.Exclude,
140-
Files: args.Files,
141-
Force: args.Force,
142-
NoStageFixed: args.NoStageFixed,
143-
RunOnlyJobs: args.RunOnlyJobs,
144-
RunOnlyTags: args.RunOnlyTags,
145-
SourceDirs: sourceDirs,
146-
FailOnChanges: failOnChanges,
136+
DisableTTY: cfg.NoTTY || args.NoTTY,
137+
SkipLFS: cfg.SkipLFS || args.SkipLFS,
138+
Templates: cfg.Templates,
139+
GlobMatcher: cfg.GlobMatcher,
140+
GitArgs: args.GitArgs,
141+
ExcludeFiles: args.Exclude,
142+
Files: args.Files,
143+
Force: args.Force,
144+
NoStageFixed: args.NoStageFixed,
145+
RunOnlyJobs: args.RunOnlyJobs,
146+
RunOnlyTags: args.RunOnlyTags,
147+
SourceDirs: sourceDirs,
148+
FailOnChanges: failOnChanges,
149+
FailOnChangesDiff: failOnChangesDiff,
147150
})
148151
}
149152

@@ -229,6 +232,18 @@ func shouldFailOnChanges(fromArg *bool, fromHook string) (bool, error) {
229232
}
230233
}
231234

235+
func shouldFailOnChangesDiff(fromArg *bool, fromHook *bool) bool {
236+
if fromArg != nil {
237+
return *fromArg
238+
}
239+
if fromHook != nil {
240+
return *fromHook
241+
}
242+
243+
_, ok := os.LookupEnv("CI")
244+
return ok
245+
}
246+
232247
func runHook(ctx context.Context, hook *config.Hook, repo *git.Repository, opts run.Options) error {
233248
ctx, stop := signal.NotifyContext(ctx, os.Interrupt)
234249
defer stop()

internal/config/hook.go

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,17 @@ package config
33
const CMD = "{cmd}"
44

55
type Hook struct {
6-
Name string `json:"-" jsonschema:"-" koanf:"-" mapstructure:"-" toml:"-" yaml:"-"`
7-
Parallel bool `json:"parallel,omitempty" mapstructure:"parallel" toml:"parallel,omitempty" yaml:",omitempty"`
8-
Piped bool `json:"piped,omitempty" mapstructure:"piped" toml:"piped,omitempty" yaml:",omitempty"`
9-
Follow bool `json:"follow,omitempty" mapstructure:"follow" toml:"follow,omitempty" yaml:",omitempty"`
10-
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"`
11-
Files string `json:"files,omitempty" mapstructure:"files" toml:"files,omitempty" yaml:",omitempty"`
12-
ExcludeTags []string `json:"exclude_tags,omitempty" koanf:"exclude_tags" mapstructure:"exclude_tags" toml:"exclude_tags,omitempty" yaml:"exclude_tags,omitempty"`
13-
Exclude []string `json:"exclude,omitempty" koanf:"exclude" mapstructure:"exclude" toml:"exclude,omitempty" yaml:"exclude,omitempty"`
14-
Skip any `json:"skip,omitempty" jsonschema:"oneof_type=boolean;array" mapstructure:"skip" toml:"skip,omitempty,inline" yaml:",omitempty"`
15-
Only any `json:"only,omitempty" jsonschema:"oneof_type=boolean;array" mapstructure:"only" toml:"only,omitempty,inline" yaml:",omitempty"`
6+
Name string `json:"-" jsonschema:"-" koanf:"-" mapstructure:"-" toml:"-" yaml:"-"`
7+
Parallel bool `json:"parallel,omitempty" mapstructure:"parallel" toml:"parallel,omitempty" yaml:",omitempty"`
8+
Piped bool `json:"piped,omitempty" mapstructure:"piped" toml:"piped,omitempty" yaml:",omitempty"`
9+
Follow bool `json:"follow,omitempty" mapstructure:"follow" toml:"follow,omitempty" yaml:",omitempty"`
10+
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"`
11+
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"`
12+
Files string `json:"files,omitempty" mapstructure:"files" toml:"files,omitempty" yaml:",omitempty"`
13+
ExcludeTags []string `json:"exclude_tags,omitempty" koanf:"exclude_tags" mapstructure:"exclude_tags" toml:"exclude_tags,omitempty" yaml:"exclude_tags,omitempty"`
14+
Exclude []string `json:"exclude,omitempty" koanf:"exclude" mapstructure:"exclude" toml:"exclude,omitempty" yaml:"exclude,omitempty"`
15+
Skip any `json:"skip,omitempty" jsonschema:"oneof_type=boolean;array" mapstructure:"skip" toml:"skip,omitempty,inline" yaml:",omitempty"`
16+
Only any `json:"only,omitempty" jsonschema:"oneof_type=boolean;array" mapstructure:"only" toml:"only,omitempty,inline" yaml:",omitempty"`
1617

1718
Jobs []*Job `json:"jobs,omitempty" mapstructure:"jobs" toml:"jobs,omitempty" yaml:",omitempty"`
1819

internal/config/jsonschema.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,9 @@
154154
"non-ci"
155155
]
156156
},
157+
"fail_on_changes_diff": {
158+
"type": "boolean"
159+
},
157160
"files": {
158161
"type": "string"
159162
},
@@ -439,7 +442,7 @@
439442
"type": "object"
440443
}
441444
},
442-
"$comment": "Last updated on 2025.12.01.",
445+
"$comment": "Last updated on 2025.12.09.",
443446
"properties": {
444447
"min_version": {
445448
"type": "string",

internal/run/controller/controller.go

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -26,19 +26,20 @@ type Controller struct {
2626
}
2727

2828
type Options struct {
29-
GitArgs []string
30-
ExcludeFiles []string
31-
Files []string
32-
RunOnlyJobs []string
33-
RunOnlyTags []string
34-
SourceDirs []string
35-
Templates map[string]string
36-
GlobMatcher string
37-
DisableTTY bool
38-
FailOnChanges bool
39-
Force bool
40-
SkipLFS bool
41-
NoStageFixed bool
29+
GitArgs []string
30+
ExcludeFiles []string
31+
Files []string
32+
RunOnlyJobs []string
33+
RunOnlyTags []string
34+
SourceDirs []string
35+
Templates map[string]string
36+
GlobMatcher string
37+
DisableTTY bool
38+
FailOnChanges bool
39+
FailOnChangesDiff bool
40+
Force bool
41+
SkipLFS bool
42+
NoStageFixed bool
4243
}
4344

4445
func NewController(repo *git.Repository) *Controller {
@@ -76,7 +77,7 @@ func (c *Controller) RunHook(ctx context.Context, opts Options, hook *config.Hoo
7677
defer log.StopSpinner()
7778
}
7879

79-
guard := newGuard(c.git, !opts.NoStageFixed && config.HookUsesStagedFiles(hook.Name), opts.FailOnChanges)
80+
guard := newGuard(c.git, !opts.NoStageFixed && config.HookUsesStagedFiles(hook.Name), opts.FailOnChanges, opts.FailOnChangesDiff)
8081
scope := newScope(hook, opts)
8182
err := guard.wrap(func() {
8283
if hook.Parallel {

internal/run/controller/guard.go

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package controller
33
import (
44
"errors"
55
"maps"
6+
"slices"
67

78
"github.com/evilmartians/lefthook/v2/internal/git"
89
"github.com/evilmartians/lefthook/v2/internal/log"
@@ -15,17 +16,19 @@ type guard struct {
1516

1617
stashUnstagedChanges bool
1718
failOnChanges bool
19+
failOnChangesDiff bool
1820

1921
didStash bool
2022
partiallyStagedFiles []string
2123
changesetBefore map[string]string
2224
}
2325

24-
func newGuard(repo *git.Repository, stashUnstagedChanges bool, failOnChanges bool) *guard {
26+
func newGuard(repo *git.Repository, stashUnstagedChanges bool, failOnChanges bool, failOnChangesDiff bool) *guard {
2527
return &guard{
2628
git: repo,
2729
stashUnstagedChanges: stashUnstagedChanges,
2830
failOnChanges: failOnChanges,
31+
failOnChangesDiff: failOnChangesDiff,
2932
}
3033
}
3134

@@ -99,6 +102,7 @@ func (g *guard) after() error {
99102
log.Warnf("Couldn't get changeset: %s\n", err)
100103
}
101104
if !maps.Equal(g.changesetBefore, changesetAfter) {
105+
g.changesetDiff(changesetAfter)
102106
return ErrFailOnChanges
103107
}
104108
}
@@ -119,3 +123,35 @@ func (g *guard) after() error {
119123

120124
return nil
121125
}
126+
127+
func (g *guard) changesetDiff(changesetAfter map[string]string) {
128+
if !g.failOnChangesDiff {
129+
return
130+
}
131+
changed := make([]string, 0, len(g.changesetBefore))
132+
for f, hashBefore := range g.changesetBefore {
133+
if hashAfter, ok := changesetAfter[f]; !ok || hashBefore != hashAfter {
134+
changed = append(changed, f)
135+
}
136+
}
137+
for f := range changesetAfter {
138+
if _, ok := g.changesetBefore[f]; !ok {
139+
changed = append(changed, f)
140+
}
141+
}
142+
if len(changed) == 0 {
143+
return
144+
}
145+
slices.Sort(changed)
146+
diffCmd := make([]string, 0, 4) //nolint:mnd // 3 or 4 elements
147+
diffCmd = append(diffCmd, "git", "diff")
148+
if log.Colorized() {
149+
diffCmd = append(diffCmd, "--color")
150+
}
151+
diffCmd = append(diffCmd, "--")
152+
if diff, err := g.git.Git.BatchedCmd(diffCmd, changed); err != nil {
153+
log.Warnf("Couldn't diff changed files: %s", err)
154+
} else {
155+
log.Println(diff)
156+
}
157+
}

0 commit comments

Comments
 (0)