From beca39f1fb765a245638578c4fb5ee4a5bd41a1e Mon Sep 17 00:00:00 2001 From: "minion[bot]" Date: Tue, 31 Mar 2026 07:41:01 +0200 Subject: [PATCH] Detect external hook managers during enable Automated by partio-io/cli (task: detect-external-hooks-cli-agent) Co-Authored-By: Claude Entire-Checkpoint: 9e34a50a5fd4 --- cmd/partio/doctor.go | 13 +++ cmd/partio/enable.go | 12 +++ internal/git/hooks/detect.go | 72 ++++++++++++++++ internal/git/hooks/detect_test.go | 136 ++++++++++++++++++++++++++++++ 4 files changed, 233 insertions(+) create mode 100644 internal/git/hooks/detect.go create mode 100644 internal/git/hooks/detect_test.go diff --git a/cmd/partio/doctor.go b/cmd/partio/doctor.go index 4a557f6..c3197c7 100644 --- a/cmd/partio/doctor.go +++ b/cmd/partio/doctor.go @@ -10,6 +10,7 @@ import ( "github.com/partio-io/cli/internal/config" "github.com/partio-io/cli/internal/git" + githooks "github.com/partio-io/cli/internal/git/hooks" ) func newDoctorCmd() *cobra.Command { @@ -73,6 +74,18 @@ func runDoctor(cmd *cobra.Command, args []string) error { // Check partio binary in PATH fmt.Println("[OK] partio binary found (you're running it!)") + // Check for external hook managers (informational, does not count as issue) + if managers := githooks.DetectExternalHookManagers(repoRoot); len(managers) > 0 { + fmt.Println("") + fmt.Println("[INFO] External hook manager(s) detected:") + for _, m := range managers { + fmt.Printf(" - %s (%s)\n", m.Name, m.Reason) + } + fmt.Println(" These tools may conflict with partio's hook installation.") + fmt.Println(" Partio backs up existing hooks and chains to them, but you may") + fmt.Println(" need to configure your hook manager to coexist with partio.") + } + if issues == 0 { fmt.Println("\nAll checks passed!") } else { diff --git a/cmd/partio/enable.go b/cmd/partio/enable.go index 4aab72f..b684524 100644 --- a/cmd/partio/enable.go +++ b/cmd/partio/enable.go @@ -59,6 +59,18 @@ func runEnable(cmd *cobra.Command, args []string) error { // Add .partio/settings.local.json to .gitignore addToGitignore(repoRoot, ".partio/settings.local.json") + // Check for external hook managers + if managers := githooks.DetectExternalHookManagers(repoRoot); len(managers) > 0 { + fmt.Println("") + fmt.Println("[WARN] External hook manager(s) detected:") + for _, m := range managers { + fmt.Printf(" - %s (%s)\n", m.Name, m.Reason) + } + fmt.Println(" Partio will install its own hooks and may conflict with these tools.") + fmt.Println(" Run 'partio doctor' for more details.") + fmt.Println("") + } + // Install git hooks if absolutePath { exePath, err := os.Executable() diff --git a/internal/git/hooks/detect.go b/internal/git/hooks/detect.go new file mode 100644 index 0000000..604437c --- /dev/null +++ b/internal/git/hooks/detect.go @@ -0,0 +1,72 @@ +package hooks + +import ( + "encoding/json" + "os" + "path/filepath" +) + +// HookManager represents an external Git hook manager that may conflict with partio. +type HookManager struct { + Name string + Reason string +} + +// DetectExternalHookManagers checks the repository root for signs of external +// Git hook managers (Husky, Lefthook, Overcommit) and returns any that are found. +func DetectExternalHookManagers(repoRoot string) []HookManager { + var found []HookManager + + // Husky: .husky/ directory + if info, err := os.Stat(filepath.Join(repoRoot, ".husky")); err == nil && info.IsDir() { + found = append(found, HookManager{Name: "Husky", Reason: ".husky/ directory found"}) + } + + // Husky: prepare script in package.json + if !hasHusky(found) { + if detectHuskyInPackageJSON(repoRoot) { + found = append(found, HookManager{Name: "Husky", Reason: "\"prepare\" script found in package.json"}) + } + } + + // Lefthook: lefthook.yml or .lefthook.yml + if _, err := os.Stat(filepath.Join(repoRoot, "lefthook.yml")); err == nil { + found = append(found, HookManager{Name: "Lefthook", Reason: "lefthook.yml found"}) + } else if _, err := os.Stat(filepath.Join(repoRoot, ".lefthook.yml")); err == nil { + found = append(found, HookManager{Name: "Lefthook", Reason: ".lefthook.yml found"}) + } + + // Overcommit: .overcommit.yml + if _, err := os.Stat(filepath.Join(repoRoot, ".overcommit.yml")); err == nil { + found = append(found, HookManager{Name: "Overcommit", Reason: ".overcommit.yml found"}) + } + + return found +} + +func hasHusky(managers []HookManager) bool { + for _, m := range managers { + if m.Name == "Husky" { + return true + } + } + return false +} + +func detectHuskyInPackageJSON(repoRoot string) bool { + data, err := os.ReadFile(filepath.Join(repoRoot, "package.json")) + if err != nil { + return false + } + + var pkg struct { + Scripts struct { + Prepare string `json:"prepare"` + } `json:"scripts"` + } + if err := json.Unmarshal(data, &pkg); err != nil { + return false + } + + return pkg.Scripts.Prepare != "" +} diff --git a/internal/git/hooks/detect_test.go b/internal/git/hooks/detect_test.go new file mode 100644 index 0000000..79b31d3 --- /dev/null +++ b/internal/git/hooks/detect_test.go @@ -0,0 +1,136 @@ +package hooks + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDetectExternalHookManagers(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T, dir string) + expected []string // expected manager names + }{ + { + name: "no hook managers", + setup: func(t *testing.T, dir string) {}, + expected: nil, + }, + { + name: "husky directory", + setup: func(t *testing.T, dir string) { + if err := os.Mkdir(filepath.Join(dir, ".husky"), 0o755); err != nil { + t.Fatal(err) + } + }, + expected: []string{"Husky"}, + }, + { + name: "husky in package.json", + setup: func(t *testing.T, dir string) { + pkg := `{"scripts":{"prepare":"husky install"}}` + if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(pkg), 0o644); err != nil { + t.Fatal(err) + } + }, + expected: []string{"Husky"}, + }, + { + name: "husky directory takes precedence over package.json", + setup: func(t *testing.T, dir string) { + if err := os.Mkdir(filepath.Join(dir, ".husky"), 0o755); err != nil { + t.Fatal(err) + } + pkg := `{"scripts":{"prepare":"husky install"}}` + if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(pkg), 0o644); err != nil { + t.Fatal(err) + } + }, + expected: []string{"Husky"}, + }, + { + name: "lefthook.yml", + setup: func(t *testing.T, dir string) { + if err := os.WriteFile(filepath.Join(dir, "lefthook.yml"), []byte(""), 0o644); err != nil { + t.Fatal(err) + } + }, + expected: []string{"Lefthook"}, + }, + { + name: "dot lefthook.yml", + setup: func(t *testing.T, dir string) { + if err := os.WriteFile(filepath.Join(dir, ".lefthook.yml"), []byte(""), 0o644); err != nil { + t.Fatal(err) + } + }, + expected: []string{"Lefthook"}, + }, + { + name: "overcommit", + setup: func(t *testing.T, dir string) { + if err := os.WriteFile(filepath.Join(dir, ".overcommit.yml"), []byte(""), 0o644); err != nil { + t.Fatal(err) + } + }, + expected: []string{"Overcommit"}, + }, + { + name: "multiple managers", + setup: func(t *testing.T, dir string) { + if err := os.Mkdir(filepath.Join(dir, ".husky"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "lefthook.yml"), []byte(""), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ".overcommit.yml"), []byte(""), 0o644); err != nil { + t.Fatal(err) + } + }, + expected: []string{"Husky", "Lefthook", "Overcommit"}, + }, + { + name: "invalid package.json ignored", + setup: func(t *testing.T, dir string) { + if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte("not json"), 0o644); err != nil { + t.Fatal(err) + } + }, + expected: nil, + }, + { + name: "package.json without prepare script", + setup: func(t *testing.T, dir string) { + pkg := `{"scripts":{"test":"jest"}}` + if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(pkg), 0o644); err != nil { + t.Fatal(err) + } + }, + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + tt.setup(t, dir) + + managers := DetectExternalHookManagers(dir) + + if len(managers) != len(tt.expected) { + t.Fatalf("got %d managers, want %d", len(managers), len(tt.expected)) + } + + for i, m := range managers { + if m.Name != tt.expected[i] { + t.Errorf("manager[%d].Name = %q, want %q", i, m.Name, tt.expected[i]) + } + if m.Reason == "" { + t.Errorf("manager[%d].Reason is empty", i) + } + } + }) + } +}