Repository: remotemobprogramming/mob (go)

Issue:
Automatically Populate Co-Authored-By lines
Because each of the WIP commits is generated by the authors in the group, it is trivial to aggregate the WIP commits and generate a unique list of Co-Author lines. I have written a little wrapper script in bash that does this when I call the done command.

```bash
CURRENT_BRANCH=`git rev-parse --abbrev-ref HEAD`
FROM_BRANCH=master
if [[ "$CURRENT_BRANCH" != "mob-session" ]]
then
  FROM_BRANCH=`echo "$CURRENT_BRANCH" | awk -F/ '{print $2}'`
fi

CO_AUTHOR_LINES=$(git log $FROM_BRANCH.. --pretty=format:"Co-Authored-By: %an <%ae>" | sort | uniq | grep -v `git config user.email`)

mob done

printf "\nRecommended Co-Author Lines have been copied to your clipboard:\n\n"
printf "$CO_AUTHOR_LINES"
printf "\n\n\n$CO_AUTHOR_LINES" | pbcopy
```

I think it would be cool to integrate this into mob done command. I am possibly willing to submit a PR but wanted to get a sense of the maintainers' opinion on this before I put in the effort.

Some things to consider:
- My implementation is not very robust at computing branch names. It is sufficient for my needs. If integrated into the mob done command, then of course this would be improved.
- Right now all I am doing is copying it to the user's clipboard. I'm assuming OSX and therefore using pbcopy. If integrated into mob done, I would need a cross-platform solution for this.
- Ideally, I would love to auto-populate this into a template so it just automatically shows up in the user's editor when they do their next commit. I haven't figured out a way to do this though. Maybe copying to the clipboard is the best we can do, but I'm curious if any git masters out there know a way to make this more seamless.

Pull request description:
Automatically append `Co-authored-by:` lines after squash
In draft mode for discussion:

- fixes #81
- includes a TestStartDoneCoAuthors() test case
- includes a refactored create-testbed that adds more local repos and sets unique committers in each to exercise coauthors code

Implementation in mob.go will need (approximately):

- an appendCoAuthors() function, called by done(), modeled after the bash code in https://github.com/remotemobprogramming/mob/issues/81#issuecomment-770440672 
  - include a "Co-authored-by: Dustin McQuay <dmcquay@gmail.com>" header in this commit
- a gitLogCoAuthors() function, modeled after gitBranches(), called by appendCoAuthors().  We would call the following `git log` command to get the list of authors on the mob branch, splitting each resulting line on comma to get the username and email.  We would also need to remove duplicates from the resulting list, and would need to remove the user who is running `mob done` before returning the list to appendCoAuthors().
```
runCommand("git",  "log", fmt.Sprintf("%s..", currentBaseBranch), "--reverse", "--pretty=format:%an,%ae")
```
- a gitUserEmail() function, modeled after gitUserName(), both called by gitLogCoAuthors(), so we can remove the current user from the coauthors list

I'll recommend that we write a separate test case for each of the above, starting from the bottom, before implementing each.

If there are no objections, I'd like to change the `/tmp/mob` basedir used for testing. To reduce risk, this should either be in e.g. a ./var directory that is in .gitignore, or if it must for some reason be in /tmp then we ought to use a `tempfile`-style algorithm to prevent collisions.  An idiomatic method would be to call t.TempDir() and pass that path to create-testbed.

If create-testbed needs to be touched much more, it may make sense to port it to Go and move it into mob_test.go -- that would also get rid of the duplication of hardcoded paths between those two files.  I'm thinking we should try to avoid that extra work for this PR though.

We'll work this locally when it comes up in our mobbing queue, and I'll also keep an eye on pull requests against https://github.com/t7a/mob/tree/append-coauthors in case anyone else wants to add commits.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index adfcbae..ad8f905 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
 - The list of commits included in a mob branch is now truncated to a maximum of 5 entries to prevent the need for scrolling up in order to see the latest included changes.
 - Show more informative error message when `mob <cmd>` is run outside of a git repository.
 - Add environment variable MOB_TIMER which allows setting a default timer duration for `mob start` and `mob timer` commands.
+- Add automatic co-author attribution. Mob will collect all committers from a WIP branch and add them as co-authors in the final WIP commit.
 
 # 1.3.0
 - The default `MOB_COMMIT_MESSAGE` now includes `[ci skip]` and `[skip ci]` so that all the typical CI systems (including Azure DevOps) will skip this commit.
