mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 19:11:25 +01:00
feat!: rebuild tea on Cobra with explicit noun/verb commands (#1126)
- **switch tea to the Cobra command tree:** `internal/app` wires the packages from parts 1–4 into explicit noun/verb commands with `--repo`, `--json` / `--jq`, `--sudo` and `--debug`; `main.go` points at it. Build, release and docs tooling (`tools/cli-docs`, Makefile, goreleaser, snap, CI) and README / CONTRIBUTING are updated. - **remove the urfave/cli command tree:** deletes `cmd/`, `modules/`, the old integration tests, contrib completion scripts and the generated `docs/CLI.md`, and drops their dependencies. This commit only deletes. ## Breaking changes This is a breaking release. It requires Gitea 28 or newer; configuration moves to separate `config.yml` and `hosts.yml` files and requires fresh authentication, and command names, aliases, flags and output formats change. `docs/migration-1.0.md` maps every old command, flag and environment variable to its replacement. ## Issues fixes #1118 fixes #1116 fixes #1115 fixes #1094 fixes #1091 fixes #1073 fixes #1052 fixes #1032 fixes #1023 fixes #966 fixes #908 fixes #833 fixes #831 fixes #817 fixes #804 fixes #779 fixes #770 fixes #737 fixes #697 fixes #726 fixes #677 fixes #672 fixes #633 fixes #632 fixes #560 fixes #556 fixes #523 fixes #516 fixes #510 fixes #504 fixes #500 fixes #482 fixes #406 fixes #403 fixes #373 fixes #351 fixes #344 fixes #326 fixes #325 fixes #323 fixes #246 fixes #240 fixes #232 fixes #197 fixes #172 fixes #141 fixes #69 fixes #1110 fixes #569 fixes #480 fixes #666 fixes #670 fixes #457 fixes #643 fixes #498 fixes #490 fixes #1069 fixes #834 fixes #602 fixes #328 fixes #417 fixes #571 fixes #1029 fixes #489 fixes #1050 fixes #162 fixes #430 fixes #1087 fixes #683 Reviewed-on: https://gitea.com/gitea/tea/pulls/1126 Reviewed-by: TheFox0x7 <95654+thefox0x7@noreply.gitea.com>
This commit is contained in:
1 parent
12e415a7a7
commit
f02245449b
456 files changed
+18892
-34015
No files matched your search
@@ -1,70 +0,0 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/tea/modules/utils"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
type pwCallback = func(string) (string, 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":
|
||||
return &AuthMethod{Scheme: remoteURL.Scheme, Username: authToken}, nil
|
||||
case "ssh":
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func shellQuote(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
// 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()
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
// 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")
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// TeaCreateBranch creates a new branch in the repo, tracking from another branch.
|
||||
func (r TeaRepo) TeaCreateBranch(localBranchName, remoteBranchName, remoteName string) error {
|
||||
return r.backend.CreateTrackingBranch(localBranchName, remoteBranchName, remoteName)
|
||||
}
|
||||
|
||||
// TeaCheckout checks out the given branch in the worktree.
|
||||
func (r TeaRepo) TeaCheckout(ref ReferenceName) error {
|
||||
return r.backend.Checkout(ref)
|
||||
}
|
||||
|
||||
// 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 *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 *Branch, err error) {
|
||||
remote, err := r.GetRemote(repoURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if remote == nil {
|
||||
return nil, fmt.Errorf("no remote found for '%s'", repoURL)
|
||||
}
|
||||
remoteName := remote.Config().Name
|
||||
|
||||
refs, err := r.backend.ListReferences("refs/heads", "refs/remotes/"+remoteName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
if remoteRefName == "" || localRefName == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
b = &Branch{Remote: remoteName, Name: localRefName.Short(), Merge: localRefName}
|
||||
return b, b.Validate()
|
||||
}
|
||||
|
||||
// TeaFindBranchByName returns a branch that is at the the given local and
|
||||
// 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 *Branch, err error) {
|
||||
remote, err := r.GetRemote(repoURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if remote == nil {
|
||||
return nil, fmt.Errorf("no remote found for '%s'", repoURL)
|
||||
}
|
||||
remoteName := remote.Config().Name
|
||||
|
||||
refs, err := r.backend.ListReferences("refs/heads", "refs/remotes/"+remoteName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var remoteRefName ReferenceName
|
||||
var localRefName ReferenceName
|
||||
remoteSearchingName := fmt.Sprintf("%s/%s", remoteName, branchName)
|
||||
for _, ref := range refs {
|
||||
if ref.Name().IsRemote() && ref.Name().Short() == remoteSearchingName {
|
||||
remoteRefName = ref.Name()
|
||||
}
|
||||
if ref.Name().IsBranch() && ref.Name().Short() == branchName {
|
||||
localRefName = ref.Name()
|
||||
}
|
||||
}
|
||||
if remoteRefName == "" || localRefName == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
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) (*Remote, error) {
|
||||
remotes, err := r.Remotes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch {
|
||||
case len(remotes) == 0:
|
||||
return nil, nil
|
||||
case len(remotes) == 1:
|
||||
return remotes[0], nil
|
||||
}
|
||||
|
||||
refs, err := r.backend.ListReferences("refs/remotes")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case fullMatch != nil:
|
||||
return fullMatch, nil
|
||||
case branchMatch != nil:
|
||||
return branchMatch, nil
|
||||
case shaMatch != nil:
|
||||
return shaMatch, nil
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if !localHead.Name().IsBranch() {
|
||||
return "", "", fmt.Errorf("active ref is no branch")
|
||||
}
|
||||
|
||||
return localHead.Name().Short(), localHead.Hash().String(), nil
|
||||
}
|
||||
|
||||
// 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 *AuthMethod) error {
|
||||
if !strings.HasPrefix(head, "refs/") {
|
||||
head = "refs/heads/" + head
|
||||
}
|
||||
|
||||
pushOptions := make(map[string]string)
|
||||
if len(title) > 0 {
|
||||
pushOptions["title"] = b64Encode(title)
|
||||
}
|
||||
if len(description) > 0 {
|
||||
pushOptions["description"] = b64Encode(description)
|
||||
}
|
||||
return r.backend.PushToAgitFlowPR(remoteName, head, base, topic, pushOptions, auth)
|
||||
}
|
||||
|
||||
func splitRemoteRef(ref ReferenceName) (remoteName, branchName string, ok bool) {
|
||||
if !ref.IsRemote() {
|
||||
return "", "", false
|
||||
}
|
||||
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.
|
||||
func b64Encode(s string) string {
|
||||
if strings.Contains(s, "\n") || !isASCII(s) {
|
||||
return "{base64}" + base64.StdEncoding.EncodeToString([]byte(s))
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// isASCII indicates string contains only ASCII.
|
||||
func isASCII(s string) bool {
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] > unicode.MaxASCII {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1,402 +0,0 @@
|
||||
// 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 {
|
||||
args := []string{"checkout"}
|
||||
switch {
|
||||
case ref.IsBranch():
|
||||
// `git checkout refs/heads/<branch>` detaches HEAD, while the short branch
|
||||
// name switches to the local branch as intended.
|
||||
args = append(args, ref.Short())
|
||||
case ref.IsRemote():
|
||||
// Be explicit about detached HEAD when checking out a remote-tracking ref.
|
||||
args = append(args, "--detach", ref.String())
|
||||
default:
|
||||
args = append(args, ref.String())
|
||||
}
|
||||
|
||||
_, err := r.git(nil, nil, args...)
|
||||
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 gitCommandEnv(authEnv []string) []string {
|
||||
baseEnv := os.Environ()
|
||||
env := make([]string, 0, len(baseEnv)+len(authEnv)+3)
|
||||
for _, value := range baseEnv {
|
||||
if strings.HasPrefix(value, "LC_ALL=") ||
|
||||
strings.HasPrefix(value, "LANG=") ||
|
||||
strings.HasPrefix(value, "LANGUAGE=") {
|
||||
continue
|
||||
}
|
||||
env = append(env, value)
|
||||
}
|
||||
env = append(env, authEnv...)
|
||||
return append(env, "LC_ALL=C", "LANG=C", "LANGUAGE=C")
|
||||
}
|
||||
|
||||
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 = gitCommandEnv(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
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOpenClassifiesLocalizedNotRepoError(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("test uses a POSIX shell script as fake git")
|
||||
}
|
||||
|
||||
fakeGitDir := t.TempDir()
|
||||
fakeGit := filepath.Join(fakeGitDir, "git")
|
||||
script := `#!/bin/sh
|
||||
locale="${LC_ALL:-${LANG:-C}}"
|
||||
if [ "$locale" = "C" ]; then
|
||||
echo "fatal: not a git repository (or any of the parent directories): .git" >&2
|
||||
else
|
||||
echo "fatal: Kein Git-Repository (oder irgendein Elternverzeichnis bis zum Einhangepunkt /)" >&2
|
||||
fi
|
||||
exit 128
|
||||
`
|
||||
require.NoError(t, os.WriteFile(fakeGit, []byte(script), 0o755))
|
||||
|
||||
t.Setenv("PATH", fakeGitDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
t.Setenv("LC_ALL", "de_DE.UTF-8")
|
||||
t.Setenv("LANG", "de_DE.UTF-8")
|
||||
t.Setenv("LANGUAGE", "de_DE:de")
|
||||
|
||||
_, err := (cliBackend{}).Open(t.TempDir())
|
||||
require.ErrorIs(t, err, ErrRepositoryNotExists)
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// 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) (*Remote, error) {
|
||||
repoURL, err := ParseURL(remoteURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
remotes, err := r.Remotes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, remote := range remotes {
|
||||
for _, u := range remote.Config().URLs {
|
||||
parsedRemoteURL, err := ParseURL(u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if parsedRemoteURL.Host == repoURL.Host && parsedRemoteURL.Path == repoURL.Path {
|
||||
return remote, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// 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) (*Remote, error) {
|
||||
localRemote, err := r.GetRemote(remoteURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if localRemote == 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.
|
||||
func (r TeaRepo) TeaRemoteURL(name string) (auth *url.URL, err error) {
|
||||
remote, err := r.Remote(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
urls := remote.Config().URLs
|
||||
if len(urls) == 0 {
|
||||
return nil, fmt.Errorf("remote %s has no URL configured", name)
|
||||
}
|
||||
return ParseURL(urls[0])
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// TeaRepo wraps a local git repository behind a swappable backend.
|
||||
type TeaRepo struct {
|
||||
backend RepositoryBackend
|
||||
}
|
||||
|
||||
func newTeaRepo(backend RepositoryBackend) *TeaRepo {
|
||||
return &TeaRepo{backend: backend}
|
||||
}
|
||||
|
||||
// RepoForWorkdir tries to open the git repository in the local directory
|
||||
// for reading or modification.
|
||||
func RepoForWorkdir() (*TeaRepo, error) {
|
||||
return RepoFromPath("")
|
||||
}
|
||||
|
||||
// RepoFromPath tries to open the git repository by path.
|
||||
func RepoFromPath(path string) (*TeaRepo, error) {
|
||||
backend, err := currentBackend().Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
if len(remote.Config().URLs) == 0 {
|
||||
return nil, fmt.Errorf("remote %s has no URL configured", remoteName)
|
||||
}
|
||||
return ParseURL(remote.Config().URLs[0])
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var protocolRe = regexp.MustCompile("^[a-zA-Z_+-]+://")
|
||||
|
||||
// URLParser represents a git URL parser
|
||||
type URLParser struct{}
|
||||
|
||||
// Parse parses the git URL
|
||||
func (p *URLParser) Parse(rawURL string) (u *url.URL, err error) {
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
|
||||
if !protocolRe.MatchString(rawURL) {
|
||||
// convert the weird git ssh url format to a canonical url:
|
||||
// git@gitea.com:gitea/tea -> ssh://git@gitea.com/gitea/tea
|
||||
if strings.Contains(rawURL, ":") &&
|
||||
// not a Windows path
|
||||
!strings.Contains(rawURL, "\\") {
|
||||
rawURL = "ssh://" + strings.Replace(rawURL, ":", "/", 1)
|
||||
} else if !strings.Contains(rawURL, "@") &&
|
||||
strings.Count(rawURL, "/") == 2 {
|
||||
// match cases like gitea.com/gitea/tea
|
||||
rawURL = "https://" + rawURL
|
||||
}
|
||||
}
|
||||
|
||||
u, err = url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if u.Scheme == "git+ssh" {
|
||||
u.Scheme = "ssh"
|
||||
}
|
||||
|
||||
if strings.HasPrefix(u.Path, "//") {
|
||||
u.Path = strings.TrimPrefix(u.Path, "/")
|
||||
}
|
||||
|
||||
// .git suffix is optional and breaks normalization
|
||||
u.Path = strings.TrimSuffix(u.Path, ".git")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// ParseURL parses URL string and return URL struct
|
||||
func ParseURL(rawURL string) (u *url.URL, err error) {
|
||||
p := &URLParser{}
|
||||
return p.Parse(rawURL)
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestParseUrl(t *testing.T) {
|
||||
u, err := ParseURL("ssh://git@gitea.com:3000/gitea/tea")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "gitea.com:3000", u.Host)
|
||||
assert.Equal(t, "ssh", u.Scheme)
|
||||
assert.Equal(t, "/gitea/tea", u.Path)
|
||||
|
||||
u, err = ParseURL("https://gitea.com/gitea/tea")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "gitea.com", u.Host)
|
||||
assert.Equal(t, "https", u.Scheme)
|
||||
assert.Equal(t, "/gitea/tea", u.Path)
|
||||
|
||||
u, err = ParseURL("git@gitea.com:gitea/tea")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "gitea.com", u.Host)
|
||||
assert.Equal(t, "ssh", u.Scheme)
|
||||
assert.Equal(t, "/gitea/tea", u.Path)
|
||||
|
||||
u, err = ParseURL("gitea.com/gitea/tea")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "gitea.com", u.Host)
|
||||
assert.Equal(t, "https", u.Scheme)
|
||||
assert.Equal(t, "/gitea/tea", u.Path)
|
||||
|
||||
u, err = ParseURL("foo/bar")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "", u.Host)
|
||||
assert.Equal(t, "", u.Scheme)
|
||||
assert.Equal(t, "foo/bar", u.Path)
|
||||
|
||||
u, err = ParseURL("/foo/bar")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "", u.Host)
|
||||
assert.Equal(t, "https", u.Scheme)
|
||||
assert.Equal(t, "/foo/bar", u.Path)
|
||||
|
||||
// this case is unintuitive, but to ambiguous to be handled differently
|
||||
u, err = ParseURL("gitea.com")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "", u.Host)
|
||||
assert.Equal(t, "", u.Scheme)
|
||||
assert.Equal(t, "gitea.com", u.Path)
|
||||
}
|
||||
Reference in new issue
Block a user