mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
- `internal/auth`: credentials in the OS keyring or a private file, OAuth login with token refresh, SSH HTTP-signature signers (key files with passphrase prompt, ssh-agent keys and certificates), and the Git credential helper protocol. - `internal/git`: cancellable Git subprocesses, repository and remote discovery (tracked remote via `branch.<name>.remote`), PR checkout by full ref, AGit push, branch cleanup. Nothing imports these packages yet. Reviewed-on: https://gitea.com/gitea/tea/pulls/1123 Reviewed-by: TheFox0x7 <95654+thefox0x7@noreply.gitea.com>
163 lines
4.7 KiB
Go
163 lines
4.7 KiB
Go
// 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
|
|
}
|