Repository: git-town/git-town

Issue:
Command to update end-to-end tests
Maintaining Git Town's extensive end-to-end test suite is currently one of the most repetitive and time-consuming parts of our workflow. A small change like adding a new flag to a commonly used Git command can require manually updating hundreds of test fixtures. This creates friction and slows down velocity.

Our E2E tests act as [golden files](https://en.wikipedia.org/wiki/Characterization_test): they embody the expected output that all future test runs must match. When we intentionally change Git Town's behavior, the test suite fails until we manually update these files. This update process is purely mechanical, but takes significant time and is easy to get wrong. I often need to run the entire test suite many dozens of times when doing this.

### Proposed Solution

Add a test mode (e.g. `make cuke-update` or `--update-golden`) that, when tests fail due to mismatching behavior, automatically updates the end-to-end tests instead of failing the test.

### Benefits

- automate repetitive manual scut work
- makes it easy to inspect the behavioral impact of a change by reviewing the generated diffs in the E2E tests

Discussion:

Solution patch:
diff --git a/Makefile b/Makefile
index e9b88c59b..b5e5077f4 100644
--- a/Makefile
+++ b/Makefile
@@ -33,6 +33,14 @@ cukethiswin:  # runs the end-to-end tests that have a @this tag on Windows
 	go install -ldflags "-X github.com/git-town/git-town/v22/internal/cmd.version=-dev -X github.com/git-town/git-town/v22/internal/cmd.buildDate=1/2/3"
 	powershell -Command '$$env:cukethis=1 ; go test . -v -count=1'
 
+cuke-update: install  # updates the E2E tests based on the actual behavior of Git Town
+	@env $(GO_TEST_ARGS) cukeupdate=1 go test . -v -count=1
+	make --no-print-directory fix
+
+cuke-update-this: install  # updates the E2E tests that have a @this tag
+	@env $(GO_TEST_ARGS) cukeupdate=1 cukethis=1 go test . -v -count=1
+	make --no-print-directory fix
+
 cukeverbose: install  # run all tests in "verbose.feature" files
 	@env $(GO_TEST_ARGS) verbose=1 go test . -v -count=1
 
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
index 640f6f6b6..793bc3c6e 100644
--- a/docs/DEVELOPMENT.md
+++ b/docs/DEVELOPMENT.md
@@ -258,6 +258,31 @@ Alternatively, open `main_test.go`, change `Format` to `pretty` and
 `Concurrency` to 1, and run the entire test suite. The detailed output will give
 you hints at which test fails.
 
+### Automatically update E2E test files
+
+Our E2E tests act as
+[golden files](https://en.wikipedia.org/wiki/Characterization_test): they embody
+the expected output that all future test runs must match. When we intentionally
+change Git Town's behavior, we also need to update possibly hundreds of E2E
+tests.
+
+To streamline this, run:
+
+<a type="make/command" dir="..">
+
+```bash
+make cuke-update
+```
+
+</a>
+
+When running in this mode, and an E2E test fails, the test runner updates the
+`.feature` file, rather than failing the test. You must then inspect the test
+files to verify whether the updated tests are correct.
+
+If needed, run <code type="make/command" dir="..">make fix</code> to format the
+recorded Gherkin code.
+
 ### Configure the Cucumber IDE extension
 
 To configure the official
diff --git a/internal/gohacks/strings.go b/internal/gohacks/strings.go
index e33c7e130..b6d189b9a 100644
--- a/internal/gohacks/strings.go
+++ b/internal/gohacks/strings.go
@@ -16,3 +16,16 @@ func IndentLines(text string, amount int) string {
 	}
 	return strings.Join(result, "\n")
 }
+
+// LeadingWhitespace provides the leading whitespace in the given string.
+func LeadingWhitespace(line string) string {
+	result := strings.Builder{}
+	for _, r := range line {
+		if r == ' ' || r == '\t' {
+			result.WriteRune(r)
+		} else {
+			break
+		}
+	}
+	return result.String()
+}
diff --git a/internal/gohacks/stringslice/change_indent_non_empty.go b/internal/gohacks/stringslice/change_indent_non_empty.go
new file mode 100644
index 000000000..2c9e40704
--- /dev/null
+++ b/internal/gohacks/stringslice/change_indent_non_empty.go
@@ -0,0 +1,16 @@
+package stringslice
+
+import "strings"
+
+// ChangeIndentNonEmpty changes the indentation of each non-empty line to the given string.
+func ChangeIndentNonEmpty(lines []string, indentation string) []string {
+	result := make([]string, len(lines))
+	for i, line := range lines {
+		if strings.TrimSpace(line) != "" {
+			result[i] = indentation + strings.TrimLeft(line, " \t")
+		} else {
+			result[i] = line
+		}
+	}
+	return result
+}
diff --git a/internal/gohacks/stringslice/equal_ignore_whitespace.go b/internal/gohacks/stringslice/equal_ignore_whitespace.go
new file mode 100644
index 000000000..f8420786b
--- /dev/null
+++ b/internal/gohacks/stringslice/equal_ignore_whitespace.go
@@ -0,0 +1,17 @@
+package stringslice
+
+import "strings"
+
+// EqualIgnoreWhitespace indicates whether the given string slices match,
+// ignoring whitespace before and after each string.
+func EqualIgnoreWhitespace(strings1, strings2 []string) bool {
+	if len(strings1) != len(strings2) {
+		return false
+	}
+	for s, string1 := range strings1 {
+		if strings.TrimSpace(string1) != strings.TrimSpace(strings2[s]) {
+			return false
+		}
+	}
+	return true
+}
diff --git a/internal/gohacks/stringslice/locate_section.go b/internal/gohacks/stringslice/locate_section.go
new file mode 100644
index 000000000..06692ce77
--- /dev/null
+++ b/internal/gohacks/stringslice/locate_section.go
@@ -0,0 +1,12 @@
+package stringslice
+
+// LocateSection provides the line number where the given section starts within the given lines.
+func LocateSection(lines, section []string) (start int, found bool) {
+	sectionLength := len(section)
+	for i := 0; i <= len(lines)-sectionLength; i++ {
+		if EqualIgnoreWhitespace(lines[i:i+sectionLength], section) {
+			return i, true
+		}
+	}
+	return -1, false
+}
diff --git a/internal/gohacks/stringslice/replace_regex.go b/internal/gohacks/stringslice/replace_regex.go
new file mode 100644
index 000000000..6e8939dc0
--- /dev/null
+++ b/internal/gohacks/stringslice/replace_regex.go
@@ -0,0 +1,12 @@
+package stringslice
+
+import "regexp"
+
+// ReplaceRegex replaces all matches of the given regex with the given replacement in the given strings.
+func ReplaceRegex(texts []string, regex *regexp.Regexp, replacement string) []string {
+	result := make([]string, len(texts))
+	for t, text := range texts {
+		result[t] = regex.ReplaceAllString(text, replacement)
+	}
+	return result
+}
diff --git a/internal/gohacks/stringslice/trim_empty_lines.go b/internal/gohacks/stringslice/trim_empty_lines.go
new file mode 100644
index 000000000..cc75b398d
--- /dev/null
+++ b/internal/gohacks/stringslice/trim_empty_lines.go
@@ -0,0 +1,18 @@
+package stringslice
+
+import "strings"
+
+// TrimEmptyLines removes leading and trailing empty lines from the given lines.
+func TrimEmptyLines(lines []string) []string {
+	result := make([]string, 0, len(lines))
+	for _, line := range lines {
+		if len(result) > 0 || strings.TrimSpace(line) != "" {
+			result = append(result, line)
+		}
+	}
+	// Trim trailing empty lines
+	for len(result) > 0 && strings.TrimSpace(result[len(result)-1]) == "" {
+		result = result[:len(result)-1]
+	}
+	return result
+}



Test patch:
diff --git a/internal/gohacks/strings_test.go b/internal/gohacks/strings_test.go
index 82b084316..46840b8d6 100644
--- a/internal/gohacks/strings_test.go
+++ b/internal/gohacks/strings_test.go
@@ -47,3 +47,19 @@ func TestIndent(t *testing.T) {
 		must.EqOp(t, want, have)
 	})
 }
