mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2024-11-27 12:53:57 +01:00
03508b33a8
- Continuation of https://github.com/go-gitea/gitea/pull/18835 (by @Gusted, so it's fine to change copyright holder to Forgejo). - Add the option to use SSH for push mirrors, this would allow for the deploy keys feature to be used and not require tokens to be used which cannot be limited to a specific repository. The private key is stored encrypted (via the `keying` module) on the database and NEVER given to the user, to avoid accidental exposure and misuse. - CAVEAT: This does require the `ssh` binary to be present, which may not be available in containerized environments, this could be solved by adding a SSH client into forgejo itself and use the forgejo binary as SSH command, but should be done in another PR. - CAVEAT: Mirroring of LFS content is not supported, this would require the previous stated problem to be solved due to LFS authentication (an attempt was made at forgejo/forgejo#2544). - Integration test added. - Resolves #4416
342 lines
9.9 KiB
Go
342 lines
9.9 KiB
Go
// 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
|
|
}
|