Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
213 changes: 209 additions & 4 deletions cmd/entire/cli/strategy/hook_managers.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"strings"

"github.com/entireio/cli/cmd/entire/cli/paths"
"gopkg.in/yaml.v3"
)

// hookManager describes an external hook manager detected in a repository.
Expand All @@ -29,8 +30,8 @@ func detectHookManagers(repoRoot string) []hookManager {
{"Overcommit", ".overcommit.yml", false},
}

// Lefthook supports {.,}lefthook{,-local}.{yml,yaml,json,toml}
for _, prefix := range []string{"", "."} {
// Lefthook supports {.config/,}{.,}lefthook{,-local}.{yml,yaml,json,toml}
for _, prefix := range []string{"", ".", ".config/"} {
for _, variant := range []string{"", "-local"} {
for _, ext := range []string{"yml", "yaml", "json", "toml"} {
name := prefix + "lefthook" + variant + "." + ext
Expand Down Expand Up @@ -74,7 +75,8 @@ func hookManagerWarning(managers []hookManager, cmdPrefix string) string {
specs := buildHookSpecs(cmdPrefix)

for _, m := range managers {
if m.OverwritesHooks {
switch {
case m.OverwritesHooks:
fmt.Fprintf(&b, "Warning: %s detected (%s)\n", m.Name, m.ConfigPath)
fmt.Fprintf(&b, "\n")
fmt.Fprintf(&b, " %s may overwrite hooks installed by Entire on npm install.\n", m.Name)
Expand All @@ -94,7 +96,16 @@ func hookManagerWarning(managers []hookManager, cmdPrefix string) string {
fmt.Fprintf(&b, " %s\n", cmdLine)
fmt.Fprintf(&b, "\n")
}
} else {
case m.Name == "Lefthook":
fmt.Fprintf(&b, "Note: %s detected (%s)\n", m.Name, m.ConfigPath)
fmt.Fprintf(&b, "\n")
if _, ok := lefthookLocalConfigPath(m.ConfigPath); ok {
fmt.Fprintf(&b, " Added a local Lefthook pre-push safety net so Entire session pushes still run if Lefthook reinstalls hooks.\n")
} else {
fmt.Fprintf(&b, " If Lefthook reinstalls hooks, run 'entire enable' to restore Entire's hooks.\n")
}
fmt.Fprintf(&b, "\n")
default:
fmt.Fprintf(&b, "Note: %s detected (%s)\n", m.Name, m.ConfigPath)
fmt.Fprintf(&b, "\n")
fmt.Fprintf(&b, " If %s reinstalls hooks, run 'entire enable' to restore Entire's hooks.\n", m.Name)
Expand Down Expand Up @@ -144,3 +155,197 @@ func CheckAndWarnHookManagers(ctx context.Context, w io.Writer, localDev, absolu
fmt.Fprint(w, warning)
}
}

const lefthookEntireCommandName = "entire-push-sessions"

func ensureLefthookSafetyNet(ctx context.Context, cmdPrefix string) error {
repoRoot, localConfigPath, ok := lefthookSafetyNetConfigPath(ctx)
if !ok {
return nil
}

config, err := readLefthookLocalConfig(localConfigPath)
if err != nil {
return err
}

prePush := ensureMap(config, "pre-push")
commands := ensureMap(prePush, "commands")
commands[lefthookEntireCommandName] = map[string]any{
"run": lefthookSafetyNetCommand(cmdPrefix),
"priority": -100,
}

if err := writeLefthookLocalConfig(localConfigPath, config); err != nil {
return err
}
return addLefthookLocalConfigToGitExclude(ctx, repoRoot, localConfigPath)
}

func removeLefthookSafetyNet(ctx context.Context) error {
_, localConfigPath, ok := lefthookSafetyNetConfigPath(ctx)
if !ok {
return nil
}

config, err := readLefthookLocalConfig(localConfigPath)
if err != nil {
return err
}

prePush, ok := config["pre-push"].(map[string]any)
if !ok {
return nil
}
commands, ok := prePush["commands"].(map[string]any)
if !ok {
return nil
}
delete(commands, lefthookEntireCommandName)

if len(commands) == 0 {
delete(prePush, "commands")
}
if len(prePush) == 0 {
delete(config, "pre-push")
}
if len(config) == 0 {
if err := os.Remove(localConfigPath); err != nil {
return fmt.Errorf("remove empty Lefthook local config %s: %w", localConfigPath, err)
}
return nil
}
return writeLefthookLocalConfig(localConfigPath, config)
}

func lefthookSafetyNetConfigPath(ctx context.Context) (repoRoot, localConfigPath string, ok bool) {
repoRoot, err := paths.WorktreeRoot(ctx)
if err != nil {
return "", "", false
}
for _, manager := range detectHookManagers(repoRoot) {
if manager.Name != "Lefthook" {
continue
}
localRel, ok := lefthookLocalConfigPath(manager.ConfigPath)
if !ok {
return "", "", false
}
return repoRoot, filepath.Join(repoRoot, localRel), true
}
return "", "", false
}

func lefthookLocalConfigPath(configPath string) (string, bool) {
ext := filepath.Ext(configPath)
if ext != ".yml" && ext != ".yaml" {
return "", false
}

dir := filepath.Dir(configPath)
base := filepath.Base(configPath)
if strings.Contains(base, "lefthook-local") {
return configPath, true
}
localBase := strings.Replace(base, "lefthook", "lefthook-local", 1)
if dir == "." {
return localBase, true
}
return filepath.Join(dir, localBase), true
}

func readLefthookLocalConfig(path string) (map[string]any, error) {
data, err := os.ReadFile(path) //nolint:gosec // path is derived from repo root + known Lefthook config name
if err != nil {
if os.IsNotExist(err) {
return make(map[string]any), nil
}
return nil, fmt.Errorf("read Lefthook local config %s: %w", path, err)
}
if strings.TrimSpace(string(data)) == "" {
return make(map[string]any), nil
}

var config map[string]any
if err := yaml.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("failed to parse %s: %w", path, err)
}
if config == nil {
config = make(map[string]any)
}
return config, nil
}

func writeLefthookLocalConfig(path string, config map[string]any) error {
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return fmt.Errorf("create Lefthook local config directory: %w", err)
}
data, err := yaml.Marshal(config)
if err != nil {
return fmt.Errorf("marshal Lefthook local config: %w", err)
}
if err := os.WriteFile(path, data, 0o644); err != nil { //nolint:gosec // local config should be user-readable like normal config files
return fmt.Errorf("write Lefthook local config %s: %w", path, err)
}
return nil
}

func ensureMap(parent map[string]any, key string) map[string]any {
if child, ok := parent[key].(map[string]any); ok {
return child
}
child := make(map[string]any)
parent[key] = child
return child
}

func lefthookSafetyNetCommand(cmdPrefix string) string {
return fmt.Sprintf(`hook="$(git rev-parse --git-path hooks)/pre-push"
if ! grep -q %s "$hook" 2>/dev/null; then
%s hooks git pre-push {1} || true
fi`, shellQuote(entireHookMarker), cmdPrefix)
}

func addLefthookLocalConfigToGitExclude(ctx context.Context, repoRoot, localConfigPath string) error {
relPath, err := filepath.Rel(repoRoot, localConfigPath)
if err != nil {
return fmt.Errorf("make Lefthook local config path relative: %w", err)
}
relPath = filepath.ToSlash(relPath)

gitDir, err := GetGitDir(ctx)
if err != nil {
return err
}
excludePath := filepath.Join(gitDir, "info", "exclude")
if err := os.MkdirAll(filepath.Dir(excludePath), 0o750); err != nil {
return fmt.Errorf("create git info directory: %w", err)
}

data, err := os.ReadFile(excludePath) //nolint:gosec // path is derived from git dir
if err == nil {
for _, line := range strings.Split(string(data), "\n") {
if strings.TrimSpace(line) == relPath {
return nil
}
}
} else if !os.IsNotExist(err) {
return fmt.Errorf("read git exclude file %s: %w", excludePath, err)
}

f, err := os.OpenFile(excludePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) //nolint:gosec // git info/exclude is a regular text file
if err != nil {
return fmt.Errorf("open git exclude file %s: %w", excludePath, err)
}
defer f.Close()

if len(data) > 0 && !strings.HasSuffix(string(data), "\n") {
if _, err := f.WriteString("\n"); err != nil {
return fmt.Errorf("append newline to git exclude file %s: %w", excludePath, err)
}
}
if _, err := fmt.Fprintln(f, relPath); err != nil {
return fmt.Errorf("append Lefthook local config to git exclude file %s: %w", excludePath, err)
}
return nil
}
59 changes: 57 additions & 2 deletions cmd/entire/cli/strategy/hook_managers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,30 @@ func TestDetectHookManagers_LefthookToml(t *testing.T) {
}
}

func TestDetectHookManagers_LefthookConfigDir(t *testing.T) {
t.Parallel()

tmpDir := t.TempDir()
configDir := filepath.Join(tmpDir, ".config")
if err := os.MkdirAll(configDir, 0o755); err != nil {
t.Fatalf("failed to create .config/: %v", err)
}
if err := os.WriteFile(filepath.Join(configDir, "lefthook.yml"), []byte(""), 0o644); err != nil {
t.Fatalf("failed to create .config/lefthook.yml: %v", err)
}

managers := detectHookManagers(tmpDir)
if len(managers) != 1 {
t.Fatalf("expected 1 manager, got %d", len(managers))
}
if managers[0].Name != "Lefthook" {
t.Errorf("expected Lefthook, got %s", managers[0].Name)
}
if managers[0].ConfigPath != filepath.Join(".config", "lefthook.yml") {
t.Errorf("expected .config/lefthook.yml, got %s", managers[0].ConfigPath)
}
}

func TestDetectHookManagers_LefthookLocal(t *testing.T) {
t.Parallel()

Expand All @@ -126,6 +150,18 @@ func TestDetectHookManagers_LefthookLocal(t *testing.T) {
}
}

func TestLefthookLocalConfigPath_LocalIsAlreadyLocal(t *testing.T) {
t.Parallel()

got, ok := lefthookLocalConfigPath("lefthook-local.yml")
if !ok {
t.Fatal("lefthook-local.yml should be supported")
}
if got != "lefthook-local.yml" {
t.Fatalf("lefthookLocalConfigPath() = %q, want lefthook-local.yml", got)
}
}

func TestDetectHookManagers_LefthookDedup(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -361,8 +397,8 @@ func TestHookManagerWarning_GitHooksManager(t *testing.T) {
if !strings.Contains(warning, "Note: Lefthook detected") {
t.Error("warning should contain 'Note: Lefthook detected'")
}
if !strings.Contains(warning, "run 'entire enable' to restore") {
t.Error("warning should mention running 'entire enable'")
if !strings.Contains(warning, "local Lefthook pre-push safety net") {
t.Error("warning should mention the local Lefthook pre-push safety net")
}

// Should NOT contain hook file copy-paste instructions
Expand All @@ -371,6 +407,25 @@ func TestHookManagerWarning_GitHooksManager(t *testing.T) {
}
}

func TestHookManagerWarning_LefthookNonYAMLFallback(t *testing.T) {
t.Parallel()

managers := []hookManager{
{Name: "Lefthook", ConfigPath: "lefthook.toml", OverwritesHooks: false},
}

warning := hookManagerWarning(managers, "entire")
if !strings.Contains(warning, "Note: Lefthook detected") {
t.Error("warning should contain 'Note: Lefthook detected'")
}
if !strings.Contains(warning, "run 'entire enable' to restore") {
t.Error("warning should mention running 'entire enable' for unsupported local config formats")
}
if strings.Contains(warning, "local Lefthook pre-push safety net") {
t.Error("warning should not claim a safety net was added for non-YAML Lefthook configs")
}
}

func TestHookManagerWarning_Empty(t *testing.T) {
t.Parallel()

Expand Down
8 changes: 8 additions & 0 deletions cmd/entire/cli/strategy/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,10 @@ func InstallGitHook(ctx context.Context, silent, localDev, absolutePath bool) (i
}
}

if err := ensureLefthookSafetyNet(ctx, cmdPrefix); err != nil {
return installedCount, fmt.Errorf("failed to install Lefthook safety net: %w", err)
}

if !silent {
fmt.Println("✓ Installed git hooks (prepare-commit-msg, commit-msg, post-commit, pre-push)")
fmt.Println(" Hooks delegate to the current strategy at runtime")
Expand Down Expand Up @@ -386,6 +390,10 @@ func RemoveGitHook(ctx context.Context) (int, error) {
}
}

if err := removeLefthookSafetyNet(ctx); err != nil {
removeErrors = append(removeErrors, fmt.Sprintf("remove Lefthook safety net: %v", err))
}

if len(removeErrors) > 0 {
return removed, fmt.Errorf("failed to remove hooks: %s", strings.Join(removeErrors, "; "))
}
Expand Down
Loading