Files
simplegit/gitcmd/command.go
T
2026-07-15 11:06:48 -04:00

192 lines
3.9 KiB
Go

package gitcmd
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"time"
)
type Command struct {
args []string
dir string
env []string
timeout time.Duration
stdin io.Reader
preErrs []error
}
func NewCommand(args ...string) *Command {
c := &Command{}
for _, arg := range args {
if !isTrustedArg(arg) {
c.preErrs = append(c.preErrs, fmt.Errorf("untrusted argument in NewCommand: %q", arg))
}
}
c.args = append(c.args, args...)
return c
}
func isTrustedArg(arg string) bool {
if arg == "" {
return true
}
for _, r := range arg {
switch r {
case ';', '`', '$', '|', '&', '\n', '\r':
return false
}
}
return true
}
func isSafeValue(arg string) bool {
return !strings.HasPrefix(arg, "-")
}
func (c *Command) Add(args ...string) *Command {
for _, arg := range args {
if !isTrustedArg(arg) {
c.preErrs = append(c.preErrs, fmt.Errorf("untrusted argument in Add: %q", arg))
}
}
if len(c.preErrs) == 0 {
c.args = append(c.args, args...)
}
return c
}
func (c *Command) AddOptionValues(opt string, values ...string) *Command {
if !isTrustedArg(opt) || !strings.HasPrefix(opt, "-") {
c.preErrs = append(c.preErrs, fmt.Errorf("invalid option in AddOptionValues: %q", opt))
return c
}
c.args = append(c.args, opt)
c.AddDynamicArguments(values...)
return c
}
func (c *Command) AddDynamicArguments(args ...string) *Command {
for _, arg := range args {
if !isSafeValue(arg) {
c.preErrs = append(c.preErrs, fmt.Errorf("user argument cannot start with '-': %q", arg))
}
}
if len(c.preErrs) == 0 {
c.args = append(c.args, args...)
}
return c
}
func (c *Command) AddDashesAndList(list ...string) *Command {
c.args = append(c.args, "--")
c.args = append(c.args, list...)
return c
}
func (c *Command) WithDir(dir string) *Command {
c.dir = dir
return c
}
func (c *Command) WithEnv(env ...string) *Command {
c.env = append(c.env, env...)
return c
}
func (c *Command) WithTimeout(d time.Duration) *Command {
c.timeout = d
return c
}
func (c *Command) WithStdin(r io.Reader) *Command {
c.stdin = r
return c
}
func (c *Command) CmdString() string {
exe, err := Executable()
if err != nil {
exe = "git"
}
parts := append([]string{exe}, c.args...)
for i, p := range parts {
if strings.ContainsAny(p, " `'\"\t\n") {
parts[i] = fmt.Sprintf("%q", p)
}
}
return strings.Join(parts, " ")
}
func (c *Command) Run(ctx context.Context) error {
_, _, err := c.RunStdString(ctx)
return err
}
func (c *Command) RunStdString(ctx context.Context) (stdout, stderr string, err error) {
outb, errb, err := c.RunStdBytes(ctx)
return string(outb), string(errb), err
}
func (c *Command) RunStdBytes(ctx context.Context) (stdout, stderr []byte, err error) {
if len(c.preErrs) > 0 {
return nil, nil, errors.Join(c.preErrs...)
}
exe, err := Executable()
if err != nil {
return nil, nil, fmt.Errorf("git executable not found: %w", err)
}
var cancel context.CancelFunc
if c.timeout > 0 {
ctx, cancel = context.WithTimeout(ctx, c.timeout)
defer cancel()
}
cmd := exec.CommandContext(ctx, exe, c.args...)
cmd.Dir = c.dir
cmd.Env = append(os.Environ(), CommonEnvs()...)
cmd.Env = append(cmd.Env, c.env...)
cmd.Stdin = c.stdin
var stdoutBuf, stderrBuf bytes.Buffer
cmd.Stdout = &stdoutBuf
cmd.Stderr = &stderrBuf
err = cmd.Run()
return stdoutBuf.Bytes(), stderrBuf.Bytes(), err
}
func (c *Command) RunPiped(ctx context.Context, stdout, stderr io.Writer) error {
if len(c.preErrs) > 0 {
return errors.Join(c.preErrs...)
}
exe, err := Executable()
if err != nil {
return fmt.Errorf("git executable not found: %w", err)
}
var cancel context.CancelFunc
if c.timeout > 0 {
ctx, cancel = context.WithTimeout(ctx, c.timeout)
defer cancel()
}
cmd := exec.CommandContext(ctx, exe, c.args...)
cmd.Dir = c.dir
cmd.Env = append(os.Environ(), CommonEnvs()...)
cmd.Env = append(cmd.Env, c.env...)
cmd.Stdin = c.stdin
cmd.Stdout = stdout
cmd.Stderr = stderr
return cmd.Run()
}