mirror of
https://gitea.com/gitea/tea.git
synced 2026-06-05 18:58:43 +02:00
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:
+43
-48
@@ -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, "'", "'\\''") + "'"
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user