Repository: git-town/git-town

Issue:
ship: support reading the body from a file
From https://github.com/git-town/git-town/discussions/5188#discussioncomment-13811453:

[git town propose](https://www.git-town.com/commands/propose.html?highlight=stdin#-f-path--body-file-path) supports reading the body from a file: 

```
git town propose --body-file /some/file.txt
```

or from STDIN:

```
git town propose -f - 
```

Let's apply a similar approach for the commit message in `git town ship`. 

Hints from issue comments:
@kevgo: Ship doesn't use bodies. So the corresponding flags would be:

- read from STDIN: `git town ship --message-file -`
- read from file: `git town ship --message-file <file>`

Solution patch:
diff --git a/features/ship/squash_merge/current_branch/commit_message/via_file.feature b/features/ship/squash_merge/current_branch/commit_message/via_file.feature
new file mode 100644
index 000000000..365d25381
--- /dev/null
+++ b/features/ship/squash_merge/current_branch/commit_message/via_file.feature
@@ -0,0 +1,52 @@
+Feature: ship the current feature branch with commit message in file
+
+  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        |
+      | feature | local, origin | feature commit |
+    And the current branch is "feature"
+    And Git setting "git-town.ship-strategy" is "squash-merge"
+    And the committed file "body.txt":
+      """
+      Commit message in file
+      """
+    When I run "git-town ship --message-file body.txt"
+
+  Scenario: result
+    Then Git Town runs the commands
+      | BRANCH  | COMMAND                                |
+      | feature | git fetch --prune --tags               |
+      |         | git checkout main                      |
+      | main    | git merge --squash --ff feature        |
+      |         | git commit -m "Commit message in file" |
+      |         | git push                               |
+      |         | git push origin :feature               |
+      |         | git branch -D feature                  |
+    And the branches are now
+      | REPOSITORY    | BRANCHES |
+      | local, origin | main     |
+    And these commits exist now
+      | BRANCH | LOCATION      | MESSAGE                |
+      | main   | local, origin | Commit message in file |
+    And no lineage exists now
+
+  Scenario: undo
+    When I run "git-town undo"
+    Then Git Town runs the commands
+      | BRANCH | COMMAND                                       |
+      | main   | git revert {{ sha 'Commit message in file' }} |
+      |        | git push                                      |
+      |        | git branch feature {{ sha 'persisted file' }} |
+      |        | git push -u origin feature                    |
+      |        | git checkout feature                          |
+    And these commits exist now
+      | BRANCH  | LOCATION      | MESSAGE                         |
+      | main    | local, origin | Commit message in file          |
+      |         |               | Revert "Commit message in file" |
+      | feature | local, origin | feature commit                  |
+      |         |               | persisted file                  |
+    And the initial branches and lineage exist now
diff --git a/features/ship/squash_merge/current_branch/commit_message/via_stdin.feature b/features/ship/squash_merge/current_branch/commit_message/via_stdin.feature
new file mode 100644
index 000000000..776faccf9
--- /dev/null
+++ b/features/ship/squash_merge/current_branch/commit_message/via_stdin.feature
@@ -0,0 +1,50 @@
+Feature: ship the current feature branch with commit message via STDIN
+
+  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        |
+      | feature | local, origin | feature commit |
+    And the current branch is "feature"
+    And Git setting "git-town.ship-strategy" is "squash-merge"
+    When I pipe the following text into "git-town ship -f -":
+      """
+      Commit message via STDIN
+      """
+
+  Scenario: result
+    Then Git Town runs the commands
+      | BRANCH  | COMMAND                                  |
+      | feature | git fetch --prune --tags                 |
+      |         | git checkout main                        |
+      | main    | git merge --squash --ff feature          |
+      |         | git commit -m "Commit message via STDIN" |
+      |         | git push                                 |
+      |         | git push origin :feature                 |
+      |         | git branch -D feature                    |
+    And the branches are now
+      | REPOSITORY    | BRANCHES |
+      | local, origin | main     |
+    And these commits exist now
+      | BRANCH | LOCATION      | MESSAGE                  |
+      | main   | local, origin | Commit message via STDIN |
+    And no lineage exists now
+
+  Scenario: undo
+    When I run "git-town undo"
+    Then Git Town runs the commands
+      | BRANCH | COMMAND                                         |
+      | main   | git revert {{ sha 'Commit message via STDIN' }} |
+      |        | git push                                        |
+      |        | git branch feature {{ sha 'feature commit' }}   |
+      |        | git push -u origin feature                      |
+      |        | git checkout feature                            |
+    And these commits exist now
+      | BRANCH  | LOCATION      | MESSAGE                           |
+      | main    | local, origin | Commit message via STDIN          |
+      |         |               | Revert "Commit message via STDIN" |
+      | feature | local, origin | feature commit                    |
+    And the initial branches and lineage exist now
diff --git a/internal/cli/flags/commit_message_file.go b/internal/cli/flags/commit_message_file.go
new file mode 100644
index 000000000..fe998b1a7
--- /dev/null
+++ b/internal/cli/flags/commit_message_file.go
@@ -0,0 +1,27 @@
+package flags
+
+import (
+	"github.com/git-town/git-town/v21/internal/git/gitdomain"
+	. "github.com/git-town/git-town/v21/pkg/prelude"
+	"github.com/spf13/cobra"
+)
+
+const (
+	commitMessageFileLong  = "message-file"
+	commitMessageFileShort = "f"
+)
+
+// provides type-safe access to the CLI arguments of type gitdomain.MessageFile
+func CommitMessageFile() (AddFunc, ReadCommitMessageFileFlagFunc) {
+	addFlag := func(cmd *cobra.Command) {
+		cmd.Flags().StringP(commitMessageFileLong, commitMessageFileShort, "", "Read the commit message from the given file (use \"-\" to read from STDIN)")
+	}
+	readFlag := func(cmd *cobra.Command) (Option[gitdomain.CommitMessageFile], error) {
+		value, err := cmd.Flags().GetString(commitMessageFileLong)
+		return NewOption(gitdomain.CommitMessageFile(value)), err
+	}
+	return addFlag, readFlag
+}
+
+// ReadCommitMessageFileFlagFunc reads gitdomain.CommitMessageFile from the CLI args.
+type ReadCommitMessageFileFlagFunc func(*cobra.Command) (Option[gitdomain.CommitMessageFile], error)
diff --git a/internal/cmd/propose.go b/internal/cmd/propose.go
index aaf274168..10676ad29 100644
--- a/internal/cmd/propose.go
+++ b/internal/cmd/propose.go
@@ -4,7 +4,6 @@ import (
 	"cmp"
 	"errors"
 	"fmt"
-	"io"
 	"os"
 
 	"github.com/git-town/git-town/v21/internal/cli/dialog/dialogcomponents"
@@ -12,6 +11,7 @@ import (
 	"github.com/git-town/git-town/v21/internal/cli/flags"
 	"github.com/git-town/git-town/v21/internal/cli/print"
 	"github.com/git-town/git-town/v21/internal/cmd/cmdhelpers"
+	"github.com/git-town/git-town/v21/internal/cmd/ship"
 	"github.com/git-town/git-town/v21/internal/cmd/sync"
 	"github.com/git-town/git-town/v21/internal/config"
 	"github.com/git-town/git-town/v21/internal/config/cliconfig"
@@ -308,24 +308,7 @@ func determineProposeData(repo execute.OpenRepoResult, cliConfig cliconfig.CliCo
 	if err != nil {
 		return data, false, err
 	}
-	var bodyText Option[gitdomain.ProposalBody]
-	if body.IsSome() {
-		bodyText = body
-	} else if bodyFile, hasBodyFile := bodyFileOpt.Get(); hasBodyFile {
-		if bodyFile.ShouldReadStdin() {
-			content, err := io.ReadAll(os.Stdin)
-			if err != nil {
-				return data, false, fmt.Errorf("cannot read STDIN: %w", err)
-			}
-			bodyText = NewOption(gitdomain.ProposalBody(content))
-		} else {
-			fileData, err := os.ReadFile(bodyFile.String())
-			if err != nil {
-				return data, false, err
-			}
-			bodyText = NewOption(gitdomain.ProposalBody(fileData))
-		}
-	}
+	bodyText, err := ship.ReadFile(body, bodyFileOpt)
 	return proposeData{
 		branchInfos:         branchesSnapshot.Branches,
 		branchInfosLastRun:  branchInfosLastRun,
diff --git a/internal/cmd/ship/cmd.go b/internal/cmd/ship/cmd.go
index e9885bcdc..cc5db9738 100644
--- a/internal/cmd/ship/cmd.go
+++ b/internal/cmd/ship/cmd.go
@@ -4,6 +4,8 @@ import (
 	"cmp"
 	"errors"
 	"fmt"
+	"io"
+	"os"
 
 	"github.com/git-town/git-town/v21/internal/cli/flags"
 	"github.com/git-town/git-town/v21/internal/cmd/cmdhelpers"
@@ -12,6 +14,7 @@ import (
 	"github.com/git-town/git-town/v21/internal/execute"
 	"github.com/git-town/git-town/v21/internal/forge/forgedomain"
 	"github.com/git-town/git-town/v21/internal/git/gitdomain"
+	"github.com/git-town/git-town/v21/internal/gohacks"
 	"github.com/git-town/git-town/v21/internal/gohacks/stringslice"
 	"github.com/git-town/git-town/v21/internal/messages"
 	"github.com/git-town/git-town/v21/internal/state/runstate"
@@ -47,6 +50,7 @@ disable the ship-delete-tracking-branch configuration setting.`
 func Cmd() *cobra.Command {
 	addDryRunFlag, readDryRunFlag := flags.DryRun()
 	addMessageFlag, readMessageFlag := flags.CommitMessage("specify the commit message for the squash commit")
+	addMessageFileFlag, readMessageFileFlag := flags.CommitMessageFile()
 	addShipStrategyFlag, readShipStrategyFlag := flags.ShipStrategy()
 	addToParentFlag, readToParentFlag := flags.ShipIntoNonPerennialParent()
 	addVerboseFlag, readVerboseFlag := flags.Verbose()
@@ -58,19 +62,21 @@ func Cmd() *cobra.Command {
 		RunE: func(cmd *cobra.Command, args []string) error {
 			dryRun, err1 := readDryRunFlag(cmd)
 			message, err2 := readMessageFlag(cmd)
-			shipStrategyOverride, err3 := readShipStrategyFlag(cmd)
-			toParent, err4 := readToParentFlag(cmd)
-			verbose, err5 := readVerboseFlag(cmd)
-			if err := cmp.Or(err1, err2, err3, err4, err5); err != nil {
+			messageFile, err3 := readMessageFileFlag(cmd)
+			shipStrategyOverride, err4 := readShipStrategyFlag(cmd)
+			toParent, err5 := readToParentFlag(cmd)
+			verbose, err6 := readVerboseFlag(cmd)
+			if err := cmp.Or(err1, err2, err3, err4, err5, err6); err != nil {
 				return err
 			}
 			cliConfig := cliconfig.CliConfig{
 				DryRun:  dryRun,
 				Verbose: verbose,
 			}
-			return executeShip(args, cliConfig, message, shipStrategyOverride, toParent)
+			return executeShip(args, cliConfig, message, messageFile, shipStrategyOverride, toParent)
 		},
 	}
+	addMessageFileFlag(&cmd)
 	addDryRunFlag(&cmd)
 	addMessageFlag(&cmd)
 	addShipStrategyFlag(&cmd)
@@ -79,7 +85,7 @@ func Cmd() *cobra.Command {
 	return &cmd
 }
 
-func executeShip(args []string, cliConfig cliconfig.CliConfig, message Option[gitdomain.CommitMessage], shipStrategy Option[configdomain.ShipStrategy], toParent configdomain.ShipIntoNonperennialParent) error {
+func executeShip(args []string, cliConfig cliconfig.CliConfig, messageOpt Option[gitdomain.CommitMessage], messageFileOpt Option[gitdomain.CommitMessageFile], shipStrategy Option[configdomain.ShipStrategy], toParent configdomain.ShipIntoNonperennialParent) error {
 	repo, err := execute.OpenRepo(execute.OpenRepoArgs{
 		CliConfig:        cliConfig,
 		PrintBranchNames: true,
@@ -94,6 +100,10 @@ func executeShip(args []string, cliConfig cliconfig.CliConfig, message Option[gi
 	if err != nil || exit {
 		return err
 	}
+	message, err := ReadFile(messageOpt, messageFileOpt)
+	if err != nil {
+		return err
+	}
 	if err = validateSharedData(sharedData, toParent, message); err != nil {
 		return err
 	}
@@ -210,3 +220,24 @@ func validateSharedData(data sharedShipData, toParent configdomain.ShipIntoNonpe
 	}
 	return nil
 }
+
+func ReadFile[TEXT ~string, FILE FileFlag](inputText Option[TEXT], inputFileOpt Option[FILE]) (Option[TEXT], error) {
+	if inputText.IsSome() {
+		return inputText, nil
+	}
+	file, hasFile := inputFileOpt.Get()
+	if !hasFile {
+		return None[TEXT](), nil
+	}
+	if file.ShouldReadStdin() {
+		content, err := io.ReadAll(os.Stdin)
+		return NewOption(TEXT(string(content))), gohacks.WrapIfError(err, "cannot read STDIN: %w")
+	}
+	fileData, err := os.ReadFile(file.String())
+	return NewOption(TEXT(string(fileData))), err
+}
+
+type FileFlag interface {
+	ShouldReadStdin() bool
+	fmt.Stringer
+}
diff --git a/internal/git/gitdomain/commit_message_file.go b/internal/git/gitdomain/commit_message_file.go
new file mode 100644
index 000000000..a81bc69d8
--- /dev/null
+++ b/internal/git/gitdomain/commit_message_file.go
@@ -0,0 +1,14 @@
+package gitdomain
+
+// CommitMessageFile is the name of the file from which to read the CommitMessage.
+type CommitMessageFile string
+
+// indicates whether the commit message should be read from STDIN
+func (self CommitMessageFile) ShouldReadStdin() bool {
+	return self == "-"
+}
+
+// String implements the fmt.Stringer interface.
+func (self CommitMessageFile) String() string {
+	return string(self)
+}
diff --git a/internal/gohacks/wrap_if_error.go b/internal/gohacks/wrap_if_error.go
new file mode 100644
index 000000000..6283fb4ca
--- /dev/null
+++ b/internal/gohacks/wrap_if_error.go
@@ -0,0 +1,14 @@
+package gohacks
+
+import (
+	"fmt"
+)
+
+// If the given error is nil, returns nil.
+// If the given error is not nil, returns it wrapped in the given error message.
+func WrapIfError(err error, text string) error {
+	if err == nil {
+		return nil
+	}
+	return fmt.Errorf(text, err)
+}
diff --git a/website/src/commands/ship.md b/website/src/commands/ship.md
index c34420088..fdd235dcf 100644
--- a/website/src/commands/ship.md
+++ b/website/src/commands/ship.md
@@ -32,6 +32,11 @@ When called with a positional argument, it ships the branch with the given name.
 Similar to `git commit`, the `--message <message>` aka `-m` parameter allows
 specifying the commit message via the CLI.
 
+#### `-f <path>`<br>`--message-file <path>`
+
+The `--message-file` aka `-f` flag uses the content of the given file for the
+commit message. The filename `-` reads the commit message from STDIN.
+
 #### `-s <name>`<br>`--strategy <name>`
 
 Overrides the configured [ship-strategy](../preferences/ship-strategy.md).



Test patch:
diff --git a/internal/test/commands/test_commands.go b/internal/test/commands/test_commands.go
index c4c41c8e3..7ad817121 100644
--- a/internal/test/commands/test_commands.go
+++ b/internal/test/commands/test_commands.go
@@ -22,7 +22,10 @@ import (
 	. "github.com/git-town/git-town/v21/pkg/prelude"
 )
 
-const ConfigFileCommitMessage = "persisted config file"
+const (
+	ConfigFileCommitMessage = "persisted config file"
+	FileCommitMessage       = "persisted file"
+)
 
 // TestCommands defines Git commands used only in test code.
 type TestCommands struct {
diff --git a/internal/test/cucumber/steps.go b/internal/test/cucumber/steps.go
index a6b910635..6d61ccff3 100644
--- a/internal/test/cucumber/steps.go
+++ b/internal/test/cucumber/steps.go
@@ -1129,6 +1129,15 @@ func defineSteps(sc *godog.ScenarioContext) {
 		devRepo.PushBranch()
 	})
 
+	sc.Step(`^the committed file "([^"]+)":$`, func(ctx context.Context, name string, content *godog.DocString) {
+		state := ctx.Value(keyScenarioState).(*ScenarioState)
+		devRepo := state.fixture.DevRepo.GetOrPanic()
+		devRepo.CreateFile(name, content.Content)
+		devRepo.StageFiles(name)
+		devRepo.CommitStagedChanges(commands.FileCommitMessage)
+		devRepo.PushBranch()
+	})
+
 	sc.Step(`^the configuration file:$`, func(ctx context.Context, content *godog.DocString) {
 		state := ctx.Value(keyScenarioState).(*ScenarioState)
 		devRepo := state.fixture.DevRepo.GetOrPanic()