diff options
| -rw-r--r-- | main.go | 14 | ||||
| -rw-r--r-- | pkg/cli/command.go (renamed from pkg/util/util.go) | 56 | ||||
| -rw-r--r-- | pkg/cli/interface.go | 157 | ||||
| -rw-r--r-- | pkg/cli/query.go | 77 | ||||
| -rw-r--r-- | pkg/cli/ui.go | 2 | ||||
| -rw-r--r-- | pkg/rtree/index.go | 146 | ||||
| -rw-r--r-- | pkg/rtree/main.go | 114 | ||||
| -rw-r--r-- | pkg/rtree/repo.go | 77 |
8 files changed, 415 insertions, 228 deletions
@@ -23,26 +23,32 @@ import ( "os" "path/filepath" + "github.com/majewsky/gofu/pkg/cli" "github.com/majewsky/gofu/pkg/rtree" ) func main() { - os.Exit(execApplet(filepath.Base(os.Args[0]), os.Args[1:], true)) + ci, err := cli.NewInterface(os.Stdin, os.Stdout, os.Stderr) + if err != nil { + fmt.Fprintln(os.Stderr, "FATAL: initialization failed") + os.Exit(255) + } + os.Exit(execApplet(ci, filepath.Base(os.Args[0]), os.Args[1:], true)) } -func execApplet(applet string, args []string, allowGofu bool) int { +func execApplet(ci *cli.Interface, applet string, args []string, allowGofu bool) int { //allow explicit specification of applet as "./build/gofu <applet> <args>" if allowGofu && applet == "gofu" { if len(args) == 0 { fmt.Fprintln(os.Stderr, "Usage: gofu <applet> [args...]") return 1 } - return execApplet(args[0], args[1:], false) + return execApplet(ci, args[0], args[1:], false) } switch applet { case "rtree": - return rtree.Exec(args) + return rtree.Exec(ci, args) default: fmt.Fprintln(os.Stderr, "ERROR: unknown applet: "+applet) return 255 diff --git a/pkg/util/util.go b/pkg/cli/command.go index 8cdf147..5b3ded5 100644 --- a/pkg/util/util.go +++ b/pkg/cli/command.go @@ -16,44 +16,48 @@ * *******************************************************************************/ -package util +package cli import ( - "bufio" "fmt" - "os" - "sort" + "io" + "os/exec" "strings" ) -//ShowSorted sorts the given lines and prints them on stdout. -func ShowSorted(lines []string) { - sort.Strings(lines) - fmt.Println(strings.Join(lines, "\n")) +//Command describes a command that can be run using the methods in the +//Interface interface. +type Command struct { + Program []string + WorkDir string } -//ShowError prints the given error on stderr if it is non-nil, or returns false otherwise. -func ShowError(err error) bool { - if err == nil { - return false - } - fmt.Fprintf(os.Stderr, "ERROR: %s\n", err.Error()) - return true +type commandError struct { + Cmd Command + Err error } -//FatalIfError prints the given error on stderr and exits with an error code. -func FatalIfError(err error) { - if err != nil { - fmt.Fprintf(os.Stderr, "FATAL: %s\n", err.Error()) - os.Exit(255) +func (e commandError) Error() string { + cmdline := strings.Join(e.Cmd.Program, " ") + if e.Cmd.WorkDir == "" { + return fmt.Sprintf("exec `%s`: %s", + cmdline, e.Err.Error(), + ) } + return fmt.Sprintf("exec `%s` in %s: %s", + cmdline, e.Cmd.WorkDir, e.Err.Error(), + ) } -var stdin = bufio.NewReader(os.Stdin) +func (c Command) run(stdout, stderr io.Writer) error { + cmd := exec.Command(c.Program[0], c.Program[1:]...) + cmd.Stdout = stdout + cmd.Stderr = stderr + cmd.Dir = c.WorkDir -//ReadLine reads a line from stdin, with whitespace already trimmed. -func ReadLine() string { - input, err := stdin.ReadString('\n') - FatalIfError(err) - return strings.TrimSpace(input) + err := cmd.Run() + if err != nil { + err = commandError{c, err} + } + return err } diff --git a/pkg/cli/interface.go b/pkg/cli/interface.go new file mode 100644 index 0000000..7ebde9d --- /dev/null +++ b/pkg/cli/interface.go @@ -0,0 +1,157 @@ +/******************************************************************************* +* +* Copyright 2017 Stefan Majewsky <majewsky@gmx.net> +* +* This program is free software: you can redistribute it and/or modify it under +* the terms of the GNU General Public License as published by the Free Software +* Foundation, either version 3 of the License, or (at your option) any later +* version. +* +* This program is distributed in the hope that it will be useful, but WITHOUT ANY +* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +* A PARTICULAR PURPOSE. See the GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License along with +* this program. If not, see <http://www.gnu.org/licenses/>. +* +*******************************************************************************/ + +package cli + +import ( + "bufio" + "bytes" + "io" + "os" + "sort" + "strings" +) + +//NewInterface creates an Interface instance. +func NewInterface(stdin, stdout, stderr *os.File) (*Interface, error) { + //TODO: check terminal.IsTerminal(int(stdin.Fd())) and choose the TUI instance accordingly + return &Interface{ + stdin: stdin, + stdout: stdout, + stderr: stderr, + stdinBuf: bufio.NewReader(stdin), + tui: &terminalTUI{}, + }, nil +} + +//Interface wraps access to the CLI, including input, output and subprocesses. +type Interface struct { + //TODO: flag isStdinTerminal that disables color output and swaps out the TUI instance + stdin io.Reader + stdout io.Writer + stderr io.Writer + stdinBuf *bufio.Reader + tui TUI + //If this flag is set, only ShowResult() will write into stdout; everything + //else that usually goes to stdout goes to stderr instead. + // + //This is useful when gofu is expected to output a certain value to stdout + //which is used by the next program in the pipe, and additional output from + //subprocesses could confuse the stdout handler. + StdoutProtected bool +} + +//TUI provides the interactive parts of the cli.Interface, so that these can be +//easily swapped out for mock implementations in unit tests. +type TUI interface { + //ReadLine reads a line from stdin (if tty: uses canonical mode). + ReadLine(prompt string) (string, error) + //Confirm displays a yes/no question and returns whether the user answered "yes". + Confirm(question string) (bool, error) + //Query displays a question and a set of answers and allows the user to select + //one of the answers. Returns the Return attribute of the selected Choice. + Query(prompt string, choices ...Choice) (string, error) +} + +func (i *Interface) safeStdout() io.Writer { + if i.StdoutProtected { + return i.stderr + } + return i.stdout +} + +//////////////////////////////////////////////////////////////////////////////// +// input + +//ReadLine reads a line from stdin (if tty: uses canonical mode). +func (i *Interface) ReadLine(prompt string) (string, error) { + return i.tui.ReadLine(prompt) +} + +//Confirm displays a yes/no question and returns whether the user answered "yes". +func (i *Interface) Confirm(question string) (bool, error) { + return i.tui.Confirm(question) +} + +//Query displays a question and a set of answers and allows the user to select +//one of the answers. Returns the Return attribute of the selected Choice. +func (i *Interface) Query(prompt string, choices ...Choice) (string, error) { + return i.tui.Query(prompt, choices...) +} + +//////////////////////////////////////////////////////////////////////////////// +// subprocesses + +//Run executes the given command on the same stdout and stderr. +func (i *Interface) Run(c Command) error { + return c.run(i.safeStdout(), i.stderr) +} + +//CaptureStdout executes the given command on the same stderr and captures its stdout. +func (i *Interface) CaptureStdout(c Command) (string, error) { + var buf bytes.Buffer + err := c.run(&buf, i.stderr) + return string(buf.Bytes()), err +} + +//////////////////////////////////////////////////////////////////////////////// +// output + +//ShowResult displays the result of a computation on stdout. +func (i *Interface) ShowResult(str string) { + str = strings.TrimSpace(str) + "\n" + i.stdout.Write([]byte(str)) +} + +//ShowResultsSorted calls ShowResult() on each of the results after sorting them. +func (i *Interface) ShowResultsSorted(strs []string) { + sort.Strings(strs) + for _, str := range strs { + i.ShowResult(str) + } +} + +//ShowProgress displays a progress message on stderr. +func (i *Interface) ShowProgress(str string) { + i.stderr.Write(StyledText{ + Styled(">> ", AnsiNormal, AnsiBold, AnsiCyan), + Styled(strings.TrimSpace(str)+"\n", AnsiCyan), + }.DisplayString(true)) +} + +//ShowWarning displays a warning message on stderr. +func (i *Interface) ShowWarning(str string) { + i.stderr.Write(StyledText{ + Styled("!! ", AnsiNormal, AnsiBold, AnsiYellow), + Styled(strings.TrimSpace(str)+"\n", AnsiYellow), + }.DisplayString(true)) +} + +//ShowError displays an error message on stderr. +func (i *Interface) ShowError(err string) { + i.stderr.Write(StyledText{ + Styled("!! ", AnsiNormal, AnsiBold, AnsiRed), + Styled(strings.TrimSpace(err)+"\n", AnsiRed), + }.DisplayString(true)) +} + +//ShowUsage displays a usage synopsis on stderr. +func (i *Interface) ShowUsage(str string) { + str = strings.TrimSpace(str) + "\n" + i.stderr.Write([]byte(str)) +} diff --git a/pkg/cli/query.go b/pkg/cli/query.go index 40baaec..1f5d16c 100644 --- a/pkg/cli/query.go +++ b/pkg/cli/query.go @@ -21,29 +21,47 @@ package cli import ( "fmt" "io" - "os" "regexp" "strings" terminal "golang.org/x/crypto/ssh/terminal" ) -//Confirm displays a yes/no question and returns whether the user answered "yes". -func Confirm(question string) bool { - os.Stdout.Write([]byte(strings.TrimSpace(question) + " [y/n] ")) +//cannot use `var errInterrupted = errors.New("Interrupted!")` because golint +//complains about the formatting of the error message +type errInterrupted struct{} - buf := buffer{Input: os.Stdin} +func (e errInterrupted) Error() string { + return "Interrupted!" +} + +type terminalTUI struct { + i *Interface +} + +func (t terminalTUI) ReadLine(prompt string) (string, error) { + if prompt != "" { + t.i.safeStdout().Write([]byte(strings.TrimSpace(prompt) + " ")) + } + str, err := t.i.stdinBuf.ReadString('\n') + return strings.TrimSpace(str), err +} + +func (t terminalTUI) Confirm(question string) (bool, error) { + out := t.i.safeStdout() + out.Write([]byte(strings.TrimSpace(question) + " [y/n] ")) + + buf := buffer{Input: t.i.stdin} for { switch string(buf.getNextInput()) { case "y", "Y": - os.Stdout.Write([]byte("-> yes\n")) - return true + out.Write([]byte("-> yes\n")) + return true, nil case "n", "N": - os.Stdout.Write([]byte("-> no\n")) - return false + out.Write([]byte("-> no\n")) + return false, nil case "\x03": // Ctrl-C - fmt.Fprintln(os.Stderr, "\nInterrupted!") - os.Exit(255) + return false, errInterrupted{} } } } @@ -55,32 +73,32 @@ type Choice struct { Shortcut byte //The display string that describes this choice. Text string + //The string to return from Interface.Query(). + Return string } func (c Choice) hasShortcut() bool { return c.Shortcut != '\000' } -//Query displays a question and a set of answers and allows the user to select -//one of the answers. Returns the selected Choice instance, as well as its -//index in the original choices list (starting from 0). -func Query(prompt string, choices ...Choice) (Choice, int) { +func (t terminalTUI) Query(prompt string, choices ...Choice) (string, error) { if len(choices) == 0 { - return Choice{}, -1 + panic("no choices") } //disable line wrap; unexpected wrapping would confuse our cursor-moving code - os.Stdout.Write([]byte("\x1B[?7l")) - defer os.Stdout.Write([]byte("\x1B[?7h")) + out := t.i.safeStdout() + out.Write([]byte("\x1B[?7l")) + defer out.Write([]byte("\x1B[?7h")) //display question - os.Stdout.Write([]byte(strings.TrimSuffix(prompt, "\n") + "\n")) + out.Write([]byte(strings.TrimSuffix(prompt, "\n") + "\n")) selected := 0 - buf := buffer{Input: os.Stdin} + buf := buffer{Input: t.i.stdin} OUTER: for { - displayChoices(os.Stdout, choices, selected) + displayChoices(out, choices, selected) input := buf.getNextInput() if len(input) == 1 { @@ -107,29 +125,28 @@ OUTER: selected = len(choices) - 1 } case "\x03": // Ctrl-C - fmt.Fprintln(os.Stderr, "Interrupted!") - os.Exit(255) + return "", errInterrupted{} } //prepare to re-render choices - removeDisplayLines(len(choices)) + removeDisplayLines(out, len(choices)) } //clear query display - removeDisplayLines(len(choices) + 1) + removeDisplayLines(out, len(choices)+1) //display question + chosen answer - fmt.Fprintf(os.Stdout, "%s -> %s\n", + fmt.Fprintf(out, "%s -> %s\n", strings.TrimSuffix(prompt, "\n"), strings.TrimSpace(choices[selected].Text), ) - return choices[selected], selected + return choices[selected].Return, nil } -func removeDisplayLines(n int) { +func removeDisplayLines(stdout io.Writer, n int) { for idx := 0; idx < n; idx++ { - os.Stdout.Write([]byte("\x1B[A\x1B[2K")) + stdout.Write([]byte("\x1B[A\x1B[2K")) } } @@ -193,7 +210,7 @@ func (b *buffer) getNextInput() []byte { defer terminal.Restore(0, oldState) //fill buffer some more - n, err := os.Stdin.Read(b.buf[b.fill:]) + n, err := b.Input.Read(b.buf[b.fill:]) if err != nil { panic(err) } diff --git a/pkg/cli/ui.go b/pkg/cli/ui.go index c4eff69..60ae553 100644 --- a/pkg/cli/ui.go +++ b/pkg/cli/ui.go @@ -29,6 +29,8 @@ type AnsiStyle string const ( //AnsiNormal reverts all styles to default. AnsiNormal AnsiStyle = "0" + //AnsiBold makes the text bold. + AnsiBold AnsiStyle = "1" //AnsiInverse swaps foreground and background color. AnsiInverse AnsiStyle = "7" //AnsiBlack is a foreground color. diff --git a/pkg/rtree/index.go b/pkg/rtree/index.go index dd1b979..c2c0202 100644 --- a/pkg/rtree/index.go +++ b/pkg/rtree/index.go @@ -23,14 +23,12 @@ import ( "fmt" "io/ioutil" "os" - "os/exec" "path" "path/filepath" "sort" "strings" "github.com/majewsky/gofu/pkg/cli" - "github.com/majewsky/gofu/pkg/util" yaml "gopkg.in/yaml.v2" ) @@ -102,7 +100,7 @@ func (r reposByAbsPath) Less(i, j int) bool { return r[i].AbsolutePath() < r[j]. func (r reposByAbsPath) Swap(i, j int) { r[i], r[j] = r[j], r[i] } //Write writes the index file to disk. -func (i *Index) Write() error { +func (i *Index) Write(ci *cli.Interface) error { buf, err := yaml.Marshal(i) if err != nil { return err @@ -123,7 +121,9 @@ func (i *Index) Write() error { warned := make(map[string]bool) for _, repo := range i.Repos { if seen[repo.CheckoutPath] && !warned[repo.CheckoutPath] { - fmt.Fprintf(os.Stderr, "warning: repo %s appears multiple times in the index file!\n", repo.AbsolutePath()) + ci.ShowWarning( + fmt.Sprintf("repo %s appears multiple times in the index file!", repo.AbsolutePath()), + ) warned[repo.CheckoutPath] = true } seen[repo.CheckoutPath] = true @@ -132,8 +132,8 @@ func (i *Index) Write() error { return nil } -//InteractiveRebuild implements the `rtree index` subcommand. -func (i *Index) InteractiveRebuild() error { +//Rebuild implements the `rtree index` subcommand. +func (i *Index) Rebuild(ci *cli.Interface) error { //check if existing index entries are still checked out var newRepos []*Repo for _, repo := range i.Repos { @@ -161,32 +161,35 @@ func (i *Index) InteractiveRebuild() error { remoteURLs = append(remoteURLs, remote.URL) } - var choice cli.Choice + var selection string if len(remoteURLs) == 0 { - choice, _ = cli.Query( + selection, err = ci.Query( fmt.Sprintf("repository %s has been deleted; no remote to restore from", filepath.Join(RootPath, repo.CheckoutPath)), - cli.Choice{Shortcut: 'd', Text: "delete from index"}, - cli.Choice{Shortcut: 's', Text: "skip"}, + cli.Choice{Return: "d", Shortcut: 'd', Text: "delete from index"}, + cli.Choice{Return: "s", Shortcut: 's', Text: "skip"}, ) } else { - choice, _ = cli.Query( + selection, err = ci.Query( fmt.Sprintf("repository %s has been deleted", filepath.Join(RootPath, repo.CheckoutPath)), - cli.Choice{Shortcut: 'r', Text: "(r)estore from " + strings.Join(remoteURLs, " and ")}, - cli.Choice{Shortcut: 'd', Text: "delete from index"}, - cli.Choice{Shortcut: 's', Text: "skip"}, + cli.Choice{Return: "r", Shortcut: 'r', Text: "(r)estore from " + strings.Join(remoteURLs, " and ")}, + cli.Choice{Return: "d", Shortcut: 'd', Text: "delete from index"}, + cli.Choice{Return: "s", Shortcut: 's', Text: "skip"}, ) } + if err != nil { + return err + } - switch choice.Shortcut { - case 'r': - err := repo.Checkout() + switch selection { + case "r": + err := repo.Checkout(ci) if err != nil { return err } newRepos = append(newRepos, repo) - case 'd': + case "d": continue - case 's': + case "s": newRepos = append(newRepos, repo) } } @@ -197,7 +200,7 @@ func (i *Index) InteractiveRebuild() error { } //index new repos - err := ForeachPhysicalRepo(func(newRepo Repo) error { + err := ForeachPhysicalRepo(ci, func(newRepo Repo) error { repo, exists := existingRepos[newRepo.CheckoutPath] if exists { //update the existing index entry with the new remotes @@ -215,12 +218,10 @@ func (i *Index) InteractiveRebuild() error { return nil } -var tenLetters = []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j"} - -//InteractiveFindRepo locates the repo with the given remote if it exists on -//disk or (if allowClone is set) clones it and adds it to the index. This -//is the meat of `rtree get`, and is also used by `rtree drop`. -func (i *Index) InteractiveFindRepo(remoteURL string, allowClone bool) (*Repo, error) { +//FindRepo locates the repo with the given remote if it exists on disk or (if +//allowClone is set) clones it and adds it to the index. This is the meat of +//`rtree get`, and is also used by `rtree drop`. +func (i *Index) FindRepo(ci *cli.Interface, remoteURL string, allowClone bool) (*Repo, error) { //make sure that stdout is not used for prompts originalStdout := os.Stdout os.Stdout = os.Stderr @@ -255,7 +256,7 @@ func (i *Index) InteractiveFindRepo(remoteURL string, allowClone bool) (*Repo, e if err != nil { return nil, err } - fi, err := os.Stat(newRepo.AbsolutePath()) + _, err = os.Stat(newRepo.AbsolutePath()) switch { case err == nil: return nil, fmt.Errorf( @@ -272,12 +273,12 @@ func (i *Index) InteractiveFindRepo(remoteURL string, allowClone bool) (*Repo, e //if no fork candidates found, clone as new repo if len(candidates) == 0 { - err := newRepo.Checkout() + err := newRepo.Checkout(ci) if err != nil { return nil, err } i.Repos = append(i.Repos, &newRepo) - i.Write() + i.Write(ci) return &newRepo, nil } @@ -288,49 +289,60 @@ func (i *Index) InteractiveFindRepo(remoteURL string, allowClone bool) (*Repo, e } choices := make([]cli.Choice, len(candidates)+1) for idx, repo := range candidates { - choices[idx] = cli.Choice{Text: "add as remote to " + repo.AbsolutePath()} + choices[idx] = cli.Choice{Text: "add as remote to " + repo.AbsolutePath(), Return: repo.CheckoutPath} } choices[len(candidates)] = cli.Choice{ + Return: "clone", Shortcut: 'n', Text: "clone to " + newRepo.AbsolutePath(), } - choice, choiceIdx := cli.Query("Found possible fork candidates. What to do?", choices...) + selection, err := ci.Query("Found possible fork candidates. What to do?", choices...) + if err != nil { + return nil, err + } - if choice.Shortcut == 'n' { - err := newRepo.Checkout() + if selection == "clone" { + err := newRepo.Checkout(ci) if err != nil { return nil, err } i.Repos = append(i.Repos, &newRepo) - i.Write() + i.Write(ci) return &newRepo, nil } //find the repo selected by the user - target := candidates[choiceIdx] + var target *Repo + for _, repo := range candidates { + if target.CheckoutPath == selection { + target = repo + break + } + } //report the existing remotes, and ask for the name of the new remote - fmt.Println("Existing remotes:") + prompt := "Existing remotes:\n" for _, remote := range target.Remotes { - fmt.Printf("\t(%s) %s\n", remote.Name, remote.URL) + prompt += fmt.Sprintf("\t(%s) %s\n", remote.Name, remote.URL) + } + prompt += fmt.Sprintf("Enter remote name for %s:", remoteURL) + remoteName, err := ci.ReadLine(prompt) + if err != nil { + return nil, err } - fmt.Printf("Enter remote name for %s: ", remoteURL) - remoteName := util.ReadLine() - cmd := exec.Command("git", "remote", "add", remoteName, remoteURL) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmd.Dir = target.AbsolutePath() - err = cmd.Run() + err = ci.Run(cli.Command{ + Program: []string{"git", "remote", "add", remoteName, remoteURL}, + WorkDir: target.AbsolutePath(), + }) if err != nil { return nil, err } - cmd = exec.Command("git", "remote", "update", remoteName) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmd.Dir = target.AbsolutePath() - err = cmd.Run() + err = ci.Run(cli.Command{ + Program: []string{"git", "remote", "update", remoteName}, + WorkDir: target.AbsolutePath(), + }) if err != nil { return nil, err } @@ -339,18 +351,18 @@ func (i *Index) InteractiveFindRepo(remoteURL string, allowClone bool) (*Repo, e Name: remoteName, URL: remoteURL, }) - i.Write() + i.Write(ci) return target, nil } -//InteractiveImportRepo moves the given repo into the rtree and adds it to the index. -func (i *Index) InteractiveImportRepo(dirPath string) error { +//ImportRepo moves the given repo into the rtree and adds it to the index. +func (i *Index) ImportRepo(ci *cli.Interface, dirPath string) error { //need to make dirPath absolute first dirPath, err := filepath.Abs(dirPath) if err != nil { return err } - repo, err := NewRepoFromAbsolutePath(dirPath) + repo, err := NewRepoFromAbsolutePath(ci, dirPath) if err != nil { return err } @@ -373,7 +385,7 @@ func (i *Index) InteractiveImportRepo(dirPath string) error { checkoutPath = thisPath break } - choices[idx] = cli.Choice{Text: thisPath} + choices[idx] = cli.Choice{Return: thisPath, Text: thisPath} } //cannot decide myself -> let the user select @@ -383,8 +395,10 @@ func (i *Index) InteractiveImportRepo(dirPath string) error { } question := fmt.Sprintf("Repo has multiple remotes. Where to put below %s?", RootPath) - choice, _ := cli.Query(question, choices...) - checkoutPath = choice.Text + checkoutPath, err = ci.Query(question, choices...) + if err != nil { + return err + } } //double-check that there is no such repo in the rtree yet @@ -403,18 +417,18 @@ func (i *Index) InteractiveImportRepo(dirPath string) error { return nil } -//InteractiveDropRepo deletes the given repo from the rtree and removes it from -//the index. -func (i *Index) InteractiveDropRepo(repo *Repo) error { - ok := repo.InteractiveExec("git", "status") - if !ok { - return nil +//DropRepo deletes the given repo from the rtree and removes it from the index. +func (i *Index) DropRepo(ci *cli.Interface, repo *Repo) error { + err := repo.Exec(ci, "git", "status") + if err != nil { + return err } - if !cli.Confirm(">> Drop this repo?") { - return nil + ok, err := ci.Confirm(">> Drop this repo?") + if !ok || err != nil { + return err } - err := os.RemoveAll(repo.AbsolutePath()) + err = os.RemoveAll(repo.AbsolutePath()) if err != nil { return err } @@ -426,5 +440,5 @@ func (i *Index) InteractiveDropRepo(repo *Repo) error { } } i.Repos = reposNew - return nil + return i.Write(ci) } diff --git a/pkg/rtree/main.go b/pkg/rtree/main.go index d57b399..42e540e 100644 --- a/pkg/rtree/main.go +++ b/pkg/rtree/main.go @@ -19,150 +19,146 @@ package rtree import ( - "fmt" - "os" + "strings" - "github.com/majewsky/gofu/pkg/util" + "github.com/majewsky/gofu/pkg/cli" ) //Exec executes the rtree applet and does not return. The argument is os.Args //minus the leading "rtree" or "gofu rtree". -func Exec(args []string) int { +func Exec(ci *cli.Interface, args []string) int { if len(args) == 0 { - return usage() + return usage(ci) } index, errs := ReadIndex() if len(errs) > 0 { for _, err := range errs { - util.ShowError(err) + ci.ShowError(err.Error()) } - return 255 + return 1 } var err error switch args[0] { case "get": if len(args) != 2 { - return usage() + return usage(ci) } - err = commandGet(index, args[1]) + err = commandGet(ci, index, args[1]) case "drop": if len(args) != 2 { - return usage() + return usage(ci) } - err = commandDrop(index, args[1]) + err = commandDrop(ci, index, args[1]) case "index": if len(args) != 1 { - return usage() + return usage(ci) } - err = commandIndex(index) + err = commandIndex(ci, index) case "repos": if len(args) != 1 { - return usage() + return usage(ci) } - commandRepos(index) + commandRepos(ci, index) case "remotes": if len(args) != 1 { - return usage() + return usage(ci) } - commandRemotes(index) + commandRemotes(ci, index) case "import": if len(args) != 2 { - return usage() + return usage(ci) } - err = commandImport(index, args[1]) + err = commandImport(ci, index, args[1]) case "each": if len(args) < 2 { - return usage() + return usage(ci) } - return commandEach(index, args[1], args[2:]) + return commandEach(ci, index, args[1:]) default: - return usage() + return usage(ci) } if err == nil { return 0 } - util.ShowError(err) + ci.ShowError(err.Error()) return 1 } -func usage() int { - fmt.Fprintln(os.Stderr, "Usage:") - fmt.Fprintln(os.Stderr, " rtree [get|drop] <url>") - fmt.Fprintln(os.Stderr, " rtree [index|repos|remotes]") - fmt.Fprintln(os.Stderr, " rtree import <path>") - fmt.Fprintln(os.Stderr, " rtree each <command>") +var usageStr = strings.TrimSpace(` +Usage: + rtree [get|drop] <url> + rtree [index|repos|remotes] + rtree import <path> + rtree each <command> +`) + +func usage(ci *cli.Interface) int { + ci.ShowUsage(usageStr) return 1 } -func commandGet(index *Index, url string) error { - repo, err := index.InteractiveFindRepo(url, true) +func commandGet(ci *cli.Interface, index *Index, url string) error { + repo, err := index.FindRepo(ci, url, true) if err != nil { return err } - fmt.Println(repo.AbsolutePath()) + ci.ShowResult(repo.AbsolutePath()) return nil } -func commandDrop(index *Index, url string) error { - repo, err := index.InteractiveFindRepo(url, true) - if err != nil { - return err - } - err = index.InteractiveDropRepo(repo) +func commandDrop(ci *cli.Interface, index *Index, url string) error { + repo, err := index.FindRepo(ci, url, true) if err != nil { return err } - return index.Write() + return index.DropRepo(ci, repo) } -func commandIndex(index *Index) error { - err := index.InteractiveRebuild() +func commandIndex(ci *cli.Interface, index *Index) error { + err := index.Rebuild(ci) if err != nil { return err } - return index.Write() + return index.Write(ci) } -func commandRepos(index *Index) { +func commandRepos(ci *cli.Interface, index *Index) { var items []string for _, repo := range index.Repos { items = append(items, repo.CheckoutPath) } - util.ShowSorted(items) + ci.ShowResultsSorted(items) } -func commandRemotes(index *Index) { +func commandRemotes(ci *cli.Interface, index *Index) { var items []string for _, repo := range index.Repos { for _, remote := range repo.Remotes { items = append(items, remote.URL) } } - util.ShowSorted(items) + ci.ShowResultsSorted(items) } -func commandEach(index *Index, command string, args []string) int { - allOK := true +func commandEach(ci *cli.Interface, index *Index, cmdline []string) (exitCode int) { + exitCode = 0 for _, repo := range index.Repos { - ok := repo.InteractiveExec(command, args...) - if !ok { - allOK = false + err := repo.Exec(ci, cmdline...) + if err != nil { + ci.ShowError(err.Error()) + exitCode = 1 } } - - if allOK { - return 0 - } - return 1 + return } -func commandImport(index *Index, dirPath string) error { - err := index.InteractiveImportRepo(dirPath) +func commandImport(ci *cli.Interface, index *Index, dirPath string) error { + err := index.ImportRepo(ci, dirPath) if err != nil { return err } - return index.Write() + return index.Write(ci) } diff --git a/pkg/rtree/repo.go b/pkg/rtree/repo.go index 12afee0..fba658b 100644 --- a/pkg/rtree/repo.go +++ b/pkg/rtree/repo.go @@ -19,14 +19,14 @@ package rtree import ( - "bytes" "errors" "fmt" "os" - "os/exec" "path/filepath" "regexp" "strings" + + "github.com/majewsky/gofu/pkg/cli" ) //RootPath is the directory below which all repositories are located. Its value @@ -64,23 +64,22 @@ func (r Repo) AbsolutePath() string { //NewRepoFromAbsolutePath initializes a Repo instance by scanning the existing //checkout at the given path. -func NewRepoFromAbsolutePath(path string) (repo Repo, err error) { +func NewRepoFromAbsolutePath(ci *cli.Interface, path string) (repo Repo, err error) { repo.CheckoutPath, err = filepath.Rel(RootPath, path) if err != nil { return } //list remotes - cmd := exec.Command("git", "-C", path, "config", "-l") - var buf bytes.Buffer - cmd.Stdout = &buf - cmd.Stderr = os.Stderr - err = cmd.Run() + out, err := ci.CaptureStdout(cli.Command{ + Program: []string{"git", "config", "-l"}, + WorkDir: path, + }) if err != nil { - return repo, fmt.Errorf("exec `git config -l` in %s: %s", path, err.Error()) + return } - for _, line := range strings.Split(string(buf.Bytes()), "\n") { + for _, line := range strings.Split(out, "\n") { match := remoteConfigRx.FindStringSubmatch(line) if match == nil { continue @@ -113,7 +112,7 @@ var remoteConfigRx = regexp.MustCompile(`remote\.([^=]+)\.url=(.+)`) //ForeachPhysicalRepo walks over the repository tree, executing the action //function once for every repo encountered (but *not* for repos contained //within other repos, e.g. submodules). -func ForeachPhysicalRepo(action func(repo Repo) error) error { +func ForeachPhysicalRepo(ci *cli.Interface, action func(repo Repo) error) error { return filepath.Walk(RootPath, func(path string, info os.FileInfo, err error) error { if err != nil { return err @@ -128,7 +127,7 @@ func ForeachPhysicalRepo(action func(repo Repo) error) error { } //appears to be a repo - repo, err := NewRepoFromAbsolutePath(path) + repo, err := NewRepoFromAbsolutePath(ci, path) if err == nil { err = action(repo) } @@ -142,7 +141,7 @@ func ForeachPhysicalRepo(action func(repo Repo) error) error { //Checkout creates the repo in the given path with the given remotes. The //working copy will only be initialized if there is an "origin" remote. -func (r Repo) Checkout() error { +func (r Repo) Checkout(ci *cli.Interface) error { //check if we have an "origin" remote to clone from var originURL string for _, remote := range r.Remotes { @@ -153,19 +152,17 @@ func (r Repo) Checkout() error { } if originURL == "" { - cmd := exec.Command("git", "init", r.AbsolutePath()) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - err := cmd.Run() + err := ci.Run(cli.Command{ + Program: []string{"git", "init", r.AbsolutePath()}, + }) if err != nil { return err } - fmt.Fprintln(os.Stderr, "warning: will not checkout anything since there is no remote named \"origin\"") + ci.ShowWarning(`will not checkout anything since there is no remote named "origin"`) } else { - cmd := exec.Command("git", "clone", originURL, r.AbsolutePath()) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - err := cmd.Run() + err := ci.Run(cli.Command{ + Program: []string{"git", "clone", originURL, r.AbsolutePath()}, + }) if err != nil { return err } @@ -174,10 +171,10 @@ func (r Repo) Checkout() error { remotesAdded := false for _, remote := range r.Remotes { if remote.Name != "origin" { - cmd := exec.Command("git", "-C", r.AbsolutePath(), "remote", "add", remote.Name, remote.URL) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - err := cmd.Run() + err := ci.Run(cli.Command{ + Program: []string{"git", "remote", "add", remote.Name, remote.URL}, + WorkDir: r.AbsolutePath(), + }) if err != nil { return err } @@ -185,29 +182,23 @@ func (r Repo) Checkout() error { } } if remotesAdded { - cmd := exec.Command("git", "-C", r.AbsolutePath(), "remote", "update") - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() + return ci.Run(cli.Command{ + Program: []string{"git", "remote", "update"}, + WorkDir: r.AbsolutePath(), + }) } return nil } -//InteractiveExec implements the meat of the `rtree exec` command. It returns +//Exec implements the meat of the `rtree exec` command. It returns //true iff the command exited successfully. -func (r Repo) InteractiveExec(command string, args ...string) (ok bool) { - fmt.Fprintf(os.Stdout, "\x1B[1;36m>> \x1B[0;36m%s\x1B[0m\n", r.AbsolutePath()) - cmd := exec.Command(command, args...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmd.Dir = r.AbsolutePath() - err := cmd.Run() - if err != nil { - fmt.Fprintf(os.Stderr, "\x1B[1;31m!! \x1B[0;31m%s\x1B[0m\n", err.Error()) - return false - } - return true +func (r Repo) Exec(ci *cli.Interface, cmdline ...string) error { + ci.ShowProgress(r.AbsolutePath()) + return ci.Run(cli.Command{ + Program: cmdline, + WorkDir: r.AbsolutePath(), + }) } //Move sets the CheckoutPath to the given value and moves the existing repo |
