6
0
mirror of https://github.com/grdl/git-get.git synced 2026-03-23 00:20:07 +00:00

Move status loader into the git package

- Loading status belongs to git domain so it makes more sense for it to
  be in git package.
- It also makes the `list` file simpler.
- Because status loader is now part of the RepoFinder, the ugly Repo interface is no longer necessary.
This commit is contained in:
Grzegorz Dlugoszewski
2020-07-26 17:19:45 +02:00
parent 942af7df6d
commit 3aef50a4d0
5 changed files with 141 additions and 144 deletions

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"syscall"
@@ -36,10 +37,11 @@ func Exists(path string) (bool, error) {
return true, errDirectoryAccess
}
// RepoFinder finds paths to git repos inside given path.
// RepoFinder finds git repositories inside a given path.
type RepoFinder struct {
root string
repos []string
root string
repos []*Repo
errors []error
}
// NewRepoFinder returns a RepoFinder pointed at given root path.
@@ -49,51 +51,95 @@ func NewRepoFinder(root string) *RepoFinder {
}
}
// Find returns a sorted list of paths to git repos found inside a given root path.
// Find finds git repositories inside a given root path.
// It doesn't add repositories nested inside other git repos.
// Returns error if root repo path can't be found or accessed.
func (r *RepoFinder) Find() ([]string, error) {
if _, err := Exists(r.root); err != nil {
return nil, err
func (f *RepoFinder) Find() error {
if _, err := Exists(f.root); err != nil {
return err
}
walkOpts := &godirwalk.Options{
ErrorCallback: r.errorCb,
Callback: r.walkCb,
ErrorCallback: f.errorCb,
Callback: f.walkCb,
// Use Unsorted to improve speed because repos will be processed by goroutines in a random order anyway.
Unsorted: true,
}
err := godirwalk.Walk(r.root, walkOpts)
err := godirwalk.Walk(f.root, walkOpts)
if err != nil {
return nil, err
return err
}
if len(r.repos) == 0 {
return nil, fmt.Errorf("no git repos found in root path %s", r.root)
if len(f.repos) == 0 {
return fmt.Errorf("no git repos found in root path %s", f.root)
}
return r.repos, nil
return nil
}
func (r *RepoFinder) walkCb(path string, ent *godirwalk.Dirent) error {
// LoadAll loads and returns sorted slice of statuses of all repositories found by RepoFinder.
// If fetch equals true, it first fetches from the remote repo before loading the status.
// Each repo is loaded concurrently in its own goroutine, with max 100 repos being loaded at the same time.
func (f *RepoFinder) LoadAll(fetch bool) []*Status {
var ss []*Status
loadedChan := make(chan *Status)
for _, repo := range f.repos {
go func(repo *Repo) {
loadedChan <- repo.LoadStatus(fetch)
}(repo)
}
for l := range loadedChan {
ss = append(ss, l)
// Close the channel when all repos are loaded.
if len(ss) == len(f.repos) {
close(loadedChan)
}
}
// Sort the status slice by path
sort.Slice(ss, func(i, j int) bool {
return strings.Compare(ss[i].path, ss[j].path) < 0
})
return ss
}
func (f *RepoFinder) walkCb(path string, ent *godirwalk.Dirent) error {
// Do not traverse .git directories
if ent.IsDir() && ent.Name() == ".git" {
r.repos = append(r.repos, strings.TrimSuffix(path, ".git"))
if ent.IsDir() && ent.Name() == dotgit {
f.addIfOk(path)
return errSkipNode
}
// Do not traverse directories containing a .git directory
if ent.IsDir() {
_, err := os.Stat(filepath.Join(path, ".git"))
_, err := os.Stat(filepath.Join(path, dotgit))
if err == nil {
r.repos = append(r.repos, strings.TrimSuffix(path, ".git"))
f.addIfOk(path)
return errSkipNode
}
}
return nil
}
func (r *RepoFinder) errorCb(_ string, err error) godirwalk.ErrorAction {
// addIfOk adds the found repo to the repos slice if it can be opened.
// If repo path can't be accessed it will add an error to the errors slice.
func (f *RepoFinder) addIfOk(path string) {
repo, err := Open(strings.TrimSuffix(path, dotgit))
if err != nil {
f.errors = append(f.errors, err)
return
}
f.repos = append(f.repos, repo)
}
func (f *RepoFinder) errorCb(_ string, err error) godirwalk.ErrorAction {
// Skip .git directory and directories we don't have permissions to access
// TODO: Will syscall.EACCES work on windows?
if errors.Is(err, errSkipNode) || errors.Is(err, syscall.EACCES) {

View File

@@ -38,10 +38,10 @@ func TestFinder(t *testing.T) {
root := test.reposMaker(t)
finder := NewRepoFinder(root)
paths, _ := finder.Find()
finder.Find()
if len(paths) != test.want {
t.Errorf("expected %d; got %d", test.want, len(paths))
if len(finder.repos) != test.want {
t.Errorf("expected %d; got %d", test.want, len(finder.repos))
}
})
}

View File

@@ -15,24 +15,12 @@ const (
head = "HEAD"
)
// Repo represents a git repository on disk.
type Repo interface {
Path() string
Branches() ([]string, error)
CurrentBranch() (string, error)
Fetch() error
Remote() (string, error)
Uncommitted() (int, error)
Untracked() (int, error)
Upstream(string) (string, error)
AheadBehind(string, string) (int, int, error)
}
type repo struct {
// Repo represents a git Repository cloned or initialized on disk.
type Repo struct {
path string
}
// CloneOpts specify detail about repository to clone.
// CloneOpts specify detail about Repository to clone.
type CloneOpts struct {
URL *url.URL
Path string // TODO: should Path be a part of clone opts?
@@ -41,18 +29,18 @@ type CloneOpts struct {
}
// Open checks if given path can be accessed and returns a Repo instance pointing to it.
func Open(path string) (Repo, error) {
func Open(path string) (*Repo, error) {
if _, err := Exists(path); err != nil {
return nil, err
}
return &repo{
return &Repo{
path: path,
}, nil
}
// Clone clones repository specified with CloneOpts.
func Clone(opts *CloneOpts) (Repo, error) {
// Clone clones Repository specified with CloneOpts.
func Clone(opts *CloneOpts) (*Repo, error) {
runGit := run.Git("clone", opts.URL.String(), opts.Path)
if opts.Branch != "" {
runGit = run.Git("clone", "--branch", opts.Branch, "--single-branch", opts.URL.String(), opts.Path)
@@ -69,19 +57,19 @@ func Clone(opts *CloneOpts) (Repo, error) {
return nil, err
}
repo, err := Open(opts.Path)
return repo, err
Repo, err := Open(opts.Path)
return Repo, err
}
// Fetch preforms a git fetch on all remotes
func (r *repo) Fetch() error {
func (r *Repo) Fetch() error {
err := run.Git("fetch", "--all").OnRepo(r.path).AndShutUp()
return err
}
// Uncommitted returns the number of uncommitted files in the repository.
// Uncommitted returns the number of uncommitted files in the Repository.
// Only tracked files are not counted.
func (r *repo) Uncommitted() (int, error) {
func (r *Repo) Uncommitted() (int, error) {
out, err := run.Git("status", "--ignore-submodules", "--porcelain").OnRepo(r.path).AndCaptureLines()
if err != nil {
return 0, err
@@ -98,8 +86,8 @@ func (r *repo) Uncommitted() (int, error) {
return count, nil
}
// Untracked returns the number of untracked files in the repository.
func (r *repo) Untracked() (int, error) {
// Untracked returns the number of untracked files in the Repository.
func (r *Repo) Untracked() (int, error) {
out, err := run.Git("status", "--ignore-submodules", "--untracked-files=all", "--porcelain").OnRepo(r.path).AndCaptureLines()
if err != nil {
return 0, err
@@ -115,9 +103,9 @@ func (r *repo) Untracked() (int, error) {
return count, nil
}
// CurrentBranch returns the short name currently checked-out branch for the repository.
// If repo is in a detached head state, it will return "HEAD".
func (r *repo) CurrentBranch() (string, error) {
// CurrentBranch returns the short name currently checked-out branch for the Repository.
// If Repo is in a detached head state, it will return "HEAD".
func (r *Repo) CurrentBranch() (string, error) {
out, err := run.Git("rev-parse", "--symbolic-full-name", "--abbrev-ref", "HEAD").OnRepo(r.path).AndCaptureLine()
if err != nil {
return "", err
@@ -126,8 +114,8 @@ func (r *repo) CurrentBranch() (string, error) {
return out, nil
}
// Branches returns a list of local branches in the repository.
func (r *repo) Branches() ([]string, error) {
// Branches returns a list of local branches in the Repository.
func (r *Repo) Branches() ([]string, error) {
out, err := run.Git("branch", "--format=%(refname:short)").OnRepo(r.path).AndCaptureLines()
if err != nil {
return nil, err
@@ -146,7 +134,7 @@ func (r *repo) Branches() ([]string, error) {
// Upstream returns the name of an upstream branch if a given branch is tracking one.
// Otherwise it returns an empty string.
func (r *repo) Upstream(branch string) (string, error) {
func (r *Repo) Upstream(branch string) (string, error) {
out, err := run.Git("rev-parse", "--abbrev-ref", "--symbolic-full-name", fmt.Sprintf("%s@{upstream}", branch)).OnRepo(r.path).AndCaptureLine()
if err != nil {
// TODO: no upstream will also throw an error.
@@ -157,7 +145,7 @@ func (r *repo) Upstream(branch string) (string, error) {
}
// AheadBehind returns the number of commits a given branch is ahead and/or behind the upstream.
func (r *repo) AheadBehind(branch string, upstream string) (int, int, error) {
func (r *Repo) AheadBehind(branch string, upstream string) (int, int, error) {
out, err := run.Git("rev-list", "--left-right", "--count", fmt.Sprintf("%s...%s", branch, upstream)).OnRepo(r.path).AndCaptureLine()
if err != nil {
return 0, 0, err
@@ -179,8 +167,8 @@ func (r *repo) AheadBehind(branch string, upstream string) (int, int, error) {
return ahead, behind, nil
}
// Remote returns URL of remote repository.
func (r *repo) Remote() (string, error) {
// Remote returns URL of remote Repository.
func (r *Repo) Remote() (string, error) {
// https://stackoverflow.com/a/16880000/1085632
out, err := run.Git("ls-remote", "--get-url").OnRepo(r.path).AndCaptureLine()
if err != nil {
@@ -191,7 +179,7 @@ func (r *repo) Remote() (string, error) {
return out, nil
}
// Path returns path to the repository.
func (r *repo) Path() string {
// Path returns path to the Repository.
func (r *Repo) Path() string {
return r.path
}

175
pkg/git/status.go Normal file
View File

@@ -0,0 +1,175 @@
package git
import (
"fmt"
"strings"
)
// Status contains human readable (and printable) representation of a git repository status.
type Status struct {
path string
current string
branches map[string]string // key: branch name, value: branch status
worktree string
remote string
errors []string // Slice of errors which occurred when loading the status.
}
// LoadStatus reads status of a repository.
// If fetch equals true, it first fetches from the remote repo before loading the status.
// If errors occur during loading, they are stored in Status.errors slice.
func (r *Repo) LoadStatus(fetch bool) *Status {
status := &Status{
path: r.path,
branches: make(map[string]string),
errors: make([]string, 0),
}
if fetch {
if err := r.Fetch(); err != nil {
status.errors = append(status.errors, err.Error())
}
}
var err error
status.current, err = r.CurrentBranch()
if err != nil {
status.errors = append(status.errors, err.Error())
}
var errs []error
status.branches, errs = r.loadBranches()
for _, err := range errs {
status.errors = append(status.errors, err.Error())
}
status.worktree, err = r.loadWorkTree()
if err != nil {
status.errors = append(status.errors, err.Error())
}
status.remote, err = r.Remote()
if err != nil {
status.errors = append(status.errors, err.Error())
}
return status
}
func (r *Repo) loadBranches() (map[string]string, []error) {
statuses := make(map[string]string)
errors := make([]error, 0)
branches, err := r.Branches()
if err != nil {
errors = append(errors, err)
return statuses, errors
}
for _, branch := range branches {
status, err := r.loadBranchStatus(branch)
statuses[branch] = status
if err != nil {
errors = append(errors, err)
}
}
return statuses, errors
}
func (r *Repo) loadBranchStatus(branch string) (string, error) {
upstream, err := r.Upstream(branch)
if err != nil {
return "", err
}
if upstream == "" {
return "no upstream", nil
}
ahead, behind, err := r.AheadBehind(branch, upstream)
if err != nil {
return "", err
}
if ahead == 0 && behind == 0 {
return "", nil
}
var res []string
if ahead != 0 {
res = append(res, fmt.Sprintf("%d ahead", ahead))
}
if behind != 0 {
res = append(res, fmt.Sprintf("%d behind", behind))
}
return strings.Join(res, " "), nil
}
func (r *Repo) loadWorkTree() (string, error) {
uncommitted, err := r.Uncommitted()
if err != nil {
return "", err
}
untracked, err := r.Untracked()
if err != nil {
return "", err
}
if uncommitted == 0 && untracked == 0 {
return "", nil
}
var res []string
if uncommitted != 0 {
res = append(res, fmt.Sprintf("%d uncommitted", uncommitted))
}
if untracked != 0 {
res = append(res, fmt.Sprintf("%d untracked", untracked))
}
return strings.Join(res, " "), nil
}
// Path returns path to a repository.
func (s *Status) Path() string {
return s.path
}
// Current returns the name of currently checked out branch (or tag or detached HEAD).
func (s *Status) Current() string {
return s.current
}
// Branches returns a list of all branches names except the currently checked out one. Use Current() to get its name.
func (s *Status) Branches() []string {
var branches []string
for b := range s.branches {
if b != s.current {
branches = append(branches, b)
}
}
return branches
}
// BranchStatus returns status of a given branch
func (s *Status) BranchStatus(branch string) string {
return s.branches[branch]
}
// WorkTreeStatus returns status of a worktree
func (s *Status) WorkTreeStatus() string {
return s.worktree
}
// Remote returns URL to remote repository
func (s *Status) Remote() string {
return s.remote
}
// Errors is a slice of errors that occurred when loading repo status
func (s *Status) Errors() []string {
return s.errors
}