aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorStefan Majewsky <majewsky@gmx.net>2020-03-28 13:53:10 +0100
committerStefan Majewsky <majewsky@gmx.net>2020-03-28 13:53:10 +0100
commited2b9374599cc00467b1c27121318748cc0d3f49 (patch)
tree65e3200a0b69817b24ae772223d1d852a52458af /internal
parent1ff86053ddef8de3247909434e0bd7000c060f2a (diff)
downloadgofu-ed2b9374599cc00467b1c27121318748cc0d3f49.tar.gz
rename pkg/ to internal/
Diffstat (limited to 'internal')
-rw-r--r--internal/cli/command.go70
-rw-r--r--internal/cli/interface.go168
-rw-r--r--internal/cli/query.go295
-rw-r--r--internal/i3status/battery.go75
-rw-r--r--internal/i3status/main.go169
-rw-r--r--internal/i3status/net.go61
-rw-r--r--internal/prompt/cloud.go83
-rw-r--r--internal/prompt/git.go138
-rw-r--r--internal/prompt/login.go66
-rw-r--r--internal/prompt/main.go123
-rw-r--r--internal/prompt/misc.go43
-rw-r--r--internal/prompt/pwd.go157
-rw-r--r--internal/rtree/get_test.go140
-rw-r--r--internal/rtree/index.go431
-rw-r--r--internal/rtree/init.go99
-rw-r--r--internal/rtree/main.go171
-rw-r--r--internal/rtree/remote.go75
-rw-r--r--internal/rtree/repo.go225
-rw-r--r--internal/rtree/shared_test.go204
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
+}