Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ Flags:
- `datetime`
- `date`
- `commit_hash`
- `github_author_handle`
- `github_author_display_name`
- `text`
- `files_changed`
- `lines_added`
Expand Down
58 changes: 42 additions & 16 deletions internal/gitlog/gitlog.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,21 @@ import (
"time"
)

const prettyFormat = "%x1e%H%x00%cI%x00%s%x00%b%x00"
const prettyFormat = "%x1e%H%x00%cI%x00%aN%x00%aE%x00%s%x00%b%x00"

type Commit struct {
Hash string
CommittedAt time.Time
Title string
Body string
CombinedText string
FilesChanged int
LinesAdded int
LinesDeleted int
Hash string
CommittedAt time.Time
AuthorDisplayName string
AuthorEmail string
GitHubAuthorHandle string
GitHubAuthorDisplayName string
Title string
Body string
CombinedText string
FilesChanged int
LinesAdded int
LinesDeleted int
}

func (c Commit) LinesChanged() int {
Expand Down Expand Up @@ -95,8 +99,8 @@ func ParseLog(data []byte) ([]Commit, error) {
return nil, fmt.Errorf("parse git log record %q: %w", string(rawRecord), errors.New("missing header terminator"))
}

parts := bytes.SplitN(rawRecord, []byte{0x00}, 5)
if len(parts) != 5 {
parts := bytes.SplitN(rawRecord, []byte{0x00}, 7)
if len(parts) != 7 {
return nil, fmt.Errorf("parse git log record %q: %w", string(rawRecord), errors.New("unexpected field count"))
}

Expand All @@ -106,14 +110,18 @@ func ParseLog(data []byte) ([]Commit, error) {
}

commit := Commit{
Hash: string(parts[0]),
CommittedAt: committedAt,
Title: strings.TrimSpace(string(parts[2])),
Body: strings.TrimSpace(string(parts[3])),
Hash: string(parts[0]),
CommittedAt: committedAt,
AuthorDisplayName: strings.TrimSpace(string(parts[2])),
AuthorEmail: strings.TrimSpace(string(parts[3])),
Title: strings.TrimSpace(string(parts[4])),
Body: strings.TrimSpace(string(parts[5])),
}
commit.GitHubAuthorHandle = githubAuthorHandle(commit.AuthorEmail)
commit.GitHubAuthorDisplayName = commit.AuthorDisplayName
commit.CombinedText = joinCommitText(commit.Title, commit.Body)

for _, line := range strings.Split(strings.TrimSpace(string(parts[4])), "\n") {
for _, line := range strings.Split(strings.TrimSpace(string(parts[6])), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
Expand Down Expand Up @@ -169,3 +177,21 @@ func joinCommitText(parts ...string) string {

return strings.Join(nonEmpty, " ")
}

func githubAuthorHandle(email string) string {
email = strings.TrimSpace(strings.ToLower(email))
if !strings.HasSuffix(email, "@users.noreply.github.com") {
return ""
}

localPart := strings.TrimSuffix(email, "@users.noreply.github.com")
if localPart == "" {
return ""
}

if plus := strings.LastIndex(localPart, "+"); plus >= 0 {
localPart = localPart[plus+1:]
}

return strings.TrimSpace(localPart)
}
35 changes: 29 additions & 6 deletions internal/gitlog/gitlog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ func TestParseLog(t *testing.T) {
t.Parallel()

raw := strings.Join([]string{
"\x1eabc123\x002026-04-01T12:00:00Z\x00feat: add cli\x00first body line\nsecond body line\x00",
"\x1eabc123\x002026-04-01T12:00:00Z\x00The Octocat\x00123+octocat@users.noreply.github.com\x00feat: add cli\x00first body line\nsecond body line\x00",
"5\t3\tmain.go",
"2\t1\tREADME.md",
"\x1edef456\x002026-04-02T08:15:00Z\x00fix: handle binary\x00\x00",
"\x1edef456\x002026-04-02T08:15:00Z\x00Monalisa Octocat\x00monalisa@example.com\x00fix: handle binary\x00\x00",
"-\t-\timage.png",
"3\t0\tinternal/gitlog/gitlog.go",
}, "\n")
Expand All @@ -40,17 +40,26 @@ func TestParseLog(t *testing.T) {
if got, want := first.CombinedText, "feat: add cli first body line second body line"; got != want {
t.Fatalf("first.CombinedText = %q, want %q", got, want)
}
if got, want := first.GitHubAuthorHandle, "octocat"; got != want {
t.Fatalf("first.GitHubAuthorHandle = %q, want %q", got, want)
}
if got, want := first.GitHubAuthorDisplayName, "The Octocat"; got != want {
t.Fatalf("first.GitHubAuthorDisplayName = %q, want %q", got, want)
}

second := commits[1]
if got, want := second.FilesChanged, 1; got != want {
t.Fatalf("second.FilesChanged = %d, want %d", got, want)
}
if got := second.GitHubAuthorHandle; got != "" {
t.Fatalf("second.GitHubAuthorHandle = %q, want empty", got)
}
}

func TestParseLogRejectsBadHeader(t *testing.T) {
t.Parallel()

_, err := ParseLog([]byte("\x1eabc123\x002026-04-01T12:00:00Z\x00missing-body"))
_, err := ParseLog([]byte("\x1eabc123\x002026-04-01T12:00:00Z\x00The Octocat\x00octocat@users.noreply.github.com\x00missing-body"))
if err == nil {
t.Fatal("ParseLog() error = nil, want error")
}
Expand All @@ -62,10 +71,10 @@ func TestCollectorCollectSortsCommits(t *testing.T) {
runner := &stubRunner{
outputs: map[string][]byte{
"rev-parse --is-inside-work-tree": []byte("true\n"),
"log --date=iso-strict --numstat --pretty=format:%x1e%H%x00%cI%x00%s%x00%b%x00": []byte(strings.Join([]string{
"\x1eb\x002026-04-02T12:00:00Z\x00second\x00\x00",
"log --date=iso-strict --numstat --pretty=format:%x1e%H%x00%cI%x00%aN%x00%aE%x00%s%x00%b%x00": []byte(strings.Join([]string{
"\x1eb\x002026-04-02T12:00:00Z\x00B\x00b@users.noreply.github.com\x00second\x00\x00",
"1\t1\tb.go",
"\x1ea\x002026-04-01T12:00:00Z\x00first\x00\x00",
"\x1ea\x002026-04-01T12:00:00Z\x00A\x00a@users.noreply.github.com\x00first\x00\x00",
"1\t0\ta.go",
}, "\n")),
},
Expand Down Expand Up @@ -117,6 +126,20 @@ func TestJoinCommitText(t *testing.T) {
}
}

func TestGithubAuthorHandle(t *testing.T) {
t.Parallel()

if got, want := githubAuthorHandle("12345+octocat@users.noreply.github.com"), "octocat"; got != want {
t.Fatalf("githubAuthorHandle() = %q, want %q", got, want)
}
if got, want := githubAuthorHandle("octocat@users.noreply.github.com"), "octocat"; got != want {
t.Fatalf("githubAuthorHandle() = %q, want %q", got, want)
}
if got := githubAuthorHandle("octocat@example.com"); got != "" {
t.Fatalf("githubAuthorHandle() = %q, want empty", got)
}
}

type stubRunner struct {
outputs map[string][]byte
errs map[string]error
Expand Down
2 changes: 2 additions & 0 deletions internal/report/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ func CommitTextRecord(commit gitlog.Commit) []string {
commit.CommittedAt.Format("2006-01-02T15:04:05Z07:00"),
commit.CommittedAt.Format("2006-01-02"),
commit.Hash,
commit.GitHubAuthorHandle,
commit.GitHubAuthorDisplayName,
commit.CombinedText,
strconv.Itoa(commit.FilesChanged),
strconv.Itoa(commit.LinesAdded),
Expand Down
30 changes: 19 additions & 11 deletions internal/report/report_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,24 +11,32 @@ func TestCommitTextRecord(t *testing.T) {
t.Parallel()

commit := gitlog.Commit{
Hash: "abc123",
CommittedAt: time.Date(2026, 4, 1, 10, 0, 0, 0, time.UTC),
Title: "feat: add parser",
Body: "with tests",
CombinedText: "feat: add parser with tests",
FilesChanged: 2,
LinesAdded: 7,
LinesDeleted: 3,
Hash: "abc123",
CommittedAt: time.Date(2026, 4, 1, 10, 0, 0, 0, time.UTC),
GitHubAuthorHandle: "octocat",
GitHubAuthorDisplayName: "The Octocat",
Title: "feat: add parser",
Body: "with tests",
CombinedText: "feat: add parser with tests",
FilesChanged: 2,
LinesAdded: 7,
LinesDeleted: 3,
}

record := CommitTextRecord(commit)
if got, want := record[0], "2026-04-01T10:00:00Z"; got != want {
t.Fatalf("record[0] = %q, want %q", got, want)
}
if got, want := record[3], "feat: add parser with tests"; got != want {
if got, want := record[3], "octocat"; got != want {
t.Fatalf("record[3] = %q, want %q", got, want)
}
if got, want := record[7], "10"; got != want {
t.Fatalf("record[7] = %q, want %q", got, want)
if got, want := record[4], "The Octocat"; got != want {
t.Fatalf("record[4] = %q, want %q", got, want)
}
if got, want := record[5], "feat: add parser with tests"; got != want {
t.Fatalf("record[5] = %q, want %q", got, want)
}
if got, want := record[9], "10"; got != want {
t.Fatalf("record[9] = %q, want %q", got, want)
}
}
2 changes: 1 addition & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ func writeCommitText(path string, commits []gitlog.Commit) error {
writer := csv.NewWriter(file)
defer writer.Flush()

if err := writer.Write([]string{"datetime", "date", "commit_hash", "text", "files_changed", "lines_added", "lines_deleted", "lines_changed"}); err != nil {
if err := writer.Write([]string{"datetime", "date", "commit_hash", "github_author_handle", "github_author_display_name", "text", "files_changed", "lines_added", "lines_deleted", "lines_changed"}); err != nil {
return err
}
for _, commit := range commits {
Expand Down
34 changes: 24 additions & 10 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,16 @@ func TestWriteCommitText(t *testing.T) {

commits := []gitlog.Commit{
{
Hash: "abc123",
CommittedAt: committedAt,
Title: "feat: add reporting",
Body: "body text",
CombinedText: "feat: add reporting body text",
FilesChanged: 1,
LinesAdded: 7,
LinesDeleted: 2,
Hash: "abc123",
CommittedAt: committedAt,
GitHubAuthorHandle: "octocat",
GitHubAuthorDisplayName: "The Octocat",
Title: "feat: add reporting",
Body: "body text",
CombinedText: "feat: add reporting body text",
FilesChanged: 1,
LinesAdded: 7,
LinesDeleted: 2,
},
}

Expand All @@ -38,10 +40,22 @@ func TestWriteCommitText(t *testing.T) {
if got, want := rows[1][2], "abc123"; got != want {
t.Fatalf("commit_hash = %q, want %q", got, want)
}
if got, want := rows[1][3], "feat: add reporting body text"; got != want {
if got, want := rows[0][3], "github_author_handle"; got != want {
t.Fatalf("header github_author_handle = %q, want %q", got, want)
}
if got, want := rows[0][4], "github_author_display_name"; got != want {
t.Fatalf("header github_author_display_name = %q, want %q", got, want)
}
if got, want := rows[1][3], "octocat"; got != want {
t.Fatalf("github_author_handle = %q, want %q", got, want)
}
if got, want := rows[1][4], "The Octocat"; got != want {
t.Fatalf("github_author_display_name = %q, want %q", got, want)
}
if got, want := rows[1][5], "feat: add reporting body text"; got != want {
t.Fatalf("text = %q, want %q", got, want)
}
if got, want := rows[1][7], "9"; got != want {
if got, want := rows[1][9], "9"; got != want {
t.Fatalf("lines_changed = %q, want %q", got, want)
}
}
Expand Down
Loading