+
+func TestLeadingWhitespace(t *testing.T) {
+	t.Parallel()
+	tests := map[string]string{
+		"    text":   "    ",   // spaces
+		"\t\ttext":   "\t\t",   // tabs
+		"  \t  text": "  \t  ", // mixed spaces and tabs
+		"text":       "",       // no indentation
+		"":           "",       // empty string
+		"    ":       "    ",   // only whitespace
+	}
+	for give, want := range tests {
+		have := gohacks.LeadingWhitespace(give)
+		must.EqOp(t, want, have)
+	}
+}
diff --git a/internal/gohacks/stringslice/change_indent_non_empty_test.go b/internal/gohacks/stringslice/change_indent_non_empty_test.go
new file mode 100644
index 000000000..ae252b18f
--- /dev/null
+++ b/internal/gohacks/stringslice/change_indent_non_empty_test.go
@@ -0,0 +1,56 @@
+package stringslice_test
+
+import (
+	"testing"
+
+	"github.com/git-town/git-town/v22/internal/gohacks/stringslice"
+	"github.com/shoenig/test/must"
+)
+
+func TestIndentNonEmpty(t *testing.T) {
+	t.Parallel()
+	tests := []struct {
+		name       string
+		giveLines  []string
+		giveIndent string
+		wantLines  []string
+	}{
+		{
+			name:       "add spaces",
+			giveLines:  []string{"one", "two"},
+			giveIndent: "    ",
+			wantLines:  []string{"    one", "    two"},
+		},
+		{
+			name:       "add tabs",
+			giveLines:  []string{"one", "two"},
+			giveIndent: "\t\t",
+			wantLines:  []string{"\t\tone", "\t\ttwo"},
+		},
+		{
+			name:       "no indentation",
+			giveLines:  []string{"one", "two"},
+			giveIndent: "",
+			wantLines:  []string{"one", "two"},
+		},
+		{
+			name:       "preserve empty lines",
+			giveLines:  []string{"one", "", "two"},
+			giveIndent: "  ",
+			wantLines:  []string{"  one", "", "  two"},
+		},
+		{
+			name:       "remove existing indentation and add new",
+			giveLines:  []string{"  one", "    two"},
+			giveIndent: "\t",
+			wantLines:  []string{"\tone", "\ttwo"},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+			haveLines := stringslice.ChangeIndentNonEmpty(tt.giveLines, tt.giveIndent)
+			must.Eq(t, tt.wantLines, haveLines)
+		})
+	}
+}
diff --git a/internal/gohacks/stringslice/equal_ignore_whitespace_test.go b/internal/gohacks/stringslice/equal_ignore_whitespace_test.go
new file mode 100644
index 000000000..66322cc24
--- /dev/null
+++ b/internal/gohacks/stringslice/equal_ignore_whitespace_test.go
@@ -0,0 +1,52 @@
+package stringslice_test
+
+import (
+	"testing"
+
+	"github.com/git-town/git-town/v22/internal/gohacks/stringslice"
+	"github.com/shoenig/test/must"
+)
+
+func TestEqualIgnoreWhitespace(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name  string
+		give1 []string
+		give2 []string
+		want  bool
+	}{
+		{
+			name:  "exact match",
+			give1: []string{"one", "two"},
+			give2: []string{"one", "two"},
+			want:  true,
+		},
+		{
+			name:  "match with different indentation",
+			give1: []string{"    one", "    two"},
+			give2: []string{"  one", "  two"},
+			want:  true,
+		},
+		{
+			name:  "no match - different content",
+			give1: []string{"one", "three"},
+			give2: []string{"one", "two"},
+			want:  false,
+		},
+		{
+			name:  "no match - different length",
+			give1: []string{"one"},
+			give2: []string{"one", "two"},
+			want:  false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+			have := stringslice.EqualIgnoreWhitespace(tt.give1, tt.give2)
+			must.EqOp(t, tt.want, have)
+		})
+	}
+}
diff --git a/internal/gohacks/stringslice/locate_section_test.go b/internal/gohacks/stringslice/locate_section_test.go
new file mode 100644
index 000000000..cb19f5f53
--- /dev/null
+++ b/internal/gohacks/stringslice/locate_section_test.go
@@ -0,0 +1,87 @@
+package stringslice_test
+
+import (
+	"testing"
+
+	"github.com/git-town/git-town/v22/internal/gohacks/stringslice"
+	"github.com/shoenig/test/must"
+)
+
+func TestLocateSection(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name         string
+		giveHaystack []string
+		giveNeedle   []string
+		wantIndex    int
+		wantFound    bool
+	}{
+		{
+			name: "section at beginning",
+			giveHaystack: []string{
+				"one",
+				"two",
+				"Some text",
+			},
+			giveNeedle: []string{
+				"one",
+				"two",
+			},
+			wantIndex: 0,
+			wantFound: true,
+		},
+		{
+			name: "section in middle",
+			giveHaystack: []string{
+				"Some text",
+				"    one",
+				"    two",
+				"More text",
+			},
+			giveNeedle: []string{
+				"one",
+				"two",
+			},
+			wantIndex: 1,
+			wantFound: true,
+		},
+		{
+			name: "section at end",
+			giveHaystack: []string{
+				"Some text",
+				"More text",
+				"  one",
+				"  two",
+			},
+			giveNeedle: []string{
+				"one",
+				"two",
+			},
+			wantIndex: 2,
+			wantFound: true,
+		},
+		{
+			name: "section not found",
+			giveHaystack: []string{
+				"one",
+				"three",
+			},
+			giveNeedle: []string{
+				"one",
+				"two",
+			},
+			wantIndex: -1,
+			wantFound: false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+			haveIdx, haveFound := stringslice.LocateSection(tt.giveHaystack, tt.giveNeedle)
+			must.EqOp(t, tt.wantIndex, haveIdx)
+			must.EqOp(t, tt.wantFound, haveFound)
+		})
+	}
+}
diff --git a/internal/gohacks/stringslice/replace_regex_test.go b/internal/gohacks/stringslice/replace_regex_test.go
new file mode 100644
index 000000000..8b0cf3ab9
--- /dev/null
+++ b/internal/gohacks/stringslice/replace_regex_test.go
@@ -0,0 +1,29 @@
+package stringslice_test
+
+import (
+	"regexp"
+	"testing"
+
+	"github.com/git-town/git-town/v22/internal/gohacks/stringslice"
+	"github.com/shoenig/test/must"
+)
+
+func TestReplaceRegex(t *testing.T) {
+	t.Parallel()
+
+	t.Run("has matches", func(t *testing.T) {
+		t.Parallel()
+		give := []string{"one", "two 123", "three"}
+		want := []string{"one", "two SHA", "three"}
+		have := stringslice.ReplaceRegex(give, regexp.MustCompile(`\d+`), "SHA")
+		must.Eq(t, want, have)
+	})
+
+	t.Run("no matches", func(t *testing.T) {
+		t.Parallel()
+		give := []string{"one", "two"}
+		want := []string{"one", "two"}
+		have := stringslice.ReplaceRegex(give, regexp.MustCompile(`\d+`), "SHA")
+		must.Eq(t, want, have)
+	})
+}
diff --git a/internal/gohacks/stringslice/trim_empty_lines_test.go b/internal/gohacks/stringslice/trim_empty_lines_test.go
new file mode 100644
index 000000000..023b4f554
--- /dev/null
+++ b/internal/gohacks/stringslice/trim_empty_lines_test.go
@@ -0,0 +1,62 @@
+package stringslice_test
+
+import (
+	"testing"
+
+	"github.com/git-town/git-town/v22/internal/gohacks/stringslice"
+	"github.com/shoenig/test/must"
+)
+
+func TestTrimEmptyLines(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name string
+		give []string
+		want []string
+	}{
+		{
+			name: "no empty lines",
+			give: []string{"one", "two"},
+			want: []string{"one", "two"},
+		},
+		{
+			name: "trailing empty line",
+			give: []string{"one", "two", ""},
+			want: []string{"one", "two"},
+		},
+		{
+			name: "multiple trailing empty lines",
+			give: []string{"one", "two", "", "", ""},
+			want: []string{"one", "two"},
+		},
+		{
+			name: "leading empty lines",
+			give: []string{"", "", "one", "two"},
+			want: []string{"one", "two"},
+		},
+		{
+			name: "empty lines before, middle, and after",
+			give: []string{"", "one", "", "two", ""},
+			want: []string{"one", "", "two"},
+		},
+		{
+			name: "empty string",
+			give: []string{},
+			want: []string{},
+		},
+		{
+			name: "only empty lines",
+			give: []string{"", "", ""},
+			want: []string{},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+			have := stringslice.TrimEmptyLines(tt.give)
+			must.Eq(t, tt.want, have)
+		})
+	}
+}
diff --git a/internal/test/cucumber/golden.go b/internal/test/cucumber/golden.go
new file mode 100644
index 000000000..ba441ad07
--- /dev/null
+++ b/internal/test/cucumber/golden.go
@@ -0,0 +1,79 @@
+package cucumber
+
+import (
+	"fmt"
+	"os"
+	"regexp"
+	"strings"
+
+	"github.com/git-town/git-town/v22/internal/gohacks"
+	"github.com/git-town/git-town/v22/internal/gohacks/stringslice"
+)
+
+// ChangeFeatureFile updates the given section of the given feature file with the given new section.
+func ChangeFeatureFile(filePath, oldSection, newSection string) error {
+	// read file
+	content, err := os.ReadFile(filePath)
+	if err != nil {
+		return fmt.Errorf("failed to read feature file: %w", err)
+	}
+	fileLines := stringslice.Lines(string(content))
+
+	// normalize file lines for searching
+	normalizedFileLines := ReplaceSHAPlaceholder(fileLines)
+	normalizedFileLines = ReplaceSHA(normalizedFileLines)
+	normalizedFileLines = NormalizeWhitespace(normalizedFileLines)
+
+	// normalize old section for searching
+	oldSectionLines := stringslice.TrimEmptyLines(stringslice.Lines(oldSection))
+	oldSectionLines = ReplaceSHAPlaceholder(oldSectionLines)
+	oldSectionLines = ReplaceSHA(oldSectionLines)
+	oldSectionLines = NormalizeWhitespace(oldSectionLines)
+
+	// normalize new section
+	newSectionLines := stringslice.TrimEmptyLines(stringslice.Lines(newSection))
+	newSectionLines = NormalizeWhitespace(newSectionLines)
+
+	// find the old section in the file
+	startLine, found := stringslice.LocateSection(normalizedFileLines, oldSectionLines)
+	if !found {
+		fmt.Println("WANTED SECTION START")
+		fmt.Println(strings.Join(oldSectionLines, "\n"))
+		fmt.Println("WANTED SECTION END")
+		fmt.Println("FILE CONTENT START")
+		fmt.Println(strings.Join(normalizedFileLines, "\n"))
+		fmt.Println("FILE CONTENT END")
+		return fmt.Errorf("could not find section in feature file %q", filePath)
+	}
+
+	// indent the new section the same way the old one is indented in the file
+	indentation := gohacks.LeadingWhitespace(fileLines[startLine])
+	indentedNewSectionLines := stringslice.ChangeIndentNonEmpty(newSectionLines, indentation)
+
+	// replace the old section with the new one
+	newLines := append([]string{}, fileLines[:startLine]...)
+	newLines = append(newLines, indentedNewSectionLines...)
+	newLines = append(newLines, fileLines[startLine+len(oldSectionLines):]...)
+
+	// Write back to the file
+	newContent := strings.Join(newLines, "\n")
+	//nolint:gosec // need permission 644 for feature files
+	if err := os.WriteFile(filePath, []byte(newContent), 0o644); err != nil {
+		return fmt.Errorf("failed to write feature file: %w", err)
+	}
+	return nil
+}
+
+// NormalizeWhitespace collapses redundant whitespace in the given lines.
+func NormalizeWhitespace(lines []string) []string {
+	return stringslice.ReplaceRegex(lines, regexp.MustCompile(`\s{2,}`), " ")
+}
+
+func ReplaceSHA(lines []string) []string {
+	return stringslice.ReplaceRegex(lines, regexp.MustCompile(`[0-9a-f]{40}`), "SHA")
+}
+
+// ReplaceSHAPlaceholder replaces all placeholders like "{{ sha.* }}" with "SHA".
+func ReplaceSHAPlaceholder(lines []string) []string {
+	return stringslice.ReplaceRegex(lines, regexp.MustCompile(`\{\{.*?\}\}`), "SHA")
+}
diff --git a/internal/test/cucumber/golden_test.go b/internal/test/cucumber/golden_test.go
new file mode 100644
index 000000000..5f07bb70c
--- /dev/null
+++ b/internal/test/cucumber/golden_test.go
@@ -0,0 +1,337 @@
+package cucumber_test
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/git-town/git-town/v22/internal/test/cucumber"
+	"github.com/shoenig/test/must"
+)
+
+func TestNormalizeWhitespace(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name string
+		give []string
+		want []string
+	}{
+		{
+			name: "multiple spaces",
+			give: []string{
+				"one    two",
+				"three     four",
+			},
+			want: []string{
+				"one two",
+				"three four",
+			},
+		},
+		{
+			name: "tabs",
+			give: []string{
+				"one\t\t\t\ttwo",
+				"three\t\tfour",
+			},
+			want: []string{
+				"one two",
+				"three four",
+			},
+		},
+		{
+			name: "mixed whitespace",
+			give: []string{
+				"one \t  \t two",
+				"three\t \tfour",
+			},
+			want: []string{
+				"one two",
+				"three four",
+			},
+		},
+		{
+			name: "single spaces unchanged",
+			give: []string{
+				"one two",
+				"three four five",
+			},
+			want: []string{
+				"one two",
+				"three four five",
+			},
+		},
+		{
+			name: "leading and trailing whitespace",
+			give: []string{
+				"  leading",
+				"trailing  ",
+				"  both  ",
+			},
+			want: []string{
+				" leading",
+				"trailing ",
+				" both ",
+			},
+		},
+		{
+			name: "no whitespace",
+			give: []string{
+				"nowhitespace",
+				"stillnone",
+			},
+			want: []string{
+				"nowhitespace",
+				"stillnone",
+			},
+		},
+		{
+			name: "empty strings",
+			give: []string{
+				"",
+				"not empty",
+				"",
+			},
+			want: []string{
+				"",
+				"not empty",
+				"",
+			},
+		},
+		{
+			name: "empty slice",
+			give: []string{},
+			want: []string{},
+		},
+		{
+			name: "newlines and other whitespace",
+			give: []string{
+				"one\n\n\ntwo",
+				"three\r\rfour",
+				"five\f\fsix",
+			},
+			want: []string{
+				"one two",
+				"three four",
+				"five six",
+			},
+		},
+		{
+			name: "real-world example with table formatting",
+			give: []string{
+				"      | BRANCH      | COMMAND   |",
+				"      | main   | git fetch |",
+			},
+			want: []string{
+				" | BRANCH | COMMAND |",
+				" | main | git fetch |",
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+			have := cucumber.NormalizeWhitespace(tt.give)
+			must.Eq(t, tt.want, have)
+		})
+	}
+}
+
+func TestReplaceSHA(t *testing.T) {
+	t.Parallel()
+	give := []string{
+		"d721118fcd545d37e87100b22ef13169160bdb3c",
+		"no sha",
+		"",
+		"gggggggggggggggggggggggggggggggggggggggg", // invalid hex (should not match)
+		"0123456789abcdef0123456789abcdef01234567", // valid hex (should match)
+	}
+	want := []string{
+		"SHA",
+		"no sha",
+		"",
+		"gggggggggggggggggggggggggggggggggggggggg", // invalid hex (should not be replaced)
+		"SHA",
+	}
+	have := cucumber.ReplaceSHA(give)
+	must.Eq(t, want, have)
+}
+
+func TestReplaceSHAPlaceholder(t *testing.T) {
+	t.Parallel()
+
+	give := []string{
+		"one {{ sha 'foo' }} two",
+		"one {{ sha-in-origin 'bar' }} two",
+		"git reset --hard {{ sha-initial 'alpha commit' }}",
+		"no placeholder",
+		"",
+	}
+	want := []string{
+		"one SHA two",
+		"one SHA two",
+		"git reset --hard SHA",
+		"no placeholder",
+		"",
+	}
+	have := cucumber.ReplaceSHAPlaceholder(give)
+	must.Eq(t, want, have)
+}
+
+func TestUpdateFeatureFileWithCommands(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name     string
+		file     string
+		oldTable string
+		newTable string
+		want     string
+	}{
+		{
+			name: "replace table with proper indentation",
+			file: `
+Feature: test
+  Scenario: test
+    Then Git Town runs the commands
+      | BRANCH | COMMAND |
+      | main   | git fetch |
+    And some other step`[1:],
+			oldTable: `
+      | BRANCH | COMMAND |
+      | main   | git fetch |`[1:],
+			newTable: `
+| BRANCH | COMMAND |
+| main   | git pull |`[1:],
+			want: `
+Feature: test
+  Scenario: test
+    Then Git Town runs the commands
+      | BRANCH | COMMAND |
+      | main | git pull |
+    And some other step`[1:],
+		},
+		{
+			name: "replace table with different number of rows",
+			file: `
+Feature: test
+  Scenario: test
+    Then Git Town runs the commands
+      | BRANCH      | COMMAND   |
+      | development | git fetch |
+    And done`[1:],
+			oldTable: `
+			| BRANCH      | COMMAND |
+			| development | git fetch |`[1:],
+			newTable: `
+			| BRANCH | COMMAND |
+			| main   | git init |
+			| main   | git pull |`[1:],
+			want: `
+Feature: test
+  Scenario: test
+    Then Git Town runs the commands
+      | BRANCH | COMMAND |
+      | main | git init |
+      | main | git pull |
+    And done`[1:],
+		},
+		{
+			name: "old table has more empty lines",
+			file: `
+Feature: test
+  Scenario: test
+    Then Git Town runs the commands
+      | BRANCH | COMMAND |
+      | main   | git fetch |
+    And done`[1:],
+			oldTable: `
+
+			| BRANCH | COMMAND |
+			| main | git fetch |
+
+`,
+			newTable: `
+			| BRANCH | COMMAND |
+			| main | git init |
+			| main | git pull |`[1:],
+			want: `
+Feature: test
+  Scenario: test
+    Then Git Town runs the commands
+      | BRANCH | COMMAND |
+      | main | git init |
+      | main | git pull |
+    And done`[1:],
+		},
+		{
+			name: "newTable has more empty lines",
+			file: `
+Feature: test
+  Scenario: test
+    Then Git Town runs the commands
+      | BRANCH | COMMAND |
+      | main   | git fetch |
+    And done`[1:],
+			oldTable: `
+			| BRANCH | COMMAND |
+			| main | git fetch |`[1:],
+			newTable: `
+
+			| BRANCH | COMMAND |
+			| main | git init |
+			| main | git pull |
+
+`,
+			want: `
+Feature: test
+  Scenario: test
+    Then Git Town runs the commands
+      | BRANCH | COMMAND |
+      | main | git init |
+      | main | git pull |
+    And done`[1:],
+		},
+		{
+			name: "SHA placeholders",
+			file: `
+Feature: test
+  Scenario: test
+    Then Git Town runs the commands
+      | BRANCH | COMMAND |
+      | main   | git reset --hard {{ sha 'commit' }} |
+    And done`[1:],
+			oldTable: `
+      | BRANCH | COMMAND                                                   |
+      | main | git reset --hard d721118fcd545d37e87100b22ef13169160bdb3c |`[1:],
+			newTable: `
+			| BRANCH | COMMAND                                                   |
+			| main | git reset --soft d721118fcd545d37e87100b22ef13169160bdb3c |`[1:],
+			want: `
+Feature: test
+  Scenario: test
+    Then Git Town runs the commands
+      | BRANCH | COMMAND |
+      | main | git reset --soft d721118fcd545d37e87100b22ef13169160bdb3c |
+    And done`[1:],
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+			tmpDir := t.TempDir()
+			tmpFile := filepath.Join(tmpDir, "test.feature")
+			err := os.WriteFile(tmpFile, []byte(tt.file), 0o600)
+			must.NoError(t, err)
+
+			err = cucumber.ChangeFeatureFile(tmpFile, tt.oldTable, tt.newTable)
+			must.NoError(t, err)
+
+			result, err := os.ReadFile(tmpFile)
+			must.NoError(t, err)
+			must.EqOp(t, tt.want, string(result))
+		})
+	}
+}
diff --git a/internal/test/cucumber/scenario_state.go b/internal/test/cucumber/scenario_state.go
index e1605e5ed..f40ece006 100644
--- a/internal/test/cucumber/scenario_state.go
+++ b/internal/test/cucumber/scenario_state.go
@@ -96,11 +96,15 @@ func (self *ScenarioState) CaptureState() {
 
 // compareExistingCommits compares the commits in the Git environment of the given ScenarioState
 // against the given Gherkin table.
-func (self *ScenarioState) compareGherkinTable(table *godog.Table) error {
+func (self *ScenarioState) compareGherkinTable(table *godog.Table, scenarioURI string) error {
 	fields := helpers.TableFields(table)
 	commitTable := self.fixture.CommitTable(fields)
 	diff, errorCount := commitTable.EqualGherkin(table)
 	if errorCount != 0 {
+		if CukeUpdate {
+			expectedTable := datatable.FromGherkin(table)
+			return ChangeFeatureFile(scenarioURI, expectedTable.String(), commitTable.String())
+		}
 		fmt.Printf("\nERROR! Found %d differences in the existing commits\n\n", errorCount)
 		fmt.Println(diff)
 		return errors.New("mismatching commits found, see diff above")
diff --git a/internal/test/cucumber/steps.go b/internal/test/cucumber/steps.go
index 4767452cd..5b0cc2734 100644
--- a/internal/test/cucumber/steps.go
+++ b/internal/test/cucumber/steps.go
@@ -43,6 +43,9 @@ import (
 // the global FixtureFactory instance.
 var fixtureFactory *fixture.Factory
 
+// CukeUpdate indicates whether to update .feature files with actual command output when tests fail
+var CukeUpdate bool
+
 // dedicated type for storing data in context.Context
 type key int
 
@@ -51,12 +54,14 @@ const (
 	keyScenarioState key = iota
 	keyScenarioName
 	keyScenarioTags
+	keyScenarioURI
 )
 
 func InitializeScenario(scenarioContext *godog.ScenarioContext) {
 	scenarioContext.Before(func(ctx context.Context, scenario *godog.Scenario) (context.Context, error) {
 		ctx = context.WithValue(ctx, keyScenarioName, scenario.Name)
 		ctx = context.WithValue(ctx, keyScenarioTags, scenario.Tags)
+		ctx = context.WithValue(ctx, keyScenarioURI, scenario.Uri)
 		return ctx, nil
 	})
 
@@ -477,6 +482,10 @@ func defineSteps(sc *godog.ScenarioContext) {
 		})
 		diff, errorCount := table.EqualDataTable(expanded)
 		if errorCount != 0 {
+			if CukeUpdate {
+				scenarioURI := ctx.Value(keyScenarioURI).(string)
+				return ChangeFeatureFile(scenarioURI, expanded.String(), table.String())
+			}
 			fmt.Printf("\nERROR! Found %d differences in the commands run\n\n", errorCount)
 			fmt.Println(diff)
 			return errors.New("mismatching commands run, see diff above")
@@ -1457,7 +1466,8 @@ func defineSteps(sc *godog.ScenarioContext) {
 				return errors.New(`please use the step "the initial commits exist now" instead`)
 			}
 		}
-		return state.compareGherkinTable(table)
+		scenarioURI := ctx.Value(keyScenarioURI).(string)
+		return state.compareGherkinTable(table, scenarioURI)
 	})
 
 	sc.Step(`^these committed files exist now$`, func(ctx context.Context, table *godog.Table) error {
@@ -1466,6 +1476,11 @@ func defineSteps(sc *godog.ScenarioContext) {
 		fileTable := devRepo.FilesInBranches("main")
 		diff, errorCount := fileTable.EqualGherkin(table)
 		if errorCount != 0 {
+			if CukeUpdate {
+				scenarioURI := ctx.Value(keyScenarioURI).(string)
+				expectedTable := datatable.FromGherkin(table)
+				return ChangeFeatureFile(scenarioURI, expectedTable.String(), fileTable.String())
+			}
 			fmt.Printf("\nERROR! Found %d differences in the existing files\n\n", errorCount)
 			fmt.Println(diff)
 			return errors.New("mismatching files found, see diff above")
diff --git a/main_test.go b/main_test.go
index f407278da..06d8418e4 100644
--- a/main_test.go
+++ b/main_test.go
@@ -25,6 +25,7 @@ func TestMain(_ *testing.M) {
 	flagSmoke := len(os.Getenv("smoke")) > 0
 	flagMessyOutput := os.Getenv("messyoutput")
 	flagVerbose := len(os.Getenv("verbose")) > 0
+	cucumber.CukeUpdate = len(os.Getenv("cukeupdate")) > 0
 	switch {
 	case flagThis:
 		options.Format = "pretty"