diff --git a/pkg/commands/git.go b/pkg/commands/git.go index 90514cbd62a..6e6de773d4f 100644 --- a/pkg/commands/git.go +++ b/pkg/commands/git.go @@ -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 } @@ -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, @@ -179,7 +181,8 @@ func NewGitCommandAux( StashLoader: stashLoader, TagLoader: tagLoader, }, - RepoPaths: repoPaths, + RepoPaths: repoPaths, + ClientHooks: clientHookCommands, } } diff --git a/pkg/commands/git_commands/client_hooks.go b/pkg/commands/git_commands/client_hooks.go new file mode 100644 index 00000000000..bb321127e6a --- /dev/null +++ b/pkg/commands/git_commands/client_hooks.go @@ -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 +} diff --git a/pkg/commands/git_commands/client_hooks_test.go b/pkg/commands/git_commands/client_hooks_test.go new file mode 100644 index 00000000000..b264feb9bfb --- /dev/null +++ b/pkg/commands/git_commands/client_hooks_test.go @@ -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) + }) +} diff --git a/pkg/gui/controllers/helpers/working_tree_helper.go b/pkg/gui/controllers/helpers/working_tree_helper.go index 6437afa3507..f700aab2506 100644 --- a/pkg/gui/controllers/helpers/working_tree_helper.go +++ b/pkg/gui/controllers/helpers/working_tree_helper.go @@ -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