diff options
Diffstat (limited to '')
332 files changed, 16641 insertions, 0 deletions
diff --git a/modules/git/README.md b/modules/git/README.md new file mode 100644 index 0000000..4418c1b --- /dev/null +++ b/modules/git/README.md @@ -0,0 +1,3 @@ +# Git Module + +This module is merged from https://github.com/go-gitea/git which is a Go module to access Git through shell commands. Now it's a part of gitea's main repository for easier pull request. diff --git a/modules/git/batch.go b/modules/git/batch.go new file mode 100644 index 0000000..3ec4f1d --- /dev/null +++ b/modules/git/batch.go @@ -0,0 +1,46 @@ +// 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 +} + +func (repo *Repository) NewBatch(ctx context.Context) (*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, repo.Path); err != nil { + return nil, err + } + + var batch Batch + batch.Writer, batch.Reader, batch.cancel = catFileBatch(ctx, repo.Path) + return &batch, nil +} + +func (repo *Repository) NewBatchCheck(ctx context.Context) (*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, repo.Path); err != nil { + return nil, err + } + + var check Batch + check.Writer, check.Reader, check.cancel = catFileBatchCheck(ctx, repo.Path) + return &check, nil +} + +func (b *Batch) Close() { + if b.cancel != nil { + b.cancel() + b.Reader = nil + b.Writer = nil + b.cancel = nil + } +} diff --git a/modules/git/batch_reader.go b/modules/git/batch_reader.go new file mode 100644 index 0000000..3b1a466 --- /dev/null +++ b/modules/git/batch_reader.go @@ -0,0 +1,347 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "bufio" + "bytes" + "context" + "fmt" + "io" + "math" + "runtime" + "strconv" + "strings" + + "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 := NewCommand(ctx, "rev-parse"). + SetDescription(fmt.Sprintf("%s rev-parse [repo_path: %s]", GitExecutable, repoPath)). + Run(&RunOpts{ + Dir: repoPath, + Stderr: &stderr, + }) + if err != nil { + return 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() + }() + + _, filename, line, _ := runtime.Caller(2) + filename = strings.TrimPrefix(filename, callerPrefix) + + go func() { + stderr := strings.Builder{} + err := NewCommand(ctx, "cat-file", "--batch-check"). + SetDescription(fmt.Sprintf("%s cat-file --batch-check [repo_path: %s] (%s:%d)", GitExecutable, repoPath, filename, line)). + Run(&RunOpts{ + Dir: repoPath, + Stdin: batchStdinReader, + Stdout: batchStdoutWriter, + Stderr: &stderr, + + UseContextTimeout: true, + }) + if err != nil { + _ = batchStdoutWriter.CloseWithError(ConcatenateError(err, (&stderr).String())) + _ = batchStdinReader.CloseWithError(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() + }() + + _, filename, line, _ := runtime.Caller(2) + filename = strings.TrimPrefix(filename, callerPrefix) + + go func() { + stderr := strings.Builder{} + err := NewCommand(ctx, "cat-file", "--batch"). + SetDescription(fmt.Sprintf("%s cat-file --batch [repo_path: %s] (%s:%d)", GitExecutable, repoPath, filename, line)). + Run(&RunOpts{ + Dir: repoPath, + Stdin: batchStdinReader, + Stdout: batchStdoutWriter, + Stderr: &stderr, + + UseContextTimeout: true, + }) + if err != nil { + _ = batchStdoutWriter.CloseWithError(ConcatenateError(err, (&stderr).String())) + _ = batchStdinReader.CloseWithError(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: +// <sha> SP <type> SP <size> LF +// sha is a hex encoded here +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 +} + +// ParseTreeLine 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 ParseTreeLine(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 ParseTreeLine: %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 +} + +var callerPrefix string + +func init() { + _, filename, _, _ := runtime.Caller(0) + callerPrefix = strings.TrimSuffix(filename, "modules/git/batch_reader.go") +} + +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 +} diff --git a/modules/git/blame.go b/modules/git/blame.go new file mode 100644 index 0000000..69e1b08 --- /dev/null +++ b/modules/git/blame.go @@ -0,0 +1,211 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "bufio" + "bytes" + "context" + "fmt" + "io" + "os" + + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/util" +) + +// 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 +} + +func (r *BlameReader) UsesIgnoreRevs() bool { + return r.ignoreRevsFile != nil +} + +// 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() + if r.ignoreRevsFile != nil { + _ = util.Remove(*r.ignoreRevsFile) + } + 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) (*BlameReader, error) { + var ignoreRevsFile *string + if CheckGitVersionAtLeast("2.23") == nil && !bypassBlameIgnore { + ignoreRevsFile = tryCreateBlameIgnoreRevsFile(commit) + } + + cmd := NewCommandContextNoGlobals(ctx, "blame", "--porcelain") + if ignoreRevsFile != nil { + // 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", *ignoreRevsFile) + } + cmd.AddDynamicArguments(commit.ID.String()). + AddDashesAndList(file). + SetDescription(fmt.Sprintf("GetBlame [repo_path: %s]", repoPath)) + reader, stdout, err := os.Pipe() + if err != nil { + if ignoreRevsFile != nil { + _ = util.Remove(*ignoreRevsFile) + } + return nil, err + } + + done := make(chan error, 1) + + 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(&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: ignoreRevsFile, + objectFormat: objectFormat, + }, nil +} + +func tryCreateBlameIgnoreRevsFile(commit *Commit) *string { + entry, err := commit.GetTreeEntryByPath(".git-blame-ignore-revs") + if err != nil { + return nil + } + + r, err := entry.Blob().DataAsync() + if err != nil { + return nil + } + defer r.Close() + + f, err := os.CreateTemp("", "gitea_git-blame-ignore-revs") + if err != nil { + return nil + } + + _, err = io.Copy(f, r) + _ = f.Close() + if err != nil { + _ = util.Remove(f.Name()) + return nil + } + + return util.ToPointer(f.Name()) +} diff --git a/modules/git/blame_sha256_test.go b/modules/git/blame_sha256_test.go new file mode 100644 index 0000000..eeeeb9f --- /dev/null +++ b/modules/git/blame_sha256_test.go @@ -0,0 +1,148 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReadingBlameOutputSha256(t *testing.T) { + skipIfSHA256NotSupported(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + t.Run("Without .git-blame-ignore-revs", func(t *testing.T) { + repo, err := OpenRepository(ctx, "./tests/repos/repo5_pulls_sha256") + require.NoError(t, err) + defer repo.Close() + + commit, err := repo.GetCommit("0b69b7bb649b5d46e14cabb6468685e5dd721290acc7ffe604d37cde57927345") + require.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) + require.NoError(t, err) + assert.NotNil(t, blameReader) + defer blameReader.Close() + + assert.False(t, blameReader.UsesIgnoreRevs()) + + for _, part := range parts { + actualPart, err := blameReader.NextPart() + require.NoError(t, err) + assert.Equal(t, part, actualPart) + } + + // make sure all parts have been read + actualPart, err := blameReader.NextPart() + assert.Nil(t, actualPart) + require.NoError(t, err) + } + }) + + t.Run("With .git-blame-ignore-revs", func(t *testing.T) { + repo, err := OpenRepository(ctx, "./tests/repos/repo6_blame_sha256") + require.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() + require.NoError(t, err) + for _, c := range cases { + commit, err := repo.GetCommit(c.CommitID) + require.NoError(t, err) + blameReader, err := CreateBlameReader(ctx, objectFormat, "./tests/repos/repo6_blame_sha256", commit, "blame.txt", c.Bypass) + require.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() + require.NoError(t, err) + assert.Equal(t, part, actualPart) + } + + // make sure all parts have been read + actualPart, err := blameReader.NextPart() + assert.Nil(t, actualPart) + require.NoError(t, err) + } + }) +} diff --git a/modules/git/blame_test.go b/modules/git/blame_test.go new file mode 100644 index 0000000..65320c7 --- /dev/null +++ b/modules/git/blame_test.go @@ -0,0 +1,147 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReadingBlameOutput(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + t.Run("Without .git-blame-ignore-revs", func(t *testing.T) { + repo, err := OpenRepository(ctx, "./tests/repos/repo5_pulls") + require.NoError(t, err) + defer repo.Close() + + commit, err := repo.GetCommit("f32b0a9dfd09a60f616f29158f772cedd89942d2") + require.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) + require.NoError(t, err) + assert.NotNil(t, blameReader) + defer blameReader.Close() + + assert.False(t, blameReader.UsesIgnoreRevs()) + + for _, part := range parts { + actualPart, err := blameReader.NextPart() + require.NoError(t, err) + assert.Equal(t, part, actualPart) + } + + // make sure all parts have been read + actualPart, err := blameReader.NextPart() + assert.Nil(t, actualPart) + require.NoError(t, err) + } + }) + + t.Run("With .git-blame-ignore-revs", func(t *testing.T) { + repo, err := OpenRepository(ctx, "./tests/repos/repo6_blame") + require.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() + require.NoError(t, err) + for _, c := range cases { + commit, err := repo.GetCommit(c.CommitID) + require.NoError(t, err) + + blameReader, err := CreateBlameReader(ctx, objectFormat, "./tests/repos/repo6_blame", commit, "blame.txt", c.Bypass) + require.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() + require.NoError(t, err) + assert.Equal(t, part, actualPart) + } + + // make sure all parts have been read + actualPart, err := blameReader.NextPart() + assert.Nil(t, actualPart) + require.NoError(t, err) + } + }) +} diff --git a/modules/git/blob.go b/modules/git/blob.go new file mode 100644 index 0000000..2f02693 --- /dev/null +++ b/modules/git/blob.go @@ -0,0 +1,228 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "bufio" + "bytes" + "encoding/base64" + "io" + + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/typesniffer" + "code.gitea.io/gitea/modules/util" +) + +// Blob represents a Git object. +type Blob struct { + ID ObjectID + + gotSize bool + size int64 + name string + repo *Repository +} + +// DataAsync gets a ReadCloser for the contents of a blob without reading it all. +// Calling the Close function on the result will discard all unread output. +func (b *Blob) DataAsync() (io.ReadCloser, error) { + wr, rd, cancel, err := b.repo.CatFileBatch(b.repo.Ctx) + if err != nil { + return nil, err + } + + _, err = wr.Write([]byte(b.ID.String() + "\n")) + 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 + } + + return &blobReader{ + rd: rd, + n: size, + cancel: cancel, + }, nil +} + +// Size returns the uncompressed size of the blob +func (b *Blob) Size() int64 { + if b.gotSize { + return b.size + } + + wr, rd, cancel, err := b.repo.CatFileBatchCheck(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")) + 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 + + return b.size +} + +type blobReader struct { + rd *bufio.Reader + n int64 + cancel func() +} + +func (b *blobReader) Read(p []byte) (n int, err error) { + if b.n <= 0 { + return 0, io.EOF + } + if int64(len(p)) > b.n { + p = p[0:b.n] + } + n, err = b.rd.Read(p) + b.n -= int64(n) + return n, err +} + +// Close implements io.Closer +func (b *blobReader) Close() error { + if b.rd == nil { + return nil + } + + defer b.cancel() + + if err := DiscardFull(b.rd, b.n+1); err != nil { + return err + } + + b.rd = nil + + return nil +} + +// Name returns name of the tree entry this blob object was created from (or empty string) +func (b *Blob) Name() string { + return b.name +} + +// GetBlobContent Gets the limited content of the blob as raw text +func (b *Blob) GetBlobContent(limit int64) (string, error) { + if limit <= 0 { + return "", nil + } + dataRc, err := b.DataAsync() + if err != nil { + return "", err + } + defer dataRc.Close() + buf, err := util.ReadWithLimit(dataRc, int(limit)) + return string(buf), err +} + +// GetBlobLineCount gets line count of the blob +func (b *Blob) GetBlobLineCount() (int, error) { + reader, err := b.DataAsync() + if err != nil { + return 0, err + } + defer reader.Close() + buf := make([]byte, 32*1024) + count := 1 + lineSep := []byte{'\n'} + + c, err := reader.Read(buf) + if c == 0 && err == io.EOF { + return 0, nil + } + for { + count += bytes.Count(buf[:c], lineSep) + switch { + case err == io.EOF: + return count, nil + case err != nil: + return count, err + } + c, err = reader.Read(buf) + } +} + +// GetBlobContentBase64 Reads the content of the blob with a base64 encode and returns the encoded string +func (b *Blob) GetBlobContentBase64() (string, error) { + dataRc, err := b.DataAsync() + if err != nil { + return "", err + } + defer dataRc.Close() + + pr, pw := io.Pipe() + encoder := base64.NewEncoder(base64.StdEncoding, pw) + + go func() { + _, err := io.Copy(encoder, dataRc) + _ = encoder.Close() + + if err != nil { + _ = pw.CloseWithError(err) + } else { + _ = pw.Close() + } + }() + + out, err := io.ReadAll(pr) + if err != nil { + return "", err + } + return string(out), nil +} + +// GuessContentType guesses the content type of the blob. +func (b *Blob) GuessContentType() (typesniffer.SniffedType, error) { + r, err := b.DataAsync() + if err != nil { + return typesniffer.SniffedType{}, err + } + defer r.Close() + + return typesniffer.DetectContentTypeFromReader(r) +} + +// GetBlob finds the blob object in the repository. +func (repo *Repository) GetBlob(idStr string) (*Blob, error) { + id, err := NewIDFromString(idStr) + if err != nil { + return nil, err + } + if id.IsZero() { + return nil, ErrNotExist{id.String(), ""} + } + return &Blob{ + ID: id, + repo: repo, + }, nil +} diff --git a/modules/git/blob_test.go b/modules/git/blob_test.go new file mode 100644 index 0000000..810964b --- /dev/null +++ b/modules/git/blob_test.go @@ -0,0 +1,59 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "io" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBlob_Data(t *testing.T) { + output := "file2\n" + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") + repo, err := openRepositoryWithDefaultContext(bareRepo1Path) + require.NoError(t, err) + + defer repo.Close() + + testBlob, err := repo.GetBlob("6c493ff740f9380390d5c9ddef4af18697ac9375") + require.NoError(t, err) + + r, err := testBlob.DataAsync() + require.NoError(t, err) + require.NotNil(t, r) + + data, err := io.ReadAll(r) + require.NoError(t, r.Close()) + + require.NoError(t, err) + assert.Equal(t, output, string(data)) +} + +func Benchmark_Blob_Data(b *testing.B) { + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") + repo, err := openRepositoryWithDefaultContext(bareRepo1Path) + if err != nil { + b.Fatal(err) + } + defer repo.Close() + + testBlob, err := repo.GetBlob("6c493ff740f9380390d5c9ddef4af18697ac9375") + if err != nil { + b.Fatal(err) + } + + for i := 0; i < b.N; i++ { + r, err := testBlob.DataAsync() + if err != nil { + b.Fatal(err) + } + io.ReadAll(r) + _ = r.Close() + } +} diff --git a/modules/git/command.go b/modules/git/command.go new file mode 100644 index 0000000..a3d43aa --- /dev/null +++ b/modules/git/command.go @@ -0,0 +1,473 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Copyright 2016 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "runtime" + "strings" + "time" + + "code.gitea.io/gitea/modules/git/internal" //nolint:depguard // only this file can use the internal type CmdArg, other files and packages should use AddXxx functions + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/process" + "code.gitea.io/gitea/modules/util" +) + +// TrustedCmdArgs returns the trusted arguments for git command. +// It's mainly for passing user-provided and trusted arguments to git command +// In most cases, it shouldn't be used. Use AddXxx function instead +type TrustedCmdArgs []internal.CmdArg + +var ( + // globalCommandArgs global command args for external package setting + globalCommandArgs TrustedCmdArgs + + // defaultCommandExecutionTimeout default command execution timeout duration + defaultCommandExecutionTimeout = 360 * time.Second +) + +// 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 { + prog string + args []string + parentContext context.Context + desc string + globalArgsLength int + brokenArgs []string +} + +func (c *Command) String() string { + return c.toString(false) +} + +func (c *Command) toString(sanitizing bool) string { + // WARNING: this function is for debugging purposes only. It's much better than old code (which only joins args with space), + // It's impossible to make a simple and 100% correct implementation of argument quoting for different platforms. + debugQuote := func(s string) string { + if strings.ContainsAny(s, " `'\"\t\r\n") { + return fmt.Sprintf("%q", s) + } + return s + } + a := make([]string, 0, len(c.args)+1) + a = append(a, debugQuote(c.prog)) + for _, arg := range c.args { + if sanitizing && (strings.Contains(arg, "://") && strings.Contains(arg, "@")) { + a = append(a, debugQuote(util.SanitizeCredentialURLs(arg))) + } else { + a = append(a, debugQuote(arg)) + } + } + return strings.Join(a, " ") +} + +// NewCommand creates and returns a new Git Command based on given command and arguments. +// Each argument should be safe to be trusted. User-provided arguments should be passed to AddDynamicArguments instead. +func NewCommand(ctx context.Context, args ...internal.CmdArg) *Command { + // Make an explicit copy of globalCommandArgs, otherwise append might overwrite it + cargs := make([]string, 0, len(globalCommandArgs)+len(args)) + for _, arg := range globalCommandArgs { + cargs = append(cargs, string(arg)) + } + for _, arg := range args { + cargs = append(cargs, string(arg)) + } + return &Command{ + prog: GitExecutable, + args: cargs, + parentContext: ctx, + globalArgsLength: len(globalCommandArgs), + } +} + +// NewCommandContextNoGlobals creates and returns a new Git Command based on given command and arguments only with the specify args and don't care global command args +// Each argument should be safe to be trusted. User-provided arguments should be passed to AddDynamicArguments instead. +func NewCommandContextNoGlobals(ctx context.Context, args ...internal.CmdArg) *Command { + cargs := make([]string, 0, len(args)) + for _, arg := range args { + cargs = append(cargs, string(arg)) + } + return &Command{ + prog: GitExecutable, + args: cargs, + parentContext: ctx, + } +} + +// SetParentContext sets the parent context for this command +func (c *Command) SetParentContext(ctx context.Context) *Command { + c.parentContext = ctx + return c +} + +// SetDescription sets the description for this command which be returned on c.String() +func (c *Command) SetDescription(desc string) *Command { + c.desc = desc + return c +} + +// isSafeArgumentValue checks if the argument is safe to be used as a value (not an option) +func isSafeArgumentValue(s string) bool { + return s == "" || s[0] != '-' +} + +// isValidArgumentOption checks if the argument is a valid option (starting with '-'). +// It doesn't check whether the option is supported or not +func isValidArgumentOption(s string) bool { + return s != "" && s[0] == '-' +} + +// AddArguments adds new git arguments (option/value) to the command. It only accepts string literals, or trusted CmdArg. +// Type CmdArg is in the internal package, so it can not be used outside of this package directly, +// it makes sure that user-provided arguments won't cause RCE risks. +// User-provided arguments should be passed by other AddXxx functions +func (c *Command) AddArguments(args ...internal.CmdArg) *Command { + for _, arg := range args { + c.args = append(c.args, string(arg)) + } + return c +} + +// AddOptionValues adds a new option with a list of non-option values +// For example: AddOptionValues("--opt", val) means 2 arguments: {"--opt", val}. +// The values are treated as dynamic argument values. It equals to: AddArguments("--opt") then AddDynamicArguments(val). +func (c *Command) AddOptionValues(opt internal.CmdArg, args ...string) *Command { + if !isValidArgumentOption(string(opt)) { + c.brokenArgs = append(c.brokenArgs, string(opt)) + return c + } + c.args = append(c.args, string(opt)) + c.AddDynamicArguments(args...) + return c +} + +// AddGitGrepExpression adds an expression option (-e) to git-grep command +// It is different from AddOptionValues in that it allows the actual expression +// to not be filtered out for leading dashes (which is otherwise a security feature +// of AddOptionValues). +func (c *Command) AddGitGrepExpression(exp string) *Command { + if c.args[len(globalCommandArgs)] != "grep" { + panic("function called on a non-grep git program: " + c.args[0]) + } + c.args = append(c.args, "-e", exp) + return c +} + +// AddOptionFormat adds a new option with a format string and arguments +// For example: AddOptionFormat("--opt=%s %s", val1, val2) means 1 argument: {"--opt=val1 val2"}. +func (c *Command) AddOptionFormat(opt string, args ...any) *Command { + if !isValidArgumentOption(opt) { + c.brokenArgs = append(c.brokenArgs, 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) + return c + } + s := fmt.Sprintf(opt, args...) + c.args = append(c.args, s) + return c +} + +// AddDynamicArguments adds new dynamic argument values to the command. +// The arguments may come from user input and can not be trusted, so no leading '-' is allowed to avoid passing options. +// TODO: in the future, this function can be renamed to AddArgumentValues +func (c *Command) AddDynamicArguments(args ...string) *Command { + for _, arg := range args { + if !isSafeArgumentValue(arg) { + c.brokenArgs = append(c.brokenArgs, arg) + } + } + if len(c.brokenArgs) != 0 { + return c + } + c.args = append(c.args, args...) + return c +} + +// AddDashesAndList adds the "--" and then add the list as arguments, it's usually for adding file list +// At the moment, this function can be only called once, maybe in future it can be refactored to support multiple calls (if necessary) +func (c *Command) AddDashesAndList(list ...string) *Command { + c.args = append(c.args, "--") + // Some old code also checks `arg != ""`, IMO it's not necessary. + // If the check is needed, the list should be prepared before the call to this function + c.args = append(c.args, list...) + return c +} + +// ToTrustedCmdArgs converts a list of strings (trusted as argument) to TrustedCmdArgs +// In most cases, it shouldn't be used. Use NewCommand().AddXxx() function instead +func ToTrustedCmdArgs(args []string) TrustedCmdArgs { + ret := make(TrustedCmdArgs, len(args)) + for i, arg := range args { + ret[i] = internal.CmdArg(arg) + } + return ret +} + +// 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 + + // Dir is the working dir for the git command, however: + // FIXME: this could be incorrect in many cases, for example: + // * /some/path/.git + // * /some/path/.git/gitea-data/data/repositories/user/repo.git + // If "user/repo.git" is invalid/broken, then running git command in it will use "/some/path/.git", and produce unexpected results + // The correct approach is to use `--git-dir" global argument + Dir string + + 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 +} + +func commonBaseEnvs() []string { + // at the moment, do not set "GIT_CONFIG_NOSYSTEM", users may have put some configs like "receive.certNonceSeed" in it + envs := []string{ + "HOME=" + HomeDir(), // make Gitea use internal git config only, to prevent conflicts with user's git config + "GIT_NO_REPLACE_OBJECTS=1", // ignore replace references (https://git-scm.com/docs/git-replace) + } + + // some environment variables should be passed to git command + passThroughEnvKeys := []string{ + "GNUPGHOME", // git may call gnupg to do commit signing + } + for _, key := range passThroughEnvKeys { + if val, ok := os.LookupEnv(key); ok { + envs = append(envs, key+"="+val) + } + } + return envs +} + +// CommonGitCmdEnvs returns the common environment variables for a "git" command. +func CommonGitCmdEnvs() []string { + return append(commonBaseEnvs(), []string{ + "LC_ALL=" + DefaultLocale, + "GIT_TERMINAL_PROMPT=0", // avoid prompting for credentials interactively, supported since git v2.3 + }...) +} + +// CommonCmdServEnvs is like CommonGitCmdEnvs, but it only returns minimal required environment variables for the "gitea serv" command +func CommonCmdServEnvs() []string { + return commonBaseEnvs() +} + +var ErrBrokenCommand = errors.New("git command is broken") + +// Run runs the command with the RunOpts +func (c *Command) Run(opts *RunOpts) error { + if len(c.brokenArgs) != 0 { + log.Error("git command is broken: %s, broken args: %s", c.String(), strings.Join(c.brokenArgs, " ")) + return ErrBrokenCommand + } + if opts == nil { + opts = &RunOpts{} + } + + // We must not change the provided options + timeout := opts.Timeout + if timeout <= 0 { + timeout = defaultCommandExecutionTimeout + } + + if len(opts.Dir) == 0 { + log.Debug("git.Command.Run: %s", c) + } else { + log.Debug("git.Command.RunDir(%s): %s", opts.Dir, c) + } + + desc := c.desc + if desc == "" { + if opts.Dir == "" { + desc = fmt.Sprintf("git: %s", c.toString(true)) + } else { + desc = fmt.Sprintf("git(dir:%s): %s", opts.Dir, c.toString(true)) + } + } + + var ctx context.Context + var cancel context.CancelFunc + var finished context.CancelFunc + + if opts.UseContextTimeout { + ctx, cancel, finished = process.GetManager().AddContext(c.parentContext, desc) + } else { + ctx, cancel, finished = process.GetManager().AddContextTimeout(c.parentContext, timeout, desc) + } + defer finished() + + startTime := time.Now() + + cmd := exec.CommandContext(ctx, c.prog, c.args...) + if opts.Env == nil { + cmd.Env = os.Environ() + } else { + cmd.Env = 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 + } + + if opts.PipelineFunc != nil { + err := opts.PipelineFunc(ctx, cancel) + if err != nil { + cancel() + _ = cmd.Wait() + return err + } + } + + err := cmd.Wait() + elapsed := time.Since(startTime) + if elapsed > time.Second { + log.Debug("slow git.Command.Run: %s (%s)", c, elapsed) + } + + // 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. + if runtime.GOOS == "windows" && + err != nil && + err.Error() == "" && + cmd.ProcessState.ExitCode() == 1 && + ctx.Err() == context.Canceled { + return ctx.Err() + } + + if err != nil && ctx.Err() != context.DeadlineExceeded { + return err + } + + return ctx.Err() +} + +type RunStdError interface { + error + Unwrap() error + Stderr() string +} + +type runStdError struct { + err error + stderr string + errMsg string +} + +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(opts *RunOpts) (stdout, stderr string, runErr RunStdError) { + stdoutBytes, stderrBytes, err := c.RunStdBytes(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(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 + 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(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 +} + +// AllowLFSFiltersArgs return globalCommandArgs with lfs filter, it should only be used for tests +func AllowLFSFiltersArgs() TrustedCmdArgs { + // Now here we should explicitly allow lfs filters to run + filteredLFSGlobalArgs := make(TrustedCmdArgs, len(globalCommandArgs)) + j := 0 + for _, arg := range globalCommandArgs { + if strings.Contains(string(arg), "lfs") { + j-- + } else { + filteredLFSGlobalArgs[j] = arg + j++ + } + } + return filteredLFSGlobalArgs[:j] +} diff --git a/modules/git/command_race_test.go b/modules/git/command_race_test.go new file mode 100644 index 0000000..f567406 --- /dev/null +++ b/modules/git/command_race_test.go @@ -0,0 +1,38 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build race + +package git + +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(context.Background(), "--version") + for i := 0; i < maxLoops; i++ { + if err := cmd.Run(&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(context.Background(), "hash-object", "--stdin") + for i := 0; i < maxLoops; i++ { + if err := cmd.Run(&RunOpts{Timeout: 1 * time.Millisecond}); err != nil { + if err != context.DeadlineExceeded { + t.Fatalf("Testing %d/%d: %v", i, maxLoops, err) + } + } + } +} diff --git a/modules/git/command_test.go b/modules/git/command_test.go new file mode 100644 index 0000000..d3b8338 --- /dev/null +++ b/modules/git/command_test.go @@ -0,0 +1,70 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRunWithContextStd(t *testing.T) { + cmd := NewCommand(context.Background(), "--version") + stdout, stderr, err := cmd.RunStdString(&RunOpts{}) + require.NoError(t, err) + assert.Empty(t, stderr) + assert.Contains(t, stdout, "git version") + + cmd = NewCommand(context.Background(), "--no-such-arg") + stdout, stderr, err = cmd.RunStdString(&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(context.Background()) + cmd.AddDynamicArguments("-test") + require.ErrorIs(t, cmd.Run(&RunOpts{}), ErrBrokenCommand) + + cmd = NewCommand(context.Background()) + cmd.AddDynamicArguments("--test") + require.ErrorIs(t, cmd.Run(&RunOpts{}), ErrBrokenCommand) + + subCmd := "version" + cmd = NewCommand(context.Background()).AddDynamicArguments(subCmd) // for test purpose only, the sub-command should never be dynamic for production + stdout, stderr, err = cmd.RunStdString(&RunOpts{}) + require.NoError(t, err) + assert.Empty(t, stderr) + assert.Contains(t, stdout, "git version") +} + +func TestGitArgument(t *testing.T) { + assert.True(t, isValidArgumentOption("-x")) + assert.True(t, isValidArgumentOption("--xx")) + assert.False(t, isValidArgumentOption("")) + assert.False(t, isValidArgumentOption("x")) + + assert.True(t, isSafeArgumentValue("")) + assert.True(t, isSafeArgumentValue("x")) + assert.False(t, isSafeArgumentValue("-x")) +} + +func TestCommandString(t *testing.T) { + cmd := NewCommandContextNoGlobals(context.Background(), "a", "-m msg", "it's a test", `say "hello"`) + assert.EqualValues(t, cmd.prog+` a "-m msg" "it's a test" "say \"hello\""`, cmd.String()) + + cmd = NewCommandContextNoGlobals(context.Background(), "url: https://a:b@c/") + assert.EqualValues(t, cmd.prog+` "url: https://sanitized-credential@c/"`, cmd.toString(true)) +} + +func TestGrepOnlyFunction(t *testing.T) { + cmd := NewCommand(context.Background(), "anything-but-grep") + assert.Panics(t, func() { + cmd.AddGitGrepExpression("whatever") + }) +} diff --git a/modules/git/commit.go b/modules/git/commit.go new file mode 100644 index 0000000..b5ae2e0 --- /dev/null +++ b/modules/git/commit.go @@ -0,0 +1,590 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "os/exec" + "strconv" + "strings" + + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/util" +) + +// Commit represents a git commit. +type Commit struct { + Tree + ID ObjectID // The ID of this commit object + Author *Signature + Committer *Signature + CommitMessage string + Signature *ObjectSignature + + Parents []ObjectID // ID strings + submoduleCache *ObjectCache +} + +// Message returns the commit message. Same as retrieving CommitMessage directly. +func (c *Commit) Message() string { + return c.CommitMessage +} + +// Summary returns first line of commit message. +// The string is forced to be valid UTF8 +func (c *Commit) Summary() string { + return strings.ToValidUTF8(strings.Split(strings.TrimSpace(c.CommitMessage), "\n")[0], "?") +} + +// ParentID returns oid of n-th parent (0-based index). +// It returns nil if no such parent exists. +func (c *Commit) ParentID(n int) (ObjectID, error) { + if n >= len(c.Parents) { + return nil, ErrNotExist{"", ""} + } + return c.Parents[n], nil +} + +// Parent returns n-th parent (0-based index) of the commit. +func (c *Commit) Parent(n int) (*Commit, error) { + id, err := c.ParentID(n) + if err != nil { + return nil, err + } + parent, err := c.repo.getCommit(id) + if err != nil { + return nil, err + } + return parent, nil +} + +// ParentCount returns number of parents of the commit. +// 0 if this is the root commit, otherwise 1,2, etc. +func (c *Commit) ParentCount() int { + return len(c.Parents) +} + +// GetCommitByPath return the commit of relative path object. +func (c *Commit) GetCommitByPath(relpath string) (*Commit, error) { + if c.repo.LastCommitCache != nil { + return c.repo.LastCommitCache.GetCommitByPath(c.ID.String(), relpath) + } + return c.repo.getCommitByPathWithID(c.ID, relpath) +} + +// AddChanges marks local changes to be ready for commit. +func AddChanges(repoPath string, all bool, files ...string) error { + return AddChangesWithArgs(repoPath, globalCommandArgs, all, files...) +} + +// AddChangesWithArgs marks local changes to be ready for commit. +func AddChangesWithArgs(repoPath string, globalArgs TrustedCmdArgs, all bool, files ...string) error { + cmd := NewCommandContextNoGlobals(DefaultContext, globalArgs...).AddArguments("add") + if all { + cmd.AddArguments("--all") + } + cmd.AddDashesAndList(files...) + _, _, err := cmd.RunStdString(&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(repoPath string, opts CommitChangesOptions) error { + cargs := make(TrustedCmdArgs, len(globalCommandArgs)) + copy(cargs, globalCommandArgs) + return CommitChangesWithArgs(repoPath, cargs, opts) +} + +// CommitChangesWithArgs commits local changes with given committer, author and message. +// If author is nil, it will be the same as committer. +func CommitChangesWithArgs(repoPath string, args TrustedCmdArgs, opts CommitChangesOptions) error { + cmd := NewCommandContextNoGlobals(DefaultContext, args...) + 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(&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 := NewCommand(ctx, "rev-list") + if hidePRRefs { + cmd.AddArguments("--exclude=" + PullPrefix + "*") + } + cmd.AddArguments("--all", "--count") + if len(files) > 0 { + cmd.AddDashesAndList(files...) + } + + stdout, _, err := cmd.RunStdString(&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 +} + +// CommitsCount returns number of total commits of until given revision. +func CommitsCount(ctx context.Context, opts CommitsCountOptions) (int64, error) { + cmd := NewCommand(ctx, "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(&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 string) ([]*Commit, error) { + return c.repo.commitsByRange(c.ID, page, pageSize, not) +} + +// CommitsBefore returns all the commits before current revision +func (c *Commit) CommitsBefore() ([]*Commit, error) { + return c.repo.getCommitsBefore(c.ID) +} + +// HasPreviousCommit returns true if a given commitHash is contained in commit's parents +func (c *Commit) HasPreviousCommit(objectID ObjectID) (bool, error) { + this := c.ID.String() + that := objectID.String() + + if this == that { + return false, nil + } + + _, _, err := NewCommand(c.repo.Ctx, "merge-base", "--is-ancestor").AddDynamicArguments(that, this).RunStdString(&RunOpts{Dir: c.repo.Path}) + if err == nil { + return true, nil + } + var exitError *exec.ExitError + if errors.As(err, &exitError) { + if exitError.ProcessState.ExitCode() == 1 && len(exitError.Stderr) == 0 { + return false, nil + } + } + return false, err +} + +// IsForcePush returns true if a push from oldCommitHash to this is a force push +func (c *Commit) IsForcePush(oldCommitID string) (bool, error) { + objectFormat, err := c.repo.GetObjectFormat() + if err != nil { + return false, err + } + if oldCommitID == objectFormat.EmptyObjectID().String() { + return false, nil + } + + oldCommit, err := c.repo.GetCommit(oldCommitID) + if err != nil { + return false, err + } + hasPreviousCommit, err := c.HasPreviousCommit(oldCommit.ID) + return !hasPreviousCommit, err +} + +// CommitsBeforeLimit returns num commits before current revision +func (c *Commit) CommitsBeforeLimit(num int) ([]*Commit, error) { + return c.repo.getCommitsBeforeLimit(c.ID, num) +} + +// CommitsBeforeUntil returns the commits between commitID to current revision +func (c *Commit) CommitsBeforeUntil(commitID string) ([]*Commit, error) { + endCommit, err := c.repo.GetCommit(commitID) + if err != nil { + return nil, err + } + return c.repo.CommitsBetween(c, endCommit) +} + +// SearchCommitsOptions specify the parameters for SearchCommits +type SearchCommitsOptions struct { + Keywords []string + Authors, Committers []string + After, Before string + All bool +} + +// NewSearchCommitsOptions construct a SearchCommitsOption from a space-delimited search string +func NewSearchCommitsOptions(searchString string, forAllRefs bool) SearchCommitsOptions { + var keywords, authors, committers []string + var after, before string + + fields := strings.Fields(searchString) + for _, k := range fields { + switch { + case strings.HasPrefix(k, "author:"): + authors = append(authors, strings.TrimPrefix(k, "author:")) + case strings.HasPrefix(k, "committer:"): + committers = append(committers, strings.TrimPrefix(k, "committer:")) + case strings.HasPrefix(k, "after:"): + after = strings.TrimPrefix(k, "after:") + case strings.HasPrefix(k, "before:"): + before = strings.TrimPrefix(k, "before:") + default: + keywords = append(keywords, k) + } + } + + return SearchCommitsOptions{ + Keywords: keywords, + Authors: authors, + Committers: committers, + After: after, + Before: before, + All: forAllRefs, + } +} + +// SearchCommits returns the commits match the keyword before current revision +func (c *Commit) SearchCommits(opts SearchCommitsOptions) ([]*Commit, error) { + return c.repo.searchCommits(c.ID, opts) +} + +// GetFilesChangedSinceCommit get all changed file names between pastCommit to current revision +func (c *Commit) GetFilesChangedSinceCommit(pastCommit string) ([]string, error) { + return c.repo.GetFilesChangedBetween(pastCommit, c.ID.String()) +} + +// FileChangedSinceCommit Returns true if the file given has changed since the past commit +// YOU MUST ENSURE THAT pastCommit is a valid commit ID. +func (c *Commit) FileChangedSinceCommit(filename, pastCommit string) (bool, error) { + return c.repo.FileChangedBetweenCommits(filename, pastCommit, c.ID.String()) +} + +// HasFile returns true if the file given exists on this commit +// This does only mean it's there - it does not mean the file was changed during the commit. +func (c *Commit) HasFile(filename string) (bool, error) { + _, err := c.GetBlobByPath(filename) + if err != nil { + return false, err + } + return true, nil +} + +// GetFileContent reads a file content as a string or returns false if this was not possible +func (c *Commit) GetFileContent(filename string, limit int) (string, error) { + entry, err := c.GetTreeEntryByPath(filename) + if err != nil { + return "", err + } + + r, err := entry.Blob().DataAsync() + if err != nil { + return "", err + } + defer r.Close() + + if limit > 0 { + bs := make([]byte, limit) + n, err := util.ReadAtMost(r, bs) + if err != nil { + return "", err + } + return string(bs[:n]), nil + } + + bytes, err := io.ReadAll(r) + if err != nil { + return "", err + } + return string(bytes), nil +} + +// GetSubModules get all the sub modules of current revision git tree +func (c *Commit) GetSubModules() (*ObjectCache, error) { + if c.submoduleCache != nil { + return c.submoduleCache, nil + } + + entry, err := c.GetTreeEntryByPath(".gitmodules") + if err != nil { + if _, ok := err.(ErrNotExist); ok { + return nil, nil + } + return nil, err + } + + rd, err := entry.Blob().DataAsync() + if err != nil { + return nil, err + } + + defer rd.Close() + scanner := bufio.NewScanner(rd) + c.submoduleCache = newObjectCache() + var ismodule bool + var path string + for scanner.Scan() { + if strings.HasPrefix(scanner.Text(), "[submodule") { + ismodule = true + continue + } + if ismodule { + fields := strings.Split(scanner.Text(), "=") + k := strings.TrimSpace(fields[0]) + if k == "path" { + path = strings.TrimSpace(fields[1]) + } else if k == "url" { + c.submoduleCache.Set(path, &SubModule{path, strings.TrimSpace(fields[1])}) + ismodule = false + } + } + } + if err = scanner.Err(); err != nil { + return nil, fmt.Errorf("GetSubModules scan: %w", err) + } + + return c.submoduleCache, nil +} + +// GetSubModule get the sub module according entryname +func (c *Commit) GetSubModule(entryname string) (*SubModule, error) { + modules, err := c.GetSubModules() + if err != nil { + return nil, err + } + + if modules != nil { + module, has := modules.Get(entryname) + if has { + return module.(*SubModule), nil + } + } + return nil, nil +} + +// GetBranchName gets the closest branch name (as returned by 'git name-rev --name-only') +func (c *Commit) GetBranchName() (string, error) { + cmd := NewCommand(c.repo.Ctx, "name-rev") + if CheckGitVersionAtLeast("2.13.0") == nil { + cmd.AddArguments("--exclude", "refs/tags/*") + } + cmd.AddArguments("--name-only", "--no-undefined").AddDynamicArguments(c.ID.String()) + data, _, err := cmd.RunStdString(&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 := NewCommand(ctx, "log", "--name-status", "-m", "--pretty=format:", "--first-parent", "--no-renames", "-z", "-1").AddDynamicArguments(commitID).Run(&RunOpts{ + Dir: repoPath, + Stdout: w, + Stderr: stderr, + }) + w.Close() // Close writer to exit parsing goroutine + if err != nil { + return nil, ConcatenateError(err, stderr.String()) + } + + <-done + return fileStatus, nil +} + +func parseCommitRenames(renames *[][2]string, stdout io.Reader) { + rd := bufio.NewReader(stdout) + for { + // Skip (R || three digits || NULL byte) + _, err := rd.Discard(5) + if err != nil { + if err != io.EOF { + log.Error("Unexpected error whilst reading from git log --name-status. Error: %v", err) + } + return + } + oldFileName, 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 + } + newFileName, 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 + } + oldFileName = strings.TrimSuffix(oldFileName, "\x00") + newFileName = strings.TrimSuffix(newFileName, "\x00") + *renames = append(*renames, [2]string{oldFileName, newFileName}) + } +} + +// GetCommitFileRenames returns the renames that the commit contains. +func GetCommitFileRenames(ctx context.Context, repoPath, commitID string) ([][2]string, error) { + renames := [][2]string{} + stdout, w := io.Pipe() + done := make(chan struct{}) + go func() { + parseCommitRenames(&renames, stdout) + close(done) + }() + + stderr := new(bytes.Buffer) + err := NewCommand(ctx, "show", "--name-status", "--pretty=format:", "-z", "--diff-filter=R").AddDynamicArguments(commitID).Run(&RunOpts{ + Dir: repoPath, + Stdout: w, + Stderr: stderr, + }) + w.Close() // Close writer to exit parsing goroutine + if err != nil { + return nil, ConcatenateError(err, stderr.String()) + } + + <-done + return renames, 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 := NewCommand(ctx, "rev-parse").AddDynamicArguments(shortID).RunStdString(&RunOpts{Dir: repoPath}) + if err != nil { + if strings.Contains(err.Error(), "exit status 128") { + return "", ErrNotExist{shortID, ""} + } + return "", 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) +} diff --git a/modules/git/commit_info.go b/modules/git/commit_info.go new file mode 100644 index 0000000..3b34b79 --- /dev/null +++ b/modules/git/commit_info.go @@ -0,0 +1,178 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "context" + "fmt" + "io" + "path" + "sort" + + "code.gitea.io/gitea/modules/log" +) + +// CommitInfo describes the first commit with the provided entry +type CommitInfo struct { + Entry *TreeEntry + Commit *Commit + SubModuleFile *SubModuleFile +} + +// GetCommitsInfo gets information of all commits that are corresponding to these entries +func (tes Entries) GetCommitsInfo(ctx context.Context, commit *Commit, treePath string) ([]CommitInfo, *Commit, error) { + entryPaths := make([]string, len(tes)+1) + // Get the commit for the treePath itself + entryPaths[0] = "" + for i, entry := range tes { + entryPaths[i+1] = entry.Name() + } + + var err error + + var revs map[string]*Commit + if commit.repo.LastCommitCache != nil { + var unHitPaths []string + revs, unHitPaths, err = getLastCommitForPathsByCache(commit.ID.String(), treePath, entryPaths, commit.repo.LastCommitCache) + if err != nil { + return nil, nil, err + } + if len(unHitPaths) > 0 { + sort.Strings(unHitPaths) + commits, err := GetLastCommitForPaths(ctx, commit, treePath, unHitPaths) + if err != nil { + return nil, nil, err + } + + for pth, found := range commits { + revs[pth] = found + } + } + } else { + sort.Strings(entryPaths) + revs, err = GetLastCommitForPaths(ctx, commit, treePath, entryPaths) + } + if err != nil { + return nil, nil, err + } + + commitsInfo := make([]CommitInfo, len(tes)) + for i, entry := range tes { + commitsInfo[i] = CommitInfo{ + Entry: entry, + } + + // Check if we have found a commit for this entry in time + if entryCommit, ok := revs[entry.Name()]; ok { + commitsInfo[i].Commit = entryCommit + } else { + log.Debug("missing commit for %s", entry.Name()) + } + + // If the entry if a submodule add a submodule file for this + if entry.IsSubModule() { + subModuleURL := "" + var fullPath string + if len(treePath) > 0 { + fullPath = treePath + "/" + entry.Name() + } else { + fullPath = entry.Name() + } + if subModule, err := commit.GetSubModule(fullPath); err != nil { + return nil, nil, err + } else if subModule != nil { + subModuleURL = subModule.URL + } + subModuleFile := NewSubModuleFile(commitsInfo[i].Commit, subModuleURL, entry.ID.String()) + commitsInfo[i].SubModuleFile = subModuleFile + } + } + + // Retrieve the commit for the treePath itself (see above). We basically + // get it for free during the tree traversal and it's used for listing + // pages to display information about newest commit for a given path. + var treeCommit *Commit + var ok bool + if treePath == "" { + treeCommit = commit + } else if treeCommit, ok = revs[""]; ok { + treeCommit.repo = commit.repo + } + return commitsInfo, treeCommit, nil +} + +func getLastCommitForPathsByCache(commitID, treePath string, paths []string, cache *LastCommitCache) (map[string]*Commit, []string, error) { + var unHitEntryPaths []string + results := make(map[string]*Commit) + for _, p := range paths { + lastCommit, err := cache.Get(commitID, path.Join(treePath, p)) + if err != nil { + return nil, nil, err + } + if lastCommit != nil { + results[p] = lastCommit + continue + } + + unHitEntryPaths = append(unHitEntryPaths, p) + } + + return results, unHitEntryPaths, nil +} + +// GetLastCommitForPaths returns last commit information +func GetLastCommitForPaths(ctx context.Context, commit *Commit, treePath string, paths []string) (map[string]*Commit, error) { + // We read backwards from the commit to obtain all of the commits + revs, err := WalkGitLog(ctx, commit.repo, commit, treePath, paths...) + if err != nil { + return nil, err + } + + batchStdinWriter, batchReader, cancel, err := commit.repo.CatFileBatch(ctx) + if err != nil { + return nil, err + } + defer cancel() + + commitsMap := map[string]*Commit{} + commitsMap[commit.ID.String()] = commit + + commitCommits := map[string]*Commit{} + for path, commitID := range revs { + c, ok := commitsMap[commitID] + if ok { + commitCommits[path] = c + continue + } + + if len(commitID) == 0 { + continue + } + + _, err := batchStdinWriter.Write([]byte(commitID + "\n")) + if err != nil { + return nil, err + } + _, typ, size, err := ReadBatchLine(batchReader) + if err != nil { + return nil, err + } + if typ != "commit" { + if err := DiscardFull(batchReader, size+1); err != nil { + return nil, err + } + return nil, fmt.Errorf("unexpected type: %s for commit id: %s", typ, commitID) + } + c, err = CommitFromReader(commit.repo, MustIDFromString(commitID), io.LimitReader(batchReader, size)) + if err != nil { + return nil, err + } + if _, err := batchReader.Discard(1); err != nil { + return nil, err + } + commitCommits[path] = c + } + + return commitCommits, nil +} diff --git a/modules/git/commit_info_test.go b/modules/git/commit_info_test.go new file mode 100644 index 0000000..dbe9ab5 --- /dev/null +++ b/modules/git/commit_info_test.go @@ -0,0 +1,175 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + testReposDir = "tests/repos/" +) + +func cloneRepo(tb testing.TB, url string) (string, error) { + repoDir := tb.TempDir() + if err := Clone(DefaultContext, url, repoDir, CloneRepoOptions{ + Mirror: false, + Bare: false, + Quiet: true, + Timeout: 5 * time.Minute, + }); err != nil { + return "", err + } + return repoDir, nil +} + +func testGetCommitsInfo(t *testing.T, repo1 *Repository) { + // these test case are specific to the repo1 test repo + testCases := []struct { + CommitID string + Path string + ExpectedIDs map[string]string + ExpectedTreeCommit string + }{ + {"8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2", "", map[string]string{ + "file1.txt": "95bb4d39648ee7e325106df01a621c530863a653", + "file2.txt": "8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2", + }, "8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2"}, + {"2839944139e0de9737a044f78b0e4b40d989a9e3", "", map[string]string{ + "file1.txt": "2839944139e0de9737a044f78b0e4b40d989a9e3", + "branch1.txt": "9c9aef8dd84e02bc7ec12641deb4c930a7c30185", + }, "2839944139e0de9737a044f78b0e4b40d989a9e3"}, + {"5c80b0245c1c6f8343fa418ec374b13b5d4ee658", "branch2", map[string]string{ + "branch2.txt": "5c80b0245c1c6f8343fa418ec374b13b5d4ee658", + }, "5c80b0245c1c6f8343fa418ec374b13b5d4ee658"}, + {"feaf4ba6bc635fec442f46ddd4512416ec43c2c2", "", map[string]string{ + "file1.txt": "95bb4d39648ee7e325106df01a621c530863a653", + "file2.txt": "8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2", + "foo": "37991dec2c8e592043f47155ce4808d4580f9123", + }, "feaf4ba6bc635fec442f46ddd4512416ec43c2c2"}, + } + for _, testCase := range testCases { + commit, err := repo1.GetCommit(testCase.CommitID) + if err != nil { + require.NoError(t, err, "Unable to get commit: %s from testcase due to error: %v", testCase.CommitID, err) + // no point trying to do anything else for this test. + continue + } + assert.NotNil(t, commit) + assert.NotNil(t, commit.Tree) + assert.NotNil(t, commit.Tree.repo) + + tree, err := commit.Tree.SubTree(testCase.Path) + if err != nil { + require.NoError(t, err, "Unable to get subtree: %s of commit: %s from testcase due to error: %v", testCase.Path, testCase.CommitID, err) + // no point trying to do anything else for this test. + continue + } + + assert.NotNil(t, tree, "tree is nil for testCase CommitID %s in Path %s", testCase.CommitID, testCase.Path) + assert.NotNil(t, tree.repo, "repo is nil for testCase CommitID %s in Path %s", testCase.CommitID, testCase.Path) + + entries, err := tree.ListEntries() + if err != nil { + require.NoError(t, err, "Unable to get entries of subtree: %s in commit: %s from testcase due to error: %v", testCase.Path, testCase.CommitID, err) + // no point trying to do anything else for this test. + continue + } + + // FIXME: Context.TODO() - if graceful has started we should use its Shutdown context otherwise use install signals in TestMain. + commitsInfo, treeCommit, err := entries.GetCommitsInfo(context.TODO(), commit, testCase.Path) + require.NoError(t, err, "Unable to get commit information for entries of subtree: %s in commit: %s from testcase due to error: %v", testCase.Path, testCase.CommitID, err) + if err != nil { + t.FailNow() + } + assert.Equal(t, testCase.ExpectedTreeCommit, treeCommit.ID.String()) + assert.Len(t, commitsInfo, len(testCase.ExpectedIDs)) + for _, commitInfo := range commitsInfo { + entry := commitInfo.Entry + commit := commitInfo.Commit + expectedID, ok := testCase.ExpectedIDs[entry.Name()] + if !assert.True(t, ok) { + continue + } + assert.Equal(t, expectedID, commit.ID.String()) + } + } +} + +func TestEntries_GetCommitsInfo(t *testing.T) { + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") + bareRepo1, err := openRepositoryWithDefaultContext(bareRepo1Path) + require.NoError(t, err) + defer bareRepo1.Close() + + testGetCommitsInfo(t, bareRepo1) + + clonedPath, err := cloneRepo(t, bareRepo1Path) + if err != nil { + require.NoError(t, err) + } + clonedRepo1, err := openRepositoryWithDefaultContext(clonedPath) + if err != nil { + require.NoError(t, err) + } + defer clonedRepo1.Close() + + testGetCommitsInfo(t, clonedRepo1) +} + +func BenchmarkEntries_GetCommitsInfo(b *testing.B) { + type benchmarkType struct { + url string + name string + } + + benchmarks := []benchmarkType{ + {url: "https://github.com/go-gitea/gitea.git", name: "gitea"}, + {url: "https://github.com/ethantkoenig/manyfiles.git", name: "manyfiles"}, + {url: "https://github.com/moby/moby.git", name: "moby"}, + {url: "https://github.com/golang/go.git", name: "go"}, + {url: "https://github.com/torvalds/linux.git", name: "linux"}, + } + + doBenchmark := func(benchmark benchmarkType) { + var commit *Commit + var entries Entries + var repo *Repository + repoPath, err := cloneRepo(b, benchmark.url) + if err != nil { + b.Fatal(err) + } + + if repo, err = openRepositoryWithDefaultContext(repoPath); err != nil { + b.Fatal(err) + } + defer repo.Close() + + if commit, err = repo.GetBranchCommit("master"); err != nil { + b.Fatal(err) + } else if entries, err = commit.Tree.ListEntries(); err != nil { + b.Fatal(err) + } + entries.Sort() + b.ResetTimer() + b.Run(benchmark.name, func(b *testing.B) { + for i := 0; i < b.N; i++ { + _, _, err := entries.GetCommitsInfo(context.Background(), commit, "") + if err != nil { + b.Fatal(err) + } + } + }) + } + + for _, benchmark := range benchmarks { + doBenchmark(benchmark) + } +} diff --git a/modules/git/commit_reader.go b/modules/git/commit_reader.go new file mode 100644 index 0000000..8e2523d --- /dev/null +++ b/modules/git/commit_reader.go @@ -0,0 +1,110 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "bufio" + "bytes" + "io" + "strings" +) + +// CommitFromReader will generate a Commit from a provided reader +// We need this to interpret commits from cat-file or cat-file --batch +// +// If used as part of a cat-file --batch stream you need to limit the reader to the correct size +func CommitFromReader(gitRepo *Repository, objectID ObjectID, reader io.Reader) (*Commit, error) { + commit := &Commit{ + ID: objectID, + Author: &Signature{}, + Committer: &Signature{}, + } + + payloadSB := new(strings.Builder) + signatureSB := new(strings.Builder) + messageSB := new(strings.Builder) + message := false + pgpsig := false + + bufReader, ok := reader.(*bufio.Reader) + if !ok { + bufReader = bufio.NewReader(reader) + } + +readLoop: + for { + line, err := bufReader.ReadBytes('\n') + if err != nil { + if err == io.EOF { + if message { + _, _ = messageSB.Write(line) + } + _, _ = payloadSB.Write(line) + break readLoop + } + return nil, err + } + if pgpsig { + if len(line) > 0 && line[0] == ' ' { + _, _ = signatureSB.Write(line[1:]) + continue + } + pgpsig = false + } + + if !message { + // This is probably not correct but is copied from go-gits interpretation... + trimmed := bytes.TrimSpace(line) + if len(trimmed) == 0 { + message = true + _, _ = payloadSB.Write(line) + continue + } + + split := bytes.SplitN(trimmed, []byte{' '}, 2) + var data []byte + if len(split) > 1 { + data = split[1] + } + + switch string(split[0]) { + case "tree": + commit.Tree = *NewTree(gitRepo, MustIDFromString(string(data))) + _, _ = payloadSB.Write(line) + case "parent": + commit.Parents = append(commit.Parents, MustIDFromString(string(data))) + _, _ = payloadSB.Write(line) + case "author": + commit.Author = &Signature{} + commit.Author.Decode(data) + _, _ = payloadSB.Write(line) + case "committer": + commit.Committer = &Signature{} + commit.Committer.Decode(data) + _, _ = payloadSB.Write(line) + case "encoding": + _, _ = payloadSB.Write(line) + case "gpgsig": + fallthrough + case "gpgsig-sha256": // FIXME: no intertop, so only 1 exists at present. + _, _ = signatureSB.Write(data) + _ = signatureSB.WriteByte('\n') + pgpsig = true + } + } else { + _, _ = messageSB.Write(line) + _, _ = payloadSB.Write(line) + } + } + commit.CommitMessage = messageSB.String() + commit.Signature = &ObjectSignature{ + Signature: signatureSB.String(), + Payload: payloadSB.String(), + } + if len(commit.Signature.Signature) == 0 { + commit.Signature = nil + } + + return commit, nil +} diff --git a/modules/git/commit_sha256_test.go b/modules/git/commit_sha256_test.go new file mode 100644 index 0000000..9e56829 --- /dev/null +++ b/modules/git/commit_sha256_test.go @@ -0,0 +1,211 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCommitsCountSha256(t *testing.T) { + skipIfSHA256NotSupported(t) + + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare_sha256") + + commitsCount, err := CommitsCount(DefaultContext, + CommitsCountOptions{ + RepoPath: bareRepo1Path, + Revision: []string{"f004f41359117d319dedd0eaab8c5259ee2263da839dcba33637997458627fdc"}, + }) + + require.NoError(t, err) + assert.Equal(t, int64(3), commitsCount) +} + +func TestCommitsCountWithoutBaseSha256(t *testing.T) { + skipIfSHA256NotSupported(t) + + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare_sha256") + + commitsCount, err := CommitsCount(DefaultContext, + CommitsCountOptions{ + RepoPath: bareRepo1Path, + Not: "main", + Revision: []string{"branch1"}, + }) + + require.NoError(t, err) + assert.Equal(t, int64(2), commitsCount) +} + +func TestGetFullCommitIDSha256(t *testing.T) { + skipIfSHA256NotSupported(t) + + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare_sha256") + + id, err := GetFullCommitID(DefaultContext, bareRepo1Path, "f004f4") + require.NoError(t, err) + assert.Equal(t, "f004f41359117d319dedd0eaab8c5259ee2263da839dcba33637997458627fdc", id) +} + +func TestGetFullCommitIDErrorSha256(t *testing.T) { + skipIfSHA256NotSupported(t) + + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare_sha256") + + id, err := GetFullCommitID(DefaultContext, bareRepo1Path, "unknown") + assert.Empty(t, id) + if assert.Error(t, err) { + assert.EqualError(t, err, "object does not exist [id: unknown, rel_path: ]") + } +} + +func TestCommitFromReaderSha256(t *testing.T) { + skipIfSHA256NotSupported(t) + + commitString := `9433b2a62b964c17a4485ae180f45f595d3e69d31b786087775e28c6b6399df0 commit 1114 +tree e7f9e96dd79c09b078cac8b303a7d3b9d65ff9b734e86060a4d20409fd379f9e +parent 26e9ccc29fad747e9c5d9f4c9ddeb7eff61cc45ef6a8dc258cbeb181afc055e8 +author Adam Majer <amajer@suse.de> 1698676906 +0100 +committer Adam Majer <amajer@suse.de> 1698676906 +0100 +gpgsig-sha256 -----BEGIN PGP SIGNATURE----- +` + " " + ` + iQIrBAABCgAtFiEES+fB08xlgTrzSdQvhkUIsBsmec8FAmU/wKoPHGFtYWplckBz + dXNlLmRlAAoJEIZFCLAbJnnP4s4PQIJATa++WPzR6/H4etT7bsOGoMyguEJYyWOd + aTybplzT7QAL7h2to0QszGabtzMJPIA39xSFZNYNN30voK5YyyYibXluPKgjemfK + WNXwF+gkwgZI38gSvKf+vlqI+EYyIFe19wOhiju0m8SIlB5NEPiWHa17q2mqmqqx + 1FWa2JdqLPYjAtSLFXeSZegrY5V1FxdemyMUONkg8YO9OSIMZiE0GsnnOXQ3xcT4 + JTCnmlUxIKw689UiEY80JopUIq+Wl7+qq9507IYYSUCyB6JazL42AKMzVCbD+qBP + oOzh/hafYgk9H9qCQXaLbmvs17zXRpicig1bAzqgAy1FDelvpERyRTydEajSLIG6 + U1cRCkgXCZ0NfsYNPPmBa8b3+rnstypXYTbyMwTln7FfUAaGo6o9JYiPMkzxlmsy + zfp/tcaY8+LlBL9aOJjtv+a0p+HrpCGd6CCa4ARfphTLq8QRSSh8uzlB9N+6HnRI + VAEUo6ecdDxSpyt2naeg9pKus/BRi7P6g4B1hkk/zZstUX/QP4IQuAJbXjkvsC+X + HKRr3NlRM/DygzTyj0gN74uoa0goCIbyAQhiT42nm0cuhM7uN/W0ayrlZjGF1cbR + 8NCJUL2Nwj0ywKIavC99Ipkb8AsFwpVT6U6effs6 + =xybZ + -----END PGP SIGNATURE----- + +signed commit` + + sha := &Sha256Hash{ + 0x94, 0x33, 0xb2, 0xa6, 0x2b, 0x96, 0x4c, 0x17, 0xa4, 0x48, 0x5a, 0xe1, 0x80, 0xf4, 0x5f, 0x59, + 0x5d, 0x3e, 0x69, 0xd3, 0x1b, 0x78, 0x60, 0x87, 0x77, 0x5e, 0x28, 0xc6, 0xb6, 0x39, 0x9d, 0xf0, + } + gitRepo, err := openRepositoryWithDefaultContext(filepath.Join(testReposDir, "repo1_bare_sha256")) + require.NoError(t, err) + assert.NotNil(t, gitRepo) + defer gitRepo.Close() + + commitFromReader, err := CommitFromReader(gitRepo, sha, strings.NewReader(commitString)) + require.NoError(t, err) + if !assert.NotNil(t, commitFromReader) { + return + } + assert.EqualValues(t, sha, commitFromReader.ID) + assert.EqualValues(t, `-----BEGIN PGP SIGNATURE----- + +iQIrBAABCgAtFiEES+fB08xlgTrzSdQvhkUIsBsmec8FAmU/wKoPHGFtYWplckBz +dXNlLmRlAAoJEIZFCLAbJnnP4s4PQIJATa++WPzR6/H4etT7bsOGoMyguEJYyWOd +aTybplzT7QAL7h2to0QszGabtzMJPIA39xSFZNYNN30voK5YyyYibXluPKgjemfK +WNXwF+gkwgZI38gSvKf+vlqI+EYyIFe19wOhiju0m8SIlB5NEPiWHa17q2mqmqqx +1FWa2JdqLPYjAtSLFXeSZegrY5V1FxdemyMUONkg8YO9OSIMZiE0GsnnOXQ3xcT4 +JTCnmlUxIKw689UiEY80JopUIq+Wl7+qq9507IYYSUCyB6JazL42AKMzVCbD+qBP +oOzh/hafYgk9H9qCQXaLbmvs17zXRpicig1bAzqgAy1FDelvpERyRTydEajSLIG6 +U1cRCkgXCZ0NfsYNPPmBa8b3+rnstypXYTbyMwTln7FfUAaGo6o9JYiPMkzxlmsy +zfp/tcaY8+LlBL9aOJjtv+a0p+HrpCGd6CCa4ARfphTLq8QRSSh8uzlB9N+6HnRI +VAEUo6ecdDxSpyt2naeg9pKus/BRi7P6g4B1hkk/zZstUX/QP4IQuAJbXjkvsC+X +HKRr3NlRM/DygzTyj0gN74uoa0goCIbyAQhiT42nm0cuhM7uN/W0ayrlZjGF1cbR +8NCJUL2Nwj0ywKIavC99Ipkb8AsFwpVT6U6effs6 +=xybZ +-----END PGP SIGNATURE----- +`, commitFromReader.Signature.Signature) + assert.EqualValues(t, `tree e7f9e96dd79c09b078cac8b303a7d3b9d65ff9b734e86060a4d20409fd379f9e +parent 26e9ccc29fad747e9c5d9f4c9ddeb7eff61cc45ef6a8dc258cbeb181afc055e8 +author Adam Majer <amajer@suse.de> 1698676906 +0100 +committer Adam Majer <amajer@suse.de> 1698676906 +0100 + +signed commit`, commitFromReader.Signature.Payload) + assert.EqualValues(t, "Adam Majer <amajer@suse.de>", commitFromReader.Author.String()) + + commitFromReader2, err := CommitFromReader(gitRepo, sha, strings.NewReader(commitString+"\n\n")) + require.NoError(t, err) + commitFromReader.CommitMessage += "\n\n" + commitFromReader.Signature.Payload += "\n\n" + assert.EqualValues(t, commitFromReader, commitFromReader2) +} + +func TestHasPreviousCommitSha256(t *testing.T) { + skipIfSHA256NotSupported(t) + + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare_sha256") + + repo, err := openRepositoryWithDefaultContext(bareRepo1Path) + require.NoError(t, err) + defer repo.Close() + + commit, err := repo.GetCommit("f004f41359117d319dedd0eaab8c5259ee2263da839dcba33637997458627fdc") + require.NoError(t, err) + + objectFormat, err := repo.GetObjectFormat() + require.NoError(t, err) + + parentSHA := MustIDFromString("b0ec7af4547047f12d5093e37ef8f1b3b5415ed8ee17894d43a34d7d34212e9c") + notParentSHA := MustIDFromString("42e334efd04cd36eea6da0599913333c26116e1a537ca76e5b6e4af4dda00236") + assert.Equal(t, parentSHA.Type(), objectFormat) + assert.Equal(t, "sha256", objectFormat.Name()) + + haz, err := commit.HasPreviousCommit(parentSHA) + require.NoError(t, err) + assert.True(t, haz) + + hazNot, err := commit.HasPreviousCommit(notParentSHA) + require.NoError(t, err) + assert.False(t, hazNot) + + selfNot, err := commit.HasPreviousCommit(commit.ID) + require.NoError(t, err) + assert.False(t, selfNot) +} + +func TestGetCommitFileStatusMergesSha256(t *testing.T) { + skipIfSHA256NotSupported(t) + + bareRepo1Path := filepath.Join(testReposDir, "repo6_merge_sha256") + + commitFileStatus, err := GetCommitFileStatus(DefaultContext, bareRepo1Path, "d2e5609f630dd8db500f5298d05d16def282412e3e66ed68cc7d0833b29129a1") + require.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(DefaultContext, bareRepo1Path, "da1ded40dc8e5b7c564171f4bf2fc8370487decfb1cb6a99ef28f3ed73d09172") + require.NoError(t, err) + + assert.Equal(t, expected.Added, commitFileStatus.Added) + assert.Equal(t, expected.Removed, commitFileStatus.Removed) + assert.Equal(t, expected.Modified, commitFileStatus.Modified) +} diff --git a/modules/git/commit_test.go b/modules/git/commit_test.go new file mode 100644 index 0000000..af85bfe --- /dev/null +++ b/modules/git/commit_test.go @@ -0,0 +1,371 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCommitsCount(t *testing.T) { + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") + + commitsCount, err := CommitsCount(DefaultContext, + CommitsCountOptions{ + RepoPath: bareRepo1Path, + Revision: []string{"8006ff9adbf0cb94da7dad9e537e53817f9fa5c0"}, + }) + + require.NoError(t, err) + assert.Equal(t, int64(3), commitsCount) +} + +func TestCommitsCountWithoutBase(t *testing.T) { + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") + + commitsCount, err := CommitsCount(DefaultContext, + CommitsCountOptions{ + RepoPath: bareRepo1Path, + Not: "master", + Revision: []string{"branch1"}, + }) + + require.NoError(t, err) + assert.Equal(t, int64(2), commitsCount) +} + +func TestGetFullCommitID(t *testing.T) { + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") + + id, err := GetFullCommitID(DefaultContext, bareRepo1Path, "8006ff9a") + require.NoError(t, err) + assert.Equal(t, "8006ff9adbf0cb94da7dad9e537e53817f9fa5c0", id) +} + +func TestGetFullCommitIDError(t *testing.T) { + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") + + id, err := GetFullCommitID(DefaultContext, bareRepo1Path, "unknown") + assert.Empty(t, id) + if assert.Error(t, err) { + assert.EqualError(t, err, "object does not exist [id: unknown, rel_path: ]") + } +} + +func TestCommitFromReader(t *testing.T) { + commitString := `feaf4ba6bc635fec442f46ddd4512416ec43c2c2 commit 1074 +tree f1a6cb52b2d16773290cefe49ad0684b50a4f930 +parent 37991dec2c8e592043f47155ce4808d4580f9123 +author silverwind <me@silverwind.io> 1563741793 +0200 +committer silverwind <me@silverwind.io> 1563741793 +0200 +gpgsig -----BEGIN PGP SIGNATURE----- +` + " " + ` + iQIzBAABCAAdFiEEWPb2jX6FS2mqyJRQLmK0HJOGlEMFAl00zmEACgkQLmK0HJOG + lEMDFBAAhQKKqLD1VICygJMEB8t1gBmNLgvziOLfpX4KPWdPtBk3v/QJ7OrfMrVK + xlC4ZZyx6yMm1Q7GzmuWykmZQJ9HMaHJ49KAbh5MMjjV/+OoQw9coIdo8nagRUld + vX8QHzNZ6Agx77xHuDJZgdHKpQK3TrMDsxzoYYMvlqoLJIDXE1Sp7KYNy12nhdRg + R6NXNmW8oMZuxglkmUwayMiPS+N4zNYqv0CXYzlEqCOgq9MJUcAMHt+KpiST+sm6 + FWkJ9D+biNPyQ9QKf1AE4BdZia4lHfPYU/C/DEL/a5xQuuop/zMQZoGaIA4p2zGQ + /maqYxEIM/yRBQpT1jlODKPJrMEgx7SgY2hRU47YZ4fj6350fb6fNBtiiMAfJbjL + S3Gh85E9fm3hJaNSPKAaJFYL1Ya2svuWfgHj677C56UcmYis7fhiiy1aJuYdHnSm + sD53z/f0J+We4VZjY+pidvA9BGZPFVdR3wd3xGs8/oH6UWaLJAMGkLG6dDb3qDLm + 1LFZwsX8sdD32i1SiWanYQYSYMyFWr0awi4xdoMtYCL7uKBYtwtPyvq3cj4IrJlb + mfeFhT57UbE4qukTDIQ0Y0WM40UYRTakRaDY7ubhXgLgx09Cnp9XTVMsHgT6j9/i + 1pxsB104XLWjQHTjr1JtiaBQEwFh9r2OKTcpvaLcbNtYpo7CzOs= + =FRsO + -----END PGP SIGNATURE----- + +empty commit` + + sha := &Sha1Hash{0xfe, 0xaf, 0x4b, 0xa6, 0xbc, 0x63, 0x5f, 0xec, 0x44, 0x2f, 0x46, 0xdd, 0xd4, 0x51, 0x24, 0x16, 0xec, 0x43, 0xc2, 0xc2} + gitRepo, err := openRepositoryWithDefaultContext(filepath.Join(testReposDir, "repo1_bare")) + require.NoError(t, err) + assert.NotNil(t, gitRepo) + defer gitRepo.Close() + + commitFromReader, err := CommitFromReader(gitRepo, sha, strings.NewReader(commitString)) + require.NoError(t, err) + require.NotNil(t, commitFromReader) + assert.EqualValues(t, sha, commitFromReader.ID) + assert.EqualValues(t, `-----BEGIN PGP SIGNATURE----- + +iQIzBAABCAAdFiEEWPb2jX6FS2mqyJRQLmK0HJOGlEMFAl00zmEACgkQLmK0HJOG +lEMDFBAAhQKKqLD1VICygJMEB8t1gBmNLgvziOLfpX4KPWdPtBk3v/QJ7OrfMrVK +xlC4ZZyx6yMm1Q7GzmuWykmZQJ9HMaHJ49KAbh5MMjjV/+OoQw9coIdo8nagRUld +vX8QHzNZ6Agx77xHuDJZgdHKpQK3TrMDsxzoYYMvlqoLJIDXE1Sp7KYNy12nhdRg +R6NXNmW8oMZuxglkmUwayMiPS+N4zNYqv0CXYzlEqCOgq9MJUcAMHt+KpiST+sm6 +FWkJ9D+biNPyQ9QKf1AE4BdZia4lHfPYU/C/DEL/a5xQuuop/zMQZoGaIA4p2zGQ +/maqYxEIM/yRBQpT1jlODKPJrMEgx7SgY2hRU47YZ4fj6350fb6fNBtiiMAfJbjL +S3Gh85E9fm3hJaNSPKAaJFYL1Ya2svuWfgHj677C56UcmYis7fhiiy1aJuYdHnSm +sD53z/f0J+We4VZjY+pidvA9BGZPFVdR3wd3xGs8/oH6UWaLJAMGkLG6dDb3qDLm +1LFZwsX8sdD32i1SiWanYQYSYMyFWr0awi4xdoMtYCL7uKBYtwtPyvq3cj4IrJlb +mfeFhT57UbE4qukTDIQ0Y0WM40UYRTakRaDY7ubhXgLgx09Cnp9XTVMsHgT6j9/i +1pxsB104XLWjQHTjr1JtiaBQEwFh9r2OKTcpvaLcbNtYpo7CzOs= +=FRsO +-----END PGP SIGNATURE----- +`, commitFromReader.Signature.Signature) + assert.EqualValues(t, `tree f1a6cb52b2d16773290cefe49ad0684b50a4f930 +parent 37991dec2c8e592043f47155ce4808d4580f9123 +author silverwind <me@silverwind.io> 1563741793 +0200 +committer silverwind <me@silverwind.io> 1563741793 +0200 + +empty commit`, commitFromReader.Signature.Payload) + assert.EqualValues(t, "silverwind <me@silverwind.io>", commitFromReader.Author.String()) + + commitFromReader2, err := CommitFromReader(gitRepo, sha, strings.NewReader(commitString+"\n\n")) + require.NoError(t, err) + commitFromReader.CommitMessage += "\n\n" + commitFromReader.Signature.Payload += "\n\n" + assert.EqualValues(t, commitFromReader, commitFromReader2) +} + +func TestCommitWithEncodingFromReader(t *testing.T) { + commitString := `feaf4ba6bc635fec442f46ddd4512416ec43c2c2 commit 1074 +tree ca3fad42080dd1a6d291b75acdfc46e5b9b307e5 +parent 47b24e7ab977ed31c5a39989d570847d6d0052af +author KN4CK3R <admin@oldschoolhack.me> 1711702962 +0100 +committer KN4CK3R <admin@oldschoolhack.me> 1711702962 +0100 +encoding ISO-8859-1 +gpgsig -----BEGIN PGP SIGNATURE----- +` + " " + ` + iQGzBAABCgAdFiEE9HRrbqvYxPT8PXbefPSEkrowAa8FAmYGg7IACgkQfPSEkrow + Aa9olwv+P0HhtCM6CRvlUmPaqswRsDPNR4i66xyXGiSxdI9V5oJL7HLiQIM7KrFR + gizKa2COiGtugv8fE+TKqXKaJx6uJUJEjaBd8E9Af9PrAzjWj+A84lU6/PgPS8hq + zOfZraLOEWRH4tZcS+u2yFLu3ez2Wqh1xW5LNy7xqEedMXEFD1HwSJ0+pjacNkzr + frp6Asyt7xRI6YmgFJZJoRsS3Ktr6rtKeRL2IErSQQyorOqj6gKrglhrhfG/114j + FKB1v4or0WZ1DE8iP2SJZ3n+/K1IuWAINh7MVdb7PndfBPEa+IL+ucNk5uzEE8Jd + G8smGxXUeFEt2cP1dj2W8EgAxuA9sTnH9dqI5aRqy5ifDjuya7Emm8sdOUvtGdmn + SONRzusmu5n3DgV956REL7x62h7JuqmBz/12HZkr0z0zgXkcZ04q08pSJATX5N1F + yN+tWxTsWg+zhDk96d5Esdo9JMjcFvPv0eioo30GAERaz1hoD7zCMT4jgUFTQwgz + jw4YcO5u + =r3UU + -----END PGP SIGNATURE----- + +ISO-8859-1` + + sha := &Sha1Hash{0xfe, 0xaf, 0x4b, 0xa6, 0xbc, 0x63, 0x5f, 0xec, 0x44, 0x2f, 0x46, 0xdd, 0xd4, 0x51, 0x24, 0x16, 0xec, 0x43, 0xc2, 0xc2} + gitRepo, err := openRepositoryWithDefaultContext(filepath.Join(testReposDir, "repo1_bare")) + require.NoError(t, err) + assert.NotNil(t, gitRepo) + defer gitRepo.Close() + + commitFromReader, err := CommitFromReader(gitRepo, sha, strings.NewReader(commitString)) + require.NoError(t, err) + require.NotNil(t, commitFromReader) + assert.EqualValues(t, sha, commitFromReader.ID) + assert.EqualValues(t, `-----BEGIN PGP SIGNATURE----- + +iQGzBAABCgAdFiEE9HRrbqvYxPT8PXbefPSEkrowAa8FAmYGg7IACgkQfPSEkrow +Aa9olwv+P0HhtCM6CRvlUmPaqswRsDPNR4i66xyXGiSxdI9V5oJL7HLiQIM7KrFR +gizKa2COiGtugv8fE+TKqXKaJx6uJUJEjaBd8E9Af9PrAzjWj+A84lU6/PgPS8hq +zOfZraLOEWRH4tZcS+u2yFLu3ez2Wqh1xW5LNy7xqEedMXEFD1HwSJ0+pjacNkzr +frp6Asyt7xRI6YmgFJZJoRsS3Ktr6rtKeRL2IErSQQyorOqj6gKrglhrhfG/114j +FKB1v4or0WZ1DE8iP2SJZ3n+/K1IuWAINh7MVdb7PndfBPEa+IL+ucNk5uzEE8Jd +G8smGxXUeFEt2cP1dj2W8EgAxuA9sTnH9dqI5aRqy5ifDjuya7Emm8sdOUvtGdmn +SONRzusmu5n3DgV956REL7x62h7JuqmBz/12HZkr0z0zgXkcZ04q08pSJATX5N1F +yN+tWxTsWg+zhDk96d5Esdo9JMjcFvPv0eioo30GAERaz1hoD7zCMT4jgUFTQwgz +jw4YcO5u +=r3UU +-----END PGP SIGNATURE----- +`, commitFromReader.Signature.Signature) + assert.EqualValues(t, `tree ca3fad42080dd1a6d291b75acdfc46e5b9b307e5 +parent 47b24e7ab977ed31c5a39989d570847d6d0052af +author KN4CK3R <admin@oldschoolhack.me> 1711702962 +0100 +committer KN4CK3R <admin@oldschoolhack.me> 1711702962 +0100 +encoding ISO-8859-1 + +ISO-8859-1`, commitFromReader.Signature.Payload) + assert.EqualValues(t, "KN4CK3R <admin@oldschoolhack.me>", commitFromReader.Author.String()) + + commitFromReader2, err := CommitFromReader(gitRepo, sha, strings.NewReader(commitString+"\n\n")) + require.NoError(t, err) + commitFromReader.CommitMessage += "\n\n" + commitFromReader.Signature.Payload += "\n\n" + assert.EqualValues(t, commitFromReader, commitFromReader2) +} + +func TestHasPreviousCommit(t *testing.T) { + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") + + repo, err := openRepositoryWithDefaultContext(bareRepo1Path) + require.NoError(t, err) + defer repo.Close() + + commit, err := repo.GetCommit("8006ff9adbf0cb94da7dad9e537e53817f9fa5c0") + require.NoError(t, err) + + parentSHA := MustIDFromString("8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2") + notParentSHA := MustIDFromString("2839944139e0de9737a044f78b0e4b40d989a9e3") + + haz, err := commit.HasPreviousCommit(parentSHA) + require.NoError(t, err) + assert.True(t, haz) + + hazNot, err := commit.HasPreviousCommit(notParentSHA) + require.NoError(t, err) + assert.False(t, hazNot) + + selfNot, err := commit.HasPreviousCommit(commit.ID) + require.NoError(t, err) + 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(DefaultContext, bareRepo1Path, "022f4ce6214973e018f02bf363bf8a2e3691f699") + require.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 TestParseCommitRenames(t *testing.T) { + testcases := []struct { + output string + renames [][2]string + }{ + { + output: "R090\x00renamed.txt\x00history.txt\x00", + renames: [][2]string{{"renamed.txt", "history.txt"}}, + }, + { + output: "R090\x00renamed.txt\x00history.txt\x00R000\x00corruptedstdouthere", + renames: [][2]string{{"renamed.txt", "history.txt"}}, + }, + { + output: "R100\x00renamed.txt\x00history.txt\x00R001\x00readme.md\x00README.md\x00", + renames: [][2]string{{"renamed.txt", "history.txt"}, {"readme.md", "README.md"}}, + }, + } + + for _, testcase := range testcases { + renames := [][2]string{} + parseCommitRenames(&renames, strings.NewReader(testcase.output)) + + assert.Equal(t, testcase.renames, renames) + } +} diff --git a/modules/git/diff.go b/modules/git/diff.go new file mode 100644 index 0000000..10ef3d8 --- /dev/null +++ b/modules/git/diff.go @@ -0,0 +1,317 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "bufio" + "bytes" + "context" + "fmt" + "io" + "os" + "regexp" + "strconv" + "strings" + + "code.gitea.io/gitea/modules/log" +) + +// RawDiffType type of a raw diff. +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 := NewCommand(ctx, "show", "--pretty=format:revert %H%n", "-R").AddDynamicArguments(commitID) + if err := cmd.Run(&RunOpts{ + Dir: repoPath, + Stdout: writer, + Stderr: stderr, + }); err != nil { + return fmt.Errorf("Run: %w - %s", err, stderr) + } + return nil +} + +// 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 { + commit, err := repo.GetCommit(endCommit) + if err != nil { + return err + } + var files []string + if len(file) > 0 { + files = append(files, file) + } + + cmd := NewCommand(repo.Ctx) + switch diffType { + case RawDiffNormal: + if len(startCommit) != 0 { + cmd.AddArguments("diff", "-M").AddDynamicArguments(startCommit, endCommit).AddDashesAndList(files...) + } else if commit.ParentCount() == 0 { + cmd.AddArguments("show").AddDynamicArguments(endCommit).AddDashesAndList(files...) + } else { + c, _ := commit.Parent(0) + cmd.AddArguments("diff", "-M").AddDynamicArguments(c.ID.String(), endCommit).AddDashesAndList(files...) + } + case RawDiffPatch: + if len(startCommit) != 0 { + query := fmt.Sprintf("%s...%s", endCommit, startCommit) + cmd.AddArguments("format-patch", "--no-signature", "--stdout", "--root").AddDynamicArguments(query).AddDashesAndList(files...) + } else if commit.ParentCount() == 0 { + cmd.AddArguments("format-patch", "--no-signature", "--stdout", "--root").AddDynamicArguments(endCommit).AddDashesAndList(files...) + } else { + c, _ := commit.Parent(0) + 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) + } + + stderr := new(bytes.Buffer) + if err = cmd.Run(&RunOpts{ + Dir: repo.Path, + Stdout: writer, + Stderr: stderr, + }); err != nil { + return fmt.Errorf("Run: %w - %s", err, stderr) + } + return nil +} + +// ParseDiffHunkString parse the diffhunk content and return +func ParseDiffHunkString(diffhunk string) (leftLine, leftHunk, rightLine, righHunk int) { + ss := strings.Split(diffhunk, "@@") + ranges := strings.Split(ss[1][1:], " ") + leftRange := strings.Split(ranges[0], ",") + leftLine, _ = strconv.Atoi(leftRange[0][1:]) + if len(leftRange) > 1 { + leftHunk, _ = strconv.Atoi(leftRange[1]) + } + if len(ranges) > 1 { + rightRange := strings.Split(ranges[1], ",") + rightLine, _ = strconv.Atoi(rightRange[0]) + if len(rightRange) > 1 { + righHunk, _ = strconv.Atoi(rightRange[1]) + } + } else { + log.Debug("Parse line number failed: %v", diffhunk) + rightLine = leftLine + righHunk = leftHunk + } + return leftLine, leftHunk, rightLine, righHunk +} + +// Example: @@ -1,8 +1,9 @@ => [..., 1, 8, 1, 9] +var hunkRegex = regexp.MustCompile(`^@@ -(?P<beginOld>[0-9]+)(,(?P<endOld>[0-9]+))? \+(?P<beginNew>[0-9]+)(,(?P<endNew>[0-9]+))? @@`) + +const cmdDiffHead = "diff --git " + +func isHeader(lof string, inHunk bool) bool { + return strings.HasPrefix(lof, cmdDiffHead) || (!inHunk && (strings.HasPrefix(lof, "---") || strings.HasPrefix(lof, "+++"))) +} + +// CutDiffAroundLine cuts a diff of a file in way that only the given line + numberOfLine above it will be shown +// it also recalculates hunks and adds the appropriate headers to the new diff. +// Warning: Only one-file diffs are allowed. +func CutDiffAroundLine(originalDiff io.Reader, line int64, old bool, numbersOfLine int) (string, error) { + if line == 0 || numbersOfLine == 0 { + // no line or num of lines => no diff + return "", nil + } + + scanner := bufio.NewScanner(originalDiff) + hunk := make([]string, 0) + + // begin is the start of the hunk containing searched line + // end is the end of the hunk ... + // currentLine is the line number on the side of the searched line (differentiated by old) + // otherLine is the line number on the opposite side of the searched line (differentiated by old) + var begin, end, currentLine, otherLine int64 + var headerLines int + + inHunk := false + + for scanner.Scan() { + lof := scanner.Text() + // Add header to enable parsing + + if isHeader(lof, inHunk) { + if strings.HasPrefix(lof, cmdDiffHead) { + inHunk = false + } + hunk = append(hunk, lof) + headerLines++ + } + if currentLine > line { + break + } + // Detect "hunk" with contains commented lof + if strings.HasPrefix(lof, "@@") { + inHunk = true + // Already got our hunk. End of hunk detected! + if len(hunk) > headerLines { + break + } + // A map with named groups of our regex to recognize them later more easily + submatches := hunkRegex.FindStringSubmatch(lof) + groups := make(map[string]string) + for i, name := range hunkRegex.SubexpNames() { + if i != 0 && name != "" { + groups[name] = submatches[i] + } + } + if old { + begin, _ = strconv.ParseInt(groups["beginOld"], 10, 64) + end, _ = strconv.ParseInt(groups["endOld"], 10, 64) + // init otherLine with begin of opposite side + otherLine, _ = strconv.ParseInt(groups["beginNew"], 10, 64) + } else { + begin, _ = strconv.ParseInt(groups["beginNew"], 10, 64) + if groups["endNew"] != "" { + end, _ = strconv.ParseInt(groups["endNew"], 10, 64) + } else { + end = 0 + } + // init otherLine with begin of opposite side + otherLine, _ = strconv.ParseInt(groups["beginOld"], 10, 64) + } + end += begin // end is for real only the number of lines in hunk + // lof is between begin and end + if begin <= line && end >= line { + hunk = append(hunk, lof) + currentLine = begin + continue + } + } else if len(hunk) > headerLines { + hunk = append(hunk, lof) + // Count lines in context + switch lof[0] { + case '+': + if !old { + currentLine++ + } else { + otherLine++ + } + case '-': + if old { + currentLine++ + } else { + otherLine++ + } + case '\\': + // FIXME: handle `\ No newline at end of file` + default: + currentLine++ + otherLine++ + } + } + } + if err := scanner.Err(); err != nil { + return "", err + } + + // No hunk found + if currentLine == 0 { + return "", nil + } + // headerLines + hunkLine (1) = totalNonCodeLines + if len(hunk)-headerLines-1 <= numbersOfLine { + // No need to cut the hunk => return existing hunk + return strings.Join(hunk, "\n"), nil + } + var oldBegin, oldNumOfLines, newBegin, newNumOfLines int64 + if old { + oldBegin = currentLine + newBegin = otherLine + } else { + oldBegin = otherLine + newBegin = currentLine + } + // headers + hunk header + newHunk := make([]string, headerLines) + // transfer existing headers + copy(newHunk, hunk[:headerLines]) + // transfer last n lines + newHunk = append(newHunk, hunk[len(hunk)-numbersOfLine-1:]...) + // calculate newBegin, ... by counting lines + for i := len(hunk) - 1; i >= len(hunk)-numbersOfLine; i-- { + switch hunk[i][0] { + case '+': + newBegin-- + newNumOfLines++ + case '-': + oldBegin-- + oldNumOfLines++ + default: + oldBegin-- + newBegin-- + newNumOfLines++ + oldNumOfLines++ + } + } + // construct the new hunk header + newHunk[headerLines] = fmt.Sprintf("@@ -%d,%d +%d,%d @@", + oldBegin, oldNumOfLines, newBegin, newNumOfLines) + return strings.Join(newHunk, "\n"), nil +} + +// GetAffectedFiles returns the affected files between two commits +func GetAffectedFiles(repo *Repository, oldCommitID, newCommitID string, env []string) ([]string, error) { + 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 = NewCommand(repo.Ctx, "diff", "--name-only").AddDynamicArguments(oldCommitID, newCommitID). + Run(&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) + } + return scanner.Err() + }, + }) + if err != nil { + log.Error("Unable to get affected files for commits from %s to %s in %s: %v", oldCommitID, newCommitID, repo.Path, err) + } + + return affectedFiles, err +} diff --git a/modules/git/diff_test.go b/modules/git/diff_test.go new file mode 100644 index 0000000..0855a7d --- /dev/null +++ b/modules/git/diff_test.go @@ -0,0 +1,169 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const exampleDiff = `diff --git a/README.md b/README.md +--- a/README.md ++++ b/README.md +@@ -1,3 +1,6 @@ + # gitea-github-migrator ++ ++ Build Status +- Latest Release + Docker Pulls ++ cut off ++ cut off` + +const breakingDiff = `diff --git a/aaa.sql b/aaa.sql +index d8e4c92..19dc8ad 100644 +--- a/aaa.sql ++++ b/aaa.sql +@@ -1,9 +1,10 @@ + --some comment +--- some comment 5 ++--some coment 2 ++-- some comment 3 + create or replace procedure test(p1 varchar2) + is + begin +---new comment + dbms_output.put_line(p1); ++--some other comment + end; + / +` + +var issue17875Diff = `diff --git a/Geschäftsordnung.md b/Geschäftsordnung.md +index d46c152..a7d2d55 100644 +--- a/Geschäftsordnung.md ++++ b/Geschäftsordnung.md +@@ -1,5 +1,5 @@ + --- +-date: "23.01.2021" ++date: "30.11.2021" + ... + ` + ` + # Geschäftsordnung +@@ -16,4 +16,22 @@ Diese Geschäftsordnung regelt alle Prozesse des Vereins, solange diese nicht du + ` + ` + ## § 3 Datenschutzverantwortlichkeit + ` + ` +-1. Der Verein bestellt eine datenschutzverantwortliche Person mit den Aufgaben nach Artikel 39 DSGVO. +\ No newline at end of file ++1. Der Verein bestellt eine datenschutzverantwortliche Person mit den Aufgaben nach Artikel 39 DSGVO. ++ ++## §4 Umgang mit der SARS-Cov-2-Pandemie ++ ++1. Der Vorstand hat die Befugnis, in Rücksprache mit den Vereinsmitgliedern, verschiedene Hygienemaßnahmen für Präsenzveranstaltungen zu beschließen. ++ ++2. Die Einführung, Änderung und Abschaffung dieser Maßnahmen sind nur zum Zweck der Eindämmung der SARS-Cov-2-Pandemie zulässig. ++ ++3. Die Einführung, Änderung und Abschaffung von Maßnahmen nach Abs. 2 bedarf einer wissenschaftlichen Grundlage. ++ ++4. Die Maßnahmen nach Abs. 2 setzen sich aus den folgenden Bausteinen inklusive einer ihrer Ausprägungen zusammen. ++ ++ 1. Maskenpflicht: Keine; Maskenpflicht, außer am Platz, oder wo Abstände nicht eingehalten werden können; Maskenpflicht, wenn Abstände nicht eingehalten werden können; Maskenpflicht ++ ++ 2. Geimpft-, Genesen- oder Testnachweis: Kein Nachweis notwendig; Nachweis, dass Person geimpft, genesen oder tagesaktuell getestet ist (3G); Nachweis, dass Person geimpft oder genesen ist (2G); Nachweis, dass Person geimpft bzw. genesen und tagesaktuell getestet ist (2G+) ++ ++ 3. Online-Veranstaltung: Keine, parallele Online-Veranstaltung, ausschließlich Online-Veranstaltung ++ ++5. Bei Präsenzveranstungen gelten außerdem die Hygienevorschriften des Veranstaltungsorts. Bei Regelkollision greift die restriktivere Regel. +\ No newline at end of file` + +func TestCutDiffAroundLineIssue17875(t *testing.T) { + result, err := CutDiffAroundLine(strings.NewReader(issue17875Diff), 23, false, 3) + require.NoError(t, err) + expected := `diff --git a/Geschäftsordnung.md b/Geschäftsordnung.md +--- a/Geschäftsordnung.md ++++ b/Geschäftsordnung.md +@@ -20,0 +21,3 @@ ++## §4 Umgang mit der SARS-Cov-2-Pandemie ++ ++1. Der Vorstand hat die Befugnis, in Rücksprache mit den Vereinsmitgliedern, verschiedene Hygienemaßnahmen für Präsenzveranstaltungen zu beschließen.` + assert.Equal(t, expected, result) +} + +func TestCutDiffAroundLine(t *testing.T) { + result, err := CutDiffAroundLine(strings.NewReader(exampleDiff), 4, false, 3) + require.NoError(t, err) + resultByLine := strings.Split(result, "\n") + assert.Len(t, resultByLine, 7) + // Check if headers got transferred + assert.Equal(t, "diff --git a/README.md b/README.md", resultByLine[0]) + assert.Equal(t, "--- a/README.md", resultByLine[1]) + assert.Equal(t, "+++ b/README.md", resultByLine[2]) + // Check if hunk header is calculated correctly + assert.Equal(t, "@@ -2,2 +3,2 @@", resultByLine[3]) + // Check if line got transferred + assert.Equal(t, "+ Build Status", resultByLine[4]) + + // Must be same result as before since old line 3 == new line 5 + newResult, err := CutDiffAroundLine(strings.NewReader(exampleDiff), 3, true, 3) + require.NoError(t, err) + assert.Equal(t, result, newResult, "Must be same result as before since old line 3 == new line 5") + + newResult, err = CutDiffAroundLine(strings.NewReader(exampleDiff), 6, false, 300) + require.NoError(t, err) + assert.Equal(t, exampleDiff, newResult) + + emptyResult, err := CutDiffAroundLine(strings.NewReader(exampleDiff), 6, false, 0) + require.NoError(t, err) + assert.Empty(t, emptyResult) + + // Line is out of scope + emptyResult, err = CutDiffAroundLine(strings.NewReader(exampleDiff), 434, false, 0) + require.NoError(t, err) + assert.Empty(t, emptyResult) + + // Handle minus diffs properly + minusDiff, err := CutDiffAroundLine(strings.NewReader(breakingDiff), 2, false, 4) + require.NoError(t, err) + + expected := `diff --git a/aaa.sql b/aaa.sql +--- a/aaa.sql ++++ b/aaa.sql +@@ -1,9 +1,10 @@ + --some comment +--- some comment 5 ++--some coment 2` + assert.Equal(t, expected, minusDiff) + + // Handle minus diffs properly + minusDiff, err = CutDiffAroundLine(strings.NewReader(breakingDiff), 3, false, 4) + require.NoError(t, err) + + expected = `diff --git a/aaa.sql b/aaa.sql +--- a/aaa.sql ++++ b/aaa.sql +@@ -1,9 +1,10 @@ + --some comment +--- some comment 5 ++--some coment 2 ++-- some comment 3` + + assert.Equal(t, expected, minusDiff) +} + +func BenchmarkCutDiffAroundLine(b *testing.B) { + for n := 0; n < b.N; n++ { + CutDiffAroundLine(strings.NewReader(exampleDiff), 3, true, 3) + } +} + +func TestParseDiffHunkString(t *testing.T) { + leftLine, leftHunk, rightLine, rightHunk := ParseDiffHunkString("@@ -19,3 +19,5 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER") + assert.EqualValues(t, 19, leftLine) + assert.EqualValues(t, 3, leftHunk) + assert.EqualValues(t, 19, rightLine) + assert.EqualValues(t, 5, rightHunk) +} diff --git a/modules/git/error.go b/modules/git/error.go new file mode 100644 index 0000000..91d25ec --- /dev/null +++ b/modules/git/error.go @@ -0,0 +1,187 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "fmt" + "strings" + "time" + + "code.gitea.io/gitea/modules/util" +) + +// ErrExecTimeout error when exec timed out +type ErrExecTimeout struct { + Duration time.Duration +} + +// IsErrExecTimeout if some error is ErrExecTimeout +func IsErrExecTimeout(err error) bool { + _, ok := err.(ErrExecTimeout) + return ok +} + +func (err ErrExecTimeout) Error() string { + return fmt.Sprintf("execution is timeout [duration: %v]", err.Duration) +} + +// ErrNotExist commit not exist error +type ErrNotExist struct { + ID string + RelPath string +} + +// IsErrNotExist if some error is ErrNotExist +func IsErrNotExist(err error) bool { + _, ok := err.(ErrNotExist) + return ok +} + +func (err ErrNotExist) Error() string { + return fmt.Sprintf("object does not exist [id: %s, rel_path: %s]", err.ID, err.RelPath) +} + +func (err ErrNotExist) Unwrap() error { + return util.ErrNotExist +} + +// ErrBadLink entry.FollowLink error +type ErrBadLink struct { + Name string + Message string +} + +func (err ErrBadLink) Error() string { + return fmt.Sprintf("%s: %s", err.Name, err.Message) +} + +// IsErrBadLink if some error is ErrBadLink +func IsErrBadLink(err error) bool { + _, ok := err.(ErrBadLink) + return ok +} + +// ErrUnsupportedVersion error when required git version not matched +type ErrUnsupportedVersion struct { + Required string +} + +// IsErrUnsupportedVersion if some error is ErrUnsupportedVersion +func IsErrUnsupportedVersion(err error) bool { + _, ok := err.(ErrUnsupportedVersion) + return ok +} + +func (err ErrUnsupportedVersion) Error() string { + return fmt.Sprintf("Operation requires higher version [required: %s]", err.Required) +} + +// ErrBranchNotExist represents a "BranchNotExist" kind of error. +type ErrBranchNotExist struct { + Name string +} + +// IsErrBranchNotExist checks if an error is a ErrBranchNotExist. +func IsErrBranchNotExist(err error) bool { + _, ok := err.(ErrBranchNotExist) + return ok +} + +func (err ErrBranchNotExist) Error() string { + return fmt.Sprintf("branch does not exist [name: %s]", err.Name) +} + +func (err ErrBranchNotExist) Unwrap() error { + return util.ErrNotExist +} + +// ErrPushOutOfDate represents an error if merging fails due to the base branch being updated +type ErrPushOutOfDate struct { + StdOut string + StdErr string + Err error +} + +// IsErrPushOutOfDate checks if an error is a ErrPushOutOfDate. +func IsErrPushOutOfDate(err error) bool { + _, ok := err.(*ErrPushOutOfDate) + return ok +} + +func (err *ErrPushOutOfDate) Error() string { + return fmt.Sprintf("PushOutOfDate Error: %v: %s\n%s", err.Err, err.StdErr, err.StdOut) +} + +// Unwrap unwraps the underlying error +func (err *ErrPushOutOfDate) Unwrap() error { + return fmt.Errorf("%w - %s", err.Err, err.StdErr) +} + +// ErrPushRejected represents an error if merging fails due to rejection from a hook +type ErrPushRejected struct { + Message string + StdOut string + StdErr string + Err error +} + +// IsErrPushRejected checks if an error is a ErrPushRejected. +func IsErrPushRejected(err error) bool { + _, ok := err.(*ErrPushRejected) + return ok +} + +func (err *ErrPushRejected) Error() string { + return fmt.Sprintf("PushRejected Error: %v: %s\n%s", err.Err, err.StdErr, err.StdOut) +} + +// Unwrap unwraps the underlying error +func (err *ErrPushRejected) Unwrap() error { + return fmt.Errorf("%w - %s", err.Err, err.StdErr) +} + +// GenerateMessage generates the remote message from the stderr +func (err *ErrPushRejected) GenerateMessage() { + messageBuilder := &strings.Builder{} + i := strings.Index(err.StdErr, "remote: ") + if i < 0 { + err.Message = "" + 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) + } + } + err.Message = strings.TrimSpace(messageBuilder.String()) +} + +// ErrMoreThanOne represents an error if pull request fails when there are more than one sources (branch, tag) with the same name +type ErrMoreThanOne struct { + StdOut string + StdErr string + Err error +} + +// IsErrMoreThanOne checks if an error is a ErrMoreThanOne +func IsErrMoreThanOne(err error) bool { + _, ok := err.(*ErrMoreThanOne) + return ok +} + +func (err *ErrMoreThanOne) Error() string { + return fmt.Sprintf("ErrMoreThanOne Error: %v: %s\n%s", err.Err, err.StdErr, err.StdOut) +} diff --git a/modules/git/foreachref/format.go b/modules/git/foreachref/format.go new file mode 100644 index 0000000..97e8ee4 --- /dev/null +++ b/modules/git/foreachref/format.go @@ -0,0 +1,83 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package foreachref + +import ( + "encoding/hex" + "fmt" + "io" + "strings" +) + +var ( + nullChar = []byte("\x00") + dualNullChar = []byte("\x00\x00") +) + +// Format supports specifying and parsing an output format for 'git +// for-each-ref'. See See git-for-each-ref(1) for available fields. +type Format struct { + // fieldNames hold %(fieldname)s to be passed to the '--format' flag of + // for-each-ref. See git-for-each-ref(1) for available fields. + fieldNames []string + + // fieldDelim is the character sequence that is used to separate fields + // for each reference. fieldDelim and refDelim should be selected to not + // interfere with each other and to not be present in field values. + fieldDelim []byte + // fieldDelimStr is a string representation of fieldDelim. Used to save + // us from repetitive reallocation whenever we need the delimiter as a + // string. + fieldDelimStr string + // refDelim is the character sequence used to separate reference from + // each other in the output. fieldDelim and refDelim should be selected + // to not interfere with each other and to not be present in field + // values. + refDelim []byte +} + +// NewFormat creates a forEachRefFormat using the specified fieldNames. See +// git-for-each-ref(1) for available fields. +func NewFormat(fieldNames ...string) Format { + return Format{ + fieldNames: fieldNames, + fieldDelim: nullChar, + fieldDelimStr: string(nullChar), + refDelim: dualNullChar, + } +} + +// Flag returns a for-each-ref --format flag value that captures the fieldNames. +func (f Format) Flag() string { + var formatFlag strings.Builder + for i, field := range f.fieldNames { + // field key and field value + formatFlag.WriteString(fmt.Sprintf("%s %%(%s)", field, field)) + + if i < len(f.fieldNames)-1 { + // note: escape delimiters to allow control characters as + // delimiters. For example, '%00' for null character or '%0a' + // for newline. + formatFlag.WriteString(f.hexEscaped(f.fieldDelim)) + } + } + formatFlag.WriteString(f.hexEscaped(f.refDelim)) + return formatFlag.String() +} + +// Parser returns a Parser capable of parsing 'git for-each-ref' output produced +// with this Format. +func (f Format) Parser(r io.Reader) *Parser { + return NewParser(r, f) +} + +// hexEscaped produces hex-escpaed characters from a string. For example, "\n\0" +// would turn into "%0a%00". +func (f Format) hexEscaped(delim []byte) string { + escaped := "" + for i := 0; i < len(delim); i++ { + escaped += "%" + hex.EncodeToString([]byte{delim[i]}) + } + return escaped +} diff --git a/modules/git/foreachref/format_test.go b/modules/git/foreachref/format_test.go new file mode 100644 index 0000000..8ff2393 --- /dev/null +++ b/modules/git/foreachref/format_test.go @@ -0,0 +1,66 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package foreachref_test + +import ( + "testing" + + "code.gitea.io/gitea/modules/git/foreachref" + + "github.com/stretchr/testify/require" +) + +func TestFormat_Flag(t *testing.T) { + tests := []struct { + name string + + givenFormat foreachref.Format + + wantFlag string + }{ + { + name: "references are delimited by dual null chars", + + // no reference fields requested + givenFormat: foreachref.NewFormat(), + + // only a reference delimiter field in --format + wantFlag: "%00%00", + }, + + { + name: "a field is a space-separated key-value pair", + + givenFormat: foreachref.NewFormat("refname:short"), + + // only a reference delimiter field + wantFlag: "refname:short %(refname:short)%00%00", + }, + + { + name: "fields are separated by a null char field-delimiter", + + givenFormat: foreachref.NewFormat("refname:short", "author"), + + wantFlag: "refname:short %(refname:short)%00author %(author)%00%00", + }, + + { + name: "multiple fields", + + givenFormat: foreachref.NewFormat("refname:lstrip=2", "objecttype", "objectname"), + + wantFlag: "refname:lstrip=2 %(refname:lstrip=2)%00objecttype %(objecttype)%00objectname %(objectname)%00%00", + }, + } + + for _, test := range tests { + tc := test // don't close over loop variable + t.Run(tc.name, func(t *testing.T) { + gotFlag := tc.givenFormat.Flag() + + require.Equal(t, tc.wantFlag, gotFlag, "unexpected for-each-ref --format string. wanted: '%s', got: '%s'", tc.wantFlag, gotFlag) + }) + } +} diff --git a/modules/git/foreachref/parser.go b/modules/git/foreachref/parser.go new file mode 100644 index 0000000..de69eaa --- /dev/null +++ b/modules/git/foreachref/parser.go @@ -0,0 +1,128 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package foreachref + +import ( + "bufio" + "bytes" + "fmt" + "io" + "strings" +) + +// Parser parses 'git for-each-ref' output according to a given output Format. +type Parser struct { + // tokenizes 'git for-each-ref' output into "reference paragraphs". + scanner *bufio.Scanner + + // format represents the '--format' string that describes the expected + // 'git for-each-ref' output structure. + format Format + + // err holds the last encountered error during parsing. + err error +} + +// NewParser creates a 'git for-each-ref' output parser that will parse all +// references in the provided Reader. The references in the output are assumed +// to follow the specified Format. +func NewParser(r io.Reader, format Format) *Parser { + scanner := bufio.NewScanner(r) + + // in addition to the reference delimiter we specified in the --format, + // `git for-each-ref` will always add a newline after every reference. + refDelim := make([]byte, 0, len(format.refDelim)+1) + refDelim = append(refDelim, format.refDelim...) + refDelim = append(refDelim, '\n') + + // Split input into delimiter-separated "reference blocks". + scanner.Split( + func(data []byte, atEOF bool) (advance int, token []byte, err error) { + // Scan until delimiter, marking end of reference. + delimIdx := bytes.Index(data, refDelim) + if delimIdx >= 0 { + token := data[:delimIdx] + advance := delimIdx + len(refDelim) + return advance, token, nil + } + // If we're at EOF, we have a final, non-terminated reference. Return it. + if atEOF { + return len(data), data, nil + } + // Not yet a full field. Request more data. + return 0, nil, nil + }) + + return &Parser{ + scanner: scanner, + format: format, + err: nil, + } +} + +// Next returns the next reference as a collection of key-value pairs. nil +// denotes EOF but is also returned on errors. The Err method should always be +// consulted after Next returning nil. +// +// It could, for example return something like: +// +// { "objecttype": "tag", "refname:short": "v1.16.4", "object": "f460b7543ed500e49c133c2cd85c8c55ee9dbe27" } +func (p *Parser) Next() map[string]string { + if !p.scanner.Scan() { + return nil + } + fields, err := p.parseRef(p.scanner.Text()) + if err != nil { + p.err = err + return nil + } + return fields +} + +// Err returns the latest encountered parsing error. +func (p *Parser) Err() error { + return p.err +} + +// parseRef parses out all key-value pairs from a single reference block, such as +// +// "objecttype tag\0refname:short v1.16.4\0object f460b7543ed500e49c133c2cd85c8c55ee9dbe27" +func (p *Parser) parseRef(refBlock string) (map[string]string, error) { + if refBlock == "" { + // must be at EOF + return nil, nil + } + + fieldValues := make(map[string]string) + + fields := strings.Split(refBlock, p.format.fieldDelimStr) + if len(fields) != len(p.format.fieldNames) { + return nil, fmt.Errorf("unexpected number of reference fields: wanted %d, was %d", + len(fields), len(p.format.fieldNames)) + } + for i, field := range fields { + field = strings.TrimSpace(field) + + var fieldKey string + var fieldVal string + firstSpace := strings.Index(field, " ") + if firstSpace > 0 { + fieldKey = field[:firstSpace] + fieldVal = field[firstSpace+1:] + } else { + // could be the case if the requested field had no value + fieldKey = field + } + + // enforce the format order of fields + if p.format.fieldNames[i] != fieldKey { + return nil, fmt.Errorf("unexpected field name at position %d: wanted: '%s', was: '%s'", + i, p.format.fieldNames[i], fieldKey) + } + + fieldValues[fieldKey] = fieldVal + } + + return fieldValues, nil +} diff --git a/modules/git/foreachref/parser_test.go b/modules/git/foreachref/parser_test.go new file mode 100644 index 0000000..7a37ced --- /dev/null +++ b/modules/git/foreachref/parser_test.go @@ -0,0 +1,227 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package foreachref_test + +import ( + "errors" + "fmt" + "io" + "strings" + "testing" + + "code.gitea.io/gitea/modules/git/foreachref" + "code.gitea.io/gitea/modules/json" + + "github.com/stretchr/testify/require" +) + +type refSlice = []map[string]string + +func TestParser(t *testing.T) { + tests := []struct { + name string + + givenFormat foreachref.Format + givenInput io.Reader + + wantRefs refSlice + wantErr bool + expectedErr error + }{ + // this would, for example, be the result when running `git + // for-each-ref refs/tags` on a repo without tags. + { + name: "no references on empty input", + + givenFormat: foreachref.NewFormat("refname:short"), + givenInput: strings.NewReader(``), + + wantRefs: []map[string]string{}, + }, + + // note: `git for-each-ref` will add a newline between every + // reference (in addition to the ref-delimiter we've chosen) + { + name: "single field requested, single reference in output", + + givenFormat: foreachref.NewFormat("refname:short"), + givenInput: strings.NewReader("refname:short v0.0.1\x00\x00" + "\n"), + + wantRefs: []map[string]string{ + {"refname:short": "v0.0.1"}, + }, + }, + { + name: "single field requested, multiple references in output", + + givenFormat: foreachref.NewFormat("refname:short"), + givenInput: strings.NewReader( + "refname:short v0.0.1\x00\x00" + "\n" + + "refname:short v0.0.2\x00\x00" + "\n" + + "refname:short v0.0.3\x00\x00" + "\n"), + + wantRefs: []map[string]string{ + {"refname:short": "v0.0.1"}, + {"refname:short": "v0.0.2"}, + {"refname:short": "v0.0.3"}, + }, + }, + + { + name: "multiple fields requested for each reference", + + givenFormat: foreachref.NewFormat("refname:short", "objecttype", "objectname"), + givenInput: strings.NewReader( + + "refname:short v0.0.1\x00objecttype commit\x00objectname 7b2c5ac9fc04fc5efafb60700713d4fa609b777b\x00\x00" + "\n" + + "refname:short v0.0.2\x00objecttype commit\x00objectname a1f051bc3eba734da4772d60e2d677f47cf93ef4\x00\x00" + "\n" + + "refname:short v0.0.3\x00objecttype commit\x00objectname ef82de70bb3f60c65fb8eebacbb2d122ef517385\x00\x00" + "\n", + ), + + wantRefs: []map[string]string{ + { + "refname:short": "v0.0.1", + "objecttype": "commit", + "objectname": "7b2c5ac9fc04fc5efafb60700713d4fa609b777b", + }, + { + "refname:short": "v0.0.2", + "objecttype": "commit", + "objectname": "a1f051bc3eba734da4772d60e2d677f47cf93ef4", + }, + { + "refname:short": "v0.0.3", + "objecttype": "commit", + "objectname": "ef82de70bb3f60c65fb8eebacbb2d122ef517385", + }, + }, + }, + + { + name: "must handle multi-line fields such as 'content'", + + givenFormat: foreachref.NewFormat("refname:short", "contents", "author"), + givenInput: strings.NewReader( + "refname:short v0.0.1\x00contents Create new buffer if not present yet (#549)\n\nFixes a nil dereference when ProcessFoo is used\nwith multiple commands.\x00author Foo Bar <foo@bar.com> 1507832733 +0200\x00\x00" + "\n" + + "refname:short v0.0.2\x00contents Update CI config (#651)\n\n\x00author John Doe <john.doe@foo.com> 1521643174 +0000\x00\x00" + "\n" + + "refname:short v0.0.3\x00contents Fixed code sample for bash completion (#687)\n\n\x00author Foo Baz <foo@baz.com> 1524836750 +0200\x00\x00" + "\n", + ), + + wantRefs: []map[string]string{ + { + "refname:short": "v0.0.1", + "contents": "Create new buffer if not present yet (#549)\n\nFixes a nil dereference when ProcessFoo is used\nwith multiple commands.", + "author": "Foo Bar <foo@bar.com> 1507832733 +0200", + }, + { + "refname:short": "v0.0.2", + "contents": "Update CI config (#651)", + "author": "John Doe <john.doe@foo.com> 1521643174 +0000", + }, + { + "refname:short": "v0.0.3", + "contents": "Fixed code sample for bash completion (#687)", + "author": "Foo Baz <foo@baz.com> 1524836750 +0200", + }, + }, + }, + + { + name: "must handle fields without values", + + givenFormat: foreachref.NewFormat("refname:short", "object", "objecttype"), + givenInput: strings.NewReader( + "refname:short v0.0.1\x00object \x00objecttype commit\x00\x00" + "\n" + + "refname:short v0.0.2\x00object \x00objecttype commit\x00\x00" + "\n" + + "refname:short v0.0.3\x00object \x00objecttype commit\x00\x00" + "\n", + ), + + wantRefs: []map[string]string{ + { + "refname:short": "v0.0.1", + "object": "", + "objecttype": "commit", + }, + { + "refname:short": "v0.0.2", + "object": "", + "objecttype": "commit", + }, + { + "refname:short": "v0.0.3", + "object": "", + "objecttype": "commit", + }, + }, + }, + + { + name: "must fail when the number of fields in the input doesn't match expected format", + + givenFormat: foreachref.NewFormat("refname:short", "objecttype", "objectname"), + givenInput: strings.NewReader( + "refname:short v0.0.1\x00objecttype commit\x00\x00" + "\n" + + "refname:short v0.0.2\x00objecttype commit\x00\x00" + "\n" + + "refname:short v0.0.3\x00objecttype commit\x00\x00" + "\n", + ), + + wantErr: true, + expectedErr: errors.New("unexpected number of reference fields: wanted 2, was 3"), + }, + + { + name: "must fail input fields don't match expected format", + + givenFormat: foreachref.NewFormat("refname:short", "objectname"), + givenInput: strings.NewReader( + "refname:short v0.0.1\x00objecttype commit\x00\x00" + "\n" + + "refname:short v0.0.2\x00objecttype commit\x00\x00" + "\n" + + "refname:short v0.0.3\x00objecttype commit\x00\x00" + "\n", + ), + + wantErr: true, + expectedErr: errors.New("unexpected field name at position 1: wanted: 'objectname', was: 'objecttype'"), + }, + } + + for _, test := range tests { + tc := test // don't close over loop variable + t.Run(tc.name, func(t *testing.T) { + parser := tc.givenFormat.Parser(tc.givenInput) + + // + // parse references from input + // + gotRefs := make([]map[string]string, 0) + for { + ref := parser.Next() + if ref == nil { + break + } + gotRefs = append(gotRefs, ref) + } + err := parser.Err() + + // + // verify expectations + // + if tc.wantErr { + require.Error(t, err) + require.EqualError(t, err, tc.expectedErr.Error()) + } else { + require.NoError(t, err, "for-each-ref parser unexpectedly failed with: %v", err) + require.Equal(t, tc.wantRefs, gotRefs, "for-each-ref parser produced unexpected reference set. wanted: %v, got: %v", pretty(tc.wantRefs), pretty(gotRefs)) + } + }) + } +} + +func pretty(v any) string { + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + // shouldn't happen + panic(fmt.Sprintf("json-marshalling failed: %v", err)) + } + return string(data) +} diff --git a/modules/git/git.go b/modules/git/git.go new file mode 100644 index 0000000..f1174e6 --- /dev/null +++ b/modules/git/git.go @@ -0,0 +1,422 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strings" + "time" + + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + + "github.com/hashicorp/go-version" +) + +// RequiredVersion is the minimum Git version required +const RequiredVersion = "2.0.0" + +var ( + // GitExecutable is the command name of git + // Could be updated to an absolute path while initialization + GitExecutable = "git" + + // DefaultContext is the default context to run git commands in, must be initialized by git.InitXxx + DefaultContext context.Context + + SupportProcReceive bool // >= 2.29 + SupportHashSha256 bool // >= 2.42, SHA-256 repositories no longer an ‘experimental curiosity’ + InvertedGitFlushEnv bool // 2.43.1 + SupportCheckAttrOnBare bool // >= 2.40 + + HasSSHExecutable bool + + gitVersion *version.Version +) + +// loadGitVersion returns current Git version from shell. Internal usage only. +func loadGitVersion() error { + // doesn't need RWMutex because it's executed by Init() + if gitVersion != nil { + return nil + } + stdout, _, runErr := NewCommand(DefaultContext, "version").RunStdString(nil) + if runErr != nil { + return runErr + } + + fields := strings.Fields(stdout) + if len(fields) < 3 { + return fmt.Errorf("invalid git version output: %s", stdout) + } + + var versionString string + + // Handle special case on Windows. + i := strings.Index(fields[2], "windows") + if i >= 1 { + versionString = fields[2][:i-1] + } else { + versionString = fields[2] + } + + var err error + gitVersion, err = version.NewVersion(versionString) + return err +} + +// SetExecutablePath changes the path of git executable and checks the file permission and version. +func SetExecutablePath(path string) error { + // If path is empty, we use the default value of GitExecutable "git" to search for the location of git. + if path != "" { + GitExecutable = path + } + absPath, err := exec.LookPath(GitExecutable) + if err != nil { + return fmt.Errorf("git not found: %w", err) + } + GitExecutable = absPath + + err = loadGitVersion() + if err != nil { + return fmt.Errorf("unable to load git version: %w", err) + } + + versionRequired, err := version.NewVersion(RequiredVersion) + if err != nil { + return err + } + + if gitVersion.LessThan(versionRequired) { + moreHint := "get git: https://git-scm.com/downloads" + if runtime.GOOS == "linux" { + // there are a lot of CentOS/RHEL users using old git, so we add a special hint for them + if _, err = os.Stat("/etc/redhat-release"); err == nil { + // ius.io is the recommended official(git-scm.com) method to install git + moreHint = "get git: https://git-scm.com/downloads/linux and https://ius.io" + } + } + return fmt.Errorf("installed git version %q is not supported, Gitea requires git version >= %q, %s", gitVersion.Original(), RequiredVersion, moreHint) + } + + return nil +} + +// VersionInfo returns git version information +func VersionInfo() string { + if gitVersion == nil { + return "(git not found)" + } + format := "%s" + args := []any{gitVersion.Original()} + // Since git wire protocol has been released from git v2.18 + if setting.Git.EnableAutoGitWireProtocol && CheckGitVersionAtLeast("2.18") == nil { + format += ", Wire Protocol %s Enabled" + args = append(args, "Version 2") // for focus color + } + + return fmt.Sprintf(format, args...) +} + +func checkInit() error { + if setting.Git.HomePath == "" { + return errors.New("unable to init Git's HomeDir, incorrect initialization of the setting and git modules") + } + if DefaultContext != nil { + log.Warn("git module has been initialized already, duplicate init may work but it's better to fix it") + } + return nil +} + +// HomeDir is the home dir for git to store the global config file used by Gitea internally +func HomeDir() string { + if setting.Git.HomePath == "" { + // strict check, make sure the git module is initialized correctly. + // attention: when the git module is called in gitea sub-command (serv/hook), the log module might not obviously show messages to users/developers. + // for example: if there is gitea git hook code calling git.NewCommand before git.InitXxx, the integration test won't show the real failure reasons. + log.Fatal("Unable to init Git's HomeDir, incorrect initialization of the setting and git modules") + return "" + } + return setting.Git.HomePath +} + +// InitSimple initializes git module with a very simple step, no config changes, no global command arguments. +// This method doesn't change anything to filesystem. At the moment, it is only used by some Gitea sub-commands. +func InitSimple(ctx context.Context) error { + if err := checkInit(); err != nil { + return err + } + + DefaultContext = ctx + globalCommandArgs = nil + + if setting.Git.Timeout.Default > 0 { + defaultCommandExecutionTimeout = time.Duration(setting.Git.Timeout.Default) * time.Second + } + + return SetExecutablePath(setting.Git.Path) +} + +// InitFull initializes git module with version check and change global variables, sync gitconfig. +// It should only be called once at the beginning of the program initialization (TestMain/GlobalInitInstalled) as this code makes unsynchronized changes to variables. +func InitFull(ctx context.Context) (err error) { + if err = InitSimple(ctx); err != nil { + return err + } + + // when git works with gnupg (commit signing), there should be a stable home for gnupg commands + if _, ok := os.LookupEnv("GNUPGHOME"); !ok { + _ = os.Setenv("GNUPGHOME", filepath.Join(HomeDir(), ".gnupg")) + } + + // Since git wire protocol has been released from git v2.18 + if setting.Git.EnableAutoGitWireProtocol && CheckGitVersionAtLeast("2.18") == nil { + globalCommandArgs = append(globalCommandArgs, "-c", "protocol.version=2") + } + + // Explicitly disable credential helper, otherwise Git credentials might leak + if CheckGitVersionAtLeast("2.9") == nil { + globalCommandArgs = append(globalCommandArgs, "-c", "credential.helper=") + } + SupportProcReceive = CheckGitVersionAtLeast("2.29") == nil + SupportHashSha256 = CheckGitVersionAtLeast("2.42") == nil + SupportCheckAttrOnBare = CheckGitVersionAtLeast("2.40") == nil + if SupportHashSha256 { + SupportedObjectFormats = append(SupportedObjectFormats, Sha256ObjectFormat) + } else { + log.Warn("sha256 hash support is disabled - requires Git >= 2.42") + } + + InvertedGitFlushEnv = CheckGitVersionEqual("2.43.1") == nil + + if setting.LFS.StartServer { + if CheckGitVersionAtLeast("2.1.2") != nil { + return errors.New("LFS server support requires Git >= 2.1.2") + } + globalCommandArgs = append(globalCommandArgs, "-c", "filter.lfs.required=", "-c", "filter.lfs.smudge=", "-c", "filter.lfs.clean=") + } + + // Detect the presence of the ssh executable in $PATH. + _, err = exec.LookPath("ssh") + HasSSHExecutable = err == nil + + return syncGitConfig() +} + +// syncGitConfig only modifies gitconfig, won't change global variables (otherwise there will be data-race problem) +func syncGitConfig() (err error) { + if err = os.MkdirAll(HomeDir(), os.ModePerm); err != nil { + return fmt.Errorf("unable to prepare git home directory %s, err: %w", HomeDir(), err) + } + + // first, write user's git config options to git config file + // user config options could be overwritten by builtin values later, because if a value is builtin, it must have some special purposes + for k, v := range setting.GitConfig.Options { + if err = configSet(strings.ToLower(k), v); err != nil { + return err + } + } + + // Git requires setting user.name and user.email in order to commit changes - old comment: "if they're not set just add some defaults" + // TODO: need to confirm whether users really need to change these values manually. It seems that these values are dummy only and not really used. + // If these values are not really used, then they can be set (overwritten) directly without considering about existence. + for configKey, defaultValue := range map[string]string{ + "user.name": "Gitea", + "user.email": "gitea@fake.local", + } { + if err := configSetNonExist(configKey, defaultValue); err != nil { + return err + } + } + + // Set git some configurations - these must be set to these values for gitea to work correctly + if err := configSet("core.quotePath", "false"); err != nil { + return err + } + + if CheckGitVersionAtLeast("2.10") == nil { + if err := configSet("receive.advertisePushOptions", "true"); err != nil { + return err + } + } + + if CheckGitVersionAtLeast("2.18") == nil { + if err := configSet("core.commitGraph", "true"); err != nil { + return err + } + if err := configSet("gc.writeCommitGraph", "true"); err != nil { + return err + } + if err := configSet("fetch.writeCommitGraph", "true"); err != nil { + return err + } + } + + if SupportProcReceive { + // set support for AGit flow + if err := configAddNonExist("receive.procReceiveRefs", "refs/for"); err != nil { + return err + } + } else { + if err := configUnsetAll("receive.procReceiveRefs", "refs/for"); err != nil { + return err + } + } + + // Due to CVE-2022-24765, git now denies access to git directories which are not owned by current user + // however, some docker users and samba users find it difficult to configure their systems so that Gitea's git repositories are owned by the Gitea user. (Possibly Windows Service users - but ownership in this case should really be set correctly on the filesystem.) + // see issue: https://github.com/go-gitea/gitea/issues/19455 + // Fundamentally the problem lies with the uid-gid-mapping mechanism for filesystems in docker on windows (and to a lesser extent samba). + // Docker's configuration mechanism for local filesystems provides no way of setting this mapping and although there is a mechanism for setting this uid through using cifs mounting it is complicated and essentially undocumented + // Thus the owner uid/gid for files on these filesystems will be marked as root. + // As Gitea now always use its internal git config file, and access to the git repositories is managed through Gitea, + // it is now safe to set "safe.directory=*" for internal usage only. + // Please note: the wildcard "*" is only supported by Git 2.30.4/2.31.3/2.32.2/2.33.3/2.34.3/2.35.3/2.36 and later + // Although only supported by Git 2.30.4/2.31.3/2.32.2/2.33.3/2.34.3/2.35.3/2.36 and later - this setting is tolerated by earlier versions + if err := configAddNonExist("safe.directory", "*"); err != nil { + return err + } + if runtime.GOOS == "windows" { + if err := configSet("core.longpaths", "true"); err != nil { + return err + } + if setting.Git.DisableCoreProtectNTFS { + err = configSet("core.protectNTFS", "false") + } else { + err = configUnsetAll("core.protectNTFS", "false") + } + if err != nil { + return err + } + } + + // By default partial clones are disabled, enable them from git v2.22 + if !setting.Git.DisablePartialClone && CheckGitVersionAtLeast("2.22") == nil { + if err = configSet("uploadpack.allowfilter", "true"); err != nil { + return err + } + err = configSet("uploadpack.allowAnySHA1InWant", "true") + } else { + if err = configUnsetAll("uploadpack.allowfilter", "true"); err != nil { + return err + } + err = configUnsetAll("uploadpack.allowAnySHA1InWant", "true") + } + + return err +} + +// CheckGitVersionAtLeast check git version is at least the constraint version +func CheckGitVersionAtLeast(atLeast string) error { + if err := loadGitVersion(); err != nil { + return err + } + atLeastVersion, err := version.NewVersion(atLeast) + if err != nil { + return err + } + if gitVersion.Compare(atLeastVersion) < 0 { + return fmt.Errorf("installed git binary version %s is not at least %s", gitVersion.Original(), atLeast) + } + return nil +} + +// CheckGitVersionEqual checks if the git version is equal to the constraint version. +func CheckGitVersionEqual(equal string) error { + if err := loadGitVersion(); err != nil { + return err + } + atLeastVersion, err := version.NewVersion(equal) + if err != nil { + return err + } + if !gitVersion.Equal(atLeastVersion) { + return fmt.Errorf("installed git binary version %s is not equal to %s", gitVersion.Original(), equal) + } + return nil +} + +func configSet(key, value string) error { + stdout, _, err := NewCommand(DefaultContext, "config", "--global", "--get").AddDynamicArguments(key).RunStdString(nil) + if err != nil && !IsErrorExitCode(err, 1) { + return fmt.Errorf("failed to get git config %s, err: %w", key, err) + } + + currValue := strings.TrimSpace(stdout) + if currValue == value { + return nil + } + + _, _, err = NewCommand(DefaultContext, "config", "--global").AddDynamicArguments(key, value).RunStdString(nil) + if err != nil { + return fmt.Errorf("failed to set git global config %s, err: %w", key, err) + } + + return nil +} + +func configSetNonExist(key, value string) error { + _, _, err := NewCommand(DefaultContext, "config", "--global", "--get").AddDynamicArguments(key).RunStdString(nil) + if err == nil { + // already exist + return nil + } + if IsErrorExitCode(err, 1) { + // not exist, set new config + _, _, err = NewCommand(DefaultContext, "config", "--global").AddDynamicArguments(key, value).RunStdString(nil) + if err != nil { + return fmt.Errorf("failed to set git global config %s, err: %w", key, err) + } + return nil + } + + return fmt.Errorf("failed to get git config %s, err: %w", key, err) +} + +func configAddNonExist(key, value string) error { + _, _, err := NewCommand(DefaultContext, "config", "--global", "--get").AddDynamicArguments(key, regexp.QuoteMeta(value)).RunStdString(nil) + if err == nil { + // already exist + return nil + } + if IsErrorExitCode(err, 1) { + // not exist, add new config + _, _, err = NewCommand(DefaultContext, "config", "--global", "--add").AddDynamicArguments(key, value).RunStdString(nil) + if err != nil { + return fmt.Errorf("failed to add git global config %s, err: %w", key, err) + } + return nil + } + return fmt.Errorf("failed to get git config %s, err: %w", key, err) +} + +func configUnsetAll(key, value string) error { + _, _, err := NewCommand(DefaultContext, "config", "--global", "--get").AddDynamicArguments(key).RunStdString(nil) + if err == nil { + // exist, need to remove + _, _, err = NewCommand(DefaultContext, "config", "--global", "--unset-all").AddDynamicArguments(key, regexp.QuoteMeta(value)).RunStdString(nil) + if err != nil { + return fmt.Errorf("failed to unset git global config %s, err: %w", key, err) + } + return nil + } + if IsErrorExitCode(err, 1) { + // not exist + return nil + } + return fmt.Errorf("failed to get git config %s, err: %w", key, err) +} + +// Fsck verifies the connectivity and validity of the objects in the database +func Fsck(ctx context.Context, repoPath string, timeout time.Duration, args TrustedCmdArgs) error { + return NewCommand(ctx, "fsck").AddArguments(args...).Run(&RunOpts{Timeout: timeout, Dir: repoPath}) +} diff --git a/modules/git/git_test.go b/modules/git/git_test.go new file mode 100644 index 0000000..cdbd2a1 --- /dev/null +++ b/modules/git/git_test.go @@ -0,0 +1,96 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +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 = InitFull(context.Background()); 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) + } +} + +func gitConfigContains(sub string) bool { + if b, err := os.ReadFile(HomeDir() + "/.gitconfig"); err == nil { + return strings.Contains(string(b), sub) + } + return false +} + +func TestGitConfig(t *testing.T) { + assert.False(t, gitConfigContains("key-a")) + + require.NoError(t, configSetNonExist("test.key-a", "val-a")) + assert.True(t, gitConfigContains("key-a = val-a")) + + require.NoError(t, configSetNonExist("test.key-a", "val-a-changed")) + assert.False(t, gitConfigContains("key-a = val-a-changed")) + + require.NoError(t, configSet("test.key-a", "val-a-changed")) + assert.True(t, gitConfigContains("key-a = val-a-changed")) + + require.NoError(t, configAddNonExist("test.key-b", "val-b")) + assert.True(t, gitConfigContains("key-b = val-b")) + + require.NoError(t, configAddNonExist("test.key-b", "val-2b")) + assert.True(t, gitConfigContains("key-b = val-b")) + assert.True(t, gitConfigContains("key-b = val-2b")) + + require.NoError(t, configUnsetAll("test.key-b", "val-b")) + assert.False(t, gitConfigContains("key-b = val-b")) + assert.True(t, gitConfigContains("key-b = val-2b")) + + require.NoError(t, configUnsetAll("test.key-b", "val-2b")) + assert.False(t, gitConfigContains("key-b = val-2b")) + + require.NoError(t, configSet("test.key-x", "*")) + assert.True(t, gitConfigContains("key-x = *")) + require.NoError(t, configSetNonExist("test.key-x", "*")) + require.NoError(t, configUnsetAll("test.key-x", "*")) + assert.False(t, gitConfigContains("key-x = *")) +} + +func TestSyncConfig(t *testing.T) { + oldGitConfig := setting.GitConfig + defer func() { + setting.GitConfig = oldGitConfig + }() + + setting.GitConfig.Options["sync-test.cfg-key-a"] = "CfgValA" + require.NoError(t, syncGitConfig()) + assert.True(t, gitConfigContains("[sync-test]")) + assert.True(t, gitConfigContains("cfg-key-a = CfgValA")) +} diff --git a/modules/git/grep.go b/modules/git/grep.go new file mode 100644 index 0000000..5572bd9 --- /dev/null +++ b/modules/git/grep.go @@ -0,0 +1,187 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "bufio" + "bytes" + "cmp" + "context" + "errors" + "fmt" + "io" + "os" + "strconv" + "strings" + "time" + + "code.gitea.io/gitea/modules/setting" +) + +type GrepResult struct { + Filename string + LineNumbers []int + LineCodes []string + HighlightedRanges [][3]int +} + +type GrepOptions struct { + RefName string + MaxResultLimit int + MatchesPerFile int + ContextLineNumber int + IsFuzzy bool + PathSpec []setting.Glob +} + +func (opts *GrepOptions) ensureDefaults() { + opts.RefName = cmp.Or(opts.RefName, "HEAD") + opts.MaxResultLimit = cmp.Or(opts.MaxResultLimit, 50) + opts.MatchesPerFile = cmp.Or(opts.MatchesPerFile, 20) +} + +func hasPrefixFold(s, t string) bool { + if len(s) < len(t) { + return false + } + return strings.EqualFold(s[:len(t)], t) +} + +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() + }() + + opts.ensureDefaults() + + /* + The output is like this ("^@" means \x00; the first number denotes the line, + the second number denotes the column of the first match in line): + + HEAD:.air.toml + 6^@8^@bin = "gitea" + + HEAD:.changelog.yml + 2^@10^@repo: go-gitea/gitea + */ + var results []*GrepResult + // -I skips binary files + cmd := NewCommand(ctx, "grep", + "-I", "--null", "--break", "--heading", "--column", + "--fixed-strings", "--line-number", "--ignore-case", "--full-name") + cmd.AddOptionValues("--context", fmt.Sprint(opts.ContextLineNumber)) + cmd.AddOptionValues("--max-count", fmt.Sprint(opts.MatchesPerFile)) + words := []string{search} + if opts.IsFuzzy { + words = strings.Fields(search) + } + for _, word := range words { + cmd.AddGitGrepExpression(word) + } + + // pathspec + files := make([]string, 0, + len(setting.Indexer.IncludePatterns)+ + len(setting.Indexer.ExcludePatterns)+ + len(opts.PathSpec)) + for _, expr := range append(setting.Indexer.IncludePatterns, opts.PathSpec...) { + files = append(files, ":"+expr.Pattern()) + } + for _, expr := range setting.Indexer.ExcludePatterns { + files = append(files, ":^"+expr.Pattern()) + } + cmd.AddDynamicArguments(opts.RefName).AddDashesAndList(files...) + + stderr := bytes.Buffer{} + err = cmd.Run(&RunOpts{ + Timeout: time.Duration(setting.Git.Timeout.Grep) * time.Second, + + Dir: repo.Path, + Stdout: stdoutWriter, + Stderr: &stderr, + PipelineFunc: func(ctx context.Context, cancel context.CancelFunc) error { + _ = stdoutWriter.Close() + defer stdoutReader.Close() + + isInBlock := false + scanner := bufio.NewReader(stdoutReader) + var res *GrepResult + for { + line, err := scanner.ReadString('\n') + if err != nil { + if err == io.EOF { + return nil + } + return err + } + // Remove delimiter. + if len(line) > 0 { + line = line[:len(line)-1] + } + + if !isInBlock { + if _ /* ref */, filename, ok := strings.Cut(line, ":"); ok { + isInBlock = true + res = &GrepResult{Filename: filename} + results = append(results, res) + } + continue + } + if line == "" { + if len(results) >= opts.MaxResultLimit { + cancel() + break + } + isInBlock = false + continue + } + if line == "--" { + continue + } + if lineNum, lineCode, ok := strings.Cut(line, "\x00"); ok { + lineNumInt, _ := strconv.Atoi(lineNum) + res.LineNumbers = append(res.LineNumbers, lineNumInt) + if lineCol, lineCode2, ok := strings.Cut(lineCode, "\x00"); ok { + lineColInt, _ := strconv.Atoi(lineCol) + start := lineColInt - 1 + matchLen := len(lineCode2) + for _, word := range words { + if hasPrefixFold(lineCode2[start:], word) { + matchLen = len(word) + break + } + } + res.HighlightedRanges = append(res.HighlightedRanges, [3]int{ + len(res.LineCodes), + start, + start + matchLen, + }) + res.LineCodes = append(res.LineCodes, lineCode2) + continue + } + res.LineCodes = append(res.LineCodes, lineCode) + } + } + return nil + }, + }) + // git grep exits by cancel (killed), usually it is caused by the limit of results + if IsErrorExitCode(err, -1) && stderr.Len() == 0 { + return results, nil + } + // git grep exits with 1 if no results are found + if IsErrorExitCode(err, 1) && stderr.Len() == 0 { + 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 results, nil +} diff --git a/modules/git/grep_test.go b/modules/git/grep_test.go new file mode 100644 index 0000000..3ba7a6e --- /dev/null +++ b/modules/git/grep_test.go @@ -0,0 +1,203 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "bytes" + "context" + "os" + "path" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGrepSearch(t *testing.T) { + repo, err := openRepositoryWithDefaultContext(filepath.Join(testReposDir, "language_stats_repo")) + require.NoError(t, err) + defer repo.Close() + + res, err := GrepSearch(context.Background(), repo, "public", GrepOptions{}) + require.NoError(t, err) + assert.Equal(t, []*GrepResult{ + { + Filename: "java-hello/main.java", + LineNumbers: []int{1, 3}, + LineCodes: []string{ + "public class HelloWorld", + " public static void main(String[] args)", + }, + HighlightedRanges: [][3]int{{0, 0, 6}, {1, 1, 7}}, + }, + { + Filename: "main.vendor.java", + LineNumbers: []int{1, 3}, + LineCodes: []string{ + "public class HelloWorld", + " public static void main(String[] args)", + }, + HighlightedRanges: [][3]int{{0, 0, 6}, {1, 1, 7}}, + }, + }, res) + + res, err = GrepSearch(context.Background(), repo, "void", GrepOptions{MaxResultLimit: 1, ContextLineNumber: 2}) + require.NoError(t, err) + assert.Equal(t, []*GrepResult{ + { + Filename: "java-hello/main.java", + LineNumbers: []int{1, 2, 3, 4, 5}, + LineCodes: []string{ + "public class HelloWorld", + "{", + " public static void main(String[] args)", + " {", + " System.out.println(\"Hello world!\");", + }, + HighlightedRanges: [][3]int{{2, 15, 19}}, + }, + }, res) + + res, err = GrepSearch(context.Background(), repo, "world", GrepOptions{MatchesPerFile: 1}) + require.NoError(t, err) + assert.Equal(t, []*GrepResult{ + { + Filename: "i-am-a-python.p", + LineNumbers: []int{1}, + LineCodes: []string{"## This is a simple file to do a hello world"}, + HighlightedRanges: [][3]int{{0, 39, 44}}, + }, + { + Filename: "java-hello/main.java", + LineNumbers: []int{1}, + LineCodes: []string{"public class HelloWorld"}, + HighlightedRanges: [][3]int{{0, 18, 23}}, + }, + { + Filename: "main.vendor.java", + LineNumbers: []int{1}, + LineCodes: []string{"public class HelloWorld"}, + HighlightedRanges: [][3]int{{0, 18, 23}}, + }, + { + Filename: "python-hello/hello.py", + LineNumbers: []int{1}, + LineCodes: []string{"## This is a simple file to do a hello world"}, + HighlightedRanges: [][3]int{{0, 39, 44}}, + }, + }, res) + + res, err = GrepSearch(context.Background(), repo, "no-such-content", GrepOptions{}) + require.NoError(t, err) + assert.Empty(t, res) + + res, err = GrepSearch(context.Background(), &Repository{Path: "no-such-git-repo"}, "no-such-content", GrepOptions{}) + require.Error(t, err) + assert.Empty(t, res) +} + +func TestGrepDashesAreFine(t *testing.T) { + tmpDir := t.TempDir() + + err := InitRepository(DefaultContext, tmpDir, false, Sha1ObjectFormat.Name()) + require.NoError(t, err) + + gitRepo, err := openRepositoryWithDefaultContext(tmpDir) + require.NoError(t, err) + defer gitRepo.Close() + + require.NoError(t, os.WriteFile(path.Join(tmpDir, "with-dashes"), []byte("--"), 0o666)) + require.NoError(t, os.WriteFile(path.Join(tmpDir, "without-dashes"), []byte(".."), 0o666)) + + err = AddChanges(tmpDir, true) + require.NoError(t, err) + + err = CommitChanges(tmpDir, CommitChangesOptions{Message: "Dashes are cool sometimes"}) + require.NoError(t, err) + + res, err := GrepSearch(context.Background(), gitRepo, "--", GrepOptions{}) + require.NoError(t, err) + assert.Len(t, res, 1) + assert.Equal(t, "with-dashes", res[0].Filename) +} + +func TestGrepNoBinary(t *testing.T) { + tmpDir := t.TempDir() + + err := InitRepository(DefaultContext, tmpDir, false, Sha1ObjectFormat.Name()) + require.NoError(t, err) + + gitRepo, err := openRepositoryWithDefaultContext(tmpDir) + require.NoError(t, err) + defer gitRepo.Close() + + require.NoError(t, os.WriteFile(path.Join(tmpDir, "BINARY"), []byte("I AM BINARY\n\x00\nYOU WON'T SEE ME"), 0o666)) + require.NoError(t, os.WriteFile(path.Join(tmpDir, "TEXT"), []byte("I AM NOT BINARY\nYOU WILL SEE ME"), 0o666)) + + err = AddChanges(tmpDir, true) + require.NoError(t, err) + + err = CommitChanges(tmpDir, CommitChangesOptions{Message: "Binary and text files"}) + require.NoError(t, err) + + res, err := GrepSearch(context.Background(), gitRepo, "BINARY", GrepOptions{}) + require.NoError(t, err) + assert.Len(t, res, 1) + assert.Equal(t, "TEXT", res[0].Filename) +} + +func TestGrepLongFiles(t *testing.T) { + tmpDir := t.TempDir() + + err := InitRepository(DefaultContext, tmpDir, false, Sha1ObjectFormat.Name()) + require.NoError(t, err) + + gitRepo, err := openRepositoryWithDefaultContext(tmpDir) + require.NoError(t, err) + defer gitRepo.Close() + + require.NoError(t, os.WriteFile(path.Join(tmpDir, "README.md"), bytes.Repeat([]byte{'a'}, 65*1024), 0o666)) + + err = AddChanges(tmpDir, true) + require.NoError(t, err) + + err = CommitChanges(tmpDir, CommitChangesOptions{Message: "Long file"}) + require.NoError(t, err) + + res, err := GrepSearch(context.Background(), gitRepo, "a", GrepOptions{}) + require.NoError(t, err) + assert.Len(t, res, 1) + assert.Len(t, res[0].LineCodes[0], 65*1024) +} + +func TestGrepRefs(t *testing.T) { + tmpDir := t.TempDir() + + err := InitRepository(DefaultContext, tmpDir, false, Sha1ObjectFormat.Name()) + require.NoError(t, err) + + gitRepo, err := openRepositoryWithDefaultContext(tmpDir) + require.NoError(t, err) + defer gitRepo.Close() + + require.NoError(t, os.WriteFile(path.Join(tmpDir, "README.md"), []byte{'A'}, 0o666)) + require.NoError(t, AddChanges(tmpDir, true)) + + err = CommitChanges(tmpDir, CommitChangesOptions{Message: "add A"}) + require.NoError(t, err) + + require.NoError(t, gitRepo.CreateTag("v1", "HEAD")) + + require.NoError(t, os.WriteFile(path.Join(tmpDir, "README.md"), []byte{'A', 'B', 'C', 'D'}, 0o666)) + require.NoError(t, AddChanges(tmpDir, true)) + + err = CommitChanges(tmpDir, CommitChangesOptions{Message: "add BCD"}) + require.NoError(t, err) + + res, err := GrepSearch(context.Background(), gitRepo, "a", GrepOptions{RefName: "v1"}) + require.NoError(t, err) + assert.Len(t, res, 1) + assert.Equal(t, "A", res[0].LineCodes[0]) +} diff --git a/modules/git/hook.go b/modules/git/hook.go new file mode 100644 index 0000000..46f93ce --- /dev/null +++ b/modules/git/hook.go @@ -0,0 +1,143 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "errors" + "os" + "path" + "path/filepath" + "strings" + + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/util" +) + +// hookNames is a list of Git server hooks' name that are supported. +var hookNames = []string{ + "pre-receive", + "update", + "post-receive", +} + +// ErrNotValidHook error when a git hook is not valid +var ErrNotValidHook = errors.New("not a valid Git hook") + +// IsValidHookName returns true if given name is a valid Git hook. +func IsValidHookName(name string) bool { + for _, hn := range hookNames { + if hn == name { + return true + } + } + return false +} + +// Hook represents a Git hook. +type Hook struct { + name string + IsActive bool // Indicates whether repository has this hook. + Content string // Content of hook if it's active. + Sample string // Sample content from Git. + path string // Hook file path. +} + +// GetHook returns a Git hook by given name and repository. +func GetHook(repoPath, name string) (*Hook, error) { + if !IsValidHookName(name) { + return nil, ErrNotValidHook + } + h := &Hook{ + name: name, + path: path.Join(repoPath, "hooks", name+".d", name), + } + samplePath := filepath.Join(repoPath, "hooks", name+".sample") + if isFile(h.path) { + data, err := os.ReadFile(h.path) + if err != nil { + return nil, err + } + h.IsActive = true + h.Content = string(data) + } else if isFile(samplePath) { + data, err := os.ReadFile(samplePath) + if err != nil { + return nil, err + } + h.Sample = string(data) + } + return h, nil +} + +// Name return the name of the hook +func (h *Hook) Name() string { + return h.name +} + +// Update updates hook settings. +func (h *Hook) Update() error { + if len(strings.TrimSpace(h.Content)) == 0 { + if isExist(h.path) { + err := util.Remove(h.path) + if err != nil { + return err + } + } + h.IsActive = false + return nil + } + d := filepath.Dir(h.path) + if err := os.MkdirAll(d, os.ModePerm); err != nil { + return err + } + + err := os.WriteFile(h.path, []byte(strings.ReplaceAll(h.Content, "\r", "")), os.ModePerm) + if err != nil { + return err + } + h.IsActive = true + return nil +} + +// ListHooks returns a list of Git hooks of given repository. +func ListHooks(repoPath string) (_ []*Hook, err error) { + if !isDir(path.Join(repoPath, "hooks")) { + return nil, errors.New("hooks path does not exist") + } + + hooks := make([]*Hook, len(hookNames)) + for i, name := range hookNames { + hooks[i], err = GetHook(repoPath, name) + if err != nil { + return nil, err + } + } + return hooks, nil +} + +const ( + // HookPathUpdate hook update path + HookPathUpdate = "hooks/update" +) + +// SetUpdateHook writes given content to update hook of the repository. +func SetUpdateHook(repoPath, content string) (err error) { + log.Debug("Setting update hook: %s", repoPath) + hookPath := path.Join(repoPath, HookPathUpdate) + isExist, err := util.IsExist(hookPath) + if err != nil { + log.Debug("Unable to check if %s exists. Error: %v", hookPath, err) + return err + } + if isExist { + err = util.Remove(hookPath) + } else { + err = os.MkdirAll(path.Dir(hookPath), os.ModePerm) + } + if err != nil { + return err + } + return os.WriteFile(hookPath, []byte(content), 0o777) +} diff --git a/modules/git/internal/cmdarg.go b/modules/git/internal/cmdarg.go new file mode 100644 index 0000000..f8f3c20 --- /dev/null +++ b/modules/git/internal/cmdarg.go @@ -0,0 +1,9 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package internal + +// CmdArg represents a command argument for git command, and it will be used for the git command directly without any further processing. +// In most cases, you should use the "AddXxx" functions to add arguments, but not use this type directly. +// Casting a risky (user-provided) string to CmdArg would cause security issues if it's injected with a "--xxx" argument. +type CmdArg string diff --git a/modules/git/last_commit_cache.go b/modules/git/last_commit_cache.go new file mode 100644 index 0000000..8c7ee5a --- /dev/null +++ b/modules/git/last_commit_cache.go @@ -0,0 +1,159 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "context" + "crypto/sha256" + "fmt" + + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" +) + +// Cache represents a caching interface +type Cache interface { + // Put puts value into cache with key and expire time. + Put(key string, val any, timeout int64) error + // Get gets cached value by given key. + Get(key string) any +} + +func getCacheKey(repoPath, commitID, entryPath string) string { + hashBytes := sha256.Sum256([]byte(fmt.Sprintf("%s:%s:%s", repoPath, commitID, entryPath))) + return fmt.Sprintf("last_commit:%x", hashBytes) +} + +// LastCommitCache represents a cache to store last commit +type LastCommitCache struct { + repoPath string + ttl func() int64 + repo *Repository + commitCache map[string]*Commit + cache Cache +} + +// NewLastCommitCache creates a new last commit cache for repo +func NewLastCommitCache(count int64, repoPath string, gitRepo *Repository, cache Cache) *LastCommitCache { + if cache == nil { + return nil + } + if count < setting.CacheService.LastCommit.CommitsCount { + return nil + } + + return &LastCommitCache{ + repoPath: repoPath, + repo: gitRepo, + ttl: setting.LastCommitCacheTTLSeconds, + cache: cache, + } +} + +// Put put the last commit id with commit and entry path +func (c *LastCommitCache) Put(ref, entryPath, commitID string) error { + if c == nil || c.cache == nil { + return nil + } + log.Debug("LastCommitCache save: [%s:%s:%s]", ref, entryPath, commitID) + return c.cache.Put(getCacheKey(c.repoPath, ref, entryPath), commitID, c.ttl()) +} + +// Get gets the last commit information by commit id and entry path +func (c *LastCommitCache) Get(ref, entryPath string) (*Commit, error) { + if c == nil || c.cache == nil { + return nil, nil + } + + commitID, ok := c.cache.Get(getCacheKey(c.repoPath, ref, entryPath)).(string) + if !ok || commitID == "" { + return nil, nil + } + + log.Debug("LastCommitCache hit level 1: [%s:%s:%s]", ref, entryPath, commitID) + if c.commitCache != nil { + if commit, ok := c.commitCache[commitID]; ok { + log.Debug("LastCommitCache hit level 2: [%s:%s:%s]", ref, entryPath, commitID) + return commit, nil + } + } + + commit, err := c.repo.GetCommit(commitID) + if err != nil { + return nil, err + } + if c.commitCache == nil { + c.commitCache = make(map[string]*Commit) + } + c.commitCache[commitID] = commit + return commit, nil +} + +// GetCommitByPath gets the last commit for the entry in the provided commit +func (c *LastCommitCache) GetCommitByPath(commitID, entryPath string) (*Commit, error) { + sha, err := NewIDFromString(commitID) + if err != nil { + return nil, err + } + + lastCommit, err := c.Get(sha.String(), entryPath) + if err != nil || lastCommit != nil { + return lastCommit, err + } + + lastCommit, err = c.repo.getCommitByPathWithID(sha, entryPath) + if err != nil { + return nil, err + } + + if err := c.Put(commitID, entryPath, lastCommit.ID.String()); err != nil { + log.Error("Unable to cache %s as the last commit for %q in %s %s. Error %v", lastCommit.ID.String(), entryPath, commitID, c.repoPath, err) + } + + return lastCommit, nil +} + +// CacheCommit will cache the commit from the gitRepository +func (c *Commit) CacheCommit(ctx context.Context) error { + if c.repo.LastCommitCache == nil { + return nil + } + return c.recursiveCache(ctx, &c.Tree, "", 1) +} + +func (c *Commit) recursiveCache(ctx context.Context, tree *Tree, treePath string, level int) error { + if level == 0 { + return nil + } + + entries, err := tree.ListEntries() + if err != nil { + return err + } + + entryPaths := make([]string, len(entries)) + for i, entry := range entries { + entryPaths[i] = entry.Name() + } + + _, err = WalkGitLog(ctx, c.repo, c, treePath, entryPaths...) + if err != nil { + return err + } + + for _, treeEntry := range entries { + // entryMap won't contain "" therefore skip this. + if treeEntry.IsDir() { + subTree, err := tree.SubTree(treeEntry.Name()) + if err != nil { + return err + } + if err := c.recursiveCache(ctx, subTree, treeEntry.Name(), level-1); err != nil { + return err + } + } + } + + return nil +} diff --git a/modules/git/log_name_status.go b/modules/git/log_name_status.go new file mode 100644 index 0000000..1fd58ab --- /dev/null +++ b/modules/git/log_name_status.go @@ -0,0 +1,437 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "bufio" + "bytes" + "context" + "errors" + "io" + "path" + "sort" + "strings" + + "code.gitea.io/gitea/modules/container" + + "github.com/djherbis/buffer" + "github.com/djherbis/nio/v3" +) + +// 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 := NewCommand(ctx) + cmd.AddArguments("log", "--name-status", "-c", "--format=commit%x00%H %P%x00", "--parents", "--no-renames", "-t", "-z").AddDynamicArguments(head) + + var files []string + if len(paths) < 70 { + if treepath != "" { + files = append(files, treepath) + for _, pth := range paths { + if pth != "" { + files = append(files, path.Join(treepath, pth)) + } + } + } else { + for _, pth := range paths { + if pth != "" { + files = append(files, pth) + } + } + } + } else if treepath != "" { + files = append(files, treepath) + } + // Use the :(literal) pathspec magic to handle edge cases with files named like ":file.txt" or "*.jpg" + for i, file := range files { + files[i] = ":(literal)" + file + } + cmd.AddDashesAndList(files...) + + go func() { + stderr := strings.Builder{} + err := cmd.Run(&RunOpts{ + Dir: repository, + Stdout: stdoutWriter, + Stderr: &stderr, + }) + if err != nil { + _ = stdoutWriter.CloseWithError(ConcatenateError(err, (&stderr).String())) + return + } + + _ = 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 +} + +// LogNameStatusRepoParser parses a git log raw output from LogRawRepo +type LogNameStatusRepoParser struct { + treepath string + paths []string + next []byte + buffull bool + rd *bufio.Reader + cancel func() +} + +// NewLogNameStatusRepoParser returns a new parser for a git log raw output +func NewLogNameStatusRepoParser(ctx context.Context, repository, head, treepath string, paths ...string) *LogNameStatusRepoParser { + rd, cancel := LogNameStatusRepo(ctx, repository, head, treepath, paths...) + return &LogNameStatusRepoParser{ + treepath: treepath, + paths: paths, + rd: rd, + cancel: cancel, + } +} + +// LogNameStatusCommitData represents a commit artefact from git log raw +type LogNameStatusCommitData struct { + CommitID string + ParentIDs []string + Paths []bool +} + +// Next returns the next LogStatusCommitData +func (g *LogNameStatusRepoParser) Next(treepath string, paths2ids map[string]int, changed []bool, maxpathlen int) (*LogNameStatusCommitData, error) { + var err error + if len(g.next) == 0 { + g.buffull = false + g.next, err = g.rd.ReadSlice('\x00') + if err != nil { + if err == bufio.ErrBufferFull { + g.buffull = true + } else if err == io.EOF { + return nil, nil + } else { + return nil, err + } + } + } + + ret := LogNameStatusCommitData{} + if bytes.Equal(g.next, []byte("commit\000")) { + g.next, err = g.rd.ReadSlice('\x00') + if err != nil { + if err == bufio.ErrBufferFull { + g.buffull = true + } else if err == io.EOF { + return nil, nil + } else { + return nil, err + } + } + } + + // Our "line" must look like: <commitid> SP (<parent> SP) * NUL + commitIDs := string(g.next) + if g.buffull { + more, err := g.rd.ReadString('\x00') + if err != nil { + return nil, err + } + commitIDs += more + } + commitIDs = commitIDs[:len(commitIDs)-1] + splitIDs := strings.Split(commitIDs, " ") + ret.CommitID = splitIDs[0] + if len(splitIDs) > 1 { + ret.ParentIDs = splitIDs[1:] + } + + // now read the next "line" + g.buffull = false + g.next, err = g.rd.ReadSlice('\x00') + if err != nil { + if err == bufio.ErrBufferFull { + g.buffull = true + } else if err != io.EOF { + return nil, err + } + } + + if err == io.EOF || !(g.next[0] == '\n' || g.next[0] == '\000') { + return &ret, nil + } + + // Ok we have some changes. + // This line will look like: NL <fname> NUL + // + // Subsequent lines will not have the NL - so drop it here - g.bufffull must also be false at this point too. + if g.next[0] == '\n' { + g.next = g.next[1:] + } else { + g.buffull = false + g.next, err = g.rd.ReadSlice('\x00') + if err != nil { + if err == bufio.ErrBufferFull { + g.buffull = true + } else if err != io.EOF { + return nil, err + } + } + if len(g.next) == 0 { + return &ret, nil + } + if g.next[0] == '\x00' { + g.buffull = false + g.next, err = g.rd.ReadSlice('\x00') + if err != nil { + if err == bufio.ErrBufferFull { + g.buffull = true + } else if err != io.EOF { + return nil, err + } + } + } + } + + fnameBuf := make([]byte, 4096) + +diffloop: + for { + if err == io.EOF || bytes.Equal(g.next, []byte("commit\000")) { + return &ret, nil + } + g.next, err = g.rd.ReadSlice('\x00') + if err != nil { + if err == bufio.ErrBufferFull { + g.buffull = true + } else if err == io.EOF { + return &ret, nil + } else { + return nil, err + } + } + copy(fnameBuf, g.next) + if len(fnameBuf) < len(g.next) { + fnameBuf = append(fnameBuf, g.next[len(fnameBuf):]...) + } else { + fnameBuf = fnameBuf[:len(g.next)] + } + if err != nil { + if err != bufio.ErrBufferFull { + return nil, err + } + more, err := g.rd.ReadBytes('\x00') + if err != nil { + return nil, err + } + fnameBuf = append(fnameBuf, more...) + } + + // read the next line + g.buffull = false + g.next, err = g.rd.ReadSlice('\x00') + if err != nil { + if err == bufio.ErrBufferFull { + g.buffull = true + } else if err != io.EOF { + return nil, err + } + } + + if treepath != "" { + if !bytes.HasPrefix(fnameBuf, []byte(treepath)) { + fnameBuf = fnameBuf[:cap(fnameBuf)] + continue diffloop + } + } + fnameBuf = fnameBuf[len(treepath) : len(fnameBuf)-1] + if len(fnameBuf) > maxpathlen { + fnameBuf = fnameBuf[:cap(fnameBuf)] + continue diffloop + } + if len(fnameBuf) > 0 { + if len(treepath) > 0 { + if fnameBuf[0] != '/' || bytes.IndexByte(fnameBuf[1:], '/') >= 0 { + fnameBuf = fnameBuf[:cap(fnameBuf)] + continue diffloop + } + fnameBuf = fnameBuf[1:] + } else if bytes.IndexByte(fnameBuf, '/') >= 0 { + fnameBuf = fnameBuf[:cap(fnameBuf)] + continue diffloop + } + } + + idx, ok := paths2ids[string(fnameBuf)] + if !ok { + fnameBuf = fnameBuf[:cap(fnameBuf)] + continue diffloop + } + if ret.Paths == nil { + ret.Paths = changed + } + changed[idx] = true + } +} + +// Close closes the parser +func (g *LogNameStatusRepoParser) Close() { + g.cancel() +} + +// WalkGitLog walks the git log --name-status for the head commit in the provided treepath and files +func WalkGitLog(ctx context.Context, repo *Repository, head *Commit, treepath string, paths ...string) (map[string]string, error) { + headRef := head.ID.String() + + tree, err := head.SubTree(treepath) + if err != nil { + return nil, err + } + + entries, err := tree.ListEntries() + if err != nil { + return nil, err + } + + if len(paths) == 0 { + paths = make([]string, 0, len(entries)+1) + paths = append(paths, "") + for _, entry := range entries { + paths = append(paths, entry.Name()) + } + } else { + sort.Strings(paths) + if paths[0] != "" { + paths = append([]string{""}, paths...) + } + // remove duplicates + for i := len(paths) - 1; i > 0; i-- { + if paths[i] == paths[i-1] { + paths = append(paths[:i-1], paths[i:]...) + } + } + } + + path2idx := map[string]int{} + maxpathlen := len(treepath) + + for i := range paths { + path2idx[paths[i]] = i + pthlen := len(paths[i]) + len(treepath) + 1 + if pthlen > maxpathlen { + maxpathlen = pthlen + } + } + + g := NewLogNameStatusRepoParser(ctx, repo.Path, head.ID.String(), treepath, paths...) + // don't use defer g.Close() here as g may change its value - instead wrap in a func + defer func() { + g.Close() + }() + + results := make([]string, len(paths)) + remaining := len(paths) + nextRestart := (len(paths) * 3) / 4 + if nextRestart > 70 { + nextRestart = 70 + } + lastEmptyParent := head.ID.String() + commitSinceLastEmptyParent := uint64(0) + commitSinceNextRestart := uint64(0) + parentRemaining := make(container.Set[string]) + + changed := make([]bool, len(paths)) + +heaploop: + for { + select { + case <-ctx.Done(): + if ctx.Err() == context.DeadlineExceeded { + break heaploop + } + g.Close() + return nil, ctx.Err() + default: + } + current, err := g.Next(treepath, path2idx, changed, maxpathlen) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + break heaploop + } + g.Close() + return nil, err + } + if current == nil { + break heaploop + } + parentRemaining.Remove(current.CommitID) + for i, found := range current.Paths { + if !found { + continue + } + changed[i] = false + if results[i] == "" { + results[i] = current.CommitID + if err := repo.LastCommitCache.Put(headRef, path.Join(treepath, paths[i]), current.CommitID); err != nil { + return nil, err + } + delete(path2idx, paths[i]) + remaining-- + if results[0] == "" { + results[0] = current.CommitID + if err := repo.LastCommitCache.Put(headRef, treepath, current.CommitID); err != nil { + return nil, err + } + delete(path2idx, "") + remaining-- + } + } + } + + if remaining <= 0 { + break heaploop + } + commitSinceLastEmptyParent++ + if len(parentRemaining) == 0 { + lastEmptyParent = current.CommitID + commitSinceLastEmptyParent = 0 + } + if remaining <= nextRestart { + commitSinceNextRestart++ + if 4*commitSinceNextRestart > 3*commitSinceLastEmptyParent { + g.Close() + remainingPaths := make([]string, 0, len(paths)) + for i, pth := range paths { + if results[i] == "" { + remainingPaths = append(remainingPaths, pth) + } + } + g = NewLogNameStatusRepoParser(ctx, repo.Path, lastEmptyParent, treepath, remainingPaths...) + parentRemaining = make(container.Set[string]) + nextRestart = (remaining * 3) / 4 + continue heaploop + } + } + parentRemaining.AddMultiple(current.ParentIDs...) + } + g.Close() + + resultsMap := map[string]string{} + for i, pth := range paths { + resultsMap[pth] = results[i] + } + + return resultsMap, nil +} diff --git a/modules/git/notes.go b/modules/git/notes.go new file mode 100644 index 0000000..ee628c0 --- /dev/null +++ b/modules/git/notes.go @@ -0,0 +1,99 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "context" + "io" + "strings" + + "code.gitea.io/gitea/modules/log" +) + +// NotesRef is the git ref where Gitea will look for git-notes data. +// The value ("refs/notes/commits") is the default ref used by git-notes. +const NotesRef = "refs/notes/commits" + +// Note stores information about a note created using git-notes. +type Note struct { + Message []byte + Commit *Commit +} + +// GetNote retrieves the git-notes data for a given commit. +// FIXME: Add LastCommitCache support +func GetNote(ctx context.Context, repo *Repository, commitID string, note *Note) error { + log.Trace("Searching for git note corresponding to the commit %q in the repository %q", commitID, repo.Path) + notes, err := repo.GetCommit(NotesRef) + if err != nil { + if IsErrNotExist(err) { + return err + } + log.Error("Unable to get commit from ref %q. Error: %v", NotesRef, err) + return err + } + + path := "" + + tree := ¬es.Tree + log.Trace("Found tree with ID %q while searching for git note corresponding to the commit %q", tree.ID, commitID) + + var entry *TreeEntry + originalCommitID := commitID + for len(commitID) > 2 { + entry, err = tree.GetTreeEntryByPath(commitID) + if err == nil { + path += commitID + break + } + if IsErrNotExist(err) { + tree, err = tree.SubTree(commitID[0:2]) + path += commitID[0:2] + "/" + commitID = commitID[2:] + } + if err != nil { + // Err may have been updated by the SubTree we need to recheck if it's again an ErrNotExist + if !IsErrNotExist(err) { + log.Error("Unable to find git note corresponding to the commit %q. Error: %v", originalCommitID, err) + } + return err + } + } + + blob := entry.Blob() + dataRc, err := blob.DataAsync() + if err != nil { + log.Error("Unable to read blob with ID %q. Error: %v", blob.ID, err) + return err + } + closed := false + defer func() { + if !closed { + _ = dataRc.Close() + } + }() + d, err := io.ReadAll(dataRc) + if err != nil { + log.Error("Unable to read blob with ID %q. Error: %v", blob.ID, err) + return err + } + _ = dataRc.Close() + closed = true + note.Message = d + + treePath := "" + if idx := strings.LastIndex(path, "/"); idx > -1 { + treePath = path[:idx] + path = path[idx+1:] + } + + lastCommits, err := GetLastCommitForPaths(ctx, notes, treePath, []string{path}) + if err != nil { + log.Error("Unable to get the commit for the path %q. Error: %v", treePath, err) + return err + } + note.Commit = lastCommits[path] + + return nil +} diff --git a/modules/git/notes_test.go b/modules/git/notes_test.go new file mode 100644 index 0000000..bbb16cc --- /dev/null +++ b/modules/git/notes_test.go @@ -0,0 +1,53 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "context" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetNotes(t *testing.T) { + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") + bareRepo1, err := openRepositoryWithDefaultContext(bareRepo1Path) + require.NoError(t, err) + defer bareRepo1.Close() + + note := Note{} + err = GetNote(context.Background(), bareRepo1, "95bb4d39648ee7e325106df01a621c530863a653", ¬e) + require.NoError(t, err) + assert.Equal(t, []byte("Note contents\n"), note.Message) + assert.Equal(t, "Vladimir Panteleev", note.Commit.Author.Name) +} + +func TestGetNestedNotes(t *testing.T) { + repoPath := filepath.Join(testReposDir, "repo3_notes") + repo, err := openRepositoryWithDefaultContext(repoPath) + require.NoError(t, err) + defer repo.Close() + + note := Note{} + err = GetNote(context.Background(), repo, "3e668dbfac39cbc80a9ff9c61eb565d944453ba4", ¬e) + require.NoError(t, err) + assert.Equal(t, []byte("Note 2"), note.Message) + err = GetNote(context.Background(), repo, "ba0a96fa63532d6c5087ecef070b0250ed72fa47", ¬e) + require.NoError(t, err) + assert.Equal(t, []byte("Note 1"), note.Message) +} + +func TestGetNonExistentNotes(t *testing.T) { + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") + bareRepo1, err := openRepositoryWithDefaultContext(bareRepo1Path) + require.NoError(t, err) + defer bareRepo1.Close() + + note := Note{} + err = GetNote(context.Background(), bareRepo1, "non_existent_sha", ¬e) + require.Error(t, err) + assert.IsType(t, ErrNotExist{}, err) +} diff --git a/modules/git/object_format.go b/modules/git/object_format.go new file mode 100644 index 0000000..db9120d --- /dev/null +++ b/modules/git/object_format.go @@ -0,0 +1,143 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "crypto/sha1" + "crypto/sha256" + "hash" + "regexp" + "strconv" +) + +// sha1Pattern can be used to determine if a string is an valid sha +var sha1Pattern = regexp.MustCompile(`^[0-9a-f]{4,40}$`) + +// sha256Pattern can be used to determine if a string is an valid sha +var sha256Pattern = regexp.MustCompile(`^[0-9a-f]{4,64}$`) + +type ObjectFormat interface { + // Name returns the name of the object format + Name() string + // EmptyObjectID creates a new empty ObjectID from an object format hash name + EmptyObjectID() ObjectID + // EmptyTree is the hash of an empty tree + EmptyTree() ObjectID + // FullLength is the length of the hash's hex string + FullLength() int + // IsValid returns true if the input is a valid hash + IsValid(input string) bool + // MustID creates a new ObjectID from a byte slice + MustID(b []byte) ObjectID + // ComputeHash compute the hash for a given ObjectType and content + ComputeHash(t ObjectType, content []byte) ObjectID +} + +func computeHash(dst []byte, hasher hash.Hash, t ObjectType, content []byte) { + _, _ = hasher.Write(t.Bytes()) + _, _ = hasher.Write([]byte(" ")) + _, _ = hasher.Write([]byte(strconv.Itoa(len(content)))) + _, _ = hasher.Write([]byte{0}) + _, _ = hasher.Write(content) + hasher.Sum(dst) +} + +/* SHA1 Type */ +type Sha1ObjectFormatImpl struct{} + +var ( + emptySha1ObjectID = &Sha1Hash{} + emptySha1Tree = &Sha1Hash{ + 0x4b, 0x82, 0x5d, 0xc6, 0x42, 0xcb, 0x6e, 0xb9, 0xa0, 0x60, + 0xe5, 0x4b, 0xf8, 0xd6, 0x92, 0x88, 0xfb, 0xee, 0x49, 0x04, + } +) + +func (Sha1ObjectFormatImpl) Name() string { return "sha1" } +func (Sha1ObjectFormatImpl) EmptyObjectID() ObjectID { + return emptySha1ObjectID +} + +func (Sha1ObjectFormatImpl) EmptyTree() ObjectID { + return emptySha1Tree +} +func (Sha1ObjectFormatImpl) FullLength() int { return 40 } +func (Sha1ObjectFormatImpl) IsValid(input string) bool { + return sha1Pattern.MatchString(input) +} + +func (Sha1ObjectFormatImpl) MustID(b []byte) ObjectID { + var id Sha1Hash + copy(id[0:20], b) + return &id +} + +// ComputeHash compute the hash for a given ObjectType and content +func (h Sha1ObjectFormatImpl) ComputeHash(t ObjectType, content []byte) ObjectID { + var obj Sha1Hash + computeHash(obj[:0], sha1.New(), t, content) + return &obj +} + +/* SHA256 Type */ +type Sha256ObjectFormatImpl struct{} + +var ( + emptySha256ObjectID = &Sha256Hash{} + emptySha256Tree = &Sha256Hash{ + 0x6e, 0xf1, 0x9b, 0x41, 0x22, 0x5c, 0x53, 0x69, 0xf1, 0xc1, + 0x04, 0xd4, 0x5d, 0x8d, 0x85, 0xef, 0xa9, 0xb0, 0x57, 0xb5, + 0x3b, 0x14, 0xb4, 0xb9, 0xb9, 0x39, 0xdd, 0x74, 0xde, 0xcc, + 0x53, 0x21, + } +) + +func (Sha256ObjectFormatImpl) Name() string { return "sha256" } +func (Sha256ObjectFormatImpl) EmptyObjectID() ObjectID { + return emptySha256ObjectID +} + +func (Sha256ObjectFormatImpl) EmptyTree() ObjectID { + return emptySha256Tree +} +func (Sha256ObjectFormatImpl) FullLength() int { return 64 } +func (Sha256ObjectFormatImpl) IsValid(input string) bool { + return sha256Pattern.MatchString(input) +} + +func (Sha256ObjectFormatImpl) MustID(b []byte) ObjectID { + var id Sha256Hash + copy(id[0:32], b) + return &id +} + +// ComputeHash compute the hash for a given ObjectType and content +func (h Sha256ObjectFormatImpl) ComputeHash(t ObjectType, content []byte) ObjectID { + var obj Sha256Hash + computeHash(obj[:0], sha256.New(), t, content) + return &obj +} + +var ( + Sha1ObjectFormat ObjectFormat = Sha1ObjectFormatImpl{} + Sha256ObjectFormat ObjectFormat = Sha256ObjectFormatImpl{} + // any addition must be reflected in IsEmptyCommitID +) + +var SupportedObjectFormats = []ObjectFormat{ + Sha1ObjectFormat, +} + +func ObjectFormatFromName(name string) ObjectFormat { + for _, objectFormat := range SupportedObjectFormats { + if name == objectFormat.Name() { + return objectFormat + } + } + return nil +} + +func IsValidObjectFormat(name string) bool { + return ObjectFormatFromName(name) != nil +} diff --git a/modules/git/object_id.go b/modules/git/object_id.go new file mode 100644 index 0000000..ecbc158 --- /dev/null +++ b/modules/git/object_id.go @@ -0,0 +1,114 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "bytes" + "encoding/hex" + "fmt" +) + +type ObjectID interface { + String() string + IsZero() bool + RawValue() []byte + Type() ObjectFormat +} + +/* SHA1 */ +type Sha1Hash [20]byte + +func (h *Sha1Hash) String() string { + return hex.EncodeToString(h[:]) +} + +func (h *Sha1Hash) IsZero() bool { + empty := Sha1Hash{} + return bytes.Equal(empty[:], h[:]) +} +func (h *Sha1Hash) RawValue() []byte { return h[:] } +func (*Sha1Hash) Type() ObjectFormat { return Sha1ObjectFormat } + +var _ ObjectID = &Sha1Hash{} + +func MustIDFromString(hexHash string) ObjectID { + id, err := NewIDFromString(hexHash) + if err != nil { + panic(err) + } + return id +} + +/* SHA256 */ +type Sha256Hash [32]byte + +func (h *Sha256Hash) String() string { + return hex.EncodeToString(h[:]) +} + +func (h *Sha256Hash) IsZero() bool { + empty := Sha256Hash{} + return bytes.Equal(empty[:], h[:]) +} +func (h *Sha256Hash) RawValue() []byte { return h[:] } +func (*Sha256Hash) Type() ObjectFormat { return Sha256ObjectFormat } + +/* utility */ +func NewIDFromString(hexHash string) (ObjectID, error) { + var theObjectFormat ObjectFormat + for _, objectFormat := range SupportedObjectFormats { + if len(hexHash) == objectFormat.FullLength() { + theObjectFormat = objectFormat + break + } + } + + if theObjectFormat == nil { + return nil, fmt.Errorf("length %d has no matched object format: %s", len(hexHash), hexHash) + } + + b, err := hex.DecodeString(hexHash) + if err != nil { + return nil, err + } + + if len(b) != theObjectFormat.FullLength()/2 { + return theObjectFormat.EmptyObjectID(), fmt.Errorf("length must be %d: %v", theObjectFormat.FullLength(), b) + } + return theObjectFormat.MustID(b), nil +} + +// IsEmptyCommitID checks if an hexadecimal string represents an empty commit according to git (only '0'). +// If objectFormat is not nil, the length will be checked as well (otherwise the length must match the sha1 or sha256 length). +func IsEmptyCommitID(commitID string, objectFormat ObjectFormat) bool { + if commitID == "" { + return true + } + if objectFormat == nil { + if Sha1ObjectFormat.FullLength() != len(commitID) && Sha256ObjectFormat.FullLength() != len(commitID) { + return false + } + } else if objectFormat.FullLength() != len(commitID) { + return false + } + for _, c := range commitID { + if c != '0' { + return false + } + } + return true +} + +// ComputeBlobHash compute the hash for a given blob content +func ComputeBlobHash(hashType ObjectFormat, content []byte) ObjectID { + return hashType.ComputeHash(ObjectBlob, content) +} + +type ErrInvalidSHA struct { + SHA string +} + +func (err ErrInvalidSHA) Error() string { + return fmt.Sprintf("invalid sha: %s", err.SHA) +} diff --git a/modules/git/object_id_test.go b/modules/git/object_id_test.go new file mode 100644 index 0000000..00a24e3 --- /dev/null +++ b/modules/git/object_id_test.go @@ -0,0 +1,49 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsValidSHAPattern(t *testing.T) { + h := Sha1ObjectFormat + assert.True(t, h.IsValid("fee1")) + assert.True(t, h.IsValid("abc000")) + assert.True(t, h.IsValid("9023902390239023902390239023902390239023")) + assert.False(t, h.IsValid("90239023902390239023902390239023902390239023")) + assert.False(t, h.IsValid("abc")) + assert.False(t, h.IsValid("123g")) + assert.False(t, h.IsValid("some random text")) + + assert.Equal(t, "79ee38a6416c1ede423ec7ee0a8639ceea4aad22", ComputeBlobHash(Sha1ObjectFormat, []byte("some random blob")).String()) + assert.Equal(t, "d5c6407415d85df49592672aa421aed39b9db5e3", ComputeBlobHash(Sha1ObjectFormat, []byte("same length blob")).String()) + assert.Equal(t, "df0b5174ed06ae65aea40d43316bcbc21d82c9e3158ce2661df2ad28d7931dd6", ComputeBlobHash(Sha256ObjectFormat, []byte("some random blob")).String()) +} + +func TestIsEmptyCommitID(t *testing.T) { + assert.True(t, IsEmptyCommitID("", nil)) + assert.True(t, IsEmptyCommitID("", Sha1ObjectFormat)) + assert.True(t, IsEmptyCommitID("", Sha256ObjectFormat)) + + assert.False(t, IsEmptyCommitID("79ee38a6416c1ede423ec7ee0a8639ceea4aad20", Sha1ObjectFormat)) + assert.True(t, IsEmptyCommitID("0000000000000000000000000000000000000000", nil)) + assert.True(t, IsEmptyCommitID("0000000000000000000000000000000000000000", Sha1ObjectFormat)) + assert.False(t, IsEmptyCommitID("0000000000000000000000000000000000000000", Sha256ObjectFormat)) + + assert.False(t, IsEmptyCommitID("00000000000000000000000000000000000000000", nil)) + + assert.False(t, IsEmptyCommitID("0f0b5174ed06ae65aea40d43316bcbc21d82c9e3158ce2661df2ad28d7931dd6", nil)) + assert.True(t, IsEmptyCommitID("0000000000000000000000000000000000000000000000000000000000000000", nil)) + assert.False(t, IsEmptyCommitID("0000000000000000000000000000000000000000000000000000000000000000", Sha1ObjectFormat)) + assert.True(t, IsEmptyCommitID("0000000000000000000000000000000000000000000000000000000000000000", Sha256ObjectFormat)) + + assert.False(t, IsEmptyCommitID("1", nil)) + assert.False(t, IsEmptyCommitID("0", nil)) + + assert.False(t, IsEmptyCommitID("010", nil)) + assert.False(t, IsEmptyCommitID("0 0", nil)) +} diff --git a/modules/git/object_signature.go b/modules/git/object_signature.go new file mode 100644 index 0000000..35fa671 --- /dev/null +++ b/modules/git/object_signature.go @@ -0,0 +1,11 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +// ObjectSignature represents a git object (commit, tag) signature part. +type ObjectSignature struct { + Signature string + Payload string // TODO check if can be reconstruct from the rest of commit information to not have duplicate data +} diff --git a/modules/git/parse.go b/modules/git/parse.go new file mode 100644 index 0000000..8c2c411 --- /dev/null +++ b/modules/git/parse.go @@ -0,0 +1,137 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "bufio" + "bytes" + "fmt" + "io" + "strconv" + "strings" + + "code.gitea.io/gitea/modules/log" +) + +// ParseTreeEntries parses the output of a `git ls-tree -l` command. +func ParseTreeEntries(data []byte) ([]*TreeEntry, error) { + return parseTreeEntries(data, nil) +} + +var sepSpace = []byte{' '} + +func parseTreeEntries(data []byte, ptree *Tree) ([]*TreeEntry, error) { + var err error + entries := make([]*TreeEntry, 0, bytes.Count(data, []byte{'\n'})+1) + for pos := 0; pos < len(data); { + // expect line to be of the form: + // <mode> <type> <sha> <space-padded-size>\t<filename> + // <mode> <type> <sha>\t<filename> + posEnd := bytes.IndexByte(data[pos:], '\n') + if posEnd == -1 { + posEnd = len(data) + } else { + posEnd += pos + } + line := data[pos:posEnd] + posTab := bytes.IndexByte(line, '\t') + if posTab == -1 { + return nil, fmt.Errorf("invalid ls-tree output (no tab): %q", line) + } + + entry := new(TreeEntry) + entry.ptree = ptree + + entryAttrs := line[:posTab] + entryName := line[posTab+1:] + + entryMode, entryAttrs, _ := bytes.Cut(entryAttrs, sepSpace) + _ /* entryType */, entryAttrs, _ = bytes.Cut(entryAttrs, sepSpace) // the type is not used, the mode is enough to determine the type + entryObjectID, entryAttrs, _ := bytes.Cut(entryAttrs, sepSpace) + if len(entryAttrs) > 0 { + entrySize := entryAttrs // the last field is the space-padded-size + entry.size, _ = strconv.ParseInt(strings.TrimSpace(string(entrySize)), 10, 64) + entry.sized = true + } + + switch string(entryMode) { + case "100644": + entry.entryMode = EntryModeBlob + case "100755": + entry.entryMode = EntryModeExec + case "120000": + entry.entryMode = EntryModeSymlink + case "160000": + entry.entryMode = EntryModeCommit + case "040000", "040755": // git uses 040000 for tree object, but some users may get 040755 for unknown reasons + entry.entryMode = EntryModeTree + default: + return nil, fmt.Errorf("unknown type: %v", string(entryMode)) + } + + entry.ID, err = NewIDFromString(string(entryObjectID)) + if err != nil { + return nil, fmt.Errorf("invalid ls-tree output (invalid object id): %q, err: %w", line, err) + } + + if len(entryName) > 0 && entryName[0] == '"' { + entry.name, err = strconv.Unquote(string(entryName)) + if err != nil { + return nil, fmt.Errorf("invalid ls-tree output (invalid name): %q, err: %w", line, err) + } + } else { + entry.name = string(entryName) + } + + pos = posEnd + 1 + entries = append(entries, entry) + } + return entries, nil +} + +func catBatchParseTreeEntries(objectFormat ObjectFormat, ptree *Tree, rd *bufio.Reader, sz int64) ([]*TreeEntry, error) { + fnameBuf := make([]byte, 4096) + modeBuf := make([]byte, 40) + shaBuf := make([]byte, objectFormat.FullLength()) + entries := make([]*TreeEntry, 0, 10) + +loop: + for sz > 0 { + mode, fname, sha, count, err := ParseTreeLine(objectFormat, rd, modeBuf, fnameBuf, shaBuf) + if err != nil { + if err == io.EOF { + break loop + } + return nil, err + } + sz -= int64(count) + entry := new(TreeEntry) + entry.ptree = ptree + + switch string(mode) { + case "100644": + entry.entryMode = EntryModeBlob + case "100755": + entry.entryMode = EntryModeExec + case "120000": + entry.entryMode = EntryModeSymlink + case "160000": + entry.entryMode = EntryModeCommit + case "40000", "40755": // git uses 40000 for tree object, but some users may get 40755 for unknown reasons + entry.entryMode = EntryModeTree + default: + log.Debug("Unknown mode: %v", string(mode)) + return nil, fmt.Errorf("unknown mode: %v", string(mode)) + } + + entry.ID = objectFormat.MustID(sha) + entry.name = string(fname) + entries = append(entries, entry) + } + if _, err := rd.Discard(1); err != nil { + return entries, err + } + + return entries, nil +} diff --git a/modules/git/parse_test.go b/modules/git/parse_test.go new file mode 100644 index 0000000..89c6e03 --- /dev/null +++ b/modules/git/parse_test.go @@ -0,0 +1,103 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseTreeEntriesLong(t *testing.T) { + testCases := []struct { + Input string + Expected []*TreeEntry + }{ + { + Input: `100644 blob ea0d83c9081af9500ac9f804101b3fd0a5c293af 8218 README.md +100644 blob 037f27dc9d353ae4fd50f0474b2194c593914e35 4681 README_ZH.md +100644 blob 9846a94f7e8350a916632929d0fda38c90dd2ca8 429 SECURITY.md +040000 tree 84b90550547016f73c5dd3f50dea662389e67b6d - assets +`, + Expected: []*TreeEntry{ + { + ID: MustIDFromString("ea0d83c9081af9500ac9f804101b3fd0a5c293af"), + name: "README.md", + entryMode: EntryModeBlob, + size: 8218, + sized: true, + }, + { + ID: MustIDFromString("037f27dc9d353ae4fd50f0474b2194c593914e35"), + name: "README_ZH.md", + entryMode: EntryModeBlob, + size: 4681, + sized: true, + }, + { + ID: MustIDFromString("9846a94f7e8350a916632929d0fda38c90dd2ca8"), + name: "SECURITY.md", + entryMode: EntryModeBlob, + size: 429, + sized: true, + }, + { + ID: MustIDFromString("84b90550547016f73c5dd3f50dea662389e67b6d"), + name: "assets", + entryMode: EntryModeTree, + sized: true, + }, + }, + }, + } + for _, testCase := range testCases { + entries, err := ParseTreeEntries([]byte(testCase.Input)) + require.NoError(t, err) + assert.Len(t, entries, len(testCase.Expected)) + for i, entry := range entries { + assert.EqualValues(t, testCase.Expected[i], entry) + } + } +} + +func TestParseTreeEntriesShort(t *testing.T) { + testCases := []struct { + Input string + Expected []*TreeEntry + }{ + { + Input: `100644 blob ea0d83c9081af9500ac9f804101b3fd0a5c293af README.md +040000 tree 84b90550547016f73c5dd3f50dea662389e67b6d assets +`, + Expected: []*TreeEntry{ + { + ID: MustIDFromString("ea0d83c9081af9500ac9f804101b3fd0a5c293af"), + name: "README.md", + entryMode: EntryModeBlob, + }, + { + ID: MustIDFromString("84b90550547016f73c5dd3f50dea662389e67b6d"), + name: "assets", + entryMode: EntryModeTree, + }, + }, + }, + } + for _, testCase := range testCases { + entries, err := ParseTreeEntries([]byte(testCase.Input)) + require.NoError(t, err) + assert.Len(t, entries, len(testCase.Expected)) + for i, entry := range entries { + assert.EqualValues(t, testCase.Expected[i], entry) + } + } +} + +func TestParseTreeEntriesInvalid(t *testing.T) { + // there was a panic: "runtime error: slice bounds out of range" when the input was invalid: #20315 + entries, err := ParseTreeEntries([]byte("100644 blob ea0d83c9081af9500ac9f804101b3fd0a5c293af")) + require.Error(t, err) + assert.Empty(t, entries) +} diff --git a/modules/git/pipeline/catfile.go b/modules/git/pipeline/catfile.go new file mode 100644 index 0000000..4677218 --- /dev/null +++ b/modules/git/pipeline/catfile.go @@ -0,0 +1,108 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pipeline + +import ( + "bufio" + "bytes" + "context" + "fmt" + "io" + "strconv" + "strings" + "sync" + + "code.gitea.io/gitea/modules/git" + "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 := git.NewCommand(ctx, "cat-file", "--batch-check") + if err := cmd.Run(&git.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())) + } +} + +// 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 := git.NewCommand(ctx, "cat-file", "--batch-check", "--batch-all-objects") + if err := cmd.Run(&git.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 + } +} + +// 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 := git.NewCommand(ctx, "cat-file", "--batch").Run(&git.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())) + } +} + +// 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()) + }() + for scanner.Scan() { + line := scanner.Text() + if len(line) == 0 { + continue + } + fields := strings.Split(line, " ") + if len(fields) < 3 || fields[1] != "blob" { + continue + } + size, _ := strconv.Atoi(fields[2]) + if size > 1024 { + continue + } + toWrite := []byte(fields[0] + "\n") + for len(toWrite) > 0 { + n, err := shasToBatchWriter.Write(toWrite) + if err != nil { + _ = catFileCheckReader.CloseWithError(err) + break + } + toWrite = toWrite[n:] + } + } +} diff --git a/modules/git/pipeline/lfs.go b/modules/git/pipeline/lfs.go new file mode 100644 index 0000000..3407eb9 --- /dev/null +++ b/modules/git/pipeline/lfs.go @@ -0,0 +1,254 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pipeline + +import ( + "bufio" + "bytes" + "fmt" + "io" + "sort" + "strings" + "sync" + "time" + + "code.gitea.io/gitea/modules/git" +) + +// LFSResult represents commits found using a provided pointer file hash +type LFSResult struct { + Name string + SHA string + Summary string + When time.Time + ParentHashes []git.ObjectID + BranchName string + FullCommitName string +} + +type lfsResultSlice []*LFSResult + +func (a lfsResultSlice) Len() int { return len(a) } +func (a lfsResultSlice) Swap(i, j int) { a[i], a[j] = a[j], a[i] } +func (a lfsResultSlice) Less(i, j int) bool { return a[j].When.After(a[i].When) } + +func lfsError(msg string, err error) error { + return fmt.Errorf("LFS error occurred, %s: err: %w", msg, err) +} + +// FindLFSFile finds commits that contain a provided pointer file hash +func FindLFSFile(repo *git.Repository, objectID git.ObjectID) ([]*LFSResult, error) { + resultsMap := map[string]*LFSResult{} + results := make([]*LFSResult, 0) + + 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 := git.NewCommand(repo.Ctx, "rev-list", "--all").Run(&git.RunOpts{ + Dir: repo.Path, + Stdout: revListWriter, + Stderr: &stderr, + }) + if err != nil { + _ = revListWriter.CloseWithError(git.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) + if err != nil { + return nil, err + } + defer cancel() + + // We'll use a scanner for the revList because it's simpler than a bufio.Reader + scan := bufio.NewScanner(revListReader) + trees := [][]byte{} + paths := []string{} + + fnameBuf := make([]byte, 4096) + modeBuf := make([]byte, 40) + workingShaBuf := make([]byte, objectID.Type().FullLength()/2) + + for scan.Scan() { + // Get the next commit ID + commitID := scan.Bytes() + + // push the commit to the cat-file --batch process + _, err := batchStdinWriter.Write(commitID) + if err != nil { + return nil, err + } + _, err = batchStdinWriter.Write([]byte{'\n'}) + if err != nil { + return nil, err + } + + var curCommit *git.Commit + curPath := "" + + commitReadingLoop: + for { + _, typ, size, err := git.ReadBatchLine(batchReader) + if err != nil { + return nil, err + } + + switch typ { + case "tag": + // This shouldn't happen but if it does well just get the commit and try again + id, err := git.ReadTagObjectID(batchReader, size) + if err != nil { + return nil, err + } + _, err = batchStdinWriter.Write([]byte(id + "\n")) + if 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)) + if err != nil { + return nil, err + } + if _, err := batchReader.Discard(1); err != nil { + return nil, err + } + + if _, err := batchStdinWriter.Write([]byte(curCommit.Tree.ID.String() + "\n")); err != nil { + return nil, err + } + curPath = "" + case "tree": + var n int64 + for n < size { + mode, fname, binObjectID, count, err := git.ParseTreeLine(objectID.Type(), batchReader, modeBuf, fnameBuf, workingShaBuf) + if err != nil { + return nil, err + } + n += int64(count) + if bytes.Equal(binObjectID, objectID.RawValue()) { + result := LFSResult{ + Name: curPath + string(fname), + SHA: curCommit.ID.String(), + Summary: strings.Split(strings.TrimSpace(curCommit.CommitMessage), "\n")[0], + 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) + paths = append(paths, curPath+string(fname)+"/") + } + } + if _, err := batchReader.Discard(1); err != nil { + 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")) + if err != nil { + return nil, err + } + curPath = paths[len(paths)-1] + trees = trees[:len(trees)-1] + paths = paths[:len(paths)-1] + } else { + break commitReadingLoop + } + default: + if err := git.DiscardFull(batchReader, size+1); err != nil { + return nil, err + } + } + } + } + + if err := scan.Err(); err != nil { + return nil, err + } + + for _, result := range resultsMap { + hasParent := false + for _, parentID := range result.ParentHashes { + if _, hasParent = resultsMap[parentID.String()+":"+result.Name]; hasParent { + break + } + } + if !hasParent { + results = append(results, result) + } + } + + sort.Sort(lfsResultSlice(results)) + + // 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 +} diff --git a/modules/git/pipeline/namerev.go b/modules/git/pipeline/namerev.go new file mode 100644 index 0000000..ad583a7 --- /dev/null +++ b/modules/git/pipeline/namerev.go @@ -0,0 +1,33 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pipeline + +import ( + "bytes" + "context" + "fmt" + "io" + "strings" + "sync" + + "code.gitea.io/gitea/modules/git" +) + +// 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() + + stderr := new(bytes.Buffer) + var errbuf strings.Builder + if err := git.NewCommand(ctx, "name-rev", "--stdin", "--name-only", "--always").Run(&git.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())) + } +} diff --git a/modules/git/pipeline/revlist.go b/modules/git/pipeline/revlist.go new file mode 100644 index 0000000..d88ebe7 --- /dev/null +++ b/modules/git/pipeline/revlist.go @@ -0,0 +1,86 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pipeline + +import ( + "bufio" + "bytes" + "context" + "fmt" + "io" + "strings" + "sync" + + "code.gitea.io/gitea/modules/git" + "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 := git.NewCommand(ctx, "rev-list", "--objects", "--all") + if err := cmd.Run(&git.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 := git.NewCommand(ctx, "rev-list", "--objects").AddDynamicArguments(headSHA) + if baseSHA != "" { + cmd = cmd.AddArguments("--not").AddDynamicArguments(baseSHA) + } + if err := cmd.Run(&git.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()) + } +} + +// 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()) + }() + for scanner.Scan() { + line := scanner.Text() + if len(line) == 0 { + continue + } + fields := strings.Split(line, " ") + if len(fields) < 2 || len(fields[1]) == 0 { + continue + } + toWrite := []byte(fields[0] + "\n") + for len(toWrite) > 0 { + n, err := shasToCheckWriter.Write(toWrite) + if err != nil { + _ = revListReader.CloseWithError(err) + break + } + toWrite = toWrite[n:] + } + } +} diff --git a/modules/git/pushoptions/pushoptions.go b/modules/git/pushoptions/pushoptions.go new file mode 100644 index 0000000..9709a8b --- /dev/null +++ b/modules/git/pushoptions/pushoptions.go @@ -0,0 +1,113 @@ +// Copyright twenty-panda <twenty-panda@posteo.com> +// SPDX-License-Identifier: MIT + +package pushoptions + +import ( + "fmt" + "os" + "strconv" + "strings" +) + +type Key string + +const ( + RepoPrivate = Key("repo.private") + RepoTemplate = Key("repo.template") + AgitTopic = Key("topic") + AgitForcePush = Key("force-push") + AgitTitle = Key("title") + AgitDescription = Key("description") + + envPrefix = "GIT_PUSH_OPTION" + EnvCount = envPrefix + "_COUNT" + EnvFormat = envPrefix + "_%d" +) + +type Interface interface { + ReadEnv() Interface + Parse(string) bool + Map() map[string]string + + ChangeRepoSettings() bool + + Empty() bool + + GetBool(key Key, def bool) bool + GetString(key Key) (val string, ok bool) +} + +type gitPushOptions map[string]string + +func New() Interface { + pushOptions := gitPushOptions(make(map[string]string)) + return &pushOptions +} + +func NewFromMap(o *map[string]string) Interface { + return (*gitPushOptions)(o) +} + +func (o *gitPushOptions) ReadEnv() Interface { + if pushCount, err := strconv.Atoi(os.Getenv(EnvCount)); err == nil { + for idx := 0; idx < pushCount; idx++ { + _ = o.Parse(os.Getenv(fmt.Sprintf(EnvFormat, idx))) + } + } + return o +} + +func (o *gitPushOptions) Parse(data string) bool { + key, value, found := strings.Cut(data, "=") + if !found { + value = "true" + } + switch Key(key) { + case RepoPrivate: + case RepoTemplate: + case AgitTopic: + case AgitForcePush: + case AgitTitle: + case AgitDescription: + default: + return false + } + (*o)[key] = value + return true +} + +func (o gitPushOptions) Map() map[string]string { + return o +} + +func (o gitPushOptions) ChangeRepoSettings() bool { + if o.Empty() { + return false + } + for _, key := range []Key{RepoPrivate, RepoTemplate} { + _, ok := o[string(key)] + if ok { + return true + } + } + return false +} + +func (o gitPushOptions) Empty() bool { + return len(o) == 0 +} + +func (o gitPushOptions) GetBool(key Key, def bool) bool { + if val, ok := o[string(key)]; ok { + if b, err := strconv.ParseBool(val); err == nil { + return b + } + } + return def +} + +func (o gitPushOptions) GetString(key Key) (string, bool) { + val, ok := o[string(key)] + return val, ok +} diff --git a/modules/git/pushoptions/pushoptions_test.go b/modules/git/pushoptions/pushoptions_test.go new file mode 100644 index 0000000..49bf2d2 --- /dev/null +++ b/modules/git/pushoptions/pushoptions_test.go @@ -0,0 +1,125 @@ +// Copyright twenty-panda <twenty-panda@posteo.com> +// SPDX-License-Identifier: MIT + +package pushoptions + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestEmpty(t *testing.T) { + options := New() + assert.True(t, options.Empty()) + options.Parse(fmt.Sprintf("%v", RepoPrivate)) + assert.False(t, options.Empty()) +} + +func TestToAndFromMap(t *testing.T) { + options := New() + options.Parse(fmt.Sprintf("%v", RepoPrivate)) + actual := options.Map() + expected := map[string]string{string(RepoPrivate): "true"} + assert.EqualValues(t, expected, actual) + assert.EqualValues(t, expected, NewFromMap(&actual).Map()) +} + +func TestChangeRepositorySettings(t *testing.T) { + options := New() + assert.False(t, options.ChangeRepoSettings()) + assert.True(t, options.Parse(fmt.Sprintf("%v=description", AgitDescription))) + assert.False(t, options.ChangeRepoSettings()) + + options.Parse(fmt.Sprintf("%v", RepoPrivate)) + assert.True(t, options.ChangeRepoSettings()) + + options = New() + options.Parse(fmt.Sprintf("%v", RepoTemplate)) + assert.True(t, options.ChangeRepoSettings()) +} + +func TestParse(t *testing.T) { + t.Run("no key", func(t *testing.T) { + options := New() + + val, ok := options.GetString(RepoPrivate) + assert.False(t, ok) + assert.Equal(t, "", val) + + assert.True(t, options.GetBool(RepoPrivate, true)) + assert.False(t, options.GetBool(RepoPrivate, false)) + }) + + t.Run("key=value", func(t *testing.T) { + options := New() + + topic := "TOPIC" + assert.True(t, options.Parse(fmt.Sprintf("%v=%s", AgitTopic, topic))) + val, ok := options.GetString(AgitTopic) + assert.True(t, ok) + assert.Equal(t, topic, val) + }) + + t.Run("key=true", func(t *testing.T) { + options := New() + + assert.True(t, options.Parse(fmt.Sprintf("%v=true", RepoPrivate))) + assert.True(t, options.GetBool(RepoPrivate, false)) + assert.True(t, options.Parse(fmt.Sprintf("%v=TRUE", RepoTemplate))) + assert.True(t, options.GetBool(RepoTemplate, false)) + }) + + t.Run("key=false", func(t *testing.T) { + options := New() + + assert.True(t, options.Parse(fmt.Sprintf("%v=false", RepoPrivate))) + assert.False(t, options.GetBool(RepoPrivate, true)) + }) + + t.Run("key", func(t *testing.T) { + options := New() + + assert.True(t, options.Parse(fmt.Sprintf("%v", RepoPrivate))) + assert.True(t, options.GetBool(RepoPrivate, false)) + }) + + t.Run("unknown keys are ignored", func(t *testing.T) { + options := New() + + assert.True(t, options.Empty()) + assert.False(t, options.Parse("unknown=value")) + assert.True(t, options.Empty()) + }) +} + +func TestReadEnv(t *testing.T) { + t.Setenv(envPrefix+"_0", fmt.Sprintf("%v=true", AgitForcePush)) + t.Setenv(envPrefix+"_1", fmt.Sprintf("%v", RepoPrivate)) + t.Setenv(envPrefix+"_2", fmt.Sprintf("%v=equal=in string", AgitTitle)) + t.Setenv(envPrefix+"_3", "not=valid") + t.Setenv(envPrefix+"_4", fmt.Sprintf("%v=description", AgitDescription)) + t.Setenv(EnvCount, "5") + + options := New().ReadEnv() + + assert.True(t, options.GetBool(AgitForcePush, false)) + assert.True(t, options.GetBool(RepoPrivate, false)) + assert.False(t, options.GetBool(RepoTemplate, false)) + + { + val, ok := options.GetString(AgitTitle) + assert.True(t, ok) + assert.Equal(t, "equal=in string", val) + } + { + val, ok := options.GetString(AgitDescription) + assert.True(t, ok) + assert.Equal(t, "description", val) + } + { + _, ok := options.GetString(AgitTopic) + assert.False(t, ok) + } +} diff --git a/modules/git/ref.go b/modules/git/ref.go new file mode 100644 index 0000000..2db630e --- /dev/null +++ b/modules/git/ref.go @@ -0,0 +1,214 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "regexp" + "strings" + + "code.gitea.io/gitea/modules/util" +) + +const ( + // RemotePrefix is the base directory of the remotes information of git. + RemotePrefix = "refs/remotes/" + // PullPrefix is the base directory of the pull information of git. + PullPrefix = "refs/pull/" +) + +// refNamePatternInvalid is regular expression with unallowed characters in git reference name +// They cannot have ASCII control characters (i.e. bytes whose values are lower than \040, or \177 DEL), space, tilde ~, caret ^, or colon : anywhere. +// They cannot have question-mark ?, asterisk *, or open bracket [ anywhere +var refNamePatternInvalid = regexp.MustCompile( + `[\000-\037\177 \\~^:?*[]|` + // No absolutely invalid characters + `(?:^[/.])|` + // Not HasPrefix("/") or "." + `(?:/\.)|` + // no "/." + `(?:\.lock$)|(?:\.lock/)|` + // No ".lock/"" or ".lock" at the end + `(?:\.\.)|` + // no ".." anywhere + `(?://)|` + // no "//" anywhere + `(?:@{)|` + // no "@{" + `(?:[/.]$)|` + // no terminal '/' or '.' + `(?:^@$)`) // Not "@" + +// IsValidRefPattern ensures that the provided string could be a valid reference +func IsValidRefPattern(name string) bool { + return !refNamePatternInvalid.MatchString(name) +} + +func SanitizeRefPattern(name string) string { + return refNamePatternInvalid.ReplaceAllString(name, "_") +} + +// Reference represents a Git ref. +type Reference struct { + Name string + repo *Repository + Object ObjectID // The id of this commit object + Type string +} + +// Commit return the commit of the reference +func (ref *Reference) Commit() (*Commit, error) { + return ref.repo.getCommit(ref.Object) +} + +// ShortName returns the short name of the reference +func (ref *Reference) ShortName() string { + return RefName(ref.Name).ShortName() +} + +// RefGroup returns the group type of the reference +func (ref *Reference) RefGroup() string { + return RefName(ref.Name).RefGroup() +} + +// ForPrefix special ref to create a pull request: refs/for/<target-branch>/<topic-branch> +// or refs/for/<targe-branch> -o topic='<topic-branch>' +const ForPrefix = "refs/for/" + +// TODO: /refs/for-review for suggest change interface + +// RefName represents a full git reference name +type RefName string + +func RefNameFromBranch(shortName string) RefName { + return RefName(BranchPrefix + shortName) +} + +func RefNameFromTag(shortName string) RefName { + return RefName(TagPrefix + shortName) +} + +func (ref RefName) String() string { + return string(ref) +} + +func (ref RefName) IsBranch() bool { + return strings.HasPrefix(string(ref), BranchPrefix) +} + +func (ref RefName) IsTag() bool { + return strings.HasPrefix(string(ref), TagPrefix) +} + +func (ref RefName) IsRemote() bool { + return strings.HasPrefix(string(ref), RemotePrefix) +} + +func (ref RefName) IsPull() bool { + return strings.HasPrefix(string(ref), PullPrefix) && strings.IndexByte(string(ref)[len(PullPrefix):], '/') > -1 +} + +func (ref RefName) IsFor() bool { + return strings.HasPrefix(string(ref), ForPrefix) +} + +func (ref RefName) nameWithoutPrefix(prefix string) string { + if strings.HasPrefix(string(ref), prefix) { + return strings.TrimPrefix(string(ref), prefix) + } + return "" +} + +// TagName returns simple tag name if it's an operation to a tag +func (ref RefName) TagName() string { + return ref.nameWithoutPrefix(TagPrefix) +} + +// BranchName returns simple branch name if it's an operation to branch +func (ref RefName) BranchName() string { + return ref.nameWithoutPrefix(BranchPrefix) +} + +// PullName returns the pull request name part of refs like refs/pull/<pull_name>/head +func (ref RefName) PullName() string { + refName := string(ref) + lastIdx := strings.LastIndexByte(refName[len(PullPrefix):], '/') + if strings.HasPrefix(refName, PullPrefix) && lastIdx > -1 { + return refName[len(PullPrefix) : lastIdx+len(PullPrefix)] + } + return "" +} + +// ForBranchName returns the branch name part of refs like refs/for/<branch_name> +func (ref RefName) ForBranchName() string { + return ref.nameWithoutPrefix(ForPrefix) +} + +func (ref RefName) RemoteName() string { + return ref.nameWithoutPrefix(RemotePrefix) +} + +// ShortName returns the short name of the reference name +func (ref RefName) ShortName() string { + refName := string(ref) + if ref.IsBranch() { + return ref.BranchName() + } + if ref.IsTag() { + return ref.TagName() + } + if ref.IsRemote() { + return ref.RemoteName() + } + if ref.IsPull() { + return ref.PullName() + } + if ref.IsFor() { + return ref.ForBranchName() + } + + return refName +} + +// RefGroup returns the group type of the reference +// Using the name of the directory under .git/refs +func (ref RefName) RefGroup() string { + if ref.IsBranch() { + return "heads" + } + if ref.IsTag() { + return "tags" + } + if ref.IsRemote() { + return "remotes" + } + if ref.IsPull() { + return "pull" + } + if ref.IsFor() { + return "for" + } + return "" +} + +// RefType returns the simple ref type of the reference, e.g. branch, tag +// It's different from RefGroup, which is using the name of the directory under .git/refs +// Here we using branch but not heads, using tag but not tags +func (ref RefName) RefType() string { + var refType string + if ref.IsBranch() { + refType = "branch" + } else if ref.IsTag() { + refType = "tag" + } + return refType +} + +// RefURL returns the absolute URL for a ref in a repository +func RefURL(repoURL, ref string) string { + refFullName := RefName(ref) + refName := util.PathEscapeSegments(refFullName.ShortName()) + switch { + case refFullName.IsBranch(): + return repoURL + "/src/branch/" + refName + case refFullName.IsTag(): + return repoURL + "/src/tag/" + refName + case !Sha1ObjectFormat.IsValid(ref): + // assume they mean a branch + return repoURL + "/src/branch/" + refName + default: + return repoURL + "/src/commit/" + refName + } +} diff --git a/modules/git/ref_test.go b/modules/git/ref_test.go new file mode 100644 index 0000000..58f679b --- /dev/null +++ b/modules/git/ref_test.go @@ -0,0 +1,38 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRefName(t *testing.T) { + // Test branch names (with and without slash). + assert.Equal(t, "foo", RefName("refs/heads/foo").BranchName()) + assert.Equal(t, "feature/foo", RefName("refs/heads/feature/foo").BranchName()) + + // Test tag names (with and without slash). + assert.Equal(t, "foo", RefName("refs/tags/foo").TagName()) + assert.Equal(t, "release/foo", RefName("refs/tags/release/foo").TagName()) + + // Test pull names + assert.Equal(t, "1", RefName("refs/pull/1/head").PullName()) + assert.Equal(t, "my/pull", RefName("refs/pull/my/pull/head").PullName()) + + // Test for branch names + assert.Equal(t, "main", RefName("refs/for/main").ForBranchName()) + assert.Equal(t, "my/branch", RefName("refs/for/my/branch").ForBranchName()) + + // Test commit hashes. + assert.Equal(t, "c0ffee", RefName("c0ffee").ShortName()) +} + +func TestRefURL(t *testing.T) { + repoURL := "/user/repo" + assert.Equal(t, repoURL+"/src/branch/foo", RefURL(repoURL, "refs/heads/foo")) + assert.Equal(t, repoURL+"/src/tag/foo", RefURL(repoURL, "refs/tags/foo")) + assert.Equal(t, repoURL+"/src/commit/c0ffee", RefURL(repoURL, "c0ffee")) +} diff --git a/modules/git/remote.go b/modules/git/remote.go new file mode 100644 index 0000000..3585313 --- /dev/null +++ b/modules/git/remote.go @@ -0,0 +1,39 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "context" + + giturl "code.gitea.io/gitea/modules/git/url" +) + +// GetRemoteAddress returns remote url of git repository in the repoPath with special remote name +func GetRemoteAddress(ctx context.Context, repoPath, remoteName string) (string, error) { + var cmd *Command + if CheckGitVersionAtLeast("2.7") == nil { + cmd = NewCommand(ctx, "remote", "get-url").AddDynamicArguments(remoteName) + } else { + cmd = NewCommand(ctx, "config", "--get").AddDynamicArguments("remote." + remoteName + ".url") + } + + result, _, err := cmd.RunStdString(&RunOpts{Dir: repoPath}) + if err != nil { + return "", err + } + + if len(result) > 0 { + result = result[:len(result)-1] + } + return result, nil +} + +// GetRemoteURL returns the url of a specific remote of the repository. +func GetRemoteURL(ctx context.Context, repoPath, remoteName string) (*giturl.GitURL, error) { + addr, err := GetRemoteAddress(ctx, repoPath, remoteName) + if err != nil { + return nil, err + } + return giturl.Parse(addr) +} diff --git a/modules/git/repo.go b/modules/git/repo.go new file mode 100644 index 0000000..84db08d --- /dev/null +++ b/modules/git/repo.go @@ -0,0 +1,342 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Copyright 2017 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "bytes" + "context" + "fmt" + "io" + "net/url" + "os" + "path" + "path/filepath" + "strconv" + "strings" + "time" + + "code.gitea.io/gitea/modules/proxy" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" +) + +// GPGSettings represents the default GPG settings for this repository +type GPGSettings struct { + Sign bool + KeyID string + Email string + Name string + PublicKeyContent 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) parsePrettyFormatLogToList(logs []byte) ([]*Commit, error) { + var commits []*Commit + if len(logs) == 0 { + return commits, nil + } + + parts := bytes.Split(logs, []byte{'\n'}) + + for _, commitID := range parts { + commit, err := repo.GetCommit(string(commitID)) + if err != nil { + return nil, err + } + commits = append(commits, commit) + } + + return commits, nil +} + +// IsRepoURLAccessible checks if given repository URL is accessible. +func IsRepoURLAccessible(ctx context.Context, url string) bool { + _, _, err := NewCommand(ctx, "ls-remote", "-q", "-h").AddDynamicArguments(url, "HEAD").RunStdString(nil) + return err == nil +} + +// InitRepository initializes a new Git repository. +func InitRepository(ctx context.Context, repoPath string, bare bool, objectFormatName string) error { + err := os.MkdirAll(repoPath, os.ModePerm) + if err != nil { + return err + } + + cmd := NewCommand(ctx, "init") + + if !IsValidObjectFormat(objectFormatName) { + return fmt.Errorf("invalid object format: %s", objectFormatName) + } + if SupportHashSha256 { + cmd.AddOptionValues("--object-format", objectFormatName) + } + + if bare { + cmd.AddArguments("--bare") + } + _, _, err = cmd.RunStdString(&RunOpts{Dir: repoPath}) + return err +} + +// IsEmpty Check if repository is empty. +func (repo *Repository) IsEmpty() (bool, error) { + var errbuf, output strings.Builder + if err := NewCommand(repo.Ctx).AddOptionFormat("--git-dir=%s", repo.Path).AddArguments("rev-list", "-n", "1", "--all"). + Run(&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" { + // 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 strings.TrimSpace(output.String()) == "", nil +} + +// CloneRepoOptions options when clone a repository +type CloneRepoOptions struct { + Timeout time.Duration + Mirror bool + Bare bool + Quiet bool + Branch string + Shared bool + NoCheckout bool + Depth int + Filter string + SkipTLSVerify bool +} + +// Clone clones original repository to target path. +func Clone(ctx context.Context, from, to string, opts CloneRepoOptions) error { + return CloneWithArgs(ctx, globalCommandArgs, from, to, opts) +} + +// CloneWithArgs original repository to target path. +func CloneWithArgs(ctx context.Context, args TrustedCmdArgs, from, to string, opts CloneRepoOptions) (err error) { + toDir := path.Dir(to) + if err = os.MkdirAll(toDir, os.ModePerm); err != nil { + return err + } + + cmd := NewCommandContextNoGlobals(ctx, args...).AddArguments("clone") + if opts.SkipTLSVerify { + cmd.AddArguments("-c", "http.sslVerify=false") + } + if opts.Mirror { + cmd.AddArguments("--mirror") + } + if opts.Bare { + cmd.AddArguments("--bare") + } + if opts.Quiet { + cmd.AddArguments("--quiet") + } + if opts.Shared { + cmd.AddArguments("-s") + } + if opts.NoCheckout { + cmd.AddArguments("--no-checkout") + } + if opts.Depth > 0 { + cmd.AddArguments("--depth").AddDynamicArguments(strconv.Itoa(opts.Depth)) + } + if opts.Filter != "" { + cmd.AddArguments("--filter").AddDynamicArguments(opts.Filter) + } + if len(opts.Branch) > 0 { + cmd.AddArguments("-b").AddDynamicArguments(opts.Branch) + } + cmd.AddDashesAndList(from, to) + + if strings.Contains(from, "://") && strings.Contains(from, "@") { + cmd.SetDescription(fmt.Sprintf("clone branch %s from %s to %s (shared: %t, mirror: %t, depth: %d)", opts.Branch, util.SanitizeCredentialURLs(from), to, opts.Shared, opts.Mirror, opts.Depth)) + } else { + cmd.SetDescription(fmt.Sprintf("clone branch %s from %s to %s (shared: %t, mirror: %t, depth: %d)", opts.Branch, from, to, opts.Shared, opts.Mirror, opts.Depth)) + } + + if opts.Timeout <= 0 { + opts.Timeout = -1 + } + + envs := os.Environ() + u, err := url.Parse(from) + if err == nil { + envs = proxy.EnvWithProxy(u) + } + + stderr := new(bytes.Buffer) + if err = cmd.Run(&RunOpts{ + Timeout: opts.Timeout, + Env: envs, + Stdout: io.Discard, + Stderr: stderr, + }); err != nil { + return ConcatenateError(err, stderr.String()) + } + return nil +} + +// PushOptions options when push to remote +type PushOptions struct { + Remote string + Branch string + Force bool + Mirror bool + Env []string + Timeout time.Duration + PrivateKeyPath string +} + +// Push pushs local commits to given remote branch. +func Push(ctx context.Context, repoPath string, opts PushOptions) error { + cmd := NewCommand(ctx, "push") + + if opts.PrivateKeyPath != "" { + // Preserve the behavior that existing environments are used if no + // environments are passed. + if len(opts.Env) == 0 { + opts.Env = os.Environ() + } + + // Use environment because it takes precedence over using -c core.sshcommand + // and it's possible that a system might have an existing GIT_SSH_COMMAND + // environment set. + opts.Env = append(opts.Env, "GIT_SSH_COMMAND=ssh"+ + fmt.Sprintf(` -i %s`, opts.PrivateKeyPath)+ + " -o IdentitiesOnly=yes"+ + // This will store new SSH host keys and verify connections to existing + // host keys, but it doesn't allow replacement of existing host keys. This + // means TOFU is used for Git over SSH pushes. + " -o StrictHostKeyChecking=accept-new"+ + " -o UserKnownHostsFile="+filepath.Join(setting.SSH.RootPath, "known_hosts")) + } + + if opts.Force { + cmd.AddArguments("-f") + } + if opts.Mirror { + cmd.AddArguments("--mirror") + } + remoteBranchArgs := []string{opts.Remote} + if len(opts.Branch) > 0 { + remoteBranchArgs = append(remoteBranchArgs, opts.Branch) + } + cmd.AddDashesAndList(remoteBranchArgs...) + + if strings.Contains(opts.Remote, "://") && strings.Contains(opts.Remote, "@") { + cmd.SetDescription(fmt.Sprintf("push branch %s to %s (force: %t, mirror: %t)", opts.Branch, util.SanitizeCredentialURLs(opts.Remote), opts.Force, opts.Mirror)) + } else { + cmd.SetDescription(fmt.Sprintf("push branch %s to %s (force: %t, mirror: %t)", opts.Branch, opts.Remote, opts.Force, opts.Mirror)) + } + + stdout, stderr, err := cmd.RunStdString(&RunOpts{Env: opts.Env, Timeout: opts.Timeout, Dir: repoPath}) + if err != nil { + if strings.Contains(stderr, "non-fast-forward") { + return &ErrPushOutOfDate{StdOut: stdout, StdErr: stderr, Err: err} + } else if strings.Contains(stderr, "! [remote rejected]") { + err := &ErrPushRejected{StdOut: stdout, StdErr: stderr, Err: err} + err.GenerateMessage() + return err + } else if strings.Contains(stderr, "matches more than one") { + return &ErrMoreThanOne{StdOut: stdout, StdErr: stderr, Err: err} + } + return fmt.Errorf("push failed: %w - %s\n%s", err, stderr, stdout) + } + + return nil +} + +// GetLatestCommitTime returns time for latest commit in repository (across all branches) +func GetLatestCommitTime(ctx context.Context, repoPath string) (time.Time, error) { + cmd := NewCommand(ctx, "for-each-ref", "--sort=-committerdate", BranchPrefix, "--count", "1", "--format=%(committerdate)") + stdout, _, err := cmd.RunStdString(&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 := NewCommand(ctx, "rev-list", "--count", "--left-right"). + AddDynamicArguments(baseBranch + "..." + targetBranch).AddArguments("--") + stdout, _, err := cmd.RunStdString(&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, err := os.MkdirTemp(os.TempDir(), "gitea-bundle") + if err != nil { + return err + } + defer os.RemoveAll(tmp) + + env := append(os.Environ(), "GIT_OBJECT_DIRECTORY="+filepath.Join(repo.Path, "objects")) + _, _, err = NewCommand(ctx, "init", "--bare").RunStdString(&RunOpts{Dir: tmp, Env: env}) + if err != nil { + return err + } + + _, _, err = NewCommand(ctx, "reset", "--soft").AddDynamicArguments(commit).RunStdString(&RunOpts{Dir: tmp, Env: env}) + if err != nil { + return err + } + + _, _, err = NewCommand(ctx, "branch", "-m", "bundle").RunStdString(&RunOpts{Dir: tmp, Env: env}) + if err != nil { + return err + } + + tmpFile := filepath.Join(tmp, "bundle") + _, _, err = NewCommand(ctx, "bundle", "create").AddDynamicArguments(tmpFile, "bundle", "HEAD").RunStdString(&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 +} diff --git a/modules/git/repo_archive.go b/modules/git/repo_archive.go new file mode 100644 index 0000000..1bf1aa4 --- /dev/null +++ b/modules/git/repo_archive.go @@ -0,0 +1,80 @@ +// 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" + "os" + "path/filepath" + "strings" +) + +// ArchiveType archive types +type ArchiveType int + +const ( + // ZIP zip archive type + ZIP ArchiveType = iota + 1 + // TARGZ tar gz archive type + TARGZ + // BUNDLE bundle archive type + BUNDLE +) + +// String converts an ArchiveType to string +func (a ArchiveType) String() string { + switch a { + case ZIP: + return "zip" + case TARGZ: + return "tar.gz" + case BUNDLE: + return "bundle" + } + return "unknown" +} + +func ToArchiveType(s string) ArchiveType { + switch s { + case "zip": + return ZIP + case "tar.gz": + return TARGZ + case "bundle": + return BUNDLE + } + return 0 +} + +// 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 := NewCommand(ctx, "archive") + if usePrefix { + cmd.AddOptionFormat("--prefix=%s", filepath.Base(strings.TrimSuffix(repo.Path, ".git"))+"/") + } + cmd.AddOptionFormat("--format=%s", format.String()) + cmd.AddDynamicArguments(commitID) + + // Avoid LFS hooks getting installed because of /etc/gitconfig, which can break pull requests. + env := append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1") + + var stderr strings.Builder + err := cmd.Run(&RunOpts{ + Dir: repo.Path, + Stdout: target, + Stderr: &stderr, + Env: env, + }) + if err != nil { + return ConcatenateError(err, stderr.String()) + } + return nil +} diff --git a/modules/git/repo_attribute.go b/modules/git/repo_attribute.go new file mode 100644 index 0000000..3ccc1b8 --- /dev/null +++ b/modules/git/repo_attribute.go @@ -0,0 +1,286 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "bufio" + "bytes" + "context" + "fmt" + "io" + "os" + "strings" + "sync/atomic" + + "code.gitea.io/gitea/modules/optional" +) + +var LinguistAttributes = []string{"linguist-vendored", "linguist-generated", "linguist-language", "gitlab-language", "linguist-documentation", "linguist-detectable"} + +// newCheckAttrStdoutReader parses the nul-byte separated output of git check-attr on each call of +// the returned function. The first reading error will stop the reading and be returned on all +// subsequent calls. +func newCheckAttrStdoutReader(r io.Reader, count int) func() (map[string]GitAttribute, error) { + scanner := bufio.NewScanner(r) + + // adapted from bufio.ScanLines to split on nul-byte \x00 + scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) { + if atEOF && len(data) == 0 { + return 0, nil, nil + } + if i := bytes.IndexByte(data, '\x00'); i >= 0 { + // We have a full nul-terminated line. + return i + 1, data[0:i], nil + } + // If we're at EOF, we have a final, non-terminated line. Return it. + if atEOF { + return len(data), data, nil + } + // Request more data. + return 0, nil, nil + }) + + var err error + nextText := func() string { + if err != nil { + return "" + } + if !scanner.Scan() { + err = scanner.Err() + if err == nil { + err = io.ErrUnexpectedEOF + } + return "" + } + return scanner.Text() + } + nextAttribute := func() (string, GitAttribute, error) { + nextText() // discard filename + key := nextText() + value := GitAttribute(nextText()) + return key, value, err + } + return func() (map[string]GitAttribute, error) { + values := make(map[string]GitAttribute, count) + for range count { + k, v, err := nextAttribute() + if err != nil { + return values, err + } + values[k] = v + } + return values, scanner.Err() + } +} + +// GitAttribute exposes an attribute from the .gitattribute file +type GitAttribute string //nolint:revive + +// IsSpecified returns true if the gitattribute is set and not empty +func (ca GitAttribute) IsSpecified() bool { + return ca != "" && ca != "unspecified" +} + +// String returns the value of the attribute or "" if unspecified +func (ca GitAttribute) String() string { + if !ca.IsSpecified() { + return "" + } + return string(ca) +} + +// Prefix returns the value of the attribute before any question mark '?' +// +// sometimes used within gitlab-language: https://docs.gitlab.com/ee/user/project/highlighting.html#override-syntax-highlighting-for-a-file-type +func (ca GitAttribute) Prefix() string { + s := ca.String() + if i := strings.IndexByte(s, '?'); i >= 0 { + return s[:i] + } + return s +} + +// Bool returns true if "set"/"true", false if "unset"/"false", none otherwise +func (ca GitAttribute) Bool() optional.Option[bool] { + switch ca { + case "set", "true": + return optional.Some(true) + case "unset", "false": + return optional.Some(false) + } + return optional.None[bool]() +} + +// gitCheckAttrCommand prepares the "git check-attr" command for later use as one-shot or streaming +// instantiation. +func (repo *Repository) gitCheckAttrCommand(treeish string, attributes ...string) (*Command, *RunOpts, context.CancelFunc, error) { + if len(attributes) == 0 { + return nil, nil, nil, fmt.Errorf("no provided attributes to check-attr") + } + + env := os.Environ() + var removeTempFiles context.CancelFunc = func() {} + + // git < 2.40 cannot run check-attr on bare repo, but needs INDEX + WORK_TREE + hasIndex := treeish == "" + if !hasIndex && !SupportCheckAttrOnBare { + indexFilename, worktree, cancel, err := repo.ReadTreeToTemporaryIndex(treeish) + if err != nil { + return nil, nil, nil, err + } + removeTempFiles = cancel + + env = append(env, "GIT_INDEX_FILE="+indexFilename, "GIT_WORK_TREE="+worktree) + + hasIndex = true + + // clear treeish to read from provided index/work_tree + treeish = "" + } + + cmd := NewCommand(repo.Ctx, "check-attr", "-z") + + if hasIndex { + cmd.AddArguments("--cached") + } + + if len(treeish) > 0 { + cmd.AddArguments("--source") + cmd.AddDynamicArguments(treeish) + } + cmd.AddDynamicArguments(attributes...) + + // Version 2.43.1 has a bug where the behavior of `GIT_FLUSH` is flipped. + // Ref: https://lore.kernel.org/git/CABn0oJvg3M_kBW-u=j3QhKnO=6QOzk-YFTgonYw_UvFS1NTX4g@mail.gmail.com + if InvertedGitFlushEnv { + env = append(env, "GIT_FLUSH=0") + } else { + env = append(env, "GIT_FLUSH=1") + } + + return cmd, &RunOpts{ + Env: env, + Dir: repo.Path, + }, removeTempFiles, nil +} + +// GitAttributeFirst returns the first specified attribute of the given filename. +// +// If treeish is empty, the gitattribute will be read from the current repo (which MUST be a working directory and NOT bare). +func (repo *Repository) GitAttributeFirst(treeish, filename string, attributes ...string) (GitAttribute, error) { + values, err := repo.GitAttributes(treeish, filename, attributes...) + if err != nil { + return "", err + } + for _, a := range attributes { + if values[a].IsSpecified() { + return values[a], nil + } + } + return "", nil +} + +// GitAttributes returns the gitattribute of the given filename. +// +// If treeish is empty, the gitattribute will be read from the current repo (which MUST be a working directory and NOT bare). +func (repo *Repository) GitAttributes(treeish, filename string, attributes ...string) (map[string]GitAttribute, error) { + cmd, runOpts, removeTempFiles, err := repo.gitCheckAttrCommand(treeish, attributes...) + if err != nil { + return nil, err + } + defer removeTempFiles() + + stdOut := new(bytes.Buffer) + runOpts.Stdout = stdOut + + stdErr := new(bytes.Buffer) + runOpts.Stderr = stdErr + + cmd.AddDashesAndList(filename) + + if err := cmd.Run(runOpts); err != nil { + return nil, fmt.Errorf("failed to run check-attr: %w\n%s\n%s", err, stdOut.String(), stdErr.String()) + } + + return newCheckAttrStdoutReader(stdOut, len(attributes))() +} + +// GitAttributeChecker creates an AttributeChecker for the given repository and provided commit ID +// to retrieve the attributes of multiple files. The AttributeChecker must be closed after use. +// +// If treeish is empty, the gitattribute will be read from the current repo (which MUST be a working directory and NOT bare). +func (repo *Repository) GitAttributeChecker(treeish string, attributes ...string) (AttributeChecker, error) { + cmd, runOpts, removeTempFiles, err := repo.gitCheckAttrCommand(treeish, attributes...) + if err != nil { + return AttributeChecker{}, err + } + + cmd.AddArguments("--stdin") + + // os.Pipe is needed (and not io.Pipe), otherwise cmd.Wait will wait for the stdinReader + // to be closed before returning (which would require another goroutine) + // https://go.dev/issue/23019 + stdinReader, stdinWriter, err := os.Pipe() // reader closed in goroutine / writer closed on ac.Close + if err != nil { + return AttributeChecker{}, err + } + stdoutReader, stdoutWriter := io.Pipe() // closed in goroutine + + ac := AttributeChecker{ + removeTempFiles: removeTempFiles, // called on ac.Close + stdinWriter: stdinWriter, + readStdout: newCheckAttrStdoutReader(stdoutReader, len(attributes)), + err: &atomic.Value{}, + } + + go func() { + defer stdinReader.Close() + defer stdoutWriter.Close() // in case of a panic (no-op if already closed by CloseWithError at the end) + + stdErr := new(bytes.Buffer) + runOpts.Stdin = stdinReader + runOpts.Stdout = stdoutWriter + runOpts.Stderr = stdErr + + err := cmd.Run(runOpts) + + // if the context was cancelled, Run error is irrelevant + if e := cmd.parentContext.Err(); e != nil { + err = e + } + + if err != nil { // decorate the returned error + err = fmt.Errorf("git check-attr (stderr: %q): %w", strings.TrimSpace(stdErr.String()), err) + ac.err.Store(err) + } + stdoutWriter.CloseWithError(err) + }() + + return ac, nil +} + +type AttributeChecker struct { + removeTempFiles context.CancelFunc + stdinWriter io.WriteCloser + readStdout func() (map[string]GitAttribute, error) + err *atomic.Value +} + +func (ac AttributeChecker) CheckPath(path string) (map[string]GitAttribute, error) { + if _, err := ac.stdinWriter.Write([]byte(path + "\x00")); err != nil { + // try to return the Run error if available, since it is likely more helpful + // than just "broken pipe" + if aerr, _ := ac.err.Load().(error); aerr != nil { + return nil, aerr + } + return nil, fmt.Errorf("git check-attr: %w", err) + } + + return ac.readStdout() +} + +func (ac AttributeChecker) Close() error { + ac.removeTempFiles() + return ac.stdinWriter.Close() +} diff --git a/modules/git/repo_attribute_test.go b/modules/git/repo_attribute_test.go new file mode 100644 index 0000000..fa34164 --- /dev/null +++ b/modules/git/repo_attribute_test.go @@ -0,0 +1,351 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "context" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "code.gitea.io/gitea/modules/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewCheckAttrStdoutReader(t *testing.T) { + t.Run("two_times", func(t *testing.T) { + read := newCheckAttrStdoutReader(strings.NewReader( + ".gitignore\x00linguist-vendored\x00unspecified\x00"+ + ".gitignore\x00linguist-vendored\x00specified", + ), 1) + + // first read + attr, err := read() + require.NoError(t, err) + assert.Equal(t, map[string]GitAttribute{ + "linguist-vendored": GitAttribute("unspecified"), + }, attr) + + // second read + attr, err = read() + require.NoError(t, err) + assert.Equal(t, map[string]GitAttribute{ + "linguist-vendored": GitAttribute("specified"), + }, attr) + }) + t.Run("incomplete", func(t *testing.T) { + read := newCheckAttrStdoutReader(strings.NewReader( + "filename\x00linguist-vendored", + ), 1) + + _, err := read() + assert.Equal(t, io.ErrUnexpectedEOF, err) + }) + t.Run("three_times", func(t *testing.T) { + read := newCheckAttrStdoutReader(strings.NewReader( + "shouldbe.vendor\x00linguist-vendored\x00set\x00"+ + "shouldbe.vendor\x00linguist-generated\x00unspecified\x00"+ + "shouldbe.vendor\x00linguist-language\x00unspecified\x00", + ), 1) + + // first read + attr, err := read() + require.NoError(t, err) + assert.Equal(t, map[string]GitAttribute{ + "linguist-vendored": GitAttribute("set"), + }, attr) + + // second read + attr, err = read() + require.NoError(t, err) + assert.Equal(t, map[string]GitAttribute{ + "linguist-generated": GitAttribute("unspecified"), + }, attr) + + // third read + attr, err = read() + require.NoError(t, err) + assert.Equal(t, map[string]GitAttribute{ + "linguist-language": GitAttribute("unspecified"), + }, attr) + }) +} + +func TestGitAttributeBareNonBare(t *testing.T) { + if !SupportCheckAttrOnBare { + t.Skip("git check-attr supported on bare repo starting with git 2.40") + } + + repoPath := filepath.Join(testReposDir, "language_stats_repo") + gitRepo, err := openRepositoryWithDefaultContext(repoPath) + require.NoError(t, err) + defer gitRepo.Close() + + for _, commitID := range []string{ + "8fee858da5796dfb37704761701bb8e800ad9ef3", + "341fca5b5ea3de596dc483e54c2db28633cd2f97", + } { + bareStats, err := gitRepo.GitAttributes(commitID, "i-am-a-python.p", LinguistAttributes...) + require.NoError(t, err) + + defer test.MockVariableValue(&SupportCheckAttrOnBare, false)() + cloneStats, err := gitRepo.GitAttributes(commitID, "i-am-a-python.p", LinguistAttributes...) + require.NoError(t, err) + + assert.EqualValues(t, cloneStats, bareStats) + refStats := cloneStats + + t.Run("GitAttributeChecker/"+commitID+"/SupportBare", func(t *testing.T) { + bareChecker, err := gitRepo.GitAttributeChecker(commitID, LinguistAttributes...) + require.NoError(t, err) + defer bareChecker.Close() + + bareStats, err := bareChecker.CheckPath("i-am-a-python.p") + require.NoError(t, err) + assert.EqualValues(t, refStats, bareStats) + }) + t.Run("GitAttributeChecker/"+commitID+"/NoBareSupport", func(t *testing.T) { + defer test.MockVariableValue(&SupportCheckAttrOnBare, false)() + cloneChecker, err := gitRepo.GitAttributeChecker(commitID, LinguistAttributes...) + require.NoError(t, err) + defer cloneChecker.Close() + + cloneStats, err := cloneChecker.CheckPath("i-am-a-python.p") + require.NoError(t, err) + + assert.EqualValues(t, refStats, cloneStats) + }) + } +} + +func TestGitAttributes(t *testing.T) { + repoPath := filepath.Join(testReposDir, "language_stats_repo") + gitRepo, err := openRepositoryWithDefaultContext(repoPath) + require.NoError(t, err) + defer gitRepo.Close() + + attr, err := gitRepo.GitAttributes("8fee858da5796dfb37704761701bb8e800ad9ef3", "i-am-a-python.p", LinguistAttributes...) + require.NoError(t, err) + assert.EqualValues(t, map[string]GitAttribute{ + "gitlab-language": "unspecified", + "linguist-detectable": "unspecified", + "linguist-documentation": "unspecified", + "linguist-generated": "unspecified", + "linguist-language": "Python", + "linguist-vendored": "unspecified", + }, attr) + + attr, err = gitRepo.GitAttributes("341fca5b5ea3de596dc483e54c2db28633cd2f97", "i-am-a-python.p", LinguistAttributes...) + require.NoError(t, err) + assert.EqualValues(t, map[string]GitAttribute{ + "gitlab-language": "unspecified", + "linguist-detectable": "unspecified", + "linguist-documentation": "unspecified", + "linguist-generated": "unspecified", + "linguist-language": "Cobra", + "linguist-vendored": "unspecified", + }, attr) +} + +func TestGitAttributeFirst(t *testing.T) { + repoPath := filepath.Join(testReposDir, "language_stats_repo") + gitRepo, err := openRepositoryWithDefaultContext(repoPath) + require.NoError(t, err) + defer gitRepo.Close() + + t.Run("first is specified", func(t *testing.T) { + language, err := gitRepo.GitAttributeFirst("8fee858da5796dfb37704761701bb8e800ad9ef3", "i-am-a-python.p", "linguist-language", "gitlab-language") + require.NoError(t, err) + assert.Equal(t, "Python", language.String()) + }) + + t.Run("second is specified", func(t *testing.T) { + language, err := gitRepo.GitAttributeFirst("8fee858da5796dfb37704761701bb8e800ad9ef3", "i-am-a-python.p", "gitlab-language", "linguist-language") + require.NoError(t, err) + assert.Equal(t, "Python", language.String()) + }) + + t.Run("none is specified", func(t *testing.T) { + language, err := gitRepo.GitAttributeFirst("8fee858da5796dfb37704761701bb8e800ad9ef3", "i-am-a-python.p", "linguist-detectable", "gitlab-language", "non-existing") + require.NoError(t, err) + assert.Equal(t, "", language.String()) + }) +} + +func TestGitAttributeStruct(t *testing.T) { + assert.Equal(t, "", GitAttribute("").String()) + assert.Equal(t, "", GitAttribute("unspecified").String()) + + assert.Equal(t, "python", GitAttribute("python").String()) + + assert.Equal(t, "text?token=Error", GitAttribute("text?token=Error").String()) + assert.Equal(t, "text", GitAttribute("text?token=Error").Prefix()) +} + +func TestGitAttributeCheckerError(t *testing.T) { + prepareRepo := func(t *testing.T) *Repository { + t.Helper() + path := t.TempDir() + + // we can't use unittest.CopyDir because of an import cycle (git.Init in unittest) + require.NoError(t, CopyFS(path, os.DirFS(filepath.Join(testReposDir, "language_stats_repo")))) + + gitRepo, err := openRepositoryWithDefaultContext(path) + require.NoError(t, err) + return gitRepo + } + + t.Run("RemoveAll/BeforeRun", func(t *testing.T) { + gitRepo := prepareRepo(t) + defer gitRepo.Close() + + require.NoError(t, os.RemoveAll(gitRepo.Path)) + + ac, err := gitRepo.GitAttributeChecker("", "linguist-language") + require.NoError(t, err) + + _, err = ac.CheckPath("i-am-a-python.p") + require.Error(t, err) + assert.Contains(t, err.Error(), `git check-attr (stderr: ""):`) + }) + + t.Run("RemoveAll/DuringRun", func(t *testing.T) { + gitRepo := prepareRepo(t) + defer gitRepo.Close() + + ac, err := gitRepo.GitAttributeChecker("", "linguist-language") + require.NoError(t, err) + + // calling CheckPath before would allow git to cache part of it and successfully return later + require.NoError(t, os.RemoveAll(gitRepo.Path)) + + _, err = ac.CheckPath("i-am-a-python.p") + if err == nil { + t.Skip( + "git check-attr started too fast and CheckPath was successful (and likely cached)", + "https://codeberg.org/forgejo/forgejo/issues/2948", + ) + } + // Depending on the order of execution, the returned error can be: + // - a launch error "fork/exec /usr/bin/git: no such file or directory" (when the removal happens before the Run) + // - a git error (stderr: "fatal: Unable to read current working directory: No such file or directory"): exit status 128 (when the removal happens after the Run) + // (pipe error "write |1: broken pipe" should be replaced by one of the Run errors above) + assert.Contains(t, err.Error(), `git check-attr`) + }) + + t.Run("Cancelled/BeforeRun", func(t *testing.T) { + gitRepo := prepareRepo(t) + defer gitRepo.Close() + + var cancel context.CancelFunc + gitRepo.Ctx, cancel = context.WithCancel(gitRepo.Ctx) + cancel() + + ac, err := gitRepo.GitAttributeChecker("8fee858da5796dfb37704761701bb8e800ad9ef3", "linguist-language") + require.NoError(t, err) + + _, err = ac.CheckPath("i-am-a-python.p") + require.ErrorIs(t, err, context.Canceled) + }) + + t.Run("Cancelled/DuringRun", func(t *testing.T) { + gitRepo := prepareRepo(t) + defer gitRepo.Close() + + var cancel context.CancelFunc + gitRepo.Ctx, cancel = context.WithCancel(gitRepo.Ctx) + + ac, err := gitRepo.GitAttributeChecker("8fee858da5796dfb37704761701bb8e800ad9ef3", "linguist-language") + require.NoError(t, err) + + attr, err := ac.CheckPath("i-am-a-python.p") + require.NoError(t, err) + assert.Equal(t, "Python", attr["linguist-language"].String()) + + errCh := make(chan error) + go func() { + cancel() + + for err == nil { + _, err = ac.CheckPath("i-am-a-python.p") + runtime.Gosched() // the cancellation must have time to propagate + } + errCh <- err + }() + + select { + case <-time.After(time.Second): + t.Error("CheckPath did not complete within 1s") + case err = <-errCh: + require.ErrorIs(t, err, context.Canceled) + } + }) + + t.Run("Closed/BeforeRun", func(t *testing.T) { + gitRepo := prepareRepo(t) + defer gitRepo.Close() + + ac, err := gitRepo.GitAttributeChecker("8fee858da5796dfb37704761701bb8e800ad9ef3", "linguist-language") + require.NoError(t, err) + + require.NoError(t, ac.Close()) + + _, err = ac.CheckPath("i-am-a-python.p") + require.ErrorIs(t, err, fs.ErrClosed) + }) + + t.Run("Closed/DuringRun", func(t *testing.T) { + gitRepo := prepareRepo(t) + defer gitRepo.Close() + + ac, err := gitRepo.GitAttributeChecker("8fee858da5796dfb37704761701bb8e800ad9ef3", "linguist-language") + require.NoError(t, err) + + attr, err := ac.CheckPath("i-am-a-python.p") + require.NoError(t, err) + assert.Equal(t, "Python", attr["linguist-language"].String()) + + require.NoError(t, ac.Close()) + + _, err = ac.CheckPath("i-am-a-python.p") + require.ErrorIs(t, err, fs.ErrClosed) + }) +} + +// CopyFS is adapted from https://github.com/golang/go/issues/62484 +// which should be available with go1.23 +func CopyFS(dir string, fsys fs.FS) error { + return fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, _ error) error { + targ := filepath.Join(dir, filepath.FromSlash(path)) + if d.IsDir() { + return os.MkdirAll(targ, 0o777) + } + r, err := fsys.Open(path) + if err != nil { + return err + } + defer r.Close() + info, err := r.Stat() + if err != nil { + return err + } + w, err := os.OpenFile(targ, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o666|info.Mode()&0o777) + if err != nil { + return err + } + if _, err := io.Copy(w, r); err != nil { + w.Close() + return fmt.Errorf("copying %s: %v", path, err) + } + return w.Close() + }) +} diff --git a/modules/git/repo_base.go b/modules/git/repo_base.go new file mode 100644 index 0000000..5f17bc1 --- /dev/null +++ b/modules/git/repo_base.go @@ -0,0 +1,124 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "bufio" + "context" + "errors" + "path/filepath" + + "code.gitea.io/gitea/modules/log" +) + +// Repository represents a Git repository. +type Repository struct { + Path string + + tagCache *ObjectCache + + gpgSettings *GPGSettings + + batchInUse bool + batch *Batch + + checkInUse bool + check *Batch + + Ctx context.Context + LastCommitCache *LastCommitCache + + objectFormat ObjectFormat +} + +// openRepositoryWithDefaultContext opens the repository at the given path with DefaultContext. +func openRepositoryWithDefaultContext(repoPath string) (*Repository, error) { + return OpenRepository(DefaultContext, repoPath) +} + +// OpenRepository opens the repository at the given path with the provided context. +func OpenRepository(ctx context.Context, repoPath string) (*Repository, error) { + repoPath, err := filepath.Abs(repoPath) + if err != nil { + return nil, err + } else if !isDir(repoPath) { + return nil, errors.New("no such file or directory") + } + + return &Repository{ + Path: repoPath, + tagCache: newObjectCache(), + Ctx: ctx, + }, 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 = repo.NewBatch(ctx) + if err != nil { + return nil, nil, nil, err + } + } + + if !repo.batchInUse { + repo.batchInUse = true + return repo.batch.Writer, repo.batch.Reader, func() { + repo.batchInUse = false + }, nil + } + + log.Debug("Opening temporary cat file batch for: %s", repo.Path) + tempBatch, err := repo.NewBatch(ctx) + if err != nil { + return nil, 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 = repo.NewBatchCheck(ctx) + 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 := repo.NewBatchCheck(ctx) + if err != nil { + return nil, nil, nil, err + } + return tempBatchCheck.Writer, tempBatchCheck.Reader, tempBatchCheck.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.LastCommitCache = nil + repo.tagCache = nil + return nil +} diff --git a/modules/git/repo_base_test.go b/modules/git/repo_base_test.go new file mode 100644 index 0000000..323b28f --- /dev/null +++ b/modules/git/repo_base_test.go @@ -0,0 +1,163 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package git + +import ( + "bufio" + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// This unit test relies on the implementation detail of CatFileBatch. +func TestCatFileBatch(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + repo, err := OpenRepository(ctx, "./tests/repos/repo1_bare") + require.NoError(t, err) + defer repo.Close() + + var wr WriteCloserError + var r *bufio.Reader + var cancel1 func() + t.Run("Request cat file batch", func(t *testing.T) { + assert.Nil(t, repo.batch) + wr, r, cancel1, err = repo.CatFileBatch(ctx) + require.NoError(t, err) + assert.NotNil(t, repo.batch) + assert.Equal(t, repo.batch.Writer, wr) + assert.True(t, repo.batchInUse) + }) + + t.Run("Request temporary cat file batch", func(t *testing.T) { + wr, r, cancel, err := repo.CatFileBatch(ctx) + require.NoError(t, err) + assert.NotEqual(t, repo.batch.Writer, wr) + + t.Run("Check temporary cat file batch", func(t *testing.T) { + _, err = wr.Write([]byte("95bb4d39648ee7e325106df01a621c530863a653" + "\n")) + require.NoError(t, err) + + sha, typ, size, err := ReadBatchLine(r) + require.NoError(t, err) + assert.Equal(t, "commit", typ) + assert.EqualValues(t, []byte("95bb4d39648ee7e325106df01a621c530863a653"), sha) + assert.EqualValues(t, 144, size) + }) + + cancel() + assert.True(t, repo.batchInUse) + }) + + t.Run("Check cached cat file batch", func(t *testing.T) { + _, err = wr.Write([]byte("95bb4d39648ee7e325106df01a621c530863a653" + "\n")) + require.NoError(t, err) + + sha, typ, size, err := ReadBatchLine(r) + require.NoError(t, err) + assert.Equal(t, "commit", typ) + assert.EqualValues(t, []byte("95bb4d39648ee7e325106df01a621c530863a653"), sha) + assert.EqualValues(t, 144, size) + }) + + t.Run("Cancel cached cat file batch", func(t *testing.T) { + cancel1() + assert.False(t, repo.batchInUse) + assert.NotNil(t, repo.batch) + }) + + t.Run("Request cached cat file batch", func(t *testing.T) { + wr, _, _, err := repo.CatFileBatch(ctx) + require.NoError(t, err) + assert.NotNil(t, repo.batch) + assert.Equal(t, repo.batch.Writer, wr) + assert.True(t, repo.batchInUse) + + t.Run("Close git repo", func(t *testing.T) { + require.NoError(t, repo.Close()) + assert.Nil(t, repo.batch) + }) + + _, err = wr.Write([]byte("95bb4d39648ee7e325106df01a621c530863a653" + "\n")) + require.Error(t, err) + }) +} + +// This unit test relies on the implementation detail of CatFileBatchCheck. +func TestCatFileBatchCheck(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + repo, err := OpenRepository(ctx, "./tests/repos/repo1_bare") + require.NoError(t, err) + defer repo.Close() + + var wr WriteCloserError + var r *bufio.Reader + var cancel1 func() + t.Run("Request cat file batch check", func(t *testing.T) { + assert.Nil(t, repo.check) + wr, r, cancel1, err = repo.CatFileBatchCheck(ctx) + require.NoError(t, err) + assert.NotNil(t, repo.check) + assert.Equal(t, repo.check.Writer, wr) + assert.True(t, repo.checkInUse) + }) + + t.Run("Request temporary cat file batch check", func(t *testing.T) { + wr, r, cancel, err := repo.CatFileBatchCheck(ctx) + require.NoError(t, err) + assert.NotEqual(t, repo.check.Writer, wr) + + t.Run("Check temporary cat file batch check", func(t *testing.T) { + _, err = wr.Write([]byte("test" + "\n")) + require.NoError(t, err) + + sha, typ, size, err := ReadBatchLine(r) + require.NoError(t, err) + assert.Equal(t, "tag", typ) + assert.EqualValues(t, []byte("3ad28a9149a2864384548f3d17ed7f38014c9e8a"), sha) + assert.EqualValues(t, 807, size) + }) + + cancel() + assert.True(t, repo.checkInUse) + }) + + t.Run("Check cached cat file batch check", func(t *testing.T) { + _, err = wr.Write([]byte("test" + "\n")) + require.NoError(t, err) + + sha, typ, size, err := ReadBatchLine(r) + require.NoError(t, err) + assert.Equal(t, "tag", typ) + assert.EqualValues(t, []byte("3ad28a9149a2864384548f3d17ed7f38014c9e8a"), sha) + assert.EqualValues(t, 807, size) + }) + + t.Run("Cancel cached cat file batch check", func(t *testing.T) { + cancel1() + assert.False(t, repo.checkInUse) + assert.NotNil(t, repo.check) + }) + + t.Run("Request cached cat file batch check", func(t *testing.T) { + wr, _, _, err := repo.CatFileBatchCheck(ctx) + require.NoError(t, err) + assert.NotNil(t, repo.check) + assert.Equal(t, repo.check.Writer, wr) + assert.True(t, repo.checkInUse) + + t.Run("Close git repo", func(t *testing.T) { + require.NoError(t, repo.Close()) + assert.Nil(t, repo.check) + }) + + _, err = wr.Write([]byte("test" + "\n")) + require.Error(t, err) + }) +} diff --git a/modules/git/repo_blame.go b/modules/git/repo_blame.go new file mode 100644 index 0000000..139cdd7 --- /dev/null +++ b/modules/git/repo_blame.go @@ -0,0 +1,23 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "fmt" +) + +// LineBlame returns the latest commit at the given line +func (repo *Repository) LineBlame(revision, path, file string, line uint) (*Commit, error) { + res, _, err := NewCommand(repo.Ctx, "blame"). + AddOptionFormat("-L %d,%d", line, line). + AddOptionValues("-p", revision). + AddDashesAndList(file).RunStdString(&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]) +} diff --git a/modules/git/repo_blob_test.go b/modules/git/repo_blob_test.go new file mode 100644 index 0000000..b018479 --- /dev/null +++ b/modules/git/repo_blob_test.go @@ -0,0 +1,70 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "fmt" + "io" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRepository_GetBlob_Found(t *testing.T) { + repoPath := filepath.Join(testReposDir, "repo1_bare") + r, err := openRepositoryWithDefaultContext(repoPath) + require.NoError(t, err) + defer r.Close() + + testCases := []struct { + OID string + Data []byte + }{ + {"e2129701f1a4d54dc44f03c93bca0a2aec7c5449", []byte("file1\n")}, + {"6c493ff740f9380390d5c9ddef4af18697ac9375", []byte("file2\n")}, + } + + for _, testCase := range testCases { + blob, err := r.GetBlob(testCase.OID) + require.NoError(t, err) + + dataReader, err := blob.DataAsync() + require.NoError(t, err) + + data, err := io.ReadAll(dataReader) + require.NoError(t, dataReader.Close()) + require.NoError(t, err) + assert.Equal(t, testCase.Data, data) + } +} + +func TestRepository_GetBlob_NotExist(t *testing.T) { + repoPath := filepath.Join(testReposDir, "repo1_bare") + r, err := openRepositoryWithDefaultContext(repoPath) + require.NoError(t, err) + defer r.Close() + + testCase := "0000000000000000000000000000000000000000" + testError := ErrNotExist{testCase, ""} + + blob, err := r.GetBlob(testCase) + assert.Nil(t, blob) + assert.EqualError(t, err, testError.Error()) +} + +func TestRepository_GetBlob_NoId(t *testing.T) { + repoPath := filepath.Join(testReposDir, "repo1_bare") + r, err := openRepositoryWithDefaultContext(repoPath) + require.NoError(t, err) + defer r.Close() + + testCase := "" + testError := fmt.Errorf("length %d has no matched object format: %s", len(testCase), testCase) + + blob, err := r.GetBlob(testCase) + assert.Nil(t, blob) + assert.EqualError(t, err, testError.Error()) +} diff --git a/modules/git/repo_branch.go b/modules/git/repo_branch.go new file mode 100644 index 0000000..7339c7d --- /dev/null +++ b/modules/git/repo_branch.go @@ -0,0 +1,349 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "strings" + + "code.gitea.io/gitea/modules/log" +) + +// 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 := NewCommand(ctx, "show-ref", "--verify").AddDashesAndList(name).RunStdString(&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) +} + +// Branch represents a Git branch. +type Branch struct { + Name string + Path string + + gitRepo *Repository +} + +// GetHEADBranch returns corresponding branch of HEAD. +func (repo *Repository) GetHEADBranch() (*Branch, error) { + if repo == nil { + return nil, fmt.Errorf("nil repo") + } + stdout, _, err := NewCommand(repo.Ctx, "symbolic-ref", "HEAD").RunStdString(&RunOpts{Dir: repo.Path}) + if err != nil { + return nil, err + } + stdout = strings.TrimSpace(stdout) + + if !strings.HasPrefix(stdout, BranchPrefix) { + return nil, fmt.Errorf("invalid HEAD branch: %v", stdout) + } + + return &Branch{ + Name: stdout[len(BranchPrefix):], + Path: stdout, + gitRepo: repo, + }, nil +} + +func GetDefaultBranch(ctx context.Context, repoPath string) (string, error) { + stdout, _, err := NewCommand(ctx, "symbolic-ref", "HEAD").RunStdString(&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 +} + +// GetBranch returns a branch by it's name +func (repo *Repository) GetBranch(branch string) (*Branch, error) { + if !repo.IsBranchExist(branch) { + return nil, ErrBranchNotExist{branch} + } + return &Branch{ + Path: repo.Path, + Name: branch, + gitRepo: repo, + }, nil +} + +// GetBranches returns a slice of *git.Branch +func (repo *Repository) GetBranches(skip, limit int) ([]*Branch, int, error) { + brs, countAll, err := repo.GetBranchNames(skip, limit) + if err != nil { + return nil, 0, err + } + + branches := make([]*Branch, len(brs)) + for i := range brs { + branches[i] = &Branch{ + Path: repo.Path, + Name: brs[i], + gitRepo: repo, + } + } + + return branches, countAll, 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 := NewCommand(repo.Ctx, "branch") + + if opts.Force { + cmd.AddArguments("-D") + } else { + cmd.AddArguments("-d") + } + + cmd.AddDashesAndList(name) + _, _, err := cmd.RunStdString(&RunOpts{Dir: repo.Path}) + + return err +} + +// CreateBranch create a new branch +func (repo *Repository) CreateBranch(branch, oldbranchOrCommit string) error { + cmd := NewCommand(repo.Ctx, "branch") + cmd.AddDashesAndList(branch, oldbranchOrCommit) + + _, _, err := cmd.RunStdString(&RunOpts{Dir: repo.Path}) + + return err +} + +// AddRemote adds a new remote to repository. +func (repo *Repository) AddRemote(name, url string, fetch bool) error { + cmd := NewCommand(repo.Ctx, "remote", "add") + if fetch { + cmd.AddArguments("-f") + } + cmd.AddDynamicArguments(name, url) + + _, _, err := cmd.RunStdString(&RunOpts{Dir: repo.Path}) + return err +} + +// RemoveRemote removes a remote from repository. +func (repo *Repository) RemoveRemote(name string) error { + _, _, err := NewCommand(repo.Ctx, "remote", "rm").AddDynamicArguments(name).RunStdString(&RunOpts{Dir: repo.Path}) + return err +} + +// GetCommit returns the head commit of a branch +func (branch *Branch) GetCommit() (*Commit, error) { + return branch.gitRepo.GetBranchCommit(branch.Name) +} + +// RenameBranch rename a branch +func (repo *Repository) RenameBranch(from, to string) error { + _, _, err := NewCommand(repo.Ctx, "branch", "-m").AddDynamicArguments(from, to).RunStdString(&RunOpts{Dir: repo.Path}) + return err +} + +// IsObjectExist returns true if given reference exists in the repository. +func (repo *Repository) IsObjectExist(name string) bool { + if name == "" { + return false + } + + wr, rd, cancel, err := repo.CatFileBatchCheck(repo.Ctx) + if err != nil { + log.Debug("Error writing to CatFileBatchCheck %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 + } + sha, _, _, err := ReadBatchLine(rd) + return err == nil && bytes.HasPrefix(sha, []byte(strings.TrimSpace(name))) +} + +// IsReferenceExist returns true if given reference exists in the repository. +func (repo *Repository) IsReferenceExist(name string) bool { + if name == "" { + return false + } + + wr, rd, cancel, err := repo.CatFileBatchCheck(repo.Ctx) + if err != nil { + log.Debug("Error writing to CatFileBatchCheck %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) + return err == nil +} + +// IsBranchExist returns true if given branch exists in current repository. +func (repo *Repository) IsBranchExist(name string) bool { + if repo == nil || name == "" { + return false + } + + return repo.IsReferenceExist(BranchPrefix + name) +} + +// GetBranchNames returns branches from the repository, skipping "skip" initial branches and +// returning at most "limit" branches, or all branches if "limit" is 0. +func (repo *Repository) GetBranchNames(skip, limit int) ([]string, int, error) { + return callShowRef(repo.Ctx, repo.Path, BranchPrefix, TrustedCmdArgs{BranchPrefix, "--sort=-committerdate"}, skip, limit) +} + +// WalkReferences walks all the references from the repository +// refType should be empty, ObjectTag or ObjectBranch. All other values are equivalent to empty. +func (repo *Repository) WalkReferences(refType ObjectType, skip, limit int, walkfn func(sha1, refname string) error) (int, error) { + var args TrustedCmdArgs + switch refType { + case ObjectTag: + args = TrustedCmdArgs{TagPrefix, "--sort=-taggerdate"} + case ObjectBranch: + args = TrustedCmdArgs{BranchPrefix, "--sort=-committerdate"} + } + + return WalkShowRef(repo.Ctx, repo.Path, args, skip, limit, walkfn) +} + +// callShowRef return refs, if limit = 0 it will not limit +func callShowRef(ctx context.Context, repoPath, trimPrefix string, extraArgs TrustedCmdArgs, skip, limit int) (branchNames []string, countAll int, err error) { + countAll, err = WalkShowRef(ctx, repoPath, extraArgs, skip, limit, func(_, branchName string) error { + branchName = strings.TrimPrefix(branchName, trimPrefix) + branchNames = append(branchNames, branchName) + + return nil + }) + return branchNames, countAll, err +} + +func WalkShowRef(ctx context.Context, repoPath string, extraArgs 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 := TrustedCmdArgs{"for-each-ref", "--format=%(objectname) %(refname)"} + args = append(args, extraArgs...) + err := NewCommand(ctx, args...).Run(&RunOpts{ + Dir: repoPath, + Stdout: stdoutWriter, + Stderr: stderrBuilder, + }) + if err != nil { + if stderrBuilder.Len() == 0 { + _ = stdoutWriter.Close() + return + } + _ = stdoutWriter.CloseWithError(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++ + } + } + 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 +} + +// GetRefsBySha returns all references filtered with prefix that belong to a sha commit hash +func (repo *Repository) GetRefsBySha(sha, prefix string) ([]string, error) { + var revList []string + _, err := WalkShowRef(repo.Ctx, repo.Path, nil, 0, 0, func(walkSha, refname string) error { + if walkSha == sha && strings.HasPrefix(refname, prefix) { + revList = append(revList, refname) + } + return nil + }) + return revList, err +} diff --git a/modules/git/repo_branch_test.go b/modules/git/repo_branch_test.go new file mode 100644 index 0000000..610c845 --- /dev/null +++ b/modules/git/repo_branch_test.go @@ -0,0 +1,197 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRepository_GetBranches(t *testing.T) { + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") + bareRepo1, err := openRepositoryWithDefaultContext(bareRepo1Path) + require.NoError(t, err) + defer bareRepo1.Close() + + branches, countAll, err := bareRepo1.GetBranchNames(0, 2) + + require.NoError(t, err) + assert.Len(t, branches, 2) + assert.EqualValues(t, 3, countAll) + assert.ElementsMatch(t, []string{"master", "branch2"}, branches) + + branches, countAll, err = bareRepo1.GetBranchNames(0, 0) + + require.NoError(t, err) + assert.Len(t, branches, 3) + assert.EqualValues(t, 3, countAll) + assert.ElementsMatch(t, []string{"master", "branch2", "branch1"}, branches) + + branches, countAll, err = bareRepo1.GetBranchNames(5, 1) + + require.NoError(t, err) + assert.Empty(t, branches) + assert.EqualValues(t, 3, countAll) + assert.ElementsMatch(t, []string{}, branches) +} + +func BenchmarkRepository_GetBranches(b *testing.B) { + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") + bareRepo1, err := openRepositoryWithDefaultContext(bareRepo1Path) + if err != nil { + b.Fatal(err) + } + defer bareRepo1.Close() + + for i := 0; i < b.N; i++ { + _, _, err := bareRepo1.GetBranchNames(0, 0) + if err != nil { + b.Fatal(err) + } + } +} + +func TestGetRefsBySha(t *testing.T) { + bareRepo5Path := filepath.Join(testReposDir, "repo5_pulls") + bareRepo5, err := OpenRepository(DefaultContext, bareRepo5Path) + if err != nil { + t.Fatal(err) + } + defer bareRepo5.Close() + + // do not exist + branches, err := bareRepo5.GetRefsBySha("8006ff9adbf0cb94da7dad9e537e53817f9fa5c0", "") + require.NoError(t, err) + assert.Empty(t, branches) + + // refs/pull/1/head + branches, err = bareRepo5.GetRefsBySha("c83380d7056593c51a699d12b9c00627bd5743e9", PullPrefix) + require.NoError(t, err) + assert.EqualValues(t, []string{"refs/pull/1/head"}, branches) + + branches, err = bareRepo5.GetRefsBySha("d8e0bbb45f200e67d9a784ce55bd90821af45ebd", BranchPrefix) + require.NoError(t, err) + assert.EqualValues(t, []string{"refs/heads/master", "refs/heads/master-clone"}, branches) + + branches, err = bareRepo5.GetRefsBySha("58a4bcc53ac13e7ff76127e0fb518b5262bf09af", BranchPrefix) + require.NoError(t, err) + assert.EqualValues(t, []string{"refs/heads/test-patch-1"}, branches) +} + +func BenchmarkGetRefsBySha(b *testing.B) { + bareRepo5Path := filepath.Join(testReposDir, "repo5_pulls") + bareRepo5, err := OpenRepository(DefaultContext, bareRepo5Path) + if err != nil { + b.Fatal(err) + } + defer bareRepo5.Close() + + _, _ = bareRepo5.GetRefsBySha("8006ff9adbf0cb94da7dad9e537e53817f9fa5c0", "") + _, _ = bareRepo5.GetRefsBySha("d8e0bbb45f200e67d9a784ce55bd90821af45ebd", "") + _, _ = bareRepo5.GetRefsBySha("c83380d7056593c51a699d12b9c00627bd5743e9", "") + _, _ = bareRepo5.GetRefsBySha("58a4bcc53ac13e7ff76127e0fb518b5262bf09af", "") +} + +func TestRepository_IsObjectExist(t *testing.T) { + repo, err := openRepositoryWithDefaultContext(filepath.Join(testReposDir, "repo1_bare")) + require.NoError(t, err) + defer repo.Close() + + supportShortHash := true + + tests := []struct { + name string + arg string + want bool + }{ + { + name: "empty", + arg: "", + want: false, + }, + { + name: "branch", + arg: "master", + want: false, + }, + { + name: "commit hash", + arg: "ce064814f4a0d337b333e646ece456cd39fab612", + want: true, + }, + { + name: "short commit hash", + arg: "ce06481", + want: supportShortHash, + }, + { + name: "blob hash", + arg: "153f451b9ee7fa1da317ab17a127e9fd9d384310", + want: true, + }, + { + name: "short blob hash", + arg: "153f451", + want: supportShortHash, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, repo.IsObjectExist(tt.arg)) + }) + } +} + +func TestRepository_IsReferenceExist(t *testing.T) { + repo, err := openRepositoryWithDefaultContext(filepath.Join(testReposDir, "repo1_bare")) + require.NoError(t, err) + defer repo.Close() + + supportBlobHash := true + + tests := []struct { + name string + arg string + want bool + }{ + { + name: "empty", + arg: "", + want: false, + }, + { + name: "branch", + arg: "master", + want: true, + }, + { + name: "commit hash", + arg: "ce064814f4a0d337b333e646ece456cd39fab612", + want: true, + }, + { + name: "short commit hash", + arg: "ce06481", + want: true, + }, + { + name: "blob hash", + arg: "153f451b9ee7fa1da317ab17a127e9fd9d384310", + want: supportBlobHash, + }, + { + name: "short blob hash", + arg: "153f451", + want: supportBlobHash, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, repo.IsReferenceExist(tt.arg)) + }) + } +} diff --git a/modules/git/repo_commit.go b/modules/git/repo_commit.go new file mode 100644 index 0000000..1f3d64f --- /dev/null +++ b/modules/git/repo_commit.go @@ -0,0 +1,677 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "bufio" + "bytes" + "errors" + "io" + "strconv" + "strings" + + "code.gitea.io/gitea/modules/cache" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" +) + +// GetBranchCommitID returns last commit ID string of given branch. +func (repo *Repository) GetBranchCommitID(name string) (string, error) { + return repo.GetRefCommitID(BranchPrefix + name) +} + +// GetTagCommitID returns last commit ID string of given tag. +func (repo *Repository) GetTagCommitID(name string) (string, error) { + return repo.GetRefCommitID(TagPrefix + name) +} + +// GetCommit returns commit object of by ID string. +func (repo *Repository) GetCommit(commitID string) (*Commit, error) { + id, err := repo.ConvertToGitID(commitID) + if err != nil { + return nil, err + } + + return repo.getCommit(id) +} + +// GetBranchCommit returns the last commit of given branch. +func (repo *Repository) GetBranchCommit(name string) (*Commit, error) { + commitID, err := repo.GetBranchCommitID(name) + if err != nil { + return nil, err + } + return repo.GetCommit(commitID) +} + +// GetTagCommit get the commit of the specific tag via name +func (repo *Repository) GetTagCommit(name string) (*Commit, error) { + commitID, err := repo.GetTagCommitID(name) + if err != nil { + return nil, err + } + return repo.GetCommit(commitID) +} + +func (repo *Repository) getCommitByPathWithID(id ObjectID, relpath string) (*Commit, error) { + // File name starts with ':' must be escaped. + if relpath[0] == ':' { + relpath = `\` + relpath + } + + stdout, _, runErr := NewCommand(repo.Ctx, "log", "-1", prettyLogFormat).AddDynamicArguments(id.String()).AddDashesAndList(relpath).RunStdString(&RunOpts{Dir: repo.Path}) + if runErr != nil { + return nil, runErr + } + + id, err := NewIDFromString(stdout) + if err != nil { + return nil, err + } + + return repo.getCommit(id) +} + +// GetCommitByPath returns the last commit of relative path. +func (repo *Repository) GetCommitByPath(relpath string) (*Commit, error) { + stdout, _, runErr := NewCommand(repo.Ctx, "log", "-1", prettyLogFormat).AddDashesAndList(relpath).RunStdBytes(&RunOpts{Dir: repo.Path}) + if runErr != nil { + return nil, runErr + } + + commits, err := repo.parsePrettyFormatLogToList(stdout) + if err != nil { + return nil, err + } + if len(commits) == 0 { + return nil, ErrNotExist{ID: relpath} + } + return commits[0], nil +} + +func (repo *Repository) commitsByRange(id ObjectID, page, pageSize int, not string) ([]*Commit, error) { + cmd := NewCommand(repo.Ctx, "log"). + AddOptionFormat("--skip=%d", (page-1)*pageSize). + AddOptionFormat("--max-count=%d", pageSize). + AddArguments(prettyLogFormat). + AddDynamicArguments(id.String()) + + if not != "" { + cmd.AddOptionValues("--not", not) + } + + stdout, _, err := cmd.RunStdBytes(&RunOpts{Dir: repo.Path}) + if err != nil { + return nil, err + } + + return repo.parsePrettyFormatLogToList(stdout) +} + +func (repo *Repository) searchCommits(id ObjectID, opts SearchCommitsOptions) ([]*Commit, error) { + // add common arguments to git command + addCommonSearchArgs := func(c *Command) { + // ignore case + c.AddArguments("-i") + + // add authors if present in search query + for _, v := range opts.Authors { + c.AddOptionFormat("--author=%s", v) + } + + // add committers if present in search query + for _, v := range opts.Committers { + c.AddOptionFormat("--committer=%s", v) + } + + // add time constraints if present in search query + if len(opts.After) > 0 { + c.AddOptionFormat("--after=%s", opts.After) + } + if len(opts.Before) > 0 { + c.AddOptionFormat("--before=%s", opts.Before) + } + } + + // create new git log command with limit of 100 commits + cmd := NewCommand(repo.Ctx, "log", "-100", prettyLogFormat).AddDynamicArguments(id.String()) + + // pretend that all refs along with HEAD were listed on command line as <commis> + // https://git-scm.com/docs/git-log#Documentation/git-log.txt---all + // note this is done only for command created above + if opts.All { + cmd.AddArguments("--all") + } + + // interpret search string keywords as string instead of regex + cmd.AddArguments("--fixed-strings") + + // add remaining keywords from search string + // note this is done only for command created above + for _, v := range opts.Keywords { + cmd.AddOptionFormat("--grep=%s", v) + } + + // search for commits matching given constraints and keywords in commit msg + addCommonSearchArgs(cmd) + stdout, _, err := cmd.RunStdBytes(&RunOpts{Dir: repo.Path}) + if err != nil { + return nil, err + } + if len(stdout) != 0 { + stdout = append(stdout, '\n') + } + + // if there are any keywords (ie not committer:, author:, time:) + // then let's iterate over them + for _, v := range opts.Keywords { + // ignore anything not matching a valid sha pattern + if id.Type().IsValid(v) { + // create new git log command with 1 commit limit + hashCmd := NewCommand(repo.Ctx, "log", "-1", prettyLogFormat) + // add previous arguments except for --grep and --all + addCommonSearchArgs(hashCmd) + // add keyword as <commit> + hashCmd.AddDynamicArguments(v) + + // search with given constraints for commit matching sha hash of v + hashMatching, _, err := hashCmd.RunStdBytes(&RunOpts{Dir: repo.Path}) + if err != nil || bytes.Contains(stdout, hashMatching) { + continue + } + stdout = append(stdout, hashMatching...) + stdout = append(stdout, '\n') + } + } + + return repo.parsePrettyFormatLogToList(bytes.TrimSuffix(stdout, []byte{'\n'})) +} + +// FileChangedBetweenCommits Returns true if the file changed between commit IDs id1 and id2 +// You must ensure that id1 and id2 are valid commit ids. +func (repo *Repository) FileChangedBetweenCommits(filename, id1, id2 string) (bool, error) { + stdout, _, err := NewCommand(repo.Ctx, "diff", "--name-only", "-z").AddDynamicArguments(id1, id2).AddDashesAndList(filename).RunStdBytes(&RunOpts{Dir: repo.Path}) + 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 + Not string + Page int +} + +// CommitsByFileAndRange return the commits according revision file and the page +func (repo *Repository) CommitsByFileAndRange(opts CommitsByFileAndRangeOptions) ([]*Commit, error) { + skip := (opts.Page - 1) * setting.Git.CommitsRangeSize + + stdoutReader, stdoutWriter := io.Pipe() + defer func() { + _ = stdoutReader.Close() + _ = stdoutWriter.Close() + }() + go func() { + stderr := strings.Builder{} + gitCmd := NewCommand(repo.Ctx, "rev-list"). + AddOptionFormat("--max-count=%d", setting.Git.CommitsRangeSize*opts.Page). + AddOptionFormat("--skip=%d", skip) + gitCmd.AddDynamicArguments(opts.Revision) + + if opts.Not != "" { + gitCmd.AddOptionValues("--not", opts.Not) + } + + gitCmd.AddDashesAndList(opts.File) + err := gitCmd.Run(&RunOpts{ + Dir: repo.Path, + Stdout: stdoutWriter, + Stderr: &stderr, + }) + if err != nil { + _ = stdoutWriter.CloseWithError(ConcatenateError(err, (&stderr).String())) + } else { + _ = stdoutWriter.Close() + } + }() + + objectFormat, err := repo.GetObjectFormat() + if err != nil { + return nil, err + } + + 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 + } + 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) + } +} + +// FilesCountBetween return the number of files changed between two commits +func (repo *Repository) FilesCountBetween(startCommitID, endCommitID string) (int, error) { + stdout, _, err := NewCommand(repo.Ctx, "diff", "--name-only").AddDynamicArguments(startCommitID + "..." + endCommitID).RunStdString(&RunOpts{Dir: repo.Path}) + 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 = NewCommand(repo.Ctx, "diff", "--name-only").AddDynamicArguments(startCommitID, endCommitID).RunStdString(&RunOpts{Dir: repo.Path}) + } + if err != nil { + return 0, err + } + return len(strings.Split(stdout, "\n")) - 1, nil +} + +// CommitsBetween returns a list that contains commits between [before, last). +// If before is detached (removed by reset + push) it is not included. +func (repo *Repository) CommitsBetween(last, before *Commit) ([]*Commit, error) { + var stdout []byte + var err error + if before == nil { + stdout, _, err = NewCommand(repo.Ctx, "rev-list").AddDynamicArguments(last.ID.String()).RunStdBytes(&RunOpts{Dir: repo.Path}) + } else { + stdout, _, err = NewCommand(repo.Ctx, "rev-list").AddDynamicArguments(before.ID.String() + ".." + last.ID.String()).RunStdBytes(&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 = NewCommand(repo.Ctx, "rev-list").AddDynamicArguments(before.ID.String(), last.ID.String()).RunStdBytes(&RunOpts{Dir: repo.Path}) + } + } + if err != nil { + return nil, err + } + return repo.parsePrettyFormatLogToList(bytes.TrimSpace(stdout)) +} + +// CommitsBetweenLimit returns a list that contains at most limit commits skipping the first skip commits between [before, last) +func (repo *Repository) CommitsBetweenLimit(last, before *Commit, limit, skip int) ([]*Commit, error) { + var stdout []byte + var err error + if before == nil { + stdout, _, err = NewCommand(repo.Ctx, "rev-list"). + AddOptionValues("--max-count", strconv.Itoa(limit)). + AddOptionValues("--skip", strconv.Itoa(skip)). + AddDynamicArguments(last.ID.String()).RunStdBytes(&RunOpts{Dir: repo.Path}) + } else { + stdout, _, err = NewCommand(repo.Ctx, "rev-list"). + AddOptionValues("--max-count", strconv.Itoa(limit)). + AddOptionValues("--skip", strconv.Itoa(skip)). + AddDynamicArguments(before.ID.String() + ".." + last.ID.String()).RunStdBytes(&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 --max-count n before last so let's try that... + stdout, _, err = NewCommand(repo.Ctx, "rev-list"). + AddOptionValues("--max-count", strconv.Itoa(limit)). + AddOptionValues("--skip", strconv.Itoa(skip)). + AddDynamicArguments(before.ID.String(), last.ID.String()).RunStdBytes(&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 = NewCommand(repo.Ctx, "rev-list").AddDynamicArguments(last.ID.String()).AddOptionValues("--not", baseBranch).RunStdBytes(&RunOpts{Dir: repo.Path}) + } else { + stdout, _, err = NewCommand(repo.Ctx, "rev-list").AddDynamicArguments(before.ID.String()+".."+last.ID.String()).AddOptionValues("--not", baseBranch).RunStdBytes(&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 = NewCommand(repo.Ctx, "rev-list").AddDynamicArguments(before.ID.String(), last.ID.String()).AddOptionValues("--not", baseBranch).RunStdBytes(&RunOpts{Dir: repo.Path}) + } + } + if err != nil { + return nil, err + } + return repo.parsePrettyFormatLogToList(bytes.TrimSpace(stdout)) +} + +// CommitsBetweenIDs return commits between twoe commits +func (repo *Repository) CommitsBetweenIDs(last, before string) ([]*Commit, error) { + lastCommit, err := repo.GetCommit(last) + if err != nil { + return nil, err + } + if before == "" { + return repo.CommitsBetween(lastCommit, nil) + } + beforeCommit, err := repo.GetCommit(before) + if err != nil { + return nil, err + } + return repo.CommitsBetween(lastCommit, beforeCommit) +} + +// 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 := NewCommand(repo.Ctx, "log", prettyLogFormat) + if limit > 0 { + cmd.AddOptionFormat("-%d", limit) + } + cmd.AddDynamicArguments(id.String()) + + stdout, _, runErr := cmd.RunStdBytes(&RunOpts{Dir: repo.Path}) + if runErr != nil { + return nil, runErr + } + + formattedLog, err := repo.parsePrettyFormatLogToList(bytes.TrimSpace(stdout)) + if err != nil { + return nil, err + } + + commits := make([]*Commit, 0, len(formattedLog)) + for _, commit := range formattedLog { + branches, err := repo.getBranches(commit, 2) + if err != nil { + return nil, err + } + + if len(branches) > 1 { + break + } + + commits = append(commits, commit) + } + + return commits, nil +} + +func (repo *Repository) getCommitsBefore(id ObjectID) ([]*Commit, error) { + return repo.commitsBefore(id, 0) +} + +func (repo *Repository) getCommitsBeforeLimit(id ObjectID, num int) ([]*Commit, error) { + return repo.commitsBefore(id, num) +} + +func (repo *Repository) getBranches(commit *Commit, limit int) ([]string, error) { + if CheckGitVersionAtLeast("2.7.0") == nil { + stdout, _, err := NewCommand(repo.Ctx, "for-each-ref", "--format=%(refname:strip=2)"). + AddOptionFormat("--count=%d", limit). + AddOptionValues("--contains", commit.ID.String(), BranchPrefix). + RunStdString(&RunOpts{Dir: repo.Path}) + if err != nil { + return nil, err + } + + branches := strings.Fields(stdout) + return branches, nil + } + + stdout, _, err := NewCommand(repo.Ctx, "branch").AddOptionValues("--contains", commit.ID.String()).RunStdString(&RunOpts{Dir: repo.Path}) + if err != nil { + return nil, err + } + + refs := strings.Split(stdout, "\n") + + var max int + if len(refs) > limit { + max = limit + } else { + max = len(refs) - 1 + } + + branches := make([]string, max) + for i, ref := range refs[:max] { + parts := strings.Fields(ref) + + branches[i] = parts[len(parts)-1] + } + return branches, nil +} + +// GetCommitsFromIDs get commits from commit IDs +func (repo *Repository) GetCommitsFromIDs(commitIDs []string) []*Commit { + commits := make([]*Commit, 0, len(commitIDs)) + + for _, commitID := range commitIDs { + commit, err := repo.GetCommit(commitID) + if err == nil && commit != nil { + commits = append(commits, commit) + } + } + + return commits +} + +// IsCommitInBranch check if the commit is on the branch +func (repo *Repository) IsCommitInBranch(commitID, branch string) (r bool, err error) { + stdout, _, err := NewCommand(repo.Ctx, "branch", "--contains").AddDynamicArguments(commitID, branch).RunStdString(&RunOpts{Dir: repo.Path}) + 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 +} + +// ResolveReference resolves a name to a reference +func (repo *Repository) ResolveReference(name string) (string, error) { + stdout, _, err := NewCommand(repo.Ctx, "show-ref", "--hash").AddDynamicArguments(name).RunStdString(&RunOpts{Dir: repo.Path}) + if err != nil { + if strings.Contains(err.Error(), "not a valid ref") { + return "", ErrNotExist{name, ""} + } + return "", err + } + stdout = strings.TrimSpace(stdout) + if stdout == "" { + return "", ErrNotExist{name, ""} + } + + return stdout, nil +} + +// GetRefCommitID returns the last commit ID string of given reference (branch or tag). +func (repo *Repository) GetRefCommitID(name string) (string, error) { + wr, rd, cancel, err := repo.CatFileBatchCheck(repo.Ctx) + if err != nil { + return "", err + } + defer cancel() + _, err = wr.Write([]byte(name + "\n")) + if err != nil { + return "", err + } + shaBs, _, _, err := ReadBatchLine(rd) + if IsErrNotExist(err) { + return "", ErrNotExist{name, ""} + } + + return string(shaBs), nil +} + +// SetReference sets the commit ID string of given reference (e.g. branch or tag). +func (repo *Repository) SetReference(name, commitID string) error { + _, _, err := NewCommand(repo.Ctx, "update-ref").AddDynamicArguments(name, commitID).RunStdString(&RunOpts{Dir: repo.Path}) + return err +} + +// RemoveReference removes the given reference (e.g. branch or tag). +func (repo *Repository) RemoveReference(name string) error { + _, _, err := NewCommand(repo.Ctx, "update-ref", "--no-deref", "-d").AddDynamicArguments(name).RunStdString(&RunOpts{Dir: repo.Path}) + return err +} + +// 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 := NewCommand(repo.Ctx, "cat-file", "-e").AddDynamicArguments(name).RunStdString(&RunOpts{Dir: repo.Path}) + return err == nil +} + +func (repo *Repository) getCommit(id ObjectID) (*Commit, error) { + wr, rd, cancel, err := repo.CatFileBatch(repo.Ctx) + if err != nil { + return nil, err + } + defer cancel() + + _, _ = wr.Write([]byte(id.String() + "\n")) + + return repo.getCommitFromBatchReader(rd, id) +} + +func (repo *Repository) getCommitFromBatchReader(rd *bufio.Reader, id ObjectID) (*Commit, error) { + _, typ, size, err := ReadBatchLine(rd) + if err != nil { + if errors.Is(err, io.EOF) || IsErrNotExist(err) { + return nil, ErrNotExist{ID: id.String()} + } + return nil, err + } + + switch typ { + 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)) + if err != nil { + return nil, err + } + _, err = rd.Discard(1) + if err != nil { + return nil, err + } + tag, err := parseTagData(id.Type(), data) + if err != nil { + return nil, err + } + + commit, err := tag.Commit(repo) + if err != nil { + return nil, err + } + + return commit, nil + case "commit": + commit, err := CommitFromReader(repo, id, io.LimitReader(rd, size)) + if err != nil { + return nil, err + } + _, err = rd.Discard(1) + if err != nil { + return nil, err + } + + return commit, nil + default: + log.Debug("Unknown typ: %s", typ) + if err := DiscardFull(rd, size+1); err != nil { + return nil, err + } + return nil, ErrNotExist{ + ID: id.String(), + } + } +} + +// ConvertToGitID returns a GitHash object from a potential ID string +func (repo *Repository) ConvertToGitID(commitID string) (ObjectID, error) { + objectFormat, err := repo.GetObjectFormat() + if err != nil { + return nil, err + } + if len(commitID) == objectFormat.FullLength() && objectFormat.IsValid(commitID) { + ID, err := NewIDFromString(commitID) + if err == nil { + return ID, nil + } + } + + wr, rd, cancel, err := repo.CatFileBatchCheck(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) + if err != nil { + if IsErrNotExist(err) { + return nil, ErrNotExist{commitID, ""} + } + return nil, err + } + + return MustIDFromString(string(sha)), nil +} diff --git a/modules/git/repo_commit_test.go b/modules/git/repo_commit_test.go new file mode 100644 index 0000000..e2a9f97 --- /dev/null +++ b/modules/git/repo_commit_test.go @@ -0,0 +1,103 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRepository_GetCommitBranches(t *testing.T) { + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") + bareRepo1, err := openRepositoryWithDefaultContext(bareRepo1Path) + require.NoError(t, err) + defer bareRepo1.Close() + + // these test case are specific to the repo1_bare test repo + testCases := []struct { + CommitID string + ExpectedBranches []string + }{ + {"2839944139e0de9737a044f78b0e4b40d989a9e3", []string{"branch1"}}, + {"5c80b0245c1c6f8343fa418ec374b13b5d4ee658", []string{"branch2"}}, + {"37991dec2c8e592043f47155ce4808d4580f9123", []string{"master"}}, + {"95bb4d39648ee7e325106df01a621c530863a653", []string{"branch1", "branch2"}}, + {"8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2", []string{"branch2", "master"}}, + {"master", []string{"master"}}, + } + for _, testCase := range testCases { + commit, err := bareRepo1.GetCommit(testCase.CommitID) + require.NoError(t, err) + branches, err := bareRepo1.getBranches(commit, 2) + require.NoError(t, err) + assert.Equal(t, testCase.ExpectedBranches, branches) + } +} + +func TestGetTagCommitWithSignature(t *testing.T) { + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") + bareRepo1, err := openRepositoryWithDefaultContext(bareRepo1Path) + require.NoError(t, err) + defer bareRepo1.Close() + + // both the tag and the commit are signed here, this validates only the commit signature + commit, err := bareRepo1.GetCommit("28b55526e7100924d864dd89e35c1ea62e7a5a32") + require.NoError(t, err) + assert.NotNil(t, commit) + assert.NotNil(t, commit.Signature) + // test that signature is not in message + assert.Equal(t, "signed-commit\n", commit.CommitMessage) +} + +func TestGetCommitWithBadCommitID(t *testing.T) { + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") + bareRepo1, err := openRepositoryWithDefaultContext(bareRepo1Path) + require.NoError(t, err) + defer bareRepo1.Close() + + commit, err := bareRepo1.GetCommit("bad_branch") + assert.Nil(t, commit) + require.Error(t, err) + assert.True(t, IsErrNotExist(err)) +} + +func TestIsCommitInBranch(t *testing.T) { + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") + bareRepo1, err := openRepositoryWithDefaultContext(bareRepo1Path) + require.NoError(t, err) + defer bareRepo1.Close() + + result, err := bareRepo1.IsCommitInBranch("2839944139e0de9737a044f78b0e4b40d989a9e3", "branch1") + require.NoError(t, err) + assert.True(t, result) + + result, err = bareRepo1.IsCommitInBranch("2839944139e0de9737a044f78b0e4b40d989a9e3", "branch2") + require.NoError(t, err) + assert.False(t, result) +} + +func TestRepository_CommitsBetweenIDs(t *testing.T) { + bareRepo1Path := filepath.Join(testReposDir, "repo4_commitsbetween") + bareRepo1, err := openRepositoryWithDefaultContext(bareRepo1Path) + require.NoError(t, err) + defer bareRepo1.Close() + + cases := []struct { + OldID string + NewID string + ExpectedCommits int + }{ + {"fdc1b615bdcff0f0658b216df0c9209e5ecb7c78", "78a445db1eac62fe15e624e1137965969addf344", 1}, // com1 -> com2 + {"78a445db1eac62fe15e624e1137965969addf344", "fdc1b615bdcff0f0658b216df0c9209e5ecb7c78", 0}, // reset HEAD~, com2 -> com1 + {"78a445db1eac62fe15e624e1137965969addf344", "a78e5638b66ccfe7e1b4689d3d5684e42c97d7ca", 1}, // com2 -> com2_new + } + for i, c := range cases { + commits, err := bareRepo1.CommitsBetweenIDs(c.NewID, c.OldID) + require.NoError(t, err) + assert.Len(t, commits, c.ExpectedCommits, "case %d", i) + } +} diff --git a/modules/git/repo_commitgraph.go b/modules/git/repo_commitgraph.go new file mode 100644 index 0000000..492438b --- /dev/null +++ b/modules/git/repo_commitgraph.go @@ -0,0 +1,20 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "context" + "fmt" +) + +// WriteCommitGraph write commit graph to speed up repo access +// this requires git v2.18 to be installed +func WriteCommitGraph(ctx context.Context, repoPath string) error { + if CheckGitVersionAtLeast("2.18") == nil { + if _, _, err := NewCommand(ctx, "commit-graph", "write").RunStdString(&RunOpts{Dir: repoPath}); err != nil { + return fmt.Errorf("unable to write commit-graph for '%s' : %w", repoPath, err) + } + } + return nil +} diff --git a/modules/git/repo_compare.go b/modules/git/repo_compare.go new file mode 100644 index 0000000..b6e9d2b --- /dev/null +++ b/modules/git/repo_compare.go @@ -0,0 +1,345 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + logger "code.gitea.io/gitea/modules/log" +) + +// CompareInfo represents needed information for comparing references. +type CompareInfo struct { + MergeBase string + BaseCommitID string + HeadCommitID string + Commits []*Commit + NumFiles int +} + +// 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 := NewCommand(repo.Ctx, "fetch", "--no-tags").AddDynamicArguments(tmpRemote).AddDashesAndList(base + ":" + tmpBaseName).RunStdString(&RunOpts{Dir: repo.Path}) + if err == nil { + base = tmpBaseName + } + } + + stdout, _, err := NewCommand(repo.Ctx, "merge-base").AddDashesAndList(base, head).RunStdString(&RunOpts{Dir: repo.Path}) + return strings.TrimSpace(stdout), base, err +} + +// GetCompareInfo generates and returns compare information between base and head branches of repositories. +func (repo *Repository) GetCompareInfo(basePath, baseBranch, headBranch string, directComparison, fileOnly bool) (_ *CompareInfo, err error) { + var ( + remoteBranch string + tmpRemote string + ) + + // We don't need a temporary remote for same repository. + if repo.Path != basePath { + // Add a temporary remote + tmpRemote = strconv.FormatInt(time.Now().UnixNano(), 10) + if err = repo.AddRemote(tmpRemote, basePath, false); err != nil { + return nil, fmt.Errorf("AddRemote: %w", err) + } + defer func() { + if err := repo.RemoveRemote(tmpRemote); err != nil { + logger.Error("GetPullRequestInfo: RemoveRemote: %v", err) + } + }() + } + + compareInfo := new(CompareInfo) + + compareInfo.HeadCommitID, err = GetFullCommitID(repo.Ctx, repo.Path, headBranch) + if err != nil { + compareInfo.HeadCommitID = headBranch + } + + compareInfo.MergeBase, remoteBranch, err = repo.GetMergeBase(tmpRemote, baseBranch, headBranch) + if err == nil { + compareInfo.BaseCommitID, err = GetFullCommitID(repo.Ctx, repo.Path, remoteBranch) + if err != nil { + compareInfo.BaseCommitID = remoteBranch + } + separator := "..." + baseCommitID := compareInfo.MergeBase + if directComparison { + separator = ".." + baseCommitID = compareInfo.BaseCommitID + } + + // We have a common base - therefore we know that ... should work + if !fileOnly { + // avoid: ambiguous argument 'refs/a...refs/b': unknown revision or path not in the working tree. Use '--': 'git <command> [<revision>...] -- [<file>...]' + var logs []byte + logs, _, err = NewCommand(repo.Ctx, "log").AddArguments(prettyLogFormat). + AddDynamicArguments(baseCommitID + separator + headBranch).AddArguments("--"). + RunStdBytes(&RunOpts{Dir: repo.Path}) + if err != nil { + return nil, err + } + compareInfo.Commits, err = repo.parsePrettyFormatLogToList(logs) + if err != nil { + return nil, fmt.Errorf("parsePrettyFormatLogToList: %w", err) + } + } else { + compareInfo.Commits = []*Commit{} + } + } else { + compareInfo.Commits = []*Commit{} + compareInfo.MergeBase, err = GetFullCommitID(repo.Ctx, repo.Path, remoteBranch) + if err != nil { + compareInfo.MergeBase = remoteBranch + } + compareInfo.BaseCommitID = compareInfo.MergeBase + } + + // Count number of changed files. + // This probably should be removed as we need to use shortstat elsewhere + // Now there is git diff --shortstat but this appears to be slower than simply iterating with --nameonly + compareInfo.NumFiles, err = repo.GetDiffNumChangedFiles(remoteBranch, headBranch, directComparison) + if err != nil { + return nil, err + } + return compareInfo, nil +} + +type lineCountWriter struct { + numLines int +} + +// Write counts the number of newlines in the provided bytestream +func (l *lineCountWriter) Write(p []byte) (n int, err error) { + n = len(p) + l.numLines += bytes.Count(p, []byte{'\000'}) + return n, err +} + +// GetDiffNumChangedFiles counts the number of changed files +// This is substantially quicker than shortstat but... +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 { + separator = ".." + } + + // avoid: ambiguous argument 'refs/a...refs/b': unknown revision or path not in the working tree. Use '--': 'git <command> [<revision>...] -- [<file>...]' + if err := NewCommand(repo.Ctx, "diff", "-z", "--name-only").AddDynamicArguments(base + separator + head).AddArguments("--"). + Run(&RunOpts{ + Dir: repo.Path, + Stdout: w, + Stderr: stderr, + }); err != nil { + if strings.Contains(stderr.String(), "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 = NewCommand(repo.Ctx, "diff", "-z", "--name-only").AddDynamicArguments(base, head).AddArguments("--").Run(&RunOpts{ + Dir: repo.Path, + Stdout: w, + Stderr: stderr, + }); err == nil { + return w.numLines, nil + } + } + return 0, fmt.Errorf("%w: Stderr: %s", err, stderr) + } + return w.numLines, nil +} + +// GetDiffShortStat counts number of changed files, number of additions and deletions +func (repo *Repository) GetDiffShortStat(base, head string) (numFiles, totalAdditions, totalDeletions int, err error) { + numFiles, totalAdditions, totalDeletions, err = GetDiffShortStat(repo.Ctx, repo.Path, nil, base+"..."+head) + if err != nil && strings.Contains(err.Error(), "no merge base") { + return GetDiffShortStat(repo.Ctx, repo.Path, nil, base, head) + } + return numFiles, totalAdditions, totalDeletions, err +} + +// GetDiffShortStat counts number of changed files, number of additions and deletions +func GetDiffShortStat(ctx context.Context, repoPath string, trustedArgs 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 := NewCommand(ctx, "diff", "--shortstat").AddArguments(trustedArgs...).AddDynamicArguments(dynamicArgs...) + stdout, _, err := cmd.RunStdString(&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 +} + +// GetDiffOrPatch generates either diff or formatted patch data between given revisions +func (repo *Repository) GetDiffOrPatch(base, head string, w io.Writer, patch, binary bool) error { + if patch { + return repo.GetPatch(base, head, w) + } + if binary { + return repo.GetDiffBinary(base, head, w) + } + return repo.GetDiff(base, head, w) +} + +// GetDiff generates and returns patch data between given revisions, optimized for human readability +func (repo *Repository) GetDiff(base, head string, w io.Writer) error { + return NewCommand(repo.Ctx, "diff", "-p").AddDynamicArguments(base, head).Run(&RunOpts{ + Dir: repo.Path, + Stdout: w, + }) +} + +// GetDiffBinary generates and returns patch data between given revisions, including binary diffs. +func (repo *Repository) GetDiffBinary(base, head string, w io.Writer) error { + return NewCommand(repo.Ctx, "diff", "-p", "--binary", "--histogram").AddDynamicArguments(base, head).Run(&RunOpts{ + Dir: repo.Path, + Stdout: w, + }) +} + +// GetPatch generates and returns format-patch data between given revisions, able to be used with `git apply` +func (repo *Repository) GetPatch(base, head string, w io.Writer) error { + stderr := new(bytes.Buffer) + err := NewCommand(repo.Ctx, "format-patch", "--binary", "--stdout").AddDynamicArguments(base + "..." + head). + Run(&RunOpts{ + Dir: repo.Path, + Stdout: w, + Stderr: stderr, + }) + if err != nil && bytes.Contains(stderr.Bytes(), []byte("no merge base")) { + return NewCommand(repo.Ctx, "format-patch", "--binary", "--stdout").AddDynamicArguments(base, head). + Run(&RunOpts{ + Dir: repo.Path, + Stdout: w, + }) + } + return err +} + +// GetFilesChangedBetween returns a list of all files that have been changed between the given commits +// If base is undefined empty SHA (zeros), it only returns the files changed in the head commit +// If base is the SHA of an empty tree (EmptyTreeSHA), it returns the files changes from the initial commit to the head commit +func (repo *Repository) GetFilesChangedBetween(base, head string) ([]string, error) { + objectFormat, err := repo.GetObjectFormat() + if err != nil { + return nil, err + } + cmd := NewCommand(repo.Ctx, "diff-tree", "--name-only", "--root", "--no-commit-id", "-r", "-z") + if base == objectFormat.EmptyObjectID().String() { + cmd.AddDynamicArguments(head) + } else { + cmd.AddDynamicArguments(base, head) + } + stdout, _, err := cmd.RunStdString(&RunOpts{Dir: repo.Path}) + if err != nil { + return nil, err + } + split := strings.Split(stdout, "\000") + + // Because Git will always emit filenames with a terminal NUL ignore the last entry in the split - which will always be empty. + if len(split) > 0 { + split = split[:len(split)-1] + } + + return split, err +} + +// GetDiffFromMergeBase generates and return patch data from merge base to head +func (repo *Repository) GetDiffFromMergeBase(base, head string, w io.Writer) error { + stderr := new(bytes.Buffer) + err := NewCommand(repo.Ctx, "diff", "-p", "--binary").AddDynamicArguments(base + "..." + head). + Run(&RunOpts{ + Dir: repo.Path, + Stdout: w, + Stderr: stderr, + }) + if err != nil && bytes.Contains(stderr.Bytes(), []byte("no merge base")) { + return repo.GetDiffBinary(base, head, w) + } + return err +} + +// ReadPatchCommit will check if a diff patch exists and return stats +func (repo *Repository) ReadPatchCommit(prID int64) (commitSHA string, err error) { + // Migrated repositories download patches to "pulls" location + patchFile := fmt.Sprintf("pulls/%d.patch", prID) + loadPatch, err := os.Open(filepath.Join(repo.Path, patchFile)) + if err != nil { + return "", err + } + defer loadPatch.Close() + // Read only the first line of the patch - usually it contains the first commit made in patch + scanner := bufio.NewScanner(loadPatch) + scanner.Scan() + // Parse the Patch stats, sometimes Migration returns a 404 for the patch file + commitSHAGroups := patchCommits.FindStringSubmatch(scanner.Text()) + if len(commitSHAGroups) != 0 { + commitSHA = commitSHAGroups[1] + } else { + return "", errors.New("patch file doesn't contain valid commit ID") + } + return commitSHA, nil +} diff --git a/modules/git/repo_compare_test.go b/modules/git/repo_compare_test.go new file mode 100644 index 0000000..86bd685 --- /dev/null +++ b/modules/git/repo_compare_test.go @@ -0,0 +1,164 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "bytes" + "io" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetFormatPatch(t *testing.T) { + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") + clonedPath, err := cloneRepo(t, bareRepo1Path) + if err != nil { + require.NoError(t, err) + return + } + + repo, err := openRepositoryWithDefaultContext(clonedPath) + if err != nil { + require.NoError(t, err) + return + } + defer repo.Close() + + rd := &bytes.Buffer{} + err = repo.GetPatch("8d92fc95^", "8d92fc95", rd) + if err != nil { + require.NoError(t, err) + return + } + + patchb, err := io.ReadAll(rd) + if err != nil { + require.NoError(t, err) + return + } + + patch := string(patchb) + assert.Regexp(t, "^From 8d92fc95", patch) + assert.Contains(t, patch, "Subject: [PATCH] Add file2.txt") +} + +func TestReadPatch(t *testing.T) { + // Ensure we can read the patch files + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") + repo, err := openRepositoryWithDefaultContext(bareRepo1Path) + if err != nil { + require.NoError(t, err) + return + } + defer repo.Close() + // This patch doesn't exist + noFile, err := repo.ReadPatchCommit(0) + require.Error(t, err) + + // This patch is an empty one (sometimes it's a 404) + noCommit, err := repo.ReadPatchCommit(1) + require.Error(t, err) + + // This patch is legit and should return a commit + oldCommit, err := repo.ReadPatchCommit(2) + if err != nil { + require.NoError(t, err) + return + } + + assert.Empty(t, noFile) + assert.Empty(t, noCommit) + assert.Len(t, oldCommit, 40) + assert.Equal(t, "6e8e2a6f9efd71dbe6917816343ed8415ad696c3", oldCommit) +} + +func TestReadWritePullHead(t *testing.T) { + // Ensure we can write SHA1 head corresponding to PR and open them + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") + + // As we are writing we should clone the repository first + clonedPath, err := cloneRepo(t, bareRepo1Path) + if err != nil { + require.NoError(t, err) + return + } + + repo, err := openRepositoryWithDefaultContext(clonedPath) + if err != nil { + require.NoError(t, err) + return + } + defer repo.Close() + + // Try to open non-existing Pull + _, err = repo.GetRefCommitID(PullPrefix + "0/head") + require.Error(t, err) + + // Write a fake sha1 with only 40 zeros + newCommit := "feaf4ba6bc635fec442f46ddd4512416ec43c2c2" + err = repo.SetReference(PullPrefix+"1/head", newCommit) + if err != nil { + require.NoError(t, err) + return + } + + // Read the file created + headContents, err := repo.GetRefCommitID(PullPrefix + "1/head") + if err != nil { + require.NoError(t, err) + return + } + + assert.Len(t, headContents, 40) + assert.Equal(t, newCommit, headContents) + + // Remove file after the test + err = repo.RemoveReference(PullPrefix + "1/head") + require.NoError(t, err) +} + +func TestGetCommitFilesChanged(t *testing.T) { + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") + repo, err := openRepositoryWithDefaultContext(bareRepo1Path) + require.NoError(t, err) + defer repo.Close() + + objectFormat, err := repo.GetObjectFormat() + require.NoError(t, err) + + testCases := []struct { + base, head string + files []string + }{ + { + objectFormat.EmptyObjectID().String(), + "95bb4d39648ee7e325106df01a621c530863a653", + []string{"file1.txt"}, + }, + { + objectFormat.EmptyObjectID().String(), + "8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2", + []string{"file2.txt"}, + }, + { + "95bb4d39648ee7e325106df01a621c530863a653", + "8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2", + []string{"file2.txt"}, + }, + { + objectFormat.EmptyTree().String(), + "8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2", + []string{"file1.txt", "file2.txt"}, + }, + } + + for _, tc := range testCases { + changedFiles, err := repo.GetFilesChangedBetween(tc.base, tc.head) + require.NoError(t, err) + assert.ElementsMatch(t, tc.files, changedFiles) + } +} diff --git a/modules/git/repo_gpg.go b/modules/git/repo_gpg.go new file mode 100644 index 0000000..e2b4506 --- /dev/null +++ b/modules/git/repo_gpg.go @@ -0,0 +1,58 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "fmt" + "strings" + + "code.gitea.io/gitea/modules/process" +) + +// LoadPublicKeyContent will load the key from gpg +func (gpgSettings *GPGSettings) LoadPublicKeyContent() error { + 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, _, _ := NewCommand(repo.Ctx, "config", "--get", "commit.gpgsign").RunStdString(&RunOpts{Dir: repo.Path}) + sign, valid := ParseBool(strings.TrimSpace(value)) + if !sign || !valid { + gpgSettings.Sign = false + repo.gpgSettings = gpgSettings + return gpgSettings, nil + } + + signingKey, _, _ := NewCommand(repo.Ctx, "config", "--get", "user.signingkey").RunStdString(&RunOpts{Dir: repo.Path}) + gpgSettings.KeyID = strings.TrimSpace(signingKey) + + defaultEmail, _, _ := NewCommand(repo.Ctx, "config", "--get", "user.email").RunStdString(&RunOpts{Dir: repo.Path}) + gpgSettings.Email = strings.TrimSpace(defaultEmail) + + defaultName, _, _ := NewCommand(repo.Ctx, "config", "--get", "user.name").RunStdString(&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 +} diff --git a/modules/git/repo_hook.go b/modules/git/repo_hook.go new file mode 100644 index 0000000..cdf0765 --- /dev/null +++ b/modules/git/repo_hook.go @@ -0,0 +1,14 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +// GetHook get one hook according the name on a repository +func (repo *Repository) GetHook(name string) (*Hook, error) { + return GetHook(repo.Path, name) +} + +// Hooks get all the hooks on the repository +func (repo *Repository) Hooks() ([]*Hook, error) { + return ListHooks(repo.Path) +} diff --git a/modules/git/repo_index.go b/modules/git/repo_index.go new file mode 100644 index 0000000..8390570 --- /dev/null +++ b/modules/git/repo_index.go @@ -0,0 +1,159 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/util" +) + +// ReadTreeToIndex reads a treeish to the index +func (repo *Repository) ReadTreeToIndex(treeish string, indexFilename ...string) error { + objectFormat, err := repo.GetObjectFormat() + if err != nil { + return err + } + + if len(treeish) != objectFormat.FullLength() { + res, _, err := NewCommand(repo.Ctx, "rev-parse", "--verify").AddDynamicArguments(treeish).RunStdString(&RunOpts{Dir: repo.Path}) + if err != nil { + return err + } + if len(res) > 0 { + treeish = res[:len(res)-1] + } + } + id, err := NewIDFromString(treeish) + if err != nil { + return err + } + return repo.readTreeToIndex(id, indexFilename...) +} + +func (repo *Repository) readTreeToIndex(id ObjectID, indexFilename ...string) error { + var env []string + if len(indexFilename) > 0 { + env = append(os.Environ(), "GIT_INDEX_FILE="+indexFilename[0]) + } + _, _, err := NewCommand(repo.Ctx, "read-tree").AddDynamicArguments(id.String()).RunStdString(&RunOpts{Dir: repo.Path, Env: env}) + if err != nil { + return err + } + return nil +} + +// ReadTreeToTemporaryIndex reads a treeish to a temporary index file +func (repo *Repository) ReadTreeToTemporaryIndex(treeish string) (filename, tmpDir string, cancel context.CancelFunc, err error) { + tmpDir, err = os.MkdirTemp("", "index") + if err != nil { + return filename, tmpDir, cancel, err + } + + filename = filepath.Join(tmpDir, ".tmp-index") + cancel = func() { + err := util.RemoveAll(tmpDir) + if err != nil { + log.Error("failed to remove tmp index file: %v", err) + } + } + err = repo.ReadTreeToIndex(treeish, filename) + if err != nil { + defer cancel() + return "", "", func() {}, err + } + return filename, tmpDir, cancel, err +} + +// EmptyIndex empties the index +func (repo *Repository) EmptyIndex() error { + _, _, err := NewCommand(repo.Ctx, "read-tree", "--empty").RunStdString(&RunOpts{Dir: repo.Path}) + return err +} + +// LsFiles checks if the given filenames are in the index +func (repo *Repository) LsFiles(filenames ...string) ([]string, error) { + cmd := NewCommand(repo.Ctx, "ls-files", "-z").AddDashesAndList(filenames...) + res, _, err := cmd.RunStdBytes(&RunOpts{Dir: repo.Path}) + if err != nil { + return nil, err + } + filelist := make([]string, 0, len(filenames)) + for _, line := range bytes.Split(res, []byte{'\000'}) { + filelist = append(filelist, string(line)) + } + + return filelist, err +} + +// RemoveFilesFromIndex removes given filenames from the index - it does not check whether they are present. +func (repo *Repository) RemoveFilesFromIndex(filenames ...string) error { + objectFormat, err := repo.GetObjectFormat() + if err != nil { + return err + } + cmd := NewCommand(repo.Ctx, "update-index", "--remove", "-z", "--index-info") + stdout := new(bytes.Buffer) + stderr := new(bytes.Buffer) + buffer := 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") + } + } + return cmd.Run(&RunOpts{ + Dir: repo.Path, + Stdin: bytes.NewReader(buffer.Bytes()), + Stdout: stdout, + Stderr: stderr, + }) +} + +type IndexObjectInfo struct { + Mode string + Object ObjectID + Filename string +} + +// AddObjectsToIndex adds the provided object hashes to the index at the provided filenames +func (repo *Repository) AddObjectsToIndex(objects ...IndexObjectInfo) error { + cmd := NewCommand(repo.Ctx, "update-index", "--add", "--replace", "-z", "--index-info") + stdout := new(bytes.Buffer) + stderr := new(bytes.Buffer) + buffer := 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") + } + return cmd.Run(&RunOpts{ + Dir: repo.Path, + Stdin: bytes.NewReader(buffer.Bytes()), + Stdout: stdout, + Stderr: stderr, + }) +} + +// AddObjectToIndex adds the provided object hash to the index at the provided filename +func (repo *Repository) AddObjectToIndex(mode string, object ObjectID, filename string) error { + return repo.AddObjectsToIndex(IndexObjectInfo{Mode: mode, Object: object, Filename: 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 := NewCommand(repo.Ctx, "write-tree").RunStdString(&RunOpts{Dir: repo.Path}) + if runErr != nil { + return nil, runErr + } + id, err := NewIDFromString(strings.TrimSpace(stdout)) + if err != nil { + return nil, err + } + return NewTree(repo, id), nil +} diff --git a/modules/git/repo_language_stats.go b/modules/git/repo_language_stats.go new file mode 100644 index 0000000..37c23fa --- /dev/null +++ b/modules/git/repo_language_stats.go @@ -0,0 +1,251 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "bytes" + "cmp" + "io" + "strings" + "unicode" + + "code.gitea.io/gitea/modules/analyze" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + + "github.com/go-enry/go-enry/v2" +) + +const ( + fileSizeLimit int64 = 16 * 1024 // 16 KiB + bigFileSize int64 = 1024 * 1024 // 1 MiB +) + +// mergeLanguageStats mergers language names with different cases. The name with most upper case letters is used. +func mergeLanguageStats(stats map[string]int64) map[string]int64 { + names := map[string]struct { + uniqueName string + upperCount int + }{} + + countUpper := func(s string) (count int) { + for _, r := range s { + if unicode.IsUpper(r) { + count++ + } + } + return count + } + + for name := range stats { + cnt := countUpper(name) + lower := strings.ToLower(name) + if cnt >= names[lower].upperCount { + names[lower] = struct { + uniqueName string + upperCount int + }{uniqueName: name, upperCount: cnt} + } + } + + res := make(map[string]int64, len(names)) + for name, num := range stats { + res[names[strings.ToLower(name)].uniqueName] += num + } + return res +} + +// GetLanguageStats calculates language stats for git repository at specified commit +func (repo *Repository) GetLanguageStats(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) + 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 { + return nil, err + } + shaBytes, typ, size, err := ReadBatchLine(batchReader) + if typ != "commit" { + log.Debug("Unable to get commit for: %s. Err: %v", commitID, err) + return nil, ErrNotExist{commitID, ""} + } + + sha, err := NewIDFromString(string(shaBytes)) + if err != nil { + log.Debug("Unable to get commit for: %s. Err: %v", commitID, err) + return nil, ErrNotExist{commitID, ""} + } + + commit, err := CommitFromReader(repo, sha, io.LimitReader(batchReader, size)) + if err != nil { + log.Debug("Unable to get commit for: %s. Err: %v", commitID, err) + return nil, err + } + if _, err = batchReader.Discard(1); err != nil { + return nil, err + } + + tree := commit.Tree + + entries, err := tree.ListEntriesRecursiveWithSize() + if err != nil { + return nil, err + } + + checker, err := repo.GitAttributeChecker(commitID, LinguistAttributes...) + if err != nil { + return nil, err + } + defer checker.Close() + + contentBuf := bytes.Buffer{} + var content []byte + + // sizes contains the current calculated size of all files by language + sizes := make(map[string]int64) + // by default we will only count the sizes of programming languages or markup languages + // unless they are explicitly set using linguist-language + includedLanguage := map[string]bool{} + // or if there's only one language in the repository + firstExcludedLanguage := "" + firstExcludedLanguageSize := int64(0) + + isTrue := func(v optional.Option[bool]) bool { + return v.ValueOrDefault(false) + } + isFalse := func(v optional.Option[bool]) bool { + return !v.ValueOrDefault(true) + } + + for _, f := range entries { + select { + case <-repo.Ctx.Done(): + return sizes, repo.Ctx.Err() + default: + } + + contentBuf.Reset() + content = contentBuf.Bytes() + + if f.Size() == 0 { + continue + } + + isVendored := optional.None[bool]() + isGenerated := optional.None[bool]() + isDocumentation := optional.None[bool]() + isDetectable := optional.None[bool]() + + attrs, err := checker.CheckPath(f.Name()) + if err == nil { + isVendored = attrs["linguist-vendored"].Bool() + isGenerated = attrs["linguist-generated"].Bool() + isDocumentation = attrs["linguist-documentation"].Bool() + isDetectable = attrs["linguist-detectable"].Bool() + if language := cmp.Or( + attrs["linguist-language"].String(), + attrs["gitlab-language"].Prefix(), + ); language != "" { + // group languages, such as Pug -> HTML; SCSS -> CSS + group := enry.GetLanguageGroup(language) + if len(group) != 0 { + language = group + } + + // this language will always be added to the size + sizes[language] += f.Size() + continue + } + } + + if isFalse(isDetectable) || isTrue(isVendored) || isTrue(isDocumentation) || + (!isFalse(isVendored) && analyze.IsVendor(f.Name())) || + enry.IsDotFile(f.Name()) || + enry.IsConfiguration(f.Name()) || + (!isFalse(isDocumentation) && enry.IsDocumentation(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 := ReadBatchLine(batchReader) + if err != nil { + log.Debug("Error reading blob: %s Err: %v", f.ID.String(), err) + return nil, err + } + + sizeToRead := size + discard := int64(1) + if size > fileSizeLimit { + sizeToRead = fileSizeLimit + discard = size - fileSizeLimit + 1 + } + + _, err = contentBuf.ReadFrom(io.LimitReader(batchReader, sizeToRead)) + if err != nil { + return nil, err + } + content = contentBuf.Bytes() + if err := DiscardFull(batchReader, discard); err != nil { + return nil, err + } + } + if !isTrue(isGenerated) && enry.IsGenerated(f.Name(), content) { + continue + } + + // FIXME: Why can't we split this and the IsGenerated tests to avoid reading the blob unless absolutely necessary? + // - eg. do the all the detection tests using filename first before reading content. + language := analyze.GetCodeLanguage(f.Name(), content) + if language == "" { + continue + } + + // group languages, such as Pug -> HTML; SCSS -> CSS + group := enry.GetLanguageGroup(language) + if group != "" { + language = group + } + + included, checked := includedLanguage[language] + langType := enry.GetLanguageType(language) + if !checked { + included = langType == enry.Programming || langType == enry.Markup + if !included && (isTrue(isDetectable) || (langType == enry.Prose && isFalse(isDocumentation))) { + included = true + } + includedLanguage[language] = included + } + if included { + sizes[language] += f.Size() + } else if len(sizes) == 0 && (firstExcludedLanguage == "" || firstExcludedLanguage == language) { + // Only consider Programming or Markup languages as fallback + if !(langType == enry.Programming || langType == enry.Markup) { + continue + } + firstExcludedLanguage = language + firstExcludedLanguageSize += f.Size() + } + } + + // If there are no included languages add the first excluded language + if len(sizes) == 0 && firstExcludedLanguage != "" { + sizes[firstExcludedLanguage] = firstExcludedLanguageSize + } + + return mergeLanguageStats(sizes), nil +} diff --git a/modules/git/repo_language_stats_test.go b/modules/git/repo_language_stats_test.go new file mode 100644 index 0000000..fd80e44 --- /dev/null +++ b/modules/git/repo_language_stats_test.go @@ -0,0 +1,42 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRepository_GetLanguageStats(t *testing.T) { + repoPath := filepath.Join(testReposDir, "language_stats_repo") + gitRepo, err := openRepositoryWithDefaultContext(repoPath) + require.NoError(t, err) + + defer gitRepo.Close() + + stats, err := gitRepo.GetLanguageStats("8fee858da5796dfb37704761701bb8e800ad9ef3") + require.NoError(t, err) + + assert.EqualValues(t, map[string]int64{ + "Python": 134, + "Java": 112, + }, stats) +} + +func TestMergeLanguageStats(t *testing.T) { + assert.EqualValues(t, map[string]int64{ + "PHP": 1, + "python": 10, + "JAVA": 700, + }, mergeLanguageStats(map[string]int64{ + "PHP": 1, + "python": 10, + "Java": 100, + "java": 200, + "JAVA": 400, + })) +} diff --git a/modules/git/repo_object.go b/modules/git/repo_object.go new file mode 100644 index 0000000..3d48b91 --- /dev/null +++ b/modules/git/repo_object.go @@ -0,0 +1,101 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "bytes" + "io" + "strings" +) + +// ObjectType git object type +type ObjectType string + +const ( + // ObjectCommit commit object type + ObjectCommit ObjectType = "commit" + // ObjectTree tree object type + ObjectTree ObjectType = "tree" + // ObjectBlob blob object type + ObjectBlob ObjectType = "blob" + // ObjectTag tag object type + ObjectTag ObjectType = "tag" + // ObjectBranch branch object type + ObjectBranch ObjectType = "branch" +) + +// Bytes returns the byte array for the Object Type +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) + if err != nil { + return nil, err + } + hash, err := NewIDFromString(str) + if err != nil { + return nil, err + } + + repo.objectFormat = hash.Type() + + 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) + if err != nil { + return nil, err + } + return NewIDFromString(idStr) +} + +func (repo *Repository) hashObject(reader io.Reader, save bool) (string, error) { + var cmd *Command + if save { + cmd = NewCommand(repo.Ctx, "hash-object", "-w", "--stdin") + } else { + cmd = NewCommand(repo.Ctx, "hash-object", "--stdin") + } + stdout := new(bytes.Buffer) + stderr := new(bytes.Buffer) + err := cmd.Run(&RunOpts{ + Dir: repo.Path, + Stdin: reader, + Stdout: stdout, + Stderr: stderr, + }) + if err != nil { + return "", err + } + return strings.TrimSpace(stdout.String()), nil +} + +// GetRefType gets the type of the ref based on the string +func (repo *Repository) GetRefType(ref string) ObjectType { + if repo.IsTagExist(ref) { + return ObjectTag + } else if repo.IsBranchExist(ref) { + return ObjectBranch + } else if repo.IsCommitExist(ref) { + return ObjectCommit + } else if _, err := repo.GetBlob(ref); err == nil { + return ObjectBlob + } + return ObjectType("invalid") +} diff --git a/modules/git/repo_ref.go b/modules/git/repo_ref.go new file mode 100644 index 0000000..550c653 --- /dev/null +++ b/modules/git/repo_ref.go @@ -0,0 +1,157 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "bufio" + "context" + "fmt" + "io" + "strings" + + "code.gitea.io/gitea/modules/util" +) + +// GetRefs returns all references of the repository. +func (repo *Repository) GetRefs() ([]*Reference, error) { + return repo.GetRefsFiltered("") +} + +// ListOccurrences lists all refs of the given refType the given commit appears in sorted by creation date DESC +// refType should only be a literal "branch" or "tag" and nothing else +func (repo *Repository) ListOccurrences(ctx context.Context, refType, commitSHA string) ([]string, error) { + cmd := NewCommand(ctx) + if refType == "branch" { + cmd.AddArguments("branch") + } else if refType == "tag" { + cmd.AddArguments("tag") + } else { + 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(&RunOpts{Dir: repo.Path}) + if err != nil { + return nil, err + } + + refs := strings.Split(strings.TrimSpace(stdout), "\n") + if refType == "branch" { + return parseBranches(refs), nil + } + return parseTags(refs), nil +} + +func parseBranches(refs []string) []string { + results := make([]string, 0, len(refs)) + for _, ref := range refs { + if strings.HasPrefix(ref, "* ") { // current branch (main branch) + results = append(results, ref[len("* "):]) + } else if strings.HasPrefix(ref, " ") { // all other branches + results = append(results, ref[len(" "):]) + } else if ref != "" { + results = append(results, ref) + } + } + return results +} + +func parseTags(refs []string) []string { + results := make([]string, 0, len(refs)) + for _, ref := range refs { + if ref != "" { + results = append(results, ref) + } + } + return results +} + +// ExpandRef expands any partial reference to its full form +func (repo *Repository) ExpandRef(ref string) (string, error) { + if strings.HasPrefix(ref, "refs/") { + return ref, nil + } else if strings.HasPrefix(ref, "tags/") || strings.HasPrefix(ref, "heads/") { + return "refs/" + ref, nil + } else if repo.IsTagExist(ref) { + return TagPrefix + ref, nil + } else if repo.IsBranchExist(ref) { + return BranchPrefix + ref, nil + } else if repo.IsCommitExist(ref) { + return ref, nil + } + return "", fmt.Errorf("could not expand reference '%s'", ref) +} + +// 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 := NewCommand(repo.Ctx, "for-each-ref").Run(&RunOpts{ + Dir: repo.Path, + Stdout: stdoutWriter, + Stderr: stderrBuilder, + }) + if err != nil { + _ = stdoutWriter.CloseWithError(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] + + 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] + + 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 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, + } + refs = append(refs, r) + } + } + + return refs, nil +} diff --git a/modules/git/repo_ref_test.go b/modules/git/repo_ref_test.go new file mode 100644 index 0000000..609bef5 --- /dev/null +++ b/modules/git/repo_ref_test.go @@ -0,0 +1,56 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRepository_GetRefs(t *testing.T) { + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") + bareRepo1, err := openRepositoryWithDefaultContext(bareRepo1Path) + require.NoError(t, err) + defer bareRepo1.Close() + + refs, err := bareRepo1.GetRefs() + + require.NoError(t, err) + assert.Len(t, refs, 6) + + expectedRefs := []string{ + BranchPrefix + "branch1", + BranchPrefix + "branch2", + BranchPrefix + "master", + TagPrefix + "test", + TagPrefix + "signed-tag", + NotesRef, + } + + for _, ref := range refs { + assert.Contains(t, expectedRefs, ref.Name) + } +} + +func TestRepository_GetRefsFiltered(t *testing.T) { + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") + bareRepo1, err := openRepositoryWithDefaultContext(bareRepo1Path) + require.NoError(t, err) + defer bareRepo1.Close() + + refs, err := bareRepo1.GetRefsFiltered(TagPrefix) + + require.NoError(t, err) + if assert.Len(t, refs, 2) { + assert.Equal(t, TagPrefix+"signed-tag", refs[0].Name) + assert.Equal(t, "tag", refs[0].Type) + assert.Equal(t, "36f97d9a96457e2bab511db30fe2db03893ebc64", refs[0].Object.String()) + assert.Equal(t, TagPrefix+"test", refs[1].Name) + assert.Equal(t, "tag", refs[1].Type) + assert.Equal(t, "3ad28a9149a2864384548f3d17ed7f38014c9e8a", refs[1].Object.String()) + } +} diff --git a/modules/git/repo_stats.go b/modules/git/repo_stats.go new file mode 100644 index 0000000..8322010 --- /dev/null +++ b/modules/git/repo_stats.go @@ -0,0 +1,151 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "bufio" + "context" + "fmt" + "os" + "sort" + "strconv" + "strings" + "time" + + "code.gitea.io/gitea/modules/container" +) + +// CodeActivityStats represents git statistics data +type CodeActivityStats struct { + AuthorCount int64 + CommitCount int64 + ChangedFiles int64 + Additions int64 + Deletions int64 + CommitCountInAllBranches int64 + Authors []*CodeActivityAuthor +} + +// CodeActivityAuthor represents git statistics data for commit authors +type CodeActivityAuthor struct { + Name string + Email string + Commits int64 +} + +// GetCodeActivityStats returns code statistics for activity page +func (repo *Repository) GetCodeActivityStats(fromTime time.Time, branch string) (*CodeActivityStats, error) { + stats := &CodeActivityStats{} + + since := fromTime.Format(time.RFC3339) + + stdout, _, runErr := NewCommand(repo.Ctx, "rev-list", "--count", "--no-merges", "--branches=*", "--date=iso").AddOptionFormat("--since='%s'", since).RunStdString(&RunOpts{Dir: repo.Path}) + if runErr != nil { + return nil, runErr + } + + c, err := strconv.ParseInt(strings.TrimSpace(stdout), 10, 64) + if err != nil { + return nil, err + } + stats.CommitCountInAllBranches = c + + stdoutReader, stdoutWriter, err := os.Pipe() + if err != nil { + return nil, err + } + defer func() { + _ = stdoutReader.Close() + _ = stdoutWriter.Close() + }() + + gitCmd := NewCommand(repo.Ctx, "log", "--numstat", "--no-merges", "--pretty=format:---%n%h%n%aN%n%aE%n", "--date=iso").AddOptionFormat("--since='%s'", since) + if len(branch) == 0 { + gitCmd.AddArguments("--branches=*") + } else { + gitCmd.AddArguments("--first-parent").AddDynamicArguments(branch) + } + + stderr := new(strings.Builder) + err = gitCmd.Run(&RunOpts{ + Env: []string{}, + Dir: repo.Path, + Stdout: stdoutWriter, + Stderr: stderr, + PipelineFunc: func(ctx context.Context, cancel context.CancelFunc) error { + _ = stdoutWriter.Close() + scanner := bufio.NewScanner(stdoutReader) + scanner.Split(bufio.ScanLines) + stats.CommitCount = 0 + stats.Additions = 0 + stats.Deletions = 0 + authors := make(map[string]*CodeActivityAuthor) + files := make(container.Set[string]) + var author string + p := 0 + for scanner.Scan() { + l := strings.TrimSpace(scanner.Text()) + if l == "---" { + p = 1 + } else if p == 0 { + continue + } else { + p++ + } + if p > 4 && len(l) == 0 { + continue + } + switch p { + case 1: // Separator + case 2: // Commit sha-1 + stats.CommitCount++ + case 3: // Author + author = l + case 4: // E-mail + email := strings.ToLower(l) + if _, ok := authors[email]; !ok { + authors[email] = &CodeActivityAuthor{Name: author, Email: email, Commits: 0} + } + authors[email].Commits++ + default: // Changed file + if parts := strings.Fields(l); len(parts) >= 3 { + if parts[0] != "-" { + if c, err := strconv.ParseInt(strings.TrimSpace(parts[0]), 10, 64); err == nil { + stats.Additions += c + } + } + if parts[1] != "-" { + if c, err := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64); err == nil { + stats.Deletions += c + } + } + files.Add(parts[2]) + } + } + } + if err = scanner.Err(); err != nil { + _ = stdoutReader.Close() + return fmt.Errorf("GetCodeActivityStats scan: %w", err) + } + a := make([]*CodeActivityAuthor, 0, len(authors)) + for _, v := range authors { + a = append(a, v) + } + // Sort authors descending depending on commit count + sort.Slice(a, func(i, j int) bool { + return a[i].Commits > a[j].Commits + }) + stats.AuthorCount = int64(len(authors)) + stats.ChangedFiles = int64(len(files)) + stats.Authors = a + _ = stdoutReader.Close() + return nil + }, + }) + if err != nil { + return nil, fmt.Errorf("Failed to get GetCodeActivityStats for repository.\nError: %w\nStderr: %s", err, stderr) + } + + return stats, nil +} diff --git a/modules/git/repo_stats_test.go b/modules/git/repo_stats_test.go new file mode 100644 index 0000000..2a15b6f --- /dev/null +++ b/modules/git/repo_stats_test.go @@ -0,0 +1,37 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRepository_GetCodeActivityStats(t *testing.T) { + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") + bareRepo1, err := openRepositoryWithDefaultContext(bareRepo1Path) + require.NoError(t, err) + defer bareRepo1.Close() + + timeFrom, err := time.Parse(time.RFC3339, "2016-01-01T00:00:00+00:00") + require.NoError(t, err) + + code, err := bareRepo1.GetCodeActivityStats(timeFrom, "") + require.NoError(t, err) + assert.NotNil(t, code) + + assert.EqualValues(t, 10, code.CommitCount) + assert.EqualValues(t, 3, code.AuthorCount) + assert.EqualValues(t, 10, code.CommitCountInAllBranches) + assert.EqualValues(t, 10, code.Additions) + assert.EqualValues(t, 1, code.Deletions) + assert.Len(t, code.Authors, 3) + assert.EqualValues(t, "tris.git@shoddynet.org", code.Authors[1].Email) + assert.EqualValues(t, 3, code.Authors[1].Commits) + assert.EqualValues(t, 5, code.Authors[0].Commits) +} diff --git a/modules/git/repo_tag.go b/modules/git/repo_tag.go new file mode 100644 index 0000000..12b0c02 --- /dev/null +++ b/modules/git/repo_tag.go @@ -0,0 +1,366 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + + "code.gitea.io/gitea/modules/git/foreachref" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/util" +) + +// TagPrefix tags prefix path on the repository +const TagPrefix = "refs/tags/" + +// IsTagExist returns true if given tag exists in the repository. +func IsTagExist(ctx context.Context, repoPath, name string) bool { + return IsReferenceExist(ctx, repoPath, TagPrefix+name) +} + +// CreateTag create one tag in the repository +func (repo *Repository) CreateTag(name, revision string) error { + _, _, err := NewCommand(repo.Ctx, "tag").AddDashesAndList(name, revision).RunStdString(&RunOpts{Dir: repo.Path}) + return err +} + +// CreateAnnotatedTag create one annotated tag in the repository +func (repo *Repository) CreateAnnotatedTag(name, message, revision string) error { + _, _, err := NewCommand(repo.Ctx, "tag", "-a", "-m").AddDynamicArguments(message).AddDashesAndList(name, revision).RunStdString(&RunOpts{Dir: repo.Path}) + return err +} + +// GetTagNameBySHA returns the name of a tag from its tag object SHA or commit SHA +func (repo *Repository) GetTagNameBySHA(sha string) (string, error) { + if len(sha) < 5 { + return "", fmt.Errorf("SHA is too short: %s", sha) + } + + stdout, _, err := NewCommand(repo.Ctx, "show-ref", "--tags", "-d").RunStdString(&RunOpts{Dir: repo.Path}) + if err != nil { + return "", err + } + + tagRefs := strings.Split(stdout, "\n") + for _, tagRef := range tagRefs { + if len(strings.TrimSpace(tagRef)) > 0 { + fields := strings.Fields(tagRef) + if strings.HasPrefix(fields[0], sha) && strings.HasPrefix(fields[1], TagPrefix) { + name := fields[1][len(TagPrefix):] + // annotated tags show up twice, we should only return if is not the ^{} ref + if !strings.HasSuffix(name, "^{}") { + return name, nil + } + } + } + } + return "", ErrNotExist{ID: sha} +} + +// 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 := NewCommand(repo.Ctx, "show-ref", "--tags").AddDashesAndList(name).RunStdString(&RunOpts{Dir: repo.Path}) + if err != nil { + return "", err + } + // Make sure exact match is used: "v1" != "release/v1" + for _, line := range strings.Split(stdout, "\n") { + fields := strings.Fields(line) + if len(fields) == 2 && fields[1] == "refs/tags/"+name { + return fields[0], nil + } + } + return "", ErrNotExist{ID: name} +} + +// GetTag returns a Git tag by given name. +func (repo *Repository) GetTag(name string) (*Tag, error) { + idStr, err := repo.GetTagID(name) + if err != nil { + return nil, err + } + + id, err := NewIDFromString(idStr) + if err != nil { + return nil, err + } + + tag, err := repo.getTag(id, name) + if err != nil { + return nil, err + } + return tag, nil +} + +// GetTagWithID returns a Git tag by given name and ID +func (repo *Repository) GetTagWithID(idStr, name string) (*Tag, error) { + id, err := NewIDFromString(idStr) + if err != nil { + return nil, err + } + + tag, err := repo.getTag(id, name) + if err != nil { + return nil, err + } + return tag, nil +} + +// GetTagInfos returns all tag infos of the repository. +func (repo *Repository) GetTagInfos(page, pageSize int) ([]*Tag, int, error) { + // Generally, refname:short should be equal to refname:lstrip=2 except core.warnAmbiguousRefs is used to select the strict abbreviation mode. + // 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 := &RunOpts{Dir: repo.Path, Stdout: stdoutWriter, Stderr: &stderr} + + go func() { + err := NewCommand(repo.Ctx, "for-each-ref"). + AddOptionFormat("--format=%s", forEachRefFmt.Flag()). + AddArguments("--sort", "-*creatordate", "refs/tags").Run(rc) + if err != nil { + _ = stdoutWriter.CloseWithError(ConcatenateError(err, stderr.String())) + } else { + _ = stdoutWriter.Close() + } + }() + + var tags []*Tag + 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) + } + + sortTagsByTime(tags) + tagsTotal := len(tags) + if page != 0 { + tags = util.PaginateSlice(tags, page, pageSize).([]*Tag) + } + + return tags, tagsTotal, nil +} + +// parseTagRef parses a tag from a 'git for-each-ref'-produced reference. +func parseTagRef(ref map[string]string) (tag *Tag, err error) { + tag = &Tag{ + Type: ref["objecttype"], + Name: ref["refname:lstrip=2"], + } + + tag.ID, err = NewIDFromString(ref["objectname"]) + if err != nil { + return nil, fmt.Errorf("parse objectname '%s': %w", ref["objectname"], err) + } + + if tag.Type == "commit" { + // lightweight tag + tag.Object = tag.ID + } else { + // annotated tag + tag.Object, err = NewIDFromString(ref["object"]) + if err != nil { + return nil, fmt.Errorf("parse object '%s': %w", ref["object"], err) + } + } + + tag.Tagger = parseSignatureFromCommitLine(ref["creator"]) + tag.Message = ref["contents"] + // strip the signature if present in contents field + pgpStart := strings.Index(tag.Message, beginpgp) + if pgpStart >= 0 { + tag.Message = tag.Message[0:pgpStart] + } else { + sshStart := strings.Index(tag.Message, beginssh) + if sshStart >= 0 { + tag.Message = tag.Message[0:sshStart] + } + } + + // annotated tag with signature + if tag.Type == "tag" && ref["contents:signature"] != "" { + payload := fmt.Sprintf("object %s\ntype commit\ntag %s\ntagger %s\n\n%s\n", + tag.Object, tag.Name, ref["creator"], strings.TrimSpace(tag.Message)) + tag.Signature = &ObjectSignature{ + Signature: ref["contents:signature"], + Payload: payload, + } + } + + return tag, nil +} + +// GetAnnotatedTag returns a Git tag by its SHA, must be an annotated tag +func (repo *Repository) GetAnnotatedTag(sha string) (*Tag, error) { + id, err := NewIDFromString(sha) + if err != nil { + return nil, err + } + + // Tag type must be "tag" (annotated) and not a "commit" (lightweight) tag + if tagType, err := repo.GetTagType(id); err != nil { + return nil, err + } else if ObjectType(tagType) != ObjectTag { + // not an annotated tag + return nil, ErrNotExist{ID: id.String()} + } + + // Get tag name + name, err := repo.GetTagNameBySHA(id.String()) + if err != nil { + return nil, err + } + + tag, err := repo.getTag(id, name) + if err != nil { + return nil, err + } + return tag, nil +} + +// IsTagExist returns true if given tag exists in the repository. +func (repo *Repository) IsTagExist(name string) bool { + if repo == nil || name == "" { + return false + } + + return repo.IsReferenceExist(TagPrefix + name) +} + +// GetTags returns all tags of the repository. +// returning at most limit tags, or all if limit is 0. +func (repo *Repository) GetTags(skip, limit int) (tags []string, err error) { + tags, _, err = callShowRef(repo.Ctx, repo.Path, TagPrefix, TrustedCmdArgs{TagPrefix, "--sort=-taggerdate"}, skip, limit) + return tags, err +} + +// 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) + if err != nil { + return "", err + } + defer cancel() + _, err = wr.Write([]byte(id.String() + "\n")) + if err != nil { + return "", err + } + _, typ, _, err := ReadBatchLine(rd) + if IsErrNotExist(err) { + return "", ErrNotExist{ID: id.String()} + } + return typ, nil +} + +func (repo *Repository) getTag(tagID ObjectID, name string) (*Tag, error) { + t, ok := repo.tagCache.Get(tagID.String()) + if ok { + log.Debug("Hit cache: %s", tagID) + tagClone := *t.(*Tag) + tagClone.Name = name // This is necessary because lightweight tags may have same id + return &tagClone, nil + } + + tp, err := repo.GetTagType(tagID) + if err != nil { + return nil, err + } + + // Get the commit ID and tag ID (may be different for annotated tag) for the returned tag object + commitIDStr, err := repo.GetTagCommitID(name) + if err != nil { + // every tag should have a commit ID so return all errors + return nil, err + } + commitID, err := NewIDFromString(commitIDStr) + if err != nil { + return nil, err + } + + // If type is "commit, the tag is a lightweight tag + if ObjectType(tp) == ObjectCommit { + commit, err := repo.GetCommit(commitIDStr) + if err != nil { + return nil, err + } + tag := &Tag{ + Name: name, + ID: tagID, + Object: commitID, + Type: tp, + Tagger: commit.Committer, + Message: commit.Message(), + } + + repo.tagCache.Set(tagID.String(), tag) + return tag, nil + } + + // The tag is an annotated tag with a message. + wr, rd, 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) + if err != nil { + if errors.Is(err, io.EOF) || IsErrNotExist(err) { + return nil, ErrNotExist{ID: tagID.String()} + } + return nil, err + } + if typ != "tag" { + if err := DiscardFull(rd, size+1); err != nil { + return nil, err + } + return nil, ErrNotExist{ID: tagID.String()} + } + + // then we need to parse the tag + // and load the commit + data, err := io.ReadAll(io.LimitReader(rd, size)) + if err != nil { + return nil, err + } + _, err = rd.Discard(1) + if err != nil { + return nil, err + } + + tag, err := parseTagData(tagID.Type(), data) + if err != nil { + return nil, err + } + + tag.Name = name + tag.ID = tagID + tag.Type = tp + + repo.tagCache.Set(tagID.String(), tag) + return tag, nil +} diff --git a/modules/git/repo_tag_test.go b/modules/git/repo_tag_test.go new file mode 100644 index 0000000..1cf420a --- /dev/null +++ b/modules/git/repo_tag_test.go @@ -0,0 +1,364 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRepository_GetTags(t *testing.T) { + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") + bareRepo1, err := openRepositoryWithDefaultContext(bareRepo1Path) + if err != nil { + require.NoError(t, err) + return + } + defer bareRepo1.Close() + + tags, total, err := bareRepo1.GetTagInfos(0, 0) + if err != nil { + require.NoError(t, err) + return + } + assert.Len(t, tags, 2) + assert.Len(t, tags, total) + assert.EqualValues(t, "signed-tag", tags[0].Name) + assert.EqualValues(t, "36f97d9a96457e2bab511db30fe2db03893ebc64", tags[0].ID.String()) + assert.EqualValues(t, "tag", tags[0].Type) + assert.EqualValues(t, "test", tags[1].Name) + assert.EqualValues(t, "3ad28a9149a2864384548f3d17ed7f38014c9e8a", tags[1].ID.String()) + assert.EqualValues(t, "tag", tags[1].Type) +} + +func TestRepository_GetTag(t *testing.T) { + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") + + clonedPath, err := cloneRepo(t, bareRepo1Path) + if err != nil { + require.NoError(t, err) + return + } + + bareRepo1, err := openRepositoryWithDefaultContext(clonedPath) + if err != nil { + require.NoError(t, err) + return + } + defer bareRepo1.Close() + + // LIGHTWEIGHT TAGS + lTagCommitID := "6fbd69e9823458e6c4a2fc5c0f6bc022b2f2acd1" + lTagName := "lightweightTag" + + // Create the lightweight tag + err = bareRepo1.CreateTag(lTagName, lTagCommitID) + if err != nil { + require.NoError(t, err, "Unable to create the lightweight tag: %s for ID: %s. Error: %v", lTagName, lTagCommitID, err) + return + } + + // and try to get the Tag for lightweight tag + lTag, err := bareRepo1.GetTag(lTagName) + if err != nil { + require.NoError(t, err) + return + } + if lTag == nil { + assert.NotNil(t, lTag) + assert.FailNow(t, "nil lTag: %s", lTagName) + } + assert.EqualValues(t, lTagName, lTag.Name) + assert.EqualValues(t, lTagCommitID, lTag.ID.String()) + assert.EqualValues(t, lTagCommitID, lTag.Object.String()) + assert.EqualValues(t, "commit", lTag.Type) + + // ANNOTATED TAGS + aTagCommitID := "8006ff9adbf0cb94da7dad9e537e53817f9fa5c0" + aTagName := "annotatedTag" + aTagMessage := "my annotated message \n - test two line" + + // Create the annotated tag + err = bareRepo1.CreateAnnotatedTag(aTagName, aTagMessage, aTagCommitID) + if err != nil { + require.NoError(t, err, "Unable to create the annotated tag: %s for ID: %s. Error: %v", aTagName, aTagCommitID, err) + return + } + + // Now try to get the tag for the annotated Tag + aTagID, err := bareRepo1.GetTagID(aTagName) + if err != nil { + require.NoError(t, err) + return + } + + aTag, err := bareRepo1.GetTag(aTagName) + if err != nil { + require.NoError(t, err) + return + } + if aTag == nil { + assert.NotNil(t, aTag) + assert.FailNow(t, "nil aTag: %s", aTagName) + } + assert.EqualValues(t, aTagName, aTag.Name) + assert.EqualValues(t, aTagID, aTag.ID.String()) + assert.NotEqual(t, aTagID, aTag.Object.String()) + assert.EqualValues(t, aTagCommitID, aTag.Object.String()) + assert.EqualValues(t, "tag", aTag.Type) + + // RELEASE TAGS + + rTagCommitID := "8006ff9adbf0cb94da7dad9e537e53817f9fa5c0" + rTagName := "release/" + lTagName + + err = bareRepo1.CreateTag(rTagName, rTagCommitID) + if err != nil { + require.NoError(t, err, "Unable to create the tag: %s for ID: %s. Error: %v", rTagName, rTagCommitID, err) + return + } + + rTagID, err := bareRepo1.GetTagID(rTagName) + if err != nil { + require.NoError(t, err) + return + } + assert.EqualValues(t, rTagCommitID, rTagID) + + oTagID, err := bareRepo1.GetTagID(lTagName) + if err != nil { + require.NoError(t, err) + return + } + assert.EqualValues(t, lTagCommitID, oTagID) +} + +func TestRepository_GetAnnotatedTag(t *testing.T) { + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") + + clonedPath, err := cloneRepo(t, bareRepo1Path) + if err != nil { + require.NoError(t, err) + return + } + + bareRepo1, err := openRepositoryWithDefaultContext(clonedPath) + if err != nil { + require.NoError(t, err) + return + } + defer bareRepo1.Close() + + lTagCommitID := "6fbd69e9823458e6c4a2fc5c0f6bc022b2f2acd1" + lTagName := "lightweightTag" + bareRepo1.CreateTag(lTagName, lTagCommitID) + + aTagCommitID := "8006ff9adbf0cb94da7dad9e537e53817f9fa5c0" + aTagName := "annotatedTag" + aTagMessage := "my annotated message" + bareRepo1.CreateAnnotatedTag(aTagName, aTagMessage, aTagCommitID) + aTagID, _ := bareRepo1.GetTagID(aTagName) + + // Try an annotated tag + tag, err := bareRepo1.GetAnnotatedTag(aTagID) + if err != nil { + require.NoError(t, err) + return + } + assert.NotNil(t, tag) + assert.EqualValues(t, aTagName, tag.Name) + assert.EqualValues(t, aTagID, tag.ID.String()) + assert.EqualValues(t, "tag", tag.Type) + + // Annotated tag's Commit ID should fail + tag2, err := bareRepo1.GetAnnotatedTag(aTagCommitID) + require.Error(t, err) + assert.True(t, IsErrNotExist(err)) + assert.Nil(t, tag2) + + // Annotated tag's name should fail + tag3, err := bareRepo1.GetAnnotatedTag(aTagName) + require.Error(t, err) + require.Errorf(t, err, "Length must be 40: %d", len(aTagName)) + assert.Nil(t, tag3) + + // Lightweight Tag should fail + tag4, err := bareRepo1.GetAnnotatedTag(lTagCommitID) + require.Error(t, err) + assert.True(t, IsErrNotExist(err)) + assert.Nil(t, tag4) +} + +func TestRepository_parseTagRef(t *testing.T) { + tests := []struct { + name string + + givenRef map[string]string + + want *Tag + wantErr bool + expectedErr error + }{ + { + name: "lightweight tag", + + givenRef: map[string]string{ + "objecttype": "commit", + "refname:lstrip=2": "v1.9.1", + // object will be empty for lightweight tags + "object": "", + "objectname": "ab23e4b7f4cd0caafe0174c0e7ef6d651ba72889", + "creator": "Foo Bar <foo@bar.com> 1565789218 +0300", + "contents": `Add changelog of v1.9.1 (#7859) + +* add changelog of v1.9.1 +* Update CHANGELOG.md +`, + "contents:signature": "", + }, + + want: &Tag{ + Name: "v1.9.1", + ID: MustIDFromString("ab23e4b7f4cd0caafe0174c0e7ef6d651ba72889"), + Object: MustIDFromString("ab23e4b7f4cd0caafe0174c0e7ef6d651ba72889"), + Type: "commit", + Tagger: parseSignatureFromCommitLine("Foo Bar <foo@bar.com> 1565789218 +0300"), + Message: "Add changelog of v1.9.1 (#7859)\n\n* add changelog of v1.9.1\n* Update CHANGELOG.md\n", + Signature: nil, + }, + }, + + { + name: "annotated tag", + + givenRef: map[string]string{ + "objecttype": "tag", + "refname:lstrip=2": "v0.0.1", + // object will refer to commit hash for annotated tag + "object": "3325fd8a973321fd59455492976c042dde3fd1ca", + "objectname": "8c68a1f06fc59c655b7e3905b159d761e91c53c9", + "creator": "Foo Bar <foo@bar.com> 1565789218 +0300", + "contents": `Add changelog of v1.9.1 (#7859) + +* add changelog of v1.9.1 +* Update CHANGELOG.md +`, + "contents:signature": "", + }, + + want: &Tag{ + Name: "v0.0.1", + ID: MustIDFromString("8c68a1f06fc59c655b7e3905b159d761e91c53c9"), + Object: MustIDFromString("3325fd8a973321fd59455492976c042dde3fd1ca"), + Type: "tag", + Tagger: parseSignatureFromCommitLine("Foo Bar <foo@bar.com> 1565789218 +0300"), + Message: "Add changelog of v1.9.1 (#7859)\n\n* add changelog of v1.9.1\n* Update CHANGELOG.md\n", + Signature: nil, + }, + }, + + { + name: "annotated tag with signature", + + givenRef: map[string]string{ + "objecttype": "tag", + "refname:lstrip=2": "v0.0.1", + "object": "3325fd8a973321fd59455492976c042dde3fd1ca", + "objectname": "8c68a1f06fc59c655b7e3905b159d761e91c53c9", + "creator": "Foo Bar <foo@bar.com> 1565789218 +0300", + "contents": `Add changelog of v1.9.1 (#7859) + +* add changelog of v1.9.1 +* Update CHANGELOG.md +-----BEGIN PGP SIGNATURE----- + +aBCGzBAABCgAdFiEEyWRwv/q1Q6IjSv+D4IPOwzt33PoFAmI8jbIACgkQ4IPOwzt3 +3PoRuAv9FVSbPBXvzECubls9KQd7urwEvcfG20Uf79iBwifQJUv+egNQojrs6APT +T4CdIXeGRpwJZaGTUX9RWnoDO1SLXAWnc82CypWraNwrHq8Go2YeoVu0Iy3vb0EU +REdob/tXYZecMuP8AjhUR0XfdYaERYAvJ2dYsH/UkFrqDjM3V4kPXWG+R5DCaZiE +slB5U01i4Dwb/zm/ckzhUGEcOgcnpOKX8SnY5kYRVDY47dl/yJZ1u2XWir3mu60G +1geIitH7StBddHi/8rz+sJwTfcVaLjn2p59p/Dr9aGbk17GIaKq1j0pZA2lKT0Xt +f9jDqU+9vCxnKgjSDhrwN69LF2jT47ZFjEMGV/wFPOa1EBxVWpgQ/CfEolBlbUqx +yVpbxi/6AOK2lmG130e9jEZJcu+WeZUeq851WgKSEkf2d5f/JpwtSTEOlOedu6V6 +kl845zu5oE2nKM4zMQ7XrYQn538I31ps+VGQ0H8R07WrZP8WKUWugL2cU8KmXFwg +qbHDASXl +=2yGi +-----END PGP SIGNATURE----- + +`, + "contents:signature": `-----BEGIN PGP SIGNATURE----- + +aBCGzBAABCgAdFiEEyWRwv/q1Q6IjSv+D4IPOwzt33PoFAmI8jbIACgkQ4IPOwzt3 +3PoRuAv9FVSbPBXvzECubls9KQd7urwEvcfG20Uf79iBwifQJUv+egNQojrs6APT +T4CdIXeGRpwJZaGTUX9RWnoDO1SLXAWnc82CypWraNwrHq8Go2YeoVu0Iy3vb0EU +REdob/tXYZecMuP8AjhUR0XfdYaERYAvJ2dYsH/UkFrqDjM3V4kPXWG+R5DCaZiE +slB5U01i4Dwb/zm/ckzhUGEcOgcnpOKX8SnY5kYRVDY47dl/yJZ1u2XWir3mu60G +1geIitH7StBddHi/8rz+sJwTfcVaLjn2p59p/Dr9aGbk17GIaKq1j0pZA2lKT0Xt +f9jDqU+9vCxnKgjSDhrwN69LF2jT47ZFjEMGV/wFPOa1EBxVWpgQ/CfEolBlbUqx +yVpbxi/6AOK2lmG130e9jEZJcu+WeZUeq851WgKSEkf2d5f/JpwtSTEOlOedu6V6 +kl845zu5oE2nKM4zMQ7XrYQn538I31ps+VGQ0H8R07WrZP8WKUWugL2cU8KmXFwg +qbHDASXl +=2yGi +-----END PGP SIGNATURE----- + +`, + }, + + want: &Tag{ + Name: "v0.0.1", + ID: MustIDFromString("8c68a1f06fc59c655b7e3905b159d761e91c53c9"), + Object: MustIDFromString("3325fd8a973321fd59455492976c042dde3fd1ca"), + Type: "tag", + Tagger: parseSignatureFromCommitLine("Foo Bar <foo@bar.com> 1565789218 +0300"), + Message: "Add changelog of v1.9.1 (#7859)\n\n* add changelog of v1.9.1\n* Update CHANGELOG.md", + Signature: &ObjectSignature{ + Signature: `-----BEGIN PGP SIGNATURE----- + +aBCGzBAABCgAdFiEEyWRwv/q1Q6IjSv+D4IPOwzt33PoFAmI8jbIACgkQ4IPOwzt3 +3PoRuAv9FVSbPBXvzECubls9KQd7urwEvcfG20Uf79iBwifQJUv+egNQojrs6APT +T4CdIXeGRpwJZaGTUX9RWnoDO1SLXAWnc82CypWraNwrHq8Go2YeoVu0Iy3vb0EU +REdob/tXYZecMuP8AjhUR0XfdYaERYAvJ2dYsH/UkFrqDjM3V4kPXWG+R5DCaZiE +slB5U01i4Dwb/zm/ckzhUGEcOgcnpOKX8SnY5kYRVDY47dl/yJZ1u2XWir3mu60G +1geIitH7StBddHi/8rz+sJwTfcVaLjn2p59p/Dr9aGbk17GIaKq1j0pZA2lKT0Xt +f9jDqU+9vCxnKgjSDhrwN69LF2jT47ZFjEMGV/wFPOa1EBxVWpgQ/CfEolBlbUqx +yVpbxi/6AOK2lmG130e9jEZJcu+WeZUeq851WgKSEkf2d5f/JpwtSTEOlOedu6V6 +kl845zu5oE2nKM4zMQ7XrYQn538I31ps+VGQ0H8R07WrZP8WKUWugL2cU8KmXFwg +qbHDASXl +=2yGi +-----END PGP SIGNATURE----- + +`, + Payload: `object 3325fd8a973321fd59455492976c042dde3fd1ca +type commit +tag v0.0.1 +tagger Foo Bar <foo@bar.com> 1565789218 +0300 + +Add changelog of v1.9.1 (#7859) + +* add changelog of v1.9.1 +* Update CHANGELOG.md +`, + }, + }, + }, + } + + for _, test := range tests { + tc := test // don't close over loop variable + t.Run(tc.name, func(t *testing.T) { + got, err := parseTagRef(tc.givenRef) + + if tc.wantErr { + require.Error(t, err) + require.ErrorIs(t, err, tc.expectedErr) + } else { + require.NoError(t, err) + require.Equal(t, tc.want, got) + } + }) + } +} diff --git a/modules/git/repo_test.go b/modules/git/repo_test.go new file mode 100644 index 0000000..8fb19a5 --- /dev/null +++ b/modules/git/repo_test.go @@ -0,0 +1,57 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "context" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetLatestCommitTime(t *testing.T) { + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") + lct, err := GetLatestCommitTime(DefaultContext, bareRepo1Path) + require.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 := openRepositoryWithDefaultContext(emptyRepo2Path) + require.NoError(t, err) + defer repo.Close() + isEmpty, err := repo.IsEmpty() + require.NoError(t, err) + assert.True(t, isEmpty) +} + +func TestRepoGetDivergingCommits(t *testing.T) { + bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") + do, err := GetDivergingCommits(context.Background(), bareRepo1Path, "master", "branch2") + require.NoError(t, err) + assert.Equal(t, DivergeObject{ + Ahead: 1, + Behind: 5, + }, do) + + do, err = GetDivergingCommits(context.Background(), bareRepo1Path, "master", "master") + require.NoError(t, err) + assert.Equal(t, DivergeObject{ + Ahead: 0, + Behind: 0, + }, do) + + do, err = GetDivergingCommits(context.Background(), bareRepo1Path, "master", "test") + require.NoError(t, err) + assert.Equal(t, DivergeObject{ + Ahead: 0, + Behind: 2, + }, do) +} diff --git a/modules/git/repo_tree.go b/modules/git/repo_tree.go new file mode 100644 index 0000000..53d94d9 --- /dev/null +++ b/modules/git/repo_tree.go @@ -0,0 +1,156 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "bytes" + "io" + "os" + "strings" + "time" +) + +// CommitTreeOpts represents the possible options to CommitTree +type CommitTreeOpts struct { + Parents []string + Message string + KeyID string + NoGPGSign bool + AlwaysSign bool +} + +// CommitTree creates a commit from a given tree id for the user with provided message +func (repo *Repository) CommitTree(author, committer *Signature, tree *Tree, opts CommitTreeOpts) (ObjectID, error) { + commitTimeStr := time.Now().Format(time.RFC3339) + + // Because this may call hooks we should pass in the environment + env := append(os.Environ(), + "GIT_AUTHOR_NAME="+author.Name, + "GIT_AUTHOR_EMAIL="+author.Email, + "GIT_AUTHOR_DATE="+commitTimeStr, + "GIT_COMMITTER_NAME="+committer.Name, + "GIT_COMMITTER_EMAIL="+committer.Email, + "GIT_COMMITTER_DATE="+commitTimeStr, + ) + cmd := NewCommand(repo.Ctx, "commit-tree").AddDynamicArguments(tree.ID.String()) + + for _, parent := range opts.Parents { + cmd.AddArguments("-p").AddDynamicArguments(parent) + } + + messageBytes := new(bytes.Buffer) + _, _ = messageBytes.WriteString(opts.Message) + _, _ = messageBytes.WriteString("\n") + + if opts.KeyID != "" || opts.AlwaysSign { + cmd.AddOptionFormat("-S%s", opts.KeyID) + } + + if opts.NoGPGSign { + cmd.AddArguments("--no-gpg-sign") + } + + stdout := new(bytes.Buffer) + stderr := new(bytes.Buffer) + err := cmd.Run(&RunOpts{ + Env: env, + Dir: repo.Path, + Stdin: messageBytes, + Stdout: stdout, + Stderr: stderr, + }) + if err != nil { + return nil, ConcatenateError(err, stderr.String()) + } + return NewIDFromString(strings.TrimSpace(stdout.String())) +} + +func (repo *Repository) getTree(id ObjectID) (*Tree, error) { + wr, rd, 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) + if err != nil { + return nil, err + } + + switch typ { + case "tag": + resolvedID := id + data, err := io.ReadAll(io.LimitReader(rd, size)) + if err != nil { + return nil, err + } + tag, err := parseTagData(id.Type(), data) + if err != nil { + return nil, err + } + commit, err := tag.Commit(repo) + 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)) + if err != nil { + return nil, err + } + if _, err := rd.Discard(1); err != nil { + return nil, err + } + commit.Tree.ResolvedID = commit.ID + return &commit.Tree, nil + case "tree": + tree := NewTree(repo, id) + tree.ResolvedID = id + objectFormat, err := repo.GetObjectFormat() + if err != nil { + return nil, err + } + tree.entries, err = catBatchParseTreeEntries(objectFormat, tree, rd, size) + if err != nil { + return nil, err + } + tree.entriesParsed = true + return tree, nil + default: + if err := DiscardFull(rd, size+1); err != nil { + return nil, err + } + return nil, ErrNotExist{ + ID: id.String(), + } + } +} + +// GetTree find the tree object in the repository. +func (repo *Repository) GetTree(idStr string) (*Tree, error) { + objectFormat, err := repo.GetObjectFormat() + if err != nil { + return nil, err + } + if len(idStr) != objectFormat.FullLength() { + res, err := repo.GetRefCommitID(idStr) + if err != nil { + return nil, err + } + if len(res) > 0 { + idStr = res + } + } + id, err := NewIDFromString(idStr) + if err != nil { + return nil, err + } + + return repo.getTree(id) +} diff --git a/modules/git/signature.go b/modules/git/signature.go new file mode 100644 index 0000000..c368ce3 --- /dev/null +++ b/modules/git/signature.go @@ -0,0 +1,67 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "fmt" + "strconv" + "strings" + "time" + + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/util" +) + +// Signature represents the Author, Committer or Tagger information. +type Signature struct { + Name string // the committer name, it can be anything + Email string // the committer email, it can be anything + When time.Time // the timestamp of the signature +} + +func (s *Signature) String() string { + return fmt.Sprintf("%s <%s>", s.Name, s.Email) +} + +// Decode decodes a byte array representing a signature to signature +func (s *Signature) Decode(b []byte) { + *s = *parseSignatureFromCommitLine(util.UnsafeBytesToString(b)) +} + +// Helper to get a signature from the commit line, which looks like: +// +// full name <user@example.com> 1378823654 +0200 +// +// Haven't found the official reference for the standard format yet. +// This function never fails, if the "line" can't be parsed, it returns a default Signature with "zero" time. +func parseSignatureFromCommitLine(line string) *Signature { + sig := &Signature{} + s1, sx, ok1 := strings.Cut(line, " <") + s2, s3, ok2 := strings.Cut(sx, "> ") + if !ok1 || !ok2 { + sig.Name = line + return sig + } + sig.Name, sig.Email = s1, s2 + + if strings.Count(s3, " ") == 1 { + ts, tz, _ := strings.Cut(s3, " ") + seconds, _ := strconv.ParseInt(ts, 10, 64) + if tzTime, err := time.Parse("-0700", tz); err == nil { + sig.When = time.Unix(seconds, 0).In(tzTime.Location()) + } + } else { + // the old gitea code tried to parse the date in a few different formats, but it's not clear why. + // according to public document, only the standard format "timestamp timezone" could be found, so drop other formats. + log.Error("suspicious commit line format: %q", line) + for _, fmt := range []string{ /*"Mon Jan _2 15:04:05 2006 -0700"*/ } { + if t, err := time.Parse(fmt, s3); err == nil { + sig.When = t + break + } + } + } + return sig +} diff --git a/modules/git/signature_test.go b/modules/git/signature_test.go new file mode 100644 index 0000000..92681fe --- /dev/null +++ b/modules/git/signature_test.go @@ -0,0 +1,47 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestParseSignatureFromCommitLine(t *testing.T) { + tests := []struct { + line string + want *Signature + }{ + { + line: "a b <c@d.com> 12345 +0100", + want: &Signature{ + Name: "a b", + Email: "c@d.com", + When: time.Unix(12345, 0).In(time.FixedZone("", 3600)), + }, + }, + { + line: "bad line", + want: &Signature{Name: "bad line"}, + }, + { + line: "bad < line", + want: &Signature{Name: "bad < line"}, + }, + { + line: "bad > line", + want: &Signature{Name: "bad > line"}, + }, + { + line: "bad-line <name@example.com>", + want: &Signature{Name: "bad-line <name@example.com>"}, + }, + } + for _, test := range tests { + got := parseSignatureFromCommitLine(test.line) + assert.EqualValues(t, test.want, got) + } +} diff --git a/modules/git/submodule.go b/modules/git/submodule.go new file mode 100644 index 0000000..b99c815 --- /dev/null +++ b/modules/git/submodule.go @@ -0,0 +1,119 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Copyright 2015 The Gogs Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "fmt" + "net" + "net/url" + "path" + "regexp" + "strings" +) + +var scpSyntax = regexp.MustCompile(`^([a-zA-Z0-9_]+@)?([a-zA-Z0-9._-]+):(.*)$`) + +// SubModule submodule is a reference on git repository +type SubModule struct { + Name string + URL string +} + +// SubModuleFile represents a file with submodule type. +type SubModuleFile struct { + *Commit + + refURL string + refID string +} + +// NewSubModuleFile create a new submodule file +func NewSubModuleFile(c *Commit, refURL, refID string) *SubModuleFile { + return &SubModuleFile{ + Commit: c, + refURL: refURL, + refID: refID, + } +} + +func getRefURL(refURL, urlPrefix, repoFullName, sshDomain string) string { + if refURL == "" { + return "" + } + + refURI := strings.TrimSuffix(refURL, ".git") + + prefixURL, _ := url.Parse(urlPrefix) + urlPrefixHostname, _, err := net.SplitHostPort(prefixURL.Host) + if err != nil { + urlPrefixHostname = prefixURL.Host + } + + urlPrefix = strings.TrimSuffix(urlPrefix, "/") + + // FIXME: Need to consider branch - which will require changes in modules/git/commit.go:GetSubModules + // Relative url prefix check (according to git submodule documentation) + if strings.HasPrefix(refURI, "./") || strings.HasPrefix(refURI, "../") { + return urlPrefix + path.Clean(path.Join("/", repoFullName, refURI)) + } + + if !strings.Contains(refURI, "://") { + // scp style syntax which contains *no* port number after the : (and is not parsed by net/url) + // ex: git@try.gitea.io:go-gitea/gitea + match := scpSyntax.FindAllStringSubmatch(refURI, -1) + if len(match) > 0 { + m := match[0] + refHostname := m[2] + pth := m[3] + + if !strings.HasPrefix(pth, "/") { + pth = "/" + pth + } + + if urlPrefixHostname == refHostname || refHostname == sshDomain { + return urlPrefix + path.Clean(path.Join("/", pth)) + } + return "http://" + refHostname + pth + } + } + + ref, err := url.Parse(refURI) + if err != nil { + return "" + } + + refHostname, _, err := net.SplitHostPort(ref.Host) + if err != nil { + refHostname = ref.Host + } + + supportedSchemes := []string{"http", "https", "git", "ssh", "git+ssh"} + + for _, scheme := range supportedSchemes { + if ref.Scheme == scheme { + if ref.Scheme == "http" || ref.Scheme == "https" { + if len(ref.User.Username()) > 0 { + return ref.Scheme + "://" + fmt.Sprintf("%v", ref.User) + "@" + ref.Host + ref.Path + } + return ref.Scheme + "://" + ref.Host + ref.Path + } else if urlPrefixHostname == refHostname || refHostname == sshDomain { + return urlPrefix + path.Clean(path.Join("/", ref.Path)) + } + return "http://" + refHostname + ref.Path + } + } + + return "" +} + +// RefURL guesses and returns reference URL. +func (sf *SubModuleFile) RefURL(urlPrefix, repoFullName, sshDomain string) string { + return getRefURL(sf.refURL, urlPrefix, repoFullName, sshDomain) +} + +// RefID returns reference ID. +func (sf *SubModuleFile) RefID() string { + return sf.refID +} diff --git a/modules/git/submodule_test.go b/modules/git/submodule_test.go new file mode 100644 index 0000000..e05f251 --- /dev/null +++ b/modules/git/submodule_test.go @@ -0,0 +1,42 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetRefURL(t *testing.T) { + kases := []struct { + refURL string + prefixURL string + parentPath string + SSHDomain string + expect string + }{ + {"git://github.com/user1/repo1", "/", "user1/repo2", "", "http://github.com/user1/repo1"}, + {"https://localhost/user1/repo1.git", "/", "user1/repo2", "", "https://localhost/user1/repo1"}, + {"http://localhost/user1/repo1.git", "/", "owner/reponame", "", "http://localhost/user1/repo1"}, + {"git@github.com:user1/repo1.git", "/", "owner/reponame", "", "http://github.com/user1/repo1"}, + {"ssh://git@git.zefie.net:2222/zefie/lge_g6_kernel_scripts.git", "/", "zefie/lge_g6_kernel", "", "http://git.zefie.net/zefie/lge_g6_kernel_scripts"}, + {"git@git.zefie.net:2222/zefie/lge_g6_kernel_scripts.git", "/", "zefie/lge_g6_kernel", "", "http://git.zefie.net/2222/zefie/lge_g6_kernel_scripts"}, + {"git@try.gitea.io:go-gitea/gitea", "https://try.gitea.io/", "go-gitea/sdk", "", "https://try.gitea.io/go-gitea/gitea"}, + {"ssh://git@try.gitea.io:9999/go-gitea/gitea", "https://try.gitea.io/", "go-gitea/sdk", "", "https://try.gitea.io/go-gitea/gitea"}, + {"git://git@try.gitea.io:9999/go-gitea/gitea", "https://try.gitea.io/", "go-gitea/sdk", "", "https://try.gitea.io/go-gitea/gitea"}, + {"ssh://git@127.0.0.1:9999/go-gitea/gitea", "https://127.0.0.1:3000/", "go-gitea/sdk", "", "https://127.0.0.1:3000/go-gitea/gitea"}, + {"https://gitea.com:3000/user1/repo1.git", "https://127.0.0.1:3000/", "user/repo2", "", "https://gitea.com:3000/user1/repo1"}, + {"https://example.gitea.com/gitea/user1/repo1.git", "https://example.gitea.com/gitea/", "", "user/repo2", "https://example.gitea.com/gitea/user1/repo1"}, + {"https://username:password@github.com/username/repository.git", "/", "username/repository2", "", "https://username:password@github.com/username/repository"}, + {"somethingbad", "https://127.0.0.1:3000/go-gitea/gitea", "/", "", ""}, + {"git@localhost:user/repo", "https://localhost/", "user2/repo1", "", "https://localhost/user/repo"}, + {"../path/to/repo.git/", "https://localhost/", "user/repo2", "", "https://localhost/user/path/to/repo.git"}, + {"ssh://git@ssh.gitea.io:2222/go-gitea/gitea", "https://try.gitea.io/", "go-gitea/sdk", "ssh.gitea.io", "https://try.gitea.io/go-gitea/gitea"}, + } + + for _, kase := range kases { + assert.EqualValues(t, kase.expect, getRefURL(kase.refURL, kase.prefixURL, kase.parentPath, kase.SSHDomain)) + } +} diff --git a/modules/git/tag.go b/modules/git/tag.go new file mode 100644 index 0000000..04f50e8 --- /dev/null +++ b/modules/git/tag.go @@ -0,0 +1,129 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "bytes" + "sort" + "strings" + + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" +) + +const ( + beginpgp = "\n-----BEGIN PGP SIGNATURE-----\n" + endpgp = "\n-----END PGP SIGNATURE-----" + beginssh = "\n-----BEGIN SSH SIGNATURE-----\n" + endssh = "\n-----END SSH SIGNATURE-----" +) + +// Tag represents a Git tag. +type Tag struct { + Name string + ID ObjectID + Object ObjectID // The id of this commit object + Type string + Tagger *Signature + Message string + Signature *ObjectSignature + ArchiveDownloadCount *api.TagArchiveDownloadCount +} + +// Commit return the commit of the tag reference +func (tag *Tag) Commit(gitRepo *Repository) (*Commit, error) { + return gitRepo.getCommit(tag.Object) +} + +// Parse commit information from the (uncompressed) raw +// data from the commit object. +// \n\n separate headers from message +func parseTagData(objectFormat ObjectFormat, data []byte) (*Tag, error) { + tag := new(Tag) + tag.ID = objectFormat.EmptyObjectID() + tag.Object = objectFormat.EmptyObjectID() + tag.Tagger = &Signature{} + // we now have the contents of the commit object. Let's investigate... + nextline := 0 +l: + for { + eol := bytes.IndexByte(data[nextline:], '\n') + switch { + case eol > 0: + line := data[nextline : nextline+eol] + spacepos := bytes.IndexByte(line, ' ') + reftype := line[:spacepos] + switch string(reftype) { + case "object": + id, err := NewIDFromString(string(line[spacepos+1:])) + if err != nil { + return nil, err + } + tag.Object = id + case "type": + // A commit can have one or more parents + tag.Type = string(line[spacepos+1:]) + case "tagger": + tag.Tagger = parseSignatureFromCommitLine(util.UnsafeBytesToString(line[spacepos+1:])) + } + nextline += eol + 1 + case eol == 0: + tag.Message = string(data[nextline+1:]) + break l + default: + break l + } + } + + extractTagSignature := func(signatureBeginMark, signatureEndMark string) (bool, *ObjectSignature, string) { + idx := strings.LastIndex(tag.Message, signatureBeginMark) + if idx == -1 { + return false, nil, "" + } + + endSigIdx := strings.Index(tag.Message[idx:], signatureEndMark) + if endSigIdx == -1 { + return false, nil, "" + } + + return true, &ObjectSignature{ + Signature: tag.Message[idx+1 : idx+endSigIdx+len(signatureEndMark)], + Payload: string(data[:bytes.LastIndex(data, []byte(signatureBeginMark))+1]), + }, tag.Message[:idx+1] + } + + // Try to find an OpenPGP signature + found, sig, message := extractTagSignature(beginpgp, endpgp) + if !found { + // If not found, try an SSH one + found, sig, message = extractTagSignature(beginssh, endssh) + } + // If either is found, update the tag Signature and Message + if found { + tag.Signature = sig + tag.Message = message + } + + return tag, nil +} + +type tagSorter []*Tag + +func (ts tagSorter) Len() int { + return len([]*Tag(ts)) +} + +func (ts tagSorter) Less(i, j int) bool { + return []*Tag(ts)[i].Tagger.When.After([]*Tag(ts)[j].Tagger.When) +} + +func (ts tagSorter) Swap(i, j int) { + []*Tag(ts)[i], []*Tag(ts)[j] = []*Tag(ts)[j], []*Tag(ts)[i] +} + +// sortTagsByTime +func sortTagsByTime(tags []*Tag) { + sorter := tagSorter(tags) + sort.Sort(sorter) +} diff --git a/modules/git/tag_test.go b/modules/git/tag_test.go new file mode 100644 index 0000000..8279066 --- /dev/null +++ b/modules/git/tag_test.go @@ -0,0 +1,109 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_parseTagData(t *testing.T) { + testData := []struct { + data []byte + tag Tag + }{ + {data: []byte(`object 3b114ab800c6432ad42387ccf6bc8d4388a2885a +type commit +tag 1.22.0 +tagger Lucas Michot <lucas@semalead.com> 1484491741 +0100 + +`), tag: Tag{ + Name: "", + ID: Sha1ObjectFormat.EmptyObjectID(), + Object: &Sha1Hash{0x3b, 0x11, 0x4a, 0xb8, 0x0, 0xc6, 0x43, 0x2a, 0xd4, 0x23, 0x87, 0xcc, 0xf6, 0xbc, 0x8d, 0x43, 0x88, 0xa2, 0x88, 0x5a}, + Type: "commit", + Tagger: &Signature{Name: "Lucas Michot", Email: "lucas@semalead.com", When: time.Unix(1484491741, 0)}, + Message: "", + Signature: nil, + }}, + {data: []byte(`object 7cdf42c0b1cc763ab7e4c33c47a24e27c66bfccc +type commit +tag 1.22.1 +tagger Lucas Michot <lucas@semalead.com> 1484553735 +0100 + +test message +o + +ono`), tag: Tag{ + Name: "", + ID: Sha1ObjectFormat.EmptyObjectID(), + Object: &Sha1Hash{0x7c, 0xdf, 0x42, 0xc0, 0xb1, 0xcc, 0x76, 0x3a, 0xb7, 0xe4, 0xc3, 0x3c, 0x47, 0xa2, 0x4e, 0x27, 0xc6, 0x6b, 0xfc, 0xcc}, + Type: "commit", + Tagger: &Signature{Name: "Lucas Michot", Email: "lucas@semalead.com", When: time.Unix(1484553735, 0)}, + Message: "test message\no\n\nono", + Signature: nil, + }}, + {data: []byte(`object d8d1fdb5b20eaca882e34ee510eb55941a242b24 +type commit +tag v0 +tagger Jane Doe <jane.doe@example.com> 1709146405 +0100 + +v0 +-----BEGIN SSH SIGNATURE----- +U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgvD4pK7baygXxoWoVoKjVEc/xZh +6w+1FUn5hypFqJXNAAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5 +AAAAQKFeTnxi9ssRqSg+sJcmjAgpgoPq1k5SXm306+mJmkPwvhim8f9Gz6uy1AddPmXaD7 +5LVB3fV2GmmFDKGB+wCAo= +-----END SSH SIGNATURE----- +`), tag: Tag{ + Name: "", + ID: Sha1ObjectFormat.EmptyObjectID(), + Object: &Sha1Hash{0xd8, 0xd1, 0xfd, 0xb5, 0xb2, 0x0e, 0xac, 0xa8, 0x82, 0xe3, 0x4e, 0xe5, 0x10, 0xeb, 0x55, 0x94, 0x1a, 0x24, 0x2b, 0x24}, + Type: "commit", + Tagger: &Signature{Name: "Jane Doe", Email: "jane.doe@example.com", When: time.Unix(1709146405, 0)}, + Message: "v0\n", + Signature: &ObjectSignature{ + Signature: `-----BEGIN SSH SIGNATURE----- +U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgvD4pK7baygXxoWoVoKjVEc/xZh +6w+1FUn5hypFqJXNAAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5 +AAAAQKFeTnxi9ssRqSg+sJcmjAgpgoPq1k5SXm306+mJmkPwvhim8f9Gz6uy1AddPmXaD7 +5LVB3fV2GmmFDKGB+wCAo= +-----END SSH SIGNATURE-----`, + Payload: `object d8d1fdb5b20eaca882e34ee510eb55941a242b24 +type commit +tag v0 +tagger Jane Doe <jane.doe@example.com> 1709146405 +0100 + +v0 +`, + }, + }}, + } + + for _, test := range testData { + tag, err := parseTagData(Sha1ObjectFormat, test.data) + require.NoError(t, err) + assert.EqualValues(t, test.tag.ID, tag.ID) + assert.EqualValues(t, test.tag.Object, tag.Object) + assert.EqualValues(t, test.tag.Name, tag.Name) + assert.EqualValues(t, test.tag.Message, tag.Message) + assert.EqualValues(t, test.tag.Type, tag.Type) + if test.tag.Signature != nil && assert.NotNil(t, tag.Signature) { + assert.EqualValues(t, test.tag.Signature.Signature, tag.Signature.Signature) + assert.EqualValues(t, test.tag.Signature.Payload, tag.Signature.Payload) + } else { + assert.Nil(t, tag.Signature) + } + if test.tag.Tagger != nil && assert.NotNil(t, tag.Tagger) { + assert.EqualValues(t, test.tag.Tagger.Name, tag.Tagger.Name) + assert.EqualValues(t, test.tag.Tagger.Email, tag.Tagger.Email) + assert.EqualValues(t, test.tag.Tagger.When.Unix(), tag.Tagger.When.Unix()) + } else { + assert.Nil(t, tag.Tagger) + } + } +} diff --git a/modules/git/tests/repos/language_stats_repo/COMMIT_EDITMSG b/modules/git/tests/repos/language_stats_repo/COMMIT_EDITMSG new file mode 100644 index 0000000..ec4d890 --- /dev/null +++ b/modules/git/tests/repos/language_stats_repo/COMMIT_EDITMSG @@ -0,0 +1,3 @@ +Add some test files for GetLanguageStats + +Signed-off-by: Andrew Thornton <art27@cantab.net> diff --git a/modules/git/tests/repos/language_stats_repo/HEAD b/modules/git/tests/repos/language_stats_repo/HEAD new file mode 100644 index 0000000..cb089cd --- /dev/null +++ b/modules/git/tests/repos/language_stats_repo/HEAD @@ -0,0 +1 @@ +ref: refs/heads/master diff --git a/modules/git/tests/repos/language_stats_repo/config b/modules/git/tests/repos/language_stats_repo/config new file mode 100644 index 0000000..a4ef456 --- /dev/null +++ b/modules/git/tests/repos/language_stats_repo/config @@ -0,0 +1,5 @@ +[core] + repositoryformatversion = 0 + filemode = true + bare = true + logallrefupdates = true diff --git a/modules/git/tests/repos/language_stats_repo/description b/modules/git/tests/repos/language_stats_repo/description new file mode 100644 index 0000000..498b267 --- /dev/null +++ b/modules/git/tests/repos/language_stats_repo/description @@ -0,0 +1 @@ +Unnamed repository; edit this file 'description' to name the repository. diff --git a/modules/git/tests/repos/language_stats_repo/index b/modules/git/tests/repos/language_stats_repo/index Binary files differnew file mode 100644 index 0000000..e6c0223 --- /dev/null +++ b/modules/git/tests/repos/language_stats_repo/index diff --git a/modules/git/tests/repos/language_stats_repo/info/exclude b/modules/git/tests/repos/language_stats_repo/info/exclude new file mode 100644 index 0000000..a5196d1 --- /dev/null +++ b/modules/git/tests/repos/language_stats_repo/info/exclude @@ -0,0 +1,6 @@ +# git ls-files --others --exclude-from=.git/info/exclude +# Lines that start with '#' are comments. +# For a project mostly in C, the following would be a good set of +# exclude patterns (uncomment them if you want to use them): +# *.[oa] +# *~ diff --git a/modules/git/tests/repos/language_stats_repo/logs/HEAD b/modules/git/tests/repos/language_stats_repo/logs/HEAD new file mode 100644 index 0000000..9cedbb6 --- /dev/null +++ b/modules/git/tests/repos/language_stats_repo/logs/HEAD @@ -0,0 +1,2 @@ +0000000000000000000000000000000000000000 8fee858da5796dfb37704761701bb8e800ad9ef3 Andrew Thornton <art27@cantab.net> 1632140318 +0100 commit (initial): Add some test files for GetLanguageStats +8fee858da5796dfb37704761701bb8e800ad9ef3 341fca5b5ea3de596dc483e54c2db28633cd2f97 oliverpool <git@olivier.pfad.fr> 1711278775 +0100 push diff --git a/modules/git/tests/repos/language_stats_repo/logs/refs/heads/master b/modules/git/tests/repos/language_stats_repo/logs/refs/heads/master new file mode 100644 index 0000000..9cedbb6 --- /dev/null +++ b/modules/git/tests/repos/language_stats_repo/logs/refs/heads/master @@ -0,0 +1,2 @@ +0000000000000000000000000000000000000000 8fee858da5796dfb37704761701bb8e800ad9ef3 Andrew Thornton <art27@cantab.net> 1632140318 +0100 commit (initial): Add some test files for GetLanguageStats +8fee858da5796dfb37704761701bb8e800ad9ef3 341fca5b5ea3de596dc483e54c2db28633cd2f97 oliverpool <git@olivier.pfad.fr> 1711278775 +0100 push diff --git a/modules/git/tests/repos/language_stats_repo/objects/1e/ea60592b55dcb45c36029cc1202132e9fb756c b/modules/git/tests/repos/language_stats_repo/objects/1e/ea60592b55dcb45c36029cc1202132e9fb756c Binary files differnew file mode 100644 index 0000000..3c55bab --- /dev/null +++ b/modules/git/tests/repos/language_stats_repo/objects/1e/ea60592b55dcb45c36029cc1202132e9fb756c diff --git a/modules/git/tests/repos/language_stats_repo/objects/22/b6aa0588563508d8879f062470c8cbc7b2f2bb b/modules/git/tests/repos/language_stats_repo/objects/22/b6aa0588563508d8879f062470c8cbc7b2f2bb Binary files differnew file mode 100644 index 0000000..947feec --- /dev/null +++ b/modules/git/tests/repos/language_stats_repo/objects/22/b6aa0588563508d8879f062470c8cbc7b2f2bb diff --git a/modules/git/tests/repos/language_stats_repo/objects/34/1fca5b5ea3de596dc483e54c2db28633cd2f97 b/modules/git/tests/repos/language_stats_repo/objects/34/1fca5b5ea3de596dc483e54c2db28633cd2f97 Binary files differnew file mode 100644 index 0000000..9ce337e --- /dev/null +++ b/modules/git/tests/repos/language_stats_repo/objects/34/1fca5b5ea3de596dc483e54c2db28633cd2f97 diff --git a/modules/git/tests/repos/language_stats_repo/objects/42/25ecfaf6bafbcfa31ea5cbd8121c36d9457085 b/modules/git/tests/repos/language_stats_repo/objects/42/25ecfaf6bafbcfa31ea5cbd8121c36d9457085 Binary files differnew file mode 100644 index 0000000..ff3b642 --- /dev/null +++ b/modules/git/tests/repos/language_stats_repo/objects/42/25ecfaf6bafbcfa31ea5cbd8121c36d9457085 diff --git a/modules/git/tests/repos/language_stats_repo/objects/4a/c803638e4b8995146e329a05e096fa2c77a03d b/modules/git/tests/repos/language_stats_repo/objects/4a/c803638e4b8995146e329a05e096fa2c77a03d Binary files differnew file mode 100644 index 0000000..b71abc1 --- /dev/null +++ b/modules/git/tests/repos/language_stats_repo/objects/4a/c803638e4b8995146e329a05e096fa2c77a03d diff --git a/modules/git/tests/repos/language_stats_repo/objects/64/4c37ad7fe64ac012df7e59d27a92e3137c640e b/modules/git/tests/repos/language_stats_repo/objects/64/4c37ad7fe64ac012df7e59d27a92e3137c640e Binary files differnew file mode 100644 index 0000000..5c2485d --- /dev/null +++ b/modules/git/tests/repos/language_stats_repo/objects/64/4c37ad7fe64ac012df7e59d27a92e3137c640e diff --git a/modules/git/tests/repos/language_stats_repo/objects/6c/633a0067b463e459ae952716b17ae36aa30adc b/modules/git/tests/repos/language_stats_repo/objects/6c/633a0067b463e459ae952716b17ae36aa30adc Binary files differnew file mode 100644 index 0000000..873cb71 --- /dev/null +++ b/modules/git/tests/repos/language_stats_repo/objects/6c/633a0067b463e459ae952716b17ae36aa30adc diff --git a/modules/git/tests/repos/language_stats_repo/objects/8e/b563dc106e3dfd3ad0fa81f7a0c5e2604f80cd b/modules/git/tests/repos/language_stats_repo/objects/8e/b563dc106e3dfd3ad0fa81f7a0c5e2604f80cd Binary files differnew file mode 100644 index 0000000..f89ecb7 --- /dev/null +++ b/modules/git/tests/repos/language_stats_repo/objects/8e/b563dc106e3dfd3ad0fa81f7a0c5e2604f80cd diff --git a/modules/git/tests/repos/language_stats_repo/objects/8f/ee858da5796dfb37704761701bb8e800ad9ef3 b/modules/git/tests/repos/language_stats_repo/objects/8f/ee858da5796dfb37704761701bb8e800ad9ef3 Binary files differnew file mode 100644 index 0000000..0219c2d --- /dev/null +++ b/modules/git/tests/repos/language_stats_repo/objects/8f/ee858da5796dfb37704761701bb8e800ad9ef3 diff --git a/modules/git/tests/repos/language_stats_repo/objects/aa/a21bf84c8b2304608d3fc83b747840f2456299 b/modules/git/tests/repos/language_stats_repo/objects/aa/a21bf84c8b2304608d3fc83b747840f2456299 Binary files differnew file mode 100644 index 0000000..adc50f2 --- /dev/null +++ b/modules/git/tests/repos/language_stats_repo/objects/aa/a21bf84c8b2304608d3fc83b747840f2456299 diff --git a/modules/git/tests/repos/language_stats_repo/objects/da/a5abe3c5f42cae598e362e8a8db6284565d6bb b/modules/git/tests/repos/language_stats_repo/objects/da/a5abe3c5f42cae598e362e8a8db6284565d6bb Binary files differnew file mode 100644 index 0000000..9d4d4b1 --- /dev/null +++ b/modules/git/tests/repos/language_stats_repo/objects/da/a5abe3c5f42cae598e362e8a8db6284565d6bb diff --git a/modules/git/tests/repos/language_stats_repo/refs/heads/master b/modules/git/tests/repos/language_stats_repo/refs/heads/master new file mode 100644 index 0000000..e89143e --- /dev/null +++ b/modules/git/tests/repos/language_stats_repo/refs/heads/master @@ -0,0 +1 @@ +341fca5b5ea3de596dc483e54c2db28633cd2f97 diff --git a/modules/git/tests/repos/repo1_bare/HEAD b/modules/git/tests/repos/repo1_bare/HEAD new file mode 100644 index 0000000..cb089cd --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/HEAD @@ -0,0 +1 @@ +ref: refs/heads/master diff --git a/modules/git/tests/repos/repo1_bare/config b/modules/git/tests/repos/repo1_bare/config new file mode 100644 index 0000000..07d359d --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/config @@ -0,0 +1,4 @@ +[core] + repositoryformatversion = 0 + filemode = true + bare = true diff --git a/modules/git/tests/repos/repo1_bare/description b/modules/git/tests/repos/repo1_bare/description new file mode 100644 index 0000000..498b267 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/description @@ -0,0 +1 @@ +Unnamed repository; edit this file 'description' to name the repository. diff --git a/modules/git/tests/repos/repo1_bare/index b/modules/git/tests/repos/repo1_bare/index Binary files differnew file mode 100644 index 0000000..65d6751 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/index diff --git a/modules/git/tests/repos/repo1_bare/info/exclude b/modules/git/tests/repos/repo1_bare/info/exclude new file mode 100644 index 0000000..a5196d1 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/info/exclude @@ -0,0 +1,6 @@ +# git ls-files --others --exclude-from=.git/info/exclude +# Lines that start with '#' are comments. +# For a project mostly in C, the following would be a good set of +# exclude patterns (uncomment them if you want to use them): +# *.[oa] +# *~ diff --git a/modules/git/tests/repos/repo1_bare/logs/HEAD b/modules/git/tests/repos/repo1_bare/logs/HEAD new file mode 100644 index 0000000..46da5fe --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/logs/HEAD @@ -0,0 +1,2 @@ +37991dec2c8e592043f47155ce4808d4580f9123 feaf4ba6bc635fec442f46ddd4512416ec43c2c2 silverwind <me@silverwind.io> 1563741799 +0200 push +feaf4ba6bc635fec442f46ddd4512416ec43c2c2 ce064814f4a0d337b333e646ece456cd39fab612 silverwind <me@silverwind.io> 1668354026 +0100 push diff --git a/modules/git/tests/repos/repo1_bare/logs/refs/heads/master b/modules/git/tests/repos/repo1_bare/logs/refs/heads/master new file mode 100644 index 0000000..46da5fe --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/logs/refs/heads/master @@ -0,0 +1,2 @@ +37991dec2c8e592043f47155ce4808d4580f9123 feaf4ba6bc635fec442f46ddd4512416ec43c2c2 silverwind <me@silverwind.io> 1563741799 +0200 push +feaf4ba6bc635fec442f46ddd4512416ec43c2c2 ce064814f4a0d337b333e646ece456cd39fab612 silverwind <me@silverwind.io> 1668354026 +0100 push diff --git a/modules/git/tests/repos/repo1_bare/objects/0b/9f291245f6c596fd30bee925fe94fe0cbadd60 b/modules/git/tests/repos/repo1_bare/objects/0b/9f291245f6c596fd30bee925fe94fe0cbadd60 Binary files differnew file mode 100644 index 0000000..11de5ad --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/0b/9f291245f6c596fd30bee925fe94fe0cbadd60 diff --git a/modules/git/tests/repos/repo1_bare/objects/11/93ff46343f4f6a0522e2b28b871e905178c1f0 b/modules/git/tests/repos/repo1_bare/objects/11/93ff46343f4f6a0522e2b28b871e905178c1f0 Binary files differnew file mode 100644 index 0000000..3541cd1 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/11/93ff46343f4f6a0522e2b28b871e905178c1f0 diff --git a/modules/git/tests/repos/repo1_bare/objects/15/3f451b9ee7fa1da317ab17a127e9fd9d384310 b/modules/git/tests/repos/repo1_bare/objects/15/3f451b9ee7fa1da317ab17a127e9fd9d384310 Binary files differnew file mode 100644 index 0000000..8db3c79 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/15/3f451b9ee7fa1da317ab17a127e9fd9d384310 diff --git a/modules/git/tests/repos/repo1_bare/objects/18/4d49c75a0b202b1d2ea2fcb5861c329321fcd6 b/modules/git/tests/repos/repo1_bare/objects/18/4d49c75a0b202b1d2ea2fcb5861c329321fcd6 Binary files differnew file mode 100644 index 0000000..45e014e --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/18/4d49c75a0b202b1d2ea2fcb5861c329321fcd6 diff --git a/modules/git/tests/repos/repo1_bare/objects/1c/91d130dc5fb75fd2d9f586a058650889cfe7fb b/modules/git/tests/repos/repo1_bare/objects/1c/91d130dc5fb75fd2d9f586a058650889cfe7fb Binary files differnew file mode 100644 index 0000000..fb50b65 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/1c/91d130dc5fb75fd2d9f586a058650889cfe7fb diff --git a/modules/git/tests/repos/repo1_bare/objects/21/6bf54c2f2e2916b830ebe09e8c58a6ed52d86b b/modules/git/tests/repos/repo1_bare/objects/21/6bf54c2f2e2916b830ebe09e8c58a6ed52d86b Binary files differnew file mode 100644 index 0000000..8c0b1b3 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/21/6bf54c2f2e2916b830ebe09e8c58a6ed52d86b diff --git a/modules/git/tests/repos/repo1_bare/objects/28/345b214c5967bd9cdd98cc7f88f2f1ac574e02 b/modules/git/tests/repos/repo1_bare/objects/28/345b214c5967bd9cdd98cc7f88f2f1ac574e02 Binary files differnew file mode 100644 index 0000000..05dc472 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/28/345b214c5967bd9cdd98cc7f88f2f1ac574e02 diff --git a/modules/git/tests/repos/repo1_bare/objects/28/39944139e0de9737a044f78b0e4b40d989a9e3 b/modules/git/tests/repos/repo1_bare/objects/28/39944139e0de9737a044f78b0e4b40d989a9e3 new file mode 100644 index 0000000..e22e656 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/28/39944139e0de9737a044f78b0e4b40d989a9e3 @@ -0,0 +1 @@ +x…ÎÁ
Â0@QΙ"€â¸il që8Ž¨ÔB‚Ôñ©X€óÿ‡'¯u»ÆSoª6*摨"Ž,É“æTsI\I+r,|2[júì–…“V*…u>KT?P4ÂèRt@Á¤O¼šö´n‹Úû[›½Þ,À\`oÏŽœ3òõ£þ]ÍTz…Kß»ùe;ƒ
\ No newline at end of file diff --git a/modules/git/tests/repos/repo1_bare/objects/28/b55526e7100924d864dd89e35c1ea62e7a5a32 b/modules/git/tests/repos/repo1_bare/objects/28/b55526e7100924d864dd89e35c1ea62e7a5a32 Binary files differnew file mode 100644 index 0000000..7779599 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/28/b55526e7100924d864dd89e35c1ea62e7a5a32 diff --git a/modules/git/tests/repos/repo1_bare/objects/2e/65efe2a145dda7ee51d1741299f848e5bf752e b/modules/git/tests/repos/repo1_bare/objects/2e/65efe2a145dda7ee51d1741299f848e5bf752e Binary files differnew file mode 100644 index 0000000..3e46ba4 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/2e/65efe2a145dda7ee51d1741299f848e5bf752e diff --git a/modules/git/tests/repos/repo1_bare/objects/30/4c56b3bef33d0afeb8515ee803c839daf30ab8 b/modules/git/tests/repos/repo1_bare/objects/30/4c56b3bef33d0afeb8515ee803c839daf30ab8 Binary files differnew file mode 100644 index 0000000..3a5c6c1 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/30/4c56b3bef33d0afeb8515ee803c839daf30ab8 diff --git a/modules/git/tests/repos/repo1_bare/objects/34/d1da713bf7de1c535e1d7d3ca985afd84bc7e5 b/modules/git/tests/repos/repo1_bare/objects/34/d1da713bf7de1c535e1d7d3ca985afd84bc7e5 Binary files differnew file mode 100644 index 0000000..29f2d4f --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/34/d1da713bf7de1c535e1d7d3ca985afd84bc7e5 diff --git a/modules/git/tests/repos/repo1_bare/objects/36/f97d9a96457e2bab511db30fe2db03893ebc64 b/modules/git/tests/repos/repo1_bare/objects/36/f97d9a96457e2bab511db30fe2db03893ebc64 Binary files differnew file mode 100644 index 0000000..c96b843 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/36/f97d9a96457e2bab511db30fe2db03893ebc64 diff --git a/modules/git/tests/repos/repo1_bare/objects/37/991dec2c8e592043f47155ce4808d4580f9123 b/modules/git/tests/repos/repo1_bare/objects/37/991dec2c8e592043f47155ce4808d4580f9123 new file mode 100644 index 0000000..3658e95 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/37/991dec2c8e592043f47155ce4808d4580f9123 @@ -0,0 +1 @@ +xŽKN1YçÞ#ç3 !Øp.ØÎL‹™J›·§á,kñêû}5 PlªB÷5sKÔH|>ŸdíKÌKl kì% û¬S7ƒÜ›ä¢e¡Ó¢™c¥Î‰±çÆH‡§Señ®~ÙuLxŸëocì Óeµ—ý:D¾7µÓ˜—gð‰¢_Bñ=":þËüÝüSà^ETàø™·uûp?ƒ6M^
\ No newline at end of file diff --git a/modules/git/tests/repos/repo1_bare/objects/38/441bf2c4d4c27efff94728c9eb33266f44a702 b/modules/git/tests/repos/repo1_bare/objects/38/441bf2c4d4c27efff94728c9eb33266f44a702 Binary files differnew file mode 100644 index 0000000..c3d484a --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/38/441bf2c4d4c27efff94728c9eb33266f44a702 diff --git a/modules/git/tests/repos/repo1_bare/objects/3a/d28a9149a2864384548f3d17ed7f38014c9e8a b/modules/git/tests/repos/repo1_bare/objects/3a/d28a9149a2864384548f3d17ed7f38014c9e8a Binary files differnew file mode 100644 index 0000000..ee2652b --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/3a/d28a9149a2864384548f3d17ed7f38014c9e8a diff --git a/modules/git/tests/repos/repo1_bare/objects/4b/825dc642cb6eb9a060e54bf8d69288fbee4904 b/modules/git/tests/repos/repo1_bare/objects/4b/825dc642cb6eb9a060e54bf8d69288fbee4904 Binary files differnew file mode 100644 index 0000000..adf6411 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/4b/825dc642cb6eb9a060e54bf8d69288fbee4904 diff --git a/modules/git/tests/repos/repo1_bare/objects/50/13716a9da8e66ea21059a84f1b4311424d2b7f b/modules/git/tests/repos/repo1_bare/objects/50/13716a9da8e66ea21059a84f1b4311424d2b7f Binary files differnew file mode 100644 index 0000000..b969059 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/50/13716a9da8e66ea21059a84f1b4311424d2b7f diff --git a/modules/git/tests/repos/repo1_bare/objects/59/dfb0bb505a601006e31fed53d2e24e44fca9ca b/modules/git/tests/repos/repo1_bare/objects/59/dfb0bb505a601006e31fed53d2e24e44fca9ca Binary files differnew file mode 100644 index 0000000..1629271 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/59/dfb0bb505a601006e31fed53d2e24e44fca9ca diff --git a/modules/git/tests/repos/repo1_bare/objects/5c/80b0245c1c6f8343fa418ec374b13b5d4ee658 b/modules/git/tests/repos/repo1_bare/objects/5c/80b0245c1c6f8343fa418ec374b13b5d4ee658 new file mode 100644 index 0000000..234d41b --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/5c/80b0245c1c6f8343fa418ec374b13b5d4ee658 @@ -0,0 +1,3 @@ +x…Ž; +1@sŠ¹€:Éf7ÁÂ#x€ÉL‚‚û!FØ㻈½Õ+Þ+žÌãøhàÐíZÍb—H¼²²ö„‘J<J™…B +VK6×<5ˆJ®õ½)J1Iú‚)¤d#1ê Tœáw»Ï®+Ë3Ãí•+œÎ`{»å;„=FD#ß¡¶Ù¿©¹¨Bª<ÉÝ<´µ™3ã>=
\ No newline at end of file diff --git a/modules/git/tests/repos/repo1_bare/objects/62/d735f9efa9cf5b7df6bac9917b80e4779f4315 b/modules/git/tests/repos/repo1_bare/objects/62/d735f9efa9cf5b7df6bac9917b80e4779f4315 Binary files differnew file mode 100644 index 0000000..15b958a --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/62/d735f9efa9cf5b7df6bac9917b80e4779f4315 diff --git a/modules/git/tests/repos/repo1_bare/objects/64/3a35374408002fcf2f0e8d42d262a1e0e2f80e b/modules/git/tests/repos/repo1_bare/objects/64/3a35374408002fcf2f0e8d42d262a1e0e2f80e Binary files differnew file mode 100644 index 0000000..eb0ad47 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/64/3a35374408002fcf2f0e8d42d262a1e0e2f80e diff --git a/modules/git/tests/repos/repo1_bare/objects/6c/493ff740f9380390d5c9ddef4af18697ac9375 b/modules/git/tests/repos/repo1_bare/objects/6c/493ff740f9380390d5c9ddef4af18697ac9375 Binary files differnew file mode 100644 index 0000000..7d217a7 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/6c/493ff740f9380390d5c9ddef4af18697ac9375 diff --git a/modules/git/tests/repos/repo1_bare/objects/6f/bd69e9823458e6c4a2fc5c0f6bc022b2f2acd1 b/modules/git/tests/repos/repo1_bare/objects/6f/bd69e9823458e6c4a2fc5c0f6bc022b2f2acd1 new file mode 100644 index 0000000..5b2cfb2 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/6f/bd69e9823458e6c4a2fc5c0f6bc022b2f2acd1 @@ -0,0 +1 @@ +xŽ1nÃ0E3ëÜ”-™&ÉÒô’H&F+Õ¡·oÚ#døÃÞÃ/õñX:ÁïzS…i£±Zâb1“Ø”Saö”gÔ@ÄFÝ35];̈“'Ɇ%sD’„5ŽôÚìÉØR,èÒw¿Ö_mÙ೶kƒCÑþ²ôÓv"?«ö}m—#ø8"Í#|xDtåÿæŸófÀET ·zÓîËzÛÜ/—þN°
\ No newline at end of file diff --git a/modules/git/tests/repos/repo1_bare/objects/7e/3b688f3369ca28ebafbda9f8ef39713dd12fc8 b/modules/git/tests/repos/repo1_bare/objects/7e/3b688f3369ca28ebafbda9f8ef39713dd12fc8 Binary files differnew file mode 100644 index 0000000..113089d --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/7e/3b688f3369ca28ebafbda9f8ef39713dd12fc8 diff --git a/modules/git/tests/repos/repo1_bare/objects/80/06ff9adbf0cb94da7dad9e537e53817f9fa5c0 b/modules/git/tests/repos/repo1_bare/objects/80/06ff9adbf0cb94da7dad9e537e53817f9fa5c0 Binary files differnew file mode 100644 index 0000000..808fe6e --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/80/06ff9adbf0cb94da7dad9e537e53817f9fa5c0 diff --git a/modules/git/tests/repos/repo1_bare/objects/82/26f571dcc2d2f33a7179d929b10b9c39faa631 b/modules/git/tests/repos/repo1_bare/objects/82/26f571dcc2d2f33a7179d929b10b9c39faa631 Binary files differnew file mode 100644 index 0000000..6a194fb --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/82/26f571dcc2d2f33a7179d929b10b9c39faa631 diff --git a/modules/git/tests/repos/repo1_bare/objects/83/b9c4da46ed59098a009f8640c77eac97b71dfe b/modules/git/tests/repos/repo1_bare/objects/83/b9c4da46ed59098a009f8640c77eac97b71dfe Binary files differnew file mode 100644 index 0000000..8602ab5 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/83/b9c4da46ed59098a009f8640c77eac97b71dfe diff --git a/modules/git/tests/repos/repo1_bare/objects/8d/92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2 b/modules/git/tests/repos/repo1_bare/objects/8d/92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2 Binary files differnew file mode 100644 index 0000000..431a481 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/8d/92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2 diff --git a/modules/git/tests/repos/repo1_bare/objects/93/3305878a3c9ad485c29b87fb662a73a9675c4b b/modules/git/tests/repos/repo1_bare/objects/93/3305878a3c9ad485c29b87fb662a73a9675c4b Binary files differnew file mode 100644 index 0000000..e198e76 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/93/3305878a3c9ad485c29b87fb662a73a9675c4b diff --git a/modules/git/tests/repos/repo1_bare/objects/95/bb4d39648ee7e325106df01a621c530863a653 b/modules/git/tests/repos/repo1_bare/objects/95/bb4d39648ee7e325106df01a621c530863a653 Binary files differnew file mode 100644 index 0000000..6bb6a25 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/95/bb4d39648ee7e325106df01a621c530863a653 diff --git a/modules/git/tests/repos/repo1_bare/objects/98/1ff127cc331753bba28e1377c35934f1ca9b56 b/modules/git/tests/repos/repo1_bare/objects/98/1ff127cc331753bba28e1377c35934f1ca9b56 Binary files differnew file mode 100644 index 0000000..ae6c93a --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/98/1ff127cc331753bba28e1377c35934f1ca9b56 diff --git a/modules/git/tests/repos/repo1_bare/objects/9c/9aef8dd84e02bc7ec12641deb4c930a7c30185 b/modules/git/tests/repos/repo1_bare/objects/9c/9aef8dd84e02bc7ec12641deb4c930a7c30185 new file mode 100644 index 0000000..8a263d0 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/9c/9aef8dd84e02bc7ec12641deb4c930a7c30185 @@ -0,0 +1,2 @@ +x…ÎM +1@a×=E. ¤“¦Ó‚.<‚HÛ”œj…9¾â\¿oñò:Ï6ºCoª@è2ûDI+QA©š[V
H9P,R %³IÓ¥Cä”\¡è]P•¶èKE+~°™ ƒ'ñLFÞ}ZÜv™·§Âý¥
ΰliddáˆÑäßPÿÖ¿Ô\KÔdÉ“=õ½›Ü²:c
\ No newline at end of file diff --git a/modules/git/tests/repos/repo1_bare/objects/a4/79ead1abb694ffca26f67b09c8313b12fa2a13 b/modules/git/tests/repos/repo1_bare/objects/a4/79ead1abb694ffca26f67b09c8313b12fa2a13 Binary files differnew file mode 100644 index 0000000..35d27dc --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/a4/79ead1abb694ffca26f67b09c8313b12fa2a13 diff --git a/modules/git/tests/repos/repo1_bare/objects/b1/4df6442ea5a1b382985a6549b85d435376c351 b/modules/git/tests/repos/repo1_bare/objects/b1/4df6442ea5a1b382985a6549b85d435376c351 Binary files differnew file mode 100644 index 0000000..02fe24f --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/b1/4df6442ea5a1b382985a6549b85d435376c351 diff --git a/modules/git/tests/repos/repo1_bare/objects/b1/fc9917b618c924cf4aa421dae74e8bf9b556d3 b/modules/git/tests/repos/repo1_bare/objects/b1/fc9917b618c924cf4aa421dae74e8bf9b556d3 Binary files differnew file mode 100644 index 0000000..aacc5ef --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/b1/fc9917b618c924cf4aa421dae74e8bf9b556d3 diff --git a/modules/git/tests/repos/repo1_bare/objects/b7/5f44edbd9252c32bf9faa0c1257ffb3b126c24 b/modules/git/tests/repos/repo1_bare/objects/b7/5f44edbd9252c32bf9faa0c1257ffb3b126c24 Binary files differnew file mode 100644 index 0000000..6c2e007 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/b7/5f44edbd9252c32bf9faa0c1257ffb3b126c24 diff --git a/modules/git/tests/repos/repo1_bare/objects/c8/c90111bdc18b3afd2b2906007059e95ac8fdc3 b/modules/git/tests/repos/repo1_bare/objects/c8/c90111bdc18b3afd2b2906007059e95ac8fdc3 Binary files differnew file mode 100644 index 0000000..57c5d7c --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/c8/c90111bdc18b3afd2b2906007059e95ac8fdc3 diff --git a/modules/git/tests/repos/repo1_bare/objects/ca/6b5ddf303169a72d2a2971acde4f6eea194e5c b/modules/git/tests/repos/repo1_bare/objects/ca/6b5ddf303169a72d2a2971acde4f6eea194e5c new file mode 100644 index 0000000..d4c2138 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/ca/6b5ddf303169a72d2a2971acde4f6eea194e5c @@ -0,0 +1,4 @@ +x¥ŽM +Â0F]ç³ëB&&m"ž@\¹Of¦6ÐHG¥··ô +~Ë·xïÃy³€Ñþ …Œ?[—Œ¶èBÓ& +H<bÛyß™NGtåÚ¨ø–~.ð"å1xÄIx`þÀå•å&=㚸,}¤ù{šX® ó¶ p¬·)ÜãÂjÔ}^ 1AZ¡ÚÀ´3¦,•ú½ÀI0
\ No newline at end of file diff --git a/modules/git/tests/repos/repo1_bare/objects/ce/064814f4a0d337b333e646ece456cd39fab612 b/modules/git/tests/repos/repo1_bare/objects/ce/064814f4a0d337b333e646ece456cd39fab612 Binary files differnew file mode 100644 index 0000000..93f1525 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/ce/064814f4a0d337b333e646ece456cd39fab612 diff --git a/modules/git/tests/repos/repo1_bare/objects/cf/8b0b492a950b358a7ce7f9d01b18aef48a6b2d b/modules/git/tests/repos/repo1_bare/objects/cf/8b0b492a950b358a7ce7f9d01b18aef48a6b2d Binary files differnew file mode 100644 index 0000000..1152b25 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/cf/8b0b492a950b358a7ce7f9d01b18aef48a6b2d diff --git a/modules/git/tests/repos/repo1_bare/objects/d0/845fe2f85710b50d673dafe98236bf9f2023da b/modules/git/tests/repos/repo1_bare/objects/d0/845fe2f85710b50d673dafe98236bf9f2023da Binary files differnew file mode 100644 index 0000000..d29ca5a --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/d0/845fe2f85710b50d673dafe98236bf9f2023da diff --git a/modules/git/tests/repos/repo1_bare/objects/e2/129701f1a4d54dc44f03c93bca0a2aec7c5449 b/modules/git/tests/repos/repo1_bare/objects/e2/129701f1a4d54dc44f03c93bca0a2aec7c5449 Binary files differnew file mode 100644 index 0000000..08245d0 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/e2/129701f1a4d54dc44f03c93bca0a2aec7c5449 diff --git a/modules/git/tests/repos/repo1_bare/objects/f1/a6cb52b2d16773290cefe49ad0684b50a4f930 b/modules/git/tests/repos/repo1_bare/objects/f1/a6cb52b2d16773290cefe49ad0684b50a4f930 Binary files differnew file mode 100644 index 0000000..6a412c7 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/f1/a6cb52b2d16773290cefe49ad0684b50a4f930 diff --git a/modules/git/tests/repos/repo1_bare/objects/fe/af4ba6bc635fec442f46ddd4512416ec43c2c2 b/modules/git/tests/repos/repo1_bare/objects/fe/af4ba6bc635fec442f46ddd4512416ec43c2c2 Binary files differnew file mode 100644 index 0000000..95edd9a --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/objects/fe/af4ba6bc635fec442f46ddd4512416ec43c2c2 diff --git a/modules/git/tests/repos/repo1_bare/pulls/1.patch b/modules/git/tests/repos/repo1_bare/pulls/1.patch new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/pulls/1.patch diff --git a/modules/git/tests/repos/repo1_bare/pulls/2.patch b/modules/git/tests/repos/repo1_bare/pulls/2.patch new file mode 100644 index 0000000..caab605 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/pulls/2.patch @@ -0,0 +1,39 @@ +From 6e8e2a6f9efd71dbe6917816343ed8415ad696c3 Mon Sep 17 00:00:00 2001 +From: 99rgosse <renaud@mycompany.com> +Date: Fri, 26 Mar 2021 12:44:22 +0000 +Subject: [PATCH] Update gitea_import_actions.py + +--- + gitea_import_actions.py | 6 +++--- + 1 file changed, 3 insertions(+), 3 deletions(-) + +diff --git a/gitea_import_actions.py b/gitea_import_actions.py +index f0d72cd..7b31963 100644 +--- a/gitea_import_actions.py ++++ b/gitea_import_actions.py +@@ -3,14 +3,14 @@ + # git log --pretty=format:'%H,%at,%s' --date=default > /tmp/commit.log + # to get the commits logfile for a repository + +-import mysql.connector as mariadb ++import psycopg2 + + # set the following variables to fit your need... + USERID = 1 + REPOID = 1 + BRANCH = "master" + +-mydb = mariadb.connect( ++mydb = psycopg2.connect( + host="localhost", + user="user", + passwd="password", +@@ -31,4 +31,4 @@ with open("/tmp/commit.log") as f: + + mydb.commit() + +-print("actions inserted.") +\ No newline at end of file ++print("actions inserted.") +-- +GitLab diff --git a/modules/git/tests/repos/repo1_bare/refs/heads/branch1 b/modules/git/tests/repos/repo1_bare/refs/heads/branch1 new file mode 100644 index 0000000..eb33bd0 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/refs/heads/branch1 @@ -0,0 +1 @@ +2839944139e0de9737a044f78b0e4b40d989a9e3 diff --git a/modules/git/tests/repos/repo1_bare/refs/heads/branch2 b/modules/git/tests/repos/repo1_bare/refs/heads/branch2 new file mode 100644 index 0000000..0475e61 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/refs/heads/branch2 @@ -0,0 +1 @@ +5c80b0245c1c6f8343fa418ec374b13b5d4ee658 diff --git a/modules/git/tests/repos/repo1_bare/refs/heads/master b/modules/git/tests/repos/repo1_bare/refs/heads/master new file mode 100644 index 0000000..9b0de22 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/refs/heads/master @@ -0,0 +1 @@ +ce064814f4a0d337b333e646ece456cd39fab612 diff --git a/modules/git/tests/repos/repo1_bare/refs/notes/commits b/modules/git/tests/repos/repo1_bare/refs/notes/commits new file mode 100644 index 0000000..c88ca21 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/refs/notes/commits @@ -0,0 +1 @@ +ca6b5ddf303169a72d2a2971acde4f6eea194e5c diff --git a/modules/git/tests/repos/repo1_bare/refs/tags/signed-tag b/modules/git/tests/repos/repo1_bare/refs/tags/signed-tag new file mode 100644 index 0000000..3998a68 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/refs/tags/signed-tag @@ -0,0 +1 @@ +36f97d9a96457e2bab511db30fe2db03893ebc64 diff --git a/modules/git/tests/repos/repo1_bare/refs/tags/test b/modules/git/tests/repos/repo1_bare/refs/tags/test new file mode 100644 index 0000000..ee31172 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare/refs/tags/test @@ -0,0 +1 @@ +3ad28a9149a2864384548f3d17ed7f38014c9e8a diff --git a/modules/git/tests/repos/repo1_bare_sha256/HEAD b/modules/git/tests/repos/repo1_bare_sha256/HEAD new file mode 100644 index 0000000..b870d82 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare_sha256/HEAD @@ -0,0 +1 @@ +ref: refs/heads/main diff --git a/modules/git/tests/repos/repo1_bare_sha256/config b/modules/git/tests/repos/repo1_bare_sha256/config new file mode 100644 index 0000000..2388a50 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare_sha256/config @@ -0,0 +1,6 @@ +[core] + repositoryformatversion = 1 + filemode = true + bare = true +[extensions] + objectformat = sha256 diff --git a/modules/git/tests/repos/repo1_bare_sha256/description b/modules/git/tests/repos/repo1_bare_sha256/description new file mode 100644 index 0000000..498b267 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare_sha256/description @@ -0,0 +1 @@ +Unnamed repository; edit this file 'description' to name the repository. diff --git a/modules/git/tests/repos/repo1_bare_sha256/info/exclude b/modules/git/tests/repos/repo1_bare_sha256/info/exclude new file mode 100644 index 0000000..a5196d1 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare_sha256/info/exclude @@ -0,0 +1,6 @@ +# git ls-files --others --exclude-from=.git/info/exclude +# Lines that start with '#' are comments. +# For a project mostly in C, the following would be a good set of +# exclude patterns (uncomment them if you want to use them): +# *.[oa] +# *~ diff --git a/modules/git/tests/repos/repo1_bare_sha256/info/refs b/modules/git/tests/repos/repo1_bare_sha256/info/refs new file mode 100644 index 0000000..b4de954 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare_sha256/info/refs @@ -0,0 +1,7 @@ +42e334efd04cd36eea6da0599913333c26116e1a537ca76e5b6e4af4dda00236 refs/heads/branch1 +5bc2249e32e0ba40a08879fba2bd4e97a13cb345831549f4bc5649525da8f6cc refs/heads/branch2 +9433b2a62b964c17a4485ae180f45f595d3e69d31b786087775e28c6b6399df0 refs/heads/main +29a82d4fc02e19190fb489cc90d5730ed91970b49f4e39acda2798b3dd4f814e refs/tags/signed-tag +9433b2a62b964c17a4485ae180f45f595d3e69d31b786087775e28c6b6399df0 refs/tags/signed-tag^{} +171822a62559f3aa28a00aa3785dbe915d6a8eb02712682740db44fc8bd2187a refs/tags/test +6aae864a3d1d0d6a5be0cc64028c1e7021e2632b031fd8eb82afc5a283d1c3d1 refs/tags/test^{} diff --git a/modules/git/tests/repos/repo1_bare_sha256/objects/info/commit-graph b/modules/git/tests/repos/repo1_bare_sha256/objects/info/commit-graph Binary files differnew file mode 100644 index 0000000..2985d3e --- /dev/null +++ b/modules/git/tests/repos/repo1_bare_sha256/objects/info/commit-graph diff --git a/modules/git/tests/repos/repo1_bare_sha256/objects/info/packs b/modules/git/tests/repos/repo1_bare_sha256/objects/info/packs new file mode 100644 index 0000000..c2d1bb8 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare_sha256/objects/info/packs @@ -0,0 +1,2 @@ +P pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.pack + diff --git a/modules/git/tests/repos/repo1_bare_sha256/objects/pack/pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.bitmap b/modules/git/tests/repos/repo1_bare_sha256/objects/pack/pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.bitmap Binary files differnew file mode 100644 index 0000000..535ba16 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare_sha256/objects/pack/pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.bitmap diff --git a/modules/git/tests/repos/repo1_bare_sha256/objects/pack/pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.idx b/modules/git/tests/repos/repo1_bare_sha256/objects/pack/pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.idx Binary files differnew file mode 100644 index 0000000..ab45b6f --- /dev/null +++ b/modules/git/tests/repos/repo1_bare_sha256/objects/pack/pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.idx diff --git a/modules/git/tests/repos/repo1_bare_sha256/objects/pack/pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.pack b/modules/git/tests/repos/repo1_bare_sha256/objects/pack/pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.pack Binary files differnew file mode 100644 index 0000000..c77bf20 --- /dev/null +++ b/modules/git/tests/repos/repo1_bare_sha256/objects/pack/pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.pack diff --git a/modules/git/tests/repos/repo1_bare_sha256/objects/pack/pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.rev b/modules/git/tests/repos/repo1_bare_sha256/objects/pack/pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.rev Binary files differnew file mode 100644 index 0000000..d24fd8e --- /dev/null +++ b/modules/git/tests/repos/repo1_bare_sha256/objects/pack/pack-c01aa121b9c5e345fe0da2f9be78665970b0c38c6b495d5fc034bc7a7b95334b.rev diff --git a/modules/git/tests/repos/repo1_bare_sha256/packed-refs b/modules/git/tests/repos/repo1_bare_sha256/packed-refs new file mode 100644 index 0000000..36c92ce --- /dev/null +++ b/modules/git/tests/repos/repo1_bare_sha256/packed-refs @@ -0,0 +1,8 @@ +# pack-refs with: peeled fully-peeled sorted +42e334efd04cd36eea6da0599913333c26116e1a537ca76e5b6e4af4dda00236 refs/heads/branch1 +5bc2249e32e0ba40a08879fba2bd4e97a13cb345831549f4bc5649525da8f6cc refs/heads/branch2 +9433b2a62b964c17a4485ae180f45f595d3e69d31b786087775e28c6b6399df0 refs/heads/main +29a82d4fc02e19190fb489cc90d5730ed91970b49f4e39acda2798b3dd4f814e refs/tags/signed-tag +^9433b2a62b964c17a4485ae180f45f595d3e69d31b786087775e28c6b6399df0 +171822a62559f3aa28a00aa3785dbe915d6a8eb02712682740db44fc8bd2187a refs/tags/test +^6aae864a3d1d0d6a5be0cc64028c1e7021e2632b031fd8eb82afc5a283d1c3d1 diff --git a/modules/git/tests/repos/repo1_bare_sha256/refs/heads/main b/modules/git/tests/repos/repo1_bare_sha256/refs/heads/main new file mode 100644 index 0000000..b09fd5c --- /dev/null +++ b/modules/git/tests/repos/repo1_bare_sha256/refs/heads/main @@ -0,0 +1 @@ +9433b2a62b964c17a4485ae180f45f595d3e69d31b786087775e28c6b6399df0 diff --git a/modules/git/tests/repos/repo2_empty/HEAD b/modules/git/tests/repos/repo2_empty/HEAD new file mode 100644 index 0000000..cb089cd --- /dev/null +++ b/modules/git/tests/repos/repo2_empty/HEAD @@ -0,0 +1 @@ +ref: refs/heads/master diff --git a/modules/git/tests/repos/repo2_empty/config b/modules/git/tests/repos/repo2_empty/config new file mode 100644 index 0000000..e6da231 --- /dev/null +++ b/modules/git/tests/repos/repo2_empty/config @@ -0,0 +1,6 @@ +[core] + repositoryformatversion = 0 + filemode = true + bare = true + ignorecase = true + precomposeunicode = true diff --git a/modules/git/tests/repos/repo2_empty/description b/modules/git/tests/repos/repo2_empty/description new file mode 100644 index 0000000..498b267 --- /dev/null +++ b/modules/git/tests/repos/repo2_empty/description @@ -0,0 +1 @@ +Unnamed repository; edit this file 'description' to name the repository. diff --git a/modules/git/tests/repos/repo2_empty/info/exclude b/modules/git/tests/repos/repo2_empty/info/exclude new file mode 100644 index 0000000..a5196d1 --- /dev/null +++ b/modules/git/tests/repos/repo2_empty/info/exclude @@ -0,0 +1,6 @@ +# git ls-files --others --exclude-from=.git/info/exclude +# Lines that start with '#' are comments. +# For a project mostly in C, the following would be a good set of +# exclude patterns (uncomment them if you want to use them): +# *.[oa] +# *~ diff --git a/modules/git/tests/repos/repo2_empty/objects/info/.gitkeep b/modules/git/tests/repos/repo2_empty/objects/info/.gitkeep new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/modules/git/tests/repos/repo2_empty/objects/info/.gitkeep diff --git a/modules/git/tests/repos/repo2_empty/objects/pack/.gitkeep b/modules/git/tests/repos/repo2_empty/objects/pack/.gitkeep new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/modules/git/tests/repos/repo2_empty/objects/pack/.gitkeep diff --git a/modules/git/tests/repos/repo2_empty/refs/heads/.gitkeep b/modules/git/tests/repos/repo2_empty/refs/heads/.gitkeep new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/modules/git/tests/repos/repo2_empty/refs/heads/.gitkeep diff --git a/modules/git/tests/repos/repo2_empty/refs/tags/.gitkeep b/modules/git/tests/repos/repo2_empty/refs/tags/.gitkeep new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/modules/git/tests/repos/repo2_empty/refs/tags/.gitkeep diff --git a/modules/git/tests/repos/repo3_notes/COMMIT_EDITMSG b/modules/git/tests/repos/repo3_notes/COMMIT_EDITMSG new file mode 100644 index 0000000..0cfbf08 --- /dev/null +++ b/modules/git/tests/repos/repo3_notes/COMMIT_EDITMSG @@ -0,0 +1 @@ +2 diff --git a/modules/git/tests/repos/repo3_notes/HEAD b/modules/git/tests/repos/repo3_notes/HEAD new file mode 100644 index 0000000..cb089cd --- /dev/null +++ b/modules/git/tests/repos/repo3_notes/HEAD @@ -0,0 +1 @@ +ref: refs/heads/master diff --git a/modules/git/tests/repos/repo3_notes/config b/modules/git/tests/repos/repo3_notes/config new file mode 100644 index 0000000..d545cda --- /dev/null +++ b/modules/git/tests/repos/repo3_notes/config @@ -0,0 +1,7 @@ +[core] + repositoryformatversion = 0 + filemode = false + bare = false + logallrefupdates = true + symlinks = false + ignorecase = true diff --git a/modules/git/tests/repos/repo3_notes/description b/modules/git/tests/repos/repo3_notes/description new file mode 100644 index 0000000..498b267 --- /dev/null +++ b/modules/git/tests/repos/repo3_notes/description @@ -0,0 +1 @@ +Unnamed repository; edit this file 'description' to name the repository. diff --git a/modules/git/tests/repos/repo3_notes/index b/modules/git/tests/repos/repo3_notes/index Binary files differnew file mode 100644 index 0000000..783158b --- /dev/null +++ b/modules/git/tests/repos/repo3_notes/index diff --git a/modules/git/tests/repos/repo3_notes/logs/HEAD b/modules/git/tests/repos/repo3_notes/logs/HEAD new file mode 100644 index 0000000..4bd0a61 --- /dev/null +++ b/modules/git/tests/repos/repo3_notes/logs/HEAD @@ -0,0 +1,2 @@ +0000000000000000000000000000000000000000 ba0a96fa63532d6c5087ecef070b0250ed72fa47 Filip Navara <filip.navara@gmail.com> 1567767895 +0200 commit (initial): 1 +ba0a96fa63532d6c5087ecef070b0250ed72fa47 3e668dbfac39cbc80a9ff9c61eb565d944453ba4 Filip Navara <filip.navara@gmail.com> 1567767909 +0200 commit: 2 diff --git a/modules/git/tests/repos/repo3_notes/logs/refs/heads/master b/modules/git/tests/repos/repo3_notes/logs/refs/heads/master new file mode 100644 index 0000000..4bd0a61 --- /dev/null +++ b/modules/git/tests/repos/repo3_notes/logs/refs/heads/master @@ -0,0 +1,2 @@ +0000000000000000000000000000000000000000 ba0a96fa63532d6c5087ecef070b0250ed72fa47 Filip Navara <filip.navara@gmail.com> 1567767895 +0200 commit (initial): 1 +ba0a96fa63532d6c5087ecef070b0250ed72fa47 3e668dbfac39cbc80a9ff9c61eb565d944453ba4 Filip Navara <filip.navara@gmail.com> 1567767909 +0200 commit: 2 diff --git a/modules/git/tests/repos/repo3_notes/objects/29/7128d6553180486c780e2f747cb6d0014bf1f6 b/modules/git/tests/repos/repo3_notes/objects/29/7128d6553180486c780e2f747cb6d0014bf1f6 Binary files differnew file mode 100644 index 0000000..96fb749 --- /dev/null +++ b/modules/git/tests/repos/repo3_notes/objects/29/7128d6553180486c780e2f747cb6d0014bf1f6 diff --git a/modules/git/tests/repos/repo3_notes/objects/2f/7e2ea1e905c14c8a98e7ce47b395592834b9ef b/modules/git/tests/repos/repo3_notes/objects/2f/7e2ea1e905c14c8a98e7ce47b395592834b9ef Binary files differnew file mode 100644 index 0000000..71cff17 --- /dev/null +++ b/modules/git/tests/repos/repo3_notes/objects/2f/7e2ea1e905c14c8a98e7ce47b395592834b9ef diff --git a/modules/git/tests/repos/repo3_notes/objects/3e/668dbfac39cbc80a9ff9c61eb565d944453ba4 b/modules/git/tests/repos/repo3_notes/objects/3e/668dbfac39cbc80a9ff9c61eb565d944453ba4 new file mode 100644 index 0000000..8f13b31 --- /dev/null +++ b/modules/git/tests/repos/repo3_notes/objects/3e/668dbfac39cbc80a9ff9c61eb565d944453ba4 @@ -0,0 +1,3 @@ +xŽ;Â0@™s +ïH•ã&v*!ÄÄÈœ4Jô£(p~ +G`|oxziç©‘;´š3Ð –ÂÈÞ÷6 œ$`¦"NRäѺXla³iÍKƒ¨¨åÞ÷4rò$§\P0"yÌ£PQ'F_í±V¸NÏiƒ›¾µ*œÊ—ºåG—û¬Ó³Kë|ëY„eÀŽHˆf·ûfË™ÜãEm
\ No newline at end of file diff --git a/modules/git/tests/repos/repo3_notes/objects/42/716fdb6f261867472899d785123e6ecaa5ca02 b/modules/git/tests/repos/repo3_notes/objects/42/716fdb6f261867472899d785123e6ecaa5ca02 Binary files differnew file mode 100644 index 0000000..3d522eb --- /dev/null +++ b/modules/git/tests/repos/repo3_notes/objects/42/716fdb6f261867472899d785123e6ecaa5ca02 diff --git a/modules/git/tests/repos/repo3_notes/objects/56/a6051ca2b02b04ef92d5150c9ef600403cb1de b/modules/git/tests/repos/repo3_notes/objects/56/a6051ca2b02b04ef92d5150c9ef600403cb1de Binary files differnew file mode 100644 index 0000000..b17dfe3 --- /dev/null +++ b/modules/git/tests/repos/repo3_notes/objects/56/a6051ca2b02b04ef92d5150c9ef600403cb1de diff --git a/modules/git/tests/repos/repo3_notes/objects/61/6c62e75fce60d806f4afe993211705a00a2544 b/modules/git/tests/repos/repo3_notes/objects/61/6c62e75fce60d806f4afe993211705a00a2544 Binary files differnew file mode 100644 index 0000000..b09d3a2 --- /dev/null +++ b/modules/git/tests/repos/repo3_notes/objects/61/6c62e75fce60d806f4afe993211705a00a2544 diff --git a/modules/git/tests/repos/repo3_notes/objects/65/4c8b6b63c08bf37f638d3f521626b7fbbd4d37 b/modules/git/tests/repos/repo3_notes/objects/65/4c8b6b63c08bf37f638d3f521626b7fbbd4d37 new file mode 100644 index 0000000..0d317cb --- /dev/null +++ b/modules/git/tests/repos/repo3_notes/objects/65/4c8b6b63c08bf37f638d3f521626b7fbbd4d37 @@ -0,0 +1 @@ +x;Â0©}Ší‘"Ç¿u$„RQr‡•ÙK1F–Éù1nfŠ÷R-%w˜Ñzcá´{ä%7À¢h#Ñx¶ˆÉQ¤àéfXÑ»?jƒKÞò®´S#8ÉצçÏÖ{¡¼M©–3Ì> †¨…£6Z«QÇmç¿Ôÿ8Â
\ No newline at end of file diff --git a/modules/git/tests/repos/repo3_notes/objects/ba/0a96fa63532d6c5087ecef070b0250ed72fa47 b/modules/git/tests/repos/repo3_notes/objects/ba/0a96fa63532d6c5087ecef070b0250ed72fa47 Binary files differnew file mode 100644 index 0000000..c21f2b2 --- /dev/null +++ b/modules/git/tests/repos/repo3_notes/objects/ba/0a96fa63532d6c5087ecef070b0250ed72fa47 diff --git a/modules/git/tests/repos/repo3_notes/objects/c9/34d51cee361fdee21a3f3bb1a285f5ea9bc225 b/modules/git/tests/repos/repo3_notes/objects/c9/34d51cee361fdee21a3f3bb1a285f5ea9bc225 Binary files differnew file mode 100644 index 0000000..f5a8caa --- /dev/null +++ b/modules/git/tests/repos/repo3_notes/objects/c9/34d51cee361fdee21a3f3bb1a285f5ea9bc225 diff --git a/modules/git/tests/repos/repo3_notes/objects/d8/263ee9860594d2806b0dfd1bfd17528b0ba2a4 b/modules/git/tests/repos/repo3_notes/objects/d8/263ee9860594d2806b0dfd1bfd17528b0ba2a4 Binary files differnew file mode 100644 index 0000000..4b1baef --- /dev/null +++ b/modules/git/tests/repos/repo3_notes/objects/d8/263ee9860594d2806b0dfd1bfd17528b0ba2a4 diff --git a/modules/git/tests/repos/repo3_notes/objects/f3/6ad903e408cb8f4ed90bda02e3a1fd2fab7907 b/modules/git/tests/repos/repo3_notes/objects/f3/6ad903e408cb8f4ed90bda02e3a1fd2fab7907 Binary files differnew file mode 100644 index 0000000..dc2af77 --- /dev/null +++ b/modules/git/tests/repos/repo3_notes/objects/f3/6ad903e408cb8f4ed90bda02e3a1fd2fab7907 diff --git a/modules/git/tests/repos/repo3_notes/objects/fe/c9fe57e9864fe537f02f825e377c4a8a65ad2e b/modules/git/tests/repos/repo3_notes/objects/fe/c9fe57e9864fe537f02f825e377c4a8a65ad2e Binary files differnew file mode 100644 index 0000000..6372ff1 --- /dev/null +++ b/modules/git/tests/repos/repo3_notes/objects/fe/c9fe57e9864fe537f02f825e377c4a8a65ad2e diff --git a/modules/git/tests/repos/repo3_notes/refs/heads/master b/modules/git/tests/repos/repo3_notes/refs/heads/master new file mode 100644 index 0000000..e96af8d --- /dev/null +++ b/modules/git/tests/repos/repo3_notes/refs/heads/master @@ -0,0 +1 @@ +3e668dbfac39cbc80a9ff9c61eb565d944453ba4
\ No newline at end of file diff --git a/modules/git/tests/repos/repo3_notes/refs/notes/commits b/modules/git/tests/repos/repo3_notes/refs/notes/commits new file mode 100644 index 0000000..74e3d3a --- /dev/null +++ b/modules/git/tests/repos/repo3_notes/refs/notes/commits @@ -0,0 +1 @@ +654c8b6b63c08bf37f638d3f521626b7fbbd4d37
\ No newline at end of file diff --git a/modules/git/tests/repos/repo4_commitsbetween/HEAD b/modules/git/tests/repos/repo4_commitsbetween/HEAD new file mode 100644 index 0000000..b870d82 --- /dev/null +++ b/modules/git/tests/repos/repo4_commitsbetween/HEAD @@ -0,0 +1 @@ +ref: refs/heads/main diff --git a/modules/git/tests/repos/repo4_commitsbetween/config b/modules/git/tests/repos/repo4_commitsbetween/config new file mode 100644 index 0000000..d545cda --- /dev/null +++ b/modules/git/tests/repos/repo4_commitsbetween/config @@ -0,0 +1,7 @@ +[core] + repositoryformatversion = 0 + filemode = false + bare = false + logallrefupdates = true + symlinks = false + ignorecase = true diff --git a/modules/git/tests/repos/repo4_commitsbetween/logs/HEAD b/modules/git/tests/repos/repo4_commitsbetween/logs/HEAD new file mode 100644 index 0000000..24cc684 --- /dev/null +++ b/modules/git/tests/repos/repo4_commitsbetween/logs/HEAD @@ -0,0 +1,4 @@ +0000000000000000000000000000000000000000 fdc1b615bdcff0f0658b216df0c9209e5ecb7c78 KN4CK3R <admin@oldschoolhack.me> 1624915979 +0200 commit (initial): com1 +fdc1b615bdcff0f0658b216df0c9209e5ecb7c78 78a445db1eac62fe15e624e1137965969addf344 KN4CK3R <admin@oldschoolhack.me> 1624915993 +0200 commit: com2 +78a445db1eac62fe15e624e1137965969addf344 fdc1b615bdcff0f0658b216df0c9209e5ecb7c78 KN4CK3R <admin@oldschoolhack.me> 1624916008 +0200 reset: moving to HEAD~1 +fdc1b615bdcff0f0658b216df0c9209e5ecb7c78 a78e5638b66ccfe7e1b4689d3d5684e42c97d7ca KN4CK3R <admin@oldschoolhack.me> 1624916029 +0200 commit: com2_new diff --git a/modules/git/tests/repos/repo4_commitsbetween/logs/refs/heads/main b/modules/git/tests/repos/repo4_commitsbetween/logs/refs/heads/main new file mode 100644 index 0000000..24cc684 --- /dev/null +++ b/modules/git/tests/repos/repo4_commitsbetween/logs/refs/heads/main @@ -0,0 +1,4 @@ +0000000000000000000000000000000000000000 fdc1b615bdcff0f0658b216df0c9209e5ecb7c78 KN4CK3R <admin@oldschoolhack.me> 1624915979 +0200 commit (initial): com1 +fdc1b615bdcff0f0658b216df0c9209e5ecb7c78 78a445db1eac62fe15e624e1137965969addf344 KN4CK3R <admin@oldschoolhack.me> 1624915993 +0200 commit: com2 +78a445db1eac62fe15e624e1137965969addf344 fdc1b615bdcff0f0658b216df0c9209e5ecb7c78 KN4CK3R <admin@oldschoolhack.me> 1624916008 +0200 reset: moving to HEAD~1 +fdc1b615bdcff0f0658b216df0c9209e5ecb7c78 a78e5638b66ccfe7e1b4689d3d5684e42c97d7ca KN4CK3R <admin@oldschoolhack.me> 1624916029 +0200 commit: com2_new diff --git a/modules/git/tests/repos/repo4_commitsbetween/objects/27/734c860ab19650d48e71f9f12d9bd194ed82ea b/modules/git/tests/repos/repo4_commitsbetween/objects/27/734c860ab19650d48e71f9f12d9bd194ed82ea Binary files differnew file mode 100644 index 0000000..5b26f8b --- /dev/null +++ b/modules/git/tests/repos/repo4_commitsbetween/objects/27/734c860ab19650d48e71f9f12d9bd194ed82ea diff --git a/modules/git/tests/repos/repo4_commitsbetween/objects/56/a6051ca2b02b04ef92d5150c9ef600403cb1de b/modules/git/tests/repos/repo4_commitsbetween/objects/56/a6051ca2b02b04ef92d5150c9ef600403cb1de Binary files differnew file mode 100644 index 0000000..b17dfe3 --- /dev/null +++ b/modules/git/tests/repos/repo4_commitsbetween/objects/56/a6051ca2b02b04ef92d5150c9ef600403cb1de diff --git a/modules/git/tests/repos/repo4_commitsbetween/objects/78/a445db1eac62fe15e624e1137965969addf344 b/modules/git/tests/repos/repo4_commitsbetween/objects/78/a445db1eac62fe15e624e1137965969addf344 new file mode 100644 index 0000000..6d23de0 --- /dev/null +++ b/modules/git/tests/repos/repo4_commitsbetween/objects/78/a445db1eac62fe15e624e1137965969addf344 @@ -0,0 +1,3 @@ +xÎM +Â0@a×=Eö‚̤Iš€ˆà²àÂ$“Zl©ñþþÁ|¼Gµ”¹)îmÌŠuOä"€·€‚&`ã8GtÀIœ7Ý#n¼6%™09´)“8ë“F—(hl™Ò@ƒïâ«MuSãÕ\Æþ¦Ž1—y=×%?iªu™"Ý…O +þDm½ÚƒèèwØøûź{p‹C_
\ No newline at end of file diff --git a/modules/git/tests/repos/repo4_commitsbetween/objects/a7/8e5638b66ccfe7e1b4689d3d5684e42c97d7ca b/modules/git/tests/repos/repo4_commitsbetween/objects/a7/8e5638b66ccfe7e1b4689d3d5684e42c97d7ca Binary files differnew file mode 100644 index 0000000..d5c554a --- /dev/null +++ b/modules/git/tests/repos/repo4_commitsbetween/objects/a7/8e5638b66ccfe7e1b4689d3d5684e42c97d7ca diff --git a/modules/git/tests/repos/repo4_commitsbetween/objects/ad/74ceca1b8fde10c7d933bd2e56d347dddb4ab5 b/modules/git/tests/repos/repo4_commitsbetween/objects/ad/74ceca1b8fde10c7d933bd2e56d347dddb4ab5 Binary files differnew file mode 100644 index 0000000..26ed785 --- /dev/null +++ b/modules/git/tests/repos/repo4_commitsbetween/objects/ad/74ceca1b8fde10c7d933bd2e56d347dddb4ab5 diff --git a/modules/git/tests/repos/repo4_commitsbetween/objects/b5/d8dd0ddd9d8d752bb47b5f781f09f478316098 b/modules/git/tests/repos/repo4_commitsbetween/objects/b5/d8dd0ddd9d8d752bb47b5f781f09f478316098 Binary files differnew file mode 100644 index 0000000..8060b57 --- /dev/null +++ b/modules/git/tests/repos/repo4_commitsbetween/objects/b5/d8dd0ddd9d8d752bb47b5f781f09f478316098 diff --git a/modules/git/tests/repos/repo4_commitsbetween/objects/d8/263ee9860594d2806b0dfd1bfd17528b0ba2a4 b/modules/git/tests/repos/repo4_commitsbetween/objects/d8/263ee9860594d2806b0dfd1bfd17528b0ba2a4 Binary files differnew file mode 100644 index 0000000..4b1baef --- /dev/null +++ b/modules/git/tests/repos/repo4_commitsbetween/objects/d8/263ee9860594d2806b0dfd1bfd17528b0ba2a4 diff --git a/modules/git/tests/repos/repo4_commitsbetween/objects/e2/3cc6a008501f1491b0480cedaef160e41cf684 b/modules/git/tests/repos/repo4_commitsbetween/objects/e2/3cc6a008501f1491b0480cedaef160e41cf684 Binary files differnew file mode 100644 index 0000000..0a70530 --- /dev/null +++ b/modules/git/tests/repos/repo4_commitsbetween/objects/e2/3cc6a008501f1491b0480cedaef160e41cf684 diff --git a/modules/git/tests/repos/repo4_commitsbetween/objects/fd/c1b615bdcff0f0658b216df0c9209e5ecb7c78 b/modules/git/tests/repos/repo4_commitsbetween/objects/fd/c1b615bdcff0f0658b216df0c9209e5ecb7c78 Binary files differnew file mode 100644 index 0000000..2e6d945 --- /dev/null +++ b/modules/git/tests/repos/repo4_commitsbetween/objects/fd/c1b615bdcff0f0658b216df0c9209e5ecb7c78 diff --git a/modules/git/tests/repos/repo4_commitsbetween/refs/heads/main b/modules/git/tests/repos/repo4_commitsbetween/refs/heads/main new file mode 100644 index 0000000..9e1b981 --- /dev/null +++ b/modules/git/tests/repos/repo4_commitsbetween/refs/heads/main @@ -0,0 +1 @@ +a78e5638b66ccfe7e1b4689d3d5684e42c97d7ca diff --git a/modules/git/tests/repos/repo5_pulls/HEAD b/modules/git/tests/repos/repo5_pulls/HEAD new file mode 100644 index 0000000..cb089cd --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls/HEAD @@ -0,0 +1 @@ +ref: refs/heads/master diff --git a/modules/git/tests/repos/repo5_pulls/config b/modules/git/tests/repos/repo5_pulls/config new file mode 100644 index 0000000..0a0ad6d --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls/config @@ -0,0 +1,6 @@ +[core] + repositoryformatversion = 0 + filemode = true + bare = true +[receive] + advertisePushOptions = true diff --git a/modules/git/tests/repos/repo5_pulls/description b/modules/git/tests/repos/repo5_pulls/description new file mode 100644 index 0000000..498b267 --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls/description @@ -0,0 +1 @@ +Unnamed repository; edit this file 'description' to name the repository. diff --git a/modules/git/tests/repos/repo5_pulls/info/exclude b/modules/git/tests/repos/repo5_pulls/info/exclude new file mode 100644 index 0000000..a5196d1 --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls/info/exclude @@ -0,0 +1,6 @@ +# git ls-files --others --exclude-from=.git/info/exclude +# Lines that start with '#' are comments. +# For a project mostly in C, the following would be a good set of +# exclude patterns (uncomment them if you want to use them): +# *.[oa] +# *~ diff --git a/modules/git/tests/repos/repo5_pulls/objects/1a/2959532d2d18daa87bbd9f9d16051bef7b51df b/modules/git/tests/repos/repo5_pulls/objects/1a/2959532d2d18daa87bbd9f9d16051bef7b51df Binary files differnew file mode 100644 index 0000000..90464be --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls/objects/1a/2959532d2d18daa87bbd9f9d16051bef7b51df diff --git a/modules/git/tests/repos/repo5_pulls/objects/56/51a1c4a48c47484a7a00a967ba4b6dde070bbf b/modules/git/tests/repos/repo5_pulls/objects/56/51a1c4a48c47484a7a00a967ba4b6dde070bbf Binary files differnew file mode 100644 index 0000000..cf9d59f --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls/objects/56/51a1c4a48c47484a7a00a967ba4b6dde070bbf diff --git a/modules/git/tests/repos/repo5_pulls/objects/58/a4bcc53ac13e7ff76127e0fb518b5262bf09af b/modules/git/tests/repos/repo5_pulls/objects/58/a4bcc53ac13e7ff76127e0fb518b5262bf09af new file mode 100644 index 0000000..efc69b1 --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls/objects/58/a4bcc53ac13e7ff76127e0fb518b5262bf09af @@ -0,0 +1 @@ +x%Ž½nÃ0„;ë)¸0H…ú1 P]Úô(‘F2¸Tåýk·7|¸wu]–{OôÒ›„H¨²p®œ8³$A”1¦"\¢ªaÂRf÷fß4Û‡Ù#ZL:JÊ\-„¢#fO2s°¢Nžý¶6èöÓ¯ÓçN»;ïv¼Å#úè 3p“«×º5ˆpÚy^‹µåyÔþL)xÚÛ¼s_•nð1]ÞÞ§aÑ_)@X
\ No newline at end of file diff --git a/modules/git/tests/repos/repo5_pulls/objects/6d/0b4cca434953833618fcd3dd7acff42c800df1 b/modules/git/tests/repos/repo5_pulls/objects/6d/0b4cca434953833618fcd3dd7acff42c800df1 Binary files differnew file mode 100644 index 0000000..74e848f --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls/objects/6d/0b4cca434953833618fcd3dd7acff42c800df1 diff --git a/modules/git/tests/repos/repo5_pulls/objects/a5/2ca5af1b0277638ce20797f80bb1a2997470ab b/modules/git/tests/repos/repo5_pulls/objects/a5/2ca5af1b0277638ce20797f80bb1a2997470ab Binary files differnew file mode 100644 index 0000000..d6e616d --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls/objects/a5/2ca5af1b0277638ce20797f80bb1a2997470ab diff --git a/modules/git/tests/repos/repo5_pulls/objects/bf/4dc0709be60f043821351ff4bb2b17e5cabbb2 b/modules/git/tests/repos/repo5_pulls/objects/bf/4dc0709be60f043821351ff4bb2b17e5cabbb2 new file mode 100644 index 0000000..271cffb --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls/objects/bf/4dc0709be60f043821351ff4bb2b17e5cabbb2 @@ -0,0 +1,2 @@ +xMNÄ0…Y÷Öl„œ'„€ ‰i%ú£4ÜŸÄ
Ø<=ù}~²ó2MccÜM«" ¢ÈhÖ¬zŽ±÷)q(•CRIŠO¤¸tk¬27Ƚ1=²GrL&]ØYBFtÚ'&o„?^¸/–u‰”´ÕèÑѾ®‚*ÄL˜ØÝ›Òů6,¶\ǵÅO©íöô +ï²5øؤžî#xjûìå‡CžA9ƒVyB÷¨»üóc“ÿiëÞ¤^Rsà<Åmo>Ã8·Ž.kly¸¨îC©iè
\ No newline at end of file diff --git a/modules/git/tests/repos/repo5_pulls/objects/d8/e0bbb45f200e67d9a784ce55bd90821af45ebd b/modules/git/tests/repos/repo5_pulls/objects/d8/e0bbb45f200e67d9a784ce55bd90821af45ebd new file mode 100644 index 0000000..0e2dc87 --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls/objects/d8/e0bbb45f200e67d9a784ce55bd90821af45ebd @@ -0,0 +1,2 @@ +xŽAJAE]÷)Š¬"‚VwWÏt E²Äz€NU5Ì$ôTö9ˆ¸ò&Þ$'1Ñ+¸y|þƒÏçíf³6=^XS…NpEÌ…"ÍRÌ1v>W–(Ò®•gD©ÞíJÓÁ@%W’PKZ +Øc—2ŠŸùšD2)r¬®ìímÛ`ä¶ÞYy×fÓɼèhð:j›\Þü)˜Û©»=ãúŒø."ù>ùWÿ~6ýŸ5w<|>>Ü/Ÿž—ÇÃ|
¢mp?ˆXó
\ No newline at end of file diff --git a/modules/git/tests/repos/repo5_pulls/objects/ed/5119b3c1f45547b6785bc03eac7f87570fa17f b/modules/git/tests/repos/repo5_pulls/objects/ed/5119b3c1f45547b6785bc03eac7f87570fa17f Binary files differnew file mode 100644 index 0000000..33d2a21 --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls/objects/ed/5119b3c1f45547b6785bc03eac7f87570fa17f diff --git a/modules/git/tests/repos/repo5_pulls/objects/ed/8f4d2fa5b2420706580d191f5dd50c4e491f3f b/modules/git/tests/repos/repo5_pulls/objects/ed/8f4d2fa5b2420706580d191f5dd50c4e491f3f new file mode 100644 index 0000000..d64847c --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls/objects/ed/8f4d2fa5b2420706580d191f5dd50c4e491f3f @@ -0,0 +1,3 @@ +xŽAJAE]÷)Š¬!V×ÌtMƒˆ"YâF=@uw5Ì$ô”ûD\yo’“hô +nÞâ?ø¼¼ÝlÖÄxbMd ,ƒTŸ˜C7f%äÈuÄ”¼PŒÜ3Jr;i:ÔŽJ,µ`”€5øP)úa¬Ì”µ”1Æž +9y³—mƒ9·õÎäU›.nàIgƒçYÛâìâOÁ¥ýl×G,¸:ì=÷q€s$D—›MÿçÍö÷w·«‡ÇÕaÿ_ŸSÑ6¹o9X‚
\ No newline at end of file diff --git a/modules/git/tests/repos/repo5_pulls/objects/ee/469963e76ae1bb7ee83d7510df2864e6c8c640 b/modules/git/tests/repos/repo5_pulls/objects/ee/469963e76ae1bb7ee83d7510df2864e6c8c640 Binary files differnew file mode 100644 index 0000000..9cd9d00 --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls/objects/ee/469963e76ae1bb7ee83d7510df2864e6c8c640 diff --git a/modules/git/tests/repos/repo5_pulls/objects/info/packs b/modules/git/tests/repos/repo5_pulls/objects/info/packs new file mode 100644 index 0000000..8bbc848 --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls/objects/info/packs @@ -0,0 +1,2 @@ +P pack-81423f591973f5d9dab89cc45afa1c544448133e.pack + diff --git a/modules/git/tests/repos/repo5_pulls/objects/pack/pack-81423f591973f5d9dab89cc45afa1c544448133e.idx b/modules/git/tests/repos/repo5_pulls/objects/pack/pack-81423f591973f5d9dab89cc45afa1c544448133e.idx Binary files differnew file mode 100644 index 0000000..b66df23 --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls/objects/pack/pack-81423f591973f5d9dab89cc45afa1c544448133e.idx diff --git a/modules/git/tests/repos/repo5_pulls/objects/pack/pack-81423f591973f5d9dab89cc45afa1c544448133e.pack b/modules/git/tests/repos/repo5_pulls/objects/pack/pack-81423f591973f5d9dab89cc45afa1c544448133e.pack Binary files differnew file mode 100644 index 0000000..a5dfc5e --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls/objects/pack/pack-81423f591973f5d9dab89cc45afa1c544448133e.pack diff --git a/modules/git/tests/repos/repo5_pulls/packed-refs b/modules/git/tests/repos/repo5_pulls/packed-refs new file mode 100644 index 0000000..d0012b5 --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls/packed-refs @@ -0,0 +1,5 @@ +# pack-refs with: peeled fully-peeled sorted +c83380d7056593c51a699d12b9c00627bd5743e9 refs/heads/test-patch-1 +c83380d7056593c51a699d12b9c00627bd5743e9 refs/pull/1/head +111cac04bd7d20301964e27a93698aabb5781b80 refs/pull/1/merge +72866af952e98d02a73003501836074b286a78f6 refs/tags/v0.9.99 diff --git a/modules/git/tests/repos/repo5_pulls/refs/heads/master b/modules/git/tests/repos/repo5_pulls/refs/heads/master new file mode 100644 index 0000000..9a8e3b2 --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls/refs/heads/master @@ -0,0 +1 @@ +d8e0bbb45f200e67d9a784ce55bd90821af45ebd diff --git a/modules/git/tests/repos/repo5_pulls/refs/heads/master-clone b/modules/git/tests/repos/repo5_pulls/refs/heads/master-clone new file mode 100644 index 0000000..9a8e3b2 --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls/refs/heads/master-clone @@ -0,0 +1 @@ +d8e0bbb45f200e67d9a784ce55bd90821af45ebd diff --git a/modules/git/tests/repos/repo5_pulls/refs/heads/test-patch-1 b/modules/git/tests/repos/repo5_pulls/refs/heads/test-patch-1 new file mode 100644 index 0000000..d8b26cb --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls/refs/heads/test-patch-1 @@ -0,0 +1 @@ +58a4bcc53ac13e7ff76127e0fb518b5262bf09af diff --git a/modules/git/tests/repos/repo5_pulls/refs/pull/4/head b/modules/git/tests/repos/repo5_pulls/refs/pull/4/head new file mode 100644 index 0000000..d8b26cb --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls/refs/pull/4/head @@ -0,0 +1 @@ +58a4bcc53ac13e7ff76127e0fb518b5262bf09af diff --git a/modules/git/tests/repos/repo5_pulls_sha256/HEAD b/modules/git/tests/repos/repo5_pulls_sha256/HEAD new file mode 100644 index 0000000..b870d82 --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls_sha256/HEAD @@ -0,0 +1 @@ +ref: refs/heads/main diff --git a/modules/git/tests/repos/repo5_pulls_sha256/config b/modules/git/tests/repos/repo5_pulls_sha256/config new file mode 100644 index 0000000..2388a50 --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls_sha256/config @@ -0,0 +1,6 @@ +[core] + repositoryformatversion = 1 + filemode = true + bare = true +[extensions] + objectformat = sha256 diff --git a/modules/git/tests/repos/repo5_pulls_sha256/description b/modules/git/tests/repos/repo5_pulls_sha256/description new file mode 100644 index 0000000..498b267 --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls_sha256/description @@ -0,0 +1 @@ +Unnamed repository; edit this file 'description' to name the repository. diff --git a/modules/git/tests/repos/repo5_pulls_sha256/info/refs b/modules/git/tests/repos/repo5_pulls_sha256/info/refs new file mode 100644 index 0000000..454e45d --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls_sha256/info/refs @@ -0,0 +1,4 @@ +35ecd0f946c8baeb76fa5a3876f46bf35218655e2304d8505026fa4bfb496a4b refs/heads/main +35ecd0f946c8baeb76fa5a3876f46bf35218655e2304d8505026fa4bfb496a4b refs/heads/main-clone +7f50a4906503378b0bbb7d61bd2ca8d8d8ff4f7a2474980f99402d742ccc9665 refs/heads/test-patch-1 +1e35a51dc00fd7de730344c07061acfe80e8117e075ac979b6a29a3a045190ca refs/tags/v0.9.99 diff --git a/modules/git/tests/repos/repo5_pulls_sha256/objects/info/commit-graph b/modules/git/tests/repos/repo5_pulls_sha256/objects/info/commit-graph Binary files differnew file mode 100644 index 0000000..8e5ef41 --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls_sha256/objects/info/commit-graph diff --git a/modules/git/tests/repos/repo5_pulls_sha256/objects/info/packs b/modules/git/tests/repos/repo5_pulls_sha256/objects/info/packs new file mode 100644 index 0000000..6f51e7b --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls_sha256/objects/info/packs @@ -0,0 +1,2 @@ +P pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.pack + diff --git a/modules/git/tests/repos/repo5_pulls_sha256/objects/pack/pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.bitmap b/modules/git/tests/repos/repo5_pulls_sha256/objects/pack/pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.bitmap Binary files differnew file mode 100644 index 0000000..38fca6e --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls_sha256/objects/pack/pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.bitmap diff --git a/modules/git/tests/repos/repo5_pulls_sha256/objects/pack/pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.idx b/modules/git/tests/repos/repo5_pulls_sha256/objects/pack/pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.idx Binary files differnew file mode 100644 index 0000000..fd43d04 --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls_sha256/objects/pack/pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.idx diff --git a/modules/git/tests/repos/repo5_pulls_sha256/objects/pack/pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.pack b/modules/git/tests/repos/repo5_pulls_sha256/objects/pack/pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.pack Binary files differnew file mode 100644 index 0000000..689318d --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls_sha256/objects/pack/pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.pack diff --git a/modules/git/tests/repos/repo5_pulls_sha256/objects/pack/pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.rev b/modules/git/tests/repos/repo5_pulls_sha256/objects/pack/pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.rev Binary files differnew file mode 100644 index 0000000..c0bac95 --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls_sha256/objects/pack/pack-bfe8f09d42ef5dd1610bf42641fe145d4a02b788eb26c31022a362312660a29d.rev diff --git a/modules/git/tests/repos/repo5_pulls_sha256/packed-refs b/modules/git/tests/repos/repo5_pulls_sha256/packed-refs new file mode 100644 index 0000000..1525083 --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls_sha256/packed-refs @@ -0,0 +1,5 @@ +# pack-refs with: peeled fully-peeled sorted +35ecd0f946c8baeb76fa5a3876f46bf35218655e2304d8505026fa4bfb496a4b refs/heads/main +35ecd0f946c8baeb76fa5a3876f46bf35218655e2304d8505026fa4bfb496a4b refs/heads/main-clone +7f50a4906503378b0bbb7d61bd2ca8d8d8ff4f7a2474980f99402d742ccc9665 refs/heads/test-patch-1 +1e35a51dc00fd7de730344c07061acfe80e8117e075ac979b6a29a3a045190ca refs/tags/v0.9.99 diff --git a/modules/git/tests/repos/repo5_pulls_sha256/refs/heads/main b/modules/git/tests/repos/repo5_pulls_sha256/refs/heads/main new file mode 100644 index 0000000..9b32e79 --- /dev/null +++ b/modules/git/tests/repos/repo5_pulls_sha256/refs/heads/main @@ -0,0 +1 @@ +35ecd0f946c8baeb76fa5a3876f46bf35218655e2304d8505026fa4bfb496a4b diff --git a/modules/git/tests/repos/repo6_blame/HEAD b/modules/git/tests/repos/repo6_blame/HEAD new file mode 100644 index 0000000..cb089cd --- /dev/null +++ b/modules/git/tests/repos/repo6_blame/HEAD @@ -0,0 +1 @@ +ref: refs/heads/master diff --git a/modules/git/tests/repos/repo6_blame/config b/modules/git/tests/repos/repo6_blame/config new file mode 100644 index 0000000..07d359d --- /dev/null +++ b/modules/git/tests/repos/repo6_blame/config @@ -0,0 +1,4 @@ +[core] + repositoryformatversion = 0 + filemode = true + bare = true diff --git a/modules/git/tests/repos/repo6_blame/objects/31/bb4b42cecf0a98fc9a32fc5aaeaf53ec52643c b/modules/git/tests/repos/repo6_blame/objects/31/bb4b42cecf0a98fc9a32fc5aaeaf53ec52643c Binary files differnew file mode 100644 index 0000000..6cde910 --- /dev/null +++ b/modules/git/tests/repos/repo6_blame/objects/31/bb4b42cecf0a98fc9a32fc5aaeaf53ec52643c diff --git a/modules/git/tests/repos/repo6_blame/objects/3b/0f66d8b065f8adbf2fef7d986528c655b98cb1 b/modules/git/tests/repos/repo6_blame/objects/3b/0f66d8b065f8adbf2fef7d986528c655b98cb1 Binary files differnew file mode 100644 index 0000000..b8db01d --- /dev/null +++ b/modules/git/tests/repos/repo6_blame/objects/3b/0f66d8b065f8adbf2fef7d986528c655b98cb1 diff --git a/modules/git/tests/repos/repo6_blame/objects/45/fb6cbc12f970b04eacd5cd4165edd11c8d7376 b/modules/git/tests/repos/repo6_blame/objects/45/fb6cbc12f970b04eacd5cd4165edd11c8d7376 Binary files differnew file mode 100644 index 0000000..6c0ae47 --- /dev/null +++ b/modules/git/tests/repos/repo6_blame/objects/45/fb6cbc12f970b04eacd5cd4165edd11c8d7376 diff --git a/modules/git/tests/repos/repo6_blame/objects/49/7701e5bb8676e419b93875d8f0808c7b31aed9 b/modules/git/tests/repos/repo6_blame/objects/49/7701e5bb8676e419b93875d8f0808c7b31aed9 Binary files differnew file mode 100644 index 0000000..5c2b564 --- /dev/null +++ b/modules/git/tests/repos/repo6_blame/objects/49/7701e5bb8676e419b93875d8f0808c7b31aed9 diff --git a/modules/git/tests/repos/repo6_blame/objects/54/4d8f7a3b15927cddf2299b4b562d6ebd71b6a7 b/modules/git/tests/repos/repo6_blame/objects/54/4d8f7a3b15927cddf2299b4b562d6ebd71b6a7 Binary files differnew file mode 100644 index 0000000..3c64718 --- /dev/null +++ b/modules/git/tests/repos/repo6_blame/objects/54/4d8f7a3b15927cddf2299b4b562d6ebd71b6a7 diff --git a/modules/git/tests/repos/repo6_blame/objects/a8/9199e8dea077e4a8ba0bc01bc155275cfdd044 b/modules/git/tests/repos/repo6_blame/objects/a8/9199e8dea077e4a8ba0bc01bc155275cfdd044 Binary files differnew file mode 100644 index 0000000..847b7bc --- /dev/null +++ b/modules/git/tests/repos/repo6_blame/objects/a8/9199e8dea077e4a8ba0bc01bc155275cfdd044 diff --git a/modules/git/tests/repos/repo6_blame/objects/af/7486bd54cfc39eea97207ca666aa69c9d6df93 b/modules/git/tests/repos/repo6_blame/objects/af/7486bd54cfc39eea97207ca666aa69c9d6df93 Binary files differnew file mode 100644 index 0000000..206ef1e --- /dev/null +++ b/modules/git/tests/repos/repo6_blame/objects/af/7486bd54cfc39eea97207ca666aa69c9d6df93 diff --git a/modules/git/tests/repos/repo6_blame/objects/b8/d1ba1ccb58ee3744b3d1434aae7d26ce2d9421 b/modules/git/tests/repos/repo6_blame/objects/b8/d1ba1ccb58ee3744b3d1434aae7d26ce2d9421 Binary files differnew file mode 100644 index 0000000..bb26889 --- /dev/null +++ b/modules/git/tests/repos/repo6_blame/objects/b8/d1ba1ccb58ee3744b3d1434aae7d26ce2d9421 diff --git a/modules/git/tests/repos/repo6_blame/objects/ca/411a3b842c3caec045772da42de16b3ffdafe8 b/modules/git/tests/repos/repo6_blame/objects/ca/411a3b842c3caec045772da42de16b3ffdafe8 Binary files differnew file mode 100644 index 0000000..1653ed9 --- /dev/null +++ b/modules/git/tests/repos/repo6_blame/objects/ca/411a3b842c3caec045772da42de16b3ffdafe8 diff --git a/modules/git/tests/repos/repo6_blame/refs/heads/master b/modules/git/tests/repos/repo6_blame/refs/heads/master new file mode 100644 index 0000000..01c9922 --- /dev/null +++ b/modules/git/tests/repos/repo6_blame/refs/heads/master @@ -0,0 +1 @@ +544d8f7a3b15927cddf2299b4b562d6ebd71b6a7 diff --git a/modules/git/tests/repos/repo6_blame_sha256/HEAD b/modules/git/tests/repos/repo6_blame_sha256/HEAD new file mode 100644 index 0000000..b870d82 --- /dev/null +++ b/modules/git/tests/repos/repo6_blame_sha256/HEAD @@ -0,0 +1 @@ +ref: refs/heads/main diff --git a/modules/git/tests/repos/repo6_blame_sha256/config b/modules/git/tests/repos/repo6_blame_sha256/config new file mode 100644 index 0000000..2388a50 --- /dev/null +++ b/modules/git/tests/repos/repo6_blame_sha256/config @@ -0,0 +1,6 @@ +[core] + repositoryformatversion = 1 + filemode = true + bare = true +[extensions] + objectformat = sha256 diff --git a/modules/git/tests/repos/repo6_blame_sha256/description b/modules/git/tests/repos/repo6_blame_sha256/description new file mode 100644 index 0000000..498b267 --- /dev/null +++ b/modules/git/tests/repos/repo6_blame_sha256/description @@ -0,0 +1 @@ +Unnamed repository; edit this file 'description' to name the repository. diff --git a/modules/git/tests/repos/repo6_blame_sha256/info/exclude b/modules/git/tests/repos/repo6_blame_sha256/info/exclude new file mode 100644 index 0000000..a5196d1 --- /dev/null +++ b/modules/git/tests/repos/repo6_blame_sha256/info/exclude @@ -0,0 +1,6 @@ +# git ls-files --others --exclude-from=.git/info/exclude +# Lines that start with '#' are comments. +# For a project mostly in C, the following would be a good set of +# exclude patterns (uncomment them if you want to use them): +# *.[oa] +# *~ diff --git a/modules/git/tests/repos/repo6_blame_sha256/info/refs b/modules/git/tests/repos/repo6_blame_sha256/info/refs new file mode 100644 index 0000000..bee6d1d --- /dev/null +++ b/modules/git/tests/repos/repo6_blame_sha256/info/refs @@ -0,0 +1 @@ +e2f5660e15159082902960af0ed74fc144921d2b0c80e069361853b3ece29ba3 refs/heads/main diff --git a/modules/git/tests/repos/repo6_blame_sha256/objects/info/commit-graph b/modules/git/tests/repos/repo6_blame_sha256/objects/info/commit-graph Binary files differnew file mode 100644 index 0000000..f963aa0 --- /dev/null +++ b/modules/git/tests/repos/repo6_blame_sha256/objects/info/commit-graph diff --git a/modules/git/tests/repos/repo6_blame_sha256/objects/info/packs b/modules/git/tests/repos/repo6_blame_sha256/objects/info/packs new file mode 100644 index 0000000..73744cf --- /dev/null +++ b/modules/git/tests/repos/repo6_blame_sha256/objects/info/packs @@ -0,0 +1,2 @@ +P pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.pack + diff --git a/modules/git/tests/repos/repo6_blame_sha256/objects/pack/pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.bitmap b/modules/git/tests/repos/repo6_blame_sha256/objects/pack/pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.bitmap Binary files differnew file mode 100644 index 0000000..c34487c --- /dev/null +++ b/modules/git/tests/repos/repo6_blame_sha256/objects/pack/pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.bitmap diff --git a/modules/git/tests/repos/repo6_blame_sha256/objects/pack/pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.idx b/modules/git/tests/repos/repo6_blame_sha256/objects/pack/pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.idx Binary files differnew file mode 100644 index 0000000..faaee22 --- /dev/null +++ b/modules/git/tests/repos/repo6_blame_sha256/objects/pack/pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.idx diff --git a/modules/git/tests/repos/repo6_blame_sha256/objects/pack/pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.pack b/modules/git/tests/repos/repo6_blame_sha256/objects/pack/pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.pack Binary files differnew file mode 100644 index 0000000..626f081 --- /dev/null +++ b/modules/git/tests/repos/repo6_blame_sha256/objects/pack/pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.pack diff --git a/modules/git/tests/repos/repo6_blame_sha256/objects/pack/pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.rev b/modules/git/tests/repos/repo6_blame_sha256/objects/pack/pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.rev Binary files differnew file mode 100644 index 0000000..5617555 --- /dev/null +++ b/modules/git/tests/repos/repo6_blame_sha256/objects/pack/pack-fcb8a221b76025fd8415d3c562b611ac24312a5ffc3d3703d7c5cc906bdaee8e.rev diff --git a/modules/git/tests/repos/repo6_blame_sha256/packed-refs b/modules/git/tests/repos/repo6_blame_sha256/packed-refs new file mode 100644 index 0000000..6442692 --- /dev/null +++ b/modules/git/tests/repos/repo6_blame_sha256/packed-refs @@ -0,0 +1,2 @@ +# pack-refs with: peeled fully-peeled sorted +e2f5660e15159082902960af0ed74fc144921d2b0c80e069361853b3ece29ba3 refs/heads/main diff --git a/modules/git/tests/repos/repo6_blame_sha256/refs/refs/main b/modules/git/tests/repos/repo6_blame_sha256/refs/refs/main new file mode 100644 index 0000000..829662c --- /dev/null +++ b/modules/git/tests/repos/repo6_blame_sha256/refs/refs/main @@ -0,0 +1 @@ +e2f5660e15159082902960af0ed74fc144921d2b0c80e069361853b3ece29ba3 diff --git a/modules/git/tests/repos/repo6_merge/HEAD b/modules/git/tests/repos/repo6_merge/HEAD new file mode 100644 index 0000000..b870d82 --- /dev/null +++ b/modules/git/tests/repos/repo6_merge/HEAD @@ -0,0 +1 @@ +ref: refs/heads/main diff --git a/modules/git/tests/repos/repo6_merge/objects/02/2f4ce6214973e018f02bf363bf8a2e3691f699 b/modules/git/tests/repos/repo6_merge/objects/02/2f4ce6214973e018f02bf363bf8a2e3691f699 Binary files differnew file mode 100644 index 0000000..0778a1c --- /dev/null +++ b/modules/git/tests/repos/repo6_merge/objects/02/2f4ce6214973e018f02bf363bf8a2e3691f699 diff --git a/modules/git/tests/repos/repo6_merge/objects/05/45879290cc368a8becebc4aa34002c52d5fecc b/modules/git/tests/repos/repo6_merge/objects/05/45879290cc368a8becebc4aa34002c52d5fecc Binary files differnew file mode 100644 index 0000000..c71794f --- /dev/null +++ b/modules/git/tests/repos/repo6_merge/objects/05/45879290cc368a8becebc4aa34002c52d5fecc diff --git a/modules/git/tests/repos/repo6_merge/objects/1e/5d0a65fe099ef12d24b28f783896e4b8172576 b/modules/git/tests/repos/repo6_merge/objects/1e/5d0a65fe099ef12d24b28f783896e4b8172576 Binary files differnew file mode 100644 index 0000000..365f368 --- /dev/null +++ b/modules/git/tests/repos/repo6_merge/objects/1e/5d0a65fe099ef12d24b28f783896e4b8172576 diff --git a/modules/git/tests/repos/repo6_merge/objects/37/d35c7ed39e4e16d0b579a5b995b7e30b0e9411 b/modules/git/tests/repos/repo6_merge/objects/37/d35c7ed39e4e16d0b579a5b995b7e30b0e9411 new file mode 100644 index 0000000..83890b5 --- /dev/null +++ b/modules/git/tests/repos/repo6_merge/objects/37/d35c7ed39e4e16d0b579a5b995b7e30b0e9411 @@ -0,0 +1,2 @@ +xÍM +1†a×=Eö‚$µÍLAÄ«ô'Á‡‘iæþR½€›g÷~_ÝÖu1 ˜N¶‹@mZ)g2…D‘j™Š*_“fŒÌs¥4ïòaÏm“np>—Áˆç€Ì>!œÑ#ºú½1ù;p]ìxÿæuyIwÑN4Ã
\ No newline at end of file diff --git a/modules/git/tests/repos/repo6_merge/objects/38/ec3e0cdc88bde01014bda4a5dd9fc835f41439 b/modules/git/tests/repos/repo6_merge/objects/38/ec3e0cdc88bde01014bda4a5dd9fc835f41439 new file mode 100644 index 0000000..582d98c --- /dev/null +++ b/modules/git/tests/repos/repo6_merge/objects/38/ec3e0cdc88bde01014bda4a5dd9fc835f41439 @@ -0,0 +1,2 @@ +xÎM +Â0†a×9Åì™üM2 âUšæ¬•=¿Ô¸yv/¼ó¶®Ë Çî0:@±ò$±UѬ«.—[Ê>« ”l“‹IÌsêxò©ú8'T¯°R¹Ä¤S,ª±$x.
Öšé=n[§× óîuç´s!+9°ˆ÷BGvÌfþm
ü˜Žuû€Úr‡ùÿÁ>®
\ No newline at end of file diff --git a/modules/git/tests/repos/repo6_merge/objects/66/7e0fbc6bc02c2285d17f542e89b23c0fa5482b b/modules/git/tests/repos/repo6_merge/objects/66/7e0fbc6bc02c2285d17f542e89b23c0fa5482b Binary files differnew file mode 100644 index 0000000..d7faff6 --- /dev/null +++ b/modules/git/tests/repos/repo6_merge/objects/66/7e0fbc6bc02c2285d17f542e89b23c0fa5482b diff --git a/modules/git/tests/repos/repo6_merge/objects/9f/d90b1d524c0fea776ed5e6476da02ea1740597 b/modules/git/tests/repos/repo6_merge/objects/9f/d90b1d524c0fea776ed5e6476da02ea1740597 Binary files differnew file mode 100644 index 0000000..8ac2814 --- /dev/null +++ b/modules/git/tests/repos/repo6_merge/objects/9f/d90b1d524c0fea776ed5e6476da02ea1740597 diff --git a/modules/git/tests/repos/repo6_merge/objects/ae/4b035e7c4afbc000576cee3f713ea0c2f1e1e2 b/modules/git/tests/repos/repo6_merge/objects/ae/4b035e7c4afbc000576cee3f713ea0c2f1e1e2 new file mode 100644 index 0000000..c0f6b13 --- /dev/null +++ b/modules/git/tests/repos/repo6_merge/objects/ae/4b035e7c4afbc000576cee3f713ea0c2f1e1e2 @@ -0,0 +1,5 @@ +xÎM +1@a×=Eö‚¤?iñ*mšÁÇ‘™xOàæÛ=x².Ëlà™O¶©R¢Z80ŠÄ\[í*Ú%µb +ƒ&qï¶éË –IŠŽÈšÔç +7êÌÔ‹F쨜¼wícuÓÝàzx?¸ÜÀçš0ç +œ1 :ùm™þ¸6LóSÝÀí>&
\ No newline at end of file diff --git a/modules/git/tests/repos/repo6_merge/objects/ba/2906d0666cf726c7eaadd2cd3db615dedfdf3a b/modules/git/tests/repos/repo6_merge/objects/ba/2906d0666cf726c7eaadd2cd3db615dedfdf3a Binary files differnew file mode 100644 index 0000000..edef2a7 --- /dev/null +++ b/modules/git/tests/repos/repo6_merge/objects/ba/2906d0666cf726c7eaadd2cd3db615dedfdf3a diff --git a/modules/git/tests/repos/repo6_merge/objects/c1/a95c2eff8151c6d1437a0d5d3322a73ff38fb8 b/modules/git/tests/repos/repo6_merge/objects/c1/a95c2eff8151c6d1437a0d5d3322a73ff38fb8 Binary files differnew file mode 100644 index 0000000..353d86c --- /dev/null +++ b/modules/git/tests/repos/repo6_merge/objects/c1/a95c2eff8151c6d1437a0d5d3322a73ff38fb8 diff --git a/modules/git/tests/repos/repo6_merge/objects/cc/d1d4d594029e68c388ecef5aa3063fa1055831 b/modules/git/tests/repos/repo6_merge/objects/cc/d1d4d594029e68c388ecef5aa3063fa1055831 Binary files differnew file mode 100644 index 0000000..5449d72 --- /dev/null +++ b/modules/git/tests/repos/repo6_merge/objects/cc/d1d4d594029e68c388ecef5aa3063fa1055831 diff --git a/modules/git/tests/repos/repo6_merge/objects/cd/fc1aaf7a149151cb7bff639fafe05668d4bbd2 b/modules/git/tests/repos/repo6_merge/objects/cd/fc1aaf7a149151cb7bff639fafe05668d4bbd2 Binary files differnew file mode 100644 index 0000000..030eb98 --- /dev/null +++ b/modules/git/tests/repos/repo6_merge/objects/cd/fc1aaf7a149151cb7bff639fafe05668d4bbd2 diff --git a/modules/git/tests/repos/repo6_merge/objects/d1/792641396ff7630d35fbb0b74b86b0c71bca77 b/modules/git/tests/repos/repo6_merge/objects/d1/792641396ff7630d35fbb0b74b86b0c71bca77 Binary files differnew file mode 100644 index 0000000..867e4c0 --- /dev/null +++ b/modules/git/tests/repos/repo6_merge/objects/d1/792641396ff7630d35fbb0b74b86b0c71bca77 diff --git a/modules/git/tests/repos/repo6_merge/objects/ec/d11d8da0f25eaa99f64a37a82da98685f381e2 b/modules/git/tests/repos/repo6_merge/objects/ec/d11d8da0f25eaa99f64a37a82da98685f381e2 Binary files differnew file mode 100644 index 0000000..52d300c --- /dev/null +++ b/modules/git/tests/repos/repo6_merge/objects/ec/d11d8da0f25eaa99f64a37a82da98685f381e2 diff --git a/modules/git/tests/repos/repo6_merge/objects/fa/49b077972391ad58037050f2a75f74e3671e92 b/modules/git/tests/repos/repo6_merge/objects/fa/49b077972391ad58037050f2a75f74e3671e92 Binary files differnew file mode 100644 index 0000000..112998d --- /dev/null +++ b/modules/git/tests/repos/repo6_merge/objects/fa/49b077972391ad58037050f2a75f74e3671e92 diff --git a/modules/git/tests/repos/repo6_merge/refs/heads/main b/modules/git/tests/repos/repo6_merge/refs/heads/main new file mode 100644 index 0000000..adf9e86 --- /dev/null +++ b/modules/git/tests/repos/repo6_merge/refs/heads/main @@ -0,0 +1 @@ +022f4ce6214973e018f02bf363bf8a2e3691f699 diff --git a/modules/git/tests/repos/repo6_merge/refs/heads/merge/add_file b/modules/git/tests/repos/repo6_merge/refs/heads/merge/add_file new file mode 100644 index 0000000..035ad11 --- /dev/null +++ b/modules/git/tests/repos/repo6_merge/refs/heads/merge/add_file @@ -0,0 +1 @@ +ae4b035e7c4afbc000576cee3f713ea0c2f1e1e2 diff --git a/modules/git/tests/repos/repo6_merge/refs/heads/merge/modify_file b/modules/git/tests/repos/repo6_merge/refs/heads/merge/modify_file new file mode 100644 index 0000000..f0d5105 --- /dev/null +++ b/modules/git/tests/repos/repo6_merge/refs/heads/merge/modify_file @@ -0,0 +1 @@ +d1792641396ff7630d35fbb0b74b86b0c71bca77 diff --git a/modules/git/tests/repos/repo6_merge/refs/heads/merge/remove_file b/modules/git/tests/repos/repo6_merge/refs/heads/merge/remove_file new file mode 100644 index 0000000..ada3c8b --- /dev/null +++ b/modules/git/tests/repos/repo6_merge/refs/heads/merge/remove_file @@ -0,0 +1 @@ +38ec3e0cdc88bde01014bda4a5dd9fc835f41439 diff --git a/modules/git/tests/repos/repo6_merge_sha256/HEAD b/modules/git/tests/repos/repo6_merge_sha256/HEAD new file mode 100644 index 0000000..b870d82 --- /dev/null +++ b/modules/git/tests/repos/repo6_merge_sha256/HEAD @@ -0,0 +1 @@ +ref: refs/heads/main diff --git a/modules/git/tests/repos/repo6_merge_sha256/config b/modules/git/tests/repos/repo6_merge_sha256/config new file mode 100644 index 0000000..2388a50 --- /dev/null +++ b/modules/git/tests/repos/repo6_merge_sha256/config @@ -0,0 +1,6 @@ +[core] + repositoryformatversion = 1 + filemode = true + bare = true +[extensions] + objectformat = sha256 diff --git a/modules/git/tests/repos/repo6_merge_sha256/description b/modules/git/tests/repos/repo6_merge_sha256/description new file mode 100644 index 0000000..498b267 --- /dev/null +++ b/modules/git/tests/repos/repo6_merge_sha256/description @@ -0,0 +1 @@ +Unnamed repository; edit this file 'description' to name the repository. diff --git a/modules/git/tests/repos/repo6_merge_sha256/info/exclude b/modules/git/tests/repos/repo6_merge_sha256/info/exclude new file mode 100644 index 0000000..a5196d1 --- /dev/null +++ b/modules/git/tests/repos/repo6_merge_sha256/info/exclude @@ -0,0 +1,6 @@ +# git ls-files --others --exclude-from=.git/info/exclude +# Lines that start with '#' are comments. +# For a project mostly in C, the following would be a good set of +# exclude patterns (uncomment them if you want to use them): +# *.[oa] +# *~ diff --git a/modules/git/tests/repos/repo6_merge_sha256/info/refs b/modules/git/tests/repos/repo6_merge_sha256/info/refs new file mode 100644 index 0000000..7dae8a1 --- /dev/null +++ b/modules/git/tests/repos/repo6_merge_sha256/info/refs @@ -0,0 +1,4 @@ +d2e5609f630dd8db500f5298d05d16def282412e3e66ed68cc7d0833b29129a1 refs/heads/main +b45258e9823233edea2d40d183742f29630e1e69300479fb4a55eabfe9b1d8bf refs/heads/merge/add_file +ff2b996e2fa366146300e4c9e51ccb6818147b360e46fa1437334f4a690955ce refs/heads/merge/modify_file +da1ded40dc8e5b7c564171f4bf2fc8370487decfb1cb6a99ef28f3ed73d09172 refs/heads/merge/remove_file diff --git a/modules/git/tests/repos/repo6_merge_sha256/objects/info/commit-graph b/modules/git/tests/repos/repo6_merge_sha256/objects/info/commit-graph Binary files differnew file mode 100644 index 0000000..9806847 --- /dev/null +++ b/modules/git/tests/repos/repo6_merge_sha256/objects/info/commit-graph diff --git a/modules/git/tests/repos/repo6_merge_sha256/objects/info/packs b/modules/git/tests/repos/repo6_merge_sha256/objects/info/packs new file mode 100644 index 0000000..f3cf819 --- /dev/null +++ b/modules/git/tests/repos/repo6_merge_sha256/objects/info/packs @@ -0,0 +1,3 @@ +P pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.pack +P pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.pack + diff --git a/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.bitmap b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.bitmap Binary files differnew file mode 100644 index 0000000..d1624a0 --- /dev/null +++ b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.bitmap diff --git a/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.idx b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.idx Binary files differnew file mode 100644 index 0000000..09b897d --- /dev/null +++ b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.idx diff --git a/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.pack b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.pack Binary files differnew file mode 100644 index 0000000..3b406be --- /dev/null +++ b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.pack diff --git a/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.rev b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.rev Binary files differnew file mode 100644 index 0000000..4a695fc --- /dev/null +++ b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-2fff0848f8d8eab8f7902ac91ab6a096c7530f577d5c0a79c63d9ac2b44f7510.rev diff --git a/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.idx b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.idx Binary files differnew file mode 100644 index 0000000..3b58342 --- /dev/null +++ b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.idx diff --git a/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.mtimes b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.mtimes Binary files differnew file mode 100644 index 0000000..a669a06 --- /dev/null +++ b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.mtimes diff --git a/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.pack b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.pack Binary files differnew file mode 100644 index 0000000..a28808e --- /dev/null +++ b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.pack diff --git a/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.rev b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.rev Binary files differnew file mode 100644 index 0000000..c09bb32 --- /dev/null +++ b/modules/git/tests/repos/repo6_merge_sha256/objects/pack/pack-65162b86afdbac3c566696d487e67bb2a4a5501ca1fa3528fad8a9474fba7e50.rev diff --git a/modules/git/tests/repos/repo6_merge_sha256/packed-refs b/modules/git/tests/repos/repo6_merge_sha256/packed-refs new file mode 100644 index 0000000..b906893 --- /dev/null +++ b/modules/git/tests/repos/repo6_merge_sha256/packed-refs @@ -0,0 +1,5 @@ +# pack-refs with: peeled fully-peeled sorted +d2e5609f630dd8db500f5298d05d16def282412e3e66ed68cc7d0833b29129a1 refs/heads/main +b45258e9823233edea2d40d183742f29630e1e69300479fb4a55eabfe9b1d8bf refs/heads/merge/add_file +ff2b996e2fa366146300e4c9e51ccb6818147b360e46fa1437334f4a690955ce refs/heads/merge/modify_file +da1ded40dc8e5b7c564171f4bf2fc8370487decfb1cb6a99ef28f3ed73d09172 refs/heads/merge/remove_file diff --git a/modules/git/tests/repos/repo6_merge_sha256/refs/heads/main b/modules/git/tests/repos/repo6_merge_sha256/refs/heads/main new file mode 100644 index 0000000..c8c0292 --- /dev/null +++ b/modules/git/tests/repos/repo6_merge_sha256/refs/heads/main @@ -0,0 +1 @@ +d2e5609f630dd8db500f5298d05d16def282412e3e66ed68cc7d0833b29129a1 diff --git a/modules/git/tree.go b/modules/git/tree.go new file mode 100644 index 0000000..5b06cbf --- /dev/null +++ b/modules/git/tree.go @@ -0,0 +1,178 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "bytes" + "io" + "strings" +) + +// Tree represents a flat directory listing. +type Tree struct { + ID ObjectID + ResolvedID ObjectID + repo *Repository + + // parent tree + ptree *Tree + + entries Entries + entriesParsed bool + + entriesRecursive Entries + entriesRecursiveParsed bool +} + +// NewTree create a new tree according the repository and tree id +func NewTree(repo *Repository, id ObjectID) *Tree { + return &Tree{ + ID: id, + repo: repo, + } +} + +// ListEntries returns all entries of current tree. +func (t *Tree) ListEntries() (Entries, error) { + if t.entriesParsed { + return t.entries, nil + } + + if t.repo != nil { + wr, rd, 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) + if err != nil { + return nil, err + } + if typ == "commit" { + treeID, err := ReadTreeID(rd, sz) + if err != nil && err != io.EOF { + return nil, err + } + _, _ = wr.Write([]byte(treeID + "\n")) + _, typ, sz, err = ReadBatchLine(rd) + if err != nil { + return nil, err + } + } + if typ == "tree" { + t.entries, err = catBatchParseTreeEntries(t.ID.Type(), t, rd, sz) + if err != nil { + return nil, err + } + t.entriesParsed = true + return t.entries, nil + } + + // Not a tree just use ls-tree instead + if err := DiscardFull(rd, sz+1); err != nil { + return nil, err + } + } + + stdout, _, runErr := NewCommand(t.repo.Ctx, "ls-tree", "-l").AddDynamicArguments(t.ID.String()).RunStdBytes(&RunOpts{Dir: t.repo.Path}) + if runErr != nil { + if strings.Contains(runErr.Error(), "fatal: Not a valid object name") || strings.Contains(runErr.Error(), "fatal: not a tree object") { + return nil, ErrNotExist{ + ID: t.ID.String(), + } + } + return nil, runErr + } + + var err error + t.entries, err = parseTreeEntries(stdout, t) + if err == nil { + t.entriesParsed = true + } + + return t.entries, err +} + +// 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 TrustedCmdArgs) (Entries, error) { + if t.entriesRecursiveParsed { + return t.entriesRecursive, nil + } + + stdout, _, runErr := NewCommand(t.repo.Ctx, "ls-tree", "-t", "-r"). + AddArguments(extraArgs...). + AddDynamicArguments(t.ID.String()). + RunStdBytes(&RunOpts{Dir: t.repo.Path}) + 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 +} + +// ListEntriesRecursiveFast returns all entries of current tree recursively including all subtrees, no size +func (t *Tree) ListEntriesRecursiveFast() (Entries, error) { + return t.listEntriesRecursive(nil) +} + +// ListEntriesRecursiveWithSize returns all entries of current tree recursively including all subtrees, with size +func (t *Tree) ListEntriesRecursiveWithSize() (Entries, error) { + return t.listEntriesRecursive(TrustedCmdArgs{"--long"}) +} + +// SubTree get a sub tree by the sub dir path +func (t *Tree) SubTree(rpath string) (*Tree, error) { + if len(rpath) == 0 { + return t, nil + } + + paths := strings.Split(rpath, "/") + var ( + err error + g = t + p = t + te *TreeEntry + ) + for _, name := range paths { + te, err = p.GetTreeEntryByPath(name) + if err != nil { + return nil, err + } + + g, err = t.repo.getTree(te.ID) + if err != nil { + return nil, err + } + g.ptree = p + p = g + } + return g, nil +} + +// LsTree checks if the given filenames are in the tree +func (repo *Repository) LsTree(ref string, filenames ...string) ([]string, error) { + cmd := NewCommand(repo.Ctx, "ls-tree", "-z", "--name-only"). + AddDashesAndList(append([]string{ref}, filenames...)...) + + res, _, err := cmd.RunStdBytes(&RunOpts{Dir: repo.Path}) + if err != nil { + return nil, err + } + filelist := make([]string, 0, len(filenames)) + for _, line := range bytes.Split(res, []byte{'\000'}) { + filelist = append(filelist, string(line)) + } + + return filelist, err +} diff --git a/modules/git/tree_blob.go b/modules/git/tree_blob.go new file mode 100644 index 0000000..df339f6 --- /dev/null +++ b/modules/git/tree_blob.go @@ -0,0 +1,81 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "path" + "strings" +) + +// GetTreeEntryByPath get the tree entries according the sub dir +func (t *Tree) GetTreeEntryByPath(relpath string) (*TreeEntry, error) { + if len(relpath) == 0 { + return &TreeEntry{ + ptree: t, + ID: t.ID, + name: "", + fullName: "", + entryMode: EntryModeTree, + }, nil + } + + // FIXME: This should probably use git cat-file --batch to be a bit more efficient + relpath = path.Clean(relpath) + parts := strings.Split(relpath, "/") + var err error + tree := t + for i, name := range parts { + if i == len(parts)-1 { + entries, err := tree.ListEntries() + if err != nil { + return nil, err + } + for _, v := range entries { + if v.Name() == name { + return v, nil + } + } + } else { + tree, err = tree.SubTree(name) + if err != nil { + return nil, err + } + } + } + return nil, ErrNotExist{"", relpath} +} + +// GetBlobByPath get the blob object according the path +func (t *Tree) GetBlobByPath(relpath string) (*Blob, error) { + entry, err := t.GetTreeEntryByPath(relpath) + if err != nil { + return nil, err + } + + if !entry.IsDir() && !entry.IsSubModule() { + return entry.Blob(), nil + } + + return nil, ErrNotExist{"", relpath} +} + +// GetBlobByFoldedPath returns the blob object at relpath, regardless of the +// case of relpath. If there are multiple files with the same case-insensitive +// name, the first one found will be returned. +func (t *Tree) GetBlobByFoldedPath(relpath string) (*Blob, error) { + entries, err := t.ListEntries() + if err != nil { + return nil, err + } + + for _, entry := range entries { + if strings.EqualFold(entry.Name(), relpath) { + return t.GetBlobByPath(entry.Name()) + } + } + + return nil, ErrNotExist{"", relpath} +} diff --git a/modules/git/tree_entry.go b/modules/git/tree_entry.go new file mode 100644 index 0000000..0d9cfd2 --- /dev/null +++ b/modules/git/tree_entry.go @@ -0,0 +1,277 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "io" + "sort" + "strings" + + "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 + fullName string +} + +// Name returns the name of the entry +func (te *TreeEntry) Name() string { + if te.fullName != "" { + return te.fullName + } + 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() { + return 0 + } else if te.sized { + return te.size + } + + wr, rd, cancel, err := te.ptree.repo.CatFileBatchCheck(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) + 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.sized = true + return te.size +} + +// IsSubModule if the entry is a sub module +func (te *TreeEntry) IsSubModule() bool { + return te.entryMode == EntryModeCommit +} + +// IsDir if the entry is a sub dir +func (te *TreeEntry) IsDir() bool { + return te.entryMode == EntryModeTree +} + +// IsLink if the entry is a symlink +func (te *TreeEntry) IsLink() bool { + return te.entryMode == EntryModeSymlink +} + +// IsRegular if the entry is a regular file +func (te *TreeEntry) IsRegular() bool { + return te.entryMode == EntryModeBlob +} + +// IsExecutable if the entry is an executable file (not necessarily binary) +func (te *TreeEntry) IsExecutable() bool { + return te.entryMode == EntryModeExec +} + +// Blob returns the blob object the entry +func (te *TreeEntry) Blob() *Blob { + return &Blob{ + ID: te.ID, + name: te.Name(), + size: te.size, + gotSize: te.sized, + repo: te.ptree.repo, + } +} + +// Type returns the type of the entry (commit, tree, blob) +func (te *TreeEntry) Type() string { + switch te.Mode() { + case EntryModeCommit: + return "commit" + case EntryModeTree: + return "tree" + default: + return "blob" + } +} + +// FollowLink returns the entry pointed to by a symlink +func (te *TreeEntry) FollowLink() (*TreeEntry, string, error) { + if !te.IsLink() { + return nil, "", ErrBadLink{te.Name(), "not a symlink"} + } + + // read the link + r, err := te.Blob().DataAsync() + if err != nil { + return nil, "", err + } + closed := false + defer func() { + if !closed { + _ = r.Close() + } + }() + buf := make([]byte, te.Size()) + _, err = io.ReadFull(r, buf) + if err != nil { + return nil, "", err + } + _ = r.Close() + closed = true + + lnk := string(buf) + t := te.ptree + + // traverse up directories + for ; t != nil && strings.HasPrefix(lnk, "../"); lnk = lnk[3:] { + t = t.ptree + } + + if t == nil { + return nil, "", ErrBadLink{te.Name(), "points outside of repo"} + } + + target, err := t.GetTreeEntryByPath(lnk) + if err != nil { + if IsErrNotExist(err) { + return nil, "", ErrBadLink{te.Name(), "broken link"} + } + return nil, "", err + } + return target, lnk, nil +} + +// FollowLinks returns the entry ultimately pointed to by a symlink +func (te *TreeEntry) FollowLinks() (*TreeEntry, string, error) { + if !te.IsLink() { + return nil, "", ErrBadLink{te.Name(), "not a symlink"} + } + entry := te + entryLink := "" + for i := 0; i < 999; i++ { + if entry.IsLink() { + next, link, err := entry.FollowLink() + entryLink = link + if err != nil { + return nil, "", err + } + if next.ID == entry.ID { + return nil, "", ErrBadLink{ + entry.Name(), + "recursive link", + } + } + entry = next + } else { + break + } + } + if entry.IsLink() { + return nil, "", ErrBadLink{ + te.Name(), + "too many levels of symbolic links", + } + } + return entry, entryLink, nil +} + +// returns the Tree pointed to by this TreeEntry, or nil if this is not a tree +func (te *TreeEntry) Tree() *Tree { + t, err := te.ptree.repo.getTree(te.ID) + if err != nil { + return nil + } + t.ptree = te.ptree + return t +} + +// GetSubJumpablePathName return the full path of subdirectory jumpable ( contains only one directory ) +func (te *TreeEntry) GetSubJumpablePathName() string { + if te.IsSubModule() || !te.IsDir() { + return "" + } + tree, err := te.ptree.SubTree(te.Name()) + if err != nil { + return te.Name() + } + entries, _ := tree.ListEntries() + if len(entries) == 1 && entries[0].IsDir() { + name := entries[0].GetSubJumpablePathName() + if name != "" { + return te.Name() + "/" + name + } + } + return te.Name() +} + +// 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}) +} diff --git a/modules/git/tree_entry_mode.go b/modules/git/tree_entry_mode.go new file mode 100644 index 0000000..a399118 --- /dev/null +++ b/modules/git/tree_entry_mode.go @@ -0,0 +1,35 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import "strconv" + +// EntryMode the type of the object in the git tree +type EntryMode int + +// There are only a few file modes in Git. They look like unix file modes, but they can only be +// one of these. +const ( + // EntryModeBlob + EntryModeBlob EntryMode = 0o100644 + // EntryModeExec + EntryModeExec EntryMode = 0o100755 + // EntryModeSymlink + EntryModeSymlink EntryMode = 0o120000 + // EntryModeCommit + EntryModeCommit EntryMode = 0o160000 + // EntryModeTree + EntryModeTree EntryMode = 0o040000 +) + +// String converts an EntryMode to a string +func (e EntryMode) String() string { + return strconv.FormatInt(int64(e), 8) +} + +// ToEntryMode converts a string to an EntryMode +func ToEntryMode(value string) EntryMode { + v, _ := strconv.ParseInt(value, 8, 32) + return EntryMode(v) +} diff --git a/modules/git/tree_test.go b/modules/git/tree_test.go new file mode 100644 index 0000000..6e5d7f4 --- /dev/null +++ b/modules/git/tree_test.go @@ -0,0 +1,28 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSubTree_Issue29101(t *testing.T) { + repo, err := openRepositoryWithDefaultContext(filepath.Join(testReposDir, "repo1_bare")) + require.NoError(t, err) + defer repo.Close() + + commit, err := repo.GetCommit("ce064814f4a0d337b333e646ece456cd39fab612") + require.NoError(t, err) + + // old code could produce a different error if called multiple times + for i := 0; i < 10; i++ { + _, err = commit.SubTree("file1.txt") + require.Error(t, err) + assert.True(t, IsErrNotExist(err)) + } +} diff --git a/modules/git/url/url.go b/modules/git/url/url.go new file mode 100644 index 0000000..6376851 --- /dev/null +++ b/modules/git/url/url.go @@ -0,0 +1,89 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package url + +import ( + "fmt" + stdurl "net/url" + "strings" +) + +// ErrWrongURLFormat represents an error with wrong url format +type ErrWrongURLFormat struct { + URL string +} + +func (err ErrWrongURLFormat) Error() string { + return fmt.Sprintf("git URL %s format is wrong", err.URL) +} + +// GitURL represents a git URL +type GitURL struct { + *stdurl.URL + extraMark int // 0 no extra 1 scp 2 file path with no prefix +} + +// String returns the URL's string +func (u *GitURL) String() string { + switch u.extraMark { + case 0: + return u.URL.String() + case 1: + return fmt.Sprintf("%s@%s:%s", u.User.Username(), u.Host, u.Path) + case 2: + return u.Path + default: + return "" + } +} + +// Parse parse all kinds of git URL +func Parse(remote string) (*GitURL, error) { + if strings.Contains(remote, "://") { + u, err := stdurl.Parse(remote) + if err != nil { + return nil, err + } + return &GitURL{URL: u}, nil + } else if strings.Contains(remote, "@") && strings.Contains(remote, ":") { + url := stdurl.URL{ + Scheme: "ssh", + } + squareBrackets := false + lastIndex := -1 + FOR: + for i := 0; i < len(remote); i++ { + switch remote[i] { + case '@': + url.User = stdurl.User(remote[:i]) + lastIndex = i + 1 + case ':': + if !squareBrackets { + url.Host = strings.ReplaceAll(remote[lastIndex:i], "%25", "%") + if len(remote) <= i+1 { + return nil, ErrWrongURLFormat{URL: remote} + } + url.Path = remote[i+1:] + break FOR + } + case '[': + squareBrackets = true + case ']': + squareBrackets = false + } + } + return &GitURL{ + URL: &url, + extraMark: 1, + }, nil + } + + return &GitURL{ + URL: &stdurl.URL{ + Scheme: "file", + Path: remote, + }, + extraMark: 2, + }, nil +} diff --git a/modules/git/url/url_test.go b/modules/git/url/url_test.go new file mode 100644 index 0000000..e1e52c0 --- /dev/null +++ b/modules/git/url/url_test.go @@ -0,0 +1,167 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package url + +import ( + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseGitURLs(t *testing.T) { + kases := []struct { + kase string + expected *GitURL + }{ + { + kase: "git@127.0.0.1:go-gitea/gitea.git", + expected: &GitURL{ + URL: &url.URL{ + Scheme: "ssh", + User: url.User("git"), + Host: "127.0.0.1", + Path: "go-gitea/gitea.git", + }, + extraMark: 1, + }, + }, + { + kase: "git@[fe80:14fc:cec5:c174:d88%2510]:go-gitea/gitea.git", + expected: &GitURL{ + URL: &url.URL{ + Scheme: "ssh", + User: url.User("git"), + Host: "[fe80:14fc:cec5:c174:d88%10]", + Path: "go-gitea/gitea.git", + }, + extraMark: 1, + }, + }, + { + kase: "git@[::1]:go-gitea/gitea.git", + expected: &GitURL{ + URL: &url.URL{ + Scheme: "ssh", + User: url.User("git"), + Host: "[::1]", + Path: "go-gitea/gitea.git", + }, + extraMark: 1, + }, + }, + { + kase: "git@github.com:go-gitea/gitea.git", + expected: &GitURL{ + URL: &url.URL{ + Scheme: "ssh", + User: url.User("git"), + Host: "github.com", + Path: "go-gitea/gitea.git", + }, + extraMark: 1, + }, + }, + { + kase: "ssh://git@github.com/go-gitea/gitea.git", + expected: &GitURL{ + URL: &url.URL{ + Scheme: "ssh", + User: url.User("git"), + Host: "github.com", + Path: "/go-gitea/gitea.git", + }, + extraMark: 0, + }, + }, + { + kase: "ssh://git@[::1]/go-gitea/gitea.git", + expected: &GitURL{ + URL: &url.URL{ + Scheme: "ssh", + User: url.User("git"), + Host: "[::1]", + Path: "/go-gitea/gitea.git", + }, + extraMark: 0, + }, + }, + { + kase: "/repositories/go-gitea/gitea.git", + expected: &GitURL{ + URL: &url.URL{ + Scheme: "file", + Path: "/repositories/go-gitea/gitea.git", + }, + extraMark: 2, + }, + }, + { + kase: "file:///repositories/go-gitea/gitea.git", + expected: &GitURL{ + URL: &url.URL{ + Scheme: "file", + Path: "/repositories/go-gitea/gitea.git", + }, + extraMark: 0, + }, + }, + { + kase: "https://github.com/go-gitea/gitea.git", + expected: &GitURL{ + URL: &url.URL{ + Scheme: "https", + Host: "github.com", + Path: "/go-gitea/gitea.git", + }, + extraMark: 0, + }, + }, + { + kase: "https://git:git@github.com/go-gitea/gitea.git", + expected: &GitURL{ + URL: &url.URL{ + Scheme: "https", + Host: "github.com", + User: url.UserPassword("git", "git"), + Path: "/go-gitea/gitea.git", + }, + extraMark: 0, + }, + }, + { + kase: "https://[fe80:14fc:cec5:c174:d88%2510]:20/go-gitea/gitea.git", + expected: &GitURL{ + URL: &url.URL{ + Scheme: "https", + Host: "[fe80:14fc:cec5:c174:d88%10]:20", + Path: "/go-gitea/gitea.git", + }, + extraMark: 0, + }, + }, + + { + kase: "git://github.com/go-gitea/gitea.git", + expected: &GitURL{ + URL: &url.URL{ + Scheme: "git", + Host: "github.com", + Path: "/go-gitea/gitea.git", + }, + extraMark: 0, + }, + }, + } + + for _, kase := range kases { + t.Run(kase.kase, func(t *testing.T) { + u, err := Parse(kase.kase) + require.NoError(t, err) + assert.EqualValues(t, kase.expected.extraMark, u.extraMark) + assert.EqualValues(t, *kase.expected, *u) + }) + } +} diff --git a/modules/git/utils.go b/modules/git/utils.go new file mode 100644 index 0000000..53211c6 --- /dev/null +++ b/modules/git/utils.go @@ -0,0 +1,138 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "crypto/sha1" + "encoding/hex" + "fmt" + "io" + "os" + "strconv" + "strings" + "sync" +) + +// ObjectCache provides thread-safe cache operations. +type ObjectCache struct { + lock sync.RWMutex + cache map[string]any +} + +func newObjectCache() *ObjectCache { + return &ObjectCache{ + cache: make(map[string]any, 10), + } +} + +// Set add obj to cache +func (oc *ObjectCache) Set(id string, obj any) { + oc.lock.Lock() + defer oc.lock.Unlock() + + oc.cache[id] = obj +} + +// Get get cached obj by id +func (oc *ObjectCache) Get(id string) (any, bool) { + oc.lock.RLock() + defer oc.lock.RUnlock() + + obj, has := oc.cache[id] + return obj, has +} + +// isDir returns true if given path is a directory, +// or returns false when it's a file or does not exist. +func isDir(dir string) bool { + f, e := os.Stat(dir) + if e != nil { + return false + } + return f.IsDir() +} + +// isFile returns true if given path is a file, +// or returns false when it's a directory or does not exist. +func isFile(filePath string) bool { + f, e := os.Stat(filePath) + if e != nil { + return false + } + return !f.IsDir() +} + +// isExist checks whether a file or directory exists. +// It returns false when the file or directory does not exist. +func isExist(path string) bool { + _, err := os.Stat(path) + return err == nil || os.IsExist(err) +} + +// 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) +} + +// ParseBool returns the boolean value represented by the string as per git's git_config_bool +// true will be returned for the result if the string is empty, but valid will be false. +// "true", "yes", "on" are all true, true +// "false", "no", "off" are all false, true +// 0 is false, true +// Any other integer is true, true +// Anything else will return false, false +func ParseBool(value string) (result, valid bool) { + // Empty strings are true but invalid + if len(value) == 0 { + return true, false + } + // These are the git expected true and false values + if strings.EqualFold(value, "true") || strings.EqualFold(value, "yes") || strings.EqualFold(value, "on") { + return true, true + } + if strings.EqualFold(value, "false") || strings.EqualFold(value, "no") || strings.EqualFold(value, "off") { + return false, true + } + // Try a number + intValue, err := strconv.ParseInt(value, 10, 32) + if err != nil { + return false, false + } + return intValue != 0, true +} + +// LimitedReaderCloser is a limited reader closer +type LimitedReaderCloser struct { + R io.Reader + C io.Closer + N int64 +} + +// Read implements io.Reader +func (l *LimitedReaderCloser) Read(p []byte) (n int, err error) { + if l.N <= 0 { + _ = l.C.Close() + return 0, io.EOF + } + if int64(len(p)) > l.N { + p = p[0:l.N] + } + n, err = l.R.Read(p) + l.N -= int64(n) + return n, err +} + +// Close implements io.Closer +func (l *LimitedReaderCloser) Close() error { + return l.C.Close() +} + +func HashFilePathForWebUI(s string) string { + h := sha1.New() + _, _ = h.Write([]byte(s)) + return hex.EncodeToString(h.Sum(nil)) +} diff --git a/modules/git/utils_test.go b/modules/git/utils_test.go new file mode 100644 index 0000000..a8c3fe3 --- /dev/null +++ b/modules/git/utils_test.go @@ -0,0 +1,26 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// This file contains utility functions that are used across multiple tests, +// but not in production code. + +func skipIfSHA256NotSupported(t *testing.T) { + if CheckGitVersionAtLeast("2.42") != nil { + t.Skip("skipping because installed Git version doesn't support SHA256") + } +} + +func TestHashFilePathForWebUI(t *testing.T) { + assert.Equal(t, + "8843d7f92416211de9ebb963ff4ce28125932878", + HashFilePathForWebUI("foobar"), + ) +} diff --git a/modules/gitgraph/graph.go b/modules/gitgraph/graph.go new file mode 100644 index 0000000..331ad6b --- /dev/null +++ b/modules/gitgraph/graph.go @@ -0,0 +1,116 @@ +// Copyright 2016 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitgraph + +import ( + "bufio" + "bytes" + "context" + "os" + "strings" + + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/setting" +) + +// GetCommitGraph return a list of commit (GraphItems) from all branches +func GetCommitGraph(r *git.Repository, page, maxAllowedColors int, hidePRRefs bool, branches, files []string) (*Graph, error) { + format := "DATA:%D|%H|%ad|%h|%s" + + if page == 0 { + page = 1 + } + + graphCmd := git.NewCommand(r.Ctx, "log", "--graph", "--date-order", "--decorate=full") + + if hidePRRefs { + graphCmd.AddArguments("--exclude=" + git.PullPrefix + "*") + } + + if len(branches) == 0 { + graphCmd.AddArguments("--all") + } + + graphCmd.AddArguments("-C", "-M", "--date=iso"). + AddOptionFormat("-n %d", setting.UI.GraphMaxCommitNum*page). + AddOptionFormat("--pretty=format:%s", format) + + if len(branches) > 0 { + graphCmd.AddDynamicArguments(branches...) + } + if len(files) > 0 { + graphCmd.AddDashesAndList(files...) + } + graph := NewGraph() + + stderr := new(strings.Builder) + stdoutReader, stdoutWriter, err := os.Pipe() + if err != nil { + return nil, err + } + commitsToSkip := setting.UI.GraphMaxCommitNum * (page - 1) + + scanner := bufio.NewScanner(stdoutReader) + + if err := graphCmd.Run(&git.RunOpts{ + Dir: r.Path, + Stdout: stdoutWriter, + Stderr: stderr, + PipelineFunc: func(ctx context.Context, cancel context.CancelFunc) error { + _ = stdoutWriter.Close() + defer stdoutReader.Close() + parser := &Parser{} + parser.firstInUse = -1 + parser.maxAllowedColors = maxAllowedColors + if maxAllowedColors > 0 { + parser.availableColors = make([]int, maxAllowedColors) + for i := range parser.availableColors { + parser.availableColors[i] = i + 1 + } + } else { + parser.availableColors = []int{1, 2} + } + for commitsToSkip > 0 && scanner.Scan() { + line := scanner.Bytes() + dataIdx := bytes.Index(line, []byte("DATA:")) + if dataIdx < 0 { + dataIdx = len(line) + } + starIdx := bytes.IndexByte(line, '*') + if starIdx >= 0 && starIdx < dataIdx { + commitsToSkip-- + } + parser.ParseGlyphs(line[:dataIdx]) + } + + row := 0 + + // Skip initial non-commit lines + for scanner.Scan() { + line := scanner.Bytes() + if bytes.IndexByte(line, '*') >= 0 { + if err := parser.AddLineToGraph(graph, row, line); err != nil { + cancel() + return err + } + break + } + parser.ParseGlyphs(line) + } + + for scanner.Scan() { + row++ + line := scanner.Bytes() + if err := parser.AddLineToGraph(graph, row, line); err != nil { + cancel() + return err + } + } + return scanner.Err() + }, + }); err != nil { + return graph, err + } + return graph, nil +} diff --git a/modules/gitgraph/graph_models.go b/modules/gitgraph/graph_models.go new file mode 100644 index 0000000..82f460e --- /dev/null +++ b/modules/gitgraph/graph_models.go @@ -0,0 +1,256 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitgraph + +import ( + "bytes" + "context" + "fmt" + "strings" + + asymkey_model "code.gitea.io/gitea/models/asymkey" + "code.gitea.io/gitea/models/db" + git_model "code.gitea.io/gitea/models/git" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" +) + +// NewGraph creates a basic graph +func NewGraph() *Graph { + graph := &Graph{} + graph.relationCommit = &Commit{ + Row: -1, + Column: -1, + } + graph.Flows = map[int64]*Flow{} + return graph +} + +// Graph represents a collection of flows +type Graph struct { + Flows map[int64]*Flow + Commits []*Commit + MinRow int + MinColumn int + MaxRow int + MaxColumn int + relationCommit *Commit +} + +// Width returns the width of the graph +func (graph *Graph) Width() int { + return graph.MaxColumn - graph.MinColumn + 1 +} + +// Height returns the height of the graph +func (graph *Graph) Height() int { + return graph.MaxRow - graph.MinRow + 1 +} + +// AddGlyph adds glyph to flows +func (graph *Graph) AddGlyph(row, column int, flowID int64, color int, glyph byte) { + flow, ok := graph.Flows[flowID] + if !ok { + flow = NewFlow(flowID, color, row, column) + graph.Flows[flowID] = flow + } + flow.AddGlyph(row, column, glyph) + + if row < graph.MinRow { + graph.MinRow = row + } + if row > graph.MaxRow { + graph.MaxRow = row + } + if column < graph.MinColumn { + graph.MinColumn = column + } + if column > graph.MaxColumn { + graph.MaxColumn = column + } +} + +// AddCommit adds a commit at row, column on flowID with the provided data +func (graph *Graph) AddCommit(row, column int, flowID int64, data []byte) error { + commit, err := NewCommit(row, column, data) + if err != nil { + return err + } + commit.Flow = flowID + graph.Commits = append(graph.Commits, commit) + + graph.Flows[flowID].Commits = append(graph.Flows[flowID].Commits, commit) + return nil +} + +// LoadAndProcessCommits will load the git.Commits for each commit in the graph, +// the associate the commit with the user author, and check the commit verification +// before finally retrieving the latest status +func (graph *Graph) LoadAndProcessCommits(ctx context.Context, repository *repo_model.Repository, gitRepo *git.Repository) error { + var err error + var ok bool + + emails := map[string]*user_model.User{} + keyMap := map[string]bool{} + + for _, c := range graph.Commits { + if len(c.Rev) == 0 { + continue + } + c.Commit, err = gitRepo.GetCommit(c.Rev) + if err != nil { + return fmt.Errorf("GetCommit: %s Error: %w", c.Rev, err) + } + + if c.Commit.Author != nil { + email := c.Commit.Author.Email + if c.User, ok = emails[email]; !ok { + c.User, _ = user_model.GetUserByEmail(ctx, email) + emails[email] = c.User + } + } + + c.Verification = asymkey_model.ParseCommitWithSignature(ctx, c.Commit) + + _ = asymkey_model.CalculateTrustStatus(c.Verification, repository.GetTrustModel(), func(user *user_model.User) (bool, error) { + return repo_model.IsOwnerMemberCollaborator(ctx, repository, user.ID) + }, &keyMap) + + statuses, _, err := git_model.GetLatestCommitStatus(ctx, repository.ID, c.Commit.ID.String(), db.ListOptions{}) + if err != nil { + log.Error("GetLatestCommitStatus: %v", err) + } else { + c.Status = git_model.CalcCommitStatus(statuses) + } + } + return nil +} + +// NewFlow creates a new flow +func NewFlow(flowID int64, color, row, column int) *Flow { + return &Flow{ + ID: flowID, + ColorNumber: color, + MinRow: row, + MinColumn: column, + MaxRow: row, + MaxColumn: column, + } +} + +// Flow represents a series of glyphs +type Flow struct { + ID int64 + ColorNumber int + Glyphs []Glyph + Commits []*Commit + MinRow int + MinColumn int + MaxRow int + MaxColumn int +} + +// Color16 wraps the color numbers around mod 16 +func (flow *Flow) Color16() int { + return flow.ColorNumber % 16 +} + +// AddGlyph adds glyph at row and column +func (flow *Flow) AddGlyph(row, column int, glyph byte) { + if row < flow.MinRow { + flow.MinRow = row + } + if row > flow.MaxRow { + flow.MaxRow = row + } + if column < flow.MinColumn { + flow.MinColumn = column + } + if column > flow.MaxColumn { + flow.MaxColumn = column + } + + flow.Glyphs = append(flow.Glyphs, Glyph{ + row, + column, + glyph, + }) +} + +// Glyph represents a coordinate and glyph +type Glyph struct { + Row int + Column int + Glyph byte +} + +// RelationCommit represents an empty relation commit +var RelationCommit = &Commit{ + Row: -1, +} + +// NewCommit creates a new commit from a provided line +func NewCommit(row, column int, line []byte) (*Commit, error) { + data := bytes.SplitN(line, []byte("|"), 5) + if len(data) < 5 { + return nil, fmt.Errorf("malformed data section on line %d with commit: %s", row, string(line)) + } + return &Commit{ + Row: row, + Column: column, + // 0 matches git log --pretty=format:%d => ref names, like the --decorate option of git-log(1) + Refs: newRefsFromRefNames(data[0]), + // 1 matches git log --pretty=format:%H => commit hash + Rev: string(data[1]), + // 2 matches git log --pretty=format:%ad => author date (format respects --date= option) + Date: string(data[2]), + // 3 matches git log --pretty=format:%h => abbreviated commit hash + ShortRev: string(data[3]), + // 4 matches git log --pretty=format:%s => subject + Subject: string(data[4]), + }, nil +} + +func newRefsFromRefNames(refNames []byte) []git.Reference { + refBytes := bytes.Split(refNames, []byte{',', ' '}) + refs := make([]git.Reference, 0, len(refBytes)) + for _, refNameBytes := range refBytes { + if len(refNameBytes) == 0 { + continue + } + refName := string(refNameBytes) + if strings.HasPrefix(refName, "tag: ") { + refName = strings.TrimPrefix(refName, "tag: ") + } else { + refName = strings.TrimPrefix(refName, "HEAD -> ") + } + refs = append(refs, git.Reference{ + Name: refName, + }) + } + return refs +} + +// Commit represents a commit at coordinate X, Y with the data +type Commit struct { + Commit *git.Commit + User *user_model.User + Verification *asymkey_model.ObjectVerification + Status *git_model.CommitStatus + Flow int64 + Row int + Column int + Refs []git.Reference + Rev string + Date string + ShortRev string + Subject string +} + +// OnlyRelation returns whether this a relation only commit +func (c *Commit) OnlyRelation() bool { + return c.Row == -1 +} diff --git a/modules/gitgraph/graph_test.go b/modules/gitgraph/graph_test.go new file mode 100644 index 0000000..18d427a --- /dev/null +++ b/modules/gitgraph/graph_test.go @@ -0,0 +1,714 @@ +// Copyright 2016 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitgraph + +import ( + "bytes" + "fmt" + "strings" + "testing" + + "code.gitea.io/gitea/modules/git" +) + +func BenchmarkGetCommitGraph(b *testing.B) { + currentRepo, err := git.OpenRepository(git.DefaultContext, ".") + if err != nil || currentRepo == nil { + b.Error("Could not open repository") + } + defer currentRepo.Close() + + for i := 0; i < b.N; i++ { + graph, err := GetCommitGraph(currentRepo, 1, 0, false, nil, nil) + if err != nil { + b.Error("Could get commit graph") + } + + if len(graph.Commits) < 100 { + b.Error("Should get 100 log lines.") + } + } +} + +func BenchmarkParseCommitString(b *testing.B) { + testString := "* DATA:|4e61bacab44e9b4730e44a6615d04098dd3a8eaf|2016-12-20 21:10:41 +0100|4e61bac|Add route for graph" + + parser := &Parser{} + parser.Reset() + for i := 0; i < b.N; i++ { + parser.Reset() + graph := NewGraph() + if err := parser.AddLineToGraph(graph, 0, []byte(testString)); err != nil { + b.Error("could not parse teststring") + } + if graph.Flows[1].Commits[0].Rev != "4e61bacab44e9b4730e44a6615d04098dd3a8eaf" { + b.Error("Did not get expected data") + } + } +} + +func BenchmarkParseGlyphs(b *testing.B) { + parser := &Parser{} + parser.Reset() + tgBytes := []byte(testglyphs) + var tg []byte + for i := 0; i < b.N; i++ { + parser.Reset() + tg = tgBytes + idx := bytes.Index(tg, []byte("\n")) + for idx > 0 { + parser.ParseGlyphs(tg[:idx]) + tg = tg[idx+1:] + idx = bytes.Index(tg, []byte("\n")) + } + } +} + +func TestReleaseUnusedColors(t *testing.T) { + testcases := []struct { + availableColors []int + oldColors []int + firstInUse int // these values have to be either be correct or suggest less is + firstAvailable int // available than possibly is - i.e. you cannot say 10 is available when it + }{ + { + availableColors: []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, + oldColors: []int{1, 1, 1, 1, 1}, + firstAvailable: -1, + firstInUse: 1, + }, + { + availableColors: []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, + oldColors: []int{1, 2, 3, 4}, + firstAvailable: 6, + firstInUse: 0, + }, + { + availableColors: []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, + oldColors: []int{6, 0, 3, 5, 3, 4, 0, 0}, + firstAvailable: 6, + firstInUse: 0, + }, + { + availableColors: []int{1, 2, 3, 4, 5, 6, 7}, + oldColors: []int{6, 1, 3, 5, 3, 4, 2, 7}, + firstAvailable: -1, + firstInUse: 0, + }, + { + availableColors: []int{1, 2, 3, 4, 5, 6, 7}, + oldColors: []int{6, 0, 3, 5, 3, 4, 2, 7}, + firstAvailable: -1, + firstInUse: 0, + }, + } + for _, testcase := range testcases { + parser := &Parser{} + parser.Reset() + parser.availableColors = append([]int{}, testcase.availableColors...) + parser.oldColors = append(parser.oldColors, testcase.oldColors...) + parser.firstAvailable = testcase.firstAvailable + parser.firstInUse = testcase.firstInUse + parser.releaseUnusedColors() + + if parser.firstAvailable == -1 { + // All in use + for _, color := range parser.availableColors { + found := false + for _, oldColor := range parser.oldColors { + if oldColor == color { + found = true + break + } + } + if !found { + t.Errorf("In testcase:\n%d\t%d\t%d %d =>\n%d\t%d\t%d %d: %d should be available but is not", + testcase.availableColors, + testcase.oldColors, + testcase.firstAvailable, + testcase.firstInUse, + parser.availableColors, + parser.oldColors, + parser.firstAvailable, + parser.firstInUse, + color) + } + } + } else if parser.firstInUse != -1 { + // Some in use + for i := parser.firstInUse; i != parser.firstAvailable; i = (i + 1) % len(parser.availableColors) { + color := parser.availableColors[i] + found := false + for _, oldColor := range parser.oldColors { + if oldColor == color { + found = true + break + } + } + if !found { + t.Errorf("In testcase:\n%d\t%d\t%d %d =>\n%d\t%d\t%d %d: %d should be available but is not", + testcase.availableColors, + testcase.oldColors, + testcase.firstAvailable, + testcase.firstInUse, + parser.availableColors, + parser.oldColors, + parser.firstAvailable, + parser.firstInUse, + color) + } + } + for i := parser.firstAvailable; i != parser.firstInUse; i = (i + 1) % len(parser.availableColors) { + color := parser.availableColors[i] + found := false + for _, oldColor := range parser.oldColors { + if oldColor == color { + found = true + break + } + } + if found { + t.Errorf("In testcase:\n%d\t%d\t%d %d =>\n%d\t%d\t%d %d: %d should not be available but is", + testcase.availableColors, + testcase.oldColors, + testcase.firstAvailable, + testcase.firstInUse, + parser.availableColors, + parser.oldColors, + parser.firstAvailable, + parser.firstInUse, + color) + } + } + } else { + // None in use + for _, color := range parser.oldColors { + if color != 0 { + t.Errorf("In testcase:\n%d\t%d\t%d %d =>\n%d\t%d\t%d %d: %d should not be available but is", + testcase.availableColors, + testcase.oldColors, + testcase.firstAvailable, + testcase.firstInUse, + parser.availableColors, + parser.oldColors, + parser.firstAvailable, + parser.firstInUse, + color) + } + } + } + } +} + +func TestParseGlyphs(t *testing.T) { + parser := &Parser{} + parser.Reset() + tgBytes := []byte(testglyphs) + tg := tgBytes + idx := bytes.Index(tg, []byte("\n")) + row := 0 + for idx > 0 { + parser.ParseGlyphs(tg[:idx]) + tg = tg[idx+1:] + idx = bytes.Index(tg, []byte("\n")) + if parser.flows[0] != 1 { + t.Errorf("First column flow should be 1 but was %d", parser.flows[0]) + } + colorToFlow := map[int]int64{} + flowToColor := map[int64]int{} + + for i, flow := range parser.flows { + if flow == 0 { + continue + } + color := parser.colors[i] + + if fColor, in := flowToColor[flow]; in && fColor != color { + t.Errorf("Row %d column %d flow %d has color %d but should be %d", row, i, flow, color, fColor) + } + flowToColor[flow] = color + if cFlow, in := colorToFlow[color]; in && cFlow != flow { + t.Errorf("Row %d column %d flow %d has color %d but conflicts with flow %d", row, i, flow, color, cFlow) + } + colorToFlow[color] = flow + } + row++ + } + if len(parser.availableColors) != 9 { + t.Errorf("Expected 9 colors but have %d", len(parser.availableColors)) + } +} + +func TestCommitStringParsing(t *testing.T) { + dataFirstPart := "* DATA:|4e61bacab44e9b4730e44a6615d04098dd3a8eaf|2016-12-20 21:10:41 +0100|4e61bac|" + tests := []struct { + shouldPass bool + testName string + commitMessage string + }{ + {true, "normal", "not a fancy message"}, + {true, "extra pipe", "An extra pipe: |"}, + {true, "extra 'Data:'", "DATA: might be trouble"}, + } + + for _, test := range tests { + t.Run(test.testName, func(t *testing.T) { + testString := fmt.Sprintf("%s%s", dataFirstPart, test.commitMessage) + idx := strings.Index(testString, "DATA:") + commit, err := NewCommit(0, 0, []byte(testString[idx+5:])) + if err != nil && test.shouldPass { + t.Errorf("Could not parse %s", testString) + return + } + + if test.commitMessage != commit.Subject { + t.Errorf("%s does not match %s", test.commitMessage, commit.Subject) + } + }) + } +} + +var testglyphs = `* +* +* +* +* +* +* +* +|\ +* | +* | +* | +* | +* | +| * +* | +| * +| |\ +* | | +| | * +| | |\ +* | | \ +|\ \ \ \ +| * | | | +| |\| | | +* | | | | +|/ / / / +| | | * +| * | | +| * | | +| * | | +* | | | +* | | | +* | | | +* | | | +* | | | +|\ \ \ \ +| | * | | +| | |\| | +| | | * | +| | | | * +* | | | | +* | | | | +* | | | | +* | | | | +* | | | | +|\ \ \ \ \ +| * | | | | +|/| | | | | +| | |/ / / +| |/| | | +| | | | * +| * | | | +|/| | | | +| * | | | +|/| | | | +| | |/ / +| |/| | +| * | | +| * | | +| |\ \ \ +| | * | | +| |/| | | +| | | |/ +| | |/| +| * | | +| * | | +| * | | +| | * | +| | |\ \ +| | | * | +| | |/| | +| | | * | +| | | |\ \ +| | | | * | +| | | |/| | +| | * | | | +| | * | | | +| | |\ \ \ \ +| | | * | | | +| | |/| | | | +| | | | | * | +| | | | |/ / +* | | | / / +|/ / / / / +* | | | | +|\ \ \ \ \ +| * | | | | +|/| | | | | +| * | | | | +| * | | | | +| |\ \ \ \ \ +| | | * \ \ \ +| | | |\ \ \ \ +| | | | * | | | +| | | |/| | | | +| | | | | |/ / +| | | | |/| | +* | | | | | | +* | | | | | | +* | | | | | | +| | | | * | | +* | | | | | | +| | * | | | | +| |/| | | | | +* | | | | | | +| |/ / / / / +|/| | | | | +| | | | * | +| | | |/ / +| | |/| | +| * | | | +| | | | * +| | * | | +| | |\ \ \ +| | | * | | +| | |/| | | +| | | |/ / +| | | * | +| | * | | +| | |\ \ \ +| | | * | | +| | |/| | | +| | | |/ / +| | | * | +* | | | | +|\ \ \ \ \ +| * \ \ \ \ +| |\ \ \ \ \ +| | | |/ / / +| | |/| | | +| | | | * | +| | | | * | +* | | | | | +* | | | | | +|/ / / / / +| | | * | +* | | | | +* | | | | +* | | | | +* | | | | +|\ \ \ \ \ +| * | | | | +|/| | | | | +| | * | | | +| | |\ \ \ \ +| | | * | | | +| | |/| | | | +| |/| | |/ / +| | | |/| | +| | | | | * +| |_|_|_|/ +|/| | | | +| | * | | +| |/ / / +* | | | +* | | | +| | * | +* | | | +* | | | +| * | | +| | * | +| * | | +* | | | +|\ \ \ \ +| * | | | +|/| | | | +| |/ / / +| * | | +| |\ \ \ +| | * | | +| |/| | | +| | |/ / +| | * | +| | |\ \ +| | | * | +| | |/| | +* | | | | +* | | | | +|\ \ \ \ \ +| * | | | | +|/| | | | | +| | * | | | +| | * | | | +| | * | | | +| |/ / / / +| * | | | +| |\ \ \ \ +| | * | | | +| |/| | | | +* | | | | | +* | | | | | +* | | | | | +* | | | | | +* | | | | | +| | | | * | +* | | | | | +|\ \ \ \ \ \ +| * | | | | | +|/| | | | | | +| | | | | * | +| | | | |/ / +* | | | | | +|\ \ \ \ \ \ +* | | | | | | +* | | | | | | +| | | | * | | +* | | | | | | +* | | | | | | +|\ \ \ \ \ \ \ +| | |_|_|/ / / +| |/| | | | | +| | | | * | | +| | | | * | | +| | | | * | | +| | | | * | | +| | | | * | | +| | | | * | | +| | | |/ / / +| | | * | | +| | | * | | +| | | * | | +| | |/| | | +| | | * | | +| | |/| | | +| | | |/ / +| | * | | +| |/| | | +| | | * | +| | |/ / +| | * | +| * | | +| |\ \ \ +| * | | | +| | * | | +| |/| | | +| | |/ / +| | * | +| | |\ \ +| | * | | +* | | | | +|\| | | | +| * | | | +| * | | | +| * | | | +| | * | | +| * | | | +| |\| | | +| * | | | +| | * | | +| | * | | +| * | | | +| * | | | +| * | | | +| * | | | +| * | | | +| * | | | +| * | | | +| * | | | +| | * | | +| * | | | +| * | | | +| * | | | +| * | | | +| | * | | +* | | | | +|\| | | | +| | * | | +| * | | | +| |\| | | +| | * | | +| | * | | +| | * | | +| | | * | +* | | | | +|\| | | | +| | * | | +| | |/ / +| * | | +| * | | +| |\| | +* | | | +|\| | | +| | * | +| | * | +| | * | +| * | | +| | * | +| * | | +| | * | +| | * | +| | * | +| * | | +| * | | +| * | | +| * | | +| * | | +| * | | +| * | | +* | | | +|\| | | +| * | | +| |\| | +| | * | +| | |\ \ +* | | | | +|\| | | | +| * | | | +| |\| | | +| | * | | +| | | * | +| | |/ / +* | | | +* | | | +|\| | | +| * | | +| |\| | +| | * | +| | * | +| | * | +| | | * +* | | | +|\| | | +| * | | +| * | | +| | | * +| | | |\ +* | | | | +| |_|_|/ +|/| | | +| * | | +| |\| | +| | * | +| | * | +| | * | +| | * | +| | * | +| * | | +* | | | +|\| | | +| * | | +|/| | | +| |/ / +| * | +| |\ \ +| * | | +| * | | +* | | | +|\| | | +| | * | +| * | | +| * | | +| * | | +* | | | +|\| | | +| * | | +| * | | +| | * | +| | |\ \ +| | |/ / +| |/| | +| * | | +* | | | +|\| | | +| * | | +* | | | +|\| | | +| * | | +| |\ \ \ +| * | | | +| * | | | +| | | * | +| * | | | +| * | | | +| | |/ / +| |/| | +| | * | +* | | | +|\| | | +| * | | +| * | | +| * | | +| * | | +| * | | +| |\ \ \ +* | | | | +|\| | | | +| * | | | +| * | | | +* | | | | +* | | | | +|\| | | | +| | | | * +| | | | |\ +| |_|_|_|/ +|/| | | | +| * | | | +* | | | | +* | | | | +|\| | | | +| * | | | +| |\ \ \ \ +| | | |/ / +| | |/| | +| * | | | +| * | | | +| * | | | +| * | | | +| | * | | +| | | * | +| | |/ / +| |/| | +* | | | +|\| | | +| * | | +| * | | +| * | | +| * | | +| * | | +* | | | +|\| | | +| * | | +| * | | +* | | | +| * | | +| * | | +| * | | +* | | | +* | | | +* | | | +|\| | | +| * | | +* | | | +* | | | +* | | | +* | | | +| | | * +* | | | +|\| | | +| * | | +| * | | +| * | | +` diff --git a/modules/gitgraph/parser.go b/modules/gitgraph/parser.go new file mode 100644 index 0000000..f6bf9b0 --- /dev/null +++ b/modules/gitgraph/parser.go @@ -0,0 +1,336 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitgraph + +import ( + "bytes" + "fmt" +) + +// Parser represents a git graph parser. It is stateful containing the previous +// glyphs, detected flows and color assignments. +type Parser struct { + glyphs []byte + oldGlyphs []byte + flows []int64 + oldFlows []int64 + maxFlow int64 + colors []int + oldColors []int + availableColors []int + nextAvailable int + firstInUse int + firstAvailable int + maxAllowedColors int +} + +// Reset resets the internal parser state. +func (parser *Parser) Reset() { + parser.glyphs = parser.glyphs[0:0] + parser.oldGlyphs = parser.oldGlyphs[0:0] + parser.flows = parser.flows[0:0] + parser.oldFlows = parser.oldFlows[0:0] + parser.maxFlow = 0 + parser.colors = parser.colors[0:0] + parser.oldColors = parser.oldColors[0:0] + parser.availableColors = parser.availableColors[0:0] + parser.availableColors = append(parser.availableColors, 1, 2) + parser.nextAvailable = 0 + parser.firstInUse = -1 + parser.firstAvailable = 0 + parser.maxAllowedColors = 0 +} + +// AddLineToGraph adds the line as a row to the graph +func (parser *Parser) AddLineToGraph(graph *Graph, row int, line []byte) error { + idx := bytes.Index(line, []byte("DATA:")) + if idx < 0 { + parser.ParseGlyphs(line) + } else { + parser.ParseGlyphs(line[:idx]) + } + + var err error + commitDone := false + + for column, glyph := range parser.glyphs { + if glyph == ' ' { + continue + } + + flowID := parser.flows[column] + + graph.AddGlyph(row, column, flowID, parser.colors[column], glyph) + + if glyph == '*' { + if commitDone { + if err != nil { + err = fmt.Errorf("double commit on line %d: %s. %w", row, string(line), err) + } else { + err = fmt.Errorf("double commit on line %d: %s", row, string(line)) + } + } + commitDone = true + if idx < 0 { + if err != nil { + err = fmt.Errorf("missing data section on line %d with commit: %s. %w", row, string(line), err) + } else { + err = fmt.Errorf("missing data section on line %d with commit: %s", row, string(line)) + } + continue + } + err2 := graph.AddCommit(row, column, flowID, line[idx+5:]) + if err != nil && err2 != nil { + err = fmt.Errorf("%v %w", err2, err) + continue + } else if err2 != nil { + err = err2 + continue + } + } + } + if !commitDone { + graph.Commits = append(graph.Commits, RelationCommit) + } + return err +} + +func (parser *Parser) releaseUnusedColors() { + if parser.firstInUse > -1 { + // Here we step through the old colors, searching for them in the + // "in-use" section of availableColors (that is, the colors between + // firstInUse and firstAvailable) + // Ensure that the benchmarks are not worsened with proposed changes + stepstaken := 0 + position := parser.firstInUse + for _, color := range parser.oldColors { + if color == 0 { + continue + } + found := false + i := position + for j := stepstaken; i != parser.firstAvailable && j < len(parser.availableColors); j++ { + colorToCheck := parser.availableColors[i] + if colorToCheck == color { + found = true + break + } + i = (i + 1) % len(parser.availableColors) + } + if !found { + // Duplicate color + continue + } + // Swap them around + parser.availableColors[position], parser.availableColors[i] = parser.availableColors[i], parser.availableColors[position] + stepstaken++ + position = (parser.firstInUse + stepstaken) % len(parser.availableColors) + if position == parser.firstAvailable || stepstaken == len(parser.availableColors) { + break + } + } + if stepstaken == len(parser.availableColors) { + parser.firstAvailable = -1 + } else { + parser.firstAvailable = position + if parser.nextAvailable == -1 { + parser.nextAvailable = parser.firstAvailable + } + } + } +} + +// ParseGlyphs parses the provided glyphs and sets the internal state +func (parser *Parser) ParseGlyphs(glyphs []byte) { + // Clean state for parsing this row + parser.glyphs, parser.oldGlyphs = parser.oldGlyphs, parser.glyphs + parser.glyphs = parser.glyphs[0:0] + parser.flows, parser.oldFlows = parser.oldFlows, parser.flows + parser.flows = parser.flows[0:0] + parser.colors, parser.oldColors = parser.oldColors, parser.colors + + // Ensure we have enough flows and colors + parser.colors = parser.colors[0:0] + for range glyphs { + parser.flows = append(parser.flows, 0) + parser.colors = append(parser.colors, 0) + } + + // Copy the provided glyphs in to state.glyphs for safekeeping + parser.glyphs = append(parser.glyphs, glyphs...) + + // release unused colors + parser.releaseUnusedColors() + + for i := len(glyphs) - 1; i >= 0; i-- { + glyph := glyphs[i] + switch glyph { + case '|': + fallthrough + case '*': + parser.setUpFlow(i) + case '/': + parser.setOutFlow(i) + case '\\': + parser.setInFlow(i) + case '_': + parser.setRightFlow(i) + case '.': + fallthrough + case '-': + parser.setLeftFlow(i) + case ' ': + // no-op + default: + parser.newFlow(i) + } + } +} + +func (parser *Parser) takePreviousFlow(i, j int) { + if j < len(parser.oldFlows) && parser.oldFlows[j] > 0 { + parser.flows[i] = parser.oldFlows[j] + parser.oldFlows[j] = 0 + parser.colors[i] = parser.oldColors[j] + parser.oldColors[j] = 0 + } else { + parser.newFlow(i) + } +} + +func (parser *Parser) takeCurrentFlow(i, j int) { + if j < len(parser.flows) && parser.flows[j] > 0 { + parser.flows[i] = parser.flows[j] + parser.colors[i] = parser.colors[j] + } else { + parser.newFlow(i) + } +} + +func (parser *Parser) newFlow(i int) { + parser.maxFlow++ + parser.flows[i] = parser.maxFlow + + // Now give this flow a color + if parser.nextAvailable == -1 { + next := len(parser.availableColors) + if parser.maxAllowedColors < 1 || next < parser.maxAllowedColors { + parser.nextAvailable = next + parser.firstAvailable = next + parser.availableColors = append(parser.availableColors, next+1) + } + } + parser.colors[i] = parser.availableColors[parser.nextAvailable] + if parser.firstInUse == -1 { + parser.firstInUse = parser.nextAvailable + } + parser.availableColors[parser.firstAvailable], parser.availableColors[parser.nextAvailable] = parser.availableColors[parser.nextAvailable], parser.availableColors[parser.firstAvailable] + + parser.nextAvailable = (parser.nextAvailable + 1) % len(parser.availableColors) + parser.firstAvailable = (parser.firstAvailable + 1) % len(parser.availableColors) + + if parser.nextAvailable == parser.firstInUse { + parser.nextAvailable = parser.firstAvailable + } + if parser.nextAvailable == parser.firstInUse { + parser.nextAvailable = -1 + parser.firstAvailable = -1 + } +} + +// setUpFlow handles '|' or '*' +func (parser *Parser) setUpFlow(i int) { + // In preference order: + // + // Previous Row: '\? ' ' |' ' /' + // Current Row: ' | ' ' |' ' | ' + if i > 0 && i-1 < len(parser.oldGlyphs) && parser.oldGlyphs[i-1] == '\\' { + parser.takePreviousFlow(i, i-1) + } else if i < len(parser.oldGlyphs) && (parser.oldGlyphs[i] == '|' || parser.oldGlyphs[i] == '*') { + parser.takePreviousFlow(i, i) + } else if i+1 < len(parser.oldGlyphs) && parser.oldGlyphs[i+1] == '/' { + parser.takePreviousFlow(i, i+1) + } else { + parser.newFlow(i) + } +} + +// setOutFlow handles '/' +func (parser *Parser) setOutFlow(i int) { + // In preference order: + // + // Previous Row: ' |/' ' |_' ' |' ' /' ' _' '\' + // Current Row: '/| ' '/| ' '/ ' '/ ' '/ ' '/' + if i+2 < len(parser.oldGlyphs) && + (parser.oldGlyphs[i+1] == '|' || parser.oldGlyphs[i+1] == '*') && + (parser.oldGlyphs[i+2] == '/' || parser.oldGlyphs[i+2] == '_') && + i+1 < len(parser.glyphs) && + (parser.glyphs[i+1] == '|' || parser.glyphs[i+1] == '*') { + parser.takePreviousFlow(i, i+2) + } else if i+1 < len(parser.oldGlyphs) && + (parser.oldGlyphs[i+1] == '|' || parser.oldGlyphs[i+1] == '*' || + parser.oldGlyphs[i+1] == '/' || parser.oldGlyphs[i+1] == '_') { + parser.takePreviousFlow(i, i+1) + if parser.oldGlyphs[i+1] == '/' { + parser.glyphs[i] = '|' + } + } else if i < len(parser.oldGlyphs) && parser.oldGlyphs[i] == '\\' { + parser.takePreviousFlow(i, i) + } else { + parser.newFlow(i) + } +} + +// setInFlow handles '\' +func (parser *Parser) setInFlow(i int) { + // In preference order: + // + // Previous Row: '| ' '-. ' '| ' '\ ' '/' '---' + // Current Row: '|\' ' \' ' \' ' \' '\' ' \ ' + if i > 0 && i-1 < len(parser.oldGlyphs) && + (parser.oldGlyphs[i-1] == '|' || parser.oldGlyphs[i-1] == '*') && + (parser.glyphs[i-1] == '|' || parser.glyphs[i-1] == '*') { + parser.newFlow(i) + } else if i > 0 && i-1 < len(parser.oldGlyphs) && + (parser.oldGlyphs[i-1] == '|' || parser.oldGlyphs[i-1] == '*' || + parser.oldGlyphs[i-1] == '.' || parser.oldGlyphs[i-1] == '\\') { + parser.takePreviousFlow(i, i-1) + if parser.oldGlyphs[i-1] == '\\' { + parser.glyphs[i] = '|' + } + } else if i < len(parser.oldGlyphs) && parser.oldGlyphs[i] == '/' { + parser.takePreviousFlow(i, i) + } else { + parser.newFlow(i) + } +} + +// setRightFlow handles '_' +func (parser *Parser) setRightFlow(i int) { + // In preference order: + // + // Current Row: '__' '_/' '_|_' '_|/' + if i+1 < len(parser.glyphs) && + (parser.glyphs[i+1] == '_' || parser.glyphs[i+1] == '/') { + parser.takeCurrentFlow(i, i+1) + } else if i+2 < len(parser.glyphs) && + (parser.glyphs[i+1] == '|' || parser.glyphs[i+1] == '*') && + (parser.glyphs[i+2] == '_' || parser.glyphs[i+2] == '/') { + parser.takeCurrentFlow(i, i+2) + } else { + parser.newFlow(i) + } +} + +// setLeftFlow handles '----.' +func (parser *Parser) setLeftFlow(i int) { + if parser.glyphs[i] == '.' { + parser.newFlow(i) + } else if i+1 < len(parser.glyphs) && + (parser.glyphs[i+1] == '-' || parser.glyphs[i+1] == '.') { + parser.takeCurrentFlow(i, i+1) + } else { + parser.newFlow(i) + } +} diff --git a/modules/gitrepo/branch.go b/modules/gitrepo/branch.go new file mode 100644 index 0000000..e13a4c8 --- /dev/null +++ b/modules/gitrepo/branch.go @@ -0,0 +1,49 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitrepo + +import ( + "context" + + "code.gitea.io/gitea/modules/git" +) + +// GetBranchesByPath returns a branch by its path +// if limit = 0 it will not limit +func GetBranchesByPath(ctx context.Context, repo Repository, skip, limit int) ([]*git.Branch, int, error) { + gitRepo, err := OpenRepository(ctx, repo) + if err != nil { + return nil, 0, err + } + defer gitRepo.Close() + + return gitRepo.GetBranches(skip, limit) +} + +func GetBranchCommitID(ctx context.Context, repo Repository, branch string) (string, error) { + gitRepo, err := OpenRepository(ctx, repo) + if err != nil { + return "", err + } + defer gitRepo.Close() + + return gitRepo.GetBranchCommitID(branch) +} + +// SetDefaultBranch sets default branch of repository. +func SetDefaultBranch(ctx context.Context, repo Repository, name string) error { + _, _, err := git.NewCommand(ctx, "symbolic-ref", "HEAD"). + AddDynamicArguments(git.BranchPrefix + name). + RunStdString(&git.RunOpts{Dir: repoPath(repo)}) + return err +} + +// GetDefaultBranch gets default branch of repository. +func GetDefaultBranch(ctx context.Context, repo Repository) (string, error) { + return git.GetDefaultBranch(ctx, repoPath(repo)) +} + +func GetWikiDefaultBranch(ctx context.Context, repo Repository) (string, error) { + return git.GetDefaultBranch(ctx, wikiPath(repo)) +} diff --git a/modules/gitrepo/gitrepo.go b/modules/gitrepo/gitrepo.go new file mode 100644 index 0000000..d89f8f9 --- /dev/null +++ b/modules/gitrepo/gitrepo.go @@ -0,0 +1,103 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitrepo + +import ( + "context" + "io" + "path/filepath" + "strings" + + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/setting" +) + +type Repository interface { + GetName() string + GetOwnerName() string +} + +func repoPath(repo Repository) string { + return filepath.Join(setting.RepoRootPath, strings.ToLower(repo.GetOwnerName()), strings.ToLower(repo.GetName())+".git") +} + +func wikiPath(repo Repository) string { + return filepath.Join(setting.RepoRootPath, strings.ToLower(repo.GetOwnerName()), strings.ToLower(repo.GetName())+".wiki.git") +} + +// OpenRepository opens the repository at the given relative path with the provided context. +func OpenRepository(ctx context.Context, repo Repository) (*git.Repository, error) { + return git.OpenRepository(ctx, repoPath(repo)) +} + +func OpenWikiRepository(ctx context.Context, repo Repository) (*git.Repository, error) { + return git.OpenRepository(ctx, wikiPath(repo)) +} + +// contextKey is a value for use with context.WithValue. +type contextKey struct { + name string +} + +// RepositoryContextKey is a context key. It is used with context.Value() to get the current Repository for the context +var RepositoryContextKey = &contextKey{"repository"} + +// RepositoryFromContext attempts to get the repository from the context +func repositoryFromContext(ctx context.Context, repo Repository) *git.Repository { + value := ctx.Value(RepositoryContextKey) + if value == nil { + return nil + } + + if gitRepo, ok := value.(*git.Repository); ok && gitRepo != nil { + if gitRepo.Path == repoPath(repo) { + return gitRepo + } + } + + return nil +} + +type nopCloser func() + +func (nopCloser) Close() error { return nil } + +// RepositoryFromContextOrOpen attempts to get the repository from the context or just opens it +func RepositoryFromContextOrOpen(ctx context.Context, repo Repository) (*git.Repository, io.Closer, error) { + gitRepo := repositoryFromContext(ctx, repo) + if gitRepo != nil { + return gitRepo, nopCloser(nil), nil + } + + gitRepo, err := OpenRepository(ctx, repo) + return gitRepo, gitRepo, err +} + +// repositoryFromContextPath attempts to get the repository from the context +func repositoryFromContextPath(ctx context.Context, path string) *git.Repository { + value := ctx.Value(RepositoryContextKey) + if value == nil { + return nil + } + + if repo, ok := value.(*git.Repository); ok && repo != nil { + if repo.Path == path { + return repo + } + } + + return nil +} + +// RepositoryFromContextOrOpenPath attempts to get the repository from the context or just opens it +// Deprecated: Use RepositoryFromContextOrOpen instead +func RepositoryFromContextOrOpenPath(ctx context.Context, path string) (*git.Repository, io.Closer, error) { + gitRepo := repositoryFromContextPath(ctx, path) + if gitRepo != nil { + return gitRepo, nopCloser(nil), nil + } + + gitRepo, err := git.OpenRepository(ctx, path) + return gitRepo, gitRepo, err +} diff --git a/modules/gitrepo/walk.go b/modules/gitrepo/walk.go new file mode 100644 index 0000000..8c672ea --- /dev/null +++ b/modules/gitrepo/walk.go @@ -0,0 +1,15 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitrepo + +import ( + "context" + + "code.gitea.io/gitea/modules/git" +) + +// WalkReferences walks all the references from the repository +func WalkReferences(ctx context.Context, repo Repository, walkfn func(sha1, refname string) error) (int, error) { + return git.WalkShowRef(ctx, repoPath(repo), nil, 0, 0, walkfn) +} |