package precommit
import (
"os"
"path/filepath"
"testing"
)
func writeConfig(t *testing.T, root string) {
t.Helper()
if err := os.WriteFile(filepath.Join(root, ".pre-commit-config.yaml"), []byte("repos: []\n"), 0o644); err != nil {
t.Fatal(err)
}
}
func writeInstalledHook(t *testing.T, root string) {
t.Helper()
writeInstalledHookType(t, root, HookTypePreCommit)
writeInstalledHookType(t, root, HookTypePrePush)
}
func writeInstalledHookType(t *testing.T, root string, hookType HookType) {
t.Helper()
hooks := filepath.Join(root, ".git", "hooks")
if err := os.MkdirAll(hooks, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(hooks, string(hookType)), managedHookContent(hookType), 0o755); err != nil {
t.Fatal(err)
}
}
func TestCheckNoConfigSkips(t *testing.T) {
root := t.TempDir()
r := newFakeRunner()
res, err := Check(r, Options{Root: root})
if err != nil {
t.Fatalf("Check() error = %v", err)
}
if res.ConfigFound {
t.Fatal("ConfigFound should be false")
}
if !res.OK {
t.Fatal("no config should be OK (skipped)")
}
}
func TestCheckMissingToolNoInstall(t *testing.T) {
root := t.TempDir()
writeConfig(t, root)
r := newFakeRunner()
res, err := Check(r, Options{Root: root, AllowInstall: false})
if err != nil {
t.Fatalf("Check() error = %v", err)
}
if res.ToolInstalled {
t.Fatal("ToolInstalled should be false")
}
if res.OK {
t.Fatal("missing tool without install should not be OK")
}
if len(res.ActionsTaken) != 0 {
t.Fatalf("no actions should be taken, got %v", res.ActionsTaken)
}
}
func TestCheckAllReady(t *testing.T) {
root := t.TempDir()
writeConfig(t, root)
writeInstalledHook(t, root)
r := newFakeRunner()
r.responses[key("pre-commit", "--version")] = fakeResp{out: "pre-commit 3.7.0\n"}
res, err := Check(r, Options{Root: root})
if err != nil {
t.Fatalf("Check() error = %v", err)
}
if !res.OK || !res.ConfigFound || !res.ToolInstalled || !res.HookInstalled || !res.HooksInstalled ||
!res.PreCommitHookInstalled || !res.PrePushHookInstalled {
t.Fatalf("expected fully ready result, got %+v", res)
}
if res.ToolVersion != "3.7.0" {
t.Fatalf("ToolVersion = %q", res.ToolVersion)
}
}
func TestCheckRequiresBothHookTypes(t *testing.T) {
tests := []struct {
name string
installed HookType
}{
{name: "pre-commit only", installed: HookTypePreCommit},
{name: "pre-push only", installed: HookTypePrePush},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
root := t.TempDir()
writeConfig(t, root)
writeInstalledHookType(t, root, tt.installed)
r := newFakeRunner()
r.responses[key("pre-commit", "--version")] = fakeResp{out: "pre-commit 3.7.0\n"}
res, err := Check(r, Options{Root: root})
if err != nil {
t.Fatalf("Check() error = %v", err)
}
if res.OK || res.HooksInstalled || res.Reason != ReasonHookMissing {
t.Fatalf("expected missing aggregate hook state, got %+v", res)
}
if res.HookInstalled != (tt.installed == HookTypePreCommit) {
t.Fatalf("legacy hook state should match pre-commit, got %+v", res)
}
if res.PreCommitHookInstalled != (tt.installed == HookTypePreCommit) {
t.Fatalf("unexpected pre-commit hook state: %+v", res)
}
if res.PrePushHookInstalled != (tt.installed == HookTypePrePush) {
t.Fatalf("unexpected pre-push hook state: %+v", res)
}
})
}
}
func TestCheckInstallsHookWhenAllowed(t *testing.T) {
root := t.TempDir()
writeConfig(t, root)
r := newFakeRunner()
r.responses[key("pre-commit", "--version")] = fakeResp{out: "pre-commit 3.7.0\n"}
r.responses[key(
"pre-commit", "install",
"--hook-type", "pre-commit",
"--hook-type", "pre-push",
)] = fakeResp{out: "ok"}
hookWriter := &hookInstallingRunner{fakeRunner: r, root: root}
res, err := Check(hookWriter, Options{Root: root, AllowInstall: true})
if err != nil {
t.Fatalf("Check() error = %v", err)
}
if !res.HookInstalled || !res.HooksInstalled ||
!res.PreCommitHookInstalled || !res.PrePushHookInstalled || !res.OK {
t.Fatalf("expected hook installed + OK, got %+v", res)
}
if len(res.ActionsTaken) == 0 {
t.Fatal("expected an action recorded for hook install")
}
}
func TestCheckHookInstallFailureReason(t *testing.T) {
root := t.TempDir()
writeConfig(t, root)
r := newFakeRunner()
r.responses[key("pre-commit", "--version")] = fakeResp{out: "pre-commit 3.7.0\n"}
r.responses[key(
"pre-commit", "install",
"--hook-type", "pre-commit",
"--hook-type", "pre-push",
)] = fakeResp{out: "permission denied", err: errExit{}}
partialWriter := &partialHookInstallingRunner{fakeRunner: r, root: root}
res, err := Check(partialWriter, Options{Root: root, AllowInstall: true})
if err == nil {
t.Fatal("expected a hard error when hook installation fails")
}
if res.OK || res.Reason != ReasonInstallFailed {
t.Fatalf("want not-OK + reason=%q, got %+v", ReasonInstallFailed, res)
}
if !res.HookInstalled || res.HooksInstalled ||
!res.PreCommitHookInstalled || res.PrePushHookInstalled {
t.Fatalf("hook status should reflect the partially installed final state, got %+v", res)
}
}
func TestCheckHookInstallIncompleteReason(t *testing.T) {
root := t.TempDir()
writeConfig(t, root)
r := newFakeRunner()
r.responses[key("pre-commit", "--version")] = fakeResp{out: "pre-commit 3.7.0\n"}
r.responses[key(
"pre-commit", "install",
"--hook-type", "pre-commit",
"--hook-type", "pre-push",
)] = fakeResp{out: "ok"}
res, err := Check(r, Options{Root: root, AllowInstall: true})
if err == nil {
t.Fatal("expected a hard error when hooks remain missing after installation")
}
if res.OK || res.Reason != ReasonInstallFailed || res.HooksInstalled {
t.Fatalf("want incomplete installation classified as %q, got %+v", ReasonInstallFailed, res)
}
if len(res.ActionsTaken) != 0 {
t.Fatalf("unverified installation must not be reported as completed, got %v", res.ActionsTaken)
}
}
func TestCheckRunFails(t *testing.T) {
root := t.TempDir()
writeConfig(t, root)
writeInstalledHook(t, root)
r := newFakeRunner()
r.responses[key("pre-commit", "--version")] = fakeResp{out: "pre-commit 3.7.0\n"}
r.responses[key(
"pre-commit", "run", "--all-files", "--hook-stage", "pre-commit",
)] = fakeResp{out: "black....Failed", err: errExit{}}
r.responses[key(
"pre-commit", "run", "--all-files", "--hook-stage", "pre-push",
)] = fakeResp{out: "security passed"}
res, err := Check(r, Options{Root: root, Run: true})
if err != nil {
t.Fatalf("Check() error = %v", err)
}
if res.RunResult != "failed" {
t.Fatalf("RunResult = %q, want failed", res.RunResult)
}
if res.PreCommitRunResult != "failed" || res.PrePushRunResult != "passed" {
t.Fatalf("unexpected stage results: %+v", res)
}
if res.OK {
t.Fatal("failed run should make result not OK")
}
if res.RunOutput != "pre-commit:\nblack....Failed" {
t.Fatalf("RunOutput = %q, want the captured stage failure output", res.RunOutput)
}
if !r.called("pre-commit", "run", "--all-files", "--hook-stage", "pre-push") {
t.Fatalf("pre-push stage should run even after pre-commit fails, calls=%v", r.calls)
}
}
func TestCheckRunPrePushFails(t *testing.T) {
root := t.TempDir()
writeConfig(t, root)
writeInstalledHook(t, root)
r := newFakeRunner()
r.responses[key("pre-commit", "--version")] = fakeResp{out: "pre-commit 3.7.0\n"}
r.responses[key(
"pre-commit", "run", "--all-files", "--hook-stage", "pre-commit",
)] = fakeResp{out: "checks passed"}
r.responses[key(
"pre-commit", "run", "--all-files", "--hook-stage", "pre-push",
)] = fakeResp{out: "history scan failed", err: errExit{}}
res, err := Check(r, Options{Root: root, Run: true})
if err != nil {
t.Fatalf("Check() error = %v", err)
}
if res.PreCommitRunResult != "passed" || res.PrePushRunResult != "failed" {
t.Fatalf("unexpected stage results: %+v", res)
}
if res.RunOutput != "pre-push:\nhistory scan failed" {
t.Fatalf("RunOutput = %q, want the captured pre-push failure", res.RunOutput)
}
}
func TestCheckRunBothStagesFail(t *testing.T) {
root := t.TempDir()
writeConfig(t, root)
writeInstalledHook(t, root)
r := newFakeRunner()
r.responses[key("pre-commit", "--version")] = fakeResp{out: "pre-commit 3.7.0\n"}
r.responses[key(
"pre-commit", "run", "--all-files", "--hook-stage", "pre-commit",
)] = fakeResp{out: "format failed", err: errExit{}}
r.responses[key(
"pre-commit", "run", "--all-files", "--hook-stage", "pre-push",
)] = fakeResp{out: "history failed", err: errExit{}}
res, err := Check(r, Options{Root: root, Run: true})
if err != nil {
t.Fatalf("Check() error = %v", err)
}
wantOutput := "pre-commit:\nformat failed\n\npre-push:\nhistory failed"
if res.PreCommitRunResult != "failed" || res.PrePushRunResult != "failed" {
t.Fatalf("unexpected stage results: %+v", res)
}
if res.RunOutput != wantOutput {
t.Fatalf("RunOutput = %q, want %q", res.RunOutput, wantOutput)
}
}
func TestCheckRunPasses(t *testing.T) {
root := t.TempDir()
writeConfig(t, root)
writeInstalledHook(t, root)
r := newFakeRunner()
r.responses[key("pre-commit", "--version")] = fakeResp{out: "pre-commit 3.7.0\n"}
r.responses[key(
"pre-commit", "run", "--all-files", "--hook-stage", "pre-commit",
)] = fakeResp{out: "all passed"}
r.responses[key(
"pre-commit", "run", "--all-files", "--hook-stage", "pre-push",
)] = fakeResp{out: "all passed"}
res, err := Check(r, Options{Root: root, Run: true})
if err != nil {
t.Fatalf("Check() error = %v", err)
}
if res.RunResult != "passed" {
t.Fatalf("RunResult = %q, want passed", res.RunResult)
}
if res.PreCommitRunResult != "passed" || res.PrePushRunResult != "passed" {
t.Fatalf("unexpected stage results: %+v", res)
}
if !r.called("pre-commit", "run", "--all-files", "--hook-stage", "pre-commit") ||
!r.called("pre-commit", "run", "--all-files", "--hook-stage", "pre-push") {
t.Fatalf("expected both stages to run, calls=%v", r.calls)
}
if !res.OK {
t.Fatal("expected OK after passing run")
}
}
func TestCheckReasonNoConfig(t *testing.T) {
res, err := Check(newFakeRunner(), Options{Root: t.TempDir()})
if err != nil {
t.Fatalf("Check() error = %v", err)
}
if !res.OK || res.Reason != ReasonNoConfig {
t.Fatalf("want OK + reason=%q, got OK=%v reason=%q", ReasonNoConfig, res.OK, res.Reason)
}
}
func TestCheckReasonToolMissing(t *testing.T) {
root := t.TempDir()
writeConfig(t, root)
res, err := Check(newFakeRunner(), Options{Root: root})
if err != nil {
t.Fatalf("Check() error = %v", err)
}
if res.OK || res.Reason != ReasonToolMissing {
t.Fatalf("want not-OK + reason=%q, got OK=%v reason=%q", ReasonToolMissing, res.OK, res.Reason)
}
}
func TestCheckReasonConfigInvalid(t *testing.T) {
root := t.TempDir()
writeConfig(t, root)
r := newFakeRunner()
r.responses[key("pre-commit", "--version")] = fakeResp{out: "pre-commit 3.1.0\n"}
r.responses[key(
"pre-commit", "validate-config", filepath.Join(root, ".pre-commit-config.yaml"),
)] = fakeResp{out: "pre-commit version 3.2.0 is required", err: errExit{}}
res, err := Check(r, Options{Root: root})
if err == nil {
t.Fatal("expected invalid configuration to return a hard error")
}
if res.OK || res.Reason != ReasonConfigInvalid {
t.Fatalf("want not-OK + reason=%q, got %+v", ReasonConfigInvalid, res)
}
}
func TestCheckReasonHookMissing(t *testing.T) {
root := t.TempDir()
writeConfig(t, root)
r := newFakeRunner()
r.responses[key("pre-commit", "--version")] = fakeResp{out: "pre-commit 3.7.0\n"}
res, err := Check(r, Options{Root: root})
if err != nil {
t.Fatalf("Check() error = %v", err)
}
if res.OK || res.Reason != ReasonHookMissing {
t.Fatalf("want not-OK + reason=%q, got OK=%v reason=%q", ReasonHookMissing, res.OK, res.Reason)
}
}
func TestCheckReasonRunFailed(t *testing.T) {
root := t.TempDir()
writeConfig(t, root)
writeInstalledHook(t, root)
r := newFakeRunner()
r.responses[key("pre-commit", "--version")] = fakeResp{out: "pre-commit 3.7.0\n"}
r.responses[key(
"pre-commit", "run", "--all-files", "--hook-stage", "pre-commit",
)] = fakeResp{out: "boom", err: errExit{}}
r.responses[key(
"pre-commit", "run", "--all-files", "--hook-stage", "pre-push",
)] = fakeResp{out: "ok"}
res, err := Check(r, Options{Root: root, Run: true})
if err != nil {
t.Fatalf("Check() error = %v", err)
}
if res.OK || res.Reason != ReasonRunFailed {
t.Fatalf("want not-OK + reason=%q, got OK=%v reason=%q", ReasonRunFailed, res.OK, res.Reason)
}
}
func TestCheckReasonEmptyWhenReady(t *testing.T) {
root := t.TempDir()
writeConfig(t, root)
writeInstalledHook(t, root)
r := newFakeRunner()
r.responses[key("pre-commit", "--version")] = fakeResp{out: "pre-commit 3.7.0\n"}
res, err := Check(r, Options{Root: root})
if err != nil {
t.Fatalf("Check() error = %v", err)
}
if !res.OK || res.Reason != "" {
t.Fatalf("want OK + empty reason, got OK=%v reason=%q", res.OK, res.Reason)
}
}
func TestCheckReasonInstallFailed(t *testing.T) {
root := t.TempDir()
writeConfig(t, root)
r := newFakeRunner()
r.responses[key("pre-commit", "--version")] = fakeResp{err: errExit{}}
r.look["pipx"] = true
r.responses[key("pipx", "install", "pre-commit")] = fakeResp{
err: errExit{},
out: "ERROR: [Errno 13] Permission denied",
}
res, err := Check(r, Options{Root: root, AllowInstall: true})
if err == nil {
t.Fatal("expected a hard error when auto-install fails")
}
if res.OK || res.Reason != ReasonInstallFailed {
t.Fatalf("want not-OK + reason=%q, got OK=%v reason=%q", ReasonInstallFailed, res.OK, res.Reason)
}
if len(res.InstallFailureCategories) != 1 || res.InstallFailureCategories[0] != "permission" {
t.Fatalf("want categories=[permission], got %v", res.InstallFailureCategories)
}
}
type errExit struct{}
func (errExit) Error() string { return "exit status 1" }
type hookInstallingRunner struct {
*fakeRunner
root string
}
type partialHookInstallingRunner struct {
*fakeRunner
root string
}
func (h *partialHookInstallingRunner) Run(dir, name string, args ...string) (string, error) {
out, err := h.fakeRunner.Run(dir, name, args...)
if name == "pre-commit" && len(args) > 0 && args[0] == "install" {
writeInstalledHookNoT(h.root, HookTypePreCommit)
}
return out, err
}
func (h *hookInstallingRunner) Run(dir, name string, args ...string) (string, error) {
out, err := h.fakeRunner.Run(dir, name, args...)
if name == "pre-commit" && len(args) > 0 && args[0] == "install" {
writeInstalledHookNoT(h.root, HookTypePreCommit)
writeInstalledHookNoT(h.root, HookTypePrePush)
}
return out, err
}
func writeInstalledHookNoT(root string, hookType HookType) {
hooks := filepath.Join(root, ".git", "hooks")
_ = os.MkdirAll(hooks, 0o755)
_ = os.WriteFile(filepath.Join(hooks, string(hookType)), managedHookContent(hookType), 0o755)
}