Skip to content

Commit 2cba79b

Browse files
authored
[feat] Add pre-commit hook for main/master branch protection (#52)
Generalize the hook system to support multiple hook types. The post-merge hook (auto-cleanup) is now joined by a pre-commit hook that prevents direct commits to main/master, reinforcing the worktree-based workflow. - Rename HookBlock to PostMergeBlock, replace HookName with PostMergeHook/PreCommitHook - Parameterize Install/Uninstall/Check with hookName and block - Add PreCommitBlock() for branch protection script - Update rimba hook install/uninstall/status to manage both hooks - Add e2e tests verifying commit blocking on main and allowing on branches - Add branch protection guard to .githooks/pre-commit for rimba development
1 parent f3bfa22 commit 2cba79b

6 files changed

Lines changed: 487 additions & 130 deletions

File tree

.githooks/pre-commit

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
#!/bin/sh
22
# Pre-commit hook: format staged Go files and run linter.
33

4+
# Prevent direct commits to main/master
5+
_branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)
6+
if [ "$_branch" = "main" ] || [ "$_branch" = "master" ]; then
7+
echo "ERROR: Direct commits to '$_branch' are not allowed."
8+
exit 1
9+
fi
10+
411
set -e
512

613
# Format staged Go files

cmd/hook.go

