package precommit
import (
"errors"
"fmt"
"strings"
)
const (
ReasonNoConfig = "no_config"
ReasonToolMissing = "tool_missing"
ReasonConfigInvalid = "config_invalid"
ReasonHookMissing = "hook_missing"
ReasonRunFailed = "run_failed"
ReasonInstallFailed = "install_failed"
ReasonNotInRepo = "not_in_repo"
)
type Options struct {
Root string
AllowInstall bool
Run bool
}
type Result struct {
ConfigFound bool `json:"config_found"`
ToolInstalled bool `json:"tool_installed"`
ToolVersion string `json:"tool_version,omitempty"`
HookInstalled bool `json:"hook_installed"`
HooksInstalled bool `json:"hooks_installed"`
PreCommitHookInstalled bool `json:"pre_commit_hook_installed"`
PrePushHookInstalled bool `json:"pre_push_hook_installed"`
ActionsTaken []string `json:"actions_taken"`
RunResult string `json:"run_result,omitempty"`
RunOutput string `json:"run_output,omitempty"`
PreCommitRunResult string `json:"pre_commit_run_result,omitempty"`
PrePushRunResult string `json:"pre_push_run_result,omitempty"`
OK bool `json:"ok"`
Reason string `json:"reason,omitempty"`
InstallFailureCategories []string `json:"install_failure_categories,omitempty"`
}
func Check(r CommandRunner, opts Options) (Result, error) {
res := Result{ActionsTaken: []string{}}
configPath, found := ConfigFile(opts.Root)
if !found {
res.OK = true
res.Reason = ReasonNoConfig
return res, nil
}
res.ConfigFound = true
version, ok := ToolVersion(r)
if !ok && opts.AllowInstall {
action, err := EnsureTool(r)
if err != nil {
res.Reason = ReasonInstallFailed
var ie *InstallError
if errors.As(err, &ie) {
res.InstallFailureCategories = ie.CategoryNames()
}
return res, err
}
if action != "" {
res.ActionsTaken = append(res.ActionsTaken, action)
}
version, ok = ToolVersion(r)
}
res.ToolInstalled = ok
res.ToolVersion = version
if !ok {
res.Reason = ReasonToolMissing
return res, nil
}
if out, err := r.Run(opts.Root, "pre-commit", "validate-config", configPath); err != nil {
res.Reason = ReasonConfigInvalid
return res, fmt.Errorf("pre-commit config validation failed: %w: %s", err, strings.TrimSpace(out))
}
setHookStatus(r, opts.Root, &res)
if !res.HooksInstalled && opts.AllowInstall {
action, err := InstallHook(r, opts.Root)
setHookStatus(r, opts.Root, &res)
if err != nil {
res.Reason = ReasonInstallFailed
return res, err
}
if !res.HooksInstalled {
res.Reason = ReasonInstallFailed
return res, errors.New("pre-commit install completed but required hooks are still missing")
}
if action != "" {
res.ActionsTaken = append(res.ActionsTaken, action)
}
}
if !res.HooksInstalled {
res.Reason = ReasonHookMissing
return res, nil
}
res.OK = true
if opts.Run {
outputs, runErrors := runRequiredHookStages(r, opts.Root)
res.PreCommitRunResult = runResult(runErrors[HookTypePreCommit])
res.PrePushRunResult = runResult(runErrors[HookTypePrePush])
if hasRunFailure(runErrors) {
res.RunResult = "failed"
res.RunOutput = failedRunOutput(outputs, runErrors)
res.OK = false
res.Reason = ReasonRunFailed
} else {
res.RunResult = "passed"
}
}
return res, nil
}
func setHookStatus(r CommandRunner, root string, res *Result) {
statuses := make(map[HookType]bool, len(requiredHookTypes))
res.HooksInstalled = true
for _, hookType := range requiredHookTypes {
statuses[hookType] = HookTypeInstalled(r, root, hookType)
res.HooksInstalled = res.HooksInstalled && statuses[hookType]
}
res.PreCommitHookInstalled = statuses[HookTypePreCommit]
res.PrePushHookInstalled = statuses[HookTypePrePush]
res.HookInstalled = res.PreCommitHookInstalled
}
func runHookStage(r CommandRunner, root string, hookType HookType) (string, error) {
return r.Run(
root,
"pre-commit",
"run", "--all-files", "--hook-stage", string(hookType),
)
}
func runRequiredHookStages(r CommandRunner, root string) (map[HookType]string, map[HookType]error) {
outputs := make(map[HookType]string, len(requiredHookTypes))
runErrors := make(map[HookType]error, len(requiredHookTypes))
for _, hookType := range requiredHookTypes {
outputs[hookType], runErrors[hookType] = runHookStage(r, root, hookType)
}
return outputs, runErrors
}
func hasRunFailure(runErrors map[HookType]error) bool {
for _, hookType := range requiredHookTypes {
if runErrors[hookType] != nil {
return true
}
}
return false
}
func runResult(err error) string {
if err != nil {
return "failed"
}
return "passed"
}
func failedRunOutput(outputs map[HookType]string, runErrors map[HookType]error) string {
var failures []string
for _, hookType := range requiredHookTypes {
if runErrors[hookType] != nil {
failures = append(failures, string(hookType)+":\n"+strings.TrimSpace(outputs[hookType]))
}
}
return strings.Join(failures, "\n\n")
}