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.NewHookCommands(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
90 changes: 90 additions & 0 deletions pkg/commands/git_commands/client_hooks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package git_commands

import (
"fmt"
"path/filepath"

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

type ClientHooks struct {
PreCommit string
PrepareCommitMsg string
CommitMsg string
PreRebase string
PostRewrite string
PostMerge string
PostCommit string
PrePush string
PostCheckout string
}

func DefaultClientHooks() ClientHooks {
return ClientHooks{
PreCommit: "pre-commit",
PrepareCommitMsg: "prepare-commit-msg",
CommitMsg: "commit-msg",
PreRebase: "pre-rebase",
PostRewrite: "post-rewrite",
PostMerge: "post-merge",
PostCommit: "post-commit",
PrePush: "pre-push",
PostCheckout: "post-checkout",
}
}

func NewHookCommands(gitCommon *GitCommon) *ClientHookCommands {
return &ClientHookCommands{
gitCommon: gitCommon,
Hooks: DefaultClientHooks(),
}
}

type ClientHookCommands struct {
gitCommon *GitCommon
Hooks ClientHooks
}

func (self *ClientHookCommands) RunClientHook(hookName string, args ...string) error {
hookPath := filepath.Join(
self.gitCommon.repoPaths.WorktreeGitDirPath(),
"hooks",
hookName,
)
// Git silently ignores hooks that are missing or not executable.
// In those cases this is a no-op and the Git operation proceeds normally.
err := self.isHookValid(self.gitCommon.Common.Fs, hookPath)
if err != nil {
return nil
}

// Build argv: [ "/path/to/hook", arg... ]
argv := append([]string{hookPath}, args...)

cmd := self.gitCommon.cmd.New(argv)

err = cmd.Run()
// If the hook exists and is executable, Git *does* surface any non-zero exit code.
// Hook failures should be returned to the caller.
if err != nil {
return fmt.Errorf("client hook %s failed: %w", hookName, err)
}

return nil
}

func (self *ClientHookCommands) isHookValid(fs afero.Fs, hookPath string) error {
info, err := fs.Stat(hookPath)
if err != nil {
return err
}
mode := info.Mode()
executable, err := mode&0o111 != 0, nil

if !executable {
err = errors.Errorf("File '%s' is not executable", hookPath)
}

return err
}
61 changes: 61 additions & 0 deletions pkg/commands/git_commands/client_hooks_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package git_commands

import (
"path/filepath"
"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 TestRunClientHook(t *testing.T) {
fs := afero.NewMemMapFs()
repoDir := "/repo/.git"
hooksDir := filepath.Join(repoDir, "hooks")
_ = fs.MkdirAll(hooksDir, 0o755)

runner := oscommands.NewFakeRunner(t)
cmdBuilder := oscommands.NewDummyCmdObjBuilder(runner)

gitCommon := &GitCommon{
Common: &common.Common{
Fs: fs,
},
cmd: cmdBuilder,
repoPaths: &RepoPaths{worktreeGitDirPath: repoDir},
}

hooks := NewHookCommands(gitCommon)

t.Run("hook is executable", func(t *testing.T) {
hookPath := filepath.Join(hooksDir, hooks.Hooks.PreCommit)
_ = afero.WriteFile(fs, hookPath, []byte("#!/bin/sh"), 0o755)
runner.ExpectArgs([]string{hookPath}, "", nil)
err := hooks.RunClientHook(hooks.Hooks.PreCommit)
assert.NoError(t, err)
})

t.Run("hook exists but fails", func(t *testing.T) {
hookPath := filepath.Join(hooksDir, hooks.Hooks.PrepareCommitMsg)
_ = afero.WriteFile(fs, hookPath, []byte("#!/bin/sh"), 0o755)
runner.ExpectArgs([]string{hookPath}, "", errors.New("boom"))
err := hooks.RunClientHook(hooks.Hooks.PrepareCommitMsg)
assert.Error(t, err)
assert.Contains(t, err.Error(), "boom")
})

t.Run("hook exists but is not executable", func(t *testing.T) {
hookPath := filepath.Join(hooksDir, hooks.Hooks.CommitMsg)
_ = afero.WriteFile(fs, hookPath, []byte("#!/bin/sh"), 0o644)
err := hooks.RunClientHook(hooks.Hooks.CommitMsg)
assert.NoError(t, err)
})

t.Run("hook does not exist", func(t *testing.T) {
err := hooks.RunClientHook("missing-hook")
assert.NoError(t, err)
})
}
61 changes: 43 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,55 @@ 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(), "TMP_COMMIT_EDITMSG")
if err != nil {
return "", err
}
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()

err = self.c.Git().ClientHooks.RunClientHook(self.c.Git().ClientHooks.Hooks.PrepareCommitMsg, 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