gitserver: grpc: port GetBehindAhead from client to gitcli backend (#62212)

Part of https://github.com/sourcegraph/sourcegraph/issues/62101

This PR ports the GetBehindAhead implementation from the gitserver client to the new gitserver.Backend interface.

Here is the original implementation from the client for reference:

```go
// GetBehindAhead returns the behind/ahead commit counts information for right vs. left (both Git
// revspecs).
func (c *clientImplementor) GetBehindAhead(ctx context.Context, repo api.RepoName, left, right string) (_ *gitdomain.BehindAhead, err error) {
	ctx, _, endObservation := c.operations.getBehindAhead.With(ctx, &err, observation.Args{
		MetricLabelValues: []string{c.scope},
		Attrs: []attribute.KeyValue{
			repo.Attr(),
			attribute.String("left", left),
			attribute.String("right", right),
		},
	})
	defer endObservation(1, observation.Args{})

	if err := checkSpecArgSafety(left); err != nil {
		return nil, err
	}
	if err := checkSpecArgSafety(right); err != nil {
		return nil, err
	}

	cmd := c.gitCommand(repo, "rev-list", "--count", "--left-right", fmt.Sprintf("%s...%s", left, right))
	out, err := cmd.Output(ctx)
	if err != nil {
		return nil, err
	}
	behindAhead := strings.Split(strings.TrimSuffix(string(out), "\n"), "\t")
	b, err := strconv.ParseUint(behindAhead[0], 10, 0)
	if err != nil {
		return nil, err
	}
	a, err := strconv.ParseUint(behindAhead[1], 10, 0)
	if err != nil {
		return nil, err
	}
	return &gitdomain.BehindAhead{Behind: uint32(b), Ahead: uint32(a)}, nil
}
```

## Test plan

New unit tests
This commit is contained in:
Geoffrey Gilmore 2024-05-02 11:25:54 -07:00 committed by GitHub
parent 3e05f5cc81
commit e19c22d0a9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 306 additions and 0 deletions

View File

@ -3,6 +3,7 @@ package gitcli
import (
"bytes"
"context"
"fmt"
"io"
"os"
"strconv"
@ -188,6 +189,58 @@ func (g *gitCLIBackend) getBlobOID(ctx context.Context, commit api.CommitID, pat
return api.CommitID(fields[2]), nil
}
func (g *gitCLIBackend) BehindAhead(ctx context.Context, left, right string) (*gitdomain.BehindAhead, error) {
if err := checkSpecArgSafety(left); err != nil {
return nil, err
}
if err := checkSpecArgSafety(right); err != nil {
return nil, err
}
if left == "" {
left = "HEAD"
}
if right == "" {
right = "HEAD"
}
rc, err := g.NewCommand(ctx, WithArguments("rev-list", "--count", "--left-right", fmt.Sprintf("%s...%s", left, right)))
if err != nil {
return nil, errors.Wrap(err, "running git rev-list")
}
defer rc.Close()
out, err := io.ReadAll(rc)
if err != nil {
var e *CommandFailedError
if errors.As(err, &e) {
switch {
case e.ExitStatus == 128 && bytes.Contains(e.Stderr, []byte("fatal: ambiguous argument")):
fallthrough
case e.ExitStatus == 128 && bytes.Contains(e.Stderr, []byte("fatal: Invalid symmetric difference expression")):
return nil, &gitdomain.RevisionNotFoundError{
Repo: g.repoName,
Spec: fmt.Sprintf("%s...%s", left, right),
}
}
}
return nil, errors.Wrap(err, "reading git rev-list output")
}
behindAhead := strings.Split(strings.TrimSuffix(string(out), "\n"), "\t")
b, err := strconv.ParseUint(behindAhead[0], 10, 0)
if err != nil {
return nil, errors.Wrapf(err, "failed to parse behindahead output %q", out)
}
a, err := strconv.ParseUint(behindAhead[1], 10, 0)
if err != nil {
return nil, errors.Wrapf(err, "failed to parse behindahead output %q", out)
}
return &gitdomain.BehindAhead{Behind: uint32(b), Ahead: uint32(a)}, nil
}
func (g *gitCLIBackend) FirstEverCommit(ctx context.Context) (api.CommitID, error) {
rc, err := g.NewCommand(ctx, WithArguments("rev-list", "--reverse", "--date-order", "--max-parents=0", "HEAD"))
if err != nil {

View File

@ -325,3 +325,97 @@ func TestRepository_FirstEverCommit(t *testing.T) {
}
})
}
func TestGitCLIBackend_GetBehindAhead(t *testing.T) {
ctx := context.Background()
// Prepare repo state:
backend := BackendWithRepoCommands(t,
// This is the commit graph we are creating
//
// +-----> 3 -----> 4 (branch1)
// |
// |
// 0 ----> 1 ----> 2 -----> 5 -----> 6 (master)
//
"echo abcd > file0",
"git add file0",
"git commit -m commit0 --author='Foo Author <foo@sourcegraph.com>'",
"echo abcd > file1",
"git add file1",
"git commit -m commit1 --author='Foo Author <foo@sourcegraph.com>'",
"git branch branch1",
"echo efgh > file2",
"git add file2",
"git commit -m commit2 --author='Foo Author <foo@sourcegraph.com>'",
"git checkout branch1",
"echo ijkl > file3",
"git add file3",
"git commit -m commit3 --author='Foo Author <foo@sourcegraph.com>'",
"echo ijkl > file4",
"git add file4",
"git commit -m commit4 --author='Foo Author <foo@sourcegraph.com>'",
"git checkout master",
"echo ijkl > file5",
"git add file5",
"git commit -m commit5 --author='Foo Author <foo@sourcegraph.com>'",
"echo ijkl > file6",
"git add file6",
"git commit -m commit6 --author='Foo Author <foo@sourcegraph.com>'",
)
left := "branch1"
right := "master"
t.Run("valid branches", func(t *testing.T) {
behindAhead, err := backend.BehindAhead(ctx, left, right)
require.NoError(t, err)
require.Equal(t, &gitdomain.BehindAhead{Behind: 2, Ahead: 3}, behindAhead)
})
t.Run("missing left branch", func(t *testing.T) {
_, err := backend.BehindAhead(ctx, left, "")
require.NoError(t, err) // Should compare to HEAD
})
t.Run("missing right branch", func(t *testing.T) {
_, err := backend.BehindAhead(ctx, "", right)
require.NoError(t, err) // Should compare to HEAD
})
t.Run("invalid left branch", func(t *testing.T) {
_, err := backend.BehindAhead(ctx, "invalid-branch", right)
require.Error(t, err)
var e *gitdomain.RevisionNotFoundError
require.True(t, errors.As(err, &e))
})
t.Run("invalid right branch", func(t *testing.T) {
_, err := backend.BehindAhead(ctx, left, "invalid-branch")
require.Error(t, err)
var e *gitdomain.RevisionNotFoundError
require.True(t, errors.As(err, &e))
})
t.Run("same branch", func(t *testing.T) {
behindAhead, err := backend.BehindAhead(ctx, left, left)
require.NoError(t, err)
require.Equal(t, &gitdomain.BehindAhead{Behind: 0, Ahead: 0}, behindAhead)
})
t.Run("invalid object id", func(t *testing.T) {
_, err := backend.BehindAhead(ctx, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", right)
require.Error(t, err)
var e *gitdomain.RevisionNotFoundError
require.True(t, errors.As(err, &e))
})
}

View File

@ -101,6 +101,26 @@ type GitBackend interface {
// If the repository is empty, a RevisionNotFoundError is returned (as the
// "HEAD" ref does not exist).
FirstEverCommit(ctx context.Context) (api.CommitID, error)
// BehindAhead returns the behind/ahead commit counts information for the symmetric difference left...right (both Git
// revspecs).
//
// Behind is the number of commits that are solely reachable in "left" but not "right".
// Ahead is the number of commits that are solely reachable in "right" but not "left".
//
// For the example, given the graph below, BehindAhead("A", "B") would return {Behind: 3, Ahead: 2}.
//
// y---b---b branch B
// / \ /
// / .
// / / \
// o---x---a---a---a branch A
//
// If either left or right are the empty string (""), the HEAD commit is implicitly used.
//
// If one of the two given revspecs does not exist, a RevisionNotFoundError
// is returned.
BehindAhead(ctx context.Context, left, right string) (*gitdomain.BehindAhead, error)
}
type GitDiffComparisonType int

View File

@ -287,6 +287,9 @@ type MockGitBackend struct {
// ArchiveReaderFunc is an instance of a mock function object
// controlling the behavior of the method ArchiveReader.
ArchiveReaderFunc *GitBackendArchiveReaderFunc
// BehindAheadFunc is an instance of a mock function object controlling
// the behavior of the method BehindAhead.
BehindAheadFunc *GitBackendBehindAheadFunc
// BlameFunc is an instance of a mock function object controlling the
// behavior of the method Blame.
BlameFunc *GitBackendBlameFunc
@ -343,6 +346,11 @@ func NewMockGitBackend() *MockGitBackend {
return
},
},
BehindAheadFunc: &GitBackendBehindAheadFunc{
defaultHook: func(context.Context, string, string) (r0 *gitdomain.BehindAhead, r1 error) {
return
},
},
BlameFunc: &GitBackendBlameFunc{
defaultHook: func(context.Context, api.CommitID, string, BlameOptions) (r0 BlameHunkReader, r1 error) {
return
@ -430,6 +438,11 @@ func NewStrictMockGitBackend() *MockGitBackend {
panic("unexpected invocation of MockGitBackend.ArchiveReader")
},
},
BehindAheadFunc: &GitBackendBehindAheadFunc{
defaultHook: func(context.Context, string, string) (*gitdomain.BehindAhead, error) {
panic("unexpected invocation of MockGitBackend.BehindAhead")
},
},
BlameFunc: &GitBackendBlameFunc{
defaultHook: func(context.Context, api.CommitID, string, BlameOptions) (BlameHunkReader, error) {
panic("unexpected invocation of MockGitBackend.Blame")
@ -515,6 +528,9 @@ func NewMockGitBackendFrom(i GitBackend) *MockGitBackend {
ArchiveReaderFunc: &GitBackendArchiveReaderFunc{
defaultHook: i.ArchiveReader,
},
BehindAheadFunc: &GitBackendBehindAheadFunc{
defaultHook: i.BehindAhead,
},
BlameFunc: &GitBackendBlameFunc{
defaultHook: i.Blame,
},
@ -677,6 +693,117 @@ func (c GitBackendArchiveReaderFuncCall) Results() []interface{} {
return []interface{}{c.Result0, c.Result1}
}
// GitBackendBehindAheadFunc describes the behavior when the BehindAhead
// method of the parent MockGitBackend instance is invoked.
type GitBackendBehindAheadFunc struct {
defaultHook func(context.Context, string, string) (*gitdomain.BehindAhead, error)
hooks []func(context.Context, string, string) (*gitdomain.BehindAhead, error)
history []GitBackendBehindAheadFuncCall
mutex sync.Mutex
}
// BehindAhead delegates to the next hook function in the queue and stores
// the parameter and result values of this invocation.
func (m *MockGitBackend) BehindAhead(v0 context.Context, v1 string, v2 string) (*gitdomain.BehindAhead, error) {
r0, r1 := m.BehindAheadFunc.nextHook()(v0, v1, v2)
m.BehindAheadFunc.appendCall(GitBackendBehindAheadFuncCall{v0, v1, v2, r0, r1})
return r0, r1
}
// SetDefaultHook sets function that is called when the BehindAhead method
// of the parent MockGitBackend instance is invoked and the hook queue is
// empty.
func (f *GitBackendBehindAheadFunc) SetDefaultHook(hook func(context.Context, string, string) (*gitdomain.BehindAhead, error)) {
f.defaultHook = hook
}
// PushHook adds a function to the end of hook queue. Each invocation of the
// BehindAhead method of the parent MockGitBackend instance invokes the hook
// at the front of the queue and discards it. After the queue is empty, the
// default hook function is invoked for any future action.
func (f *GitBackendBehindAheadFunc) PushHook(hook func(context.Context, string, string) (*gitdomain.BehindAhead, error)) {
f.mutex.Lock()
f.hooks = append(f.hooks, hook)
f.mutex.Unlock()
}
// SetDefaultReturn calls SetDefaultHook with a function that returns the
// given values.
func (f *GitBackendBehindAheadFunc) SetDefaultReturn(r0 *gitdomain.BehindAhead, r1 error) {
f.SetDefaultHook(func(context.Context, string, string) (*gitdomain.BehindAhead, error) {
return r0, r1
})
}
// PushReturn calls PushHook with a function that returns the given values.
func (f *GitBackendBehindAheadFunc) PushReturn(r0 *gitdomain.BehindAhead, r1 error) {
f.PushHook(func(context.Context, string, string) (*gitdomain.BehindAhead, error) {
return r0, r1
})
}
func (f *GitBackendBehindAheadFunc) nextHook() func(context.Context, string, string) (*gitdomain.BehindAhead, error) {
f.mutex.Lock()
defer f.mutex.Unlock()
if len(f.hooks) == 0 {
return f.defaultHook
}
hook := f.hooks[0]
f.hooks = f.hooks[1:]
return hook
}
func (f *GitBackendBehindAheadFunc) appendCall(r0 GitBackendBehindAheadFuncCall) {
f.mutex.Lock()
f.history = append(f.history, r0)
f.mutex.Unlock()
}
// History returns a sequence of GitBackendBehindAheadFuncCall objects
// describing the invocations of this function.
func (f *GitBackendBehindAheadFunc) History() []GitBackendBehindAheadFuncCall {
f.mutex.Lock()
history := make([]GitBackendBehindAheadFuncCall, len(f.history))
copy(history, f.history)
f.mutex.Unlock()
return history
}
// GitBackendBehindAheadFuncCall is an object that describes an invocation
// of method BehindAhead on an instance of MockGitBackend.
type GitBackendBehindAheadFuncCall struct {
// Arg0 is the value of the 1st argument passed to this method
// invocation.
Arg0 context.Context
// Arg1 is the value of the 2nd argument passed to this method
// invocation.
Arg1 string
// Arg2 is the value of the 3rd argument passed to this method
// invocation.
Arg2 string
// Result0 is the value of the 1st result returned from this method
// invocation.
Result0 *gitdomain.BehindAhead
// Result1 is the value of the 2nd result returned from this method
// invocation.
Result1 error
}
// Args returns an interface slice containing the arguments of this
// invocation.
func (c GitBackendBehindAheadFuncCall) Args() []interface{} {
return []interface{}{c.Arg0, c.Arg1, c.Arg2}
}
// Results returns an interface slice containing the results of this
// invocation.
func (c GitBackendBehindAheadFuncCall) Results() []interface{} {
return []interface{}{c.Result0, c.Result1}
}
// GitBackendBlameFunc describes the behavior when the Blame method of the
// parent MockGitBackend instance is invoked.
type GitBackendBlameFunc struct {

View File

@ -39,6 +39,16 @@ type observableBackend struct {
backend GitBackend
}
func (b *observableBackend) BehindAhead(ctx context.Context, left, right string) (*gitdomain.BehindAhead, error) {
ctx, _, endObservation := b.operations.getBehindAhead.With(ctx, nil, observation.Args{})
defer endObservation(1, observation.Args{})
concurrentOps.WithLabelValues("BehindAhead").Inc()
defer concurrentOps.WithLabelValues("BehindAhead").Dec()
return b.backend.BehindAhead(ctx, left, right)
}
func (b *observableBackend) Config() GitConfigBackend {
return &observableGitConfigBackend{
backend: b.backend.Config(),
@ -398,6 +408,7 @@ type operations struct {
rawDiff *observation.Operation
contributorCounts *observation.Operation
firstEverCommit *observation.Operation
getBehindAhead *observation.Operation
}
func newOperations(observationCtx *observation.Context) *operations {
@ -444,6 +455,7 @@ func newOperations(observationCtx *observation.Context) *operations {
rawDiff: op("raw-diff"),
contributorCounts: op("contributor-counts"),
firstEverCommit: op("first-ever-commit"),
getBehindAhead: op("get-behind-ahead"),
}
}