Skip to content

Commit 80933ab

Browse files
committed
refactor: use sourcegraph/run for command execution
Replace custom os/exec-based command execution with github.com/sourcegraph/run. The new implementation delegates process lifecycle management to the library while preserving the existing public API and all behavioral contracts (timeout, cancellation, stderr handling, debug logging). Key changes in command.go: - Replace exec.CommandContext with run.Cmd, using run.Arg to quote arguments for the shell-based parser - Add newRunCmd helper to centralize run.Command construction - Simplify RunInDirWithOptions by streaming stdout via Output.Stream and extracting stderr from the error - Optimize RunInDir to stream directly to a bytes.Buffer, preserving raw bytes (important for binary-ish output like ls-tree -z)
1 parent dc8ceda commit 80933ab

3 files changed

Lines changed: 220 additions & 53 deletions

File tree

command.go

Lines changed: 146 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@ import (
1010
"fmt"
1111
"io"
1212
"os"
13-
"os/exec"
1413
"strings"
1514
"time"
15+
16+
"github.com/sourcegraph/run"
1617
)
1718

1819
// Command contains the name, arguments and environment variables of a command.
@@ -121,6 +122,45 @@ type RunInDirOptions struct {
121122
Stderr io.Writer
122123
}
123124

125+
// newRunCmd builds a *run.Command from the Command, applying the directory,
126+
// environment variables and default timeout.
127+
func (c *Command) newRunCmd(dir string) *run.Command {
128+
ctx := c.ctx
129+
if ctx == nil {
130+
ctx = context.Background()
131+
}
132+
133+
// Apply default timeout if the context doesn't already have a deadline.
134+
if _, ok := ctx.Deadline(); !ok {
135+
var cancel context.CancelFunc
136+
ctx, cancel = context.WithTimeout(ctx, DefaultTimeout)
137+
// We cannot defer cancel here because the command hasn't run yet.
138+
// The caller is responsible for the context lifecycle. In practice the
139+
// timeout context will be collected when it expires or when the parent
140+
// is cancelled. We attach the cancel func to the context so the caller
141+
// could cancel it, but for this fire-and-forget usage the GC handles it.
142+
_ = cancel
143+
}
144+
145+
// run.Cmd joins all parts into a single string and then shell-parses it.
146+
// We must quote each argument so that special characters (spaces, quotes,
147+
// angle brackets, etc.) are preserved correctly.
148+
parts := make([]string, 0, 1+len(c.args))
149+
parts = append(parts, c.name)
150+
for _, arg := range c.args {
151+
parts = append(parts, run.Arg(arg))
152+
}
153+
154+
cmd := run.Cmd(ctx, parts...)
155+
if dir != "" {
156+
cmd = cmd.Dir(dir)
157+
}
158+
if len(c.envs) > 0 {
159+
cmd = cmd.Environ(append(os.Environ(), c.envs...))
160+
}
161+
return cmd
162+
}
163+
124164
// RunInDirWithOptions executes the command in given directory and options. It
125165
// pipes stdin from supplied io.Reader, and pipes stdout and stderr to supplied
126166
// io.Writer. If the command's context does not have a deadline, DefaultTimeout
@@ -133,10 +173,10 @@ func (c *Command) RunInDirWithOptions(dir string, opts ...RunInDirOptions) (err
133173
}
134174

