aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--index.go36
-rw-r--r--main.go72
-rw-r--r--remote.go78
-rw-r--r--repo.go180
-rw-r--r--util.go42
5 files changed, 380 insertions, 28 deletions
diff --git a/index.go b/index.go
index 967fe35..c56c2e1 100644
--- a/index.go
+++ b/index.go
@@ -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
}
}
diff --git a/main.go b/main.go
index 012667e..9027dbb 100644
--- a/main.go
+++ b/main.go
@@ -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)
+}
diff --git a/repo.go b/repo.go
new file mode 100644
index 0000000..f0c2491
--- /dev/null
+++ b/repo.go
@@ -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
+}
diff --git a/util.go b/util.go
index 09f93be..a1cea5f 100644
--- a/util.go
+++ b/util.go
@@ -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(": "))
+ }
+}