Skip to content

Commit 5ef0c0b

Browse files
authored
Merge branch 'master' into fixes-1367
2 parents 5892b2b + 236a5bd commit 5ef0c0b

File tree

10 files changed

+179
-147
lines changed

10 files changed

+179
-147
lines changed

internal/command/install.go

Lines changed: 34 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,6 @@ type InstallArgs struct {
4141
}
4242

4343
func (l *Lefthook) Install(ctx context.Context, args InstallArgs, hooks []string) error {
44-
if err := l.ensureHooksPathUnset(args.Force, args.ResetHooksPath); err != nil {
45-
return err
46-
}
47-
4844
cfg, err := l.readOrCreateConfig()
4945
if err != nil {
5046
return err
@@ -70,6 +66,17 @@ func (l *Lefthook) Install(ctx context.Context, args InstallArgs, hooks []string
7066
}
7167
}
7268

69+
return l.installHooks(cfg, hooks, args)
70+
}
71+
72+
// installHooks is the single entry point for creating hook files.
73+
// It ensures core.hooksPath is checked before any hooks are written,
74+
// preventing silent overwrites of global hook directories.
75+
func (l *Lefthook) installHooks(cfg *config.Config, hooks []string, args InstallArgs) error {
76+
if err := l.ensureHooksPathUnset(args.Force, args.ResetHooksPath); err != nil {
77+
return err
78+
}
79+
7380
return l.createHooksIfNeeded(cfg, hooks, args.Force)
7481
}
7582

@@ -196,7 +203,11 @@ func (l *Lefthook) syncHooks(cfg *config.Config, fetchRemotes bool) (*config.Con
196203
}
197204

198205
// Don't rely on config checksum if remotes were refetched
199-
return cfg, l.createHooksIfNeeded(cfg, hooks, false)
206+
if err := l.installHooks(cfg, hooks, InstallArgs{}); err != nil {
207+
log.Warnf("Skipping hook sync: %s", err)
208+
return cfg, nil
209+
}
210+
return cfg, nil
200211
}
201212

