Skip to content

Commit 78fda40

Browse files
unknwonclaude
andauthored
refactor: use sourcegraph/run for command execution (#127)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent dc8ceda commit 78fda40

26 files changed

+556
-599
lines changed

blob.go

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,25 +15,23 @@ type Blob struct {
1515
*TreeEntry
1616
}
1717

18-
// Bytes reads and returns the content of the blob all at once in bytes. This
19-
// can be very slow and memory consuming for huge content.
18+
// Bytes reads and returns the content of the blob all at once in bytes. This can
19+
// be very slow and memory consuming for huge content.
2020
func (b *Blob) Bytes(ctx context.Context) ([]byte, error) {
2121
stdout := new(bytes.Buffer)
22-
stderr := new(bytes.Buffer)
2322

2423
// Preallocate memory to save ~50% memory usage on big files.
2524
if size := b.Size(ctx); size > 0 && size < int64(^uint(0)>>1) {
2625
stdout.Grow(int(size))
2726
}
2827

29-
if err := b.Pipeline(ctx, stdout, stderr); err != nil {
30-
return nil, concatenateError(err, stderr.String())
28+
if err := b.Pipe(ctx, stdout); err != nil {
29+
return nil, err
3130
}
3231
return stdout.Bytes(), nil
3332
}
3433

35-
// Pipeline reads the content of the blob and pipes stdout and stderr to
36-
// supplied io.Writer.
37-
func (b *Blob) Pipeline(ctx context.Context, stdout, stderr io.Writer) error {
38-
return NewCommand(ctx, "show", b.id.String()).RunInDirPipeline(stdout, stderr, b.parent.repo.path)
34+
// Pipe reads the content of the blob and pipes stdout to the supplied io.Writer.
35+
func (b *Blob) Pipe(ctx context.Context, stdout io.Writer) error {
36+
return pipe(ctx, b.parent.repo.path, []string{"show", "--end-of-options", b.id.String()}, nil, stdout)
3937
}

blob_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,9 @@ This demo also includes an image with changes on a branch for examination of ima
4848
assert.Equal(t, expOutput, string(p))
4949
})
5050

51-
t.Run("get data with pipeline", func(t *testing.T) {
51+
t.Run("get data with pipe", func(t *testing.T) {
5252
stdout := new(bytes.Buffer)
53-
err := blob.Pipeline(ctx, stdout, nil)
53+
err := blob.Pipe(ctx, stdout)
5454
assert.Nil(t, err)
5555
assert.Equal(t, expOutput, stdout.String())
5656
})

command.go

Lines changed: 138 additions & 173 deletions
Original file line numberDiff line numberDiff line change
@@ -7,81 +7,155 @@ package git
77
import (
88
"bytes"
99
"context"
10-
"fmt"
10+
"errors"
1111
"io"
1212
"os"
13-
"os/exec"
13+
"strconv"
1414
"strings"
1515
"time"
16-
)
1716

18-
// Command contains the name, arguments and environment variables of a command.
19-
type Command struct {
20-
name string
21-
args []string
22-
envs []string
23-
ctx context.Context
24-
}
17+
"github.com/sourcegraph/run"
18+
)
2519

26-
// CommandOptions contains options for running a command.
20+
// CommandOptions contains additional options for running a Git command.
2721
type CommandOptions struct {
28-
Args []string
2922
Envs []string
3023
}
3124

32-
// String returns the string representation of the command.
33-
func (c *Command) String() string {
34-
if len(c.args) == 0 {
35-
return c.name
25+
// DefaultTimeout is the default timeout duration for all commands. It is
26+
// applied when the context does not already have a deadline.
27+
const DefaultTimeout = time.Minute
28+
29+
// cmd builds a *run.Command for git with the given arguments, environment
30+
// variables and working directory. DefaultTimeout will be applied if the context
31+
// does not already have a deadline.
32+
func cmd(ctx context.Context, dir string, args []string, envs []string) (*run.Command, context.CancelFunc) {
33+
cancel := func() {}
34+
if _, ok := ctx.Deadline(); !ok {
35+
var timeoutCancel context.CancelFunc
36+
ctx, timeoutCancel = context.WithTimeout(ctx, DefaultTimeout)
37+
cancel = timeoutCancel
3638
}
37-
return fmt.Sprintf("%s %s", c.name, strings.Join(c.args, " "))
38-
}
3939

40-
// NewCommand creates and returns a new Command with given arguments for "git".
41-
func NewCommand(ctx context.Context, args ...string) *Command {
42-
return &Command{
43-
name: "git",
44-
args: args,
45-
ctx: ctx,
40+
// run.Cmd joins all parts into a single string and then shell-parses it. We must
41+
// quote each argument so that special characters (spaces, quotes, angle
42+
// brackets, etc.) are preserved correctly.
43+
parts := make([]string, 0, 1+len(args))
44+
parts = append(parts, "git")
45+
for _, arg := range args {
46+
parts = append(parts, run.Arg(arg))
4647
}
47-
}
4848

49-
// AddArgs appends given arguments to the command.
50-
func (c *Command) AddArgs(args ...string) *Command {
51-
c.args = append(c.args, args...)
52-
return c
49+
c := run.Cmd(ctx, parts...)
50+
if dir != "" {
51+
c = c.Dir(dir)
52+
}
53+
if len(envs) > 0 {
54+
c = c.Environ(append(os.Environ(), envs...))
55+
}
56+
return c, cancel
5357
}
5458

55-
// AddEnvs appends given environment variables to the command.
56-
func (c *Command) AddEnvs(envs ...string) *Command {
57-
c.envs = append(c.envs, envs...)
58-
return c
59-
}
59+
// exec executes a git command in the given directory and returns stdout as
60+
// bytes. Stderr is included in the error message on failure. DefaultTimeout will
61+
// be applied if the context does not already have a deadline. It returns
62+
// ErrExecTimeout if the execution was timed out.
63+
func exec(ctx context.Context, dir string, args []string, envs []string) ([]byte, error) {
64+
c, cancel := cmd(ctx, dir, args, envs)
65+
defer cancel()
66+
67+
var logBuf *bytes.Buffer
68+
if logOutput != nil {
69+
logBuf = new(bytes.Buffer)
70+
logBuf.Grow(512)
71+
defer func() {
72+
log(dir, args, logBuf.Bytes())
73+
}()
74+
}
75+
76+
// Use Stream to a buffer to preserve raw bytes (including NUL bytes from
77+
// commands like "ls-tree -z"). The String/Lines methods process output
78+
// line-by-line which corrupts binary-ish output.
79+
stdout := new(bytes.Buffer)
80+
err := c.StdOut().Run().Stream(stdout)
81+
82+
// Capture (partial) stdout for logging even on error, so failed commands produce
83+
// a useful log entry rather than an empty one.
84+
if logOutput != nil {
85+
data := stdout.Bytes()
86+
limit := len(data)
87+
if limit > 512 {
88+
limit = 512
89+
}
90+
logBuf.Write(data[:limit])
91+
if len(data) > 512 {
92+
logBuf.WriteString("... (more omitted)")
93+
}
94+
}
6095

61-
// WithContext returns a new Command with the given context.
62-
func (c Command) WithContext(ctx context.Context) *Command {
63-
c.ctx = ctx
64-
return &c
96+
if err != nil {
97+
return nil, mapContextError(err, ctx)
98+
}
99+
return stdout.Bytes(), nil
65100
}
66101

67-
// AddOptions adds options to the command.
68-
func (c *Command) AddOptions(opts ...CommandOptions) *Command {
69-
for _, opt := range opts {
70-
c.AddArgs(opt.Args...)
71-
c.AddEnvs(opt.Envs...)
102+
// pipe executes a git command in the given directory, streaming stdout to the
103+
// given io.Writer.
104+
func pipe(ctx context.Context, dir string, args []string, envs []string, stdout io.Writer) error {
105+
c, cancel := cmd(ctx, dir, args, envs)
106+
defer cancel()
107+
108+
var buf *bytes.Buffer
109+
w := stdout
110+
if logOutput != nil {
111+
buf = new(bytes.Buffer)
112+
buf.Grow(512)
113+
w = &limitDualWriter{
114+
W: buf,
115+
N: int64(buf.Cap()),
116+
w: stdout,
117+
}
118+
119+
defer func() {
120+
log(dir, args, buf.Bytes())
121+
}()
72122
}
73-
return c
123+
124+
streamErr := c.StdOut().Run().Stream(w)
125+
if streamErr != nil {
126+
return mapContextError(streamErr, ctx)
127+
}
128+
return nil
74129
}
75130

76-
// AddCommitter appends given committer to the command.
77-
func (c *Command) AddCommitter(committer *Signature) *Command {
78-
c.AddEnvs("GIT_COMMITTER_NAME="+committer.Name, "GIT_COMMITTER_EMAIL="+committer.Email)
79-
return c
131+
// committerEnvs returns environment variables for setting the Git committer.
132+
func committerEnvs(committer *Signature) []string {
133+
return []string{
134+
"GIT_COMMITTER_NAME=" + committer.Name,
135+
"GIT_COMMITTER_EMAIL=" + committer.Email,
136+
}
80137
}
81138

82-
// DefaultTimeout is the default timeout duration for all commands. It is
83-
// applied when the context does not already have a deadline.
84-
const DefaultTimeout = time.Minute
139+
// log logs a git command execution with its output.
140+
func log(dir string, args []string, output []byte) {
141+
cmdStr := "git"
142+
if len(args) > 0 {
143+
quoted := make([]string, len(args))
144+
for i, a := range args {
145+
if strings.ContainsAny(a, " \t\n\"'\\<>") {
146+
quoted[i] = strconv.Quote(a)
147+
} else {
148+
quoted[i] = a
149+
}
150+
}
151+
cmdStr = "git " + strings.Join(quoted, " ")
152+
}
153+
if len(dir) == 0 {
154+
logf("%s\n%s", cmdStr, output)
155+
} else {
156+
logf("%s: %s\n%s", dir, cmdStr, output)
157+
}
158+
}
85159

86160
// A limitDualWriter writes to W but limits the amount of data written to just N
87161
// bytes. On the other hand, it passes everything to w.
@@ -111,134 +185,25 @@ func (w *limitDualWriter) Write(p []byte) (int, error) {
111185
return w.w.Write(p)
112186
}
113187

114-
// RunInDirOptions contains options for running a command in a directory.
115-
type RunInDirOptions struct {
116-
// Stdin is the input to the command.
117-
Stdin io.Reader
118-
// Stdout is the outputs from the command.
119-
Stdout io.Writer
120-
// Stderr is the error output from the command.
121-
Stderr io.Writer
122-
}
123-
124-
// RunInDirWithOptions executes the command in given directory and options. It
125-
// pipes stdin from supplied io.Reader, and pipes stdout and stderr to supplied
126-
// io.Writer. If the command's context does not have a deadline, DefaultTimeout
127-
// will be applied automatically. It returns an ErrExecTimeout if the execution
128-
// was timed out.
129-
func (c *Command) RunInDirWithOptions(dir string, opts ...RunInDirOptions) (err error) {
130-
var opt RunInDirOptions
131-
if len(opts) > 0 {
132-
opt = opts[0]
133-
}
134-
135-
buf := new(bytes.Buffer)
136-
w := opt.Stdout
137-
if logOutput != nil {
138-
buf.Grow(512)
139-
w = &limitDualWriter{
140-
W: buf,
141-
N: int64(buf.Cap()),
142-
w: opt.Stdout,
143-
}
144-
}
145-
146-
defer func() {
147-
if len(dir) == 0 {
148-
log("%s\n%s", c, buf.Bytes())
149-
} else {
150-
log("%s: %s\n%s", dir, c, buf.Bytes())
151-
}
152-
}()
153-
154-
ctx := c.ctx
188+
// mapContextError maps context errors to the appropriate sentinel errors used
189+
// by this package.
190+
func mapContextError(err error, ctx context.Context) error {
155191
if ctx == nil {
156-
ctx = context.Background()
157-
}
158-
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-
}
165-
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()
179-
}
180192
return err
181193
}
182-
183-
result := make(chan error)
184-
go func() {
185-
result <- cmd.Wait()
186-
}()
187-
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()
193-
}
194-
<-result
195-
196-
if ctx.Err() == context.DeadlineExceeded {
194+
if ctxErr := ctx.Err(); ctxErr != nil {
195+
if errors.Is(ctxErr, context.DeadlineExceeded) {
197196
return ErrExecTimeout
198197
}
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
198+
return ctxErr
211199
}
212-
213-
}
214-
215-
// RunInDirPipeline executes the command in given directory. It pipes stdout and
216-
// stderr to supplied io.Writer.
217-
func (c *Command) RunInDirPipeline(stdout, stderr io.Writer, dir string) error {
218-
return c.RunInDirWithOptions(dir, RunInDirOptions{
219-
Stdin: nil,
220-
Stdout: stdout,
221-
Stderr: stderr,
222-
})
223-
}
224-
225-
// RunInDir executes the command in given directory. It returns stdout and error
226-
// (combined with stderr).
227-
func (c *Command) RunInDir(dir string) ([]byte, error) {
228-
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())
232-
}
233-
return stdout.Bytes(), nil
200+
return err
234201
}
235202

236-
// Run executes the command in working directory. It returns stdout and
237-
// error (combined with stderr).
238-
func (c *Command) Run() ([]byte, error) {
239-
stdout, err := c.RunInDir("")
240-
if err != nil {
241-
return nil, err
242-
}
243-
return stdout, nil
203+
// isExitStatus reports whether err represents a specific process exit status
204+
// code, using the run.ExitCoder interface provided by sourcegraph/run.
205+
func isExitStatus(err error, code int) bool {
206+
var exitCoder run.ExitCoder
207+
ok := errors.As(err, &exitCoder)
208+
return ok && exitCoder.ExitCode() == code
244209
}

0 commit comments

Comments
 (0)