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
166 changes: 166 additions & 0 deletions src/gitstatus/commit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package gitstatus

import (
"bytes"
"fmt"
"sort"
"strconv"
"strings"
"time"

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

// Ident is a name/email pair as recorded in a commit's author or committer
// line.
type Ident struct {
Name string
Email string
}

// CommitRefs mirrors what `git log --decorate=full` reports for the refs
// pointing at a commit.
type CommitRefs struct {
Heads []string
Tags []string
Remotes []string
}

// CommitInfo is the subset of `git log -1`'s output the git segment
// displays.
type CommitInfo struct {
Timestamp time.Time
Author Ident
Committer Ident
Subject string
Hash string
Refs CommitRefs
}

// LoadCommit reads the commit hashHex points at, plus every local branch,
// tag, and remote-tracking branch that also points at it. Any error means
// the caller must fall back to exec git.
func LoadCommit(commonGitDir, hashHex string) (*CommitInfo, error) {
hash, ok := parseHash(hashHex)
if !ok {
return nil, fmt.Errorf("gitstatus: invalid hash %q", hashHex)
}

store := newObjectStore(commonGitDir)
defer store.close()

kind, data, err := store.object(hash)
if err != nil {
return nil, err
}
if kind != kindCommit {
return nil, fmt.Errorf("gitstatus: object %s is a %s, expected a commit", hashHex, kind)
}

info := &CommitInfo{Hash: hash.String()}

body := data
for {
line, rest, found := bytes.Cut(body, []byte("\n"))
if !found || len(line) == 0 {
body = rest
break
}
body = rest

key, value, ok := strings.Cut(string(line), " ")
if !ok {
continue
}

switch key {
case "author":
name, email, ts, ok := parseIdent(value)
if ok {
info.Author = Ident{Name: name, Email: email}
info.Timestamp = time.Unix(ts, 0)
}
case "committer":
name, email, _, ok := parseIdent(value)
if ok {
info.Committer = Ident{Name: name, Email: email}
}
}
}

subject, _, _ := bytes.Cut(body, []byte("\n"))
info.Subject = string(subject)

refs, err := decorate(store, commonGitDir, hash)
if err != nil {
return nil, err
}
info.Refs = *refs

return info, nil
}

// parseIdent parses a commit's "author"/"committer" header value:
// "Name <email> <unix-timestamp> <tz-offset>".
func parseIdent(value string) (name, email string, timestamp int64, ok bool) {
lt := strings.IndexByte(value, '<')
gt := strings.IndexByte(value, '>')
if lt < 0 || gt < 0 || gt < lt {
return "", "", 0, false
}

name = strings.TrimSpace(value[:lt])
email = value[lt+1 : gt]

fields := strings.Fields(strings.TrimSpace(value[gt+1:]))
if len(fields) > 0 {
timestamp, _ = strconv.ParseInt(fields[0], 10, 64)
}

return name, email, timestamp, true
}

// decorate finds every local branch, tag, and remote-tracking branch that
// points at target, the same set `git log --decorate=full` prints.
func decorate(store *objectStore, commonGitDir string, target plumbing.Hash) (*CommitRefs, error) {
refs := &CommitRefs{}

heads, err := listRefs(commonGitDir, "refs/heads/")
if err != nil {
return nil, err
}
for name, h := range heads {
if h == target {
refs.Heads = append(refs.Heads, strings.TrimPrefix(name, "refs/heads/"))
}
}
sort.Strings(refs.Heads)

remotes, err := listRefs(commonGitDir, "refs/remotes/")
if err != nil {
return nil, err
}
for name, h := range remotes {
if h == target {
refs.Remotes = append(refs.Remotes, strings.TrimPrefix(name, "refs/remotes/"))
}
}
sort.Strings(refs.Remotes)

tags, err := listRefs(commonGitDir, "refs/tags/")
if err != nil {
return nil, err
}
for name, h := range tags {
commit, ok := peelTag(store, h)
if !ok {
continue
}
if commit == target {
refs.Tags = append(refs.Tags, strings.TrimPrefix(name, "refs/tags/"))
}
}
sort.Strings(refs.Tags)

return refs, nil
}
57 changes: 57 additions & 0 deletions src/gitstatus/head.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package gitstatus

import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)