202213
func (l *Lefthook) shouldRefetch(remote *config.Remote) bool {
@@ -276,23 +287,7 @@ func (l *Lefthook) createHooksIfNeeded(cfg *config.Config, hooks []string, force
276287
return fmt.Errorf("could not create hooks dir: %w", err)
277288
}
278289

279-
rootsMap := make(map[string]struct{})
280-
for _, hook := range cfg.Hooks {
281-
for _, command := range hook.Commands {
282-
if len(command.Root) > 0 {
283-
root := strings.Trim(command.Root, "/")
284-
if _, ok := rootsMap[root]; !ok {
285-
rootsMap[root] = struct{}{}
286-
}
287-
}
288-
}
289-
290-
collectAllJobRoots(rootsMap, hook.Jobs)
291-
}
292-
roots := make([]string, 0, len(rootsMap))
293-
for root := range rootsMap {
294-
roots = append(roots, root)
295-
}
290+
roots := collectRoots(cfg)
296291

297292
hookNames := make([]string, 0, len(cfg.Hooks)+1)
298293
for hook := range cfg.Hooks {
@@ -348,6 +343,23 @@ func (l *Lefthook) createHooksIfNeeded(cfg *config.Config, hooks []string, force
348343
return nil
349344
}
350345

346+
func collectRoots(cfg *config.Config) []string {
347+
rootsMap := make(map[string]struct{})
348+
for _, hook := range cfg.Hooks {
349+
for _, command := range hook.Commands {
350+
if len(command.Root) > 0 {
351+
rootsMap[strings.Trim(command.Root, "/")] = struct{}{}
352+
}
353+
}
354+
collectAllJobRoots(rootsMap, hook.Jobs)
355+
}
356+
roots := make([]string, 0, len(rootsMap))
357+
for root := range rootsMap {
358+
roots = append(roots, root)
359+
}
360+
return roots
361+
}
362+
351363
func collectAllJobRoots(roots map[string]struct{}, jobs []*config.Job) {
352364
for _, job := range jobs {
353365
if len(job.Root) > 0 {

internal/command/install_test.go

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -317,14 +317,14 @@ remotes:
317317
t.Run(fmt.Sprintf("%d: %s", n, tt.name), func(t *testing.T) {
318318
assert := assert.New(t)
319319

320-
// Prepend git config commands required by getHooksPathConfig() in install.go.
321-
// These commands are always called at the start of Install() to detect core.hooksPath conflicts.
320+
// Append git config commands required by ensureHooksPathUnset() via installHooks().
321+
// These commands are called after remote syncing, when hooks are about to be created.
322322
gitCmds := tt.git
323323
if len(gitCmds) == 0 || gitCmds[0].Command != "git config --local core.hooksPath" {
324-
gitCmds = append([]cmdtest.Out{
325-
{Command: "git config --local core.hooksPath"},
326-
{Command: "git config --global core.hooksPath"},
327-
}, gitCmds...)
324+
gitCmds = append(gitCmds,
325+
cmdtest.Out{Command: "git config --local core.hooksPath"},
326+
cmdtest.Out{Command: "git config --global core.hooksPath"},
327+
)
328328
}
329329

330330
repo := gittest.NewRepositoryBuilder().
@@ -506,6 +506,52 @@ commit-msg:
506506
hookPath(config.GhostHookName),
507507
},
508508
},
509+
{
510+
// Reproduces the bug: when core.hooksPath is set globally (e.g. for
511+
// credential leak detection), syncHooks should skip hook creation
512+
// rather than overwriting the user's global hooks directory.
513+
// See: reproduce-bug.sh
514+
name: "unsynchronized with global core.hooksPath set",
515+
config: `
516+
pre-commit:
517+
commands:
518+
tests:
519+
run: yarn test
520+
`,
521+
checksum: "00000000f706df65f379a9ff5ce0119b 1555894311\n",
522+
git: []cmdtest.Out{
523+
{Command: "git config --local core.hooksPath"},
524+
{Command: "git config --global core.hooksPath", Output: "/home/user/.config/git/hooks"},
525+
},
526+
wantExist: []string{
527+
configPath,
528+
},
529+
wantNotExist: []string{
530+
hookPath("pre-commit"),
531+
hookPath(config.GhostHookName),
532+
},
533+
},
534+
{
535+
name: "unsynchronized with local core.hooksPath set",
536+
config: `
537+
pre-commit:
538+
commands:
539+
tests:
540+
run: yarn test
541+
`,
542+
checksum: "00000000f706df65f379a9ff5ce0119b 1555894311\n",
543+
git: []cmdtest.Out{
544+
{Command: "git config --local core.hooksPath", Output: ".custom-hooks"},
545+
{Command: "git config --global core.hooksPath"},
546+
},
547+
wantExist: []string{
548+
configPath,
549+
},
550+
wantNotExist: []string{
551+
hookPath("pre-commit"),
552+
hookPath(config.GhostHookName),
553+
},
554+
},
509555
{
510556
name: "with unfetched remote",
511557
config: `
@@ -566,7 +612,18 @@ remotes:
566612
t.Run(fmt.Sprintf("%d: %s", n, tt.name), func(t *testing.T) {
567613
assert := assert.New(t)
568614

569-
repo := gittest.NewRepositoryBuilder().Root(root).Fs(fs).Cmd(cmdtest.NewOrdered(t, tt.git)).Build()
615+
// Append git config commands required by ensureHooksPathUnset() via installHooks().
616+
// These commands are called when syncHooks needs to create/update hooks,
617+
// which happens after any remote fetching.
618+
gitCmds := tt.git
619+
if len(gitCmds) == 0 || gitCmds[0].Command != "git config --local core.hooksPath" {
620+
gitCmds = append(gitCmds,
621+
cmdtest.Out{Command: "git config --local core.hooksPath"},
622+
cmdtest.Out{Command: "git config --global core.hooksPath"},
623+
)
624+
}
625+
626+
repo := gittest.NewRepositoryBuilder().Root(root).Fs(fs).Cmd(cmdtest.NewOrdered(t, gitCmds)).Build()
570627
lefthook := &Lefthook{
571628
fs: fs,
572629
repo: repo,

internal/config/load.go

Lines changed: 29 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -385,29 +385,10 @@ func addHook(name string, main, secondary *koanf.Koanf, c *Config) error {
385385
default:
386386
}
387387

388-
var destJobs, srcJobs []any
389-
switch jobs := dest["jobs"].(type) {
390-
case []any:
391-
destJobs = jobs
392-
default:
393-
}
394-
switch jobs := src["jobs"].(type) {
395-
case []any:
396-
srcJobs = jobs
397-
default:
398-
}
399-
400-
var destSetup, srcSetup []any
401-
switch setup := dest["setup"].(type) {
402-
case []any:
403-
destSetup = setup
404-
default:
405-
}
406-
switch setup := src["setup"].(type) {
407-
case []any:
408-
srcSetup = setup
409-
default:
410-
}
388+
destJobs := anySlice(dest, "jobs")
389+
srcJobs := anySlice(src, "jobs")
390+
destSetup := anySlice(dest, "setup")
391+
srcSetup := anySlice(src, "setup")
411392

412393
destJobs = mergeJobsSlice(srcJobs, destJobs)
413394
destSetup = slices.Concat(srcSetup, destSetup)
@@ -497,57 +478,10 @@ func (k koanfProvider) ReadBytes() ([]byte, error) {
497478
// `jobs` settings get overwritten by name or get appended to the end.
498479
// `setup` always get prepended.
499480
func mergeHooks(src, dest map[string]any) error {
500-
srcJobs := make(map[string][]any)
501-
for name, maybeHook := range src {
502-
switch hook := maybeHook.(type) {
503-
case map[string]any:
504-
switch jobs := hook["jobs"].(type) {
505-
case []any:
506-
srcJobs[name] = jobs
507-
default:
508-
}
509-
default:
510-
}
511-
}
512-
513-
destJobs := make(map[string][]any)
514-
for name, maybeHook := range dest {
515-
switch hook := maybeHook.(type) {
516-
case map[string]any:
517-
switch jobs := hook["jobs"].(type) {
518-
case []any:
519-
destJobs[name] = jobs
520-
default:
521-
}
522-
default:
523-
}
524-
}
525-
526-
srcSetup := make(map[string][]any)
527-
for name, maybeHook := range src {
528-
switch hook := maybeHook.(type) {
529-
case map[string]any:
530-
switch setup := hook["setup"].(type) {
531-
case []any:
532-
srcSetup[name] = setup
533-
default:
534-
}
535-
default:
536-
}
537-
}
538-
539-
destSetup := make(map[string][]any)
540-
for name, maybeHook := range dest {
541-
switch hook := maybeHook.(type) {
542-
case map[string]any:
543-
switch setup := hook["setup"].(type) {
544-
case []any:
545-
destSetup[name] = setup
546-
default:
547-
}
548-
default:
549-
}
550-
}
481+
srcJobs := extractHookSlices(src, "jobs")
482+
destJobs := extractHookSlices(dest, "jobs")
483+
srcSetup := extractHookSlices(src, "setup")
484+
destSetup := extractHookSlices(dest, "setup")
551485

552486
if (len(srcJobs) == 0 || len(destJobs) == 0) && (len(srcSetup) == 0 || len(destSetup) == 0) {
553487
maps.Merge(src, dest)
@@ -599,10 +533,30 @@ func mergeHooks(src, dest map[string]any) error {
599533
return nil
600534
}
601535

536+
// anySlice extracts a []any value from a string-keyed map, returning nil if absent or wrong type.
537+
func anySlice(m map[string]any, key string) []any {
538+
v, _ := m[key].([]any)
539+
return v
540+
}
541+
542+
// extractHookSlices builds a name→[]any map from a hooks map for the given field key.
543+
func extractHookSlices(hooks map[string]any, key string) map[string][]any {
544+
result := make(map[string][]any)
545+
for name, maybeHook := range hooks {
546+
if hook, ok := maybeHook.(map[string]any); ok {
547+
if v, ok := hook[key].([]any); ok {
548+
result[name] = v
549+
}
550+
}
551+
}
552+
return result
553+
}
554+
602555
func mergeJobsSlice(src, dest []any) []any {
603556
mergeable := make(map[string]map[string]any)
604557
result := make([]any, 0, len(dest))
605558

559+
// Pass 1: index dest jobs by name and preserve order.
606560
for _, maybeJob := range dest {
607561
switch destJob := maybeJob.(type) {
608562
case map[string]any:
@@ -617,6 +571,7 @@ func mergeJobsSlice(src, dest []any) []any {
617571
}
618572
}
619573

574+
// Pass 2: merge src jobs into dest by name, or append unnamed ones.
620575
for _, maybeJob := range src {
621576
switch srcJob := maybeJob.(type) {
622577
case map[string]any:

internal/config/skip_checker.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,16 @@ import (
88
"github.com/evilmartians/lefthook/v2/internal/system"
99
)
1010

11-
type skipChecker struct {
11+
type SkipChecker struct {
1212
exec *commandExecutor
1313
}
1414

15-
func NewSkipChecker(cmd system.Command) *skipChecker {
16-
return &skipChecker{&commandExecutor{cmd}}
15+
func NewSkipChecker(cmd system.Command) *SkipChecker {
16+
return &SkipChecker{&commandExecutor{cmd}}
1717
}
1818

1919
// Check returns the result of applying a skip/only setting which can be a branch, git state, shell command, etc.
20-
func (sc *skipChecker) Check(state func() git.State, skip any, only any) bool {
20+
func (sc *SkipChecker) Check(state func() git.State, skip any, only any) bool {
2121
if skip == nil && only == nil {
2222
return false
2323
}
@@ -35,7 +35,7 @@ func (sc *skipChecker) Check(state func() git.State, skip any, only any) bool {
3535
return false
3636
}
3737

38-
func (sc *skipChecker) matches(state func() git.State, value any) bool {
38+
func (sc *SkipChecker) matches(state func() git.State, value any) bool {
3939
switch typedValue := value.(type) {
4040
case bool:
4141
return typedValue
@@ -47,7 +47,7 @@ func (sc *skipChecker) matches(state func() git.State, value any) bool {
4747
return false
4848
}
4949

50-
func (sc *skipChecker) matchesSlices(gitState func() git.State, slice []any) bool {
50+
func (sc *SkipChecker) matchesSlices(gitState func() git.State, slice []any) bool {
5151
for _, state := range slice {
5252
switch typedState := state.(type) {
5353
case string:
@@ -68,7 +68,7 @@ func (sc *skipChecker) matchesSlices(gitState func() git.State, slice []any) boo
6868
return false
6969
}
7070

71-
func (sc *skipChecker) matchesRef(state func() git.State, typedState map[string]any) bool {
71+
func (sc *SkipChecker) matchesRef(state func() git.State, typedState map[string]any) bool {
7272
ref, ok := typedState["ref"].(string)
7373
if !ok {
7474
return false
@@ -84,7 +84,7 @@ func (sc *skipChecker) matchesRef(state func() git.State, typedState map[string]
8484
return g.Match(branch)
8585
}
8686

87-
func (sc *skipChecker) matchesCommands(typedState map[string]any) bool {
87+
func (sc *SkipChecker) matchesCommands(typedState map[string]any) bool {
8888
commandLine, ok := typedState["run"].(string)
8989
if !ok {
9090
return false

0 commit comments

Comments
 (0)