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:
bircni committed 2026-10-11 17:23:06 +00:00
1 parent 12e415a7a7
commit f02245449b
456 files changed
+18892 -34015

No files matched your search

-70
View File
@@ -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, "'", "'\\''") + "'"
}
-103
View File
@@ -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()
}
-76
View File
@@ -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")
}
-226
View File
@@ -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
}
-402
View File
@@ -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
}
-40
View File
@@ -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)
}
-28
View File
@@ -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)
}
-68
View File
@@ -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])
}
-94
View File
@@ -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])
}
-136
View File
@@ -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)
}
-163
View File
@@ -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)
}
-58
View File
@@ -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)
}
-55
View File
@@ -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)
}