Repository: remotemobprogramming/mob
Pull request #245 (2022-03-17 18:10:51)

Open last modified file of previous typist
One thing I was thinking about is, does it make sense to set defaults here or should I just print a warning and do nothing if the open command is not set. 


Patch:
diff --git a/README.md b/README.md
index 29048df..8708542 100644
--- a/README.md
+++ b/README.md
@@ -287,6 +287,13 @@ Say you're a larger team and work on the same git repository using ticket number
 It's easy to forget exporting the room that enables the integration with timer.mob.sh.
 Just set the configuration option `MOB_TIMER_ROOM_USE_WIP_BRANCH_QUALIFIER=true` in `~/.mob` for that.
 
+### Automatically open the last modified file of the previous typist
+
+When you are rotating the typist, you often need to open the file, which the previous typist has modified last.
+Mob supports you and can automate this step. You just need the configuration option `MOB_OPEN_COMMAND` with the command to open a file in your preferred IDE. 
+
+For example if you want use IntelliJ the configuration option would look like this: `MOB_OPEN_COMMAND=idea %s`
+
 ## More on Installation
 
 ### Known Issues
@@ -348,6 +355,7 @@ MOB_WIP_BRANCH_QUALIFIER=""
 MOB_WIP_BRANCH_QUALIFIER_SEPARATOR="-"
 MOB_WIP_BRANCH_PREFIX="mob/"
 MOB_DONE_SQUASH=true
+MOB_OPEN_COMMAND="idea %s"
 MOB_TIMER=""
 MOB_TIMER_ROOM="mob"
 MOB_TIMER_ROOM_USE_WIP_BRANCH_QUALIFIER=false
