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
58 changes: 22 additions & 36 deletions branch_diff_commits.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,52 +2,38 @@ package git

import (
"fmt"

"github.com/go-git/go-git/v5/plumbing"
"strings"
)

// BranchDiffCommits compares commits from 2 branches and returns of a diff of them.
func (g *Git) BranchDiffCommits(branchA string, branchB string) ([]plumbing.Hash, error) {
branchACommit, err := g.LatestCommitOnBranch(branchA)

if err != nil {
return nil, fmt.Errorf("Failed getting latest commit for branch %v: %v", branchA, err)
}

branchBCommit, err := g.LatestCommitOnBranch(branchB)

if err != nil {
return nil, fmt.Errorf("Failed getting latest commit for branch %v: %v", branchB, err)
}

branchACommits, err := g.CommitsOnBranch(branchACommit.Hash)

// Uses git log with exclusion syntax for efficient comparison - finds commits in branchA that are not in branchB.
// This is more efficient than fetching all commits from both branches and comparing them.
func (g *Git) BranchDiffCommits(branchA string, branchB string) ([]Hash, error) {
// git log branchA ^branchB shows all commits reachable from branchA but not from branchB
// This is equivalent to: commits in branchA that are not in branchB
// The ^branchB syntax excludes all commits reachable from branchB
output, err := g.runGitCommand("log", "--format=%H", branchA, "^"+branchB)
if err != nil {
return nil, fmt.Errorf("Failed getting commits for branch %v: %v", branchA, err)
return nil, fmt.Errorf("Failed comparing branches %v and %v: %v", branchA, branchB, err)
}

branchBCommits, err := g.CommitsOnBranch(branchBCommit.Hash)
var diffCommits []Hash
lines := strings.Split(output, "\n")

if err != nil {
return nil, fmt.Errorf("Failed getting commits for branch %v: %v", branchB, err)
}

var diffCommits []plumbing.Hash
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}

for _, commit := range branchACommits {
if !contains(branchBCommits, commit) {
diffCommits = append(diffCommits, commit)
hash, err := NewHash(line)
if err != nil {
// Skip invalid hashes
continue
}

diffCommits = append(diffCommits, hash)
}

return diffCommits, nil
}

func contains(s []plumbing.Hash, e plumbing.Hash) bool {
for _, a := range s {
if a == e {
return true
}
}
return false
}
18 changes: 9 additions & 9 deletions branch_diff_commits_test.go
Original file line number Diff line number Diff line change
@@ -1,36 +1,36 @@
package git

import (
"os"
"testing"

"github.com/go-git/go-git/v5"
"github.com/stretchr/testify/assert"
)

func TestBranchDiffCommits(t *testing.T) {
repo := setupRepo()
createTestHistory(repo)
tmpDir, testGit := setupRepo(t)
defer os.RemoveAll(tmpDir)

testGit := &Git{repo: repo}
createTestHistory(t, testGit)

commits, err := testGit.BranchDiffCommits("my-branch", "master")

commit, _ := repo.CommitObject(commits[0])
commit, _ := testGit.Commit(commits[0])

assert.NoError(t, err)
assert.Equal(t, "third commit on new branch", commit.Message)
assert.Equal(t, "third commit on new branch\n", commit.Message)
assert.Equal(t, 3, len(commits))
}

func TestBranchDiffCommitsWithMasterMerge(t *testing.T) {
repo, _ := git.PlainOpen("./testdata/commits_on_branch")
testGit := &Git{repo: repo}
testGit, err := OpenGit("./testdata/commits_on_branch")
assert.NoError(t, err)

commits, err := testGit.BranchDiffCommits("behind-master", "origin/master")

assert.Equal(t, 2, len(commits))

commit, _ := repo.CommitObject(commits[1])
commit, _ := testGit.Commit(commits[1])

assert.Equal(t, "chore: commit on branch\n", commit.Message)

Expand Down
78 changes: 72 additions & 6 deletions commit.go
Original file line number Diff line number Diff line change
@@ -1,17 +1,83 @@
package git

import (
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
"strconv"
"strings"
"time"
)

// Commit find a commit based on commit hash and returns the Commit object
func (g *Git) Commit(hash plumbing.Hash) (*object.Commit, error) {
commitObject, err := g.repo.CommitObject(hash)
// Commit finds a commit based on commit hash and returns the Commit object
func (g *Git) Commit(hash Hash) (*Commit, error) {
hashStr := hash.String()

// Get commit message - use %B to get full body, then normalize
// go-git's Message field returns the raw commit message which always ends with \n
fullMessage, err := g.runGitCommand("log", "-1", "--format=%B", hashStr)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multiple separate git log calls are used to retrieve the commit message, author, and committer details. Consider consolidating these calls into a single command to reduce overhead and improve performance.

if err != nil {
return nil, err
}

// Normalize: remove trailing newlines and add exactly one
// This matches go-git's behavior where Message always ends with \n
message := strings.TrimRight(fullMessage, "\n")
if message != "" {
message += "\n"
}

// Get author info
authorName, err := g.runGitCommand("log", "-1", "--format=%an", hashStr)
if err != nil {
return nil, err
}

authorEmail, err := g.runGitCommand("log", "-1", "--format=%ae", hashStr)
if err != nil {
return nil, err
}

authorDateStr, err := g.runGitCommand("log", "-1", "--format=%at", hashStr)
if err != nil {
return nil, err
}

authorTimestamp, err := strconv.ParseInt(authorDateStr, 10, 64)
if err != nil {
return nil, err
}

// Get committer info
committerName, err := g.runGitCommand("log", "-1", "--format=%cn", hashStr)
if err != nil {
return nil, err
}

committerEmail, err := g.runGitCommand("log", "-1", "--format=%ce", hashStr)
if err != nil {
return nil, err
}

committerDateStr, err := g.runGitCommand("log", "-1", "--format=%ct", hashStr)
if err != nil {
return nil, err
}

committerTimestamp, err := strconv.ParseInt(committerDateStr, 10, 64)
if err != nil {
return nil, err
}

return commitObject, nil
return &Commit{
Hash: hash,
Message: message,
Author: Signature{
Name: authorName,
Email: authorEmail,
When: time.Unix(authorTimestamp, 0),
},
Committer: Signature{
Name: committerName,
Email: committerEmail,
When: time.Unix(committerTimestamp, 0),
},
}, nil
}
15 changes: 9 additions & 6 deletions commit_date.go
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
package git

import (
"strconv"
"time"

"github.com/go-git/go-git/v5/plumbing"
)

// commitDate gets the commit at hash and returns the time of the commit
func (g *Git) commitDate(commit plumbing.Hash) (time.Time, error) {
commitObject, err := g.repo.CommitObject(commit)
func (g *Git) commitDate(commit Hash) (time.Time, error) {
hashStr := commit.String()

dateStr, err := g.runGitCommand("log", "-1", "--format=%ct", hashStr)
if err != nil {
return time.Now(), err
}

when := commitObject.Committer.When
timestamp, err := strconv.ParseInt(dateStr, 10, 64)
if err != nil {
return time.Now(), err
}

return when, nil
return time.Unix(timestamp, 0), nil
}
9 changes: 5 additions & 4 deletions commit_test.go
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
package git

import (
"os"
"testing"

"github.com/stretchr/testify/assert"
)

func TestCommit(t *testing.T) {
repo := setupRepo()
createTestHistory(repo)
tmpDir, testGit := setupRepo(t)
defer os.RemoveAll(tmpDir)

testGit := &Git{repo: repo}
createTestHistory(t, testGit)

head, _ := testGit.CurrentCommit()

commit, err := testGit.Commit(head.Hash)
assert.NoError(t, err)
assert.Equal(t, "third commit on new branch", commit.Message)
assert.Equal(t, "third commit on new branch\n", commit.Message)
assert.NotEmpty(t, commit.Hash)
}
76 changes: 52 additions & 24 deletions commits_between.go
Original file line number Diff line number Diff line change
@@ -1,40 +1,68 @@
package git

import (
"errors"

"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
)

var (
errReachedToCommit = errors.New("reached to commit")
"strings"
)

// CommitsBetween returns a slice of commit hashes between two commits
func (g *Git) CommitsBetween(from plumbing.Hash, to plumbing.Hash) ([]plumbing.Hash, error) {
cIter, _ := g.repo.Log(&git.LogOptions{From: from})
func (g *Git) CommitsBetween(from Hash, to Hash) ([]Hash, error) {
// If from and to are equal, return empty slice
if from == to {
return []Hash{}, nil
}

var commits []plumbing.Hash
fromStr := from.String()
toStr := to.String()

err := cIter.ForEach(func(c *object.Commit) error {
// If no previous tag is found then from and to are equal
if from == to {
return nil
// Check if 'to' is an empty hash (all zeros)
var emptyHash Hash
if to == emptyHash {
// If 'to' is empty, return all commits from 'from'
output, err := g.runGitCommand("log", "--format=%H", fromStr)
if err != nil {
return nil, err
}
if c.Hash == to {
return errReachedToCommit
lines := strings.Split(output, "\n")
var commits []Hash
for _, line := range lines {
if line == "" {
continue
}
hash, err := NewHash(line)
if err != nil {
continue
}
commits = append(commits, hash)
}
commits = append(commits, c.Hash)
return nil
})

if err == errReachedToCommit {
return commits, nil
}

// Get commits from 'from' to 'to' (excluding 'to')
// Use ^to to exclude the 'to' commit
output, err := g.runGitCommand("log", "--format=%H", fromStr, "^"+toStr)
if err != nil {
return commits, err
// If the command fails, it might be because there are no commits between
// Try to check if 'to' is reachable from 'from'
_, err2 := g.runGitCommand("merge-base", "--is-ancestor", toStr, fromStr)
if err2 != nil {
return []Hash{}, nil
}
return nil, err
}

lines := strings.Split(output, "\n")
var commits []Hash

for _, line := range lines {
if line == "" {
continue
}
hash, err := NewHash(line)
if err != nil {
continue
}
commits = append(commits, hash)
}

return commits, nil
}
Loading