snapshot of https://github.com/go-gitea/gitea.git (main, 2026-09-15; blobs > 131072B omitted)
Some checks failed
cache-seeder / prune (push) Has been skipped
cache-seeder / frontend (push) Failing after 3s
cache-seeder / python (push) Failing after 3s
cache-seeder / gobuild (push) Failing after 4s
cache-seeder / lint (bindata, lint-backend) (push) Failing after 3s
giteabot backport / giteabot (push) Has been skipped
giteabot / giteabot (push) Has been skipped
cache-prune / prune (push) Has been skipped
release-nightly-snapcraft / build-and-publish (ubuntu-24.04) (push) Has been cancelled
release-nightly-snapcraft / build-and-publish (ubuntu-24.04-arm) (push) Has been cancelled
release-nightly / nightly-binary (push) Has been cancelled
release-nightly / nightly-container (push) Has been cancelled
cron-renovate / cron-renovate (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-15 21:41:59 +05:00
commit 52ae0c5802
6280 changed files with 624193 additions and 0 deletions

74
modules/git/archive.go Normal file
View File

@@ -0,0 +1,74 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"context"
"fmt"
"io"
"os"
"path"
"path/filepath"
"strings"
"gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/git/gitrepo"
"gitea.dev/modules/setting"
)
// CreateArchive create archive content to the target path
func CreateArchive(ctx context.Context, repo RepositoryFacade, repoName, format string, target io.Writer, commitID string, paths []string) error {
if format == "unknown" {
return fmt.Errorf("unknown format: %v", format)
}
cmd := gitcmd.NewCommand("archive")
if setting.Repository.PrefixArchiveFiles {
cmd.AddOptionFormat("--prefix=%s", strings.ToLower(repoName)+"/")
}
cmd.AddOptionFormat("--format=%s", format)
cmd.AddDynamicArguments(commitID)
for i := range paths {
// although "git archive" already ensures the paths won't go outside the repo, we still clean them here for safety
cmd.AddDynamicArguments(path.Clean(paths[i]))
}
return cmd.WithStdoutCopy(target).WithRepo(repo).RunWithStderr(ctx)
}
// CreateBundle create bundle content to the target path
func CreateBundle(ctx context.Context, repo RepositoryFacade, commit string, out io.Writer) error {
// TODO: use the following steps instead of creating a temp repo, also need to iterate and clean up outdated refs
// the temp ref has to be under refs/heads/*, and a clone only checks out with a HEAD, which needs the temp repo
// git update-ref refs/heads/bundle-temp-{timestamp} {commit}
// git bundle create - refs/heads/bundle-temp-{timestamp}
// git update-ref -d refs/heads/bundle-temp-{timestamp}
tmpDir, cleanup, err := setting.AppDataTempDir("git-repo-content").MkdirTempRandom("gitea-bundle")
if err != nil {
return err
}
defer cleanup()
env := append(os.Environ(), "GIT_OBJECT_DIRECTORY="+filepath.Join(gitrepo.RepoLocalPath(repo), "objects"))
gitTmpCmd := func() *gitcmd.Command {
return gitcmd.NewCommand().WithDir(tmpDir).WithEnv(env)
}
_, _, err = gitTmpCmd().AddArguments("init", "--bare").RunStdString(ctx)
if err != nil {
return err
}
_, _, err = gitTmpCmd().AddArguments("reset", "--soft").AddDynamicArguments(commit).RunStdString(ctx)
if err != nil {
return err
}
_, _, err = gitTmpCmd().AddArguments("branch", "-m", "bundle").RunStdString(ctx)
if err != nil {
return err
}
return gitTmpCmd().AddArguments("bundle", "create", "-", "bundle", "HEAD").WithStdoutCopy(out).RunWithStderr(ctx)
}

View File

@@ -0,0 +1,29 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"bytes"
"strings"
"testing"
"gitea.dev/modules/setting"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCreateBundle(t *testing.T) {
setting.AppDataPath = t.TempDir()
buf := &bytes.Buffer{}
require.NoError(t, CreateBundle(t.Context(), mockRepository("repo1_bare"), "ce064814f4a0d337b333e646ece456cd39fab612", buf))
header, _, ok := strings.Cut(buf.String(), "\n\n")
require.True(t, ok)
assert.Equal(t, "# v2 git bundle", strings.Split(header, "\n")[0])
// without a refs/heads/* ref and a HEAD, a clone of the bundle has no branch and no checkout
assert.Contains(t, header, "ce064814f4a0d337b333e646ece456cd39fab612 refs/heads/bundle")
assert.Contains(t, header, "ce064814f4a0d337b333e646ece456cd39fab612 HEAD")
}

View File

@@ -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
}

View File

@@ -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())
}

View File

@@ -0,0 +1,208 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package attribute
import (
"bytes"
"context"
"fmt"
"io"
"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(ctx context.Context, repo *git.Repository, treeish string, attributes []string) (checker *BatchChecker, returnedErr error) {
ctx, cancel := context.WithCancel(ctx)
defer func() {
if returnedErr != nil {
cancel()
}
}()
cmd, envs, cleanup, err := checkAttrCommand(ctx, 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).
WithRepo(repo).
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, c.repo.LogString(), 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, c.repo.LogString())
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
}

View File

@@ -0,0 +1,173 @@
// 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"
ctx := t.Context()
gitRepo, err := git.OpenRepositoryLocal(ctx, 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(ctx, 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.OpenRepositoryLocal(ctx, dir)
assert.NoError(t, err)
defer tempRepo.Close()
checker, err := NewBatchChecker(t.Context(), 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(ctx, 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)
})
}

View File

@@ -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(ctx context.Context, 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(ctx, 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(ctx, gitRepo, treeish, opts.Filenames, opts.Attributes)
if err != nil {
return nil, err
}
defer cancel()
stdout, _, err := cmd.WithEnv(append(os.Environ(), envs...)).
WithRepo(gitRepo).
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
}

View File

@@ -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.OpenRepositoryLocal(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.OpenRepositoryLocal(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"])
})
}

View File

@@ -0,0 +1,39 @@
// 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"
)
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 os.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)
}
}

207
modules/git/blame.go Normal file
View File

@@ -0,0 +1,207 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"bufio"
"bytes"
"context"
"io"
"gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/setting"
)
func LineBlame(ctx context.Context, repo RepositoryFacade, revision, file string, line uint) (string, error) {
stdout, _, err := gitcmd.NewCommand("blame").WithRepo(repo).
AddOptionFormat("-L %d,%d", line, line).
AddOptionValues("-p", revision).
AddDashesAndList(file).
RunStdString(ctx)
return stdout, err
}
// BlamePart represents block of blame - continuous lines with one sha
type BlamePart struct {
Sha string
Lines []string
PreviousSha string
PreviousPath string
}
// BlameReader returns part of file blame one by one
type BlameReader struct {
bufferedReader *bufio.Reader
done chan error
lastSha *string
ignoreRevsFile string
objectFormat ObjectFormat
cleanupFuncs []func()
}
func (r *BlameReader) UsesIgnoreRevs() bool {
return r.ignoreRevsFile != ""
}
// NextPart returns next part of blame (sequential code lines with the same commit)
func (r *BlameReader) NextPart() (*BlamePart, error) {
var blamePart *BlamePart
if r.lastSha != nil {
blamePart = &BlamePart{
Sha: *r.lastSha,
Lines: make([]string, 0),
}
}
const previousHeader = "previous "
var lineBytes []byte
var isPrefix bool
var err error
for err != io.EOF {
lineBytes, isPrefix, err = r.bufferedReader.ReadLine()
if err != nil && err != io.EOF {
return blamePart, err
}
if len(lineBytes) == 0 {
// isPrefix will be false
continue
}
var objectID string
objectFormatLength := r.objectFormat.FullLength()
if len(lineBytes) > objectFormatLength && lineBytes[objectFormatLength] == ' ' && r.objectFormat.IsValid(string(lineBytes[0:objectFormatLength])) {
objectID = string(lineBytes[0:objectFormatLength])
}
if len(objectID) > 0 {
if blamePart == nil {
blamePart = &BlamePart{
Sha: objectID,
Lines: make([]string, 0),
}
}
if blamePart.Sha != objectID {
r.lastSha = &objectID
// need to munch to end of line...
for isPrefix {
_, isPrefix, err = r.bufferedReader.ReadLine()
if err != nil && err != io.EOF {
return blamePart, err
}
}
return blamePart, nil
}
} else if lineBytes[0] == '\t' {
blamePart.Lines = append(blamePart.Lines, string(lineBytes[1:]))
} else if bytes.HasPrefix(lineBytes, []byte(previousHeader)) {
offset := len(previousHeader) // already includes a space
blamePart.PreviousSha = string(lineBytes[offset : offset+objectFormatLength])
offset += objectFormatLength + 1 // +1 for space
blamePart.PreviousPath = string(lineBytes[offset:])
}
// need to munch to end of line...
for isPrefix {
_, isPrefix, err = r.bufferedReader.ReadLine()
if err != nil && err != io.EOF {
return blamePart, err
}
}
}
r.lastSha = nil
return blamePart, nil
}
// Close BlameReader - don't run NextPart after invoking that
func (r *BlameReader) Close() error {
if r.bufferedReader == nil {
return nil
}
err := <-r.done
r.bufferedReader = nil
r.cleanup()
return err
}
func (r *BlameReader) cleanup() {
for _, cleanup := range r.cleanupFuncs {
cleanup()
}
}
// CreateBlameReader creates reader for given git.RepositoryFacade, commit and file
func CreateBlameReader(ctx context.Context, objectFormat ObjectFormat, repo RepositoryFacade, gitRepo *Repository, commit *Commit, file string, bypassBlameIgnore bool) (rd *BlameReader, retErr error) {
defer func() {
if retErr != nil {
rd.cleanup()
}
}()
rd = &BlameReader{
done: make(chan error, 1),
objectFormat: objectFormat,
}
cmd := gitcmd.NewCommand("blame", "--porcelain")
stdoutReader, stdoutReaderClose := cmd.MakeStdoutPipe()
rd.bufferedReader = bufio.NewReader(stdoutReader)
rd.cleanupFuncs = append(rd.cleanupFuncs, stdoutReaderClose)
if !bypassBlameIgnore {
ignoreRevsFileName, ignoreRevsFileCleanup, err := tryCreateBlameIgnoreRevsFile(ctx, gitRepo, commit)
if err != nil && !IsErrNotExist(err) {
return nil, err
} else if err == nil {
rd.ignoreRevsFile = ignoreRevsFileName
rd.cleanupFuncs = append(rd.cleanupFuncs, ignoreRevsFileCleanup)
// Possible improvement: use --ignore-revs-file /dev/stdin on unix
// There is no equivalent on Windows. May be implemented if Gitea uses an external git backend.
cmd.AddOptionValues("--ignore-revs-file", ignoreRevsFileName)
}
}
cmd.AddDynamicArguments(commit.ID.String()).AddDashesAndList(file)
go func() {
// TODO: it doesn't work for directories (the directories shouldn't be "blamed"), and the "err" should be returned by "Read" but not by "Close"
rd.done <- cmd.WithRepo(repo).RunWithStderr(ctx)
}()
return rd, nil
}
func tryCreateBlameIgnoreRevsFile(ctx context.Context, gitRepo *Repository, commit *Commit) (string, func(), error) {
entry, err := commit.GetTreeEntryByPath(ctx, gitRepo, ".git-blame-ignore-revs")
if err != nil {
return "", nil, err
}
r, err := entry.Blob(gitRepo).DataAsync(ctx)
if err != nil {
return "", nil, err
}
defer r.Close()
f, cleanup, err := setting.AppDataTempDir("git-repo-content").CreateTempFileRandom("git-blame-ignore-revs")
if err != nil {
return "", nil, err
}
filename := f.Name()
_, err = io.Copy(f, r)
_ = f.Close()
if err != nil {
cleanup()
return "", nil, err
}
return filename, cleanup, nil
}

View File

@@ -0,0 +1,155 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"context"
"testing"
"gitea.dev/modules/setting"
"github.com/stretchr/testify/assert"
)
func TestReadingBlameOutputSha256(t *testing.T) {
setting.AppDataPath = t.TempDir()
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
if DefaultFeatures().UsingGogit {
t.Skip("Skipping test since gogit does not support sha256")
return
}
t.Run("Without .git-blame-ignore-revs", func(t *testing.T) {
storage := mockRepository("repo5_pulls_sha256")
repo, err := OpenRepository(ctx, storage)
assert.NoError(t, err)
defer repo.Close()
commit, err := repo.GetCommit(t.Context(), "0b69b7bb649b5d46e14cabb6468685e5dd721290acc7ffe604d37cde57927345")
assert.NoError(t, err)
parts := []*BlamePart{
{
Sha: "1e35a51dc00fd7de730344c07061acfe80e8117e075ac979b6a29a3a045190ca",
Lines: []string{
"# test_repo",
"Test repository for testing migration from github to gitea",
},
},
{
Sha: "0b69b7bb649b5d46e14cabb6468685e5dd721290acc7ffe604d37cde57927345",
Lines: []string{"", "Do not make any changes to this repo it is used for unit testing"},
PreviousSha: "1e35a51dc00fd7de730344c07061acfe80e8117e075ac979b6a29a3a045190ca",
PreviousPath: "README.md",
},
}
for _, bypass := range []bool{false, true} {
blameReader, err := CreateBlameReader(ctx, Sha256ObjectFormat, storage, repo, commit, "README.md", bypass)
assert.NoError(t, err)
assert.NotNil(t, blameReader)
defer blameReader.Close()
assert.False(t, blameReader.UsesIgnoreRevs())
for _, part := range parts {
actualPart, err := blameReader.NextPart()
assert.NoError(t, err)
assert.Equal(t, part, actualPart)
}
// make sure all parts have been read
actualPart, err := blameReader.NextPart()
assert.Nil(t, actualPart)
assert.NoError(t, err)
}
})
t.Run("With .git-blame-ignore-revs", func(t *testing.T) {
storage := mockRepository("repo6_blame_sha256")
repo, err := OpenRepository(ctx, storage)
assert.NoError(t, err)
defer repo.Close()
full := []*BlamePart{
{
Sha: "ab2b57a4fa476fb2edb74dafa577caf918561abbaa8fba0c8dc63c412e17a7cc",
Lines: []string{"line", "line"},
},
{
Sha: "9347b0198cd1f25017579b79d0938fa89dba34ad2514f0dd92f6bc975ed1a2fe",
Lines: []string{"changed line"},
PreviousSha: "ab2b57a4fa476fb2edb74dafa577caf918561abbaa8fba0c8dc63c412e17a7cc",
PreviousPath: "blame.txt",
},
{
Sha: "ab2b57a4fa476fb2edb74dafa577caf918561abbaa8fba0c8dc63c412e17a7cc",
Lines: []string{"line", "line", ""},
},
}
cases := []struct {
CommitID string
UsesIgnoreRevs bool
Bypass bool
Parts []*BlamePart
}{
{
CommitID: "e2f5660e15159082902960af0ed74fc144921d2b0c80e069361853b3ece29ba3",
UsesIgnoreRevs: true,
Bypass: false,
Parts: []*BlamePart{
{
Sha: "ab2b57a4fa476fb2edb74dafa577caf918561abbaa8fba0c8dc63c412e17a7cc",
Lines: []string{"line", "line", "changed line", "line", "line", ""},
},
},
},
{
CommitID: "e2f5660e15159082902960af0ed74fc144921d2b0c80e069361853b3ece29ba3",
UsesIgnoreRevs: false,
Bypass: true,
Parts: full,
},
{
CommitID: "9347b0198cd1f25017579b79d0938fa89dba34ad2514f0dd92f6bc975ed1a2fe",
UsesIgnoreRevs: false,
Bypass: false,
Parts: full,
},
{
CommitID: "9347b0198cd1f25017579b79d0938fa89dba34ad2514f0dd92f6bc975ed1a2fe",
UsesIgnoreRevs: false,
Bypass: false,
Parts: full,
},
}
objectFormat, err := repo.GetObjectFormat(t.Context())
assert.NoError(t, err)
for _, c := range cases {
commit, err := repo.GetCommit(t.Context(), c.CommitID)
assert.NoError(t, err)
blameReader, err := CreateBlameReader(ctx, objectFormat, storage, repo, commit, "blame.txt", c.Bypass)
assert.NoError(t, err)
assert.NotNil(t, blameReader)
defer blameReader.Close()
assert.Equal(t, c.UsesIgnoreRevs, blameReader.UsesIgnoreRevs())
for _, part := range c.Parts {
actualPart, err := blameReader.NextPart()
assert.NoError(t, err)
assert.Equal(t, part, actualPart)
}
// make sure all parts have been read
actualPart, err := blameReader.NextPart()
assert.Nil(t, actualPart)
assert.NoError(t, err)
}
})
}

150
modules/git/blame_test.go Normal file
View File

