1package oscommands
2
3import (
4	"bufio"
5	"fmt"
6	"io/ioutil"
7	"os"
8	"os/exec"
9	"path/filepath"
10	"strings"
11	"sync"
12
13	"github.com/go-errors/errors"
14
15	"github.com/atotto/clipboard"
16	"github.com/jesseduffield/lazygit/pkg/config"
17	"github.com/jesseduffield/lazygit/pkg/secureexec"
18	"github.com/jesseduffield/lazygit/pkg/utils"
19	"github.com/mgutz/str"
20	"github.com/sirupsen/logrus"
21)
22
23// Platform stores the os state
24type Platform struct {
25	OS              string
26	Shell           string
27	ShellArg        string
28	OpenCommand     string
29	OpenLinkCommand string
30}
31
32// OSCommand holds all the os commands
33type OSCommand struct {
34	Log              *logrus.Entry
35	Platform         *Platform
36	Config           config.AppConfigurer
37	Command          func(string, ...string) *exec.Cmd
38	BeforeExecuteCmd func(*exec.Cmd)
39	Getenv           func(string) string
40
41	// callback to run before running a command, i.e. for the purposes of logging
42	onRunCommand func(CmdLogEntry)
43
44	// something like 'Staging File': allows us to group cmd logs under a single title
45	CmdLogSpan string
46
47	removeFile func(string) error
48}
49
50// TODO: make these fields private
51type CmdLogEntry struct {
52	// e.g. 'git commit -m "haha"'
53	cmdStr string
54	// Span is something like 'Staging File'. Multiple commands can be grouped under the same
55	// span
56	span string
57
58	// sometimes our command is direct like 'git commit', and sometimes it's a
59	// command to remove a file but through Go's standard library rather than the
60	// command line
61	commandLine bool
62}
63
64func (e CmdLogEntry) GetCmdStr() string {
65	return e.cmdStr
66}
67
68func (e CmdLogEntry) GetSpan() string {
69	return e.span
70}
71
72func (e CmdLogEntry) GetCommandLine() bool {
73	return e.commandLine
74}
75
76func NewCmdLogEntry(cmdStr string, span string, commandLine bool) CmdLogEntry {
77	return CmdLogEntry{cmdStr: cmdStr, span: span, commandLine: commandLine}
78}
79
80// NewOSCommand os command runner
81func NewOSCommand(log *logrus.Entry, config config.AppConfigurer) *OSCommand {
82	return &OSCommand{
83		Log:              log,
84		Platform:         getPlatform(),
85		Config:           config,
86		Command:          secureexec.Command,
87		BeforeExecuteCmd: func(*exec.Cmd) {},
88		Getenv:           os.Getenv,
89		removeFile:       os.RemoveAll,
90	}
91}
92
93func (c *OSCommand) WithSpan(span string) *OSCommand {
94	// sometimes .WithSpan(span) will be called where span actually is empty, in
95	// which case we don't need to log anything so we can just return early here
96	// with the original struct
97	if span == "" {
98		return c
99	}
100
101	newOSCommand := &OSCommand{}
102	*newOSCommand = *c
103	newOSCommand.CmdLogSpan = span
104	return newOSCommand
105}
106
107func (c *OSCommand) LogExecCmd(cmd *exec.Cmd) {
108	c.LogCommand(strings.Join(cmd.Args, " "), true)
109}
110
111func (c *OSCommand) LogCommand(cmdStr string, commandLine bool) {
112	c.Log.WithField("command", cmdStr).Info("RunCommand")
113
114	if c.onRunCommand != nil && c.CmdLogSpan != "" {
115		c.onRunCommand(NewCmdLogEntry(cmdStr, c.CmdLogSpan, commandLine))
116	}
117}
118
119func (c *OSCommand) SetOnRunCommand(f func(CmdLogEntry)) {
120	c.onRunCommand = f
121}
122
123// SetCommand sets the command function used by the struct.
124// To be used for testing only
125func (c *OSCommand) SetCommand(cmd func(string, ...string) *exec.Cmd) {
126	c.Command = cmd
127}
128
129// To be used for testing only
130func (c *OSCommand) SetRemoveFile(f func(string) error) {
131	c.removeFile = f
132}
133
134func (c *OSCommand) SetBeforeExecuteCmd(cmd func(*exec.Cmd)) {
135	c.BeforeExecuteCmd = cmd
136}
137
138type RunCommandOptions struct {
139	EnvVars []string
140}
141
142func (c *OSCommand) RunCommandWithOutputWithOptions(command string, options RunCommandOptions) (string, error) {
143	c.LogCommand(command, true)
144	cmd := c.ExecutableFromString(command)
145
146	cmd.Env = append(cmd.Env, "GIT_TERMINAL_PROMPT=0") // prevents git from prompting us for input which would freeze the program
147	cmd.Env = append(cmd.Env, options.EnvVars...)
148
149	return sanitisedCommandOutput(cmd.CombinedOutput())
150}
151
152func (c *OSCommand) RunCommandWithOptions(command string, options RunCommandOptions) error {
153	_, err := c.RunCommandWithOutputWithOptions(command, options)
154	return err
155}
156
157// RunCommandWithOutput wrapper around commands returning their output and error
158// NOTE: If you don't pass any formatArgs we'll just use the command directly,
159// however there's a bizarre compiler error/warning when you pass in a formatString
160// with a percent sign because it thinks it's supposed to be a formatString when
161// in that case it's not. To get around that error you'll need to define the string
162// in a variable and pass the variable into RunCommandWithOutput.
163func (c *OSCommand) RunCommandWithOutput(formatString string, formatArgs ...interface{}) (string, error) {
164	command := formatString
165	if formatArgs != nil {
166		command = fmt.Sprintf(formatString, formatArgs...)
167	}
168	cmd := c.ExecutableFromString(command)
169	c.LogExecCmd(cmd)
170	output, err := sanitisedCommandOutput(cmd.CombinedOutput())
171	if err != nil {
172		c.Log.WithField("command", command).Error(output)
173	}
174	return output, err
175}
176
177// RunExecutableWithOutput runs an executable file and returns its output
178func (c *OSCommand) RunExecutableWithOutput(cmd *exec.Cmd) (string, error) {
179	c.LogExecCmd(cmd)
180	c.BeforeExecuteCmd(cmd)
181	return sanitisedCommandOutput(cmd.CombinedOutput())
182}
183
184// RunExecutable runs an executable file and returns an error if there was one
185func (c *OSCommand) RunExecutable(cmd *exec.Cmd) error {
186	_, err := c.RunExecutableWithOutput(cmd)
187	return err
188}
189
190// ExecutableFromString takes a string like `git status` and returns an executable command for it
191func (c *OSCommand) ExecutableFromString(commandStr string) *exec.Cmd {
192	splitCmd := str.ToArgv(commandStr)
193	cmd := c.Command(splitCmd[0], splitCmd[1:]...)
194	cmd.Env = append(os.Environ(), "GIT_OPTIONAL_LOCKS=0")
195	return cmd
196}
197
198// ShellCommandFromString takes a string like `git commit` and returns an executable shell command for it
199func (c *OSCommand) ShellCommandFromString(commandStr string) *exec.Cmd {
200	quotedCommand := ""
201	// Windows does not seem to like quotes around the command
202	if c.Platform.OS == "windows" {
203		quotedCommand = strings.NewReplacer(
204			"^", "^^",
205			"&", "^&",
206			"|", "^|",
207			"<", "^<",
208			">", "^>",
209			"%", "^%",
210		).Replace(commandStr)
211	} else {
212		quotedCommand = c.Quote(commandStr)
213	}
214
215	shellCommand := fmt.Sprintf("%s %s %s", c.Platform.Shell, c.Platform.ShellArg, quotedCommand)
216	return c.ExecutableFromString(shellCommand)
217}
218
219// RunCommand runs a command and just returns the error
220func (c *OSCommand) RunCommand(formatString string, formatArgs ...interface{}) error {
221	_, err := c.RunCommandWithOutput(formatString, formatArgs...)
222	return err
223}
224
225// RunShellCommand runs shell commands i.e. 'sh -c <command>'. Good for when you
226// need access to the shell
227func (c *OSCommand) RunShellCommand(command string) error {
228	cmd := c.ShellCommandFromString(command)
229	c.LogExecCmd(cmd)
230
231	_, err := sanitisedCommandOutput(cmd.CombinedOutput())
232
233	return err
234}
235
236// FileType tells us if the file is a file, directory or other
237func (c *OSCommand) FileType(path string) string {
238	fileInfo, err := os.Stat(path)
239	if err != nil {
240		return "other"
241	}
242	if fileInfo.IsDir() {
243		return "directory"
244	}
245	return "file"
246}
247
248func sanitisedCommandOutput(output []byte, err error) (string, error) {
249	outputString := string(output)
250	if err != nil {
251		// errors like 'exit status 1' are not very useful so we'll create an error
252		// from the combined output
253		if outputString == "" {
254			return "", utils.WrapError(err)
255		}
256		return outputString, errors.New(outputString)
257	}
258	return outputString, nil
259}
260
261// OpenFile opens a file with the given
262func (c *OSCommand) OpenFile(filename string) error {
263	commandTemplate := c.Config.GetUserConfig().OS.OpenCommand
264	templateValues := map[string]string{
265		"filename": c.Quote(filename),
266	}
267	command := utils.ResolvePlaceholderString(commandTemplate, templateValues)
268	err := c.RunShellCommand(command)
269	return err
270}
271
272// OpenLink opens a file with the given
273func (c *OSCommand) OpenLink(link string) error {
274	c.LogCommand(fmt.Sprintf("Opening link '%s'", link), false)
275	commandTemplate := c.Config.GetUserConfig().OS.OpenLinkCommand
276	templateValues := map[string]string{
277		"link": c.Quote(link),
278	}
279
280	command := utils.ResolvePlaceholderString(commandTemplate, templateValues)
281	err := c.RunShellCommand(command)
282	return err
283}
284
285// PrepareSubProcess iniPrepareSubProcessrocess then tells the Gui to switch to it
286// TODO: see if this needs to exist, given that ExecutableFromString does the same things
287func (c *OSCommand) PrepareSubProcess(cmdName string, commandArgs ...string) *exec.Cmd {
288	cmd := c.Command(cmdName, commandArgs...)
289	if cmd != nil {
290		cmd.Env = append(os.Environ(), "GIT_OPTIONAL_LOCKS=0")
291	}
292	c.LogExecCmd(cmd)
293	return cmd
294}
295
296// PrepareShellSubProcess returns the pointer to a custom command
297func (c *OSCommand) PrepareShellSubProcess(command string) *exec.Cmd {
298	return c.PrepareSubProcess(c.Platform.Shell, c.Platform.ShellArg, command)
299}
300
301// Quote wraps a message in platform-specific quotation marks
302func (c *OSCommand) Quote(message string) string {
303	var quote string
304	if c.Platform.OS == "windows" {
305		quote = `\"`
306		message = strings.NewReplacer(
307			`"`, `"'"'"`,
308			`\"`, `\\"`,
309		).Replace(message)
310	} else {
311		quote = `"`
312		message = strings.NewReplacer(
313			`\`, `\\`,
314			`"`, `\"`,
315			`$`, `\$`,
316			"`", "\\`",
317		).Replace(message)
318	}
319	return quote + message + quote
320}
321
322// AppendLineToFile adds a new line in file
323func (c *OSCommand) AppendLineToFile(filename, line string) error {
324	c.LogCommand(fmt.Sprintf("Appending '%s' to file '%s'", line, filename), false)
325	f, err := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0600)
326	if err != nil {
327		return utils.WrapError(err)
328	}
329	defer f.Close()
330
331	_, err = f.WriteString("\n" + line)
332	if err != nil {
333		return utils.WrapError(err)
334	}
335	return nil
336}
337
338// CreateTempFile writes a string to a new temp file and returns the file's name
339func (c *OSCommand) CreateTempFile(filename, content string) (string, error) {
340	tmpfile, err := ioutil.TempFile("", filename)
341	if err != nil {
342		c.Log.Error(err)
343		return "", utils.WrapError(err)
344	}
345	c.LogCommand(fmt.Sprintf("Creating temp file '%s'", tmpfile.Name()), false)
346
347	if _, err := tmpfile.WriteString(content); err != nil {
348		c.Log.Error(err)
349		return "", utils.WrapError(err)
350	}
351	if err := tmpfile.Close(); err != nil {
352		c.Log.Error(err)
353		return "", utils.WrapError(err)
354	}
355
356	return tmpfile.Name(), nil
357}
358
359// CreateFileWithContent creates a file with the given content
360func (c *OSCommand) CreateFileWithContent(path string, content string) error {
361	c.LogCommand(fmt.Sprintf("Creating file '%s'", path), false)
362	if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil {
363		c.Log.Error(err)
364		return err
365	}
366
367	if err := ioutil.WriteFile(path, []byte(content), 0644); err != nil {
368		c.Log.Error(err)
369		return utils.WrapError(err)
370	}
371
372	return nil
373}
374
375// Remove removes a file or directory at the specified path
376func (c *OSCommand) Remove(filename string) error {
377	c.LogCommand(fmt.Sprintf("Removing '%s'", filename), false)
378	err := os.RemoveAll(filename)
379	return utils.WrapError(err)
380}
381
382// FileExists checks whether a file exists at the specified path
383func (c *OSCommand) FileExists(path string) (bool, error) {
384	if _, err := os.Stat(path); err != nil {
385		if os.IsNotExist(err) {
386			return false, nil
387		}
388		return false, err
389	}
390	return true, nil
391}
392
393// RunPreparedCommand takes a pointer to an exec.Cmd and runs it
394// this is useful if you need to give your command some environment variables
395// before running it
396func (c *OSCommand) RunPreparedCommand(cmd *exec.Cmd) error {
397	c.BeforeExecuteCmd(cmd)
398	c.LogExecCmd(cmd)
399	out, err := cmd.CombinedOutput()
400	outString := string(out)
401	c.Log.Info(outString)
402	if err != nil {
403		if len(outString) == 0 {
404			return err
405		}
406		return errors.New(outString)
407	}
408	return nil
409}
410
411// GetLazygitPath returns the path of the currently executed file
412func (c *OSCommand) GetLazygitPath() string {
413	ex, err := os.Executable() // get the executable path for git to use
414	if err != nil {
415		ex = os.Args[0] // fallback to the first call argument if needed
416	}
417	return `"` + filepath.ToSlash(ex) + `"`
418}
419
420// PipeCommands runs a heap of commands and pipes their inputs/outputs together like A | B | C
421func (c *OSCommand) PipeCommands(commandStrings ...string) error {
422	cmds := make([]*exec.Cmd, len(commandStrings))
423	logCmdStr := ""
424	for i, str := range commandStrings {
425		if i > 0 {
426			logCmdStr += " | "
427		}
428		logCmdStr += str
429		cmds[i] = c.ExecutableFromString(str)
430	}
431	c.LogCommand(logCmdStr, true)
432
433	for i := 0; i < len(cmds)-1; i++ {
434		stdout, err := cmds[i].StdoutPipe()
435		if err != nil {
436			return err
437		}
438
439		cmds[i+1].Stdin = stdout
440	}
441
442	// keeping this here in case I adapt this code for some other purpose in the future
443	// cmds[len(cmds)-1].Stdout = os.Stdout
444
445	finalErrors := []string{}
446
447	wg := sync.WaitGroup{}
448	wg.Add(len(cmds))
449
450	for _, cmd := range cmds {
451		currentCmd := cmd
452		go utils.Safe(func() {
453			stderr, err := currentCmd.StderrPipe()
454			if err != nil {
455				c.Log.Error(err)
456			}
457
458			if err := currentCmd.Start(); err != nil {
459				c.Log.Error(err)
460			}
461
462			if b, err := ioutil.ReadAll(stderr); err == nil {
463				if len(b) > 0 {
464					finalErrors = append(finalErrors, string(b))
465				}
466			}
467
468			if err := currentCmd.Wait(); err != nil {
469				c.Log.Error(err)
470			}
471
472			wg.Done()
473		})
474	}
475
476	wg.Wait()
477
478	if len(finalErrors) > 0 {
479		return errors.New(strings.Join(finalErrors, "\n"))
480	}
481	return nil
482}
483
484func Kill(cmd *exec.Cmd) error {
485	if cmd.Process == nil {
486		// somebody got to it before we were able to, poor bastard
487		return nil
488	}
489	return cmd.Process.Kill()
490}
491
492func RunLineOutputCmd(cmd *exec.Cmd, onLine func(line string) (bool, error)) error {
493	stdoutPipe, err := cmd.StdoutPipe()
494	if err != nil {
495		return err
496	}
497
498	scanner := bufio.NewScanner(stdoutPipe)
499	scanner.Split(bufio.ScanLines)
500	if err := cmd.Start(); err != nil {
501		return err
502	}
503
504	for scanner.Scan() {
505		line := scanner.Text()
506		stop, err := onLine(line)
507		if err != nil {
508			return err
509		}
510		if stop {
511			_ = cmd.Process.Kill()
512			break
513		}
514	}
515
516	_ = cmd.Wait()
517
518	return nil
519}
520
521func (c *OSCommand) CopyToClipboard(str string) error {
522	escaped := strings.Replace(str, "\n", "\\n", -1)
523	truncated := utils.TruncateWithEllipsis(escaped, 40)
524	c.LogCommand(fmt.Sprintf("Copying '%s' to clipboard", truncated), false)
525	return clipboard.WriteAll(str)
526}
527
528func (c *OSCommand) RemoveFile(path string) error {
529	c.LogCommand(fmt.Sprintf("Deleting path '%s'", path), false)
530
531	return c.removeFile(path)
532}
533
534func (c *OSCommand) NewCmdObjFromStr(cmdStr string) ICmdObj {
535	args := str.ToArgv(cmdStr)
536	cmd := c.Command(args[0], args[1:]...)
537	cmd.Env = os.Environ()
538
539	return &CmdObj{
540		cmdStr: cmdStr,
541		cmd:    cmd,
542	}
543}
544
545func (c *OSCommand) NewCmdObjFromArgs(args []string) ICmdObj {
546	cmd := c.Command(args[0], args[1:]...)
547
548	return &CmdObj{
549		cmdStr: strings.Join(args, " "),
550		cmd:    cmd,
551	}
552}
553
554func (c *OSCommand) NewCmdObj(cmd *exec.Cmd) ICmdObj {
555	return &CmdObj{
556		cmdStr: strings.Join(cmd.Args, " "),
557		cmd:    cmd,
558	}
559}
560