shelley: add co-author trailer to git commits

Philip Zeyliger created

When Shelley runs git commit commands, automatically inject
--trailer "Co-authored-by: Shelley <https://exe.dev/shelley>"
to attribute the commit as co-authored.

Uses AST parsing/modification via mvdan.cc/sh to reliably
inject the trailer flag after 'commit' in any git commit command.

Prompt: In a new worktree (fetch and reset to main) make it so that Shelley commit messages include a coauthored by Shelley line. Give me some approaches to chooose from before you do it

Change summary

claudetool/bash.go                 |  3 +
claudetool/bashkit/bashkit.go      | 82 ++++++++++++++++++++++++++++++++
claudetool/bashkit/bashkit_test.go | 57 ++++++++++++++++++++++
3 files changed, 142 insertions(+)

Detailed changes

claudetool/bash.go 🔗

@@ -198,6 +198,9 @@ func (b *BashTool) Run(ctx context.Context, m json.RawMessage) llm.ToolOut {
 		}
 	}
 
+	// Add co-author trailer to git commits
+	req.Command = bashkit.AddCoauthorTrailer(req.Command, "Co-authored-by: Shelley <shelley@exe.dev>")
+
 	timeout := req.timeout(b.Timeouts)
 
 	// If Background is set to true, use executeBackgroundBash

claudetool/bashkit/bashkit.go 🔗

@@ -1,6 +1,7 @@
 package bashkit
 
 import (
+	"bytes"
 	"fmt"
 	"strings"
 	"sync"
@@ -144,6 +145,87 @@ func hasBlindGitAdd(cmd *syntax.CallExpr) bool {
 	return false
 }
 
+// AddCoauthorTrailer modifies a bash script to add a Co-authored-by trailer
+// to any git commit commands. Returns the modified script.
+func AddCoauthorTrailer(bashScript, trailer string) string {
+	r := strings.NewReader(bashScript)
+	parser := syntax.NewParser(syntax.KeepComments(true))
+	file, err := parser.Parse(r, "")
+	if err != nil {
+		// Can't parse, return original
+		return bashScript
+	}
+
+	modified := false
+	syntax.Walk(file, func(node syntax.Node) bool {
+		callExpr, ok := node.(*syntax.CallExpr)
+		if !ok {
+			return true
+		}
+		if addTrailerToGitCommit(callExpr, trailer) {
+			modified = true
+		}
+		return true
+	})
+
+	if !modified {
+		return bashScript
+	}
+
+	var buf bytes.Buffer
+	printer := syntax.NewPrinter()
+	if err := printer.Print(&buf, file); err != nil {
+		return bashScript
+	}
+	return buf.String()
+}
+
+// addTrailerToGitCommit adds --trailer to a git commit command.
+// Returns true if the command was modified.
+func addTrailerToGitCommit(cmd *syntax.CallExpr, trailer string) bool {
+	if !isGitCommitCommand(cmd) {
+		return false
+	}
+
+	// Find where to insert --trailer (right after "commit")
+	insertIdx := -1
+	for i := 1; i < len(cmd.Args); i++ {
+		if cmd.Args[i].Lit() == "commit" {
+			insertIdx = i + 1
+			break
+		}
+	}
+	if insertIdx < 0 {
+		return false
+	}
+
+	// Create --trailer argument
+	trailerArg := &syntax.Word{
+		Parts: []syntax.WordPart{
+			&syntax.Lit{Value: "--trailer"},
+		},
+	}
+	// Create the trailer value argument
+	trailerVal := &syntax.Word{
+		Parts: []syntax.WordPart{
+			&syntax.DblQuoted{
+				Parts: []syntax.WordPart{
+					&syntax.Lit{Value: trailer},
+				},
+			},
+		},
+	}
+
+	// Insert the two new arguments
+	newArgs := make([]*syntax.Word, 0, len(cmd.Args)+2)
+	newArgs = append(newArgs, cmd.Args[:insertIdx]...)
+	newArgs = append(newArgs, trailerArg, trailerVal)
+	newArgs = append(newArgs, cmd.Args[insertIdx:]...)
+	cmd.Args = newArgs
+
+	return true
+}
+
 // isGitCommitCommand checks if a command is 'git commit'.
 func isGitCommitCommand(cmd *syntax.CallExpr) bool {
 	if len(cmd.Args) < 2 {

claudetool/bashkit/bashkit_test.go 🔗

@@ -482,3 +482,60 @@ func TestEdgeCases(t *testing.T) {
 		})
 	}
 }
+
+func TestAddCoauthorTrailer(t *testing.T) {
+	trailer := "Co-authored-by: Shelley <shelley@exe.dev>"
+	tests := []struct {
+		name   string
+		script string
+		want   string
+	}{
+		{
+			name:   "simple git commit",
+			script: `git commit -m "Add feature"`,
+			want:   `git commit --trailer "Co-authored-by: Shelley <shelley@exe.dev>" -m "Add feature"`,
+		},
+		{
+			name:   "git commit with -am",
+			script: `git commit -am "Fix bug"`,
+			want:   `git commit --trailer "Co-authored-by: Shelley <shelley@exe.dev>" -am "Fix bug"`,
+		},
+		{
+			name:   "no git commit",
+			script: `git status`,
+			want:   `git status`,
+		},
+		{
+			name:   "git with flags before commit",
+			script: `git -C /path/to/repo commit -m "Update"`,
+			want:   `git -C /path/to/repo commit --trailer "Co-authored-by: Shelley <shelley@exe.dev>" -m "Update"`,
+		},
+		{
+			name:   "pipeline with git commit",
+			script: `git add file.go && git commit -m "Add file"`,
+			want:   `git add file.go && git commit --trailer "Co-authored-by: Shelley <shelley@exe.dev>" -m "Add file"`,
+		},
+		{
+			name:   "non-git command",
+			script: `echo hello`,
+			want:   `echo hello`,
+		},
+		{
+			name:   "invalid syntax unchanged",
+			script: `git commit -m 'unterminated`,
+			want:   `git commit -m 'unterminated`,
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			got := AddCoauthorTrailer(tc.script, trailer)
+			// Normalize whitespace for comparison
+			gotNorm := strings.Join(strings.Fields(got), " ")
+			wantNorm := strings.Join(strings.Fields(tc.want), " ")
+			if gotNorm != wantNorm {
+				t.Errorf("AddCoauthorTrailer() =\n%q\nwant:\n%q", got, tc.want)
+			}
+		})
+	}
+}