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

Nothing imports these packages yet.

Reviewed-on: https://gitea.com/gitea/tea/pulls/1123
Reviewed-by: TheFox0x7 <95654+thefox0x7@noreply.gitea.com>
2026-10-11 17:09:37 +00:00

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
}