Repository: git-town/git-town

Issue:
compress should not run pre-commit hook
When we use compress, it is just to squash the commits, no new changes are being made. So this new squash commit should not run the pre-commit hook again.

It can do --no-verify when it commits it. 

Discussion:
@kevgo: Thanks for reaching out about this, good idea. @kevgo: This is now available as part of [Git Town 20.1](https://github.com/git-town/git-town/releases/tag/v20.1.0).

Solution patch:
diff --git a/features/compress/current_branch/no_verify/enabled.feature b/features/compress/current_branch/no_verify/enabled.feature
new file mode 100644
index 000000000..a5b97eac3
--- /dev/null
+++ b/features/compress/current_branch/no_verify/enabled.feature
@@ -0,0 +1,36 @@
+Feature: compress while disabling the pre-commit hook
+
+  Background:
+    Given a Git repo with origin
+    And the branches
+      | NAME    | TYPE    | PARENT | LOCATIONS     |
+      | feature | feature | main   | local, origin |
+    And the commits
+      | BRANCH  | LOCATION      | MESSAGE  | FILE NAME | FILE CONTENT |
+      | feature | local, origin | commit 1 | file_1    | content 1    |
+      |         |               | commit 2 | file_2    | content 2    |
+    And the current branch is "feature"
+    When I run "git-town compress --no-verify"
+
+  Scenario: result
+    Then Git Town runs the commands
+      | BRANCH  | COMMAND                                         |
+      | feature | git fetch --prune --tags                        |
+      |         | git reset --soft main                           |
+      |         | git commit -m "commit 1" --no-verify            |
+      |         | git push --force-with-lease --force-if-includes |
+    And all branches are now synchronized
+    And these commits exist now
+      | BRANCH  | LOCATION      | MESSAGE  |
+      | feature | local, origin | commit 1 |
+    And file "file_1" still has content "content 1"
+    And file "file_2" still has content "content 2"
+
+  Scenario: undo
+    When I run "git-town undo"
+    Then Git Town runs the commands
+      | BRANCH  | COMMAND                                         |
+      | feature | git reset --hard {{ sha 'commit 2' }}           |
+      |         | git push --force-with-lease --force-if-includes |
+    And the initial commits exist now
+    And the initial branches and lineage exist now
diff --git a/internal/cli/flags/no_verify.go b/internal/cli/flags/no_verify.go
new file mode 100644
index 000000000..4e73db5c2
--- /dev/null
+++ b/internal/cli/flags/no_verify.go
@@ -0,0 +1,26 @@
+package flags
+
+import (
+	"github.com/git-town/git-town/v20/internal/config/configdomain"
+	"github.com/spf13/cobra"
+)
+
+const noVerifyLong = "no-verify"
+
+// type-safe access to the CLI arguments of type configdomain.CommitHook
+func NoVerify() (AddFunc, ReadNoVerifyFlagFunc) {
+	addFlag := func(cmd *cobra.Command) {
+		cmd.Flags().BoolP(noVerifyLong, "", false, "do not run pre-commit hooks")
+	}
+	readFlag := func(cmd *cobra.Command) (configdomain.CommitHook, error) {
+		value, err := cmd.Flags().GetBool(noVerifyLong)
+		if value {
+			return configdomain.CommitHookDisabled, err
+		}
+		return configdomain.CommitHookEnabled, err
+	}
+	return addFlag, readFlag
+}
+
+// ReadNoVerifyFlagFunc is the type signature for the function that reads the "no-verify" flag from the args to the given Cobra command.
+type ReadNoVerifyFlagFunc func(*cobra.Command) (configdomain.CommitHook, error)
diff --git a/internal/cmd/compress.go b/internal/cmd/compress.go
index d71c627c2..089975a18 100644
--- a/internal/cmd/compress.go
+++ b/internal/cmd/compress.go
@@ -62,6 +62,7 @@ commit 1
 func compressCmd() *cobra.Command {
 	addDryRunFlag, readDryRunFlag := flags.DryRun()
 	addMessageFlag, readMessageFlag := flags.CommitMessage("customize the commit message")
+	addNoVerifyFlag, readNoVerifyFlag := flags.NoVerify()
 	addStackFlag, readStackFlag := flags.Stack("Compress the entire stack")
 	addVerboseFlag, readVerboseFlag := flags.Verbose()
 	cmd := cobra.Command{
@@ -78,6 +79,10 @@ func compressCmd() *cobra.Command {
 			if err != nil {
 				return err
 			}
+			commitHook, err := readNoVerifyFlag(cmd)
+			if err != nil {
+				return err
+			}
 			stack, err := readStackFlag(cmd)
 			if err != nil {
 				return err
@@ -86,17 +91,18 @@ func compressCmd() *cobra.Command {
 			if err != nil {
 				return err
 			}
-			return executeCompress(dryRun, verbose, message, stack)
+			return executeCompress(dryRun, verbose, message, commitHook, stack)
 		},
 	}
 	addDryRunFlag(&cmd)
 	addMessageFlag(&cmd)
+	addNoVerifyFlag(&cmd)
 	addStackFlag(&cmd)
 	addVerboseFlag(&cmd)
 	return &cmd
 }
 
-func executeCompress(dryRun configdomain.DryRun, verbose configdomain.Verbose, message Option[gitdomain.CommitMessage], compressEntireStack configdomain.FullStack) error {
+func executeCompress(dryRun configdomain.DryRun, verbose configdomain.Verbose, message Option[gitdomain.CommitMessage], commitHook configdomain.CommitHook, compressEntireStack configdomain.FullStack) error {
 	repo, err := execute.OpenRepo(execute.OpenRepoArgs{
 		DryRun:           dryRun,
 		PrintBranchNames: true,
@@ -112,7 +118,7 @@ func executeCompress(dryRun configdomain.DryRun, verbose configdomain.Verbose, m
 	if err != nil || exit {
 		return err
 	}
-	runProgram := compressProgram(data)
+	runProgram := compressProgram(data, commitHook)
 	runState := runstate.RunState{
 		BeginBranchesSnapshot: data.branchesSnapshot,
 		BeginConfigSnapshot:   repo.ConfigSnapshot,
@@ -295,10 +301,10 @@ func determineCompressBranchesData(repo execute.OpenRepoResult, dryRun configdom
 	}, false, nil
 }
 
-func compressProgram(data compressBranchesData) program.Program {
+func compressProgram(data compressBranchesData, commitHook configdomain.CommitHook) program.Program {
 	prog := NewMutable(&program.Program{})
 	for _, branchToCompress := range data.branchesToCompress {
-		compressBranchProgram(prog, branchToCompress, data.config.NormalConfig.Online(), data.initialBranch)
+		compressBranchProgram(prog, branchToCompress, data.config.NormalConfig.Online(), data.initialBranch, commitHook)
 	}
 	prog.Value.Add(&opcodes.CheckoutIfNeeded{Branch: data.initialBranch})
 	previousBranchCandidates := []Option[gitdomain.LocalBranchName]{data.previousBranch}
@@ -311,7 +317,7 @@ func compressProgram(data compressBranchesData) program.Program {
 	return optimizer.Optimize(prog.Immutable())
 }
 
-func compressBranchProgram(prog Mutable[program.Program], data compressBranchData, online configdomain.Online, initialBranch gitdomain.LocalBranchName) {
+func compressBranchProgram(prog Mutable[program.Program], data compressBranchData, online configdomain.Online, initialBranch gitdomain.LocalBranchName, commitHook configdomain.CommitHook) {
 	if !shouldCompressBranch(data.name, data.branchType, initialBranch) {
 		return
 	}
@@ -319,6 +325,7 @@ func compressBranchProgram(prog Mutable[program.Program], data compressBranchDat
 	prog.Value.Add(&opcodes.BranchCurrentReset{Base: data.parentBranch.BranchName()})
 	prog.Value.Add(&opcodes.CommitWithMessage{
 		AuthorOverride: None[gitdomain.Author](),
+		CommitHook:     commitHook,
 		Message:        data.newCommitMessage,
 	})
 	if data.hasTracking && online.IsTrue() {
diff --git a/internal/cmd/delete.go b/internal/cmd/delete.go
index 01648d54d..7a2753c01 100644
--- a/internal/cmd/delete.go
+++ b/internal/cmd/delete.go
@@ -312,6 +312,7 @@ func deleteLocalBranch(prog, finalUndoProgram Mutable[program.Program], data del
 				prog.Value.Add(&opcodes.ChangesStage{})
 				prog.Value.Add(&opcodes.CommitWithMessage{
 					AuthorOverride: None[gitdomain.Author](),
+					CommitHook:     configdomain.CommitHookEnabled,
 					Message:        "Committing open changes on deleted branch",
 				})
 				// update the registered initial SHA for this branch so that undo restores the just committed changes
diff --git a/internal/cmd/sync/sync_feature_branch.go b/internal/cmd/sync/sync_feature_branch.go
index 962b70a9b..23abe27a3 100644
--- a/internal/cmd/sync/sync_feature_branch.go
+++ b/internal/cmd/sync/sync_feature_branch.go
@@ -79,6 +79,7 @@ func FeatureTrackingBranchProgram(trackingBranch gitdomain.RemoteBranchName, syn
 			args.Program.Value.Add(&opcodes.BranchCurrentResetToParent{CurrentBranch: args.LocalName})
 			args.Program.Value.Add(&opcodes.CommitWithMessage{
 				AuthorOverride: None[gitdomain.Author](),
+				CommitHook:     configdomain.CommitHookEnabled,
 				Message:        firstCommitMessage,
 			})
 		}
diff --git a/internal/config/configdomain/commit_hook.go b/internal/config/configdomain/commit_hook.go
new file mode 100644
index 000000000..825992e26
--- /dev/null
+++ b/internal/config/configdomain/commit_hook.go
@@ -0,0 +1,17 @@
+package configdomain
+
+import (
+	"strconv"
+)
+
+// PushHook contains the push-hook configuration setting.
+type CommitHook bool
+
+const (
+	CommitHookEnabled  CommitHook = true
+	CommitHookDisabled CommitHook = false
+)
+
+func (self CommitHook) String() string {
+	return strconv.FormatBool(bool(self))
+}
diff --git a/internal/git/commands.go b/internal/git/commands.go
index 3e1864e98..e7754757f 100644
--- a/internal/git/commands.go
+++ b/internal/git/commands.go
@@ -236,7 +236,7 @@ func (self *Commands) CommentOutSquashCommitMessage(prefix Option[string]) error
 	return os.WriteFile(squashMessageFile, []byte(content), 0o600)
 }
 
-func (self *Commands) Commit(runner gitdomain.Runner, useMessage configdomain.UseMessage, author Option[gitdomain.Author]) error {
+func (self *Commands) Commit(runner gitdomain.Runner, useMessage configdomain.UseMessage, author Option[gitdomain.Author], commitHook configdomain.CommitHook) error {
 	args := []string{"commit"}
 	switch {
 	case useMessage.IsCustomMessage():
@@ -252,6 +252,11 @@ func (self *Commands) Commit(runner gitdomain.Runner, useMessage configdomain.Us
 	if author, hasAuthor := author.Get(); hasAuthor {
 		args = append(args, "--author", author.String())
 	}
+	switch commitHook {
+	case configdomain.CommitHookDisabled:
+		args = append(args, "--no-verify")
+	case configdomain.CommitHookEnabled:
+	}
 	return runner.Run("git", args...)
 }
 
diff --git a/internal/undo/undo_finished_program.go b/internal/undo/undo_finished_program.go
index bacc6482a..afba4ff17 100644
--- a/internal/undo/undo_finished_program.go
+++ b/internal/undo/undo_finished_program.go
@@ -2,6 +2,7 @@ package undo
 
 import (
 	"github.com/git-town/git-town/v20/internal/cmd/cmdhelpers"
+	"github.com/git-town/git-town/v20/internal/config/configdomain"
 	"github.com/git-town/git-town/v20/internal/git/gitdomain"
 	"github.com/git-town/git-town/v20/internal/undo/undobranches"
 	"github.com/git-town/git-town/v20/internal/undo/undoconfig"
@@ -21,6 +22,7 @@ func CreateUndoForFinishedProgram(args CreateUndoProgramArgs) program.Program {
 		result.Value.Add(&opcodes.ChangesStage{})
 		result.Value.Add(&opcodes.CommitWithMessage{
 			AuthorOverride: None[gitdomain.Author](),
+			CommitHook:     configdomain.CommitHookEnabled,
 			Message:        "Committing open changes to undo them",
 		})
 	}
diff --git a/internal/vm/opcodes/commit.go b/internal/vm/opcodes/commit.go
index 6c221b259..8b3770488 100644
--- a/internal/vm/opcodes/commit.go
+++ b/internal/vm/opcodes/commit.go
@@ -19,5 +19,5 @@ type Commit struct {
 }
 
 func (self *Commit) Run(args shared.RunArgs) error {
-	return args.Git.Commit(args.Frontend, configdomain.UseMessageWithFallbackToDefault(self.Message, self.FallbackToDefaultCommitMessage), self.AuthorOverride)
+	return args.Git.Commit(args.Frontend, configdomain.UseMessageWithFallbackToDefault(self.Message, self.FallbackToDefaultCommitMessage), self.AuthorOverride, configdomain.CommitHookEnabled)
 }
diff --git a/internal/vm/opcodes/commit_auto_undo.go b/internal/vm/opcodes/commit_auto_undo.go
index 58eccb58c..ef5b6b6b6 100644
--- a/internal/vm/opcodes/commit_auto_undo.go
+++ b/internal/vm/opcodes/commit_auto_undo.go
@@ -29,7 +29,7 @@ func (self *CommitAutoUndo) AutomaticUndoError() error {
 }
 
 func (self *CommitAutoUndo) Run(args shared.RunArgs) error {
-	return args.Git.Commit(args.Frontend, configdomain.UseMessageWithFallbackToDefault(self.Message, self.FallbackToDefaultCommitMessage), self.AuthorOverride)
+	return args.Git.Commit(args.Frontend, configdomain.UseMessageWithFallbackToDefault(self.Message, self.FallbackToDefaultCommitMessage), self.AuthorOverride, configdomain.CommitHookEnabled)
 }
 
 func (self *CommitAutoUndo) ShouldUndoOnError() bool {
diff --git a/internal/vm/opcodes/commit_with_message.go b/internal/vm/opcodes/commit_with_message.go
index df85d65a3..1dafac8a7 100644
--- a/internal/vm/opcodes/commit_with_message.go
+++ b/internal/vm/opcodes/commit_with_message.go
@@ -10,10 +10,11 @@ import (
 // CommitWithMessage commits all open changes using the given commit message.
 type CommitWithMessage struct {
 	AuthorOverride          Option[gitdomain.Author]
+	CommitHook              configdomain.CommitHook
 	Message                 gitdomain.CommitMessage
 	undeclaredOpcodeMethods `exhaustruct:"optional"`
 }
 
 func (self *CommitWithMessage) Run(args shared.RunArgs) error {
-	return args.Git.Commit(args.Frontend, configdomain.UseCustomMessage(self.Message), self.AuthorOverride)
+	return args.Git.Commit(args.Frontend, configdomain.UseCustomMessage(self.Message), self.AuthorOverride, self.CommitHook)
 }
diff --git a/website/src/commands/compress.md b/website/src/commands/compress.md
index 9ddf9576f..373f850bd 100644
--- a/website/src/commands/compress.md
+++ b/website/src/commands/compress.md
@@ -74,6 +74,11 @@ compressed commit
 The new `compressed commit` now contains the changes from the old `commit 1`,
 `commit 2`, and `commit 3`.
 
+#### `--no-verify`
+
+Disables the
+[pre-commit hook](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks).
+
 #### `-s`<br>`--stack`
 
 To compress all branches in a [stack](../stacked-changes.md) provide the



Test patch:
diff --git a/internal/test/cucumber/steps.go b/internal/test/cucumber/steps.go
index dc344d645..482279860 100644
--- a/internal/test/cucumber/steps.go
+++ b/internal/test/cucumber/steps.go
@@ -919,7 +919,7 @@ func defineSteps(sc *godog.ScenarioContext) {
 		originRepo.CheckoutBranch("main")
 		asserts.NoError(originRepo.Git.SquashMerge(originRepo.TestRunner, branchToShip))
 		originRepo.StageFiles("-A")
-		asserts.NoError(originRepo.Git.Commit(originRepo.TestRunner, configdomain.UseCustomMessage(message), gitdomain.NewAuthorOpt("CI <ci@acme.com>")))
+		asserts.NoError(originRepo.Git.Commit(originRepo.TestRunner, configdomain.UseCustomMessage(message), gitdomain.NewAuthorOpt("CI <ci@acme.com>"), configdomain.CommitHookEnabled))
 		originRepo.RemoveBranch(branchToShip)
 		originRepo.CheckoutBranch("initial")
 		return nil
@@ -936,7 +936,7 @@ func defineSteps(sc *godog.ScenarioContext) {
 		}
 		originRepo.CreateFile(fileName, fileContent)
 		originRepo.StageFiles("-A")
-		asserts.NoError(originRepo.Git.Commit(originRepo.TestRunner, configdomain.UseCustomMessage(gitdomain.CommitMessage(commitMessage)), gitdomain.NewAuthorOpt("CI <ci@acme.com>")))
+		asserts.NoError(originRepo.Git.Commit(originRepo.TestRunner, configdomain.UseCustomMessage(gitdomain.CommitMessage(commitMessage)), gitdomain.NewAuthorOpt("CI <ci@acme.com>"), configdomain.CommitHookEnabled))
 		originRepo.RemoveBranch(branchToShip)
 		originRepo.CheckoutBranch("initial")
 		return nil
@@ -949,7 +949,7 @@ func defineSteps(sc *godog.ScenarioContext) {
 		originRepo.CheckoutBranch("main")
 		asserts.NoError(originRepo.Git.SquashMerge(originRepo.TestRunner, branchToShip))
 		originRepo.StageFiles("-A")
-		asserts.NoError(originRepo.Git.Commit(originRepo.TestRunner, configdomain.UseCustomMessage(gitdomain.CommitMessage(commitMessage)), gitdomain.NewAuthorOpt("CI <ci@acme.com>")))
+		asserts.NoError(originRepo.Git.Commit(originRepo.TestRunner, configdomain.UseCustomMessage(gitdomain.CommitMessage(commitMessage)), gitdomain.NewAuthorOpt("CI <ci@acme.com>"), configdomain.CommitHookEnabled))
 		originRepo.RemoveBranch(branchToShip)
 		originRepo.CheckoutBranch("initial")
 		return nil
diff --git a/internal/vm/statefile/save_load_test.go b/internal/vm/statefile/save_load_test.go
index 33f3234f4..a6645bf72 100644
--- a/internal/vm/statefile/save_load_test.go
+++ b/internal/vm/statefile/save_load_test.go
@@ -79,7 +79,7 @@ func TestLoadSave(t *testing.T) {
 				&opcodes.CommitRemove{SHA: "123456"},
 				&opcodes.CommitRevert{SHA: "123456"},
 				&opcodes.CommitRevertIfNeeded{SHA: "123456"},
-				&opcodes.CommitWithMessage{AuthorOverride: Some(gitdomain.Author("user@acme.com")), Message: "my message"},
+				&opcodes.CommitWithMessage{AuthorOverride: Some(gitdomain.Author("user@acme.com")), Message: "my message", CommitHook: configdomain.CommitHookEnabled},
 				&opcodes.ConfigRemove{Key: configdomain.KeyOffline, Scope: configdomain.ConfigScopeLocal},
 				&opcodes.ConfigSet{Key: configdomain.KeyOffline, Scope: configdomain.ConfigScopeLocal, Value: "1"},
 				&opcodes.ConflictPhantomDetect{ParentBranch: Some(gitdomain.NewLocalBranchName("parent")), ParentSHA: Some(gitdomain.NewSHA("123456"))},
@@ -371,6 +371,7 @@ func TestLoadSave(t *testing.T) {
     {
       "data": {
         "AuthorOverride": "user@acme.com",
+        "CommitHook": true,
         "Message": "my message"
       },
       "type": "CommitWithMessage"