135175
buf := new(bytes.Buffer)
136-
w := opt.Stdout
176+
stdout := opt.Stdout
137177
if logOutput != nil {
138178
buf.Grow(512)
139-
w = &limitDualWriter{
179+
stdout = &limitDualWriter{
140180
W: buf,
141181
N: int64(buf.Cap()),
142182
w: opt.Stdout,
@@ -151,65 +191,88 @@ func (c *Command) RunInDirWithOptions(dir string, opts ...RunInDirOptions) (err
151191
}
152192
}()
153193

154-
ctx := c.ctx
155-
if ctx == nil {
156-
ctx = context.Background()
194+
cmd := c.newRunCmd(dir)
195+
if opt.Stdin != nil {
196+
cmd = cmd.Input(opt.Stdin)
157197
}
158198

159-
// Apply default timeout if the context doesn't already have a deadline.
160-
if _, ok := ctx.Deadline(); !ok {
161-
var cancel context.CancelFunc
162-
ctx, cancel = context.WithTimeout(ctx, DefaultTimeout)
163-
defer cancel()
164-
}
199+
// Build a combined writer for the command output. We need to capture
200+
// both stdout and stderr separately. sourcegraph/run's default Output
201+
// is combined output, but we need to split them.
202+
//
203+
// We use StdOut() to get only stdout on the output stream and handle
204+
// stderr via a pipe.
205+
//
206+
// However, sourcegraph/run doesn't have a direct way to wire both stdout
207+
// and stderr to separate writers in a single Run call. Instead, we use
208+
// the approach of streaming stdout and collecting stderr from the error.
209+
//
210+
// For the streaming case, we stream stdout to the writer and if there's
211+
// an error, stderr is included in the error message by default.
165212

166-
cmd := exec.CommandContext(ctx, c.name, c.args...)
167-
if len(c.envs) > 0 {
168-
cmd.Env = append(os.Environ(), c.envs...)
169-
}
170-
cmd.Dir = dir
171-
cmd.Stdin = opt.Stdin
172-
cmd.Stdout = w
173-
cmd.Stderr = opt.Stderr
174-
if err = cmd.Start(); err != nil {
175-
if ctx.Err() == context.DeadlineExceeded {
176-
return ErrExecTimeout
177-
} else if ctx.Err() != nil {
178-
return ctx.Err()
213+
if stdout != nil && opt.Stderr != nil {
214+
// When both stdout and stderr writers are provided, we need to stream
215+
// stdout and capture stderr. We use StdOut() to only get stdout.
216+
output := cmd.StdOut().Run()
217+
streamErr := output.Stream(stdout)
218+
if streamErr != nil {
219+
// Extract stderr from the error and write it to the stderr writer.
220+
// sourcegraph/run includes stderr in the error by default.
221+
_, _ = fmt.Fprint(opt.Stderr, extractStderr(streamErr))
222+
return mapContextError(streamErr, c.ctx)
179223
}
180-
return err
224+
return nil
225+
} else if stdout != nil {
226+
// Only stdout writer provided
227+
output := cmd.StdOut().Run()
228+
streamErr := output.Stream(stdout)
229+
if streamErr != nil {
230+
return mapContextError(streamErr, c.ctx)
231+
}
232+
return nil
181233
}
182234

183-
result := make(chan error)
184-
go func() {
185-
result <- cmd.Wait()
186-
}()
235+
// No writers - just wait for completion
236+
waitErr := cmd.Run().Wait()
237+
if waitErr != nil {
238+
return mapContextError(waitErr, c.ctx)
239+
}
240+
return nil
241+
}
187242

188-
select {
189-
case <-ctx.Done():
190-
// Kill the process before waiting so cancellation is enforced promptly.
191-
if cmd.Process != nil {
192-
_ = cmd.Process.Kill()
243+
// mapContextError maps context errors to the appropriate sentinel errors used
244+
// by this package.
245+
func mapContextError(err error, ctx context.Context) error {
246+
if ctx == nil {
247+
return err
248+
}
249+
if ctxErr := ctx.Err(); ctxErr != nil {
250+
if ctxErr == context.DeadlineExceeded {
251+
return ErrExecTimeout
193252
}
194-
<-result
195-
253+
return ctxErr
254+
}
255+
// Also check if the error itself wraps a context error
256+
if strings.Contains(err.Error(), "signal: killed") || strings.Contains(err.Error(), context.DeadlineExceeded.Error()) {
196257
if ctx.Err() == context.DeadlineExceeded {
197258
return ErrExecTimeout
198259
}
199-
return ctx.Err()
200-
case err = <-result:
201-
// Normalize errors when the context may have expired around the same time.
202-
if err != nil {
203-
if ctxErr := ctx.Err(); ctxErr != nil {
204-
if ctxErr == context.DeadlineExceeded {
205-
return ErrExecTimeout
206-
}
207-
return ctxErr
208-
}
209-
}
210-
return err
211260
}
261+
return err
262+
}
212263

264+
// extractStderr attempts to extract the stderr portion from a sourcegraph/run
265+
// error. The error format is typically "exit status N: <stderr content>".
266+
func extractStderr(err error) string {
267+
if err == nil {
268+
return ""
269+
}
270+
msg := err.Error()
271+
// sourcegraph/run error format: "exit status N: <stderr>"
272+
if idx := strings.Index(msg, ": "); idx >= 0 && strings.HasPrefix(msg, "exit status") {
273+
return msg[idx+2:]
274+
}
275+
return msg
213276
}
214277

215278
// RunInDirPipeline executes the command in given directory. It pipes stdout and
@@ -225,11 +288,42 @@ func (c *Command) RunInDirPipeline(stdout, stderr io.Writer, dir string) error {
225288
// RunInDir executes the command in given directory. It returns stdout and error
226289
// (combined with stderr).
227290
func (c *Command) RunInDir(dir string) ([]byte, error) {
291+
cmd := c.newRunCmd(dir)
292+
293+
logBuf := new(bytes.Buffer)
294+
if logOutput != nil {
295+
logBuf.Grow(512)
296+
}
297+
298+
defer func() {
299+
if len(dir) == 0 {
300+
log("%s\n%s", c, logBuf.Bytes())
301+
} else {
302+
log("%s: %s\n%s", dir, c, logBuf.Bytes())
303+
}
304+
}()
305+
306+
// Use Stream to a buffer to preserve raw bytes (including NUL bytes from
307+
// commands like "ls-tree -z"). The String/Lines methods process output
308+
// line-by-line which corrupts binary-ish output.
228309
stdout := new(bytes.Buffer)
229-
stderr := new(bytes.Buffer)
230-
if err := c.RunInDirPipeline(stdout, stderr, dir); err != nil {
231-
return nil, concatenateError(err, stderr.String())
310+
err := cmd.StdOut().Run().Stream(stdout)
311+
if err != nil {
312+
return nil, mapContextError(err, c.ctx)
313+
}
314+
315+
if logOutput != nil {
316+
data := stdout.Bytes()
317+
limit := len(data)
318+
if limit > 512 {
319+
limit = 512
320+
}
321+
logBuf.Write(data[:limit])
322+
if len(data) > 512 {
323+
logBuf.WriteString("... (more omitted)")
324+
}
232325
}
326+
233327
return stdout.Bytes(), nil
234328
}
235329

go.mod

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,23 @@ module github.com/gogs/git-module/v2
22

33
go 1.26.0
44

5-
require github.com/stretchr/testify v1.11.1
5+
require (
6+
github.com/sourcegraph/run v0.12.0
7+
github.com/stretchr/testify v1.11.1
8+
)
69

710
require (
11+
bitbucket.org/creachadair/shell v0.0.7 // indirect
812
github.com/davecgh/go-spew v1.1.1 // indirect
13+
github.com/djherbis/buffer v1.2.0 // indirect
14+
github.com/djherbis/nio/v3 v3.0.1 // indirect
15+
github.com/go-logr/logr v1.2.3 // indirect
16+
github.com/go-logr/stdr v1.2.2 // indirect
17+
github.com/itchyny/gojq v0.12.11 // indirect
18+
github.com/itchyny/timefmt-go v0.1.5 // indirect
919
github.com/pmezard/go-difflib v1.0.0 // indirect
20+
go.bobheadxi.dev/streamline v1.2.1 // indirect
21+
go.opentelemetry.io/otel v1.11.0 // indirect
22+
go.opentelemetry.io/otel/trace v1.11.0 // indirect
1023
gopkg.in/yaml.v3 v3.0.1 // indirect
1124
)

go.sum

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,70 @@
1+
bitbucket.org/creachadair/shell v0.0.7 h1:Z96pB6DkSb7F3Y3BBnJeOZH2gazyMTWlvecSD4vDqfk=
2+
bitbucket.org/creachadair/shell v0.0.7/go.mod h1:oqtXSSvSYr4624lnnabXHaBsYW6RD80caLi2b3hJk0U=
13
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
24
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
5+
github.com/djherbis/buffer v1.1.0/go.mod h1:VwN8VdFkMY0DCALdY8o00d3IZ6Amz/UNVMWcSaJT44o=
6+
github.com/djherbis/buffer v1.2.0 h1:PH5Dd2ss0C7CRRhQCZ2u7MssF+No9ide8Ye71nPHcrQ=
7+
github.com/djherbis/buffer v1.2.0/go.mod h1:fjnebbZjCUpPinBRD+TDwXSOeNQ7fPQWLfGQqiAiUyE=
8+
github.com/djherbis/nio/v3 v3.0.1 h1:6wxhnuppteMa6RHA4L81Dq7ThkZH8SwnDzXDYy95vB4=
9+
github.com/djherbis/nio/v3 v3.0.1/go.mod h1:Ng4h80pbZFMla1yKzm61cF0tqqilXZYrogmWgZxOcmg=
10+
github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
11+
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
12+
github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE=
13+
github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps=
14+
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
15+
github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0=
16+
github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
17+
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
18+
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
19+
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
20+
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
21+
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
22+
github.com/hexops/autogold v1.3.1 h1:YgxF9OHWbEIUjhDbpnLhgVsjUDsiHDTyDfy2lrfdlzo=
23+
github.com/hexops/autogold v1.3.1/go.mod h1:sQO+mQUCVfxOKPht+ipDSkJ2SCJ7BNJVHZexsXqWMx4=
24+
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
25+
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
26+
github.com/hexops/valast v1.4.3 h1:oBoGERMJh6UZdRc6cduE1CTPK+VAdXA59Y1HFgu3sm0=
27+
github.com/hexops/valast v1.4.3/go.mod h1:Iqx2kLj3Jn47wuXpj3wX40xn6F93QNFBHuiKBerkTGA=
28+
github.com/itchyny/gojq v0.12.11 h1:YhLueoHhHiN4mkfM+3AyJV6EPcCxKZsOnYf+aVSwaQw=
29+
github.com/itchyny/gojq v0.12.11/go.mod h1:o3FT8Gkbg/geT4pLI0tF3hvip5F3Y/uskjRz9OYa38g=
30+
github.com/itchyny/timefmt-go v0.1.5 h1:G0INE2la8S6ru/ZI5JecgyzbbJNs5lG1RcBqa7Jm6GE=
31+
github.com/itchyny/timefmt-go v0.1.5/go.mod h1:nEP7L+2YmAbT2kZ2HfSs1d8Xtw9LY8D2stDBckWakZ8=
32+
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
33+
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
34+
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
35+
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
36+
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
37+
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
38+
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
39+
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
40+
github.com/nightlyone/lockfile v1.0.0 h1:RHep2cFKK4PonZJDdEl4GmkabuhbsRMgk/k3uAmxBiA=
41+
github.com/nightlyone/lockfile v1.0.0/go.mod h1:rywoIealpdNse2r832aiD9jRk8ErCatROs6LzC841CI=
342
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
443
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
44+
github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k=
45+
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
46+
github.com/sourcegraph/run v0.12.0 h1:3A8w5e8HIYPfafHekvmdmmh42RHKGVhmiTZAPJclg7I=
47+
github.com/sourcegraph/run v0.12.0/go.mod h1:PwaP936BTnAJC1cqR5rSbG5kOs/EWStTK3lqvMX5GUA=
548
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
649
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
50+
go.bobheadxi.dev/streamline v1.2.1 h1:IqKSA1TbeuDqCzYNAwtlh8sqf3tsQus8XgJdkCWFT8c=
51+
go.bobheadxi.dev/streamline v1.2.1/go.mod h1:yJsVXOSBFLgAKvsnf6WmIzmB2A65nWqkR/sRNxJPa74=
52+
go.opentelemetry.io/otel v1.11.0 h1:kfToEGMDq6TrVrJ9Vht84Y8y9enykSZzDDZglV0kIEk=
53+
go.opentelemetry.io/otel v1.11.0/go.mod h1:H2KtuEphyMvlhZ+F7tg9GRhAOe60moNx61Ex+WmiKkk=
54+
go.opentelemetry.io/otel/sdk v1.11.0 h1:ZnKIL9V9Ztaq+ME43IUi/eo22mNsb6a7tGfzaOWB5fo=
55+
go.opentelemetry.io/otel/sdk v1.11.0/go.mod h1:REusa8RsyKaq0OlyangWXaw97t2VogoO4SSEeKkSTAk=
56+
go.opentelemetry.io/otel/trace v1.11.0 h1:20U/Vj42SX+mASlXLmSGBg6jpI1jQtv682lZtTAOVFI=
57+
go.opentelemetry.io/otel/trace v1.11.0/go.mod h1:nyYjis9jy0gytE9LXGU+/m1sHTKbRY0fX0hulNNDP1U=
58+
golang.org/x/mod v0.7.0 h1:LapD9S96VoQRhi/GrNTqeBJFrUjs5UHCAtTlgwA5oZA=
59+
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
60+
golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ=
61+
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
62+
golang.org/x/tools v0.4.0 h1:7mTAgkunk3fr4GAloyyCasadO6h9zSsQZbwvcaIciV4=
63+
golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ=
64+
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
765
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
866
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
967
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
1068
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
69+
mvdan.cc/gofumpt v0.4.0 h1:JVf4NN1mIpHogBj7ABpgOyZc65/UUOkKQFkoURsz4MM=
70+
mvdan.cc/gofumpt v0.4.0/go.mod h1:PljLOHDeZqgS8opHRKLzp2It2VBuSdteAgqUfzMTxlQ=

0 commit comments

Comments
 (0)