Skip to content
Merged
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
7 changes: 6 additions & 1 deletion cmd/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,15 @@ func install() *cli.Command {
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "force",
Usage: "overwrite .old files",
Usage: "proceed with installation even if core.hooksPath is configured (overwrite .old files)",
Aliases: []string{"f"},
Destination: &args.Force,
},
&cli.BoolFlag{
Name: "reset-hooks-path",
Usage: "automatically unset core.hooksPath configuration",
Destination: &args.ResetHooksPath,
},
&cli.BoolFlag{
Name: "verbose",
Aliases: []string{"v"},
Expand Down
105 changes: 104 additions & 1 deletion internal/command/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,18 @@ var (
)

type InstallArgs struct {
Force bool
Force bool
ResetHooksPath bool
}

func (l *Lefthook) Install(ctx context.Context, args InstallArgs, hooks []string) error {
// Check for core.hooksPath configuration
localHooksPath, globalHooksPath := l.getHooksPathConfig()

if err := l.ensureNoHooksPath(localHooksPath, globalHooksPath, args.Force, args.ResetHooksPath); err != nil {
return err
}

cfg, err := l.readOrCreateConfig()
if err != nil {
return err
Expand Down Expand Up @@ -461,3 +469,98 @@ func (l *Lefthook) ensureHooksDirExists() error {

return nil
}

// getHooksPathConfig checks if core.hooksPath is configured locally or globally.
func (l *Lefthook) getHooksPathConfig() (local, global string) {
local, _ = l.repo.Git.Cmd([]string{"git", "config", "--local", "core.hooksPath"})
global, _ = l.repo.Git.Cmd([]string{"git", "config", "--global", "core.hooksPath"})
return
}

// ensureNoHooksPath ensures core.hooksPath is not configured.
func (l *Lefthook) ensureNoHooksPath(local, global string, force, resetHooksPath bool) error {
hasLocal := len(local) > 0
hasGlobal := len(global) > 0

if !hasLocal && !hasGlobal {
return nil
}

// If neither force nor resetHooksPath, returns an error with instructions.
if !force && !resetHooksPath {
return formatHooksPathError(local, global)
}

// Warn about configured core.hooksPath
if hasLocal {
log.Warnf("core.hooksPath is set locally to '%s'.", local)
}
if hasGlobal {
log.Warnf("core.hooksPath is set globally to '%s'.", global)
}

// If resetHooksPath is true, unset the conflicting configurations.
if resetHooksPath {
return l.unsetHooksPathConfig(local, global)
}

// If force is true, proceed with installation anyway (without unsetting).
// Determine path: use global path if only global is defined, otherwise use local path
path := local
if !hasLocal && hasGlobal {
path = global
}
log.Warnf("Installing lefthook anyway in '%s'.", path)

return nil
}

// formatHooksPathError formats an error message for core.hooksPath conflicts.
func formatHooksPathError(local, global string) error {
var errMsg strings.Builder
var hints []string
hasLocal := len(local) > 0
hasGlobal := len(global) > 0

if hasLocal {
errMsg.WriteString(fmt.Sprintf("core.hooksPath is set locally to '%s'.\n", local))
hints = append(hints, "hint: git config --unset-all --local core.hooksPath")
}
if hasGlobal {
errMsg.WriteString(fmt.Sprintf("core.hooksPath is set globally to '%s'.\n", global))
hints = append(hints, "hint: git config --unset-all --global core.hooksPath")
}

errMsg.WriteString("hint: Run these commands to remove it:\n")
errMsg.WriteString(strings.Join(hints, "\n"))
errMsg.WriteString("\nhint: Or run lefthook with --reset-hooks-path to automatically unset it:\n")
errMsg.WriteString("hint: lefthook install --reset-hooks-path\n")

// Determine path: use global path if only global is defined, otherwise use local path
path := local
if !hasLocal && hasGlobal {
path = global
}
errMsg.WriteString(fmt.Sprintf("hint: Use option --force to install lefthook it anyway in '%s'.", path))

return errors.New(errMsg.String())
}

// unsetHooksPathConfig removes core.hooksPath configuration.
func (l *Lefthook) unsetHooksPathConfig(local, global string) error {
if len(local) > 0 {
if _, err := l.repo.Git.Cmd([]string{"git", "config", "--local", "--unset-all", "core.hooksPath"}); err != nil {
return fmt.Errorf("failed to unset local core.hooksPath: %w", err)
}
log.Warn("local core.hooksPath has been unset.")
}

if len(global) > 0 {
if _, err := l.repo.Git.Cmd([]string{"git", "config", "--global", "--unset-all", "core.hooksPath"}); err != nil {
return fmt.Errorf("failed to unset global core.hooksPath: %w", err)
}
log.Warn("global core.hooksPath has been unset.")
}

return nil
}
192 changes: 191 additions & 1 deletion internal/command/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -293,10 +293,20 @@ remotes:
t.Run(fmt.Sprintf("%d: %s", n, tt.name), func(t *testing.T) {
assert := assert.New(t)

// Prepend git config commands required by getHooksPathConfig() in install.go.
// These commands are always called at the start of Install() to detect core.hooksPath conflicts.
gitCmds := tt.git
if len(gitCmds) == 0 || gitCmds[0].Command != "git config --local core.hooksPath" {
gitCmds = append([]cmdtest.Out{
{Command: "git config --local core.hooksPath"},
{Command: "git config --global core.hooksPath"},
}, gitCmds...)
}

repo := gittest.NewRepositoryBuilder().
Root(root).
Fs(fs).
Cmd(cmdtest.NewOrdered(t, tt.git)).
Cmd(cmdtest.NewOrdered(t, gitCmds)).
Build()
lefthook := &Lefthook{
fs: fs,
Expand Down Expand Up @@ -684,3 +694,183 @@ remotes:
})
}
}

func TestLefthookInstallWithCoreHooksPath(t *testing.T) {
root, err := filepath.Abs("src")
assert.NoError(t, err)

configPath := filepath.Join(root, "lefthook.yml")

hookPath := func(hook string) string {
return filepath.Join(gittest.GitPath(root), "hooks", hook)
}

infoPath := func(file string) string {
return filepath.Join(gittest.GitPath(root), "info", file)
}

configContent := `
pre-commit:
commands:
tests:
run: yarn test
`

for n, tt := range [...]struct {
name string
force bool
resetHooksPath bool
git []cmdtest.Out
wantError bool
wantErrorMsg string
wantExist []string
}{
{
name: "with local and global core.hooksPath without flags",
force: false,
resetHooksPath: false,
git: []cmdtest.Out{
{
Command: "git config --local core.hooksPath",
Output: ".custom-hooks",
},
{
Command: "git config --global core.hooksPath",
Output: "/usr/local/hooks",
},
},
wantError: true,
wantErrorMsg: "core.hooksPath",
},
{
name: "with local and global core.hooksPath with --force",
force: true,
resetHooksPath: false,
git: []cmdtest.Out{
{
Command: "git config --local core.hooksPath",
Output: ".custom-hooks",
},
{
Command: "git config --global core.hooksPath",
Output: "/usr/local/hooks",
},
},
wantError: false,
wantExist: []string{
configPath,
hookPath("pre-commit"),
infoPath(config.ChecksumFileName),
},
},
{
name: "with local and global core.hooksPath with --reset-hooks-path",
force: false,
resetHooksPath: true,
git: []cmdtest.Out{
{
Command: "git config --local core.hooksPath",
Output: ".custom-hooks",
},
{
Command: "git config --global core.hooksPath",
Output: "/usr/local/hooks",
},
{
Command: "git config --local --unset-all core.hooksPath",
},
{
Command: "git config --global --unset-all core.hooksPath",
},
},
wantError: false,
wantExist: []string{
configPath,
hookPath("pre-commit"),
infoPath(config.ChecksumFileName),
},
},
{
name: "with only global core.hooksPath with --force",
force: true,
resetHooksPath: false,
git: []cmdtest.Out{
{
Command: "git config --local core.hooksPath",
Output: "",
},
{
Command: "git config --global core.hooksPath",
Output: "/usr/local/hooks",
},
},
wantError: false,
wantExist: []string{
configPath,
hookPath("pre-commit"),
infoPath(config.ChecksumFileName),
},
},
{
name: "with only local core.hooksPath with --reset-hooks-path",
force: false,
resetHooksPath: true,
git: []cmdtest.Out{
{
Command: "git config --local core.hooksPath",
Output: ".custom-hooks",
},
{
Command: "git config --global core.hooksPath",
Output: "",
},
{
Command: "git config --local --unset-all core.hooksPath",
},
},
wantError: false,
wantExist: []string{
configPath,
hookPath("pre-commit"),
infoPath(config.ChecksumFileName),
},
},
} {
fs := afero.NewMemMapFs()

t.Run(fmt.Sprintf("%d: %s", n, tt.name), func(t *testing.T) {
assert := assert.New(t)

repo := gittest.NewRepositoryBuilder().
Root(root).
Fs(fs).
Cmd(cmdtest.NewOrdered(t, tt.git)).
Build()
lefthook := &Lefthook{
fs: fs,
repo: repo,
}

// Create configuration file
assert.NoError(afero.WriteFile(fs, configPath, []byte(configContent), 0o644))
timestamp := time.Date(2022, time.June, 22, 10, 40, 10, 1, time.UTC)
assert.NoError(fs.Chtimes(configPath, timestamp, timestamp))

// Do install
err := lefthook.Install(t.Context(), InstallArgs{Force: tt.force, ResetHooksPath: tt.resetHooksPath}, nil)
if tt.wantError {
if assert.Error(err) && tt.wantErrorMsg != "" {
assert.Contains(err.Error(), tt.wantErrorMsg)
}
} else {
assert.NoError(err)
// Test files that should exist
for _, file := range tt.wantExist {
ok, err := afero.Exists(fs, file)
assert.NoError(err)
assert.True(ok)
}
}
})
}
}
Loading