Lines changed: 70 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,14 @@ func init() {
1818

1919
var hookCmd = &cobra.Command{
2020
Use: "hook",
21-
Short: "Manage Git hooks for automatic worktree cleanup",
22-
Long: "Install or remove a post-merge Git hook that automatically cleans merged worktrees after git pull.",
21+
Short: "Manage Git hooks for worktree workflow",
22+
Long: "Install or remove Git hooks: a post-merge hook for automatic cleanup and a pre-commit hook that prevents direct commits to main/master.",
2323
Annotations: map[string]string{"skipConfig": "true"},
2424
}
2525

2626
var hookInstallCmd = &cobra.Command{
2727
Use: "install",
28-
Short: "Install the post-merge hook for automatic cleanup",
28+
Short: "Install post-merge and pre-commit hooks",
2929
RunE: func(cmd *cobra.Command, args []string) error {
3030
r := newRunner()
3131

@@ -39,24 +39,35 @@ var hookInstallCmd = &cobra.Command{
3939
return err
4040
}
4141

42-
err = hook.Install(hooksDir, branch)
42+
out := cmd.OutOrStdout()
43+
44+
// Install post-merge hook
45+
err = hook.Install(hooksDir, hook.PostMergeHook, hook.PostMergeBlock(branch))
4346
if errors.Is(err, hook.ErrAlreadyInstalled) {
44-
fmt.Fprintln(cmd.OutOrStdout(), "Rimba post-merge hook is already installed.")
45-
return nil
47+
fmt.Fprintln(out, "Rimba post-merge hook is already installed.")
48+
} else if err != nil {
49+
return fmt.Errorf("install post-merge hook: %w", err)
50+
} else {
51+
fmt.Fprintf(out, "Installed post-merge hook (branch: %s)\n", branch)
4652
}
47-
if err != nil {
48-
return err
53+
54+
// Install pre-commit hook
55+
err = hook.Install(hooksDir, hook.PreCommitHook, hook.PreCommitBlock())
56+
if errors.Is(err, hook.ErrAlreadyInstalled) {
57+
fmt.Fprintln(out, "Rimba pre-commit hook is already installed.")
58+
} else if err != nil {
59+
return fmt.Errorf("install pre-commit hook: %w", err)
60+
} else {
61+
fmt.Fprintln(out, "Installed pre-commit hook (protects main/master)")
4962
}
5063

51-
fmt.Fprintf(cmd.OutOrStdout(), "Installed post-merge hook (branch: %s)\n", branch)
52-
fmt.Fprintf(cmd.OutOrStdout(), " %s\n", hook.Check(hooksDir).HookPath)
5364
return nil
5465
},
5566
}
5667

5768
var hookUninstallCmd = &cobra.Command{
5869
Use: "uninstall",
59-
Short: "Remove the post-merge hook",
70+
Short: "Remove post-merge and pre-commit hooks",
6071
RunE: func(cmd *cobra.Command, args []string) error {
6172
r := newRunner()
6273

@@ -65,22 +76,41 @@ var hookUninstallCmd = &cobra.Command{
6576
return err
6677
}
6778

68-
err = hook.Uninstall(hooksDir)
79+
out := cmd.OutOrStdout()
80+
var uninstalled int
81+
82+
// Uninstall post-merge hook
83+
err = hook.Uninstall(hooksDir, hook.PostMergeHook)
6984
if errors.Is(err, hook.ErrNotInstalled) {
70-
return errors.New("rimba post-merge hook is not installed")
85+
// skip silently
86+
} else if err != nil {
87+
return fmt.Errorf("uninstall post-merge hook: %w", err)
88+
} else {
89+
fmt.Fprintln(out, "Uninstalled rimba post-merge hook.")
90+
uninstalled++
7191
}
72-
if err != nil {
73-
return err
92+
93+
// Uninstall pre-commit hook
94+
err = hook.Uninstall(hooksDir, hook.PreCommitHook)
95+
if errors.Is(err, hook.ErrNotInstalled) {
96+
// skip silently
97+
} else if err != nil {
98+
return fmt.Errorf("uninstall pre-commit hook: %w", err)
99+
} else {
100+
fmt.Fprintln(out, "Uninstalled rimba pre-commit hook.")
101+
uninstalled++
74102
}
75103

76-
fmt.Fprintln(cmd.OutOrStdout(), "Uninstalled rimba post-merge hook.")
104+
if uninstalled == 0 {
105+
return errors.New("rimba hooks are not installed")
106+
}
77107
return nil
78108
},
79109
}
80110

81111
var hookStatusCmd = &cobra.Command{
82112
Use: "status",
83-
Short: "Show post-merge hook status",
113+
Short: "Show hook status",
84114
RunE: func(cmd *cobra.Command, args []string) error {
85115
r := newRunner()
86116

@@ -89,16 +119,32 @@ var hookStatusCmd = &cobra.Command{
89119
return err
90120
}
91121

92-
s := hook.Check(hooksDir)
93-
if s.Installed {
94-
fmt.Fprintln(cmd.OutOrStdout(), "Rimba post-merge hook is installed.")
95-
fmt.Fprintf(cmd.OutOrStdout(), " %s\n", s.HookPath)
96-
if s.HasOther {
97-
fmt.Fprintln(cmd.OutOrStdout(), " (hook file also contains other content)")
122+
out := cmd.OutOrStdout()
123+
124+
// Post-merge status
125+
pm := hook.Check(hooksDir, hook.PostMergeHook)
126+
if pm.Installed {
127+
fmt.Fprintln(out, "Rimba post-merge hook is installed.")
128+
fmt.Fprintf(out, " %s\n", pm.HookPath)
129+
if pm.HasOther {
130+
fmt.Fprintln(out, " (hook file also contains other content)")
131+
}
132+
} else {
133+
fmt.Fprintln(out, "Rimba post-merge hook is not installed.")
134+
}
135+
136+
// Pre-commit status
137+
pc := hook.Check(hooksDir, hook.PreCommitHook)
138+
if pc.Installed {
139+
fmt.Fprintln(out, "Rimba pre-commit hook is installed.")
140+
fmt.Fprintf(out, " %s\n", pc.HookPath)
141+
if pc.HasOther {
142+
fmt.Fprintln(out, " (hook file also contains other content)")
98143
}
99144
} else {
100-
fmt.Fprintln(cmd.OutOrStdout(), "Rimba post-merge hook is not installed.")
145+
fmt.Fprintln(out, "Rimba pre-commit hook is not installed.")
101146
}
147+
102148
return nil
103149
},
104150
}

cmd/hook_test.go

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ func TestHookInstallSuccess(t *testing.T) {
4545
if !strings.Contains(out, "Installed post-merge hook") {
4646
t.Errorf("output = %q, want 'Installed post-merge hook'", out)
4747
}
48+
if !strings.Contains(out, "Installed pre-commit hook") {
49+
t.Errorf("output = %q, want 'Installed pre-commit hook'", out)
50+
}
4851
}
4952

5053
func TestHookInstallAlreadyInstalled(t *testing.T) {
@@ -53,7 +56,8 @@ func TestHookInstallAlreadyInstalled(t *testing.T) {
5356
_ = config.Save(filepath.Join(repoDir, config.FileName), cfg)
5457

5558
hooksDir := filepath.Join(repoDir, ".git", "hooks")
56-
_ = hook.Install(hooksDir, branchMain)
59+
_ = hook.Install(hooksDir, hook.PostMergeHook, hook.PostMergeBlock(branchMain))
60+
_ = hook.Install(hooksDir, hook.PreCommitHook, hook.PreCommitBlock())
5761

5862
r := hookTestRunner(repoDir)
5963
restore := overrideNewRunner(r)
@@ -64,16 +68,21 @@ func TestHookInstallAlreadyInstalled(t *testing.T) {
6468
if err != nil {
6569
t.Fatalf("hookInstallCmd.RunE: %v", err)
6670
}
67-
if !strings.Contains(buf.String(), "already installed") {
68-
t.Errorf("output = %q, want 'already installed'", buf.String())
71+
out := buf.String()
72+
if !strings.Contains(out, "post-merge hook is already installed") {
73+
t.Errorf("output = %q, want 'post-merge hook is already installed'", out)
74+
}
75+
if !strings.Contains(out, "pre-commit hook is already installed") {
76+
t.Errorf("output = %q, want 'pre-commit hook is already installed'", out)
6977
}
7078
}
7179

7280
func TestHookUninstallSuccess(t *testing.T) {
7381
repoDir := t.TempDir()
7482

7583
hooksDir := filepath.Join(repoDir, ".git", "hooks")
76-
_ = hook.Install(hooksDir, branchMain)
84+
_ = hook.Install(hooksDir, hook.PostMergeHook, hook.PostMergeBlock(branchMain))
85+
_ = hook.Install(hooksDir, hook.PreCommitHook, hook.PreCommitBlock())
7786

7887
r := hookTestRunner(repoDir)
7988
restore := overrideNewRunner(r)
@@ -84,8 +93,12 @@ func TestHookUninstallSuccess(t *testing.T) {
8493
if err != nil {
8594
t.Fatalf("hookUninstallCmd.RunE: %v", err)
8695
}
87-
if !strings.Contains(buf.String(), "Uninstalled") {
88-
t.Errorf("output = %q, want 'Uninstalled'", buf.String())
96+
out := buf.String()
97+
if !strings.Contains(out, "Uninstalled rimba post-merge hook") {
98+
t.Errorf("output = %q, want 'Uninstalled rimba post-merge hook'", out)
99+
}
100+
if !strings.Contains(out, "Uninstalled rimba pre-commit hook") {
101+
t.Errorf("output = %q, want 'Uninstalled rimba pre-commit hook'", out)
89102
}
90103
}
91104

@@ -110,7 +123,8 @@ func TestHookStatusInstalled(t *testing.T) {
110123
repoDir := t.TempDir()
111124

112125
hooksDir := filepath.Join(repoDir, ".git", "hooks")
113-
_ = hook.Install(hooksDir, branchMain)
126+
_ = hook.Install(hooksDir, hook.PostMergeHook, hook.PostMergeBlock(branchMain))
127+
_ = hook.Install(hooksDir, hook.PreCommitHook, hook.PreCommitBlock())
114128

115129
r := hookTestRunner(repoDir)
116130
restore := overrideNewRunner(r)
@@ -121,8 +135,12 @@ func TestHookStatusInstalled(t *testing.T) {
121135
if err != nil {
122136
t.Fatalf("hookStatusCmd.RunE: %v", err)
123137
}
124-
if !strings.Contains(buf.String(), "is installed") {
125-
t.Errorf("output = %q, want 'is installed'", buf.String())
138+
out := buf.String()
139+
if !strings.Contains(out, "post-merge hook is installed") {
140+
t.Errorf("output = %q, want 'post-merge hook is installed'", out)
141+
}
142+
if !strings.Contains(out, "pre-commit hook is installed") {
143+
t.Errorf("output = %q, want 'pre-commit hook is installed'", out)
126144
}
127145
}
128146

@@ -138,7 +156,11 @@ func TestHookStatusNotInstalled(t *testing.T) {
138156
if err != nil {
139157
t.Fatalf("hookStatusCmd.RunE: %v", err)
140158
}
141-
if !strings.Contains(buf.String(), "not installed") {
142-
t.Errorf("output = %q, want 'not installed'", buf.String())
159+
out := buf.String()
160+
if !strings.Contains(out, "post-merge hook is not installed") {
161+
t.Errorf("output = %q, want 'post-merge hook is not installed'", out)
162+
}
163+
if !strings.Contains(out, "pre-commit hook is not installed") {
164+
t.Errorf("output = %q, want 'pre-commit hook is not installed'", out)
143165
}
144166
}

internal/hook/hook.go

Lines changed: 35 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,27 +9,30 @@ import (
99
)
1010

1111
const (
12-
BeginMarker = "# BEGIN RIMBA HOOK"
13-
EndMarker = "# END RIMBA HOOK"
14-
HookName = "post-merge"
15-
shebang = "#!/bin/sh"
16-
fileMode = 0755
12+
// Markers delimit the rimba-managed block within a hook file.
13+
// Each hook type lives in its own file, so shared markers do not collide.
14+
BeginMarker = "# BEGIN RIMBA HOOK"
15+
EndMarker = "# END RIMBA HOOK"
16+
PostMergeHook = "post-merge"
17+
PreCommitHook = "pre-commit"
18+
shebang = "#!/bin/sh"
19+
fileMode = 0755
1720
)
1821

1922
var (
2023
ErrAlreadyInstalled = errors.New("rimba hook is already installed")
2124
ErrNotInstalled = errors.New("rimba hook is not installed")
2225
)
2326

24-
// Status describes the current state of the post-merge hook.
27+
// Status describes the current state of a hook.
2528
type Status struct {
2629
Installed bool
2730
HookPath string
2831
HasOther bool // true if hook file has non-rimba content
2932
}
3033

31-
// HookBlock returns the marker-delimited block with the branch guard embedded.
32-
func HookBlock(branch string) string {
34+
// PostMergeBlock returns the marker-delimited block with the branch guard embedded.
35+
func PostMergeBlock(branch string) string {
3336
//nolint:dupword // shell script has two "fi" closings
3437
return fmt.Sprintf(`%s
3538
# Installed by rimba — do not edit this block manually
@@ -42,13 +45,13 @@ fi
4245
%s`, BeginMarker, branch, EndMarker)
4346
}
4447

45-
// Install creates or appends the rimba hook block to the post-merge hook file.
46-
func Install(hooksDir, branch string) error {
48+
// Install creates or appends the rimba hook block to the given hook file.
49+
func Install(hooksDir, hookName, block string) error {
4750
if err := os.MkdirAll(hooksDir, 0750); err != nil { //nolint:gosec // hooks dir needs exec bit for git
4851
return fmt.Errorf("create hooks directory: %w", err)
4952
}
5053

51-
hookPath := filepath.Join(hooksDir, HookName)
54+
hookPath := filepath.Join(hooksDir, hookName)
5255
existing, err := os.ReadFile(hookPath)
5356
if err != nil && !os.IsNotExist(err) {
5457
return fmt.Errorf("read hook file: %w", err)
@@ -59,7 +62,6 @@ func Install(hooksDir, branch string) error {
5962
return ErrAlreadyInstalled
6063
}
6164

62-
block := HookBlock(branch)
6365
var newContent string
6466
if content == "" {
6567
newContent = shebang + "\n\n" + block + "\n"
@@ -74,9 +76,9 @@ func Install(hooksDir, branch string) error {
7476
return nil
7577
}
7678

77-
// Uninstall removes the rimba hook block from the post-merge hook file.
78-
func Uninstall(hooksDir string) error {
79-
hookPath := filepath.Join(hooksDir, HookName)
79+
// Uninstall removes the rimba hook block from the given hook file.
80+
func Uninstall(hooksDir, hookName string) error {
81+
hookPath := filepath.Join(hooksDir, hookName)
8082
existing, err := os.ReadFile(hookPath)
8183
if err != nil {
8284
if os.IsNotExist(err) {
@@ -104,9 +106,9 @@ func Uninstall(hooksDir string) error {
104106
return nil
105107
}
106108

107-
// Check inspects the current state of the post-merge hook.
108-
func Check(hooksDir string) Status {
109-
hookPath := filepath.Join(hooksDir, HookName)
109+
// Check inspects the current state of the given hook.
110+
func Check(hooksDir, hookName string) Status {
111+
hookPath := filepath.Join(hooksDir, hookName)
110112
existing, err := os.ReadFile(hookPath)
111113
if err != nil {
112114
return Status{HookPath: hookPath}
@@ -130,6 +132,21 @@ func Check(hooksDir string) Status {
130132
}
131133
}
132134

135+
// PreCommitBlock returns the marker-delimited block that prevents direct commits
136+
// to main/master. Both branch names are hardcoded because the protection should
137+
// apply regardless of which default branch the repository uses.
138+
func PreCommitBlock() string {
139+
return fmt.Sprintf(`%s
140+
# Installed by rimba — do not edit this block manually
141+
_rimba_branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)
142+
if [ "$_rimba_branch" = "main" ] || [ "$_rimba_branch" = "master" ]; then
143+
echo "rimba: direct commits to $_rimba_branch are not allowed."
144+
echo " Use 'rimba add <task>' to create a worktree branch."
145+
exit 1
146+
fi
147+
%s`, BeginMarker, EndMarker)
148+
}
149+
133150
func containsBlock(content string) bool {
134151
return strings.Contains(content, BeginMarker) && strings.Contains(content, EndMarker)
135152
}

0 commit comments

Comments
 (0)