@@ -0,0 +1,150 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"context"
"testing"
"gitea.dev/modules/setting"
"github.com/stretchr/testify/assert"
)
func TestReadingBlameOutput(t *testing.T) {
setting.AppDataPath = t.TempDir()
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
t.Run("Without .git-blame-ignore-revs", func(t *testing.T) {
storage := mockRepository("repo5_pulls")
repo, err := OpenRepository(ctx, storage)
assert.NoError(t, err)
defer repo.Close()
commit, err := repo.GetCommit(t.Context(), "f32b0a9dfd09a60f616f29158f772cedd89942d2")
assert.NoError(t, err)
parts := []*BlamePart{
{
Sha: "72866af952e98d02a73003501836074b286a78f6",
Lines: []string{
"# test_repo",
"Test repository for testing migration from github to gitea",
},
},
{
Sha: "f32b0a9dfd09a60f616f29158f772cedd89942d2",
Lines: []string{"", "Do not make any changes to this repo it is used for unit testing"},
PreviousSha: "72866af952e98d02a73003501836074b286a78f6",
PreviousPath: "README.md",
},
}
for _, bypass := range []bool{false, true} {
blameReader, err := CreateBlameReader(ctx, Sha1ObjectFormat, storage, repo, commit, "README.md", bypass)
assert.NoError(t, err)
assert.NotNil(t, blameReader)
defer blameReader.Close()
assert.False(t, blameReader.UsesIgnoreRevs())
for _, part := range parts {
actualPart, err := blameReader.NextPart()
assert.NoError(t, err)
assert.Equal(t, part, actualPart)
}
// make sure all parts have been read
actualPart, err := blameReader.NextPart()
assert.Nil(t, actualPart)
assert.NoError(t, err)
}
})
t.Run("With .git-blame-ignore-revs", func(t *testing.T) {
storage := mockRepository("repo6_blame")
repo, err := OpenRepository(ctx, storage)
assert.NoError(t, err)
defer repo.Close()
full := []*BlamePart{
{
Sha: "af7486bd54cfc39eea97207ca666aa69c9d6df93",
Lines: []string{"line", "line"},
},
{
Sha: "45fb6cbc12f970b04eacd5cd4165edd11c8d7376",
Lines: []string{"changed line"},
PreviousSha: "af7486bd54cfc39eea97207ca666aa69c9d6df93",
PreviousPath: "blame.txt",
},
{
Sha: "af7486bd54cfc39eea97207ca666aa69c9d6df93",
Lines: []string{"line", "line", ""},
},
}
cases := []struct {
CommitID string
UsesIgnoreRevs bool
Bypass bool
Parts []*BlamePart
}{
{
CommitID: "544d8f7a3b15927cddf2299b4b562d6ebd71b6a7",
UsesIgnoreRevs: true,
Bypass: false,
Parts: []*BlamePart{
{
Sha: "af7486bd54cfc39eea97207ca666aa69c9d6df93",
Lines: []string{"line", "line", "changed line", "line", "line", ""},
},
},
},
{
CommitID: "544d8f7a3b15927cddf2299b4b562d6ebd71b6a7",
UsesIgnoreRevs: false,
Bypass: true,
Parts: full,
},
{
CommitID: "45fb6cbc12f970b04eacd5cd4165edd11c8d7376",
UsesIgnoreRevs: false,
Bypass: false,
Parts: full,
},
{
CommitID: "45fb6cbc12f970b04eacd5cd4165edd11c8d7376",
UsesIgnoreRevs: false,
Bypass: false,
Parts: full,
},
}
objectFormat, err := repo.GetObjectFormat(t.Context())
assert.NoError(t, err)
for _, c := range cases {
commit, err := repo.GetCommit(t.Context(), c.CommitID)
assert.NoError(t, err)
blameReader, err := CreateBlameReader(ctx, objectFormat, storage, repo, commit, "blame.txt", c.Bypass)
assert.NoError(t, err)
assert.NotNil(t, blameReader)
defer blameReader.Close()
assert.Equal(t, c.UsesIgnoreRevs, blameReader.UsesIgnoreRevs())
for _, part := range c.Parts {
actualPart, err := blameReader.NextPart()
assert.NoError(t, err)
assert.Equal(t, part, actualPart)
}
// make sure all parts have been read
actualPart, err := blameReader.NextPart()
assert.Nil(t, actualPart)
assert.NoError(t, err)
}
})
}

116
modules/git/blob.go Normal file
View File

@@ -0,0 +1,116 @@
// Copyright 2015 The Gogs Authors. All rights reserved.
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"bytes"
"context"
"encoding/base64"
"errors"
"io"
"strings"
"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(ctx context.Context, limit int64) ([]byte, error) {
if limit <= 0 {
return nil, nil
}
dataRc, err := b.DataAsync(ctx)
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(ctx context.Context, limit int64) (string, error) {
buf, err := b.GetBlobBytes(ctx, 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(ctx context.Context, w io.Writer) (size int64, count int, _ error) {
reader, err := b.DataAsync(ctx)
if err != nil {
return 0, 0, err
}
defer reader.Close()
return getBlobLineCount(reader, w)
}
func getBlobLineCount(r io.Reader, w io.Writer) (size int64, count int, _ error) {
buf := make([]byte, 32*1024)
size, count = 0, 0
var lastChar byte
lineSep := []byte("\n")
for {
c, err := r.Read(buf)
size += int64(c)
if w != nil {
if _, err := w.Write(buf[:c]); err != nil {
return size, count, err
}
}
if c > 0 {
count += bytes.Count(buf[:c], lineSep)
lastChar = buf[c-1]
}
switch {
case errors.Is(err, io.EOF):
if size > 0 && lastChar != '\n' {
// it should match "git diff" hunk line number. "a\nb" => 2 lines, "a\nb\n" => 2 lines
count++
}
return size, count, nil
case err != nil:
return size, count, err
}
}
}
// GetBlobContentBase64 Reads the content of the blob with a base64 encoding and returns the encoded string
func (b *Blob) GetBlobContentBase64(ctx context.Context, originContent *strings.Builder) (string, error) {
dataRc, err := b.DataAsync(ctx)
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
}

47
modules/git/blob_gogit.go Normal file
View File

@@ -0,0 +1,47 @@
// 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 (
"context"
"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(_ context.Context) (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(_ context.Context) 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()
}

108
modules/git/blob_nogogit.go Normal file
View File

@@ -0,0 +1,108 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !gogit
package git
import (
"context"
"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(ctx context.Context) (_ io.ReadCloser, retErr error) {
batch, cancel, err := b.repo.CatFileBatch()
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(ctx context.Context) int64 {
if b.gotSize {
return b.size
}
batch, cancel, err := b.repo.CatFileBatch()
if err != nil {
log.Debug("error whilst reading size for %s in %s. Error: %v", b.ID.String(), b.repo.LogString(), 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.LogString(), 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
}

76
modules/git/blob_test.go Normal file
View File

@@ -0,0 +1,76 @@
// 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"
"strings"
"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 := OpenRepositoryLocal(t.Context(), bareRepo1Path)
require.NoError(t, err)
defer repo.Close()
testBlob, err := repo.GetBlob("6c493ff740f9380390d5c9ddef4af18697ac9375")
assert.NoError(t, err)
r, err := testBlob.DataAsync(t.Context())
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 := OpenRepositoryLocal(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(b.Context())
if err != nil {
b.Fatal(err)
}
io.ReadAll(r)
_ = r.Close()
}
}
func TestGetBlobLineCount(t *testing.T) {
size, count, err := getBlobLineCount(strings.NewReader(""), nil)
assert.NoError(t, err)
assert.EqualValues(t, 0, size)
assert.Equal(t, 0, count)
size, count, err = getBlobLineCount(strings.NewReader("\n"), nil)
assert.NoError(t, err)
assert.EqualValues(t, 1, size)
assert.Equal(t, 1, count)
size, count, err = getBlobLineCount(strings.NewReader("a\nb"), nil)
assert.NoError(t, err)
assert.EqualValues(t, 3, size)
assert.Equal(t, 2, count)
}

95
modules/git/branch.go Normal file
View File

@@ -0,0 +1,95 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"context"
"errors"
"strings"
"gitea.dev/modules/git/gitcmd"
)
// GetBranchesByPath returns a branch by its path
// if limit = 0 it will not limit
func GetBranchesByPath(ctx context.Context, repo RepositoryFacade, skip, limit int) ([]string, int, error) {
gitRepo, err := OpenRepository(ctx, repo)
if err != nil {
return nil, 0, err
}
defer gitRepo.Close()
return gitRepo.GetBranchNames(ctx, skip, limit)
}
func GetBranchCommitID(ctx context.Context, repo RepositoryFacade, branch string) (string, error) {
gitRepo, err := OpenRepository(ctx, repo)
if err != nil {
return "", err
}
defer gitRepo.Close()
return gitRepo.GetBranchCommitID(ctx, branch)
}
// SetDefaultBranch sets default branch of repository.
func SetDefaultBranch(ctx context.Context, repo RepositoryFacade, name string) error {
_, _, err := gitcmd.NewCommand("symbolic-ref", "HEAD").
AddDynamicArguments(BranchPrefix + name).WithRepo(repo).RunStdString(ctx)
return err
}
// GetDefaultBranch gets default branch of repository.
func GetDefaultBranch(ctx context.Context, repo RepositoryFacade) (string, error) {
stdout, _, err := gitcmd.NewCommand("symbolic-ref", "HEAD").WithRepo(repo).RunStdString(ctx)
if err != nil {
return "", err
}
stdout = strings.TrimSpace(stdout)
if !strings.HasPrefix(stdout, BranchPrefix) {
return "", errors.New("the HEAD is not a branch: " + stdout)
}
return strings.TrimPrefix(stdout, BranchPrefix), nil
}
// IsReferenceExist returns true if given reference exists in the repository.
func IsReferenceExist(ctx context.Context, repo RepositoryFacade, name string) bool {
_, _, err := gitcmd.NewCommand("show-ref", "--verify").AddDashesAndList(name).WithRepo(repo).RunStdString(ctx)
return err == nil
}
// IsBranchExist returns true if given branch exists in the repository.
func IsBranchExist(ctx context.Context, repo RepositoryFacade, name string) bool {
return IsReferenceExist(ctx, repo, BranchPrefix+name)
}
// DeleteBranch delete a branch by name on repository.
func DeleteBranch(ctx context.Context, repo RepositoryFacade, name string, force bool) error {
cmd := gitcmd.NewCommand("branch")
if force {
cmd.AddArguments("-D")
} else {
cmd.AddArguments("-d")
}
cmd.AddDashesAndList(name)
_, _, err := cmd.WithRepo(repo).RunStdString(ctx)
return err
}
// CreateBranch create a new branch
func CreateBranch(ctx context.Context, repo RepositoryFacade, branch, oldbranchOrCommit string) error {
cmd := gitcmd.NewCommand("branch")
cmd.AddDashesAndList(branch, oldbranchOrCommit)
_, _, err := cmd.WithRepo(repo).RunStdString(ctx)
return err
}
// RenameBranch rename a branch
func RenameBranch(ctx context.Context, repo RepositoryFacade, from, to string) error {
_, _, err := gitcmd.NewCommand("branch", "-m").AddDynamicArguments(from, to).WithRepo(repo).RunStdString(ctx)
return err
}

View File

@@ -0,0 +1,61 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"context"
"io"
"os"
"gitea.dev/modules/git/gitrepo"
"gitea.dev/modules/util"
)
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
Context() context.Context
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, repo RepositoryFacade) (CatFileBatchCloser, error) {
repoPath := gitrepo.RepoLocalPath(repo)
if _, err := os.Stat(repoPath); err != nil {
return nil, util.NewNotExistErrorf("repo %q doesn't exist", repo.LogString())
}
if DefaultFeatures().SupportCatFileBatchCommand {
return newCatFileBatchCommand(ctx, repo), nil
}
return newCatFileBatchLegacy(ctx, repo), nil
}

View File

@@ -0,0 +1,75 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"context"
"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
repo RepositoryFacade
batch *catFileBatchCommunicator
}
var _ CatFileBatch = (*catFileBatchCommand)(nil)
func newCatFileBatchCommand(ctx context.Context, repo RepositoryFacade) *catFileBatchCommand {
return &catFileBatchCommand{ctx: ctx, repo: repo}
}
func (b *catFileBatchCommand) getBatch(callerInfo string) *catFileBatchCommunicator {
if b.batch != nil {
return b.batch
}
b.batch = newCatFileBatch(b.ctx, b.repo, gitcmd.NewCommand("cat-file", "--batch-command").WithParentCallerInfo(callerInfo))
return b.batch
}
func (b *catFileBatchCommand) Context() context.Context {
return b.ctx
}
func (b *catFileBatchCommand) QueryContent(obj string) (*CatFileObject, BufferedReader, error) {
if strings.Contains(obj, "\n") {
setting.PanicInDevOrTesting("invalid object name with newline: %q", obj)
}
batch := b.getBatch(util.CallerFuncName(1))
_, err := batch.reqWriter.Write([]byte("contents " + obj + "\n"))
if err != nil {
return nil, nil, err
}
info, err := catFileBatchParseInfoLine(batch.respReader)
if err != nil {
return nil, nil, err
}
return info, batch.respReader, nil
}
func (b *catFileBatchCommand) QueryInfo(obj string) (*CatFileObject, error) {
if strings.Contains(obj, "\n") {
setting.PanicInDevOrTesting("invalid object name with newline: %q", obj)
}
batch := b.getBatch(util.CallerFuncName(1))
_, err := batch.reqWriter.Write([]byte("info " + obj + "\n"))
if err != nil {
return nil, err
}
return catFileBatchParseInfoLine(batch.respReader)
}
func (b *catFileBatchCommand) Close() {
if b.batch != nil {
b.batch.Close()
b.batch = nil
}
}

View File

@@ -0,0 +1,87 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"context"
"io"
"strings"
"gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/setting"
)
// 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
repo RepositoryFacade
batchContent *catFileBatchCommunicator
batchCheck *catFileBatchCommunicator
}
var _ CatFileBatchCloser = (*catFileBatchLegacy)(nil)
func newCatFileBatchLegacy(ctx context.Context, repo RepositoryFacade) *catFileBatchLegacy {
return &catFileBatchLegacy{ctx: ctx, repo: repo}
}
func (b *catFileBatchLegacy) getBatchContent() *catFileBatchCommunicator {
if b.batchContent != nil {
return b.batchContent
}
b.batchContent = newCatFileBatch(b.ctx, b.repo, 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.repo, gitcmd.NewCommand("cat-file", "--batch-check"))
return b.batchCheck
}
func (b *catFileBatchLegacy) Context() context.Context {
return b.ctx
}
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
}
}

View File

@@ -0,0 +1,241 @@
// 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
closed chan struct{}
}
func (b *catFileBatchCommunicator) Close(err ...error) {
if fn := b.closeFunc.Swap(nil); fn != nil {
(*fn)(util.OptionalArg(err))
}
// make sure the git process has fully exited before we return from Close()
// otherwise, the opened files will block the directory renaming (rename a repo) on Windows
<-b.closed
}
// newCatFileBatch opens git cat-file --batch/--batch-check/--batch-command command and prepares the stdin/stdout pipes for communication.
func newCatFileBatch(ctx context.Context, repo RepositoryFacade, 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
closed: make(chan struct{}),
}
ret.closeFunc.Store(new(func(err error) {
ctxCancel(err)
stdPipeClose()
}))
err := cmdCatFile.WithParentCallerInfo().WithRepo(repo).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
close(ret.closed)
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", repo.LogString(), err)
}
close(ret.closed)
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
}

View File

@@ -0,0 +1,108 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"io"
"os"
"path/filepath"
"testing"
"gitea.dev/modules/git/gitrepo"
"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) {
repo1Path, _ := filepath.Abs(filepath.Join(testReposDir, "repo1_bare"))
repo1 := gitrepo.RepositoryUnmanaged(repo1Path)
t.Run("CorruptedGitRepo", func(t *testing.T) {
tmpDir := t.TempDir()
batch, err := NewBatch(t.Context(), gitrepo.RepositoryUnmanaged(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(), repo1)
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(), repo1)
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))
})
}

24
modules/git/clone.go Normal file
View File

@@ -0,0 +1,24 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"context"
"gitea.dev/modules/git/gitrepo"
)
// CloneExternalRepo clones an external repository to the managed repository.
func CloneExternalRepo(ctx context.Context, fromRemoteURL string, toRepo RepositoryFacade, opts CloneRepoOptions) error {
return Clone(ctx, fromRemoteURL, gitrepo.RepoLocalPath(toRepo), opts)
}
// CloneRepoToLocal clones a managed repository to a local path.
func CloneRepoToLocal(ctx context.Context, fromRepo RepositoryFacade, toLocalPath string, opts CloneRepoOptions) error {
return Clone(ctx, gitrepo.RepoLocalPath(fromRepo), toLocalPath, opts)
}
func CloneManaged(ctx context.Context, fromRepo, toRepo RepositoryFacade, opts CloneRepoOptions) error {
return Clone(ctx, gitrepo.RepoLocalPath(fromRepo), gitrepo.RepoLocalPath(toRepo), opts)
}

36
modules/git/cmdverb.go Normal file
View File

@@ -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
}

272
modules/git/commit.go Normal file
View File

