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
5 changes: 4 additions & 1 deletion pkg/commands/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ type GitCommand struct {
Worktree *git_commands.WorktreeCommands
Version *git_commands.GitVersion
RepoPaths *git_commands.RepoPaths
ClientHooks *git_commands.ClientHookCommands

Loaders Loaders
}
Expand Down Expand Up @@ -146,6 +147,7 @@ func NewGitCommandAux(
worktreeLoader := git_commands.NewWorktreeLoader(gitCommon)
stashLoader := git_commands.NewStashLoader(cmn, cmd)
tagLoader := git_commands.NewTagLoader(cmn, cmd)
clientHookCommands := git_commands.NewClientHookCommands(gitCommon)

return &GitCommand{
Blame: blameCommands,
Expand Down Expand Up @@ -179,7 +181,8 @@ func NewGitCommandAux(
StashLoader: stashLoader,
TagLoader: tagLoader,
},
RepoPaths: repoPaths,
RepoPaths: repoPaths,
ClientHooks: clientHookCommands,
}
}

Expand Down
81 changes: 81 additions & 0 deletions pkg/commands/git_commands/client_hooks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package git_commands

import (
"fmt"
"path/filepath"
"strings"

"github.com/go-errors/errors"
"github.com/spf13/afero"
)

const TmpCommitEditMsg = "TMP_COMMIT_EDITMSG"

type ClientHook string

const (
HookPreCommit ClientHook = "pre-commit"
HookPrepareCommitMsg ClientHook = "prepare-commit-msg"
HookCommitMsg ClientHook = "commit-msg"
HookPreRebase ClientHook = "pre-rebase"
HookPostRewrite ClientHook = "post-rewrite"
HookPostMerge ClientHook = "post-merge"
HookPostCommit ClientHook = "post-commit"
HookPrePush ClientHook = "pre-push"
HookPostCheckout ClientHook = "post-checkout"
)

type ClientHookCommands struct {
gitCommon *GitCommon
}

func NewClientHookCommands(gitCommon *GitCommon) *ClientHookCommands {
return &ClientHookCommands{
gitCommon: gitCommon,
}
}

func (self *ClientHookCommands) RunClientHook(hook ClientHook, args ...string) error {
hookPath := self.resolveHookPath(hook)

if _, err := self.gitCommon.Common.Fs.Stat(hookPath); err != nil {
if errors.Is(err, afero.ErrFileNotFound) {
return nil // Git silently ignores missing hooks
}
return err
}

argv := append([]string{hookPath}, args...)
err := self.gitCommon.cmd.New(argv).Run()
if err != nil {
// Non-zero exit code from hook
// Git surfaces error
return fmt.Errorf("client hook %s failed: %w", hook, err)
}

return nil
}

func (self *ClientHookCommands) resolveHookPath(hook ClientHook) string {
cmdArgs := NewGitCmd("config").
Arg("--get", "core.hooksPath").
ToArgv()
out, err := self.gitCommon.cmd.New(cmdArgs).RunWithOutput()

hooksPath := strings.TrimSpace(string(out))

if err != nil || hooksPath == "" {
// core.hooksPath not set or error, fallback to '.git/hooks'
return filepath.Join(
self.gitCommon.repoPaths.WorktreeGitDirPath(),
"hooks",
string(hook),
)
}

if !filepath.IsAbs(hooksPath) {
hooksPath = filepath.Join(self.gitCommon.repoPaths.repoPath, hooksPath)
}

return filepath.Join(hooksPath, string(hook))
}
215 changes: 215 additions & 0 deletions pkg/commands/git_commands/client_hooks_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
package git_commands

import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/go-errors/errors"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
)

