aboutsummaryrefslogtreecommitdiff
path: root/internal/gitutil
diff options
context:
space:
mode:
Diffstat (limited to 'internal/gitutil')
-rw-r--r--internal/gitutil/diff.go196
-rw-r--r--internal/gitutil/diff_test.go49
-rw-r--r--internal/gitutil/error.go19
-rw-r--r--internal/gitutil/error_test.go23
-rw-r--r--internal/gitutil/mock.go23
-rw-r--r--internal/gitutil/module.go90
-rw-r--r--internal/gitutil/pull_request.go69
-rw-r--r--internal/gitutil/pull_request_test.go108
-rw-r--r--internal/gitutil/submodule.go48
-rw-r--r--internal/gitutil/submodule_test.go58
-rw-r--r--internal/gitutil/tag.go95
-rw-r--r--internal/gitutil/tag_test.go109
12 files changed, 887 insertions, 0 deletions
diff --git a/internal/gitutil/diff.go b/internal/gitutil/diff.go
new file mode 100644
index 00000000..4d759742
--- /dev/null
+++ b/internal/gitutil/diff.go
@@ -0,0 +1,196 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package gitutil
+
+import (
+ "bytes"
+ "fmt"
+ "html"
+ "html/template"
+ "io"
+ "sync"
+
+ "github.com/sergi/go-diff/diffmatchpatch"
+ "golang.org/x/net/html/charset"
+ "golang.org/x/text/transform"
+
+ "github.com/gogs/git-module"
+
+ "gogs.io/gogs/internal/conf"
+ "gogs.io/gogs/internal/template/highlight"
+ "gogs.io/gogs/internal/tool"
+)
+
+// DiffSection is a wrapper to git.DiffSection with helper methods.
+type DiffSection struct {
+ *git.DiffSection
+
+ initOnce sync.Once
+ dmp *diffmatchpatch.DiffMatchPatch
+}
+
+// ComputedInlineDiffFor computes inline diff for the given line.
+func (s *DiffSection) ComputedInlineDiffFor(line *git.DiffLine) template.HTML {
+ fallback := template.HTML(html.EscapeString(line.Content))
+ if conf.Git.DisableDiffHighlight {
+ return fallback
+ }
+
+ // Find equivalent diff line, ignore when not found.
+ var diff1, diff2 string
+ switch line.Type {
+ case git.DiffLineAdd:
+ compareLine := s.Line(git.DiffLineDelete, line.RightLine)
+ if compareLine == nil {
+ return fallback
+ }
+
+ diff1 = compareLine.Content
+ diff2 = line.Content
+
+ case git.DiffLineDelete:
+ compareLine := s.Line(git.DiffLineAdd, line.LeftLine)
+ if compareLine == nil {
+ return fallback
+ }
+
+ diff1 = line.Content
+ diff2 = compareLine.Content
+
+ default:
+ return fallback
+ }
+
+ s.initOnce.Do(func() {
+ s.dmp = diffmatchpatch.New()
+ s.dmp.DiffEditCost = 100
+ })
+
+ diffs := s.dmp.DiffMain(diff1[1:], diff2[1:], true)
+ diffs = s.dmp.DiffCleanupEfficiency(diffs)
+
+ return diffsToHTML(diffs, line.Type)
+}
+
+func diffsToHTML(diffs []diffmatchpatch.Diff, lineType git.DiffLineType) template.HTML {
+ buf := bytes.NewBuffer(nil)
+
+ // Reproduce signs which are cutted for inline diff before.
+ switch lineType {
+ case git.DiffLineAdd:
+ buf.WriteByte('+')
+ case git.DiffLineDelete:
+ buf.WriteByte('-')
+ }
+ buf.WriteByte(' ')
+
+ const (
+ addedCodePrefix = `<span class="added-code">`
+ removedCodePrefix = `<span class="removed-code">`
+ codeTagSuffix = `</span>`
+ )
+
+ for i := range diffs {
+ switch {
+ case diffs[i].Type == diffmatchpatch.DiffInsert && lineType == git.DiffLineAdd:
+ buf.WriteString(addedCodePrefix)
+ buf.WriteString(html.EscapeString(diffs[i].Text))
+ buf.WriteString(codeTagSuffix)
+ case diffs[i].Type == diffmatchpatch.DiffDelete && lineType == git.DiffLineDelete:
+ buf.WriteString(removedCodePrefix)
+ buf.WriteString(html.EscapeString(diffs[i].Text))
+ buf.WriteString(codeTagSuffix)
+ case diffs[i].Type == diffmatchpatch.DiffEqual:
+ buf.WriteString(html.EscapeString(diffs[i].Text))
+ }
+ }
+
+ return template.HTML(buf.Bytes())
+}
+
+// DiffFile is a wrapper to git.DiffFile with helper methods.
+type DiffFile struct {
+ *git.DiffFile
+ Sections []*DiffSection
+}
+
+// HighlightClass returns the detected highlight class for the file.
+func (diffFile *DiffFile) HighlightClass() string {
+ return highlight.FileNameToHighlightClass(diffFile.Name)
+}
+
+// Diff is a wrapper to git.Diff with helper methods.
+type Diff struct {
+ *git.Diff
+ Files []*DiffFile
+}
+
+// NewDiff returns a new wrapper of given git.Diff.
+func NewDiff(oldDiff *git.Diff) *Diff {
+ newDiff := &Diff{
+ Diff: oldDiff,
+ Files: make([]*DiffFile, oldDiff.NumFiles()),
+ }
+
+ // FIXME: detect encoding while parsing.
+ var buf bytes.Buffer
+ for i := range oldDiff.Files {
+ buf.Reset()
+
+ newDiff.Files[i] = &DiffFile{
+ DiffFile: oldDiff.Files[i],
+ Sections: make([]*DiffSection, oldDiff.Files[i].NumSections()),
+ }
+
+ for j := range oldDiff.Files[i].Sections {
+ newDiff.Files[i].Sections[j] = &DiffSection{
+ DiffSection: oldDiff.Files[i].Sections[j],
+ }
+
+ for k := range newDiff.Files[i].Sections[j].Lines {
+ buf.WriteString(newDiff.Files[i].Sections[j].Lines[k].Content)
+ buf.WriteString("\n")
+ }
+ }
+
+ charsetLabel, err := tool.DetectEncoding(buf.Bytes())
+ if charsetLabel != "UTF-8" && err == nil {
+ encoding, _ := charset.Lookup(charsetLabel)
+ if encoding != nil {
+ d := encoding.NewDecoder()
+ for j := range newDiff.Files[i].Sections {
+ for k := range newDiff.Files[i].Sections[j].Lines {
+ if c, _, err := transform.String(d, newDiff.Files[i].Sections[j].Lines[k].Content); err == nil {
+ newDiff.Files[i].Sections[j].Lines[k].Content = c
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return newDiff
+}
+
+// ParseDiff parses the diff from given io.Reader.
+func ParseDiff(r io.Reader, maxFiles, maxFileLines, maxLineChars int) (*Diff, error) {
+ done := make(chan git.SteamParseDiffResult)
+ go git.StreamParseDiff(r, done, maxFiles, maxFileLines, maxLineChars)
+
+ result := <-done
+ if result.Err != nil {
+ return nil, fmt.Errorf("stream parse diff: %v", result.Err)
+ }
+ return NewDiff(result.Diff), nil
+}
+
+// RepoDiff parses the diff on given revisions of given repository.
+func RepoDiff(repo *git.Repository, rev string, maxFiles, maxFileLines, maxLineChars int, opts ...git.DiffOptions) (*Diff, error) {
+ diff, err := repo.Diff(rev, maxFiles, maxFileLines, maxLineChars, opts...)
+ if err != nil {
+ return nil, fmt.Errorf("get diff: %v", err)
+ }
+ return NewDiff(diff), nil
+}
diff --git a/internal/gitutil/diff_test.go b/internal/gitutil/diff_test.go
new file mode 100644
index 00000000..b651b088
--- /dev/null
+++ b/internal/gitutil/diff_test.go
@@ -0,0 +1,49 @@
+// Copyright 2016 The Gogs Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package gitutil
+
+import (
+ "html/template"
+ "testing"
+
+ dmp "github.com/sergi/go-diff/diffmatchpatch"
+ "github.com/stretchr/testify/assert"
+
+ "github.com/gogs/git-module"
+)
+
+func Test_diffsToHTML(t *testing.T) {
+ tests := []struct {
+ diffs []dmp.Diff
+ lineType git.DiffLineType
+ expHTML template.HTML
+ }{
+ {
+ diffs: []dmp.Diff{
+ {Type: dmp.DiffEqual, Text: "foo "},
+ {Type: dmp.DiffInsert, Text: "bar"},
+ {Type: dmp.DiffDelete, Text: " baz"},
+ {Type: dmp.DiffEqual, Text: " biz"},
+ },
+ lineType: git.DiffLineAdd,
+ expHTML: template.HTML(`+ foo <span class="added-code">bar</span> biz`),
+ },
+ {
+ diffs: []dmp.Diff{
+ {Type: dmp.DiffEqual, Text: "foo "},
+ {Type: dmp.DiffDelete, Text: "bar"},
+ {Type: dmp.DiffInsert, Text: " baz"},
+ {Type: dmp.DiffEqual, Text: " biz"},
+ },
+ lineType: git.DiffLineDelete,
+ expHTML: template.HTML(`- foo <span class="removed-code">bar</span> biz`),
+ },
+ }
+ for _, test := range tests {
+ t.Run("", func(t *testing.T) {
+ assert.Equal(t, test.expHTML, diffsToHTML(test.diffs, test.lineType))
+ })
+ }
+}
diff --git a/internal/gitutil/error.go b/internal/gitutil/error.go
new file mode 100644
index 00000000..08575ec3
--- /dev/null
+++ b/internal/gitutil/error.go
@@ -0,0 +1,19 @@
+// Copyright 2020 The Gogs Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package gitutil
+
+import (
+ "github.com/gogs/git-module"
+)
+
+// IsErrRevisionNotExist returns true if the error is git.ErrRevisionNotExist.
+func IsErrRevisionNotExist(err error) bool {
+ return err == git.ErrRevisionNotExist
+}
+
+// IsErrNoMergeBase returns true if the error is git.ErrNoMergeBase.
+func IsErrNoMergeBase(err error) bool {
+ return err == git.ErrNoMergeBase
+}
diff --git a/internal/gitutil/error_test.go b/internal/gitutil/error_test.go
new file mode 100644
index 00000000..c0b9f77a
--- /dev/null
+++ b/internal/gitutil/error_test.go
@@ -0,0 +1,23 @@
+// Copyright 2020 The Gogs Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package gitutil
+
+import (
+ "os"
+ "testing"
+
+ "github.com/gogs/git-module"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestIsErrRevisionNotExist(t *testing.T) {
+ assert.True(t, IsErrRevisionNotExist(git.ErrRevisionNotExist))
+ assert.False(t, IsErrRevisionNotExist(os.ErrNotExist))
+}
+
+func TestIsErrNoMergeBase(t *testing.T) {
+ assert.True(t, IsErrNoMergeBase(git.ErrNoMergeBase))
+ assert.False(t, IsErrNoMergeBase(os.ErrNotExist))
+}
diff --git a/internal/gitutil/mock.go b/internal/gitutil/mock.go
new file mode 100644
index 00000000..6cd90dfa
--- /dev/null
+++ b/internal/gitutil/mock.go
@@ -0,0 +1,23 @@
+// Copyright 2020 The Gogs Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package gitutil
+
+import (
+ "github.com/gogs/git-module"
+)
+
+type MockModuleStore struct {
+ RepoAddRemote func(repoPath, name, url string, opts ...git.AddRemoteOptions) error
+ RepoDiffNameOnly func(repoPath, base, head string, opts ...git.DiffNameOnlyOptions) ([]string, error)
+ RepoLog func(repoPath, rev string, opts ...git.LogOptions) ([]*git.Commit, error)
+ RepoMergeBase func(repoPath, base, head string, opts ...git.MergeBaseOptions) (string, error)
+ RepoRemoveRemote func(repoPath, name string, opts ...git.RemoveRemoteOptions) error
+ RepoTags func(repoPath string, opts ...git.TagsOptions) ([]string, error)
+}
+
+// MockModule holds mock implementation of each method for Modulers interface.
+// When the field is non-nil, it is considered mocked. Otherwise, the real
+// implementation will be executed.
+var MockModule MockModuleStore
diff --git a/internal/gitutil/module.go b/internal/gitutil/module.go
new file mode 100644
index 00000000..3c938041
--- /dev/null
+++ b/internal/gitutil/module.go
@@ -0,0 +1,90 @@
+// Copyright 2020 The Gogs Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package gitutil
+
+import (
+ "github.com/gogs/git-module"
+)
+
+// Moduler is the interface for Git operations.
+//
+// NOTE: All methods are sorted in alphabetically.
+type Moduler interface {
+ // AddRemote adds a new remote to the repository in given path.
+ RepoAddRemote(repoPath, name, url string, opts ...git.AddRemoteOptions) error
+ // RepoDiffNameOnly returns a list of changed files between base and head revisions
+ // of the repository in given path.
+ RepoDiffNameOnly(repoPath, base, head string, opts ...git.DiffNameOnlyOptions) ([]string, error)
+ // RepoLog returns a list of commits in the state of given revision of the repository
+ // in given path. The returned list is in reverse chronological order.
+ RepoLog(repoPath, rev string, opts ...git.LogOptions) ([]*git.Commit, error)
+ // RepoMergeBase returns merge base between base and head revisions of the repository
+ // in given path.
+ RepoMergeBase(repoPath, base, head string, opts ...git.MergeBaseOptions) (string, error)
+ // RepoRemoveRemote removes a remote from the repository in given path.
+ RepoRemoveRemote(repoPath, name string, opts ...git.RemoveRemoteOptions) error
+ // RepoTags returns a list of tags of the repository in given path.
+ RepoTags(repoPath string, opts ...git.TagsOptions) ([]string, error)
+
+ Utiler
+}
+
+// Utiler is the interface for utility helpers implemented in this package.
+//
+// NOTE: All methods are sorted in alphabetically.
+type Utiler interface {
+ // GetPullRequestMeta gathers pull request metadata based on given head and base information.
+ PullRequestMeta(headPath, basePath, headBranch, baseBranch string) (*PullRequestMeta, error)
+ // ListTagsAfter returns a list of tags "after" (exlusive) given tag.
+ ListTagsAfter(repoPath, after string, limit int) (*TagsPage, error)
+}
+
+// moduler is holds real implementation.
+type moduler struct{}
+
+func (moduler) RepoAddRemote(repoPath, name, url string, opts ...git.AddRemoteOptions) error {
+ if MockModule.RepoAddRemote != nil {
+ return MockModule.RepoAddRemote(repoPath, name, url, opts...)
+ }
+ return git.RepoAddRemote(repoPath, name, url, opts...)
+}
+
+func (moduler) RepoDiffNameOnly(repoPath, base, head string, opts ...git.DiffNameOnlyOptions) ([]string, error) {
+ if MockModule.RepoDiffNameOnly != nil {
+ return MockModule.RepoDiffNameOnly(repoPath, base, head, opts...)
+ }
+ return git.RepoDiffNameOnly(repoPath, base, head, opts...)
+}
+
+func (moduler) RepoLog(repoPath, rev string, opts ...git.LogOptions) ([]*git.Commit, error) {
+ if MockModule.RepoLog != nil {
+ return MockModule.RepoLog(repoPath, rev, opts...)
+ }
+ return git.RepoLog(repoPath, rev, opts...)
+}
+
+func (moduler) RepoMergeBase(repoPath, base, head string, opts ...git.MergeBaseOptions) (string, error) {
+ if MockModule.RepoMergeBase != nil {
+ return MockModule.RepoMergeBase(repoPath, base, head, opts...)
+ }
+ return git.RepoMergeBase(repoPath, base, head, opts...)
+}
+
+func (moduler) RepoRemoveRemote(repoPath, name string, opts ...git.RemoveRemoteOptions) error {
+ if MockModule.RepoRemoveRemote != nil {
+ return MockModule.RepoRemoveRemote(repoPath, name, opts...)
+ }
+ return git.RepoRemoveRemote(repoPath, name, opts...)
+}
+
+func (moduler) RepoTags(repoPath string, opts ...git.TagsOptions) ([]string, error) {
+ if MockModule.RepoTags != nil {
+ return MockModule.RepoTags(repoPath, opts...)
+ }
+ return git.RepoTags(repoPath, opts...)
+}
+
+// Module is a mockable interface for Git operations.
+var Module Moduler = moduler{}
diff --git a/internal/gitutil/pull_request.go b/internal/gitutil/pull_request.go
new file mode 100644
index 00000000..818dc230
--- /dev/null
+++ b/internal/gitutil/pull_request.go
@@ -0,0 +1,69 @@
+// Copyright 2020 The Gogs Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package gitutil
+
+import (
+ "fmt"
+ "strconv"
+ "time"
+
+ "github.com/gogs/git-module"
+ "github.com/pkg/errors"
+ log "unknwon.dev/clog/v2"
+)
+
+// PullRequestMeta contains metadata for a pull request.
+type PullRequestMeta struct {
+ // The merge base of the pull request.
+ MergeBase string
+ // The commits that are requested to be merged.
+ Commits []*git.Commit
+ // The number of files changed.
+ NumFiles int
+}
+
+func (moduler) PullRequestMeta(headPath, basePath, headBranch, baseBranch string) (*PullRequestMeta, error) {
+ tmpRemoteBranch := baseBranch
+
+ // We need to create a temporary remote when the pull request is sent from a forked repository.
+ if headPath != basePath {
+ tmpRemote := strconv.FormatInt(time.Now().UnixNano(), 10)
+ err := Module.RepoAddRemote(headPath, tmpRemote, basePath, git.AddRemoteOptions{Fetch: true})
+ if err != nil {
+ return nil, fmt.Errorf("add remote: %v", err)
+ }
+ defer func() {
+ err := Module.RepoRemoveRemote(headPath, tmpRemote)
+ if err != nil {
+ log.Error("Failed to remove remote %q [path: %s]: %v", tmpRemote, headPath, err)
+ return
+ }
+ }()
+
+ tmpRemoteBranch = "remotes/" + tmpRemote + "/" + baseBranch
+ }
+
+ mergeBase, err := Module.RepoMergeBase(headPath, tmpRemoteBranch, headBranch)
+ if err != nil {
+ return nil, errors.Wrap(err, "get merge base")
+ }
+
+ commits, err := Module.RepoLog(headPath, mergeBase+"..."+headBranch)
+ if err != nil {
+ return nil, errors.Wrap(err, "get commits")
+ }
+
+ // Count number of changed files
+ names, err := Module.RepoDiffNameOnly(headPath, tmpRemoteBranch, headBranch, git.DiffNameOnlyOptions{NeedsMergeBase: true})
+ if err != nil {
+ return nil, errors.Wrap(err, "get changed files")
+ }
+
+ return &PullRequestMeta{
+ MergeBase: mergeBase,
+ Commits: commits,
+ NumFiles: len(names),
+ }, nil
+}
diff --git a/internal/gitutil/pull_request_test.go b/internal/gitutil/pull_request_test.go
new file mode 100644
index 00000000..d7453ff3
--- /dev/null
+++ b/internal/gitutil/pull_request_test.go
@@ -0,0 +1,108 @@
+// Copyright 2020 The Gogs Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package gitutil
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/gogs/git-module"
+ "github.com/pkg/errors"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestModuler_PullRequestMeta(t *testing.T) {
+ headPath := "/head/path"
+ basePath := "/base/path"
+ headBranch := "head_branch"
+ baseBranch := "base_branch"
+ mergeBase := "MERGE-BASE"
+ changedFiles := []string{"a.go", "b.txt"}
+ commits := []*git.Commit{
+ {ID: git.MustIDFromString("adfd6da3c0a3fb038393144becbf37f14f780087")},
+ }
+
+ MockModule.RepoAddRemote = func(repoPath, name, url string, opts ...git.AddRemoteOptions) error {
+ if repoPath != headPath {
+ return fmt.Errorf("repoPath: want %q but got %q", headPath, repoPath)
+ } else if name == "" {
+ return errors.New("empty name")
+ } else if url != basePath {
+ return fmt.Errorf("url: want %q but got %q", basePath, url)
+ }
+
+ if len(opts) == 0 {
+ return errors.New("no options")
+ } else if !opts[0].Fetch {
+ return fmt.Errorf("opts.Fetch: want %v but got %v", true, opts[0].Fetch)
+ }
+
+ return nil
+ }
+ MockModule.RepoMergeBase = func(repoPath, base, head string, opts ...git.MergeBaseOptions) (string, error) {
+ if repoPath != headPath {
+ return "", fmt.Errorf("repoPath: want %q but got %q", headPath, repoPath)
+ } else if base == "" {
+ return "", errors.New("empty base")
+ } else if head != headBranch {
+ return "", fmt.Errorf("head: want %q but got %q", headBranch, head)
+ }
+
+ return mergeBase, nil
+ }
+ MockModule.RepoLog = func(repoPath, rev string, opts ...git.LogOptions) ([]*git.Commit, error) {
+ if repoPath != headPath {
+ return nil, fmt.Errorf("repoPath: want %q but got %q", headPath, repoPath)
+ }
+
+ expRev := mergeBase + "..." + headBranch
+ if rev != expRev {
+ return nil, fmt.Errorf("rev: want %q but got %q", expRev, rev)
+ }
+
+ return commits, nil
+ }
+ MockModule.RepoDiffNameOnly = func(repoPath, base, head string, opts ...git.DiffNameOnlyOptions) ([]string, error) {
+ if repoPath != headPath {
+ return nil, fmt.Errorf("repoPath: want %q but got %q", headPath, repoPath)
+ } else if base == "" {
+ return nil, errors.New("empty base")
+ } else if head != headBranch {
+ return nil, fmt.Errorf("head: want %q but got %q", headBranch, head)
+ }
+
+ if len(opts) == 0 {
+ return nil, errors.New("no options")
+ } else if !opts[0].NeedsMergeBase {
+ return nil, fmt.Errorf("opts.NeedsMergeBase: want %v but got %v", true, opts[0].NeedsMergeBase)
+ }
+
+ return changedFiles, nil
+ }
+ MockModule.RepoRemoveRemote = func(repoPath, name string, opts ...git.RemoveRemoteOptions) error {
+ if repoPath != headPath {
+ return fmt.Errorf("repoPath: want %q but got %q", headPath, repoPath)
+ } else if name == "" {
+ return errors.New("empty name")
+ }
+
+ return nil
+ }
+ defer func() {
+ MockModule = MockModuleStore{}
+ }()
+
+ meta, err := Module.PullRequestMeta(headPath, basePath, headBranch, baseBranch)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ expMeta := &PullRequestMeta{
+ MergeBase: mergeBase,
+ Commits: commits,
+ NumFiles: 2,
+ }
+ assert.Equal(t, expMeta, meta)
+}
diff --git a/internal/gitutil/submodule.go b/internal/gitutil/submodule.go
new file mode 100644
index 00000000..e46f48a1
--- /dev/null
+++ b/internal/gitutil/submodule.go
@@ -0,0 +1,48 @@
+// Copyright 2020 The Gogs Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package gitutil
+
+import (
+ "fmt"
+ "net/url"
+ "strings"
+
+ "github.com/gogs/git-module"
+
+ "gogs.io/gogs/internal/lazyregexp"
+)
+
+var scpSyntax = lazyregexp.New(`^([a-zA-Z0-9_]+@)?([a-zA-Z0-9._-]+):(.*)$`)
+
+// InferSubmoduleURL returns the inferred external URL of the submodule at best effort.
+func InferSubmoduleURL(mod *git.Submodule) string {
+ raw := strings.TrimSuffix(mod.URL, "/")
+ raw = strings.TrimSuffix(raw, ".git")
+
+ parsed, err := url.Parse(raw)
+ if err != nil {
+ // Try parse as SCP syntax again
+ match := scpSyntax.FindAllStringSubmatch(raw, -1)
+ if len(match) == 0 {
+ return mod.URL
+ }
+ parsed = &url.URL{
+ Scheme: "http",
+ Host: match[0][2],
+ Path: match[0][3],
+ }
+ }
+
+ switch parsed.Scheme {
+ case "http", "https":
+ raw = parsed.String()
+ case "ssh":
+ raw = fmt.Sprintf("http://%s%s", parsed.Host, parsed.Path)
+ default:
+ return raw
+ }
+
+ return fmt.Sprintf("%s/commit/%s", raw, mod.Commit)
+}
diff --git a/internal/gitutil/submodule_test.go b/internal/gitutil/submodule_test.go
new file mode 100644
index 00000000..2bfe5706
--- /dev/null
+++ b/internal/gitutil/submodule_test.go
@@ -0,0 +1,58 @@
+// Copyright 2020 The Gogs Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package gitutil
+
+import (
+ "testing"
+
+ "github.com/gogs/git-module"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestInferSubmoduleURL(t *testing.T) {
+ tests := []struct {
+ name string
+ submodule *git.Submodule
+ expURL string
+ }{
+ {
+ name: "HTTPS URL",
+ submodule: &git.Submodule{
+ URL: "https://github.com/gogs/docs-api.git",
+ Commit: "6b08f76a5313fa3d26859515b30aa17a5faa2807",
+ },
+ expURL: "https://github.com/gogs/docs-api/commit/6b08f76a5313fa3d26859515b30aa17a5faa2807",
+ },
+ {
+ name: "SSH URL with port",
+ submodule: &git.Submodule{
+ URL: "ssh://user@github.com:22/gogs/docs-api.git",
+ Commit: "6b08f76a5313fa3d26859515b30aa17a5faa2807",
+ },
+ expURL: "http://github.com:22/gogs/docs-api/commit/6b08f76a5313fa3d26859515b30aa17a5faa2807",
+ },
+ {
+ name: "SSH URL in SCP syntax",
+ submodule: &git.Submodule{
+ URL: "git@github.com:gogs/docs-api.git",
+ Commit: "6b08f76a5313fa3d26859515b30aa17a5faa2807",
+ },
+ expURL: "http://github.com/gogs/docs-api/commit/6b08f76a5313fa3d26859515b30aa17a5faa2807",
+ },
+ {
+ name: "bad URL",
+ submodule: &git.Submodule{
+ URL: "ftp://example.com",
+ Commit: "6b08f76a5313fa3d26859515b30aa17a5faa2807",
+ },
+ expURL: "ftp://example.com",
+ },
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ assert.Equal(t, test.expURL, InferSubmoduleURL(test.submodule))
+ })
+ }
+}
diff --git a/internal/gitutil/tag.go b/internal/gitutil/tag.go
new file mode 100644
index 00000000..86efd6cd
--- /dev/null
+++ b/internal/gitutil/tag.go
@@ -0,0 +1,95 @@
+// Copyright 2020 The Gogs Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package gitutil
+
+import (
+ "github.com/pkg/errors"
+)
+
+// TagsPage contains a list of tags and pagination information.
+type TagsPage struct {
+ // List of tags in the current page.
+ Tags []string
+ // Whether the results include the latest tag.
+ HasLatest bool
+ // When results do not include the latest tag, an indicator of 'after' to go back.
+ PreviousAfter string
+ // Whether there are more tags in the next page.
+ HasNext bool
+}
+
+func (moduler) ListTagsAfter(repoPath, after string, limit int) (*TagsPage, error) {
+ all, err := Module.RepoTags(repoPath)
+ if err != nil {
+ return nil, errors.Wrap(err, "get tags")
+ }
+ total := len(all)
+
+ if limit < 0 {
+ limit = 0
+ }
+
+ // Returns everything when no filter and no limit
+ if after == "" && limit == 0 {
+ return &TagsPage{
+ Tags: all,
+ HasLatest: true,
+ }, nil
+ }
+
+ // No filter but has a limit, returns first X tags
+ if after == "" && limit > 0 {
+ endIdx := limit
+ if limit > total {
+ endIdx = total
+ }
+ return &TagsPage{
+ Tags: all[:endIdx],
+ HasLatest: true,
+ HasNext: limit < total,
+ }, nil
+ }
+
+ // Loop over all tags see if we can find the filter
+ previousAfter := ""
+ found := false
+ tags := make([]string, 0, len(all))
+ for i := range all {
+ if all[i] != after {
+ continue
+ }
+
+ found = true
+ if limit > 0 && i-limit >= 0 {
+ previousAfter = all[i-limit]
+ }
+
+ // In case filter is the oldest one
+ if i+1 < total {
+ tags = all[i+1:]
+ }
+ break
+ }
+
+ if !found {
+ tags = all
+ }
+
+ // If all tags after match is equal to the limit, it reaches the oldest tag as well.
+ if limit == 0 || len(tags) <= limit {
+ return &TagsPage{
+ Tags: tags,
+ HasLatest: !found,
+ PreviousAfter: previousAfter,
+ }, nil
+ }
+
+ return &TagsPage{
+ Tags: tags[:limit],
+ HasLatest: !found,
+ PreviousAfter: previousAfter,
+ HasNext: true,
+ }, nil
+}
diff --git a/internal/gitutil/tag_test.go b/internal/gitutil/tag_test.go
new file mode 100644
index 00000000..732eced6
--- /dev/null
+++ b/internal/gitutil/tag_test.go
@@ -0,0 +1,109 @@
+// Copyright 2020 The Gogs Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package gitutil
+
+import (
+ "testing"
+
+ "github.com/gogs/git-module"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestModuler_ListTagsAfter(t *testing.T) {
+ MockModule.RepoTags = func(string, ...git.TagsOptions) ([]string, error) {
+ return []string{
+ "v2.3.0", "v2.2.1", "v2.1.0",
+ "v1.3.0", "v1.2.0", "v1.1.0",
+ "v0.8.0", "v0.5.0", "v0.1.0",
+ }, nil
+ }
+ defer func() {
+ MockModule = MockModuleStore{}
+ }()
+
+ tests := []struct {
+ name string
+ after string
+ expTagsPage *TagsPage
+ }{
+ {
+ name: "first page",
+ expTagsPage: &TagsPage{
+ Tags: []string{
+ "v2.3.0", "v2.2.1", "v2.1.0",
+ },
+ HasLatest: true,
+ HasNext: true,
+ },
+ },
+ {
+ name: "second page",
+ after: "v2.1.0",
+ expTagsPage: &TagsPage{
+ Tags: []string{
+ "v1.3.0", "v1.2.0", "v1.1.0",
+ },
+ HasLatest: false,
+ HasNext: true,
+ },
+ },
+ {
+ name: "last page",
+ after: "v1.1.0",
+ expTagsPage: &TagsPage{
+ Tags: []string{
+ "v0.8.0", "v0.5.0", "v0.1.0",
+ },
+ HasLatest: false,
+ PreviousAfter: "v2.1.0",
+ HasNext: false,
+ },
+ },
+
+ {
+ name: "arbitrary after",
+ after: "v1.2.0",
+ expTagsPage: &TagsPage{
+ Tags: []string{
+ "v1.1.0", "v0.8.0", "v0.5.0",
+ },
+ HasLatest: false,
+ PreviousAfter: "v2.2.1",
+ HasNext: true,
+ },
+ },
+ {
+ name: "after the oldest one",
+ after: "v0.1.0",
+ expTagsPage: &TagsPage{
+ Tags: []string{},
+ HasLatest: false,
+ PreviousAfter: "v1.1.0",
+ HasNext: false,
+ },
+ },
+ {
+ name: "after does not exist",
+ after: "v2.2.9",
+ expTagsPage: &TagsPage{
+ Tags: []string{
+ "v2.3.0", "v2.2.1", "v2.1.0",
+ },
+ HasLatest: true,
+ HasNext: true,
+ },
+ },
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ tagsPage, err := Module.ListTagsAfter("", test.after, 3)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ assert.Equal(t, test.expTagsPage, tagsPage)
+ })
+ }
+}