diff options
| -rw-r--r-- | index.go | 36 | ||||
| -rw-r--r-- | main.go | 72 | ||||
| -rw-r--r-- | remote.go | 78 | ||||
| -rw-r--r-- | repo.go | 180 | ||||
| -rw-r--r-- | util.go | 42 |
5 files changed, 380 insertions, 28 deletions
@@ -28,26 +28,15 @@ import ( yaml "gopkg.in/yaml.v2" ) -//Repo describes the entry for a repository in the index file. -type Repo struct { - //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 map[string]string `yaml:"remotes"` - //CheckoutPath shall be relative to the Index.RootPath. - CheckoutPath string `yaml:"path"` -} - //Index represents the contents of the index file. type Index struct { - RootPath string `yaml:"root"` - Repos []Repo `yaml:"repos"` + Repos []*Repo `yaml:"repos"` } func indexPath() string { homeDir := os.Getenv("HOME") if homeDir == "" { - FatalIfError(errors.New("$HOME is not set")) + FatalIfError(errors.New("$HOME is not set (rtree needs the HOME variable to locate its index file)")) } return filepath.Join(homeDir, ".rtree/index.yaml") } @@ -59,12 +48,7 @@ func ReadIndex() *Index { buf, err := ioutil.ReadFile(path) if err != nil { if os.IsNotExist(err) { - //initialize empty index - rootPath := os.Getenv("GOPATH") - if rootPath == "" { - FatalIfError(fmt.Errorf("cannot initialize %s ($GOPATH not set)", path)) - } - return &Index{RootPath: rootPath} + return &Index{Repos: nil} } FatalIfError(err) } @@ -75,10 +59,6 @@ func ReadIndex() *Index { //validate YAML valid := true - if index.RootPath == "" { - ShowError(errors.New("missing \"root\"")) - valid = false - } for idx, repo := range index.Repos { if repo.CheckoutPath == "" { ShowError(fmt.Errorf("missing \"repos[%d].path\"", idx)) @@ -88,13 +68,13 @@ func ReadIndex() *Index { ShowError(fmt.Errorf("missing \"repos[%d].remotes\"", idx)) valid = false } - for remoteName, remoteURL := range repo.Remotes { + for idx2, remote := range repo.Remotes { switch { - case remoteName == "": - ShowError(fmt.Errorf("empty remote name found in \"repos[%d]\"", idx)) + case remote.Name == "": + ShowError(fmt.Errorf("missing \"repos[%d].remotes[%d].name\"", idx, idx2)) valid = false - case remoteURL == "": - ShowError(fmt.Errorf("missing remote URL for remote \"%s\" in \"repos[%d]\"", idx, remoteName)) + case remote.URL == "": + ShowError(fmt.Errorf("missing \"repos[%d].remotes[%d].url\"", idx, idx2)) valid = false } } @@ -21,6 +21,8 @@ package main import ( "fmt" "os" + "path/filepath" + "strings" ) func main() { @@ -60,4 +62,74 @@ func usageAndExit() { } func commandIndex() { + oldIndex := ReadIndex() + var newIndex Index + + //check if existing index entries are still checked out + existingRepos := make(map[string]*Repo) + for _, repo := range oldIndex.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 + newIndex.Repos = append(newIndex.Repos, repo) + continue + } + FatalIfError(fmt.Errorf("%s is not a directory: I'm seriously confused", gitDirPath)) + } + if !os.IsNotExist(err) { + FatalIfError(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 = Prompt( + "no remote to restore from; (d)elete from index or (s)kip?", + []string{"d", "s"}, + ) + } else { + choice = 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": + FatalIfError(repo.Checkout()) + newIndex.Repos = append(newIndex.Repos, repo) + case "d": + continue + case "s": + newIndex.Repos = append(newIndex.Repos, repo) + } + } + + //index new repos + FatalIfError(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 { + newIndex.Repos = append(newIndex.Repos, &newRepo) + } + return nil + })) + + newIndex.Write() } diff --git a/remote.go b/remote.go new file mode 100644 index 0000000..ed5efd7 --- /dev/null +++ b/remote.go @@ -0,0 +1,78 @@ +/******************************************************************************* +* +* 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 main + +import ( + "bytes" + "os/exec" + "regexp" + "strings" +) + +//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 + 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) +} @@ -0,0 +1,180 @@ +/******************************************************************************* +* +* 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 main + +import ( + "bytes" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" +) + +//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 == "" { + 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 +} @@ -19,8 +19,10 @@ package main import ( + "bufio" "fmt" "os" + "strings" ) //ShowError prints the given error on stderr if it is non-nil, or returns false otherwise. @@ -39,3 +41,43 @@ func FatalIfError(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(": ")) + } +} |
