package run import ( "fmt" "os/exec" "path/filepath" "regexp" "strings" ) const ( gitAuthRE = `-c credential(?:\..+)?\.helper= -c credential(?:\..+)?\.helper=!"[^"]+" auth git-credential ` ) type T interface { Helper() Errorf(string, ...interface{}) } // Stub installs a catch-all for all external commands invoked from gh. It returns a restore func that, when // invoked from tests, fails the current test if some stubs that were registered were never matched. func Stub() (*CommandStubber, func(T)) { cs := &CommandStubber{} teardown := setPrepareCmd(func(cmd *exec.Cmd) Runnable { s := cs.find(cmd.Args) if s == nil { panic(fmt.Sprintf("no exec stub for `%s`", strings.Join(cmd.Args, " "))) } for _, c := range s.callbacks { c(cmd.Args) } s.matched = true return s }) return cs, func(t T) { defer teardown() var unmatched []string for _, s := range cs.stubs { if s.matched { continue } unmatched = append(unmatched, s.pattern.String()) } if len(unmatched) == 0 { return } t.Helper() t.Errorf("unmatched exec stubs (%d): %s", len(unmatched), strings.Join(unmatched, ", ")) } } func setPrepareCmd(fn func(*exec.Cmd) Runnable) func() { origPrepare := PrepareCmd PrepareCmd = func(cmd *exec.Cmd) Runnable { // normalize git executable name for consistency in tests if baseName := filepath.Base(cmd.Args[0]); baseName == "git" || baseName == "git.exe" { cmd.Args[0] = "git" } return fn(cmd) } return func() { PrepareCmd = origPrepare } } // CommandStubber stubs out invocations to external commands. type CommandStubber struct { stubs []*commandStub } // Register a stub for an external command. Pattern is a regular expression, output is the standard output // from a command. Pass callbacks to inspect raw arguments that the command was invoked with. func (cs *CommandStubber) Register(pattern string, exitStatus int, output string, callbacks ...CommandCallback) { if len(pattern) < 1 { panic("cannot use empty regexp pattern") } if strings.HasPrefix(pattern, "git") { pattern = addGitAuthentication(pattern) } cs.stubs = append(cs.stubs, &commandStub{ pattern: regexp.MustCompile(pattern), exitStatus: exitStatus, stdout: output, callbacks: callbacks, }) } func (cs *CommandStubber) find(args []string) *commandStub { line := strings.Join(args, " ") for _, s := range cs.stubs { if !s.matched && s.pattern.MatchString(line) { return s } } return nil } type CommandCallback func([]string) type commandStub struct { pattern *regexp.Regexp matched bool exitStatus int stdout string callbacks []CommandCallback } type errWithExitCode struct { message string exitCode int } func (e errWithExitCode) Error() string { return e.message } func (e errWithExitCode) ExitCode() int { return e.exitCode } // Run satisfies Runnable func (s *commandStub) Run() error { if s.exitStatus != 0 { // It's nontrivial to construct a fake `exec.ExitError` instance, so we return an error type // that has the `ExitCode() int` method. return errWithExitCode{ message: fmt.Sprintf("%s exited with status %d", s.pattern, s.exitStatus), exitCode: s.exitStatus, } } return nil } // Output satisfies Runnable func (s *commandStub) Output() ([]byte, error) { if err := s.Run(); err != nil { return []byte(nil), err } return []byte(s.stdout), nil } // Inject git authentication string for specific git commands. func addGitAuthentication(s string) string { pattern := regexp.MustCompile(`( fetch | pull | push | clone | remote add.+-f | submodule )`) loc := pattern.FindStringIndex(s) if loc == nil { return s } return s[:loc[0]+1] + gitAuthRE + s[loc[0]+1:] }