refactor: add credential and Git packages (#1123)

- `internal/auth`: credentials in the OS keyring or a private file, OAuth login with token refresh, SSH HTTP-signature signers (key files with passphrase prompt, ssh-agent keys and certificates), and the Git credential helper protocol.
- `internal/git`: cancellable Git subprocesses, repository and remote discovery (tracked remote via `branch.<name>.remote`), PR checkout by full ref, AGit push, branch cleanup.

Nothing imports these packages yet.

Reviewed-on: https://gitea.com/gitea/tea/pulls/1123
Reviewed-by: TheFox0x7 <95654+thefox0x7@noreply.gitea.com>
This commit is contained in:
bircni committed 2026-10-11 17:09:37 +00:00
1 parent 2b218a5bae
commit 88b91faa54
28 files changed
+3044 -3

No files matched your search

+3 -3
View File
@@ -11,8 +11,10 @@ require (
code.gitea.io/gitea-vet v0.2.3 code.gitea.io/gitea-vet v0.2.3
gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c
gitea.dev/sdk v1.3.0 gitea.dev/sdk v1.3.0
github.com/42wim/httpsig v1.2.4
github.com/adrg/xdg v0.5.3 github.com/adrg/xdg v0.5.3
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
github.com/davidmz/go-pageant v1.0.2
github.com/enescakir/emoji v1.0.0 github.com/enescakir/emoji v1.0.0
github.com/go-signet/sdk-go v1.1.0 github.com/go-signet/sdk-go v1.1.0
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
@@ -22,6 +24,7 @@ require (
github.com/stretchr/testify v1.12.1 github.com/stretchr/testify v1.12.1
github.com/urfave/cli-docs/v3 v3.1.0 github.com/urfave/cli-docs/v3 v3.1.0
github.com/urfave/cli/v3 v3.10.1 github.com/urfave/cli/v3 v3.10.1
github.com/zalando/go-keyring v0.2.8
golang.org/x/crypto v0.57.0 golang.org/x/crypto v0.57.0
golang.org/x/oauth2 v0.36.0 golang.org/x/oauth2 v0.36.0
golang.org/x/sys v0.48.0 golang.org/x/sys v0.48.0
@@ -32,7 +35,6 @@ require (
require ( require (
charm.land/bubbles/v2 v2.1.0 // indirect charm.land/bubbles/v2 v2.1.0 // indirect
charm.land/bubbletea/v2 v2.0.6 // indirect charm.land/bubbletea/v2 v2.0.6 // indirect
github.com/42wim/httpsig v1.2.4 // indirect
github.com/alecthomas/chroma/v2 v2.14.0 // indirect github.com/alecthomas/chroma/v2 v2.14.0 // indirect
github.com/atotto/clipboard v0.1.4 // indirect github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
@@ -52,7 +54,6 @@ require (
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/danieljoos/wincred v1.2.3 // indirect github.com/danieljoos/wincred v1.2.3 // indirect
github.com/davidmz/go-pageant v1.0.2 // indirect
github.com/dlclark/regexp2 v1.11.0 // indirect github.com/dlclark/regexp2 v1.11.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.18.0 // indirect github.com/fatih/color v1.18.0 // indirect
@@ -74,7 +75,6 @@ require (
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.7.8 // indirect github.com/yuin/goldmark v1.7.8 // indirect
github.com/yuin/goldmark-emoji v1.0.5 // indirect github.com/yuin/goldmark-emoji v1.0.5 // indirect
github.com/zalando/go-keyring v0.2.8 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/net v0.58.0 // indirect golang.org/x/net v0.58.0 // indirect
+152
View File
@@ -0,0 +1,152 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
"gitea.dev/tea/internal/settings"
"github.com/zalando/go-keyring"
)
// keyringService namespaces credentials in the platform credential manager.
const keyringService = "tea-cli-v2"
// Credential holds the secret token material kept outside instance metadata.
type Credential struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token,omitempty"`
ExpiresAt time.Time `json:"expires_at,omitzero"`
}
// Keyring is the operating-system credential storage boundary.
type Keyring interface {
Get(string, string) (string, error)
Set(string, string, string) error
Delete(string, string) error
}
// SystemKeyring delegates to the platform credential manager.
type SystemKeyring struct{}
// Get retrieves a secret from the platform credential manager.
func (SystemKeyring) Get(service, user string) (string, error) { return keyring.Get(service, user) }
// Set persists a secret in the platform credential manager.
func (SystemKeyring) Set(service, user, value string) error { return keyring.Set(service, user, value) }
// Delete removes only the selected instance/account credential.
func (SystemKeyring) Delete(service, user string) error { return keyring.Delete(service, user) }
// errNoKeyring rejects the keyring backend on a Store without a Keyring.
var errNoKeyring = errors.New("keyring storage unavailable")
// Store selects secure storage or explicitly opted-in private files.
type Store struct {
Dir string
Keyring Keyring
}
// Identity builds a stable, opaque key from an instance URL and account.
func Identity(instance, user string) string {
sum := sha256.Sum256([]byte(instance + "\x00" + user))
return hex.EncodeToString(sum[:])
}
func (s *Store) filename(instance, user string) string {
return filepath.Join(s.Dir, "credentials", Identity(instance, user)+".json")
}
// Load returns a credential without exposing storage contents in errors.
func (s *Store) Load(instance, user, backend string) (Credential, error) {
var data []byte
var err error
switch backend {
case settings.StoreKeyring:
if s.Keyring == nil {
return Credential{}, errNoKeyring
}
value, loadErr := s.Keyring.Get(keyringService, Identity(instance, user))
data, err = []byte(value), loadErr
case settings.StoreFile:
data, err = os.ReadFile(s.filename(instance, user))
default:
err = errors.New("unsupported credential store")
}
if errors.Is(err, keyring.ErrNotFound) || errors.Is(err, os.ErrNotExist) {
return Credential{}, errors.New("credential unavailable; run tea auth login for this instance and account")
}
if err != nil {
return Credential{}, fmt.Errorf("credential storage unavailable: %w", err)
}
var credential Credential
if err := json.Unmarshal(data, &credential); err != nil {
return Credential{}, errors.New("invalid stored credential; run tea auth login")
}
if credential.AccessToken == "" {
return Credential{}, errors.New("stored credential has no access token")
}
return credential, nil
}
// Save persists credentials without falling back to a different backend.
func (s *Store) Save(instance, user, backend string, credential Credential) error {
if credential.AccessToken == "" {
return errors.New("access token cannot be empty")
}
data, err := json.Marshal(credential)
if err != nil {
return err
}
switch backend {
case settings.StoreKeyring:
if s.Keyring == nil {
return errNoKeyring
}
if err := s.Keyring.Set(keyringService, Identity(instance, user), string(data)); err != nil {
return errors.New("secure credential storage unavailable; use --credential-store file only if you accept private file storage")
}
return nil
case settings.StoreFile:
if err := os.MkdirAll(filepath.Dir(s.filename(instance, user)), 0o700); err != nil {
return err
}
return settings.AtomicWrite(s.filename(instance, user), data)
default:
return errors.New("unsupported credential store")
}
}
// Delete removes only the selected instance/account credential.
func (s *Store) Delete(instance, user, backend string) error {
switch backend {
case settings.StoreKeyring:
if s.Keyring == nil {
return errNoKeyring
}
err := s.Keyring.Delete(keyringService, Identity(instance, user))
if errors.Is(err, keyring.ErrNotFound) {
return nil
}
if err != nil {
return errors.New("failed to remove credential from secure storage")
}
return nil
case settings.StoreFile:
err := os.Remove(s.filename(instance, user))
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
default:
return fmt.Errorf("unsupported credential store %q", backend)
}
}
+172
View File
@@ -0,0 +1,172 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"bufio"
"context"
"errors"
"io"
"net/http"
"net/url"
"strings"
"time"
"gitea.dev/tea/internal/settings"
)
// GitRequest contains only the credential-routing parts of Git's input protocol.
type GitRequest struct{ Protocol, Host, Path, Username string }
// ReadGitRequest limits input and rejects ambiguous routing without echoing values.
func ReadGitRequest(input io.Reader) (GitRequest, error) {
fields := map[string]string{}
scanner := bufio.NewScanner(io.LimitReader(input, 65537))
scanner.Buffer(make([]byte, 4096), 65537)
size := 0
for scanner.Scan() {
line := scanner.Text()
size += len(line) + 1
if size > 65536 {
return GitRequest{}, errors.New("Git credential request exceeds 64 KiB")
}
if line == "" {
break
}
key, value, ok := strings.Cut(line, "=")
if !ok || strings.ContainsAny(value, "\x00\r") {
return GitRequest{}, errors.New("invalid Git credential request")
}
switch key {
case "protocol", "host", "path", "username", "url":
if _, exists := fields[key]; exists {
return GitRequest{}, errors.New("duplicate Git credential routing field")
}
fields[key] = value
}
}
if scanner.Err() != nil {
return GitRequest{}, errors.New("cannot read Git credential request")
}
if raw, exists := fields["url"]; exists {
u, err := url.Parse(raw)
if err != nil || u.RawQuery != "" || u.Fragment != "" || u.Opaque != "" {
return GitRequest{}, errors.New("invalid Git credential URL")
}
if u.User != nil {
if _, present := u.User.Password(); present {
return GitRequest{}, errors.New("Git credential URL must not contain a password")
}
username := u.User.Username()
if strings.ContainsAny(username, "\x00\r\n") {
return GitRequest{}, errors.New("invalid Git credential username")
}
if previous, exists := fields["username"]; exists && previous != username {
return GitRequest{}, errors.New("conflicting Git credential usernames")
}
fields["username"] = username
}
values := map[string]string{"protocol": u.Scheme, "host": u.Host, "path": strings.TrimPrefix(u.EscapedPath(), "/")}
for key, value := range values {
if previous, exists := fields[key]; exists && previous != value {
return GitRequest{}, errors.New("conflicting Git credential URL fields")
}
fields[key] = value
}
}
return GitRequest{fields["protocol"], fields["host"], fields["path"], fields["username"]}, nil
}
// MatchGitInstance selects the most specific configured installation. A pinned
// helper declines a request belonging to a different installation on that origin.
func MatchGitInstance(request GitRequest, hosts map[string]settings.Host, explicit, environment string) (string, error) {
if request.Protocol != "http" && request.Protocol != "https" {
return "", nil
}
origin, err := settings.NormalizeHost(request.Protocol + "://" + request.Host)
if err != nil {
return "", errors.New("invalid Git credential origin")
}
parsedOrigin, _ := url.Parse(origin)
if parsedOrigin.Path != "" {
return "", errors.New("invalid Git credential host")
}
if strings.Contains(strings.ToLower(request.Path), "%2f") || strings.Contains(strings.ToLower(request.Path), "%5c") {
return "", errors.New("ambiguous Git credential path")
}
decoded, err := url.PathUnescape(request.Path)
if err != nil || strings.ContainsAny(decoded, "\\\x00\r\n") {
return "", errors.New("invalid Git credential path")
}
for segment := range strings.SplitSeq(decoded, "/") {
if segment == "." || segment == ".." || strings.Contains(segment, "%") {
return "", errors.New("ambiguous Git credential path")
}
}
requestPath := "/" + strings.TrimPrefix(decoded, "/")
candidates := make(map[string]bool, len(hosts)+2)
for instance := range hosts {
candidates[instance] = true
}
for _, extra := range []string{explicit, environment} {
if extra != "" {
normalized, err := settings.NormalizeHost(extra)
if err != nil {
return "", errors.New("invalid configured Git credential instance")
}
candidates[normalized] = true
}
}
best, specificity := "", -1
sameOrigin := 0
for instance := range candidates {
u, err := url.Parse(instance)
if err != nil || u.Scheme+"://"+u.Host != origin {
continue
}
sameOrigin++
base := strings.TrimSuffix(u.Path, "/")
if requestPath != base && !strings.HasPrefix(requestPath, base+"/") {
continue
}
if len(base) > specificity {
best, specificity = instance, len(base)
}
}
// Without useHttpPath, Git omits the path. Multiple installations then cannot
// be distinguished safely, even if one is installed at the origin root.
if request.Path == "" && sameOrigin > 1 {
return "", nil
}
if explicit != "" {
pinned, _ := settings.NormalizeHost(explicit)
if pinned != best {
return "", nil
}
}
return best, nil
}
// GitCredential loads or refreshes one explicitly selected stored account.
// It never accepts Git's store/erase protocol as an authentication mutation.
func (m Manager) GitCredential(ctx context.Context, instance, user string, client *http.Client, now time.Time) (Credential, error) {
if err := ctx.Err(); err != nil {
return Credential{}, err
}
snapshot, err := m.settings.Load()
if err != nil {
return Credential{}, err
}
account, exists := snapshot.Hosts.Hosts[instance].Users[user]
if !exists {
return Credential{}, errors.New("selected Git account is not configured; run tea auth login")
}
if account.AuthMethod == settings.AuthOAuth {
return m.Refresh(ctx, instance, user, client, false, now)
}
if account.Tokenless() {
return Credential{}, errors.New("HTTP signature accounts cannot supply Git HTTPS credentials; use SSH remotes or a token account")
}
return m.credentials.Load(instance, user, account.CredentialStore)
}
+54
View File
@@ -0,0 +1,54 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"strings"
"testing"
"gitea.dev/tea/internal/settings"
"github.com/stretchr/testify/require"
)
func TestGitCredentialRoutingSeparatesInstallations(t *testing.T) {
hosts := map[string]settings.Host{"https://example.test": {}, "https://example.test/gitea": {}, "https://example.test/gitea/nested": {}, "https://example.test:8443/gitea": {}}
for _, tc := range []struct{ host, path, pinned, want string }{
{"example.test", "gitea/owner/repo.git", "", "https://example.test/gitea"},
{"example.test:443", "gitea/owner/repo.git", "", "https://example.test/gitea"},
{"example.test", "gitea/nested/owner/repo.git", "", "https://example.test/gitea/nested"},
{"example.test", "gitea/nested/owner/repo.git", "https://example.test/gitea", ""},
{"example.test", "gitea-other/owner/repo.git", "", "https://example.test"},
{"example.test", "", "", ""},
{"example.test:8443", "gitea/owner/repo.git", "", "https://example.test:8443/gitea"},
{"other.test", "gitea/owner/repo.git", "https://example.test/gitea", ""},
} {
got, err := MatchGitInstance(GitRequest{Protocol: "https", Host: tc.host, Path: tc.path}, hosts, tc.pinned, "")
require.NoError(t, err)
require.Equal(t, tc.want, got)
}
for _, path := range []string{"gitea/../other", "gitea/%2e%2e/other", "gitea%2fother", "gitea/%252e%252e/other", `gitea\other`} { //nolint:misspell // The backslash is an intentionally invalid path separator.
_, err := MatchGitInstance(GitRequest{Protocol: "https", Host: "example.test", Path: path}, hosts, "", "")
require.Error(t, err, path)
}
got, err := MatchGitInstance(GitRequest{Protocol: "http", Host: "example.test", Path: "gitea/owner/repo.git"}, hosts, "", "")
require.NoError(t, err)
require.Empty(t, got)
}
func TestReadGitCredentialRequestRejectsAmbiguityWithoutEchoingSecrets(t *testing.T) {
request, err := ReadGitRequest(strings.NewReader("protocol=https\nhost=example.test:8443\npath=gitea/owner/repo.git\nusername=alice\npassword=discarded-secret\n\n"))
require.NoError(t, err)
require.Equal(t, GitRequest{"https", "example.test:8443", "gitea/owner/repo.git", "alice"}, request)
request, err = ReadGitRequest(strings.NewReader("url=https://example.test/gitea/owner/repo.git\n\n"))
require.NoError(t, err)
require.Equal(t, "gitea/owner/repo.git", request.Path)
request, err = ReadGitRequest(strings.NewReader("url=https://alice@example.test/gitea/owner/repo.git\n\n"))
require.NoError(t, err)
require.Equal(t, "alice", request.Username)
for _, input := range []string{"host=secret-one\nhost=secret-two\n", "url=https://alice:secret@example.test/repo\n", "url=https://example.test/repo\nhost=secret.test\n", "host=secret\x00value\n", "password=" + strings.Repeat("secret", 12000) + "\n"} {
_, err := ReadGitRequest(strings.NewReader(input))
require.Error(t, err)
require.NotContains(t, err.Error(), "secret")
}
}
+205
View File
@@ -0,0 +1,205 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"os"
"slices"
"strings"
"time"
"gitea.dev/tea/internal/settings"
"github.com/42wim/httpsig"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
)
// HTTPSign signs Gitea API requests with an SSH private key or certificate.
type HTTPSign struct {
ssh.Signer
Cert bool
}
// SignWithAlgorithm implements ssh.AlgorithmSigner for RSA keys under httpsig.
func (h *HTTPSign) SignWithAlgorithm(rand io.Reader, data []byte, algorithm string) (*ssh.Signature, error) {
if as, ok := h.Signer.(ssh.AlgorithmSigner); ok {
return as.SignWithAlgorithm(rand, data, algorithm)
}
return h.Sign(rand, data)
}
// SignRequest attaches Signature and Digest headers required by Gitea HTTPSign.
func (h *HTTPSign) SignRequest(r *http.Request, contents []byte) error {
headersToSign := []string{httpsig.RequestTarget, "(created)", "(expires)"}
if h.Cert {
pubkey, err := ssh.ParsePublicKey(h.PublicKey().Marshal())
if err != nil {
return errors.New("cannot parse SSH certificate for request signing")
}
cert, ok := pubkey.(*ssh.Certificate)
if !ok {
return errors.New("no SSH certificate found for request signing")
}
r.Header.Set("x-ssh-certificate", base64.RawStdEncoding.EncodeToString(cert.Marshal()))
headersToSign = append(headersToSign, "x-ssh-certificate")
}
if r.Body != nil && r.Body != http.NoBody {
headersToSign = append(headersToSign, "Digest")
}
signer, _, err := httpsig.NewSSHSigner(h, httpsig.DigestSha512, headersToSign, httpsig.Signature, 10)
if err != nil {
return fmt.Errorf("cannot create HTTP signature: %w", err)
}
keyID := "gitea"
if !h.Cert {
keyID = ssh.FingerprintSHA256(h.PublicKey())
}
return signer.SignRequest(keyID, r, contents)
}
// SignerForAccount builds an HTTP signer from persisted account SSH metadata. passphrase is
// called only for an encrypted key file; when it is nil, such a key fails with advice to use ssh-agent.
func SignerForAccount(account settings.Account, passphrase func() ([]byte, error)) (*HTTPSign, error) {
keyPath := account.SSHKey
if account.SSHAgent {
keyPath = ""
}
if account.SSHCertPrincipal != "" || account.AuthMethod == settings.AuthSSH {
return newHTTPSign(keyPath, true, account.SSHCertPrincipal, passphrase)
}
return newHTTPSign(keyPath, false, account.SSHKeyFingerprint, passphrase)
}
// newHTTPSign loads a key file, or selects an ssh-agent key matching the fingerprint or, for
// certificates, the principal.
func newHTTPSign(keyPath string, cert bool, match string, passphrase func() ([]byte, error)) (*HTTPSign, error) {
if keyPath == "" {
agentClient, release, err := getAgent()
if err != nil {
return nil, err
}
signer, err := agentSigner(agentClient, cert, match)
if err != nil {
release()
return nil, err
}
// The agent connection stays open for the signer's lifetime: every request is signed by the agent.
return signer, nil
}
signer, err := parsePrivateKey(keyPath, passphrase)
if err != nil {
return nil, err
}
if cert {
certbytes, err := os.ReadFile(keyPath + "-cert.pub")
if err != nil {
return nil, fmt.Errorf("cannot read SSH certificate: %w", err)
}
pub, _, _, _, err := ssh.ParseAuthorizedKey(certbytes)
if err != nil {
return nil, fmt.Errorf("cannot parse SSH certificate: %w", err)
}
certificate, ok := pub.(*ssh.Certificate)
if !ok {
return nil, errors.New("SSH certificate file does not contain a certificate")
}
signer, err = ssh.NewCertSigner(certificate, signer)
if err != nil {
return nil, fmt.Errorf("cannot use SSH certificate: %w", err)
}
}
return &HTTPSign{Signer: signer, Cert: cert}, nil
}
// agentSigner selects the agent key matching the fingerprint or, for certificates, the principal.
func agentSigner(agentClient agent.Agent, cert bool, match string) (*HTTPSign, error) {
signers, err := agentClient.Signers()
if err != nil {
return nil, errors.New("cannot list keys from ssh-agent")
}
if len(signers) == 0 {
return nil, errors.New("no signers found in ssh-agent")
}
if cert {
if signer := findCertSigner(signers, match); signer != nil {
return &HTTPSign{Signer: signer, Cert: true}, nil
}
return nil, fmt.Errorf("no certificate found in ssh-agent for principal %q", match)
}
if signer := findPubkeySigner(signers, match); signer != nil {
return &HTTPSign{Signer: signer}, nil
}
return nil, fmt.Errorf("no public key found in ssh-agent for %q", match)
}
func parsePrivateKey(keyPath string, passphrase func() ([]byte, error)) (ssh.Signer, error) {
priv, err := os.ReadFile(keyPath)
if err != nil {
return nil, fmt.Errorf("cannot read SSH key: %w", err)
}
signer, err := ssh.ParsePrivateKey(priv)
if _, ok := errors.AsType[*ssh.PassphraseMissingError](err); ok {
if passphrase == nil {
return nil, fmt.Errorf("SSH key %s is passphrase protected; add it to ssh-agent with ssh-add and log in with --ssh-agent-key or --ssh-agent-principal", keyPath)
}
secret, promptErr := passphrase()
if promptErr != nil {
return nil, promptErr
}
signer, err = ssh.ParsePrivateKeyWithPassphrase(priv, secret)
}
if err != nil {
return nil, fmt.Errorf("cannot parse SSH key: %w", err)
}
return signer, nil
}
func findCertSigner(sshsigners []ssh.Signer, principal string) ssh.Signer {
for _, s := range sshsigners {
if !strings.Contains(s.PublicKey().Type(), "cert-v01@openssh.com") {
continue
}
mpubkey, err := ssh.ParsePublicKey(s.PublicKey().Marshal())
if err != nil {
continue
}
cert, ok := mpubkey.(*ssh.Certificate)
if !ok {
continue
}
if cert.ValidBefore != ssh.CertTimeInfinity && time.Until(time.Unix(int64(cert.ValidBefore), 0)) <= 10*time.Second {
continue
}
if principal == "" {
return s
}
if slices.Contains(cert.ValidPrincipals, principal) {
return s
}
}
return nil
}
func findPubkeySigner(sshsigners []ssh.Signer, fingerprint string) ssh.Signer {
for _, s := range sshsigners {
if strings.Contains(s.PublicKey().Type(), "cert-v01@openssh.com") {
continue
}
if fingerprint == "" {
return s
}
if strings.TrimSpace(string(ssh.MarshalAuthorizedKey(s.PublicKey()))) == fingerprint {
return s
}
if ssh.FingerprintSHA256(s.PublicKey()) == fingerprint {
return s
}
}
return nil
}
+30
View File
@@ -0,0 +1,30 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !windows
package auth
import (
"errors"
"net"
"os"
"golang.org/x/crypto/ssh/agent"
)
// getAgent connects to the ssh-agent at SSH_AUTH_SOCK; release closes the connection.
func getAgent() (client agent.Agent, release func(), err error) {
socket := os.Getenv("SSH_AUTH_SOCK")
if socket == "" {
return nil, nil, errors.New("no ssh-agent available")
}
if _, err := os.Stat(socket); err != nil {
return nil, nil, errors.New("no ssh-agent available")
}
conn, err := net.Dial("unix", socket)
if err != nil {
return nil, nil, errors.New("cannot connect to ssh-agent")
}
return agent.NewClient(conn), func() { _ = conn.Close() }, nil
}
+53
View File
@@ -0,0 +1,53 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !windows
package auth
import (
"crypto/ed25519"
"crypto/rand"
"net"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh/agent"
)
func TestAgentConnectionClosesWhenNoKeyMatches(t *testing.T) {
// t.TempDir can exceed the length limit of Unix socket paths.
dir, err := os.MkdirTemp("", "tea-agent")
require.NoError(t, err)
t.Cleanup(func() { _ = os.RemoveAll(dir) })
socket := filepath.Join(dir, "agent.sock")
listener, err := net.Listen("unix", socket)
require.NoError(t, err)
defer listener.Close()
_, key, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
keyring := agent.NewKeyring()
require.NoError(t, keyring.Add(agent.AddedKey{PrivateKey: key}))
closed := make(chan struct{})
go func() {
defer close(closed)
conn, err := listener.Accept()
if err != nil {
return
}
defer conn.Close()
// ServeAgent returns once the client closes its connection.
_ = agent.ServeAgent(keyring, conn)
}()
t.Setenv("SSH_AUTH_SOCK", socket)
_, err = newHTTPSign("", false, "SHA256:missing", nil)
require.ErrorContains(t, err, "no public key found in ssh-agent")
select {
case <-closed:
case <-time.After(5 * time.Second):
t.Fatal("ssh-agent connection stayed open after the lookup failed")
}
}
+21
View File
@@ -0,0 +1,21 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build windows
package auth
import (
"errors"
"github.com/davidmz/go-pageant"
"golang.org/x/crypto/ssh/agent"
)
// getAgent returns the Pageant client, which holds no connection for release to close.
func getAgent() (client agent.Agent, release func(), err error) {
if !pageant.Available() {
return nil, nil, errors.New("no pageant available")
}
return pageant.New(), func() {}, nil
}
+123
View File
@@ -0,0 +1,123 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"bytes"
"crypto/ed25519"
"crypto/rand"
"crypto/rsa"
"encoding/pem"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"gitea.dev/tea/internal/settings"
"gitea.dev/tea/internal/transport"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
)
func TestHTTPSignAttachesSignatureHeaders(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
signer, err := ssh.NewSignerFromKey(key)
require.NoError(t, err)
dir := t.TempDir()
path := filepath.Join(dir, "id_rsa")
block, err := ssh.MarshalPrivateKey(key, "")
require.NoError(t, err)
require.NoError(t, os.WriteFile(path, pem.EncodeToMemory(block), 0o600))
var sawSignature, sawDigest bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sawSignature = r.Header.Get("Signature") != ""
sawDigest = r.Header.Get("Digest") != ""
require.Empty(t, r.Header.Get("Authorization"))
_, _ = w.Write([]byte(`{"login":"alice"}`))
}))
defer server.Close()
httpSign, err := newHTTPSign(path, false, "", nil)
require.NoError(t, err)
require.Equal(t, ssh.FingerprintSHA256(signer.PublicKey()), ssh.FingerprintSHA256(httpSign.PublicKey()))
client := transport.New(server.URL, "", false)
client.Signer = httpSign
response, err := client.Do(t.Context(), http.MethodGet, "user", nil, nil)
require.NoError(t, err)
require.NoError(t, response.Body.Close())
require.True(t, sawSignature)
require.False(t, sawDigest)
sawSignature, sawDigest = false, false
response, err = client.Do(t.Context(), http.MethodPost, "user", bytes.NewReader([]byte(`{"ok":true}`)), nil)
require.NoError(t, err)
require.NoError(t, response.Body.Close())
require.True(t, sawSignature)
require.True(t, sawDigest)
account := settings.Account{AuthMethod: "httpsign", SSHKey: path}
fromAccount, err := SignerForAccount(account, func() ([]byte, error) {
t.Error("unencrypted key asked for a passphrase")
return nil, nil
})
require.NoError(t, err)
require.NotNil(t, fromAccount)
}
func TestEncryptedSSHKeyUsesInjectedPassphrase(t *testing.T) {
_, key, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
block, err := ssh.MarshalPrivateKeyWithPassphrase(key, "", []byte("correct horse"))
require.NoError(t, err)
path := filepath.Join(t.TempDir(), "id_ed25519")
require.NoError(t, os.WriteFile(path, pem.EncodeToMemory(block), 0o600))
account := settings.Account{AuthMethod: settings.AuthHTTPSign, SSHKey: path}
_, err = SignerForAccount(account, nil)
require.ErrorContains(t, err, "passphrase protected; add it to ssh-agent")
prompts := 0
signer, err := SignerForAccount(account, func() ([]byte, error) { prompts++; return []byte("correct horse"), nil })
require.NoError(t, err)
require.Equal(t, 1, prompts)
require.Equal(t, ssh.KeyAlgoED25519, signer.PublicKey().Type())
_, err = SignerForAccount(account, func() ([]byte, error) { return []byte("wrong"), nil })
require.ErrorContains(t, err, "cannot parse SSH key")
}
func TestFindCertSignerHonorsValidity(t *testing.T) {
_, caKey, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
ca, err := ssh.NewSignerFromKey(caKey)
require.NoError(t, err)
_, userKey, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
user, err := ssh.NewSignerFromKey(userKey)
require.NoError(t, err)
certSigner := func(validBefore uint64) ssh.Signer {
cert := &ssh.Certificate{Key: user.PublicKey(), CertType: ssh.UserCert, ValidPrincipals: []string{"alice"}, ValidBefore: validBefore}
require.NoError(t, cert.SignCert(rand.Reader, ca))
signer, err := ssh.NewCertSigner(cert, user)
require.NoError(t, err)
return signer
}
for name, tc := range map[string]struct {
validBefore uint64
found bool
}{
"unlimited": {ssh.CertTimeInfinity, true},
"valid": {uint64(time.Now().Add(time.Hour).Unix()), true},
"expiring": {uint64(time.Now().Add(5 * time.Second).Unix()), false},
"expired": {uint64(time.Now().Add(-time.Hour).Unix()), false},
} {
signer := certSigner(tc.validBefore)
require.Equal(t, tc.found, findCertSigner([]ssh.Signer{signer}, "alice") != nil, name)
}
}
+110
View File
@@ -0,0 +1,110 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"errors"
"gitea.dev/tea/internal/settings"
)
// Manager coordinates account metadata and credential changes under one lock.
type Manager struct {
settings *settings.Store
credentials *Store
}
// NewManager couples the settings and credential stores that account changes keep consistent.
func NewManager(settingsStore *settings.Store, credentials *Store) (Manager, error) {
if settingsStore == nil {
return Manager{}, errors.New("account manager requires a settings store")
}
if credentials == nil {
return Manager{}, errors.New("account manager requires a credential store")
}
return Manager{settings: settingsStore, credentials: credentials}, nil
}
// HostOptions are instance settings recorded together with a new account.
type HostOptions struct {
OAuthClientID string
Insecure bool
}
// Register publishes account metadata and compensates credential writes on failure.
func (m Manager) Register(instance, user, backend, method string, options HostOptions, credential Credential) error {
return m.register(instance, user, settings.Account{AuthMethod: method, CredentialStore: backend}, options, &credential)
}
// RegisterHTTPSign publishes SSH/HTTP-signature account metadata without storing a token.
func (m Manager) RegisterHTTPSign(instance, user string, account settings.Account, options HostOptions) error {
if !account.Tokenless() {
return errors.New("HTTP signature registration requires auth_method ssh or httpsign")
}
account.CredentialStore = ""
return m.register(instance, user, account, options, nil)
}
func (m Manager) register(instance, user string, account settings.Account, options HostOptions, credential *Credential) error {
return m.settings.Transaction(settings.TargetHosts, func(s *settings.Snapshot) (func() error, error) {
host := s.Hosts.Hosts[instance]
if host.Users == nil {
host.Users = map[string]settings.Account{}
}
if _, exists := host.Users[user]; exists {
return nil, errors.New("account already configured; logout before authenticating again")
}
var rollback func() error
if credential != nil {
if err := m.credentials.Save(instance, user, account.CredentialStore, *credential); err != nil {
return nil, err
}
rollback = func() error { return m.credentials.Delete(instance, user, account.CredentialStore) }
}
host.Users[user] = account
host.User = user
if options.OAuthClientID != "" {
host.OAuthClientID = options.OAuthClientID
}
host.Insecure = host.Insecure || options.Insecure
s.Hosts.Hosts[instance] = host
return rollback, nil
})
}
// Logout removes one account and restores its credential if publication fails.
// An unreadable credential does not prevent removing the account.
func (m Manager) Logout(instance, user string) error {
return m.settings.Transaction(settings.TargetHosts, func(s *settings.Snapshot) (func() error, error) {
host := s.Hosts.Hosts[instance]
account, exists := host.Users[user]
if !exists {
return nil, errors.New("account is not configured")
}
var restore func() error
if !account.Tokenless() {
credential, err := m.credentials.Load(instance, user, account.CredentialStore)
if err == nil {
// Preserve a compensating copy until the account file is successfully published.
restore = func() error { return m.credentials.Save(instance, user, account.CredentialStore, credential) }
}
// A missing credential deletes cleanly; any other failure keeps the account so
// its token is not orphaned in storage.
if err := m.credentials.Delete(instance, user, account.CredentialStore); err != nil {
return nil, err
}
}
delete(host.Users, user)
if host.User == user {
host.User = ""
if len(host.Users) == 1 {
for name := range host.Users {
host.User = name
}
}
}
s.Hosts.Hosts[instance] = host
return restore, nil
})
}
+179
View File
@@ -0,0 +1,179 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"errors"
"os"
"path/filepath"
"testing"
"time"
"gitea.dev/tea/internal/settings"
"github.com/stretchr/testify/require"
"github.com/zalando/go-keyring"
)
type memoryKeyring struct {
values map[string]string
fail bool
}
func (m *memoryKeyring) Get(_ string, k string) (string, error) {
if m.fail {
return "", errors.New("unavailable")
}
value, ok := m.values[k]
if !ok {
return "", keyring.ErrNotFound
}
return value, nil
}
func (m *memoryKeyring) Set(_, k, v string) error {
if m.fail {
return errors.New("unavailable")
}
m.values[k] = v
return nil
}
func (m *memoryKeyring) Delete(_, k string) error {
if m.fail {
return errors.New("unavailable")
}
delete(m.values, k)
return nil
}
// newTestManager couples config and credentials, failing the test on a missing store.
func newTestManager(t *testing.T, config *settings.Store, credentials *Store) Manager {
t.Helper()
manager, err := NewManager(config, credentials)
require.NoError(t, err)
return manager
}
func TestNewManagerRequiresBothStores(t *testing.T) {
config := settings.New(t.TempDir())
_, err := NewManager(nil, &Store{Dir: config.Dir})
require.ErrorContains(t, err, "settings store")
_, err = NewManager(config, nil)
require.ErrorContains(t, err, "credential store")
}
func TestRegisterAndLogoutIsolateAccounts(t *testing.T) {
config := settings.New(t.TempDir())
credentials := &Store{Dir: config.Dir, Keyring: &memoryKeyring{values: map[string]string{}}}
manager := newTestManager(t, config, credentials)
for _, user := range []string{"alice", "bob", "charlie"} {
require.NoError(t, manager.Register("https://example.org", user, "keyring", "token", HostOptions{}, Credential{AccessToken: "secret-" + user}))
}
require.NoError(t, manager.Logout("https://example.org", "charlie"))
s, err := config.Load()
require.NoError(t, err)
require.Empty(t, s.Hosts.Hosts["https://example.org"].User)
credential, err := credentials.Load("https://example.org", "alice", "keyring")
require.NoError(t, err)
require.Equal(t, "secret-alice", credential.AccessToken)
_, err = credentials.Load("https://example.org", "charlie", "keyring")
require.Error(t, err)
data, err := os.ReadFile(filepath.Join(config.Dir, "hosts.yml"))
require.NoError(t, err)
require.NotContains(t, string(data), "secret")
}
func TestFailedConfigPublishRollsBackCredentials(t *testing.T) {
config := settings.New(t.TempDir())
// An invalid auth method fails validation after the credential was saved.
keyring := &memoryKeyring{values: map[string]string{}}
credentials := &Store{Dir: config.Dir, Keyring: keyring}
manager := newTestManager(t, config, credentials)
require.Error(t, manager.Register("https://example.org", "alice", "keyring", "invalid", HostOptions{}, Credential{AccessToken: "secret"}))
require.Empty(t, keyring.values)
_, err := os.Stat(filepath.Join(config.Dir, "hosts.yml"))
require.True(t, errors.Is(err, os.ErrNotExist))
}
func TestNoSilentCredentialFallback(t *testing.T) {
config := settings.New(t.TempDir())
credentials := &Store{Dir: config.Dir, Keyring: &memoryKeyring{values: map[string]string{}, fail: true}}
manager := newTestManager(t, config, credentials)
require.Error(t, manager.Register("https://example.org", "alice", "keyring", "token", HostOptions{}, Credential{AccessToken: "secret"}))
require.NoError(t, manager.Register("https://example.org", "alice", "file", "token", HostOptions{}, Credential{AccessToken: "secret"}))
credential, err := credentials.Load("https://example.org", "alice", "file")
require.NoError(t, err)
require.Equal(t, "secret", credential.AccessToken)
}
func TestCredentialLoadDistinguishesMissingFromUnavailable(t *testing.T) {
keyring := &memoryKeyring{values: map[string]string{}}
store := &Store{Dir: t.TempDir(), Keyring: keyring}
for _, backend := range []string{settings.StoreKeyring, settings.StoreFile} {
_, err := store.Load("https://example.org", "alice", backend)
require.ErrorContains(t, err, "run tea auth login", backend)
}
// Logging in again cannot fix a locked or unreachable keyring, so do not suggest it.
keyring.fail = true
_, err := store.Load("https://example.org", "alice", settings.StoreKeyring)
require.EqualError(t, err, "credential storage unavailable: unavailable")
}
func TestCredentialStoreWithoutKeyringRejectsKeyringBackend(t *testing.T) {
store := &Store{Dir: t.TempDir()}
_, err := store.Load("https://example.org", "alice", settings.StoreKeyring)
require.ErrorIs(t, err, errNoKeyring)
require.ErrorIs(t, store.Save("https://example.org", "alice", settings.StoreKeyring, Credential{AccessToken: "secret"}), errNoKeyring)
require.ErrorIs(t, store.Delete("https://example.org", "alice", settings.StoreKeyring), errNoKeyring)
}
func TestCredentialFilesRoundTripExpiry(t *testing.T) {
store := &Store{Dir: t.TempDir()}
expiring := Credential{AccessToken: "access", RefreshToken: "refresh", ExpiresAt: time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)}
for _, credential := range []Credential{{AccessToken: "token"}, expiring} {
require.NoError(t, store.Save("https://example.org", "alice", settings.StoreFile, credential))
loaded, err := store.Load("https://example.org", "alice", settings.StoreFile)
require.NoError(t, err)
require.Equal(t, credential, loaded)
}
require.NoError(t, store.Save("https://example.org", "alice", settings.StoreFile, Credential{AccessToken: "token"}))
data, err := os.ReadFile(store.filename("https://example.org", "alice"))
require.NoError(t, err)
require.NotContains(t, string(data), "expires_at")
// Files written while a missing expiry was stored as the zero time still load without one.
require.NoError(t, os.WriteFile(store.filename("https://example.org", "alice"), []byte(`{"access_token":"token","expires_at":"0001-01-01T00:00:00Z"}`), 0o600))
loaded, err := store.Load("https://example.org", "alice", settings.StoreFile)
require.NoError(t, err)
require.True(t, loaded.ExpiresAt.IsZero())
}
func TestLogoutRemovesAccountWithMissingCredential(t *testing.T) {
config := settings.New(t.TempDir())
keyring := &memoryKeyring{values: map[string]string{}}
manager := newTestManager(t, config, &Store{Dir: config.Dir, Keyring: keyring})
require.NoError(t, manager.Register("https://example.org", "alice", settings.StoreKeyring, settings.AuthToken, HostOptions{}, Credential{AccessToken: "secret"}))
clear(keyring.values)
require.NoError(t, manager.Logout("https://example.org", "alice"))
s, err := config.Load()
require.NoError(t, err)
require.Empty(t, s.Hosts.Hosts["https://example.org"].Users)
require.NoError(t, manager.Register("https://example.org", "alice", settings.StoreKeyring, settings.AuthToken, HostOptions{}, Credential{AccessToken: "secret"}))
// An unavailable keyring keeps the account rather than orphaning its token.
keyring.fail = true
require.Error(t, manager.Logout("https://example.org", "alice"))
s, err = config.Load()
require.NoError(t, err)
require.Contains(t, s.Hosts.Hosts["https://example.org"].Users, "alice")
}
func TestRegisterHTTPSignRejectsTokenAccounts(t *testing.T) {
config := settings.New(t.TempDir())
manager := newTestManager(t, config, &Store{Dir: config.Dir, Keyring: &memoryKeyring{values: map[string]string{}}})
require.Error(t, manager.RegisterHTTPSign("https://example.org", "alice", settings.Account{AuthMethod: settings.AuthToken}, HostOptions{}))
require.NoError(t, manager.RegisterHTTPSign("https://example.org", "alice", settings.Account{AuthMethod: settings.AuthSSH, CredentialStore: settings.StoreFile, SSHKey: "/keys/id"}, HostOptions{}))
s, err := config.Load()
require.NoError(t, err)
require.Equal(t, settings.Account{AuthMethod: settings.AuthSSH, SSHKey: "/keys/id"}, s.Hosts.Hosts["https://example.org"].Users["alice"])
require.NoError(t, manager.Logout("https://example.org", "alice"))
}
+210
View File
@@ -0,0 +1,210 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"net"
"net/http"
"time"
"gitea.dev/tea/internal/settings"
"golang.org/x/oauth2"
)
// DefaultClientID identifies the public tea OAuth application.
const DefaultClientID = "d57cb8c4-630c-4168-8324-ec79935e18d4"
// refreshTimeout bounds a token refresh, which holds the settings lock, below settings.LockTimeout.
const refreshTimeout = 20 * time.Second
// OAuthOptions supplies the instance, client, and authorization interaction.
type OAuthOptions struct {
Instance string
ClientID string
// HTTP sends the token exchange; nil uses http.DefaultClient.
HTTP *http.Client
Authorize func(context.Context, string) error
Timeout time.Duration
}
// OAuthLogin exchanges a PKCE authorization code through a loopback callback.
func OAuthLogin(ctx context.Context, opts OAuthOptions) (Credential, error) {
if opts.ClientID == "" {
opts.ClientID = DefaultClientID
}
if opts.Timeout == 0 {
opts.Timeout = 2 * time.Minute
}
ctx, cancel := context.WithTimeout(ctx, opts.Timeout)
defer cancel()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return Credential{}, errors.New("cannot listen for OAuth callback")
}
defer listener.Close()
random := make([]byte, 32)
if _, err := rand.Read(random); err != nil {
return Credential{}, err
}
state := base64.RawURLEncoding.EncodeToString(random)
verifier := oauth2.GenerateVerifier()
// Gitea ignores the port of a loopback redirect but compares its path; the built-in tea application registers none.
redirect := "http://" + listener.Addr().String()
config := oauthConfig(opts.Instance, opts.ClientID)
config.RedirectURL = redirect
codes := make(chan string, 1)
failures := make(chan error, 1)
mux := http.NewServeMux()
mux.HandleFunc("/{$}", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
if r.URL.Query().Get("state") != state {
http.Error(w, "Invalid OAuth state", http.StatusBadRequest)
return
}
code := r.URL.Query().Get("code")
if code == "" {
http.Error(w, "Authorization declined", http.StatusBadRequest)
select {
case failures <- errors.New("OAuth authorization declined"):
default:
}
return
}
select {
case codes <- code:
default:
}
_, _ = fmt.Fprintln(w, "Authorization received. Return to tea.")
})
server := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
go func() {
if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) && !errors.Is(err, net.ErrClosed) {
select {
case failures <- errors.New("OAuth callback server failed"):
default:
}
}
}()
defer server.Close()
authorizeURL := config.AuthCodeURL(state, oauth2.S256ChallengeOption(verifier))
if opts.Authorize == nil {
return Credential{}, errors.New("OAuth requires an authorization URL handler")
}
if err := opts.Authorize(ctx, authorizeURL); err != nil {
return Credential{}, err
}
select {
case <-ctx.Done():
return Credential{}, ctx.Err()
case err := <-failures:
return Credential{}, err
case code := <-codes:
ctx = withHTTPClient(ctx, opts.HTTP)
token, err := config.Exchange(ctx, code, oauth2.VerifierOption(verifier))
if err != nil {
if ctx.Err() != nil {
return Credential{}, ctx.Err()
}
return Credential{}, errors.New("OAuth token exchange failed; verify client ID and registered loopback redirect URI")
}
return Credential{AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, ExpiresAt: token.Expiry}, nil
}
}
// withHTTPClient routes oauth2 requests through client, or http.DefaultClient when it is nil.
// oauth2 would dereference a nil client stored in the context.
func withHTTPClient(ctx context.Context, client *http.Client) context.Context {
if client == nil {
client = http.DefaultClient
}
return context.WithValue(ctx, oauth2.HTTPClient, client)
}
func oauthConfig(instance, clientID string) *oauth2.Config {
return &oauth2.Config{ClientID: clientID, Endpoint: oauth2.Endpoint{AuthURL: instance + "/login/oauth/authorize", TokenURL: instance + "/login/oauth/access_token", AuthStyle: oauth2.AuthStyleInParams}, Scopes: []string{"admin", "user", "issue", "misc", "notification", "organization", "package", "repository"}}
}
// Refresh locks by instance and account, then reads again before rotating secrets.
// A nil httpClient uses http.DefaultClient.
func (m Manager) Refresh(ctx context.Context, instance, user string, httpClient *http.Client, force bool, now time.Time) (Credential, error) {
snapshot, err := m.settings.Load()
if err != nil {
return Credential{}, err
}
host := snapshot.Hosts.Hosts[instance]
account, exists := host.Users[user]
if !exists {
return Credential{}, errors.New("account is not configured")
}
if account.AuthMethod != settings.AuthOAuth {
return m.credentials.Load(instance, user, account.CredentialStore)
}
credential, err := m.credentials.Load(instance, user, account.CredentialStore)
if err != nil {
return Credential{}, err
}
needsRefresh := func(c Credential) bool { return !c.ExpiresAt.IsZero() && !now.Add(5*time.Minute).Before(c.ExpiresAt) }
if !force && !needsRefresh(credential) {
return credential, nil
}
original := credential
err = m.settings.Locked(func(current settings.Snapshot) error {
if err := ctx.Err(); err != nil {
return err
}
currentAccount, exists := current.Hosts.Hosts[instance].Users[user]
if !exists || currentAccount.AuthMethod != settings.AuthOAuth {
return errors.New("OAuth account changed while waiting to refresh")
}
credential, err = m.credentials.Load(instance, user, currentAccount.CredentialStore)
if err != nil {
return err
}
if credential.AccessToken != original.AccessToken && !needsRefresh(credential) {
return nil
}
if !force && !needsRefresh(credential) {
return nil
}
if credential.RefreshToken == "" {
return errors.New("OAuth token cannot be refreshed; run tea auth login")
}
clientID := current.Hosts.Hosts[instance].OAuthClientID
if clientID == "" {
clientID = DefaultClientID
}
config := oauthConfig(instance, clientID)
requestContext, cancel := context.WithTimeout(ctx, refreshTimeout)
defer cancel()
requestContext = withHTTPClient(requestContext, httpClient)
old := &oauth2.Token{AccessToken: credential.AccessToken, RefreshToken: credential.RefreshToken, Expiry: now.Add(-time.Hour)}
token, err := config.TokenSource(requestContext, old).Token()
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
if requestContext.Err() != nil {
return errors.New("OAuth refresh timed out; try again")
}
return errors.New("OAuth refresh failed; run tea auth login")
}
credential.AccessToken = token.AccessToken
if token.RefreshToken != "" {
credential.RefreshToken = token.RefreshToken
}
if !token.Expiry.IsZero() {
credential.ExpiresAt = token.Expiry
}
return m.credentials.Save(instance, user, currentAccount.CredentialStore, credential)
})
return credential, err
}
+138
View File
@@ -0,0 +1,138 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"sync"
"testing"
"time"
"gitea.dev/tea/internal/settings"
"github.com/stretchr/testify/require"
)
func TestOAuthLoginPKCEAndState(t *testing.T) {
var redirect, challenge string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/base/login/oauth/access_token", r.URL.Path)
require.NoError(t, r.ParseForm())
require.Equal(t, "authorization_code", r.Form.Get("grant_type"))
require.Equal(t, "valid-code", r.Form.Get("code"))
require.NotEmpty(t, r.Form.Get("code_verifier"))
require.Equal(t, redirect, r.Form.Get("redirect_uri"))
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": "access", "refresh_token": "refresh", "expires_in": 3600, "token_type": "Bearer"})
}))
defer server.Close()
credential, err := OAuthLogin(context.Background(), OAuthOptions{Instance: server.URL + "/base", ClientID: "configured-client", HTTP: server.Client(), Timeout: time.Second, Authorize: func(ctx context.Context, raw string) error {
u, err := url.Parse(raw)
if err != nil {
return err
}
require.Equal(t, "configured-client", u.Query().Get("client_id"))
require.Equal(t, "S256", u.Query().Get("code_challenge_method"))
redirect = u.Query().Get("redirect_uri")
callback, err := url.Parse(redirect)
require.NoError(t, err)
require.Equal(t, "127.0.0.1", callback.Hostname())
// Gitea's built-in tea application registers only the bare loopback URL.
require.Empty(t, callback.Path)
challenge = u.Query().Get("code_challenge")
require.NotEmpty(t, challenge)
// An unrelated callback cannot consume or satisfy the authorization flow.
response, err := http.Get(redirect + "?code=invalid&state=wrong")
if err != nil {
return err
}
response.Body.Close()
require.Equal(t, 400, response.StatusCode)
response, err = http.Get(redirect + "?code=valid-code&state=" + url.QueryEscape(u.Query().Get("state")))
if err != nil {
return err
}
response.Body.Close()
return nil
}})
require.NoError(t, err)
require.Equal(t, "access", credential.AccessToken)
require.Equal(t, "refresh", credential.RefreshToken)
}
func TestRefreshSerializesAndRetainsRefreshToken(t *testing.T) {
config := settings.New(t.TempDir())
keyring := &memoryKeyring{values: map[string]string{}}
credentials := &Store{Dir: config.Dir, Keyring: keyring}
manager := newTestManager(t, config, credentials)
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
require.NoError(t, r.ParseForm())
require.Equal(t, "old-refresh", r.Form.Get("refresh_token"))
require.Equal(t, "client", r.Form.Get("client_id"))
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintln(w, `{"access_token":"new-access","expires_in":3600,"token_type":"Bearer"}`)
}))
defer server.Close()
now := time.Now()
require.NoError(t, manager.Register(server.URL, "alice", "file", "oauth", HostOptions{OAuthClientID: "client"}, Credential{AccessToken: "old-access", RefreshToken: "old-refresh", ExpiresAt: now.Add(-time.Minute)}))
var wg sync.WaitGroup
results := make(chan Credential, 8)
failures := make(chan error, 8)
for range 8 {
wg.Go(func() {
c, err := manager.Refresh(context.Background(), server.URL, "alice", server.Client(), false, now)
results <- c
failures <- err
})
}
wg.Wait()
close(results)
close(failures)
for err := range failures {
require.NoError(t, err)
}
for credential := range results {
require.Equal(t, "new-access", credential.AccessToken)
require.Equal(t, "old-refresh", credential.RefreshToken)
}
require.Equal(t, 1, requests)
}
func TestOAuthWithoutHTTPClientUsesDefaultClient(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintln(w, `{"access_token":"access","refresh_token":"refresh","expires_in":3600,"token_type":"Bearer"}`)
}))
defer server.Close()
credential, err := OAuthLogin(context.Background(), OAuthOptions{Instance: server.URL, Timeout: time.Second, Authorize: func(_ context.Context, raw string) error {
u, err := url.Parse(raw)
if err != nil {
return err
}
response, err := http.Get(u.Query().Get("redirect_uri") + "?code=code&state=" + url.QueryEscape(u.Query().Get("state")))
if err != nil {
return err
}
return response.Body.Close()
}})
require.NoError(t, err)
require.Equal(t, "access", credential.AccessToken)
config := settings.New(t.TempDir())
manager := newTestManager(t, config, &Store{Dir: config.Dir})
require.NoError(t, manager.Register(server.URL, "alice", "file", "oauth", HostOptions{}, Credential{AccessToken: "old", RefreshToken: "refresh", ExpiresAt: time.Now().Add(-time.Minute)}))
credential, err = manager.Refresh(context.Background(), server.URL, "alice", nil, false, time.Now())
require.NoError(t, err)
require.Equal(t, "access", credential.AccessToken)
}
func TestRefreshEndsBeforeSettingsLockWaitersGiveUp(t *testing.T) {
// A refresh holds the settings lock, so concurrent tea processes must wait longer than it can take.
require.Less(t, refreshTimeout, settings.LockTimeout)
}
+68
View File
@@ -0,0 +1,68 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"context"
"encoding/base64"
"errors"
"fmt"
"strings"
"unicode"
)
// AgitOptions creates a pull request through Gitea's AGit push convention.
type AgitOptions struct {
Directory, Remote, Head, Base, Topic, Title, Body string
CredentialArgs []string
Prompt bool
}
// PushAgitPR pushes head to refs/for/<base>/<topic> with optional title/description options.
func PushAgitPR(ctx context.Context, runner Runner, opts AgitOptions) error {
if opts.Remote == "" || opts.Head == "" || opts.Base == "" || opts.Topic == "" {
return errors.New("AGit pull creation requires remote, head, base, and topic")
}
if opts.Head == opts.Base || opts.Topic == opts.Base {
return fmt.Errorf("cannot create an AGit pull from %s to %s", opts.Topic, opts.Base)
}
run := func(args ...string) (string, error) {
return runner.Output(ctx, opts.Directory, opts.Prompt, args...)
}
if err := requireWorkTree(run, "AGit pull creation"); err != nil {
return err
}
head := opts.Head
if !strings.HasPrefix(head, "refs/") {
head = "refs/heads/" + head
}
refspec := fmt.Sprintf("%s:refs/for/%s/%s", head, opts.Base, opts.Topic)
args := append([]string{}, opts.CredentialArgs...)
args = append(args, "push")
if opts.Title != "" {
args = append(args, "-o", "title="+agitPushOption(opts.Title))
}
if opts.Body != "" {
args = append(args, "-o", "description="+agitPushOption(opts.Body))
}
args = append(args, "--", opts.Remote, refspec)
_, err := run(args...)
return err
}
func agitPushOption(value string) string {
if strings.Contains(value, "\n") || !isASCII(value) {
return "{base64}" + base64.StdEncoding.EncodeToString([]byte(value))
}
return value
}
func isASCII(value string) bool {
for _, r := range value {
if r > unicode.MaxASCII {
return false
}
}
return true
}
+66
View File
@@ -0,0 +1,66 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"context"
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
func TestPushAgitPREncodesOptionsAndRefspec(t *testing.T) {
runner := &checkoutRunner{}
err := PushAgitPR(context.Background(), runner, AgitOptions{
Directory: "/work", Remote: "origin", Head: "feature", Base: "main", Topic: "feature",
Title: "Hello", Body: "line1\nline2", CredentialArgs: []string{"-c", "credential.helper=tea"},
})
require.NoError(t, err)
require.Equal(t, []string{
"rev-parse --is-inside-work-tree",
"-c credential.helper=tea push -o title=Hello -o description={base64}bGluZTEKbGluZTI= -- origin refs/heads/feature:refs/for/main/feature",
}, runner.calls)
}
func TestPushAgitPRRejectsSameBaseAndTopic(t *testing.T) {
runner := &checkoutRunner{}
err := PushAgitPR(context.Background(), runner, AgitOptions{Directory: "/work", Remote: "origin", Head: "main", Base: "main", Topic: "main"})
require.ErrorContains(t, err, "cannot create")
require.Empty(t, runner.calls)
}
func TestPushAgitPRWithOptionsToRealRemote(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("Git is not installed")
}
root := t.TempDir()
env := append(os.Environ(), "GIT_CONFIG_GLOBAL="+filepath.Join(root, "gitconfig"), "GIT_CONFIG_NOSYSTEM=1",
"GIT_AUTHOR_NAME=tea", "GIT_AUTHOR_EMAIL=tea@example.invalid", "GIT_COMMITTER_NAME=tea", "GIT_COMMITTER_EMAIL=tea@example.invalid")
runner := System{Env: env}
remote, work := filepath.Join(root, "remote.git"), filepath.Join(root, "work")
for _, step := range []struct {
dir string
args []string
}{
{root, []string{"init", "--bare", remote}},
{remote, []string{"config", "receive.advertisePushOptions", "true"}},
{root, []string{"init", "-b", "feature", work}},
{work, []string{"commit", "--allow-empty", "-m", "initial"}},
{work, []string{"remote", "add", "origin", remote}},
} {
_, err := runner.Output(context.Background(), step.dir, false, step.args...)
require.NoError(t, err, step.args)
}
require.NoError(t, PushAgitPR(context.Background(), runner, AgitOptions{
Directory: work, Remote: "origin", Head: "feature", Base: "main", Topic: "topic", Title: "Hello", Body: "line1\nline2",
}))
pushed, err := runner.Output(context.Background(), remote, false, "rev-parse", "refs/for/main/topic")
require.NoError(t, err)
local, err := runner.Output(context.Background(), work, false, "rev-parse", "HEAD")
require.NoError(t, err)
require.Equal(t, local, pushed)
}
+156
View File
@@ -0,0 +1,156 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"context"
"errors"
"fmt"
"strings"
)
// CheckoutOptions identifies the base remote and how to check out the PR commit.
type CheckoutOptions struct {
// CredentialArgs contains per-operation Git configuration for the fetch only.
CredentialArgs []string
Directory string
Remote string
Number int64
Branch string
Detached bool
Update bool
Prompt bool
ExpectedCommit string
// HeadRemote and HeadBranch select fork tracking when the head branch still exists.
// When HeadBranch is empty, checkout fetches the retained pull ref from Remote.
HeadRemote string
HeadBranch string
}
// CheckoutResult describes the selected branch or detached PR commit.
type CheckoutResult struct {
Detached bool `json:"detached"`
Branch string `json:"branch"`
Commit string `json:"commit"`
Remote string `json:"remote,omitempty"`
Tracking string `json:"tracking,omitempty"`
}
// CheckoutPull fetches a PR head and checks out its commit.
// When HeadRemote and HeadBranch are set, it fetches that head branch and configures tracking.
// Otherwise it fetches Gitea's retained pull ref from the base remote.
func CheckoutPull(ctx context.Context, runner Runner, options CheckoutOptions) (CheckoutResult, error) {
if options.Number < 1 || options.Remote == "" || (!options.Detached && options.Branch == "") {
return CheckoutResult{}, errors.New("checkout requires a pull number, base remote, and either a branch or detached mode")
}
if options.Detached && (options.Branch != "" || options.Update) {
return CheckoutResult{}, errors.New("detached checkout cannot select a branch or update an existing branch")
}
if options.HeadBranch != "" && options.HeadRemote == "" {
return CheckoutResult{}, errors.New("tracked checkout requires a head remote")
}
run := func(args ...string) (string, error) {
return runner.Output(ctx, options.Directory, options.Prompt, args...)
}
if err := requireWorkTree(run, "PR checkout"); err != nil {
return CheckoutResult{}, err
}
if !options.Detached {
if _, err := run("check-ref-format", "--branch", options.Branch); err != nil {
return CheckoutResult{}, fmt.Errorf("invalid checkout branch: %w", err)
}
}
status, err := run("status", "--porcelain=v1", "--untracked-files=normal")
if err != nil {
return CheckoutResult{}, err
}
if status != "" {
return CheckoutResult{}, errors.New("working tree has changes; commit or stash them before PR checkout")
}
existing := false
previousCommit := ""
if !options.Detached {
branches, err := run("for-each-ref", "--format=%(refname)", "refs/heads/")
if err != nil {
return CheckoutResult{}, err
}
for branch := range strings.SplitSeq(branches, "\n") {
if branch == "refs/heads/"+options.Branch {
if options.ExpectedCommit == "" {
return CheckoutResult{}, fmt.Errorf("branch %q already exists; choose another checkout branch", options.Branch)
}
commit, err := run("rev-parse", "--verify", branch+"^{commit}")
if err != nil {
return CheckoutResult{}, err
}
if commit != options.ExpectedCommit && !options.Update {
return CheckoutResult{}, fmt.Errorf("branch %q already exists with a different commit; choose another checkout branch", options.Branch)
}
previousCommit = commit
existing = true
}
}
}
fetchRemote := options.Remote
fetchRef := fmt.Sprintf("refs/pull/%d/head", options.Number)
tracking := ""
if options.HeadBranch != "" {
if _, err := run("check-ref-format", "--branch", options.HeadBranch); err != nil {
return CheckoutResult{}, fmt.Errorf("invalid head branch: %w", err)
}
fetchRemote = options.HeadRemote
fetchRef = "refs/heads/" + options.HeadBranch
tracking = options.HeadRemote + "/" + options.HeadBranch
}
fetchArgs := append([]string{}, options.CredentialArgs...)
fetchArgs = append(fetchArgs, "fetch", "--no-tags", "--", fetchRemote, fetchRef)
if _, err := run(fetchArgs...); err != nil {
return CheckoutResult{}, err
}
commit, err := run("rev-parse", "--verify", "FETCH_HEAD^{commit}")
if err != nil {
return CheckoutResult{}, fmt.Errorf("PR was fetched, but its commit could not be resolved: %w", err)
}
if options.ExpectedCommit != "" && commit != options.ExpectedCommit {
return CheckoutResult{}, errors.New("PR head changed while fetching; retry checkout")
}
if existing {
localCommit, err := run("rev-parse", "--verify", "refs/heads/"+options.Branch+"^{commit}")
if err != nil {
return CheckoutResult{}, err
}
if localCommit != previousCommit {
return CheckoutResult{}, errors.New("local branch changed while fetching; retry checkout")
}
if localCommit != commit {
if _, err := run("merge-base", "--is-ancestor", localCommit, commit); err != nil {
return CheckoutResult{}, fmt.Errorf("existing branch cannot be fast-forwarded; choose another checkout branch: %w", err)
}
}
}
switchArgs := []string{"switch", "--detach", commit}
if !options.Detached {
switchArgs = []string{"switch", "--create", options.Branch, commit}
if existing {
switchArgs = []string{"switch", options.Branch}
}
}
if _, err := run(switchArgs...); err != nil {
return CheckoutResult{}, fmt.Errorf("PR was fetched, but checkout failed: %w", err)
}
if existing && previousCommit != commit {
if _, err := run("merge", "--ff-only", "--no-edit", commit); err != nil {
return CheckoutResult{}, fmt.Errorf("checked out the existing branch, but its fast-forward failed: %w", err)
}
}
if tracking != "" && !options.Detached {
if _, err := run("config", "branch."+options.Branch+".remote", options.HeadRemote); err != nil {
return CheckoutResult{}, fmt.Errorf("checked out the branch, but could not configure tracking: %w", err)
}
if _, err := run("config", "branch."+options.Branch+".merge", "refs/heads/"+options.HeadBranch); err != nil {
return CheckoutResult{}, fmt.Errorf("checked out the branch, but could not configure tracking: %w", err)
}
}
return CheckoutResult{Branch: options.Branch, Commit: commit, Detached: options.Detached, Remote: fetchRemote, Tracking: tracking}, nil
}
+172
View File
@@ -0,0 +1,172 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"context"
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
type checkoutRunner struct {
calls []string
dirty, collision bool
localCommit string
diverged bool
}
func (r *checkoutRunner) Output(_ context.Context, dir string, prompt bool, args ...string) (string, error) {
if dir != "/work" || prompt {
return "", fmt.Errorf("unexpected Git execution policy")
}
command := strings.Join(args, " ")
r.calls = append(r.calls, command)
switch args[0] {
case "merge-base":
if r.diverged {
return "", &CommandError{ExitCode: 1}
}
case "rev-parse":
if len(args) > 2 && strings.HasPrefix(args[2], "refs/heads/") && r.localCommit != "" {
return r.localCommit, nil
}
if args[1] == "--is-inside-work-tree" {
return "true", nil
}
return "abc123", nil
case "status":
if r.dirty {
return "?? file", nil
}
case "for-each-ref":
if r.collision {
return "refs/heads/pr/7", nil
}
}
return "", nil
}
func TestPullCheckoutValidatesBeforeChangingGitState(t *testing.T) {
options := CheckoutOptions{Directory: "/work", Remote: "origin", Number: 7, Branch: "pr/7"}
for _, tc := range []struct {
dirty, collision bool
message string
}{{true, false, "working tree has changes"}, {false, true, "already exists"}} {
runner := &checkoutRunner{dirty: tc.dirty, collision: tc.collision}
_, err := CheckoutPull(context.Background(), runner, options)
require.ErrorContains(t, err, tc.message)
for _, call := range runner.calls {
require.False(t, strings.HasPrefix(call, "fetch "))
require.False(t, strings.HasPrefix(call, "switch "))
}
}
runner := &checkoutRunner{}
result, err := CheckoutPull(context.Background(), runner, options)
require.NoError(t, err)
require.Equal(t, CheckoutResult{Branch: "pr/7", Commit: "abc123", Remote: "origin"}, result)
require.Equal(t, []string{"rev-parse --is-inside-work-tree", "check-ref-format --branch pr/7", "status --porcelain=v1 --untracked-files=normal", "for-each-ref --format=%(refname) refs/heads/", "fetch --no-tags -- origin refs/pull/7/head", "rev-parse --verify FETCH_HEAD^{commit}", "switch --create pr/7 abc123"}, runner.calls)
}
func TestPullCheckoutRejectsChangedHead(t *testing.T) {
runner := &checkoutRunner{}
_, err := CheckoutPull(context.Background(), runner, CheckoutOptions{Directory: "/work", Remote: "origin", Number: 7, Branch: "pr/7", ExpectedCommit: "different"})
require.ErrorContains(t, err, "PR head changed")
for _, call := range runner.calls {
require.False(t, strings.HasPrefix(call, "switch "))
}
}
func TestPullCheckoutScopesCredentialArgumentsToFetch(t *testing.T) {
runner := &checkoutRunner{}
arguments := []string{"-c", "credential.https://example.test.helper=!tea-helper"}
_, err := CheckoutPull(context.Background(), runner, CheckoutOptions{Directory: "/work", Remote: "origin", Number: 7, Branch: "pr/7", CredentialArgs: arguments})
require.NoError(t, err)
credentialCalls := 0
for _, call := range runner.calls {
if strings.HasPrefix(call, "-c ") {
credentialCalls++
require.Equal(t, "-c credential.https://example.test.helper=!tea-helper fetch --no-tags -- origin refs/pull/7/head", call)
}
}
require.Equal(t, 1, credentialCalls)
require.Equal(t, []string{"-c", "credential.https://example.test.helper=!tea-helper"}, arguments)
}
func TestDetachedCheckoutDoesNotCreateOrChangeBranches(t *testing.T) {
runner := &checkoutRunner{collision: true}
result, err := CheckoutPull(context.Background(), runner, CheckoutOptions{Directory: "/work", Remote: "origin", Number: 7, Detached: true, ExpectedCommit: "abc123"})
require.NoError(t, err)
require.Equal(t, CheckoutResult{Detached: true, Commit: "abc123", Remote: "origin"}, result)
require.Equal(t, "switch --detach abc123", runner.calls[len(runner.calls)-1])
for _, call := range runner.calls {
require.False(t, strings.HasPrefix(call, "for-each-ref"))
require.False(t, strings.HasPrefix(call, "check-ref-format"))
require.NotContains(t, call, "--create")
}
runner = &checkoutRunner{dirty: true}
_, err = CheckoutPull(context.Background(), runner, CheckoutOptions{Directory: "/work", Remote: "origin", Number: 7, Detached: true})
require.ErrorContains(t, err, "working tree has changes")
for _, call := range runner.calls {
require.False(t, strings.HasPrefix(call, "fetch "))
require.False(t, strings.HasPrefix(call, "switch "))
}
runner = &checkoutRunner{}
_, err = CheckoutPull(context.Background(), runner, CheckoutOptions{Directory: "/work", Remote: "origin", Number: 7, Detached: true, Branch: "existing"})
require.ErrorContains(t, err, "cannot select a branch")
require.Empty(t, runner.calls)
}
func TestPullCheckoutReusesMatchingBranch(t *testing.T) {
runner := &checkoutRunner{collision: true}
result, err := CheckoutPull(context.Background(), runner, CheckoutOptions{Directory: "/work", Remote: "origin", Number: 7, Branch: "pr/7", ExpectedCommit: "abc123"})
require.NoError(t, err)
require.Equal(t, "abc123", result.Commit)
require.Contains(t, runner.calls, "rev-parse --verify refs/heads/pr/7^{commit}")
require.Equal(t, "switch pr/7", runner.calls[len(runner.calls)-1])
runner = &checkoutRunner{collision: true}
_, err = CheckoutPull(context.Background(), runner, CheckoutOptions{Directory: "/work", Remote: "origin", Number: 7, Branch: "pr/7", ExpectedCommit: "different"})
require.ErrorContains(t, err, "already exists with a different commit")
for _, call := range runner.calls {
require.False(t, strings.HasPrefix(call, "fetch "))
require.False(t, strings.HasPrefix(call, "switch "))
}
}
func TestPullCheckoutUpdatesOnlyByFastForward(t *testing.T) {
for _, diverged := range []bool{false, true} {
runner := &checkoutRunner{collision: true, localCommit: "previous", diverged: diverged}
_, err := CheckoutPull(context.Background(), runner, CheckoutOptions{Directory: "/work", Remote: "origin", Number: 7, Branch: "pr/7", ExpectedCommit: "abc123", Update: true})
require.Contains(t, runner.calls, "merge-base --is-ancestor previous abc123")
if diverged {
require.ErrorContains(t, err, "cannot be fast-forwarded")
for _, call := range runner.calls {
require.False(t, strings.HasPrefix(call, "switch "))
require.False(t, strings.HasPrefix(call, "merge "))
}
} else {
require.NoError(t, err)
require.Equal(t, []string{"switch pr/7", "merge --ff-only --no-edit abc123"}, runner.calls[len(runner.calls)-2:])
}
}
}
func TestPullCheckoutTracksHeadBranch(t *testing.T) {
runner := &checkoutRunner{}
result, err := CheckoutPull(context.Background(), runner, CheckoutOptions{
Directory: "/work", Remote: "origin", Number: 7, Branch: "pr/7", ExpectedCommit: "abc123",
HeadRemote: "pulls/bob", HeadBranch: "feature",
})
require.NoError(t, err)
require.Equal(t, CheckoutResult{Branch: "pr/7", Commit: "abc123", Remote: "pulls/bob", Tracking: "pulls/bob/feature"}, result)
require.Contains(t, runner.calls, "check-ref-format --branch feature")
require.Contains(t, runner.calls, "fetch --no-tags -- pulls/bob refs/heads/feature")
require.Contains(t, runner.calls, "config branch.pr/7.remote pulls/bob")
require.Contains(t, runner.calls, "config branch.pr/7.merge refs/heads/feature")
for _, call := range runner.calls {
require.NotContains(t, call, "refs/pull/")
}
}
+208
View File
@@ -0,0 +1,208 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"context"
"errors"
"fmt"
"strings"
)
// CleanOptions identifies a closed PR's local branch and optional remote head.
type CleanOptions struct {
Directory, DefaultBranch, LocalBranch, Remote, RemoteBranch string
CredentialArgs []string
Prompt, DeleteRemote bool
}
// CleanResult reports which branches were removed.
type CleanResult struct {
LocalBranch string `json:"local_branch"`
Remote string `json:"remote,omitempty"`
RemoteBranch string `json:"remote_branch,omitempty"`
DefaultBranch string `json:"default_branch"`
RemoteDeleted bool `json:"remote_deleted"`
RemoteSkipped bool `json:"remote_skipped"`
RemoteSkipReason string `json:"remote_skip_reason,omitempty"`
}
// FindCleanBranchOptions searches for a local branch belonging to a closed PR.
type FindCleanBranchOptions struct {
Directory, HeadSHA, HeadBranch string
DefaultBranch string
PreferredNames, MatchingRemotes []string
IgnoreSHA bool
}
// FindCleanBranch locates one local branch for PR cleanup without mutating Git state.
func FindCleanBranch(ctx context.Context, runner Runner, opts FindCleanBranchOptions) (localBranch, remote string, err error) {
run := func(args ...string) (string, error) {
return runner.Output(ctx, opts.Directory, false, args...)
}
if err := requireWorkTree(run, "PR clean"); err != nil {
return "", "", err
}
refs, err := run("for-each-ref", "--format=%(objectname)%09%(refname:short)%09%(upstream:short)", "refs/heads/")
if err != nil {
return "", "", err
}
type candidate struct {
name, remote string
matched bool
}
setOf := func(names []string) map[string]bool {
out := map[string]bool{}
for _, name := range names {
if name != "" {
out[name] = true
}
}
return out
}
preferred := setOf(opts.PreferredNames)
matching := setOf(opts.MatchingRemotes)
candidates := []candidate{}
for line := range strings.SplitSeq(refs, "\n") {
if line == "" {
continue
}
parts := strings.SplitN(line, "\t", 3)
if len(parts) < 2 {
continue
}
sha, name := parts[0], parts[1]
if name == opts.DefaultBranch {
continue
}
upstream := ""
if len(parts) == 3 {
upstream = parts[2]
}
if opts.IgnoreSHA {
if name != opts.HeadBranch && !preferred[name] {
continue
}
} else if opts.HeadSHA == "" || sha != opts.HeadSHA {
continue
}
remoteName := ""
matched := false
if upstream != "" {
remoteName = strings.SplitN(upstream, "/", 2)[0]
matched = matching[remoteName]
}
if !matched {
for remote := range matching {
tracking, trackErr := run("rev-parse", "--verify", "refs/remotes/"+remote+"/"+opts.HeadBranch+"^{commit}")
if trackErr == nil && (opts.IgnoreSHA || tracking == sha) {
remoteName, matched = remote, true
break
}
}
}
if !matched && len(matching) == 1 {
for remote := range matching {
remoteName = remote
}
}
candidates = append(candidates, candidate{name: name, remote: remoteName, matched: matched})
}
if len(candidates) == 0 {
if opts.IgnoreSHA {
return "", "", fmt.Errorf("local branch %q was not found for the selected pull request", opts.HeadBranch)
}
return "", "", fmt.Errorf(`local branch for commit %s was not found.
Either this pull request was never checked out, or the local branch diverged.
Retry with --ignore-sha to select by branch name`, opts.HeadSHA)
}
selected := []candidate{}
for _, item := range candidates {
if item.matched || preferred[item.name] || item.name == opts.HeadBranch {
selected = append(selected, item)
}
}
if len(selected) == 0 {
selected = candidates
}
if len(selected) > 1 {
strong := make([]candidate, 0, len(selected))
for _, item := range selected {
if item.matched {
strong = append(strong, item)
}
}
if len(strong) > 0 {
selected = strong
}
}
if len(selected) != 1 {
names := make([]string, 0, len(selected))
for _, item := range selected {
names = append(names, item.name)
}
return "", "", fmt.Errorf("multiple local branches match this pull request (%s); check out or rename until one remains", strings.Join(names, ", "))
}
return selected[0].name, selected[0].remote, nil
}
// CleanPull deletes the selected local branch and optionally its remote head.
func CleanPull(ctx context.Context, runner Runner, opts CleanOptions) (CleanResult, error) {
run := func(args ...string) (string, error) {
return runner.Output(ctx, opts.Directory, opts.Prompt, args...)
}
if opts.LocalBranch == "" || opts.DefaultBranch == "" {
return CleanResult{}, errors.New("clean requires a local branch and repository default branch")
}
if opts.LocalBranch == opts.DefaultBranch {
return CleanResult{}, errors.New("refusing to delete the repository default branch")
}
if opts.DeleteRemote && (opts.Remote == "" || opts.RemoteBranch == "") {
return CleanResult{}, errors.New("remote deletion requires a remote and branch name")
}
if err := requireWorkTree(run, "PR clean"); err != nil {
return CleanResult{}, err
}
if _, err := run("check-ref-format", "--branch", opts.LocalBranch); err != nil {
return CleanResult{}, fmt.Errorf("invalid local branch: %w", err)
}
if _, err := run("check-ref-format", "--branch", opts.DefaultBranch); err != nil {
return CleanResult{}, fmt.Errorf("invalid default branch: %w", err)
}
status, err := run("status", "--porcelain=v1", "--untracked-files=normal")
if err != nil {
return CleanResult{}, err
}
if status != "" {
return CleanResult{}, errors.New("working tree has changes; commit or stash them before cleaning")
}
if _, err := run("rev-parse", "--verify", "refs/heads/"+opts.LocalBranch+"^{commit}"); err != nil {
return CleanResult{}, fmt.Errorf("local branch is unavailable: %w", err)
}
if _, err := run("rev-parse", "--verify", "refs/heads/"+opts.DefaultBranch+"^{commit}"); err != nil {
return CleanResult{}, fmt.Errorf("default branch is unavailable; create or fetch it before cleaning: %w", err)
}
current, err := run("symbolic-ref", "--short", "HEAD")
if err == nil && current == opts.LocalBranch {
if _, err := run("switch", "--", opts.DefaultBranch); err != nil {
return CleanResult{}, fmt.Errorf("could not switch to %s before deleting %s: %w", opts.DefaultBranch, opts.LocalBranch, err)
}
}
if _, err := run("branch", "-D", "--", opts.LocalBranch); err != nil {
return CleanResult{}, fmt.Errorf("local branch could not be deleted: %w", err)
}
result := CleanResult{LocalBranch: opts.LocalBranch, DefaultBranch: opts.DefaultBranch}
if !opts.DeleteRemote {
result.RemoteSkipped = true
result.RemoteSkipReason = "remote head was already deleted or push is unavailable"
return result, nil
}
push := append([]string{}, opts.CredentialArgs...)
push = append(push, "push", "--delete", "--", opts.Remote, opts.RemoteBranch)
if _, err := run(push...); err != nil {
return result, fmt.Errorf("deleted local branch %s, but deleting remote branch %s failed: %w", opts.LocalBranch, opts.RemoteBranch, err)
}
result.Remote, result.RemoteBranch, result.RemoteDeleted = opts.Remote, opts.RemoteBranch, true
return result, nil
}
+139
View File
@@ -0,0 +1,139 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"context"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
type cleanRunner struct {
calls []string
dirty, missingLocal, missingDefault, onFeature bool
branches string
remoteTracking map[string]string
}
func (r *cleanRunner) Output(_ context.Context, dir string, prompt bool, args ...string) (string, error) {
if dir != "/work" {
return "", &CommandError{ExitCode: 1}
}
command := strings.Join(args, " ")
r.calls = append(r.calls, command)
switch args[0] {
case "rev-parse":
if len(args) > 1 && args[1] == "--is-inside-work-tree" {
return "true", nil
}
if len(args) > 2 && strings.HasPrefix(args[2], "refs/heads/") {
if r.missingLocal && strings.Contains(args[2], "feature") {
return "", &CommandError{ExitCode: 1}
}
if r.missingDefault && strings.Contains(args[2], "main") {
return "", &CommandError{ExitCode: 1}
}
return "abc123", nil
}
if len(args) > 2 && strings.HasPrefix(args[2], "refs/remotes/") {
if sha, ok := r.remoteTracking[args[2]]; ok {
return sha, nil
}
return "", &CommandError{ExitCode: 1}
}
case "status":
if r.dirty {
return " M file", nil
}
case "symbolic-ref":
if r.onFeature {
return "feature", nil
}
return "main", nil
case "for-each-ref":
return r.branches, nil
case "check-ref-format", "switch", "branch", "push":
return "", nil
}
return "", nil
}
func TestFindCleanBranchBySHAAndPreferredName(t *testing.T) {
runner := &cleanRunner{branches: "abc123\tpr/7\torigin/feature\ndef456\tother\t\n"}
branch, remote, err := FindCleanBranch(context.Background(), runner, FindCleanBranchOptions{
Directory: "/work", HeadSHA: "abc123", HeadBranch: "feature", PreferredNames: []string{"pr/7"}, MatchingRemotes: []string{"origin"},
})
require.NoError(t, err)
require.Equal(t, "pr/7", branch)
require.Equal(t, "origin", remote)
}
func TestFindCleanBranchIgnoreSHAUsesName(t *testing.T) {
runner := &cleanRunner{branches: "deadbeef\tfeature\torigin/feature\n"}
branch, remote, err := FindCleanBranch(context.Background(), runner, FindCleanBranchOptions{
Directory: "/work", HeadSHA: "abc123", HeadBranch: "feature", IgnoreSHA: true, MatchingRemotes: []string{"origin"},
})
require.NoError(t, err)
require.Equal(t, "feature", branch)
require.Equal(t, "origin", remote)
}
func TestFindCleanBranchRejectsAmbiguity(t *testing.T) {
runner := &cleanRunner{branches: "abc123\tone\t\nabc123\ttwo\t\n"}
_, _, err := FindCleanBranch(context.Background(), runner, FindCleanBranchOptions{
Directory: "/work", HeadSHA: "abc123", HeadBranch: "feature", MatchingRemotes: []string{"origin"},
})
require.ErrorContains(t, err, "multiple local branches")
}
func TestFindCleanBranchSkipsDefaultBranch(t *testing.T) {
runner := &cleanRunner{branches: "abc123\tmain\torigin/main\nabc123\tpr/7\t\n"}
branch, _, err := FindCleanBranch(context.Background(), runner, FindCleanBranchOptions{
Directory: "/work", HeadSHA: "abc123", HeadBranch: "feature", DefaultBranch: "main", PreferredNames: []string{"pr/7"}, MatchingRemotes: []string{"origin"},
})
require.NoError(t, err)
require.Equal(t, "pr/7", branch)
}
func TestCleanPullRejectsDefaultBranch(t *testing.T) {
_, err := CleanPull(context.Background(), &cleanRunner{}, CleanOptions{Directory: "/work", DefaultBranch: "main", LocalBranch: "main"})
require.ErrorContains(t, err, "default branch")
}
func TestCleanPullValidatesBeforeMutation(t *testing.T) {
runner := &cleanRunner{dirty: true}
_, err := CleanPull(context.Background(), runner, CleanOptions{Directory: "/work", DefaultBranch: "main", LocalBranch: "feature"})
require.ErrorContains(t, err, "working tree has changes")
for _, call := range runner.calls {
require.False(t, strings.HasPrefix(call, "switch "))
require.False(t, strings.HasPrefix(call, "branch "))
require.False(t, strings.HasPrefix(call, "push "))
}
}
func TestCleanPullDeletesLocalAndRemoteBranches(t *testing.T) {
runner := &cleanRunner{onFeature: true}
result, err := CleanPull(context.Background(), runner, CleanOptions{
Directory: "/work", DefaultBranch: "main", LocalBranch: "feature", Remote: "origin", RemoteBranch: "feature",
DeleteRemote: true, CredentialArgs: []string{"-c", "credential.helper=tea"},
})
require.NoError(t, err)
require.Equal(t, CleanResult{LocalBranch: "feature", Remote: "origin", RemoteBranch: "feature", DefaultBranch: "main", RemoteDeleted: true}, result)
require.Contains(t, runner.calls, "switch -- main")
require.Contains(t, runner.calls, "branch -D -- feature")
require.Contains(t, runner.calls, "-c credential.helper=tea push --delete -- origin feature")
}
func TestCleanPullSkipsRemoteWhenUnavailable(t *testing.T) {
runner := &cleanRunner{}
result, err := CleanPull(context.Background(), runner, CleanOptions{Directory: "/work", DefaultBranch: "main", LocalBranch: "feature"})
require.NoError(t, err)
require.True(t, result.RemoteSkipped)
require.Equal(t, "feature", result.LocalBranch)
for _, call := range runner.calls {
require.False(t, strings.HasPrefix(call, "push "))
}
}
+108
View File
@@ -0,0 +1,108 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"context"
"errors"
"path/filepath"
"slices"
"strings"
"gitea.dev/tea/internal/settings"
"github.com/kballard/go-shellquote"
)
// CredentialHelper builds a shell command for Git's helper protocol. Git itself
// supplies the final get/store/erase argument. No credential enters this command.
func CredentialHelper(instance, executable, user, configDir string) (string, error) {
normalized, err := settings.NormalizeHost(instance)
if err != nil {
return "", err
}
if !filepath.IsAbs(executable) || strings.ContainsAny(executable+user+configDir, "\x00\r\n") {
return "", errors.New("credential helper requires a valid absolute executable path")
}
command := []string{executable, "auth", "git-credential", "--host", normalized}
if user != "" {
command = append(command, "--user", user)
}
helper := shellquote.Join(command...)
if configDir != "" {
if !filepath.IsAbs(configDir) {
return "", errors.New("credential helper configuration directory must be absolute")
}
helper = "TEA_CONFIG_DIR=" + shellquote.Join(configDir) + " " + helper
}
return "!" + helper, nil
}
// CredentialArguments scopes a helper to one Git operation instead of changing
// Git configuration. The empty helper resets other helpers for this instance.
func CredentialArguments(instance, executable, user, configDir string) ([]string, error) {
helper, err := CredentialHelper(instance, executable, user, configDir)
if err != nil {
return nil, err
}
normalized, err := settings.NormalizeHost(instance)
if err != nil {
return nil, err
}
prefix := "credential." + normalized + "."
return []string{"-c", prefix + "helper=", "-c", prefix + "helper=" + helper, "-c", prefix + "useHttpPath=true"}, nil
}
// CredentialSetup specifies an explicit persistent helper installation.
type CredentialSetup struct {
Directory, Instance, Executable, User, ConfigDirectory string
Local bool
}
// SetupCredentials adds a scoped helper without removing other credential helpers.
func SetupCredentials(ctx context.Context, runner Runner, options CredentialSetup) error {
normalized, err := settings.NormalizeHost(options.Instance)
if err != nil {
return err
}
helper, err := CredentialHelper(normalized, options.Executable, options.User, options.ConfigDirectory)
if err != nil {
return err
}
cwd := options.Directory
local := options.Local
scope := "--global"
if local {
scope = "--local"
}
prefix := "credential." + normalized + "."
readValues := func(key string) ([]string, error) {
data, err := runner.Output(ctx, cwd, false, "config", scope, "--null", "--get-all", prefix+key)
if err != nil {
if failure, ok := errors.AsType[*CommandError](err); ok && failure.ExitCode == 1 {
return nil, nil
}
return nil, err
}
return strings.Split(strings.TrimSuffix(data, "\x00"), "\x00"), nil
}
helpers, err := readValues("helper")
if err != nil {
return err
}
pathValues, err := readValues("useHttpPath")
if err != nil {
return err
}
// Install path awareness first: the helper must see the installation base path.
if len(pathValues) != 1 || pathValues[0] != "true" {
if _, err := runner.Output(ctx, cwd, false, "config", scope, "--replace-all", prefix+"useHttpPath", "true"); err != nil {
return err
}
}
if slices.Contains(helpers, helper) {
return nil
}
_, err = runner.Output(ctx, cwd, false, "config", scope, "--add", prefix+"helper", helper)
return err
}
+64
View File
@@ -0,0 +1,64 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/kballard/go-shellquote"
"github.com/stretchr/testify/require"
)
func TestSetupGitCredentialsPreservesHelpersAndIsIdempotent(t *testing.T) {
ctx := context.Background()
cwd := t.TempDir()
runner := System{}
_, err := runner.Output(ctx, cwd, false, "init")
require.NoError(t, err)
instance := "https://example.test:8443/gitea"
key := "credential." + instance + ".helper"
_, err = runner.Output(ctx, cwd, false, "config", "--local", "--add", key, "existing-helper")
require.NoError(t, err)
executable := filepath.Join(cwd, "path with spaces and 'quote'", "tea")
require.NoError(t, SetupCredentials(ctx, runner, CredentialSetup{Directory: cwd, Local: true, Instance: instance, Executable: executable, User: "alice"}))
require.NoError(t, SetupCredentials(ctx, runner, CredentialSetup{Directory: cwd, Local: true, Instance: instance, Executable: executable, User: "alice"}))
data, err := runner.Output(ctx, cwd, false, "config", "--local", "--get-all", key)
require.NoError(t, err)
helpers := strings.Split(data, "\n")
require.Len(t, helpers, 2)
require.Equal(t, "existing-helper", helpers[0])
words, err := shellquote.Split(strings.TrimPrefix(helpers[1], "!"))
require.NoError(t, err)
require.Equal(t, []string{executable, "auth", "git-credential", "--host", instance, "--user", "alice"}, words)
data, err = runner.Output(ctx, cwd, false, "config", "--local", "--get", "credential."+instance+".useHttpPath")
require.NoError(t, err)
require.Equal(t, "true", data)
}
func TestSetupGitCredentialsWorksWithoutAnExistingGlobalFile(t *testing.T) {
home := t.TempDir()
t.Setenv("GIT_CONFIG_GLOBAL", filepath.Join(home, "new.gitconfig"))
executable, err := os.Executable()
require.NoError(t, err)
require.NoError(t, SetupCredentials(context.Background(), System{}, CredentialSetup{Directory: home, Instance: "https://example.test", Executable: executable}))
data, err := os.ReadFile(filepath.Join(home, "new.gitconfig"))
require.NoError(t, err)
require.Contains(t, string(data), "useHttpPath = true")
require.Contains(t, string(data), "auth git-credential")
}
func TestGitMissingConfigValueCarriesExitCode(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("Git is not installed")
}
_, err := (System{}).Output(context.Background(), t.TempDir(), false, "config", "--file", filepath.Join(t.TempDir(), "missing"), "--get", "not.present")
var failure *CommandError
require.ErrorAs(t, err, &failure)
require.Equal(t, 1, failure.ExitCode)
}
+63
View File
@@ -0,0 +1,63 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"context"
"errors"
"slices"
"strings"
)
// ErrAmbiguousRemote reports that repository discovery cannot choose between several remotes.
var ErrAmbiguousRemote = errors.New("ambiguous repository: select --remote or --repo")
// DiscoverRemote names the remote used for repository discovery: preferred when set, otherwise
// the current branch's upstream remote, then upstream, origin, or the only remote.
func DiscoverRemote(ctx context.Context, runner Runner, dir, preferred string) (string, error) {
if preferred != "" {
return preferred, nil
}
if _, remote := CurrentUpstreamRemote(ctx, runner, dir); remote != "" {
return remote, nil
}
names, err := runner.Output(ctx, dir, false, "remote")
if err != nil {
return "", err
}
available := strings.Fields(names)
for _, name := range []string{"upstream", "origin"} {
if slices.Contains(available, name) {
return name, nil
}
}
if len(available) == 1 {
return available[0], nil
}
return "", ErrAmbiguousRemote
}
// CurrentUpstreamRemote names the checked-out branch and the remote it tracks; each is "" when
// HEAD is detached or the branch tracks no remote.
func CurrentUpstreamRemote(ctx context.Context, runner Runner, dir string) (branch, remote string) {
branch, err := runner.Output(ctx, dir, false, "symbolic-ref", "--short", "HEAD")
if err != nil {
return "", ""
}
return branch, UpstreamRemote(ctx, runner, dir, branch)
}
// UpstreamRemote names the remote branch tracks, or "" when it tracks none or a local branch.
// Unlike splitting @{upstream}, it keeps remote names with slashes, such as the
// pulls/<owner>/<repo> remotes pr checkout adds.
func UpstreamRemote(ctx context.Context, runner Runner, dir, branch string) string {
if branch == "" {
return ""
}
remote, err := runner.Output(ctx, dir, false, "config", "--get", "branch."+branch+".remote")
if err != nil || remote == "." {
return ""
}
return remote
}
+52
View File
@@ -0,0 +1,52 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"context"
"errors"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
type discoverRunner map[string]string
func (r discoverRunner) Output(_ context.Context, dir string, prompt bool, args ...string) (string, error) {
value, ok := r[strings.Join(args, " ")]
if dir != "/work" || prompt || !ok {
return "", errors.New("missing fake Git result")
}
return value, nil
}
func TestDiscoverRemote(t *testing.T) {
const head, tracking = "symbolic-ref --short HEAD", "config --get branch.main.remote"
for _, test := range []struct {
name string
runner discoverRunner
preferred string
want string
wantErr error
}{
{name: "preferred", preferred: "fork", want: "fork"},
{name: "branch upstream", runner: discoverRunner{head: "main", tracking: "fork", "remote": "origin\nfork"}, want: "fork"},
{name: "branch upstream with slashes", runner: discoverRunner{head: "main", tracking: "pulls/bob/project", "remote": "origin\npulls/bob/project"}, want: "pulls/bob/project"},
{name: "local branch upstream", runner: discoverRunner{head: "main", tracking: ".", "remote": "origin\nfork"}, want: "origin"},
{name: "upstream remote", runner: discoverRunner{"remote": "origin\nupstream"}, want: "upstream"},
{name: "origin remote", runner: discoverRunner{"remote": "fork\norigin"}, want: "origin"},
{name: "sole remote", runner: discoverRunner{"remote": "fork"}, want: "fork"},
{name: "ambiguous", runner: discoverRunner{"remote": "one\ntwo"}, wantErr: ErrAmbiguousRemote},
{name: "no remotes", runner: discoverRunner{"remote": ""}, wantErr: ErrAmbiguousRemote},
} {
t.Run(test.name, func(t *testing.T) {
remote, err := DiscoverRemote(context.Background(), test.runner, "/work", test.preferred)
require.ErrorIs(t, err, test.wantErr)
require.Equal(t, test.want, remote)
})
}
_, err := DiscoverRemote(context.Background(), discoverRunner{}, "/work", "")
require.Error(t, err)
}
+162
View File
@@ -0,0 +1,162 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
// Package git contains subprocess boundaries, independent of command flags.
package git
import (
"context"
"errors"
"io"
"os"
"os/exec"
"strings"
)
// Runner abstracts context-aware Git subprocess execution.
type Runner interface {
Output(context.Context, string, bool, ...string) (string, error)
}
// CommandError carries Git's exit status without including command arguments or output.
type CommandError struct{ ExitCode int }
func (*CommandError) Error() string {
return "Git operation failed; check repository, remote, and credentials"
}
// requireWorkTree fails unless run operates inside a Git working tree. action names the operation in the error.
func requireWorkTree(run func(...string) (string, error), action string) error {
inside, err := run("rev-parse", "--is-inside-work-tree")
if err != nil {
return err
}
if inside != "true" {
return errors.New(action + " requires a Git working tree")
}
return nil
}
// System executes installed Git with explicit stream and prompting policy.
type System struct {
// Env overrides the subprocess environment when supplied.
Env []string
In io.Reader
ErrOut io.Writer
}
func setEnv(env []string, key, value string) []string {
prefix := key + "="
result := make([]string, 0, len(env)+1)
for _, entry := range env {
if !strings.HasPrefix(entry, prefix) {
result = append(result, entry)
}
}
return append(result, prefix+value)
}
func networkSubcommand(args []string) bool {
for index := 0; index < len(args); index++ {
switch {
case args[index] == "-c" || args[index] == "-C":
index++
case strings.HasPrefix(args[index], "-"):
default:
switch args[index] {
case "push", "fetch", "pull", "ls-remote", "clone":
return true
}
return false
}
}
return false
}
// batchSSHEnv makes SSH fail instead of prompting. A per-operation core.sshCommand
// is preserved, and a GIT_SSH program (such as plink) is left untouched.
func batchSSHEnv(ctx context.Context, dir string, env, args []string) []string {
sshCommand, sshProgram := "", ""
for _, entry := range env {
if value, ok := strings.CutPrefix(entry, "GIT_SSH_COMMAND="); ok {
sshCommand = value
} else if value, ok := strings.CutPrefix(entry, "GIT_SSH="); ok {
sshProgram = value
}
}
if sshCommand == "" {
configArgs := []string{}
for index := 0; index+1 < len(args) && args[index] == "-c"; index += 2 {
configArgs = append(configArgs, args[index:index+2]...)
}
configArgs = append(configArgs, "config", "--get", "core.sshCommand")
config := exec.CommandContext(ctx, "git", configArgs...)
config.Dir = dir
config.Env = env
data, _ := config.Output()
sshCommand = strings.TrimSpace(string(data))
}
if sshCommand == "" {
if sshProgram != "" {
return env
}
sshCommand = "ssh"
}
if plink(env, sshCommand) {
return setEnv(env, "GIT_SSH_COMMAND", sshCommand+" -batch")
}
return setEnv(env, "GIT_SSH_COMMAND", sshCommand+" -oBatchMode=yes")
}
// plink reports whether Git treats sshCommand as PuTTY's plink, which rejects OpenSSH options.
// Like Git, it honors GIT_SSH_VARIANT and otherwise looks at the program's base name.
func plink(env []string, sshCommand string) bool {
variant := ""
for _, entry := range env {
if value, ok := strings.CutPrefix(entry, "GIT_SSH_VARIANT="); ok {
variant = value
}
}
if variant == "" {
program, _, _ := strings.Cut(sshCommand, " ")
if quote := sshCommand[0]; quote == '"' || quote == '\'' {
program, _, _ = strings.Cut(sshCommand[1:], string(quote))
}
variant = strings.TrimSuffix(strings.ToLower(program[strings.LastIndexAny(program, `/\`)+1:]), ".exe")
}
return variant == "plink" || variant == "tortoiseplink"
}
// Output runs Git, suppressing credential prompts when interaction is disabled.
func (s System) Output(ctx context.Context, dir string, prompt bool, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, "git", args...)
cmd.Dir = dir
cmd.Stderr = s.ErrOut
env := append([]string(nil), s.Env...)
if s.Env == nil {
env = os.Environ()
}
if prompt {
cmd.Stdin = s.In
} else {
env = setEnv(env, "GIT_TERMINAL_PROMPT", "0")
env = setEnv(env, "GIT_ASKPASS", "")
env = setEnv(env, "SSH_ASKPASS", "")
env = setEnv(env, "SSH_ASKPASS_REQUIRE", "never")
if networkSubcommand(args) {
env = batchSSHEnv(ctx, dir, env, args)
}
}
cmd.Env = env
output, err := cmd.Output()
if ctx.Err() != nil {
return "", ctx.Err()
}
if err != nil {
if failure, ok := errors.AsType[*exec.ExitError](err); ok {
return "", &CommandError{ExitCode: failure.ExitCode()}
}
return "", errors.New("Git operation failed; check repository, remote, and credentials")
}
return strings.TrimSpace(string(output)), nil
}
+86
View File
@@ -0,0 +1,86 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"context"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/kballard/go-shellquote"
"github.com/stretchr/testify/require"
)
func TestNoInputPreservesPerOperationSSHCommand(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("fixture uses a POSIX shell executable")
}
directory := t.TempDir()
log := filepath.Join(directory, "ssh-arguments")
ssh := filepath.Join(directory, "ssh with spaces")
require.NoError(t, os.WriteFile(ssh, []byte("#!/bin/sh\nprintf '%s\\n' \"$@\" > "+shellquote.Join(log)+"\nexit 1\n"), 0o700))
env := setEnv(os.Environ(), "GIT_SSH_COMMAND", "")
env = setEnv(env, "GIT_SSH_VARIANT", "ssh")
command := shellquote.Join(ssh, "-i", "/keys/key with spaces")
_, err := (System{Env: env}).Output(context.Background(), directory, false, "-c", "core.sshCommand="+command, "ls-remote", "ssh://git@example.invalid/project.git")
require.Error(t, err)
data, err := os.ReadFile(log)
require.NoError(t, err)
args := strings.Split(strings.TrimSpace(string(data)), "\n")
require.Contains(t, args, "/keys/key with spaces")
require.Contains(t, args, "-oBatchMode=yes")
}
func TestBatchSSHUsesPlinkOption(t *testing.T) {
for _, tc := range []struct{ command, variant, want string }{
{"ssh -i key", "", "ssh -i key -oBatchMode=yes"},
{`"C:/Program Files/PuTTY/plink.exe" -P 2222`, "", `"C:/Program Files/PuTTY/plink.exe" -P 2222 -batch`},
{`C:\PuTTY\TortoisePlink.exe`, "", `C:\PuTTY\TortoisePlink.exe -batch`},
{"/usr/local/bin/wrapper", "plink", "/usr/local/bin/wrapper -batch"},
{"/usr/bin/plink", "ssh", "/usr/bin/plink -oBatchMode=yes"},
} {
env := []string{"GIT_SSH_COMMAND=" + tc.command}
if tc.variant != "" {
env = append(env, "GIT_SSH_VARIANT="+tc.variant)
}
require.Contains(t, batchSSHEnv(context.Background(), "", env, []string{"fetch"}), "GIT_SSH_COMMAND="+tc.want, tc.command)
}
}
func TestNetworkSubcommand(t *testing.T) {
for args, want := range map[string]bool{
"push -- origin main": true, "-c core.sshCommand=ssh fetch origin": true, "-C /work ls-remote origin": true, "clone url": true,
"rev-parse HEAD": false, "-c credential.helper=tea config --get core.sshCommand": false, "remote get-url origin": false, "": false,
} {
require.Equal(t, want, networkSubcommand(strings.Fields(args)), args)
}
}
func TestNoInputKeepsGitSSHProgram(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("fixture uses a POSIX shell executable")
}
directory := t.TempDir()
log := filepath.Join(directory, "ssh-arguments")
ssh := filepath.Join(directory, "plink")
require.NoError(t, os.WriteFile(ssh, []byte("#!/bin/sh\nprintf '%s\\n' \"$@\" > "+shellquote.Join(log)+"\nexit 1\n"), 0o700))
env := []string{}
for _, entry := range os.Environ() {
if !strings.HasPrefix(entry, "GIT_SSH_COMMAND=") {
env = append(env, entry)
}
}
env = setEnv(env, "GIT_CONFIG_GLOBAL", filepath.Join(directory, "gitconfig"))
env = setEnv(env, "GIT_CONFIG_NOSYSTEM", "1")
env = setEnv(env, "GIT_SSH_VARIANT", "ssh")
env = setEnv(env, "GIT_SSH", ssh)
_, err := (System{Env: env}).Output(context.Background(), directory, false, "ls-remote", "ssh://git@example.invalid/project.git")
require.Error(t, err)
data, err := os.ReadFile(log)
require.NoError(t, err)
require.Contains(t, string(data), "example.invalid")
}
+24
View File
@@ -0,0 +1,24 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"errors"
"strings"
"github.com/kballard/go-shellquote"
)
// SSHArguments selects a private key for one Git operation. Agent selection
// retains the user's SSH configuration, as does an account without a key.
func SSHArguments(key string, agent bool) ([]string, error) {
if strings.ContainsAny(key, "\x00\r\n") {
return nil, errors.New("SSH key path cannot contain NUL or line breaks")
}
if agent || key == "" {
return nil, nil
}
command := shellquote.Join("ssh", "-i", key, "-oIdentitiesOnly=yes", "-oIdentityAgent=none")
return []string{"-c", "core.sshCommand=" + command}, nil
}
+94
View File
@@ -0,0 +1,94 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"context"
"errors"
"fmt"
)
// SyncOptions selects one existing local branch and its source remote branch.
type SyncOptions struct {
Directory, Remote, Branch string
CredentialArgs []string
Prompt bool
}
// SyncResult reports a completed local fast-forward.
type SyncResult struct {
Branch string `json:"branch"`
Commit string `json:"commit"`
Remote string `json:"remote"`
}
// SyncBranch fetches and fast-forwards an existing branch without resetting commits.
func SyncBranch(ctx context.Context, runner Runner, opts SyncOptions) (SyncResult, error) {
run := func(args ...string) (string, error) { return runner.Output(ctx, opts.Directory, opts.Prompt, args...) }
if opts.Remote == "" || opts.Branch == "" {
return SyncResult{}, errors.New("sync requires a remote and branch")
}
if _, err := run("check-ref-format", "--branch", opts.Branch); err != nil {
return SyncResult{}, err
}
if err := requireWorkTree(run, "sync"); err != nil {
return SyncResult{}, err
}
status, err := run("status", "--porcelain=v1", "--untracked-files=normal")
if err != nil {
return SyncResult{}, err
}
if status != "" {
return SyncResult{}, errors.New("working tree has changes; commit or stash them before syncing")
}
localRef := "refs/heads/" + opts.Branch + "^{commit}"
previous, err := run("rev-parse", "--verify", localRef)
if err != nil {
return SyncResult{}, fmt.Errorf("local branch is unavailable; create it before syncing: %w", err)
}
fetch := append([]string{}, opts.CredentialArgs...)
fetch = append(fetch, "fetch", "--no-tags", "--", opts.Remote, "refs/heads/"+opts.Branch)
if _, err := run(fetch...); err != nil {
return SyncResult{}, err
}
commit, err := run("rev-parse", "--verify", "FETCH_HEAD^{commit}")
if err != nil {
return SyncResult{}, err
}
current, err := run("rev-parse", "--verify", localRef)
if err != nil {
return SyncResult{}, err
}
if current != previous {
return SyncResult{}, errors.New("local branch changed while fetching; retry sync")
}
if previous != commit {
if _, err := run("merge-base", "--is-ancestor", previous, commit); err != nil {
if failure, ok := errors.AsType[*CommandError](err); !ok || failure.ExitCode != 1 {
return SyncResult{}, err
}
if _, err := run("merge-base", "--is-ancestor", commit, previous); err != nil {
return SyncResult{}, fmt.Errorf("branch cannot be fast-forwarded; resolve divergence before syncing: %w", err)
}
// The local branch already contains the remote history.
commit = previous
}
}
if _, err := run("switch", opts.Branch); err != nil {
return SyncResult{}, err
}
if previous != commit {
if _, err := run("merge", "--ff-only", "--no-edit", commit); err != nil {
return SyncResult{}, fmt.Errorf("branch was selected but sync failed: %w", err)
}
}
actual, err := run("rev-parse", "--verify", "HEAD^{commit}")
if err != nil {
return SyncResult{}, fmt.Errorf("sync finished, but HEAD could not be verified: %w", err)
}
if actual != commit {
return SyncResult{}, errors.New("HEAD changed during sync; inspect the working tree before retrying")
}
return SyncResult{Branch: opts.Branch, Commit: commit, Remote: opts.Remote}, nil
}
+132
View File
@@ -0,0 +1,132 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"context"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestSyncValidatesBeforeChangingBranches(t *testing.T) {
for _, test := range []struct {
dirty, diverged bool
message string
}{
{true, false, "working tree has changes"},
{false, true, "cannot be fast-forwarded"},
} {
runner := &checkoutRunner{dirty: test.dirty, diverged: test.diverged, localCommit: "previous"}
_, err := SyncBranch(context.Background(), runner, SyncOptions{Directory: "/work", Remote: "origin", Branch: "main"})
require.ErrorContains(t, err, test.message)
for _, call := range runner.calls {
require.False(t, strings.HasPrefix(call, "switch "))
require.False(t, strings.HasPrefix(call, "merge "))
if test.dirty {
require.False(t, strings.HasPrefix(call, "fetch "))
}
}
}
runner := &checkoutRunner{localCommit: "previous"}
result, err := SyncBranch(context.Background(), runner, SyncOptions{Directory: "/work", Remote: "origin", Branch: "main", CredentialArgs: []string{"-c", "credential.helper=tea"}})
require.NoError(t, err)
require.Equal(t, SyncResult{Branch: "main", Commit: "abc123", Remote: "origin"}, result)
require.Contains(t, runner.calls, "-c credential.helper=tea fetch --no-tags -- origin refs/heads/main")
require.Equal(t, []string{"switch main", "merge --ff-only --no-edit abc123", "rev-parse --verify HEAD^{commit}"}, runner.calls[len(runner.calls)-3:])
}
type syncFailureRunner struct {
checkoutRunner
missing, changed bool
reads int
}
func (r *syncFailureRunner) Output(ctx context.Context, dir string, prompt bool, args ...string) (string, error) {
if len(args) == 3 && args[0] == "rev-parse" && strings.HasPrefix(args[2], "refs/heads/") {
r.reads++
if r.missing {
return "", &CommandError{ExitCode: 1}
}
if r.changed && r.reads == 2 {
return "other-commit", nil
}
}
return r.checkoutRunner.Output(ctx, dir, prompt, args...)
}
func TestSyncRejectsMissingOrChangedBranch(t *testing.T) {
for _, test := range []struct {
missing, changed bool
message string
}{
{true, false, "local branch is unavailable"},
{false, true, "local branch changed while fetching"},
} {
runner := &syncFailureRunner{missing: test.missing, changed: test.changed}
_, err := SyncBranch(context.Background(), runner, SyncOptions{Directory: "/work", Remote: "origin", Branch: "main"})
require.ErrorContains(t, err, test.message)
for _, call := range runner.calls {
require.False(t, strings.HasPrefix(call, "switch "))
require.False(t, strings.HasPrefix(call, "merge "))
if test.missing {
require.False(t, strings.HasPrefix(call, "fetch "))
}
}
}
}
type aheadSyncRunner struct{ checkoutRunner }
func (r *aheadSyncRunner) Output(ctx context.Context, dir string, prompt bool, args ...string) (string, error) {
if len(args) == 3 && args[2] == "HEAD^{commit}" {
r.calls = append(r.calls, strings.Join(args, " "))
return r.localCommit, nil
}
if len(args) == 4 && args[0] == "merge-base" && args[2] == "previous" {
r.calls = append(r.calls, strings.Join(args, " "))
return "", &CommandError{ExitCode: 1}
}
return r.checkoutRunner.Output(ctx, dir, prompt, args...)
}
func TestSyncPreservesBranchAheadOfRemote(t *testing.T) {
runner := &aheadSyncRunner{localCommit: "previous"}
result, err := SyncBranch(context.Background(), runner, SyncOptions{Directory: "/work", Remote: "origin", Branch: "main"})
require.NoError(t, err)
require.Equal(t, "previous", result.Commit)
require.Contains(t, runner.calls, "merge-base --is-ancestor abc123 previous")
require.Equal(t, "switch main", runner.calls[len(runner.calls)-2])
for _, call := range runner.calls {
require.False(t, strings.HasPrefix(call, "merge "))
}
}
func TestSyncEqualHistoryDoesNotMerge(t *testing.T) {
runner := &checkoutRunner{}
result, err := SyncBranch(context.Background(), runner, SyncOptions{Directory: "/work", Remote: "origin", Branch: "main"})
require.NoError(t, err)
require.Equal(t, "abc123", result.Commit)
for _, call := range runner.calls {
require.False(t, strings.HasPrefix(call, "merge-base "))
require.False(t, strings.HasPrefix(call, "merge "))
}
}
type changedHeadRunner struct{ checkoutRunner }
func (r *changedHeadRunner) Output(ctx context.Context, dir string, prompt bool, args ...string) (string, error) {
if len(args) == 3 && args[2] == "HEAD^{commit}" {
return "unexpected", nil
}
return r.checkoutRunner.Output(ctx, dir, prompt, args...)
}
func TestSyncRejectsUnexpectedFinalHead(t *testing.T) {
runner := &changedHeadRunner{}
result, err := SyncBranch(context.Background(), runner, SyncOptions{Directory: "/work", Remote: "origin", Branch: "main"})
require.ErrorContains(t, err, "HEAD changed during sync")
require.Equal(t, SyncResult{}, result)
}