diff --git a/cmd/gitserver/internal/git/gitcli/odb.go b/cmd/gitserver/internal/git/gitcli/odb.go index 1722156cbf6..2db4213790d 100644 --- a/cmd/gitserver/internal/git/gitcli/odb.go +++ b/cmd/gitserver/internal/git/gitcli/odb.go @@ -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 { diff --git a/cmd/gitserver/internal/git/gitcli/odb_test.go b/cmd/gitserver/internal/git/gitcli/odb_test.go index 8aef1c6b8d6..25f61e8c606 100644 --- a/cmd/gitserver/internal/git/gitcli/odb_test.go +++ b/cmd/gitserver/internal/git/gitcli/odb_test.go @@ -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 '", + + "echo abcd > file1", + "git add file1", + "git commit -m commit1 --author='Foo Author '", + + "git branch branch1", + + "echo efgh > file2", + "git add file2", + "git commit -m commit2 --author='Foo Author '", + + "git checkout branch1", + + "echo ijkl > file3", + "git add file3", + "git commit -m commit3 --author='Foo Author '", + + "echo ijkl > file4", + "git add file4", + "git commit -m commit4 --author='Foo Author '", + + "git checkout master", + + "echo ijkl > file5", + "git add file5", + "git commit -m commit5 --author='Foo Author '", + + "echo ijkl > file6", + "git add file6", + "git commit -m commit6 --author='Foo Author '", + ) + + 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)) + }) +} diff --git a/cmd/gitserver/internal/git/iface.go b/cmd/gitserver/internal/git/iface.go index a70330f19ed..7a085648e08 100644 --- a/cmd/gitserver/internal/git/iface.go +++ b/cmd/gitserver/internal/git/iface.go @@ -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 diff --git a/cmd/gitserver/internal/git/mock.go b/cmd/gitserver/internal/git/mock.go index a5559574f97..da3110e079a 100644 --- a/cmd/gitserver/internal/git/mock.go +++ b/cmd/gitserver/internal/git/mock.go @@ -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 { diff --git a/cmd/gitserver/internal/git/observability.go b/cmd/gitserver/internal/git/observability.go index f974b4d65fa..853fe0fb2e8 100644 --- a/cmd/gitserver/internal/git/observability.go +++ b/cmd/gitserver/internal/git/observability.go @@ -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"), } }