func TestResolveHookPath(t *testing.T) {
scenarios := []struct {
name string
hook ClientHook
configValue string
configError error
expectedPath string
}{
{
name: "core.hooksPath not set",
hook: HookPreCommit,
configValue: "",
configError: errors.New("not set"),
expectedPath: "/repo/.git/hooks/pre-commit",
},
{
name: "core.hooksPath absolute",
hook: HookCommitMsg,
configValue: "/custom/hooks",
expectedPath: "/custom/hooks/commit-msg",
},
{
name: "core.hooksPath relative",
hook: HookPrepareCommitMsg,
configValue: ".githooks",
expectedPath: "/repo/.githooks/prepare-commit-msg",
},
{
name: "core.hooksPath trims whitespace",
hook: HookPrePush,
configValue: " hooks ",
expectedPath: "/repo/hooks/pre-push",
},
{
name: "git config returns error",
hook: HookPostCommit,
configValue: "",
configError: errors.New("config error"),
expectedPath: "/repo/.git/hooks/post-commit",
},
}

for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
// Create fresh runner for each test
runner := oscommands.NewFakeRunner(t)
cmdArgs := NewGitCmd("config").Arg("--get", "core.hooksPath").ToArgv()
runner.ExpectArgs(cmdArgs, s.configValue, s.configError)

fs := afero.NewMemMapFs()
repoPath := "/repo"
repoDir := "/repo/.git"
cmdBuilder := oscommands.NewDummyCmdObjBuilder(runner)

gitCommon := &GitCommon{
Common: &common.Common{
Fs: fs,
},
cmd: cmdBuilder,
repoPaths: &RepoPaths{
repoPath: repoPath,
worktreeGitDirPath: repoDir,
},
}
hooks := NewClientHookCommands(gitCommon)

hookPath := hooks.resolveHookPath(s.hook)
assert.Equal(t, s.expectedPath, hookPath)
})
}
}

