diff options
Diffstat (limited to 'internal/gitutil')
-rw-r--r-- | internal/gitutil/diff.go | 196 | ||||
-rw-r--r-- | internal/gitutil/diff_test.go | 49 | ||||
-rw-r--r-- | internal/gitutil/error.go | 19 | ||||
-rw-r--r-- | internal/gitutil/error_test.go | 23 | ||||
-rw-r--r-- | internal/gitutil/mock.go | 23 | ||||
-rw-r--r-- | internal/gitutil/module.go | 90 | ||||
-rw-r--r-- | internal/gitutil/pull_request.go | 69 | ||||
-rw-r--r-- | internal/gitutil/pull_request_test.go | 108 | ||||
-rw-r--r-- | internal/gitutil/submodule.go | 48 | ||||
-rw-r--r-- | internal/gitutil/submodule_test.go | 58 | ||||
-rw-r--r-- | internal/gitutil/tag.go | 95 | ||||
-rw-r--r-- | internal/gitutil/tag_test.go | 109 |
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) + }) + } +} |