Repository: remotemobprogramming/mob
Pull request #163 (2021-04-01 21:27:35)

Keep micro commits


Patch:
diff --git a/README.md b/README.md
index 0427164..bb58835 100644
--- a/README.md
+++ b/README.md
@@ -121,6 +121,12 @@ Basic Commands(Options):
   reset
     [--branch|-b <branch-postfix>]       Set wip branch to 'mob/<base-branch>/<branch-postfix>'
 
+Experimental Commands:
+  squash-wip                             Combines wip commits in wip branch with subsequent manual commits to leave only manual commits.
+                                         ! Works only if all wip commits have the same wip commit message !
+    [--git-editor]                       Not intended for manual use. Used as a non-interactive editor (GIT_EDITOR) for git.
+    [--git-sequence-editor]              Not intended for manual use. Used as a non-interactive sequence editor (GIT_SEQUENCE_EDITOR) for git.
+
 Timer Commands:
   timer <minutes>    start a <minutes> timer
   start <minutes>    start mob session in wip branch and a timer
diff --git a/mob.go b/mob.go
index 75794ca..f67913d 100644
--- a/mob.go
+++ b/mob.go
@@ -49,6 +49,10 @@ func (c Configuration) hasCustomCommitMessage() bool {
 	return getDefaultConfiguration().WipCommitMessage != c.WipCommitMessage
 }
 