@@ -0,0 +1,272 @@
// 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/git/gitcmd"
"gitea.dev/modules/util"
)
// Commit represents a git commit.
type Commit struct {
CommitMessage
ID ObjectID
TreeID ObjectID
Parents []ObjectID
Author *Signature // never nil
Committer *Signature // never nil
Signature *CommitSignature
submoduleCache *ObjectCache[*SubModule]
treeCache *Tree
}
// CommitSignature represents a git commit signature part.
type CommitSignature struct {
Signature string
Payload string
}
// 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(ctx context.Context, gitRepo *Repository, n int) (*Commit, error) {
id, err := c.ParentID(n)
if err != nil {
return nil, err
}
parent, err := gitRepo.getCommit(ctx, 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(ctx context.Context, gitRepo *Repository, relpath string) (*Commit, error) {
return gitRepo.LastCommitCache.GetCommitByPath(ctx, c.ID, relpath)
}
func (c *Commit) Tree() *Tree {
if c.treeCache == nil {
c.treeCache = newTree(c.TreeID)
}
return c.treeCache
}
func (c *Commit) GetBlobByPath(ctx context.Context, gitRepo *Repository, relpath string) (*Blob, error) {
return c.Tree().GetBlobByPath(ctx, gitRepo, relpath)
}
func (c *Commit) GetTreeEntryByPath(ctx context.Context, gitRepo *Repository, relpath string) (_ *TreeEntry, err error) {
return c.Tree().GetTreeEntryByPath(ctx, gitRepo, relpath)
}
func (c *Commit) SubTree(ctx context.Context, gitRepo *Repository, relpath string) (*Tree, error) {
return c.Tree().SubTree(ctx, gitRepo, relpath)
}
// CommitsByRange returns the specific page commits before current revision, every page's number default by CommitsRangeSize
func (c *Commit) CommitsByRange(ctx context.Context, gitRepo *Repository, page, pageSize int, not, since, until string) ([]*Commit, error) {
return gitRepo.commitsByRangeWithTime(ctx, c.ID, page, pageSize, not, since, until)
}
// CommitsBefore returns all the commits before current revision
func (c *Commit) CommitsBefore(ctx context.Context, gitRepo *Repository) ([]*Commit, error) {
return gitRepo.getCommitsBefore(ctx, c.ID)
}
// HasPreviousCommit returns true if a given commitHash is contained in commit's parents
func (c *Commit) HasPreviousCommit(ctx context.Context, gitRepo *Repository, 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).
WithRepo(gitRepo).
RunStdString(ctx)
if err == nil {
return true, nil
}
if exitError, ok := errors.AsType[*exec.ExitError](err); ok {
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(ctx context.Context, gitRepo *Repository, oldCommitID string) (bool, error) {
objectFormat, err := gitRepo.GetObjectFormat(ctx)
if err != nil {
return false, err
}
if oldCommitID == objectFormat.EmptyObjectID().String() {
return false, nil
}
oldCommit, err := gitRepo.GetCommit(ctx, oldCommitID)
if err != nil {
return false, err
}
hasPreviousCommit, err := c.HasPreviousCommit(ctx, gitRepo, oldCommit.ID)
return !hasPreviousCommit, err
}
// CommitsBeforeLimit returns num commits before current revision
func (c *Commit) CommitsBeforeLimit(ctx context.Context, gitRepo *Repository, num int) ([]*Commit, error) {
return gitRepo.getCommitsBeforeLimit(ctx, c.ID, num)
}
// CommitsBeforeUntil returns the commits in range "[cur, ref)"
func (c *Commit) CommitsBeforeUntil(ctx context.Context, gitRepo *Repository, ref RefName) ([]*Commit, error) {
return gitRepo.CommitsBetween(ctx, c.ID.RefName(), ref, -1)
}
// 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(ctx context.Context, gitRepo *Repository, opts SearchCommitsOptions) ([]*Commit, error) {
return gitRepo.searchCommits(ctx, c.ID, opts)
}
// GetFilesChangedSinceCommit get all changed file names between pastCommit to current revision
func (c *Commit) GetFilesChangedSinceCommit(ctx context.Context, gitRepo *Repository, pastCommit string) ([]string, error) {
return gitRepo.GetFilesChangedBetween(ctx, 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(ctx context.Context, gitRepo *Repository, filename, pastCommit string) (bool, error) {
return gitRepo.FileChangedBetweenCommits(ctx, filename, pastCommit, c.ID.String())
}
// GetFileContent reads a file content as a string or returns false if this was not possible
func (c *Commit) GetFileContent(ctx context.Context, gitRepo *Repository, filename string, limit int) (string, error) {
entry, err := c.GetTreeEntryByPath(ctx, gitRepo, filename)
if err != nil {
return "", err
}
r, err := entry.Blob(gitRepo).DataAsync(ctx)
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, repo RepositoryFacade, shortID string) (string, error) {
commitID, _, err := gitcmd.NewCommand("rev-parse").
AddDynamicArguments(shortID).
WithRepo(repo).
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
}
return isStringLowerHex(s)
}
func isStringLowerHex(s string) bool {
for _, c := range s {
isHex := (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')
if !isHex {
return false
}
}
return len(s) > 0 // it accepts odd length because "shorten commit id" can be 7-chars
}

97
modules/git/commit2.go Normal file
View File

@@ -0,0 +1,97 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"context"
"strconv"
"strings"
"time"
"gitea.dev/modules/git/gitcmd"
)
// CommitsCountOptions the options when counting commits
type CommitsCountOptions struct {
Not string
Revision []string
RelPath []string
Since string
Until string
}
// CommitsCount returns number of total commits of until given revision.
func CommitsCount(ctx context.Context, repo RepositoryFacade, opts CommitsCountOptions) (int64, error) {
cmd := gitcmd.NewCommand("rev-list", "--count")
cmd.AddDynamicArguments(opts.Revision...)
if opts.Not != "" {
cmd.AddOptionValues("--not", opts.Not)
}
if opts.Since != "" {
cmd.AddOptionFormat("--since=%s", opts.Since)
}
if opts.Until != "" {
cmd.AddOptionFormat("--until=%s", opts.Until)
}
if len(opts.RelPath) > 0 {
cmd.AddDashesAndList(opts.RelPath...)
}
stdout, _, err := cmd.WithRepo(repo).RunStdString(ctx)
if err != nil {
return 0, err
}
return strconv.ParseInt(strings.TrimSpace(stdout), 10, 64)
}
// FileCommitsCount return the number of files at a revision
func FileCommitsCount(ctx context.Context, repo RepositoryFacade, revision, file string) (int64, error) {
return CommitsCount(ctx, repo,
CommitsCountOptions{
Revision: []string{revision},
RelPath: []string{file},
})
}
// CommitsCountOfCommit returns number of total commits of until current revision.
func CommitsCountOfCommit(ctx context.Context, repo RepositoryFacade, commitID string) (int64, error) {
return CommitsCount(ctx, repo, CommitsCountOptions{
Revision: []string{commitID},
})
}
// AllCommitsCount returns count of all commits in repository
func AllCommitsCount(ctx context.Context, repo RepositoryFacade, hidePRRefs bool, files ...string) (int64, error) {
cmd := gitcmd.NewCommand("rev-list")
if hidePRRefs {
cmd.AddArguments("--exclude=" + PullPrefix + "*")
}
cmd.AddArguments("--all", "--count")
if len(files) > 0 {
cmd.AddDashesAndList(files...)
}
stdout, _, err := cmd.WithRepo(repo).RunStdString(ctx)
if err != nil {
return 0, err
}
return strconv.ParseInt(strings.TrimSpace(stdout), 10, 64)
}
// GetLatestCommitTime returns time for latest commit in repository (across all branches)
func GetLatestCommitTime(ctx context.Context, repo RepositoryFacade) (time.Time, error) {
stdout, _, err := gitcmd.NewCommand("for-each-ref", "--sort=-committerdate", BranchPrefix, "--count", "1", "--format=%(committerdate)").WithRepo(repo).RunStdString(ctx)
if err != nil {
return time.Time{}, err
}
commitTime := strings.TrimSpace(stdout)
return time.Parse("Mon Jan _2 15:04:05 2006 -0700", commitTime)
}

View File

@@ -0,0 +1,76 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestCommitsCount(t *testing.T) {
bareRepo1 := mockRepository("repo1_bare")
commitsCount, err := CommitsCount(t.Context(), bareRepo1,
CommitsCountOptions{
Revision: []string{"8006ff9adbf0cb94da7dad9e537e53817f9fa5c0"},
})
assert.NoError(t, err)
assert.Equal(t, int64(3), commitsCount)
}
func TestCommitsCountWithSinceUntil(t *testing.T) {
bareRepo1 := mockRepository("repo1_bare")
revision := []string{"8006ff9adbf0cb94da7dad9e537e53817f9fa5c0"}
// The three commits on this revision are dated 2018-04-18, 2017-12-19 and 2017-12-19.
cases := []struct {
name string
since string
until string
expected int64
}{
{name: "no filter", expected: 3},
{name: "since keeps newer commits", since: "2018-01-01", expected: 1},
{name: "until keeps older commits", until: "2018-01-01", expected: 2},
{name: "since and until bound the range", since: "2017-12-19T22:16:00-08:00", until: "2018-01-01", expected: 1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
commitsCount, err := CommitsCount(t.Context(), bareRepo1,
CommitsCountOptions{
Revision: revision,
Since: tc.since,
Until: tc.until,
})
assert.NoError(t, err)
assert.Equal(t, tc.expected, commitsCount)
})
}
}
func TestCommitsCountWithoutBase(t *testing.T) {
bareRepo1 := mockRepository("repo1_bare")
commitsCount, err := CommitsCount(t.Context(), bareRepo1,
CommitsCountOptions{
Not: "master",
Revision: []string{"branch1"},
})
assert.NoError(t, err)
assert.Equal(t, int64(2), commitsCount)
}
func TestGetLatestCommitTime(t *testing.T) {
bareRepo1 := mockRepository("repo1_bare")
lct, err := GetLatestCommitTime(t.Context(), bareRepo1)
assert.NoError(t, err)
// Time is Sun Nov 13 16:40:14 2022 +0100
// which is the time of commit
// ce064814f4a0d337b333e646ece456cd39fab612 (refs/heads/master)
assert.EqualValues(t, 1668354014, lct.Unix())
}

View File

@@ -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),
}
}

View File

@@ -0,0 +1,27 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"context"
"gitea.dev/modules/cache"
)
func makeCommitsCountCacheKey(repo RepositoryFacade, ref RefName) string {
return cache.SafeCacheKey("git-commits-count:"+repo.GitRepoManagedID(), ref.String())
}
func RemoveCommitsCountCache(repo RepositoryFacade, ref RefName) {
cache.Remove(makeCommitsCountCacheKey(repo, ref))
}
func GetCommitsCountCache(ctx context.Context, repo RepositoryFacade, ref RefName, commit *Commit) (int64, error) {
if commit == nil {
return 0, nil
}
return cache.GetInt64(makeCommitsCountCacheKey(repo, ref), func() (int64, error) {
return CommitsCountOfCommit(ctx, repo, commit.ID.String())
})
}

View File

@@ -0,0 +1,88 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"bufio"
"context"
"io"
"gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/log"
)
// CommitFileStatus represents status of files in a commit.
type CommitFileStatus struct {
Added []string
Removed []string
Modified []string
}
// NewCommitFileStatus creates a CommitFileStatus
func NewCommitFileStatus() *CommitFileStatus {
return &CommitFileStatus{
[]string{}, []string{}, []string{},
}
}
func parseCommitFileStatus(fileStatus *CommitFileStatus, stdout io.Reader) {
rd := bufio.NewReader(stdout)
peek, err := rd.Peek(1)
if err != nil {
if err != io.EOF {
log.Error("Unexpected error whilst reading from git log --name-status. Error: %v", err)
}
return
}
if peek[0] == '\n' || peek[0] == '\x00' {
_, _ = rd.Discard(1)
}
for {
modifier, err := rd.ReadString('\x00')
if err != nil {
if err != io.EOF {
log.Error("Unexpected error whilst reading from git log --name-status. Error: %v", err)
}
return
}
file, err := rd.ReadString('\x00')
if err != nil {
if err != io.EOF {
log.Error("Unexpected error whilst reading from git log --name-status. Error: %v", err)
}
return
}
file = file[:len(file)-1]
switch modifier[0] {
case 'A':
fileStatus.Added = append(fileStatus.Added, file)
case 'D':
fileStatus.Removed = append(fileStatus.Removed, file)
case 'M':
fileStatus.Modified = append(fileStatus.Modified, file)
}
}
}
// GetCommitFileStatus returns file status of commit in given repository.
func GetCommitFileStatus(ctx context.Context, repo RepositoryFacade, commitID string) (*CommitFileStatus, error) {
cmd := gitcmd.NewCommand("log", "--name-status", "-m", "--pretty=format:", "--first-parent", "--no-renames", "-z", "-1")
stdout, stdoutClose := cmd.MakeStdoutPipe()
defer stdoutClose()
done := make(chan struct{})
fileStatus := NewCommitFileStatus()
go func() {
parseCommitFileStatus(fileStatus, stdout)
close(done)
}()
err := cmd.AddDynamicArguments(commitID).
WithRepo(repo).
RunWithStderr(ctx)
if err != nil {
return nil, err
}
<-done
return fileStatus, nil
}

View File

@@ -0,0 +1,175 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestParseCommitFileStatus(t *testing.T) {
type testcase struct {
output string
added []string
removed []string
modified []string
}
kases := []testcase{
{
// Merge commit
output: "MM\x00options/locale/locale_en-US.ini\x00",
modified: []string{
"options/locale/locale_en-US.ini",
},
added: []string{},
removed: []string{},
},
{
// Spaces commit
output: "D\x00b\x00D\x00b b/b\x00A\x00b b/b b/b b/b\x00A\x00b b/b b/b b/b b/b\x00",
removed: []string{
"b",
"b b/b",
},
modified: []string{},
added: []string{
"b b/b b/b b/b",
"b b/b b/b b/b b/b",
},
},
{
// larger commit
output: "M\x00go.mod\x00M\x00go.sum\x00M\x00modules/ssh/ssh.go\x00M\x00vendor/github.com/gliderlabs/ssh/circle.yml\x00M\x00vendor/github.com/gliderlabs/ssh/context.go\x00A\x00vendor/github.com/gliderlabs/ssh/go.mod\x00A\x00vendor/github.com/gliderlabs/ssh/go.sum\x00M\x00vendor/github.com/gliderlabs/ssh/server.go\x00M\x00vendor/github.com/gliderlabs/ssh/session.go\x00M\x00vendor/github.com/gliderlabs/ssh/ssh.go\x00M\x00vendor/golang.org/x/sys/unix/mkerrors.sh\x00M\x00vendor/golang.org/x/sys/unix/syscall_darwin.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_darwin_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_darwin_arm64.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_freebsd_386.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_freebsd_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_freebsd_arm.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_freebsd_arm64.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_linux.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_darwin_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_darwin_arm64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_dragonfly_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_freebsd_386.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_freebsd_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_freebsd_arm.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_freebsd_arm64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_netbsd_386.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_netbsd_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_netbsd_arm.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_netbsd_arm64.go\x00M\x00vendor/modules.txt\x00",
modified: []string{
"go.mod",
"go.sum",
"modules/ssh/ssh.go",
"vendor/github.com/gliderlabs/ssh/circle.yml",
"vendor/github.com/gliderlabs/ssh/context.go",
"vendor/github.com/gliderlabs/ssh/server.go",
"vendor/github.com/gliderlabs/ssh/session.go",
"vendor/github.com/gliderlabs/ssh/ssh.go",
"vendor/golang.org/x/sys/unix/mkerrors.sh",
"vendor/golang.org/x/sys/unix/syscall_darwin.go",
"vendor/golang.org/x/sys/unix/zerrors_darwin_amd64.go",
"vendor/golang.org/x/sys/unix/zerrors_darwin_arm64.go",
"vendor/golang.org/x/sys/unix/zerrors_freebsd_386.go",
"vendor/golang.org/x/sys/unix/zerrors_freebsd_amd64.go",
"vendor/golang.org/x/sys/unix/zerrors_freebsd_arm.go",
"vendor/golang.org/x/sys/unix/zerrors_freebsd_arm64.go",
"vendor/golang.org/x/sys/unix/zerrors_linux.go",
"vendor/golang.org/x/sys/unix/ztypes_darwin_amd64.go",
"vendor/golang.org/x/sys/unix/ztypes_darwin_arm64.go",
"vendor/golang.org/x/sys/unix/ztypes_dragonfly_amd64.go",
"vendor/golang.org/x/sys/unix/ztypes_freebsd_386.go",
"vendor/golang.org/x/sys/unix/ztypes_freebsd_amd64.go",
"vendor/golang.org/x/sys/unix/ztypes_freebsd_arm.go",
"vendor/golang.org/x/sys/unix/ztypes_freebsd_arm64.go",
"vendor/golang.org/x/sys/unix/ztypes_netbsd_386.go",
"vendor/golang.org/x/sys/unix/ztypes_netbsd_amd64.go",
"vendor/golang.org/x/sys/unix/ztypes_netbsd_arm.go",
"vendor/golang.org/x/sys/unix/ztypes_netbsd_arm64.go",
"vendor/modules.txt",
},
added: []string{
"vendor/github.com/gliderlabs/ssh/go.mod",
"vendor/github.com/gliderlabs/ssh/go.sum",
},
removed: []string{},
},
{
// git 1.7.2 adds an unnecessary \x00 on merge commit
output: "\x00MM\x00options/locale/locale_en-US.ini\x00",
modified: []string{
"options/locale/locale_en-US.ini",
},
added: []string{},
removed: []string{},
},
{
// git 1.7.2 adds an unnecessary \n on normal commit
output: "\nD\x00b\x00D\x00b b/b\x00A\x00b b/b b/b b/b\x00A\x00b b/b b/b b/b b/b\x00",
removed: []string{
"b",
"b b/b",
},
modified: []string{},
added: []string{
"b b/b b/b b/b",
"b b/b b/b b/b b/b",
},
},
}
for _, kase := range kases {
fileStatus := NewCommitFileStatus()
parseCommitFileStatus(fileStatus, strings.NewReader(kase.output))
assert.Equal(t, kase.added, fileStatus.Added)
assert.Equal(t, kase.removed, fileStatus.Removed)
assert.Equal(t, kase.modified, fileStatus.Modified)
}
}
func TestGetCommitFileStatusMerges(t *testing.T) {
bareRepo6 := mockRepository("repo6_merge")
commitFileStatus, err := GetCommitFileStatus(t.Context(), bareRepo6, "022f4ce6214973e018f02bf363bf8a2e3691f699")
assert.NoError(t, err)
expected := CommitFileStatus{
[]string{
"add_file.txt",
},
[]string{
"to_remove.txt",
},
[]string{
"to_modify.txt",
},
}
assert.Equal(t, expected.Added, commitFileStatus.Added)
assert.Equal(t, expected.Removed, commitFileStatus.Removed)
assert.Equal(t, expected.Modified, commitFileStatus.Modified)
}
func TestGetCommitFileStatusMergesSha256(t *testing.T) {
bareRepo6Sha256 := mockRepository("repo6_merge_sha256")
commitFileStatus, err := GetCommitFileStatus(t.Context(), bareRepo6Sha256, "d2e5609f630dd8db500f5298d05d16def282412e3e66ed68cc7d0833b29129a1")
assert.NoError(t, err)
expected := CommitFileStatus{
[]string{
"add_file.txt",
},
[]string{},
[]string{
"to_modify.txt",
},
}
assert.Equal(t, expected.Added, commitFileStatus.Added)
assert.Equal(t, expected.Removed, commitFileStatus.Removed)
assert.Equal(t, expected.Modified, commitFileStatus.Modified)
expected = CommitFileStatus{
[]string{},
[]string{
"to_remove.txt",
},
[]string{},
}
commitFileStatus, err = GetCommitFileStatus(t.Context(), bareRepo6Sha256, "da1ded40dc8e5b7c564171f4bf2fc8370487decfb1cb6a99ef28f3ed73d09172")
assert.NoError(t, err)
assert.Equal(t, expected.Added, commitFileStatus.Added)
assert.Equal(t, expected.Removed, commitFileStatus.Removed)
assert.Equal(t, expected.Modified, commitFileStatus.Modified)
}

115
modules/git/commit_info.go Normal file
View File

@@ -0,0 +1,115 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"context"
"maps"
"path"
"time"
"gitea.dev/modules/log"
"gitea.dev/modules/util"
)
// CommitInfo describes the first commit with the provided entry
type CommitInfo struct {
Entry *TreeEntry
Commit *Commit
SubmoduleFile *CommitSubmoduleFile
}
func GetCommitInfoSubmoduleFile(ctx context.Context, repoLink, fullPath string, gitRepo *Repository, commit *Commit, refCommitID ObjectID) (*CommitSubmoduleFile, error) {
submodule, err := commit.GetSubModule(ctx, gitRepo, 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
}
func getLastCommitForPathsWithTimeout(ctx context.Context, timeout time.Duration, gitRepo *Repository, commit *Commit, treePath string, entryNames []string) (map[string]*Commit, error) {
// Actually using "timeout ctx" here is not ideal: it creates a new git cat-file batch process
// (the other one uses the request's ctx and is cached by the gitRepo).
// Ideally, the underlying functions only need to return in predictable time,
// so it should use the shared (cached) git cat-file batcher from the request context and just use "time.After" for deadline.
// However, it's difficult to make it right at the moment.
ctxInner := ctx
if timeout > 0 {
var cancel context.CancelFunc
ctxInner, cancel = context.WithTimeout(ctx, timeout)
defer cancel()
}
revs, err := GetLastCommitForPaths(ctxInner, gitRepo, commit, treePath, entryNames)
if err != nil && ctxInner.Err() == nil {
// only return the err if context is not canceled
return nil, err
}
return revs, nil
}
// GetCommitsInfo gets information of all commits that are corresponding to these entries
func (tes Entries) GetCommitsInfo(ctx context.Context, timeout time.Duration, repoLink string, gitRepo *Repository, commit *Commit, treePath string) (commitsInfo []CommitInfo, treeLastCommit *Commit, err error) {
entryNames := make([]string, 0, len(tes)+1)
entryNames = append(entryNames, "") // include the commit for the treePath itself
for _, entry := range tes {
entryNames = append(entryNames, entry.Name())
}
revs, remainingEntryNames, err := getLastCommitForPathsByCache(ctx, commit.ID.String(), treePath, entryNames, gitRepo.LastCommitCache)
if err != nil {
return nil, nil, err
}
if len(remainingEntryNames) > 0 {
remainingRevs, err := getLastCommitForPathsWithTimeout(ctx, timeout, gitRepo, commit, treePath, remainingEntryNames)
if err != nil {
return nil, nil, err
}
maps.Copy(revs, remainingRevs)
}
for _, entry := range tes {
ci := CommitInfo{Entry: entry, Commit: revs[entry.Name()]}
if ci.Commit == nil {
log.Debug("missing commit for %s", entry.Name())
}
// If the entry is a submodule, add a submodule file for this
if entry.IsSubModule() {
ci.SubmoduleFile, err = GetCommitInfoSubmoduleFile(ctx, repoLink, path.Join(treePath, entry.Name()), gitRepo, commit, entry.ID)
if err != nil {
return nil, nil, err
}
}
commitsInfo = append(commitsInfo, ci)
}
// 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.
treeLastCommit = util.Iif(treePath == "", commit, revs[""])
return commitsInfo, treeLastCommit, nil
}
func getLastCommitForPathsByCache(ctx context.Context, 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(ctx, 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
}

View File

@@ -0,0 +1,207 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build gogit
package git
import (
"context"
"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"
)
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
}
// GetLastCommitForPaths returns last commit information
func GetLastCommitForPaths(ctx context.Context, gitRepo *Repository, commit *Commit, treePath string, paths []string) (map[string]*Commit, error) {
commitNodeIndex, closer := gitRepo.CommitNodeIndex()
defer closer()
c, err := commitNodeIndex.Get(plumbing.Hash(commit.ID.RawValue()))
if err != nil {
return nil, err
}
return getLastCommitForPathsByCommitNode(ctx, gitRepo, c, treePath, paths)
}
func getLastCommitForPathsByCommitNode(ctx context.Context, gitRepo *Repository, 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()) { //nolint:forcetypeassert // this heap only ever holds *commitAndPaths
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) //nolint:forcetypeassert // this heap only ever holds *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 := gitRepo.LastCommitCache.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
}

View File

@@ -0,0 +1,43 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !gogit
package git
import (
"context"
)
// GetLastCommitForPaths returns last commit information
func GetLastCommitForPaths(ctx context.Context, gitRepo *Repository, 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, gitRepo, 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 := gitRepo.GetCommit(ctx, commitID) // Ensure the commit exists in the repository
if err != nil {
return nil, err
}
commitCommits[path] = c
}
return commitCommits, nil
}

View File

@@ -0,0 +1,57 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !gogit
package git
import (
"context"
"path/filepath"
"testing"
"time"
"gitea.dev/modules/git/gitrepo"
"gitea.dev/modules/test"
"gitea.dev/modules/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestEntries_GetCommitsInfo_ContextErr(t *testing.T) {
repoPath, _ := filepath.Abs(filepath.Join(testReposDir, "repo1_bare"))
repo, err := OpenRepository(t.Context(), gitrepo.RepositoryManaged("dummy", repoPath))
require.NoError(t, err)
defer repo.Close()
commit, err := repo.GetCommit(t.Context(), "feaf4ba6bc635fec442f46ddd4512416ec43c2c2")
require.NoError(t, err)
entries, err := commit.Tree().ListEntries(t.Context(), repo)
require.NoError(t, err)
countCommitInfosCommit := func(infos []CommitInfo) (nilCommits, nonNilCommits int) {
for _, info := range infos {
nilCommits += util.Iif(info.Commit == nil, 1, 0)
nonNilCommits += util.Iif(info.Commit != nil, 1, 0)
}
return nilCommits, nonNilCommits
}
ctx, cancel := context.WithCancel(t.Context())
defer test.MockVariableValue(&walkGitLogDebugBeforeNext)()
walkGitLogDebugBeforeNext = cancel
commitInfos, _, err := entries.GetCommitsInfo(ctx, time.Second, "/any/repo-link", repo, commit, "")
assert.NoError(t, err)
nilCommits, nonNilCommits := countCommitInfosCommit(commitInfos)
assert.Equal(t, 0, nonNilCommits) // no commit info due to canceled (or deadline-exceeded) context
assert.Equal(t, 3, nilCommits)
walkGitLogDebugBeforeNext = nil
commitInfos, _, err = entries.GetCommitsInfo(t.Context(), time.Second, "/any/repo-link", repo, commit, "")
assert.NoError(t, err)
nilCommits, nonNilCommits = countCommitInfosCommit(commitInfos)
assert.Equal(t, 3, nonNilCommits)
assert.Equal(t, 0, nilCommits)
}

View File

@@ -0,0 +1,166 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"path/filepath"
"testing"
"time"
"gitea.dev/modules/git/gitrepo"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
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(t.Context(), 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.TreeID)
tree, err := commit.SubTree(t.Context(), repo1, 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)
entries, err := tree.ListEntries(t.Context(), repo1)
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
}
commitsInfo, treeCommit, err := entries.GetCommitsInfo(t.Context(), time.Second, "/any/repo-link", repo1, 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.GetSize(t.Context(), repo1), entry.Name())
}
}
}
func TestEntries_GetCommitsInfo(t *testing.T) {
bareRepo1Path, _ := filepath.Abs(filepath.Join(testReposDir, "repo1_bare"))
bareRepo1, err := OpenRepository(t.Context(), gitrepo.RepositoryManaged("repo1_bare", bareRepo1Path))
require.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(), gitrepo.RepositoryManaged("repo1_bare-clone", 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(t.Context(), "HEAD")
require.NoError(t, err)
treeEntry, err := commit.GetTreeEntryByPath(t.Context(), bareRepo1, "file1.txt")
require.NoError(t, err)
cisf, err := GetCommitInfoSubmoduleFile(t.Context(), "/any/repo-link", "file1.txt", bareRepo1, 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()))
})
}

View File

@@ -0,0 +1,194 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"net/mail"
"regexp"
"strings"
"sync"
"gitea.dev/modules/charset"
"gitea.dev/modules/util"
)
// CoAuthoredByTrailer is the canonical token for the `Co-authored-by:` git trailer.
const CoAuthoredByTrailer = "Co-authored-by"
const (
commitIdentityRoleAuthor = 1
commitIdentityRoleCommitter = 2
commitIdentityRoleCoAuthor = 3
)
type CommitIdentity struct {
Name string
Email string
role int
}
// CommitMessageTrailerValues keys are all in lower-case
type CommitMessageTrailerValues map[string][]string
type CommitMessage struct {
MessageRaw string
messageUTF8 *string
messageTitle *string
messageBody *string
trailerValues CommitMessageTrailerValues
allAuthors []*CommitIdentity
}
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
}
func (c *CommitMessage) MessageTrailer() CommitMessageTrailerValues {
if c.trailerValues == nil {
_, _, trailer := CommitMessageSplitTrailer(c.MessageUTF8())
c.trailerValues = CommitMessageParseTrailer(trailer)
}
return c.trailerValues
}
var commitMessageTrailerSplit = sync.OnceValue(func() *regexp.Regexp {
// ref: https://git-scm.com/docs/git-interpret-trailers
// TODO: the regexp is not able to perfectly parse the all kinds of trailers
// It was just copied from legacy code, it is not exactly the same as how Git parses the trailer and not quite right in some cases.
// For the key characters: it follows RFC 822 field name syntax (or RFC 2822/RFC 5322): printable ASCII characters between 33 and 126 except the colon (:),
// but maybe we don't want to make it that complicated, so here we only support some common "symbol-like" characters.
return regexp.MustCompile(`(?s)^(?P<content>.*?)(?P<sep>^|^\n|^-{3,}\n+|\n+-{3,}\n+|\n{2,})(?P<trailer>(?:[A-Za-z0-9][-\w]*:[^\n]*(\n\s+[^\n]*)*\n?)*\n*)$`)
})
// CommitMessageSplitTrailer tries to split the message by the trailer separator
// content + sep + trailer will reconstruct the original message
func CommitMessageSplitTrailer(s string) (content, sep, trailer string) {
s = util.NormalizeStringEOL(s)
re := commitMessageTrailerSplit()
v := re.FindStringSubmatch(s)
if v == nil {
return s, "", ""
}
return v[re.SubexpIndex("content")], v[re.SubexpIndex("sep")], v[re.SubexpIndex("trailer")]
}
// CommitMessageMerge merges two commit messages with their trailers
func CommitMessageMerge(m1, m2 string) string {
c1, s1, t1 := CommitMessageSplitTrailer(m1)
c2, s2, t2 := CommitMessageSplitTrailer(m2)
c1, t1 = strings.TrimSpace(c1), strings.TrimSpace(t1)
c2, t2 = strings.TrimSpace(c2), strings.TrimSpace(t2)
out := strings.Builder{}
if c1 != "" && c2 != "" {
out.WriteString(c1)
out.WriteString("\n\n")
out.WriteString(c2)
} else if c1 != "" {
out.WriteString(c1)
} else if c2 != "" {
out.WriteString(c2)
}
if t1 != "" || t2 != "" {
sep := util.Iif(t1 == "", s2, s1)
sep = util.IfZero(sep, "\n\n")
if c1 != "" || c2 != "" {
out.WriteString(sep)
}
if t1 != "" {
out.WriteString(t1)
}
if t1 != "" && t2 != "" {
out.WriteString("\n")
}
if t2 != "" {
out.WriteString(t2)
}
}
return out.String()
}
func CommitMessageParseTrailer(s string) CommitMessageTrailerValues {
ret := CommitMessageTrailerValues{}
for line := range strings.SplitSeq(util.NormalizeStringEOL(s), "\n") {
k, v, ok := strings.Cut(line, ":")
if !ok {
continue
}
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
kLower := strings.ToLower(k)
ret[kLower] = append(ret[kLower], v)
}
return ret
}
// AllAuthorIdentities returns all the author and co-authors in the commit. Committer is not included:
// * Author & Co-author: they changed the code (attribution)
// * Committer: they submitted the commit but didn't change the code (e.g.: maintainer signed a commit)
func (c *Commit) AllAuthorIdentities() []*CommitIdentity {
if c.allAuthors != nil {
return c.allAuthors
}
trailerCoAuthors := c.MessageTrailer()["co-authored-by"]
c.allAuthors = make([]*CommitIdentity, 0, 1+len(trailerCoAuthors))
exclude := map[string]int{}
addAuthor := func(name, email string, role int) {
if name == "" && email == "" {
return
}
key := strings.ToLower(email)
if key == "" {
key = strings.ToLower(name)
}
if existingRole := exclude[key]; key != "" && existingRole != 0 {
return
}
c.allAuthors = append(c.allAuthors, &CommitIdentity{Name: name, Email: email, role: role})
exclude[key] = role
}
addAuthor(c.Author.Name, c.Author.Email, commitIdentityRoleAuthor)
for _, coAuthorValue := range trailerCoAuthors {
coAuthorName, coAuthorEmail := parseCommitIdentityValue(coAuthorValue)
addAuthor(coAuthorName, coAuthorEmail, commitIdentityRoleCoAuthor)
}
return c.allAuthors
}
func (c *Commit) CommitterIsAuthor() bool {
return c.Committer.Name == c.Author.Name && c.Committer.Email == c.Author.Email
}
// Git identities are not RFC 5322 addresses: net/mail rejects names like "dependabot[bot]", so fall back to the angle-addr.
func parseCommitIdentityValue(value string) (name, email string) {
if addr, err := mail.ParseAddress(value); err == nil {
return addr.Name, addr.Address
}
begin, end := strings.LastIndex(value, "<"), strings.LastIndex(value, ">")
if begin == -1 || end < begin {
return value, ""
}
return strings.TrimSpace(value[:begin]), strings.TrimSpace(value[begin+1 : end])
}

View File

@@ -0,0 +1,151 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"testing"
"github.com/stretchr/testify/assert"
)
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 TestCommitMessageTrailer(t *testing.T) {
cases := []struct {
msg, body, sep, trailer string
}{
{"", "", "", ""},
{"a", "a", "", ""},
{"a\n\nk", "a\n\nk", "", ""},
{"a\n\nk:v", "a", "\n\n", "k:v"},
{"a\n\nk:v\n next-line", "a", "\n\n", "k:v\n next-line"},
{"a\n\nk:v\n next-line\nother: v", "a", "\n\n", "k:v\n next-line\nother: v"},
{"a\n\nk:v\n\n", "a", "\n\n", "k:v\n\n"},
{"a\n--\nk:v", "a\n--\nk:v", "", ""},
{"a\n---\nk:v", "a", "\n---\n", "k:v"}, // TODO: should we support such case? No empty line between "---" and the trailer
{"a\n\n---\n\nk:v", "a", "\n\n---\n\n", "k:v"},
{"k: v", "", "", "k: v"},
{"\nk:v", "", "\n", "k:v"},
{"\n\nk:v", "", "\n\n", "k:v"},
{"---\nk:v", "", "---\n", "k:v"},
{"\n---\nk:v", "", "\n---\n", "k:v"},
{"a:b\n---\nk:v", "a:b", "\n---\n", "k:v"},
}
for _, c := range cases {
body, sep, trailer := CommitMessageSplitTrailer(c.msg)
assert.Equal(t, c.body, body, "input=%q", c.msg)
assert.Equal(t, c.sep, sep, "input=%q", c.msg)
assert.Equal(t, c.trailer, trailer, "input=%q", c.msg)
}
}
func TestCommitMessageParticipants(t *testing.T) {
sig := func(n, e string) *Signature { return &Signature{Name: n, Email: e} }
idt := func(n, e string, r int) *CommitIdentity { return &CommitIdentity{n, e, r} }
roleAuthor, _, roleCoAuthor := commitIdentityRoleAuthor, commitIdentityRoleCommitter, commitIdentityRoleCoAuthor
type testCase struct {
name string
commit *Commit
identities []*CommitIdentity
}
t.Run("AllAuthors", func(t *testing.T) {
cases := []testCase{
{
"CommitterExcluded",
&Commit{
Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"),
CommitMessage: CommitMessage{MessageRaw: "CO-Authored-BY: Full Name <x@m.com>"},
},
[]*CommitIdentity{idt("a", "a@m.com", roleAuthor), idt("Full Name", "x@m.com", roleCoAuthor)},
},
{
"AuthorIsCoAuthor",
&Commit{
Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"),
CommitMessage: CommitMessage{MessageRaw: "CO-Authored-BY: other-name <a@m.com>"},
},
[]*CommitIdentity{idt("a", "a@m.com", roleAuthor)},
},
{
"EmptyAuthor", // synthesized commits (push feed) may have no author signature at all
&Commit{
Author: sig("", ""), Committer: sig("", ""),
CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: c <c@m.com>"},
},
// but if the commit message contains co-authors, the co-authors are still parsed for "all authors"
// if it is a problem, the caller should fix the problem (provide correct "author")
[]*CommitIdentity{idt("c", "c@m.com", roleCoAuthor)},
},
{
"CoAuthorIsCommitter",
&Commit{
Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"),
CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: c <c@m.com>"},
},
[]*CommitIdentity{idt("a", "a@m.com", roleAuthor), idt("c", "c@m.com", roleCoAuthor)},
},
{
"CoAuthorNameOnlyAndDuplicate",
&Commit{
Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"),
CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: b\nCo-authored-by: b\nCo-authored-by: c"},
},
[]*CommitIdentity{idt("a", "a@m.com", roleAuthor), idt("b", "", roleCoAuthor), idt("c", "", roleCoAuthor)},
},
{
"CoAuthorNameNotAnEmailAddress", // names net/mail rejects, e.g. bots and names with a comma
&Commit{
Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"),
CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>\nCo-authored-by: Smith, John <j@m.com>"},
},
[]*CommitIdentity{
idt("a", "a@m.com", roleAuthor),
idt("dependabot[bot]", "49699333+dependabot[bot]@users.noreply.github.com", roleCoAuthor),
idt("Smith, John", "j@m.com", roleCoAuthor),
},
},
}
for _, c := range cases {
assert.Equal(t, c.identities, c.commit.AllAuthorIdentities(), "case: %s", c.name)
}
})
}
func TestCommitMessageMerge(t *testing.T) {
cases := []struct {
m1, m2 string
out string
}{
{"", "", ""},
{"msg1", "", "msg1"},
{"", "msg2", "msg2"},
{"msg1", "msg2", "msg1\n\nmsg2"},
{"k1: a", "", "k1: a"},
{"", "k2: b", "k2: b"},
{"k1: a", "k2: b", "k1: a\nk2: b"},
{"msg1", "k2: b", "msg1\n\nk2: b"},
{"k1: a", "msg2", "msg2\n\nk1: a"},
{"msg1\n\nk1: a", "msg2", "msg1\n\nmsg2\n\nk1: a"},
{"msg1\n----\nk1: a", "msg2", "msg1\n\nmsg2\n----\nk1: a"},
{"msg1\n\n----\n\nk1: a", "msg2", "msg1\n\nmsg2\n\n----\n\nk1: a"},
{"msg1", "msg2\n----\nk2: b", "msg1\n\nmsg2\n----\nk2: b"},
{"msg1", "msg2\n\nk2: b", "msg1\n\nmsg2\n\nk2: b"},
{"msg1\n\nk1: a", "msg2\n\nk2: b", "msg1\n\nmsg2\n\nk1: a\nk2: b"},
}
for i, c := range cases {
out := CommitMessageMerge(c.m1, c.m2)
assert.Equal(t, c.out, out, "idx=%d, m1=%q m2=%q", i, c.m1, c.m2)
}
}

View File

@@ -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(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.TreeID = 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(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(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
}

View File

@@ -0,0 +1,128 @@
// 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) {
id, err := GetFullCommitID(t.Context(), mockRepository("repo1_bare_sha256"), "f004f4")
assert.NoError(t, err)
assert.Equal(t, "f004f41359117d319dedd0eaab8c5259ee2263da839dcba33637997458627fdc", id)
}
func TestGetFullCommitIDErrorSha256(t *testing.T) {
id, err := GetFullCommitID(t.Context(), mockRepository("repo1_bare_sha256"), "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 := OpenRepositoryLocal(t.Context(), filepath.Join(testReposDir, "repo1_bare_sha256"))
assert.NoError(t, err)
assert.NotNil(t, gitRepo)
defer gitRepo.Close()
commitFromReader, err := CommitFromReader(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(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 := OpenRepositoryLocal(t.Context(), bareRepo1Path)
assert.NoError(t, err)
defer repo.Close()
commit, err := repo.GetCommit(t.Context(), "f004f41359117d319dedd0eaab8c5259ee2263da839dcba33637997458627fdc")
assert.NoError(t, err)
objectFormat, err := repo.GetObjectFormat(t.Context())
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(t.Context(), repo, parentSHA)
assert.NoError(t, err)
assert.True(t, haz)
hazNot, err := commit.HasPreviousCommit(t.Context(), repo, notParentSHA)
assert.NoError(t, err)
assert.False(t, hazNot)
selfNot, err := commit.HasPreviousCommit(t.Context(), repo, commit.ID)
assert.NoError(t, err)
assert.False(t, selfNot)
}

View File

@@ -0,0 +1,54 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import "context"
type SubmoduleWebLink struct {
RepoWebLink, CommitWebLink string
}
// GetSubModules get all the submodules of current revision git tree
func (c *Commit) GetSubModules(ctx context.Context, gitRepo *Repository) (*ObjectCache[*SubModule], error) {
if c.submoduleCache != nil {
return c.submoduleCache, nil
}
entry, err := c.GetTreeEntryByPath(ctx, gitRepo, ".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(gitRepo).DataAsync(ctx)
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(ctx context.Context, gitRepo *Repository, entryName string) (*SubModule, error) {
modules, err := c.GetSubModules(ctx, gitRepo)
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
}

View File

@@ -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)
}

View File

@@ -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)
})
}

204
modules/git/commit_test.go Normal file
View File

@@ -0,0 +1,204 @@
// 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) {
id, err := GetFullCommitID(t.Context(), mockRepository("repo1_bare"), "8006ff9a")
assert.NoError(t, err)
assert.Equal(t, "8006ff9adbf0cb94da7dad9e537e53817f9fa5c0", id)
}
func TestGetFullCommitIDError(t *testing.T) {
id, err := GetFullCommitID(t.Context(), mockRepository("repo1_bare"), "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 := OpenRepositoryLocal(t.Context(), filepath.Join(testReposDir, "repo1_bare"))
assert.NoError(t, err)
assert.NotNil(t, gitRepo)
defer gitRepo.Close()
commitFromReader, err := CommitFromReader(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(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 := OpenRepositoryLocal(t.Context(), filepath.Join(testReposDir, "repo1_bare"))
assert.NoError(t, err)
assert.NotNil(t, gitRepo)
defer gitRepo.Close()
commitFromReader, err := CommitFromReader(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(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 TestHasPreviousCommit(t *testing.T) {
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
repo, err := OpenRepositoryLocal(t.Context(), bareRepo1Path)
assert.NoError(t, err)
defer repo.Close()
commit, err := repo.GetCommit(t.Context(), "8006ff9adbf0cb94da7dad9e537e53817f9fa5c0")
assert.NoError(t, err)
parentSHA := MustIDFromString("8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2")
notParentSHA := MustIDFromString("2839944139e0de9737a044f78b0e4b40d989a9e3")
haz, err := commit.HasPreviousCommit(t.Context(), repo, parentSHA)
assert.NoError(t, err)
assert.True(t, haz)
hazNot, err := commit.HasPreviousCommit(t.Context(), repo, notParentSHA)
assert.NoError(t, err)
assert.False(t, hazNot)
selfNot, err := commit.HasPreviousCommit(t.Context(), repo, commit.ID)
assert.NoError(t, err)
assert.False(t, selfNot)
}
func Test_GetCommitBranchStart(t *testing.T) {
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
repo, err := OpenRepositoryLocal(t.Context(), bareRepo1Path)
assert.NoError(t, err)
defer repo.Close()
commit, err := repo.GetBranchCommit(t.Context(), "branch1")
assert.NoError(t, err)
assert.Equal(t, "2839944139e0de9737a044f78b0e4b40d989a9e3", commit.ID.String())
startCommitID, err := repo.GetCommitBranchStart(t.Context(), os.Environ(), "branch1", commit.ID.String())
assert.NoError(t, err)
assert.NotEmpty(t, startCommitID)
assert.Equal(t, "95bb4d39648ee7e325106df01a621c530863a653", startCommitID)
}
func TestIsStringLikelyCommitID(t *testing.T) {
assert.True(t, IsStringLikelyCommitID(nil, "abc", 3))
assert.False(t, IsStringLikelyCommitID(nil, "abc", 4))
assert.True(t, IsStringLikelyCommitID(nil, strings.Repeat("a", 64), 4))
assert.False(t, IsStringLikelyCommitID(nil, strings.Repeat("a", 65), 4))
}

70
modules/git/compare.go Normal file
View File

@@ -0,0 +1,70 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"context"
"fmt"
"strconv"
"strings"
"gitea.dev/modules/git/gitcmd"
)
// DivergeObject represents commit count diverging commits
type DivergeObject struct {
Ahead int
Behind int
}
// GetDivergingCommits returns the number of commits a targetBranch is ahead or behind a baseBranch
func GetDivergingCommits(ctx context.Context, repo RepositoryFacade, baseBranch, targetBranch string) (*DivergeObject, error) {
cmd := gitcmd.NewCommand("rev-list", "--count", "--left-right").
AddDynamicArguments(baseBranch + "..." + targetBranch).AddArguments("--")
stdout, _, err1 := cmd.WithRepo(repo).RunStdString(ctx)
if err1 != nil {
return nil, err1
}
left, right, found := strings.Cut(strings.Trim(stdout, "\n"), "\t")
if !found {
return nil, fmt.Errorf("git rev-list output is missing a tab: %q", stdout)
}
behind, err := strconv.Atoi(left)
if err != nil {
return nil, err
}
ahead, err := strconv.Atoi(right)
if err != nil {
return nil, err
}
return &DivergeObject{Ahead: ahead, Behind: behind}, nil
}
// GetCommitIDsBetweenReverse returns the last commit IDs between two commits in reverse order (from old to new) with limit.
// If the result exceeds the limit, the old commits IDs will be ignored
func GetCommitIDsBetweenReverse(ctx context.Context, repo RepositoryFacade, startRef, endRef, notRef string, limit int) ([]string, error) {
genCmd := func(reversions ...string) *gitcmd.Command {
cmd := gitcmd.NewCommand("rev-list", "--reverse").
AddArguments("-n").AddDynamicArguments(strconv.Itoa(limit)).
AddDynamicArguments(reversions...)
if notRef != "" { // --not should be kept as the last parameter of git command, otherwise the result will be wrong
cmd.AddOptionValues("--not", notRef)
}
return cmd
}
stdout, _, err := genCmd(startRef + ".." + endRef).WithRepo(repo).RunStdString(ctx)
if gitcmd.IsStderr(err, gitcmd.StderrNoMergeBase) {
// if the start and end are not related (no merge base), just get all commits pushed by "end ref"
// previously it would return the results of git rev-list before last so let's try that...
stdout, _, err = genCmd(endRef).WithRepo(repo).RunStdString(ctx)
}
if err != nil {
return nil, err
}
commitIDs := strings.Fields(strings.TrimSpace(stdout))
return commitIDs, nil
}

138
modules/git/compare_test.go Normal file
View File

@@ -0,0 +1,138 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"path/filepath"
"strings"
"testing"
"gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMergeBaseNoCommonHistory(t *testing.T) {
repoDir := filepath.Join(t.TempDir(), "repo.git")
require.NoError(t, gitcmd.NewCommand("init").AddDynamicArguments(repoDir).Run(t.Context()))
_, _, runErr := gitcmd.NewCommand("fast-import").WithDir(repoDir).WithStdinBytes([]byte(strings.TrimSpace(`
commit refs/heads/branch1
committer User <user@example.com> 1714310400 +0000
data 12
First commit
M 100644 inline file1.txt
data 12
Hello from 1
commit refs/heads/branch2
committer User <user@example.com> 1714310400 +0000
data 13
Second commit
M 100644 inline file2.txt
data 12
Hello from 2
`))).RunStdString(t.Context())
require.NoError(t, runErr)
mergeBase, err := MergeBase(t.Context(), mockRepository(repoDir), "branch1", "branch2")
assert.Empty(t, mergeBase)
assert.ErrorIs(t, err, util.ErrNotExist)
}
func TestRepoGetDivergingCommits(t *testing.T) {
repo := mockRepository("repo1_bare")
do, err := GetDivergingCommits(t.Context(), repo, "master", "branch2")
assert.NoError(t, err)
assert.Equal(t, &DivergeObject{
Ahead: 1,
Behind: 5,
}, do)
do, err = GetDivergingCommits(t.Context(), repo, "master", "master")
assert.NoError(t, err)
assert.Equal(t, &DivergeObject{
Ahead: 0,
Behind: 0,
}, do)
do, err = GetDivergingCommits(t.Context(), repo, "master", "test")
assert.NoError(t, err)
assert.Equal(t, &DivergeObject{
Ahead: 0,
Behind: 2,
}, do)
}
func TestGetCommitIDsBetweenReverse(t *testing.T) {
repo := mockRepository("repo1_bare")
// tests raw commit IDs
commitIDs, err := GetCommitIDsBetweenReverse(t.Context(), repo,
"8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2",
"ce064814f4a0d337b333e646ece456cd39fab612",
"",
100,
)
assert.NoError(t, err)
assert.Equal(t, []string{
"8006ff9adbf0cb94da7dad9e537e53817f9fa5c0",
"6fbd69e9823458e6c4a2fc5c0f6bc022b2f2acd1",
"37991dec2c8e592043f47155ce4808d4580f9123",
"feaf4ba6bc635fec442f46ddd4512416ec43c2c2",
"ce064814f4a0d337b333e646ece456cd39fab612",
}, commitIDs)
commitIDs, err = GetCommitIDsBetweenReverse(t.Context(), repo,
"8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2",
"ce064814f4a0d337b333e646ece456cd39fab612",
"6fbd69e9823458e6c4a2fc5c0f6bc022b2f2acd1",
100,
)
assert.NoError(t, err)
assert.Equal(t, []string{
"37991dec2c8e592043f47155ce4808d4580f9123",
"feaf4ba6bc635fec442f46ddd4512416ec43c2c2",
"ce064814f4a0d337b333e646ece456cd39fab612",
}, commitIDs)
commitIDs, err = GetCommitIDsBetweenReverse(t.Context(), repo,
"8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2",
"ce064814f4a0d337b333e646ece456cd39fab612",
"",
3,
)
assert.NoError(t, err)
assert.Equal(t, []string{
"37991dec2c8e592043f47155ce4808d4580f9123",
"feaf4ba6bc635fec442f46ddd4512416ec43c2c2",
"ce064814f4a0d337b333e646ece456cd39fab612",
}, commitIDs)
// test branch names instead of raw commit IDs.
commitIDs, err = GetCommitIDsBetweenReverse(t.Context(), repo,
"test",
"master",
"",
100,
)
assert.NoError(t, err)
assert.Equal(t, []string{
"feaf4ba6bc635fec442f46ddd4512416ec43c2c2",
"ce064814f4a0d337b333e646ece456cd39fab612",
}, commitIDs)
// add notref to exclude test
commitIDs, err = GetCommitIDsBetweenReverse(t.Context(), repo,
"test",
"master",
"test",
100,
)
assert.NoError(t, err)
assert.Equal(t, []string{
"feaf4ba6bc635fec442f46ddd4512416ec43c2c2",
"ce064814f4a0d337b333e646ece456cd39fab612",
}, commitIDs)
}

205
modules/git/config.go Normal file
View File

@@ -0,0 +1,205 @@
// 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)
}
// 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 err := configSet(ctx, "receive.advertisePushOptions", "true"); err != nil {
return err
}
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
}
}
if !setting.Git.DisablePartialClone {
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")
}
if err != nil {
return err
}
// Apply user's git config options last so they take precedence over builtin defaults
for k, v := range setting.GitConfig.Options {
if err = configSet(ctx, strings.ToLower(k), v); err != nil {
return err
}
}
GlobalConfig = &GlobalConfigStruct{}
// HINT: GIT-DIFF-TREE-UI-CONFIG: Git's bug: git-diff-tree loads config with /* no "diff" UI options */ (since 20 years ago).
// https://github.com/git/git/blame/5d2e7709234afea1b6ddb25cd4f60d3d5fb3c200/builtin/diff-tree.c#L127
// Although document and manual say that "git-diff-tree" supports "diff.orderfile" option, but it is not actually supported.
// So we need to apply the diff.orderfile explicitly in our code.
GlobalConfig.DiffOrderFile, _ = configGet(ctx, "diff.orderfile")
return nil
}
func configGet(ctx context.Context, key string) (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)
}
return strings.TrimRight(stdout, "\r\n"), nil
}
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)
}

View File

@@ -0,0 +1,30 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"context"
"gitea.dev/modules/git/gitcmd"
)
// ManagedConfigAdd add a git configuration key to a specific value for the given repository.
func ManagedConfigAdd(ctx context.Context, repo RepositoryFacade, key, value string) error {
return LockConfigAndDo(ctx, repo, func(ctx context.Context) error {
_, _, err := gitcmd.NewCommand("config", "--add").
AddDynamicArguments(key, value).WithRepo(repo).RunStdString(ctx)
return err
})
}
// ManagedConfigSet updates a git configuration key to a specific value for the given repository.
// If the key does not exist, it will be created.
// If the key exists, it will be updated to the new value.
func ManagedConfigSet(ctx context.Context, repo RepositoryFacade, key, value string) error {
return LockConfigAndDo(ctx, repo, func(ctx context.Context) error {
_, _, err := gitcmd.NewCommand("config").
AddDynamicArguments(key, value).WithRepo(repo).RunStdString(ctx)
return err
})
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -0,0 +1,73 @@
// 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"
"gitea.dev/modules/test"
"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 TestSyncGitConfig(t *testing.T) {
defer test.MockVariableValue(&setting.GitConfig)()
assert.Empty(t, setting.GitConfig.Options)
assert.NoError(t, syncGitConfig(t.Context()))
assert.True(t, gitConfigContains("commitGraph = true")) // builtin default config
setting.GitConfig.Options["sync-test.cfg-key-a"] = "CfgValA"
setting.GitConfig.Options["core.commitgraph"] = "false"
assert.NoError(t, syncGitConfig(t.Context()))
assert.True(t, gitConfigContains("[sync-test]"))
assert.True(t, gitConfigContains("cfg-key-a = CfgValA"))
assert.False(t, gitConfigContains("commitGraph")) // builtin default config can be overridden
assert.True(t, gitConfigContains("commitgraph = false")) // git config key is case-insensitive
}

348
modules/git/diff.go Normal file
View File

@@ -0,0 +1,348 @@
// 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(ctx context.Context, repo *Repository, commitID string, diffType RawDiffType, writer io.Writer) (retErr error) {
cmd, err := getRepoRawDiffForFileCmd(ctx, repo, "", commitID, diffType, "")
if err != nil {
return fmt.Errorf("getRepoRawDiffForFileCmd: %w", err)
}
return cmd.WithStdoutCopy(writer).RunWithStderr(ctx)
}
// GetFileDiffCutAroundLine cuts the old or new part of the diff of a file around a specific line number
func GetFileDiffCutAroundLine(
ctx context.Context, repo *Repository, startCommit, endCommit, treePath string,
line int64, old bool, numbersOfLine int,
) (ret string, retErr error) {
cmd, err := getRepoRawDiffForFileCmd(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(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(ctx context.Context, repo *Repository, startCommit, endCommit string, diffType RawDiffType, file string) (*gitcmd.Command, error) {
commit, err := repo.GetCommit(ctx, endCommit)
if err != nil {
return nil, err
}
var files []string
if len(file) > 0 {
files = append(files, file)
}
cmd := gitcmd.NewCommand().WithRepo(repo)
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(ctx, repo, 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(ctx, repo, 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, "+++")))
}
func NewGitDiffScanner(r io.Reader) *bufio.Scanner {
// TODO: GIT-DIFF-PARSE-LONG-LINE: ideally it shouldn't use bufio.Scanner which has a limit.
// It will cause errors if a line is very long.
scanner := bufio.NewScanner(r)
scanner.Buffer(nil, max(512*1024, int(setting.UI.MaxDisplayFileSize/16)))
return scanner
}
// 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 := NewGitDiffScanner(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(ctx context.Context, repo *Repository, branchName, oldCommitID, newCommitID string, env []string) ([]string, error) {
if oldCommitID == emptySha1ObjectID.String() || oldCommitID == emptySha256ObjectID.String() {
startCommitID, err := repo.GetCommitBranchStart(ctx, 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).WithRepo(repo).
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(ctx)
if err != nil {
log.Error("Unable to get affected files for commits from %s to %s in %s: %v", oldCommitID, newCommitID, repo.LogString(), err)
}
return affectedFiles, err
}
// GetReverseRawDiff dumps the reverse diff results of repository in given commit ID to io.Writer.
func GetReverseRawDiff(ctx context.Context, repo RepositoryFacade, commitID string, writer io.Writer) error {
return gitcmd.NewCommand("show", "--pretty=format:revert %H%n", "-R").
AddDynamicArguments(commitID).
WithStdoutCopy(writer).
WithRepo(repo).RunWithStderr(ctx)
}

184
modules/git/diff_test.go Normal file
View File

@@ -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)
}

126
modules/git/error.go Normal file
View File

@@ -0,0 +1,126 @@
// 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())
}

67
modules/git/fastimport.go Normal file
View File

@@ -0,0 +1,67 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"bytes"
"context"
"fmt"
"os"
"gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/git/gitrepo"
"gitea.dev/modules/util"
)
type FastImportInit struct {
Bare bool
ObjectFormat string
}
type FastImportFile struct {
Mode EntryMode
Path string
Content string
}
type FastImportCommit struct {
Ref string
Message string
Files []FastImportFile
}
// ForceFastImportWithInit is for mainly for testing purpose
func ForceFastImportWithInit(ctx context.Context, repoLocalPath string, commits []FastImportCommit, initOpts ...FastImportInit) (RepositoryFacade, error) {
repo := gitrepo.RepositoryUnmanaged(repoLocalPath)
initOpt := util.OptionalArg(initOpts, FastImportInit{Bare: true})
dirEntries, err := os.ReadDir(repoLocalPath)
if os.IsNotExist(err) || (err == nil && len(dirEntries) == 0) {
_ = os.MkdirAll(repoLocalPath, 0o755)
err := InitRepositoryLocal(ctx, repoLocalPath, initOpt.Bare, util.IfZero(initOpt.ObjectFormat, "sha1"))
if err != nil {
return nil, err
}
}
return repo, ForceFastImport(ctx, repo, commits)
}
// ForceFastImport is for mainly for testing purpose
func ForceFastImport(ctx context.Context, repo RepositoryFacade, commits []FastImportCommit) error {
var buf bytes.Buffer
for i, c := range commits {
msg := util.IfZero(c.Message, fmt.Sprintf("commit %d", i+1))
_, _ = fmt.Fprintf(&buf, "reset %s\n", c.Ref)
_, _ = fmt.Fprintf(&buf, "commit %s\nmark :%d\ncommitter Gitea <gitea@example.com> 1500000000 +0000\n", c.Ref, i+1)
_, _ = fmt.Fprintf(&buf, "data %d\n%s\n", len(msg), msg)
for _, f := range c.Files {
mode := util.IfZero(f.Mode, EntryModeBlob)
_, _ = fmt.Fprintf(&buf, "M %s inline %s\ndata %d\n%s\n", mode.String(), f.Path, len(f.Content), f.Content)
}
}
buf.WriteString("done\n")
_, _, err := gitcmd.NewCommand("fast-import").AddArguments("--force", "--done").
WithRepo(repo).WithStdinBytes(buf.Bytes()).
RunStdString(ctx)
return err
}