func TestRunClientHook(t *testing.T) {
scenarios := []struct {
name string
hook ClientHook
configValue string
configError error
hookExists bool
hookContent string
hookMode os.FileMode
hookRunError error
expectedError string
}{
{
name: "hook exists and succeeds",
hook: HookPreCommit,
configError: errors.New("not set"), // core.hooksPath not configured
hookExists: true,
hookContent: "#!/bin/sh\nexit 0",
hookMode: 0o755,
},
{
name: "hook exists but fails",
hook: HookPrepareCommitMsg,
configError: errors.New("not set"),
hookExists: true,
hookContent: "#!/bin/sh\nexit 1",
hookMode: 0o755,
hookRunError: errors.New("exit status 1"),
expectedError: "client hook prepare-commit-msg failed",
},
{
name: "hook exists but is not executable",
hook: HookCommitMsg,
configError: errors.New("not set"),
hookExists: true,
hookContent: "#!/bin/sh",
hookMode: 0o644,
hookRunError: errors.New("permission denied"),
expectedError: "client hook commit-msg failed",
},
{
name: "hook does not exist - no error",
hook: HookPostCommit,
configError: errors.New("not set"),
hookExists: false,
},
{
name: "custom hooks path - absolute",
hook: HookPrePush,
configValue: "/custom/hooks",
hookExists: true,
hookContent: "#!/bin/sh",
hookMode: 0o755,
},
{
name: "custom hooks path - relative",
hook: HookPostCheckout,
configValue: ".githooks",
hookExists: true,
hookContent: "#!/bin/sh",
hookMode: 0o755,
},
{
name: "core.hooksPath with whitespace",
hook: HookPreRebase,
configValue: " .githooks \n",
hookExists: true,
hookContent: "#!/bin/sh",
hookMode: 0o755,
},
}

for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
fs := afero.NewMemMapFs()
repoPath := "/repo"
repoDir := "/repo/.git"
runner := oscommands.NewFakeRunner(t)

cmdArgs := NewGitCmd("config").Arg("--get", "core.hooksPath").ToArgv()
runner.ExpectArgs(cmdArgs, s.configValue, s.configError)

// Calculate expected hook path based on config
var hookPath string
trimmedConfig := strings.TrimSpace(s.configValue)
if s.configError != nil || trimmedConfig == "" {
// Default: .git/hooks
hookPath = filepath.Join(repoDir, "hooks", string(s.hook))
} else if filepath.IsAbs(trimmedConfig) {
// Absolute path
hookPath = filepath.Join(trimmedConfig, string(s.hook))
} else {
// Relative path - relative to repo root, not .git
hookPath = filepath.Join(repoPath, trimmedConfig, string(s.hook))
}

if s.hookExists {
hookDir := filepath.Dir(hookPath)
_ = fs.MkdirAll(hookDir, 0o755)
_ = afero.WriteFile(fs, hookPath, []byte(s.hookContent), s.hookMode)

runner.ExpectArgs([]string{hookPath}, "", s.hookRunError)
}

cmdBuilder := oscommands.NewDummyCmdObjBuilder(runner)
gitCommon := &GitCommon{
Common: &common.Common{
Fs: fs,
},
cmd: cmdBuilder,
repoPaths: &RepoPaths{
repoPath: repoPath,
worktreeGitDirPath: repoDir,
},
}
hooks := NewClientHookCommands(gitCommon)

err := hooks.RunClientHook(s.hook)

if s.expectedError != "" {
assert.Error(t, err)
assert.Contains(t, err.Error(), s.expectedError)
} else {
assert.NoError(t, err)
}
})
}
}
62 changes: 44 additions & 18 deletions pkg/gui/controllers/helpers/working_tree_helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,30 +182,56 @@ func (self *WorkingTreeHelper) HandleCommitPress() error {
message := self.c.Contexts().CommitMessage.GetPreservedMessageAndLogError()

if message == "" {
commitPrefixConfigs := self.commitPrefixConfigsForRepo()
for _, commitPrefixConfig := range commitPrefixConfigs {
prefixPattern := commitPrefixConfig.Pattern
if prefixPattern == "" {
continue
}
prefixReplace := commitPrefixConfig.Replace
branchName := self.refHelper.GetCheckedOutRef().Name
rgx, err := regexp.Compile(prefixPattern)
if err != nil {
return fmt.Errorf("%s: %s", self.c.Tr.CommitPrefixPatternError, err.Error())
}

if rgx.MatchString(branchName) {
prefix := rgx.ReplaceAllString(branchName, prefixReplace)
message = prefix
break
}
commitPrefix, err := self.buildCommitPrefixMessage()
if err != nil {
return err
}
message = commitPrefix
}

return self.HandleCommitPressWithMessage(message, false)
}

func (self *WorkingTreeHelper) buildCommitPrefixMessage() (string, error) {
// Prioritise user config commit prefixes
commitPrefixConfigs := self.commitPrefixConfigsForRepo()
for _, commitPrefixConfig := range commitPrefixConfigs {
prefixPattern := commitPrefixConfig.Pattern
if prefixPattern == "" {
continue
}
prefixReplace := commitPrefixConfig.Replace
branchName := self.refHelper.GetCheckedOutRef().Name
rgx, err := regexp.Compile(prefixPattern)
if err != nil {
return "", fmt.Errorf("%s: %s", self.c.Tr.CommitPrefixPatternError, err.Error())
}

if rgx.MatchString(branchName) {
return rgx.ReplaceAllString(branchName, prefixReplace), nil
}
}

// No result from user config commit prefixes
// Run prepare-commit-msg hook
tmpFile, err := os.CreateTemp(self.c.Git().RepoPaths.RepoGitDirPath(), git_commands.TmpCommitEditMsg)
if err != nil {
return "", err
}
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()

err = self.c.Git().ClientHooks.RunClientHook(git_commands.HookPrepareCommitMsg, tmpFile.Name())
if err != nil {
return "", err
}
data, err := os.ReadFile(tmpFile.Name())
if err != nil {
return "", err
}
return string(data), nil
}

func (self *WorkingTreeHelper) WithEnsureCommittableFiles(handler func() error) error {
if err := self.prepareFilesForCommit(); err != nil {
return err
Expand Down
Loading