summaryrefslogtreecommitdiff
path: root/pkg
diff options
context:
space:
mode:
authorStefan Majewsky <majewsky@gmx.net>2017-05-02 21:22:20 +0200
committerStefan Majewsky <majewsky@gmx.net>2017-05-02 21:22:20 +0200
commit72733e8203654cd26464c8c5a6c955b025102cca (patch)
tree4a8c312fb25dcf79f2cd61b88b7f2ff6baf8d996 /pkg
parent6b382c55ed7885857a6232a0c8b086ab1f64985e (diff)
downloadgofu-72733e8203654cd26464c8c5a6c955b025102cca.tar.gz
restructure rtree into gofu (with rtree as the first applet)
Diffstat (limited to 'pkg')
-rw-r--r--pkg/rtree/index.go187
-rw-r--r--pkg/rtree/main.go115
-rw-r--r--pkg/rtree/remote.go80
-rw-r--r--pkg/rtree/repo.go198
-rw-r--r--pkg/util/util.go90
5 files changed, 670 insertions, 0 deletions
diff --git a/pkg/rtree/index.go b/pkg/rtree/index.go
new file mode 100644
index 0000000..a4a0b74
--- /dev/null
+++ b/pkg/rtree/index.go
@@ -0,0 +1,187 @@
+/*******************************************************************************
+*
+* 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/filepath"
+ "sort"
+ "strings"
+
+ "github.com/majewsky/gofu/pkg/util"
+
+ yaml "gopkg.in/yaml.v2"
+)
+
+//Index represents the contents of the index file.
+type Index struct {
+ Repos []*Repo `yaml:"repos"`
+}
+
+func indexPath() string {
+ homeDir := os.Getenv("HOME")
+ if homeDir == "" {
+ util.FatalIfError(errors.New("$HOME is not set (rtree needs the HOME variable to locate its index file)"))
+ }
+ return filepath.Join(homeDir, ".rtree/index.yaml")
+}
+
+//ReadIndex reads the index file.
+func ReadIndex() *Index {
+ //read contents of index file
+ path := indexPath()
+ buf, err := ioutil.ReadFile(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return &Index{Repos: nil}
+ }
+ util.FatalIfError(err)
+ }
+
+ //deserialize YAML
+ var index Index
+ util.FatalIfError(yaml.Unmarshal(buf, &index))
+
+ //validate YAML
+ valid := true
+ for idx, repo := range index.Repos {
+ if repo.CheckoutPath == "" {
+ util.ShowError(fmt.Errorf("missing \"repos[%d].path\"", idx))
+ valid = false
+ }
+ if len(repo.Remotes) == 0 {
+ util.ShowError(fmt.Errorf("missing \"repos[%d].remotes\"", idx))
+ valid = false
+ }
+ for idx2, remote := range repo.Remotes {
+ switch {
+ case remote.Name == "":
+ util.ShowError(fmt.Errorf("missing \"repos[%d].remotes[%d].name\"", idx, idx2))
+ valid = false
+ case remote.URL == "":
+ util.ShowError(fmt.Errorf("missing \"repos[%d].remotes[%d].url\"", idx, idx2))
+ valid = false
+ }
+ }
+ }
+
+ if !valid {
+ util.FatalIfError(errors.New("index file is corrupted; see errors above"))
+ }
+
+ sort.Sort(reposByAbsPath(index.Repos))
+ return &index
+}
+
+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() {
+ buf, err := yaml.Marshal(i)
+ util.FatalIfError(err)
+ path := indexPath()
+ util.FatalIfError(os.MkdirAll(filepath.Dir(path), 0755))
+ util.FatalIfError(ioutil.WriteFile(path, buf, 0644))
+}
+
+//InteractiveRebuild implements the `rtree index` subcommand.
+func (i *Index) InteractiveRebuild() error {
+ //check if existing index entries are still checked out
+ existingRepos := make(map[string]*Repo)
+ var newRepos []*Repo
+ for _, repo := range i.Repos {
+ gitDirPath := filepath.Join(repo.AbsolutePath(), ".git")
+ fi, err := os.Stat(gitDirPath)
+ if err == nil {
+ if fi.IsDir() {
+ //everything okay with this repo
+ existingRepos[repo.CheckoutPath] = repo
+ newRepos = append(newRepos, repo)
+ continue
+ }
+ return fmt.Errorf("%s is not a directory: I'm seriously confused", gitDirPath)
+ }
+ if err != nil && !os.IsNotExist(err) {
+ return err
+ }
+
+ //repo has been deleted - ask what to do
+ fmt.Printf("repository %s has been deleted\n", filepath.Join(RootPath, repo.CheckoutPath))
+
+ var remoteURLs []string
+ for _, remote := range repo.Remotes {
+ if remote.Name == "origin" {
+ remoteURLs = []string{remote.URL}
+ break
+ }
+ remoteURLs = append(remoteURLs, remote.URL)
+ }
+
+ var choice string
+ if len(remoteURLs) == 0 {
+ choice = util.Prompt(
+ "no remote to restore from; (d)elete from index or (s)kip?",
+ []string{"d", "s"},
+ )
+ } else {
+ choice = util.Prompt(
+ fmt.Sprintf("(r)estore from %s, (d)elete from index, or (s)kip?", strings.Join(remoteURLs, " and ")),
+ []string{"r", "d", "s"},
+ )
+ }
+
+ switch choice {
+ case "r":
+ err := repo.Checkout()
+ if err != nil {
+ return err
+ }
+ newRepos = append(newRepos, repo)
+ case "d":
+ continue
+ case "s":
+ newRepos = append(newRepos, 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
+}
diff --git a/pkg/rtree/main.go b/pkg/rtree/main.go
new file mode 100644
index 0000000..9513d06
--- /dev/null
+++ b/pkg/rtree/main.go
@@ -0,0 +1,115 @@
+/*******************************************************************************
+*
+* 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"
+
+ "github.com/majewsky/gofu/pkg/util"
+)
+
+//Exec executes the rtree applet and does not return. The argument is os.Args
+//minus the leading "rtree" or "gofu rtree".
+func Exec(args []string) {
+ if len(args) == 0 {
+ usageAndExit()
+ }
+ switch args[0] {
+ case "get":
+ panic("unimplemented")
+ case "drop":
+ panic("unimplemented")
+ case "index":
+ if len(args) != 1 {
+ usageAndExit()
+ }
+ commandIndex()
+ case "repos":
+ if len(args) != 1 {
+ usageAndExit()
+ }
+ commandRepos()
+ case "remotes":
+ if len(args) != 1 {
+ usageAndExit()
+ }
+ commandRemotes()
+ case "import":
+ panic("unimplemented")
+ case "each":
+ if len(args) < 2 {
+ usageAndExit()
+ }
+ commandEach(args[1], args[2:])
+ default:
+ usageAndExit()
+ }
+
+ os.Exit(0)
+}
+
+func usageAndExit() {
+ fmt.Fprintln(os.Stderr, "Usage:")
+ fmt.Fprintln(os.Stderr, " rtree [get|drop] <url>")
+ fmt.Fprintln(os.Stderr, " rtree [index|repos|remotes]")
+ fmt.Fprintln(os.Stderr, " rtree import <path>")
+ fmt.Fprintln(os.Stderr, " rtree each <command>")
+ os.Exit(1)
+}
+
+func commandIndex() {
+ index := ReadIndex()
+ util.FatalIfError(index.InteractiveRebuild())
+ index.Write()
+}
+
+func commandRepos() {
+ index := ReadIndex()
+ var items []string
+ for _, repo := range index.Repos {
+ items = append(items, repo.CheckoutPath)
+ }
+ util.ShowSorted(items)
+}
+
+func commandRemotes() {
+ index := ReadIndex()
+ var items []string
+ for _, repo := range index.Repos {
+ for _, remote := range repo.Remotes {
+ items = append(items, remote.URL)
+ }
+ }
+ util.ShowSorted(items)
+}
+
+func commandEach(command string, args []string) {
+ allOK := true
+ for _, repo := range ReadIndex().Repos {
+ ok := repo.InteractiveExec(command, args...)
+ if !ok {
+ allOK = false
+ }
+ }
+
+ if !allOK {
+ os.Exit(1)
+ }
+}
diff --git a/pkg/rtree/remote.go b/pkg/rtree/remote.go
new file mode 100644
index 0000000..4170717
--- /dev/null
+++ b/pkg/rtree/remote.go
@@ -0,0 +1,80 @@
+/*******************************************************************************
+*
+* 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"
+ "os/exec"
+ "regexp"
+ "strings"
+
+ "github.com/majewsky/gofu/pkg/util"
+)
+
+//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
+}
+
+var remoteAliases []*remoteAlias
+
+func init() {
+ cmd := exec.Command("git", "config", "--global", "-l")
+ var buf bytes.Buffer
+ cmd.Stdout = &buf
+ util.FatalIfError(cmd.Run())
+
+ rx := regexp.MustCompile(`^url\.([^=]+)\.insteadof=(.+)$`)
+ for _, line := range strings.Split(string(buf.Bytes()), "\n") {
+ match := rx.FindStringSubmatch(line)
+ if match == nil {
+ continue
+ }
+ remoteAliases = append(remoteAliases, &remoteAlias{
+ Alias: match[2],
+ Replacement: match[1],
+ })
+ }
+}
+
+//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(url string) string {
+ var best *remoteAlias
+ for _, current := range remoteAliases {
+ if strings.HasPrefix(url, current.Alias) {
+ if best == nil || len(best.Alias) < len(current.Alias) {
+ best = current
+ }
+ }
+ }
+ if best == nil {
+ return url
+ }
+ return best.Replacement + strings.TrimPrefix(url, best.Alias)
+}
diff --git a/pkg/rtree/repo.go b/pkg/rtree/repo.go
new file mode 100644
index 0000000..f231d0a
--- /dev/null
+++ b/pkg/rtree/repo.go
@@ -0,0 +1,198 @@
+/*******************************************************************************
+*
+* 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"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "strings"
+
+ "github.com/majewsky/gofu/pkg/util"
+)
+
+//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
+
+func init() {
+ gopath := os.Getenv("GOPATH")
+ if gopath == "" {
+ util.FatalIfError(errors.New("$GOPATH is not set (rtree needs the GOPATH variable to know where to look for and place repos)"))
+ }
+ RootPath = filepath.Join(gopath, "src")
+}
+
+//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
+ cmd := exec.Command("git", "-C", path, "config", "-l")
+ var buf bytes.Buffer
+ cmd.Stdout = &buf
+ cmd.Stderr = os.Stderr
+ err = cmd.Run()
+ if err != nil {
+ return repo, fmt.Errorf("exec `git config -l` in %s: %s", path, err.Error())
+ }
+
+ for _, line := range strings.Split(string(buf.Bytes()), "\n") {
+ match := remoteConfigRx.FindStringSubmatch(line)
+ if match == nil {
+ continue
+ }
+ repo.Remotes = append(repo.Remotes, Remote{
+ Name: match[1],
+ URL: match[2],
+ })
+ }
+ return
+}
+
+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 == "" {
+ cmd := exec.Command("git", "init", r.AbsolutePath())
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ err := cmd.Run()
+ if err != nil {
+ return err
+ }
+ fmt.Fprintln(os.Stderr, "warning: will not checkout anything since there is no remote named \"origin\"")
+ } else {
+ cmd := exec.Command("git", "clone", originURL, r.AbsolutePath())
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ err := cmd.Run()
+ if err != nil {
+ return err
+ }
+ }
+
+ remotesAdded := false
+ for _, remote := range r.Remotes {
+ if remote.Name != "origin" {
+ cmd := exec.Command("git", "-C", r.AbsolutePath(), "remote", "add", remote.Name, remote.URL)
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ err := cmd.Run()
+ if err != nil {
+ return err
+ }
+ remotesAdded = true
+ }
+ }
+ if remotesAdded {
+ cmd := exec.Command("git", "-C", r.AbsolutePath(), "remote", "update")
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ return cmd.Run()
+ }
+
+ return nil
+}
+
+//InteractiveExec implements the meat of the `rtree exec` command. It returns
+//true iff the command exited successfully.
+func (r Repo) InteractiveExec(command string, args ...string) (ok bool) {
+ fmt.Fprintf(os.Stdout, "\x1B[1;36m>> \x1B[0;36m%s\x1B[0m\n", r.AbsolutePath())
+ cmd := exec.Command(command, args...)
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ cmd.Dir = r.AbsolutePath()
+ err := cmd.Run()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "\x1B[1;31m!! \x1B[0;31m%s\x1B[0m\n", err.Error())
+ return false
+ }
+ return true
+}
diff --git a/pkg/util/util.go b/pkg/util/util.go
new file mode 100644
index 0000000..3c5e724
--- /dev/null
+++ b/pkg/util/util.go
@@ -0,0 +1,90 @@
+/*******************************************************************************
+*
+* 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 util
+
+import (
+ "bufio"
+ "fmt"
+ "os"
+ "sort"
+ "strings"
+)
+
+//ShowSorted sorts the given lines and prints them on stdout.
+func ShowSorted(lines []string) {
+ sort.Strings(lines)
+ fmt.Println(strings.Join(lines, "\n"))
+}
+
+//ShowError prints the given error on stderr if it is non-nil, or returns false otherwise.
+func ShowError(err error) bool {
+ if err == nil {
+ return false
+ }
+ fmt.Fprintf(os.Stderr, "ERROR: %s\n", err.Error())
+ return true
+}
+
+//FatalIfError prints the given error on stderr and exits with an error code.
+func FatalIfError(err error) {
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "FATAL: %s\n", err.Error())
+ os.Exit(255)
+ }
+}
+
+var stdin = bufio.NewReader(os.Stdin)
+
+//Prompt prints the question, then waits for the user to press one of the
+//possible answer keys. Answer keys will automatically be converted to lower
+//case and returned as such.
+//
+// choice := Prompt("(y)es or (n)o", []string{"y","n"})
+// //choice is either "y" or "n"
+func Prompt(question string, answers []string) string {
+ for idx, answer := range answers {
+ answers[idx] = strings.ToLower(answer)
+ }
+
+ os.Stdout.Write([]byte(">> " + strings.TrimSpace(question) + " "))
+ for {
+ input, err := stdin.ReadString('\n')
+ FatalIfError(err)
+ input = strings.TrimSpace(input)
+ for _, answer := range answers {
+ if strings.ToLower(input) == answer {
+ return answer
+ }
+ }
+
+ //user typed gibberish - ask again
+ os.Stdout.Write([]byte("Please type "))
+ for idx, answer := range answers {
+ if idx > 0 {
+ if idx == len(answers)-1 {
+ os.Stdout.Write([]byte(" or "))
+ } else {
+ os.Stdout.Write([]byte(", "))
+ }
+ }
+ os.Stdout.Write([]byte("'" + answer + "'"))
+ }
+ os.Stdout.Write([]byte(": "))
+ }
+}