Skip to content

Commit

Permalink
Show branch track stats for active branches (#34)
Browse files Browse the repository at this point in the history
  • Loading branch information
RangerCD authored Feb 1, 2022
1 parent 38f001d commit a1b2433
Show file tree
Hide file tree
Showing 4 changed files with 258 additions and 37 deletions.
55 changes: 32 additions & 23 deletions branch.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import (
"github.com/muesli/reflow/truncate"
)

func printBranch(branch vcs.Branch, maxWidth int) {
func printBranch(branch vcs.Branch, stat *trackStat, maxWidth int) {
genericStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color(theme.colorGray))
numberStyle := lipgloss.NewStyle().
Expand All @@ -21,12 +21,14 @@ func printBranch(branch vcs.Branch, maxWidth int) {
timeStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color(theme.colorGreen)).Width(8).Align(lipgloss.Right)
titleStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color(theme.colorDarkGray)).Width(80 - maxWidth)
Foreground(lipgloss.Color(theme.colorDarkGray)).Width(70 - maxWidth)

var s string
s += numberStyle.Render(branch.Name)
s += genericStyle.Render(" ")
s += titleStyle.Render(truncate.StringWithTail(branch.LastCommit.MessageHeadline, uint(80-maxWidth), "…"))
s += stat.Render()
s += genericStyle.Render(" ")
s += titleStyle.Render(truncate.StringWithTail(branch.LastCommit.MessageHeadline, uint(70-maxWidth), "…"))
s += genericStyle.Render(" ")
s += timeStyle.Render(ago(branch.LastCommit.CommittedAt))
s += genericStyle.Render(" ")
Expand All @@ -35,29 +37,11 @@ func printBranch(branch vcs.Branch, maxWidth int) {
fmt.Println(s)
}

func printBranches(branches []vcs.Branch) {
func printBranches(branches []vcs.Branch, stats map[string]*trackStat) {
headerStyle := lipgloss.NewStyle().
PaddingTop(1).
Foreground(lipgloss.Color(theme.colorMagenta))

sort.Slice(branches, func(i, j int) bool {
if branches[i].LastCommit.CommittedAt.Equal(branches[j].LastCommit.CommittedAt) {
return strings.Compare(branches[i].Name, branches[j].Name) < 0
}
return branches[i].LastCommit.CommittedAt.After(branches[j].LastCommit.CommittedAt)
})

// filter list
var b []vcs.Branch //nolint
for _, v := range branches {
if *maxBranchAge > 0 &&
v.LastCommit.CommittedAt.Before(time.Now().Add(-24*time.Duration(*maxBranchAge)*time.Hour)) {
continue
}
b = append(b, v)
}
branches = b

// trimmed := false
if *maxBranches > 0 && len(branches) > *maxBranches {
branches = branches[:*maxBranches]
Expand All @@ -75,9 +59,34 @@ func printBranches(branches []vcs.Branch) {
}

for _, v := range branches {
printBranch(v, maxWidth)
stat, ok := stats[v.Name]
if !ok {
stat = nil
}
printBranch(v, stat, maxWidth)
}
// if trimmed {
// fmt.Println("...")
// }
}

func filterBranches(branches []vcs.Branch) []vcs.Branch {
sort.Slice(branches, func(i, j int) bool {
if branches[i].LastCommit.CommittedAt.Equal(branches[j].LastCommit.CommittedAt) {
return strings.Compare(branches[i].Name, branches[j].Name) < 0
}
return branches[i].LastCommit.CommittedAt.After(branches[j].LastCommit.CommittedAt)
})

// filter list
var b []vcs.Branch //nolint
for _, v := range branches {
if *maxBranchAge > 0 &&
v.LastCommit.CommittedAt.Before(time.Now().Add(-24*time.Duration(*maxBranchAge)*time.Hour)) {
continue
}
b = append(b, v)
}
branches = b
return branches
}
24 changes: 13 additions & 11 deletions git.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,15 +98,16 @@ func guessClient(host string) (Client, error) {
return nil, fmt.Errorf("not a recognized git provider")
}

func originURL(path string) (string, error) {
// remoteURL returns remote name and URL
func remoteURL(path string) (string, string, error) {
r, err := git.PlainOpen(path)
if err != nil {
return "", err
return "", "", err
}

remotes, err := r.Remotes()
if err != nil {
return "", err
return "", "", err
}

var u string
Expand All @@ -120,10 +121,11 @@ func originURL(path string) (string, error) {
}

if u == "" {
return "", fmt.Errorf("no remote found")
return "", "", fmt.Errorf("no remote found")
}

return cleanupURL(u)
u, err = cleanupURL(u)
return rn, u, err
}

func cleanupURL(arg string) (string, error) {
Expand Down Expand Up @@ -166,22 +168,22 @@ func cleanupURL(arg string) (string, error) {
return u.String(), nil
}

// parseRepo returns host, owner and repository name from a given path or URL.
func parseRepo(arg string) (string, string, string, error) {
u, err := originURL(arg)
// parseRepo returns host, owner, repository name and remote name from a given path or URL.
func parseRepo(arg string) (string, string, string, string, error) {
rn, u, err := remoteURL(arg)
if err != nil {
// not a local repo
u, err = cleanupURL(arg)
if err != nil {
return "", "", "", err
return "", "", "", "", err
}
}

p := strings.Split(u, "/")
if len(p) < 5 {
return "", "", "", fmt.Errorf("does not look like a valid path or URL")
return "", "", "", "", fmt.Errorf("does not look like a valid path or URL")
}

host, owner, name := p[2], p[3], strings.Join(p[4:], "/")
return host, owner, name, nil
return host, owner, name, rn, nil
}
20 changes: 17 additions & 3 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ func parseRepository() {
}

// parse URL from args
host, owner, name, err := parseRepo(arg)
host, owner, name, rn, err := parseRepo(arg)
if err != nil {
fmt.Println(err)
os.Exit(1)
Expand Down Expand Up @@ -130,7 +130,21 @@ func parseRepository() {
fmt.Println(err)
os.Exit(1)
}
brs <- b
brs <- filterBranches(b)
}()

// get branch stats
sts := make(chan map[string]*trackStat)
stbrs := make(chan []vcs.Branch)
go func() {
b := <-brs
if s, err := getBranchTrackStats(arg, rn, b); err != nil {
stbrs <- b
sts <- map[string]*trackStat{}
} else {
stbrs <- b
sts <- s
}
}()

// fetch commit history
Expand All @@ -152,7 +166,7 @@ func parseRepository() {

printIssues(<-is)
printPullRequests(<-prs)
printBranches(<-brs)
printBranches(<-stbrs, <-sts)
printCommits(<-repo)
}

Expand Down
196 changes: 196 additions & 0 deletions track.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
package main

import (
"fmt"
"io"

"github.com/charmbracelet/lipgloss"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/muesli/gitty/vcs"
)

const maxTrackStatCount = 99

type trackStat struct {
Outdated bool
Ahead int
Behind int
}

func (s *trackStat) Render() string {
genericStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color(theme.colorGray))
outdatedStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color(theme.colorRed))
remoteStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color(theme.colorCyan))
statCountStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color(theme.colorGreen)).Width(4).Align(lipgloss.Right)
statCountWarnStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color(theme.colorYellow)).Width(4).Align(lipgloss.Right)
if s == nil {
return remoteStyle.Render("☁") + statCountStyle.Render(" ") + statCountStyle.Render(" ")
} else {
str := ""
if s.Outdated {
str += outdatedStyle.Render("↻")
} else {
str += genericStyle.Render(" ")
}
if s.Ahead > 0 || s.Behind > 0 {
str += statCountWarnStyle.Render(s.AheadString())
str += statCountWarnStyle.Render(s.BehindString())
} else {
str += statCountStyle.Render(s.AheadString())
str += statCountStyle.Render(s.BehindString())
}
return str
}
}

func (s trackStat) AheadString() string {
if s.Ahead == 0 {
return "↑"
} else if s.Ahead > maxTrackStatCount {
return fmt.Sprintf("%d+↑", maxTrackStatCount)
} else {
return fmt.Sprintf("%d↑", s.Ahead)
}
}

func (s trackStat) BehindString() string {
if s.Behind == 0 {
return "↓"
} else if s.Behind > maxTrackStatCount {
return fmt.Sprintf("%d+↓", maxTrackStatCount)
} else {
return fmt.Sprintf("%d↓", s.Behind)
}
}

func getBranchTrackStats(path string, remote string, remoteBranches []vcs.Branch) (map[string]*trackStat, error) {
repo, err := git.PlainOpen(path)
if err != nil {
return nil, err
}

iter, err := repo.Branches()
if err != nil {
return nil, err
}

remoteBranchMap := make(map[string]struct{}, len(remoteBranches))
for _, remoteBranch := range remoteBranches {
remoteBranchMap[remoteBranch.Name] = struct{}{}
}
trackedBranchMap := make(map[string]*plumbing.Reference)

if err := iter.ForEach(func(branchRef *plumbing.Reference) error {
localName := branchRef.Name().Short()
// repo.Branch reads branch from .git/config, it will report "not found" if a local branch has no config.
if b, err := repo.Branch(localName); err == nil && b.Remote == remote {
trackedBranchMap[b.Merge.Short()] = branchRef
} else if _, ok := remoteBranchMap[localName]; ok {
trackedBranchMap[localName] = branchRef
}
return nil
}); err != nil {
return nil, err
}

results := make(map[string]*trackStat, len(remoteBranches))
for _, remoteBranch := range remoteBranches {
var result *trackStat = nil
if b, ok := trackedBranchMap[remoteBranch.Name]; !ok {
} else {
if result, err = getTrackStat(repo, b, remote, &remoteBranch); err != nil {
result = nil
}
}
results[remoteBranch.Name] = result
}
return results, nil
}

func getTrackStat(repo *git.Repository, localRef *plumbing.Reference, remote string, remoteBranch *vcs.Branch) (*trackStat, error) {
if remoteRef, err := repo.Reference(
plumbing.NewRemoteReferenceName(remote, remoteBranch.Name), true,
); err != nil {
return nil, err
} else {
stat := &trackStat{
Outdated: false,
Ahead: 0,
Behind: 0,
}

if stat.Ahead, stat.Behind, err = calculateTrackCount(
repo, localRef.Hash(), remoteRef.Hash(),
); err != nil {
return nil, err
}

if remoteRef.Hash().String() != remoteBranch.LastCommit.ID {
// mark outdated, need `git fetch`
stat.Outdated = true
}
return stat, nil
}
}

func calculateTrackCount(repo *git.Repository, ref, base plumbing.Hash) (ahead, behind int, err error) {
if ref == base {
return 0, 0, nil
}

left, err := repo.CommitObject(ref)
if err != nil {
return 0, 0, err
}
right, err := repo.CommitObject(base)
if err != nil {
return 0, 0, err
}

commitMap := make(map[plumbing.Hash]bool)

if err := iterateCommits(left, func(c *object.Commit) {
commitMap[c.Hash] = true
}); err != nil {
return 0, 0, err
}

if err := iterateCommits(right, func(c *object.Commit) {
if _, ok := commitMap[c.Hash]; !ok {
behind++
} else {
commitMap[c.Hash] = false
}
}); err != nil {
return 0, 0, err
}

for _, v := range commitMap {
if v {
ahead++
}
}
return
}

func iterateCommits(c *object.Commit, fn func(c *object.Commit)) error {
iter := object.NewCommitPreorderIter(c, map[plumbing.Hash]bool{}, []plumbing.Hash{})
defer iter.Close()
for {
if curr, err := iter.Next(); err == io.EOF {
break
} else if err != nil {
return err
} else {
fn(curr)
}
}
return nil
}

0 comments on commit a1b2433

Please sign in to comment.