diff --git a/mob.go b/mob.go
index ca0ba96..400a6cf 100644
--- a/mob.go
+++ b/mob.go
@@ -61,6 +61,7 @@ type Configuration struct {
 	WipBranchQualifierSeparator    string // override with MOB_WIP_BRANCH_QUALIFIER_SEPARATOR
 	WipBranchPrefix                string // override with MOB_WIP_BRANCH_PREFIX
 	DoneSquash                     string // override with MOB_DONE_SQUASH
+	OpenCommand                    string // override with MOB_OPEN_COMMAND
 	Timer                          string // override with MOB_TIMER
 	TimerRoom                      string // override with MOB_TIMER_ROOM
 	TimerLocal                     bool   // override with MOB_TIMER_LOCAL
@@ -82,7 +83,19 @@ func (c Configuration) hasCustomCommitMessage() bool {
 }
 
 func (c Configuration) isWipCommitMessage(line string) bool {
-	return line == c.WipCommitMessage
+	return strings.HasPrefix(line, c.WipCommitMessage)
+}
+
+func (c Configuration) openCommandFor(filepath string) (string, []string) {
+	if !c.isOpenCommandGiven() {
+		return "", []string{}
+	}
+	split := strings.Split(injectCommandWithMessage(c.OpenCommand, filepath), " ")
+	return split[0], split[1:]
+}
+
+func (c Configuration) isOpenCommandGiven() bool {
+	return strings.TrimSpace(c.OpenCommand) != ""
 }
 
 type Branch struct {
@@ -273,6 +286,7 @@ func getDefaultConfiguration() Configuration {
 		notifyCommand = "notify-send \"%s\""
 	case "windows":
 		voiceCommand = "(New-Object -ComObject SAPI.SPVoice).Speak(\\\"%s\\\")"
+
 	}
 	return Configuration{
 		CliName:                        "mob",
@@ -289,6 +303,7 @@ func getDefaultConfiguration() Configuration {
 		WipBranchQualifier:             "",
 		WipBranchQualifierSeparator:    "-",
 		DoneSquash:                     Squash,
+		OpenCommand:                    "",
 		Timer:                          "",
 		TimerLocal:                     true,
 		TimerRoom:                      "",
@@ -367,6 +382,8 @@ func parseUserConfiguration(configuration Configuration, path string) Configurat
 			setUnquotedString(&configuration.WipBranchPrefix, key, value)
 		case "MOB_DONE_SQUASH":
 			setMobDoneSquash(&configuration, key, value)
+		case "MOB_OPEN_COMMAND":
+			setUnquotedString(&configuration.OpenCommand, key, value)
 		case "MOB_TIMER":
 			setUnquotedString(&configuration.Timer, key, value)
 		case "MOB_TIMER_ROOM":
@@ -418,7 +435,7 @@ func parseProjectConfiguration(configuration Configuration, path string) Configu
 		debugInfo("Key is " + key)
 		debugInfo("Value is " + value)
 		switch key {
-		case "MOB_VOICE_COMMAND", "MOB_VOICE_MESSAGE", "MOB_NOTIFY_COMMAND", "MOB_NOTIFY_MESSAGE":
+		case "MOB_VOICE_COMMAND", "MOB_VOICE_MESSAGE", "MOB_NOTIFY_COMMAND", "MOB_NOTIFY_MESSAGE", "MOB_OPEN_COMMAND":
 			sayWarning("Skipped overwriting key " + key + " from project/.mob file out of security reasons!")
 		case "MOB_CLI_NAME":
 			setUnquotedString(&configuration.CliName, key, value)
@@ -535,6 +552,8 @@ func parseEnvironmentVariables(configuration Configuration) Configuration {
 
 	setDoneSquashFromEnvVariable(&configuration, "MOB_DONE_SQUASH")
 
+	setStringFromEnvVariable(&configuration.OpenCommand, "MOB_OPEN_COMMAND")
+
 	setStringFromEnvVariable(&configuration.Timer, "MOB_TIMER")
 	setStringFromEnvVariable(&configuration.TimerRoom, "MOB_TIMER_ROOM")
 	setBoolFromEnvVariable(&configuration.TimerRoomUseWipBranchQualifier, "MOB_TIMER_ROOM_USE_WIP_BRANCH_QUALIFIER")
@@ -634,6 +653,7 @@ func config(c Configuration) {
 	say("MOB_WIP_BRANCH_QUALIFIER_SEPARATOR" + "=" + quote(c.WipBranchQualifierSeparator))
 	say("MOB_WIP_BRANCH_PREFIX" + "=" + quote(c.WipBranchPrefix))
 	say("MOB_DONE_SQUASH" + "=" + string(c.DoneSquash))
+	say("MOB_OPEN_COMMAND" + "=" + quote(c.OpenCommand))
 	say("MOB_TIMER" + "=" + quote(c.Timer))
 	say("MOB_TIMER_ROOM" + "=" + quote(c.TimerRoom))
 	say("MOB_TIMER_ROOM_USE_WIP_BRANCH_QUALIFIER" + "=" + strconv.FormatBool(c.TimerRoomUseWipBranchQualifier))
@@ -1063,9 +1083,47 @@ func start(configuration Configuration) error {
 	sayInfo("you are on wip branch '" + currentWipBranch.String() + "' (base branch '" + currentBaseBranch.String() + "')")
 	sayLastCommitsList(currentBaseBranch.String(), currentWipBranch.String())
 
+	openLastModifiedFileIfPresent(configuration)
+
 	return nil // no error
 }
 
+func openLastModifiedFileIfPresent(configuration Configuration) {
+	if !configuration.isOpenCommandGiven() {
+		debugInfo("No open command given")
+		return
+	}
+
+	debugInfo("Try to open last modified file")
+	if !lastCommitIsWipCommit(configuration) {
+		debugInfo("Last commit isn't a WIP commit.")
+		return
+	}
+	lastCommitMessage := lastCommitMessage()
+	split := strings.Split(lastCommitMessage, "lastFile:")
+	if len(split) == 1 {
+		sayWarning("Couldn't find last modified file in commit message!")
+		return
+	}
+	if len(split) > 2 {
+		sayWarning("Could not determine last modified file from commit message, separator was used multiple times!")
+		return
+	}
+	lastModifiedFile := split[1]
+	if lastModifiedFile == "" {
+		debugInfo("Could not find last modified file in commit message")
+		return
+	}
+	lastModifiedFilePath := gitRootDir() + "/" + lastModifiedFile
+	commandname, args := configuration.openCommandFor(lastModifiedFilePath)
+	_, err := startCommand(commandname, args...)
+	if err != nil {
+		sayError(fmt.Sprintf("Couldn't open last modified file on your system (%s)", runtime.GOOS))
+		sayError(err.Error())
+	}
+	debugInfo("Open last modified file: " + lastModifiedFilePath)
+}
+
 func warnForActiveWipBranches(configuration Configuration, currentBaseBranch Branch) {
 	if isMobProgramming(configuration) {
 		return
@@ -1205,11 +1263,78 @@ func getCachedChanges() string {
 
 func makeWipCommit(configuration Configuration) {
 	git("add", "--all")
-	gitWithoutEmptyStrings("commit", "--message", configuration.WipCommitMessage, configuration.gitHooksOption())
+	commitMessage := createWipCommitMessage(configuration)
+	gitWithoutEmptyStrings("commit", "--message", commitMessage, configuration.gitHooksOption())
 	sayInfoIndented(getChangesOfLastCommit())
 	sayInfoIndented(gitCommitHash())
 }
 
+func createWipCommitMessage(configuration Configuration) string {
+	commitMessage := configuration.WipCommitMessage
+
+	lastModifiedFilePath := getPathOfLastModifiedFile()
+	if lastModifiedFilePath != "" {
+		commitMessage += "\n\nlastFile:" + lastModifiedFilePath
+	}
+
+	return commitMessage
+}
+
+// uses git status --short. To work properly files have to be staged.
+func getPathOfLastModifiedFile() string {
+	files := getModifiedFiles()
+	lastModifiedFilePath := ""
+	lastModifiedTime := time.Time{}
+	rootDir := gitRootDir()
+
+	debugInfo("Find last modified file")
+	if len(files) == 1 {
+		lastModifiedFilePath = files[0]
+		debugInfo("Just one modified file: " + lastModifiedFilePath)
+		return lastModifiedFilePath
+	}
+
+	for _, file := range files {
+		absoluteFilepath := rootDir + "/" + file
+		debugInfo(absoluteFilepath)
+		info, err := os.Stat(absoluteFilepath)
+		if err != nil {
+			sayError("Could not get statistics of file: " + absoluteFilepath)
+			sayError(err.Error())
+			continue
+		}
+		modTime := info.ModTime()
+		if modTime.After(lastModifiedTime) {
+			lastModifiedTime = modTime
+			lastModifiedFilePath = file
+		}
+		debugInfo(modTime.String())
+	}
+	return lastModifiedFilePath
+}
+
+// uses git status --short. To work properly files have to be staged.
+func getModifiedFiles() []string {
+	debugInfo("Find modified files")
+	gitstatus := silentgit("status", "--short")
+	lines := strings.Split(gitstatus, "\n")
+	files := []string{}
+	for _, line := range lines {
+		relativeFilepath := ""
+		if strings.HasPrefix(line, "M") {
+			relativeFilepath = strings.TrimPrefix(line, "M")
+		} else if strings.HasPrefix(line, "A") {
+			relativeFilepath = strings.TrimPrefix(line, "A")
+		} else {
+			continue
+		}
+		relativeFilepath = strings.TrimSpace(relativeFilepath)
+		debugInfo(relativeFilepath)
+		files = append(files, relativeFilepath)
+	}
+	return files
+}
+
 func (c Configuration) gitHooksOption() string {
 	if c.GitHooksEnabled {
 		return ""
diff --git a/squash_wip.go b/squash_wip.go
index 3b045e7..404bb00 100644
--- a/squash_wip.go
+++ b/squash_wip.go
@@ -38,11 +38,11 @@ func squashWip(configuration Configuration) {
 }
 
 func lastCommitIsWipCommit(configuration Configuration) bool {
-	return lastCommitMessage() == configuration.WipCommitMessage
+	return strings.HasPrefix(lastCommitMessage(), configuration.WipCommitMessage)
 }
 
 func lastCommitMessage() string {
-	return silentgit("log", "-1", "--pretty=format:%s")
+	return silentgit("log", "-1", "--pretty=format:%B")
 }
 
 func sayLastCommitsWithMessage(currentBaseBranch string, currentWipBranch string) {
@@ -114,8 +114,16 @@ func replaceFileContents(fileName string, replacer Replacer) {
 
 func commentWipCommits(input string, configuration Configuration) string {
 	var result []string
-	for _, line := range strings.Split(input, "\n") {
+	ignoreBlock := false
+	lines := strings.Split(input, "\n")
+	for idx, line := range lines {
 		if configuration.isWipCommitMessage(line) {
+			ignoreBlock = true
+		} else if line == "" && isNextLineComment(lines, idx) {
+			ignoreBlock = false
+		}
+
+		if ignoreBlock {
 			result = append(result, "# "+line)
 		} else {
 			result = append(result, line)
@@ -124,6 +132,10 @@ func commentWipCommits(input string, configuration Configuration) string {
 	return strings.Join(result, "\n")
 }
 
+func isNextLineComment(lines []string, currentLineIndex int) bool {
+	return len(lines) > currentLineIndex+1 && strings.HasPrefix(lines[currentLineIndex+1], "#")
+}
+
 func markPostWipCommitsForSquashing(input string, configuration Configuration) string {
 	var result []string
 
@@ -186,7 +198,7 @@ func isManualCommit(line string, configuration Configuration) bool {
 }
 
 func isWipCommit(line string, configuration Configuration) bool {
-	return strings.HasSuffix(line, configuration.WipCommitMessage)
+	return strings.Contains(line, configuration.WipCommitMessage)
 }
 
 func isPick(line string) bool {


Test patch:
diff --git a/mob_test.go b/mob_test.go
index 10d00b2..660eac5 100644
--- a/mob_test.go
+++ b/mob_test.go
@@ -346,6 +346,7 @@ func TestReadConfigurationFromFileOverrideEverything(t *testing.T) {
 		MOB_WIP_BRANCH_QUALIFIER_SEPARATOR="---"
 		MOB_WIP_BRANCH_PREFIX="ensemble/"
 		MOB_DONE_SQUASH=false
+		MOB_OPEN_COMMAND="idea %s"
 		MOB_TIMER="123"
 		MOB_TIMER_ROOM="Room_42"
 		MOB_TIMER_ROOM_USE_WIP_BRANCH_QUALIFIER=true
@@ -369,6 +370,7 @@ func TestReadConfigurationFromFileOverrideEverything(t *testing.T) {
 	equals(t, "---", actualConfiguration.WipBranchQualifierSeparator)
 	equals(t, "ensemble/", actualConfiguration.WipBranchPrefix)
 	equals(t, NoSquash, actualConfiguration.DoneSquash)
+	equals(t, "idea %s", actualConfiguration.OpenCommand)
 	equals(t, "123", actualConfiguration.Timer)
 	equals(t, "Room_42", actualConfiguration.TimerRoom)
 	equals(t, true, actualConfiguration.TimerRoomUseWipBranchQualifier)
@@ -674,8 +676,88 @@ func TestStartNextStay(t *testing.T) {
 
 	next(configuration)
 
+	equals(t, silentgit("log", "--format=%B", "-n", "1", "HEAD"), configuration.WipCommitMessage+"\n\nlastFile:file1.txt")
+	assertOnBranch(t, "mob-session")
+}
+
+func TestStartNextStay_WriteLastModifiedFileInCommit_WhenFileIsAdded(t *testing.T) {
+	_, configuration := setup(t)
+	configuration.NextStay = true
+
+	start(configuration)
+	createFile(t, "olderFile.txt", "contentIrrelevant")
+	createFile(t, "newerFile.txt", "contentIrrelevant")
+	next(configuration)
+
+	equals(t, silentgit("log", "--format=%B", "-n", "1", "HEAD"), configuration.WipCommitMessage+"\n\nlastFile:newerFile.txt")
+}
+
+func TestStartNextStay_WriteLastModifiedFileInCommit_WhenFileIsModified(t *testing.T) {
+	_, configuration := setup(t)
+	configuration.NextStay = true
+
+	start(configuration)
+	createFile(t, "file1.txt", "contentIrrelevant")
+	createFile(t, "file2.txt", "contentIrrelevant")
+	next(configuration)
+
+	start(configuration)
+	createFile(t, "file1.txt", "contentIrrelevantButModified")
+	next(configuration)
+
+	assertOnBranch(t, "mob-session")
+	equals(t, silentgit("log", "--format=%B", "-n", "1", "HEAD"), configuration.WipCommitMessage+"\n\nlastFile:file1.txt")
+}
+
+func TestStartNextStay_DoNotWriteLastModifiedFileInCommit_WhenFileIsDeleted(t *testing.T) {
+	_, configuration := setup(t)
+	configuration.NextStay = true
+
+	start(configuration)
+	createFile(t, "file1.txt", "contentIrrelevant")
+	createFile(t, "file2.txt", "contentIrrelevant")
+	next(configuration)
+
+	start(configuration)
+	run(t, "rm", workingDir+"/file1.txt")
+	next(configuration)
+
+	assertOnBranch(t, "mob-session")
 	equals(t, silentgit("log", "--format=%B", "-n", "1", "HEAD"), configuration.WipCommitMessage)
+}
+
+func TestStartNextStay_DoNotWriteLastModifiedFileInCommit_WhenFileIsMoved(t *testing.T) {
+	_, configuration := setup(t)
+	configuration.NextStay = true
+
+	start(configuration)
+	createFile(t, "file1.txt", "contentIrrelevant")
+	next(configuration)
+
+	start(configuration)
+	createDirectory(t, "dir")
+	run(t, "mv", workingDir+"/"+"file1.txt", workingDir+"/dir/"+"file1.txt")
+	next(configuration)
+
 	assertOnBranch(t, "mob-session")
+	equals(t, silentgit("log", "--format=%B", "-n", "1", "HEAD"), configuration.WipCommitMessage)
+}
+
+func TestStartNextStay_OpenLastModifiedFile(t *testing.T) {
+	_, configuration := setup(t)
+	configuration.NextStay = true
+	configuration.OpenCommand = "touch %s-1"
+
+	start(configuration)
+	createFile(t, "file.txt", "contentIrrelevant")
+	assertOnBranch(t, "mob-session")
+	next(configuration)
+
+	start(configuration)
+
+	assertGitStatus(t, GitStatus{
+		"file.txt-1": "??",
+	})
 }
 
 func TestStartDoneWithMobDoneSquash(t *testing.T) {