• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

evilmartians / lefthook / 17762708138

16 Sep 2025 10:23AM UTC coverage: 71.322% (-2.7%) from 73.996%
17762708138

push

github

web-flow
refactor: reduce the amount of code in a single file (#1131)

* refactor: reduce the amount of code in a single file

251 of 302 new or added lines in 5 files covered. (83.11%)

154 existing lines in 5 files now uncovered.

3457 of 4847 relevant lines covered (71.32%)

3.15 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

39.13
/internal/run/controller/jobs/build_command.go
1
package jobs
2

3
import (
4
        "fmt"
5
        "regexp"
6
        "runtime"
7
        "strings"
8

9
        "github.com/alessio/shellescape"
10

11
        "github.com/evilmartians/lefthook/internal/config"
12
        "github.com/evilmartians/lefthook/internal/log"
13
        "github.com/evilmartians/lefthook/internal/run/controller/filters"
14
        "github.com/evilmartians/lefthook/internal/system"
15
)
16

17
var surroundingQuotesRegexp = regexp.MustCompile(`^'(.*)'$`)
18

19
// fileTemplate contains for template replacements in a command string.
20
type filesTemplate struct {
21
        files []string
22
        cnt   int
23
}
24

UNCOV
25
func buildCommand(params *Params, settings *Settings) ([]string, []string, error) {
×
UNCOV
26
        if err := params.validateCommand(); err != nil {
×
27
                return nil, nil, err
×
28
        }
×
29

UNCOV
30
        filesCmd := params.FilesCmd
×
UNCOV
31
        if len(filesCmd) > 0 {
×
UNCOV
32
                filesCmd = replacePositionalArguments(filesCmd, settings.GitArgs)
×
UNCOV
33
        }
×
34

UNCOV
35
        var stagedFiles func() ([]string, error)
×
UNCOV
36
        var stagedFilesWithDeleted func() ([]string, error)
×
UNCOV
37
        var pushFiles func() ([]string, error)
×
UNCOV
38
        var allFiles func() ([]string, error)
×
UNCOV
39
        var cmdFiles func() ([]string, error)
×
UNCOV
40

×
UNCOV
41
        if len(settings.ForceFiles) > 0 {
×
UNCOV
42
                stagedFiles = func() ([]string, error) { return settings.ForceFiles, nil }
×
UNCOV
43
                stagedFilesWithDeleted = stagedFiles
×
UNCOV
44
                pushFiles = stagedFiles
×
UNCOV
45
                allFiles = stagedFiles
×
UNCOV
46
                cmdFiles = stagedFiles
×
UNCOV
47
        } else {
×
UNCOV
48
                stagedFiles = settings.Repo.StagedFiles
×
UNCOV
49
                stagedFilesWithDeleted = settings.Repo.StagedFilesWithDeleted
×
UNCOV
50
                pushFiles = settings.Repo.PushFiles
×
UNCOV
51
                allFiles = settings.Repo.AllFiles
×
UNCOV
52
                cmdFiles = func() ([]string, error) {
×
UNCOV
53
                        var cmd []string
×
UNCOV
54
                        if runtime.GOOS == "windows" {
×
UNCOV
55
                                cmd = strings.Split(filesCmd, " ")
×
UNCOV
56
                        } else {
×
UNCOV
57
                                cmd = []string{"sh", "-c", filesCmd}
×
UNCOV
58
                        }
×
UNCOV
59
                        return settings.Repo.FindExistingFiles(cmd, params.Root)
×
60
                }
61
        }
62

UNCOV
63
        filesFns := map[string]func() ([]string, error){
×
UNCOV
64
                config.SubStagedFiles: stagedFiles,
×
UNCOV
65
                config.SubPushFiles:   pushFiles,
×
UNCOV
66
                config.SubAllFiles:    allFiles,
×
UNCOV
67
                config.SubFiles:       cmdFiles,
×
UNCOV
68
        }
×
UNCOV
69

×
UNCOV
70
        filesTemplates := make(map[string]*filesTemplate)
×
UNCOV
71

×
UNCOV
72
        filterParams := filters.Params{
×
UNCOV
73
                Glob:         params.Glob,
×
UNCOV
74
                ExcludeFiles: params.ExcludeFiles,
×
UNCOV
75
                Root:         params.Root,
×
UNCOV
76
                FileTypes:    params.FileTypes,
×
UNCOV
77
        }
×
UNCOV
78
        for filesType, fn := range filesFns {
×
UNCOV
79
                cnt := strings.Count(params.Run, filesType)
×
UNCOV
80
                if cnt == 0 {
×
UNCOV
81
                        continue
×
82
                }
83

UNCOV
84
                templ := &filesTemplate{cnt: cnt}
×
UNCOV
85
                filesTemplates[filesType] = templ
×
UNCOV
86

×
UNCOV
87
                files, err := fn()
×
UNCOV
88
                if err != nil {
×
89
                        return nil, nil, fmt.Errorf("error replacing %s: %w", filesType, err)
×
90
                }
×
91

UNCOV
92
                files = filters.Apply(settings.Repo.Fs, files, filterParams)
×
UNCOV
93
                if !settings.Force && len(files) == 0 {
×
UNCOV
94
                        return nil, nil, SkipError{"no files for inspection"}
×
UNCOV
95
                }
×
96

UNCOV
97
                templ.files = files
×
98
        }
99

100
        // Checking substitutions and skipping execution if it is empty.
101
        //
102
        // Special case for `files` option: return if the result of files command is empty.
UNCOV
103
        if !settings.Force && len(filesCmd) > 0 && filesTemplates[config.SubFiles] == nil {
×
104
                files, err := filesFns[config.SubFiles]()
×
105
                if err != nil {
×
106
                        return nil, nil, fmt.Errorf("error calling replace command for %s: %w", config.SubFiles, err)
×
107
                }
×
108

109
                files = filters.Apply(settings.Repo.Fs, files, filterParams)
×
110

×
111
                if len(files) == 0 {
×
112
                        return nil, nil, SkipError{"no files for inspection"}
×
113
                }
×
114
        }
115

UNCOV
116
        runString := params.Run
×
UNCOV
117
        runString = replacePositionalArguments(runString, settings.GitArgs)
×
UNCOV
118

×
UNCOV
119
        for keyword, replacement := range settings.Templates {
×
UNCOV
120
                runString = strings.ReplaceAll(runString, "{"+keyword+"}", replacement)
×
UNCOV
121
        }
×
122

UNCOV
123
        runString = strings.ReplaceAll(runString, "{lefthook_job_name}", shellescape.Quote(params.Name))
×
UNCOV
124

×
UNCOV
125
        maxlen := system.MaxCmdLen()
×
UNCOV
126
        commands, files := replaceInChunks(runString, filesTemplates, maxlen)
×
UNCOV
127

×
UNCOV
128
        if settings.Force || len(files) != 0 {
×
UNCOV
129
                return commands, files, nil
×
UNCOV
130
        }
×
131

UNCOV
132
        if config.HookUsesStagedFiles(settings.HookName) {
×
UNCOV
133
                ok, err := canSkipJob(settings, filterParams, filesTemplates[config.SubStagedFiles], stagedFilesWithDeleted)
×
UNCOV
134
                if err != nil {
×
135
                        return nil, nil, err
×
136
                }
×
UNCOV
137
                if ok {
×
UNCOV
138
                        return nil, nil, SkipError{"no matching staged files"}
×
UNCOV
139
                }
×
140
        }
141

UNCOV
142
        if config.HookUsesPushFiles(settings.HookName) {
×
143
                ok, err := canSkipJob(settings, filterParams, filesTemplates[config.SubPushFiles], pushFiles)
×
144
                if err != nil {
×
145
                        return nil, nil, err
×
146
                }
×
147
                if ok {
×
148
                        return nil, nil, SkipError{"no matching push files"}
×
149
                }
×
150
        }
151

UNCOV
152
        return commands, files, nil
×
153
}
154

UNCOV
155
func canSkipJob(settings *Settings, filterParams filters.Params, template *filesTemplate, filesFn func() ([]string, error)) (bool, error) {
×
UNCOV
156
        if template != nil {
×
157
                return len(template.files) == 0, nil
×
158
        }
×
159

UNCOV
160
        files, err := filesFn()
×
UNCOV
161
        if err != nil {
×
162
                return false, fmt.Errorf("error getting files: %w", err)
×
163
        }
×
UNCOV
164
        if len(filters.Apply(settings.Repo.Fs, files, filterParams)) == 0 {
×
UNCOV
165
                return true, nil
×
UNCOV
166
        }
×
167

UNCOV
168
        return false, nil
×
169
}
170

UNCOV
171
func replacePositionalArguments(str string, args []string) string {
×
UNCOV
172
        str = strings.ReplaceAll(str, "{0}", strings.Join(args, " "))
×
UNCOV
173
        for i, arg := range args {
×
174
                str = strings.ReplaceAll(str, fmt.Sprintf("{%d}", i+1), arg)
×
175
        }
×
UNCOV
176
        return str
×
177
}
178

179
// Escape file names to prevent unexpected bugs.
180
func escapeFiles(files []string) []string {
3✔
181
        var filesEsc []string
3✔
182
        for _, fileName := range files {
6✔
183
                if len(fileName) > 0 {
6✔
184
                        filesEsc = append(filesEsc, shellescape.Quote(fileName))
3✔
185
                }
3✔
186
        }
187

188
        log.Builder(log.DebugLevel, "[lefthook] ").
3✔
189
                Add("files after escaping: ", filesEsc).
3✔
190
                Log()
3✔
191

3✔
192
        return filesEsc
3✔
193
}
194

195
func replaceInChunks(str string, templates map[string]*filesTemplate, maxlen int) ([]string, []string) {
3✔
196
        if len(templates) == 0 {
3✔
UNCOV
197
                return []string{str}, nil
×
UNCOV
198
        }
×
199

200
        var cnt int
3✔
201

3✔
202
        allFiles := make([]string, 0)
3✔
203
        for name, template := range templates {
6✔
204
                if template.cnt == 0 {
3✔
205
                        continue
×
206
                }
207

208
                cnt += template.cnt
3✔
209
                maxlen += template.cnt * len(name)
3✔
210
                allFiles = append(allFiles, template.files...)
3✔
211
                template.files = escapeFiles(template.files)
3✔
212
        }
213

214
        maxlen -= len(str)
3✔
215

3✔
216
        if cnt > 0 {
6✔
217
                maxlen /= cnt
3✔
218
        }
3✔
219

220
        var exhausted int
3✔
221
        commands := make([]string, 0)
3✔
222
        for {
6✔
223
                command := str
3✔
224
                for name, template := range templates {
6✔
225
                        added, rest := getNChars(template.files, maxlen)
3✔
226
                        if len(rest) == 0 {
6✔
227
                                exhausted += 1
3✔
228
                        } else {
6✔
229
                                template.files = rest
3✔
230
                        }
3✔
231
                        command = replaceQuoted(command, name, added)
3✔
232
                }
233

234
                log.Debug("[lefthook] job: ", command)
3✔
235
                commands = append(commands, command)
3✔
236
                if exhausted >= len(templates) {
6✔
237
                        break
3✔
238
                }
239
        }
240

241
        return commands, allFiles
3✔
242
}
243

244
func getNChars(s []string, n int) ([]string, []string) {
3✔
245
        if len(s) == 0 {
6✔
246
                return nil, nil
3✔
247
        }
3✔
248

249
        var cnt int
3✔
250
        for i, str := range s {
6✔
251
                cnt += len(str)
3✔
252
                if i > 0 {
6✔
253
                        cnt += 1 // a space
3✔
254
                }
3✔
255
                if cnt > n {
6✔
256
                        if i == 0 {
6✔
257
                                i = 1
3✔
258
                        }
3✔
259
                        return s[:i], s[i:]
3✔
260
                }
261
        }
262

263
        return s, nil
3✔
264
}
265

266
func replaceQuoted(source, substitution string, files []string) string {
3✔
267
        for _, elem := range [][]string{
3✔
268
                {"\"", "\"" + substitution + "\""},
3✔
269
                {"'", "'" + substitution + "'"},
3✔
270
                {"", substitution},
3✔
271
        } {
6✔
272
                quote := elem[0]
3✔
273
                sub := elem[1]
3✔
274
                if !strings.Contains(source, sub) {
6✔
275
                        continue
3✔
276
                }
277

278
                quotedFiles := files
3✔
279
                if len(quote) != 0 {
6✔
280
                        quotedFiles = make([]string, 0, len(files))
3✔
281
                        for _, fileName := range files {
6✔
282
                                quotedFiles = append(quotedFiles,
3✔
283
                                        quote+surroundingQuotesRegexp.ReplaceAllString(fileName, "$1")+quote)
3✔
284
                        }
3✔
285
                }
286

287
                source = strings.ReplaceAll(
3✔
288
                        source, sub, strings.Join(quotedFiles, " "),
3✔
289
                )
3✔
290
        }
291

292
        return source
3✔
293
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc