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
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:
74
modules/git/archive.go
Normal file
74
modules/git/archive.go
Normal 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)
|
||||
}
|
||||
29
modules/git/archive_test.go
Normal file
29
modules/git/archive_test.go
Normal 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")
|
||||
}
|
||||
115
modules/git/attribute/attribute.go
Normal file
115
modules/git/attribute/attribute.go
Normal 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
|
||||
}
|
||||
37
modules/git/attribute/attribute_test.go
Normal file
37
modules/git/attribute/attribute_test.go
Normal 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())
|
||||
}
|
||||
208
modules/git/attribute/batch.go
Normal file
208
modules/git/attribute/batch.go
Normal 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
|
||||
}
|
||||
173
modules/git/attribute/batch_test.go
Normal file
173
modules/git/attribute/batch_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
97
modules/git/attribute/checker.go
Normal file
97
modules/git/attribute/checker.go
Normal 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
|
||||
}
|
||||
84
modules/git/attribute/checker_test.go
Normal file
84
modules/git/attribute/checker_test.go
Normal 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"])
|
||||
})
|
||||
}
|
||||
39
modules/git/attribute/main_test.go
Normal file
39
modules/git/attribute/main_test.go
Normal 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
207
modules/git/blame.go
Normal 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
|
||||
}
|
||||
155
modules/git/blame_sha256_test.go
Normal file
155
modules/git/blame_sha256_test.go
Normal 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
150
modules/git/blame_test.go
Normal 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
116
modules/git/blob.go
Normal 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
47
modules/git/blob_gogit.go
Normal 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
108
modules/git/blob_nogogit.go
Normal 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
76
modules/git/blob_test.go
Normal 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
95
modules/git/branch.go
Normal 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
|
||||
}
|
||||
61
modules/git/catfile_batch.go
Normal file
61
modules/git/catfile_batch.go
Normal 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
|
||||
}
|
||||
75
modules/git/catfile_batch_command.go
Normal file
75
modules/git/catfile_batch_command.go
Normal 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
|
||||
}
|
||||
}
|
||||
87
modules/git/catfile_batch_legacy.go
Normal file
87
modules/git/catfile_batch_legacy.go
Normal 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
|
||||
}
|
||||
}
|
||||
241
modules/git/catfile_batch_reader.go
Normal file
241
modules/git/catfile_batch_reader.go
Normal 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
|
||||
}
|
||||
108
modules/git/catfile_batch_test.go
Normal file
108
modules/git/catfile_batch_test.go
Normal 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
24
modules/git/clone.go
Normal 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
36
modules/git/cmdverb.go
Normal 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
272
modules/git/commit.go
Normal 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
97
modules/git/commit2.go
Normal 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)
|
||||
}
|
||||
76
modules/git/commit2_test.go
Normal file
76
modules/git/commit2_test.go
Normal 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())
|
||||
}
|
||||
75
modules/git/commit_convert_gogit.go
Normal file
75
modules/git/commit_convert_gogit.go
Normal 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),
|
||||
}
|
||||
}
|
||||
27
modules/git/commit_count_cache.go
Normal file
27
modules/git/commit_count_cache.go
Normal 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())
|
||||
})
|
||||
}
|
||||
88
modules/git/commit_file.go
Normal file
88
modules/git/commit_file.go
Normal 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
|
||||
}
|
||||
175
modules/git/commit_file_test.go
Normal file
175
modules/git/commit_file_test.go
Normal 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
115
modules/git/commit_info.go
Normal 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
|
||||
}
|
||||
207
modules/git/commit_info_gogit.go
Normal file
207
modules/git/commit_info_gogit.go
Normal 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
|
||||
}
|
||||
43
modules/git/commit_info_nogogit.go
Normal file
43
modules/git/commit_info_nogogit.go
Normal 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
|
||||
}
|
||||
57
modules/git/commit_info_nogogit_test.go
Normal file
57
modules/git/commit_info_nogogit_test.go
Normal 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)
|
||||
}
|
||||
166
modules/git/commit_info_test.go
Normal file
166
modules/git/commit_info_test.go
Normal 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()))
|
||||
})
|
||||
}
|
||||
194
modules/git/commit_message.go
Normal file
194
modules/git/commit_message.go
Normal 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])
|
||||
}
|
||||
151
modules/git/commit_message_test.go
Normal file
151
modules/git/commit_message_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
100
modules/git/commit_reader.go
Normal file
100
modules/git/commit_reader.go
Normal 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
|
||||
}
|
||||
128
modules/git/commit_sha256_test.go
Normal file
128
modules/git/commit_sha256_test.go
Normal 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)
|
||||
}
|
||||
54
modules/git/commit_submodule.go
Normal file
54
modules/git/commit_submodule.go
Normal 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
|
||||
}
|
||||
69
modules/git/commit_submodule_file.go
Normal file
69
modules/git/commit_submodule_file.go
Normal 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)
|
||||
}
|
||||
40
modules/git/commit_submodule_file_test.go
Normal file
40
modules/git/commit_submodule_file_test.go
Normal 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
204
modules/git/commit_test.go
Normal 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
70
modules/git/compare.go
Normal 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
138
modules/git/compare_test.go
Normal 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
205
modules/git/config.go
Normal 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)
|
||||
}
|
||||
30
modules/git/config_managed.go
Normal file
30
modules/git/config_managed.go
Normal 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
|
||||
})
|
||||
}
|
||||
75
modules/git/config_submodule.go
Normal file
75
modules/git/config_submodule.go
Normal 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
|
||||
}
|
||||
49
modules/git/config_submodule_test.go
Normal file
49
modules/git/config_submodule_test.go
Normal 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)
|
||||
}
|
||||
73
modules/git/config_test.go
Normal file
73
modules/git/config_test.go
Normal 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
348
modules/git/diff.go
Normal 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
184
modules/git/diff_test.go
Normal 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
126
modules/git/error.go
Normal 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
67
modules/git/fastimport.go
Normal 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
29
modules/git/fetch.go
Normal 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)
|
||||
})
|
||||
}
|
||||
83
modules/git/foreachref/format.go
Normal file
83
modules/git/foreachref/format.go
Normal 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()
|
||||
}
|
||||
66
modules/git/foreachref/format_test.go
Normal file
66
modules/git/foreachref/format_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
135
modules/git/foreachref/parser.go
Normal file
135
modules/git/foreachref/parser.go
Normal 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
|
||||
}
|
||||
227
modules/git/foreachref/parser_test.go
Normal file
227
modules/git/foreachref/parser_test.go
Normal 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
16
modules/git/fsck.go
Normal 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
215
modules/git/git.go
Normal 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
37
modules/git/git_test.go
Normal 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"))))
|
||||
}
|
||||
582
modules/git/gitcmd/command.go
Normal file
582
modules/git/gitcmd/command.go
Normal 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()
|
||||
}
|
||||
142
modules/git/gitcmd/command_test.go
Normal file
142
modules/git/gitcmd/command_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
32
modules/git/gitcmd/context.go
Normal file
32
modules/git/gitcmd/context.go
Normal 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
40
modules/git/gitcmd/env.go
Normal 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
166
modules/git/gitcmd/error.go
Normal 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
|
||||
}
|
||||
26
modules/git/gitcmd/error_test.go
Normal file
26
modules/git/gitcmd/error_test.go
Normal 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))
|
||||
}
|
||||
88
modules/git/gitcmd/pipe.go
Normal file
88
modules/git/gitcmd/pipe.go
Normal 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
51
modules/git/gitrepo.go
Normal 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
|
||||
}
|
||||
28
modules/git/gitrepo/dbrepo.go
Normal file
28
modules/git/gitrepo/dbrepo.go
Normal 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))
|
||||
}
|
||||
116
modules/git/gitrepo/gitrepo.go
Normal file
116
modules/git/gitrepo/gitrepo.go
Normal 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
78
modules/git/gpg.go
Normal 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
33
modules/git/gpg_test.go
Normal 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
140
modules/git/grep.go
Normal 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
86
modules/git/grep_test.go
Normal 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
113
modules/git/hook.go
Normal 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
245
modules/git/hooks.go
Normal 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
34
modules/git/hooks_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
9
modules/git/internal/cmdarg.go
Normal file
9
modules/git/internal/cmdarg.go
Normal 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
55
modules/git/key.go
Normal 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
|
||||
}
|
||||
65
modules/git/languagestats/language_stats.go
Normal file
65
modules/git/languagestats/language_stats.go
Normal 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
|
||||
}
|
||||
209
modules/git/languagestats/language_stats_get.go
Normal file
209
modules/git/languagestats/language_stats_get.go
Normal 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
|
||||
}
|
||||
44
modules/git/languagestats/language_stats_test.go
Normal file
44
modules/git/languagestats/language_stats_test.go
Normal 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,
|
||||
}))
|
||||
}
|
||||
14
modules/git/languagestats/main_test.go
Normal file
14
modules/git/languagestats/main_test.go
Normal 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)
|
||||
}
|
||||
74
modules/git/last_commit_cache.go
Normal file
74
modules/git/last_commit_cache.go
Normal 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
|
||||
}
|
||||
63
modules/git/last_commit_cache_gogit.go
Normal file
63
modules/git/last_commit_cache_gogit.go
Normal 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
|
||||
}
|
||||
50
modules/git/last_commit_cache_nogogit.go
Normal file
50
modules/git/last_commit_cache_nogogit.go
Normal 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
66
modules/git/localfs.go
Normal 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)
|
||||
}
|
||||
390
modules/git/log_name_status_nogogit.go
Normal file
390
modules/git/log_name_status_nogogit.go
Normal 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
25
modules/git/main_test.go
Normal 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
26
modules/git/merge.go
Normal 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
59
modules/git/merge_tree.go
Normal 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)
|
||||
}
|
||||
82
modules/git/merge_tree_test.go
Normal file
82
modules/git/merge_tree_test.go
Normal 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
89
modules/git/notes.go
Normal 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
46
modules/git/notes_test.go
Normal 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)
|
||||
}
|
||||
165
modules/git/object_format.go
Normal file
165
modules/git/object_format.go
Normal 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
106
modules/git/object_id.go
Normal 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)
|
||||
}
|
||||
30
modules/git/object_id_gogit.go
Normal file
30
modules/git/object_id_gogit.go
Normal 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
Reference in New Issue
Block a user