diff options
Diffstat (limited to 'pkg')
| -rw-r--r-- | pkg/rtree/index.go | 187 | ||||
| -rw-r--r-- | pkg/rtree/main.go | 115 | ||||
| -rw-r--r-- | pkg/rtree/remote.go | 80 | ||||
| -rw-r--r-- | pkg/rtree/repo.go | 198 | ||||
| -rw-r--r-- | pkg/util/util.go | 90 |
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(": ")) + } +} |
