初始提交: Gitea 项目代码
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# Git Module
|
||||
|
||||
This module is merged from https://github.com/go-gitea/git which is a Go module to access Git through shell commands. Now it's a part of gitea's main repository for easier pull request.
|
||||
@@ -0,0 +1,115 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package attribute
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/optional"
|
||||
)
|
||||
|
||||
type Attribute string
|
||||
|
||||
const (
|
||||
LinguistVendored = "linguist-vendored"
|
||||
LinguistGenerated = "linguist-generated"
|
||||
LinguistDocumentation = "linguist-documentation"
|
||||
LinguistDetectable = "linguist-detectable"
|
||||
LinguistLanguage = "linguist-language"
|
||||
GitlabLanguage = "gitlab-language"
|
||||
Lockable = "lockable"
|
||||
Filter = "filter"
|
||||
Diff = "diff"
|
||||
)
|
||||
|
||||
var LinguistAttributes = []string{
|
||||
LinguistVendored,
|
||||
LinguistGenerated,
|
||||
LinguistDocumentation,
|
||||
LinguistDetectable,
|
||||
LinguistLanguage,
|
||||
GitlabLanguage,
|
||||
}
|
||||
|
||||
func (a Attribute) IsUnspecified() bool {
|
||||
return a == "" || a == "unspecified"
|
||||
}
|
||||
|
||||
func (a Attribute) ToString() optional.Option[string] {
|
||||
if !a.IsUnspecified() {
|
||||
return optional.Some(string(a))
|
||||
}
|
||||
return optional.None[string]()
|
||||
}
|
||||
|
||||
// ToBool converts the attribute value to optional boolean: true if "set"/"true", false if "unset"/"false", none otherwise
|
||||
func (a Attribute) ToBool() optional.Option[bool] {
|
||||
switch a {
|
||||
case "set", "true":
|
||||
return optional.Some(true)
|
||||
case "unset", "false":
|
||||
return optional.Some(false)
|
||||
}
|
||||
return optional.None[bool]()
|
||||
}
|
||||
|
||||
type Attributes struct {
|
||||
m map[string]Attribute
|
||||
}
|
||||
|
||||
func NewAttributes() *Attributes {
|
||||
return &Attributes{m: make(map[string]Attribute)}
|
||||
}
|
||||
|
||||
func (attrs *Attributes) Get(name string) Attribute {
|
||||
if value, has := attrs.m[name]; has {
|
||||
return value
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (attrs *Attributes) GetVendored() optional.Option[bool] {
|
||||
return attrs.Get(LinguistVendored).ToBool()
|
||||
}
|
||||
|
||||
func (attrs *Attributes) GetGenerated() optional.Option[bool] {
|
||||
return attrs.Get(LinguistGenerated).ToBool()
|
||||
}
|
||||
|
||||
func (attrs *Attributes) GetDocumentation() optional.Option[bool] {
|
||||
return attrs.Get(LinguistDocumentation).ToBool()
|
||||
}
|
||||
|
||||
func (attrs *Attributes) GetDetectable() optional.Option[bool] {
|
||||
return attrs.Get(LinguistDetectable).ToBool()
|
||||
}
|
||||
|
||||
func (attrs *Attributes) GetLinguistLanguage() optional.Option[string] {
|
||||
return attrs.Get(LinguistLanguage).ToString()
|
||||
}
|
||||
|
||||
func (attrs *Attributes) GetGitlabLanguage() optional.Option[string] {
|
||||
attrStr := attrs.Get(GitlabLanguage).ToString()
|
||||
if attrStr.Has() {
|
||||
raw := attrStr.Value()
|
||||
// gitlab-language may have additional parameters after the language
|
||||
// ignore them and just use the main language
|
||||
// https://docs.gitlab.com/ee/user/project/highlighting.html#override-syntax-highlighting-for-a-file-type
|
||||
if before, _, ok := strings.Cut(raw, "?"); ok {
|
||||
return optional.Some(before)
|
||||
}
|
||||
}
|
||||
return attrStr
|
||||
}
|
||||
|
||||
func (attrs *Attributes) GetLanguage() optional.Option[string] {
|
||||
// prefer linguist-language over gitlab-language
|
||||
// if linguist-language is not set, use gitlab-language
|
||||
// if both are not set, return none
|
||||
language := attrs.GetLinguistLanguage()
|
||||
if language.Value() == "" {
|
||||
language = attrs.GetGitlabLanguage()
|
||||
}
|
||||
return language
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package attribute
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_Attribute(t *testing.T) {
|
||||
assert.Empty(t, Attribute("").ToString().Value())
|
||||
assert.Empty(t, Attribute("unspecified").ToString().Value())
|
||||
assert.Equal(t, "python", Attribute("python").ToString().Value())
|
||||
assert.Equal(t, "Java", Attribute("Java").ToString().Value())
|
||||
|
||||
attributes := Attributes{
|
||||
m: map[string]Attribute{
|
||||
LinguistGenerated: "true",
|
||||
LinguistDocumentation: "false",
|
||||
LinguistDetectable: "set",
|
||||
LinguistLanguage: "Python",
|
||||
GitlabLanguage: "Java",
|
||||
"filter": "unspecified",
|
||||
"test": "",
|
||||
},
|
||||
}
|
||||
|
||||
assert.Empty(t, attributes.Get("test").ToString().Value())
|
||||
assert.Empty(t, attributes.Get("filter").ToString().Value())
|
||||
assert.Equal(t, "Python", attributes.Get(LinguistLanguage).ToString().Value())
|
||||
assert.Equal(t, "Java", attributes.Get(GitlabLanguage).ToString().Value())
|
||||
assert.True(t, attributes.Get(LinguistGenerated).ToBool().Value())
|
||||
assert.False(t, attributes.Get(LinguistDocumentation).ToBool().Value())
|
||||
assert.True(t, attributes.Get(LinguistDetectable).ToBool().Value())
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package attribute
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/log"
|
||||
)
|
||||
|
||||
// BatchChecker provides a reader for check-attribute content that can be long running
|
||||
type BatchChecker struct {
|
||||
attributesNum int
|
||||
repo *git.Repository
|
||||
stdinWriter io.WriteCloser
|
||||
stdOut *nulSeparatedAttributeWriter
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
cmd *gitcmd.Command
|
||||
}
|
||||
|
||||
// NewBatchChecker creates a check attribute reader for the current repository and provided commit ID
|
||||
// If treeish is empty, then it will use current working directory, otherwise it will use the provided treeish on the bare repo
|
||||
func NewBatchChecker(repo *git.Repository, treeish string, attributes []string) (checker *BatchChecker, returnedErr error) {
|
||||
ctx, cancel := context.WithCancel(repo.Ctx)
|
||||
defer func() {
|
||||
if returnedErr != nil {
|
||||
cancel()
|
||||
}
|
||||
}()
|
||||
|
||||
cmd, envs, cleanup, err := checkAttrCommand(repo, treeish, nil, attributes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if returnedErr != nil {
|
||||
cleanup()
|
||||
}
|
||||
}()
|
||||
|
||||
cmd.AddArguments("--stdin")
|
||||
|
||||
checker = &BatchChecker{
|
||||
attributesNum: len(attributes),
|
||||
repo: repo,
|
||||
ctx: ctx,
|
||||
cmd: cmd,
|
||||
cancel: func() {
|
||||
cancel()
|
||||
cleanup()
|
||||
},
|
||||
}
|
||||
|
||||
stdinWriter, stdinWriterClose := cmd.MakeStdinPipe()
|
||||
checker.stdinWriter = stdinWriter
|
||||
|
||||
lw := new(nulSeparatedAttributeWriter)
|
||||
lw.attributes = make(chan attributeTriple, len(attributes))
|
||||
lw.closed = make(chan struct{})
|
||||
checker.stdOut = lw
|
||||
|
||||
cmd.WithEnv(envs).
|
||||
WithDir(repo.Path).
|
||||
WithStdoutCopy(lw)
|
||||
|
||||
go func() {
|
||||
defer stdinWriterClose()
|
||||
defer checker.cancel()
|
||||
defer lw.Close()
|
||||
|
||||
err := cmd.RunWithStderr(ctx)
|
||||
if err != nil && !gitcmd.IsErrorCanceledOrKilled(err) {
|
||||
log.Error("Attribute checker for commit %s exits with error: %v", treeish, err)
|
||||
}
|
||||
}()
|
||||
|
||||
return checker, nil
|
||||
}
|
||||
|
||||
// CheckPath check attr for given path
|
||||
func (c *BatchChecker) CheckPath(path string) (rs *Attributes, err error) {
|
||||
defer func() {
|
||||
if err != nil && err != c.ctx.Err() {
|
||||
log.Error("Unexpected error when checking path %s in %s, error: %v", path, filepath.Base(c.repo.Path), err)
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-c.ctx.Done():
|
||||
return nil, c.ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
if _, err = c.stdinWriter.Write([]byte(path + "\x00")); err != nil {
|
||||
defer c.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reportTimeout := func() error {
|
||||
stdOutClosed := false
|
||||
select {
|
||||
case <-c.stdOut.closed:
|
||||
stdOutClosed = true
|
||||
default:
|
||||
}
|
||||
debugMsg := fmt.Sprintf("check path %q in repo %q", path, filepath.Base(c.repo.Path))
|
||||
debugMsg += fmt.Sprintf(", stdOut: tmp=%q, pos=%d, closed=%v", string(c.stdOut.tmp), c.stdOut.pos, stdOutClosed)
|
||||
if c.cmd != nil {
|
||||
debugMsg += fmt.Sprintf(", process state: %q", c.cmd.ProcessState())
|
||||
}
|
||||
_ = c.Close()
|
||||
return fmt.Errorf("CheckPath timeout: %s", debugMsg)
|
||||
}
|
||||
|
||||
rs = NewAttributes()
|
||||
for i := 0; i < c.attributesNum; i++ {
|
||||
select {
|
||||
case <-time.After(5 * time.Second):
|
||||
// there is no "hang" problem now. This code is just used to catch other potential problems.
|
||||
return nil, reportTimeout()
|
||||
case attr, ok := <-c.stdOut.ReadAttribute():
|
||||
if !ok {
|
||||
return nil, c.ctx.Err()
|
||||
}
|
||||
rs.m[attr.Attribute] = Attribute(attr.Value)
|
||||
case <-c.ctx.Done():
|
||||
return nil, c.ctx.Err()
|
||||
}
|
||||
}
|
||||
return rs, nil
|
||||
}
|
||||
|
||||
func (c *BatchChecker) Close() error {
|
||||
c.cancel()
|
||||
err := c.stdinWriter.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
type attributeTriple struct {
|
||||
Filename string
|
||||
Attribute string
|
||||
Value string
|
||||
}
|
||||
|
||||
type nulSeparatedAttributeWriter struct {
|
||||
tmp []byte
|
||||
attributes chan attributeTriple
|
||||
closed chan struct{}
|
||||
working attributeTriple
|
||||
pos int
|
||||
}
|
||||
|
||||
func (wr *nulSeparatedAttributeWriter) Write(p []byte) (n int, err error) {
|
||||
l, read := len(p), 0
|
||||
|
||||
nulIdx := bytes.IndexByte(p, '\x00')
|
||||
for nulIdx >= 0 {
|
||||
wr.tmp = append(wr.tmp, p[:nulIdx]...)
|
||||
switch wr.pos {
|
||||
case 0:
|
||||
wr.working = attributeTriple{
|
||||
Filename: string(wr.tmp),
|
||||
}
|
||||
case 1:
|
||||
wr.working.Attribute = string(wr.tmp)
|
||||
case 2:
|
||||
wr.working.Value = string(wr.tmp)
|
||||
}
|
||||
wr.tmp = wr.tmp[:0]
|
||||
wr.pos++
|
||||
if wr.pos > 2 {
|
||||
wr.attributes <- wr.working
|
||||
wr.pos = 0
|
||||
}
|
||||
read += nulIdx + 1
|
||||
if l > read {
|
||||
p = p[nulIdx+1:]
|
||||
nulIdx = bytes.IndexByte(p, '\x00')
|
||||
} else {
|
||||
return l, nil
|
||||
}
|
||||
}
|
||||
wr.tmp = append(wr.tmp, p...)
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func (wr *nulSeparatedAttributeWriter) ReadAttribute() <-chan attributeTriple {
|
||||
return wr.attributes
|
||||
}
|
||||
|
||||
func (wr *nulSeparatedAttributeWriter) Close() error {
|
||||
select {
|
||||
case <-wr.closed:
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
close(wr.attributes)
|
||||
close(wr.closed)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package attribute
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_nulSeparatedAttributeWriter_ReadAttribute(t *testing.T) {
|
||||
wr := &nulSeparatedAttributeWriter{
|
||||
attributes: make(chan attributeTriple, 5),
|
||||
}
|
||||
|
||||
testStr := ".gitignore\"\n\x00linguist-vendored\x00unspecified\x00"
|
||||
|
||||
n, err := wr.Write([]byte(testStr))
|
||||
|
||||
assert.Len(t, testStr, n)
|
||||
assert.NoError(t, err)
|
||||
select {
|
||||
case attr := <-wr.ReadAttribute():
|
||||
assert.Equal(t, ".gitignore\"\n", attr.Filename)
|
||||
assert.Equal(t, LinguistVendored, attr.Attribute)
|
||||
assert.Equal(t, "unspecified", attr.Value)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
assert.FailNow(t, "took too long to read an attribute from the list")
|
||||
}
|
||||
// Write a second attribute again
|
||||
n, err = wr.Write([]byte(testStr))
|
||||
|
||||
assert.Len(t, testStr, n)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case attr := <-wr.ReadAttribute():
|
||||
assert.Equal(t, ".gitignore\"\n", attr.Filename)
|
||||
assert.Equal(t, LinguistVendored, attr.Attribute)
|
||||
assert.Equal(t, "unspecified", attr.Value)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
assert.FailNow(t, "took too long to read an attribute from the list")
|
||||
}
|
||||
|
||||
// Write a partial attribute
|
||||
_, err = wr.Write([]byte("incomplete-file"))
|
||||
assert.NoError(t, err)
|
||||
_, err = wr.Write([]byte("name\x00"))
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-wr.ReadAttribute():
|
||||
assert.FailNow(t, "There should not be an attribute ready to read")
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
_, err = wr.Write([]byte("attribute\x00"))
|
||||
assert.NoError(t, err)
|
||||
select {
|
||||
case <-wr.ReadAttribute():
|
||||
assert.FailNow(t, "There should not be an attribute ready to read")
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
|
||||
_, err = wr.Write([]byte("value\x00"))
|
||||
assert.NoError(t, err)
|
||||
|
||||
attr := <-wr.ReadAttribute()
|
||||
assert.Equal(t, "incomplete-filename", attr.Filename)
|
||||
assert.Equal(t, "attribute", attr.Attribute)
|
||||
assert.Equal(t, "value", attr.Value)
|
||||
|
||||
_, err = wr.Write([]byte("shouldbe.vendor\x00linguist-vendored\x00set\x00shouldbe.vendor\x00linguist-generated\x00unspecified\x00shouldbe.vendor\x00linguist-language\x00unspecified\x00"))
|
||||
assert.NoError(t, err)
|
||||
attr = <-wr.ReadAttribute()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, attributeTriple{
|
||||
Filename: "shouldbe.vendor",
|
||||
Attribute: LinguistVendored,
|
||||
Value: "set",
|
||||
}, attr)
|
||||
attr = <-wr.ReadAttribute()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, attributeTriple{
|
||||
Filename: "shouldbe.vendor",
|
||||
Attribute: LinguistGenerated,
|
||||
Value: "unspecified",
|
||||
}, attr)
|
||||
attr = <-wr.ReadAttribute()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, attributeTriple{
|
||||
Filename: "shouldbe.vendor",
|
||||
Attribute: LinguistLanguage,
|
||||
Value: "unspecified",
|
||||
}, attr)
|
||||
}
|
||||
|
||||
func expectedAttrs() *Attributes {
|
||||
return &Attributes{
|
||||
m: map[string]Attribute{
|
||||
LinguistGenerated: "unspecified",
|
||||
LinguistDetectable: "unspecified",
|
||||
LinguistDocumentation: "unspecified",
|
||||
LinguistVendored: "unspecified",
|
||||
LinguistLanguage: "Python",
|
||||
GitlabLanguage: "unspecified",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func Test_BatchChecker(t *testing.T) {
|
||||
setting.AppDataPath = t.TempDir()
|
||||
repoPath := "../tests/repos/language_stats_repo"
|
||||
gitRepo, err := git.OpenRepository(t.Context(), repoPath)
|
||||
require.NoError(t, err)
|
||||
defer gitRepo.Close()
|
||||
|
||||
commitID := "8fee858da5796dfb37704761701bb8e800ad9ef3"
|
||||
|
||||
t.Run("Create index file to run git check-attr", func(t *testing.T) {
|
||||
defer test.MockVariableValue(&git.DefaultFeatures().SupportCheckAttrOnBare, false)()
|
||||
checker, err := NewBatchChecker(gitRepo, commitID, LinguistAttributes)
|
||||
assert.NoError(t, err)
|
||||
defer checker.Close()
|
||||
attributes, err := checker.CheckPath("i-am-a-python.p")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expectedAttrs(), attributes)
|
||||
})
|
||||
|
||||
// run git check-attr on work tree
|
||||
t.Run("Run git check-attr on git work tree", func(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "test-repo")
|
||||
err := git.Clone(t.Context(), repoPath, dir, git.CloneRepoOptions{
|
||||
Shared: true,
|
||||
Branch: "master",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
tempRepo, err := git.OpenRepository(t.Context(), dir)
|
||||
assert.NoError(t, err)
|
||||
defer tempRepo.Close()
|
||||
|
||||
checker, err := NewBatchChecker(tempRepo, "", LinguistAttributes)
|
||||
assert.NoError(t, err)
|
||||
defer checker.Close()
|
||||
attributes, err := checker.CheckPath("i-am-a-python.p")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expectedAttrs(), attributes)
|
||||
})
|
||||
|
||||
if !git.DefaultFeatures().SupportCheckAttrOnBare {
|
||||
t.Skip("git version 2.40 is required to support run check-attr on bare repo")
|
||||
return
|
||||
}
|
||||
|
||||
t.Run("Run git check-attr in bare repository", func(t *testing.T) {
|
||||
checker, err := NewBatchChecker(gitRepo, commitID, LinguistAttributes)
|
||||
assert.NoError(t, err)
|
||||
defer checker.Close()
|
||||
|
||||
attributes, err := checker.CheckPath("i-am-a-python.p")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expectedAttrs(), attributes)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package attribute
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
)
|
||||
|
||||
func checkAttrCommand(gitRepo *git.Repository, treeish string, filenames, attributes []string) (*gitcmd.Command, []string, func(), error) {
|
||||
cancel := func() {}
|
||||
envs := []string{"GIT_FLUSH=1"}
|
||||
cmd := gitcmd.NewCommand("check-attr", "-z")
|
||||
if len(attributes) == 0 {
|
||||
cmd.AddArguments("--all")
|
||||
}
|
||||
|
||||
// there is treeish, read from bare repo or temp index created by "read-tree"
|
||||
if treeish != "" {
|
||||
if git.DefaultFeatures().SupportCheckAttrOnBare {
|
||||
cmd.AddArguments("--source")
|
||||
cmd.AddDynamicArguments(treeish)
|
||||
} else {
|
||||
indexFilename, worktree, deleteTemporaryFile, err := gitRepo.ReadTreeToTemporaryIndex(treeish)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
cmd.AddArguments("--cached")
|
||||
envs = append(envs,
|
||||
"GIT_INDEX_FILE="+indexFilename,
|
||||
"GIT_WORK_TREE="+worktree,
|
||||
)
|
||||
cancel = deleteTemporaryFile
|
||||
}
|
||||
} else {
|
||||
// Read from existing index, in cases where the repo is bare and has an index,
|
||||
// or the work tree contains unstaged changes that shouldn't affect the attribute check.
|
||||
// It is caller's responsibility to add changed ".gitattributes" into the index if they want to respect the new changes.
|
||||
cmd.AddArguments("--cached")
|
||||
}
|
||||
|
||||
cmd.AddDynamicArguments(attributes...)
|
||||
if len(filenames) > 0 {
|
||||
cmd.AddDashesAndList(filenames...)
|
||||
}
|
||||
return cmd, envs, cancel, nil
|
||||
}
|
||||
|
||||
type CheckAttributeOpts struct {
|
||||
Filenames []string
|
||||
Attributes []string
|
||||
}
|
||||
|
||||
// CheckAttributes return the attributes of the given filenames and attributes in the given treeish.
|
||||
// If treeish is empty, then it will use current working directory, otherwise it will use the provided treeish on the bare repo
|
||||
func CheckAttributes(ctx context.Context, gitRepo *git.Repository, treeish string, opts CheckAttributeOpts) (map[string]*Attributes, error) {
|
||||
cmd, envs, cancel, err := checkAttrCommand(gitRepo, treeish, opts.Filenames, opts.Attributes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
stdout, _, err := cmd.WithEnv(append(os.Environ(), envs...)).
|
||||
WithDir(gitRepo.Path).
|
||||
RunStdBytes(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to run check-attr: %w", err)
|
||||
}
|
||||
|
||||
fields := bytes.Split(stdout, []byte{'\000'})
|
||||
if len(fields)%3 != 1 {
|
||||
return nil, errors.New("wrong number of fields in return from check-attr")
|
||||
}
|
||||
|
||||
attributesMap := make(map[string]*Attributes)
|
||||
for i := 0; i < (len(fields) / 3); i++ {
|
||||
filename := string(fields[3*i])
|
||||
attribute := string(fields[3*i+1])
|
||||
info := string(fields[3*i+2])
|
||||
attribute2info, ok := attributesMap[filename]
|
||||
if !ok {
|
||||
attribute2info = NewAttributes()
|
||||
attributesMap[filename] = attribute2info
|
||||
}
|
||||
attribute2info.m[attribute] = Attribute(info)
|
||||
}
|
||||
|
||||
return attributesMap, nil
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package attribute
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_Checker(t *testing.T) {
|
||||
setting.AppDataPath = t.TempDir()
|
||||
repoPath := "../tests/repos/language_stats_repo"
|
||||
gitRepo, err := git.OpenRepository(t.Context(), repoPath)
|
||||
require.NoError(t, err)
|
||||
defer gitRepo.Close()
|
||||
|
||||
commitID := "8fee858da5796dfb37704761701bb8e800ad9ef3"
|
||||
|
||||
t.Run("Create index file to run git check-attr", func(t *testing.T) {
|
||||
defer test.MockVariableValue(&git.DefaultFeatures().SupportCheckAttrOnBare, false)()
|
||||
attrs, err := CheckAttributes(t.Context(), gitRepo, commitID, CheckAttributeOpts{
|
||||
Filenames: []string{"i-am-a-python.p"},
|
||||
Attributes: LinguistAttributes,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, attrs, 1)
|
||||
assert.Equal(t, expectedAttrs(), attrs["i-am-a-python.p"])
|
||||
})
|
||||
|
||||
// run git check-attr on work tree
|
||||
t.Run("Run git check-attr on git work tree", func(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "test-repo")
|
||||
err := git.Clone(t.Context(), repoPath, dir, git.CloneRepoOptions{
|
||||
Shared: true,
|
||||
Branch: "master",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
tempRepo, err := git.OpenRepository(t.Context(), dir)
|
||||
assert.NoError(t, err)
|
||||
defer tempRepo.Close()
|
||||
|
||||
attrs, err := CheckAttributes(t.Context(), tempRepo, "", CheckAttributeOpts{
|
||||
Filenames: []string{"i-am-a-python.p"},
|
||||
Attributes: LinguistAttributes,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, attrs, 1)
|
||||
assert.Equal(t, expectedAttrs(), attrs["i-am-a-python.p"])
|
||||
})
|
||||
|
||||
t.Run("Run git check-attr in bare repository using index", func(t *testing.T) {
|
||||
attrs, err := CheckAttributes(t.Context(), gitRepo, "", CheckAttributeOpts{
|
||||
Filenames: []string{"i-am-a-python.p"},
|
||||
Attributes: LinguistAttributes,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, attrs, 1)
|
||||
assert.Equal(t, expectedAttrs(), attrs["i-am-a-python.p"])
|
||||
})
|
||||
|
||||
if !git.DefaultFeatures().SupportCheckAttrOnBare {
|
||||
t.Skip("git version 2.40 is required to support run check-attr on bare repo without using index")
|
||||
return
|
||||
}
|
||||
|
||||
t.Run("Run git check-attr in bare repository", func(t *testing.T) {
|
||||
attrs, err := CheckAttributes(t.Context(), gitRepo, commitID, CheckAttributeOpts{
|
||||
Filenames: []string{"i-am-a-python.p"},
|
||||
Attributes: LinguistAttributes,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, attrs, 1)
|
||||
assert.Equal(t, expectedAttrs(), attrs["i-am-a-python.p"])
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package attribute
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
func testRun(m *testing.M) error {
|
||||
gitHomePath, err := os.MkdirTemp(os.TempDir(), "git-home")
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create temp dir: %w", err)
|
||||
}
|
||||
defer util.RemoveAll(gitHomePath)
|
||||
setting.Git.HomePath = gitHomePath
|
||||
|
||||
if err = git.InitFull(); err != nil {
|
||||
return fmt.Errorf("failed to call Init: %w", err)
|
||||
}
|
||||
|
||||
exitCode := m.Run()
|
||||
if exitCode != 0 {
|
||||
return fmt.Errorf("run test failed, ExitCode=%d", exitCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
if err := testRun(m); err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "Test failed: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// Copyright 2015 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/typesniffer"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
// This file contains common functions between the gogit and !gogit variants for git Blobs
|
||||
|
||||
// Name returns name of the tree entry this blob object was created from (or empty string)
|
||||
func (b *Blob) Name() string {
|
||||
return b.name
|
||||
}
|
||||
|
||||
// GetBlobBytes Gets the limited content of the blob
|
||||
func (b *Blob) GetBlobBytes(limit int64) ([]byte, error) {
|
||||
if limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
dataRc, err := b.DataAsync()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer dataRc.Close()
|
||||
return util.ReadWithLimit(dataRc, int(limit))
|
||||
}
|
||||
|
||||
// GetBlobContent Gets the limited content of the blob as raw text
|
||||
func (b *Blob) GetBlobContent(limit int64) (string, error) {
|
||||
buf, err := b.GetBlobBytes(limit)
|
||||
return string(buf), err
|
||||
}
|
||||
|
||||
// GetBlobLineCount gets line count of the blob.
|
||||
// It will also try to write the content to w if it's not nil, then we could pre-fetch the content without reading it again.
|
||||
func (b *Blob) GetBlobLineCount(w io.Writer) (int, error) {
|
||||
reader, err := b.DataAsync()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer reader.Close()
|
||||
buf := make([]byte, 32*1024)
|
||||
count := 1
|
||||
lineSep := []byte{'\n'}
|
||||
for {
|
||||
c, err := reader.Read(buf)
|
||||
if w != nil {
|
||||
if _, err := w.Write(buf[:c]); err != nil {
|
||||
return count, err
|
||||
}
|
||||
}
|
||||
count += bytes.Count(buf[:c], lineSep)
|
||||
switch {
|
||||
case errors.Is(err, io.EOF):
|
||||
return count, nil
|
||||
case err != nil:
|
||||
return count, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetBlobContentBase64 Reads the content of the blob with a base64 encoding and returns the encoded string
|
||||
func (b *Blob) GetBlobContentBase64(originContent *strings.Builder) (string, error) {
|
||||
dataRc, err := b.DataAsync()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer dataRc.Close()
|
||||
|
||||
base64buf := &strings.Builder{}
|
||||
encoder := base64.NewEncoder(base64.StdEncoding, base64buf)
|
||||
buf := make([]byte, 32*1024)
|
||||
loop:
|
||||
for {
|
||||
n, err := dataRc.Read(buf)
|
||||
if n > 0 {
|
||||
if originContent != nil {
|
||||
_, _ = originContent.Write(buf[:n])
|
||||
}
|
||||
if _, err := encoder.Write(buf[:n]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, io.EOF):
|
||||
break loop
|
||||
case err != nil:
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
_ = encoder.Close()
|
||||
return base64buf.String(), nil
|
||||
}
|
||||
|
||||
// GuessContentType guesses the content type of the blob.
|
||||
func (b *Blob) GuessContentType() (typesniffer.SniffedType, error) {
|
||||
buf, err := b.GetBlobBytes(typesniffer.SniffContentSize)
|
||||
if err != nil {
|
||||
return typesniffer.SniffedType{}, err
|
||||
}
|
||||
return typesniffer.DetectContentType(buf), nil
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Copyright 2015 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build gogit
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"gitea.dev/modules/log"
|
||||
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
)
|
||||
|
||||
// Blob represents a Git object.
|
||||
type Blob struct {
|
||||
ID ObjectID
|
||||
repo *Repository
|
||||
name string
|
||||
}
|
||||
|
||||
func (b *Blob) gogitEncodedObj() (plumbing.EncodedObject, error) {
|
||||
return b.repo.gogitRepo.Storer.EncodedObject(plumbing.AnyObject, plumbing.Hash(b.ID.RawValue()))
|
||||
}
|
||||
|
||||
// DataAsync gets a ReadCloser for the contents of a blob without reading it all.
|
||||
// Calling the Close function on the result will discard all unread output.
|
||||
func (b *Blob) DataAsync() (io.ReadCloser, error) {
|
||||
obj, err := b.gogitEncodedObj()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return obj.Reader()
|
||||
}
|
||||
|
||||
// Size returns the uncompressed size of the blob
|
||||
func (b *Blob) Size() int64 {
|
||||
obj, err := b.gogitEncodedObj()
|
||||
if err != nil {
|
||||
log.Error("Error getting gogit encoded object for blob %s(%s): %v", b.name, b.ID.String(), err)
|
||||
return 0
|
||||
}
|
||||
return obj.Size()
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !gogit
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"gitea.dev/modules/log"
|
||||
)
|
||||
|
||||
// Blob represents a Git object.
|
||||
type Blob struct {
|
||||
ID ObjectID
|
||||
|
||||
gotSize bool
|
||||
size int64
|
||||
name string
|
||||
repo *Repository
|
||||
}
|
||||
|
||||
// DataAsync gets a ReadCloser for the contents of a blob without reading it all.
|
||||
// Calling the Close function on the result will discard all unread output.
|
||||
func (b *Blob) DataAsync() (_ io.ReadCloser, retErr error) {
|
||||
batch, cancel, err := b.repo.CatFileBatch(b.repo.Ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
// if there was an error, cancel the batch right away,
|
||||
// otherwise let the caller close it
|
||||
if retErr != nil {
|
||||
cancel()
|
||||
}
|
||||
}()
|
||||
|
||||
info, contentReader, err := batch.QueryContent(b.ID.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b.gotSize = true
|
||||
b.size = info.Size
|
||||
return &blobReader{
|
||||
rd: contentReader,
|
||||
n: info.Size,
|
||||
cancel: cancel,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Size returns the uncompressed size of the blob
|
||||
func (b *Blob) Size() int64 {
|
||||
if b.gotSize {
|
||||
return b.size
|
||||
}
|
||||
|
||||
batch, cancel, err := b.repo.CatFileBatch(b.repo.Ctx)
|
||||
if err != nil {
|
||||
log.Debug("error whilst reading size for %s in %s. Error: %v", b.ID.String(), b.repo.Path, err)
|
||||
return 0
|
||||
}
|
||||
defer cancel()
|
||||
info, err := batch.QueryInfo(b.ID.String())
|
||||
if err != nil {
|
||||
log.Debug("error whilst reading size for %s in %s. Error: %v", b.ID.String(), b.repo.Path, err)
|
||||
return 0
|
||||
}
|
||||
b.gotSize = true
|
||||
b.size = info.Size
|
||||
return b.size
|
||||
}
|
||||
|
||||
type blobReader struct {
|
||||
rd BufferedReader
|
||||
n int64
|
||||
cancel func()
|
||||
}
|
||||
|
||||
func (b *blobReader) Read(p []byte) (n int, err error) {
|
||||
if b.n <= 0 {
|
||||
return 0, io.EOF
|
||||
}
|
||||
if int64(len(p)) > b.n {
|
||||
p = p[0:b.n]
|
||||
}
|
||||
n, err = b.rd.Read(p)
|
||||
b.n -= int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Close implements io.Closer
|
||||
func (b *blobReader) Close() error {
|
||||
if b.rd == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
defer b.cancel()
|
||||
|
||||
if err := DiscardFull(b.rd, b.n+1); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b.rd = nil
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Copyright 2015 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"io"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBlob_Data(t *testing.T) {
|
||||
output := "file2\n"
|
||||
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
|
||||
repo, err := OpenRepository(t.Context(), bareRepo1Path)
|
||||
require.NoError(t, err)
|
||||
defer repo.Close()
|
||||
|
||||
testBlob, err := repo.GetBlob("6c493ff740f9380390d5c9ddef4af18697ac9375")
|
||||
assert.NoError(t, err)
|
||||
|
||||
r, err := testBlob.DataAsync()
|
||||
assert.NoError(t, err)
|
||||
require.NotNil(t, r)
|
||||
|
||||
data, err := io.ReadAll(r)
|
||||
assert.NoError(t, r.Close())
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, output, string(data))
|
||||
}
|
||||
|
||||
func Benchmark_Blob_Data(b *testing.B) {
|
||||
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
|
||||
repo, err := OpenRepository(b.Context(), bareRepo1Path)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
defer repo.Close()
|
||||
|
||||
testBlob, err := repo.GetBlob("6c493ff740f9380390d5c9ddef4af18697ac9375")
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
for b.Loop() {
|
||||
r, err := testBlob.DataAsync()
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
io.ReadAll(r)
|
||||
_ = r.Close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
)
|
||||
|
||||
type BufferedReader interface {
|
||||
io.Reader
|
||||
Buffered() int
|
||||
Peek(n int) ([]byte, error)
|
||||
Discard(n int) (int, error)
|
||||
ReadString(sep byte) (string, error)
|
||||
ReadSlice(sep byte) ([]byte, error)
|
||||
ReadBytes(sep byte) ([]byte, error)
|
||||
}
|
||||
|
||||
type CatFileObject struct {
|
||||
ID string
|
||||
Type string
|
||||
Size int64
|
||||
}
|
||||
|
||||
type CatFileBatch interface {
|
||||
// QueryInfo queries the object info from the git repository by its object name using "git cat-file --batch" family commands.
|
||||
// "git cat-file" accepts "<rev>" for the object name, it can be a ref name, object id, etc. https://git-scm.com/docs/gitrevisions
|
||||
// In Gitea, we only use the simple ref name or object id, no other complex rev syntax like "suffix" or "git describe" although they are supported by git.
|
||||
QueryInfo(obj string) (*CatFileObject, error)
|
||||
|
||||
// QueryContent is similar to QueryInfo, it queries the object info and additionally returns a reader for its content.
|
||||
// FIXME: this design still follows the old pattern: the returned BufferedReader is very fragile,
|
||||
// callers should carefully maintain its lifecycle and discard all unread data.
|
||||
// TODO: It needs to be refactored to a fully managed Reader stream in the future, don't let callers manually Close or Discard
|
||||
QueryContent(obj string) (*CatFileObject, BufferedReader, error)
|
||||
}
|
||||
|
||||
type CatFileBatchCloser interface {
|
||||
CatFileBatch
|
||||
Close()
|
||||
}
|
||||
|
||||
// NewBatch creates a "batch object provider (CatFileBatch)" for the given repository path to retrieve object info and content efficiently.
|
||||
// The CatFileBatch and the readers create by it should only be used in the same goroutine.
|
||||
func NewBatch(ctx context.Context, repoPath string) (CatFileBatchCloser, error) {
|
||||
if DefaultFeatures().SupportCatFileBatchCommand {
|
||||
return newCatFileBatchCommand(ctx, repoPath)
|
||||
}
|
||||
return newCatFileBatchLegacy(ctx, repoPath)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
// catFileBatchCommand implements the CatFileBatch interface using the "cat-file --batch-command" command
|
||||
// for git version >= 2.36
|
||||
// ref: https://git-scm.com/docs/git-cat-file#Documentation/git-cat-file.txt---batch-command
|
||||
type catFileBatchCommand struct {
|
||||
ctx context.Context
|
||||
repoPath string
|
||||
batch *catFileBatchCommunicator
|
||||
}
|
||||
|
||||
var _ CatFileBatch = (*catFileBatchCommand)(nil)
|
||||
|
||||
func newCatFileBatchCommand(ctx context.Context, repoPath string) (*catFileBatchCommand, error) {
|
||||
if _, err := os.Stat(repoPath); err != nil {
|
||||
return nil, util.NewNotExistErrorf("repo %q doesn't exist", filepath.Base(repoPath))
|
||||
}
|
||||
return &catFileBatchCommand{ctx: ctx, repoPath: repoPath}, nil
|
||||
}
|
||||
|
||||
func (b *catFileBatchCommand) getBatch() *catFileBatchCommunicator {
|
||||
if b.batch != nil {
|
||||
return b.batch
|
||||
}
|
||||
b.batch = newCatFileBatch(b.ctx, b.repoPath, gitcmd.NewCommand("cat-file", "--batch-command"))
|
||||
return b.batch
|
||||
}
|
||||
|
||||
func (b *catFileBatchCommand) QueryContent(obj string) (*CatFileObject, BufferedReader, error) {
|
||||
if strings.Contains(obj, "\n") {
|
||||
setting.PanicInDevOrTesting("invalid object name with newline: %q", obj)
|
||||
}
|
||||
_, err := b.getBatch().reqWriter.Write([]byte("contents " + obj + "\n"))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
info, err := catFileBatchParseInfoLine(b.getBatch().respReader)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return info, b.getBatch().respReader, nil
|
||||
}
|
||||
|
||||
func (b *catFileBatchCommand) QueryInfo(obj string) (*CatFileObject, error) {
|
||||
if strings.Contains(obj, "\n") {
|
||||
setting.PanicInDevOrTesting("invalid object name with newline: %q", obj)
|
||||
}
|
||||
_, err := b.getBatch().reqWriter.Write([]byte("info " + obj + "\n"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return catFileBatchParseInfoLine(b.getBatch().respReader)
|
||||
}
|
||||
|
||||
func (b *catFileBatchCommand) Close() {
|
||||
if b.batch != nil {
|
||||
b.batch.Close()
|
||||
b.batch = nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
// catFileBatchLegacy implements the CatFileBatch interface using the "cat-file --batch" command and "cat-file --batch-check" command
|
||||
// for git version < 2.36
|
||||
// to align with "--batch-command", it creates the two commands for querying object contents and object info separately
|
||||
// ref: https://git-scm.com/docs/git-cat-file#Documentation/git-cat-file.txt---batch
|
||||
type catFileBatchLegacy struct {
|
||||
ctx context.Context
|
||||
repoPath string
|
||||
batchContent *catFileBatchCommunicator
|
||||
batchCheck *catFileBatchCommunicator
|
||||
}
|
||||
|
||||
var _ CatFileBatchCloser = (*catFileBatchLegacy)(nil)
|
||||
|
||||
func newCatFileBatchLegacy(ctx context.Context, repoPath string) (*catFileBatchLegacy, error) {
|
||||
if _, err := os.Stat(repoPath); err != nil {
|
||||
return nil, util.NewNotExistErrorf("repo %q doesn't exist", filepath.Base(repoPath))
|
||||
}
|
||||
return &catFileBatchLegacy{ctx: ctx, repoPath: repoPath}, nil
|
||||
}
|
||||
|
||||
func (b *catFileBatchLegacy) getBatchContent() *catFileBatchCommunicator {
|
||||
if b.batchContent != nil {
|
||||
return b.batchContent
|
||||
}
|
||||
b.batchContent = newCatFileBatch(b.ctx, b.repoPath, gitcmd.NewCommand("cat-file", "--batch"))
|
||||
return b.batchContent
|
||||
}
|
||||
|
||||
func (b *catFileBatchLegacy) getBatchCheck() *catFileBatchCommunicator {
|
||||
if b.batchCheck != nil {
|
||||
return b.batchCheck
|
||||
}
|
||||
b.batchCheck = newCatFileBatch(b.ctx, b.repoPath, gitcmd.NewCommand("cat-file", "--batch-check"))
|
||||
return b.batchCheck
|
||||
}
|
||||
|
||||
func (b *catFileBatchLegacy) QueryContent(obj string) (*CatFileObject, BufferedReader, error) {
|
||||
if strings.Contains(obj, "\n") {
|
||||
setting.PanicInDevOrTesting("invalid object name with newline: %q", obj)
|
||||
}
|
||||
_, err := io.WriteString(b.getBatchContent().reqWriter, obj+"\n")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
info, err := catFileBatchParseInfoLine(b.getBatchContent().respReader)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return info, b.getBatchContent().respReader, nil
|
||||
}
|
||||
|
||||
func (b *catFileBatchLegacy) QueryInfo(obj string) (*CatFileObject, error) {
|
||||
if strings.Contains(obj, "\n") {
|
||||
setting.PanicInDevOrTesting("invalid object name with newline: %q", obj)
|
||||
}
|
||||
_, err := io.WriteString(b.getBatchCheck().reqWriter, obj+"\n")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return catFileBatchParseInfoLine(b.getBatchCheck().respReader)
|
||||
}
|
||||
|
||||
func (b *catFileBatchLegacy) Close() {
|
||||
if b.batchContent != nil {
|
||||
b.batchContent.Close()
|
||||
b.batchContent = nil
|
||||
}
|
||||
if b.batchCheck != nil {
|
||||
b.batchCheck.Close()
|
||||
b.batchCheck = nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"math"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
type catFileBatchCommunicator struct {
|
||||
closeFunc atomic.Pointer[func(err error)]
|
||||
reqWriter io.Writer
|
||||
respReader *bufio.Reader
|
||||
debugGitCmd *gitcmd.Command
|
||||
}
|
||||
|
||||
func (b *catFileBatchCommunicator) Close(err ...error) {
|
||||
if fn := b.closeFunc.Swap(nil); fn != nil {
|
||||
(*fn)(util.OptionalArg(err))
|
||||
}
|
||||
}
|
||||
|
||||
// newCatFileBatch opens git cat-file --batch/--batch-check/--batch-command command and prepares the stdin/stdout pipes for communication.
|
||||
func newCatFileBatch(ctx context.Context, repoPath string, cmdCatFile *gitcmd.Command) *catFileBatchCommunicator {
|
||||
ctx, ctxCancel := context.WithCancelCause(ctx)
|
||||
stdinWriter, stdoutReader, stdPipeClose := cmdCatFile.MakeStdinStdoutPipe()
|
||||
ret := &catFileBatchCommunicator{
|
||||
debugGitCmd: cmdCatFile,
|
||||
reqWriter: stdinWriter,
|
||||
respReader: bufio.NewReaderSize(stdoutReader, 32*1024), // use a buffered reader for rich operations
|
||||
}
|
||||
ret.closeFunc.Store(new(func(err error) {
|
||||
ctxCancel(err)
|
||||
stdPipeClose()
|
||||
}))
|
||||
|
||||
err := cmdCatFile.WithDir(repoPath).StartWithStderr(ctx)
|
||||
if err != nil {
|
||||
log.Error("Unable to start git command %v: %v", cmdCatFile.LogString(), err)
|
||||
// ideally here it should return the error, but it would require refactoring all callers
|
||||
// so just return a dummy communicator that does nothing, almost the same behavior as before, not bad
|
||||
ret.Close(err)
|
||||
return ret
|
||||
}
|
||||
|
||||
go func() {
|
||||
err := cmdCatFile.WaitWithStderr()
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
log.Error("cat-file --batch command failed in repo %s, error: %v", repoPath, err)
|
||||
}
|
||||
ret.Close(err)
|
||||
}()
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func (b *catFileBatchCommunicator) debugKill() (ret struct {
|
||||
beforeClose chan struct{}
|
||||
blockClose chan struct{}
|
||||
afterClose chan struct{}
|
||||
},
|
||||
) {
|
||||
ret.beforeClose = make(chan struct{})
|
||||
ret.blockClose = make(chan struct{})
|
||||
ret.afterClose = make(chan struct{})
|
||||
oldCloseFunc := b.closeFunc.Load()
|
||||
b.closeFunc.Store(new(func(err error) {
|
||||
b.closeFunc.Store(nil)
|
||||
close(ret.beforeClose)
|
||||
<-ret.blockClose
|
||||
(*oldCloseFunc)(err)
|
||||
close(ret.afterClose)
|
||||
}))
|
||||
b.debugGitCmd.DebugKill()
|
||||
return ret
|
||||
}
|
||||
|
||||
// catFileBatchParseInfoLine reads the header line from cat-file --batch
|
||||
// We expect: <oid> SP <type> SP <size> LF
|
||||
// then leaving the rest of the stream "<contents> LF" to be read
|
||||
func catFileBatchParseInfoLine(rd BufferedReader) (*CatFileObject, error) {
|
||||
typ, err := rd.ReadString('\n')
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(typ) == 1 {
|
||||
typ, err = rd.ReadString('\n')
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
idx := strings.IndexByte(typ, ' ')
|
||||
if idx < 0 {
|
||||
return nil, ErrNotExist{}
|
||||
}
|
||||
sha := typ[:idx]
|
||||
typ = typ[idx+1:]
|
||||
|
||||
idx = strings.IndexByte(typ, ' ')
|
||||
if idx < 0 {
|
||||
return nil, ErrNotExist{ID: sha}
|
||||
}
|
||||
|
||||
sizeStr := typ[idx+1 : len(typ)-1]
|
||||
typ = typ[:idx]
|
||||
|
||||
size, err := strconv.ParseInt(sizeStr, 10, 64)
|
||||
return &CatFileObject{ID: sha, Type: typ, Size: size}, err
|
||||
}
|
||||
|
||||
// ReadTagObjectID reads a tag object ID hash from a cat-file --batch stream, throwing away the rest of the stream.
|
||||
func ReadTagObjectID(rd BufferedReader, size int64) (string, error) {
|
||||
var id string
|
||||
var n int64
|
||||
headerLoop:
|
||||
for {
|
||||
line, err := rd.ReadBytes('\n')
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
n += int64(len(line))
|
||||
idx := bytes.Index(line, []byte{' '})
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if string(line[:idx]) == "object" {
|
||||
id = string(line[idx+1 : len(line)-1])
|
||||
break headerLoop
|
||||
}
|
||||
}
|
||||
|
||||
// Discard the rest of the tag
|
||||
return id, DiscardFull(rd, size-n+1)
|
||||
}
|
||||
|
||||
// ReadTreeID reads a tree ID from a cat-file --batch stream, throwing away the rest of the stream.
|
||||
func ReadTreeID(rd BufferedReader, size int64) (string, error) {
|
||||
var id string
|
||||
var n int64
|
||||
headerLoop:
|
||||
for {
|
||||
line, err := rd.ReadBytes('\n')
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
n += int64(len(line))
|
||||
idx := bytes.Index(line, []byte{' '})
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if string(line[:idx]) == "tree" {
|
||||
id = string(line[idx+1 : len(line)-1])
|
||||
break headerLoop
|
||||
}
|
||||
}
|
||||
|
||||
// Discard the rest of the commit
|
||||
return id, DiscardFull(rd, size-n+1)
|
||||
}
|
||||
|
||||
// ParseCatFileTreeLine reads an entry from a tree in a cat-file --batch stream
|
||||
// Each entry is composed of:
|
||||
// <mode-in-ascii-dropping-initial-zeros> SP <name> NUL <binary-hash>
|
||||
func ParseCatFileTreeLine(objectFormat ObjectFormat, rd BufferedReader) (mode EntryMode, name string, objID ObjectID, n int, err error) {
|
||||
// use the in-buffer memory as much as possible to avoid extra allocations
|
||||
bufBytes, err := rd.ReadSlice('\x00')
|
||||
const maxEntryInfoBytes = 1024 * 1024
|
||||
if errors.Is(err, bufio.ErrBufferFull) {
|
||||
bufBytes = slices.Clone(bufBytes)
|
||||
for len(bufBytes) < maxEntryInfoBytes && errors.Is(err, bufio.ErrBufferFull) {
|
||||
var tmp []byte
|
||||
tmp, err = rd.ReadSlice('\x00')
|
||||
bufBytes = append(bufBytes, tmp...)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return mode, name, objID, len(bufBytes), err
|
||||
}
|
||||
|
||||
idx := bytes.IndexByte(bufBytes, ' ')
|
||||
if idx < 0 {
|
||||
return mode, name, objID, len(bufBytes), errors.New("invalid CatFileTreeLine output")
|
||||
}
|
||||
|
||||
mode = ParseEntryMode(util.UnsafeBytesToString(bufBytes[:idx]))
|
||||
name = string(bufBytes[idx+1 : len(bufBytes)-1]) // trim the NUL terminator, it needs a copy because the bufBytes will be reused by the reader
|
||||
if mode == EntryModeNoEntry {
|
||||
return mode, name, objID, len(bufBytes), errors.New("invalid entry mode: " + string(bufBytes[:idx]))
|
||||
}
|
||||
|
||||
switch objectFormat {
|
||||
case Sha1ObjectFormat:
|
||||
objID = &Sha1Hash{}
|
||||
case Sha256ObjectFormat:
|
||||
objID = &Sha256Hash{}
|
||||
default:
|
||||
panic("unsupported object format: " + objectFormat.Name())
|
||||
}
|
||||
readIDLen, err := io.ReadFull(rd, objID.RawValue())
|
||||
return mode, name, objID, len(bufBytes) + readIDLen, err
|
||||
}
|
||||
|
||||
func DiscardFull(rd BufferedReader, discard int64) error {
|
||||
if discard > math.MaxInt32 {
|
||||
n, err := rd.Discard(math.MaxInt32)
|
||||
discard -= int64(n)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for discard > 0 {
|
||||
n, err := rd.Discard(int(discard))
|
||||
discard -= int64(n)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/test"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCatFileBatch(t *testing.T) {
|
||||
defer test.MockVariableValue(&DefaultFeatures().SupportCatFileBatchCommand)()
|
||||
DefaultFeatures().SupportCatFileBatchCommand = false
|
||||
t.Run("LegacyCheck", testCatFileBatch)
|
||||
DefaultFeatures().SupportCatFileBatchCommand = true
|
||||
t.Run("BatchCommand", testCatFileBatch)
|
||||
}
|
||||
|
||||
func testCatFileBatch(t *testing.T) {
|
||||
t.Run("CorruptedGitRepo", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
batch, err := NewBatch(t.Context(), tmpDir)
|
||||
// as long as the directory exists, no error, because we can't really know whether the git repo is valid until we run commands
|
||||
require.NoError(t, err)
|
||||
defer batch.Close()
|
||||
|
||||
_, err = batch.QueryInfo("e2129701f1a4d54dc44f03c93bca0a2aec7c5449")
|
||||
require.Error(t, err)
|
||||
_, err = batch.QueryInfo("e2129701f1a4d54dc44f03c93bca0a2aec7c5449")
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
simulateQueryTerminated := func(t *testing.T, errBeforePipeClose, errAfterPipeClose error) {
|
||||
readError := func(t *testing.T, r io.Reader, expectedErr error) {
|
||||
if expectedErr == nil {
|
||||
return // expectedErr == nil means this read should be skipped
|
||||
}
|
||||
n, err := r.Read(make([]byte, 100))
|
||||
assert.Zero(t, n)
|
||||
assert.ErrorIs(t, err, expectedErr)
|
||||
}
|
||||
|
||||
batch, err := NewBatch(t.Context(), filepath.Join(testReposDir, "repo1_bare"))
|
||||
require.NoError(t, err)
|
||||
defer batch.Close()
|
||||
_, err = batch.QueryInfo("e2129701f1a4d54dc44f03c93bca0a2aec7c5449")
|
||||
require.NoError(t, err)
|
||||
|
||||
var c *catFileBatchCommunicator
|
||||
switch b := batch.(type) {
|
||||
case *catFileBatchLegacy:
|
||||
c = b.batchCheck
|
||||
_, _ = c.reqWriter.Write([]byte("in-complete-line-"))
|
||||
case *catFileBatchCommand:
|
||||
c = b.batch
|
||||
_, _ = c.reqWriter.Write([]byte("info"))
|
||||
default:
|
||||
t.FailNow()
|
||||
}
|
||||
|
||||
require.NotEqual(t, errBeforePipeClose == nil, errAfterPipeClose == nil, "must set exactly one of the expected errors")
|
||||
|
||||
inceptor := c.debugKill()
|
||||
<-inceptor.beforeClose // wait for the command's Close to be called, the pipe is not closed yet
|
||||
readError(t, c.respReader, errBeforePipeClose) // then caller will read on an open pipe which will be closed soon
|
||||
close(inceptor.blockClose) // continue to close the pipe
|
||||
<-inceptor.afterClose // wait for the pipe to be closed
|
||||
readError(t, c.respReader, errAfterPipeClose) // then caller will read on a closed pipe
|
||||
}
|
||||
t.Run("QueryTerminated", func(t *testing.T) {
|
||||
simulateQueryTerminated(t, io.EOF, nil) // reader is faster
|
||||
simulateQueryTerminated(t, nil, os.ErrClosed) // pipes are closed faster
|
||||
})
|
||||
|
||||
batch, err := NewBatch(t.Context(), filepath.Join(testReposDir, "repo1_bare"))
|
||||
require.NoError(t, err)
|
||||
defer batch.Close()
|
||||
|
||||
t.Run("QueryInfo", func(t *testing.T) {
|
||||
info, err := batch.QueryInfo("e2129701f1a4d54dc44f03c93bca0a2aec7c5449")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "e2129701f1a4d54dc44f03c93bca0a2aec7c5449", info.ID)
|
||||
assert.Equal(t, "blob", info.Type)
|
||||
assert.EqualValues(t, 6, info.Size)
|
||||
})
|
||||
|
||||
t.Run("QueryContent", func(t *testing.T) {
|
||||
info, rd, err := batch.QueryContent("e2129701f1a4d54dc44f03c93bca0a2aec7c5449")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "e2129701f1a4d54dc44f03c93bca0a2aec7c5449", info.ID)
|
||||
assert.Equal(t, "blob", info.Type)
|
||||
assert.EqualValues(t, 6, info.Size)
|
||||
|
||||
content, err := io.ReadAll(io.LimitReader(rd, info.Size))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "file1\n", string(content))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
const (
|
||||
CmdVerbUploadPack = "git-upload-pack"
|
||||
CmdVerbUploadArchive = "git-upload-archive"
|
||||
CmdVerbReceivePack = "git-receive-pack"
|
||||
CmdVerbLfsAuthenticate = "git-lfs-authenticate"
|
||||
CmdVerbLfsTransfer = "git-lfs-transfer"
|
||||
|
||||
CmdSubVerbLfsUpload = "upload"
|
||||
CmdSubVerbLfsDownload = "download"
|
||||
)
|
||||
|
||||
func IsAllowedVerbForServe(verb string) bool {
|
||||
switch verb {
|
||||
case CmdVerbUploadPack,
|
||||
CmdVerbUploadArchive,
|
||||
CmdVerbReceivePack,
|
||||
CmdVerbLfsAuthenticate,
|
||||
CmdVerbLfsTransfer:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func IsAllowedVerbForServeLfs(verb string) bool {
|
||||
switch verb {
|
||||
case CmdVerbLfsAuthenticate,
|
||||
CmdVerbLfsTransfer:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
// Copyright 2015 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/charset"
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
type CommitMessage struct {
|
||||
MessageRaw string
|
||||
messageUTF8 *string
|
||||
messageTitle *string
|
||||
messageBody *string
|
||||
}
|
||||
|
||||
// Commit represents a git commit.
|
||||
type Commit struct {
|
||||
Tree // FIXME: bad design, this field can be nil if the commit is from "last commit cache"
|
||||
|
||||
CommitMessage
|
||||
|
||||
ID ObjectID
|
||||
Author *Signature // never nil
|
||||
Committer *Signature // never nil
|
||||
Signature *CommitSignature
|
||||
|
||||
Parents []ObjectID // ID strings
|
||||
submoduleCache *ObjectCache[*SubModule]
|
||||
}
|
||||
|
||||
// CommitSignature represents a git commit signature part.
|
||||
type CommitSignature struct {
|
||||
Signature string
|
||||
Payload string
|
||||
}
|
||||
|
||||
func (c *CommitMessage) MessageUTF8() string {
|
||||
if c.messageUTF8 == nil {
|
||||
bs := charset.ToUTF8(util.UnsafeStringToBytes(c.MessageRaw), charset.ConvertOpts{ErrorReplacement: []byte{'?'}})
|
||||
c.messageUTF8 = new(util.UnsafeBytesToString(bs))
|
||||
}
|
||||
return *c.messageUTF8
|
||||
}
|
||||
|
||||
func (c *CommitMessage) MessageTitle() string {
|
||||
if c.messageTitle == nil {
|
||||
s, _, _ := strings.Cut(strings.TrimSpace(c.MessageUTF8()), "\n")
|
||||
c.messageTitle = new(strings.TrimSpace(s))
|
||||
}
|
||||
return *c.messageTitle
|
||||
}
|
||||
|
||||
func (c *CommitMessage) MessageBody() string {
|
||||
if c.messageBody == nil {
|
||||
_, s, _ := strings.Cut(strings.TrimSpace(c.MessageUTF8()), "\n")
|
||||
c.messageBody = new(strings.TrimSpace(s))
|
||||
}
|
||||
return *c.messageBody
|
||||
}
|
||||
|
||||
// ParentID returns oid of n-th parent (0-based index).
|
||||
// It returns nil if no such parent exists.
|
||||
func (c *Commit) ParentID(n int) (ObjectID, error) {
|
||||
if n >= len(c.Parents) {
|
||||
return nil, ErrNotExist{"", ""}
|
||||
}
|
||||
return c.Parents[n], nil
|
||||
}
|
||||
|
||||
// Parent returns n-th parent (0-based index) of the commit.
|
||||
func (c *Commit) Parent(n int) (*Commit, error) {
|
||||
id, err := c.ParentID(n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parent, err := c.repo.getCommit(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parent, nil
|
||||
}
|
||||
|
||||
// ParentCount returns number of parents of the commit.
|
||||
// 0 if this is the root commit, otherwise 1,2, etc.
|
||||
func (c *Commit) ParentCount() int {
|
||||
return len(c.Parents)
|
||||
}
|
||||
|
||||
// GetCommitByPath return the commit of relative path object.
|
||||
func (c *Commit) GetCommitByPath(relpath string) (*Commit, error) {
|
||||
if c.repo.LastCommitCache != nil {
|
||||
return c.repo.LastCommitCache.GetCommitByPath(c.ID.String(), relpath)
|
||||
}
|
||||
return c.repo.getCommitByPathWithID(c.ID, relpath)
|
||||
}
|
||||
|
||||
// CommitsByRange returns the specific page commits before current revision, every page's number default by CommitsRangeSize
|
||||
func (c *Commit) CommitsByRange(page, pageSize int, not, since, until string) ([]*Commit, error) {
|
||||
return c.repo.commitsByRangeWithTime(c.ID, page, pageSize, not, since, until)
|
||||
}
|
||||
|
||||
// CommitsBefore returns all the commits before current revision
|
||||
func (c *Commit) CommitsBefore() ([]*Commit, error) {
|
||||
return c.repo.getCommitsBefore(c.ID)
|
||||
}
|
||||
|
||||
// HasPreviousCommit returns true if a given commitHash is contained in commit's parents
|
||||
func (c *Commit) HasPreviousCommit(objectID ObjectID) (bool, error) {
|
||||
this := c.ID.String()
|
||||
that := objectID.String()
|
||||
|
||||
if this == that {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
_, _, err := gitcmd.NewCommand("merge-base", "--is-ancestor").
|
||||
AddDynamicArguments(that, this).
|
||||
WithDir(c.repo.Path).
|
||||
RunStdString(c.repo.Ctx)
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
var exitError *exec.ExitError
|
||||
if errors.As(err, &exitError) {
|
||||
if exitError.ProcessState.ExitCode() == 1 && len(exitError.Stderr) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
// IsForcePush returns true if a push from oldCommitHash to this is a force push
|
||||
func (c *Commit) IsForcePush(oldCommitID string) (bool, error) {
|
||||
objectFormat, err := c.repo.GetObjectFormat()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if oldCommitID == objectFormat.EmptyObjectID().String() {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
oldCommit, err := c.repo.GetCommit(oldCommitID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
hasPreviousCommit, err := c.HasPreviousCommit(oldCommit.ID)
|
||||
return !hasPreviousCommit, err
|
||||
}
|
||||
|
||||
// CommitsBeforeLimit returns num commits before current revision
|
||||
func (c *Commit) CommitsBeforeLimit(num int) ([]*Commit, error) {
|
||||
return c.repo.getCommitsBeforeLimit(c.ID, num)
|
||||
}
|
||||
|
||||
// CommitsBeforeUntil returns the commits between commitID to current revision
|
||||
func (c *Commit) CommitsBeforeUntil(commitID string) ([]*Commit, error) {
|
||||
endCommit, err := c.repo.GetCommit(commitID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.repo.CommitsBetween(c, endCommit)
|
||||
}
|
||||
|
||||
// SearchCommitsOptions specify the parameters for SearchCommits
|
||||
type SearchCommitsOptions struct {
|
||||
Keywords []string
|
||||
Authors, Committers []string
|
||||
After, Before string
|
||||
All bool
|
||||
}
|
||||
|
||||
// NewSearchCommitsOptions construct a SearchCommitsOption from a space-delimited search string
|
||||
func NewSearchCommitsOptions(searchString string, forAllRefs bool) SearchCommitsOptions {
|
||||
var keywords, authors, committers []string
|
||||
var after, before string
|
||||
|
||||
fields := strings.FieldsSeq(searchString)
|
||||
for k := range fields {
|
||||
switch {
|
||||
case strings.HasPrefix(k, "author:"):
|
||||
authors = append(authors, strings.TrimPrefix(k, "author:"))
|
||||
case strings.HasPrefix(k, "committer:"):
|
||||
committers = append(committers, strings.TrimPrefix(k, "committer:"))
|
||||
case strings.HasPrefix(k, "after:"):
|
||||
after = strings.TrimPrefix(k, "after:")
|
||||
case strings.HasPrefix(k, "before:"):
|
||||
before = strings.TrimPrefix(k, "before:")
|
||||
default:
|
||||
keywords = append(keywords, k)
|
||||
}
|
||||
}
|
||||
|
||||
return SearchCommitsOptions{
|
||||
Keywords: keywords,
|
||||
Authors: authors,
|
||||
Committers: committers,
|
||||
After: after,
|
||||
Before: before,
|
||||
All: forAllRefs,
|
||||
}
|
||||
}
|
||||
|
||||
// SearchCommits returns the commits match the keyword before current revision
|
||||
func (c *Commit) SearchCommits(opts SearchCommitsOptions) ([]*Commit, error) {
|
||||
return c.repo.searchCommits(c.ID, opts)
|
||||
}
|
||||
|
||||
// GetFilesChangedSinceCommit get all changed file names between pastCommit to current revision
|
||||
func (c *Commit) GetFilesChangedSinceCommit(pastCommit string) ([]string, error) {
|
||||
return c.repo.GetFilesChangedBetween(pastCommit, c.ID.String())
|
||||
}
|
||||
|
||||
// FileChangedSinceCommit Returns true if the file given has changed since the past commit
|
||||
// YOU MUST ENSURE THAT pastCommit is a valid commit ID.
|
||||
func (c *Commit) FileChangedSinceCommit(filename, pastCommit string) (bool, error) {
|
||||
return c.repo.FileChangedBetweenCommits(filename, pastCommit, c.ID.String())
|
||||
}
|
||||
|
||||
// HasFile returns true if the file given exists on this commit
|
||||
// This does only mean it's there - it does not mean the file was changed during the commit.
|
||||
func (c *Commit) HasFile(filename string) (bool, error) {
|
||||
_, err := c.GetBlobByPath(filename)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// GetFileContent reads a file content as a string or returns false if this was not possible
|
||||
func (c *Commit) GetFileContent(filename string, limit int) (string, error) {
|
||||
entry, err := c.GetTreeEntryByPath(filename)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
r, err := entry.Blob().DataAsync()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
if limit > 0 {
|
||||
bs := make([]byte, limit)
|
||||
n, err := util.ReadAtMost(r, bs)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(bs[:n]), nil
|
||||
}
|
||||
|
||||
bytes, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(bytes), nil
|
||||
}
|
||||
|
||||
// GetFullCommitID returns full length (40) of commit ID by given short SHA in a repository.
|
||||
func GetFullCommitID(ctx context.Context, repoPath, shortID string) (string, error) {
|
||||
commitID, _, err := gitcmd.NewCommand("rev-parse").
|
||||
AddDynamicArguments(shortID).
|
||||
WithDir(repoPath).
|
||||
RunStdString(ctx)
|
||||
if err != nil {
|
||||
if gitcmd.IsErrorExitCode(err, 128) {
|
||||
return "", ErrNotExist{shortID, ""}
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(commitID), nil
|
||||
}
|
||||
|
||||
func IsStringLikelyCommitID(objFmt ObjectFormat, s string, minLength ...int) bool {
|
||||
maxLen := 64 // sha256
|
||||
if objFmt != nil {
|
||||
maxLen = objFmt.FullLength()
|
||||
}
|
||||
minLen := util.OptionalArg(minLength, maxLen)
|
||||
if len(s) < minLen || len(s) > maxLen {
|
||||
return false
|
||||
}
|
||||
for _, c := range s {
|
||||
isHex := (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')
|
||||
if !isHex {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// Copyright 2015 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build gogit
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/go-git/go-git/v5/plumbing/object"
|
||||
)
|
||||
|
||||
func convertPGPSignature(c *object.Commit) *CommitSignature {
|
||||
if c.PGPSignature == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var w strings.Builder
|
||||
var err error
|
||||
|
||||
if _, err = fmt.Fprintf(&w, "tree %s\n", c.TreeHash.String()); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, parent := range c.ParentHashes {
|
||||
if _, err = fmt.Fprintf(&w, "parent %s\n", parent.String()); err != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if _, err = fmt.Fprint(&w, "author "); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err = c.Author.Encode(&w); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err = fmt.Fprint(&w, "\ncommitter "); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err = c.Committer.Encode(&w); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if c.Encoding != "" && c.Encoding != "UTF-8" {
|
||||
if _, err = fmt.Fprintf(&w, "\nencoding %s\n", c.Encoding); err != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if _, err = fmt.Fprintf(&w, "\n\n%s", c.Message); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &CommitSignature{
|
||||
Signature: c.PGPSignature,
|
||||
Payload: w.String(),
|
||||
}
|
||||
}
|
||||
|
||||
func convertCommit(c *object.Commit) *Commit {
|
||||
return &Commit{
|
||||
ID: ParseGogitHash(c.Hash),
|
||||
CommitMessage: CommitMessage{MessageRaw: c.Message},
|
||||
Committer: &c.Committer,
|
||||
Author: &c.Author,
|
||||
Signature: convertPGPSignature(c),
|
||||
Parents: ParseGogitHashArray(c.ParentHashes),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
// CommitInfo describes the first commit with the provided entry
|
||||
type CommitInfo struct {
|
||||
Entry *TreeEntry
|
||||
Commit *Commit
|
||||
SubmoduleFile *CommitSubmoduleFile
|
||||
}
|
||||
|
||||
func GetCommitInfoSubmoduleFile(repoLink, fullPath string, commit *Commit, refCommitID ObjectID) (*CommitSubmoduleFile, error) {
|
||||
submodule, err := commit.GetSubModule(fullPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if submodule == nil {
|
||||
// unable to find submodule from ".gitmodules" file
|
||||
return NewCommitSubmoduleFile(repoLink, fullPath, "", refCommitID.String()), nil
|
||||
}
|
||||
return NewCommitSubmoduleFile(repoLink, fullPath, submodule.URL, refCommitID.String()), nil
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build gogit
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"maps"
|
||||
"path"
|
||||
|
||||
"github.com/emirpasic/gods/trees/binaryheap"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
"github.com/go-git/go-git/v5/plumbing/object"
|
||||
cgobject "github.com/go-git/go-git/v5/plumbing/object/commitgraph"
|
||||
)
|
||||
|
||||
// GetCommitsInfo gets information of all commits that are corresponding to these entries
|
||||
func (tes Entries) GetCommitsInfo(ctx context.Context, repoLink string, commit *Commit, treePath string) ([]CommitInfo, *Commit, error) {
|
||||
entryPaths := make([]string, len(tes)+1)
|
||||
// Get the commit for the treePath itself
|
||||
entryPaths[0] = ""
|
||||
for i, entry := range tes {
|
||||
entryPaths[i+1] = entry.Name()
|
||||
}
|
||||
|
||||
commitNodeIndex, commitGraphFile := commit.repo.CommitNodeIndex()
|
||||
if commitGraphFile != nil {
|
||||
defer commitGraphFile.Close()
|
||||
}
|
||||
|
||||
c, err := commitNodeIndex.Get(plumbing.Hash(commit.ID.RawValue()))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
var revs map[string]*Commit
|
||||
if commit.repo.LastCommitCache != nil {
|
||||
var unHitPaths []string
|
||||
revs, unHitPaths, err = getLastCommitForPathsByCache(commit.ID.String(), treePath, entryPaths, commit.repo.LastCommitCache)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(unHitPaths) > 0 {
|
||||
revs2, err := GetLastCommitForPaths(ctx, commit.repo.LastCommitCache, c, treePath, unHitPaths)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
maps.Copy(revs, revs2)
|
||||
}
|
||||
} else {
|
||||
revs, err = GetLastCommitForPaths(ctx, nil, c, treePath, entryPaths)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
commit.repo.gogitStorage.Close()
|
||||
|
||||
commitsInfo := make([]CommitInfo, len(tes))
|
||||
for i, entry := range tes {
|
||||
commitsInfo[i] = CommitInfo{
|
||||
Entry: entry,
|
||||
}
|
||||
|
||||
// Check if we have found a commit for this entry in time
|
||||
if entryCommit, ok := revs[entry.Name()]; ok {
|
||||
commitsInfo[i].Commit = entryCommit
|
||||
}
|
||||
|
||||
// If the entry is a submodule, add a submodule file for this
|
||||
if entry.IsSubModule() {
|
||||
commitsInfo[i].SubmoduleFile, err = GetCommitInfoSubmoduleFile(repoLink, path.Join(treePath, entry.Name()), commit, entry.ID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Retrieve the commit for the treePath itself (see above). We basically
|
||||
// get it for free during the tree traversal and it's used for listing
|
||||
// pages to display information about newest commit for a given path.
|
||||
var treeCommit *Commit
|
||||
var ok bool
|
||||
if treePath == "" {
|
||||
treeCommit = commit
|
||||
} else if treeCommit, ok = revs[""]; ok {
|
||||
treeCommit.repo = commit.repo
|
||||
}
|
||||
return commitsInfo, treeCommit, nil
|
||||
}
|
||||
|
||||
type commitAndPaths struct {
|
||||
commit cgobject.CommitNode
|
||||
// Paths that are still on the branch represented by commit
|
||||
paths []string
|
||||
// Set of hashes for the paths
|
||||
hashes map[string]plumbing.Hash
|
||||
}
|
||||
|
||||
func getCommitTree(c cgobject.CommitNode, treePath string) (*object.Tree, error) {
|
||||
tree, err := c.Tree()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Optimize deep traversals by focusing only on the specific tree
|
||||
if treePath != "" {
|
||||
tree, err = tree.Tree(treePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return tree, nil
|
||||
}
|
||||
|
||||
func getFileHashes(c cgobject.CommitNode, treePath string, paths []string) (map[string]plumbing.Hash, error) {
|
||||
tree, err := getCommitTree(c, treePath)
|
||||
if err == object.ErrDirectoryNotFound {
|
||||
// The whole tree didn't exist, so return empty map
|
||||
return make(map[string]plumbing.Hash), nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hashes := make(map[string]plumbing.Hash)
|
||||
for _, path := range paths {
|
||||
if path != "" {
|
||||
entry, err := tree.FindEntry(path)
|
||||
if err == nil {
|
||||
hashes[path] = entry.Hash
|
||||
}
|
||||
} else {
|
||||
hashes[path] = tree.Hash
|
||||
}
|
||||
}
|
||||
|
||||
return hashes, nil
|
||||
}
|
||||
|
||||
func getLastCommitForPathsByCache(commitID, treePath string, paths []string, cache *LastCommitCache) (map[string]*Commit, []string, error) {
|
||||
var unHitEntryPaths []string
|
||||
results := make(map[string]*Commit)
|
||||
for _, p := range paths {
|
||||
lastCommit, err := cache.Get(commitID, path.Join(treePath, p))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if lastCommit != nil {
|
||||
results[p] = lastCommit
|
||||
continue
|
||||
}
|
||||
|
||||
unHitEntryPaths = append(unHitEntryPaths, p)
|
||||
}
|
||||
|
||||
return results, unHitEntryPaths, nil
|
||||
}
|
||||
|
||||
// GetLastCommitForPaths returns last commit information
|
||||
func GetLastCommitForPaths(ctx context.Context, cache *LastCommitCache, c cgobject.CommitNode, treePath string, paths []string) (map[string]*Commit, error) {
|
||||
refSha := c.ID().String()
|
||||
|
||||
// We do a tree traversal with nodes sorted by commit time
|
||||
heap := binaryheap.NewWith(func(a, b any) int {
|
||||
if a.(*commitAndPaths).commit.CommitTime().Before(b.(*commitAndPaths).commit.CommitTime()) {
|
||||
return 1
|
||||
}
|
||||
return -1
|
||||
})
|
||||
|
||||
resultNodes := make(map[string]cgobject.CommitNode)
|
||||
initialHashes, err := getFileHashes(c, treePath, paths)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Start search from the root commit and with full set of paths
|
||||
heap.Push(&commitAndPaths{c, paths, initialHashes})
|
||||
heaploop:
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
break heaploop
|
||||
}
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
cIn, ok := heap.Pop()
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
current := cIn.(*commitAndPaths)
|
||||
|
||||
// Load the parent commits for the one we are currently examining
|
||||
numParents := current.commit.NumParents()
|
||||
var parents []cgobject.CommitNode
|
||||
for i := range numParents {
|
||||
parent, err := current.commit.ParentNode(i)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
parents = append(parents, parent)
|
||||
}
|
||||
|
||||
// Examine the current commit and set of interesting paths
|
||||
pathUnchanged := make([]bool, len(current.paths))
|
||||
parentHashes := make([]map[string]plumbing.Hash, len(parents))
|
||||
for j, parent := range parents {
|
||||
parentHashes[j], err = getFileHashes(parent, treePath, current.paths)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
for i, path := range current.paths {
|
||||
if parentHashes[j][path] == current.hashes[path] {
|
||||
pathUnchanged[i] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var remainingPaths []string
|
||||
for i, pth := range current.paths {
|
||||
// The results could already contain some newer change for the same path,
|
||||
// so don't override that and bail out on the file early.
|
||||
if resultNodes[pth] == nil {
|
||||
if pathUnchanged[i] {
|
||||
// The path existed with the same hash in at least one parent so it could
|
||||
// not have been changed in this commit directly.
|
||||
remainingPaths = append(remainingPaths, pth)
|
||||
} else {
|
||||
// There are few possible cases how can we get here:
|
||||
// - The path didn't exist in any parent, so it must have been created by
|
||||
// this commit.
|
||||
// - The path did exist in the parent commit, but the hash of the file has
|
||||
// changed.
|
||||
// - We are looking at a merge commit and the hash of the file doesn't
|
||||
// match any of the hashes being merged. This is more common for directories,
|
||||
// but it can also happen if a file is changed through conflict resolution.
|
||||
resultNodes[pth] = current.commit
|
||||
if err := cache.Put(refSha, path.Join(treePath, pth), current.commit.ID().String()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(remainingPaths) > 0 {
|
||||
// Add the parent nodes along with remaining paths to the heap for further
|
||||
// processing.
|
||||
for j, parent := range parents {
|
||||
// Combine remainingPath with paths available on the parent branch
|
||||
// and make union of them
|
||||
remainingPathsForParent := make([]string, 0, len(remainingPaths))
|
||||
newRemainingPaths := make([]string, 0, len(remainingPaths))
|
||||
for _, path := range remainingPaths {
|
||||
if parentHashes[j][path] == current.hashes[path] {
|
||||
remainingPathsForParent = append(remainingPathsForParent, path)
|
||||
} else {
|
||||
newRemainingPaths = append(newRemainingPaths, path)
|
||||
}
|
||||
}
|
||||
|
||||
if remainingPathsForParent != nil {
|
||||
heap.Push(&commitAndPaths{parent, remainingPathsForParent, parentHashes[j]})
|
||||
}
|
||||
|
||||
if len(newRemainingPaths) == 0 {
|
||||
break
|
||||
}
|
||||
remainingPaths = newRemainingPaths
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Post-processing
|
||||
result := make(map[string]*Commit)
|
||||
for path, commitNode := range resultNodes {
|
||||
commit, err := commitNode.Commit()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[path] = convertCommit(commit)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !gogit
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"maps"
|
||||
"path"
|
||||
"sort"
|
||||
|
||||
"gitea.dev/modules/log"
|
||||
)
|
||||
|
||||
// GetCommitsInfo gets information of all commits that are corresponding to these entries
|
||||
func (tes Entries) GetCommitsInfo(ctx context.Context, repoLink string, commit *Commit, treePath string) ([]CommitInfo, *Commit, error) {
|
||||
entryPaths := make([]string, len(tes)+1)
|
||||
// Get the commit for the treePath itself
|
||||
entryPaths[0] = ""
|
||||
for i, entry := range tes {
|
||||
entryPaths[i+1] = entry.Name()
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
var revs map[string]*Commit
|
||||
if commit.repo.LastCommitCache != nil {
|
||||
var unHitPaths []string
|
||||
revs, unHitPaths, err = getLastCommitForPathsByCache(commit.ID.String(), treePath, entryPaths, commit.repo.LastCommitCache)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(unHitPaths) > 0 {
|
||||
sort.Strings(unHitPaths)
|
||||
commits, err := GetLastCommitForPaths(ctx, commit, treePath, unHitPaths)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
maps.Copy(revs, commits)
|
||||
}
|
||||
} else {
|
||||
sort.Strings(entryPaths)
|
||||
revs, err = GetLastCommitForPaths(ctx, commit, treePath, entryPaths)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
commitsInfo := make([]CommitInfo, len(tes))
|
||||
for i, entry := range tes {
|
||||
commitsInfo[i] = CommitInfo{
|
||||
Entry: entry,
|
||||
}
|
||||
|
||||
// Check if we have found a commit for this entry in time
|
||||
if entryCommit, ok := revs[entry.Name()]; ok {
|
||||
commitsInfo[i].Commit = entryCommit
|
||||
} else {
|
||||
log.Debug("missing commit for %s", entry.Name())
|
||||
}
|
||||
|
||||
// If the entry is a submodule, add a submodule file for this
|
||||
if entry.IsSubModule() {
|
||||
commitsInfo[i].SubmoduleFile, err = GetCommitInfoSubmoduleFile(repoLink, path.Join(treePath, entry.Name()), commit, entry.ID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Retrieve the commit for the treePath itself (see above). We basically
|
||||
// get it for free during the tree traversal, and it's used for listing
|
||||
// pages to display information about the newest commit for a given path.
|
||||
var treeCommit *Commit
|
||||
var ok bool
|
||||
if treePath == "" {
|
||||
treeCommit = commit
|
||||
} else if treeCommit, ok = revs[""]; ok {
|
||||
treeCommit.repo = commit.repo
|
||||
}
|
||||
return commitsInfo, treeCommit, nil
|
||||
}
|
||||
|
||||
func getLastCommitForPathsByCache(commitID, treePath string, paths []string, cache *LastCommitCache) (map[string]*Commit, []string, error) {
|
||||
var unHitEntryPaths []string
|
||||
results := make(map[string]*Commit)
|
||||
for _, p := range paths {
|
||||
lastCommit, err := cache.Get(commitID, path.Join(treePath, p))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if lastCommit != nil {
|
||||
results[p] = lastCommit
|
||||
continue
|
||||
}
|
||||
|
||||
unHitEntryPaths = append(unHitEntryPaths, p)
|
||||
}
|
||||
|
||||
return results, unHitEntryPaths, nil
|
||||
}
|
||||
|
||||
// GetLastCommitForPaths returns last commit information
|
||||
func GetLastCommitForPaths(ctx context.Context, commit *Commit, treePath string, paths []string) (map[string]*Commit, error) {
|
||||
// We read backwards from the commit to obtain all of the commits
|
||||
revs, err := WalkGitLog(ctx, commit.repo, commit, treePath, paths...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
commitsMap := map[string]*Commit{}
|
||||
commitsMap[commit.ID.String()] = commit
|
||||
|
||||
commitCommits := map[string]*Commit{}
|
||||
for path, commitID := range revs {
|
||||
if len(commitID) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
c, ok := commitsMap[commitID]
|
||||
if ok {
|
||||
commitCommits[path] = c
|
||||
continue
|
||||
}
|
||||
|
||||
c, err := commit.repo.GetCommit(commitID) // Ensure the commit exists in the repository
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
commitCommits[path] = c
|
||||
}
|
||||
|
||||
return commitCommits, nil
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
testReposDir = "tests/repos/"
|
||||
)
|
||||
|
||||
func cloneRepo(tb testing.TB, url string) (string, error) {
|
||||
repoDir := tb.TempDir()
|
||||
if err := Clone(tb.Context(), url, repoDir, CloneRepoOptions{
|
||||
Mirror: false,
|
||||
Bare: false,
|
||||
Quiet: true,
|
||||
Timeout: 5 * time.Minute,
|
||||
}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return repoDir, nil
|
||||
}
|
||||
|
||||
func testGetCommitsInfo(t *testing.T, repo1 *Repository) {
|
||||
type expectedEntryInfo struct {
|
||||
CommitID string
|
||||
Size int64
|
||||
}
|
||||
|
||||
// these test case are specific to the repo1 test repo
|
||||
testCases := []struct {
|
||||
CommitID string
|
||||
Path string
|
||||
ExpectedIDs map[string]expectedEntryInfo
|
||||
ExpectedTreeCommit string
|
||||
}{
|
||||
{"8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2", "", map[string]expectedEntryInfo{
|
||||
"file1.txt": {
|
||||
CommitID: "95bb4d39648ee7e325106df01a621c530863a653",
|
||||
Size: 6,
|
||||
},
|
||||
"file2.txt": {
|
||||
CommitID: "8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2",
|
||||
Size: 6,
|
||||
},
|
||||
}, "8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2"},
|
||||
{"2839944139e0de9737a044f78b0e4b40d989a9e3", "", map[string]expectedEntryInfo{
|
||||
"file1.txt": {
|
||||
CommitID: "2839944139e0de9737a044f78b0e4b40d989a9e3",
|
||||
Size: 15,
|
||||
},
|
||||
"branch1.txt": {
|
||||
CommitID: "9c9aef8dd84e02bc7ec12641deb4c930a7c30185",
|
||||
Size: 8,
|
||||
},
|
||||
}, "2839944139e0de9737a044f78b0e4b40d989a9e3"},
|
||||
{"5c80b0245c1c6f8343fa418ec374b13b5d4ee658", "branch2", map[string]expectedEntryInfo{
|
||||
"branch2.txt": {
|
||||
CommitID: "5c80b0245c1c6f8343fa418ec374b13b5d4ee658",
|
||||
Size: 8,
|
||||
},
|
||||
}, "5c80b0245c1c6f8343fa418ec374b13b5d4ee658"},
|
||||
{"feaf4ba6bc635fec442f46ddd4512416ec43c2c2", "", map[string]expectedEntryInfo{
|
||||
"file1.txt": {
|
||||
CommitID: "95bb4d39648ee7e325106df01a621c530863a653",
|
||||
Size: 6,
|
||||
},
|
||||
"file2.txt": {
|
||||
CommitID: "8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2",
|
||||
Size: 6,
|
||||
},
|
||||
"foo": {
|
||||
CommitID: "37991dec2c8e592043f47155ce4808d4580f9123",
|
||||
Size: 0,
|
||||
},
|
||||
}, "feaf4ba6bc635fec442f46ddd4512416ec43c2c2"},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
commit, err := repo1.GetCommit(testCase.CommitID)
|
||||
if err != nil {
|
||||
assert.NoError(t, err, "Unable to get commit: %s from testcase due to error: %v", testCase.CommitID, err)
|
||||
// no point trying to do anything else for this test.
|
||||
continue
|
||||
}
|
||||
assert.NotNil(t, commit)
|
||||
assert.NotNil(t, commit.Tree)
|
||||
assert.NotNil(t, commit.Tree.repo)
|
||||
|
||||
tree, err := commit.Tree.SubTree(testCase.Path)
|
||||
if err != nil {
|
||||
assert.NoError(t, err, "Unable to get subtree: %s of commit: %s from testcase due to error: %v", testCase.Path, testCase.CommitID, err)
|
||||
// no point trying to do anything else for this test.
|
||||
continue
|
||||
}
|
||||
|
||||
assert.NotNil(t, tree, "tree is nil for testCase CommitID %s in Path %s", testCase.CommitID, testCase.Path)
|
||||
assert.NotNil(t, tree.repo, "repo is nil for testCase CommitID %s in Path %s", testCase.CommitID, testCase.Path)
|
||||
|
||||
entries, err := tree.ListEntries()
|
||||
if err != nil {
|
||||
assert.NoError(t, err, "Unable to get entries of subtree: %s in commit: %s from testcase due to error: %v", testCase.Path, testCase.CommitID, err)
|
||||
// no point trying to do anything else for this test.
|
||||
continue
|
||||
}
|
||||
|
||||
// FIXME: Context.TODO() - if graceful has started we should use its Shutdown context otherwise use install signals in TestMain.
|
||||
commitsInfo, treeCommit, err := entries.GetCommitsInfo(t.Context(), "/any/repo-link", commit, testCase.Path)
|
||||
assert.NoError(t, err, "Unable to get commit information for entries of subtree: %s in commit: %s from testcase due to error: %v", testCase.Path, testCase.CommitID, err)
|
||||
if err != nil {
|
||||
t.FailNow()
|
||||
}
|
||||
assert.Equal(t, testCase.ExpectedTreeCommit, treeCommit.ID.String())
|
||||
assert.Len(t, commitsInfo, len(testCase.ExpectedIDs))
|
||||
for _, commitInfo := range commitsInfo {
|
||||
entry := commitInfo.Entry
|
||||
commit := commitInfo.Commit
|
||||
expectedInfo, ok := testCase.ExpectedIDs[entry.Name()]
|
||||
if !assert.True(t, ok) {
|
||||
continue
|
||||
}
|
||||
assert.Equal(t, expectedInfo.CommitID, commit.ID.String())
|
||||
assert.Equal(t, expectedInfo.Size, entry.Size(), entry.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntries_GetCommitsInfo(t *testing.T) {
|
||||
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
|
||||
bareRepo1, err := OpenRepository(t.Context(), bareRepo1Path)
|
||||
assert.NoError(t, err)
|
||||
defer bareRepo1.Close()
|
||||
|
||||
testGetCommitsInfo(t, bareRepo1)
|
||||
|
||||
clonedPath, err := cloneRepo(t, bareRepo1Path)
|
||||
if err != nil {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
clonedRepo1, err := OpenRepository(t.Context(), clonedPath)
|
||||
if err != nil {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
defer clonedRepo1.Close()
|
||||
|
||||
testGetCommitsInfo(t, clonedRepo1)
|
||||
|
||||
t.Run("NonExistingSubmoduleAsNil", func(t *testing.T) {
|
||||
commit, err := bareRepo1.GetCommit("HEAD")
|
||||
require.NoError(t, err)
|
||||
treeEntry, err := commit.GetTreeEntryByPath("file1.txt")
|
||||
require.NoError(t, err)
|
||||
cisf, err := GetCommitInfoSubmoduleFile("/any/repo-link", "file1.txt", commit, treeEntry.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, &CommitSubmoduleFile{
|
||||
repoLink: "/any/repo-link",
|
||||
fullPath: "file1.txt",
|
||||
refURL: "",
|
||||
refID: "e2129701f1a4d54dc44f03c93bca0a2aec7c5449",
|
||||
}, cisf)
|
||||
// since there is no refURL, it means that the submodule info doesn't exist, so it won't have a web link
|
||||
assert.Nil(t, cisf.SubmoduleWebLinkTree(t.Context()))
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkEntries_GetCommitsInfo(b *testing.B) {
|
||||
type benchmarkType struct {
|
||||
url string
|
||||
name string
|
||||
}
|
||||
|
||||
benchmarks := []benchmarkType{
|
||||
{url: "https://github.com/go-gitea/gitea.git", name: "gitea"},
|
||||
{url: "https://github.com/ethantkoenig/manyfiles.git", name: "manyfiles"},
|
||||
{url: "https://github.com/moby/moby.git", name: "moby"},
|
||||
{url: "https://github.com/golang/go.git", name: "go"},
|
||||
{url: "https://github.com/torvalds/linux.git", name: "linux"},
|
||||
}
|
||||
|
||||
doBenchmark := func(benchmark benchmarkType) {
|
||||
var commit *Commit
|
||||
var entries Entries
|
||||
var repo *Repository
|
||||
repoPath, err := cloneRepo(b, benchmark.url)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
if repo, err = OpenRepository(b.Context(), repoPath); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
defer repo.Close()
|
||||
|
||||
if commit, err = repo.GetBranchCommit("master"); err != nil {
|
||||
b.Fatal(err)
|
||||
} else if entries, err = commit.Tree.ListEntries(); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
b.ResetTimer()
|
||||
b.Run(benchmark.name, func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
_, _, err := entries.GetCommitsInfo(b.Context(), "/any/repo-link", commit, "")
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, benchmark := range benchmarks {
|
||||
doBenchmark(benchmark)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
const (
|
||||
commitHeaderGpgsig = "gpgsig"
|
||||
commitHeaderGpgsigSha256 = "gpgsig-sha256"
|
||||
)
|
||||
|
||||
func assignCommitFields(gitRepo *Repository, commit *Commit, headerKey string, headerValue []byte) error {
|
||||
if len(headerValue) > 0 && headerValue[len(headerValue)-1] == '\n' {
|
||||
headerValue = headerValue[:len(headerValue)-1] // remove trailing newline
|
||||
}
|
||||
switch headerKey {
|
||||
case "tree":
|
||||
objID, err := NewIDFromString(string(headerValue))
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid tree ID %q: %w", string(headerValue), err)
|
||||
}
|
||||
commit.Tree = *NewTree(gitRepo, objID)
|
||||
case "parent":
|
||||
objID, err := NewIDFromString(string(headerValue))
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid parent ID %q: %w", string(headerValue), err)
|
||||
}
|
||||
commit.Parents = append(commit.Parents, objID)
|
||||
case "author":
|
||||
commit.Author.Decode(headerValue)
|
||||
case "committer":
|
||||
commit.Committer.Decode(headerValue)
|
||||
case commitHeaderGpgsig, commitHeaderGpgsigSha256:
|
||||
// if there are duplicate "gpgsig" and "gpgsig-sha256" headers, then the signature must have already been invalid
|
||||
// so we don't need to handle duplicate headers here
|
||||
commit.Signature = &CommitSignature{Signature: string(headerValue)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CommitFromReader will generate a Commit from a provided reader
|
||||
// We need this to interpret commits from cat-file or cat-file --batch
|
||||
//
|
||||
// If used as part of a cat-file --batch stream you need to limit the reader to the correct size
|
||||
func CommitFromReader(gitRepo *Repository, objectID ObjectID, reader io.Reader) (*Commit, error) {
|
||||
commit := &Commit{
|
||||
ID: objectID,
|
||||
Author: &Signature{},
|
||||
Committer: &Signature{},
|
||||
}
|
||||
|
||||
bufReader := bufio.NewReader(reader)
|
||||
inHeader := true
|
||||
var payloadSB, messageSB bytes.Buffer
|
||||
var headerKey string
|
||||
var headerValue []byte
|
||||
for {
|
||||
line, err := bufReader.ReadBytes('\n')
|
||||
if err != nil && err != io.EOF {
|
||||
return nil, fmt.Errorf("unable to read commit %q: %w", objectID.String(), err)
|
||||
}
|
||||
if len(line) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
if inHeader {
|
||||
inHeader = !(len(line) == 1 && line[0] == '\n') // still in header if line is not just a newline
|
||||
k, v, _ := bytes.Cut(line, []byte{' '})
|
||||
if len(k) != 0 || !inHeader {
|
||||
if headerKey != "" {
|
||||
if err = assignCommitFields(gitRepo, commit, headerKey, headerValue); err != nil {
|
||||
return nil, fmt.Errorf("unable to parse commit %q: %w", objectID.String(), err)
|
||||
}
|
||||
}
|
||||
headerKey = string(k) // it also resets the headerValue to empty string if not inHeader
|
||||
headerValue = v
|
||||
} else {
|
||||
headerValue = append(headerValue, v...)
|
||||
}
|
||||
if headerKey != commitHeaderGpgsig && headerKey != commitHeaderGpgsigSha256 {
|
||||
_, _ = payloadSB.Write(line)
|
||||
}
|
||||
} else {
|
||||
_, _ = messageSB.Write(line)
|
||||
_, _ = payloadSB.Write(line)
|
||||
}
|
||||
}
|
||||
|
||||
commit.MessageRaw = messageSB.String()
|
||||
if commit.Signature != nil {
|
||||
commit.Signature.Payload = payloadSB.String()
|
||||
}
|
||||
return commit, nil
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !gogit
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetFullCommitIDSha256(t *testing.T) {
|
||||
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare_sha256")
|
||||
|
||||
id, err := GetFullCommitID(t.Context(), bareRepo1Path, "f004f4")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "f004f41359117d319dedd0eaab8c5259ee2263da839dcba33637997458627fdc", id)
|
||||
}
|
||||
|
||||
func TestGetFullCommitIDErrorSha256(t *testing.T) {
|
||||
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare_sha256")
|
||||
|
||||
id, err := GetFullCommitID(t.Context(), bareRepo1Path, "unknown")
|
||||
assert.Empty(t, id)
|
||||
if assert.Error(t, err) {
|
||||
assert.EqualError(t, err, "object does not exist [id: unknown, rel_path: ]")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitFromReaderSha256(t *testing.T) {
|
||||
commitString := `tree e7f9e96dd79c09b078cac8b303a7d3b9d65ff9b734e86060a4d20409fd379f9e
|
||||
parent 26e9ccc29fad747e9c5d9f4c9ddeb7eff61cc45ef6a8dc258cbeb181afc055e8
|
||||
author Adam Majer <amajer@suse.de> 1698676906 +0100
|
||||
committer Adam Majer <amajer@suse.de> 1698676906 +0100
|
||||
gpgsig-sha256 -----BEGIN PGP SIGNATURE-----
|
||||
` + " " + `
|
||||
iQIrBAABCgAtFiEES+fB08xlgTrzSdQvhkUIsBsmec8FAmU/wKoPHGFtYWplckBz
|
||||
dXNlLmRlAAoJEIZFCLAbJnnP4s4PQIJATa++WPzR6/H4etT7bsOGoMyguEJYyWOd
|
||||
aTybplzT7QAL7h2to0QszGabtzMJPIA39xSFZNYNN30voK5YyyYibXluPKgjemfK
|
||||
WNXwF+gkwgZI38gSvKf+vlqI+EYyIFe19wOhiju0m8SIlB5NEPiWHa17q2mqmqqx
|
||||
1FWa2JdqLPYjAtSLFXeSZegrY5V1FxdemyMUONkg8YO9OSIMZiE0GsnnOXQ3xcT4
|
||||
JTCnmlUxIKw689UiEY80JopUIq+Wl7+qq9507IYYSUCyB6JazL42AKMzVCbD+qBP
|
||||
oOzh/hafYgk9H9qCQXaLbmvs17zXRpicig1bAzqgAy1FDelvpERyRTydEajSLIG6
|
||||
U1cRCkgXCZ0NfsYNPPmBa8b3+rnstypXYTbyMwTln7FfUAaGo6o9JYiPMkzxlmsy
|
||||
zfp/tcaY8+LlBL9aOJjtv+a0p+HrpCGd6CCa4ARfphTLq8QRSSh8uzlB9N+6HnRI
|
||||
VAEUo6ecdDxSpyt2naeg9pKus/BRi7P6g4B1hkk/zZstUX/QP4IQuAJbXjkvsC+X
|
||||
HKRr3NlRM/DygzTyj0gN74uoa0goCIbyAQhiT42nm0cuhM7uN/W0ayrlZjGF1cbR
|
||||
8NCJUL2Nwj0ywKIavC99Ipkb8AsFwpVT6U6effs6
|
||||
=xybZ
|
||||
-----END PGP SIGNATURE-----
|
||||
|
||||
signed commit`
|
||||
|
||||
sha := &Sha256Hash{
|
||||
0x94, 0x33, 0xb2, 0xa6, 0x2b, 0x96, 0x4c, 0x17, 0xa4, 0x48, 0x5a, 0xe1, 0x80, 0xf4, 0x5f, 0x59,
|
||||
0x5d, 0x3e, 0x69, 0xd3, 0x1b, 0x78, 0x60, 0x87, 0x77, 0x5e, 0x28, 0xc6, 0xb6, 0x39, 0x9d, 0xf0,
|
||||
}
|
||||
gitRepo, err := OpenRepository(t.Context(), filepath.Join(testReposDir, "repo1_bare_sha256"))
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, gitRepo)
|
||||
defer gitRepo.Close()
|
||||
|
||||
commitFromReader, err := CommitFromReader(gitRepo, sha, strings.NewReader(commitString))
|
||||
assert.NoError(t, err)
|
||||
require.NotNil(t, commitFromReader)
|
||||
assert.EqualValues(t, sha, commitFromReader.ID)
|
||||
assert.Equal(t, `-----BEGIN PGP SIGNATURE-----
|
||||
|
||||
iQIrBAABCgAtFiEES+fB08xlgTrzSdQvhkUIsBsmec8FAmU/wKoPHGFtYWplckBz
|
||||
dXNlLmRlAAoJEIZFCLAbJnnP4s4PQIJATa++WPzR6/H4etT7bsOGoMyguEJYyWOd
|
||||
aTybplzT7QAL7h2to0QszGabtzMJPIA39xSFZNYNN30voK5YyyYibXluPKgjemfK
|
||||
WNXwF+gkwgZI38gSvKf+vlqI+EYyIFe19wOhiju0m8SIlB5NEPiWHa17q2mqmqqx
|
||||
1FWa2JdqLPYjAtSLFXeSZegrY5V1FxdemyMUONkg8YO9OSIMZiE0GsnnOXQ3xcT4
|
||||
JTCnmlUxIKw689UiEY80JopUIq+Wl7+qq9507IYYSUCyB6JazL42AKMzVCbD+qBP
|
||||
oOzh/hafYgk9H9qCQXaLbmvs17zXRpicig1bAzqgAy1FDelvpERyRTydEajSLIG6
|
||||
U1cRCkgXCZ0NfsYNPPmBa8b3+rnstypXYTbyMwTln7FfUAaGo6o9JYiPMkzxlmsy
|
||||
zfp/tcaY8+LlBL9aOJjtv+a0p+HrpCGd6CCa4ARfphTLq8QRSSh8uzlB9N+6HnRI
|
||||
VAEUo6ecdDxSpyt2naeg9pKus/BRi7P6g4B1hkk/zZstUX/QP4IQuAJbXjkvsC+X
|
||||
HKRr3NlRM/DygzTyj0gN74uoa0goCIbyAQhiT42nm0cuhM7uN/W0ayrlZjGF1cbR
|
||||
8NCJUL2Nwj0ywKIavC99Ipkb8AsFwpVT6U6effs6
|
||||
=xybZ
|
||||
-----END PGP SIGNATURE-----`, commitFromReader.Signature.Signature)
|
||||
assert.Equal(t, `tree e7f9e96dd79c09b078cac8b303a7d3b9d65ff9b734e86060a4d20409fd379f9e
|
||||
parent 26e9ccc29fad747e9c5d9f4c9ddeb7eff61cc45ef6a8dc258cbeb181afc055e8
|
||||
author Adam Majer <amajer@suse.de> 1698676906 +0100
|
||||
committer Adam Majer <amajer@suse.de> 1698676906 +0100
|
||||
|
||||
signed commit`, commitFromReader.Signature.Payload)
|
||||
assert.Equal(t, "Adam Majer <amajer@suse.de>", commitFromReader.Author.String())
|
||||
|
||||
commitFromReader2, err := CommitFromReader(gitRepo, sha, strings.NewReader(commitString+"\n\n"))
|
||||
assert.NoError(t, err)
|
||||
commitFromReader.CommitMessage.MessageRaw += "\n\n"
|
||||
commitFromReader.Signature.Payload += "\n\n"
|
||||
assert.Equal(t, commitFromReader, commitFromReader2)
|
||||
}
|
||||
|
||||
func TestHasPreviousCommitSha256(t *testing.T) {
|
||||
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare_sha256")
|
||||
|
||||
repo, err := OpenRepository(t.Context(), bareRepo1Path)
|
||||
assert.NoError(t, err)
|
||||
defer repo.Close()
|
||||
|
||||
commit, err := repo.GetCommit("f004f41359117d319dedd0eaab8c5259ee2263da839dcba33637997458627fdc")
|
||||
assert.NoError(t, err)
|
||||
|
||||
objectFormat, err := repo.GetObjectFormat()
|
||||
assert.NoError(t, err)
|
||||
|
||||
parentSHA := MustIDFromString("b0ec7af4547047f12d5093e37ef8f1b3b5415ed8ee17894d43a34d7d34212e9c")
|
||||
notParentSHA := MustIDFromString("42e334efd04cd36eea6da0599913333c26116e1a537ca76e5b6e4af4dda00236")
|
||||
assert.Equal(t, objectFormat, parentSHA.Type())
|
||||
assert.Equal(t, "sha256", objectFormat.Name())
|
||||
|
||||
haz, err := commit.HasPreviousCommit(parentSHA)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, haz)
|
||||
|
||||
hazNot, err := commit.HasPreviousCommit(notParentSHA)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, hazNot)
|
||||
|
||||
selfNot, err := commit.HasPreviousCommit(commit.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, selfNot)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
type SubmoduleWebLink struct {
|
||||
RepoWebLink, CommitWebLink string
|
||||
}
|
||||
|
||||
// GetSubModules get all the submodules of current revision git tree
|
||||
func (c *Commit) GetSubModules() (*ObjectCache[*SubModule], error) {
|
||||
if c.submoduleCache != nil {
|
||||
return c.submoduleCache, nil
|
||||
}
|
||||
|
||||
entry, err := c.GetTreeEntryByPath(".gitmodules")
|
||||
if err != nil {
|
||||
if _, ok := err.(ErrNotExist); ok {
|
||||
return nil, nil //nolint:nilnil // return nil to indicate that the submodule does not exist
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rd, err := entry.Blob().DataAsync()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rd.Close()
|
||||
|
||||
// at the moment we do not strictly limit the size of the .gitmodules file because some users would have huge .gitmodules files (>1MB)
|
||||
c.submoduleCache, err = configParseSubModules(rd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.submoduleCache, nil
|
||||
}
|
||||
|
||||
// GetSubModule gets the submodule by the entry name.
|
||||
// It returns "nil, nil" if the submodule does not exist, caller should always remember to check the "nil"
|
||||
func (c *Commit) GetSubModule(entryName string) (*SubModule, error) {
|
||||
modules, err := c.GetSubModules()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if modules != nil {
|
||||
if module, has := modules.Get(entryName); has {
|
||||
return module, nil
|
||||
}
|
||||
}
|
||||
return nil, nil //nolint:nilnil // return nil to indicate that the submodule does not exist
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2015 The Gogs Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
giturl "gitea.dev/modules/git/url"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
// CommitSubmoduleFile represents a file with submodule type.
|
||||
type CommitSubmoduleFile struct {
|
||||
repoLink string
|
||||
fullPath string
|
||||
refURL string
|
||||
refID string
|
||||
|
||||
parsed bool
|
||||
parsedTargetLink string
|
||||
}
|
||||
|
||||
// NewCommitSubmoduleFile create a new submodule file
|
||||
func NewCommitSubmoduleFile(repoLink, fullPath, refURL, refID string) *CommitSubmoduleFile {
|
||||
return &CommitSubmoduleFile{repoLink: repoLink, fullPath: fullPath, refURL: refURL, refID: refID}
|
||||
}
|
||||
|
||||
// RefID returns the commit ID of the submodule, it returns empty string for nil receiver
|
||||
func (sf *CommitSubmoduleFile) RefID() string {
|
||||
if sf == nil {
|
||||
return ""
|
||||
}
|
||||
return sf.refID
|
||||
}
|
||||
|
||||
func (sf *CommitSubmoduleFile) getWebLinkInTargetRepo(ctx context.Context, moreLinkPath string) *SubmoduleWebLink {
|
||||
if sf == nil || sf.refURL == "" {
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(sf.refURL, "../") {
|
||||
targetLink := path.Join(sf.repoLink, sf.refURL)
|
||||
return &SubmoduleWebLink{RepoWebLink: targetLink, CommitWebLink: targetLink + moreLinkPath}
|
||||
}
|
||||
if !sf.parsed {
|
||||
sf.parsed = true
|
||||
parsedURL, err := giturl.ParseRepositoryURL(ctx, sf.refURL)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
sf.parsedTargetLink = giturl.MakeRepositoryWebLink(parsedURL)
|
||||
}
|
||||
return &SubmoduleWebLink{RepoWebLink: sf.parsedTargetLink, CommitWebLink: sf.parsedTargetLink + moreLinkPath}
|
||||
}
|
||||
|
||||
// SubmoduleWebLinkTree tries to make the submodule's tree link in its own repo, it also works on "nil" receiver
|
||||
// It returns nil if the submodule does not have a valid URL or is nil
|
||||
func (sf *CommitSubmoduleFile) SubmoduleWebLinkTree(ctx context.Context, optCommitID ...string) *SubmoduleWebLink {
|
||||
return sf.getWebLinkInTargetRepo(ctx, "/tree/"+util.OptionalArg(optCommitID, sf.RefID()))
|
||||
}
|
||||
|
||||
// SubmoduleWebLinkCompare tries to make the submodule's compare link in its own repo, it also works on "nil" receiver
|
||||
// It returns nil if the submodule does not have a valid URL or is nil
|
||||
func (sf *CommitSubmoduleFile) SubmoduleWebLinkCompare(ctx context.Context, commitID1, commitID2 string) *SubmoduleWebLink {
|
||||
return sf.getWebLinkInTargetRepo(ctx, "/compare/"+commitID1+"..."+commitID2)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCommitSubmoduleLink(t *testing.T) {
|
||||
assert.Nil(t, (*CommitSubmoduleFile)(nil).SubmoduleWebLinkTree(t.Context()))
|
||||
assert.Nil(t, (*CommitSubmoduleFile)(nil).SubmoduleWebLinkCompare(t.Context(), "", ""))
|
||||
assert.Nil(t, (&CommitSubmoduleFile{}).SubmoduleWebLinkTree(t.Context()))
|
||||
assert.Nil(t, (&CommitSubmoduleFile{}).SubmoduleWebLinkCompare(t.Context(), "", ""))
|
||||
|
||||
t.Run("GitHubRepo", func(t *testing.T) {
|
||||
sf := NewCommitSubmoduleFile("/any/repo-link", "full-path", "git@github.com:user/repo.git", "aaaa")
|
||||
wl := sf.SubmoduleWebLinkTree(t.Context())
|
||||
assert.Equal(t, "https://github.com/user/repo", wl.RepoWebLink)
|
||||
assert.Equal(t, "https://github.com/user/repo/tree/aaaa", wl.CommitWebLink)
|
||||
|
||||
wl = sf.SubmoduleWebLinkCompare(t.Context(), "1111", "2222")
|
||||
assert.Equal(t, "https://github.com/user/repo", wl.RepoWebLink)
|
||||
assert.Equal(t, "https://github.com/user/repo/compare/1111...2222", wl.CommitWebLink)
|
||||
})
|
||||
|
||||
t.Run("RelativePath", func(t *testing.T) {
|
||||
sf := NewCommitSubmoduleFile("/subpath/any/repo-home-link", "full-path", "../../user/repo", "aaaa")
|
||||
wl := sf.SubmoduleWebLinkTree(t.Context())
|
||||
assert.Equal(t, "/subpath/user/repo", wl.RepoWebLink)
|
||||
assert.Equal(t, "/subpath/user/repo/tree/aaaa", wl.CommitWebLink)
|
||||
|
||||
sf = NewCommitSubmoduleFile("/subpath/any/repo-home-link", "dir/submodule", "../../user/repo", "aaaa")
|
||||
wl = sf.SubmoduleWebLinkCompare(t.Context(), "1111", "2222")
|
||||
assert.Equal(t, "/subpath/user/repo", wl.RepoWebLink)
|
||||
assert.Equal(t, "/subpath/user/repo/compare/1111...2222", wl.CommitWebLink)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetFullCommitID(t *testing.T) {
|
||||
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
|
||||
|
||||
id, err := GetFullCommitID(t.Context(), bareRepo1Path, "8006ff9a")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "8006ff9adbf0cb94da7dad9e537e53817f9fa5c0", id)
|
||||
}
|
||||
|
||||
func TestGetFullCommitIDError(t *testing.T) {
|
||||
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
|
||||
|
||||
id, err := GetFullCommitID(t.Context(), bareRepo1Path, "unknown")
|
||||
assert.Empty(t, id)
|
||||
if assert.Error(t, err) {
|
||||
assert.EqualError(t, err, "object does not exist [id: unknown, rel_path: ]")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitFromReader(t *testing.T) {
|
||||
commitString := `tree f1a6cb52b2d16773290cefe49ad0684b50a4f930
|
||||
parent 37991dec2c8e592043f47155ce4808d4580f9123
|
||||
author silverwind <me@silverwind.io> 1563741793 +0200
|
||||
committer silverwind <me@silverwind.io> 1563741793 +0200
|
||||
gpgsig -----BEGIN PGP SIGNATURE-----
|
||||
` + " " + `
|
||||
iQIzBAABCAAdFiEEWPb2jX6FS2mqyJRQLmK0HJOGlEMFAl00zmEACgkQLmK0HJOG
|
||||
lEMDFBAAhQKKqLD1VICygJMEB8t1gBmNLgvziOLfpX4KPWdPtBk3v/QJ7OrfMrVK
|
||||
xlC4ZZyx6yMm1Q7GzmuWykmZQJ9HMaHJ49KAbh5MMjjV/+OoQw9coIdo8nagRUld
|
||||
vX8QHzNZ6Agx77xHuDJZgdHKpQK3TrMDsxzoYYMvlqoLJIDXE1Sp7KYNy12nhdRg
|
||||
R6NXNmW8oMZuxglkmUwayMiPS+N4zNYqv0CXYzlEqCOgq9MJUcAMHt+KpiST+sm6
|
||||
FWkJ9D+biNPyQ9QKf1AE4BdZia4lHfPYU/C/DEL/a5xQuuop/zMQZoGaIA4p2zGQ
|
||||
/maqYxEIM/yRBQpT1jlODKPJrMEgx7SgY2hRU47YZ4fj6350fb6fNBtiiMAfJbjL
|
||||
S3Gh85E9fm3hJaNSPKAaJFYL1Ya2svuWfgHj677C56UcmYis7fhiiy1aJuYdHnSm
|
||||
sD53z/f0J+We4VZjY+pidvA9BGZPFVdR3wd3xGs8/oH6UWaLJAMGkLG6dDb3qDLm
|
||||
1LFZwsX8sdD32i1SiWanYQYSYMyFWr0awi4xdoMtYCL7uKBYtwtPyvq3cj4IrJlb
|
||||
mfeFhT57UbE4qukTDIQ0Y0WM40UYRTakRaDY7ubhXgLgx09Cnp9XTVMsHgT6j9/i
|
||||
1pxsB104XLWjQHTjr1JtiaBQEwFh9r2OKTcpvaLcbNtYpo7CzOs=
|
||||
=FRsO
|
||||
-----END PGP SIGNATURE-----
|
||||
|
||||
empty commit`
|
||||
|
||||
sha := &Sha1Hash{0xfe, 0xaf, 0x4b, 0xa6, 0xbc, 0x63, 0x5f, 0xec, 0x44, 0x2f, 0x46, 0xdd, 0xd4, 0x51, 0x24, 0x16, 0xec, 0x43, 0xc2, 0xc2}
|
||||
gitRepo, err := OpenRepository(t.Context(), filepath.Join(testReposDir, "repo1_bare"))
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, gitRepo)
|
||||
defer gitRepo.Close()
|
||||
|
||||
commitFromReader, err := CommitFromReader(gitRepo, sha, strings.NewReader(commitString))
|
||||
assert.NoError(t, err)
|
||||
require.NotNil(t, commitFromReader)
|
||||
assert.EqualValues(t, sha, commitFromReader.ID)
|
||||
assert.Equal(t, `-----BEGIN PGP SIGNATURE-----
|
||||
|
||||
iQIzBAABCAAdFiEEWPb2jX6FS2mqyJRQLmK0HJOGlEMFAl00zmEACgkQLmK0HJOG
|
||||
lEMDFBAAhQKKqLD1VICygJMEB8t1gBmNLgvziOLfpX4KPWdPtBk3v/QJ7OrfMrVK
|
||||
xlC4ZZyx6yMm1Q7GzmuWykmZQJ9HMaHJ49KAbh5MMjjV/+OoQw9coIdo8nagRUld
|
||||
vX8QHzNZ6Agx77xHuDJZgdHKpQK3TrMDsxzoYYMvlqoLJIDXE1Sp7KYNy12nhdRg
|
||||
R6NXNmW8oMZuxglkmUwayMiPS+N4zNYqv0CXYzlEqCOgq9MJUcAMHt+KpiST+sm6
|
||||
FWkJ9D+biNPyQ9QKf1AE4BdZia4lHfPYU/C/DEL/a5xQuuop/zMQZoGaIA4p2zGQ
|
||||
/maqYxEIM/yRBQpT1jlODKPJrMEgx7SgY2hRU47YZ4fj6350fb6fNBtiiMAfJbjL
|
||||
S3Gh85E9fm3hJaNSPKAaJFYL1Ya2svuWfgHj677C56UcmYis7fhiiy1aJuYdHnSm
|
||||
sD53z/f0J+We4VZjY+pidvA9BGZPFVdR3wd3xGs8/oH6UWaLJAMGkLG6dDb3qDLm
|
||||
1LFZwsX8sdD32i1SiWanYQYSYMyFWr0awi4xdoMtYCL7uKBYtwtPyvq3cj4IrJlb
|
||||
mfeFhT57UbE4qukTDIQ0Y0WM40UYRTakRaDY7ubhXgLgx09Cnp9XTVMsHgT6j9/i
|
||||
1pxsB104XLWjQHTjr1JtiaBQEwFh9r2OKTcpvaLcbNtYpo7CzOs=
|
||||
=FRsO
|
||||
-----END PGP SIGNATURE-----`, commitFromReader.Signature.Signature)
|
||||
assert.Equal(t, `tree f1a6cb52b2d16773290cefe49ad0684b50a4f930
|
||||
parent 37991dec2c8e592043f47155ce4808d4580f9123
|
||||
author silverwind <me@silverwind.io> 1563741793 +0200
|
||||
committer silverwind <me@silverwind.io> 1563741793 +0200
|
||||
|
||||
empty commit`, commitFromReader.Signature.Payload)
|
||||
assert.Equal(t, "silverwind <me@silverwind.io>", commitFromReader.Author.String())
|
||||
|
||||
commitFromReader2, err := CommitFromReader(gitRepo, sha, strings.NewReader(commitString+"\n\n"))
|
||||
assert.NoError(t, err)
|
||||
commitFromReader.CommitMessage.MessageRaw += "\n\n"
|
||||
commitFromReader.Signature.Payload += "\n\n"
|
||||
assert.Equal(t, commitFromReader, commitFromReader2)
|
||||
}
|
||||
|
||||
func TestCommitWithEncodingFromReader(t *testing.T) {
|
||||
commitString := `tree ca3fad42080dd1a6d291b75acdfc46e5b9b307e5
|
||||
parent 47b24e7ab977ed31c5a39989d570847d6d0052af
|
||||
author KN4CK3R <admin@oldschoolhack.me> 1711702962 +0100
|
||||
committer KN4CK3R <admin@oldschoolhack.me> 1711702962 +0100
|
||||
encoding ISO-8859-1
|
||||
gpgsig -----BEGIN PGP SIGNATURE-----
|
||||
<SPACE>
|
||||
iQGzBAABCgAdFiEE9HRrbqvYxPT8PXbefPSEkrowAa8FAmYGg7IACgkQfPSEkrow
|
||||
Aa9olwv+P0HhtCM6CRvlUmPaqswRsDPNR4i66xyXGiSxdI9V5oJL7HLiQIM7KrFR
|
||||
gizKa2COiGtugv8fE+TKqXKaJx6uJUJEjaBd8E9Af9PrAzjWj+A84lU6/PgPS8hq
|
||||
zOfZraLOEWRH4tZcS+u2yFLu3ez2Wqh1xW5LNy7xqEedMXEFD1HwSJ0+pjacNkzr
|
||||
frp6Asyt7xRI6YmgFJZJoRsS3Ktr6rtKeRL2IErSQQyorOqj6gKrglhrhfG/114j
|
||||
FKB1v4or0WZ1DE8iP2SJZ3n+/K1IuWAINh7MVdb7PndfBPEa+IL+ucNk5uzEE8Jd
|
||||
G8smGxXUeFEt2cP1dj2W8EgAxuA9sTnH9dqI5aRqy5ifDjuya7Emm8sdOUvtGdmn
|
||||
SONRzusmu5n3DgV956REL7x62h7JuqmBz/12HZkr0z0zgXkcZ04q08pSJATX5N1F
|
||||
yN+tWxTsWg+zhDk96d5Esdo9JMjcFvPv0eioo30GAERaz1hoD7zCMT4jgUFTQwgz
|
||||
jw4YcO5u
|
||||
=r3UU
|
||||
-----END PGP SIGNATURE-----
|
||||
|
||||
ISO-8859-1`
|
||||
commitString = strings.ReplaceAll(commitString, "<SPACE>", " ")
|
||||
sha := &Sha1Hash{0xfe, 0xaf, 0x4b, 0xa6, 0xbc, 0x63, 0x5f, 0xec, 0x44, 0x2f, 0x46, 0xdd, 0xd4, 0x51, 0x24, 0x16, 0xec, 0x43, 0xc2, 0xc2}
|
||||
gitRepo, err := OpenRepository(t.Context(), filepath.Join(testReposDir, "repo1_bare"))
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, gitRepo)
|
||||
defer gitRepo.Close()
|
||||
|
||||
commitFromReader, err := CommitFromReader(gitRepo, sha, strings.NewReader(commitString))
|
||||
assert.NoError(t, err)
|
||||
require.NotNil(t, commitFromReader)
|
||||
assert.EqualValues(t, sha, commitFromReader.ID)
|
||||
assert.Equal(t, `-----BEGIN PGP SIGNATURE-----
|
||||
|
||||
iQGzBAABCgAdFiEE9HRrbqvYxPT8PXbefPSEkrowAa8FAmYGg7IACgkQfPSEkrow
|
||||
Aa9olwv+P0HhtCM6CRvlUmPaqswRsDPNR4i66xyXGiSxdI9V5oJL7HLiQIM7KrFR
|
||||
gizKa2COiGtugv8fE+TKqXKaJx6uJUJEjaBd8E9Af9PrAzjWj+A84lU6/PgPS8hq
|
||||
zOfZraLOEWRH4tZcS+u2yFLu3ez2Wqh1xW5LNy7xqEedMXEFD1HwSJ0+pjacNkzr
|
||||
frp6Asyt7xRI6YmgFJZJoRsS3Ktr6rtKeRL2IErSQQyorOqj6gKrglhrhfG/114j
|
||||
FKB1v4or0WZ1DE8iP2SJZ3n+/K1IuWAINh7MVdb7PndfBPEa+IL+ucNk5uzEE8Jd
|
||||
G8smGxXUeFEt2cP1dj2W8EgAxuA9sTnH9dqI5aRqy5ifDjuya7Emm8sdOUvtGdmn
|
||||
SONRzusmu5n3DgV956REL7x62h7JuqmBz/12HZkr0z0zgXkcZ04q08pSJATX5N1F
|
||||
yN+tWxTsWg+zhDk96d5Esdo9JMjcFvPv0eioo30GAERaz1hoD7zCMT4jgUFTQwgz
|
||||
jw4YcO5u
|
||||
=r3UU
|
||||
-----END PGP SIGNATURE-----`, commitFromReader.Signature.Signature)
|
||||
assert.Equal(t, `tree ca3fad42080dd1a6d291b75acdfc46e5b9b307e5
|
||||
parent 47b24e7ab977ed31c5a39989d570847d6d0052af
|
||||
author KN4CK3R <admin@oldschoolhack.me> 1711702962 +0100
|
||||
committer KN4CK3R <admin@oldschoolhack.me> 1711702962 +0100
|
||||
encoding ISO-8859-1
|
||||
|
||||
ISO-8859-1`, commitFromReader.Signature.Payload)
|
||||
assert.Equal(t, "KN4CK3R <admin@oldschoolhack.me>", commitFromReader.Author.String())
|
||||
|
||||
commitFromReader2, err := CommitFromReader(gitRepo, sha, strings.NewReader(commitString+"\n\n"))
|
||||
assert.NoError(t, err)
|
||||
commitFromReader.CommitMessage.MessageRaw += "\n\n"
|
||||
commitFromReader.Signature.Payload += "\n\n"
|
||||
assert.Equal(t, commitFromReader, commitFromReader2)
|
||||
}
|
||||
|
||||
func TestCommitMessageSanitizesInvalidUTF8(t *testing.T) {
|
||||
commit := &Commit{
|
||||
CommitMessage: CommitMessage{MessageRaw: "title \xff\n\n\n\nbody \xff\n\n\n"},
|
||||
}
|
||||
assert.Equal(t, "title ÿ", commit.MessageTitle())
|
||||
assert.Equal(t, "body ÿ", commit.MessageBody())
|
||||
assert.Equal(t, "title ÿ\n\n\n\nbody ÿ\n\n\n", commit.MessageUTF8())
|
||||
}
|
||||
|
||||
func TestHasPreviousCommit(t *testing.T) {
|
||||
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
|
||||
|
||||
repo, err := OpenRepository(t.Context(), bareRepo1Path)
|
||||
assert.NoError(t, err)
|
||||
defer repo.Close()
|
||||
|
||||
commit, err := repo.GetCommit("8006ff9adbf0cb94da7dad9e537e53817f9fa5c0")
|
||||
assert.NoError(t, err)
|
||||
|
||||
parentSHA := MustIDFromString("8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2")
|
||||
notParentSHA := MustIDFromString("2839944139e0de9737a044f78b0e4b40d989a9e3")
|
||||
|
||||
haz, err := commit.HasPreviousCommit(parentSHA)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, haz)
|
||||
|
||||
hazNot, err := commit.HasPreviousCommit(notParentSHA)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, hazNot)
|
||||
|
||||
selfNot, err := commit.HasPreviousCommit(commit.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, selfNot)
|
||||
}
|
||||
|
||||
func Test_GetCommitBranchStart(t *testing.T) {
|
||||
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
|
||||
repo, err := OpenRepository(t.Context(), bareRepo1Path)
|
||||
assert.NoError(t, err)
|
||||
defer repo.Close()
|
||||
commit, err := repo.GetBranchCommit("branch1")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "2839944139e0de9737a044f78b0e4b40d989a9e3", commit.ID.String())
|
||||
|
||||
startCommitID, err := repo.GetCommitBranchStart(os.Environ(), "branch1", commit.ID.String())
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, startCommitID)
|
||||
assert.Equal(t, "95bb4d39648ee7e325106df01a621c530863a653", startCommitID)
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
// syncGitConfig only modifies gitconfig, won't change global variables (otherwise there will be data-race problem)
|
||||
func syncGitConfig(ctx context.Context) (err error) {
|
||||
if err = os.MkdirAll(gitcmd.HomeDir(), os.ModePerm); err != nil {
|
||||
return fmt.Errorf("unable to prepare git home directory %s, err: %w", gitcmd.HomeDir(), err)
|
||||
}
|
||||
|
||||
// first, write user's git config options to git config file
|
||||
// user config options could be overwritten by builtin values later, because if a value is builtin, it must have some special purposes
|
||||
for k, v := range setting.GitConfig.Options {
|
||||
if err = configSet(ctx, strings.ToLower(k), v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Git requires setting user.name and user.email in order to commit changes - old comment: "if they're not set just add some defaults"
|
||||
// TODO: need to confirm whether users really need to change these values manually. It seems that these values are dummy only and not really used.
|
||||
// If these values are not really used, then they can be set (overwritten) directly without considering about existence.
|
||||
for configKey, defaultValue := range map[string]string{
|
||||
"user.name": "Gitea",
|
||||
"user.email": "gitea@fake.local",
|
||||
} {
|
||||
if err := configSetNonExist(ctx, configKey, defaultValue); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Set git some configurations - these must be set to these values for gitea to work correctly
|
||||
if err := configSet(ctx, "core.quotePath", "false"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if DefaultFeatures().CheckVersionAtLeast("2.10") {
|
||||
if err := configSet(ctx, "receive.advertisePushOptions", "true"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if DefaultFeatures().CheckVersionAtLeast("2.18") {
|
||||
if err := configSet(ctx, "core.commitGraph", "true"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := configSet(ctx, "gc.writeCommitGraph", "true"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := configSet(ctx, "fetch.writeCommitGraph", "true"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if DefaultFeatures().SupportProcReceive {
|
||||
// set support for AGit flow
|
||||
if err := configAddNonExist(ctx, "receive.procReceiveRefs", "refs/for"); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := configUnsetAll(ctx, "receive.procReceiveRefs", "refs/for"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Due to CVE-2022-24765, git now denies access to git directories which are not owned by current user.
|
||||
// However, some docker users and samba users find it difficult to configure their systems correctly,
|
||||
// so that Gitea's git repositories are owned by the Gitea user.
|
||||
// (Possibly Windows Service users - but ownership in this case should really be set correctly on the filesystem.)
|
||||
// See issue: https://github.com/go-gitea/gitea/issues/19455
|
||||
// As Gitea now always use its internal git config file, and access to the git repositories is managed through Gitea,
|
||||
// it is now safe to set "safe.directory=*" for internal usage only.
|
||||
// Although this setting is only supported by some new git versions, it is also tolerated by earlier versions
|
||||
if err := configAddNonExist(ctx, "safe.directory", "*"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
if err := configSet(ctx, "core.longpaths", "true"); err != nil {
|
||||
return err
|
||||
}
|
||||
if setting.Git.DisableCoreProtectNTFS {
|
||||
err = configSet(ctx, "core.protectNTFS", "false")
|
||||
} else {
|
||||
err = configUnsetAll(ctx, "core.protectNTFS", "false")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// By default partial clones are disabled, enable them from git v2.22
|
||||
if !setting.Git.DisablePartialClone && DefaultFeatures().CheckVersionAtLeast("2.22") {
|
||||
if err = configSet(ctx, "uploadpack.allowfilter", "true"); err != nil {
|
||||
return err
|
||||
}
|
||||
err = configSet(ctx, "uploadpack.allowAnySHA1InWant", "true")
|
||||
} else {
|
||||
if err = configUnsetAll(ctx, "uploadpack.allowfilter", "true"); err != nil {
|
||||
return err
|
||||
}
|
||||
err = configUnsetAll(ctx, "uploadpack.allowAnySHA1InWant", "true")
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func configSet(ctx context.Context, key, value string) error {
|
||||
stdout, _, err := gitcmd.NewCommand("config", "--global", "--get").
|
||||
AddDynamicArguments(key).
|
||||
RunStdString(ctx)
|
||||
if err != nil && !gitcmd.IsErrorExitCode(err, 1) {
|
||||
return fmt.Errorf("failed to get git config %s, err: %w", key, err)
|
||||
}
|
||||
|
||||
currValue := strings.TrimSpace(stdout)
|
||||
if currValue == value {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, _, err = gitcmd.NewCommand("config", "--global").
|
||||
AddDynamicArguments(key, value).
|
||||
RunStdString(ctx); err != nil {
|
||||
return fmt.Errorf("failed to set git global config %s, err: %w", key, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func configSetNonExist(ctx context.Context, key, value string) error {
|
||||
_, _, err := gitcmd.NewCommand("config", "--global", "--get").AddDynamicArguments(key).RunStdString(ctx)
|
||||
if err == nil {
|
||||
// already exist
|
||||
return nil
|
||||
}
|
||||
if gitcmd.IsErrorExitCode(err, 1) {
|
||||
// not exist, set new config
|
||||
_, _, err = gitcmd.NewCommand("config", "--global").AddDynamicArguments(key, value).RunStdString(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set git global config %s, err: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("failed to get git config %s, err: %w", key, err)
|
||||
}
|
||||
|
||||
func configAddNonExist(ctx context.Context, key, value string) error {
|
||||
_, _, err := gitcmd.NewCommand("config", "--global", "--get").AddDynamicArguments(key, regexp.QuoteMeta(value)).RunStdString(ctx)
|
||||
if err == nil {
|
||||
// already exist
|
||||
return nil
|
||||
}
|
||||
if gitcmd.IsErrorExitCode(err, 1) {
|
||||
// not exist, add new config
|
||||
_, _, err = gitcmd.NewCommand("config", "--global", "--add").AddDynamicArguments(key, value).RunStdString(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add git global config %s, err: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to get git config %s, err: %w", key, err)
|
||||
}
|
||||
|
||||
func configUnsetAll(ctx context.Context, key, value string) error {
|
||||
_, _, err := gitcmd.NewCommand("config", "--global", "--get").AddDynamicArguments(key).RunStdString(ctx)
|
||||
if err == nil {
|
||||
// exist, need to remove
|
||||
_, _, err = gitcmd.NewCommand("config", "--global", "--unset-all").AddDynamicArguments(key, regexp.QuoteMeta(value)).RunStdString(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unset git global config %s, err: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if gitcmd.IsErrorExitCode(err, 1) {
|
||||
// not exist
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to get git config %s, err: %w", key, err)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SubModule is a reference on git repository
|
||||
type SubModule struct {
|
||||
Path string
|
||||
URL string
|
||||
Branch string // this field is newly added but not really used
|
||||
}
|
||||
|
||||
// configParseSubModules this is not a complete parse for gitmodules file, it only
|
||||
// parses the url and path of submodules. At the moment it only parses well-formed gitmodules files.
|
||||
// In the future, there should be a complete implementation of https://git-scm.com/docs/git-config#_syntax
|
||||
func configParseSubModules(r io.Reader) (*ObjectCache[*SubModule], error) {
|
||||
var subModule *SubModule
|
||||
subModules := newObjectCache[*SubModule]()
|
||||
scanner := bufio.NewScanner(r)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
|
||||
// Skip empty lines and comments
|
||||
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Section header [section]
|
||||
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
||||
if subModule != nil {
|
||||
subModules.Set(subModule.Path, subModule)
|
||||
}
|
||||
if strings.HasPrefix(line, "[submodule") {
|
||||
subModule = &SubModule{}
|
||||
} else {
|
||||
subModule = nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if subModule == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
switch key {
|
||||
case "path":
|
||||
subModule.Path = value
|
||||
case "url":
|
||||
subModule.URL = value
|
||||
case "branch":
|
||||
subModule.Branch = value
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error reading file: %w", err)
|
||||
}
|
||||
if subModule != nil {
|
||||
subModules.Set(subModule.Path, subModule)
|
||||
}
|
||||
return subModules, nil
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestConfigSubmodule(t *testing.T) {
|
||||
input := `
|
||||
[core]
|
||||
path = test
|
||||
|
||||
[submodule "submodule1"]
|
||||
path = path1
|
||||
url = https://gitea.io/foo/foo
|
||||
#branch = b1
|
||||
|
||||
[other1]
|
||||
branch = master
|
||||
|
||||
[submodule "submodule2"]
|
||||
path = path2
|
||||
url = https://gitea.io/bar/bar
|
||||
branch = b2
|
||||
|
||||
[other2]
|
||||
branch = main
|
||||
|
||||
[submodule "submodule3"]
|
||||
path = path3
|
||||
url = https://gitea.io/xxx/xxx
|
||||
`
|
||||
|
||||
subModules, err := configParseSubModules(strings.NewReader(input))
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, subModules.cache, 3)
|
||||
|
||||
sm1, _ := subModules.Get("path1")
|
||||
assert.Equal(t, &SubModule{Path: "path1", URL: "https://gitea.io/foo/foo", Branch: ""}, sm1)
|
||||
sm2, _ := subModules.Get("path2")
|
||||
assert.Equal(t, &SubModule{Path: "path2", URL: "https://gitea.io/bar/bar", Branch: "b2"}, sm2)
|
||||
sm3, _ := subModules.Get("path3")
|
||||
assert.Equal(t, &SubModule{Path: "path3", URL: "https://gitea.io/xxx/xxx", Branch: ""}, sm3)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func gitConfigContains(sub string) bool {
|
||||
if b, err := os.ReadFile(gitcmd.HomeDir() + "/.gitconfig"); err == nil {
|
||||
return strings.Contains(string(b), sub)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestGitConfig(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
assert.False(t, gitConfigContains("key-a"))
|
||||
|
||||
assert.NoError(t, configSetNonExist(ctx, "test.key-a", "val-a"))
|
||||
assert.True(t, gitConfigContains("key-a = val-a"))
|
||||
|
||||
assert.NoError(t, configSetNonExist(ctx, "test.key-a", "val-a-changed"))
|
||||
assert.False(t, gitConfigContains("key-a = val-a-changed"))
|
||||
|
||||
assert.NoError(t, configSet(ctx, "test.key-a", "val-a-changed"))
|
||||
assert.True(t, gitConfigContains("key-a = val-a-changed"))
|
||||
|
||||
assert.NoError(t, configAddNonExist(ctx, "test.key-b", "val-b"))
|
||||
assert.True(t, gitConfigContains("key-b = val-b"))
|
||||
|
||||
assert.NoError(t, configAddNonExist(ctx, "test.key-b", "val-2b"))
|
||||
assert.True(t, gitConfigContains("key-b = val-b"))
|
||||
assert.True(t, gitConfigContains("key-b = val-2b"))
|
||||
|
||||
assert.NoError(t, configUnsetAll(ctx, "test.key-b", "val-b"))
|
||||
assert.False(t, gitConfigContains("key-b = val-b"))
|
||||
assert.True(t, gitConfigContains("key-b = val-2b"))
|
||||
|
||||
assert.NoError(t, configUnsetAll(ctx, "test.key-b", "val-2b"))
|
||||
assert.False(t, gitConfigContains("key-b = val-2b"))
|
||||
|
||||
assert.NoError(t, configSet(ctx, "test.key-x", "*"))
|
||||
assert.True(t, gitConfigContains("key-x = *"))
|
||||
assert.NoError(t, configSetNonExist(ctx, "test.key-x", "*"))
|
||||
assert.NoError(t, configUnsetAll(ctx, "test.key-x", "*"))
|
||||
assert.False(t, gitConfigContains("key-x = *"))
|
||||
}
|
||||
|
||||
func TestSyncConfig(t *testing.T) {
|
||||
oldGitConfig := setting.GitConfig
|
||||
defer func() {
|
||||
setting.GitConfig = oldGitConfig
|
||||
}()
|
||||
|
||||
setting.GitConfig.Options["sync-test.cfg-key-a"] = "CfgValA"
|
||||
assert.NoError(t, syncGitConfig(t.Context()))
|
||||
assert.True(t, gitConfigContains("[sync-test]"))
|
||||
assert.True(t, gitConfigContains("cfg-key-a = CfgValA"))
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
// RawDiffType output format: diff or patch
|
||||
type RawDiffType string
|
||||
|
||||
const (
|
||||
RawDiffNormal RawDiffType = "diff"
|
||||
RawDiffPatch RawDiffType = "patch"
|
||||
)
|
||||
|
||||
// GetRawDiff dumps diff results of repository in given commit ID to io.Writer.
|
||||
func GetRawDiff(repo *Repository, commitID string, diffType RawDiffType, writer io.Writer) (retErr error) {
|
||||
cmd, err := getRepoRawDiffForFileCmd(repo.Ctx, repo, "", commitID, diffType, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("getRepoRawDiffForFileCmd: %w", err)
|
||||
}
|
||||
return cmd.WithStdoutCopy(writer).RunWithStderr(repo.Ctx)
|
||||
}
|
||||
|
||||
// GetFileDiffCutAroundLine cuts the old or new part of the diff of a file around a specific line number
|
||||
func GetFileDiffCutAroundLine(
|
||||
repo *Repository, startCommit, endCommit, treePath string,
|
||||
line int64, old bool, numbersOfLine int,
|
||||
) (ret string, retErr error) {
|
||||
cmd, err := getRepoRawDiffForFileCmd(repo.Ctx, repo, startCommit, endCommit, RawDiffNormal, treePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("getRepoRawDiffForFileCmd: %w", err)
|
||||
}
|
||||
stdoutReader, stdoutClose := cmd.MakeStdoutPipe()
|
||||
defer stdoutClose()
|
||||
cmd.WithPipelineFunc(func(ctx gitcmd.Context) error {
|
||||
ret, err = CutDiffAroundLine(stdoutReader, line, old, numbersOfLine)
|
||||
return err
|
||||
})
|
||||
return ret, cmd.RunWithStderr(repo.Ctx)
|
||||
}
|
||||
|
||||
// getRepoRawDiffForFile returns an io.Reader for the diff results of file in given commit ID
|
||||
// and a "finish" function to wait for the git command and clean up resources after reading is done.
|
||||
func getRepoRawDiffForFileCmd(_ context.Context, repo *Repository, startCommit, endCommit string, diffType RawDiffType, file string) (*gitcmd.Command, error) {
|
||||
commit, err := repo.GetCommit(endCommit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var files []string
|
||||
if len(file) > 0 {
|
||||
files = append(files, file)
|
||||
}
|
||||
|
||||
cmd := gitcmd.NewCommand().WithDir(repo.Path)
|
||||
switch diffType {
|
||||
case RawDiffNormal:
|
||||
if len(startCommit) != 0 {
|
||||
cmd.AddArguments("diff").
|
||||
AddOptionFormat("--find-renames=%s", setting.Git.DiffRenameSimilarityThreshold).
|
||||
AddDynamicArguments(startCommit, endCommit).AddDashesAndList(files...)
|
||||
} else if commit.ParentCount() == 0 {
|
||||
cmd.AddArguments("show").AddDynamicArguments(endCommit).AddDashesAndList(files...)
|
||||
} else {
|
||||
c, err := commit.Parent(0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cmd.AddArguments("diff").
|
||||
AddOptionFormat("--find-renames=%s", setting.Git.DiffRenameSimilarityThreshold).
|
||||
AddDynamicArguments(c.ID.String(), endCommit).AddDashesAndList(files...)
|
||||
}
|
||||
case RawDiffPatch:
|
||||
if len(startCommit) != 0 {
|
||||
query := fmt.Sprintf("%s...%s", endCommit, startCommit)
|
||||
cmd.AddArguments("format-patch", "--no-signature", "--stdout", "--root").AddDynamicArguments(query).AddDashesAndList(files...)
|
||||
} else if commit.ParentCount() == 0 {
|
||||
cmd.AddArguments("format-patch", "--no-signature", "--stdout", "--root").AddDynamicArguments(endCommit).AddDashesAndList(files...)
|
||||
} else {
|
||||
c, err := commit.Parent(0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query := fmt.Sprintf("%s...%s", endCommit, c.ID.String())
|
||||
cmd.AddArguments("format-patch", "--no-signature", "--stdout").AddDynamicArguments(query).AddDashesAndList(files...)
|
||||
}
|
||||
default:
|
||||
return nil, util.NewInvalidArgumentErrorf("invalid diff type: %s", diffType)
|
||||
}
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
// ParseDiffHunkString parse the diff hunk content and return
|
||||
func ParseDiffHunkString(diffHunk string) (leftLine, leftHunk, rightLine, rightHunk int) {
|
||||
ss := strings.Split(diffHunk, "@@")
|
||||
ranges := strings.Split(ss[1][1:], " ")
|
||||
leftRange := strings.Split(ranges[0], ",")
|
||||
leftLine, _ = strconv.Atoi(leftRange[0][1:])
|
||||
if len(leftRange) > 1 {
|
||||
leftHunk, _ = strconv.Atoi(leftRange[1])
|
||||
}
|
||||
if len(ranges) > 1 {
|
||||
rightRange := strings.Split(ranges[1], ",")
|
||||
rightLine, _ = strconv.Atoi(rightRange[0])
|
||||
if len(rightRange) > 1 {
|
||||
rightHunk, _ = strconv.Atoi(rightRange[1])
|
||||
}
|
||||
} else {
|
||||
log.Debug("Parse line number failed: %v", diffHunk)
|
||||
rightLine = leftLine
|
||||
rightHunk = leftHunk
|
||||
}
|
||||
if rightLine == 0 {
|
||||
// FIXME: GIT-DIFF-CUT-BUG search this tag to see details
|
||||
// this is only a hacky patch, the rightLine&rightHunk might still be incorrect in some cases.
|
||||
rightLine++
|
||||
}
|
||||
return leftLine, leftHunk, rightLine, rightHunk
|
||||
}
|
||||
|
||||
// Example: @@ -1,8 +1,9 @@ => [..., 1, 8, 1, 9]
|
||||
var hunkRegex = regexp.MustCompile(`^@@ -(?P<beginOld>[0-9]+)(,(?P<endOld>[0-9]+))? \+(?P<beginNew>[0-9]+)(,(?P<endNew>[0-9]+))? @@`)
|
||||
|
||||
const cmdDiffHead = "diff --git "
|
||||
|
||||
func isHeader(lof string, inHunk bool) bool {
|
||||
return strings.HasPrefix(lof, cmdDiffHead) || (!inHunk && (strings.HasPrefix(lof, "---") || strings.HasPrefix(lof, "+++")))
|
||||
}
|
||||
|
||||
// CutDiffAroundLine cuts a diff of a file in way that only the given line + numberOfLine above it will be shown
|
||||
// it also recalculates hunks and adds the appropriate headers to the new diff.
|
||||
// Warning: Only one-file diffs are allowed.
|
||||
func CutDiffAroundLine(originalDiff io.Reader, line int64, old bool, numbersOfLine int) (string, error) {
|
||||
if line == 0 || numbersOfLine == 0 {
|
||||
// no line or num of lines => no diff
|
||||
return "", nil
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(originalDiff)
|
||||
hunk := make([]string, 0)
|
||||
|
||||
// begin is the start of the hunk containing searched line
|
||||
// end is the end of the hunk ...
|
||||
// currentLine is the line number on the side of the searched line (differentiated by old)
|
||||
// otherLine is the line number on the opposite side of the searched line (differentiated by old)
|
||||
var begin, end, currentLine, otherLine int64
|
||||
var headerLines int
|
||||
|
||||
inHunk := false
|
||||
|
||||
for scanner.Scan() {
|
||||
lof := scanner.Text()
|
||||
// Add header to enable parsing
|
||||
|
||||
if isHeader(lof, inHunk) {
|
||||
if strings.HasPrefix(lof, cmdDiffHead) {
|
||||
inHunk = false
|
||||
}
|
||||
hunk = append(hunk, lof)
|
||||
headerLines++
|
||||
}
|
||||
if currentLine > line {
|
||||
break
|
||||
}
|
||||
// Detect "hunk" with contains commented lof
|
||||
if strings.HasPrefix(lof, "@@") {
|
||||
inHunk = true
|
||||
// Already got our hunk. End of hunk detected!
|
||||
if len(hunk) > headerLines {
|
||||
break
|
||||
}
|
||||
// A map with named groups of our regex to recognize them later more easily
|
||||
submatches := hunkRegex.FindStringSubmatch(lof)
|
||||
groups := make(map[string]string)
|
||||
for i, name := range hunkRegex.SubexpNames() {
|
||||
if i != 0 && name != "" {
|
||||
groups[name] = submatches[i]
|
||||
}
|
||||
}
|
||||
if old {
|
||||
begin, _ = strconv.ParseInt(groups["beginOld"], 10, 64)
|
||||
end, _ = strconv.ParseInt(groups["endOld"], 10, 64)
|
||||
// init otherLine with begin of opposite side
|
||||
otherLine, _ = strconv.ParseInt(groups["beginNew"], 10, 64)
|
||||
} else {
|
||||
begin, _ = strconv.ParseInt(groups["beginNew"], 10, 64)
|
||||
if groups["endNew"] != "" {
|
||||
end, _ = strconv.ParseInt(groups["endNew"], 10, 64)
|
||||
} else {
|
||||
end = 0
|
||||
}
|
||||
// init otherLine with begin of opposite side
|
||||
otherLine, _ = strconv.ParseInt(groups["beginOld"], 10, 64)
|
||||
}
|
||||
end += begin // end is for real only the number of lines in hunk
|
||||
// lof is between begin and end
|
||||
if begin <= line && end >= line {
|
||||
hunk = append(hunk, lof)
|
||||
currentLine = begin
|
||||
continue
|
||||
}
|
||||
} else if len(hunk) > headerLines {
|
||||
hunk = append(hunk, lof)
|
||||
// Count lines in context
|
||||
switch lof[0] {
|
||||
case '+':
|
||||
if !old {
|
||||
currentLine++
|
||||
} else {
|
||||
otherLine++
|
||||
}
|
||||
case '-':
|
||||
if old {
|
||||
currentLine++
|
||||
} else {
|
||||
otherLine++
|
||||
}
|
||||
case '\\':
|
||||
// FIXME: handle `\ No newline at end of file`
|
||||
default:
|
||||
currentLine++
|
||||
otherLine++
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return "", fmt.Errorf("CutDiffAroundLine: scan: %w", err)
|
||||
}
|
||||
|
||||
// No hunk found
|
||||
if currentLine == 0 {
|
||||
return "", nil
|
||||
}
|
||||
// headerLines + hunkLine (1) = totalNonCodeLines
|
||||
if len(hunk)-headerLines-1 <= numbersOfLine {
|
||||
// No need to cut the hunk => return existing hunk
|
||||
return strings.Join(hunk, "\n"), nil
|
||||
}
|
||||
var oldBegin, oldNumOfLines, newBegin, newNumOfLines int64
|
||||
if old {
|
||||
oldBegin = currentLine
|
||||
newBegin = otherLine
|
||||
} else {
|
||||
oldBegin = otherLine
|
||||
newBegin = currentLine
|
||||
}
|
||||
// headers + hunk header
|
||||
newHunk := make([]string, headerLines)
|
||||
// transfer existing headers
|
||||
copy(newHunk, hunk[:headerLines])
|
||||
// transfer last n lines
|
||||
newHunk = append(newHunk, hunk[len(hunk)-numbersOfLine-1:]...)
|
||||
// calculate newBegin, ... by counting lines
|
||||
for i := len(hunk) - 1; i >= len(hunk)-numbersOfLine; i-- {
|
||||
switch hunk[i][0] {
|
||||
case '+':
|
||||
newBegin--
|
||||
newNumOfLines++
|
||||
case '-':
|
||||
oldBegin--
|
||||
oldNumOfLines++
|
||||
default:
|
||||
oldBegin--
|
||||
newBegin--
|
||||
newNumOfLines++
|
||||
oldNumOfLines++
|
||||
}
|
||||
}
|
||||
|
||||
// "git diff" outputs "@@ -1 +1,3 @@" for "OLD" => "A\nB\nC"
|
||||
// FIXME: GIT-DIFF-CUT-BUG But there is a bug in CutDiffAroundLine, then the "Patch" stored in the comment model becomes "@@ -1,1 +0,4 @@"
|
||||
// It may generate incorrect results for difference cases, for example: delete 2 line add 1 line, delete 2 line add 2 line etc, need to double check.
|
||||
// For example: "L1\nL2" => "A\nB", then the patch shows "L2" as line 1 on the left (deleted part)
|
||||
|
||||
// construct the new hunk header
|
||||
newHunk[headerLines] = fmt.Sprintf("@@ -%d,%d +%d,%d @@",
|
||||
oldBegin, oldNumOfLines, newBegin, newNumOfLines)
|
||||
return strings.Join(newHunk, "\n"), nil
|
||||
}
|
||||
|
||||
// GetAffectedFiles returns the affected files between two commits
|
||||
func GetAffectedFiles(repo *Repository, branchName, oldCommitID, newCommitID string, env []string) ([]string, error) {
|
||||
if oldCommitID == emptySha1ObjectID.String() || oldCommitID == emptySha256ObjectID.String() {
|
||||
startCommitID, err := repo.GetCommitBranchStart(env, branchName, newCommitID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if startCommitID == "" {
|
||||
return nil, fmt.Errorf("cannot find the start commit of %s", newCommitID)
|
||||
}
|
||||
oldCommitID = startCommitID
|
||||
}
|
||||
|
||||
affectedFiles := make([]string, 0, 32)
|
||||
|
||||
// Run `git diff --name-only` to get the names of the changed files
|
||||
cmd := gitcmd.NewCommand("diff", "--name-only").AddDynamicArguments(oldCommitID, newCommitID)
|
||||
stdoutReader, stdoutReaderClose := cmd.MakeStdoutPipe()
|
||||
defer stdoutReaderClose()
|
||||
err := cmd.WithEnv(env).WithDir(repo.Path).
|
||||
WithPipelineFunc(func(ctx gitcmd.Context) error {
|
||||
// Now scan the output from the command
|
||||
scanner := bufio.NewScanner(stdoutReader)
|
||||
for scanner.Scan() {
|
||||
path := strings.TrimSpace(scanner.Text())
|
||||
if len(path) == 0 {
|
||||
continue
|
||||
}
|
||||
affectedFiles = append(affectedFiles, path)
|
||||
}
|
||||
return scanner.Err()
|
||||
}).
|
||||
Run(repo.Ctx)
|
||||
if err != nil {
|
||||
log.Error("Unable to get affected files for commits from %s to %s in %s: %v", oldCommitID, newCommitID, repo.Path, err)
|
||||
}
|
||||
|
||||
return affectedFiles, err
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
const exampleDiff = `diff --git a/README.md b/README.md
|
||||
--- a/README.md
|
||||
+++ b/README.md
|
||||
@@ -1,3 +1,6 @@
|
||||
# gitea-github-migrator
|
||||
+
|
||||
+ Build Status
|
||||
- Latest Release
|
||||
Docker Pulls
|
||||
+ cut off
|
||||
+ cut off`
|
||||
|
||||
const breakingDiff = `diff --git a/aaa.sql b/aaa.sql
|
||||
index d8e4c92..19dc8ad 100644
|
||||
--- a/aaa.sql
|
||||
+++ b/aaa.sql
|
||||
@@ -1,9 +1,10 @@
|
||||
--some comment
|
||||
--- some comment 5
|
||||
+--some coment 2
|
||||
+-- some comment 3
|
||||
create or replace procedure test(p1 varchar2)
|
||||
is
|
||||
begin
|
||||
---new comment
|
||||
dbms_output.put_line(p1);
|
||||
+--some other comment
|
||||
end;
|
||||
/
|
||||
`
|
||||
|
||||
var issue17875Diff = `diff --git a/Geschäftsordnung.md b/Geschäftsordnung.md
|
||||
index d46c152..a7d2d55 100644
|
||||
--- a/Geschäftsordnung.md
|
||||
+++ b/Geschäftsordnung.md
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
-date: "23.01.2021"
|
||||
+date: "30.11.2021"
|
||||
...
|
||||
` + `
|
||||
# Geschäftsordnung
|
||||
@@ -16,4 +16,22 @@ Diese Geschäftsordnung regelt alle Prozesse des Vereins, solange diese nicht du
|
||||
` + `
|
||||
## § 3 Datenschutzverantwortlichkeit
|
||||
` + `
|
||||
-1. Der Verein bestellt eine datenschutzverantwortliche Person mit den Aufgaben nach Artikel 39 DSGVO.
|
||||
\ No newline at end of file
|
||||
+1. Der Verein bestellt eine datenschutzverantwortliche Person mit den Aufgaben nach Artikel 39 DSGVO.
|
||||
+
|
||||
+## §4 Umgang mit der SARS-Cov-2-Pandemie
|
||||
+
|
||||
+1. Der Vorstand hat die Befugnis, in Rücksprache mit den Vereinsmitgliedern, verschiedene Hygienemaßnahmen für Präsenzveranstaltungen zu beschließen.
|
||||
+
|
||||
+2. Die Einführung, Änderung und Abschaffung dieser Maßnahmen sind nur zum Zweck der Eindämmung der SARS-Cov-2-Pandemie zulässig.
|
||||
+
|
||||
+3. Die Einführung, Änderung und Abschaffung von Maßnahmen nach Abs. 2 bedarf einer wissenschaftlichen Grundlage.
|
||||
+
|
||||
+4. Die Maßnahmen nach Abs. 2 setzen sich aus den folgenden Bausteinen inklusive einer ihrer Ausprägungen zusammen.
|
||||
+
|
||||
+ 1. Maskenpflicht: Keine; Maskenpflicht, außer am Platz, oder wo Abstände nicht eingehalten werden können; Maskenpflicht, wenn Abstände nicht eingehalten werden können; Maskenpflicht
|
||||
+
|
||||
+ 2. Geimpft-, Genesen- oder Testnachweis: Kein Nachweis notwendig; Nachweis, dass Person geimpft, genesen oder tagesaktuell getestet ist (3G); Nachweis, dass Person geimpft oder genesen ist (2G); Nachweis, dass Person geimpft bzw. genesen und tagesaktuell getestet ist (2G+)
|
||||
+
|
||||
+ 3. Online-Veranstaltung: Keine, parallele Online-Veranstaltung, ausschließlich Online-Veranstaltung
|
||||
+
|
||||
+5. Bei Präsenzveranstungen gelten außerdem die Hygienevorschriften des Veranstaltungsorts. Bei Regelkollision greift die restriktivere Regel.
|
||||
\ No newline at end of file`
|
||||
|
||||
func TestCutDiffAroundLineIssue17875(t *testing.T) {
|
||||
result, err := CutDiffAroundLine(strings.NewReader(issue17875Diff), 23, false, 3)
|
||||
assert.NoError(t, err)
|
||||
expected := `diff --git a/Geschäftsordnung.md b/Geschäftsordnung.md
|
||||
--- a/Geschäftsordnung.md
|
||||
+++ b/Geschäftsordnung.md
|
||||
@@ -20,0 +21,3 @@
|
||||
+## §4 Umgang mit der SARS-Cov-2-Pandemie
|
||||
+
|
||||
+1. Der Vorstand hat die Befugnis, in Rücksprache mit den Vereinsmitgliedern, verschiedene Hygienemaßnahmen für Präsenzveranstaltungen zu beschließen.`
|
||||
assert.Equal(t, expected, result)
|
||||
}
|
||||
|
||||
func TestCutDiffAroundLine(t *testing.T) {
|
||||
result, err := CutDiffAroundLine(strings.NewReader(exampleDiff), 4, false, 3)
|
||||
assert.NoError(t, err)
|
||||
resultByLine := strings.Split(result, "\n")
|
||||
assert.Len(t, resultByLine, 7)
|
||||
// Check if headers got transferred
|
||||
assert.Equal(t, "diff --git a/README.md b/README.md", resultByLine[0])
|
||||
assert.Equal(t, "--- a/README.md", resultByLine[1])
|
||||
assert.Equal(t, "+++ b/README.md", resultByLine[2])
|
||||
// Check if hunk header is calculated correctly
|
||||
assert.Equal(t, "@@ -2,2 +3,2 @@", resultByLine[3])
|
||||
// Check if line got transferred
|
||||
assert.Equal(t, "+ Build Status", resultByLine[4])
|
||||
|
||||
// Must be same result as before since old line 3 == new line 5
|
||||
newResult, err := CutDiffAroundLine(strings.NewReader(exampleDiff), 3, true, 3)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, result, newResult, "Must be same result as before since old line 3 == new line 5")
|
||||
|
||||
newResult, err = CutDiffAroundLine(strings.NewReader(exampleDiff), 6, false, 300)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, exampleDiff, newResult)
|
||||
|
||||
emptyResult, err := CutDiffAroundLine(strings.NewReader(exampleDiff), 6, false, 0)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, emptyResult)
|
||||
|
||||
// Line is out of scope
|
||||
emptyResult, err = CutDiffAroundLine(strings.NewReader(exampleDiff), 434, false, 0)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, emptyResult)
|
||||
|
||||
// Handle minus diffs properly
|
||||
minusDiff, err := CutDiffAroundLine(strings.NewReader(breakingDiff), 2, false, 4)
|
||||
assert.NoError(t, err)
|
||||
|
||||
expected := `diff --git a/aaa.sql b/aaa.sql
|
||||
--- a/aaa.sql
|
||||
+++ b/aaa.sql
|
||||
@@ -1,9 +1,10 @@
|
||||
--some comment
|
||||
--- some comment 5
|
||||
+--some coment 2`
|
||||
assert.Equal(t, expected, minusDiff)
|
||||
|
||||
// Handle minus diffs properly
|
||||
minusDiff, err = CutDiffAroundLine(strings.NewReader(breakingDiff), 3, false, 4)
|
||||
assert.NoError(t, err)
|
||||
|
||||
expected = `diff --git a/aaa.sql b/aaa.sql
|
||||
--- a/aaa.sql
|
||||
+++ b/aaa.sql
|
||||
@@ -1,9 +1,10 @@
|
||||
--some comment
|
||||
--- some comment 5
|
||||
+--some coment 2
|
||||
+-- some comment 3`
|
||||
|
||||
assert.Equal(t, expected, minusDiff)
|
||||
}
|
||||
|
||||
func BenchmarkCutDiffAroundLine(b *testing.B) {
|
||||
for b.Loop() {
|
||||
CutDiffAroundLine(strings.NewReader(exampleDiff), 3, true, 3)
|
||||
}
|
||||
}
|
||||
|
||||
func ExampleCutDiffAroundLine() {
|
||||
const diff = `diff --git a/README.md b/README.md
|
||||
--- a/README.md
|
||||
+++ b/README.md
|
||||
@@ -1,3 +1,6 @@
|
||||
# gitea-github-migrator
|
||||
+
|
||||
+ Build Status
|
||||
- Latest Release
|
||||
Docker Pulls
|
||||
+ cut off
|
||||
+ cut off`
|
||||
result, _ := CutDiffAroundLine(strings.NewReader(diff), 4, false, 3)
|
||||
println(result)
|
||||
}
|
||||
|
||||
func TestParseDiffHunkString(t *testing.T) {
|
||||
leftLine, leftHunk, rightLine, rightHunk := ParseDiffHunkString("@@ -19,3 +19,5 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER")
|
||||
assert.Equal(t, 19, leftLine)
|
||||
assert.Equal(t, 3, leftHunk)
|
||||
assert.Equal(t, 19, rightLine)
|
||||
assert.Equal(t, 5, rightHunk)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// Copyright 2015 The Gogs Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
// ErrNotExist commit not exist error
|
||||
type ErrNotExist struct {
|
||||
ID string
|
||||
RelPath string
|
||||
}
|
||||
|
||||
// IsErrNotExist if some error is ErrNotExist
|
||||
func IsErrNotExist(err error) bool {
|
||||
_, ok := err.(ErrNotExist)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrNotExist) Error() string {
|
||||
return fmt.Sprintf("object does not exist [id: %s, rel_path: %s]", err.ID, err.RelPath)
|
||||
}
|
||||
|
||||
func (err ErrNotExist) Unwrap() error {
|
||||
return util.ErrNotExist
|
||||
}
|
||||
|
||||
// ErrBranchNotExist represents a "BranchNotExist" kind of error.
|
||||
type ErrBranchNotExist struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
// IsErrBranchNotExist checks if an error is a ErrBranchNotExist.
|
||||
func IsErrBranchNotExist(err error) bool {
|
||||
_, ok := err.(ErrBranchNotExist)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrBranchNotExist) Error() string {
|
||||
return fmt.Sprintf("branch does not exist [name: %s]", err.Name)
|
||||
}
|
||||
|
||||
func (err ErrBranchNotExist) Unwrap() error {
|
||||
return util.ErrNotExist
|
||||
}
|
||||
|
||||
// ErrPushOutOfDate represents an error if merging fails due to the base branch being updated
|
||||
type ErrPushOutOfDate struct {
|
||||
StdOut string
|
||||
StdErr string
|
||||
Err error
|
||||
}
|
||||
|
||||
// IsErrPushOutOfDate checks if an error is a ErrPushOutOfDate.
|
||||
func IsErrPushOutOfDate(err error) bool {
|
||||
_, ok := err.(*ErrPushOutOfDate)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err *ErrPushOutOfDate) Error() string {
|
||||
return fmt.Sprintf("PushOutOfDate Error: %v: %s\n%s", err.Err, err.StdErr, err.StdOut)
|
||||
}
|
||||
|
||||
// Unwrap unwraps the underlying error
|
||||
func (err *ErrPushOutOfDate) Unwrap() error {
|
||||
return fmt.Errorf("%w - %s", err.Err, err.StdErr)
|
||||
}
|
||||
|
||||
// ErrPushRejected represents an error if merging fails due to rejection from a hook
|
||||
type ErrPushRejected struct {
|
||||
Message string
|
||||
StdOut string
|
||||
StdErr string
|
||||
Err error
|
||||
}
|
||||
|
||||
// IsErrPushRejected checks if an error is a ErrPushRejected.
|
||||
func IsErrPushRejected(err error) bool {
|
||||
_, ok := err.(*ErrPushRejected)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err *ErrPushRejected) Error() string {
|
||||
return fmt.Sprintf("PushRejected Error: %v: %s\n%s", err.Err, err.StdErr, err.StdOut)
|
||||
}
|
||||
|
||||
// Unwrap unwraps the underlying error
|
||||
func (err *ErrPushRejected) Unwrap() error {
|
||||
return fmt.Errorf("%w - %s", err.Err, err.StdErr)
|
||||
}
|
||||
|
||||
// GenerateMessage generates the remote message from the stderr
|
||||
func (err *ErrPushRejected) GenerateMessage() {
|
||||
// The stderr is like this:
|
||||
//
|
||||
// > remote: error: push is rejected .....
|
||||
// > To /work/gitea/tests/integration/gitea-integration-sqlite/gitea-repositories/user2/repo1.git
|
||||
// > ! [remote rejected] 44e67c77559211d21b630b902cdcc6ab9d4a4f51 -> develop (pre-receive hook declined)
|
||||
// > error: failed to push some refs to '/work/gitea/tests/integration/gitea-integration-sqlite/gitea-repositories/user2/repo1.git'
|
||||
//
|
||||
// The local message contains sensitive information, so we only need the remote message
|
||||
const prefixRemote = "remote: "
|
||||
const prefixError = "error: "
|
||||
pos := strings.Index(err.StdErr, prefixRemote)
|
||||
if pos < 0 {
|
||||
err.Message = "push is rejected"
|
||||
return
|
||||
}
|
||||
|
||||
messageBuilder := &strings.Builder{}
|
||||
lines := strings.SplitSeq(err.StdErr, "\n")
|
||||
for line := range lines {
|
||||
line, ok := strings.CutPrefix(line, prefixRemote)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
line = strings.TrimPrefix(line, prefixError)
|
||||
messageBuilder.WriteString(strings.TrimSpace(line) + "\n")
|
||||
}
|
||||
err.Message = strings.TrimSpace(messageBuilder.String())
|
||||
}
|
||||
|
||||
// ErrMoreThanOne represents an error if pull request fails when there are more than one sources (branch, tag) with the same name
|
||||
type ErrMoreThanOne struct {
|
||||
StdOut string
|
||||
StdErr string
|
||||
Err error
|
||||
}
|
||||
|
||||
// IsErrMoreThanOne checks if an error is a ErrMoreThanOne
|
||||
func IsErrMoreThanOne(err error) bool {
|
||||
_, ok := err.(*ErrMoreThanOne)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err *ErrMoreThanOne) Error() string {
|
||||
return fmt.Sprintf("ErrMoreThanOne Error: %v: %s\n%s", err.Err, err.StdErr, err.StdOut)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package foreachref
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
nullChar = []byte("\x00")
|
||||
dualNullChar = []byte("\x00\x00")
|
||||
)
|
||||
|
||||
// Format supports specifying and parsing an output format for 'git
|
||||
// for-each-ref'. See See git-for-each-ref(1) for available fields.
|
||||
type Format struct {
|
||||
// fieldNames hold %(fieldname)s to be passed to the '--format' flag of
|
||||
// for-each-ref. See git-for-each-ref(1) for available fields.
|
||||
fieldNames []string
|
||||
|
||||
// fieldDelim is the character sequence that is used to separate fields
|
||||
// for each reference. fieldDelim and refDelim should be selected to not
|
||||
// interfere with each other and to not be present in field values.
|
||||
fieldDelim []byte
|
||||
// fieldDelimStr is a string representation of fieldDelim. Used to save
|
||||
// us from repetitive reallocation whenever we need the delimiter as a
|
||||
// string.
|
||||
fieldDelimStr string
|
||||
// refDelim is the character sequence used to separate reference from
|
||||
// each other in the output. fieldDelim and refDelim should be selected
|
||||
// to not interfere with each other and to not be present in field
|
||||
// values.
|
||||
refDelim []byte
|
||||
}
|
||||
|
||||
// NewFormat creates a forEachRefFormat using the specified fieldNames. See
|
||||
// git-for-each-ref(1) for available fields.
|
||||
func NewFormat(fieldNames ...string) Format {
|
||||
return Format{
|
||||
fieldNames: fieldNames,
|
||||
fieldDelim: nullChar,
|
||||
fieldDelimStr: string(nullChar),
|
||||
refDelim: dualNullChar,
|
||||
}
|
||||
}
|
||||
|
||||
// Flag returns a for-each-ref --format flag value that captures the fieldNames.
|
||||
func (f Format) Flag() string {
|
||||
var formatFlag strings.Builder
|
||||
for i, field := range f.fieldNames {
|
||||
// field key and field value
|
||||
fmt.Fprintf(&formatFlag, "%s %%(%s)", field, field)
|
||||
|
||||
if i < len(f.fieldNames)-1 {
|
||||
// note: escape delimiters to allow control characters as
|
||||
// delimiters. For example, '%00' for null character or '%0a'
|
||||
// for newline.
|
||||
formatFlag.WriteString(f.hexEscaped(f.fieldDelim))
|
||||
}
|
||||
}
|
||||
formatFlag.WriteString(f.hexEscaped(f.refDelim))
|
||||
return formatFlag.String()
|
||||
}
|
||||
|
||||
// Parser returns a Parser capable of parsing 'git for-each-ref' output produced
|
||||
// with this Format.
|
||||
func (f Format) Parser(r io.Reader) *Parser {
|
||||
return NewParser(r, f)
|
||||
}
|
||||
|
||||
// hexEscaped produces hex-escaped characters from a string. For example, "\n\0"
|
||||
// would turn into "%0a%00".
|
||||
func (f Format) hexEscaped(delim []byte) string {
|
||||
var escaped strings.Builder
|
||||
for i := range delim {
|
||||
escaped.WriteString("%" + hex.EncodeToString([]byte{delim[i]}))
|
||||
}
|
||||
return escaped.String()
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package foreachref_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/git/foreachref"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFormat_Flag(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
givenFormat foreachref.Format
|
||||
|
||||
wantFlag string
|
||||
}{
|
||||
{
|
||||
name: "references are delimited by dual null chars",
|
||||
|
||||
// no reference fields requested
|
||||
givenFormat: foreachref.NewFormat(),
|
||||
|
||||
// only a reference delimiter field in --format
|
||||
wantFlag: "%00%00",
|
||||
},
|
||||
|
||||
{
|
||||
name: "a field is a space-separated key-value pair",
|
||||
|
||||
givenFormat: foreachref.NewFormat("refname:short"),
|
||||
|
||||
// only a reference delimiter field
|
||||
wantFlag: "refname:short %(refname:short)%00%00",
|
||||
},
|
||||
|
||||
{
|
||||
name: "fields are separated by a null char field-delimiter",
|
||||
|
||||
givenFormat: foreachref.NewFormat("refname:short", "author"),
|
||||
|
||||
wantFlag: "refname:short %(refname:short)%00author %(author)%00%00",
|
||||
},
|
||||
|
||||
{
|
||||
name: "multiple fields",
|
||||
|
||||
givenFormat: foreachref.NewFormat("refname:lstrip=2", "objecttype", "objectname"),
|
||||
|
||||
wantFlag: "refname:lstrip=2 %(refname:lstrip=2)%00objecttype %(objecttype)%00objectname %(objectname)%00%00",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
tc := test // don't close over loop variable
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
gotFlag := tc.givenFormat.Flag()
|
||||
|
||||
require.Equal(t, tc.wantFlag, gotFlag, "unexpected for-each-ref --format string. wanted: '%s', got: '%s'", tc.wantFlag, gotFlag)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package foreachref
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Parser parses 'git for-each-ref' output according to a given output Format.
|
||||
type Parser struct {
|
||||
// tokenizes 'git for-each-ref' output into "reference paragraphs".
|
||||
scanner *bufio.Scanner
|
||||
|
||||
// format represents the '--format' string that describes the expected
|
||||
// 'git for-each-ref' output structure.
|
||||
format Format
|
||||
|
||||
// err holds the last encountered error during parsing.
|
||||
err error
|
||||
}
|
||||
|
||||
// NewParser creates a 'git for-each-ref' output parser that will parse all
|
||||
// references in the provided Reader. The references in the output are assumed
|
||||
// to follow the specified Format.
|
||||
func NewParser(r io.Reader, format Format) *Parser {
|
||||
scanner := bufio.NewScanner(r)
|
||||
|
||||
// default Scanner.MaxScanTokenSize = 64 kiB may be too small for some references,
|
||||
// so allow the buffer to be large enough in case the ref has long content (e.g.: a tag with long message)
|
||||
// as long as it doesn't exceed some reasonable limit (4 MiB here, or MAX_DISPLAY_FILE_SIZE=8MiB), it is OK
|
||||
// there are still some choices: 1. add a config option for the limit; 2. don't use scanner and write our own parser to fully handle large contents
|
||||
scanner.Buffer(nil, 4*1024*1024)
|
||||
|
||||
// in addition to the reference delimiter we specified in the --format,
|
||||
// `git for-each-ref` will always add a newline after every reference.
|
||||
refDelim := make([]byte, 0, len(format.refDelim)+1)
|
||||
refDelim = append(refDelim, format.refDelim...)
|
||||
refDelim = append(refDelim, '\n')
|
||||
|
||||
// Split input into delimiter-separated "reference blocks".
|
||||
scanner.Split(
|
||||
func(data []byte, atEOF bool) (advance int, token []byte, err error) {
|
||||
// Scan until delimiter, marking end of reference.
|
||||
delimIdx := bytes.Index(data, refDelim)
|
||||
if delimIdx >= 0 {
|
||||
token := data[:delimIdx]
|
||||
advance := delimIdx + len(refDelim)
|
||||
return advance, token, nil
|
||||
}
|
||||
// If we're at EOF, we have a final, non-terminated reference. Return it.
|
||||
if atEOF {
|
||||
return len(data), data, nil
|
||||
}
|
||||
// Not yet a full field. Request more data.
|
||||
return 0, nil, nil
|
||||
})
|
||||
|
||||
return &Parser{
|
||||
scanner: scanner,
|
||||
format: format,
|
||||
err: nil,
|
||||
}
|
||||
}
|
||||
|
||||
// Next returns the next reference as a collection of key-value pairs. nil
|
||||
// denotes EOF but is also returned on errors. The Err method should always be
|
||||
// consulted after Next returning nil.
|
||||
//
|
||||
// It could, for example return something like:
|
||||
//
|
||||
// { "objecttype": "tag", "refname:short": "v1.16.4", "object": "f460b7543ed500e49c133c2cd85c8c55ee9dbe27" }
|
||||
func (p *Parser) Next() map[string]string {
|
||||
if !p.scanner.Scan() {
|
||||
if err := p.scanner.Err(); err != nil {
|
||||
p.err = err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
fields, err := p.parseRef(p.scanner.Text())
|
||||
if err != nil {
|
||||
p.err = err
|
||||
return nil
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
// Err returns the latest encountered parsing error.
|
||||
func (p *Parser) Err() error {
|
||||
return p.err
|
||||
}
|
||||
|
||||
// parseRef parses out all key-value pairs from a single reference block, such as
|
||||
//
|
||||
// "objecttype tag\0refname:short v1.16.4\0object f460b7543ed500e49c133c2cd85c8c55ee9dbe27"
|
||||
func (p *Parser) parseRef(refBlock string) (map[string]string, error) {
|
||||
if refBlock == "" {
|
||||
// must be at EOF
|
||||
return nil, nil //nolint:nilnil // return nil to signal EOF
|
||||
}
|
||||
|
||||
fieldValues := make(map[string]string)
|
||||
|
||||
fields := strings.Split(refBlock, p.format.fieldDelimStr)
|
||||
if len(fields) != len(p.format.fieldNames) {
|
||||
return nil, fmt.Errorf("unexpected number of reference fields: wanted %d, was %d",
|
||||
len(fields), len(p.format.fieldNames))
|
||||
}
|
||||
for i, field := range fields {
|
||||
var fieldKey string
|
||||
var fieldVal string
|
||||
before, after, ok := strings.Cut(field, " ")
|
||||
if ok {
|
||||
fieldKey = before
|
||||
fieldVal = after
|
||||
} else {
|
||||
// could be the case if the requested field had no value
|
||||
fieldKey = field
|
||||
}
|
||||
|
||||
// enforce the format order of fields
|
||||
if p.format.fieldNames[i] != fieldKey {
|
||||
return nil, fmt.Errorf("unexpected field name at position %d: wanted: '%s', was: '%s'",
|
||||
i, p.format.fieldNames[i], fieldKey)
|
||||
}
|
||||
|
||||
fieldValues[fieldKey] = fieldVal
|
||||
}
|
||||
|
||||
return fieldValues, nil
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package foreachref_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/git/foreachref"
|
||||
"gitea.dev/modules/json"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type refSlice = []map[string]string
|
||||
|
||||
func TestParser(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
givenFormat foreachref.Format
|
||||
givenInput io.Reader
|
||||
|
||||
wantRefs refSlice
|
||||
wantErr bool
|
||||
expectedErr error
|
||||
}{
|
||||
// this would, for example, be the result when running `git
|
||||
// for-each-ref refs/tags` on a repo without tags.
|
||||
{
|
||||
name: "no references on empty input",
|
||||
|
||||
givenFormat: foreachref.NewFormat("refname:short"),
|
||||
givenInput: strings.NewReader(``),
|
||||
|
||||
wantRefs: []map[string]string{},
|
||||
},
|
||||
|
||||
// note: `git for-each-ref` will add a newline between every
|
||||
// reference (in addition to the ref-delimiter we've chosen)
|
||||
{
|
||||
name: "single field requested, single reference in output",
|
||||
|
||||
givenFormat: foreachref.NewFormat("refname:short"),
|
||||
givenInput: strings.NewReader("refname:short v0.0.1\x00\x00" + "\n"),
|
||||
|
||||
wantRefs: []map[string]string{
|
||||
{"refname:short": "v0.0.1"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "single field requested, multiple references in output",
|
||||
|
||||
givenFormat: foreachref.NewFormat("refname:short"),
|
||||
givenInput: strings.NewReader(
|
||||
"refname:short v0.0.1\x00\x00" + "\n" +
|
||||
"refname:short v0.0.2\x00\x00" + "\n" +
|
||||
"refname:short v0.0.3\x00\x00" + "\n"),
|
||||
|
||||
wantRefs: []map[string]string{
|
||||
{"refname:short": "v0.0.1"},
|
||||
{"refname:short": "v0.0.2"},
|
||||
{"refname:short": "v0.0.3"},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "multiple fields requested for each reference",
|
||||
|
||||
givenFormat: foreachref.NewFormat("refname:short", "objecttype", "objectname"),
|
||||
givenInput: strings.NewReader(
|
||||
|
||||
"refname:short v0.0.1\x00objecttype commit\x00objectname 7b2c5ac9fc04fc5efafb60700713d4fa609b777b\x00\x00" + "\n" +
|
||||
"refname:short v0.0.2\x00objecttype commit\x00objectname a1f051bc3eba734da4772d60e2d677f47cf93ef4\x00\x00" + "\n" +
|
||||
"refname:short v0.0.3\x00objecttype commit\x00objectname ef82de70bb3f60c65fb8eebacbb2d122ef517385\x00\x00" + "\n",
|
||||
),
|
||||
|
||||
wantRefs: []map[string]string{
|
||||
{
|
||||
"refname:short": "v0.0.1",
|
||||
"objecttype": "commit",
|
||||
"objectname": "7b2c5ac9fc04fc5efafb60700713d4fa609b777b",
|
||||
},
|
||||
{
|
||||
"refname:short": "v0.0.2",
|
||||
"objecttype": "commit",
|
||||
"objectname": "a1f051bc3eba734da4772d60e2d677f47cf93ef4",
|
||||
},
|
||||
{
|
||||
"refname:short": "v0.0.3",
|
||||
"objecttype": "commit",
|
||||
"objectname": "ef82de70bb3f60c65fb8eebacbb2d122ef517385",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "must handle multi-line fields such as 'content'",
|
||||
|
||||
givenFormat: foreachref.NewFormat("refname:short", "contents", "author"),
|
||||
givenInput: strings.NewReader(
|
||||
"refname:short v0.0.1\x00contents Create new buffer if not present yet (#549)\n\nFixes a nil dereference when ProcessFoo is used\nwith multiple commands.\x00author Foo Bar <foo@bar.com> 1507832733 +0200\x00\x00" + "\n" +
|
||||
"refname:short v0.0.2\x00contents Update CI config (#651)\n\n\x00author John Doe <john.doe@foo.com> 1521643174 +0000\x00\x00" + "\n" +
|
||||
"refname:short v0.0.3\x00contents Fixed code sample for bash completion (#687)\n\n\x00author Foo Baz <foo@baz.com> 1524836750 +0200\x00\x00" + "\n",
|
||||
),
|
||||
|
||||
wantRefs: []map[string]string{
|
||||
{
|
||||
"refname:short": "v0.0.1",
|
||||
"contents": "Create new buffer if not present yet (#549)\n\nFixes a nil dereference when ProcessFoo is used\nwith multiple commands.",
|
||||
"author": "Foo Bar <foo@bar.com> 1507832733 +0200",
|
||||
},
|
||||
{
|
||||
"refname:short": "v0.0.2",
|
||||
"contents": "Update CI config (#651)\n\n",
|
||||
"author": "John Doe <john.doe@foo.com> 1521643174 +0000",
|
||||
},
|
||||
{
|
||||
"refname:short": "v0.0.3",
|
||||
"contents": "Fixed code sample for bash completion (#687)\n\n",
|
||||
"author": "Foo Baz <foo@baz.com> 1524836750 +0200",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "must handle fields without values",
|
||||
|
||||
givenFormat: foreachref.NewFormat("refname:short", "object", "objecttype"),
|
||||
givenInput: strings.NewReader(
|
||||
"refname:short v0.0.1\x00object \x00objecttype commit\x00\x00" + "\n" +
|
||||
"refname:short v0.0.2\x00object \x00objecttype commit\x00\x00" + "\n" +
|
||||
"refname:short v0.0.3\x00object \x00objecttype commit\x00\x00" + "\n",
|
||||
),
|
||||
|
||||
wantRefs: []map[string]string{
|
||||
{
|
||||
"refname:short": "v0.0.1",
|
||||
"object": "",
|
||||
"objecttype": "commit",
|
||||
},
|
||||
{
|
||||
"refname:short": "v0.0.2",
|
||||
"object": "",
|
||||
"objecttype": "commit",
|
||||
},
|
||||
{
|
||||
"refname:short": "v0.0.3",
|
||||
"object": "",
|
||||
"objecttype": "commit",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "must fail when the number of fields in the input doesn't match expected format",
|
||||
|
||||
givenFormat: foreachref.NewFormat("refname:short", "objecttype", "objectname"),
|
||||
givenInput: strings.NewReader(
|
||||
"refname:short v0.0.1\x00objecttype commit\x00\x00" + "\n" +
|
||||
"refname:short v0.0.2\x00objecttype commit\x00\x00" + "\n" +
|
||||
"refname:short v0.0.3\x00objecttype commit\x00\x00" + "\n",
|
||||
),
|
||||
|
||||
wantErr: true,
|
||||
expectedErr: errors.New("unexpected number of reference fields: wanted 2, was 3"),
|
||||
},
|
||||
|
||||
{
|
||||
name: "must fail input fields don't match expected format",
|
||||
|
||||
givenFormat: foreachref.NewFormat("refname:short", "objectname"),
|
||||
givenInput: strings.NewReader(
|
||||
"refname:short v0.0.1\x00objecttype commit\x00\x00" + "\n" +
|
||||
"refname:short v0.0.2\x00objecttype commit\x00\x00" + "\n" +
|
||||
"refname:short v0.0.3\x00objecttype commit\x00\x00" + "\n",
|
||||
),
|
||||
|
||||
wantErr: true,
|
||||
expectedErr: errors.New("unexpected field name at position 1: wanted: 'objectname', was: 'objecttype'"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
tc := test // don't close over loop variable
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
parser := tc.givenFormat.Parser(tc.givenInput)
|
||||
|
||||
//
|
||||
// parse references from input
|
||||
//
|
||||
gotRefs := make([]map[string]string, 0)
|
||||
for {
|
||||
ref := parser.Next()
|
||||
if ref == nil {
|
||||
break
|
||||
}
|
||||
gotRefs = append(gotRefs, ref)
|
||||
}
|
||||
err := parser.Err()
|
||||
|
||||
//
|
||||
// verify expectations
|
||||
//
|
||||
if tc.wantErr {
|
||||
require.Error(t, err)
|
||||
require.EqualError(t, err, tc.expectedErr.Error())
|
||||
} else {
|
||||
require.NoError(t, err, "for-each-ref parser unexpectedly failed with: %v", err)
|
||||
require.Equal(t, tc.wantRefs, gotRefs, "for-each-ref parser produced unexpected reference set. wanted: %v, got: %v", pretty(tc.wantRefs), pretty(gotRefs))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func pretty(v any) string {
|
||||
data, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
// shouldn't happen
|
||||
panic(fmt.Sprintf("json-marshalling failed: %v", err))
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
// Copyright 2015 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/tempdir"
|
||||
"gitea.dev/modules/testlogger"
|
||||
|
||||
"github.com/hashicorp/go-version"
|
||||
)
|
||||
|
||||
const RequiredVersion = "2.6.0" // the minimum Git version required
|
||||
|
||||
type Features struct {
|
||||
gitVersion *version.Version
|
||||
|
||||
UsingGogit bool
|
||||
SupportProcReceive bool // >= 2.29
|
||||
SupportHashSha256 bool // >= 2.42, SHA-256 repositories no longer an ‘experimental curiosity’
|
||||
SupportedObjectFormats []ObjectFormat // sha1, sha256
|
||||
SupportCheckAttrOnBare bool // >= 2.40
|
||||
SupportCatFileBatchCommand bool // >= 2.36, support `git cat-file --batch-command`
|
||||
SupportGitMergeTree bool // >= 2.40 // we also need "--merge-base"
|
||||
}
|
||||
|
||||
var defaultFeatures *Features
|
||||
|
||||
func (f *Features) CheckVersionAtLeast(atLeast string) bool {
|
||||
return f.gitVersion.Compare(version.Must(version.NewVersion(atLeast))) >= 0
|
||||
}
|
||||
|
||||
// VersionInfo returns git version information
|
||||
func (f *Features) VersionInfo() string {
|
||||
return f.gitVersion.Original()
|
||||
}
|
||||
|
||||
func DefaultFeatures() *Features {
|
||||
if defaultFeatures == nil {
|
||||
if !setting.IsProd || setting.IsInTesting {
|
||||
log.Warn("git.DefaultFeatures is called before git.InitXxx, initializing with default values")
|
||||
}
|
||||
if err := InitSimple(); err != nil {
|
||||
log.Fatal("git.InitSimple failed: %v", err)
|
||||
}
|
||||
}
|
||||
return defaultFeatures
|
||||
}
|
||||
|
||||
func loadGitVersionFeatures() (*Features, error) {
|
||||
stdout, _, runErr := gitcmd.NewCommand("version").RunStdString(context.Background())
|
||||
if runErr != nil {
|
||||
return nil, runErr
|
||||
}
|
||||
|
||||
ver, err := parseGitVersionLine(strings.TrimSpace(stdout))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
features := &Features{gitVersion: ver, UsingGogit: isGogit}
|
||||
features.SupportProcReceive = features.CheckVersionAtLeast("2.29")
|
||||
features.SupportHashSha256 = features.CheckVersionAtLeast("2.42") && !isGogit
|
||||
features.SupportedObjectFormats = []ObjectFormat{Sha1ObjectFormat}
|
||||
if features.SupportHashSha256 {
|
||||
features.SupportedObjectFormats = append(features.SupportedObjectFormats, Sha256ObjectFormat)
|
||||
}
|
||||
features.SupportCheckAttrOnBare = features.CheckVersionAtLeast("2.40")
|
||||
features.SupportCatFileBatchCommand = features.CheckVersionAtLeast("2.36")
|
||||
features.SupportGitMergeTree = features.CheckVersionAtLeast("2.40") // we also need "--merge-base"
|
||||
return features, nil
|
||||
}
|
||||
|
||||
func parseGitVersionLine(s string) (*version.Version, error) {
|
||||
fields := strings.Fields(s)
|
||||
if len(fields) < 3 {
|
||||
return nil, fmt.Errorf("invalid git version: %q", s)
|
||||
}
|
||||
|
||||
// version output is like: "git version {versionString}"
|
||||
// versionString can be:
|
||||
// * "2.5.3"
|
||||
// * "2.29.3.windows.1"
|
||||
// * "2.28.0.618.gf4bc123cb7": https://github.com/go-gitea/gitea/issues/12731
|
||||
versionString := fields[2]
|
||||
versionFields := strings.Split(versionString, ".")
|
||||
if len(versionFields) > 3 {
|
||||
versionFields = versionFields[:3]
|
||||
}
|
||||
return version.NewVersion(strings.Join(versionFields, "."))
|
||||
}
|
||||
|
||||
func checkGitVersionCompatibility(gitVer *version.Version) error {
|
||||
badVersions := []struct {
|
||||
Version *version.Version
|
||||
Reason string
|
||||
}{
|
||||
{version.Must(version.NewVersion("2.43.1")), "regression bug of GIT_FLUSH"},
|
||||
}
|
||||
for _, bad := range badVersions {
|
||||
if gitVer.Equal(bad.Version) {
|
||||
return errors.New(bad.Reason)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureGitVersion() error {
|
||||
if !DefaultFeatures().CheckVersionAtLeast(RequiredVersion) {
|
||||
moreHint := "get git: https://git-scm.com/downloads"
|
||||
if runtime.GOOS == "linux" {
|
||||
// there are a lot of CentOS/RHEL users using old git, so we add a special hint for them
|
||||
if _, err := os.Stat("/etc/redhat-release"); err == nil {
|
||||
// ius.io is the recommended official(git-scm.com) method to install git
|
||||
moreHint = "get git: https://git-scm.com/downloads/linux and https://ius.io"
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("installed git version %q is not supported, Gitea requires git version >= %q, %s", DefaultFeatures().gitVersion.Original(), RequiredVersion, moreHint)
|
||||
}
|
||||
|
||||
if err := checkGitVersionCompatibility(DefaultFeatures().gitVersion); err != nil {
|
||||
return fmt.Errorf("installed git version %s has a known compatibility issue with Gitea: %w, please upgrade (or downgrade) git", DefaultFeatures().gitVersion.String(), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InitSimple initializes git module with a very simple step, no config changes, no global command arguments.
|
||||
// This method doesn't change anything to filesystem. At the moment, it is only used by some Gitea sub-commands.
|
||||
func InitSimple() error {
|
||||
if setting.Git.HomePath == "" {
|
||||
return errors.New("unable to init Git's HomeDir, incorrect initialization of the setting and git modules")
|
||||
}
|
||||
|
||||
if defaultFeatures != nil && (!setting.IsProd || setting.IsInTesting) {
|
||||
log.Warn("git module has been initialized already, duplicate init may work but it's better to fix it")
|
||||
}
|
||||
|
||||
if err := gitcmd.SetExecutablePath(setting.Git.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var err error
|
||||
defaultFeatures, err = loadGitVersionFeatures()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = ensureGitVersion(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// when git works with gnupg (commit signing), there should be a stable home for gnupg commands
|
||||
if _, ok := os.LookupEnv("GNUPGHOME"); !ok {
|
||||
_ = os.Setenv("GNUPGHOME", filepath.Join(gitcmd.HomeDir(), ".gnupg"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InitFull initializes git module with version check and change global variables, sync gitconfig.
|
||||
// It should only be called once at the beginning of the program initialization (TestMain/GlobalInitInstalled) as this code makes unsynchronized changes to variables.
|
||||
func InitFull() (err error) {
|
||||
if err = InitSimple(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if setting.LFS.StartServer {
|
||||
if !DefaultFeatures().CheckVersionAtLeast("2.1.2") {
|
||||
return errors.New("LFS server support requires Git >= 2.1.2")
|
||||
}
|
||||
}
|
||||
|
||||
return syncGitConfig(context.Background())
|
||||
}
|
||||
|
||||
// RunGitTests helps to init the git module and run tests.
|
||||
// FIXME: GIT-PACKAGE-DEPENDENCY: the dependency is not right, setting.Git.HomePath is initialized in this package but used in gitcmd package
|
||||
func RunGitTests(m interface{ Run() int }) {
|
||||
os.Exit(runGitTests(m))
|
||||
}
|
||||
|
||||
func runGitTests(m interface{ Run() int }) int {
|
||||
gitHomePath, cleanup, err := tempdir.OsTempDir("gitea-test").MkdirTempRandom("git-home")
|
||||
if err != nil {
|
||||
return testlogger.MainErrorf("unable to create temp dir: %v", err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
setting.Git.HomePath = gitHomePath
|
||||
if err = InitFull(); err != nil {
|
||||
return testlogger.MainErrorf("failed to call Init: %v", err)
|
||||
}
|
||||
return m.Run()
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/go-version"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
RunGitTests(m)
|
||||
}
|
||||
|
||||
func TestParseGitVersion(t *testing.T) {
|
||||
v, err := parseGitVersionLine("git version 2.29.3")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "2.29.3", v.String())
|
||||
|
||||
v, err = parseGitVersionLine("git version 2.29.3.windows.1")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "2.29.3", v.String())
|
||||
|
||||
v, err = parseGitVersionLine("git version 2.28.0.618.gf4bc123cb7")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "2.28.0", v.String())
|
||||
|
||||
_, err = parseGitVersionLine("git version")
|
||||
assert.Error(t, err)
|
||||
|
||||
_, err = parseGitVersionLine("git version windows")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestCheckGitVersionCompatibility(t *testing.T) {
|
||||
assert.NoError(t, checkGitVersionCompatibility(version.Must(version.NewVersion("2.43.0"))))
|
||||
assert.ErrorContains(t, checkGitVersionCompatibility(version.Must(version.NewVersion("2.43.1"))), "regression bug of GIT_FLUSH")
|
||||
assert.NoError(t, checkGitVersionCompatibility(version.Must(version.NewVersion("2.43.2"))))
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
// Copyright 2015 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2016 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package gitcmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dev/modules/git/internal" //nolint:depguard // only this file can use the internal type CmdArg, other files and packages should use AddXxx functions
|
||||
"gitea.dev/modules/gtprof"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/process"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
// TrustedCmdArgs returns the trusted arguments for git command.
|
||||
// It's mainly for passing user-provided and trusted arguments to git command
|
||||
// In most cases, it shouldn't be used. Use AddXxx function instead
|
||||
type TrustedCmdArgs []internal.CmdArg
|
||||
|
||||
// Command represents a command with its subcommands or arguments.
|
||||
type Command struct {
|
||||
callerInfo string
|
||||
prog string
|
||||
args []string
|
||||
preErrors []error
|
||||
configArgs []string
|
||||
opts runOpts
|
||||
|
||||
cmd *exec.Cmd
|
||||
|
||||
cmdCtx context.Context
|
||||
cmdCancel process.CancelCauseFunc
|
||||
cmdFinished process.FinishedFunc
|
||||
cmdStartTime time.Time
|
||||
|
||||
parentPipeFiles []*os.File
|
||||
parentPipeReaders []*os.File
|
||||
childrenPipeFiles []*os.File
|
||||
|
||||
// only os.Pipe and in-memory buffers can work with Stdin safely, see https://github.com/golang/go/issues/77227 if the command would exit unexpectedly
|
||||
cmdStdin io.Reader
|
||||
cmdStdout io.Writer
|
||||
cmdStderr io.Writer
|
||||
|
||||
cmdManagedStderr *bytes.Buffer
|
||||
}
|
||||
|
||||
func logArgSanitize(arg string) string {
|
||||
if filepath.IsAbs(arg) {
|
||||
base := filepath.Base(arg)
|
||||
dir := filepath.Dir(arg)
|
||||
return ".../" + filepath.Join(filepath.Base(dir), base)
|
||||
}
|
||||
return util.SanitizeCredentialURLs(arg)
|
||||
}
|
||||
|
||||
func (c *Command) LogString() string {
|
||||
// WARNING: this function is for debugging purposes only. It's much better than old code (which only joins args with space),
|
||||
// It's impossible to make a simple and 100% correct implementation of argument quoting for different platforms here.
|
||||
debugQuote := func(s string) string {
|
||||
if strings.ContainsAny(s, " `'\"\t\r\n") {
|
||||
return fmt.Sprintf("%q", s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
a := make([]string, 0, len(c.args)+1)
|
||||
a = append(a, debugQuote(c.prog))
|
||||
for i := 0; i < len(c.args); i++ {
|
||||
a = append(a, debugQuote(logArgSanitize(c.args[i])))
|
||||
}
|
||||
return strings.Join(a, " ")
|
||||
}
|
||||
|
||||
func (c *Command) ProcessState() string {
|
||||
if c.cmd == nil {
|
||||
return ""
|
||||
}
|
||||
return c.cmd.ProcessState.String()
|
||||
}
|
||||
|
||||
// NewCommand creates and returns a new Git Command based on given command and arguments.
|
||||
// Each argument should be safe to be trusted. User-provided arguments should be passed to AddDynamicArguments instead.
|
||||
func NewCommand(args ...internal.CmdArg) *Command {
|
||||
cargs := make([]string, 0, len(args))
|
||||
for _, arg := range args {
|
||||
cargs = append(cargs, string(arg))
|
||||
}
|
||||
return &Command{
|
||||
prog: GitExecutable,
|
||||
args: cargs,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Command) handlePreErrorBrokenCommand(arg string) {
|
||||
c.preErrors = append(c.preErrors, util.ErrorWrap(ErrBrokenCommand, `broken git command argument %q`, arg))
|
||||
}
|
||||
|
||||
// isSafeArgumentValue checks if the argument is safe to be used as a value (not an option)
|
||||
func isSafeArgumentValue(s string) bool {
|
||||
return s == "" || s[0] != '-'
|
||||
}
|
||||
|
||||
// isValidArgumentOption checks if the argument is a valid option (starting with '-').
|
||||
// It doesn't check whether the option is supported or not
|
||||
func isValidArgumentOption(s string) bool {
|
||||
return s != "" && s[0] == '-'
|
||||
}
|
||||
|
||||
// AddArguments adds new git arguments (option/value) to the command. It only accepts string literals, or trusted CmdArg.
|
||||
// Type CmdArg is in the internal package, so it can not be used outside of this package directly,
|
||||
// it makes sure that user-provided arguments won't cause RCE risks.
|
||||
// User-provided arguments should be passed by other AddXxx functions
|
||||
func (c *Command) AddArguments(args ...internal.CmdArg) *Command {
|
||||
for _, arg := range args {
|
||||
c.args = append(c.args, string(arg))
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// AddOptionValues adds a new option with a list of non-option values
|
||||
// For example: AddOptionValues("--opt", val) means 2 arguments: {"--opt", val}.
|
||||
// The values are treated as dynamic argument values. It equals to: AddArguments("--opt") then AddDynamicArguments(val).
|
||||
func (c *Command) AddOptionValues(opt internal.CmdArg, args ...string) *Command {
|
||||
if !isValidArgumentOption(string(opt)) {
|
||||
c.handlePreErrorBrokenCommand(string(opt))
|
||||
return c
|
||||
}
|
||||
c.args = append(c.args, string(opt))
|
||||
c.AddDynamicArguments(args...)
|
||||
return c
|
||||
}
|
||||
|
||||
// AddOptionFormat adds a new option with a format string and arguments
|
||||
// For example: AddOptionFormat("--opt=%s %s", val1, val2) means 1 argument: {"--opt=val1 val2"}.
|
||||
func (c *Command) AddOptionFormat(opt string, args ...any) *Command {
|
||||
if !isValidArgumentOption(opt) {
|
||||
c.handlePreErrorBrokenCommand(opt)
|
||||
return c
|
||||
}
|
||||
// a quick check to make sure the format string matches the number of arguments, to find low-level mistakes ASAP
|
||||
if strings.Count(strings.ReplaceAll(opt, "%%", ""), "%") != len(args) {
|
||||
c.handlePreErrorBrokenCommand(opt)
|
||||
return c
|
||||
}
|
||||
s := fmt.Sprintf(opt, args...)
|
||||
c.args = append(c.args, s)
|
||||
return c
|
||||
}
|
||||
|
||||
// AddDynamicArguments adds new dynamic argument values to the command.
|
||||
// The arguments may come from user input and can not be trusted, so no leading '-' is allowed to avoid passing options.
|
||||
// TODO: in the future, this function can be renamed to AddArgumentValues
|
||||
func (c *Command) AddDynamicArguments(args ...string) *Command {
|
||||
for _, arg := range args {
|
||||
if !isSafeArgumentValue(arg) {
|
||||
c.handlePreErrorBrokenCommand(arg)
|
||||
}
|
||||
}
|
||||
if len(c.preErrors) != 0 {
|
||||
return c
|
||||
}
|
||||
c.args = append(c.args, args...)
|
||||
return c
|
||||
}
|
||||
|
||||
// AddDashesAndList adds the "--" and then add the list as arguments, it's usually for adding file list
|
||||
// At the moment, this function can be only called once, maybe in future it can be refactored to support multiple calls (if necessary)
|
||||
func (c *Command) AddDashesAndList(list ...string) *Command {
|
||||
c.args = append(c.args, "--")
|
||||
// Some old code also checks `arg != ""`, IMO it's not necessary.
|
||||
// If the check is needed, the list should be prepared before the call to this function
|
||||
c.args = append(c.args, list...)
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Command) AddConfig(key, value string) *Command {
|
||||
kv := key + "=" + value
|
||||
if !isSafeArgumentValue(kv) {
|
||||
c.handlePreErrorBrokenCommand(kv)
|
||||
} else {
|
||||
c.configArgs = append(c.configArgs, "-c", kv)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// ToTrustedCmdArgs converts a list of strings (trusted as argument) to TrustedCmdArgs
|
||||
// In most cases, it shouldn't be used. Use NewCommand().AddXxx() function instead
|
||||
func ToTrustedCmdArgs(args []string) TrustedCmdArgs {
|
||||
ret := make(TrustedCmdArgs, len(args))
|
||||
for i, arg := range args {
|
||||
ret[i] = internal.CmdArg(arg)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
type runOpts struct {
|
||||
Env []string
|
||||
Timeout time.Duration
|
||||
|
||||
// Dir is the working dir for the git command, however:
|
||||
// FIXME: this could be incorrect in many cases, for example:
|
||||
// * /some/path/.git
|
||||
// * /some/path/.git/gitea-data/data/repositories/user/repo.git
|
||||
// If "user/repo.git" is invalid/broken, then running git command in it will use "/some/path/.git", and produce unexpected results
|
||||
// The correct approach is to use `--git-dir" global argument
|
||||
Dir string
|
||||
|
||||
PipelineFunc func(Context) error
|
||||
}
|
||||
|
||||
func commonBaseEnvs() []string {
|
||||
envs := []string{
|
||||
// Make Gitea use internal git config only, to prevent conflicts with user's git config
|
||||
// It's better to use GIT_CONFIG_GLOBAL, but it requires git >= 2.32, so we still use HOME at the moment.
|
||||
"HOME=" + HomeDir(),
|
||||
// Avoid using system git config, it would cause problems (eg: use macOS osxkeychain to show a modal dialog, auto installing lfs hooks)
|
||||
// This might be a breaking change in 1.24, because some users said that they have put some configs like "receive.certNonceSeed" in "/etc/gitconfig"
|
||||
// For these users, they need to migrate the necessary configs to Gitea's git config file manually.
|
||||
"GIT_CONFIG_NOSYSTEM=1",
|
||||
// Ignore replace references (https://git-scm.com/docs/git-replace)
|
||||
"GIT_NO_REPLACE_OBJECTS=1",
|
||||
}
|
||||
|
||||
// some environment variables should be passed to git command
|
||||
passThroughEnvKeys := []string{
|
||||
"GNUPGHOME", // git may call gnupg to do commit signing
|
||||
}
|
||||
for _, key := range passThroughEnvKeys {
|
||||
if val, ok := os.LookupEnv(key); ok {
|
||||
envs = append(envs, key+"="+val)
|
||||
}
|
||||
}
|
||||
return envs
|
||||
}
|
||||
|
||||
// CommonGitCmdEnvs returns the common environment variables for a "git" command.
|
||||
func CommonGitCmdEnvs() []string {
|
||||
return append(commonBaseEnvs(), []string{
|
||||
"LC_ALL=C", // ensure git output is in English, error messages are parsed in English
|
||||
"GIT_TERMINAL_PROMPT=0", // avoid prompting for credentials interactively, supported since git v2.3
|
||||
}...)
|
||||
}
|
||||
|
||||
// CommonCmdServEnvs is like CommonGitCmdEnvs, but it only returns minimal required environment variables for the "gitea serv" command
|
||||
func CommonCmdServEnvs() []string {
|
||||
return commonBaseEnvs()
|
||||
}
|
||||
|
||||
var ErrBrokenCommand = errors.New("git command is broken")
|
||||
|
||||
func (c *Command) WithDir(dir string) *Command {
|
||||
c.opts.Dir = dir
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Command) WithEnv(env []string) *Command {
|
||||
c.opts.Env = env
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Command) WithTimeout(timeout time.Duration) *Command {
|
||||
c.opts.Timeout = timeout
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Command) makeStdoutStderr(w *io.Writer) (PipeReader, func()) {
|
||||
pr, pw, err := os.Pipe()
|
||||
if err != nil {
|
||||
c.preErrors = append(c.preErrors, err)
|
||||
return &pipeNull{err}, func() {}
|
||||
}
|
||||
c.childrenPipeFiles = append(c.childrenPipeFiles, pw)
|
||||
c.parentPipeFiles = append(c.parentPipeFiles, pr)
|
||||
c.parentPipeReaders = append(c.parentPipeReaders, pr)
|
||||
*w /* stdout, stderr */ = pw
|
||||
return &pipeReader{f: pr}, func() { pr.Close() }
|
||||
}
|
||||
|
||||
// MakeStdinPipe creates a writer for the command's stdin.
|
||||
// The returned closer function must be called by the caller to close the pipe.
|
||||
func (c *Command) MakeStdinPipe() (writer PipeWriter, closer func()) {
|
||||
pr, pw, err := os.Pipe()
|
||||
if err != nil {
|
||||
c.preErrors = append(c.preErrors, err)
|
||||
return &pipeNull{err}, func() {}
|
||||
}
|
||||
c.childrenPipeFiles = append(c.childrenPipeFiles, pr)
|
||||
c.parentPipeFiles = append(c.parentPipeFiles, pw)
|
||||
c.cmdStdin = pr
|
||||
return &pipeWriter{pw}, func() { pw.Close() }
|
||||
}
|
||||
|
||||
// MakeStdoutPipe creates a reader for the command's stdout.
|
||||
// The returned closer function must be called by the caller to close the pipe.
|
||||
// After the pipe reader is closed, the unread data will be discarded.
|
||||
//
|
||||
// If the process (git command) still tries to write after the pipe is closed, the Wait error will be "signal: broken pipe".
|
||||
// WithPipelineFunc + Run won't return "broken pipe" error in this case if the callback returns no error.
|
||||
// But if you are calling Start / Wait family functions, you should either drain the pipe before close it, or handle the Wait error correctly.
|
||||
func (c *Command) MakeStdoutPipe() (reader PipeReader, closer func()) {
|
||||
return c.makeStdoutStderr(&c.cmdStdout)
|
||||
}
|
||||
|
||||
// MakeStderrPipe is like MakeStdoutPipe, but for stderr.
|
||||
func (c *Command) MakeStderrPipe() (reader PipeReader, closer func()) {
|
||||
return c.makeStdoutStderr(&c.cmdStderr)
|
||||
}
|
||||
|
||||
func (c *Command) MakeStdinStdoutPipe() (stdin PipeWriter, stdout PipeReader, closer func()) {
|
||||
stdin, stdinClose := c.MakeStdinPipe()
|
||||
stdout, stdoutClose := c.MakeStdoutPipe()
|
||||
return stdin, stdout, func() {
|
||||
stdinClose()
|
||||
stdoutClose()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Command) WithStdinBytes(stdin []byte) *Command {
|
||||
c.cmdStdin = bytes.NewReader(stdin)
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Command) WithStdoutBuffer(w PipeBufferWriter) *Command {
|
||||
c.cmdStdout = w
|
||||
return c
|
||||
}
|
||||
|
||||
// WithStdinCopy and WithStdoutCopy are general functions that accept any io.Reader / io.Writer.
|
||||
// In this case, Golang exec.Cmd will start new internal goroutines to do io.Copy between pipes and provided Reader/Writer.
|
||||
// If the reader or writer is blocked and never returns, then the io.Copy won't finish, then exec.Cmd.Wait won't return, which may cause deadlocks.
|
||||
// A typical deadlock example is:
|
||||
// * `r,w:=io.Pipe(); cmd.Stdin=r; defer w.Close(); cmd.Run()`: the Run() will never return because stdin reader is blocked forever and w.Close() will never be called.
|
||||
// If the reader/writer won't block forever (for example: read from a file or buffer), then these functions are safe to use.
|
||||
func (c *Command) WithStdinCopy(w io.Reader) *Command {
|
||||
c.cmdStdin = w
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Command) WithStdoutCopy(w io.Writer) *Command {
|
||||
c.cmdStdout = w
|
||||
return c
|
||||
}
|
||||
|
||||
// WithPipelineFunc sets the pipeline function for the command.
|
||||
// The pipeline function will be called in the Run / Wait function after the command is started successfully.
|
||||
// The function can read/write from/to the command's stdio pipes (if any).
|
||||
// The pipeline function can cancel (kill) the command by calling ctx.CancelPipeline before the command finishes.
|
||||
// The returned error of Run / Wait can be joined errors from the pipeline function, context cause, and command exit error.
|
||||
// Caller can get the pipeline function's error (if any) by UnwrapPipelineError.
|
||||
func (c *Command) WithPipelineFunc(f func(ctx Context) error) *Command {
|
||||
c.opts.PipelineFunc = f
|
||||
return c
|
||||
}
|
||||
|
||||
// WithParentCallerInfo can be used to set the caller info (usually function name) of the parent function of the caller.
|
||||
// For most cases, "Run" family functions can get its caller info automatically
|
||||
// But if you need to call "Run" family functions in a wrapper function: "FeatureFunc -> GeneralWrapperFunc -> RunXxx",
|
||||
// then you can to call this function in GeneralWrapperFunc to set the caller info of FeatureFunc.
|
||||
// The caller info can only be set once.
|
||||
func (c *Command) WithParentCallerInfo(optInfo ...string) *Command {
|
||||
if c.callerInfo != "" {
|
||||
return c
|
||||
}
|
||||
if len(optInfo) > 0 {
|
||||
c.callerInfo = optInfo[0]
|
||||
return c
|
||||
}
|
||||
skip := 1 /*parent "wrap/run" functions*/ + 1 /*this function*/
|
||||
callerFuncName := util.CallerFuncName(skip)
|
||||
callerInfo := callerFuncName
|
||||
if pos := strings.LastIndex(callerInfo, "/"); pos >= 0 {
|
||||
callerInfo = callerInfo[pos+1:]
|
||||
}
|
||||
c.callerInfo = callerInfo
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Command) Start(ctx context.Context) (retErr error) {
|
||||
if c.cmd != nil {
|
||||
// this is a programming error, it will cause serious deadlock problems, so it must be fixed.
|
||||
panic("git command has already been started")
|
||||
}
|
||||
|
||||
defer func() {
|
||||
c.closePipeFiles(c.childrenPipeFiles)
|
||||
if retErr != nil {
|
||||
// release the pipes to avoid resource leak since the command failed to start
|
||||
c.closePipeFiles(c.parentPipeFiles)
|
||||
// if error occurs, we must also finish the task, otherwise, cmdFinished will be called in "Wait" function
|
||||
if c.cmdFinished != nil {
|
||||
c.cmdFinished()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if len(c.preErrors) != 0 {
|
||||
// In most cases, such error shouldn't happen. If it happens, log it as error level with more details
|
||||
err := errors.Join(c.preErrors...)
|
||||
log.Error("git command: %s, error: %s", c.LogString(), err)
|
||||
return err
|
||||
}
|
||||
|
||||
cmdLogString := c.LogString()
|
||||
if c.callerInfo == "" {
|
||||
c.WithParentCallerInfo()
|
||||
}
|
||||
// these logs are for debugging purposes only, so no guarantee of correctness or stability
|
||||
desc := fmt.Sprintf("git.Run(by:%s, repo:%s): %s", c.callerInfo, logArgSanitize(c.opts.Dir), cmdLogString)
|
||||
log.Debug("git.Command: %s", desc)
|
||||
|
||||
_, span := gtprof.GetTracer().Start(ctx, gtprof.TraceSpanGitRun)
|
||||
defer span.End()
|
||||
span.SetAttributeString(gtprof.TraceAttrFuncCaller, c.callerInfo)
|
||||
span.SetAttributeString(gtprof.TraceAttrGitCommand, cmdLogString)
|
||||
|
||||
if c.opts.Timeout <= 0 {
|
||||
c.cmdCtx, c.cmdCancel, c.cmdFinished = process.GetManager().AddContext(ctx, desc)
|
||||
} else {
|
||||
c.cmdCtx, c.cmdCancel, c.cmdFinished = process.GetManager().AddContextTimeout(ctx, c.opts.Timeout, desc)
|
||||
}
|
||||
|
||||
c.cmdStartTime = time.Now()
|
||||
|
||||
c.cmd = exec.CommandContext(c.cmdCtx, c.prog, append(c.configArgs, c.args...)...)
|
||||
if c.opts.Env == nil {
|
||||
c.cmd.Env = os.Environ()
|
||||
} else {
|
||||
c.cmd.Env = c.opts.Env
|
||||
}
|
||||
|
||||
process.SetSysProcAttribute(c.cmd)
|
||||
c.cmd.Env = append(c.cmd.Env, CommonGitCmdEnvs()...)
|
||||
c.cmd.Dir = c.opts.Dir
|
||||
c.cmd.Stdout = c.cmdStdout
|
||||
c.cmd.Stdin = c.cmdStdin
|
||||
c.cmd.Stderr = c.cmdStderr
|
||||
return c.cmd.Start()
|
||||
}
|
||||
|
||||
func (c *Command) closePipeFiles(files []*os.File) {
|
||||
for _, f := range files {
|
||||
_ = f.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Command) discardPipeReaders(files []*os.File) {
|
||||
for _, f := range files {
|
||||
_, _ = io.Copy(io.Discard, f)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Command) Wait() error {
|
||||
defer func() {
|
||||
// The reader in another goroutine might be still reading the stdout, so we shouldn't close the pipes here
|
||||
// MakeStdoutPipe returns a closer function to force callers to close the pipe correctly
|
||||
// Here we only need to mark the command as finished
|
||||
c.cmdFinished()
|
||||
}()
|
||||
|
||||
if c.opts.PipelineFunc != nil {
|
||||
errPipeline := c.opts.PipelineFunc(&cmdContext{Context: c.cmdCtx, cmd: c})
|
||||
|
||||
if context.Cause(c.cmdCtx) == nil {
|
||||
// if the context is not canceled explicitly, we need to discard the unread data,
|
||||
// and wait for the command to exit normally, and then get its exit code
|
||||
c.discardPipeReaders(c.parentPipeReaders)
|
||||
} // else: canceled command will be killed, and the exit code is caused by kill
|
||||
|
||||
// after the pipeline function returns, we can safely close the pipes, then wait for the command to exit
|
||||
c.closePipeFiles(c.parentPipeFiles)
|
||||
errWait := c.cmd.Wait()
|
||||
errCause := context.Cause(c.cmdCtx) // in case the cause is set during Wait(), get the final cancel cause
|
||||
|
||||
if unwrapped, ok := UnwrapPipelineError(errCause); ok {
|
||||
if unwrapped != errPipeline {
|
||||
panic("unwrapped context pipeline error should be the same one returned by pipeline function")
|
||||
}
|
||||
if unwrapped == nil {
|
||||
// the pipeline function declares that there is no error, and it cancels (kills) the command ahead,
|
||||
// so we should ignore the errors from "wait" and "cause"
|
||||
errWait, errCause = nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// some legacy code still need to access the error returned by pipeline function by "==" but not "errors.Is"
|
||||
// so we need to make sure the original error is able to be unwrapped by UnwrapPipelineError
|
||||
return errors.Join(wrapPipelineError(errPipeline), errCause, errWait)
|
||||
}
|
||||
|
||||
// there might be other goroutines using the context or pipes, so we just wait for the command to finish
|
||||
errWait := c.cmd.Wait()
|
||||
elapsed := time.Since(c.cmdStartTime)
|
||||
if elapsed > time.Second {
|
||||
log.Debug("slow git.Command.Run: %s (%s)", c, elapsed) // TODO: no need to log this for long-running commands
|
||||
}
|
||||
|
||||
// Here the logic is different from "PipelineFunc" case,
|
||||
// because PipelineFunc can return error if it fails, it knows whether it succeeds or fails.
|
||||
// But in normal case, the caller just runs the git command, the command's exit code is the source of truth.
|
||||
// If the caller need to know whether the command error is caused by cancellation, it should check the "err" by itself.
|
||||
errCause := context.Cause(c.cmdCtx)
|
||||
return errors.Join(errCause, errWait)
|
||||
}
|
||||
|
||||
func (c *Command) StartWithStderr(ctx context.Context) RunStdError {
|
||||
if c.cmdStderr != nil {
|
||||
panic("caller-provided stderr receiver doesn't work with managed stderr buffer")
|
||||
}
|
||||
c.cmdManagedStderr = &bytes.Buffer{}
|
||||
c.cmdStderr = c.cmdManagedStderr
|
||||
err := c.Start(ctx)
|
||||
if err != nil {
|
||||
return &runStdError{err: err}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Command) WaitWithStderr() RunStdError {
|
||||
if c.cmdManagedStderr == nil {
|
||||
panic("managed stderr buffer is not initialized")
|
||||
}
|
||||
errWait := c.Wait()
|
||||
if errWait == nil {
|
||||
// if no exec error but only stderr output, the stderr output is still saved in "c.cmdManagedStderr" and can be read later
|
||||
return nil
|
||||
}
|
||||
return &runStdError{err: errWait, stderr: util.UnsafeBytesToString(c.cmdManagedStderr.Bytes())}
|
||||
}
|
||||
|
||||
func (c *Command) RunWithStderr(ctx context.Context) RunStdError {
|
||||
if err := c.StartWithStderr(ctx); err != nil {
|
||||
return &runStdError{err: err}
|
||||
}
|
||||
return c.WaitWithStderr()
|
||||
}
|
||||
|
||||
func (c *Command) Run(ctx context.Context) (err error) {
|
||||
if err = c.Start(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Wait()
|
||||
}
|
||||
|
||||
// RunStdString runs the command and returns stdout/stderr as string. and store stderr to returned error (err combined with stderr).
|
||||
func (c *Command) RunStdString(ctx context.Context) (stdout, stderr string, runErr RunStdError) {
|
||||
stdoutBytes, stderrBytes, runErr := c.WithParentCallerInfo().runStdBytes(ctx)
|
||||
return util.UnsafeBytesToString(stdoutBytes), util.UnsafeBytesToString(stderrBytes), runErr
|
||||
}
|
||||
|
||||
// RunStdBytes runs the command and returns stdout/stderr as bytes. and store stderr to returned error (err combined with stderr).
|
||||
func (c *Command) RunStdBytes(ctx context.Context) (stdout, stderr []byte, runErr RunStdError) {
|
||||
return c.WithParentCallerInfo().runStdBytes(ctx)
|
||||
}
|
||||
|
||||
func (c *Command) runStdBytes(ctx context.Context) ([]byte, []byte, RunStdError) {
|
||||
if c.cmdStdout != nil || c.cmdStderr != nil {
|
||||
// it must panic here, otherwise there would be bugs if developers set other Stdin/Stderr by mistake, and it would be very difficult to debug
|
||||
panic("stdout and stderr field must be nil when using RunStdBytes")
|
||||
}
|
||||
stdoutBuf := &bytes.Buffer{}
|
||||
err := c.WithParentCallerInfo().WithStdoutBuffer(stdoutBuf).RunWithStderr(ctx)
|
||||
return stdoutBuf.Bytes(), c.cmdManagedStderr.Bytes(), err
|
||||
}
|
||||
|
||||
func (c *Command) DebugKill() {
|
||||
_ = c.cmd.Process.Kill()
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package gitcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/tempdir"
|
||||
"gitea.dev/modules/testlogger"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func testMain(m *testing.M) int {
|
||||
// FIXME: GIT-PACKAGE-DEPENDENCY: the dependency is not right.
|
||||
// "setting.Git.HomePath" is initialized in "git" package but really used in "gitcmd" package
|
||||
gitHomePath, cleanup, err := tempdir.OsTempDir("gitea-test").MkdirTempRandom("git-home")
|
||||
if err != nil {
|
||||
return testlogger.MainErrorf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
setting.Git.HomePath = gitHomePath
|
||||
return m.Run()
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
os.Exit(testMain(m))
|
||||
}
|
||||
|
||||
func TestRunWithContextStd(t *testing.T) {
|
||||
{
|
||||
cmd := NewCommand("--version")
|
||||
stdout, stderr, err := cmd.RunStdString(t.Context())
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, stderr)
|
||||
assert.Contains(t, stdout, "git version")
|
||||
}
|
||||
|
||||
{
|
||||
cmd := NewCommand("ls-tree", "no-such")
|
||||
stdout, stderr, err := cmd.RunStdString(t.Context())
|
||||
if assert.Error(t, err) {
|
||||
assert.Equal(t, stderr, err.Stderr())
|
||||
stderrLower := strings.ToLower(stderr) // see: IsStdErrorNotValidObjectName
|
||||
assert.Equal(t, "fatal: not a valid object name no-such\n", stderrLower)
|
||||
// FIXME: GIT-CMD-STDERR: it is a bad design, the stderr should not be put in the error message
|
||||
errLower := strings.ToLower(err.Error())
|
||||
assert.Equal(t, "exit status 128 - fatal: not a valid object name no-such", errLower)
|
||||
assert.Empty(t, stdout)
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
cmd := NewCommand("ls-tree", "no-such")
|
||||
stdout, stderr, err := cmd.RunStdBytes(t.Context())
|
||||
if assert.Error(t, err) {
|
||||
assert.Equal(t, string(stderr), err.Stderr())
|
||||
stderrLower := strings.ToLower(err.Stderr()) // see: IsStdErrorNotValidObjectName
|
||||
assert.Equal(t, "fatal: not a valid object name no-such\n", stderrLower)
|
||||
// FIXME: GIT-CMD-STDERR: it is a bad design, the stderr should not be put in the error message
|
||||
errLower := strings.ToLower(err.Error())
|
||||
assert.Equal(t, "exit status 128 - fatal: not a valid object name no-such", errLower)
|
||||
assert.Empty(t, stdout)
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
cmd := NewCommand()
|
||||
cmd.AddDynamicArguments("-test")
|
||||
assert.ErrorIs(t, cmd.Run(t.Context()), ErrBrokenCommand)
|
||||
|
||||
cmd = NewCommand()
|
||||
cmd.AddDynamicArguments("--test")
|
||||
assert.ErrorIs(t, cmd.Run(t.Context()), ErrBrokenCommand)
|
||||
}
|
||||
|
||||
{
|
||||
subCmd := "version"
|
||||
cmd := NewCommand().AddDynamicArguments(subCmd) // for test purpose only, the sub-command should never be dynamic for production
|
||||
stdout, stderr, err := cmd.RunStdString(t.Context())
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, stderr)
|
||||
assert.Contains(t, stdout, "git version")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitArgument(t *testing.T) {
|
||||
assert.True(t, isValidArgumentOption("-x"))
|
||||
assert.True(t, isValidArgumentOption("--xx"))
|
||||
assert.False(t, isValidArgumentOption(""))
|
||||
assert.False(t, isValidArgumentOption("x"))
|
||||
|
||||
assert.True(t, isSafeArgumentValue(""))
|
||||
assert.True(t, isSafeArgumentValue("x"))
|
||||
assert.False(t, isSafeArgumentValue("-x"))
|
||||
}
|
||||
|
||||
func TestCommandString(t *testing.T) {
|
||||
cmd := NewCommand("a", "-m msg", "it's a test", `say "hello"`)
|
||||
assert.Equal(t, cmd.prog+` a "-m msg" "it's a test" "say \"hello\""`, cmd.LogString())
|
||||
|
||||
cmd = NewCommand("url: https://a:b@c/", "/root/dir-a/dir-b")
|
||||
assert.Equal(t, cmd.prog+` "url: https://(masked)@c/" .../dir-a/dir-b`, cmd.LogString())
|
||||
|
||||
cmd = NewCommand("url: a:b@c/", "/root/dir-a/dir-b")
|
||||
assert.Equal(t, cmd.prog+` "url: (masked)@c/" .../dir-a/dir-b`, cmd.LogString())
|
||||
}
|
||||
|
||||
func TestRunStdError(t *testing.T) {
|
||||
e := &runStdError{stderr: "some error"}
|
||||
var err RunStdError = e
|
||||
|
||||
var asErr RunStdError
|
||||
require.ErrorAs(t, err, &asErr)
|
||||
require.Equal(t, "some error", asErr.Stderr())
|
||||
|
||||
require.ErrorAs(t, fmt.Errorf("wrapped %w", err), &asErr)
|
||||
}
|
||||
|
||||
func TestRunWithContextTimeout(t *testing.T) {
|
||||
t.Run("NoTimeout", func(t *testing.T) {
|
||||
// 'git --version' does not block so it must be finished before the timeout triggered.
|
||||
err := NewCommand("--version").Run(t.Context())
|
||||
require.NoError(t, err)
|
||||
})
|
||||
t.Run("WithTimeout", func(t *testing.T) {
|
||||
cmd := NewCommand("hash-object", "--stdin")
|
||||
_, _, pipeClose := cmd.MakeStdinStdoutPipe()
|
||||
defer pipeClose()
|
||||
err := cmd.WithTimeout(1 * time.Millisecond).Run(t.Context())
|
||||
require.ErrorIs(t, err, context.DeadlineExceeded)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package gitcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
type Context interface {
|
||||
context.Context
|
||||
|
||||
// CancelPipeline is a helper function to cancel the command context (kill the command) with a specific error cause,
|
||||
// it returns the same error for convenience to break the PipelineFunc easily
|
||||
CancelPipeline(err error) error
|
||||
|
||||
// In the future, this interface will be extended to support stdio pipe readers/writers
|
||||
}
|
||||
|
||||
type cmdContext struct {
|
||||
context.Context
|
||||
cmd *Command
|
||||
}
|
||||
|
||||
func (c *cmdContext) CancelPipeline(err error) error {
|
||||
// pipelineError is used to distinguish between:
|
||||
// * context canceled by pipeline caller with/without error (normal cancellation)
|
||||
// * context canceled by parent context (still context.Canceled error)
|
||||
// * other causes
|
||||
c.cmd.cmdCancel(pipelineError{err})
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package gitcmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
var GitExecutable = "git" // the command name of git, will be updated to an absolute path during initialization
|
||||
|
||||
// SetExecutablePath changes the path of git executable and checks the file permission and version.
|
||||
func SetExecutablePath(path string) error {
|
||||
// If path is empty, we use the default value of GitExecutable "git" to search for the location of git.
|
||||
if path != "" {
|
||||
GitExecutable = path
|
||||
}
|
||||
absPath, err := exec.LookPath(GitExecutable)
|
||||
if err != nil {
|
||||
return fmt.Errorf("git not found: %w", err)
|
||||
}
|
||||
GitExecutable = absPath
|
||||
return nil
|
||||
}
|
||||
|
||||
// HomeDir is the home dir for git to store the global config file used by Gitea internally
|
||||
func HomeDir() string {
|
||||
if setting.Git.HomePath == "" {
|
||||
// strict check, make sure the git module is initialized correctly.
|
||||
// attention: when the git module is called in gitea sub-command (serv/hook), the log module might not obviously show messages to users/developers.
|
||||
// for example: if there is gitea git hook code calling NewCommand before git.InitXxx, the integration test won't show the real failure reasons.
|
||||
log.Fatal("Unable to init Git's HomeDir, incorrect initialization of the setting and git modules")
|
||||
return ""
|
||||
}
|
||||
return setting.Git.HomePath
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package gitcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type RunStdError interface {
|
||||
error
|
||||
Unwrap() error
|
||||
Stderr() string
|
||||
}
|
||||
|
||||
type runStdError struct {
|
||||
err error // usually the low-level error like `*exec.ExitError`
|
||||
stderr string // git command's stderr output
|
||||
errMsg string // the cached error message for Error() method
|
||||
}
|
||||
|
||||
func (r *runStdError) Error() string {
|
||||
// FIXME: GIT-CMD-STDERR: it is a bad design, the stderr should not be put in the error message
|
||||
// But a lot of code only checks `strings.Contains(err.Error(), "git error")`
|
||||
if r.errMsg == "" {
|
||||
r.errMsg = fmt.Sprintf("%s - %s", r.err.Error(), strings.TrimSpace(r.stderr))
|
||||
}
|
||||
return r.errMsg
|
||||
}
|
||||
|
||||
func (r *runStdError) Unwrap() error {
|
||||
return r.err
|
||||
}
|
||||
|
||||
func (r *runStdError) Stderr() string {
|
||||
return r.stderr
|
||||
}
|
||||
|
||||
func ErrorAsStderr(err error) (string, bool) {
|
||||
var runErr RunStdError
|
||||
if errors.As(err, &runErr) {
|
||||
return runErr.Stderr(), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func StderrHasPrefix(err error, prefix string) bool {
|
||||
stderr, ok := ErrorAsStderr(err)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(stderr, prefix)
|
||||
}
|
||||
|
||||
func StderrContains(err error, sub string) bool {
|
||||
stderr, ok := ErrorAsStderr(err)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(stderr, sub)
|
||||
}
|
||||
|
||||
func IsErrorExitCode(err error, code int) bool {
|
||||
var exitError *exec.ExitError
|
||||
if errors.As(err, &exitError) {
|
||||
return exitError.ExitCode() == code
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func IsErrorSignalKilled(err error) bool {
|
||||
var exitError *exec.ExitError
|
||||
return errors.As(err, &exitError) && exitError.String() == "signal: killed"
|
||||
}
|
||||
|
||||
func IsErrorCanceledOrKilled(err error) bool {
|
||||
// When "cancel()" a git command's context, the returned error of "Run()" could be one of them:
|
||||
// - context.Canceled
|
||||
// - *exec.ExitError: "signal: killed"
|
||||
// TODO: in the future, we need to use unified error type from gitcmd.Run to check whether it is manually canceled
|
||||
return errors.Is(err, context.Canceled) || IsErrorSignalKilled(err)
|
||||
}
|
||||
|
||||
func IsStdErrorNotValidObjectName(err error) bool {
|
||||
stderr, ok := ErrorAsStderr(err)
|
||||
// Git is lowercasing the "fatal: Not a valid object name" error message
|
||||
// ref: https://lore.kernel.org/git/pull.2052.git.1771836302101.gitgitgadget@gmail.com
|
||||
return ok && strings.Contains(strings.ToLower(stderr), "fatal: not a valid object name")
|
||||
}
|
||||
|
||||
type pipelineError struct {
|
||||
error
|
||||
}
|
||||
|
||||
func (e pipelineError) Unwrap() error {
|
||||
return e.error
|
||||
}
|
||||
|
||||
func wrapPipelineError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return pipelineError{err}
|
||||
}
|
||||
|
||||
func UnwrapPipelineError(err error) (error, bool) { //nolint:revive // this is for error unwrapping
|
||||
var pe pipelineError
|
||||
if errors.As(err, &pe) {
|
||||
return pe.error, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package gitcmd
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
type PipeBufferReader interface {
|
||||
// Read should be used in the same goroutine as command's Wait
|
||||
// When Reader in one goroutine, command's Wait in another goroutine, then the command exits, the pipe will be closed:
|
||||
// * If the Reader goroutine reads faster, it will read all remaining data and then get io.EOF
|
||||
// * But this io.EOF doesn't mean the Reader has gotten complete data, the data might still be corrupted
|
||||
// * If the Reader goroutine reads slower, it will get os.ErrClosed because the os.Pipe is closed ahead when the command exits
|
||||
//
|
||||
// When using 2 goroutines, no clear solution to distinguish these two cases or make Reader knows whether the data is complete
|
||||
// It should avoid using Reader in a different goroutine than the command if the Read error needs to be handled.
|
||||
Read(p []byte) (n int, err error)
|
||||
Bytes() []byte
|
||||
}
|
||||
|
||||
type PipeBufferWriter interface {
|
||||
Write(p []byte) (n int, err error)
|
||||
Bytes() []byte
|
||||
}
|
||||
|
||||
type PipeReader interface {
|
||||
io.ReadCloser
|
||||
internalOnly()
|
||||
}
|
||||
|
||||
type pipeReader struct {
|
||||
f *os.File
|
||||
}
|
||||
|
||||
func (r *pipeReader) internalOnly() {}
|
||||
|
||||
func (r *pipeReader) Read(p []byte) (n int, err error) {
|
||||
return r.f.Read(p)
|
||||
}
|
||||
|
||||
func (r *pipeReader) Close() error {
|
||||
return r.f.Close()
|
||||
}
|
||||
|
||||
type PipeWriter interface {
|
||||
io.WriteCloser
|
||||
internalOnly()
|
||||
}
|
||||
|
||||
type pipeWriter struct {
|
||||
f *os.File
|
||||
}
|
||||
|
||||
func (w *pipeWriter) internalOnly() {}
|
||||
|
||||
func (w *pipeWriter) Close() error {
|
||||
return w.f.Close()
|
||||
}
|
||||
|
||||
func (w *pipeWriter) Write(p []byte) (n int, err error) {
|
||||
return w.f.Write(p)
|
||||
}
|
||||
|
||||
func (w *pipeWriter) DrainBeforeClose() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type pipeNull struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (p *pipeNull) internalOnly() {}
|
||||
|
||||
func (p *pipeNull) Read([]byte) (n int, err error) {
|
||||
return 0, p.err
|
||||
}
|
||||
|
||||
func (p *pipeNull) Write([]byte) (n int, err error) {
|
||||
return 0, p.err
|
||||
}
|
||||
|
||||
func (p *pipeNull) Close() error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// Copyright 2015 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/process"
|
||||
)
|
||||
|
||||
// GPGSettings represents the default GPG settings for this repository
|
||||
type GPGSettings struct {
|
||||
Sign bool
|
||||
KeyID string
|
||||
Email string
|
||||
Name string
|
||||
PublicKeyContent string
|
||||
Format string
|
||||
}
|
||||
|
||||
// LoadPublicKeyContent will load the key from gpg
|
||||
func (gpgSettings *GPGSettings) LoadPublicKeyContent() error {
|
||||
if gpgSettings.PublicKeyContent != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if gpgSettings.Format == SigningKeyFormatSSH {
|
||||
content, err := os.ReadFile(gpgSettings.KeyID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to read SSH public key file: %s, %w", gpgSettings.KeyID, err)
|
||||
}
|
||||
gpgSettings.PublicKeyContent = string(content)
|
||||
return nil
|
||||
}
|
||||
content, stderr, err := process.GetManager().Exec(
|
||||
"gpg -a --export",
|
||||
"gpg", "-a", "--export", gpgSettings.KeyID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to get default signing key: %s, %s, %w", gpgSettings.KeyID, stderr, err)
|
||||
}
|
||||
gpgSettings.PublicKeyContent = content
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
loadPublicGPGKeyMutex sync.RWMutex
|
||||
globalGPGSettings *GPGSettings
|
||||
)
|
||||
|
||||
// GetDefaultPublicGPGKey will return and cache the default public GPG settings
|
||||
func GetDefaultPublicGPGKey(ctx context.Context, forceUpdate bool) (*GPGSettings, error) {
|
||||
if !forceUpdate {
|
||||
loadPublicGPGKeyMutex.RLock()
|
||||
if globalGPGSettings != nil {
|
||||
defer loadPublicGPGKeyMutex.RUnlock()
|
||||
return globalGPGSettings, nil
|
||||
}
|
||||
loadPublicGPGKeyMutex.RUnlock()
|
||||
}
|
||||
|
||||
loadPublicGPGKeyMutex.Lock()
|
||||
defer loadPublicGPGKeyMutex.Unlock()
|
||||
|
||||
if globalGPGSettings != nil && !forceUpdate {
|
||||
return globalGPGSettings, nil
|
||||
}
|
||||
|
||||
globalGPGSettings = &GPGSettings{
|
||||
Sign: true,
|
||||
}
|
||||
|
||||
value, _, _ := gitcmd.NewCommand("config", "--global", "--get", "commit.gpgsign").RunStdString(ctx)
|
||||
sign, valid := ParseBool(strings.TrimSpace(value))
|
||||
if !sign || !valid {
|
||||
globalGPGSettings.Sign = false
|
||||
return globalGPGSettings, nil
|
||||
}
|
||||
|
||||
signingKey, _, _ := gitcmd.NewCommand("config", "--global", "--get", "user.signingkey").RunStdString(ctx)
|
||||
globalGPGSettings.KeyID = strings.TrimSpace(signingKey)
|
||||
|
||||
format, _, _ := gitcmd.NewCommand("config", "--global", "--default", SigningKeyFormatOpenPGP, "--get", "gpg.format").RunStdString(ctx)
|
||||
globalGPGSettings.Format = strings.TrimSpace(format)
|
||||
|
||||
defaultEmail, _, _ := gitcmd.NewCommand("config", "--global", "--get", "user.email").RunStdString(ctx)
|
||||
globalGPGSettings.Email = strings.TrimSpace(defaultEmail)
|
||||
|
||||
defaultName, _, _ := gitcmd.NewCommand("config", "--global", "--get", "user.name").RunStdString(ctx)
|
||||
globalGPGSettings.Name = strings.TrimSpace(defaultName)
|
||||
|
||||
if err := globalGPGSettings.LoadPublicKeyContent(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return globalGPGSettings, nil
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
type GrepResult struct {
|
||||
Filename string
|
||||
LineNumbers []int
|
||||
LineCodes []string
|
||||
}
|
||||
|
||||
type GrepModeType string
|
||||
|
||||
const (
|
||||
GrepModeExact GrepModeType = "exact"
|
||||
GrepModeWords GrepModeType = "words"
|
||||
GrepModeRegexp GrepModeType = "regexp"
|
||||
)
|
||||
|
||||
type GrepOptions struct {
|
||||
RefName string
|
||||
MaxResultLimit int
|
||||
ContextLineNumber int
|
||||
GrepMode GrepModeType
|
||||
MaxLineLength int // the maximum length of a line to parse, exceeding chars will be truncated
|
||||
PathspecList []string
|
||||
}
|
||||
|
||||
// grepSearchTimeout is the timeout for git grep search, it should be long enough to get results
|
||||
// but not too long to cause performance issues
|
||||
const grepSearchTimeout = 30 * time.Second
|
||||
|
||||
func GrepSearch(ctx context.Context, repo *Repository, search string, opts GrepOptions) ([]*GrepResult, error) {
|
||||
/*
|
||||
The output is like this ( "^@" means \x00):
|
||||
|
||||
HEAD:.air.toml
|
||||
6^@bin = "gitea"
|
||||
|
||||
HEAD:.changelog.yml
|
||||
2^@repo: go-gitea/gitea
|
||||
*/
|
||||
var results []*GrepResult
|
||||
cmd := gitcmd.NewCommand("grep", "--null", "--break", "--heading", "--line-number", "--full-name")
|
||||
cmd.AddOptionValues("--context", strconv.Itoa(opts.ContextLineNumber))
|
||||
switch opts.GrepMode {
|
||||
case GrepModeExact:
|
||||
cmd.AddArguments("--fixed-strings")
|
||||
cmd.AddOptionValues("-e", strings.TrimLeft(search, "-"))
|
||||
case GrepModeRegexp:
|
||||
cmd.AddArguments("--perl-regexp")
|
||||
cmd.AddOptionValues("-e", strings.TrimLeft(search, "-"))
|
||||
default: /* words */
|
||||
words := strings.Fields(search)
|
||||
cmd.AddArguments("--fixed-strings", "--ignore-case")
|
||||
for i, word := range words {
|
||||
cmd.AddOptionValues("-e", strings.TrimLeft(word, "-"))
|
||||
if i < len(words)-1 {
|
||||
cmd.AddOptionValues("--and")
|
||||
}
|
||||
}
|
||||
}
|
||||
cmd.AddDynamicArguments(util.IfZero(opts.RefName, "HEAD"))
|
||||
cmd.AddDashesAndList(opts.PathspecList...)
|
||||
opts.MaxResultLimit = util.IfZero(opts.MaxResultLimit, 50)
|
||||
|
||||
stdoutReader, stdoutReaderClose := cmd.MakeStdoutPipe()
|
||||
defer stdoutReaderClose()
|
||||
err := cmd.WithDir(repo.Path).
|
||||
WithTimeout(grepSearchTimeout).
|
||||
WithPipelineFunc(func(ctx gitcmd.Context) error {
|
||||
isInBlock := false
|
||||
rd := bufio.NewReaderSize(stdoutReader, util.IfZero(opts.MaxLineLength, 16*1024))
|
||||
var res *GrepResult
|
||||
for {
|
||||
lineBytes, isPrefix, err := rd.ReadLine()
|
||||
if isPrefix {
|
||||
lineBytes = slices.Clone(lineBytes)
|
||||
for isPrefix && err == nil {
|
||||
_, isPrefix, err = rd.ReadLine()
|
||||
}
|
||||
}
|
||||
if len(lineBytes) == 0 && err != nil {
|
||||
break
|
||||
}
|
||||
line := string(lineBytes) // the memory of lineBytes is mutable
|
||||
if !isInBlock {
|
||||
if _ /* ref */, filename, ok := strings.Cut(line, ":"); ok {
|
||||
isInBlock = true
|
||||
res = &GrepResult{Filename: filename}
|
||||
results = append(results, res)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if line == "" {
|
||||
if len(results) >= opts.MaxResultLimit {
|
||||
return ctx.CancelPipeline(nil)
|
||||
}
|
||||
isInBlock = false
|
||||
continue
|
||||
}
|
||||
if line == "--" {
|
||||
continue
|
||||
}
|
||||
if lineNum, lineCode, ok := strings.Cut(line, "\x00"); ok {
|
||||
lineNumInt, _ := strconv.Atoi(lineNum)
|
||||
res.LineNumbers = append(res.LineNumbers, lineNumInt)
|
||||
res.LineCodes = append(res.LineCodes, lineCode)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
RunWithStderr(ctx)
|
||||
// git grep exits by cancel (killed), usually it is caused by the limit of results
|
||||
if gitcmd.IsErrorExitCode(err, -1) && err.Stderr() == "" {
|
||||
return results, nil
|
||||
}
|
||||
// git grep exits with 1 if no results are found
|
||||
if gitcmd.IsErrorExitCode(err, 1) && err.Stderr() == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
return nil, fmt.Errorf("unable to run git grep: %w", err)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGrepSearch(t *testing.T) {
|
||||
repo, err := OpenRepository(t.Context(), filepath.Join(testReposDir, "language_stats_repo"))
|
||||
assert.NoError(t, err)
|
||||
defer repo.Close()
|
||||
|
||||
res, err := GrepSearch(t.Context(), repo, "void", GrepOptions{})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []*GrepResult{
|
||||
{
|
||||
Filename: "java-hello/main.java",
|
||||
LineNumbers: []int{3},
|
||||
LineCodes: []string{" public static void main(String[] args)"},
|
||||
},
|
||||
{
|
||||
Filename: "main.vendor.java",
|
||||
LineNumbers: []int{3},
|
||||
LineCodes: []string{" public static void main(String[] args)"},
|
||||
},
|
||||
}, res)
|
||||
|
||||
res, err = GrepSearch(t.Context(), repo, "void", GrepOptions{PathspecList: []string{":(glob)java-hello/*"}})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []*GrepResult{
|
||||
{
|
||||
Filename: "java-hello/main.java",
|
||||
LineNumbers: []int{3},
|
||||
LineCodes: []string{" public static void main(String[] args)"},
|
||||
},
|
||||
}, res)
|
||||
|
||||
res, err = GrepSearch(t.Context(), repo, "void", GrepOptions{PathspecList: []string{":(glob,exclude)java-hello/*"}})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []*GrepResult{
|
||||
{
|
||||
Filename: "main.vendor.java",
|
||||
LineNumbers: []int{3},
|
||||
LineCodes: []string{" public static void main(String[] args)"},
|
||||
},
|
||||
}, res)
|
||||
|
||||
res, err = GrepSearch(t.Context(), repo, "void", GrepOptions{MaxResultLimit: 1})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []*GrepResult{
|
||||
{
|
||||
Filename: "java-hello/main.java",
|
||||
LineNumbers: []int{3},
|
||||
LineCodes: []string{" public static void main(String[] args)"},
|
||||
},
|
||||
}, res)
|
||||
|
||||
res, err = GrepSearch(t.Context(), repo, "void", GrepOptions{MaxResultLimit: 1, MaxLineLength: 39})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []*GrepResult{
|
||||
{
|
||||
Filename: "java-hello/main.java",
|
||||
LineNumbers: []int{3},
|
||||
LineCodes: []string{" public static void main(String[] arg"},
|
||||
},
|
||||
}, res)
|
||||
|
||||
res, err = GrepSearch(t.Context(), repo, "no-such-content", GrepOptions{})
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, res)
|
||||
|
||||
res, err = GrepSearch(t.Context(), &Repository{Path: "no-such-git-repo"}, "no-such-content", GrepOptions{})
|
||||
assert.Error(t, err)
|
||||
assert.Empty(t, res)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// Copyright 2015 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
// hookNames is a list of Git server hooks' name that are supported.
|
||||
var hookNames = []string{
|
||||
"pre-receive",
|
||||
"update",
|
||||
"post-receive",
|
||||
}
|
||||
|
||||
// ErrNotValidHook error when a git hook is not valid
|
||||
var ErrNotValidHook = errors.New("not a valid Git hook")
|
||||
|
||||
// IsValidHookName returns true if given name is a valid Git hook.
|
||||
func IsValidHookName(name string) bool {
|
||||
return slices.Contains(hookNames, name)
|
||||
}
|
||||
|
||||
// Hook represents a Git hook.
|
||||
type Hook struct {
|
||||
name string
|
||||
IsActive bool // Indicates whether repository has this hook.
|
||||
Content string // Content of hook if it's active.
|
||||
Sample string // Sample content from Git.
|
||||
path string // Hook file path.
|
||||
}
|
||||
|
||||
// GetHook returns a Git hook by given name and repository.
|
||||
func GetHook(repoPath, name string) (*Hook, error) {
|
||||
if !IsValidHookName(name) {
|
||||
return nil, ErrNotValidHook
|
||||
}
|
||||
h := &Hook{
|
||||
name: name,
|
||||
path: filepath.Join(repoPath, filepath.Join("hooks", name+".d", name)),
|
||||
}
|
||||
if data, err := os.ReadFile(h.path); err == nil {
|
||||
h.IsActive = true
|
||||
h.Content = string(data)
|
||||
return h, nil
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
samplePath := filepath.Join(repoPath, "hooks", name+".sample")
|
||||
if data, err := os.ReadFile(samplePath); err == nil {
|
||||
h.Sample = string(data)
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// Name return the name of the hook
|
||||
func (h *Hook) Name() string {
|
||||
return h.name
|
||||
}
|
||||
|
||||
// Update updates hook settings.
|
||||
func (h *Hook) Update() error {
|
||||
if len(strings.TrimSpace(h.Content)) == 0 {
|
||||
exist, err := util.IsExist(h.path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exist {
|
||||
err := util.Remove(h.path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
h.IsActive = false
|
||||
return nil
|
||||
}
|
||||
d := filepath.Dir(h.path)
|
||||
if err := os.MkdirAll(d, os.ModePerm); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err := os.WriteFile(h.path, []byte(strings.ReplaceAll(h.Content, "\r", "")), os.ModePerm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h.IsActive = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListHooks returns a list of Git hooks of given repository.
|
||||
func ListHooks(repoPath string) (_ []*Hook, err error) {
|
||||
exist, err := util.IsDir(filepath.Join(repoPath, "hooks"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !exist {
|
||||
return nil, errors.New("hooks path does not exist")
|
||||
}
|
||||
|
||||
hooks := make([]*Hook, len(hookNames))
|
||||
for i, name := range hookNames {
|
||||
hooks[i], err = GetHook(repoPath, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return hooks, nil
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package internal
|
||||
|
||||
// CmdArg represents a command argument for git command, and it will be used for the git command directly without any further processing.
|
||||
// In most cases, you should use the "AddXxx" functions to add arguments, but not use this type directly.
|
||||
// Casting a risky (user-provided) string to CmdArg would cause security issues if it's injected with a "--xxx" argument.
|
||||
type CmdArg string
|
||||
@@ -0,0 +1,77 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
// Based on https://git-scm.com/docs/git-config#Documentation/git-config.txt-gpgformat
|
||||
const (
|
||||
SigningKeyFormatOpenPGP = "openpgp" // for GPG keys, the expected default of git cli
|
||||
SigningKeyFormatSSH = "ssh"
|
||||
)
|
||||
|
||||
// SigningKey represents an instance key info which will be used to sign git commits.
|
||||
// FIXME: need to refactor it to a new name, this name conflicts with the variable names for "asymkey.GPGKey" in many places.
|
||||
type SigningKey struct {
|
||||
KeyID string
|
||||
Format string
|
||||
}
|
||||
|
||||
func (s *SigningKey) String() string {
|
||||
// Do not expose KeyID
|
||||
// In case the key is a file path and the struct is rendered in a template, then the server path will be exposed.
|
||||
setting.PanicInDevOrTesting("don't call SigningKey.String() - it exposes the KeyID which might be a local file path")
|
||||
return "SigningKey:" + s.Format
|
||||
}
|
||||
|
||||
// GetSigningKey returns the KeyID and git Signature for the repo
|
||||
func GetSigningKey(ctx context.Context) (*SigningKey, *Signature) {
|
||||
if setting.Repository.Signing.SigningKey == "none" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if setting.Repository.Signing.SigningKey == "default" || setting.Repository.Signing.SigningKey == "" {
|
||||
// Can ignore the error here as it means that commit.gpgsign is not set
|
||||
value, _, _ := gitcmd.NewCommand("config", "--global", "--get", "commit.gpgsign").RunStdString(ctx)
|
||||
sign, valid := ParseBool(strings.TrimSpace(value))
|
||||
if !sign || !valid {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
format, _, _ := gitcmd.NewCommand("config", "--global", "--default", SigningKeyFormatOpenPGP, "--get", "gpg.format").RunStdString(ctx)
|
||||
signingKey, _, _ := gitcmd.NewCommand("config", "--global", "--get", "user.signingkey").RunStdString(ctx)
|
||||
signingName, _, _ := gitcmd.NewCommand("config", "--global", "--get", "user.name").RunStdString(ctx)
|
||||
signingEmail, _, _ := gitcmd.NewCommand("config", "--global", "--get", "user.email").RunStdString(ctx)
|
||||
|
||||
if strings.TrimSpace(signingKey) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &SigningKey{
|
||||
KeyID: strings.TrimSpace(signingKey),
|
||||
Format: strings.TrimSpace(format),
|
||||
}, &Signature{
|
||||
Name: strings.TrimSpace(signingName),
|
||||
Email: strings.TrimSpace(signingEmail),
|
||||
}
|
||||
}
|
||||
|
||||
if setting.Repository.Signing.SigningKey == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &SigningKey{
|
||||
KeyID: setting.Repository.Signing.SigningKey,
|
||||
Format: setting.Repository.Signing.SigningFormat,
|
||||
}, &Signature{
|
||||
Name: setting.Repository.Signing.SigningName,
|
||||
Email: setting.Repository.Signing.SigningEmail,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package languagestats
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/git/attribute"
|
||||
)
|
||||
|
||||
const (
|
||||
fileSizeLimit int64 = 16 * 1024 // 16 KiB
|
||||
bigFileSize int64 = 1024 * 1024 // 1 MiB
|
||||
)
|
||||
|
||||
// mergeLanguageStats mergers language names with different cases. The name with most upper case letters is used.
|
||||
func mergeLanguageStats(stats map[string]int64) map[string]int64 {
|
||||
names := map[string]struct {
|
||||
uniqueName string
|
||||
upperCount int
|
||||
}{}
|
||||
|
||||
countUpper := func(s string) (count int) {
|
||||
for _, r := range s {
|
||||
if unicode.IsUpper(r) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
for name := range stats {
|
||||
cnt := countUpper(name)
|
||||
lower := strings.ToLower(name)
|
||||
if cnt >= names[lower].upperCount {
|
||||
names[lower] = struct {
|
||||
uniqueName string
|
||||
upperCount int
|
||||
}{uniqueName: name, upperCount: cnt}
|
||||
}
|
||||
}
|
||||
|
||||
res := make(map[string]int64, len(names))
|
||||
for name, num := range stats {
|
||||
res[names[strings.ToLower(name)].uniqueName] += num
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// GetFileLanguage tries to get the (linguist) language of the file content
|
||||
func GetFileLanguage(ctx context.Context, gitRepo *git.Repository, treeish, treePath string) (string, error) {
|
||||
attributesMap, err := attribute.CheckAttributes(ctx, gitRepo, treeish, attribute.CheckAttributeOpts{
|
||||
Attributes: []string{attribute.LinguistLanguage, attribute.GitlabLanguage},
|
||||
Filenames: []string{treePath},
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return attributesMap[treePath].GetLanguage().Value(), nil
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build gogit
|
||||
|
||||
package languagestats
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
|
||||
"gitea.dev/modules/analyze"
|
||||
git_module "gitea.dev/modules/git"
|
||||
"gitea.dev/modules/git/attribute"
|
||||
"gitea.dev/modules/optional"
|
||||
|
||||
"github.com/go-enry/go-enry/v2"
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
"github.com/go-git/go-git/v5/plumbing/object"
|
||||
)
|
||||
|
||||
// GetLanguageStats calculates language stats for git repository at specified commit
|
||||
func GetLanguageStats(repo *git_module.Repository, commitID string) (map[string]int64, error) {
|
||||
r, err := git.PlainOpen(repo.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rev, err := r.ResolveRevision(plumbing.Revision(commitID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
commit, err := r.CommitObject(*rev)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tree, err := commit.Tree()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
checker, err := attribute.NewBatchChecker(repo, commitID, attribute.LinguistAttributes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer checker.Close()
|
||||
|
||||
// sizes contains the current calculated size of all files by language
|
||||
sizes := make(map[string]int64)
|
||||
// by default we will only count the sizes of programming languages or markup languages
|
||||
// unless they are explicitly set using linguist-language
|
||||
includedLanguage := map[string]bool{}
|
||||
// or if there's only one language in the repository
|
||||
firstExcludedLanguage := ""
|
||||
firstExcludedLanguageSize := int64(0)
|
||||
|
||||
err = tree.Files().ForEach(func(f *object.File) error {
|
||||
if f.Size == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
isVendored := optional.None[bool]()
|
||||
isGenerated := optional.None[bool]()
|
||||
isDocumentation := optional.None[bool]()
|
||||
isDetectable := optional.None[bool]()
|
||||
|
||||
attrs, err := checker.CheckPath(f.Name)
|
||||
if err == nil {
|
||||
isVendored = attrs.GetVendored()
|
||||
if isVendored.ValueOrDefault(false) {
|
||||
return nil
|
||||
}
|
||||
|
||||
isGenerated = attrs.GetGenerated()
|
||||
if isGenerated.ValueOrDefault(false) {
|
||||
return nil
|
||||
}
|
||||
|
||||
isDocumentation = attrs.GetDocumentation()
|
||||
if isDocumentation.ValueOrDefault(false) {
|
||||
return nil
|
||||
}
|
||||
|
||||
isDetectable = attrs.GetDetectable()
|
||||
if !isDetectable.ValueOrDefault(true) {
|
||||
return nil
|
||||
}
|
||||
|
||||
hasLanguage := attrs.GetLanguage()
|
||||
if hasLanguage.Value() != "" {
|
||||
language := hasLanguage.Value()
|
||||
|
||||
// group languages, such as Pug -> HTML; SCSS -> CSS
|
||||
group := enry.GetLanguageGroup(language)
|
||||
if len(group) != 0 {
|
||||
language = group
|
||||
}
|
||||
|
||||
// this language will always be added to the size
|
||||
sizes[language] += f.Size
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if (!isVendored.Has() && analyze.IsVendor(f.Name)) ||
|
||||
enry.IsDotFile(f.Name) ||
|
||||
(!isDocumentation.Has() && enry.IsDocumentation(f.Name)) ||
|
||||
(!isDetectable.Has() && enry.IsConfiguration(f.Name)) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// If content can not be read or file is too big just do detection by filename
|
||||
var content []byte
|
||||
if f.Size <= bigFileSize {
|
||||
content, _ = readFile(f, fileSizeLimit)
|
||||
}
|
||||
if !isGenerated.Has() && enry.IsGenerated(f.Name, content) {
|
||||
return nil
|
||||
}
|
||||
|
||||
language := analyze.GetCodeLanguage(f.Name, content)
|
||||
if language == enry.OtherLanguage || language == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// group languages, such as Pug -> HTML; SCSS -> CSS
|
||||
group := enry.GetLanguageGroup(language)
|
||||
if group != "" {
|
||||
language = group
|
||||
}
|
||||
|
||||
included, checked := includedLanguage[language]
|
||||
if !checked {
|
||||
langtype := enry.GetLanguageType(language)
|
||||
included = langtype == enry.Programming || langtype == enry.Markup
|
||||
includedLanguage[language] = included
|
||||
}
|
||||
if included || isDetectable.ValueOrDefault(false) {
|
||||
sizes[language] += f.Size
|
||||
} else if len(sizes) == 0 && (firstExcludedLanguage == "" || firstExcludedLanguage == language) {
|
||||
firstExcludedLanguage = language
|
||||
firstExcludedLanguageSize += f.Size
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If there are no included languages add the first excluded language
|
||||
if len(sizes) == 0 && firstExcludedLanguage != "" {
|
||||
sizes[firstExcludedLanguage] = firstExcludedLanguageSize
|
||||
}
|
||||
|
||||
return mergeLanguageStats(sizes), nil
|
||||
}
|
||||
|
||||
func readFile(f *object.File, limit int64) ([]byte, error) {
|
||||
r, err := f.Reader()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
if limit <= 0 {
|
||||
return io.ReadAll(r)
|
||||
}
|
||||
|
||||
size := f.Size
|
||||
if limit > 0 && size > limit {
|
||||
size = limit
|
||||
}
|
||||
buf := bytes.NewBuffer(nil)
|
||||
buf.Grow(int(size))
|
||||
_, err = io.Copy(buf, io.LimitReader(r, limit))
|
||||
return buf.Bytes(), err
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !gogit
|
||||
|
||||
package languagestats
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
|
||||
"gitea.dev/modules/analyze"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/git/attribute"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
|
||||
"github.com/go-enry/go-enry/v2"
|
||||
)
|
||||
|
||||
// GetLanguageStats calculates language stats for git repository at specified commit
|
||||
func GetLanguageStats(repo *git.Repository, commitID string) (map[string]int64, error) {
|
||||
// We will feed the commit IDs in order into cat-file --batch, followed by blobs as necessary.
|
||||
// so let's create a batch stdin and stdout
|
||||
batch, cancel, err := repo.CatFileBatch(repo.Ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
commitInfo, batchReader, err := batch.QueryContent(commitID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if commitInfo.Type != "commit" {
|
||||
log.Debug("Unable to get commit for: %s. Err: %v", commitID, err)
|
||||
return nil, git.ErrNotExist{ID: commitID}
|
||||
}
|
||||
|
||||
sha, err := git.NewIDFromString(commitInfo.ID)
|
||||
if err != nil {
|
||||
log.Debug("Unable to get commit for: %s. Err: %v", commitID, err)
|
||||
return nil, git.ErrNotExist{ID: commitID}
|
||||
}
|
||||
|
||||
commit, err := git.CommitFromReader(repo, sha, io.LimitReader(batchReader, commitInfo.Size))
|
||||
if err != nil {
|
||||
log.Debug("Unable to get commit for: %s. Err: %v", commitID, err)
|
||||
return nil, err
|
||||
}
|
||||
if _, err = batchReader.Discard(1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tree := commit.Tree
|
||||
|
||||
entries, err := tree.ListEntriesRecursiveWithSize()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
checker, err := attribute.NewBatchChecker(repo, commitID, attribute.LinguistAttributes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer checker.Close()
|
||||
|
||||
contentBuf := bytes.Buffer{}
|
||||
var content []byte
|
||||
|
||||
// sizes contains the current calculated size of all files by language
|
||||
sizes := make(map[string]int64)
|
||||
// by default we will only count the sizes of programming languages or markup languages
|
||||
// unless they are explicitly set using linguist-language
|
||||
includedLanguage := map[string]bool{}
|
||||
// or if there's only one language in the repository
|
||||
firstExcludedLanguage := ""
|
||||
firstExcludedLanguageSize := int64(0)
|
||||
|
||||
for _, f := range entries {
|
||||
select {
|
||||
case <-repo.Ctx.Done():
|
||||
return sizes, repo.Ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
contentBuf.Reset()
|
||||
content = contentBuf.Bytes()
|
||||
|
||||
if f.Size() == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
isVendored := optional.None[bool]()
|
||||
isDocumentation := optional.None[bool]()
|
||||
isDetectable := optional.None[bool]()
|
||||
|
||||
attrs, err := checker.CheckPath(f.Name())
|
||||
attrLinguistGenerated := optional.None[bool]()
|
||||
if err == nil {
|
||||
if isVendored = attrs.GetVendored(); isVendored.ValueOrDefault(false) {
|
||||
continue
|
||||
}
|
||||
|
||||
if attrLinguistGenerated = attrs.GetGenerated(); attrLinguistGenerated.ValueOrDefault(false) {
|
||||
continue
|
||||
}
|
||||
|
||||
if isDocumentation = attrs.GetDocumentation(); isDocumentation.ValueOrDefault(false) {
|
||||
continue
|
||||
}
|
||||
|
||||
if isDetectable = attrs.GetDetectable(); !isDetectable.ValueOrDefault(true) {
|
||||
continue
|
||||
}
|
||||
|
||||
if hasLanguage := attrs.GetLanguage(); hasLanguage.Value() != "" {
|
||||
language := hasLanguage.Value()
|
||||
|
||||
// group languages, such as Pug -> HTML; SCSS -> CSS
|
||||
group := enry.GetLanguageGroup(language)
|
||||
if len(group) != 0 {
|
||||
language = group
|
||||
}
|
||||
|
||||
// this language will always be added to the size
|
||||
sizes[language] += f.Size()
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (!isVendored.Has() && analyze.IsVendor(f.Name())) ||
|
||||
enry.IsDotFile(f.Name()) ||
|
||||
(!isDocumentation.Has() && enry.IsDocumentation(f.Name())) ||
|
||||
(!isDetectable.Has() && enry.IsConfiguration(f.Name())) {
|
||||
continue
|
||||
}
|
||||
|
||||
// If content can not be read or file is too big just do detection by filename
|
||||
|
||||
if f.Size() <= bigFileSize {
|
||||
info, _, err := batch.QueryContent(f.ID.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sizeToRead := info.Size
|
||||
discard := int64(1)
|
||||
if info.Size > fileSizeLimit {
|
||||
sizeToRead = fileSizeLimit
|
||||
discard = info.Size - fileSizeLimit + 1
|
||||
}
|
||||
|
||||
_, err = contentBuf.ReadFrom(io.LimitReader(batchReader, sizeToRead))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
content = contentBuf.Bytes()
|
||||
if err := git.DiscardFull(batchReader, discard); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// if "generated" attribute is set, use it, otherwise use enry.IsGenerated to guess
|
||||
var isGenerated bool
|
||||
if attrLinguistGenerated.Has() {
|
||||
isGenerated = attrLinguistGenerated.Value()
|
||||
} else {
|
||||
isGenerated = enry.IsGenerated(f.Name(), content)
|
||||
}
|
||||
if isGenerated {
|
||||
continue
|
||||
}
|
||||
|
||||
// FIXME: Why can't we split this and the IsGenerated tests to avoid reading the blob unless absolutely necessary?
|
||||
// - eg. do the all the detection tests using filename first before reading content.
|
||||
language := analyze.GetCodeLanguage(f.Name(), content)
|
||||
if language == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// group languages, such as Pug -> HTML; SCSS -> CSS
|
||||
group := enry.GetLanguageGroup(language)
|
||||
if group != "" {
|
||||
language = group
|
||||
}
|
||||
|
||||
included, checked := includedLanguage[language]
|
||||
if !checked {
|
||||
langType := enry.GetLanguageType(language)
|
||||
included = langType == enry.Programming || langType == enry.Markup
|
||||
includedLanguage[language] = included
|
||||
}
|
||||
if included || isDetectable.ValueOrDefault(false) {
|
||||
sizes[language] += f.Size()
|
||||
} else if len(sizes) == 0 && (firstExcludedLanguage == "" || firstExcludedLanguage == language) {
|
||||
firstExcludedLanguage = language
|
||||
firstExcludedLanguageSize += f.Size()
|
||||
}
|
||||
}
|
||||
|
||||
// If there are no included languages add the first excluded language
|
||||
if len(sizes) == 0 && firstExcludedLanguage != "" {
|
||||
sizes[firstExcludedLanguage] = firstExcludedLanguageSize
|
||||
}
|
||||
|
||||
return mergeLanguageStats(sizes), nil
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !gogit
|
||||
|
||||
package languagestats
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRepository_GetLanguageStats(t *testing.T) {
|
||||
setting.AppDataPath = t.TempDir()
|
||||
repoPath := "../tests/repos/language_stats_repo"
|
||||
gitRepo, err := git.OpenRepository(t.Context(), repoPath)
|
||||
require.NoError(t, err)
|
||||
defer gitRepo.Close()
|
||||
|
||||
stats, err := GetLanguageStats(gitRepo, "8fee858da5796dfb37704761701bb8e800ad9ef3")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, map[string]int64{
|
||||
"Python": 134,
|
||||
"Java": 112,
|
||||
}, stats)
|
||||
}
|
||||
|
||||
func TestMergeLanguageStats(t *testing.T) {
|
||||
assert.Equal(t, map[string]int64{
|
||||
"PHP": 1,
|
||||
"python": 10,
|
||||
"JAVA": 700,
|
||||
}, mergeLanguageStats(map[string]int64{
|
||||
"PHP": 1,
|
||||
"python": 10,
|
||||
"Java": 100,
|
||||
"java": 200,
|
||||
"JAVA": 400,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package languagestats
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/git"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
git.RunGitTests(m)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
|
||||
"gitea.dev/modules/cache"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
func getCacheKey(repoPath, commitID, entryPath string) string {
|
||||
hashBytes := sha256.Sum256(fmt.Appendf(nil, "%s:%s:%s", repoPath, commitID, entryPath))
|
||||
return fmt.Sprintf("last_commit:%x", hashBytes)
|
||||
}
|
||||
|
||||
// LastCommitCache represents a cache to store last commit
|
||||
type LastCommitCache struct {
|
||||
repoPath string
|
||||
ttl func() int64
|
||||
repo *Repository
|
||||
commitCache map[string]*Commit
|
||||
cache cache.StringCache
|
||||
}
|
||||
|
||||
// NewLastCommitCache creates a new last commit cache for repo
|
||||
func NewLastCommitCache(count int64, repoPath string, gitRepo *Repository, cache cache.StringCache) *LastCommitCache {
|
||||
if cache == nil {
|
||||
return nil
|
||||
}
|
||||
if count < setting.CacheService.LastCommit.CommitsCount {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &LastCommitCache{
|
||||
repoPath: repoPath,
|
||||
repo: gitRepo,
|
||||
ttl: setting.LastCommitCacheTTLSeconds,
|
||||
cache: cache,
|
||||
}
|
||||
}
|
||||
|
||||
// Put put the last commit id with commit and entry path
|
||||
func (c *LastCommitCache) Put(ref, entryPath, commitID string) error {
|
||||
if c == nil || c.cache == nil {
|
||||
return nil
|
||||
}
|
||||
log.Debug("LastCommitCache save: [%s:%s:%s]", ref, entryPath, commitID)
|
||||
return c.cache.Put(getCacheKey(c.repoPath, ref, entryPath), commitID, c.ttl())
|
||||
}
|
||||
|
||||
// Get gets the last commit information by commit id and entry path
|
||||
func (c *LastCommitCache) Get(ref, entryPath string) (*Commit, error) {
|
||||
if c == nil || c.cache == nil {
|
||||
return nil, nil //nolint:nilnil // return nil when cache is not available
|
||||
}
|
||||
|
||||
commitID, ok := c.cache.Get(getCacheKey(c.repoPath, ref, entryPath))
|
||||
if !ok || commitID == "" {
|
||||
return nil, nil //nolint:nilnil // return nil when cache miss
|
||||
}
|
||||
|
||||
log.Debug("LastCommitCache hit level 1: [%s:%s:%s]", ref, entryPath, commitID)
|
||||
if c.commitCache != nil {
|
||||
if commit, ok := c.commitCache[commitID]; ok {
|
||||
log.Debug("LastCommitCache hit level 2: [%s:%s:%s]", ref, entryPath, commitID)
|
||||
return commit, nil
|
||||
}
|
||||
}
|
||||
|
||||
commit, err := c.repo.GetCommit(commitID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.commitCache == nil {
|
||||
c.commitCache = make(map[string]*Commit)
|
||||
}
|
||||
c.commitCache[commitID] = commit
|
||||
return commit, nil
|
||||
}
|
||||
|
||||
// GetCommitByPath gets the last commit for the entry in the provided commit
|
||||
func (c *LastCommitCache) GetCommitByPath(commitID, entryPath string) (*Commit, error) {
|
||||
sha, err := NewIDFromString(commitID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lastCommit, err := c.Get(sha.String(), entryPath)
|
||||
if err != nil || lastCommit != nil {
|
||||
return lastCommit, err
|
||||
}
|
||||
|
||||
lastCommit, err = c.repo.getCommitByPathWithID(sha, entryPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := c.Put(commitID, entryPath, lastCommit.ID.String()); err != nil {
|
||||
log.Error("Unable to cache %s as the last commit for %q in %s %s. Error %v", lastCommit.ID.String(), entryPath, commitID, c.repoPath, err)
|
||||
}
|
||||
|
||||
return lastCommit, nil
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build gogit
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
cgobject "github.com/go-git/go-git/v5/plumbing/object/commitgraph"
|
||||
)
|
||||
|
||||
// CacheCommit will cache the commit from the gitRepository
|
||||
func (c *Commit) CacheCommit(ctx context.Context) error {
|
||||
if c.repo.LastCommitCache == nil {
|
||||
return nil
|
||||
}
|
||||
commitNodeIndex, _ := c.repo.CommitNodeIndex()
|
||||
|
||||
index, err := commitNodeIndex.Get(plumbing.Hash(c.ID.RawValue()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.recursiveCache(ctx, index, &c.Tree, "", 1)
|
||||
}
|
||||
|
||||
func (c *Commit) recursiveCache(ctx context.Context, index cgobject.CommitNode, tree *Tree, treePath string, level int) error {
|
||||
if level == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
entries, err := tree.ListEntries()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entryPaths := make([]string, len(entries))
|
||||
entryMap := make(map[string]*TreeEntry)
|
||||
for i, entry := range entries {
|
||||
entryPaths[i] = entry.Name()
|
||||
entryMap[entry.Name()] = entry
|
||||
}
|
||||
|
||||
commits, err := GetLastCommitForPaths(ctx, c.repo.LastCommitCache, index, treePath, entryPaths)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for entry := range commits {
|
||||
if entryMap[entry].IsDir() {
|
||||
subTree, err := tree.SubTree(entry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.recursiveCache(ctx, index, subTree, entry, level-1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !gogit
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// CacheCommit will cache the commit from the gitRepository
|
||||
func (c *Commit) CacheCommit(ctx context.Context) error {
|
||||
if c.repo.LastCommitCache == nil {
|
||||
return nil
|
||||
}
|
||||
return c.recursiveCache(ctx, &c.Tree, "", 1)
|
||||
}
|
||||
|
||||
func (c *Commit) recursiveCache(ctx context.Context, tree *Tree, treePath string, level int) error {
|
||||
if level == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
entries, err := tree.ListEntries()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entryPaths := make([]string, len(entries))
|
||||
for i, entry := range entries {
|
||||
entryPaths[i] = entry.Name()
|
||||
}
|
||||
|
||||
_, err = WalkGitLog(ctx, c.repo, c, treePath, entryPaths...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, treeEntry := range entries {
|
||||
// entryMap won't contain "" therefore skip this.
|
||||
if treeEntry.IsDir() {
|
||||
subTree, err := tree.SubTree(treeEntry.Name())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.recursiveCache(ctx, subTree, treeEntry.Name(), level-1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/log"
|
||||
)
|
||||
|
||||
// LogNameStatusRepo opens git log --raw in the provided repo and returns a stdin pipe, a stdout reader and cancel function
|
||||
func LogNameStatusRepo(ctx context.Context, repository, head, treepath string, paths ...string) (*bufio.Reader, func()) {
|
||||
// Lets also create a context so that we can absolutely ensure that the command should die when we're done
|
||||
|
||||
cmd := gitcmd.NewCommand()
|
||||
cmd.AddArguments("log", "--name-status", "-c", "--format=commit%x00%H %P%x00", "--parents", "--no-renames", "-t", "-z").AddDynamicArguments(head)
|
||||
|
||||
var files []string
|
||||
if len(paths) < 70 {
|
||||
if treepath != "" {
|
||||
files = append(files, treepath)
|
||||
for _, pth := range paths {
|
||||
if pth != "" {
|
||||
files = append(files, path.Join(treepath, pth))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, pth := range paths {
|
||||
if pth != "" {
|
||||
files = append(files, pth)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if treepath != "" {
|
||||
files = append(files, treepath)
|
||||
}
|
||||
// Use the :(literal) pathspec magic to handle edge cases with files named like ":file.txt" or "*.jpg"
|
||||
for i, file := range files {
|
||||
files[i] = ":(literal)" + file
|
||||
}
|
||||
cmd.AddDashesAndList(files...)
|
||||
|
||||
stdoutReader, stdoutReaderClose := cmd.MakeStdoutPipe()
|
||||
ctx, ctxCancel := context.WithCancel(ctx)
|
||||
go func() {
|
||||
err := cmd.WithDir(repository).RunWithStderr(ctx)
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
log.Error("Unable to run git command %v: %v", cmd.LogString(), err)
|
||||
}
|
||||
}()
|
||||
|
||||
bufReader := bufio.NewReaderSize(stdoutReader, 32*1024)
|
||||
|
||||
return bufReader, func() {
|
||||
ctxCancel()
|
||||
stdoutReaderClose()
|
||||
}
|
||||
}
|
||||
|
||||
// LogNameStatusRepoParser parses a git log raw output from LogRawRepo
|
||||
type LogNameStatusRepoParser struct {
|
||||
treepath string
|
||||
paths []string
|
||||
next []byte
|
||||
buffull bool
|
||||
rd *bufio.Reader
|
||||
cancel func()
|
||||
}
|
||||
|
||||
// NewLogNameStatusRepoParser returns a new parser for a git log raw output
|
||||
func NewLogNameStatusRepoParser(ctx context.Context, repository, head, treepath string, paths ...string) *LogNameStatusRepoParser {
|
||||
rd, cancel := LogNameStatusRepo(ctx, repository, head, treepath, paths...)
|
||||
return &LogNameStatusRepoParser{
|
||||
treepath: treepath,
|
||||
paths: paths,
|
||||
rd: rd,
|
||||
cancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
// LogNameStatusCommitData represents a commit artefact from git log raw
|
||||
type LogNameStatusCommitData struct {
|
||||
CommitID string
|
||||
ParentIDs []string
|
||||
Paths []bool
|
||||
}
|
||||
|
||||
// Next returns the next LogStatusCommitData
|
||||
func (g *LogNameStatusRepoParser) Next(treepath string, paths2ids map[string]int, changed []bool, maxpathlen int) (*LogNameStatusCommitData, error) {
|
||||
var err error
|
||||
if len(g.next) == 0 {
|
||||
g.buffull = false
|
||||
g.next, err = g.rd.ReadSlice('\x00')
|
||||
if err != nil {
|
||||
switch err {
|
||||
case bufio.ErrBufferFull:
|
||||
g.buffull = true
|
||||
case io.EOF:
|
||||
return nil, nil //nolint:nilnil // return nil to signal EOF
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ret := LogNameStatusCommitData{}
|
||||
if bytes.Equal(g.next, []byte("commit\000")) {
|
||||
g.next, err = g.rd.ReadSlice('\x00')
|
||||
if err != nil {
|
||||
switch err {
|
||||
case bufio.ErrBufferFull:
|
||||
g.buffull = true
|
||||
case io.EOF:
|
||||
return nil, nil //nolint:nilnil // return nil to signal EOF
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Our "line" must look like: <commitid> SP (<parent> SP) * NUL
|
||||
commitIDs := string(g.next)
|
||||
if g.buffull {
|
||||
more, err := g.rd.ReadString('\x00')
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
commitIDs += more
|
||||
}
|
||||
commitIDs = commitIDs[:len(commitIDs)-1]
|
||||
splitIDs := strings.Split(commitIDs, " ")
|
||||
ret.CommitID = splitIDs[0]
|
||||
if len(splitIDs) > 1 {
|
||||
ret.ParentIDs = splitIDs[1:]
|
||||
}
|
||||
|
||||
// now read the next "line"
|
||||
g.buffull = false
|
||||
g.next, err = g.rd.ReadSlice('\x00')
|
||||
if err != nil {
|
||||
if err == bufio.ErrBufferFull {
|
||||
g.buffull = true
|
||||
} else if err != io.EOF {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err == io.EOF || !(g.next[0] == '\n' || g.next[0] == '\000') {
|
||||
return &ret, nil
|
||||
}
|
||||
|
||||
// Ok we have some changes.
|
||||
// This line will look like: NL <fname> NUL
|
||||
//
|
||||
// Subsequent lines will not have the NL - so drop it here - g.bufffull must also be false at this point too.
|
||||
if g.next[0] == '\n' {
|
||||
g.next = g.next[1:]
|
||||
} else {
|
||||
g.buffull = false
|
||||
g.next, err = g.rd.ReadSlice('\x00')
|
||||
if err != nil {
|
||||
if err == bufio.ErrBufferFull {
|
||||
g.buffull = true
|
||||
} else if err != io.EOF {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if len(g.next) == 0 {
|
||||
return &ret, nil
|
||||
}
|
||||
if g.next[0] == '\x00' {
|
||||
g.buffull = false
|
||||
g.next, err = g.rd.ReadSlice('\x00')
|
||||
if err != nil {
|
||||
if err == bufio.ErrBufferFull {
|
||||
g.buffull = true
|
||||
} else if err != io.EOF {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fnameBuf := make([]byte, 4096)
|
||||
|
||||
diffloop:
|
||||
for {
|
||||
if err == io.EOF || bytes.Equal(g.next, []byte("commit\000")) {
|
||||
return &ret, nil
|
||||
}
|
||||
g.next, err = g.rd.ReadSlice('\x00')
|
||||
if err != nil {
|
||||
switch err {
|
||||
case bufio.ErrBufferFull:
|
||||
g.buffull = true
|
||||
case io.EOF:
|
||||
return &ret, nil
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
copy(fnameBuf, g.next)
|
||||
if len(fnameBuf) < len(g.next) {
|
||||
fnameBuf = append(fnameBuf, g.next[len(fnameBuf):]...)
|
||||
} else {
|
||||
fnameBuf = fnameBuf[:len(g.next)]
|
||||
}
|
||||
if err != nil {
|
||||
if err != bufio.ErrBufferFull {
|
||||
return nil, err
|
||||
}
|
||||
more, err := g.rd.ReadBytes('\x00')
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fnameBuf = append(fnameBuf, more...)
|
||||
}
|
||||
|
||||
// read the next line
|
||||
g.buffull = false
|
||||
g.next, err = g.rd.ReadSlice('\x00')
|
||||
if err != nil {
|
||||
if err == bufio.ErrBufferFull {
|
||||
g.buffull = true
|
||||
} else if err != io.EOF {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if treepath != "" {
|
||||
if !bytes.HasPrefix(fnameBuf, []byte(treepath)) {
|
||||
fnameBuf = fnameBuf[:cap(fnameBuf)]
|
||||
continue diffloop
|
||||
}
|
||||
}
|
||||
fnameBuf = fnameBuf[len(treepath) : len(fnameBuf)-1]
|
||||
if len(fnameBuf) > maxpathlen {
|
||||
fnameBuf = fnameBuf[:cap(fnameBuf)]
|
||||
continue diffloop
|
||||
}
|
||||
if len(fnameBuf) > 0 {
|
||||
if len(treepath) > 0 {
|
||||
if fnameBuf[0] != '/' || bytes.IndexByte(fnameBuf[1:], '/') >= 0 {
|
||||
fnameBuf = fnameBuf[:cap(fnameBuf)]
|
||||
continue diffloop
|
||||
}
|
||||
fnameBuf = fnameBuf[1:]
|
||||
} else if bytes.IndexByte(fnameBuf, '/') >= 0 {
|
||||
fnameBuf = fnameBuf[:cap(fnameBuf)]
|
||||
continue diffloop
|
||||
}
|
||||
}
|
||||
|
||||
idx, ok := paths2ids[string(fnameBuf)]
|
||||
if !ok {
|
||||
fnameBuf = fnameBuf[:cap(fnameBuf)]
|
||||
continue diffloop
|
||||
}
|
||||
if ret.Paths == nil {
|
||||
ret.Paths = changed
|
||||
}
|
||||
changed[idx] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Close closes the parser
|
||||
func (g *LogNameStatusRepoParser) Close() {
|
||||
g.cancel()
|
||||
}
|
||||
|
||||
// WalkGitLog walks the git log --name-status for the head commit in the provided treepath and files
|
||||
func WalkGitLog(ctx context.Context, repo *Repository, head *Commit, treepath string, paths ...string) (map[string]string, error) {
|
||||
headRef := head.ID.String()
|
||||
|
||||
tree, err := head.SubTree(treepath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entries, err := tree.ListEntries()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(paths) == 0 {
|
||||
paths = make([]string, 0, len(entries)+1)
|
||||
paths = append(paths, "")
|
||||
for _, entry := range entries {
|
||||
paths = append(paths, entry.Name())
|
||||
}
|
||||
} else {
|
||||
sort.Strings(paths)
|
||||
if paths[0] != "" {
|
||||
paths = append([]string{""}, paths...)
|
||||
}
|
||||
// remove duplicates
|
||||
for i := len(paths) - 1; i > 0; i-- {
|
||||
if paths[i] == paths[i-1] {
|
||||
paths = append(paths[:i-1], paths[i:]...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
path2idx := map[string]int{}
|
||||
maxpathlen := len(treepath)
|
||||
|
||||
for i := range paths {
|
||||
path2idx[paths[i]] = i
|
||||
pthlen := len(paths[i]) + len(treepath) + 1
|
||||
if pthlen > maxpathlen {
|
||||
maxpathlen = pthlen
|
||||
}
|
||||
}
|
||||
|
||||
g := NewLogNameStatusRepoParser(ctx, repo.Path, head.ID.String(), treepath, paths...)
|
||||
// don't use defer g.Close() here as g may change its value - instead wrap in a func
|
||||
defer func() {
|
||||
g.Close()
|
||||
}()
|
||||
|
||||
results := make([]string, len(paths))
|
||||
remaining := len(paths)
|
||||
nextRestart := min((len(paths)*3)/4, 70)
|
||||
lastEmptyParent := head.ID.String()
|
||||
commitSinceLastEmptyParent := uint64(0)
|
||||
commitSinceNextRestart := uint64(0)
|
||||
parentRemaining := make(container.Set[string])
|
||||
|
||||
changed := make([]bool, len(paths))
|
||||
|
||||
heaploop:
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
break heaploop
|
||||
}
|
||||
g.Close()
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
current, err := g.Next(treepath, path2idx, changed, maxpathlen)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
break heaploop
|
||||
}
|
||||
g.Close()
|
||||
return nil, err
|
||||
}
|
||||
if current == nil {
|
||||
break heaploop
|
||||
}
|
||||
parentRemaining.Remove(current.CommitID)
|
||||
for i, found := range current.Paths {
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
changed[i] = false
|
||||
if results[i] == "" {
|
||||
results[i] = current.CommitID
|
||||
if err := repo.LastCommitCache.Put(headRef, path.Join(treepath, paths[i]), current.CommitID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
delete(path2idx, paths[i])
|
||||
remaining--
|
||||
if results[0] == "" {
|
||||
results[0] = current.CommitID
|
||||
if err := repo.LastCommitCache.Put(headRef, treepath, current.CommitID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
delete(path2idx, "")
|
||||
remaining--
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if remaining <= 0 {
|
||||
break heaploop
|
||||
}
|
||||
commitSinceLastEmptyParent++
|
||||
if len(parentRemaining) == 0 {
|
||||
lastEmptyParent = current.CommitID
|
||||
commitSinceLastEmptyParent = 0
|
||||
}
|
||||
if remaining <= nextRestart {
|
||||
commitSinceNextRestart++
|
||||
if 4*commitSinceNextRestart > 3*commitSinceLastEmptyParent {
|
||||
g.Close()
|
||||
remainingPaths := make([]string, 0, len(paths))
|
||||
for i, pth := range paths {
|
||||
if results[i] == "" {
|
||||
remainingPaths = append(remainingPaths, pth)
|
||||
}
|
||||
}
|
||||
g = NewLogNameStatusRepoParser(ctx, repo.Path, lastEmptyParent, treepath, remainingPaths...)
|
||||
parentRemaining = make(container.Set[string])
|
||||
nextRestart = (remaining * 3) / 4
|
||||
continue heaploop
|
||||
}
|
||||
}
|
||||
parentRemaining.AddMultiple(current.ParentIDs...)
|
||||
}
|
||||
g.Close()
|
||||
|
||||
resultsMap := map[string]string{}
|
||||
for i, pth := range paths {
|
||||
resultsMap[pth] = results[i]
|
||||
}
|
||||
|
||||
return resultsMap, nil
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
// NotesRef is the git ref where Gitea will look for git-notes data.
|
||||
// The value ("refs/notes/commits") is the default ref used by git-notes.
|
||||
const NotesRef = "refs/notes/commits"
|
||||
|
||||
// Note stores information about a note created using git-notes.
|
||||
type Note struct {
|
||||
Message []byte
|
||||
Commit *Commit
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build gogit
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/log"
|
||||
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
"github.com/go-git/go-git/v5/plumbing/object"
|
||||
)
|
||||
|
||||
// GetNote retrieves the git-notes data for a given commit.
|
||||
// FIXME: Add LastCommitCache support
|
||||
func GetNote(ctx context.Context, repo *Repository, commitID string, note *Note) error {
|
||||
log.Trace("Searching for git note corresponding to the commit %q in the repository %q", commitID, repo.Path)
|
||||
notes, err := repo.GetCommit(NotesRef)
|
||||
if err != nil {
|
||||
if IsErrNotExist(err) {
|
||||
return err
|
||||
}
|
||||
log.Error("Unable to get commit from ref %q. Error: %v", NotesRef, err)
|
||||
return err
|
||||
}
|
||||
|
||||
remainingCommitID := commitID
|
||||
var path strings.Builder
|
||||
currentTree, err := notes.Tree.gogitTreeObject()
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to get tree object for notes commit %q: %w", notes.ID.String(), err)
|
||||
}
|
||||
|
||||
log.Trace("Found tree with ID %q while searching for git note corresponding to the commit %q", currentTree.Entries[0].Name, commitID)
|
||||
var file *object.File
|
||||
for len(remainingCommitID) > 2 {
|
||||
file, err = currentTree.File(remainingCommitID)
|
||||
if err == nil {
|
||||
path.WriteString(remainingCommitID)
|
||||
break
|
||||
}
|
||||
if err == object.ErrFileNotFound {
|
||||
currentTree, err = currentTree.Tree(remainingCommitID[0:2])
|
||||
path.WriteString(remainingCommitID[0:2] + "/")
|
||||
remainingCommitID = remainingCommitID[2:]
|
||||
}
|
||||
if err != nil {
|
||||
if err == object.ErrDirectoryNotFound {
|
||||
return ErrNotExist{ID: remainingCommitID, RelPath: path.String()}
|
||||
}
|
||||
log.Error("Unable to find git note corresponding to the commit %q. Error: %v", commitID, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
blob := file.Blob
|
||||
dataRc, err := blob.Reader()
|
||||
if err != nil {
|
||||
log.Error("Unable to read blob with ID %q. Error: %v", blob.ID, err)
|
||||
return err
|
||||
}
|
||||
|
||||
defer dataRc.Close()
|
||||
d, err := io.ReadAll(dataRc)
|
||||
if err != nil {
|
||||
log.Error("Unable to read blob with ID %q. Error: %v", blob.ID, err)
|
||||
return err
|
||||
}
|
||||
note.Message = d
|
||||
|
||||
commitNodeIndex, commitGraphFile := repo.CommitNodeIndex()
|
||||
if commitGraphFile != nil {
|
||||
defer commitGraphFile.Close()
|
||||
}
|
||||
|
||||
commitNode, err := commitNodeIndex.Get(plumbing.Hash(notes.ID.RawValue()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
lastCommits, err := GetLastCommitForPaths(ctx, nil, commitNode, "", []string{path.String()})
|
||||
if err != nil {
|
||||
log.Error("Unable to get the commit for the path %q. Error: %v", path.String(), err)
|
||||
return err
|
||||
}
|
||||
note.Commit = lastCommits[path.String()]
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !gogit
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/log"
|
||||
)
|
||||
|
||||
// GetNote retrieves the git-notes data for a given commit.
|
||||
// FIXME: Add LastCommitCache support
|
||||
func GetNote(ctx context.Context, repo *Repository, commitID string, note *Note) error {
|
||||
log.Trace("Searching for git note corresponding to the commit %q in the repository %q", commitID, repo.Path)
|
||||
notes, err := repo.GetCommit(NotesRef)
|
||||
if err != nil {
|
||||
if IsErrNotExist(err) {
|
||||
return err
|
||||
}
|
||||
log.Error("Unable to get commit from ref %q. Error: %v", NotesRef, err)
|
||||
return err
|
||||
}
|
||||
|
||||
path := ""
|
||||
|
||||
tree := ¬es.Tree
|
||||
log.Trace("Found tree with ID %q while searching for git note corresponding to the commit %q", tree.ID, commitID)
|
||||
|
||||
var entry *TreeEntry
|
||||
originalCommitID := commitID
|
||||
for len(commitID) > 2 {
|
||||
entry, err = tree.GetTreeEntryByPath(commitID)
|
||||
if err == nil {
|
||||
path += commitID
|
||||
break
|
||||
}
|
||||
if IsErrNotExist(err) {
|
||||
tree, err = tree.SubTree(commitID[0:2])
|
||||
path += commitID[0:2] + "/"
|
||||
commitID = commitID[2:]
|
||||
}
|
||||
if err != nil {
|
||||
// Err may have been updated by the SubTree we need to recheck if it's again an ErrNotExist
|
||||
if !IsErrNotExist(err) {
|
||||
log.Error("Unable to find git note corresponding to the commit %q. Error: %v", originalCommitID, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
blob := entry.Blob()
|
||||
dataRc, err := blob.DataAsync()
|
||||
if err != nil {
|
||||
log.Error("Unable to read blob with ID %q. Error: %v", blob.ID, err)
|
||||
return err
|
||||
}
|
||||
closed := false
|
||||
defer func() {
|
||||
if !closed {
|
||||
_ = dataRc.Close()
|
||||
}
|
||||
}()
|
||||
d, err := io.ReadAll(dataRc)
|
||||
if err != nil {
|
||||
log.Error("Unable to read blob with ID %q. Error: %v", blob.ID, err)
|
||||
return err
|
||||
}
|
||||
_ = dataRc.Close()
|
||||
closed = true
|
||||
note.Message = d
|
||||
|
||||
treePath := ""
|
||||
if idx := strings.LastIndex(path, "/"); idx > -1 {
|
||||
treePath = path[:idx]
|
||||
path = path[idx+1:]
|
||||
}
|
||||
|
||||
lastCommits, err := GetLastCommitForPaths(ctx, notes, treePath, []string{path})
|
||||
if err != nil {
|
||||
log.Error("Unable to get the commit for the path %q. Error: %v", treePath, err)
|
||||
return err
|
||||
}
|
||||
note.Commit = lastCommits[path]
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGetNotes(t *testing.T) {
|
||||
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
|
||||
bareRepo1, err := OpenRepository(t.Context(), bareRepo1Path)
|
||||
assert.NoError(t, err)
|
||||
defer bareRepo1.Close()
|
||||
|
||||
note := Note{}
|
||||
err = GetNote(t.Context(), bareRepo1, "95bb4d39648ee7e325106df01a621c530863a653", ¬e)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []byte("Note contents\n"), note.Message)
|
||||
assert.Equal(t, "Vladimir Panteleev", note.Commit.Author.Name)
|
||||
}
|
||||
|
||||
func TestGetNestedNotes(t *testing.T) {
|
||||
repoPath := filepath.Join(testReposDir, "repo3_notes")
|
||||
repo, err := OpenRepository(t.Context(), repoPath)
|
||||
assert.NoError(t, err)
|
||||
defer repo.Close()
|
||||
|
||||
note := Note{}
|
||||
err = GetNote(t.Context(), repo, "3e668dbfac39cbc80a9ff9c61eb565d944453ba4", ¬e)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []byte("Note 2"), note.Message)
|
||||
err = GetNote(t.Context(), repo, "ba0a96fa63532d6c5087ecef070b0250ed72fa47", ¬e)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []byte("Note 1"), note.Message)
|
||||
}
|
||||
|
||||
func TestGetNonExistentNotes(t *testing.T) {
|
||||
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
|
||||
bareRepo1, err := OpenRepository(t.Context(), bareRepo1Path)
|
||||
assert.NoError(t, err)
|
||||
defer bareRepo1.Close()
|
||||
|
||||
note := Note{}
|
||||
err = GetNote(t.Context(), bareRepo1, "non_existent_sha", ¬e)
|
||||
assert.Error(t, err)
|
||||
assert.ErrorAs(t, err, &ErrNotExist{})
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"regexp"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// sha1Pattern can be used to determine if a string is an valid sha
|
||||
var sha1Pattern = regexp.MustCompile(`^[0-9a-f]{4,40}$`)
|
||||
|
||||
// sha256Pattern can be used to determine if a string is an valid sha
|
||||
var sha256Pattern = regexp.MustCompile(`^[0-9a-f]{4,64}$`)
|
||||
|
||||
type ObjectFormat interface {
|
||||
// Name returns the name of the object format
|
||||
Name() string
|
||||
// EmptyObjectID creates a new empty ObjectID from an object format hash name
|
||||
EmptyObjectID() ObjectID
|
||||
// EmptyTree is the hash of an empty tree
|
||||
EmptyTree() ObjectID
|
||||
// FullLength is the length of the hash's hex string
|
||||
FullLength() int
|
||||
// IsValid returns true if the input is a valid hash
|
||||
IsValid(input string) bool
|
||||
// MustID creates a new ObjectID from a byte slice
|
||||
MustID(b []byte) ObjectID
|
||||
// ComputeHash compute the hash for a given ObjectType and content
|
||||
ComputeHash(t ObjectType, content []byte) ObjectID
|
||||
}
|
||||
|
||||
type Sha1ObjectFormatImpl struct{}
|
||||
|
||||
var (
|
||||
emptySha1ObjectID = &Sha1Hash{}
|
||||
emptySha1Tree = &Sha1Hash{
|
||||
0x4b, 0x82, 0x5d, 0xc6, 0x42, 0xcb, 0x6e, 0xb9, 0xa0, 0x60,
|
||||
0xe5, 0x4b, 0xf8, 0xd6, 0x92, 0x88, 0xfb, 0xee, 0x49, 0x04,
|
||||
}
|
||||
)
|
||||
|
||||
func (Sha1ObjectFormatImpl) Name() string { return "sha1" }
|
||||
func (Sha1ObjectFormatImpl) EmptyObjectID() ObjectID {
|
||||
return emptySha1ObjectID
|
||||
}
|
||||
|
||||
func (Sha1ObjectFormatImpl) EmptyTree() ObjectID {
|
||||
return emptySha1Tree
|
||||
}
|
||||
func (Sha1ObjectFormatImpl) FullLength() int { return 40 }
|
||||
func (Sha1ObjectFormatImpl) IsValid(input string) bool {
|
||||
return sha1Pattern.MatchString(input)
|
||||
}
|
||||
|
||||
func (Sha1ObjectFormatImpl) MustID(b []byte) ObjectID {
|
||||
var id Sha1Hash
|
||||
copy(id[0:20], b)
|
||||
return &id
|
||||
}
|
||||
|
||||
// ComputeHash compute the hash for a given ObjectType and content
|
||||
func (h Sha1ObjectFormatImpl) ComputeHash(t ObjectType, content []byte) ObjectID {
|
||||
hasher := sha1.New()
|
||||
_, _ = hasher.Write(t.Bytes())
|
||||
_, _ = hasher.Write([]byte(" "))
|
||||
_, _ = hasher.Write([]byte(strconv.FormatInt(int64(len(content)), 10)))
|
||||
_, _ = hasher.Write([]byte{0})
|
||||
_, _ = hasher.Write(content)
|
||||
return h.MustID(hasher.Sum(nil))
|
||||
}
|
||||
|
||||
type Sha256ObjectFormatImpl struct{}
|
||||
|
||||
var (
|
||||
emptySha256ObjectID = &Sha256Hash{}
|
||||
emptySha256Tree = &Sha256Hash{
|
||||
0x6e, 0xf1, 0x9b, 0x41, 0x22, 0x5c, 0x53, 0x69, 0xf1, 0xc1,
|
||||
0x04, 0xd4, 0x5d, 0x8d, 0x85, 0xef, 0xa9, 0xb0, 0x57, 0xb5,
|
||||
0x3b, 0x14, 0xb4, 0xb9, 0xb9, 0x39, 0xdd, 0x74, 0xde, 0xcc,
|
||||
0x53, 0x21,
|
||||
}
|
||||
)
|
||||
|
||||
func (Sha256ObjectFormatImpl) Name() string { return "sha256" }
|
||||
func (Sha256ObjectFormatImpl) EmptyObjectID() ObjectID {
|
||||
return emptySha256ObjectID
|
||||
}
|
||||
|
||||
func (Sha256ObjectFormatImpl) EmptyTree() ObjectID {
|
||||
return emptySha256Tree
|
||||
}
|
||||
func (Sha256ObjectFormatImpl) FullLength() int { return 64 }
|
||||
func (Sha256ObjectFormatImpl) IsValid(input string) bool {
|
||||
return sha256Pattern.MatchString(input)
|
||||
}
|
||||
|
||||
func (Sha256ObjectFormatImpl) MustID(b []byte) ObjectID {
|
||||
var id Sha256Hash
|
||||
copy(id[0:32], b)
|
||||
return &id
|
||||
}
|
||||
|
||||
// ComputeHash compute the hash for a given ObjectType and content
|
||||
func (h Sha256ObjectFormatImpl) ComputeHash(t ObjectType, content []byte) ObjectID {
|
||||
hasher := sha256.New()
|
||||
_, _ = hasher.Write(t.Bytes())
|
||||
_, _ = hasher.Write([]byte(" "))
|
||||
_, _ = hasher.Write([]byte(strconv.FormatInt(int64(len(content)), 10)))
|
||||
_, _ = hasher.Write([]byte{0})
|
||||
_, _ = hasher.Write(content)
|
||||
return h.MustID(hasher.Sum(nil))
|
||||
}
|
||||
|
||||
var (
|
||||
Sha1ObjectFormat ObjectFormat = Sha1ObjectFormatImpl{}
|
||||
Sha256ObjectFormat ObjectFormat = Sha256ObjectFormatImpl{}
|
||||
)
|
||||
|
||||
func ObjectFormatFromName(name string) ObjectFormat {
|
||||
for _, objectFormat := range DefaultFeatures().SupportedObjectFormats {
|
||||
if name == objectFormat.Name() {
|
||||
return objectFormat
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func IsValidObjectFormat(name string) bool {
|
||||
return ObjectFormatFromName(name) != nil
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type ObjectID interface {
|
||||
String() string
|
||||
IsZero() bool
|
||||
RawValue() []byte
|
||||
Type() ObjectFormat
|
||||
}
|
||||
|
||||
type Sha1Hash [20]byte
|
||||
|
||||
func (h *Sha1Hash) String() string {
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func (h *Sha1Hash) IsZero() bool {
|
||||
empty := Sha1Hash{}
|
||||
return bytes.Equal(empty[:], h[:])
|
||||
}
|
||||
func (h *Sha1Hash) RawValue() []byte { return h[:] }
|
||||
func (*Sha1Hash) Type() ObjectFormat { return Sha1ObjectFormat }
|
||||
|
||||
var _ ObjectID = &Sha1Hash{}
|
||||
|
||||
func MustIDFromString(hexHash string) ObjectID {
|
||||
id, err := NewIDFromString(hexHash)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
type Sha256Hash [32]byte
|
||||
|
||||
func (h *Sha256Hash) String() string {
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func (h *Sha256Hash) IsZero() bool {
|
||||
empty := Sha256Hash{}
|
||||
return bytes.Equal(empty[:], h[:])
|
||||
}
|
||||
func (h *Sha256Hash) RawValue() []byte { return h[:] }
|
||||
func (*Sha256Hash) Type() ObjectFormat { return Sha256ObjectFormat }
|
||||
|
||||
func NewIDFromString(hexHash string) (ObjectID, error) {
|
||||
var theObjectFormat ObjectFormat
|
||||
for _, objectFormat := range DefaultFeatures().SupportedObjectFormats {
|
||||
if len(hexHash) == objectFormat.FullLength() {
|
||||
theObjectFormat = objectFormat
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if theObjectFormat == nil {
|
||||
return nil, fmt.Errorf("length %d has no matched object format: %s", len(hexHash), hexHash)
|
||||
}
|
||||
|
||||
b, err := hex.DecodeString(hexHash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(b) != theObjectFormat.FullLength()/2 {
|
||||
return theObjectFormat.EmptyObjectID(), fmt.Errorf("length must be %d: %v", theObjectFormat.FullLength(), b)
|
||||
}
|
||||
return theObjectFormat.MustID(b), nil
|
||||
}
|
||||
|
||||
func IsEmptyCommitID(commitID string) bool {
|
||||
if commitID == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
id, err := NewIDFromString(commitID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return id.IsZero()
|
||||
}
|
||||
|
||||
// ComputeBlobHash compute the hash for a given blob content
|
||||
func ComputeBlobHash(hashType ObjectFormat, content []byte) ObjectID {
|
||||
return hashType.ComputeHash(ObjectBlob, content)
|
||||
}
|
||||
|
||||
type ErrInvalidSHA struct {
|
||||
SHA string
|
||||
}
|
||||
|
||||
func (err ErrInvalidSHA) Error() string {
|
||||
return "invalid sha: " + err.SHA
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
//go:build gogit
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
"github.com/go-git/go-git/v5/plumbing/hash"
|
||||
)
|
||||
|
||||
func ParseGogitHash(h plumbing.Hash) ObjectID {
|
||||
switch hash.Size {
|
||||
case 20:
|
||||
return Sha1ObjectFormat.MustID(h[:])
|
||||
case 32:
|
||||
return Sha256ObjectFormat.MustID(h[:])
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ParseGogitHashArray(objectIDs []plumbing.Hash) []ObjectID {
|
||||
ret := make([]ObjectID, len(objectIDs))
|
||||
for i, h := range objectIDs {
|
||||
ret[i] = ParseGogitHash(h)
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestIsValidSHAPattern(t *testing.T) {
|
||||
h := Sha1ObjectFormat
|
||||
assert.True(t, h.IsValid("fee1"))
|
||||
assert.True(t, h.IsValid("abc000"))
|
||||
assert.True(t, h.IsValid("9023902390239023902390239023902390239023"))
|
||||
assert.False(t, h.IsValid("90239023902390239023902390239023902390239023"))
|
||||
assert.False(t, h.IsValid("abc"))
|
||||
assert.False(t, h.IsValid("123g"))
|
||||
assert.False(t, h.IsValid("some random text"))
|
||||
assert.Equal(t, "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391", ComputeBlobHash(Sha1ObjectFormat, nil).String())
|
||||
assert.Equal(t, "2e65efe2a145dda7ee51d1741299f848e5bf752e", ComputeBlobHash(Sha1ObjectFormat, []byte("a")).String())
|
||||
assert.Equal(t, "473a0f4c3be8a93681a267e3b1e9a7dcda1185436fe141f7749120a303721813", ComputeBlobHash(Sha256ObjectFormat, nil).String())
|
||||
assert.Equal(t, "eb337bcee2061c5313c9a1392116b6c76039e9e30d71467ae359b36277e17dc7", ComputeBlobHash(Sha256ObjectFormat, []byte("a")).String())
|
||||
assert.True(t, IsEmptyCommitID(""))
|
||||
assert.True(t, IsEmptyCommitID("0000000000000000000000000000000000000000"))
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/optional"
|
||||
)
|
||||
|
||||
var sepSpace = []byte{' '}
|
||||
|
||||
type LsTreeEntry struct {
|
||||
ID ObjectID
|
||||
EntryMode EntryMode
|
||||
Name string
|
||||
Size optional.Option[int64]
|
||||
}
|
||||
|
||||
func parseLsTreeLine(line []byte) (*LsTreeEntry, error) {
|
||||
// expect line to be of the form:
|
||||
// <mode> <type> <sha> <space-padded-size>\t<filename>
|
||||
// <mode> <type> <sha>\t<filename>
|
||||
|
||||
var err error
|
||||
before, after, ok := bytes.Cut(line, []byte{'\t'})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid ls-tree output (no tab): %q", line)
|
||||
}
|
||||
|
||||
entry := new(LsTreeEntry)
|
||||
|
||||
entryAttrs := before
|
||||
entryName := after
|
||||
|
||||
entryMode, entryAttrs, _ := bytes.Cut(entryAttrs, sepSpace)
|
||||
_ /* entryType */, entryAttrs, _ = bytes.Cut(entryAttrs, sepSpace) // the type is not used, the mode is enough to determine the type
|
||||
entryObjectID, entryAttrs, _ := bytes.Cut(entryAttrs, sepSpace)
|
||||
if len(entryAttrs) > 0 {
|
||||
entrySize := entryAttrs // the last field is the space-padded-size
|
||||
size, _ := strconv.ParseInt(strings.TrimSpace(string(entrySize)), 10, 64)
|
||||
entry.Size = optional.Some(size)
|
||||
}
|
||||
|
||||
entry.EntryMode = ParseEntryMode(string(entryMode))
|
||||
if entry.EntryMode == EntryModeNoEntry {
|
||||
return nil, fmt.Errorf("invalid ls-tree output (invalid mode): %q, err: %w", line, err)
|
||||
}
|
||||
|
||||
entry.ID, err = NewIDFromString(string(entryObjectID))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid ls-tree output (invalid object id): %q, err: %w", line, err)
|
||||
}
|
||||
|
||||
if len(entryName) > 0 && entryName[0] == '"' {
|
||||
entry.Name, err = strconv.Unquote(string(entryName))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid ls-tree output (invalid name): %q, err: %w", line, err)
|
||||
}
|
||||
} else {
|
||||
entry.Name = string(entryName)
|
||||
}
|
||||
return entry, nil
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
)
|
||||
|
||||
// ParseTreeEntries parses the output of a `git ls-tree -l` command.
|
||||
func ParseTreeEntries(data []byte) ([]*TreeEntry, error) {
|
||||
return parseTreeEntries(data, nil)
|
||||
}
|
||||
|
||||
// parseTreeEntries FIXME this function's design is not right, it should not make the caller read all data into memory
|
||||
func parseTreeEntries(data []byte, ptree *Tree) ([]*TreeEntry, error) {
|
||||
entries := make([]*TreeEntry, 0, bytes.Count(data, []byte{'\n'})+1)
|
||||
for pos := 0; pos < len(data); {
|
||||
posEnd := bytes.IndexByte(data[pos:], '\n')
|
||||
if posEnd == -1 {
|
||||
posEnd = len(data)
|
||||
} else {
|
||||
posEnd += pos
|
||||
}
|
||||
|
||||
line := data[pos:posEnd]
|
||||
lsTreeLine, err := parseLsTreeLine(line)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entry := &TreeEntry{
|
||||
ptree: ptree,
|
||||
ID: lsTreeLine.ID,
|
||||
entryMode: lsTreeLine.EntryMode,
|
||||
name: lsTreeLine.Name,
|
||||
size: lsTreeLine.Size.Value(),
|
||||
sized: lsTreeLine.Size.Has(),
|
||||
}
|
||||
pos = posEnd + 1
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
var _ = catBatchParseTreeEntries // bypass "unused" lint because it is only used by "nogogit"
|
||||
|
||||
func catBatchParseTreeEntries(objectFormat ObjectFormat, ptree *Tree, rd BufferedReader, sz int64) ([]*TreeEntry, error) {
|
||||
entries := make([]*TreeEntry, 0, 10)
|
||||
|
||||
loop:
|
||||
for sz > 0 {
|
||||
mode, fname, objID, count, err := ParseCatFileTreeLine(objectFormat, rd)
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
break loop
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
sz -= int64(count)
|
||||
entry := new(TreeEntry)
|
||||
entry.ptree = ptree
|
||||
entry.entryMode = mode
|
||||
entry.ID = objID
|
||||
entry.name = fname
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
if _, err := rd.Discard(1); err != nil {
|
||||
return entries, err
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestParseTreeEntriesLong(t *testing.T) {
|
||||
testCases := []struct {
|
||||
Input string
|
||||
Expected []*TreeEntry
|
||||
}{
|
||||
{
|
||||
Input: `100644 blob ea0d83c9081af9500ac9f804101b3fd0a5c293af 8218 README.md
|
||||
100644 blob 037f27dc9d353ae4fd50f0474b2194c593914e35 4681 README_ZH.md
|
||||
100644 blob 9846a94f7e8350a916632929d0fda38c90dd2ca8 429 SECURITY.md
|
||||
040000 tree 84b90550547016f73c5dd3f50dea662389e67b6d - assets
|
||||
`,
|
||||
Expected: []*TreeEntry{
|
||||
{
|
||||
ID: MustIDFromString("ea0d83c9081af9500ac9f804101b3fd0a5c293af"),
|
||||
name: "README.md",
|
||||
entryMode: EntryModeBlob,
|
||||
size: 8218,
|
||||
sized: true,
|
||||
},
|
||||
{
|
||||
ID: MustIDFromString("037f27dc9d353ae4fd50f0474b2194c593914e35"),
|
||||
name: "README_ZH.md",
|
||||
entryMode: EntryModeBlob,
|
||||
size: 4681,
|
||||
sized: true,
|
||||
},
|
||||
{
|
||||
ID: MustIDFromString("9846a94f7e8350a916632929d0fda38c90dd2ca8"),
|
||||
name: "SECURITY.md",
|
||||
entryMode: EntryModeBlob,
|
||||
size: 429,
|
||||
sized: true,
|
||||
},
|
||||
{
|
||||
ID: MustIDFromString("84b90550547016f73c5dd3f50dea662389e67b6d"),
|
||||
name: "assets",
|
||||
entryMode: EntryModeTree,
|
||||
sized: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
entries, err := ParseTreeEntries([]byte(testCase.Input))
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, entries, len(testCase.Expected))
|
||||
for i, entry := range entries {
|
||||
assert.Equal(t, testCase.Expected[i], entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTreeEntriesShort(t *testing.T) {
|
||||
testCases := []struct {
|
||||
Input string
|
||||
Expected []*TreeEntry
|
||||
}{
|
||||
{
|
||||
Input: `100644 blob ea0d83c9081af9500ac9f804101b3fd0a5c293af README.md
|
||||
040000 tree 84b90550547016f73c5dd3f50dea662389e67b6d assets
|
||||
`,
|
||||
Expected: []*TreeEntry{
|
||||
{
|
||||
ID: MustIDFromString("ea0d83c9081af9500ac9f804101b3fd0a5c293af"),
|
||||
name: "README.md",
|
||||
entryMode: EntryModeBlob,
|
||||
},
|
||||
{
|
||||
ID: MustIDFromString("84b90550547016f73c5dd3f50dea662389e67b6d"),
|
||||
name: "assets",
|
||||
entryMode: EntryModeTree,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
entries, err := ParseTreeEntries([]byte(testCase.Input))
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, entries, len(testCase.Expected))
|
||||
for i, entry := range entries {
|
||||
assert.Equal(t, testCase.Expected[i], entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTreeEntriesInvalid(t *testing.T) {
|
||||
// there was a panic: "runtime error: slice bounds out of range" when the input was invalid: #20315
|
||||
entries, err := ParseTreeEntries([]byte("100644 blob ea0d83c9081af9500ac9f804101b3fd0a5c293af"))
|
||||
assert.Error(t, err)
|
||||
assert.Empty(t, entries)
|
||||
}
|
||||
|
||||
func TestParseCatFileTreeLine(t *testing.T) {
|
||||
input := "100644 looooooooooooooooooooooooong-file-name.txt\x0012345678901234567890"
|
||||
input += "40755 some-directory\x00abcdefg123abcdefg123"
|
||||
|
||||
var readCount int
|
||||
|
||||
buf := bufio.NewReaderSize(strings.NewReader(input), 20) // NewReaderSize has a limit: min buffer size = 16
|
||||
mode, name, objID, n, err := ParseCatFileTreeLine(Sha1ObjectFormat, buf)
|
||||
readCount += n
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, EntryModeBlob, mode)
|
||||
assert.Equal(t, "looooooooooooooooooooooooong-file-name.txt", name)
|
||||
assert.Equal(t, "12345678901234567890", string(objID.RawValue()))
|
||||
|
||||
mode, name, objID, n, err = ParseCatFileTreeLine(Sha1ObjectFormat, buf)
|
||||
readCount += n
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, EntryModeTree, mode)
|
||||
assert.Equal(t, "some-directory", name)
|
||||
assert.Equal(t, "abcdefg123abcdefg123", string(objID.RawValue()))
|
||||
|
||||
assert.Equal(t, len(input), readCount)
|
||||
|
||||
_, _, _, n, err = ParseCatFileTreeLine(Sha1ObjectFormat, buf)
|
||||
assert.ErrorIs(t, err, io.EOF)
|
||||
assert.Zero(t, n)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
)
|
||||
|
||||
// CatFileBatchCheck runs cat-file with --batch-check
|
||||
func CatFileBatchCheck(ctx context.Context, cmd *gitcmd.Command, tmpBasePath string) error {
|
||||
cmd.AddArguments("cat-file", "--batch-check")
|
||||
return cmd.WithDir(tmpBasePath).RunWithStderr(ctx)
|
||||
}
|
||||
|
||||
// CatFileBatchCheckAllObjects runs cat-file with --batch-check --batch-all
|
||||
func CatFileBatchCheckAllObjects(ctx context.Context, cmd *gitcmd.Command, tmpBasePath string) error {
|
||||
return cmd.AddArguments("cat-file", "--batch-check", "--batch-all-objects").WithDir(tmpBasePath).RunWithStderr(ctx)
|
||||
}
|
||||
|
||||
// CatFileBatch runs cat-file --batch
|
||||
func CatFileBatch(ctx context.Context, cmd *gitcmd.Command, tmpBasePath string) error {
|
||||
return cmd.AddArguments("cat-file", "--batch").WithDir(tmpBasePath).RunWithStderr(ctx)
|
||||
}
|
||||
|
||||
// BlobsLessThan1024FromCatFileBatchCheck reads a pipeline from cat-file --batch-check and returns the blobs <1024 in size
|
||||
func BlobsLessThan1024FromCatFileBatchCheck(in io.ReadCloser, out io.WriteCloser) error {
|
||||
defer out.Close()
|
||||
scanner := bufio.NewScanner(in)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
fields := strings.Split(line, " ")
|
||||
if len(fields) < 3 || fields[1] != "blob" {
|
||||
continue
|
||||
}
|
||||
size, _ := strconv.Atoi(fields[2])
|
||||
if size > 1024 {
|
||||
continue
|
||||
}
|
||||
toWrite := []byte(fields[0] + "\n")
|
||||
for len(toWrite) > 0 {
|
||||
n, err := out.Write(toWrite)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toWrite = toWrite[n:]
|
||||
}
|
||||
}
|
||||
return scanner.Err()
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitea.dev/modules/git"
|
||||
)
|
||||
|
||||
// LFSResult represents commits found using a provided pointer file hash
|
||||
type LFSResult struct {
|
||||
Name string
|
||||
SHA string
|
||||
Summary string
|
||||
When time.Time
|
||||
ParentHashes []git.ObjectID
|
||||
BranchName string
|
||||
FullCommitName string
|
||||
}
|
||||
|
||||
type lfsResultSlice []*LFSResult
|
||||
|
||||
func (a lfsResultSlice) Len() int { return len(a) }
|
||||
func (a lfsResultSlice) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
|
||||
func (a lfsResultSlice) Less(i, j int) bool { return a[j].When.After(a[i].When) }
|
||||
@@ -0,0 +1,85 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build gogit
|
||||
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/git"
|
||||
|
||||
gogit "github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
"github.com/go-git/go-git/v5/plumbing/object"
|
||||
)
|
||||
|
||||
// FindLFSFile finds commits that contain a provided pointer file hash
|
||||
func FindLFSFile(repo *git.Repository, objectID git.ObjectID) ([]*LFSResult, error) {
|
||||
resultsMap := map[string]*LFSResult{}
|
||||
results := make([]*LFSResult, 0)
|
||||
|
||||
gogitRepo := repo.GoGitRepo()
|
||||
|
||||
commitsIter, err := gogitRepo.Log(&gogit.LogOptions{
|
||||
Order: gogit.LogOrderCommitterTime,
|
||||
All: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("LFS error occurred, failed to get GoGit CommitsIter: err: %w", err)
|
||||
}
|
||||
|
||||
err = commitsIter.ForEach(func(gitCommit *object.Commit) error {
|
||||
tree, err := gitCommit.Tree()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
treeWalker := object.NewTreeWalker(tree, true, nil)
|
||||
defer treeWalker.Close()
|
||||
for {
|
||||
name, entry, err := treeWalker.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if entry.Hash == plumbing.Hash(objectID.RawValue()) {
|
||||
parents := make([]git.ObjectID, len(gitCommit.ParentHashes))
|
||||
for i, parentCommitID := range gitCommit.ParentHashes {
|
||||
parents[i] = git.ParseGogitHash(parentCommitID)
|
||||
}
|
||||
|
||||
result := LFSResult{
|
||||
Name: name,
|
||||
SHA: gitCommit.Hash.String(),
|
||||
Summary: strings.Split(strings.TrimSpace(gitCommit.Message), "\n")[0],
|
||||
When: gitCommit.Author.When,
|
||||
ParentHashes: parents,
|
||||
}
|
||||
resultsMap[gitCommit.Hash.String()+":"+name] = &result
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil && err != io.EOF {
|
||||
return nil, fmt.Errorf("LFS error occurred, failure in CommitIter.ForEach: %w", err)
|
||||
}
|
||||
|
||||
for _, result := range resultsMap {
|
||||
hasParent := false
|
||||
for _, parentHash := range result.ParentHashes {
|
||||
if _, hasParent = resultsMap[parentHash.String()+":"+result.Name]; hasParent {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasParent {
|
||||
results = append(results, result)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Sort(lfsResultSlice(results))
|
||||
err = fillResultNameRev(repo.Ctx, repo.Path, results)
|
||||
return results, err
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !gogit
|
||||
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"io"
|
||||
"sort"
|
||||
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
)
|
||||
|
||||
// FindLFSFile finds commits that contain a provided pointer file hash
|
||||
func FindLFSFile(repo *git.Repository, objectID git.ObjectID) (results []*LFSResult, _ error) {
|
||||
cmd := gitcmd.NewCommand("rev-list", "--all")
|
||||
revListReader, revListReaderClose := cmd.MakeStdoutPipe()
|
||||
defer revListReaderClose()
|
||||
err := cmd.WithDir(repo.Path).
|
||||
WithPipelineFunc(func(context gitcmd.Context) (err error) {
|
||||
results, err = findLFSFileFunc(repo, objectID, revListReader)
|
||||
return err
|
||||
}).RunWithStderr(repo.Ctx)
|
||||
return results, err
|
||||
}
|
||||
|
||||
func findLFSFileFunc(repo *git.Repository, objectID git.ObjectID, revListReader io.Reader) ([]*LFSResult, error) {
|
||||
resultsMap := map[string]*LFSResult{}
|
||||
results := make([]*LFSResult, 0)
|
||||
// Next feed the commits in order into cat-file --batch, followed by their trees and sub trees as necessary.
|
||||
// so let's create a batch stdin and stdout
|
||||
batch, cancel, err := repo.CatFileBatch(repo.Ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
// We'll use a scanner for the revList because it's simpler than a bufio.Reader
|
||||
scan := bufio.NewScanner(revListReader)
|
||||
trees := []string{}
|
||||
paths := []string{}
|
||||
|
||||
for scan.Scan() {
|
||||
// Get the next commit ID
|
||||
commitID := scan.Text()
|
||||
|
||||
// push the commit to the cat-file --batch process
|
||||
info, batchReader, err := batch.QueryContent(commitID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var curCommit *git.Commit
|
||||
curPath := ""
|
||||
|
||||
commitReadingLoop:
|
||||
for {
|
||||
switch info.Type {
|
||||
case "tag":
|
||||
// This shouldn't happen but if it does well just get the commit and try again
|
||||
id, err := git.ReadTagObjectID(batchReader, info.Size)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if info, batchReader, err = batch.QueryContent(id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
continue
|
||||
case "commit":
|
||||
// Read in the commit to get its tree and in case this is one of the last used commits
|
||||
curCommit, err = git.CommitFromReader(repo, git.MustIDFromString(commitID), io.LimitReader(batchReader, info.Size))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := batchReader.Discard(1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if info, _, err = batch.QueryContent(curCommit.Tree.ID.String()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
curPath = ""
|
||||
case "tree":
|
||||
var n int64
|
||||
for n < info.Size {
|
||||
mode, fname, shaID, count, err := git.ParseCatFileTreeLine(objectID.Type(), batchReader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n += int64(count)
|
||||
if bytes.Equal(shaID.RawValue(), objectID.RawValue()) {
|
||||
result := LFSResult{
|
||||
Name: curPath + fname,
|
||||
SHA: curCommit.ID.String(),
|
||||
Summary: curCommit.MessageTitle(),
|
||||
When: curCommit.Author.When,
|
||||
ParentHashes: curCommit.Parents,
|
||||
}
|
||||
resultsMap[curCommit.ID.String()+":"+curPath+fname] = &result
|
||||
} else if mode == git.EntryModeTree {
|
||||
trees = append(trees, shaID.String())
|
||||
paths = append(paths, curPath+fname+"/")
|
||||
}
|
||||
}
|
||||
if _, err := batchReader.Discard(1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(trees) > 0 {
|
||||
info, _, err = batch.QueryContent(trees[len(trees)-1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
curPath = paths[len(paths)-1]
|
||||
trees = trees[:len(trees)-1]
|
||||
paths = paths[:len(paths)-1]
|
||||
} else {
|
||||
break commitReadingLoop
|
||||
}
|
||||
default:
|
||||
if err := git.DiscardFull(batchReader, info.Size+1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := scan.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, result := range resultsMap {
|
||||
hasParent := false
|
||||
for _, parentID := range result.ParentHashes {
|
||||
if _, hasParent = resultsMap[parentID.String()+":"+result.Name]; hasParent {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasParent {
|
||||
results = append(results, result)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Sort(lfsResultSlice(results))
|
||||
err = fillResultNameRev(repo.Ctx, repo.Path, results)
|
||||
return results, err
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dev/modules/git"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFindLFSFile(t *testing.T) {
|
||||
repoPath := "../../../tests/gitea-repositories-meta/user2/lfs.git"
|
||||
gitRepo, err := git.OpenRepository(t.Context(), repoPath)
|
||||
require.NoError(t, err)
|
||||
defer gitRepo.Close()
|
||||
|
||||
objectID := git.MustIDFromString("2b6c6c4eaefa24b22f2092c3d54b263ff26feb58")
|
||||
|
||||
stats, err := FindLFSFile(gitRepo, objectID)
|
||||
require.NoError(t, err)
|
||||
|
||||
tm, err := time.Parse(time.RFC3339, "2022-12-21T17:56:42-05:00")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Len(t, stats, 1)
|
||||
assert.Equal(t, "CONTRIBUTING.md", stats[0].Name)
|
||||
assert.Equal(t, "73cf03db6ece34e12bf91e8853dc58f678f2f82d", stats[0].SHA)
|
||||
assert.Equal(t, "Initial commit", stats[0].Summary)
|
||||
assert.Equal(t, tm, stats[0].When)
|
||||
assert.Empty(t, stats[0].ParentHashes)
|
||||
assert.Equal(t, "master", stats[0].BranchName)
|
||||
assert.Equal(t, "master", stats[0].FullCommitName)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/git"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
git.RunGitTests(m)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
func fillResultNameRev(ctx context.Context, basePath string, results []*LFSResult) error {
|
||||
// Should really use a go-git function here but name-rev is not completed and recapitulating it is not simple
|
||||
wg := errgroup.Group{}
|
||||
cmd := gitcmd.NewCommand("name-rev", "--stdin", "--name-only", "--always").WithDir(basePath)
|
||||
stdin, stdinClose := cmd.MakeStdinPipe()
|
||||
stdout, stdoutClose := cmd.MakeStdoutPipe()
|
||||
defer stdinClose()
|
||||
defer stdoutClose()
|
||||
|
||||
wg.Go(func() error {
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
i := 0
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
result := results[i]
|
||||
result.FullCommitName = line
|
||||
result.BranchName = strings.Split(line, "~")[0]
|
||||
i++
|
||||
}
|
||||
return scanner.Err()
|
||||
})
|
||||
wg.Go(func() error {
|
||||
defer stdinClose()
|
||||
for _, result := range results {
|
||||
_, err := stdin.Write([]byte(result.SHA))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = stdin.Write([]byte{'\n'})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
err := cmd.RunWithStderr(ctx)
|
||||
return errors.Join(err, wg.Wait())
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
)
|
||||
|
||||
// RevListObjects run rev-list --objects from headSHA to baseSHA
|
||||
func RevListObjects(ctx context.Context, cmd *gitcmd.Command, tmpBasePath, headSHA, baseSHA string) error {
|
||||
cmd.AddArguments("rev-list", "--objects").AddDynamicArguments(headSHA)
|
||||
if baseSHA != "" {
|
||||
cmd = cmd.AddArguments("--not").AddDynamicArguments(baseSHA)
|
||||
}
|
||||
return cmd.WithDir(tmpBasePath).RunWithStderr(ctx)
|
||||
}
|
||||
|
||||
// BlobsFromRevListObjects reads a RevListAllObjects and only selects blobs
|
||||
func BlobsFromRevListObjects(in io.ReadCloser, out io.WriteCloser) error {
|
||||
defer out.Close()
|
||||
scanner := bufio.NewScanner(in)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
fields := strings.Split(line, " ")
|
||||
if len(fields) < 2 || len(fields[1]) == 0 {
|
||||
continue
|
||||
}
|
||||
toWrite := []byte(fields[0] + "\n")
|
||||
for len(toWrite) > 0 {
|
||||
n, err := out.Write(toWrite)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toWrite = toWrite[n:]
|
||||
}
|
||||
}
|
||||
return scanner.Err()
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
const (
|
||||
// RemotePrefix is the base directory of the remotes information of git.
|
||||
RemotePrefix = "refs/remotes/"
|
||||
// PullPrefix is the base directory of the pull information of git.
|
||||
PullPrefix = "refs/pull/"
|
||||
)
|
||||
|
||||
// refNamePatternInvalid is regular expression with unallowed characters in git reference name
|
||||
// They cannot have ASCII control characters (i.e. bytes whose values are lower than \040, or \177 DEL), space, tilde ~, caret ^, or colon : anywhere.
|
||||
// They cannot have question-mark ?, asterisk *, or open bracket [ anywhere
|
||||
var refNamePatternInvalid = regexp.MustCompile(
|
||||
`[\000-\037\177 \\~^:?*[]|` + // No absolutely invalid characters
|
||||
`(?:^[/.])|` + // Not HasPrefix("/") or "."
|
||||
`(?:/\.)|` + // no "/."
|
||||
`(?:\.lock$)|(?:\.lock/)|` + // No ".lock/"" or ".lock" at the end
|
||||
`(?:\.\.)|` + // no ".." anywhere
|
||||
`(?://)|` + // no "//" anywhere
|
||||
`(?:@{)|` + // no "@{"
|
||||
`(?:[/.]$)|` + // no terminal '/' or '.'
|
||||
`(?:^@$)`) // Not "@"
|
||||
|
||||
// IsValidRefPattern ensures that the provided string could be a valid reference
|
||||
func IsValidRefPattern(name string) bool {
|
||||
return !refNamePatternInvalid.MatchString(name)
|
||||
}
|
||||
|
||||
func SanitizeRefPattern(name string) string {
|
||||
return refNamePatternInvalid.ReplaceAllString(name, "_")
|
||||
}
|
||||
|
||||
// Reference represents a Git ref.
|
||||
type Reference struct {
|
||||
Name string
|
||||
repo *Repository
|
||||
Object ObjectID // The id of this commit object
|
||||
Type string
|
||||
}
|
||||
|
||||
// Commit return the commit of the reference
|
||||
func (ref *Reference) Commit() (*Commit, error) {
|
||||
return ref.repo.getCommit(ref.Object)
|
||||
}
|
||||
|
||||
// ShortName returns the short name of the reference
|
||||
func (ref *Reference) ShortName() string {
|
||||
return RefName(ref.Name).ShortName()
|
||||
}
|
||||
|
||||
// RefGroup returns the group type of the reference
|
||||
func (ref *Reference) RefGroup() string {
|
||||
return RefName(ref.Name).RefGroup()
|
||||
}
|
||||
|
||||
// ForPrefix special ref to create a pull request: refs/for/<target-branch>/<topic-branch>
|
||||
// or refs/for/<targe-branch> -o topic='<topic-branch>'
|
||||
const ForPrefix = "refs/for/"
|
||||
|
||||
// TODO: /refs/for-review for suggest change interface
|
||||
|
||||
// RefName represents a full git reference name
|
||||
type RefName string
|
||||
|
||||
func RefNameFromBranch(shortName string) RefName {
|
||||
return RefName(BranchPrefix + shortName)
|
||||
}
|
||||
|
||||
func RefNameFromTag(shortName string) RefName {
|
||||
return RefName(TagPrefix + shortName)
|
||||
}
|
||||
|
||||
func RefNameFromCommit(shortName string) RefName {
|
||||
return RefName(shortName)
|
||||
}
|
||||
|
||||
func (ref RefName) String() string {
|
||||
return string(ref)
|
||||
}
|
||||
|
||||
func (ref RefName) IsBranch() bool {
|
||||
return strings.HasPrefix(string(ref), BranchPrefix)
|
||||
}
|
||||
|
||||
func (ref RefName) IsTag() bool {
|
||||
return strings.HasPrefix(string(ref), TagPrefix)
|
||||
}
|
||||
|
||||
func (ref RefName) IsRemote() bool {
|
||||
return strings.HasPrefix(string(ref), RemotePrefix)
|
||||
}
|
||||
|
||||
func (ref RefName) IsPull() bool {
|
||||
return strings.HasPrefix(string(ref), PullPrefix) && strings.IndexByte(string(ref)[len(PullPrefix):], '/') > -1
|
||||
}
|
||||
|
||||
func (ref RefName) IsFor() bool {
|
||||
return strings.HasPrefix(string(ref), ForPrefix)
|
||||
}
|
||||
|
||||
func (ref RefName) nameWithoutPrefix(prefix string) string {
|
||||
if after, ok := strings.CutPrefix(string(ref), prefix); ok {
|
||||
return after
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// TagName returns simple tag name if it's an operation to a tag
|
||||
func (ref RefName) TagName() string {
|
||||
return ref.nameWithoutPrefix(TagPrefix)
|
||||
}
|
||||
|
||||
// BranchName returns simple branch name if it's an operation to branch
|
||||
func (ref RefName) BranchName() string {
|
||||
return ref.nameWithoutPrefix(BranchPrefix)
|
||||
}
|
||||
|
||||
// PullName returns the pull request name part of refs like refs/pull/<pull_name>/head
|
||||
func (ref RefName) PullName() string {
|
||||
refName := string(ref)
|
||||
lastIdx := strings.LastIndexByte(refName[len(PullPrefix):], '/')
|
||||
if strings.HasPrefix(refName, PullPrefix) && lastIdx > -1 {
|
||||
return refName[len(PullPrefix) : lastIdx+len(PullPrefix)]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ForBranchName returns the branch name part of refs like refs/for/<branch_name>
|
||||
func (ref RefName) ForBranchName() string {
|
||||
return ref.nameWithoutPrefix(ForPrefix)
|
||||
}
|
||||
|
||||
func (ref RefName) RemoteName() string {
|
||||
return ref.nameWithoutPrefix(RemotePrefix)
|
||||
}
|
||||
|
||||
// ShortName returns the short name of the reference name
|
||||
func (ref RefName) ShortName() string {
|
||||
if ref.IsBranch() {
|
||||
return ref.BranchName()
|
||||
}
|
||||
if ref.IsTag() {
|
||||
return ref.TagName()
|
||||
}
|
||||
if ref.IsRemote() {
|
||||
return ref.RemoteName()
|
||||
}
|
||||
if ref.IsPull() {
|
||||
return ref.PullName()
|
||||
}
|
||||
if ref.IsFor() {
|
||||
return ref.ForBranchName()
|
||||
}
|
||||
return string(ref) // usually it is a commit ID
|
||||
}
|
||||
|
||||
// RefGroup returns the group type of the reference
|
||||
// Using the name of the directory under .git/refs
|
||||
func (ref RefName) RefGroup() string {
|
||||
if ref.IsBranch() {
|
||||
return "heads"
|
||||
}
|
||||
if ref.IsTag() {
|
||||
return "tags"
|
||||
}
|
||||
if ref.IsRemote() {
|
||||
return "remotes"
|
||||
}
|
||||
if ref.IsPull() {
|
||||
return "pull"
|
||||
}
|
||||
if ref.IsFor() {
|
||||
return "for"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// RefType is a simple ref type of the reference, it is used for UI and webhooks
|
||||
type RefType string
|
||||
|
||||
const (
|
||||
RefTypeBranch RefType = "branch"
|
||||
RefTypeTag RefType = "tag"
|
||||
RefTypeCommit RefType = "commit"
|
||||
)
|
||||
|
||||
// RefType returns the simple ref type of the reference, e.g. branch, tag
|
||||
// It's different from RefGroup, which is using the name of the directory under .git/refs
|
||||
func (ref RefName) RefType() RefType {
|
||||
switch {
|
||||
case ref.IsBranch():
|
||||
return RefTypeBranch
|
||||
case ref.IsTag():
|
||||
return RefTypeTag
|
||||
case IsStringLikelyCommitID(nil, string(ref), 6):
|
||||
return RefTypeCommit
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// RefWebLinkPath returns a path for the reference that can be used in a web link:
|
||||
// * "branch/<branch_name>"
|
||||
// * "tag/<tag_name>"
|
||||
// * "commit/<commit_id>"
|
||||
// It returns an empty string if the reference is not a branch, tag or commit.
|
||||
func (ref RefName) RefWebLinkPath() string {
|
||||
refType := ref.RefType()
|
||||
if refType == "" {
|
||||
return ""
|
||||
}
|
||||
return string(refType) + "/" + util.PathEscapeSegments(ref.ShortName())
|
||||
}
|
||||
|
||||
func ParseRefSuffix(ref string) (string, string) {
|
||||
// Partially support https://git-scm.com/docs/gitrevisions
|
||||
if idx := strings.Index(ref, "@{"); idx != -1 {
|
||||
return ref[:idx], ref[idx:]
|
||||
}
|
||||
if idx := strings.Index(ref, "^"); idx != -1 {
|
||||
return ref[:idx], ref[idx:]
|
||||
}
|
||||
return ref, ""
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRefName(t *testing.T) {
|
||||
// Test branch names (with and without slash).
|
||||
assert.Equal(t, "foo", RefName("refs/heads/foo").BranchName())
|
||||
assert.Equal(t, "feature/foo", RefName("refs/heads/feature/foo").BranchName())
|
||||
|
||||
// Test tag names (with and without slash).
|
||||
assert.Equal(t, "foo", RefName("refs/tags/foo").TagName())
|
||||
assert.Equal(t, "release/foo", RefName("refs/tags/release/foo").TagName())
|
||||
|
||||
// Test pull names
|
||||
assert.Equal(t, "1", RefName("refs/pull/1/head").PullName())
|
||||
assert.True(t, RefName("refs/pull/1/head").IsPull())
|
||||
assert.True(t, RefName("refs/pull/1/merge").IsPull())
|
||||
assert.Equal(t, "my/pull", RefName("refs/pull/my/pull/head").PullName())
|
||||
|
||||
// Test for branch names
|
||||
assert.Equal(t, "main", RefName("refs/for/main").ForBranchName())
|
||||
assert.Equal(t, "my/branch", RefName("refs/for/my/branch").ForBranchName())
|
||||
|
||||
// Test commit hashes.
|
||||
assert.Equal(t, "c0ffee", RefName("c0ffee").ShortName())
|
||||
}
|
||||
|
||||
func TestRefWebLinkPath(t *testing.T) {
|
||||
assert.Equal(t, "branch/foo", RefName("refs/heads/foo").RefWebLinkPath())
|
||||
assert.Equal(t, "tag/foo", RefName("refs/tags/foo").RefWebLinkPath())
|
||||
assert.Equal(t, "commit/c0ffee", RefName("c0ffee").RefWebLinkPath())
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
// GetRemoteAddress returns remote url of git repository in the repoPath with special remote name
|
||||
func GetRemoteAddress(ctx context.Context, repoPath, remoteName string) (string, error) {
|
||||
var cmd *gitcmd.Command
|
||||
if DefaultFeatures().CheckVersionAtLeast("2.7") {
|
||||
cmd = gitcmd.NewCommand("remote", "get-url").AddDynamicArguments(remoteName)
|
||||
} else {
|
||||
cmd = gitcmd.NewCommand("config", "--get").AddDynamicArguments("remote." + remoteName + ".url")
|
||||
}
|
||||
|
||||
result, _, err := cmd.WithDir(repoPath).RunStdString(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if len(result) > 0 {
|
||||
result = result[:len(result)-1]
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ErrInvalidCloneAddr represents a "InvalidCloneAddr" kind of error.
|
||||
type ErrInvalidCloneAddr struct {
|
||||
Host string
|
||||
IsURLError bool
|
||||
IsInvalidPath bool
|
||||
IsProtocolInvalid bool
|
||||
IsPermissionDenied bool
|
||||
LocalPath bool
|
||||
}
|
||||
|
||||
// IsErrInvalidCloneAddr checks if an error is a ErrInvalidCloneAddr.
|
||||
func IsErrInvalidCloneAddr(err error) bool {
|
||||
_, ok := err.(*ErrInvalidCloneAddr)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err *ErrInvalidCloneAddr) Error() string {
|
||||
if err.IsInvalidPath {
|
||||
return fmt.Sprintf("migration/cloning from '%s' is not allowed: the provided path is invalid", err.Host)
|
||||
}
|
||||
if err.IsProtocolInvalid {
|
||||
return fmt.Sprintf("migration/cloning from '%s' is not allowed: the provided url protocol is not allowed", err.Host)
|
||||
}
|
||||
if err.IsPermissionDenied {
|
||||
return fmt.Sprintf("migration/cloning from '%s' is not allowed.", err.Host)
|
||||
}
|
||||
if err.IsURLError {
|
||||
return fmt.Sprintf("migration/cloning from '%s' is not allowed: the provided url is invalid", err.Host)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("migration/cloning from '%s' is not allowed", err.Host)
|
||||
}
|
||||
|
||||
func (err *ErrInvalidCloneAddr) Unwrap() error {
|
||||
return util.ErrInvalidArgument
|
||||
}
|
||||
|
||||
// IsRemoteNotExistError checks the prefix of the error message to see whether a remote does not exist.
|
||||
func IsRemoteNotExistError(err error) bool {
|
||||
// see: https://github.com/go-gitea/gitea/issues/32889#issuecomment-2571848216
|
||||
// Should not add space in the end, sometimes git will add a `:`
|
||||
prefix1 := "fatal: No such remote" // git < 2.30, exit status 128
|
||||
prefix2 := "error: No such remote" // git >= 2.30. exit status 2
|
||||
return gitcmd.StderrHasPrefix(err, prefix1) || gitcmd.StderrHasPrefix(err, prefix2)
|
||||
}
|
||||
|
||||
// ParseRemoteAddr checks if given remote address is valid,
|
||||
// and returns composed URL with needed username and password.
|
||||
func ParseRemoteAddr(remoteAddr, authUsername, authPassword string) (string, error) {
|
||||
remoteAddr = strings.TrimSpace(remoteAddr)
|
||||
// Remote address can be HTTP/HTTPS/Git URL or local path.
|
||||
if strings.HasPrefix(remoteAddr, "http://") ||
|
||||
strings.HasPrefix(remoteAddr, "https://") ||
|
||||
strings.HasPrefix(remoteAddr, "git://") {
|
||||
u, err := url.Parse(remoteAddr)
|
||||
if err != nil {
|
||||
return "", &ErrInvalidCloneAddr{IsURLError: true, Host: remoteAddr}
|
||||
}
|
||||
if len(authUsername)+len(authPassword) > 0 {
|
||||
u.User = url.UserPassword(authUsername, authPassword)
|
||||
}
|
||||
remoteAddr = u.String()
|
||||
}
|
||||
|
||||
return remoteAddr, nil
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
// Copyright 2015 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/proxy"
|
||||
)
|
||||
|
||||
const prettyLogFormat = `--pretty=format:%H`
|
||||
|
||||
func (repo *Repository) ShowPrettyFormatLogToList(ctx context.Context, revisionRange string) ([]*Commit, error) {
|
||||
// avoid: ambiguous argument 'refs/a...refs/b': unknown revision or path not in the working tree. Use '--': 'git <command> [<revision>...] -- [<file>...]'
|
||||
logs, _, err := gitcmd.NewCommand("log").AddArguments(prettyLogFormat).
|
||||
AddDynamicArguments(revisionRange).AddArguments("--").WithDir(repo.Path).
|
||||
RunStdBytes(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return repo.parsePrettyFormatLogToList(logs)
|
||||
}
|
||||
|
||||
func (repo *Repository) parsePrettyFormatLogToList(logs []byte) ([]*Commit, error) {
|
||||
var commits []*Commit
|
||||
if len(logs) == 0 {
|
||||
return commits, nil
|
||||
}
|
||||
|
||||
parts := bytes.SplitSeq(logs, []byte{'\n'})
|
||||
|
||||
for commitID := range parts {
|
||||
commit, err := repo.GetCommit(string(commitID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
commits = append(commits, commit)
|
||||
}
|
||||
|
||||
return commits, nil
|
||||
}
|
||||
|
||||
// IsRepoURLAccessible checks if given repository URL is accessible.
|
||||
func IsRepoURLAccessible(ctx context.Context, url string) bool {
|
||||
_, _, err := gitcmd.NewCommand("ls-remote", "-q", "-h").AddDynamicArguments(url, "HEAD").RunStdString(ctx)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// InitRepository initializes a new Git repository.
|
||||
func InitRepository(ctx context.Context, repoPath string, bare bool, objectFormatName string) error {
|
||||
err := os.MkdirAll(repoPath, os.ModePerm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd := gitcmd.NewCommand("init")
|
||||
|
||||
if !IsValidObjectFormat(objectFormatName) {
|
||||
return fmt.Errorf("invalid object format: %s", objectFormatName)
|
||||
}
|
||||
if DefaultFeatures().SupportHashSha256 {
|
||||
cmd.AddOptionValues("--object-format", objectFormatName)
|
||||
}
|
||||
|
||||
if bare {
|
||||
cmd.AddArguments("--bare")
|
||||
}
|
||||
_, _, err = cmd.WithDir(repoPath).RunStdString(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
// IsEmpty Check if repository is empty.
|
||||
func (repo *Repository) IsEmpty() (bool, error) {
|
||||
stdout, _, err := gitcmd.NewCommand().
|
||||
AddOptionFormat("--git-dir=%s", repo.Path).
|
||||
AddArguments("rev-list", "-n", "1", "--all").
|
||||
WithDir(repo.Path).
|
||||
RunStdString(repo.Ctx)
|
||||
if err != nil {
|
||||
if (gitcmd.IsErrorExitCode(err, 1) && err.Stderr() == "") || gitcmd.IsErrorExitCode(err, 129) {
|
||||
// git 2.11 exits with 129 if the repo is empty
|
||||
return true, nil
|
||||
}
|
||||
return true, fmt.Errorf("check empty: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(stdout) == "", nil
|
||||
}
|
||||
|
||||
// CloneRepoOptions options when clone a repository
|
||||
type CloneRepoOptions struct {
|
||||
Timeout time.Duration
|
||||
Mirror bool
|
||||
Bare bool
|
||||
Quiet bool
|
||||
Branch string
|
||||
Shared bool
|
||||
NoCheckout bool
|
||||
Depth int
|
||||
Filter string
|
||||
SkipTLSVerify bool
|
||||
SingleBranch bool
|
||||
Env []string
|
||||
}
|
||||
|
||||
// Clone clones original repository to target path.
|
||||
func Clone(ctx context.Context, from, to string, opts CloneRepoOptions) error {
|
||||
toDir := path.Dir(to)
|
||||
if err := os.MkdirAll(toDir, os.ModePerm); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd := gitcmd.NewCommand().AddArguments("clone")
|
||||
if opts.SkipTLSVerify {
|
||||
cmd.AddArguments("-c", "http.sslVerify=false")
|
||||
}
|
||||
if opts.Mirror {
|
||||
cmd.AddArguments("--mirror")
|
||||
}
|
||||
if opts.Bare {
|
||||
cmd.AddArguments("--bare")
|
||||
}
|
||||
if opts.Quiet {
|
||||
cmd.AddArguments("--quiet")
|
||||
}
|
||||
if opts.Shared {
|
||||
cmd.AddArguments("-s")
|
||||
}
|
||||
if opts.NoCheckout {
|
||||
cmd.AddArguments("--no-checkout")
|
||||
}
|
||||
if opts.Depth > 0 {
|
||||
cmd.AddArguments("--depth").AddDynamicArguments(strconv.Itoa(opts.Depth))
|
||||
}
|
||||
if opts.Filter != "" {
|
||||
cmd.AddArguments("--filter").AddDynamicArguments(opts.Filter)
|
||||
}
|
||||
if opts.SingleBranch {
|
||||
cmd.AddArguments("--single-branch")
|
||||
}
|
||||
if len(opts.Branch) > 0 {
|
||||
cmd.AddArguments("-b").AddDynamicArguments(opts.Branch)
|
||||
}
|
||||
cmd.AddDashesAndList(from, to)
|
||||
|
||||
if opts.Timeout <= 0 {
|
||||
opts.Timeout = -1
|
||||
}
|
||||
|
||||
envs := os.Environ()
|
||||
if opts.Env != nil {
|
||||
envs = opts.Env
|
||||
} else {
|
||||
u, err := url.Parse(from)
|
||||
if err == nil {
|
||||
envs = proxy.EnvWithProxy(u)
|
||||
}
|
||||
}
|
||||
|
||||
return cmd.
|
||||
WithTimeout(opts.Timeout).
|
||||
WithEnv(envs).
|
||||
RunWithStderr(ctx)
|
||||
}
|
||||
|
||||
// PushOptions options when push to remote
|
||||
type PushOptions struct {
|
||||
Remote string
|
||||
LocalRefName string
|
||||
Branch string
|
||||
Force bool
|
||||
ForceWithLease string
|
||||
Mirror bool
|
||||
Env []string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// Push pushs local commits to given remote branch.
|
||||
func Push(ctx context.Context, repoPath string, opts PushOptions) error {
|
||||
cmd := gitcmd.NewCommand("push")
|
||||
if opts.ForceWithLease != "" {
|
||||
cmd.AddOptionFormat("--force-with-lease=%s", opts.ForceWithLease)
|
||||
} else if opts.Force {
|
||||
cmd.AddArguments("-f")
|
||||
}
|
||||
if opts.Mirror {
|
||||
cmd.AddArguments("--mirror")
|
||||
}
|
||||
remoteBranchArgs := []string{opts.Remote}
|
||||
if len(opts.Branch) > 0 {
|
||||
var refspec string
|
||||
if opts.LocalRefName != "" {
|
||||
refspec = fmt.Sprintf("%s:%s", opts.LocalRefName, opts.Branch)
|
||||
} else {
|
||||
refspec = opts.Branch
|
||||
}
|
||||
remoteBranchArgs = append(remoteBranchArgs, refspec)
|
||||
}
|
||||
cmd.AddDashesAndList(remoteBranchArgs...)
|
||||
|
||||
stdout, stderr, err := cmd.WithEnv(opts.Env).WithTimeout(opts.Timeout).WithDir(repoPath).RunStdString(ctx)
|
||||
if err != nil {
|
||||
if strings.Contains(stderr, "non-fast-forward") {
|
||||
return &ErrPushOutOfDate{StdOut: stdout, StdErr: stderr, Err: err}
|
||||
} else if strings.Contains(stderr, "! [remote rejected]") || strings.Contains(stderr, "! [rejected]") {
|
||||
err := &ErrPushRejected{StdOut: stdout, StdErr: stderr, Err: err}
|
||||
err.GenerateMessage()
|
||||
return err
|
||||
} else if strings.Contains(stderr, "matches more than one") {
|
||||
return &ErrMoreThanOne{StdOut: stdout, StdErr: stderr, Err: err}
|
||||
}
|
||||
return fmt.Errorf("push failed: %w - %s\n%s", err, stderr, stdout)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// Copyright 2015 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build gogit
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
|
||||
gitealog "gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/go-git/go-billy/v5"
|
||||
"github.com/go-git/go-billy/v5/osfs"
|
||||
gogit "github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
"github.com/go-git/go-git/v5/plumbing/cache"
|
||||
"github.com/go-git/go-git/v5/storage/filesystem"
|
||||
)
|
||||
|
||||
const isGogit = true
|
||||
|
||||
// Repository represents a Git repository.
|
||||
type Repository struct {
|
||||
Path string
|
||||
|
||||
tagCache *ObjectCache[*Tag]
|
||||
|
||||
gogitRepo *gogit.Repository
|
||||
gogitStorage *filesystem.Storage
|
||||
|
||||
Ctx context.Context
|
||||
LastCommitCache *LastCommitCache
|
||||
objectFormat ObjectFormat
|
||||
}
|
||||
|
||||
// OpenRepository opens the repository at the given path within the context.Context
|
||||
func OpenRepository(ctx context.Context, repoPath string) (*Repository, error) {
|
||||
repoPath, err := filepath.Abs(repoPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
exist, err := util.IsDir(repoPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !exist {
|
||||
return nil, util.NewNotExistErrorf("no such file or directory")
|
||||
}
|
||||
|
||||
fs := osfs.New(repoPath)
|
||||
_, err = fs.Stat(".git")
|
||||
if err == nil {
|
||||
fs, err = fs.Chroot(".git")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// the "clone --shared" repo doesn't work well with go-git AlternativeFS, https://github.com/go-git/go-git/issues/1006
|
||||
// so use "/" for AlternatesFS, I guess it is the same behavior as current nogogit (no limitation or check for the "objects/info/alternates" paths), trust the "clone" command executed by the server.
|
||||
var altFs billy.Filesystem
|
||||
if setting.IsWindows {
|
||||
altFs = osfs.New(filepath.VolumeName(setting.RepoRootPath) + "\\") // TODO: does it really work for Windows? Need some time to check.
|
||||
} else {
|
||||
altFs = osfs.New("/")
|
||||
}
|
||||
storage := filesystem.NewStorageWithOptions(fs, cache.NewObjectLRUDefault(), filesystem.Options{KeepDescriptors: true, LargeObjectThreshold: setting.Git.LargeObjectThreshold, AlternatesFS: altFs})
|
||||
gogitRepo, err := gogit.Open(storage, fs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Repository{
|
||||
Path: repoPath,
|
||||
gogitRepo: gogitRepo,
|
||||
gogitStorage: storage,
|
||||
tagCache: newObjectCache[*Tag](),
|
||||
Ctx: ctx,
|
||||
objectFormat: ParseGogitHash(plumbing.ZeroHash).Type(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Close this repository, in particular close the underlying gogitStorage if this is not nil
|
||||
func (repo *Repository) Close() error {
|
||||
if repo == nil || repo.gogitStorage == nil {
|
||||
return nil
|
||||
}
|
||||
if err := repo.gogitStorage.Close(); err != nil {
|
||||
gitealog.Error("Error closing storage: %v", err)
|
||||
}
|
||||
repo.gogitStorage = nil
|
||||
repo.LastCommitCache = nil
|
||||
repo.tagCache = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// GoGitRepo gets the go-git repo representation
|
||||
func (repo *Repository) GoGitRepo() *gogit.Repository {
|
||||
return repo.gogitRepo
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// Copyright 2015 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !gogit
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
const isGogit = false
|
||||
|
||||
// Repository represents a Git repository.
|
||||
type Repository struct {
|
||||
Path string
|
||||
|
||||
tagCache *ObjectCache[*Tag]
|
||||
|
||||
mu sync.Mutex
|
||||
catFileBatchCloser CatFileBatchCloser
|
||||
catFileBatchInUse bool
|
||||
|
||||
Ctx context.Context
|
||||
LastCommitCache *LastCommitCache
|
||||
|
||||
objectFormat ObjectFormat
|
||||
}
|
||||
|
||||
// OpenRepository opens the repository at the given path with the provided context.
|
||||
func OpenRepository(ctx context.Context, repoPath string) (*Repository, error) {
|
||||
repoPath, err := filepath.Abs(repoPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
exist, err := util.IsDir(repoPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !exist {
|
||||
return nil, util.NewNotExistErrorf("no such file or directory")
|
||||
}
|
||||
|
||||
return &Repository{
|
||||
Path: repoPath,
|
||||
tagCache: newObjectCache[*Tag](),
|
||||
Ctx: ctx,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CatFileBatch obtains a "batch object provider" for this repository.
|
||||
// It reuses an existing one if available, otherwise creates a new one.
|
||||
func (repo *Repository) CatFileBatch(ctx context.Context) (_ CatFileBatch, closeFunc func(), err error) {
|
||||
repo.mu.Lock()
|
||||
defer repo.mu.Unlock()
|
||||
|
||||
if repo.catFileBatchCloser == nil {
|
||||
repo.catFileBatchCloser, err = NewBatch(ctx, repo.Path)
|
||||
if err != nil {
|
||||
repo.catFileBatchCloser = nil // otherwise it is "interface(nil)" and will cause wrong logic
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if !repo.catFileBatchInUse {
|
||||
repo.catFileBatchInUse = true
|
||||
return CatFileBatch(repo.catFileBatchCloser), func() {
|
||||
repo.mu.Lock()
|
||||
defer repo.mu.Unlock()
|
||||
repo.catFileBatchInUse = false
|
||||
}, nil
|
||||
}
|
||||
|
||||
log.Debug("Opening temporary cat file batch for: %s", repo.Path)
|
||||
tempBatch, err := NewBatch(ctx, repo.Path)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return tempBatch, tempBatch.Close, nil
|
||||
}
|
||||
|
||||
func (repo *Repository) Close() error {
|
||||
if repo == nil {
|
||||
return nil
|
||||
}
|
||||
repo.mu.Lock()
|
||||
defer repo.mu.Unlock()
|
||||
if repo.catFileBatchCloser != nil {
|
||||
repo.catFileBatchCloser.Close()
|
||||
repo.catFileBatchCloser = nil
|
||||
repo.catFileBatchInUse = false
|
||||
}
|
||||
repo.LastCommitCache = nil
|
||||
repo.tagCache = nil
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !gogit
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRepoCatFileBatch(t *testing.T) {
|
||||
t.Run("MissingRepoAndClose", func(t *testing.T) {
|
||||
repo, err := OpenRepository(t.Context(), filepath.Join(testReposDir, "repo1_bare"))
|
||||
require.NoError(t, err)
|
||||
repo.Path = "/no-such" // when the repo is missing (it usually occurs during testing because the fixtures are synced frequently)
|
||||
_, _, err = repo.CatFileBatch(t.Context())
|
||||
require.Error(t, err)
|
||||
require.NoError(t, repo.Close()) // shouldn't panic
|
||||
})
|
||||
|
||||
// TODO: test more methods and concurrency queries
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
// GetBlob finds the blob object in the repository.
|
||||
func (repo *Repository) GetBlob(idStr string) (*Blob, error) {
|
||||
id, err := NewIDFromString(idStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if id.IsZero() {
|
||||
return nil, ErrNotExist{id.String(), ""}
|
||||
}
|
||||
return &Blob{
|
||||
ID: id,
|
||||
repo: repo,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRepository_GetBlob_Found(t *testing.T) {
|
||||
repoPath := filepath.Join(testReposDir, "repo1_bare")
|
||||
r, err := OpenRepository(t.Context(), repoPath)
|
||||
assert.NoError(t, err)
|
||||
defer r.Close()
|
||||
|
||||
testCases := []struct {
|
||||
OID string
|
||||
Data []byte
|
||||
}{
|
||||
{"e2129701f1a4d54dc44f03c93bca0a2aec7c5449", []byte("file1\n")},
|
||||
{"6c493ff740f9380390d5c9ddef4af18697ac9375", []byte("file2\n")},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
blob, err := r.GetBlob(testCase.OID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
dataReader, err := blob.DataAsync()
|
||||
assert.NoError(t, err)
|
||||
|
||||
data, err := io.ReadAll(dataReader)
|
||||
assert.NoError(t, dataReader.Close())
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, testCase.Data, data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepository_GetBlob_NotExist(t *testing.T) {
|
||||
repoPath := filepath.Join(testReposDir, "repo1_bare")
|
||||
r, err := OpenRepository(t.Context(), repoPath)
|
||||
assert.NoError(t, err)
|
||||
defer r.Close()
|
||||
|
||||
testCase := "0000000000000000000000000000000000000000"
|
||||
testError := ErrNotExist{testCase, ""}
|
||||
|
||||
blob, err := r.GetBlob(testCase)
|
||||
assert.Nil(t, blob)
|
||||
assert.EqualError(t, err, testError.Error())
|
||||
}
|
||||
|
||||
func TestRepository_GetBlob_NoId(t *testing.T) {
|
||||
repoPath := filepath.Join(testReposDir, "repo1_bare")
|
||||
r, err := OpenRepository(t.Context(), repoPath)
|
||||
assert.NoError(t, err)
|
||||
defer r.Close()
|
||||
|
||||
testCase := ""
|
||||
testError := fmt.Errorf("length %d has no matched object format: %s", len(testCase), testCase)
|
||||
|
||||
blob, err := r.GetBlob(testCase)
|
||||
assert.Nil(t, blob)
|
||||
assert.EqualError(t, err, testError.Error())
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Copyright 2015 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
)
|
||||
|
||||
// BranchPrefix base dir of the branch information file store on git
|
||||
const BranchPrefix = "refs/heads/"
|
||||
|
||||
// AddRemote adds a new remote to repository.
|
||||
func (repo *Repository) AddRemote(name, url string, fetch bool) error {
|
||||
cmd := gitcmd.NewCommand("remote", "add")
|
||||
if fetch {
|
||||
cmd.AddArguments("-f")
|
||||
}
|
||||
_, _, err := cmd.AddDynamicArguments(name, url).
|
||||
WithDir(repo.Path).
|
||||
RunStdString(repo.Ctx)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// Copyright 2015 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build gogit
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
"github.com/go-git/go-git/v5/plumbing/storer"
|
||||
)
|
||||
|
||||
// IsObjectExist returns true if the given object exists in the repository.
|
||||
// FIXME: Inconsistent behavior with nogogit edition
|
||||
// Unlike the implementation of IsObjectExist in nogogit edition, it does not support short hashes here.
|
||||
// For example, IsObjectExist("153f451") will return false, but it will return true in nogogit edition.
|
||||
// To fix this, the solution could be adding support for short hashes in gogit edition if it's really needed.
|
||||
func (repo *Repository) IsObjectExist(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
_, err := repo.gogitRepo.Object(plumbing.AnyObject, plumbing.NewHash(name))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// IsReferenceExist returns true if given reference exists in the repository.
|
||||
// FIXME: Inconsistent behavior with nogogit edition
|
||||
// Unlike the implementation of IsObjectExist in nogogit edition, it does not support blob hashes here.
|
||||
// For example, IsObjectExist([existing_blob_hash]) will return false, but it will return true in nogogit edition.
|
||||
// To fix this, the solution could be refusing to support blob hashes in nogogit edition since a blob hash is not a reference.
|
||||
func (repo *Repository) IsReferenceExist(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
_, err := repo.gogitRepo.ResolveRevision(plumbing.Revision(name))
|
||||
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// IsBranchExist returns true if given branch exists in current repository.
|
||||
func (repo *Repository) IsBranchExist(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
reference, err := repo.gogitRepo.Reference(plumbing.ReferenceName(BranchPrefix+name), true)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return reference.Type() != plumbing.InvalidReference
|
||||
}
|
||||
|
||||
// GetBranches returns branches from the repository, skipping "skip" initial branches and
|
||||
// returning at most "limit" branches, or all branches if "limit" is 0.
|
||||
// Branches are returned with sort of `-committerdate` as the nogogit
|
||||
// implementation. This requires full fetch, sort and then the
|
||||
// skip/limit applies later as gogit returns in undefined order.
|
||||
func (repo *Repository) GetBranchNames(skip, limit int) ([]string, int, error) {
|
||||
type BranchData struct {
|
||||
name string
|
||||
committerDate int64
|
||||
}
|
||||
var branchData []BranchData
|
||||
|
||||
branchIter, err := repo.gogitRepo.Branches()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
_ = branchIter.ForEach(func(branch *plumbing.Reference) error {
|
||||
obj, err := repo.gogitRepo.CommitObject(branch.Hash())
|
||||
if err != nil {
|
||||
// skip branch if can't find commit
|
||||
return nil
|
||||
}
|
||||
|
||||
branchData = append(branchData, BranchData{strings.TrimPrefix(branch.Name().String(), BranchPrefix), obj.Committer.When.Unix()})
|
||||
return nil
|
||||
})
|
||||
|
||||
sort.Slice(branchData, func(i, j int) bool {
|
||||
return !(branchData[i].committerDate < branchData[j].committerDate)
|
||||
})
|
||||
|
||||
var branchNames []string
|
||||
maxPos := len(branchData)
|
||||
if limit > 0 {
|
||||
maxPos = min(skip+limit, maxPos)
|
||||
}
|
||||
for i := skip; i < maxPos; i++ {
|
||||
branchNames = append(branchNames, branchData[i].name)
|
||||
}
|
||||
|
||||
return branchNames, len(branchData), nil
|
||||
}
|
||||
|
||||
// WalkReferences walks all the references from the repository
|
||||
func (repo *Repository) WalkReferences(arg ObjectType, skip, limit int, walkfn func(sha1, refname string) error) (int, error) {
|
||||
i := 0
|
||||
var iter storer.ReferenceIter
|
||||
var err error
|
||||
switch arg {
|
||||
case ObjectTag:
|
||||
iter, err = repo.gogitRepo.Tags()
|
||||
case ObjectBranch:
|
||||
iter, err = repo.gogitRepo.Branches()
|
||||
default:
|
||||
iter, err = repo.gogitRepo.References()
|
||||
}
|
||||
if err != nil {
|
||||
return i, err
|
||||
}
|
||||
defer iter.Close()
|
||||
|
||||
err = iter.ForEach(func(ref *plumbing.Reference) error {
|
||||
if i < skip {
|
||||
i++
|
||||
return nil
|
||||
}
|
||||
err := walkfn(ref.Hash().String(), string(ref.Name()))
|
||||
i++
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if limit != 0 && i >= skip+limit {
|
||||
return storer.ErrStop
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return i, err
|
||||
}
|
||||
|
||||
// GetRefsBySha returns all references filtered with prefix that belong to a sha commit hash
|
||||
func (repo *Repository) GetRefsBySha(sha, prefix string) ([]string, error) {
|
||||
var revList []string
|
||||
iter, err := repo.gogitRepo.References()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = iter.ForEach(func(ref *plumbing.Reference) error {
|
||||
if ref.Hash().String() == sha && strings.HasPrefix(string(ref.Name()), prefix) {
|
||||
revList = append(revList, string(ref.Name()))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return revList, err
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// Copyright 2015 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !gogit
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/log"
|
||||
)
|
||||
|
||||
// IsObjectExist returns true if the given object exists in the repository.
|
||||
// FIXME: this function doesn't seem right, it is only used by GarbageCollectLFSMetaObjectsForRepo
|
||||
func (repo *Repository) IsObjectExist(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
batch, cancel, err := repo.CatFileBatch(repo.Ctx)
|
||||
if err != nil {
|
||||
log.Debug("Error opening CatFileBatch %v", err)
|
||||
return false
|
||||
}
|
||||
defer cancel()
|
||||
info, err := batch.QueryInfo(name)
|
||||
if err != nil {
|
||||
log.Debug("Error checking object info %v", err)
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(info.ID, name) // FIXME: this logic doesn't seem right, why "HasPrefix"
|
||||
}
|
||||
|
||||
// IsReferenceExist returns true if given reference exists in the repository.
|
||||
func (repo *Repository) IsReferenceExist(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
batch, cancel, err := repo.CatFileBatch(repo.Ctx)
|
||||
if err != nil {
|
||||
log.Error("Error opening CatFileBatch %v", err)
|
||||
return false
|
||||
}
|
||||
defer cancel()
|
||||
_, err = batch.QueryInfo(name)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// IsBranchExist returns true if given branch exists in current repository.
|
||||
func (repo *Repository) IsBranchExist(name string) bool {
|
||||
if repo == nil || name == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
return repo.IsReferenceExist(BranchPrefix + name)
|
||||
}
|
||||
|
||||
// GetBranchNames returns branches from the repository, skipping "skip" initial branches and
|
||||
// returning at most "limit" branches, or all branches if "limit" is 0.
|
||||
func (repo *Repository) GetBranchNames(skip, limit int) ([]string, int, error) {
|
||||
return callShowRef(repo.Ctx, repo.Path, BranchPrefix, gitcmd.TrustedCmdArgs{BranchPrefix, "--sort=-committerdate"}, skip, limit)
|
||||
}
|
||||
|
||||
// WalkReferences walks all the references from the repository
|
||||
// refType should be empty, ObjectTag or ObjectBranch. All other values are equivalent to empty.
|
||||
func (repo *Repository) WalkReferences(refType ObjectType, skip, limit int, walkfn func(sha1, refname string) error) (int, error) {
|
||||
var args gitcmd.TrustedCmdArgs
|
||||
switch refType {
|
||||
case ObjectTag:
|
||||
args = gitcmd.TrustedCmdArgs{TagPrefix, "--sort=-taggerdate"}
|
||||
case ObjectBranch:
|
||||
args = gitcmd.TrustedCmdArgs{BranchPrefix, "--sort=-committerdate"}
|
||||
}
|
||||
|
||||
return WalkShowRef(repo.Ctx, repo.Path, args, skip, limit, walkfn)
|
||||
}
|
||||
|
||||
// callShowRef return refs, if limit = 0 it will not limit
|
||||
func callShowRef(ctx context.Context, repoPath, trimPrefix string, extraArgs gitcmd.TrustedCmdArgs, skip, limit int) (branchNames []string, countAll int, err error) {
|
||||
countAll, err = WalkShowRef(ctx, repoPath, extraArgs, skip, limit, func(_, branchName string) error {
|
||||
branchName = strings.TrimPrefix(branchName, trimPrefix)
|
||||
branchNames = append(branchNames, branchName)
|
||||
|
||||
return nil
|
||||
})
|
||||
return branchNames, countAll, err
|
||||
}
|
||||
|
||||
func WalkShowRef(ctx context.Context, repoPath string, extraArgs gitcmd.TrustedCmdArgs, skip, limit int, walkfn func(sha1, refname string) error) (countAll int, err error) {
|
||||
i := 0
|
||||
args := gitcmd.TrustedCmdArgs{"for-each-ref", "--format=%(objectname) %(refname)"}
|
||||
args = append(args, extraArgs...)
|
||||
cmd := gitcmd.NewCommand(args...)
|
||||
stdoutReader, stdoutReaderClose := cmd.MakeStdoutPipe()
|
||||
defer stdoutReaderClose()
|
||||
cmd.WithDir(repoPath).
|
||||
WithPipelineFunc(func(gitcmd.Context) error {
|
||||
bufReader := bufio.NewReader(stdoutReader)
|
||||
for i < skip {
|
||||
_, isPrefix, err := bufReader.ReadLine()
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !isPrefix {
|
||||
i++
|
||||
}
|
||||
}
|
||||
for limit == 0 || i < skip+limit {
|
||||
// The output of show-ref is simply a list:
|
||||
// <sha> SP <ref> LF
|
||||
sha, err := bufReader.ReadString(' ')
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
branchName, err := bufReader.ReadString('\n')
|
||||
if err == io.EOF {
|
||||
// This shouldn't happen... but we'll tolerate it for the sake of peace
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(branchName) > 0 {
|
||||
branchName = branchName[:len(branchName)-1]
|
||||
}
|
||||
|
||||
if len(sha) > 0 {
|
||||
sha = sha[:len(sha)-1]
|
||||
}
|
||||
|
||||
err = walkfn(sha, branchName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
i++
|
||||
}
|
||||
// count all refs
|
||||
for limit != 0 {
|
||||
_, isPrefix, err := bufReader.ReadLine()
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !isPrefix {
|
||||
i++
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
err = cmd.RunWithStderr(ctx)
|
||||
if errPipeline, ok := gitcmd.UnwrapPipelineError(err); ok {
|
||||
return i, errPipeline // keep the old behavior: return pipeline error directly
|
||||
}
|
||||
return i, err
|
||||
}
|
||||
|
||||
// GetRefsBySha returns all references filtered with prefix that belong to a sha commit hash
|
||||
func (repo *Repository) GetRefsBySha(sha, prefix string) ([]string, error) {
|
||||
var revList []string
|
||||
_, err := WalkShowRef(repo.Ctx, repo.Path, nil, 0, 0, func(walkSha, refname string) error {
|
||||
if walkSha == sha && strings.HasPrefix(refname, prefix) {
|
||||
revList = append(revList, refname)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return revList, err
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRepository_GetBranches(t *testing.T) {
|
||||
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
|
||||
bareRepo1, err := OpenRepository(t.Context(), bareRepo1Path)
|
||||
assert.NoError(t, err)
|
||||
defer bareRepo1.Close()
|
||||
|
||||
branches, countAll, err := bareRepo1.GetBranchNames(0, 2)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, branches, 2)
|
||||
assert.Equal(t, 3, countAll)
|
||||
assert.ElementsMatch(t, []string{"master", "branch2"}, branches)
|
||||
|
||||
branches, countAll, err = bareRepo1.GetBranchNames(0, 0)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, branches, 3)
|
||||
assert.Equal(t, 3, countAll)
|
||||
assert.ElementsMatch(t, []string{"master", "branch2", "branch1"}, branches)
|
||||
|
||||
branches, countAll, err = bareRepo1.GetBranchNames(5, 1)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, branches)
|
||||
assert.Equal(t, 3, countAll)
|
||||
assert.ElementsMatch(t, []string{}, branches)
|
||||
}
|
||||
|
||||
func BenchmarkRepository_GetBranches(b *testing.B) {
|
||||
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
|
||||
bareRepo1, err := OpenRepository(b.Context(), bareRepo1Path)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
defer bareRepo1.Close()
|
||||
|
||||
for b.Loop() {
|
||||
_, _, err := bareRepo1.GetBranchNames(0, 0)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRefsBySha(t *testing.T) {
|
||||
bareRepo5Path := filepath.Join(testReposDir, "repo5_pulls")
|
||||
bareRepo5, err := OpenRepository(t.Context(), bareRepo5Path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer bareRepo5.Close()
|
||||
|
||||
// do not exist
|
||||
branches, err := bareRepo5.GetRefsBySha("8006ff9adbf0cb94da7dad9e537e53817f9fa5c0", "")
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, branches)
|
||||
|
||||
// refs/pull/1/head
|
||||
branches, err = bareRepo5.GetRefsBySha("c83380d7056593c51a699d12b9c00627bd5743e9", PullPrefix)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []string{"refs/pull/1/head"}, branches)
|
||||
|
||||
branches, err = bareRepo5.GetRefsBySha("d8e0bbb45f200e67d9a784ce55bd90821af45ebd", BranchPrefix)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []string{"refs/heads/master", "refs/heads/master-clone"}, branches)
|
||||
|
||||
branches, err = bareRepo5.GetRefsBySha("58a4bcc53ac13e7ff76127e0fb518b5262bf09af", BranchPrefix)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []string{"refs/heads/test-patch-1"}, branches)
|
||||
}
|
||||
|
||||
func BenchmarkGetRefsBySha(b *testing.B) {
|
||||
bareRepo5Path := filepath.Join(testReposDir, "repo5_pulls")
|
||||
bareRepo5, err := OpenRepository(b.Context(), bareRepo5Path)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
defer bareRepo5.Close()
|
||||
|
||||
_, _ = bareRepo5.GetRefsBySha("8006ff9adbf0cb94da7dad9e537e53817f9fa5c0", "")
|
||||
_, _ = bareRepo5.GetRefsBySha("d8e0bbb45f200e67d9a784ce55bd90821af45ebd", "")
|
||||
_, _ = bareRepo5.GetRefsBySha("c83380d7056593c51a699d12b9c00627bd5743e9", "")
|
||||
_, _ = bareRepo5.GetRefsBySha("58a4bcc53ac13e7ff76127e0fb518b5262bf09af", "")
|
||||
}
|
||||
|
||||
func TestRepository_IsObjectExist(t *testing.T) {
|
||||
repo, err := OpenRepository(t.Context(), filepath.Join(testReposDir, "repo1_bare"))
|
||||
require.NoError(t, err)
|
||||
defer repo.Close()
|
||||
|
||||
// FIXME: Inconsistent behavior between gogit and nogogit editions
|
||||
// See the comment of IsObjectExist in gogit edition for more details.
|
||||
supportShortHash := !isGogit
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
arg string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
arg: "",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "branch",
|
||||
arg: "master",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "commit hash",
|
||||
arg: "ce064814f4a0d337b333e646ece456cd39fab612",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "short commit hash",
|
||||
arg: "ce06481",
|
||||
want: supportShortHash,
|
||||
},
|
||||
{
|
||||
name: "blob hash",
|
||||
arg: "153f451b9ee7fa1da317ab17a127e9fd9d384310",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "short blob hash",
|
||||
arg: "153f451",
|
||||
want: supportShortHash,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, repo.IsObjectExist(tt.arg))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepository_IsReferenceExist(t *testing.T) {
|
||||
repo, err := OpenRepository(t.Context(), filepath.Join(testReposDir, "repo1_bare"))
|
||||
require.NoError(t, err)
|
||||
defer repo.Close()
|
||||
|
||||
// FIXME: Inconsistent behavior between gogit and nogogit editions
|
||||
// See the comment of IsReferenceExist in gogit edition for more details.
|
||||
supportBlobHash := !isGogit
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
arg string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
arg: "",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "branch",
|
||||
arg: "master",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "commit hash",
|
||||
arg: "ce064814f4a0d337b333e646ece456cd39fab612",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "short commit hash",
|
||||
arg: "ce06481",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "blob hash",
|
||||
arg: "153f451b9ee7fa1da317ab17a127e9fd9d384310",
|
||||
want: supportBlobHash,
|
||||
},
|
||||
{
|
||||
name: "short blob hash",
|
||||
arg: "153f451",
|
||||
want: supportBlobHash,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, repo.IsReferenceExist(tt.arg))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
// Copyright 2015 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
// GetBranchCommitID returns last commit ID string of given branch.
|
||||
func (repo *Repository) GetBranchCommitID(name string) (string, error) {
|
||||
return repo.GetRefCommitID(BranchPrefix + name)
|
||||
}
|
||||
|
||||
// GetTagCommitID returns last commit ID string of given tag.
|
||||
func (repo *Repository) GetTagCommitID(name string) (string, error) {
|
||||
return repo.GetRefCommitID(TagPrefix + name)
|
||||
}
|
||||
|
||||
// GetCommit returns commit object of by ID string.
|
||||
func (repo *Repository) GetCommit(commitID string) (*Commit, error) {
|
||||
id, err := repo.ConvertToGitID(commitID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return repo.getCommit(id)
|
||||
}
|
||||
|
||||
// GetBranchCommit returns the last commit of given branch.
|
||||
func (repo *Repository) GetBranchCommit(name string) (*Commit, error) {
|
||||
commitID, err := repo.GetBranchCommitID(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return repo.GetCommit(commitID)
|
||||
}
|
||||
|
||||
// GetTagCommit get the commit of the specific tag via name
|
||||
func (repo *Repository) GetTagCommit(name string) (*Commit, error) {
|
||||
commitID, err := repo.GetTagCommitID(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return repo.GetCommit(commitID)
|
||||
}
|
||||
|
||||
func (repo *Repository) getCommitByPathWithID(id ObjectID, relpath string) (*Commit, error) {
|
||||
// File name starts with ':' must be escaped.
|
||||
if relpath[0] == ':' {
|
||||
relpath = `\` + relpath
|
||||
}
|
||||
|
||||
stdout, _, runErr := gitcmd.NewCommand("log", "-1", prettyLogFormat).
|
||||
AddDynamicArguments(id.String()).
|
||||
AddDashesAndList(relpath).
|
||||
WithDir(repo.Path).
|
||||
RunStdString(repo.Ctx)
|
||||
if runErr != nil {
|
||||
return nil, runErr
|
||||
}
|
||||
|
||||
id, err := NewIDFromString(stdout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return repo.getCommit(id)
|
||||
}
|
||||
|
||||
// GetCommitByPath returns the last commit of relative path.
|
||||
func (repo *Repository) GetCommitByPath(relpath string) (*Commit, error) {
|
||||
stdout, _, runErr := gitcmd.NewCommand("log", "-1", prettyLogFormat).
|
||||
AddDashesAndList(relpath).
|
||||
WithDir(repo.Path).
|
||||
RunStdBytes(repo.Ctx)
|
||||
if runErr != nil {
|
||||
return nil, runErr
|
||||
}
|
||||
|
||||
commits, err := repo.parsePrettyFormatLogToList(stdout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(commits) == 0 {
|
||||
return nil, ErrNotExist{ID: relpath}
|
||||
}
|
||||
return commits[0], nil
|
||||
}
|
||||
|
||||
// commitsByRangeWithTime returns the specific page commits before current revision, with not, since, until support
|
||||
func (repo *Repository) commitsByRangeWithTime(id ObjectID, page, pageSize int, not, since, until string) ([]*Commit, error) {
|
||||
cmd := gitcmd.NewCommand("log").
|
||||
AddOptionFormat("--skip=%d", (page-1)*pageSize).
|
||||
AddOptionFormat("--max-count=%d", pageSize).
|
||||
AddArguments(prettyLogFormat).
|
||||
AddDynamicArguments(id.String())
|
||||
|
||||
if not != "" {
|
||||
cmd.AddOptionValues("--not", not)
|
||||
}
|
||||
if since != "" {
|
||||
cmd.AddOptionFormat("--since=%s", since)
|
||||
}
|
||||
if until != "" {
|
||||
cmd.AddOptionFormat("--until=%s", until)
|
||||
}
|
||||
|
||||
stdout, _, err := cmd.WithDir(repo.Path).RunStdBytes(repo.Ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return repo.parsePrettyFormatLogToList(stdout)
|
||||
}
|
||||
|
||||
func (repo *Repository) searchCommits(id ObjectID, opts SearchCommitsOptions) ([]*Commit, error) {
|
||||
// add common arguments to git command
|
||||
addCommonSearchArgs := func(c *gitcmd.Command) {
|
||||
// ignore case
|
||||
c.AddArguments("-i")
|
||||
|
||||
// add authors if present in search query
|
||||
for _, v := range opts.Authors {
|
||||
c.AddOptionFormat("--author=%s", v)
|
||||
}
|
||||
|
||||
// add committers if present in search query
|
||||
for _, v := range opts.Committers {
|
||||
c.AddOptionFormat("--committer=%s", v)
|
||||
}
|
||||
|
||||
// add time constraints if present in search query
|
||||
if len(opts.After) > 0 {
|
||||
c.AddOptionFormat("--after=%s", opts.After)
|
||||
}
|
||||
if len(opts.Before) > 0 {
|
||||
c.AddOptionFormat("--before=%s", opts.Before)
|
||||
}
|
||||
}
|
||||
|
||||
// create new git log command with limit of 100 commits
|
||||
cmd := gitcmd.NewCommand("log", "-100", prettyLogFormat).AddDynamicArguments(id.String())
|
||||
|
||||
// pretend that all refs along with HEAD were listed on command line as <commis>
|
||||
// https://git-scm.com/docs/git-log#Documentation/git-log.txt---all
|
||||
// note this is done only for command created above
|
||||
if opts.All {
|
||||
cmd.AddArguments("--all")
|
||||
}
|
||||
|
||||
// interpret search string keywords as string instead of regex
|
||||
cmd.AddArguments("--fixed-strings")
|
||||
|
||||
// add remaining keywords from search string
|
||||
// note this is done only for command created above
|
||||
for _, v := range opts.Keywords {
|
||||
cmd.AddOptionFormat("--grep=%s", v)
|
||||
}
|
||||
|
||||
// search for commits matching given constraints and keywords in commit msg
|
||||
addCommonSearchArgs(cmd)
|
||||
stdout, _, err := cmd.WithDir(repo.Path).RunStdBytes(repo.Ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(stdout) != 0 {
|
||||
stdout = append(stdout, '\n')
|
||||
}
|
||||
|
||||
// if there are any keywords (ie not committer:, author:, time:)
|
||||
// then let's iterate over them
|
||||
for _, v := range opts.Keywords {
|
||||
// ignore anything not matching a valid sha pattern
|
||||
if id.Type().IsValid(v) {
|
||||
// create new git log command with 1 commit limit
|
||||
hashCmd := gitcmd.NewCommand("log", "-1", prettyLogFormat)
|
||||
// add previous arguments except for --grep and --all
|
||||
addCommonSearchArgs(hashCmd)
|
||||
// add keyword as <commit>
|
||||
hashCmd.AddDynamicArguments(v)
|
||||
|
||||
// search with given constraints for commit matching sha hash of v
|
||||
hashMatching, _, err := hashCmd.WithDir(repo.Path).RunStdBytes(repo.Ctx)
|
||||
if err != nil || bytes.Contains(stdout, hashMatching) {
|
||||
continue
|
||||
}
|
||||
stdout = append(stdout, hashMatching...)
|
||||
stdout = append(stdout, '\n')
|
||||
}
|
||||
}
|
||||
|
||||
return repo.parsePrettyFormatLogToList(bytes.TrimSuffix(stdout, []byte{'\n'}))
|
||||
}
|
||||
|
||||
// FileChangedBetweenCommits Returns true if the file changed between commit IDs id1 and id2
|
||||
// You must ensure that id1 and id2 are valid commit ids.
|
||||
func (repo *Repository) FileChangedBetweenCommits(filename, id1, id2 string) (bool, error) {
|
||||
stdout, _, err := gitcmd.NewCommand("diff", "--name-only", "-z").
|
||||
AddDynamicArguments(id1, id2).
|
||||
AddDashesAndList(filename).
|
||||
WithDir(repo.Path).
|
||||
RunStdBytes(repo.Ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return len(strings.TrimSpace(string(stdout))) > 0, nil
|
||||
}
|
||||
|
||||
type CommitsByFileAndRangeOptions struct {
|
||||
Revision string
|
||||
File string
|
||||
Not string
|
||||
Page int
|
||||
Since string
|
||||
Until string
|
||||
}
|
||||
|
||||
// CommitsByFileAndRange return the commits according revision file and the page
|
||||
func (repo *Repository) CommitsByFileAndRange(opts CommitsByFileAndRangeOptions) ([]*Commit, error) {
|
||||
gitCmd := gitcmd.NewCommand("rev-list").
|
||||
AddOptionFormat("--max-count=%d", setting.Git.CommitsRangeSize).
|
||||
AddOptionFormat("--skip=%d", (opts.Page-1)*setting.Git.CommitsRangeSize)
|
||||
gitCmd.AddDynamicArguments(opts.Revision)
|
||||
|
||||
if opts.Not != "" {
|
||||
gitCmd.AddOptionValues("--not", opts.Not)
|
||||
}
|
||||
if opts.Since != "" {
|
||||
gitCmd.AddOptionFormat("--since=%s", opts.Since)
|
||||
}
|
||||
if opts.Until != "" {
|
||||
gitCmd.AddOptionFormat("--until=%s", opts.Until)
|
||||
}
|
||||
gitCmd.AddDashesAndList(opts.File)
|
||||
|
||||
var commits []*Commit
|
||||
stdoutReader, stdoutReaderClose := gitCmd.MakeStdoutPipe()
|
||||
defer stdoutReaderClose()
|
||||
err := gitCmd.WithDir(repo.Path).
|
||||
WithPipelineFunc(func(context gitcmd.Context) error {
|
||||
objectFormat, err := repo.GetObjectFormat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
length := objectFormat.FullLength()
|
||||
shaline := make([]byte, length+1)
|
||||
for {
|
||||
n, err := io.ReadFull(stdoutReader, shaline)
|
||||
if err != nil || n < length {
|
||||
if err == io.EOF {
|
||||
err = nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
objectID, err := NewIDFromString(string(shaline[0:length]))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
commit, err := repo.getCommit(objectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
commits = append(commits, commit)
|
||||
}
|
||||
}).
|
||||
RunWithStderr(repo.Ctx)
|
||||
return commits, err
|
||||
}
|
||||
|
||||
// FilesCountBetween return the number of files changed between two commits
|
||||
func (repo *Repository) FilesCountBetween(startCommitID, endCommitID string) (int, error) {
|
||||
stdout, _, err := gitcmd.NewCommand("diff", "--name-only").
|
||||
AddDynamicArguments(startCommitID + "..." + endCommitID).
|
||||
WithDir(repo.Path).
|
||||
RunStdString(repo.Ctx)
|
||||
if err != nil && strings.Contains(err.Error(), "no merge base") {
|
||||
// git >= 2.28 now returns an error if startCommitID and endCommitID have become unrelated.
|
||||
// previously it would return the results of git diff --name-only startCommitID endCommitID so let's try that...
|
||||
stdout, _, err = gitcmd.NewCommand("diff", "--name-only").
|
||||
AddDynamicArguments(startCommitID, endCommitID).
|
||||
WithDir(repo.Path).
|
||||
RunStdString(repo.Ctx)
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(strings.Split(stdout, "\n")) - 1, nil
|
||||
}
|
||||
|
||||
// CommitsBetween returns a list that contains commits between [before, last).
|
||||
// If before is detached (removed by reset + push) it is not included.
|
||||
func (repo *Repository) CommitsBetween(last, before *Commit) ([]*Commit, error) {
|
||||
var stdout []byte
|
||||
var err error
|
||||
if before == nil {
|
||||
stdout, _, err = gitcmd.NewCommand("rev-list").
|
||||
AddDynamicArguments(last.ID.String()).
|
||||
WithDir(repo.Path).
|
||||
RunStdBytes(repo.Ctx)
|
||||
} else {
|
||||
stdout, _, err = gitcmd.NewCommand("rev-list").
|
||||
AddDynamicArguments(before.ID.String() + ".." + last.ID.String()).
|
||||
WithDir(repo.Path).
|
||||
RunStdBytes(repo.Ctx)
|
||||
if err != nil && strings.Contains(err.Error(), "no merge base") {
|
||||
// future versions of git >= 2.28 are likely to return an error if before and last have become unrelated.
|
||||
// previously it would return the results of git rev-list before last so let's try that...
|
||||
stdout, _, err = gitcmd.NewCommand("rev-list").
|
||||
AddDynamicArguments(before.ID.String(), last.ID.String()).
|
||||
WithDir(repo.Path).
|
||||
RunStdBytes(repo.Ctx)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return repo.parsePrettyFormatLogToList(bytes.TrimSpace(stdout))
|
||||
}
|
||||
|
||||
// CommitsBetweenLimit returns a list that contains at most limit commits skipping the first skip commits between [before, last)
|
||||
func (repo *Repository) CommitsBetweenLimit(last, before *Commit, limit, skip int) ([]*Commit, error) {
|
||||
var stdout []byte
|
||||
var err error
|
||||
if before == nil {
|
||||
stdout, _, err = gitcmd.NewCommand("rev-list").
|
||||
AddOptionValues("--max-count", strconv.Itoa(limit)).
|
||||
AddOptionValues("--skip", strconv.Itoa(skip)).
|
||||
AddDynamicArguments(last.ID.String()).
|
||||
WithDir(repo.Path).
|
||||
RunStdBytes(repo.Ctx)
|
||||
} else {
|
||||
stdout, _, err = gitcmd.NewCommand("rev-list").
|
||||
AddOptionValues("--max-count", strconv.Itoa(limit)).
|
||||
AddOptionValues("--skip", strconv.Itoa(skip)).
|
||||
AddDynamicArguments(before.ID.String() + ".." + last.ID.String()).
|
||||
WithDir(repo.Path).
|
||||
RunStdBytes(repo.Ctx)
|
||||
if err != nil && strings.Contains(err.Error(), "no merge base") {
|
||||
// future versions of git >= 2.28 are likely to return an error if before and last have become unrelated.
|
||||
// previously it would return the results of git rev-list --max-count n before last so let's try that...
|
||||
stdout, _, err = gitcmd.NewCommand("rev-list").
|
||||
AddOptionValues("--max-count", strconv.Itoa(limit)).
|
||||
AddOptionValues("--skip", strconv.Itoa(skip)).
|
||||
AddDynamicArguments(before.ID.String(), last.ID.String()).
|
||||
WithDir(repo.Path).
|
||||
RunStdBytes(repo.Ctx)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return repo.parsePrettyFormatLogToList(bytes.TrimSpace(stdout))
|
||||
}
|
||||
|
||||
// CommitsBetweenIDs return commits between twoe commits
|
||||
func (repo *Repository) CommitsBetweenIDs(last, before string) ([]*Commit, error) {
|
||||
lastCommit, err := repo.GetCommit(last)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if before == "" {
|
||||
return repo.CommitsBetween(lastCommit, nil)
|
||||
}
|
||||
beforeCommit, err := repo.GetCommit(before)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return repo.CommitsBetween(lastCommit, beforeCommit)
|
||||
}
|
||||
|
||||
// commitsBefore the limit is depth, not total number of returned commits.
|
||||
func (repo *Repository) commitsBefore(id ObjectID, limit int) ([]*Commit, error) {
|
||||
cmd := gitcmd.NewCommand("log", prettyLogFormat)
|
||||
if limit > 0 {
|
||||
cmd.AddOptionFormat("-%d", limit)
|
||||
}
|
||||
cmd.AddDynamicArguments(id.String())
|
||||
|
||||
stdout, _, runErr := cmd.WithDir(repo.Path).RunStdBytes(repo.Ctx)
|
||||
if runErr != nil {
|
||||
return nil, runErr
|
||||
}
|
||||
|
||||
formattedLog, err := repo.parsePrettyFormatLogToList(bytes.TrimSpace(stdout))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
commits := make([]*Commit, 0, len(formattedLog))
|
||||
for _, commit := range formattedLog {
|
||||
branches, err := repo.getBranches(os.Environ(), commit.ID.String(), 2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(branches) > 1 {
|
||||
break
|
||||
}
|
||||
|
||||
commits = append(commits, commit)
|
||||
}
|
||||
|
||||
return commits, nil
|
||||
}
|
||||
|
||||
func (repo *Repository) getCommitsBefore(id ObjectID) ([]*Commit, error) {
|
||||
return repo.commitsBefore(id, 0)
|
||||
}
|
||||
|
||||
func (repo *Repository) getCommitsBeforeLimit(id ObjectID, num int) ([]*Commit, error) {
|
||||
return repo.commitsBefore(id, num)
|
||||
}
|
||||
|
||||
func (repo *Repository) getBranches(env []string, commitID string, limit int) ([]string, error) {
|
||||
if DefaultFeatures().CheckVersionAtLeast("2.7.0") {
|
||||
stdout, _, err := gitcmd.NewCommand("for-each-ref", "--format=%(refname:strip=2)").
|
||||
AddOptionFormat("--count=%d", limit).
|
||||
AddOptionValues("--contains", commitID, BranchPrefix).
|
||||
WithDir(repo.Path).
|
||||
WithEnv(env).
|
||||
RunStdString(repo.Ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
branches := strings.Fields(stdout)
|
||||
return branches, nil
|
||||
}
|
||||
|
||||
stdout, _, err := gitcmd.NewCommand("branch").
|
||||
AddOptionValues("--contains", commitID).
|
||||
WithDir(repo.Path).
|
||||
WithEnv(env).
|
||||
RunStdString(repo.Ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
refs := strings.Split(stdout, "\n")
|
||||
|
||||
var maxNum int
|
||||
if len(refs) > limit {
|
||||
maxNum = limit
|
||||
} else {
|
||||
maxNum = len(refs) - 1
|
||||
}
|
||||
|
||||
branches := make([]string, maxNum)
|
||||
for i, ref := range refs[:maxNum] {
|
||||
parts := strings.Fields(ref)
|
||||
|
||||
branches[i] = parts[len(parts)-1]
|
||||
}
|
||||
return branches, nil
|
||||
}
|
||||
|
||||
// GetCommitsFromIDs get commits from commit IDs
|
||||
func (repo *Repository) GetCommitsFromIDs(commitIDs []string) []*Commit {
|
||||
commits := make([]*Commit, 0, len(commitIDs))
|
||||
|
||||
for _, commitID := range commitIDs {
|
||||
commit, err := repo.GetCommit(commitID)
|
||||
if err == nil && commit != nil {
|
||||
commits = append(commits, commit)
|
||||
}
|
||||
}
|
||||
|
||||
return commits
|
||||
}
|
||||
|
||||
// IsCommitInBranch check if the commit is on the branch
|
||||
func (repo *Repository) IsCommitInBranch(commitID, branch string) (r bool, err error) {
|
||||
stdout, _, err := gitcmd.NewCommand("branch", "--contains").
|
||||
AddDynamicArguments(commitID, branch).
|
||||
WithDir(repo.Path).
|
||||
RunStdString(repo.Ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return len(stdout) > 0, err
|
||||
}
|
||||
|
||||
// GetCommitBranchStart returns the commit where the branch diverged
|
||||
func (repo *Repository) GetCommitBranchStart(env []string, branch, endCommitID string) (string, error) {
|
||||
cmd := gitcmd.NewCommand("log", prettyLogFormat)
|
||||
cmd.AddDynamicArguments(endCommitID)
|
||||
|
||||
stdout, _, runErr := cmd.WithDir(repo.Path).
|
||||
WithEnv(env).
|
||||
RunStdBytes(repo.Ctx)
|
||||
if runErr != nil {
|
||||
return "", runErr
|
||||
}
|
||||
|
||||
parts := bytes.SplitSeq(bytes.TrimSpace(stdout), []byte{'\n'})
|
||||
|
||||
// check the commits one by one until we find a commit contained by another branch
|
||||
// and we think this commit is the divergence point
|
||||
for commitID := range parts {
|
||||
branches, err := repo.getBranches(env, string(commitID), 2)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, b := range branches {
|
||||
if b != branch {
|
||||
return string(commitID), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// Copyright 2015 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build gogit
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
"github.com/go-git/go-git/v5/plumbing/hash"
|
||||
"github.com/go-git/go-git/v5/plumbing/object"
|
||||
)
|
||||
|
||||
// GetRefCommitID returns the last commit ID string of given reference.
|
||||
func (repo *Repository) GetRefCommitID(name string) (string, error) {
|
||||
if plumbing.IsHash(name) {
|
||||
return name, nil
|
||||
}
|
||||
refName := plumbing.ReferenceName(name)
|
||||
if err := refName.Validate(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
ref, err := repo.gogitRepo.Reference(refName, true)
|
||||
if err != nil {
|
||||
if err == plumbing.ErrReferenceNotFound {
|
||||
return "", ErrNotExist{
|
||||
ID: name,
|
||||
}
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
return ref.Hash().String(), nil
|
||||
}
|
||||
|
||||
// ConvertToHash returns a Hash object from a potential ID string
|
||||
func (repo *Repository) ConvertToGitID(commitID string) (ObjectID, error) {
|
||||
objectFormat, err := repo.GetObjectFormat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(commitID) == hash.HexSize && objectFormat.IsValid(commitID) {
|
||||
ID, err := NewIDFromString(commitID)
|
||||
if err == nil {
|
||||
return ID, nil
|
||||
}
|
||||
}
|
||||
|
||||
actualCommitID, _, err := gitcmd.NewCommand("rev-parse", "--verify").
|
||||
AddDynamicArguments(commitID).
|
||||
WithDir(repo.Path).
|
||||
RunStdString(repo.Ctx)
|
||||
actualCommitID = strings.TrimSpace(actualCommitID)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "unknown revision or path") ||
|
||||
strings.Contains(err.Error(), "fatal: Needed a single revision") {
|
||||
return objectFormat.EmptyObjectID(), ErrNotExist{commitID, ""}
|
||||
}
|
||||
return objectFormat.EmptyObjectID(), err
|
||||
}
|
||||
|
||||
return NewIDFromString(actualCommitID)
|
||||
}
|
||||
|
||||
func (repo *Repository) getCommit(id ObjectID) (*Commit, error) {
|
||||
var tagObject *object.Tag
|
||||
|
||||
commitID := plumbing.Hash(id.RawValue())
|
||||
gogitCommit, err := repo.gogitRepo.CommitObject(commitID)
|
||||
if err == plumbing.ErrObjectNotFound {
|
||||
tagObject, err = repo.gogitRepo.TagObject(commitID)
|
||||
if err == plumbing.ErrObjectNotFound {
|
||||
return nil, ErrNotExist{
|
||||
ID: id.String(),
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
gogitCommit, err = repo.gogitRepo.CommitObject(tagObject.Target)
|
||||
}
|
||||
// if we get a plumbing.ErrObjectNotFound here then the repository is broken and it should be 500
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
commit := convertCommit(gogitCommit)
|
||||
commit.repo = repo
|
||||
|
||||
tree, err := gogitCommit.Tree()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
commit.Tree.ID = ParseGogitHash(tree.Hash)
|
||||
commit.Tree.resolvedGogitTreeObject = tree
|
||||
|
||||
return commit, nil
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !gogit
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/log"
|
||||
)
|
||||
|
||||
// ResolveReference resolves a name to a reference
|
||||
func (repo *Repository) ResolveReference(name string) (string, error) {
|
||||
stdout, _, err := gitcmd.NewCommand("show-ref", "--hash").
|
||||
AddDynamicArguments(name).
|
||||
WithDir(repo.Path).
|
||||
RunStdString(repo.Ctx)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not a valid ref") {
|
||||
return "", ErrNotExist{name, ""}
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
stdout = strings.TrimSpace(stdout)
|
||||
if stdout == "" {
|
||||
return "", ErrNotExist{name, ""}
|
||||
}
|
||||
|
||||
return stdout, nil
|
||||
}
|
||||
|
||||
// GetRefCommitID returns the last commit ID string of given reference (branch or tag).
|
||||
func (repo *Repository) GetRefCommitID(name string) (string, error) {
|
||||
batch, cancel, err := repo.CatFileBatch(repo.Ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer cancel()
|
||||
info, err := batch.QueryInfo(name)
|
||||
if IsErrNotExist(err) {
|
||||
return "", ErrNotExist{name, ""}
|
||||
} else if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return info.ID, nil
|
||||
}
|
||||
|
||||
func (repo *Repository) getCommit(id ObjectID) (*Commit, error) {
|
||||
batch, cancel, err := repo.CatFileBatch(repo.Ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cancel()
|
||||
return repo.getCommitWithBatch(batch, id)
|
||||
}
|
||||
|
||||
func (repo *Repository) getCommitWithBatch(batch CatFileBatch, id ObjectID) (*Commit, error) {
|
||||
info, rd, err := batch.QueryContent(id.String())
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) || IsErrNotExist(err) {
|
||||
return nil, ErrNotExist{ID: id.String()}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch info.Type {
|
||||
case "missing":
|
||||
return nil, ErrNotExist{ID: id.String()}
|
||||
case "tag":
|
||||
// then we need to parse the tag
|
||||
// and load the commit
|
||||
data, err := io.ReadAll(io.LimitReader(rd, info.Size))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = rd.Discard(1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tag, err := parseTagData(id.Type(), data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return repo.getCommitWithBatch(batch, tag.Object)
|
||||
case "commit":
|
||||
commit, err := CommitFromReader(repo, id, io.LimitReader(rd, info.Size))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = rd.Discard(1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return commit, nil
|
||||
default:
|
||||
log.Debug("Unknown cat-file object type: %s", info.Type)
|
||||
if err := DiscardFull(rd, info.Size+1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, ErrNotExist{
|
||||
ID: id.String(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ConvertToGitID returns a GitHash object from a potential ID string
|
||||
func (repo *Repository) ConvertToGitID(commitID string) (ObjectID, error) {
|
||||
objectFormat, err := repo.GetObjectFormat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(commitID) == objectFormat.FullLength() && objectFormat.IsValid(commitID) {
|
||||
ID, err := NewIDFromString(commitID)
|
||||
if err == nil {
|
||||
return ID, nil
|
||||
}
|
||||
}
|
||||
|
||||
batch, cancel, err := repo.CatFileBatch(repo.Ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cancel()
|
||||
info, err := batch.QueryInfo(commitID)
|
||||
if err != nil {
|
||||
if IsErrNotExist(err) {
|
||||
return nil, ErrNotExist{commitID, ""}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return MustIDFromString(info.ID), nil
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRepository_GetCommitBranches(t *testing.T) {
|
||||
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
|
||||
bareRepo1, err := OpenRepository(t.Context(), bareRepo1Path)
|
||||
assert.NoError(t, err)
|
||||
defer bareRepo1.Close()
|
||||
|
||||
// these test case are specific to the repo1_bare test repo
|
||||
testCases := []struct {
|
||||
CommitID string
|
||||
ExpectedBranches []string
|
||||
}{
|
||||
{"2839944139e0de9737a044f78b0e4b40d989a9e3", []string{"branch1"}},
|
||||
{"5c80b0245c1c6f8343fa418ec374b13b5d4ee658", []string{"branch2"}},
|
||||
{"37991dec2c8e592043f47155ce4808d4580f9123", []string{"master"}},
|
||||
{"95bb4d39648ee7e325106df01a621c530863a653", []string{"branch1", "branch2"}},
|
||||
{"8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2", []string{"branch2", "master"}},
|
||||
{"master", []string{"master"}},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
commit, err := bareRepo1.GetCommit(testCase.CommitID)
|
||||
assert.NoError(t, err)
|
||||
branches, err := bareRepo1.getBranches(os.Environ(), commit.ID.String(), 2)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, testCase.ExpectedBranches, branches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTagCommitWithSignature(t *testing.T) {
|
||||
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
|
||||
bareRepo1, err := OpenRepository(t.Context(), bareRepo1Path)
|
||||
assert.NoError(t, err)
|
||||
defer bareRepo1.Close()
|
||||
|
||||
// both the tag and the commit are signed here, this validates only the commit signature
|
||||
commit, err := bareRepo1.GetCommit("28b55526e7100924d864dd89e35c1ea62e7a5a32")
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, commit)
|
||||
assert.NotNil(t, commit.Signature)
|
||||
// test that signature is not in message
|
||||
assert.Equal(t, "signed-commit\n", commit.CommitMessage.MessageRaw)
|
||||
}
|
||||
|
||||
func TestGetCommitWithBadCommitID(t *testing.T) {
|
||||
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
|
||||
bareRepo1, err := OpenRepository(t.Context(), bareRepo1Path)
|
||||
assert.NoError(t, err)
|
||||
defer bareRepo1.Close()
|
||||
|
||||
commit, err := bareRepo1.GetCommit("bad_branch")
|
||||
assert.Nil(t, commit)
|
||||
assert.Error(t, err)
|
||||
assert.True(t, IsErrNotExist(err))
|
||||
}
|
||||
|
||||
func TestIsCommitInBranch(t *testing.T) {
|
||||
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
|
||||
bareRepo1, err := OpenRepository(t.Context(), bareRepo1Path)
|
||||
assert.NoError(t, err)
|
||||
defer bareRepo1.Close()
|
||||
|
||||
result, err := bareRepo1.IsCommitInBranch("2839944139e0de9737a044f78b0e4b40d989a9e3", "branch1")
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, result)
|
||||
|
||||
result, err = bareRepo1.IsCommitInBranch("2839944139e0de9737a044f78b0e4b40d989a9e3", "branch2")
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, result)
|
||||
}
|
||||
|
||||
func TestRepository_CommitsBetweenIDs(t *testing.T) {
|
||||
bareRepo1Path := filepath.Join(testReposDir, "repo4_commitsbetween")
|
||||
bareRepo1, err := OpenRepository(t.Context(), bareRepo1Path)
|
||||
assert.NoError(t, err)
|
||||
defer bareRepo1.Close()
|
||||
|
||||
cases := []struct {
|
||||
OldID string
|
||||
NewID string
|
||||
ExpectedCommits int
|
||||
}{
|
||||
{"fdc1b615bdcff0f0658b216df0c9209e5ecb7c78", "78a445db1eac62fe15e624e1137965969addf344", 1}, // com1 -> com2
|
||||
{"78a445db1eac62fe15e624e1137965969addf344", "fdc1b615bdcff0f0658b216df0c9209e5ecb7c78", 0}, // reset HEAD~, com2 -> com1
|
||||
{"78a445db1eac62fe15e624e1137965969addf344", "a78e5638b66ccfe7e1b4689d3d5684e42c97d7ca", 1}, // com2 -> com2_new
|
||||
}
|
||||
for i, c := range cases {
|
||||
commits, err := bareRepo1.CommitsBetweenIDs(c.NewID, c.OldID)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, commits, c.ExpectedCommits, "case %d", i)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRefCommitID(t *testing.T) {
|
||||
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
|
||||
bareRepo1, err := OpenRepository(t.Context(), bareRepo1Path)
|
||||
assert.NoError(t, err)
|
||||
defer bareRepo1.Close()
|
||||
|
||||
// these test case are specific to the repo1_bare test repo
|
||||
testCases := []struct {
|
||||
Ref string
|
||||
ExpectedCommitID string
|
||||
}{
|
||||
{RefNameFromBranch("master").String(), "ce064814f4a0d337b333e646ece456cd39fab612"},
|
||||
{RefNameFromBranch("branch1").String(), "2839944139e0de9737a044f78b0e4b40d989a9e3"},
|
||||
{RefNameFromTag("test").String(), "3ad28a9149a2864384548f3d17ed7f38014c9e8a"},
|
||||
{"ce064814f4a0d337b333e646ece456cd39fab612", "ce064814f4a0d337b333e646ece456cd39fab612"},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
commitID, err := bareRepo1.GetRefCommitID(testCase.Ref)
|
||||
if assert.NoError(t, err) {
|
||||
assert.Equal(t, testCase.ExpectedCommitID, commitID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitsByFileAndRange(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.Git.CommitsRangeSize, 2)()
|
||||
|
||||
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
|
||||
bareRepo1, err := OpenRepository(t.Context(), bareRepo1Path)
|
||||
require.NoError(t, err)
|
||||
defer bareRepo1.Close()
|
||||
|
||||
// "foo" has 3 commits in "master" branch
|
||||
commits, err := bareRepo1.CommitsByFileAndRange(CommitsByFileAndRangeOptions{Revision: "master", File: "foo", Page: 1})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, commits, 2)
|
||||
|
||||
commits, err = bareRepo1.CommitsByFileAndRange(CommitsByFileAndRangeOptions{Revision: "master", File: "foo", Page: 2})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, commits, 1)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user