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>
This commit is contained in:
Lunny Xiao
2026-05-23 20:24:47 +00:00
parent 8e0666ab85
commit a664449282
19 changed files with 1113 additions and 380 deletions
+43 -48
View File
@@ -7,69 +7,64 @@ import (
"fmt"
"net/url"
"os"
"strings"
"gitea.dev/tea/modules/utils"
git_transport "github.com/go-git/go-git/v5/plumbing/transport"
gogit_http "github.com/go-git/go-git/v5/plumbing/transport/http"
gogit_ssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
"golang.org/x/crypto/ssh"
)
type pwCallback = func(string) (string, error)
// GetAuthForURL returns the appropriate AuthMethod to be used in Push() / Pull()
// operations depending on the protocol, and prompts the user for credentials if
// necessary.
func GetAuthForURL(remoteURL *url.URL, authToken, keyFile string, passwordCallback pwCallback) (git_transport.AuthMethod, error) {
// GetAuthForURL returns backend-agnostic auth settings for git network operations.
func GetAuthForURL(remoteURL *url.URL, authToken, keyFile string, passwordCallback pwCallback) (*AuthMethod, error) {
switch remoteURL.Scheme {
case "http", "https":
// gitea supports push/pull via app token as username.
return &gogit_http.BasicAuth{Password: "", Username: authToken}, nil
return &AuthMethod{Scheme: remoteURL.Scheme, Username: authToken}, nil
case "ssh":
// try to select right key via ssh-agent. if it fails, try to read a key manually
user := remoteURL.User.Username()
auth, err := gogit_ssh.DefaultAuthBuilder(user)
if err != nil {
signer, err2 := readSSHPrivKey(keyFile, passwordCallback)
if err2 != nil {
return nil, err2
}
auth = &gogit_ssh.PublicKeys{User: user, Signer: signer}
if keyFile == "" {
return &AuthMethod{Scheme: remoteURL.Scheme, Username: remoteURL.User.Username()}, nil
}
expandedKeyFile, err := utils.AbsPathWithExpansion(keyFile)
if err != nil {
return nil, err
}
sshKey, err := os.ReadFile(expandedKeyFile)
if err != nil {
return nil, fmt.Errorf("can not read ssh key '%s'", expandedKeyFile)
}
return auth, nil
}
return nil, fmt.Errorf("don't know how to handle url scheme %v", remoteURL.Scheme)
}
func readSSHPrivKey(keyFile string, passwordCallback pwCallback) (sig ssh.Signer, err error) {
if keyFile != "" {
keyFile, err = utils.AbsPathWithExpansion(keyFile)
} else {
keyFile, err = utils.AbsPathWithExpansion("~/.ssh/id_rsa")
}
if err != nil {
return nil, err
}
sshKey, err := os.ReadFile(keyFile)
if err != nil {
return nil, fmt.Errorf("can not read ssh key '%s'", keyFile)
}
sig, err = ssh.ParsePrivateKey(sshKey)
if _, ok := err.(*ssh.PassphraseMissingError); ok && passwordCallback != nil {
// allow for up to 3 password attempts
for i := 0; i < 3; i++ {
var pass string
pass, err = passwordCallback(keyFile)
if err != nil {
auth := &AuthMethod{
Scheme: remoteURL.Scheme,
Username: remoteURL.User.Username(),
KeyFile: expandedKeyFile,
}
if _, err := ssh.ParsePrivateKey(sshKey); err == nil {
return auth, nil
}
if _, ok := err.(*ssh.PassphraseMissingError); ok {
if passwordCallback == nil {
return nil, err
}
sig, err = ssh.ParsePrivateKeyWithPassphrase(sshKey, []byte(pass))
if err == nil {
break
for i := 0; i < 3; i++ {
pass, err := passwordCallback(expandedKeyFile)
if err != nil {
return nil, err
}
if _, err := ssh.ParsePrivateKeyWithPassphrase(sshKey, []byte(pass)); err == nil {
auth.KeyPassphrase = pass
return auth, nil
}
}
return nil, err
}
return nil, err
default:
return nil, fmt.Errorf("don't know how to handle url scheme %v", remoteURL.Scheme)
}
return sig, err
}
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
}
+103
View File
@@ -0,0 +1,103 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"fmt"
"sort"
"sync"
)
var backendRegistry = struct {
sync.RWMutex
current string
backends map[string]Backend
}{
backends: make(map[string]Backend),
}
func init() {
mustRegisterBackend(cliBackend{})
mustUseBackend("cli")
}
// RegisterBackend makes a git backend available for later switching.
func RegisterBackend(backend Backend) error {
if backend == nil {
return fmt.Errorf("git backend is nil")
}
name := backend.Name()
if name == "" {
return fmt.Errorf("git backend name is empty")
}
backendRegistry.Lock()
defer backendRegistry.Unlock()
backendRegistry.backends[name] = backend
if backendRegistry.current == "" {
backendRegistry.current = name
}
return nil
}
func mustRegisterBackend(backend Backend) {
if err := RegisterBackend(backend); err != nil {
panic(err)
}
}
// UseBackend switches the active git backend implementation.
func UseBackend(name string) error {
backendRegistry.Lock()
defer backendRegistry.Unlock()
if _, ok := backendRegistry.backends[name]; !ok {
return fmt.Errorf("git backend %q is not registered", name)
}
backendRegistry.current = name
return nil
}
func mustUseBackend(name string) {
if err := UseBackend(name); err != nil {
panic(err)
}
}
// CurrentBackendName returns the active backend name.
func CurrentBackendName() string {
backendRegistry.RLock()
defer backendRegistry.RUnlock()
return backendRegistry.current
}
// RegisteredBackends returns all registered backend names.
func RegisteredBackends() []string {
backendRegistry.RLock()
defer backendRegistry.RUnlock()
out := make([]string, 0, len(backendRegistry.backends))
for name := range backendRegistry.backends {
out = append(out, name)
}
sort.Strings(out)
return out
}
func currentBackend() Backend {
backendRegistry.RLock()
defer backendRegistry.RUnlock()
return backendRegistry.backends[backendRegistry.current]
}
func setBackendForTesting(t testingT, backend Backend) {
t.Helper()
prev := CurrentBackendName()
mustRegisterBackend(backend)
mustUseBackend(backend.Name())
t.Cleanup(func() { mustUseBackend(prev) })
}
type testingT interface {
Cleanup(func())
Helper()
}
+74
View File
@@ -0,0 +1,74 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"fmt"
"testing"
"github.com/stretchr/testify/require"
)
type fakeBackend struct{}
type fakeRepoBackend struct {
workTree string
}
func (fakeBackend) Name() string { return "fake" }
func (fakeBackend) Open(path string) (RepositoryBackend, error) {
return &fakeRepoBackend{workTree: "open:" + path}, nil
}
func (fakeBackend) Clone(path, remoteURL string, auth *AuthMethod, opts CloneOptions) (RepositoryBackend, error) {
return &fakeRepoBackend{workTree: fmt.Sprintf("clone:%s:%s", path, remoteURL)}, nil
}
func (r *fakeRepoBackend) WorkTree() string { return r.workTree }
func (r *fakeRepoBackend) Config() (*Config, error) {
return &Config{Remotes: map[string]*RemoteConfig{}, Branches: map[string]*Branch{}}, nil
}
func (r *fakeRepoBackend) Head() (*Reference, error) {
return &Reference{name: NewBranchReferenceName("main"), hash: Hash("deadbeef")}, nil
}
func (r *fakeRepoBackend) AddRemote(name, remoteURL string) error { return nil }
func (r *fakeRepoBackend) SetBranchUpstream(branchName, remoteName, remoteBranch string) error {
return nil
}
func (r *fakeRepoBackend) Fetch(remoteName string, refspecs []string, auth *AuthMethod) error {
return nil
}
func (r *fakeRepoBackend) CreateTrackingBranch(localBranchName, remoteBranchName, remoteName string) error {
return nil
}
func (r *fakeRepoBackend) Checkout(ref ReferenceName) error { return nil }
func (r *fakeRepoBackend) DeleteLocalBranch(branchName string) error { return nil }
func (r *fakeRepoBackend) DeleteRemoteBranch(remoteName, remoteBranch string, auth *AuthMethod) error {
return nil
}
func (r *fakeRepoBackend) ListReferences(prefixes ...string) ([]*Reference, error) { return nil, nil }
func (r *fakeRepoBackend) PushToAgitFlowPR(remoteName, head, base, topic string, pushOptions map[string]string, auth *AuthMethod) error {
return nil
}
func TestCanSwitchBackends(t *testing.T) {
setBackendForTesting(t, fakeBackend{})
repo, err := RepoFromPath("demo")
require.NoError(t, err)
require.Equal(t, "open:demo", repo.WorkTree())
require.Equal(t, "fake", CurrentBackendName())
cloned, err := Clone("target", "https://example.com/repo.git", nil, 1, false)
require.NoError(t, err)
require.Equal(t, "clone:target:https://example.com/repo.git", cloned.WorkTree())
}
func TestRegisteredBackendsContainsCLI(t *testing.T) {
require.Contains(t, RegisteredBackends(), "cli")
}
+74 -143
View File
@@ -8,73 +8,31 @@ import (
"fmt"
"strings"
"unicode"
"github.com/go-git/go-git/v5"
git_config "github.com/go-git/go-git/v5/config"
git_plumbing "github.com/go-git/go-git/v5/plumbing"
git_transport "github.com/go-git/go-git/v5/plumbing/transport"
)
// TeaCreateBranch creates a new branch in the repo, tracking from another branch.
func (r TeaRepo) TeaCreateBranch(localBranchName, remoteBranchName, remoteName string) error {
// save in .git/config to assign remote for future pulls
localBranchRefName := git_plumbing.NewBranchReferenceName(localBranchName)
err := r.CreateBranch(&git_config.Branch{
Name: localBranchName,
Merge: git_plumbing.NewBranchReferenceName(remoteBranchName),
Remote: remoteName,
})
if err != nil {
return err
}
// serialize the branch to .git/refs/heads
remoteBranchRefName := git_plumbing.NewRemoteReferenceName(remoteName, remoteBranchName)
remoteBranchRef, err := r.Storer.Reference(remoteBranchRefName)
if err != nil {
return err
}
localHashRef := git_plumbing.NewHashReference(localBranchRefName, remoteBranchRef.Hash())
return r.Storer.SetReference(localHashRef)
return r.backend.CreateTrackingBranch(localBranchName, remoteBranchName, remoteName)
}
// TeaCheckout checks out the given branch in the worktree.
func (r TeaRepo) TeaCheckout(ref git_plumbing.ReferenceName) error {
tree, err := r.Worktree()
if err != nil {
return err
}
return tree.Checkout(&git.CheckoutOptions{Branch: ref})
func (r TeaRepo) TeaCheckout(ref ReferenceName) error {
return r.backend.Checkout(ref)
}
// TeaDeleteLocalBranch removes the given branch locally
func (r TeaRepo) TeaDeleteLocalBranch(branch *git_config.Branch) error {
err := r.DeleteBranch(branch.Name)
// if the branch is not found that's ok, as .git/config may have no entry if
// no remote tracking branch is configured for it (eg push without -u flag)
if err != nil && err.Error() != "branch not found" {
return err
}
return r.Storer.RemoveReference(git_plumbing.NewBranchReferenceName(branch.Name))
// TeaDeleteLocalBranch removes the given branch locally.
func (r TeaRepo) TeaDeleteLocalBranch(branch *Branch) error {
return r.backend.DeleteLocalBranch(branch.Name)
}
// TeaDeleteRemoteBranch removes the given branch on the given remote via git protocol
func (r TeaRepo) TeaDeleteRemoteBranch(remoteName, remoteBranch string, auth git_transport.AuthMethod) error {
// delete remote branch via git protocol:
// an empty source in the refspec means remote deletion to git 🙃
refspec := fmt.Sprintf(":%s", git_plumbing.NewBranchReferenceName(remoteBranch))
return r.Push(&git.PushOptions{
RemoteName: remoteName,
RefSpecs: []git_config.RefSpec{git_config.RefSpec(refspec)},
Prune: true,
Auth: auth,
})
// TeaDeleteRemoteBranch removes the given branch on the given remote via git protocol.
func (r TeaRepo) TeaDeleteRemoteBranch(remoteName, remoteBranch string, auth *AuthMethod) error {
return r.backend.DeleteRemoteBranch(remoteName, remoteBranch, auth)
}
// TeaFindBranchBySha returns a branch that is at the the given SHA and syncs to the
// given remote repo.
func (r TeaRepo) TeaFindBranchBySha(sha, repoURL string) (b *git_config.Branch, err error) {
// find remote matching our repoURL
func (r TeaRepo) TeaFindBranchBySha(sha, repoURL string) (b *Branch, err error) {
remote, err := r.GetRemote(repoURL)
if err != nil {
return nil, err
@@ -84,41 +42,26 @@ func (r TeaRepo) TeaFindBranchBySha(sha, repoURL string) (b *git_config.Branch,
}
remoteName := remote.Config().Name
// check if the given remote has our branch (.git/refs/remotes/<remoteName>/*)
iter, err := r.References()
refs, err := r.backend.ListReferences("refs/heads", "refs/remotes/"+remoteName)
if err != nil {
return nil, err
}
defer iter.Close()
var remoteRefName git_plumbing.ReferenceName
var localRefName git_plumbing.ReferenceName
err = iter.ForEach(func(ref *git_plumbing.Reference) error {
if ref.Name().IsRemote() {
name := ref.Name().Short()
if ref.Hash().String() == sha && strings.HasPrefix(name, remoteName) {
remoteRefName = ref.Name()
}
}
var remoteRefName ReferenceName
var localRefName ReferenceName
for _, ref := range refs {
if ref.Name().IsRemote() && ref.Hash().String() == sha {
remoteRefName = ref.Name()
}
if ref.Name().IsBranch() && ref.Hash().String() == sha {
localRefName = ref.Name()
}
return nil
})
if err != nil {
return nil, err
}
if remoteRefName == "" || localRefName == "" {
// no remote tracking branch found, so a potential local branch
// can't be a match either
return nil, nil
}
b = &git_config.Branch{
Remote: remoteName,
Name: localRefName.Short(),
Merge: localRefName,
}
b = &Branch{Remote: remoteName, Name: localRefName.Short(), Merge: localRefName}
return b, b.Validate()
}
@@ -126,8 +69,7 @@ func (r TeaRepo) TeaFindBranchBySha(sha, repoURL string) (b *git_config.Branch,
// remote names and syncs to the given remote repo. This method is less precise
// than TeaFindBranchBySha(), but may be desirable if local and remote branch
// have diverged.
func (r TeaRepo) TeaFindBranchByName(branchName, repoURL string) (b *git_config.Branch, err error) {
// find remote matching our repoURL
func (r TeaRepo) TeaFindBranchByName(branchName, repoURL string) (b *Branch, err error) {
remote, err := r.GetRemote(repoURL)
if err != nil {
return nil, err
@@ -137,45 +79,35 @@ func (r TeaRepo) TeaFindBranchByName(branchName, repoURL string) (b *git_config.
}
remoteName := remote.Config().Name
// check if the given remote has our branch (.git/refs/remotes/<remoteName>/*)
iter, err := r.References()
refs, err := r.backend.ListReferences("refs/heads", "refs/remotes/"+remoteName)
if err != nil {
return nil, err
}
defer iter.Close()
var remoteRefName git_plumbing.ReferenceName
var localRefName git_plumbing.ReferenceName
var remoteRefName ReferenceName
var localRefName ReferenceName
remoteSearchingName := fmt.Sprintf("%s/%s", remoteName, branchName)
err = iter.ForEach(func(ref *git_plumbing.Reference) error {
for _, ref := range refs {
if ref.Name().IsRemote() && ref.Name().Short() == remoteSearchingName {
remoteRefName = ref.Name()
}
n := ref.Name()
if n.IsBranch() && n.Short() == branchName {
localRefName = n
if ref.Name().IsBranch() && ref.Name().Short() == branchName {
localRefName = ref.Name()
}
return nil
})
if err != nil {
return nil, err
}
if remoteRefName == "" || localRefName == "" {
return nil, nil
}
b = &git_config.Branch{
Remote: remoteName,
Name: localRefName.Short(),
Merge: localRefName,
}
b = &Branch{Remote: remoteName, Name: localRefName.Short(), Merge: localRefName}
return b, b.Validate()
}
// TeaFindBranchRemote gives the first remote that has a branch with the same name or sha,
// depending on what is passed in.
// This function is needed, as git does not always define branches in .git/config with remote entries.
// Priority order is: first match of sha and branch -> first match of branch -> first match of sha
func (r TeaRepo) TeaFindBranchRemote(branchName, hash string) (*git.Remote, error) {
// Priority order is: first match of sha and branch -> first match of branch -> first match of sha.
func (r TeaRepo) TeaFindBranchRemote(branchName, hash string) (*Remote, error) {
remotes, err := r.Remotes()
if err != nil {
return nil, err
@@ -188,55 +120,53 @@ func (r TeaRepo) TeaFindBranchRemote(branchName, hash string) (*git.Remote, erro
return remotes[0], nil
}
// check if the given remote has our branch (.git/refs/remotes/<remoteName>/*)
iter, err := r.References()
refs, err := r.backend.ListReferences("refs/remotes")
if err != nil {
return nil, err
}
defer iter.Close()
var shaMatch *git.Remote
var branchMatch *git.Remote
var fullMatch *git.Remote
if err := iter.ForEach(func(ref *git_plumbing.Reference) error {
if ref.Name().IsRemote() {
names := strings.SplitN(ref.Name().Short(), "/", 2)
remote := names[0]
branch := names[1]
if branchMatch == nil && branchName != "" && branchName == branch {
if branchMatch, err = r.Remote(remote); err != nil {
return err
}
}
if shaMatch == nil && hash != "" && hash == ref.Hash().String() {
if shaMatch, err = r.Remote(remote); err != nil {
return err
}
}
if fullMatch == nil && branchName != "" && branchName == branch && hash != "" && hash == ref.Hash().String() {
if fullMatch, err = r.Remote(remote); err != nil {
return err
}
// stop asap you have a full match
return nil
}
remoteByName := make(map[string]*Remote, len(remotes))
for _, remote := range remotes {
remoteByName[remote.Config().Name] = remote
}
var shaMatch *Remote
var branchMatch *Remote
var fullMatch *Remote
for _, ref := range refs {
remoteName, remoteBranch, ok := splitRemoteRef(ref.Name())
if !ok {
continue
}
remote := remoteByName[remoteName]
if remote == nil {
continue
}
if branchMatch == nil && branchName != "" && branchName == remoteBranch {
branchMatch = remote
}
if shaMatch == nil && hash != "" && hash == ref.Hash().String() {
shaMatch = remote
}
if fullMatch == nil && branchName != "" && branchName == remoteBranch && hash != "" && hash == ref.Hash().String() {
fullMatch = remote
break
}
return nil
}); err != nil {
return nil, err
}
if fullMatch != nil {
switch {
case fullMatch != nil:
return fullMatch, nil
} else if branchMatch != nil {
case branchMatch != nil:
return branchMatch, nil
} else if shaMatch != nil {
case shaMatch != nil:
return shaMatch, nil
default:
return nil, nil
}
return nil, nil
}
// TeaGetCurrentBranchNameAndSHA return the name and sha of the branch witch is currently active
// TeaGetCurrentBranchNameAndSHA return the name and sha of the branch witch is currently active.
func (r TeaRepo) TeaGetCurrentBranchNameAndSHA() (string, string, error) {
localHead, err := r.Head()
if err != nil {
@@ -251,13 +181,11 @@ func (r TeaRepo) TeaGetCurrentBranchNameAndSHA() (string, string, error) {
}
// PushToCreatAgitFlowPR pushes the given head to the refs/for/<base>/<topic> ref on the remote to create an agit flow PR.
func (r TeaRepo) PushToCreatAgitFlowPR(remoteName, head, base, topic, title, description string, auth git_transport.AuthMethod) error {
func (r TeaRepo) PushToCreatAgitFlowPR(remoteName, head, base, topic, title, description string, auth *AuthMethod) error {
if !strings.HasPrefix(head, "refs/") {
head = "refs/heads/" + head
}
ref := fmt.Sprintf("%s:refs/for/%s/%s", head, base, topic)
pushOptions := make(map[string]string)
if len(title) > 0 {
pushOptions["title"] = b64Encode(title)
@@ -265,15 +193,18 @@ func (r TeaRepo) PushToCreatAgitFlowPR(remoteName, head, base, topic, title, des
if len(description) > 0 {
pushOptions["description"] = b64Encode(description)
}
return r.backend.PushToAgitFlowPR(remoteName, head, base, topic, pushOptions, auth)
}
opts := &git.PushOptions{
RemoteName: remoteName,
RefSpecs: []git_config.RefSpec{git_config.RefSpec(ref)},
Options: pushOptions,
Auth: auth,
func splitRemoteRef(ref ReferenceName) (remoteName, branchName string, ok bool) {
if !ref.IsRemote() {
return "", "", false
}
return r.Push(opts)
parts := strings.SplitN(ref.Short(), "/", 2)
if len(parts) != 2 {
return "", "", false
}
return parts[0], parts[1], true
}
// b64Encode implements base64 encode for string if necessary.
+374
View File
@@ -0,0 +1,374 @@
// 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
}
+28
View File
@@ -0,0 +1,28 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
// Clone clones a repository using the active backend.
func Clone(path, remoteURL string, auth *AuthMethod, depth int, insecure bool) (*TeaRepo, error) {
backend, err := currentBackend().Clone(path, remoteURL, auth, CloneOptions{Depth: depth, Insecure: insecure})
if err != nil {
return nil, err
}
return newTeaRepo(backend), nil
}
// AddRemote adds a new remote to the repository.
func (r TeaRepo) AddRemote(name, remoteURL string) error {
return r.backend.AddRemote(name, remoteURL)
}
// SetBranchUpstream configures the branch's upstream remote.
func (r TeaRepo) SetBranchUpstream(branchName, remoteName, remoteBranch string) error {
return r.backend.SetBranchUpstream(branchName, remoteName, remoteBranch)
}
// Fetch fetches updates from the named remote.
func (r TeaRepo) Fetch(remoteName string, refspecs []string, auth *AuthMethod) error {
return r.backend.Fetch(remoteName, refspecs, auth)
}
+11 -18
View File
@@ -6,14 +6,11 @@ package git
import (
"fmt"
"net/url"
"github.com/go-git/go-git/v5"
git_config "github.com/go-git/go-git/v5/config"
)
// GetRemote tries to match a Remote of the repo via the given URL.
// Matching is based on the normalized URL, accepting different protocols.
func (r TeaRepo) GetRemote(remoteURL string) (*git.Remote, error) {
func (r TeaRepo) GetRemote(remoteURL string) (*Remote, error) {
repoURL, err := ParseURL(remoteURL)
if err != nil {
return nil, err
@@ -23,14 +20,14 @@ func (r TeaRepo) GetRemote(remoteURL string) (*git.Remote, error) {
if err != nil {
return nil, err
}
for _, r := range remotes {
for _, u := range r.Config().URLs {
remoteURL, err := ParseURL(u)
for _, remote := range remotes {
for _, u := range remote.Config().URLs {
parsedRemoteURL, err := ParseURL(u)
if err != nil {
return nil, err
}
if remoteURL.Host == repoURL.Host && remoteURL.Path == repoURL.Path {
return r, nil
if parsedRemoteURL.Host == repoURL.Host && parsedRemoteURL.Path == repoURL.Path {
return remote, nil
}
}
}
@@ -41,27 +38,23 @@ func (r TeaRepo) GetRemote(remoteURL string) (*git.Remote, error) {
// GetOrCreateRemote tries to match a Remote of the repo via the given URL.
// If no match is found, a new Remote with `newRemoteName` is created.
// Matching is based on the normalized URL, accepting different protocols.
func (r TeaRepo) GetOrCreateRemote(remoteURL, newRemoteName string) (*git.Remote, error) {
func (r TeaRepo) GetOrCreateRemote(remoteURL, newRemoteName string) (*Remote, error) {
localRemote, err := r.GetRemote(remoteURL)
if err != nil {
return nil, err
}
// if no match found, create a new remote
if localRemote == nil {
localRemote, err = r.CreateRemote(&git_config.RemoteConfig{
Name: newRemoteName,
URLs: []string{remoteURL},
})
if err != nil {
if err := r.AddRemote(newRemoteName, remoteURL); err != nil {
return nil, err
}
return r.Remote(newRemoteName)
}
return localRemote, nil
}
// TeaRemoteURL returns the first url entry for the given remote name
// TeaRemoteURL returns the first url entry for the given remote name.
func (r TeaRepo) TeaRemoteURL(name string) (auth *url.URL, err error) {
remote, err := r.Remote(name)
if err != nil {
@@ -71,5 +64,5 @@ func (r TeaRepo) TeaRemoteURL(name string) (auth *url.URL, err error) {
if len(urls) == 0 {
return nil, fmt.Errorf("remote %s has no URL configured", name)
}
return ParseURL(remote.Config().URLs[0])
return ParseURL(urls[0])
}
+62 -15
View File
@@ -4,14 +4,18 @@
package git
import (
"fmt"
"net/url"
"github.com/go-git/go-git/v5"
"sort"
)
// TeaRepo is a go-git Repository, with an extended high level interface.
// TeaRepo wraps a local git repository behind a swappable backend.
type TeaRepo struct {
*git.Repository
backend RepositoryBackend
}
func newTeaRepo(backend RepositoryBackend) *TeaRepo {
return &TeaRepo{backend: backend}
}
// RepoForWorkdir tries to open the git repository in the local directory
@@ -20,28 +24,71 @@ func RepoForWorkdir() (*TeaRepo, error) {
return RepoFromPath("")
}
// RepoFromPath tries to open the git repository by path
// RepoFromPath tries to open the git repository by path.
func RepoFromPath(path string) (*TeaRepo, error) {
if len(path) == 0 {
path = "./"
backend, err := currentBackend().Open(path)
if err != nil {
return nil, err
}
repo, err := git.PlainOpenWithOptions(path, &git.PlainOpenOptions{
DetectDotGit: true,
EnableDotGitCommonDir: true, // Enable commondir support for worktrees
})
return newTeaRepo(backend), nil
}
// WorkTree returns the repository work tree path.
func (r TeaRepo) WorkTree() string {
return r.backend.WorkTree()
}
// Config returns the repository config values tea needs.
func (r TeaRepo) Config() (*Config, error) {
return r.backend.Config()
}
// Remote returns the configured remote by name.
func (r TeaRepo) Remote(remoteName string) (*Remote, error) {
cfg, err := r.Config()
if err != nil {
return nil, err
}
remoteCfg, ok := cfg.Remotes[remoteName]
if !ok {
return nil, fmt.Errorf("remote %s not found", remoteName)
}
return &Remote{repo: &r, config: remoteCfg}, nil
}
// Remotes returns all configured remotes sorted by name.
func (r TeaRepo) Remotes() ([]*Remote, error) {
cfg, err := r.Config()
if err != nil {
return nil, err
}
return &TeaRepo{repo}, nil
remoteNames := make([]string, 0, len(cfg.Remotes))
for name := range cfg.Remotes {
remoteNames = append(remoteNames, name)
}
sort.Strings(remoteNames)
remotes := make([]*Remote, 0, len(remoteNames))
for _, name := range remoteNames {
remotes = append(remotes, &Remote{repo: &r, config: cfg.Remotes[name]})
}
return remotes, nil
}
// RemoteURL returns the URL of the given remote
// Head returns the currently checked out ref.
func (r TeaRepo) Head() (*Reference, error) {
return r.backend.Head()
}
// RemoteURL returns the URL of the given remote.
func (r TeaRepo) RemoteURL(remoteName string) (*url.URL, error) {
remote, err := r.Remote(remoteName)
if err != nil {
return nil, err
}
return url.Parse(remote.Config().URLs[0])
if len(remote.Config().URLs) == 0 {
return nil, fmt.Errorf("remote %s has no URL configured", remoteName)
}
return ParseURL(remote.Config().URLs[0])
}
+136
View File
@@ -0,0 +1,136 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func runGit(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
if dir != "" {
cmd.Dir = dir
}
out, err := cmd.CombinedOutput()
require.NoErrorf(t, err, "git %v failed: %s", args, out)
return string(out)
}
func tryGit(dir string, args ...string) error {
cmd := exec.Command("git", args...)
if dir != "" {
cmd.Dir = dir
}
_, err := cmd.CombinedOutput()
return err
}
func TestRepoFromPathSupportsWorktrees(t *testing.T) {
tmpDir := t.TempDir()
mainRepoPath := filepath.Join(tmpDir, "main-repo")
worktreePath := filepath.Join(tmpDir, "worktree")
runGit(t, "", "init", mainRepoPath)
runGit(t, mainRepoPath, "config", "user.email", "test@example.com")
runGit(t, mainRepoPath, "config", "user.name", "Test User")
runGit(t, mainRepoPath, "remote", "add", "origin", "https://gitea.com/owner/repo.git")
readmePath := filepath.Join(mainRepoPath, "README.md")
require.NoError(t, os.WriteFile(readmePath, []byte("# Test Repo\n"), 0o644))
runGit(t, mainRepoPath, "add", "README.md")
runGit(t, mainRepoPath, "commit", "-m", "Initial commit")
runGit(t, mainRepoPath, "worktree", "add", worktreePath, "-b", "test-branch")
repo, err := RepoFromPath(worktreePath)
require.NoError(t, err)
config, err := repo.Config()
require.NoError(t, err)
require.Contains(t, config.Remotes, "origin")
require.Equal(t, "https://gitea.com/owner/repo.git", config.Remotes["origin"].URLs[0])
head, err := repo.Head()
require.NoError(t, err)
require.Equal(t, "test-branch", head.Name().Short())
}
func TestRepoFromPathSupportsSHA256Repos(t *testing.T) {
tmpDir := t.TempDir()
repoPath := filepath.Join(tmpDir, "sha256-repo")
if err := tryGit("", "init", "--object-format=sha256", repoPath); err != nil {
t.Skip("git does not support sha256 object format in this environment")
}
runGit(t, repoPath, "config", "user.email", "test@example.com")
runGit(t, repoPath, "config", "user.name", "Test User")
runGit(t, repoPath, "remote", "add", "origin", "https://gitea.com/owner/repo.git")
readmePath := filepath.Join(repoPath, "README.md")
require.NoError(t, os.WriteFile(readmePath, []byte("sha256\n"), 0o644))
runGit(t, repoPath, "add", "README.md")
runGit(t, repoPath, "commit", "-m", "Initial commit")
repo, err := RepoFromPath(repoPath)
require.NoError(t, err)
branch, sha, err := repo.TeaGetCurrentBranchNameAndSHA()
require.NoError(t, err)
require.NotEmpty(t, branch)
require.Len(t, sha, 64)
config, err := repo.Config()
require.NoError(t, err)
require.Contains(t, config.Remotes, "origin")
}
func TestTeaFindBranchByShaAndName(t *testing.T) {
tmpDir := t.TempDir()
remotePath := filepath.Join(tmpDir, "remote.git")
localPath := filepath.Join(tmpDir, "local")
runGit(t, "", "init", "--bare", remotePath)
runGit(t, "", "clone", remotePath, localPath)
runGit(t, localPath, "config", "user.email", "test@example.com")
runGit(t, localPath, "config", "user.name", "Test User")
filePath := filepath.Join(localPath, "README.md")
require.NoError(t, os.WriteFile(filePath, []byte("main\n"), 0o644))
runGit(t, localPath, "add", "README.md")
runGit(t, localPath, "commit", "-m", "Initial commit")
runGit(t, localPath, "branch", "-M", "main")
runGit(t, localPath, "push", "-u", "origin", "main")
runGit(t, localPath, "checkout", "-b", "feature/demo")
require.NoError(t, os.WriteFile(filePath, []byte("feature\n"), 0o644))
runGit(t, localPath, "commit", "-am", "Feature commit")
runGit(t, localPath, "push", "-u", "origin", "feature/demo")
repo, err := RepoFromPath(localPath)
require.NoError(t, err)
sha := strings.TrimSpace(runGit(t, localPath, "rev-parse", "HEAD"))
branchBySha, err := repo.TeaFindBranchBySha(sha, remotePath)
require.NoError(t, err)
require.NotNil(t, branchBySha)
require.Equal(t, "feature/demo", branchBySha.Name)
require.Equal(t, "origin", branchBySha.Remote)
branchByName, err := repo.TeaFindBranchByName("feature/demo", remotePath)
require.NoError(t, err)
require.NotNil(t, branchByName)
require.Equal(t, "feature/demo", branchByName.Name)
remote, err := repo.TeaFindBranchRemote("feature/demo", sha)
require.NoError(t, err)
require.NotNil(t, remote)
require.Equal(t, "origin", remote.Config().Name)
}
+163
View File
@@ -0,0 +1,163 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"errors"
"strings"
)
var (
// ErrRepositoryNotExists indicates the requested path is not inside a git repository.
ErrRepositoryNotExists = errors.New("repository does not exist")
// ErrBranchExists indicates the requested branch already exists locally.
ErrBranchExists = errors.New("branch already exists")
)
// AuthMethod carries backend-agnostic authentication information for git operations.
type AuthMethod struct {
Scheme string
Username string
Password string
KeyFile string
KeyPassphrase string
}
// CloneOptions describes repository clone behavior.
type CloneOptions struct {
Depth int
Insecure bool
}
// RepositoryBackend is the backend abstraction used by TeaRepo.
type RepositoryBackend interface {
WorkTree() string
Config() (*Config, error)
Head() (*Reference, error)
AddRemote(name, remoteURL string) error
SetBranchUpstream(branchName, remoteName, remoteBranch string) error
Fetch(remoteName string, refspecs []string, auth *AuthMethod) error
CreateTrackingBranch(localBranchName, remoteBranchName, remoteName string) error
Checkout(ref ReferenceName) error
DeleteLocalBranch(branchName string) error
DeleteRemoteBranch(remoteName, remoteBranch string, auth *AuthMethod) error
ListReferences(prefixes ...string) ([]*Reference, error)
PushToAgitFlowPR(remoteName, head, base, topic string, pushOptions map[string]string, auth *AuthMethod) error
}
// Backend opens and clones repositories using a concrete git implementation.
type Backend interface {
Name() string
Open(path string) (RepositoryBackend, error)
Clone(path, remoteURL string, auth *AuthMethod, opts CloneOptions) (RepositoryBackend, error)
}
// Config mirrors the repository config fields tea needs.
type Config struct {
Remotes map[string]*RemoteConfig
Branches map[string]*Branch
}
// RemoteConfig stores remote configuration.
type RemoteConfig struct {
Name string
URLs []string
}
// Branch stores branch configuration.
type Branch struct {
Name string
Remote string
Merge ReferenceName
}
// Validate checks whether the branch contains the fields tea needs.
func (b *Branch) Validate() error {
if b == nil || b.Name == "" {
return errors.New("branch name is required")
}
return nil
}
// Remote wraps a configured git remote.
type Remote struct {
repo *TeaRepo
config *RemoteConfig
}
// Config returns the remote configuration.
func (r *Remote) Config() *RemoteConfig {
if r == nil {
return nil
}
return r.config
}
// ReferenceName identifies a git reference.
type ReferenceName string
func (r ReferenceName) String() string { return string(r) }
// Short returns the short display name for the reference.
func (r ReferenceName) Short() string {
s := string(r)
switch {
case strings.HasPrefix(s, "refs/heads/"):
return strings.TrimPrefix(s, "refs/heads/")
case strings.HasPrefix(s, "refs/remotes/"):
return strings.TrimPrefix(s, "refs/remotes/")
case strings.HasPrefix(s, "refs/tags/"):
return strings.TrimPrefix(s, "refs/tags/")
default:
return s
}
}
// IsBranch reports whether the reference points to a local branch.
func (r ReferenceName) IsBranch() bool {
return strings.HasPrefix(string(r), "refs/heads/")
}
// IsRemote reports whether the reference points to a remote-tracking branch.
func (r ReferenceName) IsRemote() bool {
return strings.HasPrefix(string(r), "refs/remotes/")
}
// IsTag reports whether the reference points to a tag.
func (r ReferenceName) IsTag() bool {
return strings.HasPrefix(string(r), "refs/tags/")
}
// Hash wraps a git object id.
type Hash string
func (h Hash) String() string { return string(h) }
// Reference stores a resolved git ref and its hash.
type Reference struct {
name ReferenceName
hash Hash
}
// Name returns the reference name.
func (r *Reference) Name() ReferenceName { return r.name }
// Hash returns the reference hash.
func (r *Reference) Hash() Hash { return r.hash }
// NewBranchReferenceName constructs a local branch ref name.
func NewBranchReferenceName(name string) ReferenceName {
if strings.HasPrefix(name, "refs/") {
return ReferenceName(name)
}
return ReferenceName("refs/heads/" + name)
}
// NewRemoteReferenceName constructs a remote-tracking ref name.
func NewRemoteReferenceName(remote, name string) ReferenceName {
if strings.HasPrefix(name, "refs/") {
return ReferenceName(name)
}
return ReferenceName("refs/remotes/" + remote + "/" + name)
}