初始提交: Gitea 项目代码

This commit is contained in:
root
2026-05-30 22:47:36 +08:00
commit f288f76350
6116 changed files with 776822 additions and 0 deletions
+80
View File
@@ -0,0 +1,80 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitrepo
import (
"context"
"fmt"
"io"
"os"
"path"
"path/filepath"
"slices"
"strings"
"gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/setting"
)
// CreateArchive create archive content to the target path
func CreateArchive(ctx context.Context, repo Repository, format string, target io.Writer, usePrefix bool, commitID string, paths []string) error {
if format == "unknown" {
return fmt.Errorf("unknown format: %v", format)
}
cmd := gitcmd.NewCommand("archive")
if usePrefix {
cmd.AddOptionFormat("--prefix=%s", filepath.Base(strings.TrimSuffix(repo.RelativePath(), ".git"))+"/")
}
cmd.AddOptionFormat("--format=%s", format)
cmd.AddDynamicArguments(commitID)
paths = slices.Clone(paths)
for i := range paths {
// although "git archive" already ensures the paths won't go outside the repo, we still clean them here for safety
paths[i] = path.Clean(paths[i])
}
cmd.AddDynamicArguments(paths...)
return RunCmdWithStderr(ctx, repo, cmd.WithStdoutCopy(target))
}
// CreateBundle create bundle content to the target path
func CreateBundle(ctx context.Context, repo Repository, commit string, out io.Writer) error {
tmp, cleanup, err := setting.AppDataTempDir("git-repo-content").MkdirTempRandom("gitea-bundle")
if err != nil {
return err
}
defer cleanup()
env := append(os.Environ(), "GIT_OBJECT_DIRECTORY="+filepath.Join(repoPath(repo), "objects"))
_, _, err = gitcmd.NewCommand("init", "--bare").WithDir(tmp).WithEnv(env).RunStdString(ctx)
if err != nil {
return err
}
_, _, err = gitcmd.NewCommand("reset", "--soft").AddDynamicArguments(commit).WithDir(tmp).WithEnv(env).RunStdString(ctx)
if err != nil {
return err
}
_, _, err = gitcmd.NewCommand("branch", "-m", "bundle").WithDir(tmp).WithEnv(env).RunStdString(ctx)
if err != nil {
return err
}
tmpFile := filepath.Join(tmp, "bundle")
_, _, err = gitcmd.NewCommand("bundle", "create").AddDynamicArguments(tmpFile, "bundle", "HEAD").WithDir(tmp).WithEnv(env).RunStdString(ctx)
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
}
+208
View File
@@ -0,0 +1,208 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitrepo
import (
"bufio"
"bytes"
"context"
"io"
"gitea.dev/modules/git"
"gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/setting"
)
func LineBlame(ctx context.Context, repo Repository, revision, file string, line uint) (string, error) {
stdout, _, err := RunCmdString(ctx, repo,
gitcmd.NewCommand("blame").
AddOptionFormat("-L %d,%d", line, line).
AddOptionValues("-p", revision).
AddDashesAndList(file))
return stdout, err
}
// BlamePart represents block of blame - continuous lines with one sha
type BlamePart struct {
Sha string
Lines []string
PreviousSha string
PreviousPath string
}
// BlameReader returns part of file blame one by one
type BlameReader struct {
bufferedReader *bufio.Reader
done chan error
lastSha *string
ignoreRevsFile string
objectFormat git.ObjectFormat
cleanupFuncs []func()
}
func (r *BlameReader) UsesIgnoreRevs() bool {
return r.ignoreRevsFile != ""
}
// NextPart returns next part of blame (sequential code lines with the same commit)
func (r *BlameReader) NextPart() (*BlamePart, error) {
var blamePart *BlamePart
if r.lastSha != nil {
blamePart = &BlamePart{
Sha: *r.lastSha,
Lines: make([]string, 0),
}
}
const previousHeader = "previous "
var lineBytes []byte
var isPrefix bool
var err error
for err != io.EOF {
lineBytes, isPrefix, err = r.bufferedReader.ReadLine()
if err != nil && err != io.EOF {
return blamePart, err
}
if len(lineBytes) == 0 {
// isPrefix will be false
continue
}
var objectID string
objectFormatLength := r.objectFormat.FullLength()
if len(lineBytes) > objectFormatLength && lineBytes[objectFormatLength] == ' ' && r.objectFormat.IsValid(string(lineBytes[0:objectFormatLength])) {
objectID = string(lineBytes[0:objectFormatLength])
}
if len(objectID) > 0 {
if blamePart == nil {
blamePart = &BlamePart{
Sha: objectID,
Lines: make([]string, 0),
}
}
if blamePart.Sha != objectID {
r.lastSha = &objectID
// need to munch to end of line...
for isPrefix {
_, isPrefix, err = r.bufferedReader.ReadLine()
if err != nil && err != io.EOF {
return blamePart, err
}
}
return blamePart, nil
}
} else if lineBytes[0] == '\t' {
blamePart.Lines = append(blamePart.Lines, string(lineBytes[1:]))
} else if bytes.HasPrefix(lineBytes, []byte(previousHeader)) {
offset := len(previousHeader) // already includes a space
blamePart.PreviousSha = string(lineBytes[offset : offset+objectFormatLength])
offset += objectFormatLength + 1 // +1 for space
blamePart.PreviousPath = string(lineBytes[offset:])
}
// need to munch to end of line...
for isPrefix {
_, isPrefix, err = r.bufferedReader.ReadLine()
if err != nil && err != io.EOF {
return blamePart, err
}
}
}
r.lastSha = nil
return blamePart, nil
}
// Close BlameReader - don't run NextPart after invoking that
func (r *BlameReader) Close() error {
if r.bufferedReader == nil {
return nil
}
err := <-r.done
r.bufferedReader = nil
r.cleanup()
return err
}
func (r *BlameReader) cleanup() {
for _, cleanup := range r.cleanupFuncs {
cleanup()
}
}
// CreateBlameReader creates reader for given repository, commit and file
func CreateBlameReader(ctx context.Context, objectFormat git.ObjectFormat, repo Repository, commit *git.Commit, file string, bypassBlameIgnore bool) (rd *BlameReader, retErr error) {
defer func() {
if retErr != nil {
rd.cleanup()
}
}()
rd = &BlameReader{
done: make(chan error, 1),
objectFormat: objectFormat,
}
cmd := gitcmd.NewCommand("blame", "--porcelain")
stdoutReader, stdoutReaderClose := cmd.MakeStdoutPipe()
rd.bufferedReader = bufio.NewReader(stdoutReader)
rd.cleanupFuncs = append(rd.cleanupFuncs, stdoutReaderClose)
if git.DefaultFeatures().CheckVersionAtLeast("2.23") && !bypassBlameIgnore {
ignoreRevsFileName, ignoreRevsFileCleanup, err := tryCreateBlameIgnoreRevsFile(commit)
if err != nil && !git.IsErrNotExist(err) {
return nil, err
} else if err == nil {
rd.ignoreRevsFile = ignoreRevsFileName
rd.cleanupFuncs = append(rd.cleanupFuncs, ignoreRevsFileCleanup)
// Possible improvement: use --ignore-revs-file /dev/stdin on unix
// There is no equivalent on Windows. May be implemented if Gitea uses an external git backend.
cmd.AddOptionValues("--ignore-revs-file", ignoreRevsFileName)
}
}
cmd.AddDynamicArguments(commit.ID.String()).AddDashesAndList(file)
go func() {
// TODO: it doesn't work for directories (the directories shouldn't be "blamed"), and the "err" should be returned by "Read" but not by "Close"
rd.done <- RunCmdWithStderr(ctx, repo, cmd)
}()
return rd, nil
}
func tryCreateBlameIgnoreRevsFile(commit *git.Commit) (string, func(), error) {
entry, err := commit.GetTreeEntryByPath(".git-blame-ignore-revs")
if err != nil {
return "", nil, err
}
r, err := entry.Blob().DataAsync()
if err != nil {
return "", nil, err
}
defer r.Close()
f, cleanup, err := setting.AppDataTempDir("git-repo-content").CreateTempFileRandom("git-blame-ignore-revs")
if err != nil {
return "", nil, err
}
filename := f.Name()
_, err = io.Copy(f, r)
_ = f.Close()
if err != nil {
cleanup()
return "", nil, err
}
return filename, cleanup, nil
}
+156
View File
@@ -0,0 +1,156 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitrepo
import (
"context"
"testing"
"gitea.dev/modules/git"
"gitea.dev/modules/setting"
"github.com/stretchr/testify/assert"
)
func TestReadingBlameOutputSha256(t *testing.T) {
setting.AppDataPath = t.TempDir()
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
if git.DefaultFeatures().UsingGogit {
t.Skip("Skipping test since gogit does not support sha256")
return
}
t.Run("Without .git-blame-ignore-revs", func(t *testing.T) {
storage := &mockRepository{path: "repo5_pulls_sha256"}
repo, err := OpenRepository(ctx, storage)
assert.NoError(t, err)
defer repo.Close()
commit, err := repo.GetCommit("0b69b7bb649b5d46e14cabb6468685e5dd721290acc7ffe604d37cde57927345")
assert.NoError(t, err)
parts := []*BlamePart{
{
Sha: "1e35a51dc00fd7de730344c07061acfe80e8117e075ac979b6a29a3a045190ca",
Lines: []string{
"# test_repo",
"Test repository for testing migration from github to gitea",
},
},
{
Sha: "0b69b7bb649b5d46e14cabb6468685e5dd721290acc7ffe604d37cde57927345",
Lines: []string{"", "Do not make any changes to this repo it is used for unit testing"},
PreviousSha: "1e35a51dc00fd7de730344c07061acfe80e8117e075ac979b6a29a3a045190ca",
PreviousPath: "README.md",
},
}
for _, bypass := range []bool{false, true} {
blameReader, err := CreateBlameReader(ctx, git.Sha256ObjectFormat, storage, commit, "README.md", bypass)
assert.NoError(t, err)
assert.NotNil(t, blameReader)
defer blameReader.Close()
assert.False(t, blameReader.UsesIgnoreRevs())
for _, part := range parts {
actualPart, err := blameReader.NextPart()
assert.NoError(t, err)
assert.Equal(t, part, actualPart)
}
// make sure all parts have been read
actualPart, err := blameReader.NextPart()
assert.Nil(t, actualPart)
assert.NoError(t, err)
}
})
t.Run("With .git-blame-ignore-revs", func(t *testing.T) {
storage := &mockRepository{path: "repo6_blame_sha256"}
repo, err := OpenRepository(ctx, storage)
assert.NoError(t, err)
defer repo.Close()
full := []*BlamePart{
{
Sha: "ab2b57a4fa476fb2edb74dafa577caf918561abbaa8fba0c8dc63c412e17a7cc",
Lines: []string{"line", "line"},
},
{
Sha: "9347b0198cd1f25017579b79d0938fa89dba34ad2514f0dd92f6bc975ed1a2fe",
Lines: []string{"changed line"},
PreviousSha: "ab2b57a4fa476fb2edb74dafa577caf918561abbaa8fba0c8dc63c412e17a7cc",
PreviousPath: "blame.txt",
},
{
Sha: "ab2b57a4fa476fb2edb74dafa577caf918561abbaa8fba0c8dc63c412e17a7cc",
Lines: []string{"line", "line", ""},
},
}
cases := []struct {
CommitID string
UsesIgnoreRevs bool
Bypass bool
Parts []*BlamePart
}{
{
CommitID: "e2f5660e15159082902960af0ed74fc144921d2b0c80e069361853b3ece29ba3",
UsesIgnoreRevs: true,
Bypass: false,
Parts: []*BlamePart{
{
Sha: "ab2b57a4fa476fb2edb74dafa577caf918561abbaa8fba0c8dc63c412e17a7cc",
Lines: []string{"line", "line", "changed line", "line", "line", ""},
},
},
},
{
CommitID: "e2f5660e15159082902960af0ed74fc144921d2b0c80e069361853b3ece29ba3",
UsesIgnoreRevs: false,
Bypass: true,
Parts: full,
},
{
CommitID: "9347b0198cd1f25017579b79d0938fa89dba34ad2514f0dd92f6bc975ed1a2fe",
UsesIgnoreRevs: false,
Bypass: false,
Parts: full,
},
{
CommitID: "9347b0198cd1f25017579b79d0938fa89dba34ad2514f0dd92f6bc975ed1a2fe",
UsesIgnoreRevs: false,
Bypass: false,
Parts: full,
},
}
objectFormat, err := repo.GetObjectFormat()
assert.NoError(t, err)
for _, c := range cases {
commit, err := repo.GetCommit(c.CommitID)
assert.NoError(t, err)
blameReader, err := CreateBlameReader(ctx, objectFormat, storage, commit, "blame.txt", c.Bypass)
assert.NoError(t, err)
assert.NotNil(t, blameReader)
defer blameReader.Close()
assert.Equal(t, c.UsesIgnoreRevs, blameReader.UsesIgnoreRevs())
for _, part := range c.Parts {
actualPart, err := blameReader.NextPart()
assert.NoError(t, err)
assert.Equal(t, part, actualPart)
}
// make sure all parts have been read
actualPart, err := blameReader.NextPart()
assert.Nil(t, actualPart)
assert.NoError(t, err)
}
})
}
+151
View File
@@ -0,0 +1,151 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitrepo
import (
"context"
"testing"
"gitea.dev/modules/git"
"gitea.dev/modules/setting"
"github.com/stretchr/testify/assert"
)
func TestReadingBlameOutput(t *testing.T) {
setting.AppDataPath = t.TempDir()
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
t.Run("Without .git-blame-ignore-revs", func(t *testing.T) {
storage := &mockRepository{path: "repo5_pulls"}
repo, err := OpenRepository(ctx, storage)
assert.NoError(t, err)
defer repo.Close()
commit, err := repo.GetCommit("f32b0a9dfd09a60f616f29158f772cedd89942d2")
assert.NoError(t, err)
parts := []*BlamePart{
{
Sha: "72866af952e98d02a73003501836074b286a78f6",
Lines: []string{
"# test_repo",
"Test repository for testing migration from github to gitea",
},
},
{
Sha: "f32b0a9dfd09a60f616f29158f772cedd89942d2",
Lines: []string{"", "Do not make any changes to this repo it is used for unit testing"},
PreviousSha: "72866af952e98d02a73003501836074b286a78f6",
PreviousPath: "README.md",
},
}
for _, bypass := range []bool{false, true} {
blameReader, err := CreateBlameReader(ctx, git.Sha1ObjectFormat, storage, commit, "README.md", bypass)
assert.NoError(t, err)
assert.NotNil(t, blameReader)
defer blameReader.Close()
assert.False(t, blameReader.UsesIgnoreRevs())
for _, part := range parts {
actualPart, err := blameReader.NextPart()
assert.NoError(t, err)
assert.Equal(t, part, actualPart)
}
// make sure all parts have been read
actualPart, err := blameReader.NextPart()
assert.Nil(t, actualPart)
assert.NoError(t, err)
}
})
t.Run("With .git-blame-ignore-revs", func(t *testing.T) {
storage := &mockRepository{path: "repo6_blame"}
repo, err := OpenRepository(ctx, storage)
assert.NoError(t, err)
defer repo.Close()
full := []*BlamePart{
{
Sha: "af7486bd54cfc39eea97207ca666aa69c9d6df93",
Lines: []string{"line", "line"},
},
{
Sha: "45fb6cbc12f970b04eacd5cd4165edd11c8d7376",
Lines: []string{"changed line"},
PreviousSha: "af7486bd54cfc39eea97207ca666aa69c9d6df93",
PreviousPath: "blame.txt",
},
{
Sha: "af7486bd54cfc39eea97207ca666aa69c9d6df93",
Lines: []string{"line", "line", ""},
},
}
cases := []struct {
CommitID string
UsesIgnoreRevs bool
Bypass bool
Parts []*BlamePart
}{
{
CommitID: "544d8f7a3b15927cddf2299b4b562d6ebd71b6a7",
UsesIgnoreRevs: true,
Bypass: false,
Parts: []*BlamePart{
{
Sha: "af7486bd54cfc39eea97207ca666aa69c9d6df93",
Lines: []string{"line", "line", "changed line", "line", "line", ""},
},
},
},
{
CommitID: "544d8f7a3b15927cddf2299b4b562d6ebd71b6a7",
UsesIgnoreRevs: false,
Bypass: true,
Parts: full,
},
{
CommitID: "45fb6cbc12f970b04eacd5cd4165edd11c8d7376",
UsesIgnoreRevs: false,
Bypass: false,
Parts: full,
},
{
CommitID: "45fb6cbc12f970b04eacd5cd4165edd11c8d7376",
UsesIgnoreRevs: false,
Bypass: false,
Parts: full,
},
}
objectFormat, err := repo.GetObjectFormat()
assert.NoError(t, err)
for _, c := range cases {
commit, err := repo.GetCommit(c.CommitID)
assert.NoError(t, err)
blameReader, err := CreateBlameReader(ctx, objectFormat, storage, commit, "blame.txt", c.Bypass)
assert.NoError(t, err)
assert.NotNil(t, blameReader)
defer blameReader.Close()
assert.Equal(t, c.UsesIgnoreRevs, blameReader.UsesIgnoreRevs())
for _, part := range c.Parts {
actualPart, err := blameReader.NextPart()
assert.NoError(t, err)
assert.Equal(t, part, actualPart)
}
// make sure all parts have been read
actualPart, err := blameReader.NextPart()
assert.Nil(t, actualPart)
assert.NoError(t, err)
}
})
}
+96
View File
@@ -0,0 +1,96 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitrepo
import (
"context"
"errors"
"strings"
"gitea.dev/modules/git"
"gitea.dev/modules/git/gitcmd"
)
// GetBranchesByPath returns a branch by its path
// if limit = 0 it will not limit
func GetBranchesByPath(ctx context.Context, repo Repository, skip, limit int) ([]string, int, error) {
gitRepo, err := OpenRepository(ctx, repo)
if err != nil {
return nil, 0, err
}
defer gitRepo.Close()
return gitRepo.GetBranchNames(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 := RunCmdString(ctx, repo, gitcmd.NewCommand("symbolic-ref", "HEAD").
AddDynamicArguments(git.BranchPrefix+name))
return err
}
// GetDefaultBranch gets default branch of repository.
func GetDefaultBranch(ctx context.Context, repo Repository) (string, error) {
stdout, _, err := RunCmdString(ctx, repo, gitcmd.NewCommand("symbolic-ref", "HEAD"))
if err != nil {
return "", err
}
stdout = strings.TrimSpace(stdout)
if !strings.HasPrefix(stdout, git.BranchPrefix) {
return "", errors.New("the HEAD is not a branch: " + stdout)
}
return strings.TrimPrefix(stdout, git.BranchPrefix), nil
}
// IsReferenceExist returns true if given reference exists in the repository.
func IsReferenceExist(ctx context.Context, repo Repository, name string) bool {
_, _, err := RunCmdString(ctx, repo, gitcmd.NewCommand("show-ref", "--verify").AddDashesAndList(name))
return err == nil
}
// IsBranchExist returns true if given branch exists in the repository.
func IsBranchExist(ctx context.Context, repo Repository, name string) bool {
return IsReferenceExist(ctx, repo, git.BranchPrefix+name)
}
// DeleteBranch delete a branch by name on repository.
func DeleteBranch(ctx context.Context, repo Repository, name string, force bool) error {
cmd := gitcmd.NewCommand("branch")
if force {
cmd.AddArguments("-D")
} else {
cmd.AddArguments("-d")
}
cmd.AddDashesAndList(name)
_, _, err := RunCmdString(ctx, repo, cmd)
return err
}
// CreateBranch create a new branch
func CreateBranch(ctx context.Context, repo Repository, branch, oldbranchOrCommit string) error {
cmd := gitcmd.NewCommand("branch")
cmd.AddDashesAndList(branch, oldbranchOrCommit)
_, _, err := RunCmdString(ctx, repo, cmd)
return err
}
// RenameBranch rename a branch
func RenameBranch(ctx context.Context, repo Repository, from, to string) error {
_, _, err := RunCmdString(ctx, repo, gitcmd.NewCommand("branch", "-m").AddDynamicArguments(from, to))
return err
}
+14
View File
@@ -0,0 +1,14 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitrepo
import (
"context"
"gitea.dev/modules/git"
)
func NewBatch(ctx context.Context, repo Repository) (git.CatFileBatchCloser, error) {
return git.NewBatch(ctx, repoPath(repo))
}
+24
View File
@@ -0,0 +1,24 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitrepo
import (
"context"
"gitea.dev/modules/git"
)
// CloneExternalRepo clones an external repository to the managed repository.
func CloneExternalRepo(ctx context.Context, fromRemoteURL string, toRepo Repository, opts git.CloneRepoOptions) error {
return git.Clone(ctx, fromRemoteURL, repoPath(toRepo), opts)
}
// CloneRepoToLocal clones a managed repository to a local path.
func CloneRepoToLocal(ctx context.Context, fromRepo Repository, toLocalPath string, opts git.CloneRepoOptions) error {
return git.Clone(ctx, repoPath(fromRepo), toLocalPath, opts)
}
func Clone(ctx context.Context, fromRepo, toRepo Repository, opts git.CloneRepoOptions) error {
return git.Clone(ctx, repoPath(fromRepo), repoPath(toRepo), opts)
}
+26
View File
@@ -0,0 +1,26 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitrepo
import (
"context"
"gitea.dev/modules/git/gitcmd"
)
func RunCmd(ctx context.Context, repo Repository, cmd *gitcmd.Command) error {
return cmd.WithDir(repoPath(repo)).WithParentCallerInfo().Run(ctx)
}
func RunCmdString(ctx context.Context, repo Repository, cmd *gitcmd.Command) (string, string, gitcmd.RunStdError) {
return cmd.WithDir(repoPath(repo)).WithParentCallerInfo().RunStdString(ctx)
}
func RunCmdBytes(ctx context.Context, repo Repository, cmd *gitcmd.Command) ([]byte, []byte, gitcmd.RunStdError) {
return cmd.WithDir(repoPath(repo)).WithParentCallerInfo().RunStdBytes(ctx)
}
func RunCmdWithStderr(ctx context.Context, repo Repository, cmd *gitcmd.Command) gitcmd.RunStdError {
return cmd.WithDir(repoPath(repo)).WithParentCallerInfo().RunWithStderr(ctx)
}
+95
View File
@@ -0,0 +1,95 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitrepo
import (
"context"
"strconv"
"strings"
"time"
"gitea.dev/modules/git"
"gitea.dev/modules/git/gitcmd"
)
// CommitsCountOptions the options when counting commits
type CommitsCountOptions struct {
Not string
Revision []string
RelPath []string
Since string
Until string
}
// CommitsCount returns number of total commits of until given revision.
func CommitsCount(ctx context.Context, repo Repository, opts CommitsCountOptions) (int64, error) {
cmd := gitcmd.NewCommand("rev-list", "--count")
cmd.AddDynamicArguments(opts.Revision...)
if opts.Not != "" {
cmd.AddOptionValues("--not", opts.Not)
}
if len(opts.RelPath) > 0 {
cmd.AddDashesAndList(opts.RelPath...)
}
stdout, _, err := cmd.WithDir(repoPath(repo)).RunStdString(ctx)
if err != nil {
return 0, err
}
return strconv.ParseInt(strings.TrimSpace(stdout), 10, 64)
}
// FileCommitsCount return the number of files at a revision
func FileCommitsCount(ctx context.Context, repo Repository, revision, file string) (int64, error) {
return CommitsCount(ctx, repo,
CommitsCountOptions{
Revision: []string{revision},
RelPath: []string{file},
})
}
// CommitsCountOfCommit returns number of total commits of until current revision.
func CommitsCountOfCommit(ctx context.Context, repo Repository, commitID string) (int64, error) {
return CommitsCount(ctx, repo, CommitsCountOptions{
Revision: []string{commitID},
})
}
// AllCommitsCount returns count of all commits in repository
func AllCommitsCount(ctx context.Context, repo Repository, hidePRRefs bool, files ...string) (int64, error) {
cmd := gitcmd.NewCommand("rev-list")
if hidePRRefs {
cmd.AddArguments("--exclude=" + git.PullPrefix + "*")
}
cmd.AddArguments("--all", "--count")
if len(files) > 0 {
cmd.AddDashesAndList(files...)
}
stdout, _, err := RunCmdString(ctx, repo, cmd)
if err != nil {
return 0, err
}
return strconv.ParseInt(strings.TrimSpace(stdout), 10, 64)
}
func GetFullCommitID(ctx context.Context, repo Repository, shortID string) (string, error) {
return git.GetFullCommitID(ctx, repoPath(repo), shortID)
}
// GetLatestCommitTime returns time for latest commit in repository (across all branches)
func GetLatestCommitTime(ctx context.Context, repo Repository) (time.Time, error) {
stdout, _, err := RunCmdString(ctx, repo,
gitcmd.NewCommand("for-each-ref", "--sort=-committerdate", git.BranchPrefix, "--count", "1", "--format=%(committerdate)"))
if err != nil {
return time.Time{}, err
}
commitTime := strings.TrimSpace(stdout)
return time.Parse("Mon Jan _2 15:04:05 2006 -0700", commitTime)
}
+88
View File
@@ -0,0 +1,88 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitrepo
import (
"bufio"
"context"
"io"
"gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/log"
)
// CommitFileStatus represents status of files in a commit.
type CommitFileStatus struct {
Added []string
Removed []string
Modified []string
}
// NewCommitFileStatus creates a CommitFileStatus
func NewCommitFileStatus() *CommitFileStatus {
return &CommitFileStatus{
[]string{}, []string{}, []string{},
}
}
func parseCommitFileStatus(fileStatus *CommitFileStatus, stdout io.Reader) {
rd := bufio.NewReader(stdout)
peek, err := rd.Peek(1)
if err != nil {
if err != io.EOF {
log.Error("Unexpected error whilst reading from git log --name-status. Error: %v", err)
}
return
}
if peek[0] == '\n' || peek[0] == '\x00' {
_, _ = rd.Discard(1)
}
for {
modifier, err := rd.ReadString('\x00')
if err != nil {
if err != io.EOF {
log.Error("Unexpected error whilst reading from git log --name-status. Error: %v", err)
}
return
}
file, err := rd.ReadString('\x00')
if err != nil {
if err != io.EOF {
log.Error("Unexpected error whilst reading from git log --name-status. Error: %v", err)
}
return
}
file = file[:len(file)-1]
switch modifier[0] {
case 'A':
fileStatus.Added = append(fileStatus.Added, file)
case 'D':
fileStatus.Removed = append(fileStatus.Removed, file)
case 'M':
fileStatus.Modified = append(fileStatus.Modified, file)
}
}
}
// GetCommitFileStatus returns file status of commit in given repository.
func GetCommitFileStatus(ctx context.Context, repo Repository, commitID string) (*CommitFileStatus, error) {
cmd := gitcmd.NewCommand("log", "--name-status", "-m", "--pretty=format:", "--first-parent", "--no-renames", "-z", "-1")
stdout, stdoutClose := cmd.MakeStdoutPipe()
defer stdoutClose()
done := make(chan struct{})
fileStatus := NewCommitFileStatus()
go func() {
parseCommitFileStatus(fileStatus, stdout)
close(done)
}()
err := cmd.AddDynamicArguments(commitID).
WithDir(repoPath(repo)).
RunWithStderr(ctx)
if err != nil {
return nil, err
}
<-done
return fileStatus, nil
}
+175
View File
@@ -0,0 +1,175 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitrepo
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestParseCommitFileStatus(t *testing.T) {
type testcase struct {
output string
added []string
removed []string
modified []string
}
kases := []testcase{
{
// Merge commit
output: "MM\x00options/locale/locale_en-US.ini\x00",
modified: []string{
"options/locale/locale_en-US.ini",
},
added: []string{},
removed: []string{},
},
{
// Spaces commit
output: "D\x00b\x00D\x00b b/b\x00A\x00b b/b b/b b/b\x00A\x00b b/b b/b b/b b/b\x00",
removed: []string{
"b",
"b b/b",
},
modified: []string{},
added: []string{
"b b/b b/b b/b",
"b b/b b/b b/b b/b",
},
},
{
// larger commit
output: "M\x00go.mod\x00M\x00go.sum\x00M\x00modules/ssh/ssh.go\x00M\x00vendor/github.com/gliderlabs/ssh/circle.yml\x00M\x00vendor/github.com/gliderlabs/ssh/context.go\x00A\x00vendor/github.com/gliderlabs/ssh/go.mod\x00A\x00vendor/github.com/gliderlabs/ssh/go.sum\x00M\x00vendor/github.com/gliderlabs/ssh/server.go\x00M\x00vendor/github.com/gliderlabs/ssh/session.go\x00M\x00vendor/github.com/gliderlabs/ssh/ssh.go\x00M\x00vendor/golang.org/x/sys/unix/mkerrors.sh\x00M\x00vendor/golang.org/x/sys/unix/syscall_darwin.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_darwin_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_darwin_arm64.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_freebsd_386.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_freebsd_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_freebsd_arm.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_freebsd_arm64.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_linux.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_darwin_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_darwin_arm64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_dragonfly_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_freebsd_386.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_freebsd_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_freebsd_arm.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_freebsd_arm64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_netbsd_386.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_netbsd_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_netbsd_arm.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_netbsd_arm64.go\x00M\x00vendor/modules.txt\x00",
modified: []string{
"go.mod",
"go.sum",
"modules/ssh/ssh.go",
"vendor/github.com/gliderlabs/ssh/circle.yml",
"vendor/github.com/gliderlabs/ssh/context.go",
"vendor/github.com/gliderlabs/ssh/server.go",
"vendor/github.com/gliderlabs/ssh/session.go",
"vendor/github.com/gliderlabs/ssh/ssh.go",
"vendor/golang.org/x/sys/unix/mkerrors.sh",
"vendor/golang.org/x/sys/unix/syscall_darwin.go",
"vendor/golang.org/x/sys/unix/zerrors_darwin_amd64.go",
"vendor/golang.org/x/sys/unix/zerrors_darwin_arm64.go",
"vendor/golang.org/x/sys/unix/zerrors_freebsd_386.go",
"vendor/golang.org/x/sys/unix/zerrors_freebsd_amd64.go",
"vendor/golang.org/x/sys/unix/zerrors_freebsd_arm.go",
"vendor/golang.org/x/sys/unix/zerrors_freebsd_arm64.go",
"vendor/golang.org/x/sys/unix/zerrors_linux.go",
"vendor/golang.org/x/sys/unix/ztypes_darwin_amd64.go",
"vendor/golang.org/x/sys/unix/ztypes_darwin_arm64.go",
"vendor/golang.org/x/sys/unix/ztypes_dragonfly_amd64.go",
"vendor/golang.org/x/sys/unix/ztypes_freebsd_386.go",
"vendor/golang.org/x/sys/unix/ztypes_freebsd_amd64.go",
"vendor/golang.org/x/sys/unix/ztypes_freebsd_arm.go",
"vendor/golang.org/x/sys/unix/ztypes_freebsd_arm64.go",
"vendor/golang.org/x/sys/unix/ztypes_netbsd_386.go",
"vendor/golang.org/x/sys/unix/ztypes_netbsd_amd64.go",
"vendor/golang.org/x/sys/unix/ztypes_netbsd_arm.go",
"vendor/golang.org/x/sys/unix/ztypes_netbsd_arm64.go",
"vendor/modules.txt",
},
added: []string{
"vendor/github.com/gliderlabs/ssh/go.mod",
"vendor/github.com/gliderlabs/ssh/go.sum",
},
removed: []string{},
},
{
// git 1.7.2 adds an unnecessary \x00 on merge commit
output: "\x00MM\x00options/locale/locale_en-US.ini\x00",
modified: []string{
"options/locale/locale_en-US.ini",
},
added: []string{},
removed: []string{},
},
{
// git 1.7.2 adds an unnecessary \n on normal commit
output: "\nD\x00b\x00D\x00b b/b\x00A\x00b b/b b/b b/b\x00A\x00b b/b b/b b/b b/b\x00",
removed: []string{
"b",
"b b/b",
},
modified: []string{},
added: []string{
"b b/b b/b b/b",
"b b/b b/b b/b b/b",
},
},
}
for _, kase := range kases {
fileStatus := NewCommitFileStatus()
parseCommitFileStatus(fileStatus, strings.NewReader(kase.output))
assert.Equal(t, kase.added, fileStatus.Added)
assert.Equal(t, kase.removed, fileStatus.Removed)
assert.Equal(t, kase.modified, fileStatus.Modified)
}
}
func TestGetCommitFileStatusMerges(t *testing.T) {
bareRepo6 := &mockRepository{path: "repo6_merge"}
commitFileStatus, err := GetCommitFileStatus(t.Context(), bareRepo6, "022f4ce6214973e018f02bf363bf8a2e3691f699")
assert.NoError(t, err)
expected := CommitFileStatus{
[]string{
"add_file.txt",
},
[]string{
"to_remove.txt",
},
[]string{
"to_modify.txt",
},
}
assert.Equal(t, expected.Added, commitFileStatus.Added)
assert.Equal(t, expected.Removed, commitFileStatus.Removed)
assert.Equal(t, expected.Modified, commitFileStatus.Modified)
}
func TestGetCommitFileStatusMergesSha256(t *testing.T) {
bareRepo6Sha256 := &mockRepository{path: "repo6_merge_sha256"}
commitFileStatus, err := GetCommitFileStatus(t.Context(), bareRepo6Sha256, "d2e5609f630dd8db500f5298d05d16def282412e3e66ed68cc7d0833b29129a1")
assert.NoError(t, err)
expected := CommitFileStatus{
[]string{
"add_file.txt",
},
[]string{},
[]string{
"to_modify.txt",
},
}
assert.Equal(t, expected.Added, commitFileStatus.Added)
assert.Equal(t, expected.Removed, commitFileStatus.Removed)
assert.Equal(t, expected.Modified, commitFileStatus.Modified)
expected = CommitFileStatus{
[]string{},
[]string{
"to_remove.txt",
},
[]string{},
}
commitFileStatus, err = GetCommitFileStatus(t.Context(), bareRepo6Sha256, "da1ded40dc8e5b7c564171f4bf2fc8370487decfb1cb6a99ef28f3ed73d09172")
assert.NoError(t, err)
assert.Equal(t, expected.Added, commitFileStatus.Added)
assert.Equal(t, expected.Removed, commitFileStatus.Removed)
assert.Equal(t, expected.Modified, commitFileStatus.Modified)
}
+45
View File
@@ -0,0 +1,45 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitrepo
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestCommitsCount(t *testing.T) {
bareRepo1 := &mockRepository{path: "repo1_bare"}
commitsCount, err := CommitsCount(t.Context(), bareRepo1,
CommitsCountOptions{
Revision: []string{"8006ff9adbf0cb94da7dad9e537e53817f9fa5c0"},
})
assert.NoError(t, err)
assert.Equal(t, int64(3), commitsCount)
}
func TestCommitsCountWithoutBase(t *testing.T) {
bareRepo1 := &mockRepository{path: "repo1_bare"}
commitsCount, err := CommitsCount(t.Context(), bareRepo1,
CommitsCountOptions{
Not: "master",
Revision: []string{"branch1"},
})
assert.NoError(t, err)
assert.Equal(t, int64(2), commitsCount)
}
func TestGetLatestCommitTime(t *testing.T) {
bareRepo1 := &mockRepository{path: "repo1_bare"}
lct, err := GetLatestCommitTime(t.Context(), bareRepo1)
assert.NoError(t, err)
// Time is Sun Nov 13 16:40:14 2022 +0100
// which is the time of commit
// ce064814f4a0d337b333e646ece456cd39fab612 (refs/heads/master)
assert.EqualValues(t, 1668354014, lct.Unix())
}
+14
View File
@@ -0,0 +1,14 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitrepo
import (
"context"
"gitea.dev/modules/git"
)
func WriteCommitGraph(ctx context.Context, repo Repository) error {
return git.WriteCommitGraph(ctx, repoPath(repo))
}
+71
View File
@@ -0,0 +1,71 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitrepo
import (
"context"
"fmt"
"strconv"
"strings"
"gitea.dev/modules/git/gitcmd"
)
// DivergeObject represents commit count diverging commits
type DivergeObject struct {
Ahead int
Behind int
}
// GetDivergingCommits returns the number of commits a targetBranch is ahead or behind a baseBranch
func GetDivergingCommits(ctx context.Context, repo Repository, baseBranch, targetBranch string) (*DivergeObject, error) {
cmd := gitcmd.NewCommand("rev-list", "--count", "--left-right").
AddDynamicArguments(baseBranch + "..." + targetBranch).AddArguments("--")
stdout, _, err1 := RunCmdString(ctx, repo, cmd)
if err1 != nil {
return nil, err1
}
left, right, found := strings.Cut(strings.Trim(stdout, "\n"), "\t")
if !found {
return nil, fmt.Errorf("git rev-list output is missing a tab: %q", stdout)
}
behind, err := strconv.Atoi(left)
if err != nil {
return nil, err
}
ahead, err := strconv.Atoi(right)
if err != nil {
return nil, err
}
return &DivergeObject{Ahead: ahead, Behind: behind}, nil
}
// GetCommitIDsBetweenReverse returns the last commit IDs between two commits in reverse order (from old to new) with limit.
// If the result exceeds the limit, the old commits IDs will be ignored
func GetCommitIDsBetweenReverse(ctx context.Context, repo Repository, startRef, endRef, notRef string, limit int) ([]string, error) {
genCmd := func(reversions ...string) *gitcmd.Command {
cmd := gitcmd.NewCommand("rev-list", "--reverse").
AddArguments("-n").AddDynamicArguments(strconv.Itoa(limit)).
AddDynamicArguments(reversions...)
if notRef != "" { // --not should be kept as the last parameter of git command, otherwise the result will be wrong
cmd.AddOptionValues("--not", notRef)
}
return cmd
}
stdout, _, err := RunCmdString(ctx, repo, genCmd(startRef+".."+endRef))
// example git error message: fatal: origin/main..HEAD: no merge base
if err != nil && strings.Contains(err.Stderr(), "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 = RunCmdString(ctx, repo, genCmd(startRef, endRef))
}
if err != nil {
return nil, err
}
commitIDs := strings.Fields(strings.TrimSpace(stdout))
return commitIDs, nil
}
+146
View File
@@ -0,0 +1,146 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitrepo
import (
"path/filepath"
"strings"
"testing"
"gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type mockRepository struct {
path string
}
func (r *mockRepository) RelativePath() string {
return r.path
}
func TestMergeBaseNoCommonHistory(t *testing.T) {
repoDir := filepath.Join(t.TempDir(), "repo.git")
require.NoError(t, gitcmd.NewCommand("init").AddDynamicArguments(repoDir).Run(t.Context()))
_, _, runErr := gitcmd.NewCommand("fast-import").WithDir(repoDir).WithStdinBytes([]byte(strings.TrimSpace(`
commit refs/heads/branch1
committer User <user@example.com> 1714310400 +0000
data 12
First commit
M 100644 inline file1.txt
data 12
Hello from 1
commit refs/heads/branch2
committer User <user@example.com> 1714310400 +0000
data 13
Second commit
M 100644 inline file2.txt
data 12
Hello from 2
`))).RunStdString(t.Context())
require.NoError(t, runErr)
mergeBase, err := MergeBase(t.Context(), &mockRepository{path: repoDir}, "branch1", "branch2")
assert.Empty(t, mergeBase)
assert.ErrorIs(t, err, util.ErrNotExist)
}
func TestRepoGetDivergingCommits(t *testing.T) {
repo := &mockRepository{path: "repo1_bare"}
do, err := GetDivergingCommits(t.Context(), repo, "master", "branch2")
assert.NoError(t, err)
assert.Equal(t, &DivergeObject{
Ahead: 1,
Behind: 5,
}, do)
do, err = GetDivergingCommits(t.Context(), repo, "master", "master")
assert.NoError(t, err)
assert.Equal(t, &DivergeObject{
Ahead: 0,
Behind: 0,
}, do)
do, err = GetDivergingCommits(t.Context(), repo, "master", "test")
assert.NoError(t, err)
assert.Equal(t, &DivergeObject{
Ahead: 0,
Behind: 2,
}, do)
}
func TestGetCommitIDsBetweenReverse(t *testing.T) {
repo := &mockRepository{path: "repo1_bare"}
// tests raw commit IDs
commitIDs, err := GetCommitIDsBetweenReverse(t.Context(), repo,
"8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2",
"ce064814f4a0d337b333e646ece456cd39fab612",
"",
100,
)
assert.NoError(t, err)
assert.Equal(t, []string{
"8006ff9adbf0cb94da7dad9e537e53817f9fa5c0",
"6fbd69e9823458e6c4a2fc5c0f6bc022b2f2acd1",
"37991dec2c8e592043f47155ce4808d4580f9123",
"feaf4ba6bc635fec442f46ddd4512416ec43c2c2",
"ce064814f4a0d337b333e646ece456cd39fab612",
}, commitIDs)
commitIDs, err = GetCommitIDsBetweenReverse(t.Context(), repo,
"8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2",
"ce064814f4a0d337b333e646ece456cd39fab612",
"6fbd69e9823458e6c4a2fc5c0f6bc022b2f2acd1",
100,
)
assert.NoError(t, err)
assert.Equal(t, []string{
"37991dec2c8e592043f47155ce4808d4580f9123",
"feaf4ba6bc635fec442f46ddd4512416ec43c2c2",
"ce064814f4a0d337b333e646ece456cd39fab612",
}, commitIDs)
commitIDs, err = GetCommitIDsBetweenReverse(t.Context(), repo,
"8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2",
"ce064814f4a0d337b333e646ece456cd39fab612",
"",
3,
)
assert.NoError(t, err)
assert.Equal(t, []string{
"37991dec2c8e592043f47155ce4808d4580f9123",
"feaf4ba6bc635fec442f46ddd4512416ec43c2c2",
"ce064814f4a0d337b333e646ece456cd39fab612",
}, commitIDs)
// test branch names instead of raw commit IDs.
commitIDs, err = GetCommitIDsBetweenReverse(t.Context(), repo,
"test",
"master",
"",
100,
)
assert.NoError(t, err)
assert.Equal(t, []string{
"feaf4ba6bc635fec442f46ddd4512416ec43c2c2",
"ce064814f4a0d337b333e646ece456cd39fab612",
}, commitIDs)
// add notref to exclude test
commitIDs, err = GetCommitIDsBetweenReverse(t.Context(), repo,
"test",
"master",
"test",
100,
)
assert.NoError(t, err)
assert.Equal(t, []string{
"feaf4ba6bc635fec442f46ddd4512416ec43c2c2",
"ce064814f4a0d337b333e646ece456cd39fab612",
}, commitIDs)
}
+35
View File
@@ -0,0 +1,35 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitrepo
import (
"context"
"gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/globallock"
)
func getRepoConfigLockKey(repoStoragePath string) string {
return "repo-config:" + repoStoragePath
}
// GitConfigAdd add a git configuration key to a specific value for the given repository.
func GitConfigAdd(ctx context.Context, repo Repository, key, value string) error {
return globallock.LockAndDo(ctx, getRepoConfigLockKey(repo.RelativePath()), func(ctx context.Context) error {
_, _, err := RunCmdString(ctx, repo, gitcmd.NewCommand("config", "--add").
AddDynamicArguments(key, value))
return err
})
}
// GitConfigSet updates a git configuration key to a specific value for the given repository.
// If the key does not exist, it will be created.
// If the key exists, it will be updated to the new value.
func GitConfigSet(ctx context.Context, repo Repository, key, value string) error {
return globallock.LockAndDo(ctx, getRepoConfigLockKey(repo.RelativePath()), func(ctx context.Context) error {
_, _, err := RunCmdString(ctx, repo, gitcmd.NewCommand("config").
AddDynamicArguments(key, value))
return err
})
}
+71
View File
@@ -0,0 +1,71 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitrepo
import (
"context"
"fmt"
"io"
"regexp"
"strconv"
"gitea.dev/modules/git/gitcmd"
)
// GetDiffShortStatByCmdArgs counts number of changed files, number of additions and deletions
// TODO: it can be merged with another "GetDiffShortStat" in the future
func GetDiffShortStatByCmdArgs(ctx context.Context, repo Repository, trustedArgs gitcmd.TrustedCmdArgs, dynamicArgs ...string) (numFiles, totalAdditions, totalDeletions int, err error) {
// Now if we call:
// $ git diff --shortstat 1ebb35b98889ff77299f24d82da426b434b0cca0...788b8b1440462d477f45b0088875
// we get:
// " 9902 files changed, 2034198 insertions(+), 298800 deletions(-)\n"
cmd := gitcmd.NewCommand("diff", "--shortstat").AddArguments(trustedArgs...).AddDynamicArguments(dynamicArgs...)
stdout, _, err := RunCmdString(ctx, repo, cmd)
if err != nil {
return 0, 0, 0, err
}
return parseDiffStat(stdout)
}
var shortStatFormat = regexp.MustCompile(
`\s*(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?`)
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
}
// GetReverseRawDiff dumps the reverse diff results of repository in given commit ID to io.Writer.
func GetReverseRawDiff(ctx context.Context, repo Repository, commitID string, writer io.Writer) error {
return RunCmdWithStderr(ctx, repo, gitcmd.NewCommand("show", "--pretty=format:revert %H%n", "-R").
AddDynamicArguments(commitID).
WithStdoutCopy(writer),
)
}
+28
View File
@@ -0,0 +1,28 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitrepo
import (
"context"
"gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/globallock"
)
// FetchRemoteCommit fetches a specific commit and its related objects from a remote
// repository into the managed repository.
//
// If no reference (branch, tag, or other ref) points to the fetched commit, it will
// be treated as unreachable and cleaned up by `git gc` after the default prune
// expiration period (2 weeks). Ref: https://www.kernel.org/pub/software/scm/git/docs/git-gc.html
//
// This behavior is sufficient for temporary operations, such as determining the
// merge base between commits.
func FetchRemoteCommit(ctx context.Context, repo, remoteRepo Repository, commitID string) error {
return globallock.LockAndDo(ctx, getRepoWriteLockKey(repo.RelativePath()), func(ctx context.Context) error {
return RunCmd(ctx, repo, gitcmd.NewCommand("fetch", "--no-tags").
AddDynamicArguments(repoPath(remoteRepo)).
AddDynamicArguments(commitID))
})
}
+16
View File
@@ -0,0 +1,16 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitrepo
import (
"context"
"time"
"gitea.dev/modules/git/gitcmd"
)
// Fsck verifies the connectivity and validity of the objects in the database
func Fsck(ctx context.Context, repo Repository, timeout time.Duration, args gitcmd.TrustedCmdArgs) error {
return RunCmd(ctx, repo, gitcmd.NewCommand("fsck").AddArguments(args...).WithTimeout(timeout))
}
+128
View File
@@ -0,0 +1,128 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitrepo
import (
"context"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"gitea.dev/modules/git"
"gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/reqctx"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
)
// Repository represents a git repository which stored in a disk
type Repository interface {
RelativePath() string // We don't assume how the directory structure of the repository is, so we only need the relative path
}
// repoPath resolves the Repository.RelativePath (which is a unix-style path like "username/reponame.git")
// to a local filesystem path according to setting.RepoRootPath
var repoPath = func(repo Repository) string {
return filepath.Join(setting.RepoRootPath, filepath.FromSlash(repo.RelativePath()))
}
// 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))
}
// contextKey is a value for use with context.WithValue.
type contextKey struct {
repoPath string
}
// RepositoryFromContextOrOpen attempts to get the repository from the context or just opens it
// The caller must call "defer gitRepo.Close()"
func RepositoryFromContextOrOpen(ctx context.Context, repo Repository) (*git.Repository, io.Closer, error) {
reqCtx := reqctx.FromContext(ctx)
if reqCtx != nil {
gitRepo, err := RepositoryFromRequestContextOrOpen(reqCtx, repo)
return gitRepo, util.NopCloser{}, err
}
gitRepo, err := OpenRepository(ctx, repo)
return gitRepo, gitRepo, err
}
// RepositoryFromRequestContextOrOpen opens the repository at the given relative path in the provided request context.
// Caller shouldn't close the git repo manually, the git repo will be automatically closed when the request context is done.
func RepositoryFromRequestContextOrOpen(ctx reqctx.RequestContext, repo Repository) (*git.Repository, error) {
ck := contextKey{repoPath: repoPath(repo)}
if gitRepo, ok := ctx.Value(ck).(*git.Repository); ok {
return gitRepo, nil
}
gitRepo, err := git.OpenRepository(ctx, ck.repoPath)
if err != nil {
return nil, err
}
ctx.AddCloser(gitRepo)
ctx.SetContextValue(ck, gitRepo)
return gitRepo, nil
}
// IsRepositoryExist returns true if the repository directory exists in the disk
func IsRepositoryExist(ctx context.Context, repo Repository) (bool, error) {
return util.IsExist(repoPath(repo))
}
// DeleteRepository deletes the repository directory from the disk, it will return
// nil if the repository does not exist.
func DeleteRepository(ctx context.Context, repo Repository) error {
return util.RemoveAll(repoPath(repo))
}
// RenameRepository renames a repository's name on disk
func RenameRepository(ctx context.Context, repo, newRepo Repository) error {
dstDir := repoPath(newRepo)
if err := os.MkdirAll(filepath.Dir(dstDir), os.ModePerm); err != nil {
return fmt.Errorf("Failed to create dir %s: %w", filepath.Dir(dstDir), err)
}
if err := util.Rename(repoPath(repo), dstDir); err != nil {
return fmt.Errorf("rename repository directory: %w", err)
}
return nil
}
func InitRepository(ctx context.Context, repo Repository, objectFormatName string) error {
return git.InitRepository(ctx, repoPath(repo), true, objectFormatName)
}
func UpdateServerInfo(ctx context.Context, repo Repository) error {
_, _, err := RunCmdBytes(ctx, repo, gitcmd.NewCommand("update-server-info"))
return err
}
func GetRepoFS(repo Repository) fs.FS {
return os.DirFS(repoPath(repo))
}
func IsRepoFileExist(ctx context.Context, repo Repository, relativeFilePath string) (bool, error) {
absoluteFilePath := filepath.Join(repoPath(repo), relativeFilePath)
return util.IsExist(absoluteFilePath)
}
func IsRepoDirExist(ctx context.Context, repo Repository, relativeDirPath string) (bool, error) {
absoluteDirPath := filepath.Join(repoPath(repo), relativeDirPath)
return util.IsDir(absoluteDirPath)
}
func RemoveRepoFileOrDir(ctx context.Context, repo Repository, relativeFileOrDirPath string) error {
absoluteFilePath := filepath.Join(repoPath(repo), relativeFileOrDirPath)
return util.Remove(absoluteFilePath)
}
func CreateRepoFile(ctx context.Context, repo Repository, relativeFilePath string) (io.WriteCloser, error) {
absoluteFilePath := filepath.Join(repoPath(repo), relativeFilePath)
if err := os.MkdirAll(filepath.Dir(absoluteFilePath), os.ModePerm); err != nil {
return nil, err
}
return os.Create(absoluteFilePath)
}
+240
View File
@@ -0,0 +1,240 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitrepo
import (
"context"
"fmt"
"os"
"path/filepath"
"runtime"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
)
func getHookTemplates() (hookNames, hookTpls, giteaHookTpls []string) {
hookNames = []string{"pre-receive", "update", "post-receive"}
hookTpls = []string{
// for pre-receive
fmt.Sprintf(`#!/usr/bin/env %s
# AUTO GENERATED BY GITEA, DO NOT MODIFY
data=$(cat)
exitcodes=""
hookname=$(basename $0)
GIT_DIR=${GIT_DIR:-$(dirname $0)/..}
for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
test -x "${hook}" && test -f "${hook}" || continue
echo "${data}" | "${hook}"
exitcodes="${exitcodes} $?"
done
for i in ${exitcodes}; do
[ ${i} -eq 0 ] || exit ${i}
done
`, setting.ScriptType),
// for update
fmt.Sprintf(`#!/usr/bin/env %s
# AUTO GENERATED BY GITEA, DO NOT MODIFY
exitcodes=""
hookname=$(basename $0)
GIT_DIR=${GIT_DIR:-$(dirname $0/..)}
for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
test -x "${hook}" && test -f "${hook}" || continue
"${hook}" $1 $2 $3
exitcodes="${exitcodes} $?"
done
for i in ${exitcodes}; do
[ ${i} -eq 0 ] || exit ${i}
done
`, setting.ScriptType),
// for post-receive
fmt.Sprintf(`#!/usr/bin/env %s
# AUTO GENERATED BY GITEA, DO NOT MODIFY
data=$(cat)
exitcodes=""
hookname=$(basename $0)
GIT_DIR=${GIT_DIR:-$(dirname $0)/..}
for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
test -x "${hook}" && test -f "${hook}" || continue
echo "${data}" | "${hook}"
exitcodes="${exitcodes} $?"
done
for i in ${exitcodes}; do
[ ${i} -eq 0 ] || exit ${i}
done
`, setting.ScriptType),
}
giteaHookTpls = []string{
// for pre-receive
fmt.Sprintf(`#!/usr/bin/env %s
# AUTO GENERATED BY GITEA, DO NOT MODIFY
%s hook --config=%s pre-receive
`, setting.ScriptType, util.ShellEscape(setting.AppPath), util.ShellEscape(setting.CustomConf)),
// for update
fmt.Sprintf(`#!/usr/bin/env %s
# AUTO GENERATED BY GITEA, DO NOT MODIFY
%s hook --config=%s update $1 $2 $3
`, setting.ScriptType, util.ShellEscape(setting.AppPath), util.ShellEscape(setting.CustomConf)),
// for post-receive
fmt.Sprintf(`#!/usr/bin/env %s
# AUTO GENERATED BY GITEA, DO NOT MODIFY
%s hook --config=%s post-receive
`, setting.ScriptType, util.ShellEscape(setting.AppPath), util.ShellEscape(setting.CustomConf)),
}
// although only new git (>=2.29) supports proc-receive, it's still good to create its hook, in case the user upgrades git
hookNames = append(hookNames, "proc-receive")
hookTpls = append(hookTpls,
fmt.Sprintf(`#!/usr/bin/env %s
# AUTO GENERATED BY GITEA, DO NOT MODIFY
%s hook --config=%s proc-receive
`, setting.ScriptType, util.ShellEscape(setting.AppPath), util.ShellEscape(setting.CustomConf)))
giteaHookTpls = append(giteaHookTpls, "")
return hookNames, hookTpls, giteaHookTpls
}
// CreateDelegateHooks creates all the hooks scripts for the repo
func CreateDelegateHooks(_ context.Context, repo Repository) (err error) {
return createDelegateHooks(filepath.Join(repoPath(repo), "hooks"))
}
func createDelegateHooks(hookDir string) (err error) {
hookNames, hookTpls, giteaHookTpls := getHookTemplates()
for i, hookName := range hookNames {
oldHookPath := filepath.Join(hookDir, hookName)
newHookPath := filepath.Join(hookDir, hookName+".d", "gitea")
if err := os.MkdirAll(filepath.Join(hookDir, hookName+".d"), os.ModePerm); err != nil {
return fmt.Errorf("create hooks dir '%s': %w", filepath.Join(hookDir, hookName+".d"), err)
}
// WARNING: This will override all old server-side hooks
if err = util.Remove(oldHookPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("unable to pre-remove old hook file '%s' prior to rewriting: %w ", oldHookPath, err)
}
if err = os.WriteFile(oldHookPath, []byte(hookTpls[i]), 0o777); err != nil {
return fmt.Errorf("write old hook file '%s': %w", oldHookPath, err)
}
if err = ensureExecutable(oldHookPath); err != nil {
return fmt.Errorf("Unable to set %s executable. Error %w", oldHookPath, err)
}
if err = util.Remove(newHookPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("unable to pre-remove new hook file '%s' prior to rewriting: %w", newHookPath, err)
}
if err = os.WriteFile(newHookPath, []byte(giteaHookTpls[i]), 0o777); err != nil {
return fmt.Errorf("write new hook file '%s': %w", newHookPath, err)
}
if err = ensureExecutable(newHookPath); err != nil {
return fmt.Errorf("Unable to set %s executable. Error %w", oldHookPath, err)
}
}
return nil
}
func checkExecutable(filename string) bool {
// windows has no concept of a executable bit
if runtime.GOOS == "windows" {
return true
}
fileInfo, err := os.Stat(filename)
if err != nil {
return false
}
return (fileInfo.Mode() & 0o100) > 0
}
func ensureExecutable(filename string) error {
fileInfo, err := os.Stat(filename)
if err != nil {
return err
}
if (fileInfo.Mode() & 0o100) > 0 {
return nil
}
mode := fileInfo.Mode() | 0o100
return os.Chmod(filename, mode)
}
// CheckDelegateHooks checks the hooks scripts for the repo
func CheckDelegateHooks(_ context.Context, repo Repository) ([]string, error) {
return checkDelegateHooks(filepath.Join(repoPath(repo), "hooks"))
}
func checkDelegateHooks(hookDir string) ([]string, error) {
hookNames, hookTpls, giteaHookTpls := getHookTemplates()
results := make([]string, 0, 10)
for i, hookName := range hookNames {
oldHookPath := filepath.Join(hookDir, hookName)
newHookPath := filepath.Join(hookDir, hookName+".d", "gitea")
cont := false
isExist, err := util.IsExist(oldHookPath)
if err != nil {
results = append(results, fmt.Sprintf("unable to check if %s exists. Error: %v", oldHookPath, err))
}
if err == nil && !isExist {
results = append(results, fmt.Sprintf("old hook file %s does not exist", oldHookPath))
cont = true
}
isExist, err = util.IsExist(oldHookPath + ".d")
if err != nil {
results = append(results, fmt.Sprintf("unable to check if %s exists. Error: %v", oldHookPath+".d", err))
}
if err == nil && !isExist {
results = append(results, fmt.Sprintf("hooks directory %s does not exist", oldHookPath+".d"))
cont = true
}
isExist, err = util.IsExist(newHookPath)
if err != nil {
results = append(results, fmt.Sprintf("unable to check if %s exists. Error: %v", newHookPath, err))
}
if err == nil && !isExist {
results = append(results, fmt.Sprintf("new hook file %s does not exist", newHookPath))
cont = true
}
if cont {
continue
}
contents, err := os.ReadFile(oldHookPath)
if err != nil {
return results, err
}
if string(contents) != hookTpls[i] {
results = append(results, fmt.Sprintf("old hook file %s is out of date", oldHookPath))
}
if !checkExecutable(oldHookPath) {
results = append(results, fmt.Sprintf("old hook file %s is not executable", oldHookPath))
}
contents, err = os.ReadFile(newHookPath)
if err != nil {
return results, err
}
if string(contents) != giteaHookTpls[i] {
results = append(results, fmt.Sprintf("new hook file %s is out of date", newHookPath))
}
if !checkExecutable(newHookPath) {
results = append(results, fmt.Sprintf("new hook file %s is not executable", newHookPath))
}
}
return results, nil
}
+25
View File
@@ -0,0 +1,25 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitrepo
import (
"path/filepath"
"testing"
"gitea.dev/modules/git"
"gitea.dev/modules/setting"
)
func TestMain(m *testing.M) {
// resolve repository path relative to the test directory
setting.SetupGiteaTestEnv()
giteaRoot := setting.GetGiteaTestSourceRoot()
repoPath = func(repo Repository) string {
if filepath.IsAbs(repo.RelativePath()) {
return repo.RelativePath() // for testing purpose only
}
return filepath.Join(giteaRoot, "modules/git/tests/repos", repo.RelativePath())
}
git.RunGitTests(m)
}
+26
View File
@@ -0,0 +1,26 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitrepo
import (
"context"
"fmt"
"strings"
"gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/util"
)
// MergeBase checks and returns merge base of two commits.
func MergeBase(ctx context.Context, repo Repository, baseCommitID, headCommitID string) (string, error) {
mergeBase, stderr, err := RunCmdString(ctx, repo, gitcmd.NewCommand("merge-base").
AddDashesAndList(baseCommitID, headCommitID))
if err != nil {
if gitcmd.IsErrorExitCode(err, 1) && strings.TrimSpace(stderr) == "" {
return "", util.NewNotExistErrorf("merge-base for %s and %s doesn't exist", baseCommitID, headCommitID)
}
return "", fmt.Errorf("get merge-base of %s and %s failed: %w", baseCommitID, headCommitID, err)
}
return strings.TrimSpace(mergeBase), nil
}
+59
View File
@@ -0,0 +1,59 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitrepo
import (
"bufio"
"context"
"fmt"
"gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/util"
)
const MaxConflictedDetectFiles = 10
// MergeTree performs a merge between two commits (baseRef and headRef) with an optional merge base.
// It returns the resulting tree hash, a list of conflicted files (if any), and an error if the operation fails.
// If there are no conflicts, the list of conflicted files will be nil.
func MergeTree(ctx context.Context, repo Repository, baseRef, headRef, mergeBase string) (treeID string, isErrHasConflicts bool, conflictFiles []string, _ error) {
cmd := gitcmd.NewCommand("merge-tree", "--write-tree", "-z", "--name-only", "--no-messages").
AddOptionFormat("--merge-base=%s", mergeBase).
AddDynamicArguments(baseRef, headRef)
stdout, stdoutClose := cmd.MakeStdoutPipe()
defer stdoutClose()
cmd.WithPipelineFunc(func(ctx gitcmd.Context) error {
// https://git-scm.com/docs/git-merge-tree/2.38.0#OUTPUT
// For a conflicted merge, the output is:
// <OID of toplevel tree>NUL
// <Conflicted file name 1>NUL
// <Conflicted file name 2>NUL
// ...
scanner := bufio.NewScanner(stdout)
scanner.Split(util.BufioScannerSplit(0))
for scanner.Scan() {
line := scanner.Text()
if treeID == "" { // first line is tree ID
treeID = line
continue
}
conflictFiles = append(conflictFiles, line)
if len(conflictFiles) >= MaxConflictedDetectFiles {
break
}
}
return scanner.Err()
})
err := RunCmdWithStderr(ctx, repo, cmd)
// For a successful, non-conflicted merge, the exit status is 0. When the merge has conflicts, the exit status is 1.
// A merge can have conflicts without having individual files conflict
// https://git-scm.com/docs/git-merge-tree/2.38.0#_mistakes_to_avoid
isErrHasConflicts = gitcmd.IsErrorExitCode(err, 1)
if err == nil || isErrHasConflicts {
return treeID, isErrHasConflicts, conflictFiles, nil
}
return "", false, nil, fmt.Errorf("run merge-tree failed: %w", err)
}
+82
View File
@@ -0,0 +1,82 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitrepo
import (
"path/filepath"
"testing"
"gitea.dev/modules/git/gitcmd"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func prepareRepoDirRenameConflict(t *testing.T) string {
repoDir := filepath.Join(t.TempDir(), "repo-dir-rename-conflict.git")
require.NoError(t, gitcmd.NewCommand("init", "--bare").AddDynamicArguments(repoDir).Run(t.Context()))
stdin := `blob
mark :1
data 2
b
blob
mark :2
data 2
c
reset refs/heads/master
commit refs/heads/master
mark :3
author test <test@example.com> 1769202331 -0800
committer test <test@example.com> 1769202331 -0800
data 2
O
M 100644 :1 z/b
M 100644 :2 z/c
commit refs/heads/split
mark :4
author test <test@example.com> 1769202336 -0800
committer test <test@example.com> 1769202336 -0800
data 2
A
from :3
M 100644 :2 w/c
M 100644 :1 y/b
D z/b
D z/c
blob
mark :5
data 2
d
commit refs/heads/add
mark :6
author test <test@example.com> 1769202342 -0800
committer test <test@example.com> 1769202342 -0800
data 2
B
from :3
M 100644 :5 z/d
`
require.NoError(t, gitcmd.NewCommand("fast-import").WithDir(repoDir).WithStdinBytes([]byte(stdin)).Run(t.Context()))
return repoDir
}
func TestMergeTreeDirectoryRenameConflictWithoutFiles(t *testing.T) {
repoDir := prepareRepoDirRenameConflict(t)
require.DirExists(t, repoDir)
repo := &mockRepository{path: repoDir}
mergeBase, err := MergeBase(t.Context(), repo, "add", "split")
require.NoError(t, err)
treeID, conflicted, conflictedFiles, err := MergeTree(t.Context(), repo, "add", "split", mergeBase)
require.NoError(t, err)
assert.True(t, conflicted)
assert.Empty(t, conflictedFiles)
assert.Equal(t, "5e3dd4cfc5b11e278a35b2daa83b7274175e3ab1", treeID)
}
+27
View File
@@ -0,0 +1,27 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitrepo
import (
"context"
"gitea.dev/modules/git"
)
// PushToExternal pushes a managed repository to an external remote.
func PushToExternal(ctx context.Context, repo Repository, opts git.PushOptions) error {
return git.Push(ctx, repoPath(repo), opts)
}
// Push pushes from one managed repository to another managed repository.
func Push(ctx context.Context, fromRepo, toRepo Repository, opts git.PushOptions) error {
opts.Remote = repoPath(toRepo)
return git.Push(ctx, repoPath(fromRepo), opts)
}
// PushFromLocal pushes from a local path to a managed repository.
func PushFromLocal(ctx context.Context, fromLocalPath string, toRepo Repository, opts git.PushOptions) error {
opts.Remote = repoPath(toRepo)
return git.Push(ctx, fromLocalPath, opts)
}
+19
View File
@@ -0,0 +1,19 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitrepo
import (
"context"
"gitea.dev/modules/git/gitcmd"
)
func UpdateRef(ctx context.Context, repo Repository, refName, newCommitID string) error {
return RunCmd(ctx, repo, gitcmd.NewCommand("update-ref").AddDynamicArguments(refName, newCommitID))
}
func RemoveRef(ctx context.Context, repo Repository, refName string) error {
return RunCmd(ctx, repo, gitcmd.NewCommand("update-ref", "--no-deref", "-d").
AddDynamicArguments(refName))
}
+60
View File
@@ -0,0 +1,60 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitrepo
import (
"context"
"errors"
"gitea.dev/modules/git"
"gitea.dev/modules/git/gitcmd"
giturl "gitea.dev/modules/git/url"
"gitea.dev/modules/globallock"
"gitea.dev/modules/util"
)
type RemoteOption string
const (
RemoteOptionMirrorPush RemoteOption = "--mirror=push"
RemoteOptionMirrorFetch RemoteOption = "--mirror=fetch"
)
func GitRemoteAdd(ctx context.Context, repo Repository, remoteName, remoteURL string, options ...RemoteOption) error {
return globallock.LockAndDo(ctx, getRepoConfigLockKey(repo.RelativePath()), func(ctx context.Context) error {
cmd := gitcmd.NewCommand("remote", "add")
if len(options) > 0 {
switch options[0] {
case RemoteOptionMirrorPush:
cmd.AddArguments("--mirror=push")
case RemoteOptionMirrorFetch:
cmd.AddArguments("--mirror=fetch")
default:
return errors.New("unknown remote option: " + string(options[0]))
}
}
_, _, err := RunCmdString(ctx, repo, cmd.AddDynamicArguments(remoteName, remoteURL))
return err
})
}
func GitRemoteRemove(ctx context.Context, repo Repository, remoteName string) error {
return globallock.LockAndDo(ctx, getRepoConfigLockKey(repo.RelativePath()), func(ctx context.Context) error {
cmd := gitcmd.NewCommand("remote", "rm").AddDynamicArguments(remoteName)
_, _, err := RunCmdString(ctx, repo, cmd)
return err
})
}
// GitRemoteGetURL returns the url of a specific remote of the repository.
func GitRemoteGetURL(ctx context.Context, repo Repository, remoteName string) (*giturl.GitURL, error) {
addr, err := git.GetRemoteAddress(ctx, repoPath(repo), remoteName)
if err != nil {
return nil, err
}
if addr == "" {
return nil, util.NewNotExistErrorf("remote '%s' does not exist", remoteName)
}
return giturl.ParseGitURL(addr)
}
+10
View File
@@ -0,0 +1,10 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitrepo
// getRepoWriteLockKey returns the global lock key for write operations on the repository.
// Parallel write operations on the same git repository should be avoided to prevent data corruption.
func getRepoWriteLockKey(repoStoragePath string) string {
return "repo-write:" + repoStoragePath
}
+14
View File
@@ -0,0 +1,14 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitrepo
import (
"context"
"gitea.dev/modules/git"
)
func GetSigningKey(ctx context.Context) (*git.SigningKey, *git.Signature) {
return git.GetSigningKey(ctx)
}
+37
View File
@@ -0,0 +1,37 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitrepo
import (
"os"
"path/filepath"
)
const notRegularFileMode = os.ModeSymlink | os.ModeNamedPipe | os.ModeSocket | os.ModeDevice | os.ModeCharDevice | os.ModeIrregular
// CalcRepositorySize returns the disk consumption for a given path
func CalcRepositorySize(repo Repository) (int64, error) {
var size int64
err := filepath.WalkDir(repoPath(repo), func(_ string, entry os.DirEntry, err error) error {
if os.IsNotExist(err) { // ignore the error because some files (like temp/lock file) may be deleted during traversing.
return nil
} else if err != nil {
return err
}
if entry.IsDir() {
return nil
}
info, err := entry.Info()
if os.IsNotExist(err) { // ignore the error as above
return nil
} else if err != nil {
return err
}
if (info.Mode() & notRegularFileMode) == 0 {
size += info.Size()
}
return nil
})
return size, err
}
+15
View File
@@ -0,0 +1,15 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitrepo
import (
"context"
"gitea.dev/modules/git"
)
// IsTagExist returns true if given tag exists in the repository.
func IsTagExist(ctx context.Context, repo Repository, name string) bool {
return IsReferenceExist(ctx, repo, git.TagPrefix+name)
}
+8
View File
@@ -0,0 +1,8 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitrepo
func RepoGitURL(repo Repository) string {
return repoPath(repo)
}
+36
View File
@@ -0,0 +1,36 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build gogit
package gitrepo
import (
"context"
"github.com/go-git/go-git/v5/plumbing"
)
// WalkReferences walks all the references from the repository
// refname is empty, ObjectTag or ObjectBranch. All other values should be treated as equivalent to empty.
func WalkReferences(ctx context.Context, repo Repository, walkfn func(sha1, refname string) error) (int, error) {
gitRepo, closer, err := RepositoryFromContextOrOpen(ctx, repo)
if err != nil {
return 0, err
}
defer closer.Close()
i := 0
iter, err := gitRepo.GoGitRepo().References()
if err != nil {
return i, err
}
defer iter.Close()
err = iter.ForEach(func(ref *plumbing.Reference) error {
err := walkfn(ref.Hash().String(), string(ref.Name()))
i++
return err
})
return i, err
}
+17
View File
@@ -0,0 +1,17 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !gogit
package gitrepo
import (
"context"
"gitea.dev/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)
}