// HeadInfo is the resolved identity of a repository's HEAD.
type HeadInfo struct {
// Hash is the full HEAD commit hash.
Hash string
// Ref is the branch name HEAD points at, or Detached when HEAD is not
// on a branch.
Ref string
Detached bool
}

// LoadHead resolves worktreeGitDir/HEAD to a commit hash, following a
// branch ref through loose or packed refs in commonGitDir. It is a
// lightweight sibling of Load: callers that only need the current hash and
// branch name (not the full status) can use this instead. Any error means
// the caller must fall back to exec git: a reftables HEAD, an unborn
// branch, or a corrupt/unsupported ref.
func LoadHead(worktreeGitDir, commonGitDir string) (*HeadInfo, error) {
data, err := os.ReadFile(filepath.Join(worktreeGitDir, "HEAD"))
if err != nil {
return nil, err
}

head := strings.TrimSpace(string(data))
if head == reftablesHead {
return nil, errors.New("gitstatus: reftables HEAD requires exec fallback")
}

branchName, isBranch := strings.CutPrefix(head, branchRefPrefix)
if !isBranch {
hash, ok := parseHash(head)
if !ok {
return nil, fmt.Errorf("gitstatus: unrecognized HEAD content %q", head)
}

return &HeadInfo{Hash: hash.String(), Ref: Detached, Detached: true}, nil
}

hash, ok, err := resolveRef(commonGitDir, "refs/heads/"+branchName)
if err != nil {
return nil, err
}
if !ok {
return nil, errors.New("gitstatus: unborn branch")
}

return &HeadInfo{Hash: hash.String(), Ref: branchName}, nil
}
173 changes: 173 additions & 0 deletions src/gitstatus/native_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
package gitstatus

