mirror of
https://github.com/go-gitea/gitea.git
synced 2025-10-13 12:16:09 +00:00
Move some functions to gitrepo package (#35543)
Refactor Git command functions to use WithXXX methods instead of exposing RunOpts. This change simplifies reuse across gitrepo and improves consistency, encapsulation, and maintainability of command options. --------- Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
@@ -46,6 +46,7 @@ type Command struct {
|
||||
brokenArgs []string
|
||||
cmd *exec.Cmd // for debug purpose only
|
||||
configArgs []string
|
||||
opts runOpts
|
||||
}
|
||||
|
||||
func logArgSanitize(arg string) string {
|
||||
@@ -194,8 +195,8 @@ func ToTrustedCmdArgs(args []string) TrustedCmdArgs {
|
||||
return ret
|
||||
}
|
||||
|
||||
// RunOpts represents parameters to run the command. If UseContextTimeout is specified, then Timeout is ignored.
|
||||
type RunOpts struct {
|
||||
// runOpts represents parameters to run the command. If UseContextTimeout is specified, then Timeout is ignored.
|
||||
type runOpts struct {
|
||||
Env []string
|
||||
Timeout time.Duration
|
||||
UseContextTimeout bool
|
||||
@@ -221,6 +222,8 @@ type RunOpts struct {
|
||||
Stdin io.Reader
|
||||
|
||||
PipelineFunc func(context.Context, context.CancelFunc) error
|
||||
|
||||
callerInfo string
|
||||
}
|
||||
|
||||
func commonBaseEnvs() []string {
|
||||
@@ -263,44 +266,99 @@ func CommonCmdServEnvs() []string {
|
||||
|
||||
var ErrBrokenCommand = errors.New("git command is broken")
|
||||
|
||||
// Run runs the command with the RunOpts
|
||||
func (c *Command) Run(ctx context.Context, opts *RunOpts) error {
|
||||
return c.run(ctx, 1, opts)
|
||||
func (c *Command) WithDir(dir string) *Command {
|
||||
c.opts.Dir = dir
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Command) run(ctx context.Context, skip int, opts *RunOpts) error {
|
||||
func (c *Command) WithEnv(env []string) *Command {
|
||||
c.opts.Env = env
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Command) WithTimeout(timeout time.Duration) *Command {
|
||||
c.opts.Timeout = timeout
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Command) WithStdout(stdout io.Writer) *Command {
|
||||
c.opts.Stdout = stdout
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Command) WithStderr(stderr io.Writer) *Command {
|
||||
c.opts.Stderr = stderr
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Command) WithStdin(stdin io.Reader) *Command {
|
||||
c.opts.Stdin = stdin
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Command) WithPipelineFunc(f func(context.Context, context.CancelFunc) error) *Command {
|
||||
c.opts.PipelineFunc = f
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Command) WithUseContextTimeout(useContextTimeout bool) *Command {
|
||||
c.opts.UseContextTimeout = useContextTimeout
|
||||
return c
|
||||
}
|
||||
|
||||
// WithParentCallerInfo can be used to set the caller info (usually function name) of the parent function of the caller.
|
||||
// For most cases, "Run" family functions can get its caller info automatically
|
||||
// But if you need to call "Run" family functions in a wrapper function: "FeatureFunc -> GeneralWrapperFunc -> RunXxx",
|
||||
// then you can to call this function in GeneralWrapperFunc to set the caller info of FeatureFunc.
|
||||
// The caller info can only be set once.
|
||||
func (c *Command) WithParentCallerInfo(optInfo ...string) *Command {
|
||||
if c.opts.callerInfo != "" {
|
||||
return c
|
||||
}
|
||||
if len(optInfo) > 0 {
|
||||
c.opts.callerInfo = optInfo[0]
|
||||
return c
|
||||
}
|
||||
skip := 1 /*parent "wrap/run" functions*/ + 1 /*this function*/
|
||||
callerFuncName := util.CallerFuncName(skip)
|
||||
callerInfo := callerFuncName
|
||||
if pos := strings.LastIndex(callerInfo, "/"); pos >= 0 {
|
||||
callerInfo = callerInfo[pos+1:]
|
||||
}
|
||||
c.opts.callerInfo = callerInfo
|
||||
return c
|
||||
}
|
||||
|
||||
// Run runs the command
|
||||
func (c *Command) Run(ctx context.Context) error {
|
||||
if len(c.brokenArgs) != 0 {
|
||||
log.Error("git command is broken: %s, broken args: %s", c.LogString(), strings.Join(c.brokenArgs, " "))
|
||||
return ErrBrokenCommand
|
||||
}
|
||||
if opts == nil {
|
||||
opts = &RunOpts{}
|
||||
}
|
||||
|
||||
// We must not change the provided options
|
||||
timeout := opts.Timeout
|
||||
timeout := c.opts.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultCommandExecutionTimeout
|
||||
}
|
||||
|
||||
cmdLogString := c.LogString()
|
||||
callerInfo := util.CallerFuncName(1 /* util */ + 1 /* this */ + skip /* parent */)
|
||||
if pos := strings.LastIndex(callerInfo, "/"); pos >= 0 {
|
||||
callerInfo = callerInfo[pos+1:]
|
||||
if c.opts.callerInfo == "" {
|
||||
c.WithParentCallerInfo()
|
||||
}
|
||||
// these logs are for debugging purposes only, so no guarantee of correctness or stability
|
||||
desc := fmt.Sprintf("git.Run(by:%s, repo:%s): %s", callerInfo, logArgSanitize(opts.Dir), cmdLogString)
|
||||
desc := fmt.Sprintf("git.Run(by:%s, repo:%s): %s", c.opts.callerInfo, logArgSanitize(c.opts.Dir), cmdLogString)
|
||||
log.Debug("git.Command: %s", desc)
|
||||
|
||||
_, span := gtprof.GetTracer().Start(ctx, gtprof.TraceSpanGitRun)
|
||||
defer span.End()
|
||||
span.SetAttributeString(gtprof.TraceAttrFuncCaller, callerInfo)
|
||||
span.SetAttributeString(gtprof.TraceAttrFuncCaller, c.opts.callerInfo)
|
||||
span.SetAttributeString(gtprof.TraceAttrGitCommand, cmdLogString)
|
||||
|
||||
var cancel context.CancelFunc
|
||||
var finished context.CancelFunc
|
||||
|
||||
if opts.UseContextTimeout {
|
||||
if c.opts.UseContextTimeout {
|
||||
ctx, cancel, finished = process.GetManager().AddContext(ctx, desc)
|
||||
} else {
|
||||
ctx, cancel, finished = process.GetManager().AddContextTimeout(ctx, timeout, desc)
|
||||
@@ -311,24 +369,24 @@ func (c *Command) run(ctx context.Context, skip int, opts *RunOpts) error {
|
||||
|
||||
cmd := exec.CommandContext(ctx, c.prog, append(c.configArgs, c.args...)...)
|
||||
c.cmd = cmd // for debug purpose only
|
||||
if opts.Env == nil {
|
||||
if c.opts.Env == nil {
|
||||
cmd.Env = os.Environ()
|
||||
} else {
|
||||
cmd.Env = opts.Env
|
||||
cmd.Env = c.opts.Env
|
||||
}
|
||||
|
||||
process.SetSysProcAttribute(cmd)
|
||||
cmd.Env = append(cmd.Env, CommonGitCmdEnvs()...)
|
||||
cmd.Dir = opts.Dir
|
||||
cmd.Stdout = opts.Stdout
|
||||
cmd.Stderr = opts.Stderr
|
||||
cmd.Stdin = opts.Stdin
|
||||
cmd.Dir = c.opts.Dir
|
||||
cmd.Stdout = c.opts.Stdout
|
||||
cmd.Stderr = c.opts.Stderr
|
||||
cmd.Stdin = c.opts.Stdin
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if opts.PipelineFunc != nil {
|
||||
err := opts.PipelineFunc(ctx, cancel)
|
||||
if c.opts.PipelineFunc != nil {
|
||||
err := c.opts.PipelineFunc(ctx, cancel)
|
||||
if err != nil {
|
||||
cancel()
|
||||
_ = cmd.Wait()
|
||||
@@ -374,7 +432,8 @@ type runStdError struct {
|
||||
}
|
||||
|
||||
func (r *runStdError) Error() string {
|
||||
// the stderr must be in the returned error text, some code only checks `strings.Contains(err.Error(), "git error")`
|
||||
// FIXME: GIT-CMD-STDERR: it is a bad design, the stderr should not be put in the error message
|
||||
// But a lof of code only checks `strings.Contains(err.Error(), "git error")`
|
||||
if r.errMsg == "" {
|
||||
r.errMsg = ConcatenateError(r.err, r.stderr).Error()
|
||||
}
|
||||
@@ -397,51 +456,33 @@ func IsErrorExitCode(err error, code int) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// RunStdString runs the command with options and returns stdout/stderr as string. and store stderr to returned error (err combined with stderr).
|
||||
func (c *Command) RunStdString(ctx context.Context, opts *RunOpts) (stdout, stderr string, runErr RunStdError) {
|
||||
stdoutBytes, stderrBytes, err := c.runStdBytes(ctx, opts)
|
||||
stdout = util.UnsafeBytesToString(stdoutBytes)
|
||||
stderr = util.UnsafeBytesToString(stderrBytes)
|
||||
if err != nil {
|
||||
return stdout, stderr, &runStdError{err: err, stderr: stderr}
|
||||
}
|
||||
// even if there is no err, there could still be some stderr output, so we just return stdout/stderr as they are
|
||||
return stdout, stderr, nil
|
||||
// RunStdString runs the command and returns stdout/stderr as string. and store stderr to returned error (err combined with stderr).
|
||||
func (c *Command) RunStdString(ctx context.Context) (stdout, stderr string, runErr RunStdError) {
|
||||
stdoutBytes, stderrBytes, runErr := c.WithParentCallerInfo().runStdBytes(ctx)
|
||||
return util.UnsafeBytesToString(stdoutBytes), util.UnsafeBytesToString(stderrBytes), runErr
|
||||
}
|
||||
|
||||
// RunStdBytes runs the command with options and returns stdout/stderr as bytes. and store stderr to returned error (err combined with stderr).
|
||||
func (c *Command) RunStdBytes(ctx context.Context, opts *RunOpts) (stdout, stderr []byte, runErr RunStdError) {
|
||||
return c.runStdBytes(ctx, opts)
|
||||
// RunStdBytes runs the command and returns stdout/stderr as bytes. and store stderr to returned error (err combined with stderr).
|
||||
func (c *Command) RunStdBytes(ctx context.Context) (stdout, stderr []byte, runErr RunStdError) {
|
||||
return c.WithParentCallerInfo().runStdBytes(ctx)
|
||||
}
|
||||
|
||||
func (c *Command) runStdBytes(ctx context.Context, opts *RunOpts) (stdout, stderr []byte, runErr RunStdError) {
|
||||
if opts == nil {
|
||||
opts = &RunOpts{}
|
||||
}
|
||||
if opts.Stdout != nil || opts.Stderr != nil {
|
||||
func (c *Command) runStdBytes(ctx context.Context) ( /*stdout*/ []byte /*stderr*/, []byte /*runErr*/, RunStdError) {
|
||||
if c.opts.Stdout != nil || c.opts.Stderr != nil {
|
||||
// we must panic here, otherwise there would be bugs if developers set Stdin/Stderr by mistake, and it would be very difficult to debug
|
||||
panic("stdout and stderr field must be nil when using RunStdBytes")
|
||||
}
|
||||
stdoutBuf := &bytes.Buffer{}
|
||||
stderrBuf := &bytes.Buffer{}
|
||||
|
||||
// We must not change the provided options as it could break future calls - therefore make a copy.
|
||||
newOpts := &RunOpts{
|
||||
Env: opts.Env,
|
||||
Timeout: opts.Timeout,
|
||||
UseContextTimeout: opts.UseContextTimeout,
|
||||
Dir: opts.Dir,
|
||||
Stdout: stdoutBuf,
|
||||
Stderr: stderrBuf,
|
||||
Stdin: opts.Stdin,
|
||||
PipelineFunc: opts.PipelineFunc,
|
||||
}
|
||||
|
||||
err := c.run(ctx, 2, newOpts)
|
||||
stderr = stderrBuf.Bytes()
|
||||
err := c.WithParentCallerInfo().
|
||||
WithStdout(stdoutBuf).
|
||||
WithStderr(stderrBuf).
|
||||
Run(ctx)
|
||||
if err != nil {
|
||||
return nil, stderr, &runStdError{err: err, stderr: util.UnsafeBytesToString(stderr)}
|
||||
// FIXME: GIT-CMD-STDERR: it is a bad design, the stderr should not be put in the error message
|
||||
// But a lot of code depends on it, so we have to keep this behavior
|
||||
return nil, stderrBuf.Bytes(), &runStdError{err: err, stderr: util.UnsafeBytesToString(stderrBuf.Bytes())}
|
||||
}
|
||||
// even if there is no err, there could still be some stderr output
|
||||
return stdoutBuf.Bytes(), stderr, nil
|
||||
return stdoutBuf.Bytes(), stderrBuf.Bytes(), nil
|
||||
}
|
||||
|
@@ -17,7 +17,7 @@ func TestRunWithContextNoTimeout(t *testing.T) {
|
||||
// 'git --version' does not block so it must be finished before the timeout triggered.
|
||||
cmd := NewCommand("--version")
|
||||
for i := 0; i < maxLoops; i++ {
|
||||
if err := cmd.Run(t.Context(), &RunOpts{}); err != nil {
|
||||
if err := cmd.Run(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ func TestRunWithContextTimeout(t *testing.T) {
|
||||
// 'git hash-object --stdin' blocks on stdin so we can have the timeout triggered.
|
||||
cmd := NewCommand("hash-object", "--stdin")
|
||||
for i := 0; i < maxLoops; i++ {
|
||||
if err := cmd.Run(t.Context(), &RunOpts{Timeout: 1 * time.Millisecond}); err != nil {
|
||||
if err := cmd.WithTimeout(1 * time.Millisecond).Run(t.Context()); err != nil {
|
||||
if err != context.DeadlineExceeded {
|
||||
t.Fatalf("Testing %d/%d: %v", i, maxLoops, err)
|
||||
}
|
||||
|
@@ -23,38 +23,60 @@ func TestMain(m *testing.M) {
|
||||
defer cleanup()
|
||||
|
||||
setting.Git.HomePath = gitHomePath
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func TestRunWithContextStd(t *testing.T) {
|
||||
cmd := NewCommand("--version")
|
||||
stdout, stderr, err := cmd.RunStdString(t.Context(), &RunOpts{})
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, stderr)
|
||||
assert.Contains(t, stdout, "git version")
|
||||
|
||||
cmd = NewCommand("--no-such-arg")
|
||||
stdout, stderr, err = cmd.RunStdString(t.Context(), &RunOpts{})
|
||||
if assert.Error(t, err) {
|
||||
assert.Equal(t, stderr, err.Stderr())
|
||||
assert.Contains(t, err.Stderr(), "unknown option:")
|
||||
assert.Contains(t, err.Error(), "exit status 129 - unknown option:")
|
||||
assert.Empty(t, stdout)
|
||||
{
|
||||
cmd := NewCommand("--version")
|
||||
stdout, stderr, err := cmd.RunStdString(t.Context())
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, stderr)
|
||||
assert.Contains(t, stdout, "git version")
|
||||
}
|
||||
|
||||
cmd = NewCommand()
|
||||
cmd.AddDynamicArguments("-test")
|
||||
assert.ErrorIs(t, cmd.Run(t.Context(), &RunOpts{}), ErrBrokenCommand)
|
||||
{
|
||||
cmd := NewCommand("ls-tree", "no-such")
|
||||
stdout, stderr, err := cmd.RunStdString(t.Context())
|
||||
if assert.Error(t, err) {
|
||||
assert.Equal(t, stderr, err.Stderr())
|
||||
assert.Equal(t, "fatal: Not a valid object name no-such\n", err.Stderr())
|
||||
// FIXME: GIT-CMD-STDERR: it is a bad design, the stderr should not be put in the error message
|
||||
assert.Equal(t, "exit status 128 - fatal: Not a valid object name no-such\n", err.Error())
|
||||
assert.Empty(t, stdout)
|
||||
}
|
||||
}
|
||||
|
||||
cmd = NewCommand()
|
||||
cmd.AddDynamicArguments("--test")
|
||||
assert.ErrorIs(t, cmd.Run(t.Context(), &RunOpts{}), ErrBrokenCommand)
|
||||
{
|
||||
cmd := NewCommand("ls-tree", "no-such")
|
||||
stdout, stderr, err := cmd.RunStdBytes(t.Context())
|
||||
if assert.Error(t, err) {
|
||||
assert.Equal(t, string(stderr), err.Stderr())
|
||||
assert.Equal(t, "fatal: Not a valid object name no-such\n", err.Stderr())
|
||||
// FIXME: GIT-CMD-STDERR: it is a bad design, the stderr should not be put in the error message
|
||||
assert.Equal(t, "exit status 128 - fatal: Not a valid object name no-such\n", err.Error())
|
||||
assert.Empty(t, stdout)
|
||||
}
|
||||
}
|
||||
|
||||
subCmd := "version"
|
||||
cmd = NewCommand().AddDynamicArguments(subCmd) // for test purpose only, the sub-command should never be dynamic for production
|
||||
stdout, stderr, err = cmd.RunStdString(t.Context(), &RunOpts{})
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, stderr)
|
||||
assert.Contains(t, stdout, "git version")
|
||||
{
|
||||
cmd := NewCommand()
|
||||
cmd.AddDynamicArguments("-test")
|
||||
assert.ErrorIs(t, cmd.Run(t.Context()), ErrBrokenCommand)
|
||||
|
||||
cmd = NewCommand()
|
||||
cmd.AddDynamicArguments("--test")
|
||||
assert.ErrorIs(t, cmd.Run(t.Context()), ErrBrokenCommand)
|
||||
}
|
||||
|
||||
{
|
||||
subCmd := "version"
|
||||
cmd := NewCommand().AddDynamicArguments(subCmd) // for test purpose only, the sub-command should never be dynamic for production
|
||||
stdout, stderr, err := cmd.RunStdString(t.Context())
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, stderr)
|
||||
assert.Contains(t, stdout, "git version")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitArgument(t *testing.T) {
|
||||
|
Reference in New Issue
Block a user