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
13 changes: 13 additions & 0 deletions cmd/partio/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
12 changes: 12 additions & 0 deletions cmd/partio/enable.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
72 changes: 72 additions & 0 deletions internal/git/hooks/detect.go
Original file line number Diff line number Diff line change
@@ -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 != ""
}
136 changes: 136 additions & 0 deletions internal/git/hooks/detect_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
})
}
}