diff options
| author | Stefan Majewsky <majewsky@gmx.net> | 2020-03-28 13:53:10 +0100 |
|---|---|---|
| committer | Stefan Majewsky <majewsky@gmx.net> | 2020-03-28 13:53:10 +0100 |
| commit | ed2b9374599cc00467b1c27121318748cc0d3f49 (patch) | |
| tree | 65e3200a0b69817b24ae772223d1d852a52458af /internal | |
| parent | 1ff86053ddef8de3247909434e0bd7000c060f2a (diff) | |
| download | gofu-ed2b9374599cc00467b1c27121318748cc0d3f49.tar.gz | |
rename pkg/ to internal/
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/cli/command.go | 70 | ||||
| -rw-r--r-- | internal/cli/interface.go | 168 | ||||
| -rw-r--r-- | internal/cli/query.go | 295 | ||||
| -rw-r--r-- | internal/i3status/battery.go | 75 | ||||
| -rw-r--r-- | internal/i3status/main.go | 169 | ||||
| -rw-r--r-- | internal/i3status/net.go | 61 | ||||
| -rw-r--r-- | internal/prompt/cloud.go | 83 | ||||
| -rw-r--r-- | internal/prompt/git.go | 138 | ||||
| -rw-r--r-- | internal/prompt/login.go | 66 | ||||
| -rw-r--r-- | internal/prompt/main.go | 123 | ||||
| -rw-r--r-- | internal/prompt/misc.go | 43 | ||||
| -rw-r--r-- | internal/prompt/pwd.go | 157 | ||||
| -rw-r--r-- | internal/rtree/get_test.go | 140 | ||||
| -rw-r--r-- | internal/rtree/index.go | 431 | ||||
| -rw-r--r-- | internal/rtree/init.go | 99 | ||||
| -rw-r--r-- | internal/rtree/main.go | 171 | ||||
| -rw-r--r-- | internal/rtree/remote.go | 75 | ||||
| -rw-r--r-- | internal/rtree/repo.go | 225 | ||||
| -rw-r--r-- | internal/rtree/shared_test.go | 204 |
19 files changed, 2793 insertions, 0 deletions
diff --git a/internal/cli/command.go b/internal/cli/command.go new file mode 100644 index 0000000..a9938d1 --- /dev/null +++ b/internal/cli/command.go @@ -0,0 +1,70 @@ +/******************************************************************************* +* +* 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 ( + "fmt" + "io" + "os/exec" + "strings" +) + +//Command describes a command that can be run using the methods in the +//Implementation interface. +type Command struct { + Program []string + WorkDir string +} + +type commandError struct { + Cmd Command + Err error +} + +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(), + ) +} + +//CommandRunner is a function that can execute commands given to it. +//This interface is only useful for unit tests; the default CommandRunner +//suffices for all regular operation. +type CommandRunner func(c Command, stdin io.Reader, stdout, stderr io.Writer) error + +//DefaultCommandRunner is a CommandRunner that actually executes the command. +func DefaultCommandRunner(c Command, stdin io.Reader, stdout, stderr io.Writer) error { + cmd := exec.Command(c.Program[0], c.Program[1:]...) + cmd.Stdin = stdin + cmd.Stdout = stdout + cmd.Stderr = stderr + cmd.Dir = c.WorkDir + + err := cmd.Run() + if err != nil { + err = commandError{c, err} + } + return err +} diff --git a/internal/cli/interface.go b/internal/cli/interface.go new file mode 100644 index 0000000..a88212b --- /dev/null +++ b/internal/cli/interface.go @@ -0,0 +1,168 @@ +/******************************************************************************* +* +* 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" + "fmt" + "io" + "os" + "sort" + "strings" + + "golang.org/x/crypto/ssh/terminal" +) + +//Interface wraps access to the CLI, including input, output and subprocesses. +var Interface *Implementation + +func init() { + SetupInterface(os.Stdin, os.Stdout, os.Stderr, DefaultCommandRunner) +} + +//SetupInterface prepares the Interface instance with nonstandard file streams +//or a nonstandard CommandRunner. This is only required for unit tests. +func SetupInterface(stdin io.Reader, stdout, stderr io.Writer, commandRunner CommandRunner) { + Interface = &Implementation{ + stdin: stdin, + stdout: stdout, + stderr: stderr, + stdinBuf: bufio.NewReader(stdin), + commandRunner: commandRunner, + } + + if stdinFile, ok := stdin.(*os.File); ok && terminal.IsTerminal(int(stdinFile.Fd())) { + Interface.tui = &terminalTUI{Interface} + } else { + Interface.tui = &pipeTUI{Interface} + } +} + +//Implementation wraps access to the CLI, including input, output and subprocesses. +type Implementation struct { + stdin io.Reader + stdout io.Writer + stderr io.Writer + stdinBuf *bufio.Reader + tui TUI + commandRunner CommandRunner + //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.Implementation, 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) + //Print writes the given string (potentially including ANSI escape codes) to + //the given writer. At this point, it can be decided whether to strip out the + //ANSI escape codes. + Print(w io.Writer, msg string) +} + +func (i *Implementation) 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 *Implementation) 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 *Implementation) 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 *Implementation) 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 *Implementation) Run(c Command) error { + return i.commandRunner(c, nil, i.safeStdout(), i.stderr) +} + +//CaptureStdout executes the given command on the same stderr and captures its stdout. +func (i *Implementation) CaptureStdout(c Command) (string, error) { + var buf bytes.Buffer + err := i.commandRunner(c, nil, &buf, i.stderr) + return string(buf.Bytes()), err +} + +//////////////////////////////////////////////////////////////////////////////// +// output + +//ShowResult displays the result of a computation on stdout. +func (i *Implementation) 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 *Implementation) ShowResultsSorted(strs []string) { + sort.Strings(strs) + for _, str := range strs { + i.ShowResult(str) + } +} + +//ShowProgress displays a progress message on stderr. +func (i *Implementation) ShowProgress(str string) { + i.tui.Print(i.stderr, fmt.Sprintf("\x1B[0;1;36m>>\x1B[0;36m %s\x1B[0m\n", strings.TrimSpace(str))) +} + +//ShowWarning displays a warning message on stderr. +func (i *Implementation) ShowWarning(str string) { + i.tui.Print(i.stderr, fmt.Sprintf("\x1B[0;1;33m!!\x1B[0;33m %s\x1B[0m\n", strings.TrimSpace(str))) +} + +//ShowError displays an error message on stderr. +func (i *Implementation) ShowError(str string) { + i.tui.Print(i.stderr, fmt.Sprintf("\x1B[0;1;31m!!\x1B[0;31m %s\x1B[0m\n", strings.TrimSpace(str))) +} + +//ShowUsage displays a usage synopsis on stderr. +func (i *Implementation) ShowUsage(str string) { + str = strings.TrimSpace(str) + "\n" + i.stderr.Write([]byte(str)) +} diff --git a/internal/cli/query.go b/internal/cli/query.go new file mode 100644 index 0000000..add170c --- /dev/null +++ b/internal/cli/query.go @@ -0,0 +1,295 @@ +/******************************************************************************* +* +* 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 ( + "errors" + "fmt" + "io" + "regexp" + "strconv" + "strings" + + terminal "golang.org/x/crypto/ssh/terminal" +) + +//cannot use `var errInterrupted = errors.New("Interrupted!")` because golint +//complains about the formatting of the error message +type errInterrupted struct{} + +func (e errInterrupted) Error() string { + return "Interrupted!" +} + +//////////////////////////////////////////////////////////////////////////////// +// TUI implementation for when stdin is a terminal + +type terminalTUI struct { + i *Implementation +} + +func (t terminalTUI) Print(w io.Writer, msg string) { + w.Write([]byte(msg)) +} + +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": + out.Write([]byte("-> yes\n")) + return true, nil + case "n", "N": + out.Write([]byte("-> no\n")) + return false, nil + case "\x03": // Ctrl-C + return false, errInterrupted{} + } + } +} + +//Choice is a thing that the user can choose during Query(). +type Choice struct { + //If given, the choice can be selected by pressing the key that + //produces this character. + Shortcut byte + //The display string that describes this choice. + Text string + //The string to return from Implementation.Query(). + Return string +} + +func (c Choice) hasShortcut() bool { + return c.Shortcut != '\000' +} + +func (t terminalTUI) Query(prompt string, choices ...Choice) (string, error) { + if len(choices) == 0 { + panic("no choices") + } + + //disable line wrap; unexpected wrapping would confuse our cursor-moving code + out := t.i.safeStdout() + out.Write([]byte("\x1B[?7l")) + defer out.Write([]byte("\x1B[?7h")) + + //display question + out.Write([]byte(strings.TrimSuffix(prompt, "\n") + "\n")) + selected := 0 + + buf := buffer{Input: t.i.stdin} +OUTER: + for { + displayChoices(out, choices, selected) + + input := buf.getNextInput() + if len(input) == 1 { + //single character entered - check if a shortcut matches + for idx, choice := range choices { + if choice.Shortcut == input[0] { + selected = idx + break OUTER + } + } + } + + switch string(input) { + case "\r", "\n": + break OUTER + case "\x1B[A": // Up arrow key + selected-- + if selected < 0 { + selected = 0 + } + case "\x1B[B": // Down arrow key + selected++ + if selected >= len(choices) { + selected = len(choices) - 1 + } + case "\x03": // Ctrl-C + return "", errInterrupted{} + } + + //prepare to re-render choices + removeDisplayLines(out, len(choices)) + } + + //clear query display + removeDisplayLines(out, len(choices)+1) + + //display question + chosen answer + fmt.Fprintf(out, "%s -> %s\n", + strings.TrimSuffix(prompt, "\n"), + strings.TrimSpace(choices[selected].Text), + ) + + return choices[selected].Return, nil +} + +func removeDisplayLines(stdout io.Writer, n int) { + for idx := 0; idx < n; idx++ { + stdout.Write([]byte("\x1B[A\x1B[2K")) + } +} + +func displayChoices(out io.Writer, choices []Choice, selectedIndex int) { + hasShortcuts := false + for _, choice := range choices { + if choice.hasShortcut() { + hasShortcuts = true + break + } + } + + for idx, choice := range choices { + text := " " + strings.TrimSpace(choice.Text) + " \n" + if hasShortcuts { + shortcut := choice.Shortcut + if !choice.hasShortcut() { + shortcut = ' ' + } + text = fmt.Sprintf(" [%c]%s", shortcut, text) + } + + if idx == selectedIndex { + fmt.Fprintf(out, "\x1B[0;7m%s\x1B[0m", text) + } else { + out.Write([]byte(text)) + } + } +} + +var ansiEscapeRx = regexp.MustCompile(`^\x1B\[[\x20-\x3F]*[\x40-\x7E]`) + +type buffer struct { + Input io.Reader + buf [128]byte + fill int +} + +func (b *buffer) getNextInput() []byte { + //do we have a simple input character? + if b.fill > 0 && b.buf[0] != '\x1B' { + result := append([]byte(nil), b.buf[0]) + copy(b.buf[0:], b.buf[1:]) + b.fill-- + return result + } + + //do we have a full ANSI escape sequence? + match := ansiEscapeRx.Find(b.buf[0:b.fill]) + if match != nil { + result := append([]byte(nil), match...) + copy(b.buf[0:], b.buf[len(match):]) + b.fill -= len(match) + return result + } + + oldState, err := terminal.MakeRaw(0) + if err != nil { + panic(err) + } + defer terminal.Restore(0, oldState) + + //fill buffer some more + n, err := b.Input.Read(b.buf[b.fill:]) + if err != nil { + panic(err) + } + b.fill += n + + return b.getNextInput() //restart +} + +//////////////////////////////////////////////////////////////////////////////// +// TUI implementation for when stdin is a pipe (also used in unit tests) + +type pipeTUI struct { + i *Implementation +} + +//AnsiColorCodeRx is a regexp that matches ANSI escape sequences of the type SGR. +var AnsiColorCodeRx = regexp.MustCompile("\x1B" + `\[[0-9;]*m`) + +func (t *pipeTUI) Print(w io.Writer, msg string) { + w.Write([]byte(AnsiColorCodeRx.ReplaceAllString(msg, ""))) +} + +func (t *pipeTUI) ReadLine(prompt string) (string, error) { + str, err := t.i.stdinBuf.ReadString('\n') + str = strings.TrimSpace(str) + if err == nil { + fmt.Fprintf(t.i.stderr, "%s %s\n", strings.TrimSpace(prompt), str) + } + return str, err +} + +func (t *pipeTUI) Confirm(question string) (bool, error) { + question = strings.TrimSpace(question) + str, err := t.ReadLine(question) + if err != nil { + return false, err + } + //recognize /[01tTfF]|true|false/ + ok, err := strconv.ParseBool(str) + if err != nil { + //recognize /[yY]|yes|no/ + ok = strings.HasPrefix(question, "y") || strings.HasPrefix(question, "Y") + } + fmt.Fprintf(t.i.stderr, "%s -> %v (%s)\n", question, ok, str) + return ok, nil +} + +//Query for the pipe TUI is limited to exact matches of the choice text, or +//matching by choice shortcut. +func (t *pipeTUI) Query(prompt string, choices ...Choice) (string, error) { + str, err := t.i.stdinBuf.ReadString('\n') + if err != nil { + return str, err + } + str = strings.TrimSpace(str) + + //prefer exact match on choice.Text + for _, choice := range choices { + if choice.Text == str { + fmt.Fprintf(t.i.stderr, "%s -> %s\n", prompt, choice.Text) + return choice.Return, nil + } + } + //allow match on choice.Shortcut + for _, choice := range choices { + if string(choice.Shortcut) == str { + fmt.Fprintf(t.i.stderr, "%s -> %s\n", prompt, choice.Text) + return choice.Return, nil + } + } + fmt.Fprintf(t.i.stderr, "%s -> [%s]\n", prompt, str) + return "", errors.New("cannot match input with available choices") +} diff --git a/internal/i3status/battery.go b/internal/i3status/battery.go new file mode 100644 index 0000000..e41f189 --- /dev/null +++ b/internal/i3status/battery.go @@ -0,0 +1,75 @@ +/******************************************************************************* +* +* 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 i3status + +import ( + "fmt" + "io/ioutil" + "strconv" + "strings" +) + +const ( + batteryFullPath = "/sys/class/power_supply/BAT0/energy_full" + batteryNowPath = "/sys/class/power_supply/BAT0/energy_now" + powerOnlinePath = "/sys/class/power_supply/AC0/online" +) + +func getBatteryStatus() []Block { + //TODO: What's with /sys/class/power_supply/BAT0/uevent? Can this be used to + //remove all the polling overhead here? + + energyFull, err := readNumberFromFile(batteryFullPath) + if err != nil { + return nil + } + energyNow, err := readNumberFromFile(batteryNowPath) + if err != nil { + return nil + } + powerOnline, err := readNumberFromFile(powerOnlinePath) + if err != nil { + return nil + } + + energyPerc := energyNow * 100 / energyFull + charging := powerOnline > 0 + color := "#AAAA00" + if charging { + color = "#00AA00" + } else if energyPerc < 10 { + color = "#AA0000" + } + + return section("bat", Block{ + Name: "battery", + Position: PositionBattery, + FullText: fmt.Sprintf("%d%%", energyPerc), + Urgent: energyPerc < 10 && !charging, + Color: color, + }) +} + +func readNumberFromFile(path string) (int64, error) { + buf, err := ioutil.ReadFile(path) + if err != nil { + return 0, err + } + return strconv.ParseInt(strings.TrimSpace(string(buf)), 0, 64) +} diff --git a/internal/i3status/main.go b/internal/i3status/main.go new file mode 100644 index 0000000..4af726a --- /dev/null +++ b/internal/i3status/main.go @@ -0,0 +1,169 @@ +/******************************************************************************* +* +* 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 i3status + +import ( + "encoding/json" + "fmt" + "os" + "sort" + "time" +) + +//Exec executes the i3status applet and returns an exit code (0 for +//success, >0 for error). +func Exec(args []string) int { + //start protocol (we handle errors here for once because if stdout is + //working, we have nothing left to do) + _, err := os.Stdout.Write([]byte("{\"version\":1}\n[\n")) + if err != nil { + fmt.Fprintf(os.Stderr, err.Error()) + return 1 + } + + //main loop + currentBlocks := make(map[string][]Block) + for { + //prepare clock (this is inline instead of split into a separate function + //because the wallclock also drives the main loop's clock, see below) + now := time.Now() + currentBlocks["clock"] = []Block{ + { + Name: "clock", + Instance: "date", + Position: PositionClock, + FullText: now.Format("2006-01-02"), + ShortText: " ", + Color: "#AAAAAA", + SeparatorBlockWidth: 6, + }, + { + Name: "clock", + Instance: "time", + Position: PositionClock, + FullText: now.Format("15:04:05"), + }, + } + + //prepare other blocks + currentBlocks["battery"] = getBatteryStatus() + currentBlocks["network"] = getNetworkStatus() + + //put blocks in rendering order + var allBlocks []Block + for _, blocks := range currentBlocks { + allBlocks = append(allBlocks, blocks...) + } + sort.Sort(byPositionAndInstance(allBlocks)) + + //write output + buf, err := json.Marshal(allBlocks) + if err == nil { + os.Stdout.Write(append(buf, ',', '\n')) + } else { + //this should not happen, but if it does, fall back to just encoding the + //error string (which always works, otherwise wtf) + buf, _ = json.Marshal(err.Error()) + fmt.Printf( + `[{"name":"error","urgent":true,"color":"#FF0000","full_text":%s}],`, + string(buf), + ) + } + + //sleep until the next second starts, so that the clock is always on time + nsec := 1000000000 - now.Nanosecond() + time.Sleep(time.Duration(nsec) * time.Nanosecond) + } + + return 0 +} + +//Block is a block of text as used in the i3status protocol. +//(Not all attributes are represented.) +type Block struct { + Position Position `json:"-"` + Name string `json:"name"` //REQUIRED + Instance string `json:"instance,omitempty"` + FullText string `json:"full_text"` //REQUIRED + ShortText string `json:"short_text"` + Color string `json:"color,omitempty"` //CSS hex syntax, e.g. #123456 + BackgroundColor string `json:"background,omitempty"` + MinWidth uint `json:"min_width,omitempty"` + Alignment Alignment `json:"align,omitempty"` //only plausible with MinWidth + Urgent bool `json:"urgent,omitempty"` + Separator bool `json:"separator"` + SeparatorBlockWidth int `json:"separator_block_width,omitempty"` +} + +type byPositionAndInstance []Block + +func (b byPositionAndInstance) Len() int { return len(b) } +func (b byPositionAndInstance) Swap(i, j int) { b[i], b[j] = b[j], b[i] } +func (b byPositionAndInstance) Less(i, j int) bool { + if b[i].Position == b[j].Position { + if b[i].Instance == "_caption" { + return true + } + if b[j].Instance == "_caption" { + return false + } + return b[i].Instance < b[j].Instance + } + return b[i].Position < b[j].Position +} + +//Alignment is the alignment of a Block. +type Alignment string + +//Acceptable values for Alignment. +const ( + AlignmentLeft Alignment = "left" + AlignmentCenter Alignment = "center" + AlignmentRight Alignment = "right" +) + +//Position defines how blocks are ordered. +type Position int + +//Acceptable values for Position, from left to right. +const ( + PositionNone Position = iota + PositionNetwork + PositionBattery + PositionClock +) + +//Order the given blocks byPositionAndInstance, then add a separator to the last one, then add a caption block of the same style in front. +func section(caption string, blocks ...Block) []Block { + if len(blocks) == 0 { + return nil + } + sort.Sort(byPositionAndInstance(blocks)) + last := len(blocks) - 1 + blocks[last].Separator = true + blocks[last].SeparatorBlockWidth = 15 + + return append([]Block{{ + Name: blocks[0].Name, + Position: blocks[0].Position, + Instance: "_caption", + FullText: caption, + Color: blocks[0].Color, + }}, blocks...) +} diff --git a/internal/i3status/net.go b/internal/i3status/net.go new file mode 100644 index 0000000..7367b28 --- /dev/null +++ b/internal/i3status/net.go @@ -0,0 +1,61 @@ +/******************************************************************************* +* +* 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 i3status + +import ( + "net" + "regexp" + "strings" +) + +var networkPortRx = regexp.MustCompile(`:\d+$`) +var networkMaskRx = regexp.MustCompile(`/\d+$`) + +func getNetworkStatus() []Block { + addrs, err := net.InterfaceAddrs() + if err != nil { + return nil + } + addrStrs := make([]string, 0, len(addrs)) + for _, addr := range addrs { + str := addr.String() + //remove uninteresting parts + str = networkPortRx.ReplaceAllString(str, "") + str = networkMaskRx.ReplaceAllString(str, "") + //ignore IPv6 for now + if strings.ContainsRune(str, ':') { + continue + } + //ignore uninteresting addrs + if strings.HasPrefix(str, "127.") { + continue + } + addrStrs = append(addrStrs, str) + } + + if len(addrStrs) == 0 { + return nil + } + return section("ip", Block{ + Name: "network", + Position: PositionNetwork, + FullText: strings.Join(addrStrs, " "), + Color: "#00AAAA", + }) +} diff --git a/internal/prompt/cloud.go b/internal/prompt/cloud.go new file mode 100644 index 0000000..72bd6f9 --- /dev/null +++ b/internal/prompt/cloud.go @@ -0,0 +1,83 @@ +/******************************************************************************* +* +* 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 prompt + +import ( + "io/ioutil" + "os" + "path/filepath" + "strings" + + yaml "gopkg.in/yaml.v2" +) + +func getKubernetesField() string { + configPaths := filepath.SplitList(os.Getenv("KUBECONFIG")) + + var context string + for _, configPath := range configPaths { + context = getKubernetesContext(configPath) + if context != "" { + break + } + } + if context == "" { + return "" + } + + namespaceBytes, err := ioutil.ReadFile(filepath.Join(os.Getenv("HOME"), ".kubectl-namespace")) + namespace := strings.TrimSpace(string(namespaceBytes)) + if err != nil { + if !os.IsNotExist(err) { + handleError(err) + } + namespace = "" + } + if namespace != "" { + context += "/" + namespace + } + + return withType("kube", context) +} + +func getKubernetesContext(configPath string) string { + buf, err := ioutil.ReadFile(configPath) + if err != nil { + //non-existence is acceptable, just make the caller continue with the next configPath + if !os.IsNotExist(err) { + handleError(err) + } + return "" + } + + var data struct { + CurrentContext string `yaml:"current-context"` + } + err = yaml.Unmarshal(buf, &data) + handleError(err) + return strings.TrimSpace(data.CurrentContext) +} + +func getOpenstackField() string { + cloudName := os.Getenv("CURRENT_OS_CLOUD") + if cloudName == "" { + return "" + } + return withType("cloud", cloudName) +} diff --git a/internal/prompt/git.go b/internal/prompt/git.go new file mode 100644 index 0000000..8393c3e --- /dev/null +++ b/internal/prompt/git.go @@ -0,0 +1,138 @@ +/******************************************************************************* +* +* 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 prompt + +import ( + "fmt" + "io/ioutil" + "os" + "path/filepath" + "strings" +) + +type gitRepo struct { + RootPath string + GitDir string +} + +//Returns two empty strings if `path` is not inside a Git repo. +func findRepo(path string) (*gitRepo, error) { + //find .git directory or file + gitEntry := filepath.Join(path, ".git") + fi, err := os.Stat(gitEntry) + switch { + case err == nil: + //found - continue below with further checks + case !os.IsNotExist(err): + return nil, err + case path == "/": + return nil, nil + default: + return findRepo(filepath.Dir(path)) + } + + //found .git - what is it? + if fi.Mode().IsDir() { + //normal case - .git is a directory + return &gitRepo{RootPath: path, GitDir: gitEntry}, nil + } + + //.git is a file (e.g. for submodules) - it contains a line like "gitdir: path/to/gitdir" + bytes, err := ioutil.ReadFile(gitEntry) + if err != nil { + return nil, err + } + for _, line := range strings.Split(string(bytes), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "gitdir:") { + return &gitRepo{ + RootPath: path, + GitDir: filepath.Join(path, strings.TrimSpace(strings.TrimPrefix(line, "gitdir:"))), + }, nil + } + } + + return nil, fmt.Errorf("read %s: missing gitdir directive", gitEntry) +} + +func getRepoStatusField(repo *gitRepo) string { + if repo == nil { + return "" + } + + bytes, err := ioutil.ReadFile(filepath.Join(repo.GitDir, "HEAD")) + if err != nil { + handleError(err) + return withType("git", withColor("1;41", "unknown")) + } + refSpec := strings.TrimSpace(string(bytes)) + + //is current HEAD detached? + if !strings.HasPrefix(refSpec, "ref: refs/") { + return formatRepoStatusField(withColor("1;41", "detached"), refSpec) + } + + //current HEAD is a ref + refSpec = strings.TrimPrefix(refSpec, "ref: ") + refSpecDisplay := strings.TrimPrefix(refSpec, "refs/") + refSpecDisplay = strings.TrimPrefix(refSpecDisplay, "heads/") + + //read file corresponding to refspec to find commit ID + bytes, err = ioutil.ReadFile(filepath.Join(repo.GitDir, refSpec)) + commitID := strings.TrimSpace(string(bytes)) + if err != nil { + if os.IsNotExist(err) { + commitID = tryReadFromPackedRefs(repo, refSpec) + if commitID == "" { + commitID = withColor("37", "blank") + } + } else { + handleError(err) + commitID = withColor("1;41", "unknown") + } + } + + return formatRepoStatusField(refSpecDisplay, commitID) +} + +func tryReadFromPackedRefs(repo *gitRepo, refSpec string) string { + bytes, err := ioutil.ReadFile(filepath.Join(repo.GitDir, "packed-refs")) + if err != nil { + return "" + } + for _, line := range strings.Split(string(bytes), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + fields := strings.Fields(line) + if len(fields) == 2 && fields[1] == refSpec { + return fields[0] + } + } + return "" +} + +func formatRepoStatusField(refSpec, commitID string) string { + //shorten plain commit IDs from 40 to 10 bytes + if len(commitID) == 40 && !strings.Contains(commitID, "\x1B") { + commitID = commitID[0:10] + } + return withType("git", refSpec+"/"+commitID) +} diff --git a/internal/prompt/login.go b/internal/prompt/login.go new file mode 100644 index 0000000..92cc270 --- /dev/null +++ b/internal/prompt/login.go @@ -0,0 +1,66 @@ +/******************************************************************************* +* +* 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 prompt + +import "os" + +//#include <sys/types.h> +//#include <pwd.h> +//#include <unistd.h> +import "C" + +func getLoginField() string { + var result string + + //show user name + userName := getUserName() + commonUser := getenvOrDefault("PRETTYPROMPT_COMMONUSER", "stefan") + if commonUser != userName { + color := "0" + if userName == "root" { + color = "1;41" + } + result = withColor(color, userName) + "@" + } + + //show hostname + hostname, err := os.Hostname() + if err != nil { + handleError(err) + hostname = "<unknown>" + } + return result + withColor( + getenvOrDefault("PRETTYPROMPT_HOSTCOLOR", "0;33"), + hostname, + ) +} + +func getUserName() string { + //try to find username via getpwuid(getuid()) + pw, err := C.getpwuid(C.getuid()) + if err == nil { + return C.GoString(pw.pw_name) + } + //fallback to $USER, if set + if name := os.Getenv("USER"); name != "" { + return name + } + handleError(err) + return "<unknown>" +} diff --git a/internal/prompt/main.go b/internal/prompt/main.go new file mode 100644 index 0000000..dcef513 --- /dev/null +++ b/internal/prompt/main.go @@ -0,0 +1,123 @@ +/******************************************************************************* +* +* 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 prompt + +import ( + "fmt" + "os" + "strings" + + "golang.org/x/crypto/ssh/terminal" + + "github.com/majewsky/gofu/internal/cli" +) + +//Exec executes the prettyprompt applet and returns an exit code (0 for +//success, >0 for error). +func Exec(args []string) int { + fields := []string{ + getLoginField(), + } + cwd := CurrentDirectory() + fields = appendUnlessEmpty(fields, getDirectoryField(cwd)) + fields = appendUnlessEmpty(fields, getDeletedMessageField(cwd)) + fields = appendUnlessEmpty(fields, getRepoStatusField(cwd.Repo)) + fields = appendUnlessEmpty(fields, getTerminalField()) + fields = appendUnlessEmpty(fields, getOpenstackField()) + fields = appendUnlessEmpty(fields, getKubernetesField()) + //this field should always be last + if len(args) > 0 { + fields = appendUnlessEmpty(fields, getExitCodeField(args[0])) + } + + line := strings.Join(fields, " ") + lineWidth := getPrintableLength(line) + + //add dashes to expand `line` to fill the terminal's width + termWidth, _, err := terminal.GetSize(0) + if err != nil { + termWidth = 80 + } + if termWidth > lineWidth { + line += " " + lineWidth++ + } + if termWidth > lineWidth { + dashes := make([]byte, termWidth-lineWidth) + for idx := range dashes { + dashes[idx] = '-' + } + line += withColor("1", string(dashes)) + } + + os.Stdout.Write([]byte(line + "\n")) + + //print second line: a letter identifying the shell, and the final "$ ") + shellIdent := "" + switch os.Getenv("PRETTYPROMPT_SHELL") { + case "zsh": + shellIdent = "Z" + case "bash": + shellIdent = "B" + } + os.Stdout.Write([]byte(shellIdent + "$ ")) + + return 0 +} + +func getenvOrDefault(key, defaultValue string) (value string) { + value = os.Getenv(key) + if value == "" { + value = defaultValue + } + return +} + +func appendUnlessEmpty(list []string, val string) []string { + if val == "" { + return list + } + return append(list, val) +} + +func handleError(err error) { + if err != nil { + os.Stderr.Write([]byte("\x1B[1;31mPrompt error: " + err.Error() + "\x1B[0m\n")) + } +} + +func getPrintableLength(text string) int { + return len(cli.AnsiColorCodeRx.ReplaceAllString(text, "")) +} + +//withColor adds ANSI escape sequences to the string to display it with a +//certain color. The color is given as the semicolon-separated list of +//arguments to the ANSI escape sequence SGR, e.g. "1;41" for bold with red +//background. +func withColor(color, text string) string { + if color == "0" { + return text + } + return fmt.Sprintf("\x1B[%sm%s\x1B[0m", color, text) +} + +//withType adds a type annotation with a standardized format to the text. +func withType(typeStr, text string) string { + return fmt.Sprintf("\x1B[37m%s:\x1B[0m%s", typeStr, text) +} diff --git a/internal/prompt/misc.go b/internal/prompt/misc.go new file mode 100644 index 0000000..124015a --- /dev/null +++ b/internal/prompt/misc.go @@ -0,0 +1,43 @@ +/******************************************************************************* +* +* 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 prompt + +import ( + "os" + "strconv" +) + +func getTerminalField() string { + termName := os.Getenv("TERM") + if termName == "xterm-256color" { + return "" + } + if termName == "" { + termName = withColor("1;41", "not set") + } + return withType("term", termName) +} + +func getExitCodeField(arg string) string { + exitCode, err := strconv.Atoi(arg) + if err == nil && exitCode > 0 { + return withColor("1;31", "exit:"+arg) + } + return "" +} diff --git a/internal/prompt/pwd.go b/internal/prompt/pwd.go new file mode 100644 index 0000000..1b93987 --- /dev/null +++ b/internal/prompt/pwd.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 prompt + +import ( + "os" + "path/filepath" + "strings" +) + +//Directory contains all data about a directory that the prompt needs. +type Directory struct { + Path string + DisplayPath string + InBuildTree bool + InRepoTree bool + Repo *gitRepo + NearestAccessiblePath string +} + +//CurrentDirectory prepares a Directory struct for the current working +//directory. +func CurrentDirectory() Directory { + cwd, err := os.Getwd() + if err != nil { + cwd = filepath.Clean(os.Getenv("PWD")) + } + return NewDirectory(cwd) +} + +//NewDirectory prepares a Directory struct for the given path. +func NewDirectory(path string) (dir Directory) { + dir.Path = path + dir.DisplayPath = path + + //check if path actually exists + dir.NearestAccessiblePath = findNearestAccessiblePath(dir.Path) + if dir.NearestAccessiblePath == dir.Path { + //marks that everything is okay existence-wise + dir.NearestAccessiblePath = "" + + //display tag if below /x/build + if buildPath := os.Getenv("BUILD_ROOT"); buildPath != "" { + rel, _ := filepath.Rel(buildPath, dir.DisplayPath) + if !strings.HasPrefix(rel, "..") && rel != "." { + dir.InBuildTree = true + dir.DisplayPath = filepath.Join("/", rel) + } + } + + //display tag if below /x/src + if gopath := os.Getenv("GOPATH"); gopath != "" { + repoPath := filepath.Join(gopath, "src") + rel, _ := filepath.Rel(repoPath, dir.DisplayPath) + if !strings.HasPrefix(rel, "..") && rel != "." { + dir.InRepoTree = true + dir.DisplayPath = rel + } + } + + //strip $HOME prefix if applicable and desirable + dir.stripHomeDirFromDisplay() + + //check if we are inside a Git repository + var err error + dir.Repo, err = findRepo(dir.Path) + handleError(err) + } + + return +} + +//This part can benefit from a "return" in the middle, so it's in a separate function. +func (dir *Directory) stripHomeDirFromDisplay() { + if !strings.HasPrefix(dir.DisplayPath, "/") { + return + } + + homePath := os.Getenv("HOME") + if homePath == "" { + return + } + + rel, _ := filepath.Rel(homePath, dir.DisplayPath) + if rel == "." { + //do not display an empty DisplayPath if tags are displayed + if dir.InBuildTree || dir.InRepoTree { + return + } + rel = "" + } + if !strings.HasPrefix(rel, "..") { + dir.DisplayPath = rel + } +} + +func findNearestAccessiblePath(path string) string { + _, err := os.Stat(path) + if err == nil { + return path + } + return findNearestAccessiblePath(filepath.Dir(path)) +} + +func getDirectoryField(dir Directory) string { + if dir.DisplayPath == "" { + return "" + } + + txt := withColor("1;36", dir.DisplayPath) + if dir.NearestAccessiblePath == "" { + //cwd accessible -> highlight path elements inside the repo (if any) + if dir.Repo != nil && dir.Repo.RootPath != dir.Path { + rel, _ := filepath.Rel(dir.Repo.RootPath, dir.Path) + if strings.HasSuffix(dir.DisplayPath, rel) { + base := strings.TrimSuffix(dir.DisplayPath, rel) + txt = withColor("0;36", base) + withColor("1;36", rel) + } + } + } else { + //cwd inaccessible -> highlight inaccessible path elements + rel, _ := filepath.Rel(dir.NearestAccessiblePath, dir.Path) + txt = withColor("1;36", dir.NearestAccessiblePath+"/") + withColor("1;31", rel) + } + + //apply tags + if dir.InRepoTree { + txt = withType("repo", txt) + } + if dir.InBuildTree { + txt = withType("build", txt) + } + return txt +} + +func getDeletedMessageField(dir Directory) string { + if dir.NearestAccessiblePath == "" { + return "" + } + return withColor("1;41", "cannot stat cwd") +} diff --git a/internal/rtree/get_test.go b/internal/rtree/get_test.go new file mode 100644 index 0000000..bdd0f2e --- /dev/null +++ b/internal/rtree/get_test.go @@ -0,0 +1,140 @@ +/******************************************************************************* +* +* 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 rtree + +import ( + "fmt" + "path/filepath" + "testing" +) + +var testIndexWithTwoRepos = Index{ + Repos: []*Repo{ + { + CheckoutPath: "github.com/foo/bar", + Remotes: []Remote{ + {Name: "origin", URL: "https://github.com/foo/bar"}, + }, + }, + { + CheckoutPath: "github.com/git/git", + Remotes: []Remote{ + {Name: "origin", URL: "gh:git/git"}, + }, + }, + }, +} + +func TestGetExistingRepo(t *testing.T) { + Test{ + Args: []string{"get", "gh:git/git"}, + Index: testIndexWithTwoRepos, + ExpectOutput: filepath.Join(RootPath, "/github.com/git/git") + "\n", + }.Run(t) +} + +func TestGetExistingRepoWithoutShortcut(t *testing.T) { + Test{ + Args: []string{"get", "https://github.com/git/git"}, + Index: testIndexWithTwoRepos, + ExpectOutput: filepath.Join(RootPath, "/github.com/git/git") + "\n", + }.Run(t) +} + +func TestGetNewRepo(t *testing.T) { + target := filepath.Join(RootPath, "/github.com/another/repo") + + Test{ + Args: []string{"get", "gh:another/repo"}, + Index: testIndexWithTwoRepos, + ExpectOutput: target + "\n", + ExpectExecution: Recorded("git clone gh:another/repo " + target), + ExpectIndex: &Index{ + Repos: []*Repo{ + { + CheckoutPath: "github.com/another/repo", + Remotes: []Remote{ + {Name: "origin", URL: "gh:another/repo"}, + }, + }, + testIndexWithTwoRepos.Repos[0], + testIndexWithTwoRepos.Repos[1], + }, + }, + }.Run(t) +} + +func TestGetNewForkAsRemote(t *testing.T) { + target := filepath.Join(RootPath, "/github.com/git/git") + Test{ + Args: []string{"get", "https://example.com/git"}, + Index: testIndexWithTwoRepos, + Input: fmt.Sprintf("add as remote to %s\nmyfork\n", target), + ExpectOutput: target + "\n", + ExpectError: fmt.Sprintf( + "Found possible fork candidates. What to do? -> add as remote to %s\n"+ + "Existing remotes:\n\t(origin) gh:git/git\n"+ + "Enter remote name for https://example.com/git: myfork\n", + target, + ), + ExpectExecution: Recorded( + "@"+target+" git remote add myfork https://example.com/git", + "@"+target+" git remote update myfork", + ), + ExpectIndex: &Index{ + Repos: []*Repo{ + testIndexWithTwoRepos.Repos[0], + { + CheckoutPath: "github.com/git/git", + Remotes: []Remote{ + {Name: "origin", URL: "gh:git/git"}, + {Name: "myfork", URL: "https://example.com/git"}, + }, + }, + }, + }, + }.Run(t) +} + +func TestGetNewForkAsSeparate(t *testing.T) { + target := filepath.Join(RootPath, "/example.com/git") + Test{ + Args: []string{"get", "https://example.com/git"}, + Index: testIndexWithTwoRepos, + Input: fmt.Sprintf("clone to %s\n", target), + ExpectOutput: target + "\n", + ExpectError: fmt.Sprintf( + "Found possible fork candidates. What to do? -> clone to %s\n", + target, + ), + ExpectExecution: Recorded("git clone https://example.com/git " + target), + ExpectIndex: &Index{ + Repos: []*Repo{ + { + CheckoutPath: "example.com/git", + Remotes: []Remote{ + {Name: "origin", URL: "https://example.com/git"}, + }, + }, + testIndexWithTwoRepos.Repos[0], + testIndexWithTwoRepos.Repos[1], + }, + }, + }.Run(t) +} diff --git a/internal/rtree/index.go b/internal/rtree/index.go new file mode 100644 index 0000000..4ca7a41 --- /dev/null +++ b/internal/rtree/index.go @@ -0,0 +1,431 @@ +/******************************************************************************* +* +* 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 rtree + +import ( + "errors" + "fmt" + "io/ioutil" + "os" + "path" + "path/filepath" + "sort" + "strings" + + "github.com/majewsky/gofu/internal/cli" + + yaml "gopkg.in/yaml.v2" +) + +//Index represents the contents of the index file. +type Index struct { + Repos []*Repo `yaml:"repos"` +} + +//ReadIndex reads the index file. +func ReadIndex() (*Index, []error) { + //read contents of index file + buf, err := ioutil.ReadFile(IndexPath) + if err != nil { + if os.IsNotExist(err) { + return &Index{Repos: nil}, nil + } + return nil, []error{err} + } + + //deserialize YAML + var index Index + err = yaml.Unmarshal(buf, &index) + if err != nil { + return nil, []error{err} + } + + //validate YAML + var errs []error + missing := func(key string, args ...interface{}) { + errs = append(errs, fmt.Errorf("read %s: missing \"%s\"", + IndexPath, fmt.Sprintf(key, args...), + )) + } + for idx, repo := range index.Repos { + if repo.CheckoutPath == "" { + missing("repos[%d].path", idx) + } + if len(repo.Remotes) == 0 { + missing("repos[%d].remotes", idx) + } + for idx2, remote := range repo.Remotes { + switch { + case remote.Name == "": + missing("repos[%d].remotes[%d].name", idx, idx2) + case remote.URL == "": + missing("repos[%d].remotes[%d].url", idx, idx2) + } + } + } + + sort.Sort(reposByAbsPath(index.Repos)) + return &index, errs +} + +type reposByAbsPath []*Repo + +func (r reposByAbsPath) Len() int { return len(r) } +func (r reposByAbsPath) Less(i, j int) bool { return r[i].AbsolutePath() < r[j].AbsolutePath() } +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 { + sort.Sort(reposByAbsPath(i.Repos)) + buf, err := yaml.Marshal(i) + if err != nil { + return err + } + + err = os.MkdirAll(filepath.Dir(IndexPath), 0755) + if err != nil { + return err + } + err = ioutil.WriteFile(IndexPath, buf, 0644) + if err != nil { + return err + } + + //perform sanity check (TODO: do this instead when rebuilding the index) + seen := make(map[string]bool) + warned := make(map[string]bool) + for _, repo := range i.Repos { + if seen[repo.CheckoutPath] && !warned[repo.CheckoutPath] { + cli.Interface.ShowWarning( + fmt.Sprintf("repo %s appears multiple times in the index file!", repo.AbsolutePath()), + ) + warned[repo.CheckoutPath] = true + } + seen[repo.CheckoutPath] = true + } + + return nil +} + +//Rebuild implements the `rtree index` subcommand. +func (i *Index) Rebuild() error { + //check if existing index entries are still checked out + var newRepos []*Repo + for _, repo := range i.Repos { + gitDirPath := filepath.Join(repo.AbsolutePath(), ".git") + fi, err := os.Stat(gitDirPath) + switch { + case err == nil: + if fi.IsDir() { + //everything okay with this repo + newRepos = append(newRepos, repo) + continue + } + return fmt.Errorf("expected repository at %s, but is not a directory", gitDirPath) + case !os.IsNotExist(err): + return err + } + + //repo has been deleted - ask what to do + var remoteURLs []string + for _, remote := range repo.Remotes { + if remote.Name == "origin" { + remoteURLs = []string{remote.URL} + break + } + remoteURLs = append(remoteURLs, remote.URL) + } + + var selection string + if len(remoteURLs) == 0 { + selection, err = cli.Interface.Query( + fmt.Sprintf("repository %s has been deleted; no remote to restore from", filepath.Join(RootPath, repo.CheckoutPath)), + cli.Choice{Return: "d", Shortcut: 'd', Text: "delete from index"}, + cli.Choice{Return: "s", Shortcut: 's', Text: "skip"}, + ) + } else { + selection, err = cli.Interface.Query( + fmt.Sprintf("repository %s has been deleted", filepath.Join(RootPath, repo.CheckoutPath)), + cli.Choice{Return: "r", Shortcut: 'r', Text: "restore 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 selection { + case "r": + err := repo.Checkout() + if err != nil { + return err + } + newRepos = append(newRepos, repo) + case "d": + continue + case "s": + newRepos = append(newRepos, repo) + } + } + + existingRepos := make(map[string]*Repo) + for _, repo := range newRepos { + existingRepos[repo.CheckoutPath] = repo + } + + //index new repos + err := ForeachPhysicalRepo(func(newRepo Repo) error { + repo, exists := existingRepos[newRepo.CheckoutPath] + if exists { + //update the existing index entry with the new remotes + repo.Remotes = newRepo.Remotes + } else { + newRepos = append(newRepos, &newRepo) + } + return nil + }) + if err != nil { + return err + } + + i.Repos = newRepos + return nil +} + +//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(remoteURL string, allowClone bool) (*Repo, error) { + //make sure that stdout is not used for prompts + cli.Interface.StdoutProtected = true + + expandedRemoteURL := ExpandRemoteURL(remoteURL) + basename := path.Base(expandedRemoteURL) + + //is this remote already checked out directly? also look for repos with the + //same basename that could be forks + var candidates []*Repo + for _, repo := range i.Repos { + isCandidate := false + for _, remote := range repo.Remotes { + otherExpandedRemoteURL := ExpandRemoteURL(remote.URL) + if expandedRemoteURL == otherExpandedRemoteURL { + return repo, nil + } + if basename == path.Base(otherExpandedRemoteURL) { + isCandidate = true + } + } + if isCandidate { + candidates = append(candidates, repo) + } + } + + //double-check if the repo is already checked out, but we didn't notice it yet + newRepo, err := NewRepoFromRemoteURL(remoteURL) + if err != nil { + return nil, err + } + _, err = os.Stat(newRepo.AbsolutePath()) + switch { + case err == nil: + return nil, fmt.Errorf( + "%s already exists (if there is a repo there, try `rtree index`)", + newRepo.AbsolutePath(), + ) + case !os.IsNotExist(err): + return nil, err + } + + if !allowClone { + return nil, errors.New("no such remote in index (you can validate the index with `rtree index`)") + } + + //if no fork candidates found, clone as new repo + if len(candidates) == 0 { + err := newRepo.Checkout() + if err != nil { + return nil, err + } + i.Repos = append(i.Repos, &newRepo) + i.Write() + return &newRepo, nil + } + + //if we found fork candidates, ask the user to match the repo with a fork + //candidate (or confirm that the repo shall be cloned fresh) + if len(candidates) > 10 { + candidates = candidates[:10] + } + choices := make([]cli.Choice, len(candidates)+1) + for idx, repo := range candidates { + 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(), + } + selection, err := cli.Interface.Query("Found possible fork candidates. What to do?", choices...) + if err != nil { + return nil, err + } + + if selection == "clone" { + err := newRepo.Checkout() + if err != nil { + return nil, err + } + i.Repos = append(i.Repos, &newRepo) + i.Write() + return &newRepo, nil + } + + //find the repo selected by the user + var target *Repo + for _, repo := range candidates { + if repo.CheckoutPath == selection { + target = repo + break + } + } + + //report the existing remotes, and ask for the name of the new remote + prompt := "Existing remotes:\n" + for _, remote := range target.Remotes { + prompt += fmt.Sprintf("\t(%s) %s\n", remote.Name, remote.URL) + } + prompt += fmt.Sprintf("Enter remote name for %s:", remoteURL) + remoteName, err := cli.Interface.ReadLine(prompt) + if err != nil { + return nil, err + } + + err = cli.Interface.Run(cli.Command{ + Program: []string{"git", "remote", "add", remoteName, remoteURL}, + WorkDir: target.AbsolutePath(), + }) + if err != nil { + return nil, err + } + + err = cli.Interface.Run(cli.Command{ + Program: []string{"git", "remote", "update", remoteName}, + WorkDir: target.AbsolutePath(), + }) + if err != nil { + return nil, err + } + + target.Remotes = append(target.Remotes, Remote{ + Name: remoteName, + URL: remoteURL, + }) + i.Write() + return target, nil +} + +//ImportRepo moves the given repo into the rtree and adds it to the index. +func (i *Index) ImportRepo(dirPath string) error { + //need to make dirPath absolute first + dirPath, err := filepath.Abs(dirPath) + if err != nil { + return err + } + repo, err := NewRepoFromAbsolutePath(dirPath) + if err != nil { + return err + } + + //repo must be outside $GOPATH/src + if !strings.HasPrefix(repo.CheckoutPath, "../") { + return fmt.Errorf("%s is already inside GOPATH", dirPath) + } + + //select the remote which determines the checkout path + choices := make([]cli.Choice, len(repo.Remotes)) + var checkoutPath string + for idx, remote := range repo.Remotes { + thisPath, err := CheckoutPathForRemoteURL(ExpandRemoteURL(remote.URL)) + if err != nil { + return err + } + if remote.Name == "origin" { + //prefer "origin" over everything else + checkoutPath = thisPath + break + } + choices[idx] = cli.Choice{Return: thisPath, Text: thisPath} + } + + //cannot decide myself -> let the user select + if checkoutPath == "" { + if len(choices) == 0 { + return errors.New("repo has no remotes") + } + + question := fmt.Sprintf("Repo has multiple remotes. Where to put below %s?", RootPath) + checkoutPath, err = cli.Interface.Query(question, choices...) + if err != nil { + return err + } + } + + //double-check that there is no such repo in the rtree yet + for _, other := range i.Repos { + if other.CheckoutPath == checkoutPath { + return errors.New("will not overwrite existing checkout at " + other.AbsolutePath()) + } + } + + //do the move + err = repo.Move(checkoutPath, true) + if err != nil { + return err + } + i.Repos = append(i.Repos, &repo) + return nil +} + +//DropRepo deletes the given repo from the rtree and removes it from the index. +func (i *Index) DropRepo(repo *Repo) error { + err := repo.Exec("git", "status") + if err != nil { + return err + } + ok, err := cli.Interface.Confirm(">> Drop this repo?") + if !ok || err != nil { + return err + } + + err = os.RemoveAll(repo.AbsolutePath()) + if err != nil { + return err + } + + reposNew := make([]*Repo, 0, len(i.Repos)-1) + for _, r := range i.Repos { + if r.CheckoutPath != repo.CheckoutPath { + reposNew = append(reposNew, r) + } + } + i.Repos = reposNew + return i.Write() +} diff --git a/internal/rtree/init.go b/internal/rtree/init.go new file mode 100644 index 0000000..569f042 --- /dev/null +++ b/internal/rtree/init.go @@ -0,0 +1,99 @@ +/******************************************************************************* +* +* 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 rtree + +import ( + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/majewsky/gofu/internal/cli" +) + +//RemoteAlias describes an alias that can be used in a Git remote URL (as +//defined by the "url.<base>.insteadOf" directive in man:git-config(1)). +type RemoteAlias struct { + Alias string + Replacement string +} + +//IndexPath is where the index file is stored. +var IndexPath string + +//RootPath is the directory below which all repositories are located. Its value +//is $GOPATH/src to match the repository layout created by `go get`. +var RootPath string + +//RemoteAliases is the list of remote aliases that is used by ExpandRemoteURL(). +var RemoteAliases []*RemoteAlias + +//Init initializes the global variables of this package to their standard +//values, unless they are already populated. Unit tests shall set IndexPath, +//RootPath etc. before calling Exec(), such that this function becomes a no-op +//when called by Exec(). +// +//Returns false if initialization failed. +func Init() bool { + ok := true //until shown otherwise + + if IndexPath == "" { + homeDir := os.Getenv("HOME") + if homeDir == "" { + cli.Interface.ShowError("$HOME is not set (rtree needs the HOME variable to locate its index file)") + ok = false //but keep going to report all errors at once + } else { + IndexPath = filepath.Join(homeDir, ".rtree/index.yaml") + } + } + + if RootPath == "" { + gopath := os.Getenv("GOPATH") + if gopath == "" { + cli.Interface.ShowError("$GOPATH is not set (rtree needs the GOPATH variable to know where to look for and place repos)") + ok = false //but keep going to report all errors at once + } else { + RootPath = filepath.Join(gopath, "src") + } + } + + if RemoteAliases == nil { + out, err := cli.Interface.CaptureStdout(cli.Command{ + Program: []string{"git", "config", "--global", "-l"}, + }) + if err != nil { + cli.Interface.ShowError(err.Error()) + ok = false //but keep going to report all errors at once + } + + rx := regexp.MustCompile(`^url\.([^=]+)\.insteadof=(.+)$`) + for _, line := range strings.Split(out, "\n") { + match := rx.FindStringSubmatch(line) + if match == nil { + continue + } + RemoteAliases = append(RemoteAliases, &RemoteAlias{ + Alias: match[2], + Replacement: match[1], + }) + } + } + + return ok +} diff --git a/internal/rtree/main.go b/internal/rtree/main.go new file mode 100644 index 0000000..d1692cd --- /dev/null +++ b/internal/rtree/main.go @@ -0,0 +1,171 @@ +/******************************************************************************* +* +* 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 rtree + +import ( + "strings" + + "github.com/majewsky/gofu/internal/cli" +) + +//Exec executes the rtree applet and returns an exit code (0 for success, >0 +//for error). The argument is os.Args minus the leading "rtree" or "gofu +//rtree". All side-effects (reading from stdin, writing to stdout/stderr, +//executing other programs) pass through cli.Interface and can be intercepted +//there for the purpose of testing. +func Exec(args []string) int { + if !Init() { + return 1 + } + + if len(args) == 0 { + return usage() + } + + index, errs := ReadIndex() + if len(errs) > 0 { + for _, err := range errs { + cli.Interface.ShowError(err.Error()) + } + return 1 + } + + var err error + switch args[0] { + case "get": + if len(args) != 2 { + return usage() + } + err = commandGet(index, args[1]) + case "drop": + if len(args) != 2 { + return usage() + } + err = commandDrop(index, args[1]) + case "index": + if len(args) != 1 { + return usage() + } + err = commandIndex(index) + case "repos": + if len(args) != 1 { + return usage() + } + commandRepos(index) + case "remotes": + if len(args) != 1 { + return usage() + } + commandRemotes(index) + case "import": + if len(args) != 2 { + return usage() + } + err = commandImport(index, args[1]) + case "each": + if len(args) < 2 { + return usage() + } + return commandEach(index, args[1:]) + default: + return usage() + } + + if err == nil { + return 0 + } + cli.Interface.ShowError(err.Error()) + return 1 +} + +var usageStr = strings.TrimSpace(` +Usage: + rtree [get|drop] <url> + rtree [index|repos|remotes] + rtree import <path> + rtree each <command> +`) + +func usage() int { + cli.Interface.ShowUsage(usageStr) + return 1 +} + +func commandGet(index *Index, url string) error { + repo, err := index.FindRepo(url, true) + if err != nil { + return err + } + cli.Interface.ShowResult(repo.AbsolutePath()) + return nil +} + +func commandDrop(index *Index, url string) error { + repo, err := index.FindRepo(url, false) + if err != nil { + return err + } + return index.DropRepo(repo) +} + +func commandIndex(index *Index) error { + err := index.Rebuild() + if err != nil { + return err + } + return index.Write() +} + +func commandRepos(index *Index) { + var items []string + for _, repo := range index.Repos { + items = append(items, repo.CheckoutPath) + } + cli.Interface.ShowResultsSorted(items) +} + +func commandRemotes(index *Index) { + var items []string + for _, repo := range index.Repos { + for _, remote := range repo.Remotes { + items = append(items, remote.URL) + } + } + cli.Interface.ShowResultsSorted(items) +} + +func commandEach(index *Index, cmdline []string) (exitCode int) { + exitCode = 0 + for _, repo := range index.Repos { + err := repo.Exec(cmdline...) + if err != nil { + cli.Interface.ShowError(err.Error()) + exitCode = 1 + } + } + return +} + +func commandImport(index *Index, dirPath string) error { + err := index.ImportRepo(dirPath) + if err != nil { + return err + } + return index.Write() +} diff --git a/internal/rtree/remote.go b/internal/rtree/remote.go new file mode 100644 index 0000000..0c4e880 --- /dev/null +++ b/internal/rtree/remote.go @@ -0,0 +1,75 @@ +/******************************************************************************* +* +* 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 rtree + +import ( + "net/url" + "path/filepath" + "regexp" + "strings" +) + +//ExpandRemoteURL derive the canonical URL for a given remote by substituting +//aliases defined in the system-wide and user-global Git config. For example, +//with +// +// $ cat /etc/gitconfig +// [url "git://github.com/"] +// insteadOf = gh: +// +//and the input "gh:foo/bar", this function returns "git://github.com/foo/bar". +func ExpandRemoteURL(remoteURL string) string { + var best *RemoteAlias + for _, current := range RemoteAliases { + if strings.HasPrefix(remoteURL, current.Alias) { + if best == nil || len(best.Alias) < len(current.Alias) { + best = current + } + } + } + if best == nil { + return remoteURL + } + return best.Replacement + strings.TrimPrefix(remoteURL, best.Alias) +} + +//This regex recognizes the scp-like syntax for git remotes +//(i.e. "[user@]example.org:path/to/repo") as specified by the "GIT URLS" +//section of man:git-clone(1). +var scpSyntaxRx = regexp.MustCompile(`^(?:[^/@:]+@)?([^/:]+\.[^/:]+):(.+)$`) + +//CheckoutPathForRemoteURL derives the checkout path for a remote URL that has +//already been expanded with ExpandRemoteURL() if necessary. +// +// "https://example.org/foo/bar" -> "example.org/foo/bar" +// "git@example.org:foo/bar" -> "example.org/foo/bar" +// +func CheckoutPathForRemoteURL(remoteURL string) (string, error) { + match := scpSyntaxRx.FindStringSubmatch(remoteURL) + if match != nil { + //match[1] is the hostname, match[2] is the path to the repo + return filepath.Join(match[1], match[2]), nil + } + + u, err := url.Parse(remoteURL) + if err != nil { + return "", err + } + return filepath.Join(u.Hostname(), u.Path), nil +} diff --git a/internal/rtree/repo.go b/internal/rtree/repo.go new file mode 100644 index 0000000..4af51c1 --- /dev/null +++ b/internal/rtree/repo.go @@ -0,0 +1,225 @@ +/******************************************************************************* +* +* 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 rtree + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/majewsky/gofu/internal/cli" +) + +//Repo describes the entry for a repository in the index file. +type Repo struct { + //CheckoutPath shall be relative to the RootPath. + CheckoutPath string `yaml:"path"` + //Remotes maps remote names (as noted in the .git/config of the repo) to + //remote URLs (as they appear in the .git/config of the repo, i.e. possibly + //abbreviated). + Remotes []Remote `yaml:"remotes"` +} + +//Remote describes a remote that is configured in a Repo. +type Remote struct { + Name string `yaml:"name"` + URL string `yaml:"url"` +} + +//AbsolutePath returns the absolute CheckoutPath of this repo. +func (r Repo) AbsolutePath() string { + return filepath.Join(RootPath, r.CheckoutPath) +} + +//NewRepoFromAbsolutePath initializes a Repo instance by scanning the existing +//checkout at the given path. +func NewRepoFromAbsolutePath(path string) (repo Repo, err error) { + repo.CheckoutPath, err = filepath.Rel(RootPath, path) + if err != nil { + return + } + + //list remotes + out, err := cli.Interface.CaptureStdout(cli.Command{ + Program: []string{"git", "config", "-l"}, + WorkDir: path, + }) + if err != nil { + return + } + + for _, line := range strings.Split(out, "\n") { + match := remoteConfigRx.FindStringSubmatch(line) + if match == nil { + continue + } + repo.Remotes = append(repo.Remotes, Remote{ + Name: match[1], + URL: match[2], + }) + } + return +} + +//NewRepoFromRemoteURL initializes a Repo instance for checking out a remote +//for the first time. The checkout does not happen until Checkout() is called. +func NewRepoFromRemoteURL(remoteURL string) (Repo, error) { + checkoutPath, err := CheckoutPathForRemoteURL(ExpandRemoteURL(remoteURL)) + return Repo{ + CheckoutPath: checkoutPath, + Remotes: []Remote{ + { + Name: "origin", + URL: remoteURL, + }, + }, + }, err +} + +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 { + return filepath.Walk(RootPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + //look for repos, i.e. directories containing a .git directory + if !info.IsDir() { + return nil + } + _, err = os.Stat(filepath.Join(path, ".git")) + if err != nil { + return nil + } + + //appears to be a repo + repo, err := NewRepoFromAbsolutePath(path) + if err == nil { + err = action(repo) + } + if err != nil { + return err + } + //do not traverse further down into submodules etc. + return filepath.SkipDir + }) +} + +//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 { + //check if we have an "origin" remote to clone from + var originURL string + for _, remote := range r.Remotes { + if remote.Name == "origin" { + originURL = remote.URL + break + } + } + + if originURL == "" { + err := cli.Interface.Run(cli.Command{ + Program: []string{"git", "init", r.AbsolutePath()}, + }) + if err != nil { + return err + } + cli.Interface.ShowWarning(`will not checkout anything since there is no remote named "origin"`) + } else { + err := cli.Interface.Run(cli.Command{ + Program: []string{"git", "clone", originURL, r.AbsolutePath()}, + }) + if err != nil { + return err + } + } + + remotesAdded := false + for _, remote := range r.Remotes { + if remote.Name != "origin" { + err := cli.Interface.Run(cli.Command{ + Program: []string{"git", "remote", "add", remote.Name, remote.URL}, + WorkDir: r.AbsolutePath(), + }) + if err != nil { + return err + } + remotesAdded = true + } + } + if remotesAdded { + return cli.Interface.Run(cli.Command{ + Program: []string{"git", "remote", "update"}, + WorkDir: r.AbsolutePath(), + }) + } + + return nil +} + +//Exec implements the meat of the `rtree exec` command. It returns +//true iff the command exited successfully. +func (r Repo) Exec(cmdline ...string) error { + cli.Interface.ShowProgress(r.AbsolutePath()) + return cli.Interface.Run(cli.Command{ + Program: cmdline, + WorkDir: r.AbsolutePath(), + }) +} + +//Move sets the CheckoutPath to the given value and moves the existing repo +//from the old to the new checkoutPath. If makeSymlink is given, a symlink will +//be created from the old to the new location. +func (r *Repo) Move(checkoutPath string, makeSymlink bool) error { + sourcePath := filepath.Join(RootPath, r.CheckoutPath) + targetPath := filepath.Join(RootPath, checkoutPath) + + //ensure that target does not exist + _, err := os.Lstat(targetPath) + if err == nil { + return fmt.Errorf("cannot move %s to %s: target exists in filesystem", sourcePath, targetPath) + } + if !os.IsNotExist(err) { + return err + } + + //prepare directory to move repo into + err = os.MkdirAll(filepath.Dir(targetPath), 0755) + if err != nil { + return err + } + + //move directory + err = os.Rename(sourcePath, targetPath) + if err != nil { + return err + } + r.CheckoutPath = checkoutPath + + //if requested, make compatibility symlink + if makeSymlink { + return os.Symlink(targetPath, sourcePath) + } + return nil +} diff --git a/internal/rtree/shared_test.go b/internal/rtree/shared_test.go new file mode 100644 index 0000000..6720b1c --- /dev/null +++ b/internal/rtree/shared_test.go @@ -0,0 +1,204 @@ +/******************************************************************************* +* +* 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 rtree + +import ( + "bytes" + "errors" + "fmt" + "io" + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/majewsky/gofu/internal/cli" + yaml "gopkg.in/yaml.v2" +) + +//Path to a directory where tests can put their index files. +var indexTmpDir = filepath.Join(os.TempDir(), fmt.Sprintf("rtree-test-%d", os.Getpid())) + +func TestMain(m *testing.M) { + //make sure that test does not accidentally access user's actual rtree or index + os.Setenv("HOME", "") + os.Setenv("GOPATH", "") + //setup test configuration + RootPath = "/unittest/gopath/src" + RemoteAliases = []*RemoteAlias{ + {Alias: "gh:", Replacement: "https://github.com/"}, + {Alias: "my/", Replacement: "git@git.example.com:"}, + } + + exitCode := m.Run() + + //shared teardown + os.RemoveAll(indexTmpDir) + + os.Exit(exitCode) +} + +//Test describes a call to Main(), the environment that's given to it, and the +//assertions that are checked after the call returns. +type Test struct { + Args []string + Input string + Index Index + ExpectFailure bool + ExpectOutput string + ExpectError string + ExpectIndex *Index //if nil, .Index will be used instead + ExpectExecution []RecordedCommand +} + +func (test Test) Run(t *testing.T) { + //write index file, if any + IndexPath = filepath.Join(indexTmpDir, t.Name()+".yaml") + if test.Index.Repos != nil { + err := test.Index.Write() + if err != nil { + t.Fatalf("%s: cannot write index to %s: %s", t.Name(), IndexPath, err.Error()) + } + } + + //setup cli.Interface for test + var stdout bytes.Buffer + var stderr bytes.Buffer + cs := CommandSimulator{Cmd: test.ExpectExecution} + cli.SetupInterface(bytes.NewReader([]byte(test.Input)), &stdout, &stderr, cs.Next) + + //check exit code + exitCode := Exec(test.Args) + switch { + case exitCode == 0 && test.ExpectFailure: + t.Errorf("%s: expected failure, but returned success", t.Name()) + case exitCode != 0 && !test.ExpectFailure: + t.Errorf("%s: expected success, but returned failure", t.Name()) + } + + //check output + output := string(stdout.Bytes()) + if output != test.ExpectOutput { + t.Errorf("%s: expected stdout %#v, but got %#v", t.Name(), test.ExpectOutput, output) + } + output = string(stderr.Bytes()) + if output != test.ExpectError { + t.Errorf("%s: expected stderr %#v, but got %#v", t.Name(), test.ExpectError, output) + } + + //check index + idx := &test.Index + if test.ExpectIndex != nil { + idx = test.ExpectIndex + } + expectedIdxStr, err := yaml.Marshal(idx) + if err != nil { + t.Fatal(err.Error()) + } + actualIdxStr, err := ioutil.ReadFile(IndexPath) + if err != nil { + t.Fatalf("%s: could not read index from %s: %s", t.Name(), IndexPath, err.Error()) + } + if string(expectedIdxStr) != string(actualIdxStr) { + t.Errorf("%s: index does not match expectation after test; diff follows", t.Name()) + cmd := exec.Command("diff", "-u", "-", IndexPath) + cmd.Stdin = bytes.NewReader([]byte(expectedIdxStr)) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err := cmd.Run() + if err != nil { + t.Fatal(err.Error()) + } + } +} + +type RecordedCommand struct { + Cmd cli.Command + Stdout string + Stderr string + Fails bool +} + +//Recorded is a shortcut function for initializing a []RecordedCommand. It +//splits each line on whitespace to obtain the command line of that command, +//and recognizes a leading "@/some/path" to set the workdir. +// +//This function can only be used for RecordedCommands without output that do not fail. +func Recorded(lines ...string) (cs []RecordedCommand) { + cs = make([]RecordedCommand, len(lines)) + for idx, line := range lines { + cmdline := strings.Fields(line) + if strings.HasPrefix(cmdline[0], "@") { + cs[idx].Cmd.WorkDir = strings.TrimPrefix(cmdline[0], "@") + cmdline = cmdline[1:] + } + cs[idx].Cmd.Program = cmdline + } + return +} + +//CommandSimulator implements the cli.CommandRunner interface (via its Next +//method). When a cli.Command is given to Next(), it is matched with the next +//command in the .Cmd list, and the result from that RecordedCommand is +//returned. If the given Command is different from the one expected (or if the +//.Cmd list has been exhausted), an error is returned. +type CommandSimulator struct { + Cmd []RecordedCommand + idx int +} + +func (s *CommandSimulator) Next(c cli.Command, stdin io.Reader, stdout, stderr io.Writer) error { + //take next RecordedCommand from list + if s.idx >= len(s.Cmd) { + return errors.New("got command to execute, but recorded commands have been exhausted") + } + sc := s.Cmd[s.idx] + s.idx++ + + //check if the given Command matches the expectation + if !areStringListsEqual(sc.Cmd.Program, c.Program) { + return fmt.Errorf("expected command %#v, but got %#v", + strings.Join(sc.Cmd.Program, " "), strings.Join(c.Program, " "), + ) + } + if sc.Cmd.WorkDir != c.WorkDir { + return fmt.Errorf("expected command workdir %s, but got %s", sc.Cmd.WorkDir, c.WorkDir) + } + + stdout.Write([]byte(sc.Stdout)) + stderr.Write([]byte(sc.Stderr)) + if sc.Fails { + return fmt.Errorf("command %#v has failed", strings.Join(c.Program, " ")) + } + return nil +} + +func areStringListsEqual(a []string, b []string) bool { + if len(a) != len(b) { + return false + } + for idx := range a { + if a[idx] != b[idx] { + return false + } + } + return true +} |
