From c9f3f17c8d108a907d253e52d03db559d1ca40f1 Mon Sep 17 00:00:00 2001 From: Adam Mustafa Date: Wed, 5 Aug 2026 11:14:34 -0400 Subject: [PATCH 1/2] feat(submit): add --no-verify to skip git hooks on push --- cmd/root.go | 2 +- cmd/submit.go | 8 ++++++++ internal/git/remote.go | 3 +++ internal/git/remote_test.go | 34 ++++++++++++++++++++++++++++++++++ 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/cmd/root.go b/cmd/root.go index 37cfec5..576e959 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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..42328bc 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.Verify = false + } 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/git/remote.go b/internal/git/remote.go index 2027a7e..291fa86 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.Verify { + 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..3aa8ca2 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,39 @@ func TestPushPinned_EmptyExpectCreatesBranchAndRejectsOnceItExists(t *testing.T) } } +func TestPushPinned_VerifyControlsPrePushHook(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") + + local.Verify = true + if err := local.PushPinned("origin", branch, inspected, true); err == nil { + t.Fatal("push must fail while the pre-push hook rejects and Verify is on") + } + + local.Verify = false + if err := local.PushPinned("origin", branch, inspected, true); err != nil { + t.Fatalf("Verify=false must pass --no-verify and skip the hook: %v", err) + } +} + func TestPushPinned_ForcePushOverOurOwnRewriteSucceeds(t *testing.T) { local, _, _ := newRepoWithRemote(t) branch, _ := local.CurrentBranch() From 0d57b0d6ff3625455b3bd5f8874f9b952c8ca412 Mon Sep 17 00:00:00 2001 From: Adam Mustafa Date: Wed, 19 Aug 2026 10:55:08 -0400 Subject: [PATCH 2/2] fix(git): invert Verify to NoVerify so a zero-value Runner runs hooks --- cmd/init_test.go | 14 +++++++------- cmd/root.go | 2 +- cmd/submit.go | 2 +- internal/engine/create.go | 2 +- internal/git/commit.go | 2 +- internal/git/git.go | 2 +- internal/git/rebase.go | 4 ++-- internal/git/remote.go | 2 +- internal/git/remote_test.go | 12 +++++++----- 9 files changed, 22 insertions(+), 20 deletions(-) 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 576e959..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, diff --git a/cmd/submit.go b/cmd/submit.go index 42328bc..9790d35 100644 --- a/cmd/submit.go +++ b/cmd/submit.go @@ -16,7 +16,7 @@ var submitCmd = &cobra.Command{ // 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.Verify = false + ctx.Git.NoVerify = true } return engine.Submit(ctx, engine.SubmitOpts{ Draft: submitFlagDraft, 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 291fa86..350a529 100644 --- a/internal/git/remote.go +++ b/internal/git/remote.go @@ -59,7 +59,7 @@ func (r *Runner) PushPinned(remote, branch, expectSHA string, setUpstream bool) if setUpstream { args = append(args, "-u") } - if !r.Verify { + if r.NoVerify { args = append(args, "--no-verify") } // An explicit refspec keeps the destination independent of push.default and diff --git a/internal/git/remote_test.go b/internal/git/remote_test.go index 3aa8ca2..f917b7a 100644 --- a/internal/git/remote_test.go +++ b/internal/git/remote_test.go @@ -125,7 +125,9 @@ func TestPushPinned_EmptyExpectCreatesBranchAndRejectsOnceItExists(t *testing.T) } } -func TestPushPinned_VerifyControlsPrePushHook(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() @@ -147,14 +149,14 @@ func TestPushPinned_VerifyControlsPrePushHook(t *testing.T) { inspected, _ := local.RevParse("refs/remotes/origin/" + branch) commitFile(t, local, "b.txt", "two\n", "c2") - local.Verify = true + // 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 Verify is on") + t.Fatal("push must fail while the pre-push hook rejects and NoVerify is unset") } - local.Verify = false + local.NoVerify = true if err := local.PushPinned("origin", branch, inspected, true); err != nil { - t.Fatalf("Verify=false must pass --no-verify and skip the hook: %v", err) + t.Fatalf("NoVerify must pass --no-verify and skip the hook: %v", err) } }