6
0
mirror of https://github.com/grdl/git-get.git synced 2026-02-14 18:47:35 +00:00

Refactor packages structure

- Isolate files into their own packages
- Create new printer package and interface
- Refactor Repo stuct to embed the go-git *Repository directly
- Simplify cmd package
This commit is contained in:
Grzegorz Dlugoszewski
2020-06-08 12:07:03 +02:00
parent 29c21cb78d
commit f3d0df1bfd
14 changed files with 318 additions and 261 deletions

View File

@@ -1,4 +1,4 @@
package pkg
package cfg
import (
"path"

View File

@@ -1,4 +1,4 @@
package pkg
package cfg
import (
"os"
@@ -156,3 +156,9 @@ func TestConfig(t *testing.T) {
checkFatal(t, err)
}
}
func checkFatal(t *testing.T, err error) {
if err != nil {
t.Fatalf("%+v", err)
}
}

View File

@@ -2,9 +2,14 @@ package main
import (
"fmt"
"git-get/pkg"
"git-get/cfg"
"git-get/git"
"git-get/path"
"git-get/print"
"os"
pathpkg "path"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
@@ -27,30 +32,37 @@ var list bool
func init() {
cmd.PersistentFlags().BoolVarP(&list, "list", "l", false, "Lists all repositories inside git-get root")
cmd.PersistentFlags().StringP(pkg.KeyReposRoot, "r", "", "repos root")
cmd.PersistentFlags().StringP(pkg.KeyPrivateKey, "p", "", "SSH private key path")
viper.BindPFlag(pkg.KeyReposRoot, cmd.PersistentFlags().Lookup(pkg.KeyReposRoot))
viper.BindPFlag(pkg.KeyPrivateKey, cmd.PersistentFlags().Lookup(pkg.KeyReposRoot))
cmd.PersistentFlags().StringP(cfg.KeyReposRoot, "r", "", "repos root")
cmd.PersistentFlags().StringP(cfg.KeyPrivateKey, "p", "", "SSH private key path")
viper.BindPFlag(cfg.KeyReposRoot, cmd.PersistentFlags().Lookup(cfg.KeyReposRoot))
viper.BindPFlag(cfg.KeyPrivateKey, cmd.PersistentFlags().Lookup(cfg.KeyReposRoot))
}
func Run(cmd *cobra.Command, args []string) {
pkg.InitConfig()
cfg.InitConfig()
root := viper.GetString(cfg.KeyReposRoot)
if list {
paths, err := pkg.FindRepos()
paths, err := path.FindRepos()
exitIfError(err)
repos, err := pkg.OpenAll(paths)
repos, err := path.OpenAll(paths)
exitIfError(err)
pkg.PrintRepos(repos)
//tree := BuildTree(root, repos)
//fmt.Println(RenderSmartTree(tree))
printer := print.NewFlatPrinter()
fmt.Println(printer.Print(root, repos))
os.Exit(0)
}
url, err := pkg.ParseURL(args[0])
url, err := path.ParseURL(args[0])
exitIfError(err)
repoPath := pathpkg.Join(root, path.URLToPath(url))
_, err = pkg.CloneRepo(url, viper.GetString(pkg.KeyReposRoot), false)
_, err = git.CloneRepo(url, repoPath, false)
exitIfError(err)
}

View File

@@ -1,12 +1,13 @@
package pkg
package git
import (
"fmt"
"git-get/cfg"
"io"
"io/ioutil"
"net/url"
"os"
"path"
"github.com/pkg/errors"
"github.com/spf13/viper"
@@ -18,18 +19,16 @@ import (
)
type Repo struct {
repo *git.Repository
path string
*git.Repository
Path string
Status *RepoStatus
}
func CloneRepo(url *url.URL, reposRoot string, quiet bool) (*Repo, error) {
repoPath := path.Join(reposRoot, URLToPath(url))
func CloneRepo(url *url.URL, path string, quiet bool) (*Repo, error) {
var progress io.Writer
if !quiet {
progress = os.Stdout
fmt.Printf("Cloning into '%s'...\n", repoPath)
fmt.Printf("Cloning into '%s'...\n", path)
}
// TODO: can this be cleaner?
@@ -54,12 +53,12 @@ func CloneRepo(url *url.URL, reposRoot string, quiet bool) (*Repo, error) {
Tags: git.AllTags,
}
repo, err := git.PlainClone(repoPath, false, opts)
repo, err := git.PlainClone(path, false, opts)
if err != nil {
return nil, errors.Wrap(err, "Failed cloning repo")
}
return newRepo(repo, repoPath), nil
return NewRepo(repo, path), nil
}
func OpenRepo(repoPath string) (*Repo, error) {
@@ -68,20 +67,20 @@ func OpenRepo(repoPath string) (*Repo, error) {
return nil, errors.Wrap(err, "Failed opening repo")
}
return newRepo(repo, repoPath), nil
return NewRepo(repo, repoPath), nil
}
func newRepo(repo *git.Repository, repoPath string) *Repo {
func NewRepo(repo *git.Repository, repoPath string) *Repo {
return &Repo{
repo: repo,
path: repoPath,
Repository: repo,
Path: repoPath,
Status: &RepoStatus{},
}
}
// Fetch performs a git fetch on all remotes
func (r *Repo) Fetch() error {
remotes, err := r.repo.Remotes()
remotes, err := r.Remotes()
if err != nil {
return errors.Wrap(err, "Failed getting remotes")
}
@@ -97,7 +96,7 @@ func (r *Repo) Fetch() error {
}
func sshKeyAuth() (transport.AuthMethod, error) {
privateKey := viper.GetString(KeyPrivateKey)
privateKey := viper.GetString(cfg.KeyPrivateKey)
sshKey, err := ioutil.ReadFile(privateKey)
if err != nil {
return nil, errors.Wrapf(err, "Failed to open ssh private key %s", privateKey)
@@ -112,3 +111,17 @@ func sshKeyAuth() (transport.AuthMethod, error) {
auth := &go_git_ssh.PublicKeys{User: "git", Signer: signer}
return auth, nil
}
func (r *Repo) CurrentBranchStatus() *BranchStatus {
if r.Status.CurrentBranch == StatusDetached || r.Status.CurrentBranch == StatusUnknown {
return nil
}
for _, b := range r.Status.Branches {
if b.Name == r.Status.CurrentBranch {
return b
}
}
return nil
}

View File

@@ -1,6 +1,8 @@
package pkg
package git
import (
"net/url"
"io/ioutil"
"os"
"testing"
@@ -25,7 +27,7 @@ func newRepoEmpty(t *testing.T) *Repo {
repo, err := git.PlainInit(dir, false)
checkFatal(t, err)
return newRepo(repo, dir)
return NewRepo(repo, dir)
}
func newRepoWithUntracked(t *testing.T) *Repo {
@@ -156,7 +158,7 @@ func newTempDir(t *testing.T) string {
}
func (r *Repo) writeFile(t *testing.T, name string, content string) {
wt, err := r.repo.Worktree()
wt, err := r.Worktree()
checkFatal(t, errors.Wrap(err, "Failed getting worktree"))
file, err := wt.Filesystem.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
@@ -167,7 +169,7 @@ func (r *Repo) writeFile(t *testing.T, name string, content string) {
}
func (r *Repo) addFile(t *testing.T, name string) {
wt, err := r.repo.Worktree()
wt, err := r.Worktree()
checkFatal(t, errors.Wrap(err, "Failed getting worktree"))
_, err = wt.Add(name)
@@ -175,7 +177,7 @@ func (r *Repo) addFile(t *testing.T, name string) {
}
func (r *Repo) newCommit(t *testing.T, msg string) plumbing.Hash {
wt, err := r.repo.Worktree()
wt, err := r.Worktree()
checkFatal(t, errors.Wrap(err, "Failed getting worktree"))
opts := &git.CommitOptions{
@@ -192,21 +194,21 @@ func (r *Repo) newCommit(t *testing.T, msg string) plumbing.Hash {
}
func (r *Repo) newBranch(t *testing.T, name string) {
head, err := r.repo.Head()
head, err := r.Head()
checkFatal(t, err)
ref := plumbing.NewHashReference(plumbing.NewBranchReferenceName(name), head.Hash())
err = r.repo.Storer.SetReference(ref)
err = r.Storer.SetReference(ref)
checkFatal(t, err)
}
func (r *Repo) clone(t *testing.T) *Repo {
dir := newTempDir(t)
url, err := ParseURL("file://" + r.path)
repoURL, err := url.Parse("file://" + r.Path)
checkFatal(t, err)
repo, err := CloneRepo(url, dir, true)
repo, err := CloneRepo(repoURL, dir, true)
checkFatal(t, err)
return repo
@@ -218,7 +220,7 @@ func (r *Repo) fetch(t *testing.T) {
}
func (r *Repo) checkoutBranch(t *testing.T, name string) {
wt, err := r.repo.Worktree()
wt, err := r.Worktree()
checkFatal(t, errors.Wrap(err, "Failed getting worktree"))
opts := &git.CheckoutOptions{
@@ -229,7 +231,7 @@ func (r *Repo) checkoutBranch(t *testing.T, name string) {
}
func (r *Repo) checkoutHash(t *testing.T, hash plumbing.Hash) {
wt, err := r.repo.Worktree()
wt, err := r.Worktree()
checkFatal(t, errors.Wrap(err, "Failed getting worktree"))
opts := &git.CheckoutOptions{

View File

@@ -1,4 +1,4 @@
package pkg
package git
import (
"sort"
@@ -39,7 +39,7 @@ type BranchStatus struct {
}
func (r *Repo) LoadStatus() error {
wt, err := r.repo.Worktree()
wt, err := r.Worktree()
if err != nil {
return errors.Wrap(err, "Failed getting worktree")
}
@@ -105,7 +105,7 @@ func hasUncommitted(status git.Status) bool {
}
func currentBranch(r *Repo) string {
head, err := r.repo.Head()
head, err := r.Head()
if err != nil {
return StatusUnknown
}
@@ -118,7 +118,7 @@ func currentBranch(r *Repo) string {
}
func (r *Repo) loadBranchesStatus() error {
iter, err := r.repo.Branches()
iter, err := r.Branches()
if err != nil {
return errors.Wrap(err, "Failed getting branches iterator")
}
@@ -181,7 +181,7 @@ func (r *Repo) newBranchStatus(branch string) (*BranchStatus, error) {
// "remote" - name of the remote containing upstream branch (or "." if upstream is a local branch)
// "merge" - full ref name of the upstream branch (eg, ref/heads/master)
func (r *Repo) upstream(branch string) (string, error) {
cfg, err := r.repo.Config()
cfg, err := r.Config()
if err != nil {
return "", errors.Wrap(err, "Failed getting repo config")
}
@@ -205,22 +205,22 @@ func (r *Repo) upstream(branch string) (string, error) {
}
func (r *Repo) needsPullOrPush(localBranch string, upstreamBranch string) (needsPull bool, needsPush bool, err error) {
localHash, err := r.repo.ResolveRevision(plumbing.Revision(localBranch))
localHash, err := r.ResolveRevision(plumbing.Revision(localBranch))
if err != nil {
return false, false, errors.Wrapf(err, "Failed resolving revision %s", localBranch)
}
upstreamHash, err := r.repo.ResolveRevision(plumbing.Revision(upstreamBranch))
upstreamHash, err := r.ResolveRevision(plumbing.Revision(upstreamBranch))
if err != nil {
return false, false, errors.Wrapf(err, "Failed resolving revision %s", upstreamBranch)
}
localCommit, err := r.repo.CommitObject(*localHash)
localCommit, err := r.CommitObject(*localHash)
if err != nil {
return false, false, errors.Wrapf(err, "Failed finding a commit for hash %s", localHash.String())
}
upstreamCommit, err := r.repo.CommitObject(*upstreamHash)
upstreamCommit, err := r.CommitObject(*upstreamHash)
if err != nil {
return false, false, errors.Wrapf(err, "Failed finding a commit for hash %s", upstreamHash.String())
}

View File

@@ -1,4 +1,4 @@
package pkg
package git
import (
"reflect"

103
path/list.go Normal file
View File

@@ -0,0 +1,103 @@
package path
import (
"fmt"
"git-get/cfg"
"git-get/git"
"os"
"sort"
"strings"
"github.com/karrick/godirwalk"
"github.com/pkg/errors"
"github.com/spf13/viper"
)
// skipNode is used as an error indicating that .git directory has been found.
// It's handled by ErrorsCallback to tell the WalkCallback to skip this dir.
var skipNode = errors.New(".git directory found, skipping this node")
var repos []string
func FindRepos() ([]string, error) {
repos = []string{}
root := viper.GetString(cfg.KeyReposRoot)
if _, err := os.Stat(root); err != nil {
return nil, fmt.Errorf("Repos root %s does not exist or can't be accessed", root)
}
walkOpts := &godirwalk.Options{
ErrorCallback: ErrorCb,
Callback: WalkCb,
// Use Unsorted to improve speed because repos will be processed by goroutines in a random order anyway.
Unsorted: true,
}
err := godirwalk.Walk(root, walkOpts)
if err != nil {
return nil, err
}
if len(repos) == 0 {
return nil, fmt.Errorf("No git repos found in repos root %s", root)
}
return repos, nil
}
func WalkCb(path string, ent *godirwalk.Dirent) error {
if ent.IsDir() && ent.Name() == ".git" {
repos = append(repos, strings.TrimSuffix(path, ".git"))
return skipNode
}
return nil
}
func ErrorCb(_ string, err error) godirwalk.ErrorAction {
if errors.Is(err, skipNode) {
return godirwalk.SkipNode
}
return godirwalk.Halt
}
func OpenAll(paths []string) ([]*git.Repo, error) {
var repos []*git.Repo
reposChan := make(chan *git.Repo)
for _, path := range paths {
go func(path string) {
repo, err := git.OpenRepo(path)
if err != nil {
// TODO handle error
fmt.Println(err)
}
err = repo.LoadStatus()
if err != nil {
// TODO handle error
fmt.Println(err)
}
// when error happened we just sent a nil
reposChan <- repo
}(path)
}
for repo := range reposChan {
repos = append(repos, repo)
// TODO: is this the right way to close the channel? What if we have non-unique paths?
if len(repos) == len(paths) {
close(reposChan)
}
}
// sort the final array to make printing easier
sort.Slice(repos, func(i, j int) bool {
return strings.Compare(repos[i].Path, repos[j].Path) < 0
})
return repos, nil
}

View File

@@ -1,6 +1,7 @@
package pkg
package path
import (
"git-get/cfg"
urlpkg "net/url"
"path"
"regexp"
@@ -46,7 +47,7 @@ func ParseURL(rawURL string) (url *urlpkg.URL, err error) {
// Default to configured defaultHost when host is empty
if url.Host == "" {
url.Host = viper.GetString(KeyDefaultHost)
url.Host = viper.GetString(cfg.KeyDefaultHost)
}
// Default to https when scheme is empty

View File

@@ -1,6 +1,7 @@
package pkg
package path
import (
"git-get/cfg"
"testing"
)
@@ -49,7 +50,7 @@ func TestURLParse(t *testing.T) {
}
// We need to init config first so the default values are correctly loaded
InitConfig()
cfg.InitConfig()
for _, test := range tests {
url, err := ParseURL(test.in)

View File

@@ -1,198 +0,0 @@
package pkg
import (
"errors"
"fmt"
"os"
"sort"
"strings"
"github.com/go-git/go-git/v5"
"github.com/spf13/viper"
"github.com/karrick/godirwalk"
)
// skipNode is used as an error indicating that .git directory has been found.
// It's handled by ErrorsCallback to tell the WalkCallback to skip this dir.
var skipNode = errors.New(".git directory found, skipping this node")
var repos []string
func FindRepos() ([]string, error) {
repos = []string{}
root := viper.GetString(KeyReposRoot)
if _, err := os.Stat(root); err != nil {
return nil, fmt.Errorf("Repos root %s does not exist or can't be accessed", root)
}
walkOpts := &godirwalk.Options{
ErrorCallback: ErrorCb,
Callback: WalkCb,
// Use Unsorted to improve speed because repos will be processed by goroutines in a random order anyway.
Unsorted: true,
}
err := godirwalk.Walk(root, walkOpts)
if err != nil {
return nil, err
}
if len(repos) == 0 {
return nil, fmt.Errorf("No git repos found in repos root %s", root)
}
return repos, nil
}
func WalkCb(path string, ent *godirwalk.Dirent) error {
if ent.IsDir() && ent.Name() == git.GitDirName {
repos = append(repos, strings.TrimSuffix(path, git.GitDirName))
return skipNode
}
return nil
}
func ErrorCb(_ string, err error) godirwalk.ErrorAction {
if errors.Is(err, skipNode) {
return godirwalk.SkipNode
}
return godirwalk.Halt
}
func OpenAll(paths []string) ([]*Repo, error) {
var repos []*Repo
reposChan := make(chan *Repo)
for _, path := range paths {
go func(path string) {
repo, err := OpenRepo(path)
if err != nil {
// TODO handle error
fmt.Println(err)
}
err = repo.LoadStatus()
if err != nil {
// TODO handle error
fmt.Println(err)
}
// when error happened we just sent a nil
reposChan <- repo
}(path)
}
for repo := range reposChan {
repos = append(repos, repo)
// TODO: is this the right way to close the channel? What if we have non-unique paths?
if len(repos) == len(paths) {
close(reposChan)
}
}
// sort the final array to make printing easier
sort.Slice(repos, func(i, j int) bool {
return strings.Compare(repos[i].path, repos[j].path) < 0
})
return repos, nil
}
func PrintRepos(repos []*Repo) {
root := viper.GetString(KeyReposRoot)
tree := BuildTree(root, repos)
fmt.Println(RenderSmartTree(tree))
}
const (
ColorRed = "\033[1;31m%s\033[0m"
ColorGreen = "\033[1;32m%s\033[0m"
ColorBlue = "\033[1;34m%s\033[0m"
ColorYellow = "\033[1;33m%s\033[0m"
)
func renderWorktreeStatus(repo *Repo) string {
clean := true
var status []string
// if current branch status can't be found it's probably a detached head
// TODO: what if current HEAD points to a tag?
if current := repo.findCurrentBranchStatus(); current == nil {
status = append(status, fmt.Sprintf(ColorYellow, repo.Status.CurrentBranch))
} else {
status = append(status, renderBranchStatus(current))
}
// TODO: this is ugly
// unset clean flag to use it to render braces around worktree status and remove "ok" from branch status if it's there
if repo.Status.HasUncommittedChanges || repo.Status.HasUntrackedFiles {
clean = false
}
if !clean {
status[len(status)-1] = strings.TrimSuffix(status[len(status)-1], StatusOk)
status = append(status, "[")
}
if repo.Status.HasUntrackedFiles {
status = append(status, fmt.Sprintf(ColorRed, StatusUntracked))
}
if repo.Status.HasUncommittedChanges {
status = append(status, fmt.Sprintf(ColorRed, StatusUncommitted))
}
if !clean {
status = append(status, "]")
}
return strings.Join(status, " ")
}
func renderBranchStatus(branch *BranchStatus) string {
// ok indicates that the branch has upstream and is not ahead or behind it
ok := true
var status []string
status = append(status, fmt.Sprintf(ColorBlue, branch.Name))
if branch.Upstream == "" {
ok = false
status = append(status, fmt.Sprintf(ColorYellow, StatusNoUpstream))
}
if branch.NeedsPull {
ok = false
status = append(status, fmt.Sprintf(ColorYellow, StatusBehind))
}
if branch.NeedsPush {
ok = false
status = append(status, fmt.Sprintf(ColorYellow, StatusAhead))
}
if ok {
status = append(status, fmt.Sprintf(ColorGreen, StatusOk))
}
return strings.Join(status, " ")
}
func (r *Repo) findCurrentBranchStatus() *BranchStatus {
if r.Status.CurrentBranch == StatusDetached || r.Status.CurrentBranch == StatusUnknown {
return nil
}
for _, b := range r.Status.Branches {
if b.Name == r.Status.CurrentBranch {
return b
}
}
return nil
}

115
print/print.go Normal file
View File

@@ -0,0 +1,115 @@
package print
import (
"fmt"
"git-get/git"
"path/filepath"
"strings"
)
type Printer interface {
Print(root string, repos []*git.Repo) string
}
type FlatPrinter struct{}
func NewFlatPrinter() *FlatPrinter {
return &FlatPrinter{}
}
func (p *FlatPrinter) Print(root string, repos []*git.Repo) string {
val := root
for _, repo := range repos {
path := strings.TrimPrefix(repo.Path, root)
path = strings.Trim(path, string(filepath.Separator))
val += fmt.Sprintf("\n%s %s", path, renderWorktreeStatus(repo))
for _, branch := range repo.Status.Branches {
// Don't print the status of the current branch. It was already printed above.
if branch.Name == repo.Status.CurrentBranch {
continue
}
indent := strings.Repeat(" ", len(path))
val += fmt.Sprintf("\n%s %s", indent, renderBranchStatus(branch))
}
}
return val
}
const (
ColorRed = "\033[1;31m%s\033[0m"
ColorGreen = "\033[1;32m%s\033[0m"
ColorBlue = "\033[1;34m%s\033[0m"
ColorYellow = "\033[1;33m%s\033[0m"
)
func renderWorktreeStatus(repo *git.Repo) string {
clean := true
var status []string
// if current branch status can't be found it's probably a detached head
// TODO: what if current HEAD points to a tag?
if current := repo.CurrentBranchStatus(); current == nil {
status = append(status, fmt.Sprintf(ColorYellow, repo.Status.CurrentBranch))
} else {
status = append(status, renderBranchStatus(current))
}
// TODO: this is ugly
// unset clean flag to use it to render braces around worktree status and remove "ok" from branch status if it's there
if repo.Status.HasUncommittedChanges || repo.Status.HasUntrackedFiles {
clean = false
}
if !clean {
status[len(status)-1] = strings.TrimSuffix(status[len(status)-1], git.StatusOk)
status = append(status, "[")
}
if repo.Status.HasUntrackedFiles {
status = append(status, fmt.Sprintf(ColorRed, git.StatusUntracked))
}
if repo.Status.HasUncommittedChanges {
status = append(status, fmt.Sprintf(ColorRed, git.StatusUncommitted))
}
if !clean {
status = append(status, "]")
}
return strings.Join(status, " ")
}
func renderBranchStatus(branch *git.BranchStatus) string {
// ok indicates that the branch has upstream and is not ahead or behind it
ok := true
var status []string
status = append(status, fmt.Sprintf(ColorBlue, branch.Name))
if branch.Upstream == "" {
ok = false
status = append(status, fmt.Sprintf(ColorYellow, git.StatusNoUpstream))
}
if branch.NeedsPull {
ok = false
status = append(status, fmt.Sprintf(ColorYellow, git.StatusBehind))
}
if branch.NeedsPush {
ok = false
status = append(status, fmt.Sprintf(ColorYellow, git.StatusAhead))
}
if ok {
status = append(status, fmt.Sprintf(ColorGreen, git.StatusOk))
}
return strings.Join(status, " ")
}

View File

@@ -1,6 +1,7 @@
package pkg
package print
import (
"git-get/git"
"path/filepath"
"strings"
)
@@ -11,7 +12,7 @@ type Node struct {
depth int // depth is a nesting depth used when rendering a tree, not an depth level of a node inside the tree
parent *Node
children []*Node
repo *Repo
repo *git.Repo
}
// Root creates a new root of a tree
@@ -55,11 +56,11 @@ func (n *Node) GetChild(val string) *Node {
// BuildTree builds a directory tree of paths to repositories.
// Each node represents a directory in the repo path.
// Each leaf (final node) contains a pointer to the repo.
func BuildTree(root string, repos []*Repo) *Node {
func BuildTree(root string, repos []*git.Repo) *Node {
tree := Root(root)
for _, repo := range repos {
path := strings.TrimPrefix(repo.path, root)
path := strings.TrimPrefix(repo.Path, root)
path = strings.Trim(path, string(filepath.Separator))
subs := strings.Split(path, string(filepath.Separator))
@@ -115,7 +116,7 @@ func RenderSmartTree(node *Node) string {
// TODO: Ugly
// If this is called from tests the repo will be nil and we should return just the name without the status.
if node.repo.repo == nil {
if node.repo.Repository == nil {
return value
}

View File

@@ -1,7 +1,8 @@
package pkg
package print
import (
"fmt"
"git-get/git"
"strings"
"testing"
)
@@ -90,9 +91,9 @@ gitlab.com/
}
for i, test := range tests {
var repos []*Repo
var repos []*git.Repo
for _, path := range test.paths {
repos = append(repos, newRepo(nil, path)) //&Repo{path: path})
repos = append(repos, git.NewRepo(nil, path)) //&Repo{path: path})
}
tree := BuildTree("root", repos)