diff --git a/coauthors.go b/coauthors.go
new file mode 100644
index 0000000..76a7d74
--- /dev/null
+++ b/coauthors.go
@@ -0,0 +1,110 @@
+package main
+
+import (
+	"bufio"
+	"fmt"
+	"os"
+	"path"
+	"regexp"
+	"sort"
+	"strings"
+)
+
+// Author is a coauthor "Full Name <email>"
+type Author = string
+
+func collectCoauthorsFromWipCommits(file *os.File) []Author {
+	// Here we parse the SQUASH_MSG file for the list of authors on
+	// the WIP branch.  If this technique later turns out to be
+	// problematic, an alternative would be to instead fetch the
+	// authors' list from the git log, using e.g.:
+	//
+	// silentgit("log", fmt.Sprintf("%s..", currentBaseBranch), "--reverse", "--pretty=format:%an <%ae>")
+	//
+	// For details and background, see https://github.com/remotemobprogramming/mob/issues/81
+
+	committerEmail := gitUserEmail()
+	coauthorsHashSet := make(map[Author]bool)
+
+	authorOrCoauthorMatcher := regexp.MustCompile("(?i).*(author)+.+<+.*>+")
+	scanner := bufio.NewScanner(file)
+
+	for scanner.Scan() {
+		line := scanner.Text()
+		if authorOrCoauthorMatcher.MatchString(line) {
+			author := stripToAuthor(line)
+
+			// committer of this commit should
+			// not be included as a co-author
+			if strings.Contains(author, committerEmail) {
+				continue
+			}
+			coauthorsHashSet[author] = true
+		}
+	}
+
+	coauthors := make([]string, 0, len(coauthorsHashSet))
+
+	for k := range coauthorsHashSet {
+		coauthors = append(coauthors, k)
+	}
+	sort.Sort(byLength(coauthors))
+
+	return coauthors
+}
+
+func appendCoauthorsToSquashMsg(workingDir string) error {
+	squashMsgPath := path.Join(workingDir, ".git", "SQUASH_MSG")
+	file, err := os.OpenFile(squashMsgPath, os.O_APPEND|os.O_RDWR, 0644)
+	if err != nil {
+		if err == os.ErrNotExist {
+			// No wip commits, nothing to squash, this isn't really an error
+			return nil
+		}
+		return err
+	}
+
+	defer file.Close()
+
+	coauthors := collectCoauthorsFromWipCommits(file)
+
+	if len(coauthors) > 0 {
+		coauthorSuffix := "\n\n"
+		coauthorSuffix += "# mob automatically added all co-authors from WIP commits\n"
+		coauthorSuffix += "# add missing co-authors manually\n"
+
+		coauthorSuffix += coauthorsForCommitMsg(coauthors)
+
+		writer := bufio.NewWriter(file)
+		_, err = writer.WriteString(coauthorSuffix)
+		err = writer.Flush()
+	}
+
+	return err
+}
+
+func coauthorsForCommitMsg(coauthors []Author) string {
+	var commitMsg string
+
+	for _, coauthor := range coauthors {
+		commitMsg += fmt.Sprintf("Co-authored-by: %s\n", coauthor)
+	}
+
+	return commitMsg
+}
+
+type byLength []string
+
+func (s byLength) Len() int {
+	return len(s)
+}
+func (s byLength) Swap(i, j int) {
+	s[i], s[j] = s[j], s[i]
+}
+func (s byLength) Less(i, j int) bool {
+	return len(s[i]) < len(s[j])
+}
+
+func stripToAuthor(line string) string {
+	return strings.TrimSpace(strings.Join(strings.Split(line, ":")[1:], ""))
+}
diff --git a/mob.go b/mob.go
index 5973f6c..af49348 100644
--- a/mob.go
+++ b/mob.go
@@ -624,8 +624,8 @@ func next(configuration Configuration) {
 			sayInfo("nothing was done, so nothing to commit")
 		}
 	} else {
-		git("add", "--all")
-		git("commit", "--message", configuration.WipCommitMessage, "--no-verify")
+		makeWipCommit()
+
 		changes := getChangesOfLastCommit()
 		git("push", "--no-verify", configuration.RemoteName, currentWipBranch)
 		say(changes)
@@ -645,6 +645,12 @@ func getCachedChanges() string {
 	return strings.TrimSpace(silentgit("diff", "--cached", "--stat"))
 }
 
+func makeWipCommit() {
+	commitMsg := configuration.WipCommitMessage
+	git("add", "--all")
+	git("commit", "--message", commitMsg, "--no-verify")
+}
+
 func done(configuration Configuration) {
 	if !isMobProgramming(configuration) {
 		sayError("you aren't mob programming")
@@ -658,8 +664,7 @@ func done(configuration Configuration) {
 
 	if hasRemoteBranch(currentWipBranch, configuration) {
 		if !isNothingToCommit() {
-			git("add", "--all")
-			git("commit", "--message", configuration.WipCommitMessage, "--no-verify")
+			makeWipCommit()
 		}
 		git("push", "--no-verify", configuration.RemoteName, currentWipBranch)
 
@@ -674,6 +679,10 @@ func done(configuration Configuration) {
 		git("push", "--no-verify", configuration.RemoteName, "--delete", currentWipBranch)
 
 		say(getCachedChanges())
+		err := appendCoauthorsToSquashMsg(workingDir)
+		if err != nil {
+			sayError(err.Error())
+		}
 		sayTodo("To finish, use", "git commit")
 	} else {
 		git("checkout", currentBaseBranch)
@@ -788,6 +797,10 @@ func gitUserName() string {
 	return strings.TrimSpace(silentgit("config", "--get", "user.name"))
 }
 
+func gitUserEmail() string {
+	return strings.TrimSpace(silentgit("config", "--get", "user.email"))
+}
+
 func showNext(configuration Configuration) {
 	debugInfo("determining next person based on previous changes")
 
@@ -914,6 +927,7 @@ func gitignorefailure(args ...string) error {
 	}
 	return err
 }
+
 func runCommand(name string, args ...string) (string, string, error) {
 	command := exec.Command(name, args...)
 	if len(workingDir) > 0 {


Test patch:
diff --git a/coauthors_test.go b/coauthors_test.go
new file mode 100644
index 0000000..813dbda
--- /dev/null
+++ b/coauthors_test.go
@@ -0,0 +1,44 @@
+package main
+
+import (
+	"testing"
+)
+
+func TestStartDoneCoAuthors(t *testing.T) {
+	setup(t)
+
+	setWorkingDir("/tmp/mob/alice")
+	start(configuration)
+	createFile(t, "file3.txt", "owqe")
+	next(configuration)
+
+	setWorkingDir("/tmp/mob/local")
+	start(configuration)
+	createFile(t, "file1.txt", "asdf")
+	next(configuration)
+
+	setWorkingDir("/tmp/mob/localother")
+	start(configuration)
+	createFile(t, "file2.txt", "asdf")
+	next(configuration)
+
+	setWorkingDir("/tmp/mob/alice")
+	start(configuration)
+	createFile(t, "file4.txt", "zcvx")
+	next(configuration)
+
+	setWorkingDir("/tmp/mob/bob")
+	start(configuration)
+	createFile(t, "file5.txt", "oiuo")
+	next(configuration)
+
+	setWorkingDir("/tmp/mob/local")
+	start(configuration)
+	done(configuration)
+
+	output := run(t, "cat", "/tmp/mob/local/.git/SQUASH_MSG")
+	// don't include the person running `mob done`
+	assertOutputNotContains(t, output, "Co-authored-by: local <local@example.com>")
+	// include everyone else in commit order after removing duplicates
+	assertOutputContains(t, output, "\nCo-authored-by: bob <bob@example.com>\nCo-authored-by: alice <alice@example.com>\nCo-authored-by: localother <localother@example.com>\n")
+}
diff --git a/create-testbed b/create-testbed
index d5ecc47..f655bc6 100755
--- a/create-testbed
+++ b/create-testbed
@@ -13,7 +13,9 @@ createrepo() {
     (cd "${dir}" && git config --local user.email ${name}@example.com)
 }
 
-tmpdir="/tmp/mob" # XXX this should be more unique
+# XXX tmpdir should be unique 
+# see https://github.com/remotemobprogramming/mob/issues/149
+tmpdir="/tmp/mob" 
 localdir="${tmpdir}/local"
 remotedir="${tmpdir}/remote"
 notgit="${tmpdir}/notgit"
diff --git a/mob_test.go b/mob_test.go
index 44c16ec..a15a982 100644
--- a/mob_test.go
+++ b/mob_test.go
@@ -2,7 +2,7 @@ package main
 
 import (
 	"errors"
-	fmt "fmt"
+	"fmt"
 	"io/ioutil"
 	"os"
 	"path/filepath"