29
modules/git/fetch.go Normal file
View File

@@ -0,0 +1,29 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"context"
"gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/git/gitrepo"
)
// FetchRemoteCommit fetches a specific commit and its related objects from a remote
// repository into the managed repository.
//
// If no reference (branch, tag, or other ref) points to the fetched commit, it will
// be treated as unreachable and cleaned up by `git gc` after the default prune
// expiration period (2 weeks). Ref: https://www.kernel.org/pub/software/scm/git/docs/git-gc.html
//
// This behavior is sufficient for temporary operations, such as determining the
// merge base between commits.
func FetchRemoteCommit(ctx context.Context, repo, remoteRepo RepositoryFacade, commitID string) error {
return LockWriteAndDo(ctx, repo, func(ctx context.Context) error {
return gitcmd.NewCommand("fetch", "--no-tags").
AddDynamicArguments(gitrepo.RepoLocalPath(remoteRepo)).
AddDynamicArguments(commitID).
WithRepo(repo).Run(ctx)
})
}

View File

@@ -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 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()
}

View File

@@ -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)
})
}
}

View File

@@ -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, len(p.format.fieldNames))
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
}

View File

@@ -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)
}

16
modules/git/fsck.go Normal file
View File

@@ -0,0 +1,16 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"context"
"time"
"gitea.dev/modules/git/gitcmd"
)
// Fsck verifies the connectivity and validity of the objects in the database
func Fsck(ctx context.Context, repo RepositoryFacade, timeout time.Duration, args gitcmd.TrustedCmdArgs) error {
return gitcmd.NewCommand("fsck").AddArguments(args...).WithTimeout(timeout).WithRepo(repo).Run(ctx)
}

215
modules/git/git.go Normal file
View File

@@ -0,0 +1,215 @@
// 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/cache"
"gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/globallock"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/tempdir"
"gitea.dev/modules/testlogger"
"github.com/hashicorp/go-version"
)
const RequiredVersion = "2.25.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"
}
type GlobalConfigStruct struct {
DiffOrderFile string
}
var (
defaultFeatures *Features
GlobalConfig *GlobalConfigStruct
)
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
}
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 {
_ = cache.Init()
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()
}
func LockConfigAndDo(ctx context.Context, repo RepositoryFacade, fn func(ctx context.Context) error) error {
return globallock.LockAndDo(ctx, "repo-config:"+repo.GitRepoManagedID(), fn)
}
func LockWriteAndDo(ctx context.Context, repo RepositoryFacade, fn func(ctx context.Context) error) error {
return globallock.LockAndDo(ctx, "repo-write:"+repo.GitRepoManagedID(), fn)
}

37
modules/git/git_test.go Normal file
View File

@@ -0,0 +1,37 @@
// 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 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"))))
}

View File

@@ -0,0 +1,582 @@
// 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"
"path/filepath"
"strings"
"time"
"gitea.dev/modules/git/gitrepo"
"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
// Dir is the working dir for the git command, however:
// FIXME: GIT-DIR-ARGUMENT: 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 or "GIT_DIR=..." environment variable.
// Actually, when working with a bare repo, the current directory should not be the git dir,
// otherwise some git commands might overwrite git dir internal files by a repo file.
gitDir string
cmd *process.Cmd
cmdCtx context.Context
cmdCancel process.CancelCauseFunc
cmdFinished process.FinishedFunc
cmdStartTime time.Time
pipelineFunc func(Context) error
parentPipeFiles []*os.File
parentPipeReaders []*os.File
childrenPipeFiles []*os.File
cmdEnv []string
cmdTimeout time.Duration
// 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
}
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.gitDir = dir
return c
}
func (c *Command) WithRepo(repo gitrepo.RepositoryFacade) *Command {
c.gitDir = gitrepo.RepoLocalPath(repo)
return c
}
func (c *Command) WithEnv(env []string) *Command {
c.cmdEnv = env
return c
}
func (c *Command) WithTimeout(timeout time.Duration) *Command {
c.cmdTimeout = 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.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*/
c.callerInfo = util.CallerFuncName(skip)
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.gitDir), 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.cmdTimeout <= 0 {
c.cmdCtx, c.cmdCancel, c.cmdFinished = process.GetManager().AddContext(ctx, desc)
} else {
c.cmdCtx, c.cmdCancel, c.cmdFinished = process.GetManager().AddContextTimeout(ctx, c.cmdTimeout, desc)
}
c.cmdStartTime = time.Now()
c.cmd = process.CommandContext(c.cmdCtx, c.prog, append(c.configArgs, c.args...)...)
if c.cmdEnv == nil {
c.cmd.Env = os.Environ()
} else {
c.cmd.Env = c.cmdEnv
}
c.cmd.Env = append(c.cmd.Env, CommonGitCmdEnvs()...)
c.cmd.Dir = c.gitDir
c.cmd.Stdout = c.cmdStdout
c.cmd.Stdin = c.cmdStdin
c.cmd.Stderr = c.cmdStderr
c.cmd.WithOnCancelGracefully(func() error {
// Need to close the pipes to notify all sub processes to exit.
// Especially on Windows: there is no process group, and we didn't implement process job object (like process group).
c.closePipeFiles(c.parentPipeFiles)
return nil
})
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.pipelineFunc != nil {
errPipeline := c.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.WithParentCallerInfo().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 NewRunStdError(errWait, util.UnsafeBytesToString(c.cmdManagedStderr.Bytes()))
}
func (c *Command) RunWithStderr(ctx context.Context) RunStdError {
if err := c.WithParentCallerInfo().StartWithStderr(ctx); err != nil {
return &runStdError{err: err}
}
return c.WaitWithStderr()
}
func (c *Command) Run(ctx context.Context) (err error) {
if err = c.WithParentCallerInfo().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.WithStdoutBuffer(stdoutBuf).RunWithStderr(ctx)
return stdoutBuf.Bytes(), c.cmdManagedStderr.Bytes(), err
}
func (c *Command) DebugKill() {
_ = c.cmd.Process.Kill()
}

View File

@@ -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)
})
}

View File

@@ -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
}

40
modules/git/gitcmd/env.go Normal file
View File

@@ -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
}

166
modules/git/gitcmd/error.go Normal file
View File

@@ -0,0 +1,166 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitcmd
import (
"context"
"errors"
"fmt"
"os/exec"
"strings"
"gitea.dev/modules/regexplru"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
)
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 NewRunStdError(err error, stderr string) RunStdError {
return &runStdError{err: err, stderr: util.NormalizeStringEOL(stderr)}
}
func ErrorAsStderr(err error) (string, bool) {
if runErr, ok := errors.AsType[RunStdError](err); ok {
return runErr.Stderr(), true
}
return "", false
}
func IsErrorExitCode(err error, code int) bool {
if exitError, ok := errors.AsType[*exec.ExitError](err); ok {
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)
}
type StderrCheck interface {
internalOnly()
}
type (
StderrPrefix string
StderrRegexp string
)
func (StderrPrefix) internalOnly() {}
func (StderrRegexp) internalOnly() {}
const (
StderrNotValidObjectName StderrPrefix = "fatal: not a valid object name"
StderrNotTreeObject StderrPrefix = "fatal: not a tree object"
StderrPathSpec StderrPrefix = "fatal: pathspec"
StderrBadRevision StderrPrefix = "fatal: bad revision"
StderrNoSuchPath StderrPrefix = "fatal: no such path"
StderrNoSuchRemote1 StderrPrefix = "fatal: no such remote" // git < 2.30, exit status 128
StderrNoSuchRemote2 StderrPrefix = "error: no such remote" // git >= 2.30. exit status 2
StderrAuthenticationFailed StderrPrefix = "fatal: Authentication failed for"
StderrCouldNotReadUsername StderrPrefix = "fatal: could not read Username"
StderrUnknownRevisionOrPath StderrRegexp = "^fatal: .*: unknown revision or path not in the working tree"
StderrNoMergeBase StderrRegexp = "^fatal: .*: no merge base"
StderrFileNoEnoughLines StderrRegexp = `^fatal: file .* has only \d+ lines?`
)
func matchStderrCheck(stderr string, checkIntf StderrCheck) (match bool) {
switch check := any(checkIntf).(type) {
case StderrPrefix:
checkLen := len(check)
if len(stderr) >= checkLen {
// 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
match = util.AsciiEqualFold(stderr[:checkLen], string(check))
}
case StderrRegexp:
re, err := regexplru.SystemCache().GetCompiled(string(check))
if err != nil {
setting.PanicInDevOrTesting("invalid stderr regexp %s", check)
} else {
match = re.MatchString(stderr)
}
default:
setting.PanicInDevOrTesting("invalid stderr type %T", checkIntf)
}
return match
}
func IsStderr(err error, checks ...StderrCheck) bool {
stderr, ok := ErrorAsStderr(err)
if !ok {
return false
}
for line := range strings.SplitSeq(stderr, "\n") { // git can emit multiple-line message in stderr
for _, checkIntf := range checks {
if matchStderrCheck(line, checkIntf) {
return true
}
}
}
return false
}
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
if pe, ok := errors.AsType[pipelineError](err); ok {
return pe.error, true
}
return nil, false
}

View File

@@ -0,0 +1,26 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitcmd
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestIsStderr(t *testing.T) {
cases := []struct {
check StderrCheck
stderr string
}{
{StderrUnknownRevisionOrPath, "fatal: ambiguous argument 'origin': unknown revision or path not in the working tree...."},
{StderrNoMergeBase, "fatal: origin/main..HEAD: no merge base...."},
{StderrFileNoEnoughLines, "fatal: file foo/bar has only 1 line"},
{StderrAuthenticationFailed, "Cloning into 'repo'...\r\nremote: Invalid username or token.\r\nfatal: Authentication failed for 'https://host/repo.git/'\r\n"},
}
for _, tc := range cases {
assert.True(t, IsStderr(&runStdError{stderr: tc.stderr}, tc.check), "stderr: %s", tc.stderr)
}
assert.False(t, IsStderr(&runStdError{stderr: "remote: fatal: Authentication failed for 'https://host/repo.git/'\n"}, StderrAuthenticationFailed))
}

View File

@@ -0,0 +1,88 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitcmd
import (
"io"
"os"
)
type PipeBufferReader interface {
Read(p []byte) (n int, err error)
Bytes() []byte
}
type PipeBufferWriter interface {
Write(p []byte) (n int, err error)
Bytes() []byte
}
// PipeReader 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.
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
}

51
modules/git/gitrepo.go Normal file
View File

@@ -0,0 +1,51 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"context"
"io"
"gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/reqctx"
"gitea.dev/modules/util"
)
// contextKey is a value for use with context.WithValue.
type contextKey struct {
key string
}
// RepositoryFromContextOrOpen attempts to get the repository from the context or just opens it
// The caller must call Closer.Close()
func RepositoryFromContextOrOpen(ctx context.Context, repo RepositoryFacade) (*Repository, io.Closer, error) {
reqCtx := reqctx.FromContext(ctx)
if reqCtx != nil {
gitRepo, err := RepositoryFromRequestContextOrOpen(reqCtx, repo)
return gitRepo, util.NopCloser{}, err
}
gitRepo, err := OpenRepository(ctx, repo)
return gitRepo, gitRepo, err
}
// RepositoryFromRequestContextOrOpen opens the repository at the given relative path in the provided request context.
// Caller shouldn't close the git repo manually, the git repo will be automatically closed when the request context is done.
func RepositoryFromRequestContextOrOpen(ctx reqctx.RequestContext, repo RepositoryFacade) (*Repository, error) {
ck := contextKey{key: repo.GitRepoLocation()}
if gitRepo, ok := ctx.Value(ck).(*Repository); ok {
return gitRepo, nil
}
gitRepo, err := OpenRepository(ctx, repo)
if err != nil {
return nil, err
}
ctx.AddCloser(gitRepo)
ctx.SetContextValue(ck, gitRepo)
return gitRepo, nil
}
func UpdateServerInfo(ctx context.Context, repo RepositoryFacade) error {
_, _, err := gitcmd.NewCommand("update-server-info").WithRepo(repo).RunStdBytes(ctx)
return err
}

View File

@@ -0,0 +1,28 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitrepo
import (
"strings"
"gitea.dev/modules/util"
)
func RepoCodeGitRepoRelativePath(ownerName, repoName string) string {
return util.PathJoinRelX(strings.ToLower(ownerName), strings.ToLower(repoName)+".git")
}
func RepoWikiGitRepoRelativePath(ownerName, repoName string) string {
return util.PathJoinRelX(strings.ToLower(ownerName), strings.ToLower(repoName)+".wiki.git")
}
// CodeRepoByName returns an unmanaged repository facade for the code repository of the given owner and repository name.
// Usually it is used for migration fixes or repository adoption/creation/rename/transfer.
func CodeRepoByName(ownerName, repoName string) RepositoryFacade {
return RepositoryUnmanaged(RepoCodeGitRepoRelativePath(ownerName, repoName))
}
func WikiRepoByName(ownerName, repoName string) RepositoryFacade {
return RepositoryUnmanaged(RepoWikiGitRepoRelativePath(ownerName, repoName))
}

View File

@@ -0,0 +1,116 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitrepo
import (
"io/fs"
"os"
"path/filepath"
"strings"
"sync/atomic"
"gitea.dev/modules/setting"
)
type RepositoryFacade interface {
// GitRepoManagedID returns a "managed id", which should be a cache-key-friendly string.
// e.g.: ID with prefix&suffix or UUID
GitRepoManagedID() string
// GitRepoLocation returns the location for the git repository.
// * relative path: will be converted to an absolute path by setting.RepoRootPath
// * absolute path: will be used as-is
// * in the future: maybe URI for more flexible definitions
GitRepoLocation() string
LogString() string
}
// RepoLocalPath returns an absolute path for a RepositoryFacade.
// TODO: most of the calls to this function should be replaced with a "Repo FS" in the future
// to handle file accesses in the git repo (e.g.: read, write, list, remove).
func RepoLocalPath(repo RepositoryFacade) string {
repoLoc := repo.GitRepoLocation()
if filepath.IsAbs(repoLoc) {
return repoLoc
}
if setting.RepoRootPath == "" {
panic("repo root path is not initialized")
}
// the repo root path and the repo loc should all have been cleaned, so we can safely join them together
return setting.RepoRootPath + string(filepath.Separator) + filepath.FromSlash(repoLoc)
}
func UserLocalPath(userName string) string {
if setting.RepoRootPath == "" {
panic("repo root path is not initialized")
}
return filepath.Join(setting.RepoRootPath, filepath.Clean(strings.ToLower(userName)))
}
func repoLogNameByLocation(loc string) string {
t := filepath.FromSlash(loc)
// hide the parent paths, then the name should be safe for end users
return ".../" + filepath.Base(filepath.Dir(t)) + "/" + filepath.Base(t)
}
type repositoryUnmanaged struct {
loc string
logName atomic.Pointer[string]
}
func (r *repositoryUnmanaged) LogString() string {
s := r.logName.Load()
if s == nil {
s = new(repoLogNameByLocation(r.loc))
r.logName.Store(s)
}
return *s
}
func (r *repositoryUnmanaged) GitRepoManagedID() string {
panic("this repo is not managed by Gitea, can't be used in this managed context")
}
func (r *repositoryUnmanaged) GitRepoLocation() string {
return r.loc
}
// RepositoryUnmanaged returns a RepositoryFacade for a repository that might not be managed by Gitea.
// If the path is not absolute, then it is relative to setting.RepoRootPath
// This function is mainly for maintaining the owner's repo when the repo is not managed yet.
// e.g.: init, clone, transfer, rename, adopt, etc., and temp repo creation and modification.
func RepositoryUnmanaged(s string) RepositoryFacade {
return &repositoryUnmanaged{loc: filepath.Clean(s)}
}
type repositoryManaged struct {
id, loc string
logName atomic.Pointer[string]
}
func (r *repositoryManaged) LogString() string {
s := r.logName.Load()
if s == nil {
s = new(repoLogNameByLocation(r.loc))
r.logName.Store(s)
}
return *s
}
func (r *repositoryManaged) GitRepoManagedID() string {
return r.id
}
func (r *repositoryManaged) GitRepoLocation() string {
return r.loc
}
func RepositoryManaged(id, loc string) RepositoryFacade {
return &repositoryManaged{id: id, loc: filepath.Clean(loc)}
}
func RepoLocalFS(repo RepositoryFacade) fs.FS {
return os.DirFS(RepoLocalPath(repo))
}

78
modules/git/gpg.go Normal file
View File

@@ -0,0 +1,78 @@
// 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/atomic"
"gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/process"
"gitea.dev/modules/util"
)
type CommitSignSettings struct {
Sign bool
Email string
Name string
Format string // default to GPG
KeyID string // GPG key id or SSH key file
cachedPublicKeyContent atomic.Pointer[string]
}
func (css *CommitSignSettings) PublicKeyContent(ctx context.Context) (string, error) {
cached := css.cachedPublicKeyContent.Load()
if cached != nil {
return *cached, nil
}
if css.Format == SigningKeyFormatSSH {
content, err := os.ReadFile(css.KeyID)
if err != nil {
return "", fmt.Errorf("unable to read SSH public key file: %s, %w", css.KeyID, err)
}
s := string(content)
css.cachedPublicKeyContent.Store(&s)
return s, nil
}
content, stderr, err := process.CommandContext(ctx, "gpg", "-a", "--export", css.KeyID).OutputString()
if err != nil {
return "", fmt.Errorf("unable to get default signing key: %s, %s, %w", css.KeyID, stderr, err)
}
css.cachedPublicKeyContent.Store(&content)
return content, nil
}
var GlobalCommitSignSettings = util.OnceValue[*CommitSignSettings]{
Func: func() *CommitSignSettings {
ctx := context.Background()
css := &CommitSignSettings{}
// all errors are ignored because the keys might not exist
// "--type=bool" resolves a valueless "commit.gpgsign" to true
value, _, _ := gitcmd.NewCommand("config", "--global", "--default", "false", "--type=bool", "--get", "commit.gpgsign").RunStdString(ctx)
css.Sign = strings.TrimSpace(value) == "true"
signingKey, _, _ := gitcmd.NewCommand("config", "--global", "--get", "user.signingkey").RunStdString(ctx)
css.KeyID = strings.TrimSpace(signingKey)
css.Sign = css.Sign && css.KeyID != ""
format, _, _ := gitcmd.NewCommand("config", "--global", "--default", SigningKeyFormatOpenPGP, "--get", "gpg.format").RunStdString(ctx)
css.Format = strings.TrimSpace(format)
defaultEmail, _, _ := gitcmd.NewCommand("config", "--global", "--get", "user.email").RunStdString(ctx)
css.Email = strings.TrimSpace(defaultEmail)
defaultName, _, _ := gitcmd.NewCommand("config", "--global", "--get", "user.name").RunStdString(ctx)
css.Name = strings.TrimSpace(defaultName)
return css
},
}

