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() }