+func (c Configuration) isWipCommitMessage(line string) bool {
+	return line == c.WipCommitMessage
+}
+
 func main() {
 	parseDebug(os.Args)
 
@@ -298,6 +302,14 @@ func execute(command string, parameter []string, configuration Configuration) {
 		}
 	case "moo":
 		moo(configuration)
+	case "sw", "squash-wip":
+		if len(parameter) > 1 && parameter[0] == "--git-editor" {
+			squashWipGitEditor(parameter[1], configuration)
+		} else if len(parameter) > 1 && parameter[0] == "--git-sequence-editor" {
+			squashWipGitSequenceEditor(parameter[1], configuration)
+		} else {
+			squashWip(configuration)
+		}
 	case "version", "--version", "-v":
 		version()
 	case "help", "--help", "-h":
@@ -737,6 +749,17 @@ func sayLastCommitsList(currentBaseBranch string, currentWipBranch string) {
 	say(output)
 }
 
+func sayLastCommitsWithMessage(currentBaseBranch string, currentWipBranch string) {
+	log := silentgit("--no-pager", "log", currentBaseBranch+".."+currentWipBranch, "--pretty=oneline", "--abbrev-commit")
+	lines := strings.Split(log, "\n")
+	if len(lines) > 10 {
+		sayInfo("This mob branch contains " + strconv.Itoa(len(lines)) + " commits. The last 10 were:")
+		lines = lines[:10]
+	}
+	output := strings.Join(lines, "\n")
+	say(output)
+}
+
 func isNothingToCommit() bool {
 	output := silentgit("status", "--short")
 	return len(output) == 0
@@ -860,6 +883,8 @@ Basic Commands:
   next               handover changes in wip branch to next person
   done               squashes all changes in wip branch to index in base branch
   reset              removes local and remote wip branch
+  squash-wip         combines wip commits in wip branch with subsequent manual commits to leave only manual commits 
+                     ! Works only if all wip commits have the same wip message !
 
 Basic Commands(Options):
   start [<minutes>]                      Start a <minutes> timer
@@ -874,6 +899,15 @@ Basic Commands(Options):
     [--squash]                           Squash commits from wip branch
   reset 
     [--branch|-b <branch-postfix>]       Set wip branch to 'mob/<base-branch>/<branch-postfix>'
+  squash-wip 
+    [--git-editor]                       Not intended for manual use. Used as a non-interactive editor (GIT_EDITOR) for git.
+    [--git-sequence-editor]              Not intended for manual use. Used as a non-interactive sequence editor (GIT_SEQUENCE_EDITOR) for git.
+
+Experimental Commands:
+  squash-wip                             Combines wip commits in wip branch with subsequent manual commits to leave only manual commits.
+                                         ! Works only if all wip commits have the same wip commit message !
+    [--git-editor]                       Not intended for manual use. Used as a non-interactive editor (GIT_EDITOR) for git.
+    [--git-sequence-editor]              Not intended for manual use. Used as a non-interactive sequence editor (GIT_SEQUENCE_EDITOR) for git.
 
 Timer Commands:
   timer <minutes>    start a <minutes> timer
diff --git a/squash_wip.go b/squash_wip.go
new file mode 100644
index 0000000..73dd97a
--- /dev/null
+++ b/squash_wip.go
@@ -0,0 +1,148 @@
+package main
+
+import (
+	"io"
+	"io/ioutil"
+	"os"
+	"strings"
+)
+
+type Replacer func(string) string
+
+func squashWip(configuration Configuration) {
+	if !isMobProgramming(configuration) {
+		sayError("you aren't mob programming")
+		sayTodo("to start mob programming, use", "mob start")
+		return
+	}
+
+	if endsWithWipCommit(configuration) {
+		sayError(`failed to squash wip commits
+last commit must be a manual commit`)
+		sayEmptyLine()
+		sayTodo("create a manual commit with a commit message to fix this:", "git commit --allow-empty -m \"your message goes here\"")
+		return
+	}
+
+	currentBaseBranch, currentWipBranch := determineBranches(gitCurrentBranch(), gitBranches(), configuration)
+	mergeBase := silentgit("merge-base", currentWipBranch, currentBaseBranch)
+
+	originalGitEditor, originalGitSequenceEditor := getEnvGitEditor()
+	setEnvGitEditor(
+		mobExecutable()+" squash-wip --git-editor",
+		mobExecutable()+" squash-wip --git-sequence-editor",
+	)
+	silentgit("rebase", "-i", "--keep-empty", mergeBase)
+	setEnvGitEditor(originalGitEditor, originalGitSequenceEditor)
+	sayInfo("the history of your " + currentWipBranch + " branch has been rewritten to combine all wip commits with their following manual commits:")
+	sayEmptyLine()
+	sayLastCommitsWithMessage(currentBaseBranch, currentWipBranch)
+	sayEmptyLine()
+	sayTodo("to finally put the changes into the base branch preserving the resulting commits, call:", "mob done --no-squash")
+}
+
+func setEnvGitEditor(gitEditor string, gitSequenceEditor string) {
+	os.Setenv("GIT_EDITOR", gitEditor)
+	os.Setenv("GIT_SEQUENCE_EDITOR", gitSequenceEditor)
+}
+
+func getEnvGitEditor() (gitEditor string, gitSequenceEditor string) {
+	gitEditor = os.Getenv("GIT_EDITOR")
+	gitSequenceEditor = os.Getenv("GIT_SEQUENCE_EDITOR")
+	return
+}
+
+func mobExecutable() string {
+	if isTestEnvironment() {
+		wd, _ := os.Getwd()
+		return "go run $(ls -1 " + wd + "/*.go | grep -v _test.go)"
+	} else {
+		return "mob"
+	}
+}
+
+func isTestEnvironment() bool {
+	return strings.HasSuffix(os.Args[0], ".test") ||
+		strings.HasSuffix(os.Args[0], "_test") ||
+		os.Args[1] == "-test.v"
+}
+
+// used for non-interactive fixing of commit messages of squashed commits
+func squashWipGitEditor(fileName string, configuration Configuration) {
+	replaceFileContents(fileName, func(input string) string {
+		return commentWipCommits(input, configuration)
+	})
+}
+
+// used for non-interactive rebase to squash post-wip-commits
+func squashWipGitSequenceEditor(fileName string, configuration Configuration) {
+	replaceFileContents(fileName, func(input string) string {
+		return markPostWipCommitsForSquashing(input, configuration)
+	})
+}
+
+func replaceFileContents(fileName string, replacer Replacer) {
+	file, err := os.OpenFile(fileName, os.O_RDWR, 0666)
+	input, err := ioutil.ReadAll(file)
+	if err != nil {
+		panic(err)
+	}
+
+	result := replacer(string(input))
+
+	file.Seek(0, io.SeekStart)
+	file.Truncate(0)
+	file.WriteString(result)
+	file.Close()
+}
+
+func commentWipCommits(input string, configuration Configuration) string {
+	var result []string
+	for _, line := range strings.Split(input, "\n") {
+		if configuration.isWipCommitMessage(line) {
+			result = append(result, "# "+line)
+		} else {
+			result = append(result, line)
+		}
+	}
+	return strings.Join(result, "\n")
+}
+
+func endsWithWipCommit(configuration Configuration) bool {
+	return configuration.isWipCommitMessage(commitsOnCurrentBranch(configuration)[0])
+}
+
+func commitsOnCurrentBranch(configuration Configuration) []string {
+	currentBaseBranch, currentWipBranch := determineBranches(gitCurrentBranch(), gitBranches(), configuration)
+	log := silentgit("--no-pager", "log", currentBaseBranch+".."+currentWipBranch, "--pretty=format:%s")
+	lines := strings.Split(log, "\n")
+	return lines
+}
+
+func markPostWipCommitsForSquashing(input string, configuration Configuration) string {
+	var result []string
+
+	var squashNext = false
+	for _, line := range strings.Split(input, "\n") {
+		if squashNext && isPick(line) {
+			result = append(result, markSquash(line))
+		} else {
+			result = append(result, line)
+		}
+		squashNext = isRebaseWipCommitLine(line, configuration)
+	}
+
+	return strings.Join(result, "\n")
+}
+
+func markSquash(line string) string {
+	return strings.Replace(line, "pick ", "squash ", 1)
+}
+
+func isRebaseWipCommitLine(line string, configuration Configuration) bool {
+	return isPick(line) && strings.HasSuffix(line, configuration.WipCommitMessage)
+}
+
+func isPick(line string) bool {
+	return strings.HasPrefix(line, "pick ")
+}


Test patch:
diff --git a/mob_test.go b/mob_test.go
index 026c45e..72152e0 100644
--- a/mob_test.go
+++ b/mob_test.go
@@ -912,6 +912,18 @@ func setup(t *testing.T) *string {
 	return output
 }
 
+func localSetup(t *testing.T) (output *string, configuration Configuration) {
+	configuration = getDefaultConfiguration()
+	configuration.MobNextStay = false
+	output = captureOutput(t)
+	createTestbed(t)
+	assertOnBranch(t, "master")
+	equals(t, []string{"master"}, gitBranches())
+	equals(t, []string{"origin/master"}, gitRemoteBranches())
+	assertNoMobSessionBranches(t, "mob-session")
+	return output, configuration
+}
+
 func captureOutput(t *testing.T) *string {
 	messages := ""
 	printToConsole = func(text string) {
@@ -982,12 +994,14 @@ func createFileAndCommitIt(t *testing.T, filename string, content string, commit
 	git("commit", "-m", commitMessage)
 }
 
-func createFile(t *testing.T, filename string, content string) {
-	d1 := []byte(content)
-	err := ioutil.WriteFile(workingDir+"/"+filename, d1, 0644)
+func createFile(t *testing.T, filename string, content string) (pathToFile string) {
+	contentAsBytes := []byte(content)
+	pathToFile = workingDir + "/" + filename
+	err := ioutil.WriteFile(pathToFile, contentAsBytes, 0644)
 	if err != nil {
 		failWithFailure(t, "creating file "+filename+" with content "+content, "error")
 	}
+	return
 }
 
 func assertOnBranch(t *testing.T, branch string) {
diff --git a/squash_wip_test.go b/squash_wip_test.go
new file mode 100644
index 0000000..9599101
--- /dev/null
+++ b/squash_wip_test.go
@@ -0,0 +1,299 @@
+package main
+
+import (
+	"fmt"
+	"io/ioutil"
+	"os"
+	"testing"
+)
+
+func TestSquashWipCommits_acceptance(t *testing.T) {
+	_, configuration := localSetup(t)
+
+	// change without manual commit
+	start(configuration)
+	createFile(t, "file1.txt", "irrelevant")
+	next(configuration)
+
+	// change with a manual commit
+	start(configuration)
+	createFileAndCommitIt(t, "file2.txt", "irrelevant", "first manual commit")
+	next(configuration)
+
+	// change with a manual commit followed by an uncommited change
+	start(configuration)
+	createFileAndCommitIt(t, "file3.txt", "irrelevant", "second manual commit")
+	createFile(t, "file4.txt", "irrelevant")
+	next(configuration)
+
+	// change with a final manual commit
+	start(configuration)
+	createFileAndCommitIt(t, "file5.txt", "irrelevant", "third manual commit")
+
+	squashWip(configuration)
+
+	assertOnBranch(t, "mob-session")
+	equals(t, []string{
+		"third manual commit",
+		"second manual commit",
+		"first manual commit",
+	}, commitsOnCurrentBranch(configuration))
+}
+
+func TestSquashWipCommits_resetsEnv(t *testing.T) {
+	_, configuration := localSetup(t)
+	start(configuration)
+	createFileAndCommitIt(t, "file1.txt", "irrelevant", "new file")
+	originalGitEditor := "irrelevant"
+	originalGitSequenceEditor := "irrelevant, too"
+	os.Setenv("GIT_EDITOR", originalGitEditor)
+	os.Setenv("GIT_SEQUENCE_EDITOR", originalGitSequenceEditor)
+
+	squashWip(configuration)
+
+	equals(t, originalGitEditor, os.Getenv("GIT_EDITOR"))
+	equals(t, originalGitSequenceEditor, os.Getenv("GIT_SEQUENCE_EDITOR"))
+}
+
+func TestSquashWipCommits_failsOnFinalWipCommit(t *testing.T) {
+	output, configuration := localSetup(t)
+	start(configuration)
+	createFile(t, "file2.txt", "irrelevant")
+	next(configuration)
+	start(configuration)
+
+	squashWip(configuration)
+
+	assertCommitLogContainsMessage(t, gitCurrentBranch(), configuration.WipCommitMessage)
+	assertOutputContains(t, output, "failed to squash wip commits")
+}
+
+func TestSquashWipCommits_failsOnMainBranch(t *testing.T) {
+	output, configuration := localSetup(t)
+
+	squashWip(configuration)
+
+	assertOutputContains(t, output, "you aren't mob programming")
+}
+
+func TestSquashWipCommits_worksWithEmptyCommits(t *testing.T) {
+	_, configuration := localSetup(t)
+
+	// change without manual commit
+	start(configuration)
+	createFile(t, "file1.txt", "irrelevant")
+	next(configuration)
+
+	start(configuration)
+	silentgit("commit", "--allow-empty", "-m ok")
+
+	squashWip(configuration)
+
+	assertOnBranch(t, "mob-session")
+	equals(t, []string{
+		"ok",
+	}, commitsOnCurrentBranch(configuration))
+}
+
+func TestCommitsOnCurrentBranch(t *testing.T) {
+	_, configuration := localSetup(t)
+	createFileAndCommitIt(t, "file1.txt", "irrelevant", "not on branch")
+	silentgit("push")
+	start(configuration)
+	createFileAndCommitIt(t, "file2.txt", "irrelevant", "on branch")
+	createFile(t, "file3.txt", "irrelevant")
+	next(configuration)
+	start(configuration)
+
+	commits := commitsOnCurrentBranch(configuration)
+
+	equals(t, []string{
+		configuration.WipCommitMessage,
+		"on branch",
+	}, commits)
+}
+
+func TestEndsWithWipCommit_finalManualCommit(t *testing.T) {
+	_, configuration := localSetup(t)
+	start(configuration)
+	createFileAndCommitIt(t, "file1.txt", "irrelevant", "new file")
+
+	equals(t, false, endsWithWipCommit(configuration))
+}
+
+func TestEndsWithWipCommit_finalWipCommit(t *testing.T) {
+	_, configuration := localSetup(t)
+	start(configuration)
+	createFile(t, "file1.txt", "irrelevant")
+	next(configuration)
+	start(configuration)
+
+	equals(t, true, endsWithWipCommit(configuration))
+}
+
+func TestEndsWithWipCommit_manualThenWipCommit(t *testing.T) {
+	_, configuration := localSetup(t)
+	start(configuration)
+	createFileAndCommitIt(t, "file1.txt", "irrelevant", "new file")
+	createFile(t, "file2.txt", "irrelevant")
+	next(configuration)
+	start(configuration)
+
+	equals(t, true, endsWithWipCommit(configuration))
+}
+
+func TestEndsWithWipCommit_wipThenManualCommit(t *testing.T) {
+	_, configuration := localSetup(t)
+	start(configuration)
+	createFile(t, "file2.txt", "irrelevant")
+	next(configuration)
+	start(configuration)
+	createFileAndCommitIt(t, "file1.txt", "irrelevant", "new file")
+
+	equals(t, false, endsWithWipCommit(configuration))
+}
+
+func TestMarkSquashWip_singleManualCommit(t *testing.T) {
+	configuration = getDefaultConfiguration()
+	input := `pick c51a56d new file
+
+# Rebase ...`
+
+	result := markPostWipCommitsForSquashing(input, configuration)
+
+	equals(t, input, result)
+}
+
+func TestMarkSquashWip_manyManualCommits(t *testing.T) {
+	configuration = getDefaultConfiguration()
+	input := `pick c51a56d new file
+pick 63ef7a4 another commit
+
+# Rebase ...`
+
+	result := markPostWipCommitsForSquashing(input, configuration)
+
+	equals(t, input, result)
+}
+
+func TestMarkSquashWip_wipCommitFollowedByManualCommit(t *testing.T) {
+	configuration = getDefaultConfiguration()
+	input := fmt.Sprintf(`pick 01a9a31 %s
+pick c51a56d manual commit
+
+# Rebase ...`, configuration.WipCommitMessage)
+	expected := fmt.Sprintf(`pick 01a9a31 %s
+squash c51a56d manual commit
+
+# Rebase ...`, configuration.WipCommitMessage)
+
+	result := markPostWipCommitsForSquashing(input, configuration)
+
+	equals(t, expected, result)
+}
+
+func TestMarkSquashWip_manyWipCommitsFollowedByManualCommit(t *testing.T) {
+	configuration = getDefaultConfiguration()
+	input := fmt.Sprintf(`pick 01a9a31 %[1]s
+pick 01a9a32 %[1]s
+pick 01a9a33 %[1]s
+pick c51a56d manual commit
+
+# Rebase ...`, configuration.WipCommitMessage)
+	expected := fmt.Sprintf(`pick 01a9a31 %[1]s
+squash 01a9a32 %[1]s
+squash 01a9a33 %[1]s
+squash c51a56d manual commit
+
+# Rebase ...`, configuration.WipCommitMessage)
+
+	result := markPostWipCommitsForSquashing(input, configuration)
+
+	equals(t, expected, result)
+}
+
+func TestCommentWipCommits_oneWipAndOneManualCommit(t *testing.T) {
+	configuration = getDefaultConfiguration()
+	input := fmt.Sprintf(`# This is a combination of 2 commits.
+# This is the 1st commit message:
+
+%s
+
+# This is the commit message #2:
+
+manual commit
+
+# Please enter ...`, configuration.WipCommitMessage)
+	expected := fmt.Sprintf(`# This is a combination of 2 commits.
+# This is the 1st commit message:
+
+# %s
+
+# This is the commit message #2:
+
+manual commit
+
+# Please enter ...`, configuration.WipCommitMessage)
+
+	result := commentWipCommits(input, configuration)
+
+	equals(t, expected, result)
+}
+
+func TestSquashWipCommitGitEditor(t *testing.T) {
+	configuration := getDefaultConfiguration()
+	createTestbed(t)
+	input := createFile(t, "commits", fmt.Sprintf(
+		`# This is a combination of 2 commits.
+# This is the 1st commit message:
+
+%s
+
+# This is the commit message #2:
+
+new file
+
+# Please enter the commit message for your changes. Lines starting`, configuration.WipCommitMessage))
+	expected := fmt.Sprintf(
+		`# This is a combination of 2 commits.
+# This is the 1st commit message:
+
+# %s
+
+# This is the commit message #2:
+
+new file
+
+# Please enter the commit message for your changes. Lines starting`, configuration.WipCommitMessage)
+
+	squashWipGitEditor(input, getDefaultConfiguration())
+
+	result, _ := ioutil.ReadFile(input)
+	equals(t, expected, string(result))
+}
+
+func TestSquashWipCommitGitSequenceEditor(t *testing.T) {
+	createTestbed(t)
+	configuration = getDefaultConfiguration()
+	input := createFile(t, "rebase", fmt.Sprintf(
+		`pick 01a9a31 %[1]s
+pick 01a9a32 %[1]s
+pick 01a9a33 %[1]s
+pick c51a56d manual commit
+
+# Rebase ...
+`, configuration.WipCommitMessage))
+	expected := fmt.Sprintf(
+		`pick 01a9a31 %[1]s
+squash 01a9a32 %[1]s
+squash 01a9a33 %[1]s
+squash c51a56d manual commit
+
+# Rebase ...
+`, configuration.WipCommitMessage)
+
+	squashWipGitSequenceEditor(input, getDefaultConfiguration())
+
+	result, _ := ioutil.ReadFile(input)
+	equals(t, expected, string(result))
+}