From 88b91faa546b69f651ea8eec5f935bc74ddf9097 Mon Sep 17 00:00:00 2001 From: bircni Date: Sun, 11 Oct 2026 17:09:37 +0000 Subject: [PATCH] refactor: add credential and Git packages (#1123) - `internal/auth`: credentials in the OS keyring or a private file, OAuth login with token refresh, SSH HTTP-signature signers (key files with passphrase prompt, ssh-agent keys and certificates), and the Git credential helper protocol. - `internal/git`: cancellable Git subprocesses, repository and remote discovery (tracked remote via `branch..remote`), PR checkout by full ref, AGit push, branch cleanup. Nothing imports these packages yet. Reviewed-on: https://gitea.com/gitea/tea/pulls/1123 Reviewed-by: TheFox0x7 <95654+thefox0x7@noreply.gitea.com> --- go.mod | 6 +- internal/auth/credentials.go | 152 +++++++++++++++++ internal/auth/gitcredential.go | 172 +++++++++++++++++++ internal/auth/gitcredential_test.go | 54 ++++++ internal/auth/httpsign.go | 205 +++++++++++++++++++++++ internal/auth/httpsign_agent.go | 30 ++++ internal/auth/httpsign_agent_test.go | 53 ++++++ internal/auth/httpsign_agent_windows.go | 21 +++ internal/auth/httpsign_test.go | 123 ++++++++++++++ internal/auth/manager.go | 110 +++++++++++++ internal/auth/manager_test.go | 179 ++++++++++++++++++++ internal/auth/oauth.go | 210 ++++++++++++++++++++++++ internal/auth/oauth_test.go | 138 ++++++++++++++++ internal/git/agit.go | 68 ++++++++ internal/git/agit_test.go | 66 ++++++++ internal/git/checkout.go | 156 ++++++++++++++++++ internal/git/checkout_test.go | 172 +++++++++++++++++++ internal/git/clean.go | 208 +++++++++++++++++++++++ internal/git/clean_test.go | 139 ++++++++++++++++ internal/git/credentials.go | 108 ++++++++++++ internal/git/credentials_test.go | 64 ++++++++ internal/git/discover.go | 63 +++++++ internal/git/discover_test.go | 52 ++++++ internal/git/runner.go | 162 ++++++++++++++++++ internal/git/runner_test.go | 86 ++++++++++ internal/git/ssh.go | 24 +++ internal/git/sync.go | 94 +++++++++++ internal/git/sync_test.go | 132 +++++++++++++++ 28 files changed, 3044 insertions(+), 3 deletions(-) create mode 100644 internal/auth/credentials.go create mode 100644 internal/auth/gitcredential.go create mode 100644 internal/auth/gitcredential_test.go create mode 100644 internal/auth/httpsign.go create mode 100644 internal/auth/httpsign_agent.go create mode 100644 internal/auth/httpsign_agent_test.go create mode 100644 internal/auth/httpsign_agent_windows.go create mode 100644 internal/auth/httpsign_test.go create mode 100644 internal/auth/manager.go create mode 100644 internal/auth/manager_test.go create mode 100644 internal/auth/oauth.go create mode 100644 internal/auth/oauth_test.go create mode 100644 internal/git/agit.go create mode 100644 internal/git/agit_test.go create mode 100644 internal/git/checkout.go create mode 100644 internal/git/checkout_test.go create mode 100644 internal/git/clean.go create mode 100644 internal/git/clean_test.go create mode 100644 internal/git/credentials.go create mode 100644 internal/git/credentials_test.go create mode 100644 internal/git/discover.go create mode 100644 internal/git/discover_test.go create mode 100644 internal/git/runner.go create mode 100644 internal/git/runner_test.go create mode 100644 internal/git/ssh.go create mode 100644 internal/git/sync.go create mode 100644 internal/git/sync_test.go 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) +}