diff --git a/go.mod b/go.mod
index fab4fe0d..f8cfe88b 100644
--- a/go.mod
+++ b/go.mod
@@ -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
diff --git a/internal/auth/credentials.go b/internal/auth/credentials.go
new file mode 100644
index 00000000..e87e7957
--- /dev/null
+++ b/internal/auth/credentials.go
@@ -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)
+ }
+}
diff --git a/internal/auth/gitcredential.go b/internal/auth/gitcredential.go
new file mode 100644
index 00000000..a9914c80
--- /dev/null
+++ b/internal/auth/gitcredential.go
@@ -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)
+}
diff --git a/internal/auth/gitcredential_test.go b/internal/auth/gitcredential_test.go
new file mode 100644
index 00000000..0a96c00c
--- /dev/null
+++ b/internal/auth/gitcredential_test.go
@@ -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")
+ }
+}
diff --git a/internal/auth/httpsign.go b/internal/auth/httpsign.go
new file mode 100644
index 00000000..95f897cb
--- /dev/null
+++ b/internal/auth/httpsign.go
@@ -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
+}
diff --git a/internal/auth/httpsign_agent.go b/internal/auth/httpsign_agent.go
new file mode 100644
index 00000000..8a0d3d6b
--- /dev/null
+++ b/internal/auth/httpsign_agent.go
@@ -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
+}
diff --git a/internal/auth/httpsign_agent_test.go b/internal/auth/httpsign_agent_test.go
new file mode 100644
index 00000000..263c9811
--- /dev/null
+++ b/internal/auth/httpsign_agent_test.go
@@ -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")
+ }
+}
diff --git a/internal/auth/httpsign_agent_windows.go b/internal/auth/httpsign_agent_windows.go
new file mode 100644
index 00000000..a4b3f423
--- /dev/null
+++ b/internal/auth/httpsign_agent_windows.go
@@ -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
+}
diff --git a/internal/auth/httpsign_test.go b/internal/auth/httpsign_test.go
new file mode 100644
index 00000000..100a1057
--- /dev/null
+++ b/internal/auth/httpsign_test.go
@@ -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)
+ }
+}
diff --git a/internal/auth/manager.go b/internal/auth/manager.go
new file mode 100644
index 00000000..037851b2
--- /dev/null
+++ b/internal/auth/manager.go
@@ -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
+ })
+}
diff --git a/internal/auth/manager_test.go b/internal/auth/manager_test.go
new file mode 100644
index 00000000..41712adb
--- /dev/null
+++ b/internal/auth/manager_test.go
@@ -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"))
+}
diff --git a/internal/auth/oauth.go b/internal/auth/oauth.go
new file mode 100644
index 00000000..f3ff6315
--- /dev/null
+++ b/internal/auth/oauth.go
@@ -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
+}
diff --git a/internal/auth/oauth_test.go b/internal/auth/oauth_test.go
new file mode 100644
index 00000000..91a51ac8
--- /dev/null
+++ b/internal/auth/oauth_test.go
@@ -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)
+}
diff --git a/internal/git/agit.go b/internal/git/agit.go
new file mode 100644
index 00000000..306b06b7
--- /dev/null
+++ b/internal/git/agit.go
@@ -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// 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
+}
diff --git a/internal/git/agit_test.go b/internal/git/agit_test.go
new file mode 100644
index 00000000..833498c6
--- /dev/null
+++ b/internal/git/agit_test.go
@@ -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)
+}
diff --git a/internal/git/checkout.go b/internal/git/checkout.go
new file mode 100644
index 00000000..cd5e2f16
--- /dev/null
+++ b/internal/git/checkout.go
@@ -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
+}
diff --git a/internal/git/checkout_test.go b/internal/git/checkout_test.go
new file mode 100644
index 00000000..5920e2c6
--- /dev/null
+++ b/internal/git/checkout_test.go
@@ -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/")
+ }
+}
diff --git a/internal/git/clean.go b/internal/git/clean.go
new file mode 100644
index 00000000..d78c74a1
--- /dev/null
+++ b/internal/git/clean.go
@@ -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
+}
diff --git a/internal/git/clean_test.go b/internal/git/clean_test.go
new file mode 100644
index 00000000..79f200e2
--- /dev/null
+++ b/internal/git/clean_test.go
@@ -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 "))
+ }
+}
diff --git a/internal/git/credentials.go b/internal/git/credentials.go
new file mode 100644
index 00000000..a3faaab9
--- /dev/null
+++ b/internal/git/credentials.go
@@ -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
+}
diff --git a/internal/git/credentials_test.go b/internal/git/credentials_test.go
new file mode 100644
index 00000000..1973fc91
--- /dev/null
+++ b/internal/git/credentials_test.go
@@ -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)
+}
diff --git a/internal/git/discover.go b/internal/git/discover.go
new file mode 100644
index 00000000..5676b125
--- /dev/null
+++ b/internal/git/discover.go
@@ -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// 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
+}
diff --git a/internal/git/discover_test.go b/internal/git/discover_test.go
new file mode 100644
index 00000000..7d6c02e8
--- /dev/null
+++ b/internal/git/discover_test.go
@@ -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)
+}
diff --git a/internal/git/runner.go b/internal/git/runner.go
new file mode 100644
index 00000000..ac05c6f9
--- /dev/null
+++ b/internal/git/runner.go
@@ -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
+}
diff --git a/internal/git/runner_test.go b/internal/git/runner_test.go
new file mode 100644
index 00000000..60a0069f
--- /dev/null
+++ b/internal/git/runner_test.go
@@ -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")
+}
diff --git a/internal/git/ssh.go b/internal/git/ssh.go
new file mode 100644
index 00000000..1bf7feb9
--- /dev/null
+++ b/internal/git/ssh.go
@@ -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
+}
diff --git a/internal/git/sync.go b/internal/git/sync.go
new file mode 100644
index 00000000..0cf0f92c
--- /dev/null
+++ b/internal/git/sync.go
@@ -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
+}
diff --git a/internal/git/sync_test.go b/internal/git/sync_test.go
new file mode 100644
index 00000000..674e8521
--- /dev/null
+++ b/internal/git/sync_test.go
@@ -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)
+}