Files
Lunny Xiao a664449282 Use git command instead of go git (#1005)
Remove go git library because it doesn't support sha256 repository but have an interface so that we could have other backend for the future.

Reviewed-on: https://gitea.com/gitea/tea/pulls/1005
Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com>
2026-05-23 20:24:47 +00:00

375 lines
9.8 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"bytes"
"encoding/base64"
"errors"
"fmt"
"os"
"os/exec"
"sort"
"strconv"
"strings"
)
type cliBackend struct{}
type cliRepository struct {
workTree string
}
func (cliBackend) Name() string {
return "cli"
}
func (b cliBackend) Open(path string) (RepositoryBackend, error) {
if path == "" {
path = "."
}
out, err := runGitCommand(path, nil, nil, "rev-parse", "--show-toplevel")
if err != nil {
return nil, classifyRepoError(err)
}
return &cliRepository{workTree: strings.TrimSpace(out)}, nil
}
func (b cliBackend) Clone(path, remoteURL string, auth *AuthMethod, opts CloneOptions) (RepositoryBackend, error) {
extraConfigs := make([]string, 0, 1)
if opts.Insecure {
extraConfigs = append(extraConfigs, "http.sslVerify=false")
}
args := []string{"clone"}
if opts.Depth > 0 {
args = append(args, "--depth", strconv.Itoa(opts.Depth))
}
args = append(args, remoteURL, path)
if _, err := runGitCommand("", auth, extraConfigs, args...); err != nil {
return nil, err
}
return b.Open(path)
}
func (r *cliRepository) WorkTree() string {
return r.workTree
}
func (r *cliRepository) Config() (*Config, error) {
cfg := &Config{
Remotes: map[string]*RemoteConfig{},
Branches: map[string]*Branch{},
}
remoteOut, err := r.git(nil, nil, "remote")
if err != nil {
return nil, err
}
for _, remoteName := range strings.Fields(remoteOut) {
urlOut, err := r.git(nil, nil, "config", "--get-all", "remote."+remoteName+".url")
if err != nil {
return nil, err
}
cfg.Remotes[remoteName] = &RemoteConfig{Name: remoteName, URLs: splitNonEmptyLines(urlOut)}
}
branchOut, err := r.git(nil, nil, "config", "--get-regexp", `^branch\..*\.(remote|merge)$`)
if err != nil {
var gitErr *gitCommandError
if !errors.As(err, &gitErr) {
return nil, err
}
if strings.TrimSpace(gitErr.stderr) != "" && !strings.Contains(gitErr.stderr, "No such section or key") {
return nil, err
}
return cfg, nil
}
for _, line := range splitNonEmptyLines(branchOut) {
parts := strings.SplitN(line, " ", 2)
if len(parts) != 2 {
continue
}
branchName, field, ok := parseBranchConfigKey(parts[0])
if !ok {
continue
}
branch := cfg.Branches[branchName]
if branch == nil {
branch = &Branch{Name: branchName}
cfg.Branches[branchName] = branch
}
switch field {
case "remote":
branch.Remote = parts[1]
case "merge":
branch.Merge = ReferenceName(parts[1])
}
}
return cfg, nil
}
func (r *cliRepository) Head() (*Reference, error) {
hashOut, err := r.git(nil, nil, "rev-parse", "HEAD")
if err != nil {
return nil, err
}
hash := Hash(strings.TrimSpace(hashOut))
if refOut, err := r.git(nil, nil, "symbolic-ref", "-q", "HEAD"); err == nil {
return &Reference{name: ReferenceName(strings.TrimSpace(refOut)), hash: hash}, nil
}
if tagOut, err := r.git(nil, nil, "describe", "--exact-match", "--tags", "HEAD"); err == nil {
return &Reference{name: ReferenceName("refs/tags/" + strings.TrimSpace(tagOut)), hash: hash}, nil
}
return &Reference{name: ReferenceName("HEAD"), hash: hash}, nil
}
func (r *cliRepository) AddRemote(name, remoteURL string) error {
_, err := r.git(nil, nil, "remote", "add", name, remoteURL)
return err
}
func (r *cliRepository) SetBranchUpstream(branchName, remoteName, remoteBranch string) error {
mergeRef := NewBranchReferenceName(remoteBranch).String()
if _, err := r.git(nil, nil, "config", "branch."+branchName+".remote", remoteName); err != nil {
return err
}
_, err := r.git(nil, nil, "config", "branch."+branchName+".merge", mergeRef)
return err
}
func (r *cliRepository) Fetch(remoteName string, refspecs []string, auth *AuthMethod) error {
args := []string{"fetch", remoteName}
args = append(args, refspecs...)
_, err := r.git(auth, nil, args...)
return err
}
func (r *cliRepository) CreateTrackingBranch(localBranchName, remoteBranchName, remoteName string) error {
_, err := r.git(nil, nil, "branch", "--track", localBranchName, fmt.Sprintf("%s/%s", remoteName, remoteBranchName))
if err == nil {
return nil
}
if gitErr, ok := err.(*gitCommandError); ok && strings.Contains(gitErr.stderr, "already exists") {
return ErrBranchExists
}
return err
}
func (r *cliRepository) Checkout(ref ReferenceName) error {
_, err := r.git(nil, nil, "checkout", ref.String())
return err
}
func (r *cliRepository) DeleteLocalBranch(branchName string) error {
_, err := r.git(nil, nil, "branch", "-D", branchName)
if err == nil {
return nil
}
if gitErr, ok := err.(*gitCommandError); ok {
stderr := strings.ToLower(gitErr.stderr)
if strings.Contains(stderr, "not found") || strings.Contains(stderr, "not exist") {
return nil
}
}
return err
}
func (r *cliRepository) DeleteRemoteBranch(remoteName, remoteBranch string, auth *AuthMethod) error {
_, err := r.git(auth, nil, "push", "--delete", remoteName, remoteBranch)
return err
}
func (r *cliRepository) ListReferences(prefixes ...string) ([]*Reference, error) {
args := []string{"for-each-ref", "--format=%(objectname)%09%(refname)"}
args = append(args, prefixes...)
out, err := r.git(nil, nil, args...)
if err != nil {
return nil, err
}
refs := make([]*Reference, 0)
for _, line := range splitNonEmptyLines(out) {
parts := strings.SplitN(line, " ", 2)
if len(parts) != 2 {
continue
}
refs = append(refs, &Reference{name: ReferenceName(parts[1]), hash: Hash(parts[0])})
}
return refs, nil
}
func (r *cliRepository) PushToAgitFlowPR(remoteName, head, base, topic string, pushOptions map[string]string, auth *AuthMethod) error {
ref := fmt.Sprintf("%s:refs/for/%s/%s", head, base, topic)
args := []string{"push", remoteName, ref}
if len(pushOptions) > 0 {
keys := make([]string, 0, len(pushOptions))
for key := range pushOptions {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
args = append(args, "-o", key+"="+pushOptions[key])
}
}
_, err := r.git(auth, nil, args...)
return err
}
func (r *cliRepository) git(auth *AuthMethod, extraConfigs []string, args ...string) (string, error) {
return runGitCommand(r.workTree, auth, extraConfigs, args...)
}
type gitCommandError struct {
args []string
stderr string
err error
}
func (e *gitCommandError) Error() string {
stderr := strings.TrimSpace(e.stderr)
if stderr == "" {
return fmt.Sprintf("git %s: %v", strings.Join(e.args, " "), e.err)
}
return fmt.Sprintf("git %s: %s", strings.Join(e.args, " "), stderr)
}
func (e *gitCommandError) Unwrap() error {
return e.err
}
func runGitCommand(dir string, auth *AuthMethod, extraConfigs []string, args ...string) (string, error) {
authConfigs, authEnv, cleanup, err := prepareCLIAuth(auth)
if err != nil {
return "", err
}
defer cleanup()
fullArgs := make([]string, 0, (len(extraConfigs)+len(authConfigs))*2+len(args))
for _, cfg := range extraConfigs {
fullArgs = append(fullArgs, "-c", cfg)
}
for _, cfg := range authConfigs {
fullArgs = append(fullArgs, "-c", cfg)
}
fullArgs = append(fullArgs, args...)
cmd := exec.Command("git", fullArgs...)
if dir != "" {
cmd.Dir = dir
}
cmd.Env = append(os.Environ(), authEnv...)
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return "", &gitCommandError{args: fullArgs, stderr: stderr.String(), err: err}
}
return stdout.String(), nil
}
func prepareCLIAuth(auth *AuthMethod) ([]string, []string, func(), error) {
if auth == nil {
return nil, nil, func() {}, nil
}
configs := make([]string, 0, 1)
env := make([]string, 0, 4)
cleanup := func() {}
switch auth.Scheme {
case "http", "https":
if auth.Username != "" || auth.Password != "" {
header := "Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte(auth.Username+":"+auth.Password))
configs = append(configs, "http.extraHeader="+header)
}
case "ssh":
sshCommand := "ssh"
if auth.KeyFile != "" {
sshCommand += " -i " + shellQuote(auth.KeyFile) + " -o IdentitiesOnly=yes"
}
env = append(env, "GIT_SSH_COMMAND="+sshCommand)
if auth.KeyPassphrase != "" {
askPassPath, err := writeAskPassScript(auth.KeyPassphrase)
if err != nil {
return nil, nil, cleanup, err
}
cleanup = func() { _ = os.Remove(askPassPath) }
env = append(env,
"SSH_ASKPASS="+askPassPath,
"SSH_ASKPASS_REQUIRE=force",
"DISPLAY=tea",
)
}
}
return configs, env, cleanup, nil
}
func writeAskPassScript(passphrase string) (string, error) {
f, err := os.CreateTemp("", "tea-ssh-askpass-*")
if err != nil {
return "", err
}
defer f.Close()
content := "#!/bin/sh\nprintf '%s\\n' " + shellQuote(passphrase) + "\n"
if _, err := f.WriteString(content); err != nil {
_ = os.Remove(f.Name())
return "", err
}
if err := f.Chmod(0o700); err != nil {
_ = os.Remove(f.Name())
return "", err
}
return f.Name(), nil
}
func classifyRepoError(err error) error {
var gitErr *gitCommandError
if errors.As(err, &gitErr) {
msg := strings.ToLower(gitErr.stderr)
if strings.Contains(msg, "not a git repository") || strings.Contains(msg, "cannot change to") {
return ErrRepositoryNotExists
}
}
return err
}
func parseBranchConfigKey(key string) (branchName, field string, ok bool) {
const prefix = "branch."
if !strings.HasPrefix(key, prefix) {
return "", "", false
}
trimmed := strings.TrimPrefix(key, prefix)
switch {
case strings.HasSuffix(trimmed, ".remote"):
return strings.TrimSuffix(trimmed, ".remote"), "remote", true
case strings.HasSuffix(trimmed, ".merge"):
return strings.TrimSuffix(trimmed, ".merge"), "merge", true
default:
return "", "", false
}
}
func splitNonEmptyLines(s string) []string {
lines := strings.Split(strings.TrimSpace(s), "\n")
out := make([]string, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if line != "" {
out = append(out, line)
}
}
return out
}