diff --git a/cmd/init_test.go b/cmd/init_test.go index a3d8846..6c71fb6 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -90,7 +90,7 @@ func TestWriteReadmeSkipsExisting(t *testing.T) { func TestBootstrapNonInteractive(t *testing.T) { dir := t.TempDir() - r := &git.Runner{Dir: dir, Debug: false, Verify: true} + r := &git.Runner{Dir: dir, Debug: false} r.Init() r.RunGit("config", "user.email", "test@test.com") r.RunGit("config", "user.name", "Test") @@ -106,7 +106,7 @@ func TestBootstrapNonInteractive(t *testing.T) { func TestBootstrapNonInteractiveWithTrunk(t *testing.T) { dir := t.TempDir() - r := &git.Runner{Dir: dir, Debug: false, Verify: true} + r := &git.Runner{Dir: dir, Debug: false} r.Init() r.RunGit("config", "user.email", "test@test.com") r.RunGit("config", "user.name", "Test") @@ -130,7 +130,7 @@ func TestBootstrapNonInteractiveWithTrunk(t *testing.T) { func TestApplyFormResultCreatesFiles(t *testing.T) { dir := t.TempDir() - r := &git.Runner{Dir: dir, Debug: false, Verify: true} + r := &git.Runner{Dir: dir, Debug: false} r.Init() r.RunGit("config", "user.email", "test@test.com") r.RunGit("config", "user.name", "Test") @@ -157,7 +157,7 @@ func TestApplyFormResultCreatesFiles(t *testing.T) { func TestApplyFormResultNoFiles(t *testing.T) { dir := t.TempDir() - r := &git.Runner{Dir: dir, Debug: false, Verify: true} + r := &git.Runner{Dir: dir, Debug: false} r.Init() r.RunGit("config", "user.email", "test@test.com") r.RunGit("config", "user.name", "Test") @@ -178,7 +178,7 @@ func TestApplyFormResultNoFiles(t *testing.T) { func TestApplyFormResultSetsConfig(t *testing.T) { dir := t.TempDir() - r := &git.Runner{Dir: dir, Debug: false, Verify: true} + r := &git.Runner{Dir: dir, Debug: false} r.Init() r.RunGit("config", "user.email", "test@test.com") r.RunGit("config", "user.name", "Test") @@ -204,7 +204,7 @@ func TestApplyFormResultSetsConfig(t *testing.T) { func TestApplyFormResultAddsRemotes(t *testing.T) { dir := t.TempDir() - r := &git.Runner{Dir: dir, Debug: false, Verify: true} + r := &git.Runner{Dir: dir, Debug: false} r.Init() r.RunGit("config", "user.email", "test@test.com") r.RunGit("config", "user.name", "Test") @@ -230,7 +230,7 @@ func TestApplyFormResultAddsRemotes(t *testing.T) { func TestApplyFormResultCustomBranch(t *testing.T) { dir := t.TempDir() - r := &git.Runner{Dir: dir, Debug: false, Verify: true} + r := &git.Runner{Dir: dir, Debug: false} r.Init() r.RunGit("config", "user.email", "test@test.com") r.RunGit("config", "user.name", "Test") diff --git a/cmd/root.go b/cmd/root.go index 37cfec5..11c06fe 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -60,7 +60,7 @@ var rootCmd = &cobra.Command{ return err } ctx.Quiet = flagQuiet - ctx.Git.Verify = flagVerify + ctx.Git.NoVerify = !flagVerify return nil }, SilenceUsage: true, @@ -72,7 +72,7 @@ func init() { rootCmd.PersistentFlags().BoolVar(&flagDebug, "debug", false, "print git commands as they run") rootCmd.PersistentFlags().BoolVar(&flagInteractive, "interactive", true, "enable interactive prompts") rootCmd.PersistentFlags().BoolVarP(&flagQuiet, "quiet", "q", false, "suppress non-essential output") - rootCmd.PersistentFlags().BoolVar(&flagVerify, "verify", true, "run git hooks (use --no-verify to skip)") + rootCmd.PersistentFlags().BoolVar(&flagVerify, "verify", true, "run git hooks (use --verify=false to skip)") } // Execute runs the root command. diff --git a/cmd/submit.go b/cmd/submit.go index 2999257..9790d35 100644 --- a/cmd/submit.go +++ b/cmd/submit.go @@ -13,6 +13,11 @@ var submitCmd = &cobra.Command{ if err := ctx.RequireInit(); err != nil { return err } + // A local override of the global --verify: pushed straight to the git + // runner so every git command this submit runs gets --no-verify. + if submitFlagNoVerify { + ctx.Git.NoVerify = true + } return engine.Submit(ctx, engine.SubmitOpts{ Draft: submitFlagDraft, Stack: submitFlagStack, @@ -37,6 +42,7 @@ var ( submitFlagForce bool submitFlagNoForce bool submitFlagDryRun bool + submitFlagNoVerify bool submitFlagTitle string submitFlagBody string submitFlagBodyFile string @@ -66,6 +72,8 @@ func init() { "never force-push; fail instead") submitCmd.MarkFlagsMutuallyExclusive("force", "no-force") submitCmd.Flags().BoolVar(&submitFlagDryRun, "dry-run", false, "show what would be pushed, changing nothing") + submitCmd.Flags().BoolVar(&submitFlagNoVerify, "no-verify", false, + "skip git hooks (passes --no-verify to git push)") submitCmd.Flags().StringVar(&submitFlagTitle, "title", "", "PR title (skips interactive prompts)") submitCmd.Flags().StringVar(&submitFlagBody, "body", "", "PR body (used with --title)") submitCmd.Flags().StringVar(&submitFlagBodyFile, "body-file", "", "read PR body from file (used with --title)") diff --git a/internal/engine/create.go b/internal/engine/create.go index 0adf5a7..dac9f7e 100644 --- a/internal/engine/create.go +++ b/internal/engine/create.go @@ -78,7 +78,7 @@ func Create(c *context.Context, opts CreateOpts) error { commitOpts := git.CommitOpts{} if opts.NoVerify { - // Runner.Verify controls --no-verify; set it via the flag. + // Runner.NoVerify controls --no-verify; set it via the flag. } if err := c.Git.Commit(opts.Message, commitOpts); err != nil { return err diff --git a/internal/git/commit.go b/internal/git/commit.go index 57dc1f7..5a8b241 100644 --- a/internal/git/commit.go +++ b/internal/git/commit.go @@ -21,7 +21,7 @@ func (r *Runner) Commit(msg string, opts CommitOpts) error { if opts.Edit { args = append(args, "--edit") } - if !r.Verify { + if r.NoVerify { args = append(args, "--no-verify") } return r.RunGit(args...) diff --git a/internal/git/git.go b/internal/git/git.go index a794205..9651e98 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -15,7 +15,7 @@ type Runner struct { Dir string // Working directory for git commands Env []string Debug bool - Verify bool // Pass --no-verify when false (for hooks) + NoVerify bool // Pass --no-verify to git (skip hooks); the zero value runs hooks } // RunGit executes a git command, forwarding stdout/stderr to the terminal. diff --git a/internal/git/rebase.go b/internal/git/rebase.go index aeff8c8..ccef5c3 100644 --- a/internal/git/rebase.go +++ b/internal/git/rebase.go @@ -3,7 +3,7 @@ package git // Rebase rebases the current branch onto target. func (r *Runner) Rebase(onto string) error { args := []string{"rebase", onto} - if !r.Verify { + if r.NoVerify { args = append(args, "--no-verify") } return r.RunGit(args...) @@ -12,7 +12,7 @@ func (r *Runner) Rebase(onto string) error { // RebaseOnto performs `git rebase --onto newBase oldBase branch`. func (r *Runner) RebaseOnto(newBase, oldBase, branch string) error { args := []string{"rebase", "--onto", newBase, oldBase, branch} - if !r.Verify { + if r.NoVerify { args = append(args, "--no-verify") } return r.RunGit(args...) diff --git a/internal/git/remote.go b/internal/git/remote.go index 2027a7e..350a529 100644 --- a/internal/git/remote.go +++ b/internal/git/remote.go @@ -59,6 +59,9 @@ func (r *Runner) PushPinned(remote, branch, expectSHA string, setUpstream bool) if setUpstream { args = append(args, "-u") } + if r.NoVerify { + args = append(args, "--no-verify") + } // An explicit refspec keeps the destination independent of push.default and // of whether an upstream is configured; the lease is a pure server-side // compare-and-swap and needs no remote-tracking ref to work. diff --git a/internal/git/remote_test.go b/internal/git/remote_test.go index 7b94760..f917b7a 100644 --- a/internal/git/remote_test.go +++ b/internal/git/remote_test.go @@ -1,6 +1,7 @@ package git import ( + "os" "path/filepath" "strings" "testing" @@ -124,6 +125,41 @@ func TestPushPinned_EmptyExpectCreatesBranchAndRejectsOnceItExists(t *testing.T) } } +// The zero-value Runner must run hooks: skipping them has to be an explicit +// opt-in (NoVerify), never something a forgotten field silently grants. +func TestPushPinned_NoVerifyControlsPrePushHook(t *testing.T) { + local, _, _ := newRepoWithRemote(t) + branch, _ := local.CurrentBranch() + + hookDir, err := local.RunGitCapture("rev-parse", "--git-path", "hooks") + if err != nil { + t.Fatalf("locate hooks dir: %v", err) + } + if !filepath.IsAbs(hookDir) { + hookDir = filepath.Join(local.Dir, hookDir) + } + if err := os.MkdirAll(hookDir, 0o755); err != nil { + t.Fatal(err) + } + hook := filepath.Join(hookDir, "pre-push") + if err := os.WriteFile(hook, []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil { + t.Fatal(err) + } + + inspected, _ := local.RevParse("refs/remotes/origin/" + branch) + commitFile(t, local, "b.txt", "two\n", "c2") + + // NoVerify deliberately left at its zero value: hooks must run. + if err := local.PushPinned("origin", branch, inspected, true); err == nil { + t.Fatal("push must fail while the pre-push hook rejects and NoVerify is unset") + } + + local.NoVerify = true + if err := local.PushPinned("origin", branch, inspected, true); err != nil { + t.Fatalf("NoVerify must pass --no-verify and skip the hook: %v", err) + } +} + func TestPushPinned_ForcePushOverOurOwnRewriteSucceeds(t *testing.T) { local, _, _ := newRepoWithRemote(t) branch, _ := local.CurrentBranch()