mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
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:
1 parent
2b218a5bae
commit
88b91faa54
28 files changed
+3044
-3
No files matched your search
@@ -11,8 +11,10 @@ require (
|
||||
code.gitea.io/gitea-vet v0.2.3
|
||||
gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c
|
||||
gitea.dev/sdk v1.3.0
|
||||
github.com/42wim/httpsig v1.2.4
|
||||
github.com/adrg/xdg v0.5.3
|
||||
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/go-signet/sdk-go v1.1.0
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
|
||||
@@ -22,6 +24,7 @@ require (
|
||||
github.com/stretchr/testify v1.12.1
|
||||
github.com/urfave/cli-docs/v3 v3.1.0
|
||||
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/oauth2 v0.36.0
|
||||
golang.org/x/sys v0.48.0
|
||||
@@ -32,7 +35,6 @@ require (
|
||||
require (
|
||||
charm.land/bubbles/v2 v2.1.0 // 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/atotto/clipboard v0.1.4 // 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/cpuguy83/go-md2man/v2 v2.0.7 // 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/dustin/go-humanize v1.0.1 // 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/yuin/goldmark v1.7.8 // 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
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
|
||||
golang.org/x/net v0.58.0 // indirect
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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/")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 "))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user