feat: read git history with go-git, not the git binary (#15)
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
// Package gitlog reads commit history with go-git (no git binary).
|
||||
package gitlog
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/plumbing/object"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
Limit int
|
||||
Since time.Time
|
||||
}
|
||||
|
||||
type Commit struct {
|
||||
SHA string `json:"sha"`
|
||||
Author string `json:"author"`
|
||||
Email string `json:"email"`
|
||||
Date string `json:"date"`
|
||||
Subject string `json:"subject"`
|
||||
Files []string `json:"files"`
|
||||
}
|
||||
|
||||
type Leaf struct {
|
||||
Source string `json:"source"`
|
||||
Repo string `json:"repo"`
|
||||
Heading string `json:"heading"`
|
||||
Text string `json:"text"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Related string `json:"related"`
|
||||
}
|
||||
|
||||
// Log walks commits from HEAD, newest first, skipping merges.
|
||||
func Log(repo string, opt Options) ([]Commit, error) {
|
||||
r, err := git.PlainOpen(repo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logOpt := &git.LogOptions{Order: git.LogOrderCommitterTime}
|
||||
if !opt.Since.IsZero() {
|
||||
t := opt.Since
|
||||
logOpt.Since = &t
|
||||
}
|
||||
iter, err := r.Log(logOpt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer iter.Close()
|
||||
|
||||
var out []Commit
|
||||
err = iter.ForEach(func(c *object.Commit) error {
|
||||
if c.NumParents() > 1 {
|
||||
return nil
|
||||
}
|
||||
if opt.Limit > 0 && len(out) >= opt.Limit {
|
||||
return Stop
|
||||
}
|
||||
files, ferr := changedFiles(c)
|
||||
if ferr != nil {
|
||||
return ferr
|
||||
}
|
||||
out = append(out, Commit{
|
||||
SHA: c.Hash.String(),
|
||||
Author: c.Author.Name,
|
||||
Email: c.Author.Email,
|
||||
Date: c.Author.When.Format(time.RFC3339),
|
||||
Subject: firstLine(c.Message),
|
||||
Files: files,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if errors.Is(err, Stop) {
|
||||
err = nil
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
// Stop ends a log walk early (limit reached).
|
||||
var Stop = fmt.Errorf("gitlog: stop")
|
||||
|
||||
func changedFiles(c *object.Commit) ([]string, error) {
|
||||
var names []string
|
||||
if c.NumParents() == 0 {
|
||||
t, err := c.Tree()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = t.Files().ForEach(func(f *object.File) error {
|
||||
names = append(names, f.Name)
|
||||
return nil
|
||||
})
|
||||
sort.Strings(names)
|
||||
return names, err
|
||||
}
|
||||
parent, err := c.Parent(0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
from, err := parent.Tree()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
to, err := c.Tree()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
changes, err := object.DiffTree(from, to)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, ch := range changes {
|
||||
name := ch.To.Name
|
||||
if name == "" {
|
||||
name = ch.From.Name
|
||||
}
|
||||
if name != "" {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func firstLine(msg string) string {
|
||||
msg = strings.ReplaceAll(msg, "\r\n", "\n")
|
||||
if i := strings.IndexByte(msg, '\n'); i >= 0 {
|
||||
return strings.TrimSpace(msg[:i])
|
||||
}
|
||||
return strings.TrimSpace(msg)
|
||||
}
|
||||
|
||||
func ToLeaf(c Commit, repo string) Leaf {
|
||||
short := c.SHA
|
||||
if len(short) > 12 {
|
||||
short = short[:12]
|
||||
}
|
||||
head := fmt.Sprintf("commit %s — %s", short, c.Subject)
|
||||
body := []string{
|
||||
fmt.Sprintf("commit %s in %s — %s", short, repo, c.Subject),
|
||||
fmt.Sprintf("Author: %s <%s>", c.Author, c.Email),
|
||||
fmt.Sprintf("Date: %s", c.Date),
|
||||
}
|
||||
if len(c.Files) > 0 {
|
||||
body = append(body, "Changing: "+strings.Join(c.Files, ", "))
|
||||
}
|
||||
return Leaf{
|
||||
Source: repo + "@" + c.SHA,
|
||||
Repo: repo,
|
||||
Heading: head,
|
||||
Text: strings.Join(body, "\n"),
|
||||
Type: "commit",
|
||||
Status: "current",
|
||||
Related: strings.Join(c.Files, ","),
|
||||
}
|
||||
}
|
||||
|
||||
func RepoName(repo string) (string, error) {
|
||||
r, err := git.PlainOpen(repo)
|
||||
if err != nil {
|
||||
return filepath.Base(repo), err
|
||||
}
|
||||
rem, err := r.Remote("origin")
|
||||
if err != nil {
|
||||
return filepath.Base(repo), nil
|
||||
}
|
||||
urls := rem.Config().URLs
|
||||
if len(urls) == 0 {
|
||||
return filepath.Base(repo), nil
|
||||
}
|
||||
u := strings.TrimSuffix(strings.TrimSuffix(urls[0], "/"), ".git")
|
||||
return path.Base(strings.ReplaceAll(u, "\\", "/")), nil
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package gitlog
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/config"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
"github.com/go-git/go-git/v5/plumbing/object"
|
||||
)
|
||||
|
||||
func TestLogReadsCommitsWithoutGitBinary(t *testing.T) {
|
||||
dir := initRepo(t, []commitSpec{
|
||||
{
|
||||
when: time.Date(2026, 8, 10, 12, 0, 0, 0, time.FixedZone("CEST", 3600)),
|
||||
name: "Ada Lovelace",
|
||||
email: "ada@example.com",
|
||||
subject: "feat: first commit",
|
||||
files: map[string]string{"README.md": "hi\n", "src/main.c": "int main(){}\n"},
|
||||
},
|
||||
{
|
||||
when: time.Date(2026, 8, 11, 9, 30, 0, 0, time.FixedZone("CEST", 3600)),
|
||||
name: "Bob Babbage",
|
||||
email: "bob@example.com",
|
||||
subject: "fix: typo",
|
||||
files: map[string]string{"docs/notes.md": "note\n"},
|
||||
},
|
||||
})
|
||||
|
||||
cs, err := Log(dir, Options{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cs) != 2 {
|
||||
t.Fatalf("commits = %d, want 2", len(cs))
|
||||
}
|
||||
if cs[0].Subject != "fix: typo" {
|
||||
t.Fatalf("head subject = %q, want fix: typo", cs[0].Subject)
|
||||
}
|
||||
if cs[1].Author != "Ada Lovelace" || cs[1].Email != "ada@example.com" {
|
||||
t.Fatalf("author = %s <%s>", cs[1].Author, cs[1].Email)
|
||||
}
|
||||
sort.Strings(cs[1].Files)
|
||||
if got := cs[1].Files; len(got) != 2 || got[0] != "README.md" || got[1] != "src/main.c" {
|
||||
t.Fatalf("first commit files = %v", got)
|
||||
}
|
||||
if cs[0].Files[0] != "docs/notes.md" {
|
||||
t.Fatalf("second commit files = %v", cs[0].Files)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogSkipsMerges(t *testing.T) {
|
||||
dir := initRepo(t, []commitSpec{{
|
||||
when: time.Now(), name: "Ada Lovelace", email: "ada@example.com",
|
||||
subject: "base", files: map[string]string{"a.txt": "a\n"},
|
||||
}})
|
||||
r, err := git.PlainOpen(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
head, err := r.Head()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := r.CommitObject(head.Hash())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Second parent: duplicate the same tree so we do not need a real branch.
|
||||
merge := &object.Commit{
|
||||
Author: object.Signature{Name: "Ada Lovelace", Email: "ada@example.com", When: time.Now()},
|
||||
Committer: object.Signature{Name: "Ada Lovelace", Email: "ada@example.com", When: time.Now()},
|
||||
Message: "merge",
|
||||
TreeHash: c.TreeHash,
|
||||
ParentHashes: []plumbing.Hash{c.Hash, c.Hash},
|
||||
}
|
||||
obj := r.Storer.NewEncodedObject()
|
||||
if err := merge.Encode(obj); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := r.Storer.SetEncodedObject(obj)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Storer.SetReference(plumbing.NewHashReference(head.Name(), h)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cs, err := Log(dir, Options{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, x := range cs {
|
||||
if x.Subject == "merge" {
|
||||
t.Fatal("merge commit was not skipped")
|
||||
}
|
||||
}
|
||||
if len(cs) != 1 || cs[0].Subject != "base" {
|
||||
t.Fatalf("after skip merges: %+v", subjects(cs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogSinceAndLimit(t *testing.T) {
|
||||
old := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
neu := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||
dir := initRepo(t, []commitSpec{
|
||||
{when: old, name: "Ada Lovelace", email: "ada@example.com", subject: "old", files: map[string]string{"old.md": "x"}},
|
||||
{when: neu, name: "Ada Lovelace", email: "ada@example.com", subject: "new", files: map[string]string{"new.md": "y"}},
|
||||
})
|
||||
cs, err := Log(dir, Options{Since: neu.Add(-time.Hour)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cs) != 1 || cs[0].Subject != "new" {
|
||||
t.Fatalf("since filter: %v", subjects(cs))
|
||||
}
|
||||
cs, err = Log(dir, Options{Limit: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cs) != 1 {
|
||||
t.Fatalf("limit=1 got %d", len(cs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeafShape(t *testing.T) {
|
||||
c := Commit{
|
||||
SHA: "a1b2c3d4e5f6aaaa",
|
||||
Author: "Ada Lovelace",
|
||||
Email: "ada@example.com",
|
||||
Date: "2026-08-10T12:00:00+01:00",
|
||||
Subject: "feat: first commit",
|
||||
Files: []string{"README.md", "src/main.c"},
|
||||
}
|
||||
lf := ToLeaf(c, "sample-repo")
|
||||
if lf.Type != "commit" || lf.Repo != "sample-repo" {
|
||||
t.Fatalf("leaf meta = %+v", lf)
|
||||
}
|
||||
if lf.Source != "sample-repo@a1b2c3d4e5f6aaaa" {
|
||||
t.Fatalf("source = %s", lf.Source)
|
||||
}
|
||||
if lf.Related != "README.md,src/main.c" {
|
||||
t.Fatalf("related = %s", lf.Related)
|
||||
}
|
||||
if lf.Heading != "commit a1b2c3d4e5f6 — feat: first commit" {
|
||||
t.Fatalf("heading = %q", lf.Heading)
|
||||
}
|
||||
if !strings.Contains(lf.Text, "Ada Lovelace") || !strings.Contains(lf.Text, "README.md") {
|
||||
t.Fatalf("text = %s", lf.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoNameFromOrigin(t *testing.T) {
|
||||
dir := initRepo(t, []commitSpec{{
|
||||
when: time.Now(), name: "Ada Lovelace", email: "ada@example.com",
|
||||
subject: "init", files: map[string]string{"README.md": "x"},
|
||||
}})
|
||||
r, err := git.PlainOpen(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := r.CreateRemote(&config.RemoteConfig{
|
||||
Name: "origin",
|
||||
URLs: []string{"https://git.example.com/eSlider/sample-repo.git"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
name, err := RepoName(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if name != "sample-repo" {
|
||||
t.Fatalf("RepoName = %q, want sample-repo", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoNameFallsBackToDir(t *testing.T) {
|
||||
dir := initRepo(t, []commitSpec{{
|
||||
when: time.Now(), name: "Ada Lovelace", email: "ada@example.com",
|
||||
subject: "init", files: map[string]string{"README.md": "x"},
|
||||
}})
|
||||
name, err := RepoName(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if name != filepath.Base(dir) {
|
||||
t.Fatalf("RepoName = %q, want %s", name, filepath.Base(dir))
|
||||
}
|
||||
}
|
||||
|
||||
type commitSpec struct {
|
||||
when time.Time
|
||||
name string
|
||||
email string
|
||||
subject string
|
||||
files map[string]string
|
||||
}
|
||||
|
||||
func initRepo(t *testing.T, specs []commitSpec) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
r, err := git.PlainInit(dir, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w, err := r.Worktree()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, s := range specs {
|
||||
for path, body := range s.files {
|
||||
full := filepath.Join(dir, path)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil && !os.IsExist(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Add(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := w.Commit(s.subject, &git.CommitOptions{
|
||||
Author: &object.Signature{Name: s.name, Email: s.email, When: s.when},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func subjects(cs []Commit) []string {
|
||||
out := make([]string, len(cs))
|
||||
for i, c := range cs {
|
||||
out[i] = c.Subject
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user