33
modules/git/gpg_test.go Normal file
View File

@@ -0,0 +1,33 @@
// Copyright 2026 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 TestGlobalCommitSignSettings(t *testing.T) {
defer test.MockVariableValue(&setting.Git.HomePath)()
defer GlobalCommitSignSettings.Reset()
signWithGitConfig := func(gitConfig string) bool {
setting.Git.HomePath = t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(setting.Git.HomePath, ".gitconfig"), []byte(gitConfig), 0o600))
GlobalCommitSignSettings.Reset()
return GlobalCommitSignSettings.Value().Sign
}
assert.False(t, signWithGitConfig("[user]\n\tsigningkey = KEY\n"), "must not sign when commit.gpgsign is unset")
assert.True(t, signWithGitConfig("[user]\n\tsigningkey = KEY\n[commit]\n\tgpgsign = true\n"))
assert.True(t, signWithGitConfig("[user]\n\tsigningkey = KEY\n[commit]\n\tgpgsign\n"), "git reads a valueless commit.gpgsign as true")
assert.False(t, signWithGitConfig("[user]\n\tsigningkey = KEY\n[commit]\n\tgpgsign = nonsense\n"), "must not sign when git cannot parse commit.gpgsign")
}

140
modules/git/grep.go Normal file
View File

@@ -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.WithRepo(repo).
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
}

86
modules/git/grep_test.go Normal file
View File

@@ -0,0 +1,86 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"path/filepath"
"testing"
"gitea.dev/modules/git/gitrepo"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"github.com/stretchr/testify/assert"
)
func TestGrepSearch(t *testing.T) {
defer test.MockVariableValue(&setting.RepoRootPath, t.TempDir())()
repo, err := OpenRepositoryLocal(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)
nonExistingRepo := &Repository{RepositoryBase: RepositoryBase{repoFacade: gitrepo.RepositoryUnmanaged("no-such-git-repo")}}
res, err = GrepSearch(t.Context(), nonExistingRepo, "no-such-content", GrepOptions{})
assert.Error(t, err)
assert.Empty(t, res)
}

113
modules/git/hook.go Normal file
View File

@@ -0,0 +1,113 @@
// 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/git/gitrepo"
"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(repo RepositoryFacade, name string) (*Hook, error) {
repoPath := gitrepo.RepoLocalPath(repo)
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 {
// empty content means to remove the file
err := util.RemoveWithRetry(h.path)
if err != nil && !os.IsNotExist(err) {
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(repo RepositoryFacade) (_ []*Hook, err error) {
exist, err := util.IsDir(filepath.Join(gitrepo.RepoLocalPath(repo), "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(repo, name)
if err != nil {
return nil, err
}
}
return hooks, nil
}

245
modules/git/hooks.go Normal file
View File

@@ -0,0 +1,245 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"context"
"fmt"
"os"
"path/filepath"
"runtime"
"gitea.dev/modules/git/gitrepo"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
)
func getHookTemplates() (hookNames, hookTpls, giteaHookTpls []string) {
hookNames = []string{"pre-receive", "update", "post-receive"}
hookTpls = []string{
// for pre-receive
fmt.Sprintf(`#!/usr/bin/env %s
# AUTO GENERATED BY GITEA, DO NOT MODIFY
data=$(cat)
exitcodes=""
hookname=$(basename $0)
GIT_DIR=${GIT_DIR:-$(dirname $0)/..}
for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
test -x "${hook}" && test -f "${hook}" || continue
echo "${data}" | "${hook}"
exitcodes="${exitcodes} $?"
done
for i in ${exitcodes}; do
[ ${i} -eq 0 ] || exit ${i}
done
`, setting.ScriptType),
// for update
fmt.Sprintf(`#!/usr/bin/env %s
# AUTO GENERATED BY GITEA, DO NOT MODIFY
exitcodes=""
hookname=$(basename $0)
GIT_DIR=${GIT_DIR:-$(dirname $0/..)}
for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
test -x "${hook}" && test -f "${hook}" || continue
"${hook}" $1 $2 $3
exitcodes="${exitcodes} $?"
done
for i in ${exitcodes}; do
[ ${i} -eq 0 ] || exit ${i}
done
`, setting.ScriptType),
// for post-receive
fmt.Sprintf(`#!/usr/bin/env %s
# AUTO GENERATED BY GITEA, DO NOT MODIFY
data=$(cat)
exitcodes=""
hookname=$(basename $0)
GIT_DIR=${GIT_DIR:-$(dirname $0)/..}
for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
test -x "${hook}" && test -f "${hook}" || continue
echo "${data}" | "${hook}"
exitcodes="${exitcodes} $?"
done
for i in ${exitcodes}; do
[ ${i} -eq 0 ] || exit ${i}
done
`, setting.ScriptType),
}
giteaHookTpls = []string{
// for pre-receive
fmt.Sprintf(`#!/usr/bin/env %s
# AUTO GENERATED BY GITEA, DO NOT MODIFY
%s hook --config=%s pre-receive
`, setting.ScriptType, util.ShellEscape(setting.AppPath), util.ShellEscape(setting.CustomConf)),
// for update
fmt.Sprintf(`#!/usr/bin/env %s
# AUTO GENERATED BY GITEA, DO NOT MODIFY
%s hook --config=%s update $1 $2 $3
`, setting.ScriptType, util.ShellEscape(setting.AppPath), util.ShellEscape(setting.CustomConf)),
// for post-receive
fmt.Sprintf(`#!/usr/bin/env %s
# AUTO GENERATED BY GITEA, DO NOT MODIFY
%s hook --config=%s post-receive
`, setting.ScriptType, util.ShellEscape(setting.AppPath), util.ShellEscape(setting.CustomConf)),
}
// although only new git (>=2.29) supports proc-receive, it's still good to create its hook, in case the user upgrades git
hookNames = append(hookNames, "proc-receive")
hookTpls = append(hookTpls,
fmt.Sprintf(`#!/usr/bin/env %s
# AUTO GENERATED BY GITEA, DO NOT MODIFY
%s hook --config=%s proc-receive
`, setting.ScriptType, util.ShellEscape(setting.AppPath), util.ShellEscape(setting.CustomConf)))
giteaHookTpls = append(giteaHookTpls, "")
return hookNames, hookTpls, giteaHookTpls
}
// CreateDelegateHooks creates all the hooks scripts for the repo
func CreateDelegateHooks(_ context.Context, repo RepositoryFacade) (err error) {
return createDelegateHooks(filepath.Join(gitrepo.RepoLocalPath(repo), "hooks"))
}
func createDelegateHooks(hookDir string) (err error) {
hookNames, hookTpls, giteaHookTpls := getHookTemplates()
for i, hookName := range hookNames {
oldHookPath := filepath.Join(hookDir, hookName)
newHookPath := filepath.Join(hookDir, hookName+".d", "gitea")
hookDDir := filepath.Join(hookDir, hookName+".d")
if err := os.MkdirAll(hookDDir, 0o755); err != nil {
return fmt.Errorf("create hooks dir '%s': %w", hookDDir, err)
}
if err := os.Chmod(hookDDir, 0o755); err != nil {
return fmt.Errorf("chmod hooks dir '%s': %w", hookDDir, err)
}
// WARNING: This will override all old server-side hooks
if err = util.RemoveWithRetry(oldHookPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("unable to pre-remove old hook file '%s' prior to rewriting: %w ", oldHookPath, err)
}
if err = os.WriteFile(oldHookPath, []byte(hookTpls[i]), 0o755); err != nil {
return fmt.Errorf("write old hook file '%s': %w", oldHookPath, err)
}
if err = ensureExecutable(oldHookPath); err != nil {
return fmt.Errorf("Unable to set %s executable. Error %w", oldHookPath, err)
}
if err = util.RemoveWithRetry(newHookPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("unable to pre-remove new hook file '%s' prior to rewriting: %w", newHookPath, err)
}
if err = os.WriteFile(newHookPath, []byte(giteaHookTpls[i]), 0o755); err != nil {
return fmt.Errorf("write new hook file '%s': %w", newHookPath, err)
}
if err = ensureExecutable(newHookPath); err != nil {
return fmt.Errorf("Unable to set %s executable. Error %w", oldHookPath, err)
}
}
return nil
}
func checkExecutable(filename string) bool {
// windows has no concept of a executable bit
if runtime.GOOS == "windows" {
return true
}
fileInfo, err := os.Stat(filename)
if err != nil {
return false
}
return (fileInfo.Mode() & 0o100) > 0
}
func ensureExecutable(filename string) error {
fileInfo, err := os.Stat(filename)
if err != nil {
return err
}
if (fileInfo.Mode() & 0o100) > 0 {
return nil
}
mode := fileInfo.Mode() | 0o100
return os.Chmod(filename, mode)
}
// CheckDelegateHooks checks the hooks scripts for the repo
func CheckDelegateHooks(_ context.Context, repo RepositoryFacade) ([]string, error) {
return checkDelegateHooks(filepath.Join(gitrepo.RepoLocalPath(repo), "hooks"))
}
func checkDelegateHooks(hookDir string) ([]string, error) {
hookNames, hookTpls, giteaHookTpls := getHookTemplates()
results := make([]string, 0, 10)
for i, hookName := range hookNames {
oldHookPath := filepath.Join(hookDir, hookName)
newHookPath := filepath.Join(hookDir, hookName+".d", "gitea")
cont := false
isExist, err := util.IsExist(oldHookPath)
if err != nil {
results = append(results, fmt.Sprintf("unable to check if %s exists. Error: %v", oldHookPath, err))
}
if err == nil && !isExist {
results = append(results, fmt.Sprintf("old hook file %s does not exist", oldHookPath))
cont = true
}
isExist, err = util.IsExist(oldHookPath + ".d")
if err != nil {
results = append(results, fmt.Sprintf("unable to check if %s exists. Error: %v", oldHookPath+".d", err))
}
if err == nil && !isExist {
results = append(results, fmt.Sprintf("hooks directory %s does not exist", oldHookPath+".d"))
cont = true
}
isExist, err = util.IsExist(newHookPath)
if err != nil {
results = append(results, fmt.Sprintf("unable to check if %s exists. Error: %v", newHookPath, err))
}
if err == nil && !isExist {
results = append(results, fmt.Sprintf("new hook file %s does not exist", newHookPath))
cont = true
}
if cont {
continue
}
contents, err := os.ReadFile(oldHookPath)
if err != nil {
return results, err
}
if string(contents) != hookTpls[i] {
results = append(results, fmt.Sprintf("old hook file %s is out of date", oldHookPath))
}
if !checkExecutable(oldHookPath) {
results = append(results, fmt.Sprintf("old hook file %s is not executable", oldHookPath))
}
contents, err = os.ReadFile(newHookPath)
if err != nil {
return results, err
}
if string(contents) != giteaHookTpls[i] {
results = append(results, fmt.Sprintf("new hook file %s is out of date", newHookPath))
}
if !checkExecutable(newHookPath) {
results = append(results, fmt.Sprintf("new hook file %s is not executable", newHookPath))
}
}
return results, nil
}

34
modules/git/hooks_test.go Normal file
View File

@@ -0,0 +1,34 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
func TestCreateDelegateHooksPermissions(t *testing.T) {
hookDir := t.TempDir()
existingHookDir := filepath.Join(hookDir, "post-receive.d")
require.NoError(t, os.MkdirAll(existingHookDir, 0o777))
require.NoError(t, os.Chmod(existingHookDir, 0o777))
require.NoError(t, createDelegateHooks(hookDir))
hookNames, _, _ := getHookTemplates()
for _, hookName := range hookNames {
for _, path := range []string{
filepath.Join(hookDir, hookName),
filepath.Join(hookDir, hookName+".d"),
filepath.Join(hookDir, hookName+".d", "gitea"),
} {
info, err := os.Stat(path)
require.NoError(t, err)
require.Equal(t, os.FileMode(0o755), info.Mode().Perm(), path)
}
}
}

View File

@@ -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

55
modules/git/key.go Normal file
View File

@@ -0,0 +1,55 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"context"
"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 == "" {
commitSignSettings := GlobalCommitSignSettings.Value()
if !commitSignSettings.Sign {
return nil, nil
}
sigKey := &SigningKey{KeyID: commitSignSettings.KeyID, Format: commitSignSettings.Format}
sig := &Signature{Name: commitSignSettings.Name, Email: commitSignSettings.Email}
return sigKey, sig
}
if setting.Repository.Signing.SigningKey == "" {
return nil, nil
}
sigKey := &SigningKey{KeyID: setting.Repository.Signing.SigningKey, Format: setting.Repository.Signing.SigningFormat}
sig := &Signature{Name: setting.Repository.Signing.SigningName, Email: setting.Repository.Signing.SigningEmail}
return sigKey, sig
}

View File

@@ -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
}

View File

@@ -0,0 +1,209 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package languagestats
import (
"bytes"
"context"
"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(ctx context.Context, 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()
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(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
}
entries, err := commit.Tree().ListEntriesRecursiveWithSize(ctx, repo)
if err != nil {
return nil, err
}
checker, err := attribute.NewBatchChecker(ctx, 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 <-ctx.Done():
return sizes, ctx.Err()
default:
}
contentBuf.Reset()
content = contentBuf.Bytes()
if f.GetSize(ctx, repo) == 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()
if canonicalLanguage, ok := enry.GetLanguageByAlias(language); ok {
language = canonicalLanguage
}
// 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.GetSize(ctx, repo)
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.GetSize(ctx, repo) <= 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.GetSize(ctx, repo)
} else if len(sizes) == 0 && (firstExcludedLanguage == "" || firstExcludedLanguage == language) {
firstExcludedLanguage = language
firstExcludedLanguageSize += f.GetSize(ctx, repo)
}
}
// If there are no included languages add the first excluded language
if len(sizes) == 0 && firstExcludedLanguage != "" {
sizes[firstExcludedLanguage] = firstExcludedLanguageSize
}
return mergeLanguageStats(sizes), nil
}

View File

@@ -0,0 +1,44 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
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.OpenRepositoryLocal(t.Context(), repoPath)
require.NoError(t, err)
defer gitRepo.Close()
stats, err := GetLanguageStats(t.Context(), 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,
}))
}

View File

@@ -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)
}

View File

@@ -0,0 +1,74 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"context"
"fmt"
"gitea.dev/modules/cache"
"gitea.dev/modules/log"
)
func getCacheKey(repo RepositoryFacade, commitID, entryPath string) string {
return cache.SafeCacheKey(fmt.Sprintf("git-last-commit:%s:%s", repo.GitRepoManagedID(), commitID), entryPath)
}
// LastCommitCache represents a cache to store last commit
type LastCommitCache struct {
ttlFn func() int64
repo *Repository
commitCache map[string]*Commit
cache cache.StringCache
}
// Put puts the last commit id with commit and entry path
func (c *LastCommitCache) Put(ref, entryPath, commitID string) error {
log.Debug("LastCommitCache save: [%s:%s:%s]", ref, entryPath, commitID)
return c.cache.Put(getCacheKey(c.repo, ref, entryPath), commitID, c.ttlFn())
}
// Get gets the last commit information by commit id and entry path
func (c *LastCommitCache) Get(ctx context.Context, ref, entryPath string) (*Commit, error) {
lastCommitID, ok := c.cache.Get(getCacheKey(c.repo, ref, entryPath))
if !ok || lastCommitID == "" {
return nil, nil //nolint:nilnil // return nil when cache miss
}
log.Debug("LastCommitCache hit level 1: [%s:%s:%s]", ref, entryPath, lastCommitID)
if lastCommit, ok := c.commitCache[lastCommitID]; ok {
log.Debug("LastCommitCache hit level 2: [%s:%s:%s]", ref, entryPath, lastCommitID)
return lastCommit, nil
}
lastCommit, err := c.repo.GetCommit(ctx, lastCommitID)
if err != nil {
return nil, err
}
if c.commitCache == nil {
c.commitCache = make(map[string]*Commit)
}
c.commitCache[lastCommitID] = lastCommit
return lastCommit, nil
}
// GetCommitByPath gets the last commit for the entry in the provided commit
func (c *LastCommitCache) GetCommitByPath(ctx context.Context, entryCommitID ObjectID, entryPath string) (*Commit, error) {
entryCommitIDStr := entryCommitID.String()
lastCommit, err := c.Get(ctx, entryCommitIDStr, entryPath)
if err != nil || lastCommit != nil {
return lastCommit, err
}
lastCommit, err = c.repo.getCommitByPathWithID(ctx, entryCommitID, entryPath)
if err != nil {
return nil, err
}
if err := c.Put(entryCommitIDStr, 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, entryCommitID, c.repo.LogString(), err)
}
return lastCommit, nil
}

View File

@@ -0,0 +1,63 @@
// 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, gitRepo *Repository) error {
commitNodeIndex, closer := gitRepo.CommitNodeIndex()
defer closer()
index, err := commitNodeIndex.Get(plumbing.Hash(c.ID.RawValue()))
if err != nil {
return err
}
return c.recursiveCache(ctx, gitRepo, index, c.Tree(), "", 1)
}
func (c *Commit) recursiveCache(ctx context.Context, gitRepo *Repository, index cgobject.CommitNode, tree *Tree, treePath string, level int) error {
if level == 0 {
return nil
}
entries, err := tree.ListEntries(ctx, gitRepo)
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 := getLastCommitForPathsByCommitNode(ctx, gitRepo, index, treePath, entryPaths)
if err != nil {
return err
}
for entry := range commits {
if entryMap[entry].IsDir() {
subTree, err := tree.SubTree(ctx, gitRepo, entry)
if err != nil {
return err
}
if err := c.recursiveCache(ctx, gitRepo, index, subTree, entry, level-1); err != nil {
return err
}
}
}
return nil
}

View File