import (
"strings"
"testing"

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

// TestLoadHeadParity covers both a branch checkout and a detached HEAD
// against the real git CLI's own idea of the current commit.
func TestLoadHeadParity(t *testing.T) {
skipIfNoGit(t)
hermeticHome(t)

dir := t.TempDir()
initGitRepo(t, dir)
writeFile(t, dir, "a.txt", "a\n")
runGit(t, dir, "add", ".")
runGit(t, dir, "commit", "-q", "-m", "one")

worktreeGitDir := gitPath(t, dir, "--git-dir")
commonGitDir := gitPath(t, dir, "--git-common-dir")
wantHash := runGit(t, dir, "rev-parse", "HEAD")

head, err := LoadHead(worktreeGitDir, commonGitDir)
require.NoError(t, err)
assert.Equal(t, strings.TrimSpace(wantHash), head.Hash)
assert.Equal(t, "main", head.Ref)
assert.False(t, head.Detached)

runGit(t, dir, "checkout", "-q", "--detach", "HEAD")
head, err = LoadHead(worktreeGitDir, commonGitDir)
require.NoError(t, err)
assert.Equal(t, strings.TrimSpace(wantHash), head.Hash)
assert.Equal(t, Detached, head.Ref)
assert.True(t, head.Detached)
}

func TestLoadHeadUnbornBranchFallsBack(t *testing.T) {
skipIfNoGit(t)
hermeticHome(t)

dir := t.TempDir()
initGitRepo(t, dir)

_, err := LoadHead(gitPath(t, dir, "--git-dir"), gitPath(t, dir, "--git-common-dir"))
assert.Error(t, err)
}

// TestExactTagParity covers a lightweight tag, an annotated tag (which
// requires peeling), a non-tagged commit, and an ambiguous multi-tag commit
// against the real `git describe --tags --exact-match`.
func TestExactTagParity(t *testing.T) {
skipIfNoGit(t)
hermeticHome(t)

dir := t.TempDir()
initGitRepo(t, dir)
writeFile(t, dir, "a.txt", "a\n")
runGit(t, dir, "add", ".")
runGit(t, dir, "commit", "-q", "-m", "one")
runGit(t, dir, "tag", "lightweight")

writeFile(t, dir, "b.txt", "b\n")
runGit(t, dir, "add", ".")
runGit(t, dir, "commit", "-q", "-m", "two")
runGit(t, dir, "tag", "-a", "annotated", "-m", "msg")

writeFile(t, dir, "c.txt", "c\n")
runGit(t, dir, "add", ".")
runGit(t, dir, "commit", "-q", "-m", "three")

commonGitDir := gitPath(t, dir, "--git-common-dir")

lightweightHash := strings.TrimSpace(runGit(t, dir, "rev-parse", "lightweight"))
tag, found, err := ExactTag(commonGitDir, lightweightHash)
require.NoError(t, err)
assert.True(t, found)
assert.Equal(t, "lightweight", tag)

annotatedHash := strings.TrimSpace(runGit(t, dir, "rev-parse", "annotated^{commit}"))
tag, found, err = ExactTag(commonGitDir, annotatedHash)
require.NoError(t, err)
assert.True(t, found)
assert.Equal(t, "annotated", tag)

headHash := strings.TrimSpace(runGit(t, dir, "rev-parse", "HEAD"))
_, found, err = ExactTag(commonGitDir, headHash)
require.NoError(t, err)
assert.False(t, found)

// two tags on the same commit: the engine must refuse to guess
runGit(t, dir, "tag", "second-tag", "lightweight")
_, _, err = ExactTag(commonGitDir, lightweightHash)
assert.Error(t, err)
}

// TestLoadCommitParity covers a commit decorated with a local branch, two
// tags, and a remote-tracking branch against `git log -1 --decorate=full`.
func TestLoadCommitParity(t *testing.T) {
skipIfNoGit(t)
hermeticHome(t)

remote := t.TempDir()
runGit(t, remote, "init", "-q", "--bare", "-b", "main")

dir := t.TempDir()
initGitRepo(t, dir)
writeFile(t, dir, "a.txt", "a\n")
runGit(t, dir, "add", ".")
runGit(t, dir, "commit", "-q", "-m", "feat: decorated commit")
runGit(t, dir, "tag", "v1.0")
runGit(t, dir, "tag", "-a", "v1.1", "-m", "annotated")
runGit(t, dir, "remote", "add", "origin", remote)
runGit(t, dir, "push", "-q", "-u", "origin", "main")

commonGitDir := gitPath(t, dir, "--git-common-dir")
hash := strings.TrimSpace(runGit(t, dir, "rev-parse", "HEAD"))

info, err := LoadCommit(commonGitDir, hash)
require.NoError(t, err)

assert.Equal(t, "Test", info.Author.Name)
assert.Equal(t, "test@example.com", info.Author.Email)
assert.Equal(t, "Test", info.Committer.Name)
assert.Equal(t, "test@example.com", info.Committer.Email)
assert.Equal(t, "feat: decorated commit", info.Subject)
assert.Equal(t, hash, info.Hash)
assert.ElementsMatch(t, []string{"main"}, info.Refs.Heads)
assert.ElementsMatch(t, []string{"v1.0", "v1.1"}, info.Refs.Tags)
assert.ElementsMatch(t, []string{"origin/main"}, info.Refs.Remotes)
}

// TestAheadBehindAndResolveRefParity mirrors setupAheadBehind's scenario but
// drives it through the public AheadBehind/ResolveRef wrappers instead of
// Load, matching `git rev-list --count` for the same repo.
func TestAheadBehindAndResolveRefParity(t *testing.T) {
skipIfNoGit(t)
hermeticHome(t)

remote := t.TempDir()
runGit(t, remote, "init", "-q", "--bare", "-b", "main")

dir := t.TempDir()
initGitRepo(t, dir)
writeFile(t, dir, "a.txt", "a\n")
runGit(t, dir, "add", ".")
runGit(t, dir, "commit", "-q", "-m", "base")
runGit(t, dir, "remote", "add", "origin", remote)
runGit(t, dir, "push", "-q", "-u", "origin", "main")

writeFile(t, dir, "b.txt", "b\n")
runGit(t, dir, "add", ".")
runGit(t, dir, "commit", "-q", "-m", "local only")

commonGitDir := gitPath(t, dir, "--git-common-dir")
ours := strings.TrimSpace(runGit(t, dir, "rev-parse", "HEAD"))

theirs, found, err := ResolveRef(commonGitDir, "refs/remotes/origin/main")
require.NoError(t, err)
require.True(t, found)

ahead, behind, err := AheadBehind(commonGitDir, ours, theirs)
require.NoError(t, err)
assert.Equal(t, 1, ahead)
assert.Equal(t, 0, behind)

_, found, err = ResolveRef(commonGitDir, "refs/remotes/origin/does-not-exist")
require.NoError(t, err)
assert.False(t, found)
}
Loading
Loading