feat: vendor gitea 1.16.2

This commit is contained in:
2026-06-02 08:36:01 +00:00
parent 247cd60f69
commit 54798b4615
2145 changed files with 162965 additions and 126447 deletions
@@ -96,8 +96,8 @@ func (attrs *Attributes) GetGitlabLanguage() optional.Option[string] {
// 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 idx := strings.IndexByte(raw, '?'); idx >= 0 {
return optional.Some(raw[:idx])
if before, _, ok := strings.Cut(raw, "?"); ok {
return optional.Some(before)
}
}
return attrStr
@@ -7,7 +7,7 @@ import (
"bytes"
"context"
"fmt"
"os"
"io"
"path/filepath"
"time"
@@ -20,7 +20,7 @@ import (
type BatchChecker struct {
attributesNum int
repo *git.Repository
stdinWriter *os.File
stdinWriter io.WriteCloser
stdOut *nulSeparatedAttributeWriter
ctx context.Context
cancel context.CancelFunc
@@ -60,10 +60,7 @@ func NewBatchChecker(repo *git.Repository, treeish string, attributes []string)
},
}
stdinReader, stdinWriter, err := os.Pipe()
if err != nil {
return nil, err
}
stdinWriter, stdinWriterClose := cmd.MakeStdinPipe()
checker.stdinWriter = stdinWriter
lw := new(nulSeparatedAttributeWriter)
@@ -71,24 +68,19 @@ func NewBatchChecker(repo *git.Repository, treeish string, attributes []string)
lw.closed = make(chan struct{})
checker.stdOut = lw
go func() {
defer func() {
_ = stdinReader.Close()
_ = lw.Close()
}()
stdErr := new(bytes.Buffer)
err := cmd.Run(ctx, &gitcmd.RunOpts{
Env: envs,
Dir: repo.Path,
Stdin: stdinReader,
Stdout: lw,
Stderr: stdErr,
})
cmd.WithEnv(envs).
WithDir(repo.Path).
WithStdoutCopy(lw)
if err != nil && !git.IsErrCanceledOrKilled(err) {
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)
}
checker.cancel()
}()
return checker, nil
@@ -68,19 +68,14 @@ func CheckAttributes(ctx context.Context, gitRepo *git.Repository, treeish strin
}
defer cancel()
stdOut := new(bytes.Buffer)
stdErr := new(bytes.Buffer)
if err := cmd.Run(ctx, &gitcmd.RunOpts{
Env: append(os.Environ(), envs...),
Dir: gitRepo.Path,
Stdout: stdOut,
Stderr: stdErr,
}); err != nil {
return nil, fmt.Errorf("failed to run check-attr: %w\n%s\n%s", err, stdOut.String(), stdErr.String())
stdout, _, err := cmd.WithEnv(append(os.Environ(), envs...)).
WithDir(gitRepo.Path).
RunStdBytes(ctx)
if err != nil {
return nil, fmt.Errorf("failed to run check-attr: %w", err)
}
fields := bytes.Split(stdOut.Bytes(), []byte{'\000'})
fields := bytes.Split(stdout, []byte{'\000'})
if len(fields)%3 != 1 {
return nil, errors.New("wrong number of fields in return from check-attr")
}
-47
View File
@@ -1,47 +0,0 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"bufio"
"context"
)
type Batch struct {
cancel context.CancelFunc
Reader *bufio.Reader
Writer WriteCloserError
}
// NewBatch creates a new batch for the given repository, the Close must be invoked before release the batch
func NewBatch(ctx context.Context, repoPath string) (*Batch, error) {
// Now because of some insanity with git cat-file not immediately failing if not run in a valid git directory we need to run git rev-parse first!
if err := ensureValidGitRepository(ctx, repoPath); err != nil {
return nil, err
}
var batch Batch
batch.Writer, batch.Reader, batch.cancel = catFileBatch(ctx, repoPath)
return &batch, nil
}
func NewBatchCheck(ctx context.Context, repoPath string) (*Batch, error) {
// Now because of some insanity with git cat-file not immediately failing if not run in a valid git directory we need to run git rev-parse first!
if err := ensureValidGitRepository(ctx, repoPath); err != nil {
return nil, err
}
var check Batch
check.Writer, check.Reader, check.cancel = catFileBatchCheck(ctx, repoPath)
return &check, nil
}
func (b *Batch) Close() {
if b.cancel != nil {
b.cancel()
b.Reader = nil
b.Writer = nil
b.cancel = nil
}
}
@@ -1,329 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"bufio"
"bytes"
"context"
"io"
"math"
"strconv"
"strings"
"code.gitea.io/gitea/modules/git/gitcmd"
"code.gitea.io/gitea/modules/log"
"github.com/djherbis/buffer"
"github.com/djherbis/nio/v3"
)
// WriteCloserError wraps an io.WriteCloser with an additional CloseWithError function
type WriteCloserError interface {
io.WriteCloser
CloseWithError(err error) error
}
// ensureValidGitRepository runs git rev-parse in the repository path - thus ensuring that the repository is a valid repository.
// Run before opening git cat-file.
// This is needed otherwise the git cat-file will hang for invalid repositories.
func ensureValidGitRepository(ctx context.Context, repoPath string) error {
stderr := strings.Builder{}
err := gitcmd.NewCommand("rev-parse").
Run(ctx, &gitcmd.RunOpts{
Dir: repoPath,
Stderr: &stderr,
})
if err != nil {
return gitcmd.ConcatenateError(err, (&stderr).String())
}
return nil
}
// catFileBatchCheck opens git cat-file --batch-check in the provided repo and returns a stdin pipe, a stdout reader and cancel function
func catFileBatchCheck(ctx context.Context, repoPath string) (WriteCloserError, *bufio.Reader, func()) {
batchStdinReader, batchStdinWriter := io.Pipe()
batchStdoutReader, batchStdoutWriter := io.Pipe()
ctx, ctxCancel := context.WithCancel(ctx)
closed := make(chan struct{})
cancel := func() {
ctxCancel()
_ = batchStdoutReader.Close()
_ = batchStdinWriter.Close()
<-closed
}
// Ensure cancel is called as soon as the provided context is cancelled
go func() {
<-ctx.Done()
cancel()
}()
go func() {
stderr := strings.Builder{}
err := gitcmd.NewCommand("cat-file", "--batch-check").
Run(ctx, &gitcmd.RunOpts{
Dir: repoPath,
Stdin: batchStdinReader,
Stdout: batchStdoutWriter,
Stderr: &stderr,
UseContextTimeout: true,
})
if err != nil {
_ = batchStdoutWriter.CloseWithError(gitcmd.ConcatenateError(err, (&stderr).String()))
_ = batchStdinReader.CloseWithError(gitcmd.ConcatenateError(err, (&stderr).String()))
} else {
_ = batchStdoutWriter.Close()
_ = batchStdinReader.Close()
}
close(closed)
}()
// For simplicities sake we'll use a buffered reader to read from the cat-file --batch-check
batchReader := bufio.NewReader(batchStdoutReader)
return batchStdinWriter, batchReader, cancel
}
// catFileBatch opens git cat-file --batch in the provided repo and returns a stdin pipe, a stdout reader and cancel function
func catFileBatch(ctx context.Context, repoPath string) (WriteCloserError, *bufio.Reader, func()) {
// We often want to feed the commits in order into cat-file --batch, followed by their trees and sub trees as necessary.
// so let's create a batch stdin and stdout
batchStdinReader, batchStdinWriter := io.Pipe()
batchStdoutReader, batchStdoutWriter := nio.Pipe(buffer.New(32 * 1024))
ctx, ctxCancel := context.WithCancel(ctx)
closed := make(chan struct{})
cancel := func() {
ctxCancel()
_ = batchStdinWriter.Close()
_ = batchStdoutReader.Close()
<-closed
}
// Ensure cancel is called as soon as the provided context is cancelled
go func() {
<-ctx.Done()
cancel()
}()
go func() {
stderr := strings.Builder{}
err := gitcmd.NewCommand("cat-file", "--batch").
Run(ctx, &gitcmd.RunOpts{
Dir: repoPath,
Stdin: batchStdinReader,
Stdout: batchStdoutWriter,
Stderr: &stderr,
UseContextTimeout: true,
})
if err != nil {
_ = batchStdoutWriter.CloseWithError(gitcmd.ConcatenateError(err, (&stderr).String()))
_ = batchStdinReader.CloseWithError(gitcmd.ConcatenateError(err, (&stderr).String()))
} else {
_ = batchStdoutWriter.Close()
_ = batchStdinReader.Close()
}
close(closed)
}()
// For simplicities sake we'll us a buffered reader to read from the cat-file --batch
batchReader := bufio.NewReaderSize(batchStdoutReader, 32*1024)
return batchStdinWriter, batchReader, cancel
}
// ReadBatchLine 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 ReadBatchLine(rd *bufio.Reader) (sha []byte, typ string, size int64, err error) {
typ, err = rd.ReadString('\n')
if err != nil {
return sha, typ, size, err
}
if len(typ) == 1 {
typ, err = rd.ReadString('\n')
if err != nil {
return sha, typ, size, err
}
}
idx := strings.IndexByte(typ, ' ')
if idx < 0 {
log.Debug("missing space typ: %s", typ)
return sha, typ, size, ErrNotExist{ID: string(sha)}
}
sha = []byte(typ[:idx])
typ = typ[idx+1:]
idx = strings.IndexByte(typ, ' ')
if idx < 0 {
return sha, typ, size, ErrNotExist{ID: string(sha)}
}
sizeStr := typ[idx+1 : len(typ)-1]
typ = typ[:idx]
size, err = strconv.ParseInt(sizeStr, 10, 64)
return sha, typ, 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 *bufio.Reader, 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 *bufio.Reader, 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)
}
// git tree files are a list:
// <mode-in-ascii> SP <fname> NUL <binary Hash>
//
// Unfortunately this 20-byte notation is somewhat in conflict to all other git tools
// Therefore we need some method to convert these binary hashes to hex hashes
// constant hextable to help quickly convert between binary and hex representation
const hextable = "0123456789abcdef"
// BinToHexHeash converts a binary Hash into a hex encoded one. Input and output can be the
// same byte slice to support in place conversion without allocations.
// This is at least 100x quicker that hex.EncodeToString
func BinToHex(objectFormat ObjectFormat, sha, out []byte) []byte {
for i := objectFormat.FullLength()/2 - 1; i >= 0; i-- {
v := sha[i]
vhi, vlo := v>>4, v&0x0f
shi, slo := hextable[vhi], hextable[vlo]
out[i*2], out[i*2+1] = shi, slo
}
return out
}
// ParseCatFileTreeLine reads an entry from a tree in a cat-file --batch stream
// This carefully avoids allocations - except where fnameBuf is too small.
// It is recommended therefore to pass in an fnameBuf large enough to avoid almost all allocations
//
// Each line is composed of:
// <mode-in-ascii-dropping-initial-zeros> SP <fname> NUL <binary HASH>
//
// We don't attempt to convert the raw HASH to save a lot of time
func ParseCatFileTreeLine(objectFormat ObjectFormat, rd *bufio.Reader, modeBuf, fnameBuf, shaBuf []byte) (mode, fname, sha []byte, n int, err error) {
var readBytes []byte
// Read the Mode & fname
readBytes, err = rd.ReadSlice('\x00')
if err != nil {
return mode, fname, sha, n, err
}
idx := bytes.IndexByte(readBytes, ' ')
if idx < 0 {
log.Debug("missing space in readBytes ParseCatFileTreeLine: %s", readBytes)
return mode, fname, sha, n, &ErrNotExist{}
}
n += idx + 1
copy(modeBuf, readBytes[:idx])
if len(modeBuf) >= idx {
modeBuf = modeBuf[:idx]
} else {
modeBuf = append(modeBuf, readBytes[len(modeBuf):idx]...)
}
mode = modeBuf
readBytes = readBytes[idx+1:]
// Deal with the fname
copy(fnameBuf, readBytes)
if len(fnameBuf) > len(readBytes) {
fnameBuf = fnameBuf[:len(readBytes)]
} else {
fnameBuf = append(fnameBuf, readBytes[len(fnameBuf):]...)
}
for err == bufio.ErrBufferFull {
readBytes, err = rd.ReadSlice('\x00')
fnameBuf = append(fnameBuf, readBytes...)
}
n += len(fnameBuf)
if err != nil {
return mode, fname, sha, n, err
}
fnameBuf = fnameBuf[:len(fnameBuf)-1]
fname = fnameBuf
// Deal with the binary hash
idx = 0
length := objectFormat.FullLength() / 2
for idx < length {
var read int
read, err = rd.Read(shaBuf[idx:length])
n += read
if err != nil {
return mode, fname, sha, n, err
}
idx += read
}
sha = shaBuf
return mode, fname, sha, n, err
}
func DiscardFull(rd *bufio.Reader, 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
}
-219
View File
@@ -1,219 +0,0 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"bufio"
"bytes"
"context"
"io"
"os"
"code.gitea.io/gitea/modules/git/gitcmd"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
)
// 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 {
output io.WriteCloser
reader io.ReadCloser
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.reader.Close()
_ = r.output.Close()
for _, cleanup := range r.cleanupFuncs {
if cleanup != nil {
cleanup()
}
}
return err
}
// CreateBlameReader creates reader for given repository, commit and file
func CreateBlameReader(ctx context.Context, objectFormat ObjectFormat, repoPath string, commit *Commit, file string, bypassBlameIgnore bool) (rd *BlameReader, err error) {
var ignoreRevsFileName string
var ignoreRevsFileCleanup func()
defer func() {
if err != nil && ignoreRevsFileCleanup != nil {
ignoreRevsFileCleanup()
}
}()
cmd := gitcmd.NewCommand("blame", "--porcelain")
if DefaultFeatures().CheckVersionAtLeast("2.23") && !bypassBlameIgnore {
ignoreRevsFileName, ignoreRevsFileCleanup, err = tryCreateBlameIgnoreRevsFile(commit)
if err != nil && !IsErrNotExist(err) {
return nil, err
}
if ignoreRevsFileName != "" {
// 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)
done := make(chan error, 1)
reader, stdout, err := os.Pipe()
if err != nil {
return nil, err
}
go func() {
stderr := bytes.Buffer{}
// 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"
err := cmd.Run(ctx, &gitcmd.RunOpts{
UseContextTimeout: true,
Dir: repoPath,
Stdout: stdout,
Stderr: &stderr,
})
done <- err
_ = stdout.Close()
if err != nil {
log.Error("Error running git blame (dir: %v): %v, stderr: %v", repoPath, err, stderr.String())
}
}()
bufferedReader := bufio.NewReader(reader)
return &BlameReader{
output: stdout,
reader: reader,
bufferedReader: bufferedReader,
done: done,
ignoreRevsFile: ignoreRevsFileName,
objectFormat: objectFormat,
cleanupFuncs: []func(){ignoreRevsFileCleanup},
}, nil
}
func tryCreateBlameIgnoreRevsFile(commit *Commit) (string, func(), error) {
entry, err := commit.GetTreeEntryByPath(".git-blame-ignore-revs")
if err != nil {
return "", nil, err
}
r, err := entry.Blob().DataAsync()
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
}
@@ -1,153 +0,0 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"context"
"testing"
"code.gitea.io/gitea/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 isGogit {
t.Skip("Skipping test since gogit does not support sha256")
return
}
t.Run("Without .git-blame-ignore-revs", func(t *testing.T) {
repo, err := OpenRepository(ctx, "./tests/repos/repo5_pulls_sha256")
assert.NoError(t, err)
defer repo.Close()
commit, err := repo.GetCommit("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, "./tests/repos/repo5_pulls_sha256", 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) {
repo, err := OpenRepository(ctx, "./tests/repos/repo6_blame_sha256")
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()
assert.NoError(t, err)
for _, c := range cases {
commit, err := repo.GetCommit(c.CommitID)
assert.NoError(t, err)
blameReader, err := CreateBlameReader(ctx, objectFormat, "./tests/repos/repo6_blame_sha256", 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)
}
})
}
@@ -1,149 +0,0 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"context"
"testing"
"code.gitea.io/gitea/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) {
repo, err := OpenRepository(ctx, "./tests/repos/repo5_pulls")
assert.NoError(t, err)
defer repo.Close()
commit, err := repo.GetCommit("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, "./tests/repos/repo5_pulls", 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) {
repo, err := OpenRepository(ctx, "./tests/repos/repo6_blame")
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()
assert.NoError(t, err)
for _, c := range cases {
commit, err := repo.GetCommit(c.CommitID)
assert.NoError(t, err)
blameReader, err := CreateBlameReader(ctx, objectFormat, "./tests/repos/repo6_blame", 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)
}
})
}
@@ -6,8 +6,6 @@
package git
import (
"bufio"
"bytes"
"io"
"code.gitea.io/gitea/modules/log"
@@ -25,38 +23,28 @@ type Blob struct {
// DataAsync gets a ReadCloser for the contents of a blob without reading it all.
// Calling the Close function on the result will discard all unread output.
func (b *Blob) DataAsync() (io.ReadCloser, error) {
wr, rd, cancel, err := b.repo.CatFileBatch(b.repo.Ctx)
func (b *Blob) DataAsync() (_ io.ReadCloser, retErr error) {
batch, cancel, err := b.repo.CatFileBatch(b.repo.Ctx)
if err != nil {
return nil, err
}
defer func() {
// if there was an error, cancel the batch right away,
// otherwise let the caller close it
if retErr != nil {
cancel()
}
}()
_, err = wr.Write([]byte(b.ID.String() + "\n"))
info, contentReader, err := batch.QueryContent(b.ID.String())
if err != nil {
cancel()
return nil, err
}
_, _, size, err := ReadBatchLine(rd)
if err != nil {
cancel()
return nil, err
}
b.gotSize = true
b.size = size
if size < 4096 {
bs, err := io.ReadAll(io.LimitReader(rd, size))
defer cancel()
if err != nil {
return nil, err
}
_, err = rd.Discard(1)
return io.NopCloser(bytes.NewReader(bs)), err
}
b.size = info.Size
return &blobReader{
rd: rd,
n: size,
rd: contentReader,
n: info.Size,
cancel: cancel,
}, nil
}
@@ -67,30 +55,24 @@ func (b *Blob) Size() int64 {
return b.size
}
wr, rd, cancel, err := b.repo.CatFileBatchCheck(b.repo.Ctx)
batch, cancel, err := b.repo.CatFileBatch(b.repo.Ctx)
if err != nil {
log.Debug("error whilst reading size for %s in %s. Error: %v", b.ID.String(), b.repo.Path, err)
return 0
}
defer cancel()
_, err = wr.Write([]byte(b.ID.String() + "\n"))
info, err := batch.QueryInfo(b.ID.String())
if err != nil {
log.Debug("error whilst reading size for %s in %s. Error: %v", b.ID.String(), b.repo.Path, err)
return 0
}
_, _, b.size, err = ReadBatchLine(rd)
if err != nil {
log.Debug("error whilst reading size for %s in %s. Error: %v", b.ID.String(), b.repo.Path, err)
return 0
}
b.gotSize = true
b.size = info.Size
return b.size
}
type blobReader struct {
rd *bufio.Reader
rd BufferedReader
n int64
cancel func()
}
@@ -0,0 +1,52 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"context"
"io"
)
type BufferedReader interface {
io.Reader
Buffered() int
Peek(n int) ([]byte, error)
Discard(n int) (int, error)
ReadString(sep byte) (string, error)
ReadSlice(sep byte) ([]byte, error)
ReadBytes(sep byte) ([]byte, error)
}
type CatFileObject struct {
ID string
Type string
Size int64
}
type CatFileBatch interface {
// QueryInfo queries the object info from the git repository by its object name using "git cat-file --batch" family commands.
// "git cat-file" accepts "<rev>" for the object name, it can be a ref name, object id, etc. https://git-scm.com/docs/gitrevisions
// In Gitea, we only use the simple ref name or object id, no other complex rev syntax like "suffix" or "git describe" although they are supported by git.
QueryInfo(obj string) (*CatFileObject, error)
// QueryContent is similar to QueryInfo, it queries the object info and additionally returns a reader for its content.
// FIXME: this design still follows the old pattern: the returned BufferedReader is very fragile,
// callers should carefully maintain its lifecycle and discard all unread data.
// TODO: It needs to be refactored to a fully managed Reader stream in the future, don't let callers manually Close or Discard
QueryContent(obj string) (*CatFileObject, BufferedReader, error)
}
type CatFileBatchCloser interface {
CatFileBatch
Close()
}
// NewBatch creates a "batch object provider (CatFileBatch)" for the given repository path to retrieve object info and content efficiently.
// The CatFileBatch and the readers create by it should only be used in the same goroutine.
func NewBatch(ctx context.Context, repoPath string) (CatFileBatchCloser, error) {
if DefaultFeatures().SupportCatFileBatchCommand {
return newCatFileBatchCommand(ctx, repoPath)
}
return newCatFileBatchLegacy(ctx, repoPath)
}
@@ -0,0 +1,74 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"context"
"os"
"path/filepath"
"strings"
"code.gitea.io/gitea/modules/git/gitcmd"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
)
// catFileBatchCommand implements the CatFileBatch interface using the "cat-file --batch-command" command
// for git version >= 2.36
// ref: https://git-scm.com/docs/git-cat-file#Documentation/git-cat-file.txt---batch-command
type catFileBatchCommand struct {
ctx context.Context
repoPath string
batch *catFileBatchCommunicator
}
var _ CatFileBatch = (*catFileBatchCommand)(nil)
func newCatFileBatchCommand(ctx context.Context, repoPath string) (*catFileBatchCommand, error) {
if _, err := os.Stat(repoPath); err != nil {
return nil, util.NewNotExistErrorf("repo %q doesn't exist", filepath.Base(repoPath))
}
return &catFileBatchCommand{ctx: ctx, repoPath: repoPath}, nil
}
func (b *catFileBatchCommand) getBatch() *catFileBatchCommunicator {
if b.batch != nil {
return b.batch
}
b.batch = newCatFileBatch(b.ctx, b.repoPath, gitcmd.NewCommand("cat-file", "--batch-command"))
return b.batch
}
func (b *catFileBatchCommand) QueryContent(obj string) (*CatFileObject, BufferedReader, error) {
if strings.Contains(obj, "\n") {
setting.PanicInDevOrTesting("invalid object name with newline: %q", obj)
}
_, err := b.getBatch().reqWriter.Write([]byte("contents " + obj + "\n"))
if err != nil {
return nil, nil, err
}
info, err := catFileBatchParseInfoLine(b.getBatch().respReader)
if err != nil {
return nil, nil, err
}
return info, b.getBatch().respReader, nil
}
func (b *catFileBatchCommand) QueryInfo(obj string) (*CatFileObject, error) {
if strings.Contains(obj, "\n") {
setting.PanicInDevOrTesting("invalid object name with newline: %q", obj)
}
_, err := b.getBatch().reqWriter.Write([]byte("info " + obj + "\n"))
if err != nil {
return nil, err
}
return catFileBatchParseInfoLine(b.getBatch().respReader)
}
func (b *catFileBatchCommand) Close() {
if b.batch != nil {
b.batch.Close()
b.batch = nil
}
}
@@ -0,0 +1,89 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"context"
"io"
"os"
"path/filepath"
"strings"
"code.gitea.io/gitea/modules/git/gitcmd"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
)
// catFileBatchLegacy implements the CatFileBatch interface using the "cat-file --batch" command and "cat-file --batch-check" command
// for git version < 2.36
// to align with "--batch-command", it creates the two commands for querying object contents and object info separately
// ref: https://git-scm.com/docs/git-cat-file#Documentation/git-cat-file.txt---batch
type catFileBatchLegacy struct {
ctx context.Context
repoPath string
batchContent *catFileBatchCommunicator
batchCheck *catFileBatchCommunicator
}
var _ CatFileBatchCloser = (*catFileBatchLegacy)(nil)
func newCatFileBatchLegacy(ctx context.Context, repoPath string) (*catFileBatchLegacy, error) {
if _, err := os.Stat(repoPath); err != nil {
return nil, util.NewNotExistErrorf("repo %q doesn't exist", filepath.Base(repoPath))
}
return &catFileBatchLegacy{ctx: ctx, repoPath: repoPath}, nil
}
func (b *catFileBatchLegacy) getBatchContent() *catFileBatchCommunicator {
if b.batchContent != nil {
return b.batchContent
}
b.batchContent = newCatFileBatch(b.ctx, b.repoPath, gitcmd.NewCommand("cat-file", "--batch"))
return b.batchContent
}
func (b *catFileBatchLegacy) getBatchCheck() *catFileBatchCommunicator {
if b.batchCheck != nil {
return b.batchCheck
}
b.batchCheck = newCatFileBatch(b.ctx, b.repoPath, gitcmd.NewCommand("cat-file", "--batch-check"))
return b.batchCheck
}
func (b *catFileBatchLegacy) QueryContent(obj string) (*CatFileObject, BufferedReader, error) {
if strings.Contains(obj, "\n") {
setting.PanicInDevOrTesting("invalid object name with newline: %q", obj)
}
_, err := io.WriteString(b.getBatchContent().reqWriter, obj+"\n")
if err != nil {
return nil, nil, err
}
info, err := catFileBatchParseInfoLine(b.getBatchContent().respReader)
if err != nil {
return nil, nil, err
}
return info, b.getBatchContent().respReader, nil
}
func (b *catFileBatchLegacy) QueryInfo(obj string) (*CatFileObject, error) {
if strings.Contains(obj, "\n") {
setting.PanicInDevOrTesting("invalid object name with newline: %q", obj)
}
_, err := io.WriteString(b.getBatchCheck().reqWriter, obj+"\n")
if err != nil {
return nil, err
}
return catFileBatchParseInfoLine(b.getBatchCheck().respReader)
}
func (b *catFileBatchLegacy) Close() {
if b.batchContent != nil {
b.batchContent.Close()
b.batchContent = nil
}
if b.batchCheck != nil {
b.batchCheck.Close()
b.batchCheck = nil
}
}
@@ -0,0 +1,264 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"bufio"
"bytes"
"context"
"errors"
"io"
"math"
"strconv"
"strings"
"sync/atomic"
"code.gitea.io/gitea/modules/git/gitcmd"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/util"
)
type catFileBatchCommunicator struct {
closeFunc atomic.Pointer[func(err error)]
reqWriter io.Writer
respReader *bufio.Reader
debugGitCmd *gitcmd.Command
}
func (b *catFileBatchCommunicator) Close(err ...error) {
if fn := b.closeFunc.Swap(nil); fn != nil {
(*fn)(util.OptionalArg(err))
}
}
// newCatFileBatch opens git cat-file --batch/--batch-check/--batch-command command and prepares the stdin/stdout pipes for communication.
func newCatFileBatch(ctx context.Context, repoPath string, cmdCatFile *gitcmd.Command) *catFileBatchCommunicator {
ctx, ctxCancel := context.WithCancelCause(ctx)
stdinWriter, stdoutReader, stdPipeClose := cmdCatFile.MakeStdinStdoutPipe()
ret := &catFileBatchCommunicator{
debugGitCmd: cmdCatFile,
reqWriter: stdinWriter,
respReader: bufio.NewReaderSize(stdoutReader, 32*1024), // use a buffered reader for rich operations
}
ret.closeFunc.Store(new(func(err error) {
ctxCancel(err)
stdPipeClose()
}))
err := cmdCatFile.WithDir(repoPath).StartWithStderr(ctx)
if err != nil {
log.Error("Unable to start git command %v: %v", cmdCatFile.LogString(), err)
// ideally here it should return the error, but it would require refactoring all callers
// so just return a dummy communicator that does nothing, almost the same behavior as before, not bad
ret.Close(err)
return ret
}
go func() {
err := cmdCatFile.WaitWithStderr()
if err != nil && !errors.Is(err, context.Canceled) {
log.Error("cat-file --batch command failed in repo %s, error: %v", repoPath, err)
}
ret.Close(err)
}()
return ret
}
func (b *catFileBatchCommunicator) debugKill() (ret struct {
beforeClose chan struct{}
blockClose chan struct{}
afterClose chan struct{}
},
) {
ret.beforeClose = make(chan struct{})
ret.blockClose = make(chan struct{})
ret.afterClose = make(chan struct{})
oldCloseFunc := b.closeFunc.Load()
b.closeFunc.Store(new(func(err error) {
b.closeFunc.Store(nil)
close(ret.beforeClose)
<-ret.blockClose
(*oldCloseFunc)(err)
close(ret.afterClose)
}))
b.debugGitCmd.DebugKill()
return ret
}
// catFileBatchParseInfoLine reads the header line from cat-file --batch
// We expect: <oid> SP <type> SP <size> LF
// then leaving the rest of the stream "<contents> LF" to be read
func catFileBatchParseInfoLine(rd BufferedReader) (*CatFileObject, error) {
typ, err := rd.ReadString('\n')
if err != nil {
return nil, err
}
if len(typ) == 1 {
typ, err = rd.ReadString('\n')
if err != nil {
return nil, err
}
}
idx := strings.IndexByte(typ, ' ')
if idx < 0 {
return nil, ErrNotExist{}
}
sha := typ[:idx]
typ = typ[idx+1:]
idx = strings.IndexByte(typ, ' ')
if idx < 0 {
return nil, ErrNotExist{ID: sha}
}
sizeStr := typ[idx+1 : len(typ)-1]
typ = typ[:idx]
size, err := strconv.ParseInt(sizeStr, 10, 64)
return &CatFileObject{ID: sha, Type: typ, Size: size}, err
}
// ReadTagObjectID reads a tag object ID hash from a cat-file --batch stream, throwing away the rest of the stream.
func ReadTagObjectID(rd BufferedReader, size int64) (string, error) {
var id string
var n int64
headerLoop:
for {
line, err := rd.ReadBytes('\n')
if err != nil {
return "", err
}
n += int64(len(line))
idx := bytes.Index(line, []byte{' '})
if idx < 0 {
continue
}
if string(line[:idx]) == "object" {
id = string(line[idx+1 : len(line)-1])
break headerLoop
}
}
// Discard the rest of the tag
return id, DiscardFull(rd, size-n+1)
}
// ReadTreeID reads a tree ID from a cat-file --batch stream, throwing away the rest of the stream.
func ReadTreeID(rd BufferedReader, size int64) (string, error) {
var id string
var n int64
headerLoop:
for {
line, err := rd.ReadBytes('\n')
if err != nil {
return "", err
}
n += int64(len(line))
idx := bytes.Index(line, []byte{' '})
if idx < 0 {
continue
}
if string(line[:idx]) == "tree" {
id = string(line[idx+1 : len(line)-1])
break headerLoop
}
}
// Discard the rest of the commit
return id, DiscardFull(rd, size-n+1)
}
// git tree files are a list:
// <mode-in-ascii> SP <fname> NUL <binary Hash>
//
// Unfortunately this 20-byte notation is somewhat in conflict to all other git tools
// Therefore we need some method to convert these binary hashes to hex hashes
// ParseCatFileTreeLine reads an entry from a tree in a cat-file --batch stream
// This carefully avoids allocations - except where fnameBuf is too small.
// It is recommended therefore to pass in an fnameBuf large enough to avoid almost all allocations
//
// Each line is composed of:
// <mode-in-ascii-dropping-initial-zeros> SP <fname> NUL <binary HASH>
//
// We don't attempt to convert the raw HASH to save a lot of time
func ParseCatFileTreeLine(objectFormat ObjectFormat, rd BufferedReader, modeBuf, fnameBuf, shaBuf []byte) (mode, fname, sha []byte, n int, err error) {
var readBytes []byte
// Read the Mode & fname
readBytes, err = rd.ReadSlice('\x00')
if err != nil {
return mode, fname, sha, n, err
}
idx := bytes.IndexByte(readBytes, ' ')
if idx < 0 {
log.Debug("missing space in readBytes ParseCatFileTreeLine: %s", readBytes)
return mode, fname, sha, n, &ErrNotExist{}
}
n += idx + 1
copy(modeBuf, readBytes[:idx])
if len(modeBuf) >= idx {
modeBuf = modeBuf[:idx]
} else {
modeBuf = append(modeBuf, readBytes[len(modeBuf):idx]...)
}
mode = modeBuf
readBytes = readBytes[idx+1:]
// Deal with the fname
copy(fnameBuf, readBytes)
if len(fnameBuf) > len(readBytes) {
fnameBuf = fnameBuf[:len(readBytes)]
} else {
fnameBuf = append(fnameBuf, readBytes[len(fnameBuf):]...)
}
for err == bufio.ErrBufferFull {
readBytes, err = rd.ReadSlice('\x00')
fnameBuf = append(fnameBuf, readBytes...)
}
n += len(fnameBuf)
if err != nil {
return mode, fname, sha, n, err
}
fnameBuf = fnameBuf[:len(fnameBuf)-1]
fname = fnameBuf
// Deal with the binary hash
idx = 0
length := objectFormat.FullLength() / 2
for idx < length {
var read int
read, err = rd.Read(shaBuf[idx:length])
n += read
if err != nil {
return mode, fname, sha, n, err
}
idx += read
}
sha = shaBuf
return mode, fname, sha, n, err
}
func DiscardFull(rd BufferedReader, discard int64) error {
if discard > math.MaxInt32 {
n, err := rd.Discard(math.MaxInt32)
discard -= int64(n)
if err != nil {
return err
}
}
for discard > 0 {
n, err := rd.Discard(int(discard))
discard -= int64(n)
if err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,105 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"io"
"os"
"path/filepath"
"testing"
"code.gitea.io/gitea/modules/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCatFileBatch(t *testing.T) {
defer test.MockVariableValue(&DefaultFeatures().SupportCatFileBatchCommand)()
DefaultFeatures().SupportCatFileBatchCommand = false
t.Run("LegacyCheck", testCatFileBatch)
DefaultFeatures().SupportCatFileBatchCommand = true
t.Run("BatchCommand", testCatFileBatch)
}
func testCatFileBatch(t *testing.T) {
t.Run("CorruptedGitRepo", func(t *testing.T) {
tmpDir := t.TempDir()
batch, err := NewBatch(t.Context(), tmpDir)
// as long as the directory exists, no error, because we can't really know whether the git repo is valid until we run commands
require.NoError(t, err)
defer batch.Close()
_, err = batch.QueryInfo("e2129701f1a4d54dc44f03c93bca0a2aec7c5449")
require.Error(t, err)
_, err = batch.QueryInfo("e2129701f1a4d54dc44f03c93bca0a2aec7c5449")
require.Error(t, err)
})
simulateQueryTerminated := func(t *testing.T, errBeforePipeClose, errAfterPipeClose error) {
readError := func(t *testing.T, r io.Reader, expectedErr error) {
if expectedErr == nil {
return // expectedErr == nil means this read should be skipped
}
n, err := r.Read(make([]byte, 100))
assert.Zero(t, n)
assert.ErrorIs(t, err, expectedErr)
}
batch, err := NewBatch(t.Context(), filepath.Join(testReposDir, "repo1_bare"))
require.NoError(t, err)
defer batch.Close()
_, err = batch.QueryInfo("e2129701f1a4d54dc44f03c93bca0a2aec7c5449")
require.NoError(t, err)
var c *catFileBatchCommunicator
switch b := batch.(type) {
case *catFileBatchLegacy:
c = b.batchCheck
_, _ = c.reqWriter.Write([]byte("in-complete-line-"))
case *catFileBatchCommand:
c = b.batch
_, _ = c.reqWriter.Write([]byte("info"))
default:
t.FailNow()
}
require.NotEqual(t, errBeforePipeClose == nil, errAfterPipeClose == nil, "must set exactly one of the expected errors")
inceptor := c.debugKill()
<-inceptor.beforeClose // wait for the command's Close to be called, the pipe is not closed yet
readError(t, c.respReader, errBeforePipeClose) // then caller will read on an open pipe which will be closed soon
close(inceptor.blockClose) // continue to close the pipe
<-inceptor.afterClose // wait for the pipe to be closed
readError(t, c.respReader, errAfterPipeClose) // then caller will read on a closed pipe
}
t.Run("QueryTerminated", func(t *testing.T) {
simulateQueryTerminated(t, io.EOF, nil) // reader is faster
simulateQueryTerminated(t, nil, os.ErrClosed) // pipes are closed faster
})
batch, err := NewBatch(t.Context(), filepath.Join(testReposDir, "repo1_bare"))
require.NoError(t, err)
defer batch.Close()
t.Run("QueryInfo", func(t *testing.T) {
info, err := batch.QueryInfo("e2129701f1a4d54dc44f03c93bca0a2aec7c5449")
require.NoError(t, err)
assert.Equal(t, "e2129701f1a4d54dc44f03c93bca0a2aec7c5449", info.ID)
assert.Equal(t, "blob", info.Type)
assert.EqualValues(t, 6, info.Size)
})
t.Run("QueryContent", func(t *testing.T) {
info, rd, err := batch.QueryContent("e2129701f1a4d54dc44f03c93bca0a2aec7c5449")
require.NoError(t, err)
assert.Equal(t, "e2129701f1a4d54dc44f03c93bca0a2aec7c5449", info.ID)
assert.Equal(t, "blob", info.Type)
assert.EqualValues(t, 6, info.Size)
content, err := io.ReadAll(io.LimitReader(rd, info.Size))
require.NoError(t, err)
require.Equal(t, "file1\n", string(content))
})
}
+10 -218
View File
@@ -5,17 +5,13 @@
package git
import (
"bufio"
"bytes"
"context"
"errors"
"io"
"os/exec"
"strconv"
"strings"
"code.gitea.io/gitea/modules/git/gitcmd"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/util"
)
@@ -41,7 +37,7 @@ type CommitSignature struct {
// Message returns the commit message. Same as retrieving CommitMessage directly.
func (c *Commit) Message() string {
return c.CommitMessage
return strings.ToValidUTF8(c.CommitMessage, "?")
}
// Summary returns first line of commit message.
@@ -86,109 +82,6 @@ func (c *Commit) GetCommitByPath(relpath string) (*Commit, error) {
return c.repo.getCommitByPathWithID(c.ID, relpath)
}
// AddChanges marks local changes to be ready for commit.
func AddChanges(ctx context.Context, repoPath string, all bool, files ...string) error {
cmd := gitcmd.NewCommand().AddArguments("add")
if all {
cmd.AddArguments("--all")
}
cmd.AddDashesAndList(files...)
_, _, err := cmd.RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath})
return err
}
// CommitChangesOptions the options when a commit created
type CommitChangesOptions struct {
Committer *Signature
Author *Signature
Message string
}
// CommitChanges commits local changes with given committer, author and message.
// If author is nil, it will be the same as committer.
func CommitChanges(ctx context.Context, repoPath string, opts CommitChangesOptions) error {
cmd := gitcmd.NewCommand()
if opts.Committer != nil {
cmd.AddOptionValues("-c", "user.name="+opts.Committer.Name)
cmd.AddOptionValues("-c", "user.email="+opts.Committer.Email)
}
cmd.AddArguments("commit")
if opts.Author == nil {
opts.Author = opts.Committer
}
if opts.Author != nil {
cmd.AddOptionFormat("--author='%s <%s>'", opts.Author.Name, opts.Author.Email)
}
cmd.AddOptionFormat("--message=%s", opts.Message)
_, _, err := cmd.RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath})
// No stderr but exit status 1 means nothing to commit.
if err != nil && err.Error() == "exit status 1" {
return nil
}
return err
}
// AllCommitsCount returns count of all commits in repository
func AllCommitsCount(ctx context.Context, repoPath string, 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.RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath})
if err != nil {
return 0, err
}
return strconv.ParseInt(strings.TrimSpace(stdout), 10, 64)
}
// CommitsCountOptions the options when counting commits
type CommitsCountOptions struct {
RepoPath string
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, opts CommitsCountOptions) (int64, error) {
cmd := gitcmd.NewCommand("rev-list", "--count")
cmd.AddDynamicArguments(opts.Revision...)
if opts.Not != "" {
cmd.AddOptionValues("--not", opts.Not)
}
if len(opts.RelPath) > 0 {
cmd.AddDashesAndList(opts.RelPath...)
}
stdout, _, err := cmd.RunStdString(ctx, &gitcmd.RunOpts{Dir: opts.RepoPath})
if err != nil {
return 0, err
}
return strconv.ParseInt(strings.TrimSpace(stdout), 10, 64)
}
// CommitsCount returns number of total commits of until current revision.
func (c *Commit) CommitsCount() (int64, error) {
return CommitsCount(c.repo.Ctx, CommitsCountOptions{
RepoPath: c.repo.Path,
Revision: []string{c.ID.String()},
})
}
// CommitsByRange returns the specific page commits before current revision, every page's number default by CommitsRangeSize
func (c *Commit) CommitsByRange(page, pageSize int, not, since, until string) ([]*Commit, error) {
return c.repo.commitsByRangeWithTime(c.ID, page, pageSize, not, since, until)
@@ -208,7 +101,10 @@ func (c *Commit) HasPreviousCommit(objectID ObjectID) (bool, error) {
return false, nil
}
_, _, err := gitcmd.NewCommand("merge-base", "--is-ancestor").AddDynamicArguments(that, this).RunStdString(c.repo.Ctx, &gitcmd.RunOpts{Dir: c.repo.Path})
_, _, err := gitcmd.NewCommand("merge-base", "--is-ancestor").
AddDynamicArguments(that, this).
WithDir(c.repo.Path).
RunStdString(c.repo.Ctx)
if err == nil {
return true, nil
}
@@ -347,110 +243,14 @@ func (c *Commit) GetFileContent(filename string, limit int) (string, error) {
return string(bytes), nil
}
// GetBranchName gets the closest branch name (as returned by 'git name-rev --name-only')
func (c *Commit) GetBranchName() (string, error) {
cmd := gitcmd.NewCommand("name-rev")
if DefaultFeatures().CheckVersionAtLeast("2.13.0") {
cmd.AddArguments("--exclude", "refs/tags/*")
}
cmd.AddArguments("--name-only", "--no-undefined").AddDynamicArguments(c.ID.String())
data, _, err := cmd.RunStdString(c.repo.Ctx, &gitcmd.RunOpts{Dir: c.repo.Path})
if err != nil {
// handle special case where git can not describe commit
if strings.Contains(err.Error(), "cannot describe") {
return "", nil
}
return "", err
}
// name-rev commitID output will be "master" or "master~12"
return strings.SplitN(strings.TrimSpace(data), "~", 2)[0], nil
}
// 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, repoPath, commitID string) (*CommitFileStatus, error) {
stdout, w := io.Pipe()
done := make(chan struct{})
fileStatus := NewCommitFileStatus()
go func() {
parseCommitFileStatus(fileStatus, stdout)
close(done)
}()
stderr := new(bytes.Buffer)
err := gitcmd.NewCommand("log", "--name-status", "-m", "--pretty=format:", "--first-parent", "--no-renames", "-z", "-1").AddDynamicArguments(commitID).Run(ctx, &gitcmd.RunOpts{
Dir: repoPath,
Stdout: w,
Stderr: stderr,
})
w.Close() // Close writer to exit parsing goroutine
if err != nil {
return nil, gitcmd.ConcatenateError(err, stderr.String())
}
<-done
return fileStatus, nil
}
// GetFullCommitID returns full length (40) of commit ID by given short SHA in a repository.
func GetFullCommitID(ctx context.Context, repoPath, shortID string) (string, error) {
commitID, _, err := gitcmd.NewCommand("rev-parse").AddDynamicArguments(shortID).RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath})
commitID, _, err := gitcmd.NewCommand("rev-parse").
AddDynamicArguments(shortID).
WithDir(repoPath).
RunStdString(ctx)
if err != nil {
if strings.Contains(err.Error(), "exit status 128") {
if gitcmd.IsErrorExitCode(err, 128) {
return "", ErrNotExist{shortID, ""}
}
return "", err
@@ -458,14 +258,6 @@ func GetFullCommitID(ctx context.Context, repoPath, shortID string) (string, err
return strings.TrimSpace(commitID), nil
}
// GetRepositoryDefaultPublicGPGKey returns the default public key for this commit
func (c *Commit) GetRepositoryDefaultPublicGPGKey(forceUpdate bool) (*GPGSettings, error) {
if c.repo == nil {
return nil, nil
}
return c.repo.GetDefaultPublicGPGKey(forceUpdate)
}
func IsStringLikelyCommitID(objFmt ObjectFormat, s string, minLength ...int) bool {
maxLen := 64 // sha256
if objFmt != nil {
@@ -30,28 +30,57 @@ func cloneRepo(tb testing.TB, url string) (string, error) {
}
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]string
ExpectedIDs map[string]expectedEntryInfo
ExpectedTreeCommit string
}{
{"8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2", "", map[string]string{
"file1.txt": "95bb4d39648ee7e325106df01a621c530863a653",
"file2.txt": "8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2",
{"8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2", "", map[string]expectedEntryInfo{
"file1.txt": {
CommitID: "95bb4d39648ee7e325106df01a621c530863a653",
Size: 6,
},
"file2.txt": {
CommitID: "8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2",
Size: 6,
},
}, "8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2"},
{"2839944139e0de9737a044f78b0e4b40d989a9e3", "", map[string]string{
"file1.txt": "2839944139e0de9737a044f78b0e4b40d989a9e3",
"branch1.txt": "9c9aef8dd84e02bc7ec12641deb4c930a7c30185",
{"2839944139e0de9737a044f78b0e4b40d989a9e3", "", map[string]expectedEntryInfo{
"file1.txt": {
CommitID: "2839944139e0de9737a044f78b0e4b40d989a9e3",
Size: 15,
},
"branch1.txt": {
CommitID: "9c9aef8dd84e02bc7ec12641deb4c930a7c30185",
Size: 8,
},
}, "2839944139e0de9737a044f78b0e4b40d989a9e3"},
{"5c80b0245c1c6f8343fa418ec374b13b5d4ee658", "branch2", map[string]string{
"branch2.txt": "5c80b0245c1c6f8343fa418ec374b13b5d4ee658",
{"5c80b0245c1c6f8343fa418ec374b13b5d4ee658", "branch2", map[string]expectedEntryInfo{
"branch2.txt": {
CommitID: "5c80b0245c1c6f8343fa418ec374b13b5d4ee658",
Size: 8,
},
}, "5c80b0245c1c6f8343fa418ec374b13b5d4ee658"},
{"feaf4ba6bc635fec442f46ddd4512416ec43c2c2", "", map[string]string{
"file1.txt": "95bb4d39648ee7e325106df01a621c530863a653",
"file2.txt": "8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2",
"foo": "37991dec2c8e592043f47155ce4808d4580f9123",
{"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 {
@@ -93,11 +122,12 @@ func testGetCommitsInfo(t *testing.T, repo1 *Repository) {
for _, commitInfo := range commitsInfo {
entry := commitInfo.Entry
commit := commitInfo.Commit
expectedID, ok := testCase.ExpectedIDs[entry.Name()]
expectedInfo, ok := testCase.ExpectedIDs[entry.Name()]
if !assert.True(t, ok) {
continue
}
assert.Equal(t, expectedID, commit.ID.String())
assert.Equal(t, expectedInfo.CommitID, commit.ID.String())
assert.Equal(t, expectedInfo.Size, entry.Size(), entry.Name())
}
}
}
@@ -173,7 +203,6 @@ func BenchmarkEntries_GetCommitsInfo(b *testing.B) {
} else if entries, err = commit.Tree.ListEntries(); err != nil {
b.Fatal(err)
}
entries.Sort()
b.ResetTimer()
b.Run(benchmark.name, func(b *testing.B) {
for b.Loop() {
@@ -14,33 +14,6 @@ import (
"github.com/stretchr/testify/require"
)
func TestCommitsCountSha256(t *testing.T) {
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare_sha256")
commitsCount, err := CommitsCount(t.Context(),
CommitsCountOptions{
RepoPath: bareRepo1Path,
Revision: []string{"f004f41359117d319dedd0eaab8c5259ee2263da839dcba33637997458627fdc"},
})
assert.NoError(t, err)
assert.Equal(t, int64(3), commitsCount)
}
func TestCommitsCountWithoutBaseSha256(t *testing.T) {
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare_sha256")
commitsCount, err := CommitsCount(t.Context(),
CommitsCountOptions{
RepoPath: bareRepo1Path,
Not: "main",
Revision: []string{"branch1"},
})
assert.NoError(t, err)
assert.Equal(t, int64(2), commitsCount)
}
func TestGetFullCommitIDSha256(t *testing.T) {
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare_sha256")
@@ -157,39 +130,3 @@ func TestHasPreviousCommitSha256(t *testing.T) {
assert.NoError(t, err)
assert.False(t, selfNot)
}
func TestGetCommitFileStatusMergesSha256(t *testing.T) {
bareRepo1Path := filepath.Join(testReposDir, "repo6_merge_sha256")
commitFileStatus, err := GetCommitFileStatus(t.Context(), bareRepo1Path, "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(), bareRepo1Path, "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)
}
@@ -16,7 +16,7 @@ func (c *Commit) GetSubModules() (*ObjectCache[*SubModule], error) {
entry, err := c.GetTreeEntryByPath(".gitmodules")
if err != nil {
if _, ok := err.(ErrNotExist); ok {
return nil, nil
return nil, nil //nolint:nilnil // return nil to indicate that the submodule does not exist
}
return nil, err
}
@@ -48,5 +48,5 @@ func (c *Commit) GetSubModule(entryName string) (*SubModule, error) {
return module, nil
}
}
return nil, nil
return nil, nil //nolint:nilnil // return nil to indicate that the submodule does not exist
}
+8 -155
View File
@@ -13,33 +13,6 @@ import (
"github.com/stretchr/testify/require"
)
func TestCommitsCount(t *testing.T) {
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
commitsCount, err := CommitsCount(t.Context(),
CommitsCountOptions{
RepoPath: bareRepo1Path,
Revision: []string{"8006ff9adbf0cb94da7dad9e537e53817f9fa5c0"},
})
assert.NoError(t, err)
assert.Equal(t, int64(3), commitsCount)
}
func TestCommitsCountWithoutBase(t *testing.T) {
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
commitsCount, err := CommitsCount(t.Context(),
CommitsCountOptions{
RepoPath: bareRepo1Path,
Not: "master",
Revision: []string{"branch1"},
})
assert.NoError(t, err)
assert.Equal(t, int64(2), commitsCount)
}
func TestGetFullCommitID(t *testing.T) {
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
@@ -186,6 +159,14 @@ ISO-8859-1`, commitFromReader.Signature.Payload)
assert.Equal(t, commitFromReader, commitFromReader2)
}
func TestCommitMessageSanitizesInvalidUTF8(t *testing.T) {
commit := &Commit{
CommitMessage: "title \xff\n\n\nbody \xff\n\n\n",
}
assert.Equal(t, "title ?\n\n\nbody ?\n\n\n", commit.Message())
assert.Equal(t, "title ?", commit.Summary())
}
func TestHasPreviousCommit(t *testing.T) {
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
@@ -212,134 +193,6 @@ func TestHasPreviousCommit(t *testing.T) {
assert.False(t, selfNot)
}
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) {
bareRepo1Path := filepath.Join(testReposDir, "repo6_merge")
commitFileStatus, err := GetCommitFileStatus(t.Context(), bareRepo1Path, "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 Test_GetCommitBranchStart(t *testing.T) {
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
repo, err := OpenRepository(t.Context(), bareRepo1Path)
+12 -9
View File
@@ -118,7 +118,9 @@ func syncGitConfig(ctx context.Context) (err error) {
}
func configSet(ctx context.Context, key, value string) error {
stdout, _, err := gitcmd.NewCommand("config", "--global", "--get").AddDynamicArguments(key).RunStdString(ctx, nil)
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)
}
@@ -128,8 +130,9 @@ func configSet(ctx context.Context, key, value string) error {
return nil
}
_, _, err = gitcmd.NewCommand("config", "--global").AddDynamicArguments(key, value).RunStdString(ctx, nil)
if err != 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)
}
@@ -137,14 +140,14 @@ func configSet(ctx context.Context, key, value string) error {
}
func configSetNonExist(ctx context.Context, key, value string) error {
_, _, err := gitcmd.NewCommand("config", "--global", "--get").AddDynamicArguments(key).RunStdString(ctx, nil)
_, _, 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, nil)
_, _, 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)
}
@@ -155,14 +158,14 @@ func configSetNonExist(ctx context.Context, key, value string) error {
}
func configAddNonExist(ctx context.Context, key, value string) error {
_, _, err := gitcmd.NewCommand("config", "--global", "--get").AddDynamicArguments(key, regexp.QuoteMeta(value)).RunStdString(ctx, nil)
_, _, 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, nil)
_, _, 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)
}
@@ -172,10 +175,10 @@ func configAddNonExist(ctx context.Context, key, value string) error {
}
func configUnsetAll(ctx context.Context, key, value string) error {
_, _, err := gitcmd.NewCommand("config", "--global", "--get").AddDynamicArguments(key).RunStdString(ctx, nil)
_, _, 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, nil)
_, _, 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)
}
+58 -71
View File
@@ -5,71 +5,83 @@ package git
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"os"
"regexp"
"strconv"
"strings"
"code.gitea.io/gitea/modules/git/gitcmd"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
)
// RawDiffType type of a raw diff.
// RawDiffType output format: diff or patch
type RawDiffType string
// RawDiffType possible values.
const (
RawDiffNormal RawDiffType = "diff"
RawDiffPatch RawDiffType = "patch"
)
// GetRawDiff dumps diff results of repository in given commit ID to io.Writer.
func GetRawDiff(repo *Repository, commitID string, diffType RawDiffType, writer io.Writer) error {
return GetRepoRawDiffForFile(repo, "", commitID, diffType, "", writer)
}
// GetReverseRawDiff dumps the reverse diff results of repository in given commit ID to io.Writer.
func GetReverseRawDiff(ctx context.Context, repoPath, commitID string, writer io.Writer) error {
stderr := new(bytes.Buffer)
cmd := gitcmd.NewCommand("show", "--pretty=format:revert %H%n", "-R").AddDynamicArguments(commitID)
if err := cmd.Run(ctx, &gitcmd.RunOpts{
Dir: repoPath,
Stdout: writer,
Stderr: stderr,
}); err != nil {
return fmt.Errorf("Run: %w - %s", err, stderr)
func GetRawDiff(repo *Repository, commitID string, diffType RawDiffType, writer io.Writer) (retErr error) {
cmd, err := getRepoRawDiffForFileCmd(repo.Ctx, repo, "", commitID, diffType, "")
if err != nil {
return fmt.Errorf("getRepoRawDiffForFileCmd: %w", err)
}
return nil
return cmd.WithStdoutCopy(writer).RunWithStderr(repo.Ctx)
}
// GetRepoRawDiffForFile dumps diff results of file in given commit ID to io.Writer according given repository
func GetRepoRawDiffForFile(repo *Repository, startCommit, endCommit string, diffType RawDiffType, file string, writer io.Writer) error {
// GetFileDiffCutAroundLine cuts the old or new part of the diff of a file around a specific line number
func GetFileDiffCutAroundLine(
repo *Repository, startCommit, endCommit, treePath string,
line int64, old bool, numbersOfLine int,
) (ret string, retErr error) {
cmd, err := getRepoRawDiffForFileCmd(repo.Ctx, repo, startCommit, endCommit, RawDiffNormal, treePath)
if err != nil {
return "", fmt.Errorf("getRepoRawDiffForFileCmd: %w", err)
}
stdoutReader, stdoutClose := cmd.MakeStdoutPipe()
defer stdoutClose()
cmd.WithPipelineFunc(func(ctx gitcmd.Context) error {
ret, err = CutDiffAroundLine(stdoutReader, line, old, numbersOfLine)
return err
})
return ret, cmd.RunWithStderr(repo.Ctx)
}
// getRepoRawDiffForFile returns an io.Reader for the diff results of file in given commit ID
// and a "finish" function to wait for the git command and clean up resources after reading is done.
func getRepoRawDiffForFileCmd(_ context.Context, repo *Repository, startCommit, endCommit string, diffType RawDiffType, file string) (*gitcmd.Command, error) {
commit, err := repo.GetCommit(endCommit)
if err != nil {
return err
return nil, err
}
var files []string
if len(file) > 0 {
files = append(files, file)
}
cmd := gitcmd.NewCommand()
cmd := gitcmd.NewCommand().WithDir(repo.Path)
switch diffType {
case RawDiffNormal:
if len(startCommit) != 0 {
cmd.AddArguments("diff", "-M").AddDynamicArguments(startCommit, endCommit).AddDashesAndList(files...)
cmd.AddArguments("diff").
AddOptionFormat("--find-renames=%s", setting.Git.DiffRenameSimilarityThreshold).
AddDynamicArguments(startCommit, endCommit).AddDashesAndList(files...)
} else if commit.ParentCount() == 0 {
cmd.AddArguments("show").AddDynamicArguments(endCommit).AddDashesAndList(files...)
} else {
c, err := commit.Parent(0)
if err != nil {
return err
return nil, err
}
cmd.AddArguments("diff", "-M").AddDynamicArguments(c.ID.String(), endCommit).AddDashesAndList(files...)
cmd.AddArguments("diff").
AddOptionFormat("--find-renames=%s", setting.Git.DiffRenameSimilarityThreshold).
AddDynamicArguments(c.ID.String(), endCommit).AddDashesAndList(files...)
}
case RawDiffPatch:
if len(startCommit) != 0 {
@@ -80,24 +92,15 @@ func GetRepoRawDiffForFile(repo *Repository, startCommit, endCommit string, diff
} else {
c, err := commit.Parent(0)
if err != nil {
return err
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 fmt.Errorf("invalid diffType: %s", diffType)
return nil, util.NewInvalidArgumentErrorf("invalid diff type: %s", diffType)
}
stderr := new(bytes.Buffer)
if err = cmd.Run(repo.Ctx, &gitcmd.RunOpts{
Dir: repo.Path,
Stdout: writer,
Stderr: stderr,
}); err != nil {
return fmt.Errorf("Run: %w - %s", err, stderr)
}
return nil
return cmd, nil
}
// ParseDiffHunkString parse the diff hunk content and return
@@ -234,7 +237,7 @@ func CutDiffAroundLine(originalDiff io.Reader, line int64, old bool, numbersOfLi
}
}
if err := scanner.Err(); err != nil {
return "", err
return "", fmt.Errorf("CutDiffAroundLine: scan: %w", err)
}
// No hunk found
@@ -300,43 +303,27 @@ func GetAffectedFiles(repo *Repository, branchName, oldCommitID, newCommitID str
}
oldCommitID = startCommitID
}
stdoutReader, stdoutWriter, err := os.Pipe()
if err != nil {
log.Error("Unable to create os.Pipe for %s", repo.Path)
return nil, err
}
defer func() {
_ = stdoutReader.Close()
_ = stdoutWriter.Close()
}()
affectedFiles := make([]string, 0, 32)
// Run `git diff --name-only` to get the names of the changed files
err = gitcmd.NewCommand("diff", "--name-only").AddDynamicArguments(oldCommitID, newCommitID).
Run(repo.Ctx, &gitcmd.RunOpts{
Env: env,
Dir: repo.Path,
Stdout: stdoutWriter,
PipelineFunc: func(ctx context.Context, cancel context.CancelFunc) error {
// Close the writer end of the pipe to begin processing
_ = stdoutWriter.Close()
defer func() {
// Close the reader on return to terminate the git command if necessary
_ = stdoutReader.Close()
}()
// 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)
cmd := gitcmd.NewCommand("diff", "--name-only").AddDynamicArguments(oldCommitID, newCommitID)
stdoutReader, stdoutReaderClose := cmd.MakeStdoutPipe()
defer stdoutReaderClose()
err := cmd.WithEnv(env).WithDir(repo.Path).
WithPipelineFunc(func(ctx gitcmd.Context) error {
// Now scan the output from the command
scanner := bufio.NewScanner(stdoutReader)
for scanner.Scan() {
path := strings.TrimSpace(scanner.Text())
if len(path) == 0 {
continue
}
return scanner.Err()
},
})
affectedFiles = append(affectedFiles, path)
}
return scanner.Err()
}).
Run(repo.Ctx)
if err != nil {
log.Error("Unable to get affected files for commits from %s to %s in %s: %v", oldCommitID, newCommitID, repo.Path, err)
}
+22 -28
View File
@@ -4,8 +4,6 @@
package git
import (
"context"
"errors"
"fmt"
"strings"
@@ -98,28 +96,31 @@ func (err *ErrPushRejected) Unwrap() error {
// GenerateMessage generates the remote message from the stderr
func (err *ErrPushRejected) GenerateMessage() {
messageBuilder := &strings.Builder{}
i := strings.Index(err.StdErr, "remote: ")
if i < 0 {
err.Message = ""
// 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
}
for {
if len(err.StdErr) <= i+8 {
break
}
if err.StdErr[i:i+8] != "remote: " {
break
}
i += 8
nl := strings.IndexByte(err.StdErr[i:], '\n')
if nl >= 0 {
messageBuilder.WriteString(err.StdErr[i : i+nl+1])
i = i + nl + 1
} else {
messageBuilder.WriteString(err.StdErr[i:])
i = len(err.StdErr)
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())
}
@@ -140,10 +141,3 @@ func IsErrMoreThanOne(err error) bool {
func (err *ErrMoreThanOne) Error() string {
return fmt.Sprintf("ErrMoreThanOne Error: %v: %s\n%s", err.Err, err.StdErr, err.StdOut)
}
func IsErrCanceledOrKilled(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"
return err != nil && (errors.Is(err, context.Canceled) || err.Error() == "signal: killed")
}
@@ -53,7 +53,7 @@ func (f Format) Flag() string {
var formatFlag strings.Builder
for i, field := range f.fieldNames {
// field key and field value
formatFlag.WriteString(fmt.Sprintf("%s %%(%s)", field, field))
fmt.Fprintf(&formatFlag, "%s %%(%s)", field, field)
if i < len(f.fieldNames)-1 {
// note: escape delimiters to allow control characters as
@@ -72,12 +72,12 @@ func (f Format) Parser(r io.Reader) *Parser {
return NewParser(r, f)
}
// hexEscaped produces hex-escpaed characters from a string. For example, "\n\0"
// hexEscaped produces hex-escaped characters from a string. For example, "\n\0"
// would turn into "%0a%00".
func (f Format) hexEscaped(delim []byte) string {
escaped := ""
var escaped strings.Builder
for i := range delim {
escaped += "%" + hex.EncodeToString([]byte{delim[i]})
escaped.WriteString("%" + hex.EncodeToString([]byte{delim[i]}))
}
return escaped
return escaped.String()
}
@@ -100,7 +100,7 @@ func (p *Parser) Err() error {
func (p *Parser) parseRef(refBlock string) (map[string]string, error) {
if refBlock == "" {
// must be at EOF
return nil, nil
return nil, nil //nolint:nilnil // return nil to signal EOF
}
fieldValues := make(map[string]string)
@@ -115,10 +115,10 @@ func (p *Parser) parseRef(refBlock string) (map[string]string, error) {
var fieldKey string
var fieldVal string
firstSpace := strings.Index(field, " ")
if firstSpace > 0 {
fieldKey = field[:firstSpace]
fieldVal = field[firstSpace+1:]
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
+42 -16
View File
@@ -12,25 +12,28 @@ import (
"path/filepath"
"runtime"
"strings"
"time"
"code.gitea.io/gitea/modules/git/gitcmd"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/tempdir"
"code.gitea.io/gitea/modules/testlogger"
"github.com/hashicorp/go-version"
)
const RequiredVersion = "2.0.0" // the minimum Git version required
const RequiredVersion = "2.6.0" // the minimum Git version required
type Features struct {
gitVersion *version.Version
UsingGogit bool
SupportProcReceive bool // >= 2.29
SupportHashSha256 bool // >= 2.42, SHA-256 repositories no longer an experimental curiosity
SupportedObjectFormats []ObjectFormat // sha1, sha256
SupportCheckAttrOnBare bool // >= 2.40
UsingGogit bool
SupportProcReceive bool // >= 2.29
SupportHashSha256 bool // >= 2.42, SHA-256 repositories no longer an experimental curiosity
SupportedObjectFormats []ObjectFormat // sha1, sha256
SupportCheckAttrOnBare bool // >= 2.40
SupportCatFileBatchCommand bool // >= 2.36, support `git cat-file --batch-command`
SupportGitMergeTree bool // >= 2.40 // we also need "--merge-base"
}
var defaultFeatures *Features
@@ -57,7 +60,7 @@ func DefaultFeatures() *Features {
}
func loadGitVersionFeatures() (*Features, error) {
stdout, _, runErr := gitcmd.NewCommand("version").RunStdString(context.Background(), nil)
stdout, _, runErr := gitcmd.NewCommand("version").RunStdString(context.Background())
if runErr != nil {
return nil, runErr
}
@@ -75,6 +78,8 @@ func loadGitVersionFeatures() (*Features, error) {
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
}
@@ -84,12 +89,17 @@ func parseGitVersionLine(s string) (*version.Version, error) {
return nil, fmt.Errorf("invalid git version: %q", s)
}
// version string is like: "git version 2.29.3" or "git version 2.29.3.windows.1"
// 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]
if pos := strings.Index(versionString, "windows"); pos >= 1 {
versionString = versionString[:pos-1]
versionFields := strings.Split(versionString, ".")
if len(versionFields) > 3 {
versionFields = versionFields[:3]
}
return version.NewVersion(versionString)
return version.NewVersion(strings.Join(versionFields, "."))
}
func checkGitVersionCompatibility(gitVer *version.Version) error {
@@ -137,10 +147,6 @@ func InitSimple() error {
log.Warn("git module has been initialized already, duplicate init may work but it's better to fix it")
}
if setting.Git.Timeout.Default > 0 {
gitcmd.SetDefaultCommandExecutionTimeout(time.Duration(setting.Git.Timeout.Default) * time.Second)
}
if err := gitcmd.SetExecutablePath(setting.Git.Path); err != nil {
return err
}
@@ -176,3 +182,23 @@ func InitFull() (err error) {
return syncGitConfig(context.Background())
}
// RunGitTests helps to init the git module and run tests.
// FIXME: GIT-PACKAGE-DEPENDENCY: the dependency is not right, setting.Git.HomePath is initialized in this package but used in gitcmd package
func RunGitTests(m interface{ Run() int }) {
os.Exit(runGitTests(m))
}
func runGitTests(m interface{ Run() int }) int {
gitHomePath, cleanup, err := tempdir.OsTempDir("gitea-test").MkdirTempRandom("git-home")
if err != nil {
testlogger.Panicf("unable to create temp dir: %s", err.Error())
}
defer cleanup()
setting.Git.HomePath = gitHomePath
if err = InitFull(); err != nil {
testlogger.Panicf("failed to call Init: %s", err.Error())
}
return m.Run()
}
+5 -29
View File
@@ -4,42 +4,14 @@
package git
import (
"fmt"
"os"
"testing"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/tempdir"
"github.com/hashicorp/go-version"
"github.com/stretchr/testify/assert"
)
func testRun(m *testing.M) error {
gitHomePath, cleanup, err := tempdir.OsTempDir("gitea-test").MkdirTempRandom("git-home")
if err != nil {
return fmt.Errorf("unable to create temp dir: %w", err)
}
defer cleanup()
setting.Git.HomePath = gitHomePath
if err = 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)
}
RunGitTests(m)
}
func TestParseGitVersion(t *testing.T) {
@@ -51,6 +23,10 @@ func TestParseGitVersion(t *testing.T) {
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)
+314 -183
View File
@@ -13,7 +13,6 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
@@ -29,34 +28,41 @@ import (
// In most cases, it shouldn't be used. Use AddXxx function instead
type TrustedCmdArgs []internal.CmdArg
// defaultCommandExecutionTimeout default command execution timeout duration
var defaultCommandExecutionTimeout = 360 * time.Second
func SetDefaultCommandExecutionTimeout(timeout time.Duration) {
defaultCommandExecutionTimeout = timeout
}
// DefaultLocale is the default LC_ALL to run git commands in.
const DefaultLocale = "C"
// Command represents a command with its subcommands or arguments.
type Command struct {
callerInfo string
prog string
args []string
brokenArgs []string
cmd *exec.Cmd // for debug purpose only
preErrors []error
configArgs []string
opts runOpts
cmd *exec.Cmd
cmdCtx context.Context
cmdCancel process.CancelCauseFunc
cmdFinished process.FinishedFunc
cmdStartTime time.Time
parentPipeFiles []*os.File
parentPipeReaders []*os.File
childrenPipeFiles []*os.File
// only os.Pipe and in-memory buffers can work with Stdin safely, see https://github.com/golang/go/issues/77227 if the command would exit unexpectedly
cmdStdin io.Reader
cmdStdout io.Writer
cmdStderr io.Writer
cmdManagedStderr *bytes.Buffer
}
func logArgSanitize(arg string) string {
if strings.Contains(arg, "://") && strings.Contains(arg, "@") {
return util.SanitizeCredentialURLs(arg)
} else if filepath.IsAbs(arg) {
if filepath.IsAbs(arg) {
base := filepath.Base(arg)
dir := filepath.Dir(arg)
return ".../" + filepath.Join(filepath.Base(dir), base)
}
return arg
return util.SanitizeCredentialURLs(arg)
}
func (c *Command) LogString() string {
@@ -96,6 +102,10 @@ func NewCommand(args ...internal.CmdArg) *Command {
}
}
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] != '-'
@@ -123,7 +133,7 @@ func (c *Command) AddArguments(args ...internal.CmdArg) *Command {
// 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.brokenArgs = append(c.brokenArgs, string(opt))
c.handlePreErrorBrokenCommand(string(opt))
return c
}
c.args = append(c.args, string(opt))
@@ -135,12 +145,12 @@ func (c *Command) AddOptionValues(opt internal.CmdArg, args ...string) *Command
// 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.brokenArgs = append(c.brokenArgs, 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.brokenArgs = append(c.brokenArgs, opt)
c.handlePreErrorBrokenCommand(opt)
return c
}
s := fmt.Sprintf(opt, args...)
@@ -154,10 +164,10 @@ func (c *Command) AddOptionFormat(opt string, args ...any) *Command {
func (c *Command) AddDynamicArguments(args ...string) *Command {
for _, arg := range args {
if !isSafeArgumentValue(arg) {
c.brokenArgs = append(c.brokenArgs, arg)
c.handlePreErrorBrokenCommand(arg)
}
}
if len(c.brokenArgs) != 0 {
if len(c.preErrors) != 0 {
return c
}
c.args = append(c.args, args...)
@@ -177,7 +187,7 @@ func (c *Command) AddDashesAndList(list ...string) *Command {
func (c *Command) AddConfig(key, value string) *Command {
kv := key + "=" + value
if !isSafeArgumentValue(kv) {
c.brokenArgs = append(c.brokenArgs, key)
c.handlePreErrorBrokenCommand(kv)
} else {
c.configArgs = append(c.configArgs, "-c", kv)
}
@@ -194,11 +204,9 @@ func ToTrustedCmdArgs(args []string) TrustedCmdArgs {
return ret
}
// RunOpts represents parameters to run the command. If UseContextTimeout is specified, then Timeout is ignored.
type RunOpts struct {
Env []string
Timeout time.Duration
UseContextTimeout bool
type runOpts struct {
Env []string
Timeout time.Duration
// Dir is the working dir for the git command, however:
// FIXME: this could be incorrect in many cases, for example:
@@ -208,19 +216,7 @@ type RunOpts struct {
// The correct approach is to use `--git-dir" global argument
Dir string
Stdout, Stderr io.Writer
// Stdin is used for passing input to the command
// The caller must make sure the Stdin writer is closed properly to finish the Run function.
// Otherwise, the Run function may hang for long time or forever, especially when the Git's context deadline is not the same as the caller's.
// Some common mistakes:
// * `defer stdinWriter.Close()` then call `cmd.Run()`: the Run() would never return if the command is killed by timeout
// * `go { case <- parentContext.Done(): stdinWriter.Close() }` with `cmd.Run(DefaultTimeout)`: the command would have been killed by timeout but the Run doesn't return until stdinWriter.Close()
// * `go { if stdoutReader.Read() err != nil: stdinWriter.Close() }` with `cmd.Run()`: the stdoutReader may never return error if the command is killed by timeout
// In the future, ideally the git module itself should have full control of the stdin, to avoid such problems and make it easier to refactor to a better architecture.
Stdin io.Reader
PipelineFunc func(context.Context, context.CancelFunc) error
PipelineFunc func(Context) error
}
func commonBaseEnvs() []string {
@@ -251,7 +247,7 @@ func commonBaseEnvs() []string {
// CommonGitCmdEnvs returns the common environment variables for a "git" command.
func CommonGitCmdEnvs() []string {
return append(commonBaseEnvs(), []string{
"LC_ALL=" + DefaultLocale,
"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
}...)
}
@@ -263,185 +259,320 @@ func CommonCmdServEnvs() []string {
var ErrBrokenCommand = errors.New("git command is broken")
// Run runs the command with the RunOpts
func (c *Command) Run(ctx context.Context, opts *RunOpts) error {
return c.run(ctx, 1, opts)
func (c *Command) WithDir(dir string) *Command {
c.opts.Dir = dir
return c
}
func (c *Command) run(ctx context.Context, skip int, opts *RunOpts) error {
if len(c.brokenArgs) != 0 {
log.Error("git command is broken: %s, broken args: %s", c.LogString(), strings.Join(c.brokenArgs, " "))
return ErrBrokenCommand
}
if opts == nil {
opts = &RunOpts{}
}
func (c *Command) WithEnv(env []string) *Command {
c.opts.Env = env
return c
}
// We must not change the provided options
timeout := opts.Timeout
if timeout <= 0 {
timeout = defaultCommandExecutionTimeout
}
func (c *Command) WithTimeout(timeout time.Duration) *Command {
c.opts.Timeout = timeout
return c
}
cmdLogString := c.LogString()
callerInfo := util.CallerFuncName(1 /* util */ + 1 /* this */ + skip /* parent */)
func (c *Command) makeStdoutStderr(w *io.Writer) (PipeReader, func()) {
pr, pw, err := os.Pipe()
if err != nil {
c.preErrors = append(c.preErrors, err)
return &pipeNull{err}, func() {}
}
c.childrenPipeFiles = append(c.childrenPipeFiles, pw)
c.parentPipeFiles = append(c.parentPipeFiles, pr)
c.parentPipeReaders = append(c.parentPipeReaders, pr)
*w /* stdout, stderr */ = pw
return &pipeReader{f: pr}, func() { pr.Close() }
}
// MakeStdinPipe creates a writer for the command's stdin.
// The returned closer function must be called by the caller to close the pipe.
func (c *Command) MakeStdinPipe() (writer PipeWriter, closer func()) {
pr, pw, err := os.Pipe()
if err != nil {
c.preErrors = append(c.preErrors, err)
return &pipeNull{err}, func() {}
}
c.childrenPipeFiles = append(c.childrenPipeFiles, pr)
c.parentPipeFiles = append(c.parentPipeFiles, pw)
c.cmdStdin = pr
return &pipeWriter{pw}, func() { pw.Close() }
}
// MakeStdoutPipe creates a reader for the command's stdout.
// The returned closer function must be called by the caller to close the pipe.
// After the pipe reader is closed, the unread data will be discarded.
//
// If the process (git command) still tries to write after the pipe is closed, the Wait error will be "signal: broken pipe".
// WithPipelineFunc + Run won't return "broken pipe" error in this case if the callback returns no error.
// But if you are calling Start / Wait family functions, you should either drain the pipe before close it, or handle the Wait error correctly.
func (c *Command) MakeStdoutPipe() (reader PipeReader, closer func()) {
return c.makeStdoutStderr(&c.cmdStdout)
}
// MakeStderrPipe is like MakeStdoutPipe, but for stderr.
func (c *Command) MakeStderrPipe() (reader PipeReader, closer func()) {
return c.makeStdoutStderr(&c.cmdStderr)
}
func (c *Command) MakeStdinStdoutPipe() (stdin PipeWriter, stdout PipeReader, closer func()) {
stdin, stdinClose := c.MakeStdinPipe()
stdout, stdoutClose := c.MakeStdoutPipe()
return stdin, stdout, func() {
stdinClose()
stdoutClose()
}
}
func (c *Command) WithStdinBytes(stdin []byte) *Command {
c.cmdStdin = bytes.NewReader(stdin)
return c
}
func (c *Command) WithStdoutBuffer(w PipeBufferWriter) *Command {
c.cmdStdout = w
return c
}
// WithStdinCopy and WithStdoutCopy are general functions that accept any io.Reader / io.Writer.
// In this case, Golang exec.Cmd will start new internal goroutines to do io.Copy between pipes and provided Reader/Writer.
// If the reader or writer is blocked and never returns, then the io.Copy won't finish, then exec.Cmd.Wait won't return, which may cause deadlocks.
// A typical deadlock example is:
// * `r,w:=io.Pipe(); cmd.Stdin=r; defer w.Close(); cmd.Run()`: the Run() will never return because stdin reader is blocked forever and w.Close() will never be called.
// If the reader/writer won't block forever (for example: read from a file or buffer), then these functions are safe to use.
func (c *Command) WithStdinCopy(w io.Reader) *Command {
c.cmdStdin = w
return c
}
func (c *Command) WithStdoutCopy(w io.Writer) *Command {
c.cmdStdout = w
return c
}
// WithPipelineFunc sets the pipeline function for the command.
// The pipeline function will be called in the Run / Wait function after the command is started successfully.
// The function can read/write from/to the command's stdio pipes (if any).
// The pipeline function can cancel (kill) the command by calling ctx.CancelPipeline before the command finishes.
// The returned error of Run / Wait can be joined errors from the pipeline function, context cause, and command exit error.
// Caller can get the pipeline function's error (if any) by UnwrapPipelineError.
func (c *Command) WithPipelineFunc(f func(ctx Context) error) *Command {
c.opts.PipelineFunc = f
return c
}
// WithParentCallerInfo can be used to set the caller info (usually function name) of the parent function of the caller.
// For most cases, "Run" family functions can get its caller info automatically
// But if you need to call "Run" family functions in a wrapper function: "FeatureFunc -> GeneralWrapperFunc -> RunXxx",
// then you can to call this function in GeneralWrapperFunc to set the caller info of FeatureFunc.
// The caller info can only be set once.
func (c *Command) WithParentCallerInfo(optInfo ...string) *Command {
if c.callerInfo != "" {
return c
}
if len(optInfo) > 0 {
c.callerInfo = optInfo[0]
return c
}
skip := 1 /*parent "wrap/run" functions*/ + 1 /*this function*/
callerFuncName := util.CallerFuncName(skip)
callerInfo := callerFuncName
if pos := strings.LastIndex(callerInfo, "/"); pos >= 0 {
callerInfo = callerInfo[pos+1:]
}
c.callerInfo = callerInfo
return c
}
func (c *Command) Start(ctx context.Context) (retErr error) {
if c.cmd != nil {
// this is a programming error, it will cause serious deadlock problems, so it must be fixed.
panic("git command has already been started")
}
defer func() {
c.closePipeFiles(c.childrenPipeFiles)
if retErr != nil {
// release the pipes to avoid resource leak since the command failed to start
c.closePipeFiles(c.parentPipeFiles)
// if error occurs, we must also finish the task, otherwise, cmdFinished will be called in "Wait" function
if c.cmdFinished != nil {
c.cmdFinished()
}
}
}()
if len(c.preErrors) != 0 {
// In most cases, such error shouldn't happen. If it happens, log it as error level with more details
err := errors.Join(c.preErrors...)
log.Error("git command: %s, error: %s", c.LogString(), err)
return err
}
cmdLogString := c.LogString()
if c.callerInfo == "" {
c.WithParentCallerInfo()
}
// these logs are for debugging purposes only, so no guarantee of correctness or stability
desc := fmt.Sprintf("git.Run(by:%s, repo:%s): %s", callerInfo, logArgSanitize(opts.Dir), cmdLogString)
desc := fmt.Sprintf("git.Run(by:%s, repo:%s): %s", c.callerInfo, logArgSanitize(c.opts.Dir), cmdLogString)
log.Debug("git.Command: %s", desc)
_, span := gtprof.GetTracer().Start(ctx, gtprof.TraceSpanGitRun)
defer span.End()
span.SetAttributeString(gtprof.TraceAttrFuncCaller, callerInfo)
span.SetAttributeString(gtprof.TraceAttrFuncCaller, c.callerInfo)
span.SetAttributeString(gtprof.TraceAttrGitCommand, cmdLogString)
var cancel context.CancelFunc
var finished context.CancelFunc
if opts.UseContextTimeout {
ctx, cancel, finished = process.GetManager().AddContext(ctx, desc)
if c.opts.Timeout <= 0 {
c.cmdCtx, c.cmdCancel, c.cmdFinished = process.GetManager().AddContext(ctx, desc)
} else {
ctx, cancel, finished = process.GetManager().AddContextTimeout(ctx, timeout, desc)
c.cmdCtx, c.cmdCancel, c.cmdFinished = process.GetManager().AddContextTimeout(ctx, c.opts.Timeout, desc)
}
defer finished()
startTime := time.Now()
c.cmdStartTime = time.Now()
cmd := exec.CommandContext(ctx, c.prog, append(c.configArgs, c.args...)...)
c.cmd = cmd // for debug purpose only
if opts.Env == nil {
cmd.Env = os.Environ()
c.cmd = exec.CommandContext(c.cmdCtx, c.prog, append(c.configArgs, c.args...)...)
if c.opts.Env == nil {
c.cmd.Env = os.Environ()
} else {
cmd.Env = opts.Env
c.cmd.Env = c.opts.Env
}
process.SetSysProcAttribute(cmd)
cmd.Env = append(cmd.Env, CommonGitCmdEnvs()...)
cmd.Dir = opts.Dir
cmd.Stdout = opts.Stdout
cmd.Stderr = opts.Stderr
cmd.Stdin = opts.Stdin
if err := cmd.Start(); err != nil {
return err
}
process.SetSysProcAttribute(c.cmd)
c.cmd.Env = append(c.cmd.Env, CommonGitCmdEnvs()...)
c.cmd.Dir = c.opts.Dir
c.cmd.Stdout = c.cmdStdout
c.cmd.Stdin = c.cmdStdin
c.cmd.Stderr = c.cmdStderr
return c.cmd.Start()
}
if opts.PipelineFunc != nil {
err := opts.PipelineFunc(ctx, cancel)
if err != nil {
cancel()
_ = cmd.Wait()
return err
func (c *Command) closePipeFiles(files []*os.File) {
for _, f := range files {
_ = f.Close()
}
}
func (c *Command) discardPipeReaders(files []*os.File) {
for _, f := range files {
_, _ = io.Copy(io.Discard, f)
}
}
func (c *Command) Wait() error {
defer func() {
// The reader in another goroutine might be still reading the stdout, so we shouldn't close the pipes here
// MakeStdoutPipe returns a closer function to force callers to close the pipe correctly
// Here we only need to mark the command as finished
c.cmdFinished()
}()
if c.opts.PipelineFunc != nil {
errPipeline := c.opts.PipelineFunc(&cmdContext{Context: c.cmdCtx, cmd: c})
if context.Cause(c.cmdCtx) == nil {
// if the context is not canceled explicitly, we need to discard the unread data,
// and wait for the command to exit normally, and then get its exit code
c.discardPipeReaders(c.parentPipeReaders)
} // else: canceled command will be killed, and the exit code is caused by kill
// after the pipeline function returns, we can safely close the pipes, then wait for the command to exit
c.closePipeFiles(c.parentPipeFiles)
errWait := c.cmd.Wait()
errCause := context.Cause(c.cmdCtx) // in case the cause is set during Wait(), get the final cancel cause
if unwrapped, ok := UnwrapPipelineError(errCause); ok {
if unwrapped != errPipeline {
panic("unwrapped context pipeline error should be the same one returned by pipeline function")
}
if unwrapped == nil {
// the pipeline function declares that there is no error, and it cancels (kills) the command ahead,
// so we should ignore the errors from "wait" and "cause"
errWait, errCause = nil, nil
}
}
// some legacy code still need to access the error returned by pipeline function by "==" but not "errors.Is"
// so we need to make sure the original error is able to be unwrapped by UnwrapPipelineError
return errors.Join(wrapPipelineError(errPipeline), errCause, errWait)
}
err := cmd.Wait()
elapsed := time.Since(startTime)
// 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)
log.Debug("slow git.Command.Run: %s (%s)", c, elapsed) // TODO: no need to log this for long-running commands
}
// We need to check if the context is canceled by the program on Windows.
// This is because Windows does not have signal checking when terminating the process.
// It always returns exit code 1, unlike Linux, which has many exit codes for signals.
// `err.Error()` returns "exit status 1" when using the `git check-attr` command after the context is canceled.
if runtime.GOOS == "windows" &&
err != nil &&
(err.Error() == "" || err.Error() == "exit status 1") &&
cmd.ProcessState.ExitCode() == 1 &&
ctx.Err() == context.Canceled {
return ctx.Err()
}
// 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)
}
if err != nil && ctx.Err() != context.DeadlineExceeded {
func (c *Command) StartWithStderr(ctx context.Context) RunStdError {
if c.cmdStderr != nil {
panic("caller-provided stderr receiver doesn't work with managed stderr buffer")
}
c.cmdManagedStderr = &bytes.Buffer{}
c.cmdStderr = c.cmdManagedStderr
err := c.Start(ctx)
if err != nil {
return &runStdError{err: err}
}
return nil
}
func (c *Command) WaitWithStderr() RunStdError {
if c.cmdManagedStderr == nil {
panic("managed stderr buffer is not initialized")
}
errWait := c.Wait()
if errWait == nil {
// if no exec error but only stderr output, the stderr output is still saved in "c.cmdManagedStderr" and can be read later
return nil
}
return &runStdError{err: errWait, stderr: util.UnsafeBytesToString(c.cmdManagedStderr.Bytes())}
}
func (c *Command) RunWithStderr(ctx context.Context) RunStdError {
if err := c.StartWithStderr(ctx); err != nil {
return &runStdError{err: err}
}
return c.WaitWithStderr()
}
func (c *Command) Run(ctx context.Context) (err error) {
if err = c.Start(ctx); err != nil {
return err
}
return ctx.Err()
return c.Wait()
}
type RunStdError interface {
error
Unwrap() error
Stderr() string
// 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
}
type runStdError struct {
err error
stderr string
errMsg string
// 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 (r *runStdError) Error() string {
// the stderr must be in the returned error text, some code only checks `strings.Contains(err.Error(), "git error")`
if r.errMsg == "" {
r.errMsg = ConcatenateError(r.err, r.stderr).Error()
}
return r.errMsg
}
func (r *runStdError) Unwrap() error {
return r.err
}
func (r *runStdError) Stderr() string {
return r.stderr
}
func IsErrorExitCode(err error, code int) bool {
var exitError *exec.ExitError
if errors.As(err, &exitError) {
return exitError.ExitCode() == code
}
return false
}
// RunStdString runs the command with options and returns stdout/stderr as string. and store stderr to returned error (err combined with stderr).
func (c *Command) RunStdString(ctx context.Context, opts *RunOpts) (stdout, stderr string, runErr RunStdError) {
stdoutBytes, stderrBytes, err := c.runStdBytes(ctx, opts)
stdout = util.UnsafeBytesToString(stdoutBytes)
stderr = util.UnsafeBytesToString(stderrBytes)
if err != nil {
return stdout, stderr, &runStdError{err: err, stderr: stderr}
}
// even if there is no err, there could still be some stderr output, so we just return stdout/stderr as they are
return stdout, stderr, nil
}
// RunStdBytes runs the command with options and returns stdout/stderr as bytes. and store stderr to returned error (err combined with stderr).
func (c *Command) RunStdBytes(ctx context.Context, opts *RunOpts) (stdout, stderr []byte, runErr RunStdError) {
return c.runStdBytes(ctx, opts)
}
func (c *Command) runStdBytes(ctx context.Context, opts *RunOpts) (stdout, stderr []byte, runErr RunStdError) {
if opts == nil {
opts = &RunOpts{}
}
if opts.Stdout != nil || opts.Stderr != nil {
// we must panic here, otherwise there would be bugs if developers set Stdin/Stderr by mistake, and it would be very difficult to debug
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{}
stderrBuf := &bytes.Buffer{}
// We must not change the provided options as it could break future calls - therefore make a copy.
newOpts := &RunOpts{
Env: opts.Env,
Timeout: opts.Timeout,
UseContextTimeout: opts.UseContextTimeout,
Dir: opts.Dir,
Stdout: stdoutBuf,
Stderr: stderrBuf,
Stdin: opts.Stdin,
PipelineFunc: opts.PipelineFunc,
}
err := c.run(ctx, 2, newOpts)
stderr = stderrBuf.Bytes()
if err != nil {
return nil, stderr, &runStdError{err: err, stderr: util.UnsafeBytesToString(stderr)}
}
// even if there is no err, there could still be some stderr output
return stdoutBuf.Bytes(), stderr, nil
err := c.WithParentCallerInfo().WithStdoutBuffer(stdoutBuf).RunWithStderr(ctx)
return stdoutBuf.Bytes(), c.cmdManagedStderr.Bytes(), err
}
func (c *Command) DebugKill() {
_ = c.cmd.Process.Kill()
}
@@ -1,38 +0,0 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build race
package gitcmd
import (
"context"
"testing"
"time"
)
func TestRunWithContextNoTimeout(t *testing.T) {
maxLoops := 10
// 'git --version' does not block so it must be finished before the timeout triggered.
cmd := NewCommand("--version")
for i := 0; i < maxLoops; i++ {
if err := cmd.Run(t.Context(), &RunOpts{}); err != nil {
t.Fatal(err)
}
}
}
func TestRunWithContextTimeout(t *testing.T) {
maxLoops := 10
// 'git hash-object --stdin' blocks on stdin so we can have the timeout triggered.
cmd := NewCommand("hash-object", "--stdin")
for i := 0; i < maxLoops; i++ {
if err := cmd.Run(t.Context(), &RunOpts{Timeout: 1 * time.Millisecond}); err != nil {
if err != context.DeadlineExceeded {
t.Fatalf("Testing %d/%d: %v", i, maxLoops, err)
}
}
}
}
@@ -4,57 +4,93 @@
package gitcmd
import (
"context"
"fmt"
"os"
"strings"
"testing"
"time"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/tempdir"
"code.gitea.io/gitea/modules/testlogger"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMain(m *testing.M) {
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 {
_, _ = fmt.Fprintf(os.Stderr, "unable to create temp dir: %v", err)
os.Exit(1)
testlogger.Panicf("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(), &RunOpts{})
assert.NoError(t, err)
assert.Empty(t, stderr)
assert.Contains(t, stdout, "git version")
cmd = NewCommand("--no-such-arg")
stdout, stderr, err = cmd.RunStdString(t.Context(), &RunOpts{})
if assert.Error(t, err) {
assert.Equal(t, stderr, err.Stderr())
assert.Contains(t, err.Stderr(), "unknown option:")
assert.Contains(t, err.Error(), "exit status 129 - unknown option:")
assert.Empty(t, stdout)
{
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()
cmd.AddDynamicArguments("-test")
assert.ErrorIs(t, cmd.Run(t.Context(), &RunOpts{}), ErrBrokenCommand)
{
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()
cmd.AddDynamicArguments("--test")
assert.ErrorIs(t, cmd.Run(t.Context(), &RunOpts{}), ErrBrokenCommand)
{
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)
}
}
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(), &RunOpts{})
assert.NoError(t, err)
assert.Empty(t, stderr)
assert.Contains(t, stdout, "git version")
{
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) {
@@ -73,5 +109,34 @@ func TestCommandString(t *testing.T) {
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://sanitized-credential@c/" .../dir-a/dir-b`, cmd.LogString())
assert.Equal(t, cmd.prog+` "url: https://(masked)@c/" .../dir-a/dir-b`, cmd.LogString())
cmd = NewCommand("url: a:b@c/", "/root/dir-a/dir-b")
assert.Equal(t, cmd.prog+` "url: (masked)@c/" .../dir-a/dir-b`, cmd.LogString())
}
func TestRunStdError(t *testing.T) {
e := &runStdError{stderr: "some error"}
var err RunStdError = e
var asErr RunStdError
require.ErrorAs(t, err, &asErr)
require.Equal(t, "some error", asErr.Stderr())
require.ErrorAs(t, fmt.Errorf("wrapped %w", err), &asErr)
}
func TestRunWithContextTimeout(t *testing.T) {
t.Run("NoTimeout", func(t *testing.T) {
// 'git --version' does not block so it must be finished before the timeout triggered.
err := NewCommand("--version").Run(t.Context())
require.NoError(t, err)
})
t.Run("WithTimeout", func(t *testing.T) {
cmd := NewCommand("hash-object", "--stdin")
_, _, pipeClose := cmd.MakeStdinStdoutPipe()
defer pipeClose()
err := cmd.WithTimeout(1 * time.Millisecond).Run(t.Context())
require.ErrorIs(t, err, context.DeadlineExceeded)
})
}
@@ -0,0 +1,32 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitcmd
import (
"context"
)
type Context interface {
context.Context
// CancelPipeline is a helper function to cancel the command context (kill the command) with a specific error cause,
// it returns the same error for convenience to break the PipelineFunc easily
CancelPipeline(err error) error
// In the future, this interface will be extended to support stdio pipe readers/writers
}
type cmdContext struct {
context.Context
cmd *Command
}
func (c *cmdContext) CancelPipeline(err error) error {
// pipelineError is used to distinguish between:
// * context canceled by pipeline caller with/without error (normal cancellation)
// * context canceled by parent context (still context.Canceled error)
// * other causes
c.cmd.cmdCancel(pipelineError{err})
return err
}
@@ -0,0 +1,108 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitcmd
import (
"context"
"errors"
"fmt"
"os/exec"
"strings"
)
type RunStdError interface {
error
Unwrap() error
Stderr() string
}
type runStdError struct {
err error // usually the low-level error like `*exec.ExitError`
stderr string // git command's stderr output
errMsg string // the cached error message for Error() method
}
func (r *runStdError) Error() string {
// FIXME: GIT-CMD-STDERR: it is a bad design, the stderr should not be put in the error message
// But a lot of code only checks `strings.Contains(err.Error(), "git error")`
if r.errMsg == "" {
r.errMsg = fmt.Sprintf("%s - %s", r.err.Error(), strings.TrimSpace(r.stderr))
}
return r.errMsg
}
func (r *runStdError) Unwrap() error {
return r.err
}
func (r *runStdError) Stderr() string {
return r.stderr
}
func ErrorAsStderr(err error) (string, bool) {
var runErr RunStdError
if errors.As(err, &runErr) {
return runErr.Stderr(), true
}
return "", false
}
func StderrHasPrefix(err error, prefix string) bool {
stderr, ok := ErrorAsStderr(err)
if !ok {
return false
}
return strings.HasPrefix(stderr, prefix)
}
func IsErrorExitCode(err error, code int) bool {
var exitError *exec.ExitError
if errors.As(err, &exitError) {
return exitError.ExitCode() == code
}
return false
}
func IsErrorSignalKilled(err error) bool {
var exitError *exec.ExitError
return errors.As(err, &exitError) && exitError.String() == "signal: killed"
}
func IsErrorCanceledOrKilled(err error) bool {
// When "cancel()" a git command's context, the returned error of "Run()" could be one of them:
// - context.Canceled
// - *exec.ExitError: "signal: killed"
// TODO: in the future, we need to use unified error type from gitcmd.Run to check whether it is manually canceled
return errors.Is(err, context.Canceled) || IsErrorSignalKilled(err)
}
func IsStdErrorNotValidObjectName(err error) bool {
stderr, ok := ErrorAsStderr(err)
// Git is lowercasing the "fatal: Not a valid object name" error message
// ref: https://lore.kernel.org/git/pull.2052.git.1771836302101.gitgitgadget@gmail.com
return ok && strings.Contains(strings.ToLower(stderr), "fatal: not a valid object name")
}
type pipelineError struct {
error
}
func (e pipelineError) Unwrap() error {
return e.error
}
func wrapPipelineError(err error) error {
if err == nil {
return nil
}
return pipelineError{err}
}
func UnwrapPipelineError(err error) (error, bool) { //nolint:revive // this is for error unwrapping
var pe pipelineError
if errors.As(err, &pe) {
return pe.error, true
}
return nil, false
}
@@ -0,0 +1,87 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitcmd
import (
"io"
"os"
)
type PipeBufferReader interface {
// Read should be used in the same goroutine as command's Wait
// When Reader in one goroutine, command's Wait in another goroutine, then the command exits, the pipe will be closed:
// * If the Reader goroutine reads faster, it will read all remaining data and then get io.EOF
// * But this io.EOF doesn't mean the Reader has gotten complete data, the data might still be corrupted
// * If the Reader goroutine reads slower, it will get os.ErrClosed because the os.Pipe is closed ahead when the command exits
//
// When using 2 goroutines, no clear solution to distinguish these two cases or make Reader knows whether the data is complete
// It should avoid using Reader in a different goroutine than the command if the Read error needs to be handled.
Read(p []byte) (n int, err error)
Bytes() []byte
}
type PipeBufferWriter interface {
Write(p []byte) (n int, err error)
Bytes() []byte
}
type PipeReader interface {
io.ReadCloser
internalOnly()
}
type pipeReader struct {
f *os.File
}
func (r *pipeReader) internalOnly() {}
func (r *pipeReader) Read(p []byte) (n int, err error) {
return r.f.Read(p)
}
func (r *pipeReader) Close() error {
return r.f.Close()
}
type PipeWriter interface {
io.WriteCloser
internalOnly()
}
type pipeWriter struct {
f *os.File
}
func (w *pipeWriter) internalOnly() {}
func (w *pipeWriter) Close() error {
return w.f.Close()
}
func (w *pipeWriter) Write(p []byte) (n int, err error) {
return w.f.Write(p)
}
func (w *pipeWriter) DrainBeforeClose() error {
return nil
}
type pipeNull struct {
err error
}
func (p *pipeNull) internalOnly() {}
func (p *pipeNull) Read([]byte) (n int, err error) {
return 0, p.err
}
func (p *pipeNull) Write([]byte) (n int, err error) {
return 0, p.err
}
func (p *pipeNull) Close() error {
return nil
}
@@ -1,14 +0,0 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitcmd
import "fmt"
// ConcatenateError concatenats an error with stderr string
func ConcatenateError(err error, stderr string) error {
if len(stderr) == 0 {
return err
}
return fmt.Errorf("%w - %s", err, stderr)
}
+102
View File
@@ -0,0 +1,102 @@
// Copyright 2015 The Gogs Authors. All rights reserved.
// Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"context"
"fmt"
"os"
"strings"
"sync"
"code.gitea.io/gitea/modules/git/gitcmd"
"code.gitea.io/gitea/modules/process"
)
// GPGSettings represents the default GPG settings for this repository
type GPGSettings struct {
Sign bool
KeyID string
Email string
Name string
PublicKeyContent string
Format string
}
// LoadPublicKeyContent will load the key from gpg
func (gpgSettings *GPGSettings) LoadPublicKeyContent() error {
if gpgSettings.PublicKeyContent != "" {
return nil
}
if gpgSettings.Format == SigningKeyFormatSSH {
content, err := os.ReadFile(gpgSettings.KeyID)
if err != nil {
return fmt.Errorf("unable to read SSH public key file: %s, %w", gpgSettings.KeyID, err)
}
gpgSettings.PublicKeyContent = string(content)
return nil
}
content, stderr, err := process.GetManager().Exec(
"gpg -a --export",
"gpg", "-a", "--export", gpgSettings.KeyID)
if err != nil {
return fmt.Errorf("unable to get default signing key: %s, %s, %w", gpgSettings.KeyID, stderr, err)
}
gpgSettings.PublicKeyContent = content
return nil
}
var (
loadPublicGPGKeyMutex sync.RWMutex
globalGPGSettings *GPGSettings
)
// GetDefaultPublicGPGKey will return and cache the default public GPG settings
func GetDefaultPublicGPGKey(ctx context.Context, forceUpdate bool) (*GPGSettings, error) {
if !forceUpdate {
loadPublicGPGKeyMutex.RLock()
if globalGPGSettings != nil {
defer loadPublicGPGKeyMutex.RUnlock()
return globalGPGSettings, nil
}
loadPublicGPGKeyMutex.RUnlock()
}
loadPublicGPGKeyMutex.Lock()
defer loadPublicGPGKeyMutex.Unlock()
if globalGPGSettings != nil && !forceUpdate {
return globalGPGSettings, nil
}
globalGPGSettings = &GPGSettings{
Sign: true,
}
value, _, _ := gitcmd.NewCommand("config", "--global", "--get", "commit.gpgsign").RunStdString(ctx)
sign, valid := ParseBool(strings.TrimSpace(value))
if !sign || !valid {
globalGPGSettings.Sign = false
return globalGPGSettings, nil
}
signingKey, _, _ := gitcmd.NewCommand("config", "--global", "--get", "user.signingkey").RunStdString(ctx)
globalGPGSettings.KeyID = strings.TrimSpace(signingKey)
format, _, _ := gitcmd.NewCommand("config", "--global", "--default", SigningKeyFormatOpenPGP, "--get", "gpg.format").RunStdString(ctx)
globalGPGSettings.Format = strings.TrimSpace(format)
defaultEmail, _, _ := gitcmd.NewCommand("config", "--global", "--get", "user.email").RunStdString(ctx)
globalGPGSettings.Email = strings.TrimSpace(defaultEmail)
defaultName, _, _ := gitcmd.NewCommand("config", "--global", "--get", "user.name").RunStdString(ctx)
globalGPGSettings.Name = strings.TrimSpace(defaultName)
if err := globalGPGSettings.LoadPublicKeyContent(); err != nil {
return nil, err
}
return globalGPGSettings, nil
}
+16 -26
View File
@@ -5,14 +5,13 @@ package git
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"os"
"slices"
"strconv"
"strings"
"time"
"code.gitea.io/gitea/modules/git/gitcmd"
"code.gitea.io/gitea/modules/util"
@@ -41,16 +40,11 @@ type GrepOptions struct {
PathspecList []string
}
func GrepSearch(ctx context.Context, repo *Repository, search string, opts GrepOptions) ([]*GrepResult, error) {
stdoutReader, stdoutWriter, err := os.Pipe()
if err != nil {
return nil, fmt.Errorf("unable to create os pipe to grep: %w", err)
}
defer func() {
_ = stdoutReader.Close()
_ = stdoutWriter.Close()
}()
// 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):
@@ -83,15 +77,12 @@ func GrepSearch(ctx context.Context, repo *Repository, search string, opts GrepO
cmd.AddDynamicArguments(util.IfZero(opts.RefName, "HEAD"))
cmd.AddDashesAndList(opts.PathspecList...)
opts.MaxResultLimit = util.IfZero(opts.MaxResultLimit, 50)
stderr := bytes.Buffer{}
err = cmd.Run(ctx, &gitcmd.RunOpts{
Dir: repo.Path,
Stdout: stdoutWriter,
Stderr: &stderr,
PipelineFunc: func(ctx context.Context, cancel context.CancelFunc) error {
_ = stdoutWriter.Close()
defer stdoutReader.Close()
stdoutReader, stdoutReaderClose := cmd.MakeStdoutPipe()
defer stdoutReaderClose()
err := cmd.WithDir(repo.Path).
WithTimeout(grepSearchTimeout).
WithPipelineFunc(func(ctx gitcmd.Context) error {
isInBlock := false
rd := bufio.NewReaderSize(stdoutReader, util.IfZero(opts.MaxLineLength, 16*1024))
var res *GrepResult
@@ -117,8 +108,7 @@ func GrepSearch(ctx context.Context, repo *Repository, search string, opts GrepO
}
if line == "" {
if len(results) >= opts.MaxResultLimit {
cancel()
break
return ctx.CancelPipeline(nil)
}
isInBlock = false
continue
@@ -133,18 +123,18 @@ func GrepSearch(ctx context.Context, repo *Repository, search string, opts GrepO
}
}
return nil
},
})
}).
RunWithStderr(ctx)
// git grep exits by cancel (killed), usually it is caused by the limit of results
if gitcmd.IsErrorExitCode(err, -1) && stderr.Len() == 0 {
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) && stderr.Len() == 0 {
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, stderr: %s", err, stderr.String())
return nil, fmt.Errorf("unable to run git grep: %w", err)
}
return results, nil
}
+1 -1
View File
@@ -45,7 +45,7 @@ func GetHook(repoPath, name string) (*Hook, error) {
}
h := &Hook{
name: name,
path: filepath.Join(repoPath, "hooks", name+".d", name),
path: filepath.Join(repoPath, filepath.Join("hooks", name+".d", name)),
}
if data, err := os.ReadFile(h.path); err == nil {
h.IsActive = true
+52 -1
View File
@@ -3,7 +3,13 @@
package git
import "code.gitea.io/gitea/modules/setting"
import (
"context"
"strings"
"code.gitea.io/gitea/modules/git/gitcmd"
"code.gitea.io/gitea/modules/setting"
)
// Based on https://git-scm.com/docs/git-config#Documentation/git-config.txt-gpgformat
const (
@@ -24,3 +30,48 @@ func (s *SigningKey) String() string {
setting.PanicInDevOrTesting("don't call SigningKey.String() - it exposes the KeyID which might be a local file path")
return "SigningKey:" + s.Format
}
// GetSigningKey returns the KeyID and git Signature for the repo
func GetSigningKey(ctx context.Context) (*SigningKey, *Signature) {
if setting.Repository.Signing.SigningKey == "none" {
return nil, nil
}
if setting.Repository.Signing.SigningKey == "default" || setting.Repository.Signing.SigningKey == "" {
// Can ignore the error here as it means that commit.gpgsign is not set
value, _, _ := gitcmd.NewCommand("config", "--global", "--get", "commit.gpgsign").RunStdString(ctx)
sign, valid := ParseBool(strings.TrimSpace(value))
if !sign || !valid {
return nil, nil
}
format, _, _ := gitcmd.NewCommand("config", "--global", "--default", SigningKeyFormatOpenPGP, "--get", "gpg.format").RunStdString(ctx)
signingKey, _, _ := gitcmd.NewCommand("config", "--global", "--get", "user.signingkey").RunStdString(ctx)
signingName, _, _ := gitcmd.NewCommand("config", "--global", "--get", "user.name").RunStdString(ctx)
signingEmail, _, _ := gitcmd.NewCommand("config", "--global", "--get", "user.email").RunStdString(ctx)
if strings.TrimSpace(signingKey) == "" {
return nil, nil
}
return &SigningKey{
KeyID: strings.TrimSpace(signingKey),
Format: strings.TrimSpace(format),
}, &Signature{
Name: strings.TrimSpace(signingName),
Email: strings.TrimSpace(signingEmail),
}
}
if setting.Repository.Signing.SigningKey == "" {
return nil, nil
}
return &SigningKey{
KeyID: setting.Repository.Signing.SigningKey,
Format: setting.Repository.Signing.SigningFormat,
}, &Signature{
Name: setting.Repository.Signing.SigningName,
Email: setting.Repository.Signing.SigningEmail,
}
}
@@ -108,7 +108,7 @@ func GetLanguageStats(repo *git_module.Repository, commitID string) (map[string]
if (!isVendored.Has() && analyze.IsVendor(f.Name)) ||
enry.IsDotFile(f.Name) ||
(!isDocumentation.Has() && enry.IsDocumentation(f.Name)) ||
enry.IsConfiguration(f.Name) {
(!isDetectable.Has() && enry.IsConfiguration(f.Name)) {
return nil
}
@@ -22,33 +22,28 @@ import (
func GetLanguageStats(repo *git.Repository, commitID string) (map[string]int64, error) {
// We will feed the commit IDs in order into cat-file --batch, followed by blobs as necessary.
// so let's create a batch stdin and stdout
batchStdinWriter, batchReader, cancel, err := repo.CatFileBatch(repo.Ctx)
batch, cancel, err := repo.CatFileBatch(repo.Ctx)
if err != nil {
return nil, err
}
defer cancel()
writeID := func(id string) error {
_, err := batchStdinWriter.Write([]byte(id + "\n"))
return err
}
if err := writeID(commitID); err != nil {
commitInfo, batchReader, err := batch.QueryContent(commitID)
if err != nil {
return nil, err
}
shaBytes, typ, size, err := git.ReadBatchLine(batchReader)
if typ != "commit" {
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(string(shaBytes))
sha, err := git.NewIDFromString(commitInfo.ID)
if err != nil {
log.Debug("Unable to get commit for: %s. Err: %v", commitID, err)
return nil, git.ErrNotExist{ID: commitID}
}
commit, err := git.CommitFromReader(repo, sha, io.LimitReader(batchReader, size))
commit, err := git.CommitFromReader(repo, sha, io.LimitReader(batchReader, commitInfo.Size))
if err != nil {
log.Debug("Unable to get commit for: %s. Err: %v", commitID, err)
return nil, err
@@ -137,27 +132,23 @@ func GetLanguageStats(repo *git.Repository, commitID string) (map[string]int64,
if (!isVendored.Has() && analyze.IsVendor(f.Name())) ||
enry.IsDotFile(f.Name()) ||
(!isDocumentation.Has() && enry.IsDocumentation(f.Name())) ||
enry.IsConfiguration(f.Name()) {
(!isDetectable.Has() && enry.IsConfiguration(f.Name())) {
continue
}
// If content can not be read or file is too big just do detection by filename
if f.Size() <= bigFileSize {
if err := writeID(f.ID.String()); err != nil {
return nil, err
}
_, _, size, err := git.ReadBatchLine(batchReader)
info, _, err := batch.QueryContent(f.ID.String())
if err != nil {
log.Debug("Error reading blob: %s Err: %v", f.ID.String(), err)
return nil, err
}
sizeToRead := size
sizeToRead := info.Size
discard := int64(1)
if size > fileSizeLimit {
if info.Size > fileSizeLimit {
sizeToRead = fileSizeLimit
discard = size - fileSizeLimit + 1
discard = info.Size - fileSizeLimit + 1
}
_, err = contentBuf.ReadFrom(io.LimitReader(batchReader, sizeToRead))
@@ -4,37 +4,11 @@
package languagestats
import (
"fmt"
"os"
"testing"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
)
func testRun(m *testing.M) error {
gitHomePath, err := os.MkdirTemp(os.TempDir(), "git-home")
if err != nil {
return fmt.Errorf("unable to create temp dir: %w", err)
}
defer util.RemoveAll(gitHomePath)
setting.Git.HomePath = gitHomePath
if err = git.InitFull(); err != nil {
return fmt.Errorf("failed to call Init: %w", err)
}
exitCode := m.Run()
if exitCode != 0 {
return fmt.Errorf("run test failed, ExitCode=%d", exitCode)
}
return nil
}
func TestMain(m *testing.M) {
if err := testRun(m); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "Test failed: %v", err)
os.Exit(1)
}
git.RunGitTests(m)
}
@@ -55,12 +55,12 @@ func (c *LastCommitCache) Put(ref, entryPath, commitID string) error {
// Get gets the last commit information by commit id and entry path
func (c *LastCommitCache) Get(ref, entryPath string) (*Commit, error) {
if c == nil || c.cache == nil {
return nil, nil
return nil, nil //nolint:nilnil // return nil when cache is not available
}
commitID, ok := c.cache.Get(getCacheKey(c.repoPath, ref, entryPath))
if !ok || commitID == "" {
return nil, nil
return nil, nil //nolint:nilnil // return nil when cache miss
}
log.Debug("LastCommitCache hit level 1: [%s:%s:%s]", ref, entryPath, commitID)
@@ -15,25 +15,12 @@ import (
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/git/gitcmd"
"github.com/djherbis/buffer"
"github.com/djherbis/nio/v3"
"code.gitea.io/gitea/modules/log"
)
// LogNameStatusRepo opens git log --raw in the provided repo and returns a stdin pipe, a stdout reader and cancel function
func LogNameStatusRepo(ctx context.Context, repository, head, treepath string, paths ...string) (*bufio.Reader, func()) {
// We often want to feed the commits in order into cat-file --batch, followed by their trees and sub trees as necessary.
// so let's create a batch stdin and stdout
stdoutReader, stdoutWriter := nio.Pipe(buffer.New(32 * 1024))
// Lets also create a context so that we can absolutely ensure that the command should die when we're done
ctx, ctxCancel := context.WithCancel(ctx)
cancel := func() {
ctxCancel()
_ = stdoutReader.Close()
_ = stdoutWriter.Close()
}
cmd := gitcmd.NewCommand()
cmd.AddArguments("log", "--name-status", "-c", "--format=commit%x00%H %P%x00", "--parents", "--no-renames", "-t", "-z").AddDynamicArguments(head)
@@ -63,25 +50,21 @@ func LogNameStatusRepo(ctx context.Context, repository, head, treepath string, p
}
cmd.AddDashesAndList(files...)
stdoutReader, stdoutReaderClose := cmd.MakeStdoutPipe()
ctx, ctxCancel := context.WithCancel(ctx)
go func() {
stderr := strings.Builder{}
err := cmd.Run(ctx, &gitcmd.RunOpts{
Dir: repository,
Stdout: stdoutWriter,
Stderr: &stderr,
})
if err != nil {
_ = stdoutWriter.CloseWithError(gitcmd.ConcatenateError(err, (&stderr).String()))
return
err := cmd.WithDir(repository).RunWithStderr(ctx)
if err != nil && !errors.Is(err, context.Canceled) {
log.Error("Unable to run git command %v: %v", cmd.LogString(), err)
}
_ = stdoutWriter.Close()
}()
// For simplicities sake we'll us a buffered reader to read from the cat-file --batch
bufReader := bufio.NewReaderSize(stdoutReader, 32*1024)
return bufReader, cancel
return bufReader, func() {
ctxCancel()
stdoutReaderClose()
}
}
// LogNameStatusRepoParser parses a git log raw output from LogRawRepo
@@ -123,7 +106,7 @@ func (g *LogNameStatusRepoParser) Next(treepath string, paths2ids map[string]int
case bufio.ErrBufferFull:
g.buffull = true
case io.EOF:
return nil, nil
return nil, nil //nolint:nilnil // return nil to signal EOF
default:
return nil, err
}
@@ -138,7 +121,7 @@ func (g *LogNameStatusRepoParser) Next(treepath string, paths2ids map[string]int
case bufio.ErrBufferFull:
g.buffull = true
case io.EOF:
return nil, nil
return nil, nil //nolint:nilnil // return nil to signal EOF
default:
return nil, err
}
@@ -7,6 +7,7 @@ package git
import (
"context"
"fmt"
"io"
"code.gitea.io/gitea/modules/log"
@@ -30,7 +31,11 @@ func GetNote(ctx context.Context, repo *Repository, commitID string, note *Note)
remainingCommitID := commitID
path := ""
currentTree := notes.Tree.gogitTree
currentTree, err := notes.Tree.gogitTreeObject()
if err != nil {
return fmt.Errorf("unable to get tree object for notes commit %q: %w", notes.ID.String(), err)
}
log.Trace("Found tree with ID %q while searching for git note corresponding to the commit %q", currentTree.Entries[0].Name, commitID)
var file *object.File
for len(remainingCommitID) > 2 {
@@ -47,5 +47,5 @@ func TestGetNonExistentNotes(t *testing.T) {
note := Note{}
err = GetNote(t.Context(), bareRepo1, "non_existent_sha", &note)
assert.Error(t, err)
assert.IsType(t, ErrNotExist{}, err)
assert.ErrorAs(t, err, &ErrNotExist{})
}
@@ -22,4 +22,6 @@ func TestIsValidSHAPattern(t *testing.T) {
assert.Equal(t, "2e65efe2a145dda7ee51d1741299f848e5bf752e", ComputeBlobHash(Sha1ObjectFormat, []byte("a")).String())
assert.Equal(t, "473a0f4c3be8a93681a267e3b1e9a7dcda1185436fe141f7749120a303721813", ComputeBlobHash(Sha256ObjectFormat, nil).String())
assert.Equal(t, "eb337bcee2061c5313c9a1392116b6c76039e9e30d71467ae359b36277e17dc7", ComputeBlobHash(Sha256ObjectFormat, []byte("a")).String())
assert.True(t, IsEmptyCommitID(""))
assert.True(t, IsEmptyCommitID("0000000000000000000000000000000000000000"))
}
+6 -6
View File
@@ -27,15 +27,15 @@ func parseLsTreeLine(line []byte) (*LsTreeEntry, error) {
// <mode> <type> <sha>\t<filename>
var err error
posTab := bytes.IndexByte(line, '\t')
if posTab == -1 {
before, after, ok := bytes.Cut(line, []byte{'\t'})
if !ok {
return nil, fmt.Errorf("invalid ls-tree output (no tab): %q", line)
}
entry := new(LsTreeEntry)
entryAttrs := line[:posTab]
entryName := line[posTab+1:]
entryAttrs := before
entryName := after
entryMode, entryAttrs, _ := bytes.Cut(entryAttrs, sepSpace)
_ /* entryType */, entryAttrs, _ = bytes.Cut(entryAttrs, sepSpace) // the type is not used, the mode is enough to determine the type
@@ -46,8 +46,8 @@ func parseLsTreeLine(line []byte) (*LsTreeEntry, error) {
entry.Size = optional.Some(size)
}
entry.EntryMode, err = ParseEntryMode(string(entryMode))
if err != nil || entry.EntryMode == EntryModeNoEntry {
entry.EntryMode = ParseEntryMode(string(entryMode))
if entry.EntryMode == EntryModeNoEntry {
return nil, fmt.Errorf("invalid ls-tree output (invalid mode): %q, err: %w", line, err)
}
@@ -1,96 +0,0 @@
// Copyright 2018 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build gogit
package git
import (
"bytes"
"fmt"
"strconv"
"strings"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/filemode"
"github.com/go-git/go-git/v5/plumbing/hash"
"github.com/go-git/go-git/v5/plumbing/object"
)
// ParseTreeEntries parses the output of a `git ls-tree -l` command.
func ParseTreeEntries(data []byte) ([]*TreeEntry, error) {
return parseTreeEntries(data, nil)
}
func parseTreeEntries(data []byte, ptree *Tree) ([]*TreeEntry, error) {
entries := make([]*TreeEntry, 0, 10)
for pos := 0; pos < len(data); {
// expect line to be of the form "<mode> <type> <sha> <space-padded-size>\t<filename>"
entry := new(TreeEntry)
entry.gogitTreeEntry = &object.TreeEntry{}
entry.ptree = ptree
if pos+6 > len(data) {
return nil, fmt.Errorf("Invalid ls-tree output: %s", string(data))
}
switch string(data[pos : pos+6]) {
case "100644":
entry.gogitTreeEntry.Mode = filemode.Regular
pos += 12 // skip over "100644 blob "
case "100755":
entry.gogitTreeEntry.Mode = filemode.Executable
pos += 12 // skip over "100755 blob "
case "120000":
entry.gogitTreeEntry.Mode = filemode.Symlink
pos += 12 // skip over "120000 blob "
case "160000":
entry.gogitTreeEntry.Mode = filemode.Submodule
pos += 14 // skip over "160000 object "
case "040000":
entry.gogitTreeEntry.Mode = filemode.Dir
pos += 12 // skip over "040000 tree "
default:
return nil, fmt.Errorf("unknown type: %v", string(data[pos:pos+6]))
}
// in hex format, not byte format ....
if pos+hash.Size*2 > len(data) {
return nil, fmt.Errorf("Invalid ls-tree output: %s", string(data))
}
var err error
entry.ID, err = NewIDFromString(string(data[pos : pos+hash.Size*2]))
if err != nil {
return nil, fmt.Errorf("invalid ls-tree output: %w", err)
}
entry.gogitTreeEntry.Hash = plumbing.Hash(entry.ID.RawValue())
pos += 41 // skip over sha and trailing space
end := pos + bytes.IndexByte(data[pos:], '\t')
if end < pos {
return nil, fmt.Errorf("Invalid ls-tree -l output: %s", string(data))
}
entry.size, _ = strconv.ParseInt(strings.TrimSpace(string(data[pos:end])), 10, 64)
entry.sized = true
pos = end + 1
end = pos + bytes.IndexByte(data[pos:], '\n')
if end < pos {
return nil, fmt.Errorf("Invalid ls-tree output: %s", string(data))
}
// In case entry name is surrounded by double quotes(it happens only in git-shell).
if data[pos] == '"' {
var err error
entry.gogitTreeEntry.Name, err = strconv.Unquote(string(data[pos:end]))
if err != nil {
return nil, fmt.Errorf("Invalid ls-tree output: %w", err)
}
} else {
entry.gogitTreeEntry.Name = string(data[pos:end])
}
pos = end + 1
entries = append(entries, entry)
}
return entries, nil
}
@@ -1,78 +0,0 @@
// Copyright 2018 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build gogit
package git
import (
"fmt"
"testing"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/filemode"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/stretchr/testify/assert"
)
func TestParseTreeEntries(t *testing.T) {
testCases := []struct {
Input string
Expected []*TreeEntry
}{
{
Input: "",
Expected: []*TreeEntry{},
},
{
Input: "100644 blob 61ab7345a1a3bbc590068ccae37b8515cfc5843c 1022\texample/file2.txt\n",
Expected: []*TreeEntry{
{
ID: MustIDFromString("61ab7345a1a3bbc590068ccae37b8515cfc5843c"),
gogitTreeEntry: &object.TreeEntry{
Hash: plumbing.Hash(MustIDFromString("61ab7345a1a3bbc590068ccae37b8515cfc5843c").RawValue()),
Name: "example/file2.txt",
Mode: filemode.Regular,
},
size: 1022,
sized: true,
},
},
},
{
Input: "120000 blob 61ab7345a1a3bbc590068ccae37b8515cfc5843c 234131\t\"example/\\n.txt\"\n" +
"040000 tree 1d01fb729fb0db5881daaa6030f9f2d3cd3d5ae8 -\texample\n",
Expected: []*TreeEntry{
{
ID: MustIDFromString("61ab7345a1a3bbc590068ccae37b8515cfc5843c"),
gogitTreeEntry: &object.TreeEntry{
Hash: plumbing.Hash(MustIDFromString("61ab7345a1a3bbc590068ccae37b8515cfc5843c").RawValue()),
Name: "example/\n.txt",
Mode: filemode.Symlink,
},
size: 234131,
sized: true,
},
{
ID: MustIDFromString("1d01fb729fb0db5881daaa6030f9f2d3cd3d5ae8"),
sized: true,
gogitTreeEntry: &object.TreeEntry{
Hash: plumbing.Hash(MustIDFromString("1d01fb729fb0db5881daaa6030f9f2d3cd3d5ae8").RawValue()),
Name: "example",
Mode: filemode.Dir,
},
},
},
},
}
for _, testCase := range testCases {
entries, err := ParseTreeEntries([]byte(testCase.Input))
assert.NoError(t, err)
if len(entries) > 1 {
fmt.Println(testCase.Expected[0].ID)
fmt.Println(entries[0].ID)
}
assert.EqualValues(t, testCase.Expected, entries)
}
}
@@ -1,12 +1,9 @@
// Copyright 2018 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !gogit
package git
import (
"bufio"
"bytes"
"fmt"
"io"
@@ -49,7 +46,7 @@ func parseTreeEntries(data []byte, ptree *Tree) ([]*TreeEntry, error) {
return entries, nil
}
func catBatchParseTreeEntries(objectFormat ObjectFormat, ptree *Tree, rd *bufio.Reader, sz int64) ([]*TreeEntry, error) {
func catBatchParseTreeEntries(objectFormat ObjectFormat, ptree *Tree, rd BufferedReader, sz int64) ([]*TreeEntry, error) {
fnameBuf := make([]byte, 4096)
modeBuf := make([]byte, 40)
shaBuf := make([]byte, objectFormat.FullLength())
@@ -1,8 +1,6 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !gogit
package git
import (
@@ -5,83 +5,34 @@ package pipeline
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"strconv"
"strings"
"sync"
"code.gitea.io/gitea/modules/git/gitcmd"
"code.gitea.io/gitea/modules/log"
)
// CatFileBatchCheck runs cat-file with --batch-check
func CatFileBatchCheck(ctx context.Context, shasToCheckReader *io.PipeReader, catFileCheckWriter *io.PipeWriter, wg *sync.WaitGroup, tmpBasePath string) {
defer wg.Done()
defer shasToCheckReader.Close()
defer catFileCheckWriter.Close()
stderr := new(bytes.Buffer)
var errbuf strings.Builder
cmd := gitcmd.NewCommand("cat-file", "--batch-check")
if err := cmd.Run(ctx, &gitcmd.RunOpts{
Dir: tmpBasePath,
Stdin: shasToCheckReader,
Stdout: catFileCheckWriter,
Stderr: stderr,
}); err != nil {
_ = catFileCheckWriter.CloseWithError(fmt.Errorf("git cat-file --batch-check [%s]: %w - %s", tmpBasePath, err, errbuf.String()))
}
func CatFileBatchCheck(ctx context.Context, cmd *gitcmd.Command, tmpBasePath string) error {
cmd.AddArguments("cat-file", "--batch-check")
return cmd.WithDir(tmpBasePath).RunWithStderr(ctx)
}
// CatFileBatchCheckAllObjects runs cat-file with --batch-check --batch-all
func CatFileBatchCheckAllObjects(ctx context.Context, catFileCheckWriter *io.PipeWriter, wg *sync.WaitGroup, tmpBasePath string, errChan chan<- error) {
defer wg.Done()
defer catFileCheckWriter.Close()
stderr := new(bytes.Buffer)
var errbuf strings.Builder
cmd := gitcmd.NewCommand("cat-file", "--batch-check", "--batch-all-objects")
if err := cmd.Run(ctx, &gitcmd.RunOpts{
Dir: tmpBasePath,
Stdout: catFileCheckWriter,
Stderr: stderr,
}); err != nil {
log.Error("git cat-file --batch-check --batch-all-object [%s]: %v - %s", tmpBasePath, err, errbuf.String())
err = fmt.Errorf("git cat-file --batch-check --batch-all-object [%s]: %w - %s", tmpBasePath, err, errbuf.String())
_ = catFileCheckWriter.CloseWithError(err)
errChan <- err
}
func CatFileBatchCheckAllObjects(ctx context.Context, cmd *gitcmd.Command, tmpBasePath string) error {
return cmd.AddArguments("cat-file", "--batch-check", "--batch-all-objects").WithDir(tmpBasePath).RunWithStderr(ctx)
}
// CatFileBatch runs cat-file --batch
func CatFileBatch(ctx context.Context, shasToBatchReader *io.PipeReader, catFileBatchWriter *io.PipeWriter, wg *sync.WaitGroup, tmpBasePath string) {
defer wg.Done()
defer shasToBatchReader.Close()
defer catFileBatchWriter.Close()
stderr := new(bytes.Buffer)
var errbuf strings.Builder
if err := gitcmd.NewCommand("cat-file", "--batch").Run(ctx, &gitcmd.RunOpts{
Dir: tmpBasePath,
Stdout: catFileBatchWriter,
Stdin: shasToBatchReader,
Stderr: stderr,
}); err != nil {
_ = shasToBatchReader.CloseWithError(fmt.Errorf("git rev-list [%s]: %w - %s", tmpBasePath, err, errbuf.String()))
}
func CatFileBatch(ctx context.Context, cmd *gitcmd.Command, tmpBasePath string) error {
return cmd.AddArguments("cat-file", "--batch").WithDir(tmpBasePath).RunWithStderr(ctx)
}
// BlobsLessThan1024FromCatFileBatchCheck reads a pipeline from cat-file --batch-check and returns the blobs <1024 in size
func BlobsLessThan1024FromCatFileBatchCheck(catFileCheckReader *io.PipeReader, shasToBatchWriter *io.PipeWriter, wg *sync.WaitGroup) {
defer wg.Done()
defer catFileCheckReader.Close()
scanner := bufio.NewScanner(catFileCheckReader)
defer func() {
_ = shasToBatchWriter.CloseWithError(scanner.Err())
}()
func BlobsLessThan1024FromCatFileBatchCheck(in io.ReadCloser, out io.WriteCloser) error {
defer out.Close()
scanner := bufio.NewScanner(in)
for scanner.Scan() {
line := scanner.Text()
if len(line) == 0 {
@@ -97,12 +48,12 @@ func BlobsLessThan1024FromCatFileBatchCheck(catFileCheckReader *io.PipeReader, s
}
toWrite := []byte(fields[0] + "\n")
for len(toWrite) > 0 {
n, err := shasToBatchWriter.Write(toWrite)
n, err := out.Write(toWrite)
if err != nil {
_ = catFileCheckReader.CloseWithError(err)
break
return err
}
toWrite = toWrite[n:]
}
}
return scanner.Err()
}
@@ -4,7 +4,6 @@
package pipeline
import (
"fmt"
"time"
"code.gitea.io/gitea/modules/git"
@@ -26,7 +25,3 @@ type lfsResultSlice []*LFSResult
func (a lfsResultSlice) Len() int { return len(a) }
func (a lfsResultSlice) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a lfsResultSlice) Less(i, j int) bool { return a[j].When.After(a[i].When) }
func lfsError(msg string, err error) error {
return fmt.Errorf("LFS error occurred, %s: err: %w", msg, err)
}
@@ -6,11 +6,10 @@
package pipeline
import (
"bufio"
"fmt"
"io"
"sort"
"strings"
"sync"
"code.gitea.io/gitea/modules/git"
@@ -24,7 +23,6 @@ func FindLFSFile(repo *git.Repository, objectID git.ObjectID) ([]*LFSResult, err
resultsMap := map[string]*LFSResult{}
results := make([]*LFSResult, 0)
basePath := repo.Path
gogitRepo := repo.GoGitRepo()
commitsIter, err := gogitRepo.Log(&gogit.LogOptions{
@@ -32,7 +30,7 @@ func FindLFSFile(repo *git.Repository, objectID git.ObjectID) ([]*LFSResult, err
All: true,
})
if err != nil {
return nil, lfsError("failed to get GoGit CommitsIter", err)
return nil, fmt.Errorf("LFS error occurred, failed to get GoGit CommitsIter: err: %w", err)
}
err = commitsIter.ForEach(func(gitCommit *object.Commit) error {
@@ -66,7 +64,7 @@ func FindLFSFile(repo *git.Repository, objectID git.ObjectID) ([]*LFSResult, err
return nil
})
if err != nil && err != io.EOF {
return nil, lfsError("failure in CommitIter.ForEach", err)
return nil, fmt.Errorf("LFS error occurred, failure in CommitIter.ForEach: %w", err)
}
for _, result := range resultsMap {
@@ -82,65 +80,6 @@ func FindLFSFile(repo *git.Repository, objectID git.ObjectID) ([]*LFSResult, err
}
sort.Sort(lfsResultSlice(results))
// Should really use a go-git function here but name-rev is not completed and recapitulating it is not simple
shasToNameReader, shasToNameWriter := io.Pipe()
nameRevStdinReader, nameRevStdinWriter := io.Pipe()
errChan := make(chan error, 1)
wg := sync.WaitGroup{}
wg.Add(3)
go func() {
defer wg.Done()
scanner := bufio.NewScanner(nameRevStdinReader)
i := 0
for scanner.Scan() {
line := scanner.Text()
if len(line) == 0 {
continue
}
result := results[i]
result.FullCommitName = line
result.BranchName = strings.Split(line, "~")[0]
i++
}
}()
go NameRevStdin(repo.Ctx, shasToNameReader, nameRevStdinWriter, &wg, basePath)
go func() {
defer wg.Done()
defer shasToNameWriter.Close()
for _, result := range results {
i := 0
if i < len(result.SHA) {
n, err := shasToNameWriter.Write([]byte(result.SHA)[i:])
if err != nil {
errChan <- err
break
}
i += n
}
n := 0
for n < 1 {
n, err = shasToNameWriter.Write([]byte{'\n'})
if err != nil {
errChan <- err
break
}
}
}
}()
wg.Wait()
select {
case err, has := <-errChan:
if has {
return nil, lfsError("unable to obtain name for LFS files", err)
}
default:
}
return results, nil
err = fillResultNameRev(repo.Ctx, repo.Path, results)
return results, err
}
@@ -8,46 +8,33 @@ package pipeline
import (
"bufio"
"bytes"
"encoding/hex"
"io"
"sort"
"strings"
"sync"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/git/gitcmd"
)
// FindLFSFile finds commits that contain a provided pointer file hash
func FindLFSFile(repo *git.Repository, objectID git.ObjectID) ([]*LFSResult, error) {
func FindLFSFile(repo *git.Repository, objectID git.ObjectID) (results []*LFSResult, _ error) {
cmd := gitcmd.NewCommand("rev-list", "--all")
revListReader, revListReaderClose := cmd.MakeStdoutPipe()
defer revListReaderClose()
err := cmd.WithDir(repo.Path).
WithPipelineFunc(func(context gitcmd.Context) (err error) {
results, err = findLFSFileFunc(repo, objectID, revListReader)
return err
}).RunWithStderr(repo.Ctx)
return results, err
}
func findLFSFileFunc(repo *git.Repository, objectID git.ObjectID, revListReader io.Reader) ([]*LFSResult, error) {
resultsMap := map[string]*LFSResult{}
results := make([]*LFSResult, 0)
basePath := repo.Path
// Use rev-list to provide us with all commits in order
revListReader, revListWriter := io.Pipe()
defer func() {
_ = revListWriter.Close()
_ = revListReader.Close()
}()
go func() {
stderr := strings.Builder{}
err := gitcmd.NewCommand("rev-list", "--all").Run(repo.Ctx, &gitcmd.RunOpts{
Dir: repo.Path,
Stdout: revListWriter,
Stderr: &stderr,
})
if err != nil {
_ = revListWriter.CloseWithError(gitcmd.ConcatenateError(err, (&stderr).String()))
} else {
_ = revListWriter.Close()
}
}()
// Next feed the commits in order into cat-file --batch, followed by their trees and sub trees as necessary.
// so let's create a batch stdin and stdout
batchStdinWriter, batchReader, cancel, err := repo.CatFileBatch(repo.Ctx)
batch, cancel, err := repo.CatFileBatch(repo.Ctx)
if err != nil {
return nil, err
}
@@ -55,7 +42,7 @@ func FindLFSFile(repo *git.Repository, objectID git.ObjectID) ([]*LFSResult, err
// We'll use a scanner for the revList because it's simpler than a bufio.Reader
scan := bufio.NewScanner(revListReader)
trees := [][]byte{}
trees := []string{}
paths := []string{}
fnameBuf := make([]byte, 4096)
@@ -64,14 +51,10 @@ func FindLFSFile(repo *git.Repository, objectID git.ObjectID) ([]*LFSResult, err
for scan.Scan() {
// Get the next commit ID
commitID := scan.Bytes()
commitID := scan.Text()
// push the commit to the cat-file --batch process
_, err := batchStdinWriter.Write(commitID)
if err != nil {
return nil, err
}
_, err = batchStdinWriter.Write([]byte{'\n'})
info, batchReader, err := batch.QueryContent(commitID)
if err != nil {
return nil, err
}
@@ -81,26 +64,20 @@ func FindLFSFile(repo *git.Repository, objectID git.ObjectID) ([]*LFSResult, err
commitReadingLoop:
for {
_, typ, size, err := git.ReadBatchLine(batchReader)
if err != nil {
return nil, err
}
switch typ {
switch info.Type {
case "tag":
// This shouldn't happen but if it does well just get the commit and try again
id, err := git.ReadTagObjectID(batchReader, size)
id, err := git.ReadTagObjectID(batchReader, info.Size)
if err != nil {
return nil, err
}
_, err = batchStdinWriter.Write([]byte(id + "\n"))
if err != nil {
if info, batchReader, err = batch.QueryContent(id); err != nil {
return nil, err
}
continue
case "commit":
// Read in the commit to get its tree and in case this is one of the last used commits
curCommit, err = git.CommitFromReader(repo, git.MustIDFromString(string(commitID)), io.LimitReader(batchReader, size))
curCommit, err = git.CommitFromReader(repo, git.MustIDFromString(commitID), io.LimitReader(batchReader, info.Size))
if err != nil {
return nil, err
}
@@ -108,13 +85,13 @@ func FindLFSFile(repo *git.Repository, objectID git.ObjectID) ([]*LFSResult, err
return nil, err
}
if _, err := batchStdinWriter.Write([]byte(curCommit.Tree.ID.String() + "\n")); err != nil {
if info, _, err = batch.QueryContent(curCommit.Tree.ID.String()); err != nil {
return nil, err
}
curPath = ""
case "tree":
var n int64
for n < size {
for n < info.Size {
mode, fname, binObjectID, count, err := git.ParseCatFileTreeLine(objectID.Type(), batchReader, modeBuf, fnameBuf, workingShaBuf)
if err != nil {
return nil, err
@@ -124,15 +101,13 @@ func FindLFSFile(repo *git.Repository, objectID git.ObjectID) ([]*LFSResult, err
result := LFSResult{
Name: curPath + string(fname),
SHA: curCommit.ID.String(),
Summary: strings.Split(strings.TrimSpace(curCommit.CommitMessage), "\n")[0],
Summary: curCommit.Summary(),
When: curCommit.Author.When,
ParentHashes: curCommit.Parents,
}
resultsMap[curCommit.ID.String()+":"+curPath+string(fname)] = &result
} else if string(mode) == git.EntryModeTree.String() {
hexObjectID := make([]byte, objectID.Type().FullLength())
git.BinToHex(objectID.Type(), binObjectID, hexObjectID)
trees = append(trees, hexObjectID)
trees = append(trees, hex.EncodeToString(binObjectID))
paths = append(paths, curPath+string(fname)+"/")
}
}
@@ -140,11 +115,7 @@ func FindLFSFile(repo *git.Repository, objectID git.ObjectID) ([]*LFSResult, err
return nil, err
}
if len(trees) > 0 {
_, err := batchStdinWriter.Write(trees[len(trees)-1])
if err != nil {
return nil, err
}
_, err = batchStdinWriter.Write([]byte("\n"))
info, _, err = batch.QueryContent(trees[len(trees)-1])
if err != nil {
return nil, err
}
@@ -155,7 +126,7 @@ func FindLFSFile(repo *git.Repository, objectID git.ObjectID) ([]*LFSResult, err
break commitReadingLoop
}
default:
if err := git.DiscardFull(batchReader, size+1); err != nil {
if err := git.DiscardFull(batchReader, info.Size+1); err != nil {
return nil, err
}
}
@@ -179,56 +150,6 @@ func FindLFSFile(repo *git.Repository, objectID git.ObjectID) ([]*LFSResult, err
}
sort.Sort(lfsResultSlice(results))
// Should really use a go-git function here but name-rev is not completed and recapitulating it is not simple
shasToNameReader, shasToNameWriter := io.Pipe()
nameRevStdinReader, nameRevStdinWriter := io.Pipe()
errChan := make(chan error, 1)
wg := sync.WaitGroup{}
wg.Add(3)
go func() {
defer wg.Done()
scanner := bufio.NewScanner(nameRevStdinReader)
i := 0
for scanner.Scan() {
line := scanner.Text()
if len(line) == 0 {
continue
}
result := results[i]
result.FullCommitName = line
result.BranchName = strings.Split(line, "~")[0]
i++
}
}()
go NameRevStdin(repo.Ctx, shasToNameReader, nameRevStdinWriter, &wg, basePath)
go func() {
defer wg.Done()
defer shasToNameWriter.Close()
for _, result := range results {
_, err := shasToNameWriter.Write([]byte(result.SHA))
if err != nil {
errChan <- err
break
}
_, err = shasToNameWriter.Write([]byte{'\n'})
if err != nil {
errChan <- err
break
}
}
}()
wg.Wait()
select {
case err, has := <-errChan:
if has {
return nil, lfsError("unable to obtain name for LFS files", err)
}
default:
}
return results, nil
err = fillResultNameRev(repo.Ctx, repo.Path, results)
return results, err
}
@@ -0,0 +1,38 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package pipeline
import (
"testing"
"time"
"code.gitea.io/gitea/modules/git"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFindLFSFile(t *testing.T) {
repoPath := "../../../tests/gitea-repositories-meta/user2/lfs.git"
gitRepo, err := git.OpenRepository(t.Context(), repoPath)
require.NoError(t, err)
defer gitRepo.Close()
objectID := git.MustIDFromString("2b6c6c4eaefa24b22f2092c3d54b263ff26feb58")
stats, err := FindLFSFile(gitRepo, objectID)
require.NoError(t, err)
tm, err := time.Parse(time.RFC3339, "2022-12-21T17:56:42-05:00")
require.NoError(t, err)
assert.Len(t, stats, 1)
assert.Equal(t, "CONTRIBUTING.md", stats[0].Name)
assert.Equal(t, "73cf03db6ece34e12bf91e8853dc58f678f2f82d", stats[0].SHA)
assert.Equal(t, "Initial commit", stats[0].Summary)
assert.Equal(t, tm, stats[0].When)
assert.Empty(t, stats[0].ParentHashes)
assert.Equal(t, "master", stats[0].BranchName)
assert.Equal(t, "master", stats[0].FullCommitName)
}
@@ -0,0 +1,14 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package pipeline
import (
"testing"
"code.gitea.io/gitea/modules/git"
)
func TestMain(m *testing.M) {
git.RunGitTests(m)
}
@@ -4,30 +4,54 @@
package pipeline
import (
"bytes"
"bufio"
"context"
"fmt"
"io"
"errors"
"strings"
"sync"
"code.gitea.io/gitea/modules/git/gitcmd"
"golang.org/x/sync/errgroup"
)
// NameRevStdin runs name-rev --stdin
func NameRevStdin(ctx context.Context, shasToNameReader *io.PipeReader, nameRevStdinWriter *io.PipeWriter, wg *sync.WaitGroup, tmpBasePath string) {
defer wg.Done()
defer shasToNameReader.Close()
defer nameRevStdinWriter.Close()
func fillResultNameRev(ctx context.Context, basePath string, results []*LFSResult) error {
// Should really use a go-git function here but name-rev is not completed and recapitulating it is not simple
wg := errgroup.Group{}
cmd := gitcmd.NewCommand("name-rev", "--stdin", "--name-only", "--always").WithDir(basePath)
stdin, stdinClose := cmd.MakeStdinPipe()
stdout, stdoutClose := cmd.MakeStdoutPipe()
defer stdinClose()
defer stdoutClose()
stderr := new(bytes.Buffer)
var errbuf strings.Builder
if err := gitcmd.NewCommand("name-rev", "--stdin", "--name-only", "--always").Run(ctx, &gitcmd.RunOpts{
Dir: tmpBasePath,
Stdout: nameRevStdinWriter,
Stdin: shasToNameReader,
Stderr: stderr,
}); err != nil {
_ = shasToNameReader.CloseWithError(fmt.Errorf("git name-rev [%s]: %w - %s", tmpBasePath, err, errbuf.String()))
}
wg.Go(func() error {
scanner := bufio.NewScanner(stdout)
i := 0
for scanner.Scan() {
line := scanner.Text()
if len(line) == 0 {
continue
}
result := results[i]
result.FullCommitName = line
result.BranchName = strings.Split(line, "~")[0]
i++
}
return scanner.Err()
})
wg.Go(func() error {
defer stdinClose()
for _, result := range results {
_, err := stdin.Write([]byte(result.SHA))
if err != nil {
return err
}
_, err = stdin.Write([]byte{'\n'})
if err != nil {
return err
}
}
return nil
})
err := cmd.RunWithStderr(ctx)
return errors.Join(err, wg.Wait())
}
@@ -5,65 +5,26 @@ package pipeline
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"strings"
"sync"
"code.gitea.io/gitea/modules/git/gitcmd"
"code.gitea.io/gitea/modules/log"
)
// RevListAllObjects runs rev-list --objects --all and writes to a pipewriter
func RevListAllObjects(ctx context.Context, revListWriter *io.PipeWriter, wg *sync.WaitGroup, basePath string, errChan chan<- error) {
defer wg.Done()
defer revListWriter.Close()
stderr := new(bytes.Buffer)
var errbuf strings.Builder
cmd := gitcmd.NewCommand("rev-list", "--objects", "--all")
if err := cmd.Run(ctx, &gitcmd.RunOpts{
Dir: basePath,
Stdout: revListWriter,
Stderr: stderr,
}); err != nil {
log.Error("git rev-list --objects --all [%s]: %v - %s", basePath, err, errbuf.String())
err = fmt.Errorf("git rev-list --objects --all [%s]: %w - %s", basePath, err, errbuf.String())
_ = revListWriter.CloseWithError(err)
errChan <- err
}
}
// RevListObjects run rev-list --objects from headSHA to baseSHA
func RevListObjects(ctx context.Context, revListWriter *io.PipeWriter, wg *sync.WaitGroup, tmpBasePath, headSHA, baseSHA string, errChan chan<- error) {
defer wg.Done()
defer revListWriter.Close()
stderr := new(bytes.Buffer)
var errbuf strings.Builder
cmd := gitcmd.NewCommand("rev-list", "--objects").AddDynamicArguments(headSHA)
func RevListObjects(ctx context.Context, cmd *gitcmd.Command, tmpBasePath, headSHA, baseSHA string) error {
cmd.AddArguments("rev-list", "--objects").AddDynamicArguments(headSHA)
if baseSHA != "" {
cmd = cmd.AddArguments("--not").AddDynamicArguments(baseSHA)
}
if err := cmd.Run(ctx, &gitcmd.RunOpts{
Dir: tmpBasePath,
Stdout: revListWriter,
Stderr: stderr,
}); err != nil {
log.Error("git rev-list [%s]: %v - %s", tmpBasePath, err, errbuf.String())
errChan <- fmt.Errorf("git rev-list [%s]: %w - %s", tmpBasePath, err, errbuf.String())
}
return cmd.WithDir(tmpBasePath).RunWithStderr(ctx)
}
// BlobsFromRevListObjects reads a RevListAllObjects and only selects blobs
func BlobsFromRevListObjects(revListReader *io.PipeReader, shasToCheckWriter *io.PipeWriter, wg *sync.WaitGroup) {
defer wg.Done()
defer revListReader.Close()
scanner := bufio.NewScanner(revListReader)
defer func() {
_ = shasToCheckWriter.CloseWithError(scanner.Err())
}()
func BlobsFromRevListObjects(in io.ReadCloser, out io.WriteCloser) error {
defer out.Close()
scanner := bufio.NewScanner(in)
for scanner.Scan() {
line := scanner.Text()
if len(line) == 0 {
@@ -75,12 +36,12 @@ func BlobsFromRevListObjects(revListReader *io.PipeReader, shasToCheckWriter *io
}
toWrite := []byte(fields[0] + "\n")
for len(toWrite) > 0 {
n, err := shasToCheckWriter.Write(toWrite)
n, err := out.Write(toWrite)
if err != nil {
_ = revListReader.CloseWithError(err)
break
return err
}
toWrite = toWrite[n:]
}
}
return scanner.Err()
}
+11
View File
@@ -220,3 +220,14 @@ func (ref RefName) RefWebLinkPath() string {
}
return string(refType) + "/" + util.PathEscapeSegments(ref.ShortName())
}
func ParseRefSuffix(ref string) (string, string) {
// Partially support https://git-scm.com/docs/gitrevisions
if idx := strings.Index(ref, "@{"); idx != -1 {
return ref[:idx], ref[idx:]
}
if idx := strings.Index(ref, "^"); idx != -1 {
return ref[:idx], ref[idx:]
}
return ref, ""
}
+4 -4
View File
@@ -22,7 +22,7 @@ func GetRemoteAddress(ctx context.Context, repoPath, remoteName string) (string,
cmd = gitcmd.NewCommand("config", "--get").AddDynamicArguments("remote." + remoteName + ".url")
}
result, _, err := cmd.RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath})
result, _, err := cmd.WithDir(repoPath).RunStdString(ctx)
if err != nil {
return "", err
}
@@ -74,9 +74,9 @@ func (err *ErrInvalidCloneAddr) Unwrap() error {
func IsRemoteNotExistError(err error) bool {
// see: https://github.com/go-gitea/gitea/issues/32889#issuecomment-2571848216
// Should not add space in the end, sometimes git will add a `:`
prefix1 := "exit status 128 - fatal: No such remote" // git < 2.30
prefix2 := "exit status 2 - error: No such remote" // git >= 2.30
return strings.HasPrefix(err.Error(), prefix1) || strings.HasPrefix(err.Error(), prefix2)
prefix1 := "fatal: No such remote" // git < 2.30, exit status 128
prefix2 := "error: No such remote" // git >= 2.30. exit status 2
return gitcmd.StderrHasPrefix(err, prefix1) || gitcmd.StderrHasPrefix(err, prefix2)
}
// ParseRemoteAddr checks if given remote address is valid,
+48 -136
View File
@@ -8,42 +8,24 @@ import (
"bytes"
"context"
"fmt"
"io"
"net/url"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"time"
"code.gitea.io/gitea/modules/git/gitcmd"
"code.gitea.io/gitea/modules/proxy"
"code.gitea.io/gitea/modules/setting"
)
// GPGSettings represents the default GPG settings for this repository
type GPGSettings struct {
Sign bool
KeyID string
Email string
Name string
PublicKeyContent string
Format string
}
const prettyLogFormat = `--pretty=format:%H`
// GetAllCommitsCount returns count of all commits in repository
func (repo *Repository) GetAllCommitsCount() (int64, error) {
return AllCommitsCount(repo.Ctx, repo.Path, false)
}
func (repo *Repository) ShowPrettyFormatLogToList(ctx context.Context, revisionRange string) ([]*Commit, error) {
// avoid: ambiguous argument 'refs/a...refs/b': unknown revision or path not in the working tree. Use '--': 'git <command> [<revision>...] -- [<file>...]'
logs, _, err := gitcmd.NewCommand("log").AddArguments(prettyLogFormat).
AddDynamicArguments(revisionRange).AddArguments("--").
RunStdBytes(ctx, &gitcmd.RunOpts{Dir: repo.Path})
AddDynamicArguments(revisionRange).AddArguments("--").WithDir(repo.Path).
RunStdBytes(ctx)
if err != nil {
return nil, err
}
@@ -71,7 +53,7 @@ func (repo *Repository) parsePrettyFormatLogToList(logs []byte) ([]*Commit, erro
// IsRepoURLAccessible checks if given repository URL is accessible.
func IsRepoURLAccessible(ctx context.Context, url string) bool {
_, _, err := gitcmd.NewCommand("ls-remote", "-q", "-h").AddDynamicArguments(url, "HEAD").RunStdString(ctx, nil)
_, _, err := gitcmd.NewCommand("ls-remote", "-q", "-h").AddDynamicArguments(url, "HEAD").RunStdString(ctx)
return err == nil
}
@@ -94,27 +76,25 @@ func InitRepository(ctx context.Context, repoPath string, bare bool, objectForma
if bare {
cmd.AddArguments("--bare")
}
_, _, err = cmd.RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath})
_, _, err = cmd.WithDir(repoPath).RunStdString(ctx)
return err
}
// IsEmpty Check if repository is empty.
func (repo *Repository) IsEmpty() (bool, error) {
var errbuf, output strings.Builder
if err := gitcmd.NewCommand().AddOptionFormat("--git-dir=%s", repo.Path).AddArguments("rev-list", "-n", "1", "--all").
Run(repo.Ctx, &gitcmd.RunOpts{
Dir: repo.Path,
Stdout: &output,
Stderr: &errbuf,
}); err != nil {
if (err.Error() == "exit status 1" && strings.TrimSpace(errbuf.String()) == "") || err.Error() == "exit status 129" {
stdout, _, err := gitcmd.NewCommand().
AddOptionFormat("--git-dir=%s", repo.Path).
AddArguments("rev-list", "-n", "1", "--all").
WithDir(repo.Path).
RunStdString(repo.Ctx)
if err != nil {
if (gitcmd.IsErrorExitCode(err, 1) && err.Stderr() == "") || gitcmd.IsErrorExitCode(err, 129) {
// git 2.11 exits with 129 if the repo is empty
return true, nil
}
return true, fmt.Errorf("check empty: %w - %s", err, errbuf.String())
return true, fmt.Errorf("check empty: %w", err)
}
return strings.TrimSpace(output.String()) == "", nil
return strings.TrimSpace(stdout) == "", nil
}
// CloneRepoOptions options when clone a repository
@@ -129,6 +109,8 @@ type CloneRepoOptions struct {
Depth int
Filter string
SkipTLSVerify bool
SingleBranch bool
Env []string
}
// Clone clones original repository to target path.
@@ -163,6 +145,9 @@ func Clone(ctx context.Context, from, to string, opts CloneRepoOptions) error {
if opts.Filter != "" {
cmd.AddArguments("--filter").AddDynamicArguments(opts.Filter)
}
if opts.SingleBranch {
cmd.AddArguments("--single-branch")
}
if len(opts.Branch) > 0 {
cmd.AddArguments("-b").AddDynamicArguments(opts.Branch)
}
@@ -173,37 +158,39 @@ func Clone(ctx context.Context, from, to string, opts CloneRepoOptions) error {
}
envs := os.Environ()
u, err := url.Parse(from)
if err == nil {
envs = proxy.EnvWithProxy(u)
if opts.Env != nil {
envs = opts.Env
} else {
u, err := url.Parse(from)
if err == nil {
envs = proxy.EnvWithProxy(u)
}
}
stderr := new(bytes.Buffer)
if err = cmd.Run(ctx, &gitcmd.RunOpts{
Timeout: opts.Timeout,
Env: envs,
Stdout: io.Discard,
Stderr: stderr,
}); err != nil {
return gitcmd.ConcatenateError(err, stderr.String())
}
return nil
return cmd.
WithTimeout(opts.Timeout).
WithEnv(envs).
RunWithStderr(ctx)
}
// PushOptions options when push to remote
type PushOptions struct {
Remote string
Branch string
Force bool
Mirror bool
Env []string
Timeout time.Duration
Remote string
LocalRefName string
Branch string
Force bool
ForceWithLease string
Mirror bool
Env []string
Timeout time.Duration
}
// Push pushs local commits to given remote branch.
func Push(ctx context.Context, repoPath string, opts PushOptions) error {
cmd := gitcmd.NewCommand("push")
if opts.Force {
if opts.ForceWithLease != "" {
cmd.AddOptionFormat("--force-with-lease=%s", opts.ForceWithLease)
} else if opts.Force {
cmd.AddArguments("-f")
}
if opts.Mirror {
@@ -211,11 +198,17 @@ func Push(ctx context.Context, repoPath string, opts PushOptions) error {
}
remoteBranchArgs := []string{opts.Remote}
if len(opts.Branch) > 0 {
remoteBranchArgs = append(remoteBranchArgs, opts.Branch)
var refspec string
if opts.LocalRefName != "" {
refspec = fmt.Sprintf("%s:%s", opts.LocalRefName, opts.Branch)
} else {
refspec = opts.Branch
}
remoteBranchArgs = append(remoteBranchArgs, refspec)
}
cmd.AddDashesAndList(remoteBranchArgs...)
stdout, stderr, err := cmd.RunStdString(ctx, &gitcmd.RunOpts{Env: opts.Env, Timeout: opts.Timeout, Dir: repoPath})
stdout, stderr, err := cmd.WithEnv(opts.Env).WithTimeout(opts.Timeout).WithDir(repoPath).RunStdString(ctx)
if err != nil {
if strings.Contains(stderr, "non-fast-forward") {
return &ErrPushOutOfDate{StdOut: stdout, StdErr: stderr, Err: err}
@@ -231,84 +224,3 @@ func Push(ctx context.Context, repoPath string, opts PushOptions) error {
return nil
}
// GetLatestCommitTime returns time for latest commit in repository (across all branches)
func GetLatestCommitTime(ctx context.Context, repoPath string) (time.Time, error) {
cmd := gitcmd.NewCommand("for-each-ref", "--sort=-committerdate", BranchPrefix, "--count", "1", "--format=%(committerdate)")
stdout, _, err := cmd.RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath})
if err != nil {
return time.Time{}, err
}
commitTime := strings.TrimSpace(stdout)
return time.Parse("Mon Jan _2 15:04:05 2006 -0700", commitTime)
}
// 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, repoPath, baseBranch, targetBranch string) (do DivergeObject, err error) {
cmd := gitcmd.NewCommand("rev-list", "--count", "--left-right").
AddDynamicArguments(baseBranch + "..." + targetBranch).AddArguments("--")
stdout, _, err := cmd.RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath})
if err != nil {
return do, err
}
left, right, found := strings.Cut(strings.Trim(stdout, "\n"), "\t")
if !found {
return do, fmt.Errorf("git rev-list output is missing a tab: %q", stdout)
}
do.Behind, err = strconv.Atoi(left)
if err != nil {
return do, err
}
do.Ahead, err = strconv.Atoi(right)
if err != nil {
return do, err
}
return do, nil
}
// CreateBundle create bundle content to the target path
func (repo *Repository) CreateBundle(ctx context.Context, commit string, out io.Writer) error {
tmp, 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(repo.Path, "objects"))
_, _, err = gitcmd.NewCommand("init", "--bare").RunStdString(ctx, &gitcmd.RunOpts{Dir: tmp, Env: env})
if err != nil {
return err
}
_, _, err = gitcmd.NewCommand("reset", "--soft").AddDynamicArguments(commit).RunStdString(ctx, &gitcmd.RunOpts{Dir: tmp, Env: env})
if err != nil {
return err
}
_, _, err = gitcmd.NewCommand("branch", "-m", "bundle").RunStdString(ctx, &gitcmd.RunOpts{Dir: tmp, Env: env})
if err != nil {
return err
}
tmpFile := filepath.Join(tmp, "bundle")
_, _, err = gitcmd.NewCommand("bundle", "create").AddDynamicArguments(tmpFile, "bundle", "HEAD").RunStdString(ctx, &gitcmd.RunOpts{Dir: tmp, Env: env})
if err != nil {
return err
}
fi, err := os.Open(tmpFile)
if err != nil {
return err
}
defer fi.Close()
_, err = io.Copy(out, fi)
return err
}
@@ -1,75 +0,0 @@
// Copyright 2015 The Gogs Authors. All rights reserved.
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"context"
"fmt"
"io"
"path/filepath"
"strings"
"code.gitea.io/gitea/modules/git/gitcmd"
)
// ArchiveType archive types
type ArchiveType int
const (
ArchiveUnknown ArchiveType = iota
ArchiveZip // 1
ArchiveTarGz // 2
ArchiveBundle // 3
)
// String converts an ArchiveType to string: the extension of the archive file without prefix dot
func (a ArchiveType) String() string {
switch a {
case ArchiveZip:
return "zip"
case ArchiveTarGz:
return "tar.gz"
case ArchiveBundle:
return "bundle"
}
return "unknown"
}
func SplitArchiveNameType(s string) (string, ArchiveType) {
switch {
case strings.HasSuffix(s, ".zip"):
return strings.TrimSuffix(s, ".zip"), ArchiveZip
case strings.HasSuffix(s, ".tar.gz"):
return strings.TrimSuffix(s, ".tar.gz"), ArchiveTarGz
case strings.HasSuffix(s, ".bundle"):
return strings.TrimSuffix(s, ".bundle"), ArchiveBundle
}
return s, ArchiveUnknown
}
// CreateArchive create archive content to the target path
func (repo *Repository) CreateArchive(ctx context.Context, format ArchiveType, target io.Writer, usePrefix bool, commitID string) error {
if format.String() == "unknown" {
return fmt.Errorf("unknown format: %v", format)
}
cmd := gitcmd.NewCommand("archive")
if usePrefix {
cmd.AddOptionFormat("--prefix=%s", filepath.Base(strings.TrimSuffix(repo.Path, ".git"))+"/")
}
cmd.AddOptionFormat("--format=%s", format.String())
cmd.AddDynamicArguments(commitID)
var stderr strings.Builder
err := cmd.Run(ctx, &gitcmd.RunOpts{
Dir: repo.Path,
Stdout: target,
Stderr: &stderr,
})
if err != nil {
return gitcmd.ConcatenateError(err, stderr.String())
}
return nil
}
@@ -1,32 +0,0 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestArchiveType(t *testing.T) {
name, archiveType := SplitArchiveNameType("test.tar.gz")
assert.Equal(t, "test", name)
assert.Equal(t, "tar.gz", archiveType.String())
name, archiveType = SplitArchiveNameType("a/b/test.zip")
assert.Equal(t, "a/b/test", name)
assert.Equal(t, "zip", archiveType.String())
name, archiveType = SplitArchiveNameType("1234.bundle")
assert.Equal(t, "1234", name)
assert.Equal(t, "bundle", archiveType.String())
name, archiveType = SplitArchiveNameType("test")
assert.Equal(t, "test", name)
assert.Equal(t, "unknown", archiveType.String())
name, archiveType = SplitArchiveNameType("test.xz")
assert.Equal(t, "test.xz", name)
assert.Equal(t, "unknown", archiveType.String())
}
@@ -32,7 +32,6 @@ type Repository struct {
gogitRepo *gogit.Repository
gogitStorage *filesystem.Storage
gpgSettings *GPGSettings
Ctx context.Context
LastCommitCache *LastCommitCache
@@ -7,9 +7,9 @@
package git
import (
"bufio"
"context"
"path/filepath"
"sync"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/util"
@@ -23,13 +23,9 @@ type Repository struct {
tagCache *ObjectCache[*Tag]
gpgSettings *GPGSettings
batchInUse bool
batch *Batch
checkInUse bool
check *Batch
mu sync.Mutex
catFileBatchCloser CatFileBatchCloser
catFileBatchInUse bool
Ctx context.Context
LastCommitCache *LastCommitCache
@@ -58,69 +54,47 @@ func OpenRepository(ctx context.Context, repoPath string) (*Repository, error) {
}, nil
}
// CatFileBatch obtains a CatFileBatch for this repository
func (repo *Repository) CatFileBatch(ctx context.Context) (WriteCloserError, *bufio.Reader, func(), error) {
if repo.batch == nil {
var err error
repo.batch, err = NewBatch(ctx, repo.Path)
// CatFileBatch obtains a "batch object provider" for this repository.
// It reuses an existing one if available, otherwise creates a new one.
func (repo *Repository) CatFileBatch(ctx context.Context) (_ CatFileBatch, closeFunc func(), err error) {
repo.mu.Lock()
defer repo.mu.Unlock()
if repo.catFileBatchCloser == nil {
repo.catFileBatchCloser, err = NewBatch(ctx, repo.Path)
if err != nil {
return nil, nil, nil, err
repo.catFileBatchCloser = nil // otherwise it is "interface(nil)" and will cause wrong logic
return nil, nil, err
}
}
if !repo.batchInUse {
repo.batchInUse = true
return repo.batch.Writer, repo.batch.Reader, func() {
repo.batchInUse = false
if !repo.catFileBatchInUse {
repo.catFileBatchInUse = true
return CatFileBatch(repo.catFileBatchCloser), func() {
repo.mu.Lock()
defer repo.mu.Unlock()
repo.catFileBatchInUse = false
}, nil
}
log.Debug("Opening temporary cat file batch for: %s", repo.Path)
tempBatch, err := NewBatch(ctx, repo.Path)
if err != nil {
return nil, nil, nil, err
return nil, nil, err
}
return tempBatch.Writer, tempBatch.Reader, tempBatch.Close, nil
}
// CatFileBatchCheck obtains a CatFileBatchCheck for this repository
func (repo *Repository) CatFileBatchCheck(ctx context.Context) (WriteCloserError, *bufio.Reader, func(), error) {
if repo.check == nil {
var err error
repo.check, err = NewBatchCheck(ctx, repo.Path)
if err != nil {
return nil, nil, nil, err
}
}
if !repo.checkInUse {
repo.checkInUse = true
return repo.check.Writer, repo.check.Reader, func() {
repo.checkInUse = false
}, nil
}
log.Debug("Opening temporary cat file batch-check for: %s", repo.Path)
tempBatchCheck, err := NewBatchCheck(ctx, repo.Path)
if err != nil {
return nil, nil, nil, err
}
return tempBatchCheck.Writer, tempBatchCheck.Reader, tempBatchCheck.Close, nil
return tempBatch, tempBatch.Close, nil
}
func (repo *Repository) Close() error {
if repo == nil {
return nil
}
if repo.batch != nil {
repo.batch.Close()
repo.batch = nil
repo.batchInUse = false
}
if repo.check != nil {
repo.check.Close()
repo.check = nil
repo.checkInUse = false
repo.mu.Lock()
defer repo.mu.Unlock()
if repo.catFileBatchCloser != nil {
repo.catFileBatchCloser.Close()
repo.catFileBatchCloser = nil
repo.catFileBatchInUse = false
}
repo.LastCommitCache = nil
repo.tagCache = nil
@@ -0,0 +1,26 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !gogit
package git
import (
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
func TestRepoCatFileBatch(t *testing.T) {
t.Run("MissingRepoAndClose", func(t *testing.T) {
repo, err := OpenRepository(t.Context(), filepath.Join(testReposDir, "repo1_bare"))
require.NoError(t, err)
repo.Path = "/no-such" // when the repo is missing (it usually occurs during testing because the fixtures are synced frequently)
_, _, err = repo.CatFileBatch(t.Context())
require.Error(t, err)
require.NoError(t, repo.Close()) // shouldn't panic
})
// TODO: test more methods and concurrency queries
}
@@ -1,25 +0,0 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"fmt"
"code.gitea.io/gitea/modules/git/gitcmd"
)
// LineBlame returns the latest commit at the given line
func (repo *Repository) LineBlame(revision, path, file string, line uint) (*Commit, error) {
res, _, err := gitcmd.NewCommand("blame").
AddOptionFormat("-L %d,%d", line, line).
AddOptionValues("-p", revision).
AddDashesAndList(file).RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: path})
if err != nil {
return nil, err
}
if len(res) < 40 {
return nil, fmt.Errorf("invalid result of blame: %s", res)
}
return repo.GetCommit(res[:40])
}
@@ -5,84 +5,20 @@
package git
import (
"context"
"errors"
"strings"
"code.gitea.io/gitea/modules/git/gitcmd"
)
// BranchPrefix base dir of the branch information file store on git
const BranchPrefix = "refs/heads/"
// IsReferenceExist returns true if given reference exists in the repository.
func IsReferenceExist(ctx context.Context, repoPath, name string) bool {
_, _, err := gitcmd.NewCommand("show-ref", "--verify").AddDashesAndList(name).RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath})
return err == nil
}
// IsBranchExist returns true if given branch exists in the repository.
func IsBranchExist(ctx context.Context, repoPath, name string) bool {
return IsReferenceExist(ctx, repoPath, BranchPrefix+name)
}
func GetDefaultBranch(ctx context.Context, repoPath string) (string, error) {
stdout, _, err := gitcmd.NewCommand("symbolic-ref", "HEAD").RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath})
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
}
// DeleteBranchOptions Option(s) for delete branch
type DeleteBranchOptions struct {
Force bool
}
// DeleteBranch delete a branch by name on repository.
func (repo *Repository) DeleteBranch(name string, opts DeleteBranchOptions) error {
cmd := gitcmd.NewCommand("branch")
if opts.Force {
cmd.AddArguments("-D")
} else {
cmd.AddArguments("-d")
}
cmd.AddDashesAndList(name)
_, _, err := cmd.RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
return err
}
// CreateBranch create a new branch
func (repo *Repository) CreateBranch(branch, oldbranchOrCommit string) error {
cmd := gitcmd.NewCommand("branch")
cmd.AddDashesAndList(branch, oldbranchOrCommit)
_, _, err := cmd.RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
return err
}
// AddRemote adds a new remote to repository.
func (repo *Repository) AddRemote(name, url string, fetch bool) error {
cmd := gitcmd.NewCommand("remote", "add")
if fetch {
cmd.AddArguments("-f")
}
cmd.AddDynamicArguments(name, url)
_, _, err := cmd.RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
return err
}
// RenameBranch rename a branch
func (repo *Repository) RenameBranch(from, to string) error {
_, _, err := gitcmd.NewCommand("branch", "-m").AddDynamicArguments(from, to).RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
_, _, err := cmd.AddDynamicArguments(name, url).
WithDir(repo.Path).
RunStdString(repo.Ctx)
return err
}
@@ -8,7 +8,6 @@ package git
import (
"bufio"
"bytes"
"context"
"io"
"strings"
@@ -18,24 +17,24 @@ import (
)
// IsObjectExist returns true if the given object exists in the repository.
// FIXME: this function doesn't seem right, it is only used by GarbageCollectLFSMetaObjectsForRepo
func (repo *Repository) IsObjectExist(name string) bool {
if name == "" {
return false
}
wr, rd, cancel, err := repo.CatFileBatchCheck(repo.Ctx)
batch, cancel, err := repo.CatFileBatch(repo.Ctx)
if err != nil {
log.Debug("Error writing to CatFileBatchCheck %v", err)
log.Debug("Error opening CatFileBatch %v", err)
return false
}
defer cancel()
_, err = wr.Write([]byte(name + "\n"))
info, err := batch.QueryInfo(name)
if err != nil {
log.Debug("Error writing to CatFileBatchCheck %v", err)
log.Debug("Error checking object info %v", err)
return false
}
sha, _, _, err := ReadBatchLine(rd)
return err == nil && bytes.HasPrefix(sha, []byte(strings.TrimSpace(name)))
return strings.HasPrefix(info.ID, name) // FIXME: this logic doesn't seem right, why "HasPrefix"
}
// IsReferenceExist returns true if given reference exists in the repository.
@@ -44,18 +43,13 @@ func (repo *Repository) IsReferenceExist(name string) bool {
return false
}
wr, rd, cancel, err := repo.CatFileBatchCheck(repo.Ctx)
batch, cancel, err := repo.CatFileBatch(repo.Ctx)
if err != nil {
log.Debug("Error writing to CatFileBatchCheck %v", err)
log.Error("Error opening CatFileBatch %v", err)
return false
}
defer cancel()
_, err = wr.Write([]byte(name + "\n"))
if err != nil {
log.Debug("Error writing to CatFileBatchCheck %v", err)
return false
}
_, _, _, err = ReadBatchLine(rd)
_, err = batch.QueryInfo(name)
return err == nil
}
@@ -100,94 +94,81 @@ func callShowRef(ctx context.Context, repoPath, trimPrefix string, extraArgs git
}
func WalkShowRef(ctx context.Context, repoPath string, extraArgs gitcmd.TrustedCmdArgs, skip, limit int, walkfn func(sha1, refname string) error) (countAll int, err error) {
stdoutReader, stdoutWriter := io.Pipe()
defer func() {
_ = stdoutReader.Close()
_ = stdoutWriter.Close()
}()
go func() {
stderrBuilder := &strings.Builder{}
args := gitcmd.TrustedCmdArgs{"for-each-ref", "--format=%(objectname) %(refname)"}
args = append(args, extraArgs...)
err := gitcmd.NewCommand(args...).Run(ctx, &gitcmd.RunOpts{
Dir: repoPath,
Stdout: stdoutWriter,
Stderr: stderrBuilder,
})
if err != nil {
if stderrBuilder.Len() == 0 {
_ = stdoutWriter.Close()
return
}
_ = stdoutWriter.CloseWithError(gitcmd.ConcatenateError(err, stderrBuilder.String()))
} else {
_ = stdoutWriter.Close()
}
}()
i := 0
bufReader := bufio.NewReader(stdoutReader)
for i < skip {
_, isPrefix, err := bufReader.ReadLine()
if err == io.EOF {
return i, nil
}
if err != nil {
return 0, err
}
if !isPrefix {
i++
}
args := gitcmd.TrustedCmdArgs{"for-each-ref", "--format=%(objectname) %(refname)"}
args = append(args, extraArgs...)
cmd := gitcmd.NewCommand(args...)
stdoutReader, stdoutReaderClose := cmd.MakeStdoutPipe()
defer stdoutReaderClose()
cmd.WithDir(repoPath).
WithPipelineFunc(func(gitcmd.Context) error {
bufReader := bufio.NewReader(stdoutReader)
for i < skip {
_, isPrefix, err := bufReader.ReadLine()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
if !isPrefix {
i++
}
}
for limit == 0 || i < skip+limit {
// The output of show-ref is simply a list:
// <sha> SP <ref> LF
sha, err := bufReader.ReadString(' ')
if err == io.EOF {
return nil
}
if err != nil {
return err
}
branchName, err := bufReader.ReadString('\n')
if err == io.EOF {
// This shouldn't happen... but we'll tolerate it for the sake of peace
return nil
}
if err != nil {
return err
}
if len(branchName) > 0 {
branchName = branchName[:len(branchName)-1]
}
if len(sha) > 0 {
sha = sha[:len(sha)-1]
}
err = walkfn(sha, branchName)
if err != nil {
return err
}
i++
}
// count all refs
for limit != 0 {
_, isPrefix, err := bufReader.ReadLine()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
if !isPrefix {
i++
}
}
return nil
})
err = cmd.RunWithStderr(ctx)
if errPipeline, ok := gitcmd.UnwrapPipelineError(err); ok {
return i, errPipeline // keep the old behavior: return pipeline error directly
}
for limit == 0 || i < skip+limit {
// The output of show-ref is simply a list:
// <sha> SP <ref> LF
sha, err := bufReader.ReadString(' ')
if err == io.EOF {
return i, nil
}
if err != nil {
return 0, err
}
branchName, err := bufReader.ReadString('\n')
if err == io.EOF {
// This shouldn't happen... but we'll tolerate it for the sake of peace
return i, nil
}
if err != nil {
return i, err
}
if len(branchName) > 0 {
branchName = branchName[:len(branchName)-1]
}
if len(sha) > 0 {
sha = sha[:len(sha)-1]
}
err = walkfn(sha, branchName)
if err != nil {
return i, err
}
i++
}
// count all refs
for limit != 0 {
_, isPrefix, err := bufReader.ReadLine()
if err == io.EOF {
return i, nil
}
if err != nil {
return 0, err
}
if !isPrefix {
i++
}
}
return i, nil
return i, err
}
// GetRefsBySha returns all references filtered with prefix that belong to a sha commit hash
+107 -153
View File
@@ -11,7 +11,6 @@ import (
"strconv"
"strings"
"code.gitea.io/gitea/modules/cache"
"code.gitea.io/gitea/modules/git/gitcmd"
"code.gitea.io/gitea/modules/setting"
)
@@ -60,7 +59,11 @@ func (repo *Repository) getCommitByPathWithID(id ObjectID, relpath string) (*Com
relpath = `\` + relpath
}
stdout, _, runErr := gitcmd.NewCommand("log", "-1", prettyLogFormat).AddDynamicArguments(id.String()).AddDashesAndList(relpath).RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
stdout, _, runErr := gitcmd.NewCommand("log", "-1", prettyLogFormat).
AddDynamicArguments(id.String()).
AddDashesAndList(relpath).
WithDir(repo.Path).
RunStdString(repo.Ctx)
if runErr != nil {
return nil, runErr
}
@@ -75,7 +78,10 @@ func (repo *Repository) getCommitByPathWithID(id ObjectID, relpath string) (*Com
// GetCommitByPath returns the last commit of relative path.
func (repo *Repository) GetCommitByPath(relpath string) (*Commit, error) {
stdout, _, runErr := gitcmd.NewCommand("log", "-1", prettyLogFormat).AddDashesAndList(relpath).RunStdBytes(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
stdout, _, runErr := gitcmd.NewCommand("log", "-1", prettyLogFormat).
AddDashesAndList(relpath).
WithDir(repo.Path).
RunStdBytes(repo.Ctx)
if runErr != nil {
return nil, runErr
}
@@ -108,7 +114,7 @@ func (repo *Repository) commitsByRangeWithTime(id ObjectID, page, pageSize int,
cmd.AddOptionFormat("--until=%s", until)
}
stdout, _, err := cmd.RunStdBytes(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
stdout, _, err := cmd.WithDir(repo.Path).RunStdBytes(repo.Ctx)
if err != nil {
return nil, err
}
@@ -162,7 +168,7 @@ func (repo *Repository) searchCommits(id ObjectID, opts SearchCommitsOptions) ([
// search for commits matching given constraints and keywords in commit msg
addCommonSearchArgs(cmd)
stdout, _, err := cmd.RunStdBytes(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
stdout, _, err := cmd.WithDir(repo.Path).RunStdBytes(repo.Ctx)
if err != nil {
return nil, err
}
@@ -183,7 +189,7 @@ func (repo *Repository) searchCommits(id ObjectID, opts SearchCommitsOptions) ([
hashCmd.AddDynamicArguments(v)
// search with given constraints for commit matching sha hash of v
hashMatching, _, err := hashCmd.RunStdBytes(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
hashMatching, _, err := hashCmd.WithDir(repo.Path).RunStdBytes(repo.Ctx)
if err != nil || bytes.Contains(stdout, hashMatching) {
continue
}
@@ -198,23 +204,17 @@ func (repo *Repository) searchCommits(id ObjectID, opts SearchCommitsOptions) ([
// FileChangedBetweenCommits Returns true if the file changed between commit IDs id1 and id2
// You must ensure that id1 and id2 are valid commit ids.
func (repo *Repository) FileChangedBetweenCommits(filename, id1, id2 string) (bool, error) {
stdout, _, err := gitcmd.NewCommand("diff", "--name-only", "-z").AddDynamicArguments(id1, id2).AddDashesAndList(filename).RunStdBytes(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
stdout, _, err := gitcmd.NewCommand("diff", "--name-only", "-z").
AddDynamicArguments(id1, id2).
AddDashesAndList(filename).
WithDir(repo.Path).
RunStdBytes(repo.Ctx)
if err != nil {
return false, err
}
return len(strings.TrimSpace(string(stdout))) > 0, nil
}
// FileCommitsCount return the number of files at a revision
func (repo *Repository) FileCommitsCount(revision, file string) (int64, error) {
return CommitsCount(repo.Ctx,
CommitsCountOptions{
RepoPath: repo.Path,
Revision: []string{revision},
RelPath: []string{file},
})
}
type CommitsByFileAndRangeOptions struct {
Revision string
File string
@@ -226,76 +226,70 @@ type CommitsByFileAndRangeOptions struct {
// CommitsByFileAndRange return the commits according revision file and the page
func (repo *Repository) CommitsByFileAndRange(opts CommitsByFileAndRangeOptions) ([]*Commit, error) {
stdoutReader, stdoutWriter := io.Pipe()
defer func() {
_ = stdoutReader.Close()
_ = stdoutWriter.Close()
}()
go func() {
stderr := strings.Builder{}
gitCmd := gitcmd.NewCommand("rev-list").
AddOptionFormat("--max-count=%d", setting.Git.CommitsRangeSize).
AddOptionFormat("--skip=%d", (opts.Page-1)*setting.Git.CommitsRangeSize)
gitCmd.AddDynamicArguments(opts.Revision)
gitCmd := gitcmd.NewCommand("rev-list").
AddOptionFormat("--max-count=%d", setting.Git.CommitsRangeSize).
AddOptionFormat("--skip=%d", (opts.Page-1)*setting.Git.CommitsRangeSize)
gitCmd.AddDynamicArguments(opts.Revision)
if opts.Not != "" {
gitCmd.AddOptionValues("--not", opts.Not)
}
if opts.Since != "" {
gitCmd.AddOptionFormat("--since=%s", opts.Since)
}
if opts.Until != "" {
gitCmd.AddOptionFormat("--until=%s", opts.Until)
}
gitCmd.AddDashesAndList(opts.File)
err := gitCmd.Run(repo.Ctx, &gitcmd.RunOpts{
Dir: repo.Path,
Stdout: stdoutWriter,
Stderr: &stderr,
})
if err != nil {
_ = stdoutWriter.CloseWithError(gitcmd.ConcatenateError(err, (&stderr).String()))
} else {
_ = stdoutWriter.Close()
}
}()
objectFormat, err := repo.GetObjectFormat()
if err != nil {
return nil, err
if opts.Not != "" {
gitCmd.AddOptionValues("--not", opts.Not)
}
if opts.Since != "" {
gitCmd.AddOptionFormat("--since=%s", opts.Since)
}
if opts.Until != "" {
gitCmd.AddOptionFormat("--until=%s", opts.Until)
}
gitCmd.AddDashesAndList(opts.File)
length := objectFormat.FullLength()
commits := []*Commit{}
shaline := make([]byte, length+1)
for {
n, err := io.ReadFull(stdoutReader, shaline)
if err != nil || n < length {
if err == io.EOF {
err = nil
var commits []*Commit
stdoutReader, stdoutReaderClose := gitCmd.MakeStdoutPipe()
defer stdoutReaderClose()
err := gitCmd.WithDir(repo.Path).
WithPipelineFunc(func(context gitcmd.Context) error {
objectFormat, err := repo.GetObjectFormat()
if err != nil {
return err
}
return commits, err
}
objectID, err := NewIDFromString(string(shaline[0:length]))
if err != nil {
return nil, err
}
commit, err := repo.getCommit(objectID)
if err != nil {
return nil, err
}
commits = append(commits, commit)
}
length := objectFormat.FullLength()
shaline := make([]byte, length+1)
for {
n, err := io.ReadFull(stdoutReader, shaline)
if err != nil || n < length {
if err == io.EOF {
err = nil
}
return err
}
objectID, err := NewIDFromString(string(shaline[0:length]))
if err != nil {
return err
}
commit, err := repo.getCommit(objectID)
if err != nil {
return err
}
commits = append(commits, commit)
}
}).
RunWithStderr(repo.Ctx)
return commits, err
}
// FilesCountBetween return the number of files changed between two commits
func (repo *Repository) FilesCountBetween(startCommitID, endCommitID string) (int, error) {
stdout, _, err := gitcmd.NewCommand("diff", "--name-only").AddDynamicArguments(startCommitID+"..."+endCommitID).RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
stdout, _, err := gitcmd.NewCommand("diff", "--name-only").
AddDynamicArguments(startCommitID + "..." + endCommitID).
WithDir(repo.Path).
RunStdString(repo.Ctx)
if err != nil && strings.Contains(err.Error(), "no merge base") {
// git >= 2.28 now returns an error if startCommitID and endCommitID have become unrelated.
// previously it would return the results of git diff --name-only startCommitID endCommitID so let's try that...
stdout, _, err = gitcmd.NewCommand("diff", "--name-only").AddDynamicArguments(startCommitID, endCommitID).RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
stdout, _, err = gitcmd.NewCommand("diff", "--name-only").
AddDynamicArguments(startCommitID, endCommitID).
WithDir(repo.Path).
RunStdString(repo.Ctx)
}
if err != nil {
return 0, err
@@ -309,13 +303,22 @@ func (repo *Repository) CommitsBetween(last, before *Commit) ([]*Commit, error)
var stdout []byte
var err error
if before == nil {
stdout, _, err = gitcmd.NewCommand("rev-list").AddDynamicArguments(last.ID.String()).RunStdBytes(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
stdout, _, err = gitcmd.NewCommand("rev-list").
AddDynamicArguments(last.ID.String()).
WithDir(repo.Path).
RunStdBytes(repo.Ctx)
} else {
stdout, _, err = gitcmd.NewCommand("rev-list").AddDynamicArguments(before.ID.String()+".."+last.ID.String()).RunStdBytes(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
stdout, _, err = gitcmd.NewCommand("rev-list").
AddDynamicArguments(before.ID.String() + ".." + last.ID.String()).
WithDir(repo.Path).
RunStdBytes(repo.Ctx)
if err != nil && strings.Contains(err.Error(), "no merge base") {
// future versions of git >= 2.28 are likely to return an error if before and last have become unrelated.
// previously it would return the results of git rev-list before last so let's try that...
stdout, _, err = gitcmd.NewCommand("rev-list").AddDynamicArguments(before.ID.String(), last.ID.String()).RunStdBytes(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
stdout, _, err = gitcmd.NewCommand("rev-list").
AddDynamicArguments(before.ID.String(), last.ID.String()).
WithDir(repo.Path).
RunStdBytes(repo.Ctx)
}
}
if err != nil {
@@ -332,40 +335,25 @@ func (repo *Repository) CommitsBetweenLimit(last, before *Commit, limit, skip in
stdout, _, err = gitcmd.NewCommand("rev-list").
AddOptionValues("--max-count", strconv.Itoa(limit)).
AddOptionValues("--skip", strconv.Itoa(skip)).
AddDynamicArguments(last.ID.String()).RunStdBytes(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
AddDynamicArguments(last.ID.String()).
WithDir(repo.Path).
RunStdBytes(repo.Ctx)
} else {
stdout, _, err = gitcmd.NewCommand("rev-list").
AddOptionValues("--max-count", strconv.Itoa(limit)).
AddOptionValues("--skip", strconv.Itoa(skip)).
AddDynamicArguments(before.ID.String()+".."+last.ID.String()).RunStdBytes(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
AddDynamicArguments(before.ID.String() + ".." + last.ID.String()).
WithDir(repo.Path).
RunStdBytes(repo.Ctx)
if err != nil && strings.Contains(err.Error(), "no merge base") {
// future versions of git >= 2.28 are likely to return an error if before and last have become unrelated.
// previously it would return the results of git rev-list --max-count n before last so let's try that...
stdout, _, err = gitcmd.NewCommand("rev-list").
AddOptionValues("--max-count", strconv.Itoa(limit)).
AddOptionValues("--skip", strconv.Itoa(skip)).
AddDynamicArguments(before.ID.String(), last.ID.String()).RunStdBytes(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
}
}
if err != nil {
return nil, err
}
return repo.parsePrettyFormatLogToList(bytes.TrimSpace(stdout))
}
// CommitsBetweenNotBase returns a list that contains commits between [before, last), excluding commits in baseBranch.
// If before is detached (removed by reset + push) it is not included.
func (repo *Repository) CommitsBetweenNotBase(last, before *Commit, baseBranch string) ([]*Commit, error) {
var stdout []byte
var err error
if before == nil {
stdout, _, err = gitcmd.NewCommand("rev-list").AddDynamicArguments(last.ID.String()).AddOptionValues("--not", baseBranch).RunStdBytes(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
} else {
stdout, _, err = gitcmd.NewCommand("rev-list").AddDynamicArguments(before.ID.String()+".."+last.ID.String()).AddOptionValues("--not", baseBranch).RunStdBytes(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
if err != nil && strings.Contains(err.Error(), "no merge base") {
// future versions of git >= 2.28 are likely to return an error if before and last have become unrelated.
// previously it would return the results of git rev-list before last so let's try that...
stdout, _, err = gitcmd.NewCommand("rev-list").AddDynamicArguments(before.ID.String(), last.ID.String()).AddOptionValues("--not", baseBranch).RunStdBytes(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
AddDynamicArguments(before.ID.String(), last.ID.String()).
WithDir(repo.Path).
RunStdBytes(repo.Ctx)
}
}
if err != nil {
@@ -390,25 +378,6 @@ func (repo *Repository) CommitsBetweenIDs(last, before string) ([]*Commit, error
return repo.CommitsBetween(lastCommit, beforeCommit)
}
// CommitsCountBetween return numbers of commits between two commits
func (repo *Repository) CommitsCountBetween(start, end string) (int64, error) {
count, err := CommitsCount(repo.Ctx, CommitsCountOptions{
RepoPath: repo.Path,
Revision: []string{start + ".." + end},
})
if err != nil && strings.Contains(err.Error(), "no merge base") {
// future versions of git >= 2.28 are likely to return an error if before and last have become unrelated.
// previously it would return the results of git rev-list before last so let's try that...
return CommitsCount(repo.Ctx, CommitsCountOptions{
RepoPath: repo.Path,
Revision: []string{start, end},
})
}
return count, err
}
// commitsBefore the limit is depth, not total number of returned commits.
func (repo *Repository) commitsBefore(id ObjectID, limit int) ([]*Commit, error) {
cmd := gitcmd.NewCommand("log", prettyLogFormat)
@@ -417,7 +386,7 @@ func (repo *Repository) commitsBefore(id ObjectID, limit int) ([]*Commit, error)
}
cmd.AddDynamicArguments(id.String())
stdout, _, runErr := cmd.RunStdBytes(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
stdout, _, runErr := cmd.WithDir(repo.Path).RunStdBytes(repo.Ctx)
if runErr != nil {
return nil, runErr
}
@@ -457,10 +426,9 @@ func (repo *Repository) getBranches(env []string, commitID string, limit int) ([
stdout, _, err := gitcmd.NewCommand("for-each-ref", "--format=%(refname:strip=2)").
AddOptionFormat("--count=%d", limit).
AddOptionValues("--contains", commitID, BranchPrefix).
RunStdString(repo.Ctx, &gitcmd.RunOpts{
Dir: repo.Path,
Env: env,
})
WithDir(repo.Path).
WithEnv(env).
RunStdString(repo.Ctx)
if err != nil {
return nil, err
}
@@ -469,10 +437,11 @@ func (repo *Repository) getBranches(env []string, commitID string, limit int) ([
return branches, nil
}
stdout, _, err := gitcmd.NewCommand("branch").AddOptionValues("--contains", commitID).RunStdString(repo.Ctx, &gitcmd.RunOpts{
Dir: repo.Path,
Env: env,
})
stdout, _, err := gitcmd.NewCommand("branch").
AddOptionValues("--contains", commitID).
WithDir(repo.Path).
WithEnv(env).
RunStdString(repo.Ctx)
if err != nil {
return nil, err
}
@@ -511,39 +480,24 @@ func (repo *Repository) GetCommitsFromIDs(commitIDs []string) []*Commit {
// IsCommitInBranch check if the commit is on the branch
func (repo *Repository) IsCommitInBranch(commitID, branch string) (r bool, err error) {
stdout, _, err := gitcmd.NewCommand("branch", "--contains").AddDynamicArguments(commitID, branch).RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
stdout, _, err := gitcmd.NewCommand("branch", "--contains").
AddDynamicArguments(commitID, branch).
WithDir(repo.Path).
RunStdString(repo.Ctx)
if err != nil {
return false, err
}
return len(stdout) > 0, err
}
func (repo *Repository) AddLastCommitCache(cacheKey, fullName, sha string) error {
if repo.LastCommitCache == nil {
commitsCount, err := cache.GetInt64(cacheKey, func() (int64, error) {
commit, err := repo.GetCommit(sha)
if err != nil {
return 0, err
}
return commit.CommitsCount()
})
if err != nil {
return err
}
repo.LastCommitCache = NewLastCommitCache(commitsCount, fullName, repo, cache.GetCache())
}
return nil
}
// GetCommitBranchStart returns the commit where the branch diverged
func (repo *Repository) GetCommitBranchStart(env []string, branch, endCommitID string) (string, error) {
cmd := gitcmd.NewCommand("log", prettyLogFormat)
cmd.AddDynamicArguments(endCommitID)
stdout, _, runErr := cmd.RunStdBytes(repo.Ctx, &gitcmd.RunOpts{
Dir: repo.Path,
Env: env,
})
stdout, _, runErr := cmd.WithDir(repo.Path).
WithEnv(env).
RunStdBytes(repo.Ctx)
if runErr != nil {
return "", runErr
}
@@ -51,7 +51,10 @@ func (repo *Repository) ConvertToGitID(commitID string) (ObjectID, error) {
}
}
actualCommitID, _, err := gitcmd.NewCommand("rev-parse", "--verify").AddDynamicArguments(commitID).RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
actualCommitID, _, err := gitcmd.NewCommand("rev-parse", "--verify").
AddDynamicArguments(commitID).
WithDir(repo.Path).
RunStdString(repo.Ctx)
actualCommitID = strings.TrimSpace(actualCommitID)
if err != nil {
if strings.Contains(err.Error(), "unknown revision or path") ||
@@ -64,16 +67,6 @@ func (repo *Repository) ConvertToGitID(commitID string) (ObjectID, error) {
return NewIDFromString(actualCommitID)
}
// IsCommitExist returns true if given commit exists in current repository.
func (repo *Repository) IsCommitExist(name string) bool {
hash, err := repo.ConvertToGitID(name)
if err != nil {
return false
}
_, err = repo.gogitRepo.CommitObject(plumbing.Hash(hash.RawValue()))
return err == nil
}
func (repo *Repository) getCommit(id ObjectID) (*Commit, error) {
var tagObject *object.Tag
@@ -104,7 +97,7 @@ func (repo *Repository) getCommit(id ObjectID) (*Commit, error) {
}
commit.Tree.ID = ParseGogitHash(tree.Hash)
commit.Tree.gogitTree = tree
commit.Tree.resolvedGogitTreeObject = tree
return commit, nil
}
@@ -6,7 +6,6 @@
package git
import (
"bufio"
"errors"
"io"
"strings"
@@ -17,7 +16,10 @@ import (
// ResolveReference resolves a name to a reference
func (repo *Repository) ResolveReference(name string) (string, error) {
stdout, _, err := gitcmd.NewCommand("show-ref", "--hash").AddDynamicArguments(name).RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
stdout, _, err := gitcmd.NewCommand("show-ref", "--hash").
AddDynamicArguments(name).
WithDir(repo.Path).
RunStdString(repo.Ctx)
if err != nil {
if strings.Contains(err.Error(), "not a valid ref") {
return "", ErrNotExist{name, ""}
@@ -34,47 +36,31 @@ func (repo *Repository) ResolveReference(name string) (string, error) {
// GetRefCommitID returns the last commit ID string of given reference (branch or tag).
func (repo *Repository) GetRefCommitID(name string) (string, error) {
wr, rd, cancel, err := repo.CatFileBatchCheck(repo.Ctx)
batch, cancel, err := repo.CatFileBatch(repo.Ctx)
if err != nil {
return "", err
}
defer cancel()
_, err = wr.Write([]byte(name + "\n"))
if err != nil {
return "", err
}
shaBs, _, _, err := ReadBatchLine(rd)
info, err := batch.QueryInfo(name)
if IsErrNotExist(err) {
return "", ErrNotExist{name, ""}
} else if err != nil {
return "", err
}
return string(shaBs), nil
}
// IsCommitExist returns true if given commit exists in current repository.
func (repo *Repository) IsCommitExist(name string) bool {
if err := ensureValidGitRepository(repo.Ctx, repo.Path); err != nil {
log.Error("IsCommitExist: %v", err)
return false
}
_, _, err := gitcmd.NewCommand("cat-file", "-e").AddDynamicArguments(name).RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
return err == nil
return info.ID, nil
}
func (repo *Repository) getCommit(id ObjectID) (*Commit, error) {
wr, rd, cancel, err := repo.CatFileBatch(repo.Ctx)
batch, cancel, err := repo.CatFileBatch(repo.Ctx)
if err != nil {
return nil, err
}
defer cancel()
_, _ = wr.Write([]byte(id.String() + "\n"))
return repo.getCommitFromBatchReader(wr, rd, id)
return repo.getCommitWithBatch(batch, id)
}
func (repo *Repository) getCommitFromBatchReader(wr WriteCloserError, rd *bufio.Reader, id ObjectID) (*Commit, error) {
_, typ, size, err := ReadBatchLine(rd)
func (repo *Repository) getCommitWithBatch(batch CatFileBatch, id ObjectID) (*Commit, error) {
info, rd, err := batch.QueryContent(id.String())
if err != nil {
if errors.Is(err, io.EOF) || IsErrNotExist(err) {
return nil, ErrNotExist{ID: id.String()}
@@ -82,13 +68,13 @@ func (repo *Repository) getCommitFromBatchReader(wr WriteCloserError, rd *bufio.
return nil, err
}
switch typ {
switch info.Type {
case "missing":
return nil, ErrNotExist{ID: id.String()}
case "tag":
// then we need to parse the tag
// and load the commit
data, err := io.ReadAll(io.LimitReader(rd, size))
data, err := io.ReadAll(io.LimitReader(rd, info.Size))
if err != nil {
return nil, err
}
@@ -100,19 +86,9 @@ func (repo *Repository) getCommitFromBatchReader(wr WriteCloserError, rd *bufio.
if err != nil {
return nil, err
}
if _, err := wr.Write([]byte(tag.Object.String() + "\n")); err != nil {
return nil, err
}
commit, err := repo.getCommitFromBatchReader(wr, rd, tag.Object)
if err != nil {
return nil, err
}
return commit, nil
return repo.getCommitWithBatch(batch, tag.Object)
case "commit":
commit, err := CommitFromReader(repo, id, io.LimitReader(rd, size))
commit, err := CommitFromReader(repo, id, io.LimitReader(rd, info.Size))
if err != nil {
return nil, err
}
@@ -123,8 +99,8 @@ func (repo *Repository) getCommitFromBatchReader(wr WriteCloserError, rd *bufio.
return commit, nil
default:
log.Debug("Unknown typ: %s", typ)
if err := DiscardFull(rd, size+1); err != nil {
log.Debug("Unknown cat-file object type: %s", info.Type)
if err := DiscardFull(rd, info.Size+1); err != nil {
return nil, err
}
return nil, ErrNotExist{
@@ -146,16 +122,12 @@ func (repo *Repository) ConvertToGitID(commitID string) (ObjectID, error) {
}
}
wr, rd, cancel, err := repo.CatFileBatchCheck(repo.Ctx)
batch, cancel, err := repo.CatFileBatch(repo.Ctx)
if err != nil {
return nil, err
}
defer cancel()
_, err = wr.Write([]byte(commitID + "\n"))
if err != nil {
return nil, err
}
sha, _, _, err := ReadBatchLine(rd)
info, err := batch.QueryInfo(commitID)
if err != nil {
if IsErrNotExist(err) {
return nil, ErrNotExist{commitID, ""}
@@ -163,5 +135,5 @@ func (repo *Repository) ConvertToGitID(commitID string) (ObjectID, error) {
return nil, err
}
return MustIDFromString(string(sha)), nil
return MustIDFromString(info.ID), nil
}
@@ -14,7 +14,7 @@ import (
// this requires git v2.18 to be installed
func WriteCommitGraph(ctx context.Context, repoPath string) error {
if DefaultFeatures().CheckVersionAtLeast("2.18") {
if _, _, err := gitcmd.NewCommand("commit-graph", "write").RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath}); err != nil {
if _, _, err := gitcmd.NewCommand("commit-graph", "write").WithDir(repoPath).RunStdString(ctx); err != nil {
return fmt.Errorf("unable to write commit-graph for '%s' : %w", repoPath, err)
}
}
+26 -102
View File
@@ -7,38 +7,17 @@ package git
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"code.gitea.io/gitea/modules/git/gitcmd"
)
// GetMergeBase checks and returns merge base of two branches and the reference used as base.
func (repo *Repository) GetMergeBase(tmpRemote, base, head string) (string, string, error) {
if tmpRemote == "" {
tmpRemote = "origin"
}
if tmpRemote != "origin" {
tmpBaseName := RemotePrefix + tmpRemote + "/tmp_" + base
// Fetch commit into a temporary branch in order to be able to handle commits and tags
_, _, err := gitcmd.NewCommand("fetch", "--no-tags").AddDynamicArguments(tmpRemote).AddDashesAndList(base+":"+tmpBaseName).RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
if err == nil {
base = tmpBaseName
}
}
stdout, _, err := gitcmd.NewCommand("merge-base").AddDashesAndList(base, head).RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
return strings.TrimSpace(stdout), base, err
}
type lineCountWriter struct {
numLines int
}
@@ -55,7 +34,6 @@ func (l *lineCountWriter) Write(p []byte) (n int, err error) {
func (repo *Repository) GetDiffNumChangedFiles(base, head string, directComparison bool) (int, error) {
// Now there is git diff --shortstat but this appears to be slower than simply iterating with --nameonly
w := &lineCountWriter{}
stderr := new(bytes.Buffer)
separator := "..."
if directComparison {
@@ -63,109 +41,55 @@ func (repo *Repository) GetDiffNumChangedFiles(base, head string, directComparis
}
// avoid: ambiguous argument 'refs/a...refs/b': unknown revision or path not in the working tree. Use '--': 'git <command> [<revision>...] -- [<file>...]'
if err := gitcmd.NewCommand("diff", "-z", "--name-only").AddDynamicArguments(base+separator+head).AddArguments("--").
Run(repo.Ctx, &gitcmd.RunOpts{
Dir: repo.Path,
Stdout: w,
Stderr: stderr,
}); err != nil {
if strings.Contains(stderr.String(), "no merge base") {
if err := gitcmd.NewCommand("diff", "-z", "--name-only").
AddDynamicArguments(base + separator + head).
AddArguments("--").
WithDir(repo.Path).
WithStdoutCopy(w).
RunWithStderr(repo.Ctx); err != nil {
if strings.Contains(err.Stderr(), "no merge base") {
// git >= 2.28 now returns an error if base and head have become unrelated.
// previously it would return the results of git diff -z --name-only base head so let's try that...
w = &lineCountWriter{}
stderr.Reset()
if err = gitcmd.NewCommand("diff", "-z", "--name-only").AddDynamicArguments(base, head).AddArguments("--").Run(repo.Ctx, &gitcmd.RunOpts{
Dir: repo.Path,
Stdout: w,
Stderr: stderr,
}); err == nil {
if err = gitcmd.NewCommand("diff", "-z", "--name-only").
AddDynamicArguments(base, head).
AddArguments("--").
WithDir(repo.Path).
WithStdoutCopy(w).
RunWithStderr(repo.Ctx); err == nil {
return w.numLines, nil
}
}
return 0, fmt.Errorf("%w: Stderr: %s", err, stderr)
return 0, err
}
return w.numLines, nil
}
// GetDiffShortStatByCmdArgs counts number of changed files, number of additions and deletions
// TODO: it can be merged with another "GetDiffShortStat" in the future
func GetDiffShortStatByCmdArgs(ctx context.Context, repoPath string, trustedArgs gitcmd.TrustedCmdArgs, dynamicArgs ...string) (numFiles, totalAdditions, totalDeletions int, err error) {
// Now if we call:
// $ git diff --shortstat 1ebb35b98889ff77299f24d82da426b434b0cca0...788b8b1440462d477f45b0088875
// we get:
// " 9902 files changed, 2034198 insertions(+), 298800 deletions(-)\n"
cmd := gitcmd.NewCommand("diff", "--shortstat").AddArguments(trustedArgs...).AddDynamicArguments(dynamicArgs...)
stdout, _, err := cmd.RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath})
if err != nil {
return 0, 0, 0, err
}
return parseDiffStat(stdout)
}
var shortStatFormat = regexp.MustCompile(
`\s*(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?`)
var patchCommits = regexp.MustCompile(`^From\s(\w+)\s`)
func parseDiffStat(stdout string) (numFiles, totalAdditions, totalDeletions int, err error) {
if len(stdout) == 0 || stdout == "\n" {
return 0, 0, 0, nil
}
groups := shortStatFormat.FindStringSubmatch(stdout)
if len(groups) != 4 {
return 0, 0, 0, fmt.Errorf("unable to parse shortstat: %s groups: %s", stdout, groups)
}
numFiles, err = strconv.Atoi(groups[1])
if err != nil {
return 0, 0, 0, fmt.Errorf("unable to parse shortstat: %s. Error parsing NumFiles %w", stdout, err)
}
if len(groups[2]) != 0 {
totalAdditions, err = strconv.Atoi(groups[2])
if err != nil {
return 0, 0, 0, fmt.Errorf("unable to parse shortstat: %s. Error parsing NumAdditions %w", stdout, err)
}
}
if len(groups[3]) != 0 {
totalDeletions, err = strconv.Atoi(groups[3])
if err != nil {
return 0, 0, 0, fmt.Errorf("unable to parse shortstat: %s. Error parsing NumDeletions %w", stdout, err)
}
}
return numFiles, totalAdditions, totalDeletions, err
}
// GetDiff generates and returns patch data between given revisions, optimized for human readability
func (repo *Repository) GetDiff(compareArg string, w io.Writer) error {
stderr := new(bytes.Buffer)
return gitcmd.NewCommand("diff", "-p").AddDynamicArguments(compareArg).
Run(repo.Ctx, &gitcmd.RunOpts{
Dir: repo.Path,
Stdout: w,
Stderr: stderr,
})
WithDir(repo.Path).
WithStdoutCopy(w).
Run(repo.Ctx)
}
// GetDiffBinary generates and returns patch data between given revisions, including binary diffs.
func (repo *Repository) GetDiffBinary(compareArg string, w io.Writer) error {
return gitcmd.NewCommand("diff", "-p", "--binary", "--histogram").AddDynamicArguments(compareArg).Run(repo.Ctx, &gitcmd.RunOpts{
Dir: repo.Path,
Stdout: w,
})
return gitcmd.NewCommand("diff", "-p", "--binary", "--histogram").
AddDynamicArguments(compareArg).
WithDir(repo.Path).
WithStdoutCopy(w).
Run(repo.Ctx)
}
// GetPatch generates and returns format-patch data between given revisions, able to be used with `git apply`
func (repo *Repository) GetPatch(compareArg string, w io.Writer) error {
stderr := new(bytes.Buffer)
return gitcmd.NewCommand("format-patch", "--binary", "--stdout").AddDynamicArguments(compareArg).
Run(repo.Ctx, &gitcmd.RunOpts{
Dir: repo.Path,
Stdout: w,
Stderr: stderr,
})
WithDir(repo.Path).
WithStdoutCopy(w).
Run(repo.Ctx)
}
// GetFilesChangedBetween returns a list of all files that have been changed between the given commits
@@ -182,7 +106,7 @@ func (repo *Repository) GetFilesChangedBetween(base, head string) ([]string, err
} else {
cmd.AddDynamicArguments(base, head)
}
stdout, _, err := cmd.RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
stdout, _, err := cmd.WithDir(repo.Path).RunStdString(repo.Ctx)
if err != nil {
return nil, err
}
@@ -103,7 +103,8 @@ func TestReadWritePullHead(t *testing.T) {
newCommit := "feaf4ba6bc635fec442f46ddd4512416ec43c2c2"
_, _, err = gitcmd.NewCommand("update-ref").
AddDynamicArguments(PullPrefix+"1/head", newCommit).
RunStdString(t.Context(), &gitcmd.RunOpts{Dir: repo.Path})
WithDir(repo.Path).
RunStdString(t.Context())
if err != nil {
assert.NoError(t, err)
return
@@ -121,8 +122,9 @@ func TestReadWritePullHead(t *testing.T) {
// Remove file after the test
_, _, err = gitcmd.NewCommand("update-ref", "--no-deref", "-d").
AddDynamicArguments(PullPrefix+"1/head").
RunStdString(t.Context(), &gitcmd.RunOpts{Dir: repo.Path})
AddDynamicArguments(PullPrefix + "1/head").
WithDir(repo.Path).
RunStdString(t.Context())
assert.NoError(t, err)
}
@@ -1,71 +0,0 @@
// Copyright 2015 The Gogs Authors. All rights reserved.
// Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"fmt"
"os"
"strings"
"code.gitea.io/gitea/modules/git/gitcmd"
"code.gitea.io/gitea/modules/process"
)
// LoadPublicKeyContent will load the key from gpg
func (gpgSettings *GPGSettings) LoadPublicKeyContent() error {
if gpgSettings.Format == SigningKeyFormatSSH {
content, err := os.ReadFile(gpgSettings.KeyID)
if err != nil {
return fmt.Errorf("unable to read SSH public key file: %s, %w", gpgSettings.KeyID, err)
}
gpgSettings.PublicKeyContent = string(content)
return nil
}
content, stderr, err := process.GetManager().Exec(
"gpg -a --export",
"gpg", "-a", "--export", gpgSettings.KeyID)
if err != nil {
return fmt.Errorf("unable to get default signing key: %s, %s, %w", gpgSettings.KeyID, stderr, err)
}
gpgSettings.PublicKeyContent = content
return nil
}
// GetDefaultPublicGPGKey will return and cache the default public GPG settings for this repository
func (repo *Repository) GetDefaultPublicGPGKey(forceUpdate bool) (*GPGSettings, error) {
if repo.gpgSettings != nil && !forceUpdate {
return repo.gpgSettings, nil
}
gpgSettings := &GPGSettings{
Sign: true,
}
value, _, _ := gitcmd.NewCommand("config", "--get", "commit.gpgsign").RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
sign, valid := ParseBool(strings.TrimSpace(value))
if !sign || !valid {
gpgSettings.Sign = false
repo.gpgSettings = gpgSettings
return gpgSettings, nil
}
signingKey, _, _ := gitcmd.NewCommand("config", "--get", "user.signingkey").RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
gpgSettings.KeyID = strings.TrimSpace(signingKey)
format, _, _ := gitcmd.NewCommand("config", "--default", SigningKeyFormatOpenPGP, "--get", "gpg.format").RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
gpgSettings.Format = strings.TrimSpace(format)
defaultEmail, _, _ := gitcmd.NewCommand("config", "--get", "user.email").RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
gpgSettings.Email = strings.TrimSpace(defaultEmail)
defaultName, _, _ := gitcmd.NewCommand("config", "--get", "user.name").RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
gpgSettings.Name = strings.TrimSpace(defaultName)
if err := gpgSettings.LoadPublicKeyContent(); err != nil {
return nil, err
}
repo.gpgSettings = gpgSettings
return repo.gpgSettings, nil
}
+17 -25
View File
@@ -22,7 +22,7 @@ func (repo *Repository) ReadTreeToIndex(treeish string, indexFilename ...string)
}
if len(treeish) != objectFormat.FullLength() {
res, _, err := gitcmd.NewCommand("rev-parse", "--verify").AddDynamicArguments(treeish).RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
res, _, err := gitcmd.NewCommand("rev-parse", "--verify").AddDynamicArguments(treeish).WithDir(repo.Path).RunStdString(repo.Ctx)
if err != nil {
return err
}
@@ -42,7 +42,7 @@ func (repo *Repository) readTreeToIndex(id ObjectID, indexFilename ...string) er
if len(indexFilename) > 0 {
env = append(os.Environ(), "GIT_INDEX_FILE="+indexFilename[0])
}
_, _, err := gitcmd.NewCommand("read-tree").AddDynamicArguments(id.String()).RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path, Env: env})
_, _, err := gitcmd.NewCommand("read-tree").AddDynamicArguments(id.String()).WithDir(repo.Path).WithEnv(env).RunStdString(repo.Ctx)
if err != nil {
return err
}
@@ -75,14 +75,14 @@ func (repo *Repository) ReadTreeToTemporaryIndex(treeish string) (tmpIndexFilena
// EmptyIndex empties the index
func (repo *Repository) EmptyIndex() error {
_, _, err := gitcmd.NewCommand("read-tree", "--empty").RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
_, _, err := gitcmd.NewCommand("read-tree", "--empty").WithDir(repo.Path).RunStdString(repo.Ctx)
return err
}
// LsFiles checks if the given filenames are in the index
func (repo *Repository) LsFiles(filenames ...string) ([]string, error) {
cmd := gitcmd.NewCommand("ls-files", "-z").AddDashesAndList(filenames...)
res, _, err := cmd.RunStdBytes(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
res, _, err := cmd.WithDir(repo.Path).RunStdBytes(repo.Ctx)
if err != nil {
return nil, err
}
@@ -101,21 +101,17 @@ func (repo *Repository) RemoveFilesFromIndex(filenames ...string) error {
return err
}
cmd := gitcmd.NewCommand("update-index", "--remove", "-z", "--index-info")
stdout := new(bytes.Buffer)
stderr := new(bytes.Buffer)
buffer := new(bytes.Buffer)
input := new(bytes.Buffer)
for _, file := range filenames {
if file != "" {
// using format: mode SP type SP sha1 TAB path
buffer.WriteString("0 blob " + objectFormat.EmptyObjectID().String() + "\t" + file + "\000")
input.WriteString("0 blob " + objectFormat.EmptyObjectID().String() + "\t" + file + "\000")
}
}
return cmd.Run(repo.Ctx, &gitcmd.RunOpts{
Dir: repo.Path,
Stdin: bytes.NewReader(buffer.Bytes()),
Stdout: stdout,
Stderr: stderr,
})
return cmd.
WithDir(repo.Path).
WithStdinBytes(input.Bytes()).
RunWithStderr(repo.Ctx)
}
type IndexObjectInfo struct {
@@ -127,19 +123,15 @@ type IndexObjectInfo struct {
// AddObjectsToIndex adds the provided object hashes to the index at the provided filenames
func (repo *Repository) AddObjectsToIndex(objects ...IndexObjectInfo) error {
cmd := gitcmd.NewCommand("update-index", "--add", "--replace", "-z", "--index-info")
stdout := new(bytes.Buffer)
stderr := new(bytes.Buffer)
buffer := new(bytes.Buffer)
input := new(bytes.Buffer)
for _, object := range objects {
// using format: mode SP type SP sha1 TAB path
buffer.WriteString(object.Mode + " blob " + object.Object.String() + "\t" + object.Filename + "\000")
input.WriteString(object.Mode + " blob " + object.Object.String() + "\t" + object.Filename + "\000")
}
return cmd.Run(repo.Ctx, &gitcmd.RunOpts{
Dir: repo.Path,
Stdin: bytes.NewReader(buffer.Bytes()),
Stdout: stdout,
Stderr: stderr,
})
return cmd.
WithDir(repo.Path).
WithStdinBytes(input.Bytes()).
RunWithStderr(repo.Ctx)
}
// AddObjectToIndex adds the provided object hash to the index at the provided filename
@@ -149,7 +141,7 @@ func (repo *Repository) AddObjectToIndex(mode string, object ObjectID, filename
// WriteTree writes the current index as a tree to the object db and returns its hash
func (repo *Repository) WriteTree() (*Tree, error) {
stdout, _, runErr := gitcmd.NewCommand("write-tree").RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
stdout, _, runErr := gitcmd.NewCommand("write-tree").WithDir(repo.Path).RunStdString(repo.Ctx)
if runErr != nil {
return nil, runErr
}
+10 -22
View File
@@ -5,8 +5,6 @@
package git
import (
"bytes"
"io"
"strings"
"code.gitea.io/gitea/modules/git/gitcmd"
@@ -33,18 +31,12 @@ func (o ObjectType) Bytes() []byte {
return []byte(o)
}
type EmptyReader struct{}
func (EmptyReader) Read(p []byte) (int, error) {
return 0, io.EOF
}
func (repo *Repository) GetObjectFormat() (ObjectFormat, error) {
if repo != nil && repo.objectFormat != nil {
return repo.objectFormat, nil
}
str, err := repo.hashObject(EmptyReader{}, false)
str, err := repo.hashObjectBytes(nil, false)
if err != nil {
return nil, err
}
@@ -58,32 +50,28 @@ func (repo *Repository) GetObjectFormat() (ObjectFormat, error) {
return repo.objectFormat, nil
}
// HashObject takes a reader and returns hash for that reader
func (repo *Repository) HashObject(reader io.Reader) (ObjectID, error) {
idStr, err := repo.hashObject(reader, true)
// HashObjectBytes returns hash for the content
func (repo *Repository) HashObjectBytes(buf []byte) (ObjectID, error) {
idStr, err := repo.hashObjectBytes(buf, true)
if err != nil {
return nil, err
}
return NewIDFromString(idStr)
}
func (repo *Repository) hashObject(reader io.Reader, save bool) (string, error) {
func (repo *Repository) hashObjectBytes(buf []byte, save bool) (string, error) {
var cmd *gitcmd.Command
if save {
cmd = gitcmd.NewCommand("hash-object", "-w", "--stdin")
} else {
cmd = gitcmd.NewCommand("hash-object", "--stdin")
}
stdout := new(bytes.Buffer)
stderr := new(bytes.Buffer)
err := cmd.Run(repo.Ctx, &gitcmd.RunOpts{
Dir: repo.Path,
Stdin: reader,
Stdout: stdout,
Stderr: stderr,
})
stdout, _, err := cmd.
WithDir(repo.Path).
WithStdinBytes(buf).
RunStdString(repo.Ctx)
if err != nil {
return "", err
}
return strings.TrimSpace(stdout.String()), nil
return strings.TrimSpace(stdout), nil
}
+2 -1
View File
@@ -28,7 +28,8 @@ func (repo *Repository) ListOccurrences(ctx context.Context, refType, commitSHA
default:
return nil, util.NewInvalidArgumentErrorf(`can only use "branch" or "tag" for refType, but got %q`, refType)
}
stdout, _, err := cmd.AddArguments("--no-color", "--sort=-creatordate", "--contains").AddDynamicArguments(commitSHA).RunStdString(ctx, &gitcmd.RunOpts{Dir: repo.Path})
stdout, _, err := cmd.AddArguments("--no-color", "--sort=-creatordate", "--contains").
AddDynamicArguments(commitSHA).WithDir(repo.Path).RunStdString(ctx)
if err != nil {
return nil, err
}
@@ -15,75 +15,61 @@ import (
// GetRefsFiltered returns all references of the repository that matches patterm exactly or starting with.
func (repo *Repository) GetRefsFiltered(pattern string) ([]*Reference, error) {
stdoutReader, stdoutWriter := io.Pipe()
defer func() {
_ = stdoutReader.Close()
_ = stdoutWriter.Close()
}()
go func() {
stderrBuilder := &strings.Builder{}
err := gitcmd.NewCommand("for-each-ref").Run(repo.Ctx, &gitcmd.RunOpts{
Dir: repo.Path,
Stdout: stdoutWriter,
Stderr: stderrBuilder,
})
if err != nil {
_ = stdoutWriter.CloseWithError(gitcmd.ConcatenateError(err, stderrBuilder.String()))
} else {
_ = stdoutWriter.Close()
}
}()
refs := make([]*Reference, 0)
bufReader := bufio.NewReader(stdoutReader)
for {
// The output of for-each-ref is simply a list:
// <sha> SP <type> TAB <ref> LF
sha, err := bufReader.ReadString(' ')
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
sha = sha[:len(sha)-1]
cmd := gitcmd.NewCommand("for-each-ref")
stdoutReader, stdoutReaderClose := cmd.MakeStdoutPipe()
defer stdoutReaderClose()
err := cmd.WithDir(repo.Path).
WithPipelineFunc(func(context gitcmd.Context) error {
bufReader := bufio.NewReader(stdoutReader)
for {
// The output of for-each-ref is simply a list:
// <sha> SP <type> TAB <ref> LF
sha, err := bufReader.ReadString(' ')
if err == io.EOF {
break
}
if err != nil {
return err
}
sha = sha[:len(sha)-1]
typ, err := bufReader.ReadString('\t')
if err == io.EOF {
// This should not happen, but we'll tolerate it
break
}
if err != nil {
return nil, err
}
typ = typ[:len(typ)-1]
typ, err := bufReader.ReadString('\t')
if err == io.EOF {
// This should not happen, but we'll tolerate it
break
}
if err != nil {
return err
}
typ = typ[:len(typ)-1]
refName, err := bufReader.ReadString('\n')
if err == io.EOF {
// This should not happen, but we'll tolerate it
break
}
if err != nil {
return nil, err
}
refName = refName[:len(refName)-1]
refName, err := bufReader.ReadString('\n')
if err == io.EOF {
// This should not happen, but we'll tolerate it
break
}
if err != nil {
return err
}
refName = refName[:len(refName)-1]
// refName cannot be HEAD but can be remotes or stash
if strings.HasPrefix(refName, RemotePrefix) || refName == "/refs/stash" {
continue
}
// refName cannot be HEAD but can be remotes or stash
if strings.HasPrefix(refName, RemotePrefix) || refName == "/refs/stash" {
continue
}
if pattern == "" || strings.HasPrefix(refName, pattern) {
r := &Reference{
Name: refName,
Object: MustIDFromString(sha),
Type: typ,
repo: repo,
if pattern == "" || strings.HasPrefix(refName, pattern) {
r := &Reference{
Name: refName,
Object: MustIDFromString(sha),
Type: typ,
repo: repo,
}
refs = append(refs, r)
}
}
refs = append(refs, r)
}
}
return refs, nil
return nil
}).RunWithStderr(repo.Ctx)
return refs, err
}
+10 -25
View File
@@ -5,9 +5,7 @@ package git
import (
"bufio"
"context"
"fmt"
"os"
"sort"
"strconv"
"strings"
@@ -43,7 +41,8 @@ func (repo *Repository) GetCodeActivityStats(fromTime time.Time, branch string)
stdout, _, runErr := gitcmd.NewCommand("rev-list", "--count", "--no-merges", "--branches=*", "--date=iso").
AddOptionFormat("--since=%s", since).
RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
WithDir(repo.Path).
RunStdString(repo.Ctx)
if runErr != nil {
return nil, runErr
}
@@ -54,15 +53,6 @@ func (repo *Repository) GetCodeActivityStats(fromTime time.Time, branch string)
}
stats.CommitCountInAllBranches = c
stdoutReader, stdoutWriter, err := os.Pipe()
if err != nil {
return nil, err
}
defer func() {
_ = stdoutReader.Close()
_ = stdoutWriter.Close()
}()
gitCmd := gitcmd.NewCommand("log", "--numstat", "--no-merges", "--pretty=format:---%n%h%n%aN%n%aE%n", "--date=iso").
AddOptionFormat("--since=%s", since)
if len(branch) == 0 {
@@ -71,14 +61,11 @@ func (repo *Repository) GetCodeActivityStats(fromTime time.Time, branch string)
gitCmd.AddArguments("--first-parent").AddDynamicArguments(branch)
}
stderr := new(strings.Builder)
err = gitCmd.Run(repo.Ctx, &gitcmd.RunOpts{
Env: []string{},
Dir: repo.Path,
Stdout: stdoutWriter,
Stderr: stderr,
PipelineFunc: func(ctx context.Context, cancel context.CancelFunc) error {
_ = stdoutWriter.Close()
stdoutReader, stdoutReaderClose := gitCmd.MakeStdoutPipe()
defer stdoutReaderClose()
err = gitCmd.
WithDir(repo.Path).
WithPipelineFunc(func(ctx gitcmd.Context) error {
scanner := bufio.NewScanner(stdoutReader)
scanner.Split(bufio.ScanLines)
stats.CommitCount = 0
@@ -129,7 +116,6 @@ func (repo *Repository) GetCodeActivityStats(fromTime time.Time, branch string)
}
}
if err = scanner.Err(); err != nil {
_ = stdoutReader.Close()
return fmt.Errorf("GetCodeActivityStats scan: %w", err)
}
a := make([]*CodeActivityAuthor, 0, len(authors))
@@ -143,12 +129,11 @@ func (repo *Repository) GetCodeActivityStats(fromTime time.Time, branch string)
stats.AuthorCount = int64(len(authors))
stats.ChangedFiles = int64(len(files))
stats.Authors = a
_ = stdoutReader.Close()
return nil
},
})
}).
RunWithStderr(repo.Ctx)
if err != nil {
return nil, fmt.Errorf("Failed to get GetCodeActivityStats for repository.\nError: %w\nStderr: %s", err, stderr)
return nil, fmt.Errorf("GetCodeActivityStats: %w", err)
}
return stats, nil
+40 -43
View File
@@ -6,7 +6,6 @@ package git
import (
"fmt"
"io"
"strings"
"code.gitea.io/gitea/modules/git/foreachref"
@@ -19,13 +18,17 @@ const TagPrefix = "refs/tags/"
// CreateTag create one tag in the repository
func (repo *Repository) CreateTag(name, revision string) error {
_, _, err := gitcmd.NewCommand("tag").AddDashesAndList(name, revision).RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
_, _, err := gitcmd.NewCommand("tag").AddDashesAndList(name, revision).WithDir(repo.Path).RunStdString(repo.Ctx)
return err
}
// CreateAnnotatedTag create one annotated tag in the repository
func (repo *Repository) CreateAnnotatedTag(name, message, revision string) error {
_, _, err := gitcmd.NewCommand("tag", "-a", "-m").AddDynamicArguments(message).AddDashesAndList(name, revision).RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
_, _, err := gitcmd.NewCommand("tag", "-a", "-m").
AddDynamicArguments(message).
AddDashesAndList(name, revision).
WithDir(repo.Path).
RunStdString(repo.Ctx)
return err
}
@@ -35,7 +38,7 @@ func (repo *Repository) GetTagNameBySHA(sha string) (string, error) {
return "", fmt.Errorf("SHA is too short: %s", sha)
}
stdout, _, err := gitcmd.NewCommand("show-ref", "--tags", "-d").RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
stdout, _, err := gitcmd.NewCommand("show-ref", "--tags", "-d").WithDir(repo.Path).RunStdString(repo.Ctx)
if err != nil {
return "", err
}
@@ -58,7 +61,7 @@ func (repo *Repository) GetTagNameBySHA(sha string) (string, error) {
// GetTagID returns the object ID for a tag (annotated tags have both an object SHA AND a commit SHA)
func (repo *Repository) GetTagID(name string) (string, error) {
stdout, _, err := gitcmd.NewCommand("show-ref", "--tags").AddDashesAndList(name).RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
stdout, _, err := gitcmd.NewCommand("show-ref", "--tags").AddDashesAndList(name).WithDir(repo.Path).RunStdString(repo.Ctx)
if err != nil {
return "", err
}
@@ -111,48 +114,42 @@ func (repo *Repository) GetTagInfos(page, pageSize int) ([]*Tag, int, error) {
// https://git-scm.com/docs/git-for-each-ref#Documentation/git-for-each-ref.txt-refname
forEachRefFmt := foreachref.NewFormat("objecttype", "refname:lstrip=2", "object", "objectname", "creator", "contents", "contents:signature")
stdoutReader, stdoutWriter := io.Pipe()
defer stdoutReader.Close()
defer stdoutWriter.Close()
stderr := strings.Builder{}
rc := &gitcmd.RunOpts{Dir: repo.Path, Stdout: stdoutWriter, Stderr: &stderr}
go func() {
err := gitcmd.NewCommand("for-each-ref").
AddOptionFormat("--format=%s", forEachRefFmt.Flag()).
AddArguments("--sort", "-*creatordate", "refs/tags").Run(repo.Ctx, rc)
if err != nil {
_ = stdoutWriter.CloseWithError(gitcmd.ConcatenateError(err, stderr.String()))
} else {
_ = stdoutWriter.Close()
}
}()
var tags []*Tag
parser := forEachRefFmt.Parser(stdoutReader)
for {
ref := parser.Next()
if ref == nil {
break
}
var tagsTotal int
cmd := gitcmd.NewCommand("for-each-ref")
stdoutReader, stdoutReaderClose := cmd.MakeStdoutPipe()
defer stdoutReaderClose()
err := cmd.AddOptionFormat("--format=%s", forEachRefFmt.Flag()).
AddArguments("--sort", "-*creatordate", "refs/tags").
WithDir(repo.Path).
WithPipelineFunc(func(context gitcmd.Context) error {
parser := forEachRefFmt.Parser(stdoutReader)
for {
ref := parser.Next()
if ref == nil {
break
}
tag, err := parseTagRef(ref)
if err != nil {
return nil, 0, fmt.Errorf("GetTagInfos: parse tag: %w", err)
}
tags = append(tags, tag)
}
if err := parser.Err(); err != nil {
return nil, 0, fmt.Errorf("GetTagInfos: parse output: %w", err)
}
tag, err := parseTagRef(ref)
if err != nil {
return fmt.Errorf("GetTagInfos: parse tag: %w", err)
}
tags = append(tags, tag)
}
if err := parser.Err(); err != nil {
return fmt.Errorf("GetTagInfos: parse output: %w", err)
}
sortTagsByTime(tags)
tagsTotal := len(tags)
if page != 0 {
tags = util.PaginateSlice(tags, page, pageSize).([]*Tag)
}
sortTagsByTime(tags)
tagsTotal = len(tags)
if page != 0 {
tags = util.PaginateSlice(tags, page, pageSize).([]*Tag)
}
return nil
}).
RunWithStderr(repo.Ctx)
return tags, tagsTotal, nil
return tags, tagsTotal, err
}
// parseTagRef parses a tag from a 'git for-each-ref'-produced reference.
@@ -24,23 +24,19 @@ func (repo *Repository) IsTagExist(name string) bool {
// GetTagType gets the type of the tag, either commit (simple) or tag (annotated)
func (repo *Repository) GetTagType(id ObjectID) (string, error) {
wr, rd, cancel, err := repo.CatFileBatchCheck(repo.Ctx)
batch, cancel, err := repo.CatFileBatch(repo.Ctx)
if err != nil {
return "", err
}
defer cancel()
_, err = wr.Write([]byte(id.String() + "\n"))
if err != nil {
return "", err
}
_, typ, _, err := ReadBatchLine(rd)
info, err := batch.QueryInfo(id.String())
if err != nil {
if IsErrNotExist(err) {
return "", ErrNotExist{ID: id.String()}
}
return "", err
}
return typ, nil
return info.Type, nil
}
func (repo *Repository) getTag(tagID ObjectID, name string) (*Tag, error) {
@@ -88,22 +84,20 @@ func (repo *Repository) getTag(tagID ObjectID, name string) (*Tag, error) {
}
// The tag is an annotated tag with a message.
wr, rd, cancel, err := repo.CatFileBatch(repo.Ctx)
batch, cancel, err := repo.CatFileBatch(repo.Ctx)
if err != nil {
return nil, err
}
defer cancel()
if _, err := wr.Write([]byte(tagID.String() + "\n")); err != nil {
return nil, err
}
_, typ, size, err := ReadBatchLine(rd)
info, rd, err := batch.QueryContent(tagID.String())
if err != nil {
if errors.Is(err, io.EOF) || IsErrNotExist(err) {
return nil, ErrNotExist{ID: tagID.String()}
}
return nil, err
}
typ, size := info.Type, info.Size
if typ != "tag" {
if err := DiscardFull(rd, size+1); err != nil {
return nil, err
@@ -10,16 +10,6 @@ import (
"github.com/stretchr/testify/assert"
)
func TestGetLatestCommitTime(t *testing.T) {
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
lct, err := GetLatestCommitTime(t.Context(), bareRepo1Path)
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())
}
func TestRepoIsEmpty(t *testing.T) {
emptyRepo2Path := filepath.Join(testReposDir, "repo2_empty")
repo, err := OpenRepository(t.Context(), emptyRepo2Path)
@@ -29,27 +19,3 @@ func TestRepoIsEmpty(t *testing.T) {
assert.NoError(t, err)
assert.True(t, isEmpty)
}
func TestRepoGetDivergingCommits(t *testing.T) {
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
do, err := GetDivergingCommits(t.Context(), bareRepo1Path, "master", "branch2")
assert.NoError(t, err)
assert.Equal(t, DivergeObject{
Ahead: 1,
Behind: 5,
}, do)
do, err = GetDivergingCommits(t.Context(), bareRepo1Path, "master", "master")
assert.NoError(t, err)
assert.Equal(t, DivergeObject{
Ahead: 0,
Behind: 0,
}, do)
do, err = GetDivergingCommits(t.Context(), bareRepo1Path, "master", "test")
assert.NoError(t, err)
assert.Equal(t, DivergeObject{
Ahead: 0,
Behind: 2,
}, do)
}
+6 -11
View File
@@ -58,17 +58,12 @@ func (repo *Repository) CommitTree(author, committer *Signature, tree *Tree, opt
cmd.AddArguments("--no-gpg-sign")
}
stdout := new(bytes.Buffer)
stderr := new(bytes.Buffer)
err := cmd.Run(repo.Ctx, &gitcmd.RunOpts{
Env: env,
Dir: repo.Path,
Stdin: messageBytes,
Stdout: stdout,
Stderr: stderr,
})
stdout, _, err := cmd.WithEnv(env).
WithDir(repo.Path).
WithStdinBytes(messageBytes.Bytes()).
RunStdString(repo.Ctx)
if err != nil {
return nil, gitcmd.ConcatenateError(err, stderr.String())
return nil, err
}
return NewIDFromString(strings.TrimSpace(stdout.String()))
return NewIDFromString(strings.TrimSpace(stdout))
}
@@ -26,7 +26,7 @@ func (repo *Repository) getTree(id ObjectID) (*Tree, error) {
}
tree := NewTree(repo, id)
tree.gogitTree = gogitTree
tree.resolvedGogitTreeObject = gogitTree
return tree, nil
}
@@ -38,7 +38,10 @@ func (repo *Repository) GetTree(idStr string) (*Tree, error) {
}
if len(idStr) != objectFormat.FullLength() {
res, _, err := gitcmd.NewCommand("rev-parse", "--verify").AddDynamicArguments(idStr).RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
res, _, err := gitcmd.NewCommand("rev-parse", "--verify").
AddDynamicArguments(idStr).
WithDir(repo.Path).
RunStdString(repo.Ctx)
if err != nil {
return nil, err
}
@@ -10,24 +10,21 @@ import (
)
func (repo *Repository) getTree(id ObjectID) (*Tree, error) {
wr, rd, cancel, err := repo.CatFileBatch(repo.Ctx)
batch, cancel, err := repo.CatFileBatch(repo.Ctx)
if err != nil {
return nil, err
}
defer cancel()
_, _ = wr.Write([]byte(id.String() + "\n"))
// ignore the SHA
_, typ, size, err := ReadBatchLine(rd)
info, rd, err := batch.QueryContent(id.String())
if err != nil {
return nil, err
}
switch typ {
switch info.Type {
case "tag":
resolvedID := id
data, err := io.ReadAll(io.LimitReader(rd, size))
data, err := io.ReadAll(io.LimitReader(rd, info.Size))
if err != nil {
return nil, err
}
@@ -36,17 +33,14 @@ func (repo *Repository) getTree(id ObjectID) (*Tree, error) {
return nil, err
}
if _, err := wr.Write([]byte(tag.Object.String() + "\n")); err != nil {
return nil, err
}
commit, err := repo.getCommitFromBatchReader(wr, rd, tag.Object)
commit, err := repo.getCommitWithBatch(batch, tag.Object)
if err != nil {
return nil, err
}
commit.Tree.ResolvedID = resolvedID
return &commit.Tree, nil
case "commit":
commit, err := CommitFromReader(repo, id, io.LimitReader(rd, size))
commit, err := CommitFromReader(repo, id, io.LimitReader(rd, info.Size))
if err != nil {
return nil, err
}
@@ -62,14 +56,14 @@ func (repo *Repository) getTree(id ObjectID) (*Tree, error) {
if err != nil {
return nil, err
}
tree.entries, err = catBatchParseTreeEntries(objectFormat, tree, rd, size)
tree.entries, err = catBatchParseTreeEntries(objectFormat, tree, rd, info.Size)
if err != nil {
return nil, err
}
tree.entriesParsed = true
return tree, nil
default:
if err := DiscardFull(rd, size+1); err != nil {
if err := DiscardFull(rd, info.Size+1); err != nil {
return nil, err
}
return nil, ErrNotExist{
+8 -17
View File
@@ -7,7 +7,6 @@ import (
"bufio"
"context"
"fmt"
"os"
"code.gitea.io/gitea/modules/git/gitcmd"
"code.gitea.io/gitea/modules/log"
@@ -21,22 +20,15 @@ type TemplateSubmoduleCommit struct {
// GetTemplateSubmoduleCommits returns a list of submodules paths and their commits from a repository
// This function is only for generating new repos based on existing template, the template couldn't be too large.
func GetTemplateSubmoduleCommits(ctx context.Context, repoPath string) (submoduleCommits []TemplateSubmoduleCommit, _ error) {
stdoutReader, stdoutWriter, err := os.Pipe()
if err != nil {
return nil, err
}
opts := &gitcmd.RunOpts{
Dir: repoPath,
Stdout: stdoutWriter,
PipelineFunc: func(ctx context.Context, cancel context.CancelFunc) error {
_ = stdoutWriter.Close()
defer stdoutReader.Close()
cmd := gitcmd.NewCommand("ls-tree", "-r", "--", "HEAD")
stdoutReader, stdoutReaderClose := cmd.MakeStdoutPipe()
defer stdoutReaderClose()
err := cmd.WithDir(repoPath).
WithPipelineFunc(func(ctx gitcmd.Context) error {
scanner := bufio.NewScanner(stdoutReader)
for scanner.Scan() {
entry, err := parseLsTreeLine(scanner.Bytes())
if err != nil {
cancel()
return err
}
if entry.EntryMode == EntryModeCommit {
@@ -44,9 +36,8 @@ func GetTemplateSubmoduleCommits(ctx context.Context, repoPath string) (submodul
}
}
return scanner.Err()
},
}
err = gitcmd.NewCommand("ls-tree", "-r", "--", "HEAD").Run(ctx, opts)
}).
Run(ctx)
if err != nil {
return nil, fmt.Errorf("GetTemplateSubmoduleCommits: error running git ls-tree: %v", err)
}
@@ -58,7 +49,7 @@ func GetTemplateSubmoduleCommits(ctx context.Context, repoPath string) (submodul
func AddTemplateSubmoduleIndexes(ctx context.Context, repoPath string, submodules []TemplateSubmoduleCommit) error {
for _, submodule := range submodules {
cmd := gitcmd.NewCommand("update-index", "--add", "--cacheinfo", "160000").AddDynamicArguments(submodule.Commit, submodule.Path)
if stdout, _, err := cmd.RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath}); err != nil {
if stdout, _, err := cmd.WithDir(repoPath).RunStdString(ctx); err != nil {
log.Error("Unable to add %s as submodule to repo %s: stdout %s\nError: %v", submodule.Path, repoPath, stdout, err)
return err
}
@@ -32,14 +32,14 @@ func TestAddTemplateSubmoduleIndexes(t *testing.T) {
ctx := t.Context()
tmpDir := t.TempDir()
var err error
_, _, err = gitcmd.NewCommand("init").RunStdString(ctx, &gitcmd.RunOpts{Dir: tmpDir})
_, _, err = gitcmd.NewCommand("init").WithDir(tmpDir).RunStdString(ctx)
require.NoError(t, err)
_ = os.Mkdir(filepath.Join(tmpDir, "new-dir"), 0o755)
err = AddTemplateSubmoduleIndexes(ctx, tmpDir, []TemplateSubmoduleCommit{{Path: "new-dir", Commit: "1234567890123456789012345678901234567890"}})
require.NoError(t, err)
_, _, err = gitcmd.NewCommand("add", "--all").RunStdString(ctx, &gitcmd.RunOpts{Dir: tmpDir})
_, _, err = gitcmd.NewCommand("add", "--all").WithDir(tmpDir).RunStdString(ctx)
require.NoError(t, err)
_, _, err = gitcmd.NewCommand("-c", "user.name=a", "-c", "user.email=b", "commit", "-m=test").RunStdString(ctx, &gitcmd.RunOpts{Dir: tmpDir})
_, _, err = gitcmd.NewCommand("-c", "user.name=a", "-c", "user.email=b", "commit", "-m=test").WithDir(tmpDir).RunStdString(ctx)
require.NoError(t, err)
submodules, err := GetTemplateSubmoduleCommits(t.Context(), tmpDir)
require.NoError(t, err)
+15 -4
View File
@@ -11,11 +11,21 @@ import (
"code.gitea.io/gitea/modules/git/gitcmd"
)
type TreeCommon struct {
ID ObjectID
ResolvedID ObjectID
repo *Repository
ptree *Tree // parent tree
}
// NewTree create a new tree according the repository and tree id
func NewTree(repo *Repository, id ObjectID) *Tree {
return &Tree{
ID: id,
repo: repo,
TreeCommon: TreeCommon{
ID: id,
repo: repo,
},
}
}
@@ -53,7 +63,7 @@ func (repo *Repository) LsTree(ref string, filenames ...string) ([]string, error
cmd := gitcmd.NewCommand("ls-tree", "-z", "--name-only").
AddDashesAndList(append([]string{ref}, filenames...)...)
res, _, err := cmd.RunStdBytes(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
res, _, err := cmd.WithDir(repo.Path).RunStdBytes(repo.Ctx)
if err != nil {
return nil, err
}
@@ -69,7 +79,8 @@ func (repo *Repository) LsTree(ref string, filenames ...string) ([]string, error
func (repo *Repository) GetTreePathLatestCommit(refName, treePath string) (*Commit, error) {
stdout, _, err := gitcmd.NewCommand("rev-list", "-1").
AddDynamicArguments(refName).AddDashesAndList(treePath).
RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
WithDir(repo.Path).
RunStdString(repo.Ctx)
if err != nil {
return nil, err
}
@@ -11,22 +11,16 @@ import (
"strings"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/filemode"
"github.com/go-git/go-git/v5/plumbing/object"
)
// GetTreeEntryByPath get the tree entries according the sub dir
func (t *Tree) GetTreeEntryByPath(relpath string) (*TreeEntry, error) {
if len(relpath) == 0 {
return &TreeEntry{
ID: t.ID,
// Type: ObjectTree,
ptree: t,
gogitTreeEntry: &object.TreeEntry{
Name: "",
Mode: filemode.Dir,
Hash: plumbing.Hash(t.ID.RawValue()),
},
ID: t.ID,
ptree: t,
name: "",
entryMode: EntryModeTree,
}, nil
}
+60 -45
View File
@@ -6,12 +6,60 @@ package git
import (
"path"
"sort"
"slices"
"strings"
"code.gitea.io/gitea/modules/util"
)
// TreeEntry the leaf in the git tree
type TreeEntry struct {
ID ObjectID
name string
ptree *Tree
entryMode EntryMode
size int64
sized bool
}
// Name returns the name of the entry (base name)
func (te *TreeEntry) Name() string {
return te.name
}
// Mode returns the mode of the entry
func (te *TreeEntry) Mode() EntryMode {
return te.entryMode
}
// IsSubModule if the entry is a submodule
func (te *TreeEntry) IsSubModule() bool {
return te.entryMode.IsSubModule()
}
// IsDir if the entry is a sub dir
func (te *TreeEntry) IsDir() bool {
return te.entryMode.IsDir()
}
// IsLink if the entry is a symlink
func (te *TreeEntry) IsLink() bool {
return te.entryMode.IsLink()
}
// IsRegular if the entry is a regular file
func (te *TreeEntry) IsRegular() bool {
return te.entryMode.IsRegular()
}
// IsExecutable if the entry is an executable file (not necessarily binary)
func (te *TreeEntry) IsExecutable() bool {
return te.entryMode.IsExecutable()
}
// Type returns the type of the entry (commit, tree, blob)
func (te *TreeEntry) Type() string {
switch te.Mode() {
@@ -109,49 +157,16 @@ func (te *TreeEntry) GetSubJumpablePathName() string {
// Entries a list of entry
type Entries []*TreeEntry
type customSortableEntries struct {
Comparer func(s1, s2 string) bool
Entries
}
var sorter = []func(t1, t2 *TreeEntry, cmp func(s1, s2 string) bool) bool{
func(t1, t2 *TreeEntry, cmp func(s1, s2 string) bool) bool {
return (t1.IsDir() || t1.IsSubModule()) && !t2.IsDir() && !t2.IsSubModule()
},
func(t1, t2 *TreeEntry, cmp func(s1, s2 string) bool) bool {
return cmp(t1.Name(), t2.Name())
},
}
func (ctes customSortableEntries) Len() int { return len(ctes.Entries) }
func (ctes customSortableEntries) Swap(i, j int) {
ctes.Entries[i], ctes.Entries[j] = ctes.Entries[j], ctes.Entries[i]
}
func (ctes customSortableEntries) Less(i, j int) bool {
t1, t2 := ctes.Entries[i], ctes.Entries[j]
var k int
for k = 0; k < len(sorter)-1; k++ {
s := sorter[k]
switch {
case s(t1, t2, ctes.Comparer):
return true
case s(t2, t1, ctes.Comparer):
return false
}
}
return sorter[k](t1, t2, ctes.Comparer)
}
// Sort sort the list of entry
func (tes Entries) Sort() {
sort.Sort(customSortableEntries{func(s1, s2 string) bool {
return s1 < s2
}, tes})
}
// CustomSort customizable string comparing sort entry list
func (tes Entries) CustomSort(cmp func(s1, s2 string) bool) {
sort.Sort(customSortableEntries{cmp, tes})
func (tes Entries) CustomSort(cmp func(s1, s2 string) int) {
slices.SortFunc(tes, func(a, b *TreeEntry) int {
s1Dir, s2Dir := a.IsDir() || a.IsSubModule(), b.IsDir() || b.IsSubModule()
if s1Dir != s2Dir {
if s1Dir {
return -1
}
return 1
}
return cmp(a.Name(), b.Name())
})
}
@@ -7,34 +7,26 @@
package git
import (
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/filemode"
"github.com/go-git/go-git/v5/plumbing/object"
)
// TreeEntry the leaf in the git tree
type TreeEntry struct {
ID ObjectID
gogitTreeEntry *object.TreeEntry
ptree *Tree
fullName string
size int64
sized bool
// gogitFileModeToEntryMode converts go-git filemode to EntryMode
func gogitFileModeToEntryMode(mode filemode.FileMode) EntryMode {
return EntryMode(mode)
}
// Name returns the name of the entry
func (te *TreeEntry) Name() string {
if te.fullName != "" {
return te.fullName
func entryModeToGogitFileMode(mode EntryMode) filemode.FileMode {
return filemode.FileMode(mode)
}
func (te *TreeEntry) toGogitTreeEntry() *object.TreeEntry {
return &object.TreeEntry{
Name: te.name,
Mode: entryModeToGogitFileMode(te.entryMode),
Hash: plumbing.Hash(te.ID.RawValue()),
}
return te.gogitTreeEntry.Name
}
// Mode returns the mode of the entry
func (te *TreeEntry) Mode() EntryMode {
return EntryMode(te.gogitTreeEntry.Mode)
}
// Size returns the size of the entry
@@ -45,7 +37,11 @@ func (te *TreeEntry) Size() int64 {
return te.size
}
file, err := te.ptree.gogitTree.TreeEntryFile(te.gogitTreeEntry)
ptreeGogitTree, err := te.ptree.gogitTreeObject()
if err != nil {
return 0
}
file, err := ptreeGogitTree.TreeEntryFile(te.toGogitTreeEntry())
if err != nil {
return 0
}
@@ -55,35 +51,10 @@ func (te *TreeEntry) Size() int64 {
return te.size
}
// IsSubModule if the entry is a submodule
func (te *TreeEntry) IsSubModule() bool {
return te.gogitTreeEntry.Mode == filemode.Submodule
}
// IsDir if the entry is a sub dir
func (te *TreeEntry) IsDir() bool {
return te.gogitTreeEntry.Mode == filemode.Dir
}
// IsLink if the entry is a symlink
func (te *TreeEntry) IsLink() bool {
return te.gogitTreeEntry.Mode == filemode.Symlink
}
// IsRegular if the entry is a regular file
func (te *TreeEntry) IsRegular() bool {
return te.gogitTreeEntry.Mode == filemode.Regular
}
// IsExecutable if the entry is an executable file (not necessarily binary)
func (te *TreeEntry) IsExecutable() bool {
return te.gogitTreeEntry.Mode == filemode.Executable
}
// Blob returns the blob object the entry
func (te *TreeEntry) Blob() *Blob {
return &Blob{
ID: ParseGogitHash(te.gogitTreeEntry.Hash),
ID: te.ID,
repo: te.ptree.repo,
name: te.Name(),
}
@@ -0,0 +1,27 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build gogit
package git
import (
"testing"
"github.com/go-git/go-git/v5/plumbing/filemode"
"github.com/stretchr/testify/assert"
)
func TestEntryGogit(t *testing.T) {
cases := map[EntryMode]filemode.FileMode{
EntryModeBlob: filemode.Regular,
EntryModeCommit: filemode.Submodule,
EntryModeExec: filemode.Executable,
EntryModeSymlink: filemode.Symlink,
EntryModeTree: filemode.Dir,
}
for emode, fmode := range cases {
assert.EqualValues(t, fmode, entryModeToGogitFileMode(emode))
assert.EqualValues(t, emode, gogitFileModeToEntryMode(fmode))
}
}
@@ -4,7 +4,6 @@
package git
import (
"fmt"
"strconv"
)
@@ -55,21 +54,38 @@ func (e EntryMode) IsExecutable() bool {
return e == EntryModeExec
}
func ParseEntryMode(mode string) (EntryMode, error) {
func ParseEntryMode(mode string) EntryMode {
switch mode {
case "000000":
return EntryModeNoEntry, nil
return EntryModeNoEntry
case "100644":
return EntryModeBlob, nil
return EntryModeBlob
case "100755":
return EntryModeExec, nil
return EntryModeExec
case "120000":
return EntryModeSymlink, nil
return EntryModeSymlink
case "160000":
return EntryModeCommit, nil
case "040000", "040755": // git uses 040000 for tree object, but some users may get 040755 for unknown reasons
return EntryModeTree, nil
return EntryModeCommit
case "040000":
return EntryModeTree
default:
return 0, fmt.Errorf("unparsable entry mode: %s", mode)
// git uses 040000 for tree object, but some users may get 040755 from non-standard git implementations
m, _ := strconv.ParseInt(mode, 8, 32)
modeInt := EntryMode(m)
switch modeInt & 0o770000 {
case 0o040000:
return EntryModeTree
case 0o160000:
return EntryModeCommit
case 0o120000:
return EntryModeSymlink
case 0o100000:
if modeInt&0o777 == 0o755 {
return EntryModeExec
}
return EntryModeBlob
default:
return EntryModeNoEntry
}
}
}
@@ -7,27 +7,6 @@ package git
import "code.gitea.io/gitea/modules/log"
// TreeEntry the leaf in the git tree
type TreeEntry struct {
ID ObjectID
ptree *Tree
entryMode EntryMode
name string
size int64
sized bool
}
// Name returns the name of the entry (base name)
func (te *TreeEntry) Name() string {
return te.name
}
// Mode returns the mode of the entry
func (te *TreeEntry) Mode() EntryMode {
return te.entryMode
}
// Size returns the size of the entry
func (te *TreeEntry) Size() int64 {
if te.IsDir() {
@@ -36,52 +15,23 @@ func (te *TreeEntry) Size() int64 {
return te.size
}
wr, rd, cancel, err := te.ptree.repo.CatFileBatchCheck(te.ptree.repo.Ctx)
batch, cancel, err := te.ptree.repo.CatFileBatch(te.ptree.repo.Ctx)
if err != nil {
log.Debug("error whilst reading size for %s in %s. Error: %v", te.ID.String(), te.ptree.repo.Path, err)
return 0
}
defer cancel()
_, err = wr.Write([]byte(te.ID.String() + "\n"))
if err != nil {
log.Debug("error whilst reading size for %s in %s. Error: %v", te.ID.String(), te.ptree.repo.Path, err)
return 0
}
_, _, te.size, err = ReadBatchLine(rd)
info, err := batch.QueryInfo(te.ID.String())
if err != nil {
log.Debug("error whilst reading size for %s in %s. Error: %v", te.ID.String(), te.ptree.repo.Path, err)
return 0
}
te.size = info.Size
te.sized = true
return te.size
}
// IsSubModule if the entry is a submodule
func (te *TreeEntry) IsSubModule() bool {
return te.entryMode.IsSubModule()
}
// IsDir if the entry is a sub dir
func (te *TreeEntry) IsDir() bool {
return te.entryMode.IsDir()
}
// IsLink if the entry is a symlink
func (te *TreeEntry) IsLink() bool {
return te.entryMode.IsLink()
}
// IsRegular if the entry is a regular file
func (te *TreeEntry) IsRegular() bool {
return te.entryMode.IsRegular()
}
// IsExecutable if the entry is an executable file (not necessarily binary)
func (te *TreeEntry) IsExecutable() bool {
return te.entryMode.IsExecutable()
}
// Blob returns the blob object the entry
func (te *TreeEntry) Blob() *Blob {
return &Blob{
@@ -1,55 +1,57 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build gogit
package git
import (
"math/rand/v2"
"slices"
"strings"
"testing"
"github.com/go-git/go-git/v5/plumbing/filemode"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/stretchr/testify/assert"
)
func getTestEntries() Entries {
return Entries{
&TreeEntry{gogitTreeEntry: &object.TreeEntry{Name: "v1.0", Mode: filemode.Dir}},
&TreeEntry{gogitTreeEntry: &object.TreeEntry{Name: "v2.0", Mode: filemode.Dir}},
&TreeEntry{gogitTreeEntry: &object.TreeEntry{Name: "v2.1", Mode: filemode.Dir}},
&TreeEntry{gogitTreeEntry: &object.TreeEntry{Name: "v2.12", Mode: filemode.Dir}},
&TreeEntry{gogitTreeEntry: &object.TreeEntry{Name: "v2.2", Mode: filemode.Dir}},
&TreeEntry{gogitTreeEntry: &object.TreeEntry{Name: "v12.0", Mode: filemode.Dir}},
&TreeEntry{gogitTreeEntry: &object.TreeEntry{Name: "abc", Mode: filemode.Regular}},
&TreeEntry{gogitTreeEntry: &object.TreeEntry{Name: "bcd", Mode: filemode.Regular}},
func TestEntriesCustomSort(t *testing.T) {
entries := Entries{
&TreeEntry{name: "a-dir", entryMode: EntryModeTree},
&TreeEntry{name: "a-submodule", entryMode: EntryModeCommit},
&TreeEntry{name: "b-dir", entryMode: EntryModeTree},
&TreeEntry{name: "b-submodule", entryMode: EntryModeCommit},
&TreeEntry{name: "a-file", entryMode: EntryModeBlob},
&TreeEntry{name: "b-file", entryMode: EntryModeBlob},
}
expected := slices.Clone(entries)
for slices.Equal(expected, entries) {
rand.Shuffle(len(entries), func(i, j int) { entries[i], entries[j] = entries[j], entries[i] })
}
entries.CustomSort(strings.Compare)
assert.Equal(t, expected, entries)
}
func TestParseEntryMode(t *testing.T) {
tests := []struct {
modeStr string
expectMod EntryMode
}{
{"000000", EntryModeNoEntry},
{"000755", EntryModeNoEntry},
{"100644", EntryModeBlob},
{"100755", EntryModeExec},
{"120000", EntryModeSymlink},
{"120755", EntryModeSymlink},
{"160000", EntryModeCommit},
{"160755", EntryModeCommit},
{"040000", EntryModeTree},
{"040755", EntryModeTree},
{"777777", EntryModeNoEntry}, // invalid mode
}
for _, test := range tests {
mod := ParseEntryMode(test.modeStr)
assert.Equal(t, test.expectMod, mod, "modeStr: %s", test.modeStr)
}
}
func TestEntriesSort(t *testing.T) {
entries := getTestEntries()
entries.Sort()
assert.Equal(t, "v1.0", entries[0].Name())
assert.Equal(t, "v12.0", entries[1].Name())
assert.Equal(t, "v2.0", entries[2].Name())
assert.Equal(t, "v2.1", entries[3].Name())
assert.Equal(t, "v2.12", entries[4].Name())
assert.Equal(t, "v2.2", entries[5].Name())
assert.Equal(t, "abc", entries[6].Name())
assert.Equal(t, "bcd", entries[7].Name())
}
func TestEntriesCustomSort(t *testing.T) {
entries := getTestEntries()
entries.CustomSort(func(s1, s2 string) bool {
return s1 > s2
})
assert.Equal(t, "v2.2", entries[0].Name())
assert.Equal(t, "v2.12", entries[1].Name())
assert.Equal(t, "v2.1", entries[2].Name())
assert.Equal(t, "v2.0", entries[3].Name())
assert.Equal(t, "v12.0", entries[4].Name())
assert.Equal(t, "v1.0", entries[5].Name())
assert.Equal(t, "bcd", entries[6].Name())
assert.Equal(t, "abc", entries[7].Name())
}
+30 -47
View File
@@ -15,41 +15,34 @@ import (
// Tree represents a flat directory listing.
type Tree struct {
ID ObjectID
ResolvedID ObjectID
repo *Repository
TreeCommon
gogitTree *object.Tree
// parent tree
ptree *Tree
resolvedGogitTreeObject *object.Tree
}
func (t *Tree) loadTreeObject() error {
gogitTree, err := t.repo.gogitRepo.TreeObject(plumbing.Hash(t.ID.RawValue()))
if err != nil {
return err
}
t.gogitTree = gogitTree
return nil
}
// ListEntries returns all entries of current tree.
func (t *Tree) ListEntries() (Entries, error) {
if t.gogitTree == nil {
err := t.loadTreeObject()
func (t *Tree) gogitTreeObject() (_ *object.Tree, err error) {
if t.resolvedGogitTreeObject == nil {
t.resolvedGogitTreeObject, err = t.repo.gogitRepo.TreeObject(plumbing.Hash(t.ID.RawValue()))
if err != nil {
return nil, err
}
}
return t.resolvedGogitTreeObject, nil
}
entries := make([]*TreeEntry, len(t.gogitTree.Entries))
for i, entry := range t.gogitTree.Entries {
// ListEntries returns all entries of current tree.
func (t *Tree) ListEntries() (Entries, error) {
gogitTree, err := t.gogitTreeObject()
if err != nil {
return nil, err
}
entries := make([]*TreeEntry, len(gogitTree.Entries))
for i, gogitTreeEntry := range gogitTree.Entries {
entries[i] = &TreeEntry{
ID: ParseGogitHash(entry.Hash),
gogitTreeEntry: &t.gogitTree.Entries[i],
ptree: t,
ID: ParseGogitHash(gogitTreeEntry.Hash),
ptree: t,
name: gogitTreeEntry.Name,
entryMode: gogitFileModeToEntryMode(gogitTreeEntry.Mode),
}
}
@@ -57,38 +50,28 @@ func (t *Tree) ListEntries() (Entries, error) {
}
// ListEntriesRecursiveWithSize returns all entries of current tree recursively including all subtrees
func (t *Tree) ListEntriesRecursiveWithSize() (Entries, error) {
if t.gogitTree == nil {
err := t.loadTreeObject()
if err != nil {
return nil, err
}
func (t *Tree) ListEntriesRecursiveWithSize() (entries Entries, _ error) {
gogitTree, err := t.gogitTreeObject()
if err != nil {
return nil, err
}
var entries []*TreeEntry
seen := map[plumbing.Hash]bool{}
walker := object.NewTreeWalker(t.gogitTree, true, seen)
walker := object.NewTreeWalker(gogitTree, true, nil)
for {
fullName, entry, err := walker.Next()
fullName, gogitTreeEntry, err := walker.Next()
if err == io.EOF {
break
}
if err != nil {
} else if err != nil {
return nil, err
}
if seen[entry.Hash] {
continue
}
convertedEntry := &TreeEntry{
ID: ParseGogitHash(entry.Hash),
gogitTreeEntry: &entry,
ptree: t,
fullName: fullName,
ID: ParseGogitHash(gogitTreeEntry.Hash),
name: fullName, // FIXME: the "name" field is abused, here it is a full path
ptree: t, // FIXME: this ptree is not right, fortunately it isn't really used
entryMode: gogitFileModeToEntryMode(gogitTreeEntry.Mode),
}
entries = append(entries, convertedEntry)
}
return entries, nil
}
@@ -14,18 +14,10 @@ import (
// Tree represents a flat directory listing.
type Tree struct {
ID ObjectID
ResolvedID ObjectID
repo *Repository
// parent tree
ptree *Tree
TreeCommon
entries Entries
entriesParsed bool
entriesRecursive Entries
entriesRecursiveParsed bool
}
// ListEntries returns all entries of current tree.
@@ -35,30 +27,29 @@ func (t *Tree) ListEntries() (Entries, error) {
}
if t.repo != nil {
wr, rd, cancel, err := t.repo.CatFileBatch(t.repo.Ctx)
batch, cancel, err := t.repo.CatFileBatch(t.repo.Ctx)
if err != nil {
return nil, err
}
defer cancel()
_, _ = wr.Write([]byte(t.ID.String() + "\n"))
_, typ, sz, err := ReadBatchLine(rd)
info, rd, err := batch.QueryContent(t.ID.String())
if err != nil {
return nil, err
}
if typ == "commit" {
treeID, err := ReadTreeID(rd, sz)
if info.Type == "commit" {
treeID, err := ReadTreeID(rd, info.Size)
if err != nil && err != io.EOF {
return nil, err
}
_, _ = wr.Write([]byte(treeID + "\n"))
_, typ, sz, err = ReadBatchLine(rd)
info, rd, err = batch.QueryContent(treeID)
if err != nil {
return nil, err
}
}
if typ == "tree" {
t.entries, err = catBatchParseTreeEntries(t.ID.Type(), t, rd, sz)
if info.Type == "tree" {
t.entries, err = catBatchParseTreeEntries(t.ID.Type(), t, rd, info.Size)
if err != nil {
return nil, err
}
@@ -67,14 +58,14 @@ func (t *Tree) ListEntries() (Entries, error) {
}
// Not a tree just use ls-tree instead
if err := DiscardFull(rd, sz+1); err != nil {
if err := DiscardFull(rd, info.Size+1); err != nil {
return nil, err
}
}
stdout, _, runErr := gitcmd.NewCommand("ls-tree", "-l").AddDynamicArguments(t.ID.String()).RunStdBytes(t.repo.Ctx, &gitcmd.RunOpts{Dir: t.repo.Path})
stdout, _, runErr := gitcmd.NewCommand("ls-tree", "-l").AddDynamicArguments(t.ID.String()).WithDir(t.repo.Path).RunStdBytes(t.repo.Ctx)
if runErr != nil {
if strings.Contains(runErr.Error(), "fatal: Not a valid object name") || strings.Contains(runErr.Error(), "fatal: not a tree object") {
if gitcmd.IsStdErrorNotValidObjectName(runErr) || strings.Contains(runErr.Error(), "fatal: not a tree object") {
return nil, ErrNotExist{
ID: t.ID.String(),
}
@@ -94,25 +85,18 @@ func (t *Tree) ListEntries() (Entries, error) {
// listEntriesRecursive returns all entries of current tree recursively including all subtrees
// extraArgs could be "-l" to get the size, which is slower
func (t *Tree) listEntriesRecursive(extraArgs gitcmd.TrustedCmdArgs) (Entries, error) {
if t.entriesRecursiveParsed {
return t.entriesRecursive, nil
}
stdout, _, runErr := gitcmd.NewCommand("ls-tree", "-t", "-r").
AddArguments(extraArgs...).
AddDynamicArguments(t.ID.String()).
RunStdBytes(t.repo.Ctx, &gitcmd.RunOpts{Dir: t.repo.Path})
WithDir(t.repo.Path).
RunStdBytes(t.repo.Ctx)
if runErr != nil {
return nil, runErr
}
var err error
t.entriesRecursive, err = parseTreeEntries(stdout, t)
if err == nil {
t.entriesRecursiveParsed = true
}
return t.entriesRecursive, err
// FIXME: the "name" field is abused, here it is a full path
// FIXME: this ptree is not right, fortunately it isn't really used
return parseTreeEntries(stdout, t)
}
// ListEntriesRecursiveFast returns all entries of current tree recursively including all subtrees, no size