@@ -0,0 +1,50 @@
// 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, gitRepo *Repository) error {
return c.recursiveCache(ctx, gitRepo, c.Tree(), "", 1)
}
func (c *Commit) recursiveCache(ctx context.Context, gitRepo *Repository, tree *Tree, treePath string, level int) error {
if level == 0 {
return nil
}
entries, err := tree.ListEntries(ctx, gitRepo)
if err != nil {
return err
}
entryPaths := make([]string, len(entries))
for i, entry := range entries {
entryPaths[i] = entry.Name()
}
_, err = walkGitLog(ctx, gitRepo, 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(ctx, gitRepo, treeEntry.Name())
if err != nil {
return err
}
if err := c.recursiveCache(ctx, gitRepo, subTree, treeEntry.Name(), level-1); err != nil {
return err
}
}
}
return nil
}

66
modules/git/localfs.go Normal file
View File

@@ -0,0 +1,66 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"gitea.dev/modules/git/gitrepo"
"gitea.dev/modules/util"
)
// IsRepositoryExist returns true if the repository directory exists in the disk
func IsRepositoryExist(ctx context.Context, repo RepositoryFacade) (bool, error) {
return util.IsExist(gitrepo.RepoLocalPath(repo))
}
// DeleteRepository deletes the repository directory from the disk, it will return
// nil if the repository does not exist.
func DeleteRepository(ctx context.Context, repo RepositoryFacade) error {
return util.RemoveAllWithRetry(gitrepo.RepoLocalPath(repo))
}
// RenameRepository renames a repository's name on disk
func RenameRepository(ctx context.Context, repo, newRepo RepositoryFacade) error {
dstDir := gitrepo.RepoLocalPath(newRepo)
if err := os.MkdirAll(filepath.Dir(dstDir), os.ModePerm); err != nil {
return fmt.Errorf("Failed to create dir %s: %w", filepath.Dir(dstDir), err)
}
if err := util.RenameWithRetry(gitrepo.RepoLocalPath(repo), dstDir); err != nil {
return fmt.Errorf("rename repository directory: %w", err)
}
return nil
}
func InitRepository(ctx context.Context, repo RepositoryFacade, objectFormatName string) error {
return InitRepositoryLocal(ctx, gitrepo.RepoLocalPath(repo), true, objectFormatName)
}
func IsRepoFileExist(ctx context.Context, repo RepositoryFacade, relativeFilePath string) (bool, error) {
absoluteFilePath := filepath.Join(gitrepo.RepoLocalPath(repo), relativeFilePath)
return util.IsExist(absoluteFilePath)
}
func IsRepoDirExist(ctx context.Context, repo RepositoryFacade, relativeDirPath string) (bool, error) {
absoluteDirPath := filepath.Join(gitrepo.RepoLocalPath(repo), relativeDirPath)
return util.IsDir(absoluteDirPath)
}
func RemoveRepoFileOrDir(ctx context.Context, repo RepositoryFacade, relativeFileOrDirPath string) error {
absoluteFilePath := filepath.Join(gitrepo.RepoLocalPath(repo), relativeFileOrDirPath)
return util.RemoveWithRetry(absoluteFilePath)
}
func CreateRepoFile(ctx context.Context, repo RepositoryFacade, relativeFilePath string) (io.WriteCloser, error) {
absoluteFilePath := filepath.Join(gitrepo.RepoLocalPath(repo), relativeFilePath)
if err := os.MkdirAll(filepath.Dir(absoluteFilePath), os.ModePerm); err != nil {
return nil, err
}
return os.Create(absoluteFilePath)
}

View File

@@ -0,0 +1,390 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !gogit
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 parser
func logNameStatusRepo(ctx context.Context, repo RepositoryFacade, head, treepath string, paths ...string) *logNameStatusRepoParser {
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.WithRepo(repo).RunWithStderr(ctx)
if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
log.Error("Unable to run git command %v: %v", cmd.LogString(), err)
}
}()
bufReader := bufio.NewReaderSize(stdoutReader, 32*1024)
return &logNameStatusRepoParser{
treepath: treepath,
paths: paths,
rd: bufReader,
close: 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
close func()
}
// logNameStatusCommitData represents a commit artifact from git log raw
type logNameStatusCommitData struct {
CommitID string
ParentIDs []string
Paths []bool
}
// walkNext returns the next LogStatusCommitData
func (g *logNameStatusRepoParser) walkNext(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')
switch {
case errors.Is(err, bufio.ErrBufferFull):
g.buffull = true
case err != nil:
return nil, err
}
}
ret := logNameStatusCommitData{}
if bytes.Equal(g.next, []byte("commit\000")) {
g.next, err = g.rd.ReadSlice('\x00')
switch {
case errors.Is(err, bufio.ErrBufferFull):
g.buffull = true
case err != nil:
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
}
}
var walkGitLogDebugBeforeNext func() // is used to simulate various edge git process cases
// 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(ctx, repo, treepath)
if err != nil {
return nil, err
}
entries, err := tree.ListEntries(ctx, repo)
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 := logNameStatusRepo(ctx, repo, head.ID.String(), treepath, paths...)
// don't use defer g.cancel() 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 {
if walkGitLogDebugBeforeNext != nil {
walkGitLogDebugBeforeNext()
}
current, err := g.walkNext(treepath, path2idx, changed, maxpathlen)
if ctx.Err() != nil {
break heaploop // context is either canceled or deadline exceeded - break the loop and return what we have so far
} else if errors.Is(err, io.EOF) {
break heaploop // reached to the end of log output
} else if err != nil {
return nil, err // other unknown errors
}
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 {
remainingPaths := make([]string, 0, len(paths))
for i, pth := range paths {
if results[i] == "" {
remainingPaths = append(remainingPaths, pth)
}
}
g.close()
g = logNameStatusRepo(ctx, repo, lastEmptyParent, treepath, remainingPaths...)
parentRemaining = make(container.Set[string])
nextRestart = (remaining * 3) / 4
continue heaploop
}
}
parentRemaining.AddMultiple(current.ParentIDs...)
}
resultsMap := map[string]string{}
for i, pth := range paths {
resultsMap[pth] = results[i]
}
return resultsMap, nil
}

25
modules/git/main_test.go Normal file
View File

@@ -0,0 +1,25 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"path/filepath"
"testing"
"gitea.dev/modules/git/gitrepo"
)
const testReposDir = "tests/repos/"
func mockRepository(repoPath string) RepositoryFacade {
if !filepath.IsAbs(repoPath) {
// resolve repository path relative to the unit test fixture directory
repoPath, _ = filepath.Abs(filepath.Join(testReposDir, repoPath))
}
return gitrepo.RepositoryUnmanaged(repoPath)
}
func TestMain(m *testing.M) {
RunGitTests(m)
}

26
modules/git/merge.go Normal file
View File

@@ -0,0 +1,26 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"context"
"fmt"
"strings"
"gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/util"
)
// MergeBase checks and returns merge base of two commits.
func MergeBase(ctx context.Context, repo RepositoryFacade, baseCommitID, headCommitID string) (string, error) {
mergeBase, stderr, err := gitcmd.NewCommand("merge-base").
AddDashesAndList(baseCommitID, headCommitID).WithRepo(repo).RunStdString(ctx)
if err != nil {
if gitcmd.IsErrorExitCode(err, 1) && strings.TrimSpace(stderr) == "" {
return "", util.NewNotExistErrorf("merge-base for %s and %s doesn't exist", baseCommitID, headCommitID)
}
return "", fmt.Errorf("get merge-base of %s and %s failed: %w", baseCommitID, headCommitID, err)
}
return strings.TrimSpace(mergeBase), nil
}

59
modules/git/merge_tree.go Normal file
View File

@@ -0,0 +1,59 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"bufio"
"context"
"fmt"
"gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/util"
)
const MaxConflictedDetectFiles = 10
// MergeTree performs a merge between two commits (baseRef and headRef) with an optional merge base.
// It returns the resulting tree hash, a list of conflicted files (if any), and an error if the operation fails.
// If there are no conflicts, the list of conflicted files will be nil.
func MergeTree(ctx context.Context, repo RepositoryFacade, baseRef, headRef, mergeBase string) (treeID string, isErrHasConflicts bool, conflictFiles []string, _ error) {
cmd := gitcmd.NewCommand("merge-tree", "--write-tree", "-z", "--name-only", "--no-messages").
AddOptionFormat("--merge-base=%s", mergeBase).
AddDynamicArguments(baseRef, headRef)
stdout, stdoutClose := cmd.MakeStdoutPipe()
defer stdoutClose()
cmd.WithPipelineFunc(func(ctx gitcmd.Context) error {
// https://git-scm.com/docs/git-merge-tree/2.38.0#OUTPUT
// For a conflicted merge, the output is:
// <OID of toplevel tree>NUL
// <Conflicted file name 1>NUL
// <Conflicted file name 2>NUL
// ...
scanner := bufio.NewScanner(stdout)
scanner.Split(util.BufioScannerSplit(0))
for scanner.Scan() {
line := scanner.Text()
if treeID == "" { // first line is tree ID
treeID = line
continue
}
conflictFiles = append(conflictFiles, line)
if len(conflictFiles) >= MaxConflictedDetectFiles {
break
}
}
return scanner.Err()
})
err := cmd.WithRepo(repo).RunWithStderr(ctx)
// For a successful, non-conflicted merge, the exit status is 0. When the merge has conflicts, the exit status is 1.
// A merge can have conflicts without having individual files conflict
// https://git-scm.com/docs/git-merge-tree/2.38.0#_mistakes_to_avoid
isErrHasConflicts = gitcmd.IsErrorExitCode(err, 1)
if err == nil || isErrHasConflicts {
return treeID, isErrHasConflicts, conflictFiles, nil
}
return "", false, nil, fmt.Errorf("run merge-tree failed: %w", err)
}

View File

@@ -0,0 +1,82 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"path/filepath"
"testing"
"gitea.dev/modules/git/gitcmd"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func prepareRepoDirRenameConflict(t *testing.T) string {
repoDir := filepath.Join(t.TempDir(), "repo-dir-rename-conflict.git")
require.NoError(t, gitcmd.NewCommand("init", "--bare").AddDynamicArguments(repoDir).Run(t.Context()))
stdin := `blob
mark :1
data 2
b
blob
mark :2
data 2
c
reset refs/heads/master
commit refs/heads/master
mark :3
author test <test@example.com> 1769202331 -0800
committer test <test@example.com> 1769202331 -0800
data 2
O
M 100644 :1 z/b
M 100644 :2 z/c
commit refs/heads/split
mark :4
author test <test@example.com> 1769202336 -0800
committer test <test@example.com> 1769202336 -0800
data 2
A
from :3
M 100644 :2 w/c
M 100644 :1 y/b
D z/b
D z/c
blob
mark :5
data 2
d
commit refs/heads/add
mark :6
author test <test@example.com> 1769202342 -0800
committer test <test@example.com> 1769202342 -0800
data 2
B
from :3
M 100644 :5 z/d
`
require.NoError(t, gitcmd.NewCommand("fast-import").WithDir(repoDir).WithStdinBytes([]byte(stdin)).Run(t.Context()))
return repoDir
}
func TestMergeTreeDirectoryRenameConflictWithoutFiles(t *testing.T) {
repoDir := prepareRepoDirRenameConflict(t)
require.DirExists(t, repoDir)
repo := mockRepository(repoDir)
mergeBase, err := MergeBase(t.Context(), repo, "add", "split")
require.NoError(t, err)
treeID, conflicted, conflictedFiles, err := MergeTree(t.Context(), repo, "add", "split", mergeBase)
require.NoError(t, err)
assert.True(t, conflicted)
assert.Empty(t, conflictedFiles)
assert.Equal(t, "5e3dd4cfc5b11e278a35b2daa83b7274175e3ab1", treeID)
}

89
modules/git/notes.go Normal file
View File

@@ -0,0 +1,89 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"context"
"path"
"strings"
"gitea.dev/modules/setting"
)
// 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 {
refCommit *Commit
BlobMessage CommitMessage // if the blob is too large, the message will be truncated
BlobSize int64
TreePath string
}
// GetNote retrieves the git-notes data for a given commit.
func GetNote(ctx context.Context, repo *Repository, commitID string) (*Note, error) {
noteCommit, err := repo.GetCommit(ctx, NotesRef)
if err != nil {
return nil, err
}
// A note for a commit is stored in a blob in the notes commit tree, with the path being the commit ID.
// The path can be "FullCommitID" or a fanout path like "ab/cdef...." or "ab/cd/ef.....".
tree := noteCommit.Tree()
entryName := commitID
var entry *TreeEntry
var treePathBuf strings.Builder
for len(entryName) > 2 {
entry, err = tree.GetTreeEntryByPath(ctx, repo, entryName)
if err == nil {
treePathBuf.WriteString(entryName)
break
} else if IsErrNotExist(err) {
fanoutDir, fanoutName := entryName[0:2], entryName[2:]
tree, err = tree.SubTree(ctx, repo, fanoutDir)
if err != nil {
return nil, err
}
treePathBuf.WriteString(fanoutDir)
treePathBuf.WriteByte('/')
entryName = fanoutName
} else {
return nil, err
}
}
if entry == nil {
return nil, ErrNotExist{ID: commitID}
}
treePath := treePathBuf.String()
blob := entry.Blob(repo)
note := &Note{TreePath: treePath, refCommit: noteCommit}
note.BlobMessage.MessageRaw, err = blob.GetBlobContent(ctx, setting.UI.MaxDisplayFileSize)
if err != nil {
return nil, err
}
note.BlobSize = blob.Size(ctx) // it should be called after the get blob content, then the "size" is cached
return note, nil
}
func GetNoteWithLastCommit(ctx context.Context, repo *Repository, commitID string) (*Note, *Commit, error) {
note, err := GetNote(ctx, repo, commitID)
if err != nil {
return nil, nil, err
}
parentPath, entryName := path.Split(note.TreePath)
parentPath = strings.Trim(parentPath, "/")
lastCommits, err := GetLastCommitForPaths(ctx, repo, note.refCommit, parentPath, []string{entryName})
if err != nil {
return nil, nil, err
}
lastCommit := lastCommits[entryName]
if lastCommit == nil {
return nil, nil, ErrNotExist{ID: commitID}
}
return note, lastCommit, nil
}

46
modules/git/notes_test.go Normal file
View File

@@ -0,0 +1,46 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"path/filepath"
"testing"
"gitea.dev/modules/git/gitrepo"
"github.com/stretchr/testify/assert"
)
func TestGetNote(t *testing.T) {
repo, err := OpenRepositoryLocal(t.Context(), filepath.Join(testReposDir, "repo1_bare"))
assert.NoError(t, err)
defer repo.Close()
note, err := GetNote(t.Context(), repo, "95bb4d39648ee7e325106df01a621c530863a653")
assert.NoError(t, err)
assert.Equal(t, "Note contents\n", note.BlobMessage.MessageUTF8())
assert.EqualValues(t, len(note.BlobMessage.MessageRaw), note.BlobSize)
_, err = GetNote(t.Context(), repo, "non_existent_sha")
assert.ErrorAs(t, err, &ErrNotExist{})
}
func TestGetNoteNestedWithCache(t *testing.T) {
repoPath, _ := filepath.Abs(filepath.Join(testReposDir, "repo3_notes"))
repo, err := OpenRepository(t.Context(), gitrepo.RepositoryManaged("repo3_notes", repoPath))
assert.NoError(t, err)
defer repo.Close()
note, lastCommit, err := GetNoteWithLastCommit(t.Context(), repo, "ba0a96fa63532d6c5087ecef070b0250ed72fa47")
assert.NoError(t, err)
assert.Equal(t, "Note 1", note.BlobMessage.MessageUTF8())
assert.Equal(t, "ba0a96fa63532d6c5087ecef070b0250ed72fa47", note.TreePath)
assert.Equal(t, "Filip Navara", lastCommit.Author.Name)
note, lastCommit, err = GetNoteWithLastCommit(t.Context(), repo, "3e668dbfac39cbc80a9ff9c61eb565d944453ba4")
assert.NoError(t, err)
assert.Equal(t, "Note 2", note.BlobMessage.MessageUTF8())
assert.Equal(t, "3e/66/8dbfac39cbc80a9ff9c61eb565d944453ba4", note.TreePath)
assert.Equal(t, "Filip Navara", lastCommit.Author.Name)
}

View File

@@ -0,0 +1,165 @@
// 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 a valid sha
var sha1Pattern = regexp.MustCompile(`^[0-9a-f]{4,40}$`)
// sha256Pattern can be used to determine if a string is a 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: 4b825dc642cb6eb9a060e54bf8d69288fbee4904
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))
}
type invalidObjectFormatImpl struct{}
var emptyInvalidObjectID = &Sha1Hash{}
func (h invalidObjectFormatImpl) Name() string {
return "invalid-object-format"
}
func (h invalidObjectFormatImpl) EmptyObjectID() ObjectID {
return emptyInvalidObjectID
}
func (h invalidObjectFormatImpl) EmptyTree() ObjectID {
return emptyInvalidObjectID
}
func (h invalidObjectFormatImpl) FullLength() int {
return len(emptyInvalidObjectID) * 2
}
func (h invalidObjectFormatImpl) IsValid(input string) bool {
return false
}
func (h invalidObjectFormatImpl) MustID(b []byte) ObjectID {
return emptyInvalidObjectID
}
func (h invalidObjectFormatImpl) ComputeHash(t ObjectType, content []byte) ObjectID {
return emptyInvalidObjectID
}
var (
Sha1ObjectFormat ObjectFormat = Sha1ObjectFormatImpl{}
Sha256ObjectFormat ObjectFormat = Sha256ObjectFormatImpl{}
invalidObjectFormat ObjectFormat = invalidObjectFormatImpl{}
)
func ObjectFormatFromName(name string) ObjectFormat {
for _, objectFormat := range DefaultFeatures().SupportedObjectFormats {
if name == objectFormat.Name() {
return objectFormat
}
}
return invalidObjectFormat
}
func IsValidObjectFormat(name string) bool {
return ObjectFormatFromName(name) != invalidObjectFormat
}

106
modules/git/object_id.go Normal file
View File

@@ -0,0 +1,106 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"bytes"
"encoding/hex"
"fmt"
)
type ObjectID interface {
String() string
RefName() RefName
IsZero() bool
RawValue() []byte
Type() ObjectFormat
}
type Sha1Hash [20]byte
var _ ObjectID = (*Sha1Hash)(nil)
func (h *Sha1Hash) String() string {
return hex.EncodeToString(h[:])
}
func (h *Sha1Hash) RefName() RefName {
return RefName(h.String())
}
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 }
func MustIDFromString(hexHash string) ObjectID {
id, err := NewIDFromString(hexHash)
if err != nil {
panic(err)
}
return id
}
type Sha256Hash [32]byte
var _ ObjectID = (*Sha256Hash)(nil)
func (h *Sha256Hash) String() string {
return hex.EncodeToString(h[:])
}
func (h *Sha256Hash) RefName() RefName {
return RefName(h.String())
}
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)
}

View File

@@ -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
}

Some files were not shown because too many files have changed in this diff Show More