gitstack

grindlemire/gothem-stack code browser

5.0 KB Go 217 lines 2025-01-11 · 04d4ded raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
package cmd

import (
	"context"
	"io"
	"os"
	"os/exec"
	"os/signal"
	"strings"

	"github.com/pkg/errors"
	"go.uber.org/zap"
)

type cmdconf struct {
	name string
	args []string
	dir  string
	env  []string

	logCMD  bool
	infoLog io.Writer
	errLog  io.Writer
	in      io.Reader
}

type cmdopt func(*cmdconf) error

// WithDir specifies the directory to run the subprocess in
func WithDir(dir string) cmdopt {
	return func(conf *cmdconf) (err error) {
		conf.dir = dir
		return nil
	}
}

// WithCMD specifies the command to run and the args to pass
func WithCMD(name string, args ...string) cmdopt {
	return func(conf *cmdconf) (err error) {
		conf.name = name
		if conf.args == nil {
			conf.args = []string{}
		}
		if len(args) > 0 {
			// prepend the command args before the others
			conf.args = append(args, conf.args...)
		}
		return nil
	}
}

// WithSilent discards the subprocess stdout
func WithSilent() cmdopt {
	return func(conf *cmdconf) (err error) {
		conf.infoLog = io.Discard
		conf.errLog = io.Discard
		return nil
	}
}

// WithArgs adds additional arguments for the command
func WithArgs(args ...string) cmdopt {
	return func(conf *cmdconf) (err error) {
		if conf.args == nil {
			conf.args = []string{}
		}
		conf.args = append(conf.args, args...)
		return nil
	}
}

// WithEnv provides additional env vars for the subprocess
func WithEnv(args ...string) cmdopt {
	return func(conf *cmdconf) (err error) {
		if conf.env == nil {
			conf.env = []string{}
		}
		conf.env = append(conf.env, args...)
		return nil
	}
}

// WithLog logs the command that is run and gives the ability to copy
func WithLog() cmdopt {
	return func(conf *cmdconf) (err error) {
		conf.logCMD = true
		return nil
	}
}

// WithLogger hooks up the external zap logger with the stdout and stderr of the subprocess
func WithLogger() cmdopt {
	return func(conf *cmdconf) (err error) {
		infoLog, err := zap.NewStdLogAt(zap.L(), zap.InfoLevel)
		if err != nil {
			return errors.Wrap(err, "wrapping info level zap logger")
		}

		errLog, err := zap.NewStdLogAt(zap.L(), zap.ErrorLevel)
		if err != nil {
			return errors.Wrap(err, "wrapping error level zap logger")
		}
		conf.infoLog = infoLog.Writer()
		conf.errLog = errLog.Writer()
		return nil
	}
}

// CMD creates a command but does not run it. Pass it to Run to run the command.
func CMD(ctx context.Context, opts ...cmdopt) *exec.Cmd {
	conf := &cmdconf{
		infoLog: os.Stdout,
		errLog:  os.Stderr,
		in:      os.Stdin,
	}
	for _, opt := range opts {
		err := opt(conf)
		if err != nil {
			zap.S().Fatalf("creating command config: %v", err)
		}
	}
	cmd := exec.CommandContext(ctx, conf.name, conf.args...)
	cmd.Dir = conf.dir

	cmd.Stderr = conf.errLog
	cmd.Stdout = conf.infoLog
	cmd.Stdin = conf.in
	cmd.Env = append(os.Environ(), conf.env...)

	if conf.logCMD {
		zap.S().Infof("ENV: %s", conf.env)
		zap.S().Infof("DIR: %s", cmd.Dir)
		zap.S().Infof("CMD: %s", cmd.String())
		zap.S().Infof("COPY: pushd %s; %s %s; popd", cmd.Dir, strings.Join(conf.env, " "), cmd.String())
	}
	return cmd
}

// Run a command and wait for it to return
func Run(ctx context.Context, opts ...cmdopt) (err error) {
	cmd := CMD(ctx, opts...)
	// Create a signal channel
	sigCh := make(chan os.Signal, 1)

	// Register a signal handler for SIGINT
	signal.Notify(sigCh, os.Interrupt)

	// start the command and return the result
	if err := cmd.Start(); err != nil {
		return errors.Wrap(err, "starting subprocess")
	}
	done := make(chan error, 1)
	go func() {
		done <- cmd.Wait()
	}()

	for {
		select {
		case err = <-done:
			return err
		case <-sigCh:
			zap.S().Debugf("killing subprocess %s", cmd.String())
			err := cmd.Process.Signal(os.Interrupt)
			if err != nil {
				return errors.Wrap(err, "sending cancel signal")
			}
		case <-ctx.Done():
			zap.S().Infof("context cancelled, killing subprocess %s", cmd.String())
			cmd.Process.Signal(os.Interrupt)
			_, err := cmd.Process.Wait()
			return err
		}
	}
}

// Output runs a command and returns its stdout output as bytes
func Output(ctx context.Context, opts ...cmdopt) ([]byte, error) {
	// Create a config with default values
	conf := &cmdconf{
		infoLog: os.Stdout,
		errLog:  os.Stderr,
		in:      os.Stdin,
	}

	// Apply all options
	for _, opt := range opts {
		err := opt(conf)
		if err != nil {
			return nil, errors.Wrap(err, "creating command config")
		}
	}

	// Create the command
	cmd := exec.CommandContext(ctx, conf.name, conf.args...)
	cmd.Dir = conf.dir
	cmd.Env = append(os.Environ(), conf.env...)
	cmd.Stdin = conf.in

	// Log the command if requested
	if conf.logCMD {
		zap.S().Infof("ENV: %s", conf.env)
		zap.S().Infof("DIR: %s", cmd.Dir)
		zap.S().Infof("CMD: %s", cmd.String())
		zap.S().Infof("COPY: pushd %s; %s %s; popd", cmd.Dir, strings.Join(conf.env, " "), cmd.String())
	}

	// Run the command and capture output
	output, err := cmd.Output()
	if err != nil {
		if exitErr, ok := err.(*exec.ExitError); ok {
			return nil, errors.Wrapf(err, "command failed with stderr: %s", string(exitErr.Stderr))
		}
		return nil, errors.Wrap(err, "running command")
	}

	return output, nil
}