Skip to content

Commit b877504

Browse files
authored
Refactor git.Command.Run*, introduce RunWithContextString and RunWithContextBytes (#19266)
This follows * #18553 Introduce `RunWithContextString` and `RunWithContextBytes` to help the refactoring. Add related unit tests. They keep the same behavior to save stderr into err.Error() as `RunInXxx` before. Remove `RunInDirTimeoutPipeline` `RunInDirTimeoutFullPipeline` `RunInDirTimeout` `RunInDirTimeoutEnv` `RunInDirPipeline` `RunInDirFullPipeline` `RunTimeout`, `RunInDirTimeoutEnvPipeline`, `RunInDirTimeoutEnvFullPipeline`, `RunInDirTimeoutEnvFullPipelineFunc`. Then remaining `RunInDir` `RunInDirBytes` `RunInDirWithEnv` can be easily refactored in next PR with a simple search & replace: * before: `stdout, err := RunInDir(path)` * next: `stdout, _, err := RunWithContextString(&git.RunContext{Dir:path})` Other changes: 1. When `timeout <= 0`, use default. Because `timeout==0` is meaningless and could cause bugs. And now many functions becomes more simple, eg: `GitGcRepos` 9 lines to 1 line. `Fsck` 6 lines to 1 line. 2. Only set defaultCommandExecutionTimeout when the option `setting.Git.Timeout.Default > 0`
1 parent d4f84f1 commit b877504

File tree

8 files changed

+126
-126
lines changed

8 files changed

+126
-126
lines changed

modules/git/command.go

Lines changed: 58 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"os/exec"
1515
"strings"
1616
"time"
17+
"unsafe"
1718

1819
"code.gitea.io/gitea/modules/log"
1920
"code.gitea.io/gitea/modules/process"
@@ -93,32 +94,6 @@ func (c *Command) AddArguments(args ...string) *Command {
9394
return c
9495
}
9596

96-
// RunInDirTimeoutEnvPipeline executes the command in given directory with given timeout,
97-
// it pipes stdout and stderr to given io.Writer.
98-
func (c *Command) RunInDirTimeoutEnvPipeline(env []string, timeout time.Duration, dir string, stdout, stderr io.Writer) error {
99-
return c.RunInDirTimeoutEnvFullPipeline(env, timeout, dir, stdout, stderr, nil)
100-
}
101-
102-
// RunInDirTimeoutEnvFullPipeline executes the command in given directory with given timeout,
103-
// it pipes stdout and stderr to given io.Writer and passes in an io.Reader as stdin.
104-
func (c *Command) RunInDirTimeoutEnvFullPipeline(env []string, timeout time.Duration, dir string, stdout, stderr io.Writer, stdin io.Reader) error {
105-
return c.RunInDirTimeoutEnvFullPipelineFunc(env, timeout, dir, stdout, stderr, stdin, nil)
106-
}
107-
108-
// RunInDirTimeoutEnvFullPipelineFunc executes the command in given directory with given timeout,
109-
// it pipes stdout and stderr to given io.Writer and passes in an io.Reader as stdin. Between cmd.Start and cmd.Wait the passed in function is run.
110-
func (c *Command) RunInDirTimeoutEnvFullPipelineFunc(env []string, timeout time.Duration, dir string, stdout, stderr io.Writer, stdin io.Reader, fn func(context.Context, context.CancelFunc) error) error {
111-
return c.RunWithContext(&RunContext{
112-
Env: env,
113-
Timeout: timeout,
114-
Dir: dir,
115-
Stdout: stdout,
116-
Stderr: stderr,
117-
Stdin: stdin,
118-
PipelineFunc: fn,
119-
})
120-
}
121-
12297
// RunContext represents parameters to run the command
12398
type RunContext struct {
12499
Env []string
@@ -131,7 +106,7 @@ type RunContext struct {
131106

132107
// RunWithContext run the command with context
133108
func (c *Command) RunWithContext(rc *RunContext) error {
134-
if rc.Timeout == -1 {
109+
if rc.Timeout <= 0 {
135110
rc.Timeout = defaultCommandExecutionTimeout
136111
}
137112

@@ -203,58 +178,73 @@ func (c *Command) RunWithContext(rc *RunContext) error {
203178
return ctx.Err()
204179
}
205180

206-
// RunInDirTimeoutPipeline executes the command in given directory with given timeout,
207-
// it pipes stdout and stderr to given io.Writer.
208-
func (c *Command) RunInDirTimeoutPipeline(timeout time.Duration, dir string, stdout, stderr io.Writer) error {
209-
return c.RunInDirTimeoutEnvPipeline(nil, timeout, dir, stdout, stderr)
181+
type RunError interface {
182+
error
183+
Stderr() string
210184
}
211185

212-
// RunInDirTimeoutFullPipeline executes the command in given directory with given timeout,
213-
// it pipes stdout and stderr to given io.Writer, and stdin from the given io.Reader
214-
func (c *Command) RunInDirTimeoutFullPipeline(timeout time.Duration, dir string, stdout, stderr io.Writer, stdin io.Reader) error {
215-
return c.RunInDirTimeoutEnvFullPipeline(nil, timeout, dir, stdout, stderr, stdin)
186+
type runError struct {
187+
err error
188+
stderr string
189+
errMsg string
216190
}
217191

218-
// RunInDirTimeout executes the command in given directory with given timeout,
219-
// and returns stdout in []byte and error (combined with stderr).
220-
func (c *Command) RunInDirTimeout(timeout time.Duration, dir string) ([]byte, error) {
221-
return c.RunInDirTimeoutEnv(nil, timeout, dir)
192+
func (r *runError) Error() string {
193+
// the stderr must be in the returned error text, some code only checks `strings.Contains(err.Error(), "git error")`
194+
if r.errMsg == "" {
195+
r.errMsg = ConcatenateError(r.err, r.stderr).Error()
196+
}
197+
return r.errMsg
222198
}
223199

224-
// RunInDirTimeoutEnv executes the command in given directory with given timeout,
225-
// and returns stdout in []byte and error (combined with stderr).
226-
func (c *Command) RunInDirTimeoutEnv(env []string, timeout time.Duration, dir string) ([]byte, error) {
227-
stdout := new(bytes.Buffer)
228-
stderr := new(bytes.Buffer)
229-
if err := c.RunInDirTimeoutEnvPipeline(env, timeout, dir, stdout, stderr); err != nil {
230-
return nil, ConcatenateError(err, stderr.String())
231-
}
232-
if stdout.Len() > 0 && log.IsTrace() {
233-
tracelen := stdout.Len()
234-
if tracelen > 1024 {
235-
tracelen = 1024
236-
}
237-
log.Trace("Stdout:\n %s", stdout.Bytes()[:tracelen])
238-
}
239-
return stdout.Bytes(), nil
200+
func (r *runError) Unwrap() error {
201+
return r.err
202+
}
203+
204+
func (r *runError) Stderr() string {
205+
return r.stderr
240206
}
241207

242-
// RunInDirPipeline executes the command in given directory,
243-
// it pipes stdout and stderr to given io.Writer.
244-
func (c *Command) RunInDirPipeline(dir string, stdout, stderr io.Writer) error {
245-
return c.RunInDirFullPipeline(dir, stdout, stderr, nil)
208+
func bytesToString(b []byte) string {
209+
return *(*string)(unsafe.Pointer(&b)) // that's what Golang's strings.Builder.String() does (go/src/strings/builder.go)
246210
}
247211

248-
// RunInDirFullPipeline executes the command in given directory,
249-
// it pipes stdout and stderr to given io.Writer.
250-
func (c *Command) RunInDirFullPipeline(dir string, stdout, stderr io.Writer, stdin io.Reader) error {
251-
return c.RunInDirTimeoutFullPipeline(-1, dir, stdout, stderr, stdin)
212+
// RunWithContextString run the command with context and returns stdout/stderr as string. and store stderr to returned error (err combined with stderr).
213+
func (c *Command) RunWithContextString(rc *RunContext) (stdout, stderr string, runErr RunError) {
214+
stdoutBytes, stderrBytes, err := c.RunWithContextBytes(rc)
215+
stdout = bytesToString(stdoutBytes)
216+
stderr = bytesToString(stderrBytes)
217+
if err != nil {
218+
return stdout, stderr, &runError{err: err, stderr: stderr}
219+
}
220+
// even if there is no err, there could still be some stderr output, so we just return stdout/stderr as they are
221+
return stdout, stderr, nil
222+
}
223+
224+
// RunWithContextBytes run the command with context and returns stdout/stderr as bytes. and store stderr to returned error (err combined with stderr).
225+
func (c *Command) RunWithContextBytes(rc *RunContext) (stdout, stderr []byte, runErr RunError) {
226+
if rc.Stdout != nil || rc.Stderr != nil {
227+
// we must panic here, otherwise there would be bugs if developers set Stdin/Stderr by mistake, and it would be very difficult to debug
228+
panic("stdout and stderr field must be nil when using RunWithContextBytes")
229+
}
230+
stdoutBuf := &bytes.Buffer{}
231+
stderrBuf := &bytes.Buffer{}
232+
rc.Stdout = stdoutBuf
233+
rc.Stderr = stderrBuf
234+
err := c.RunWithContext(rc)
235+
stderr = stderrBuf.Bytes()
236+
if err != nil {
237+
return nil, stderr, &runError{err: err, stderr: string(stderr)}
238+
}
239+
// even if there is no err, there could still be some stderr output
240+
return stdoutBuf.Bytes(), stderr, nil
252241
}
253242

254243
// RunInDirBytes executes the command in given directory
255244
// and returns stdout in []byte and error (combined with stderr).
256245
func (c *Command) RunInDirBytes(dir string) ([]byte, error) {
257-
return c.RunInDirTimeout(-1, dir)
246+
stdout, _, err := c.RunWithContextBytes(&RunContext{Dir: dir})
247+
return stdout, err
258248
}
259249

260250
// RunInDir executes the command in given directory
@@ -266,27 +256,15 @@ func (c *Command) RunInDir(dir string) (string, error) {
266256
// RunInDirWithEnv executes the command in given directory
267257
// and returns stdout in string and error (combined with stderr).
268258
func (c *Command) RunInDirWithEnv(dir string, env []string) (string, error) {
269-
stdout, err := c.RunInDirTimeoutEnv(env, -1, dir)
270-
if err != nil {
271-
return "", err
272-
}
273-
return string(stdout), nil
274-
}
275-
276-
// RunTimeout executes the command in default working directory with given timeout,
277-
// and returns stdout in string and error (combined with stderr).
278-
func (c *Command) RunTimeout(timeout time.Duration) (string, error) {
279-
stdout, err := c.RunInDirTimeout(timeout, "")
280-
if err != nil {
281-
return "", err
282-
}
283-
return string(stdout), nil
259+
stdout, _, err := c.RunWithContextString(&RunContext{Env: env, Dir: dir})
260+
return stdout, err
284261
}
285262

286263
// Run executes the command in default working directory
287264
// and returns stdout in string and error (combined with stderr).
288265
func (c *Command) Run() (string, error) {
289-
return c.RunTimeout(-1)
266+
stdout, _, err := c.RunWithContextString(&RunContext{})
267+
return stdout, err
290268
}
291269

292270
// AllowLFSFiltersArgs return globalCommandArgs with lfs filter, it should only be used for tests

modules/git/command_race_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Copyright 2017 The Gitea Authors. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
//go:build race
6+
// +build race
7+
8+
package git
9+
10+
import (
11+
"context"
12+
"testing"
13+
"time"
14+
)
15+
16+
func TestRunWithContextNoTimeout(t *testing.T) {
17+
maxLoops := 10
18+
19+
// 'git --version' does not block so it must be finished before the timeout triggered.
20+
cmd := NewCommand(context.Background(), "--version")
21+
for i := 0; i < maxLoops; i++ {
22+
if err := cmd.RunWithContext(&RunContext{}); err != nil {
23+
t.Fatal(err)
24+
}
25+
}
26+
}
27+
28+
func TestRunWithContextTimeout(t *testing.T) {
29+
maxLoops := 10
30+
31+
// 'git hash-object --stdin' blocks on stdin so we can have the timeout triggered.
32+
cmd := NewCommand(context.Background(), "hash-object", "--stdin")
33+
for i := 0; i < maxLoops; i++ {
34+
if err := cmd.RunWithContext(&RunContext{Timeout: 1 * time.Millisecond}); err != nil {
35+
if err != context.DeadlineExceeded {
36+
t.Fatalf("Testing %d/%d: %v", i, maxLoops, err)
37+
}
38+
}
39+
}
40+
}

modules/git/command_test.go

Lines changed: 15 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,29 @@
1-
// Copyright 2017 The Gitea Authors. All rights reserved.
1+
// Copyright 2022 The Gitea Authors. All rights reserved.
22
// Use of this source code is governed by a MIT-style
33
// license that can be found in the LICENSE file.
44

5-
//go:build race
6-
// +build race
7-
85
package git
96

107
import (
118
"context"
129
"testing"
13-
"time"
14-
)
1510

16-
func TestRunInDirTimeoutPipelineNoTimeout(t *testing.T) {
17-
maxLoops := 1000
11+
"github.com/stretchr/testify/assert"
12+
)
1813

19-
// 'git --version' does not block so it must be finished before the timeout triggered.
14+
func TestRunWithContextStd(t *testing.T) {
2015
cmd := NewCommand(context.Background(), "--version")
21-
for i := 0; i < maxLoops; i++ {
22-
if err := cmd.RunInDirTimeoutPipeline(-1, "", nil, nil); err != nil {
23-
t.Fatal(err)
24-
}
25-
}
26-
}
27-
28-
func TestRunInDirTimeoutPipelineAlwaysTimeout(t *testing.T) {
29-
maxLoops := 1000
16+
stdout, stderr, err := cmd.RunWithContextString(&RunContext{})
17+
assert.NoError(t, err)
18+
assert.Empty(t, stderr)
19+
assert.Contains(t, stdout, "git version")
3020

31-
// 'git hash-object --stdin' blocks on stdin so we can have the timeout triggered.
32-
cmd := NewCommand(context.Background(), "hash-object", "--stdin")
33-
for i := 0; i < maxLoops; i++ {
34-
if err := cmd.RunInDirTimeoutPipeline(1*time.Microsecond, "", nil, nil); err != nil {
35-
if err != context.DeadlineExceeded {
36-
t.Fatalf("Testing %d/%d: %v", i, maxLoops, err)
37-
}
38-
}
21+
cmd = NewCommand(context.Background(), "--no-such-arg")
22+
stdout, stderr, err = cmd.RunWithContextString(&RunContext{})
23+
if assert.Error(t, err) {
24+
assert.Equal(t, stderr, err.Stderr())
25+
assert.Contains(t, err.Stderr(), "unknown option:")
26+
assert.Contains(t, err.Error(), "exit status 129 - unknown option:")
27+
assert.Empty(t, stdout)
3928
}
4029
}

modules/git/git.go

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,9 @@ func VersionInfo() string {
124124
func Init(ctx context.Context) error {
125125
DefaultContext = ctx
126126

127-
defaultCommandExecutionTimeout = time.Duration(setting.Git.Timeout.Default) * time.Second
127+
if setting.Git.Timeout.Default > 0 {
128+
defaultCommandExecutionTimeout = time.Duration(setting.Git.Timeout.Default) * time.Second
129+
}
128130

129131
if err := SetExecutablePath(setting.Git.Path); err != nil {
130132
return err
@@ -295,10 +297,5 @@ func checkAndRemoveConfig(key, value string) error {
295297

296298
// Fsck verifies the connectivity and validity of the objects in the database
297299
func Fsck(ctx context.Context, repoPath string, timeout time.Duration, args ...string) error {
298-
// Make sure timeout makes sense.
299-
if timeout <= 0 {
300-
timeout = -1
301-
}
302-
_, err := NewCommand(ctx, "fsck").AddArguments(args...).RunInDirTimeout(timeout, repoPath)
303-
return err
300+
return NewCommand(ctx, "fsck").AddArguments(args...).RunWithContext(&RunContext{Timeout: timeout, Dir: repoPath})
304301
}

modules/process/manager.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@ func (pm *Manager) AddContext(parent context.Context, description string) (ctx c
8686
// Most processes will not need to use the cancel function but there will be cases whereby you want to cancel the process but not immediately remove it from the
8787
// process table.
8888
func (pm *Manager) AddContextTimeout(parent context.Context, timeout time.Duration, description string) (ctx context.Context, cancel context.CancelFunc, finshed FinishedFunc) {
89+
if timeout <= 0 {
90+
// it's meaningless to use timeout <= 0, and it must be a bug! so we must panic here to tell developers to make the timeout correct
91+
panic("the timeout must be greater than zero, otherwise the context will be cancelled immediately")
92+
}
8993
ctx, cancel = context.WithTimeout(parent, timeout)
9094

9195
ctx, pid, finshed := pm.Add(ctx, description, cancel)
@@ -239,7 +243,7 @@ func (pm *Manager) ExecDirEnv(ctx context.Context, timeout time.Duration, dir, d
239243
// Returns its complete stdout and stderr
240244
// outputs and an error, if any (including timeout)
241245
func (pm *Manager) ExecDirEnvStdIn(ctx context.Context, timeout time.Duration, dir, desc string, env []string, stdIn io.Reader, cmdName string, args ...string) (string, string, error) {
242-
if timeout == -1 {
246+
if timeout <= 0 {
243247
timeout = 60 * time.Second
244248
}
245249

routers/web/repo/http.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -542,7 +542,7 @@ func GetInfoRefs(ctx *context.Context) {
542542
}
543543
h.environ = append(os.Environ(), h.environ...)
544544

545-
refs, err := git.NewCommand(ctx, service, "--stateless-rpc", "--advertise-refs", ".").RunInDirTimeoutEnv(h.environ, -1, h.dir)
545+
refs, _, err := git.NewCommand(ctx, service, "--stateless-rpc", "--advertise-refs", ".").RunWithContextBytes(&git.RunContext{Env: h.environ, Dir: h.dir})
546546
if err != nil {
547547
log.Error(fmt.Sprintf("%v - %s", err, string(refs)))
548548
}

services/repository/check.go

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -77,15 +77,7 @@ func GitGcRepos(ctx context.Context, timeout time.Duration, args ...string) erro
7777
SetDescription(fmt.Sprintf("Repository Garbage Collection: %s", repo.FullName()))
7878
var stdout string
7979
var err error
80-
if timeout > 0 {
81-
var stdoutBytes []byte
82-
stdoutBytes, err = command.RunInDirTimeout(
83-
timeout,
84-
repo.RepoPath())
85-
stdout = string(stdoutBytes)
86-
} else {
87-
stdout, err = command.RunInDir(repo.RepoPath())
88-
}
80+
stdout, _, err = command.RunWithContextString(&git.RunContext{Timeout: timeout, Dir: repo.RepoPath()})
8981

9082
if err != nil {
9183
log.Error("Repository garbage collection failed for %v. Stdout: %s\nError: %v", repo, stdout, err)

services/repository/fork.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,10 +108,10 @@ func ForkRepository(ctx context.Context, doer, owner *user_model.User, opts Fork
108108
needsRollback = true
109109

110110
repoPath := repo_model.RepoPath(owner.Name, repo.Name)
111-
if stdout, err := git.NewCommand(txCtx,
111+
if stdout, _, err := git.NewCommand(txCtx,
112112
"clone", "--bare", oldRepoPath, repoPath).
113113
SetDescription(fmt.Sprintf("ForkRepository(git clone): %s to %s", opts.BaseRepo.FullName(), repo.FullName())).
114-
RunInDirTimeout(10*time.Minute, ""); err != nil {
114+
RunWithContextBytes(&git.RunContext{Timeout: 10 * time.Minute}); err != nil {
115115
log.Error("Fork Repository (git clone) Failed for %v (from %v):\nStdout: %s\nError: %v", repo, opts.BaseRepo, stdout, err)
116116
return fmt.Errorf("git clone: %v", err)
117117
}

0 commit comments

Comments
 (0)