Skip to content

Commit eb81b18

Browse files
committed
Proof of concept implementation
1 parent 5c8852b commit eb81b18

File tree

5 files changed

+309
-0
lines changed

5 files changed

+309
-0
lines changed

branch.go

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
package main
2+
3+
import (
4+
"os"
5+
"os/exec"
6+
"regexp"
7+
"strings"
8+
9+
"github.com/fatih/color"
10+
)
11+
12+
func cmdOutput(command string, args ...string) (string, error) {
13+
cmd := exec.Command(command, args...)
14+
b, err := cmd.CombinedOutput()
15+
return string(b), err
16+
}
17+
18+
func cmdRun(command string, args ...string) error {
19+
cmd := exec.Command(command, args...)
20+
cmd.Stdout = os.Stdout
21+
cmd.Stderr = os.Stderr
22+
if err := cmd.Run(); err != nil {
23+
return err
24+
}
25+
return nil
26+
}
27+
28+
// branch describes a Git branch.
29+
type branch struct {
30+
name string
31+
isCurrent bool
32+
}
33+
34+
// branchesState provides the state for the application, based on CQRS
35+
// Includes view and command methods
36+
type branchesState struct {
37+
branches []branch
38+
selected int
39+
}
40+
41+
func (b *branchesState) init(args []string) error {
42+
args = append([]string{"branch"}, args...)
43+
out, err := cmdOutput("git", args...)
44+
if err != nil {
45+
println(out)
46+
return err
47+
}
48+
b.branches = splitBranches(out)
49+
b.selectCurrent()
50+
return nil
51+
}
52+
53+
func splitBranches(output string) []branch {
54+
names := strings.Split(output, "\n")
55+
var branches []branch
56+
for _, name := range names {
57+
if len(name) == 0 {
58+
continue
59+
}
60+
isCurrent := false
61+
if strings.Contains(name, "*") {
62+
name = strings.Replace(name, "*", "", -1)
63+
isCurrent = true
64+
}
65+
66+
name = strings.TrimSpace(name)
67+
branches = append(branches, branch{name: name, isCurrent: isCurrent})
68+
}
69+
return branches
70+
}
71+
72+
func extractBranch(name string) string {
73+
if strings.Contains(name, "->") {
74+
s := strings.Split(name, "->")
75+
return strings.TrimSpace(s[0])
76+
}
77+
return name
78+
}
79+
80+
// Commands
81+
// These methods allow to mutate state
82+
83+
func (b *branchesState) selectCurrent() {
84+
for ; !b.branches[b.selected].isCurrent; b.selected++ {
85+
}
86+
}
87+
88+
func (b *branchesState) selectNext() {
89+
b.selected = (b.selected + len(b.branches) - 1) % len(b.branches)
90+
}
91+
92+
func (b *branchesState) selectPrevious() {
93+
b.selected = ((b.selected + 1) % len(b.branches))
94+
}
95+
96+
// View
97+
// These methods allow to present the state
98+
99+
func (b *branchesState) selectedBranchName() string {
100+
return b.branches[b.selected].name
101+
}
102+
103+
func (b *branchesState) selectedBranchWithColor() string {
104+
formatted := []string{}
105+
fields := strings.Split(b.selectedBranchName(), "/")
106+
for _, f := range fields {
107+
formatted = append(formatted, withColor(f, colorDefaults))
108+
}
109+
out := strings.Join(formatted, withColor("/", colorDefaults))
110+
if b.branches[b.selected].isCurrent {
111+
out += "\t(" + withColor("currently checked-out", colorDefaults) + ")"
112+
}
113+
return out
114+
}
115+
116+
type formatFunc func(format string, a ...interface{}) string
117+
118+
type colorFormatter struct {
119+
pattern *regexp.Regexp
120+
format formatFunc
121+
}
122+
123+
func newColorFormatter(pattern string, format formatFunc) colorFormatter {
124+
return colorFormatter{
125+
pattern: regexp.MustCompile(pattern),
126+
format: format,
127+
}
128+
}
129+
130+
func withColor(str string, colors []colorFormatter) string {
131+
for _, f := range colors {
132+
if f.pattern.MatchString(str) {
133+
return f.format(str)
134+
}
135+
}
136+
return str
137+
}
138+
139+
var colorDefaults = []colorFormatter{
140+
newColorFormatter("feature", color.GreenString),
141+
newColorFormatter("test", color.GreenString),
142+
143+
newColorFormatter("improvement", color.YellowString),
144+
newColorFormatter("refactor", color.YellowString),
145+
newColorFormatter("currently checked-out", color.YellowString),
146+
147+
newColorFormatter("fix", color.RedString),
148+
newColorFormatter("bugfix", color.RedString),
149+
newColorFormatter("bug", color.RedString),
150+
newColorFormatter("fixup", color.RedString),
151+
newColorFormatter("debug", color.RedString),
152+
newColorFormatter("backup", color.RedString),
153+
154+
newColorFormatter("master", color.CyanString),
155+
156+
newColorFormatter("remotes", color.MagentaString),
157+
newColorFormatter("origin", color.MagentaString),
158+
159+
newColorFormatter(".*", color.WhiteString),
160+
}

branch_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package main
2+
3+
import "testing"
4+
5+
func Test_extractBranch(t *testing.T) {
6+
tests := []struct {
7+
name string
8+
want string
9+
}{
10+
{"feature", "feature"},
11+
{"hotfix", "hotfix"},
12+
{"origin/HEAD -> origin/master", "origin/HEAD"},
13+
}
14+
for _, tt := range tests {
15+
if got := extractBranch(tt.name); got != tt.want {
16+
t.Errorf("extractBranch() = %v, want %v", got, tt.want)
17+
}
18+
}
19+
}

go.mod

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
module github.com/giannimassi/git-checkout-interactive
2+
3+
go 1.13
4+
5+
require (
6+
github.com/eiannone/keyboard v0.0.0-20200508000154-caf4b762e807
7+
github.com/fatih/color v1.9.0
8+
github.com/gosuri/uilive v0.0.4
9+
github.com/mattn/go-isatty v0.0.12 // indirect
10+
golang.org/x/sys v0.0.0-20200622214017-ed371f2e16b4 // indirect
11+
)

go.sum

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
github.com/eiannone/keyboard v0.0.0-20200508000154-caf4b762e807 h1:jdjd5e68T4R/j4PWxfZqcKY8KtT9oo8IPNVuV4bSXDQ=
2+
github.com/eiannone/keyboard v0.0.0-20200508000154-caf4b762e807/go.mod h1:Xoiu5VdKMvbRgHuY7+z64lhu/7lvax/22nzASF6GrO8=
3+
github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s=
4+
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
5+
github.com/gosuri/uilive v0.0.4 h1:hUEBpQDj8D8jXgtCdBu7sWsy5sbW/5GhuO8KBwJ2jyY=
6+
github.com/gosuri/uilive v0.0.4/go.mod h1:V/epo5LjjlDE5RJUcqx8dbw+zc93y5Ya3yg8tfZ74VI=
7+
github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA=
8+
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
9+
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
10+
github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
11+
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
12+
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
13+
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
14+
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
15+
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
16+
golang.org/x/sys v0.0.0-20200622214017-ed371f2e16b4 h1:5/PjkGUjvEU5Gl6BxmvKRPpqo2uNMv4rcHBMwzk/st8=
17+
golang.org/x/sys v0.0.0-20200622214017-ed371f2e16b4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=

main.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"os"
7+
8+
"github.com/eiannone/keyboard"
9+
"github.com/gosuri/uilive"
10+
)
11+
12+
func main() {
13+
if err := run(os.Args[1:]); err != nil {
14+
println("Unexpected error: " + err.Error())
15+
}
16+
}
17+
18+
func run(args []string) error {
19+
// Init state
20+
view := &branchesState{}
21+
if err := view.init(args); err != nil {
22+
return err
23+
}
24+
25+
// Setup key input
26+
keyCh, err := keyboard.GetKeys(10)
27+
if err != nil {
28+
return err
29+
}
30+
defer keyboard.Close()
31+
32+
// Setup writer and start with currently selected branch
33+
stdout := uilive.New()
34+
fmt.Fprintf(stdout, "→ %s\n", view.selectedBranchWithColor())
35+
stdout.Flush()
36+
37+
// Listen for keyboard events and term signal
38+
for {
39+
select {
40+
case ev := <-keyCh:
41+
done, err := handleKeyEvent(stdout, view, ev)
42+
if !done {
43+
continue
44+
}
45+
return err
46+
}
47+
}
48+
}
49+
50+
func handleKeyEvent(wf writerFlusher, view *branchesState, ev keyboard.KeyEvent) (bool, error) {
51+
if ev.Err != nil {
52+
return true, ev.Err
53+
}
54+
switch {
55+
// Exit
56+
case ev.Key == keyboard.KeyCtrlC:
57+
fallthrough
58+
case ev.Key == keyboard.KeyEsc:
59+
return true, nil
60+
61+
// Up
62+
case ev.Key == keyboard.KeyArrowUp:
63+
fallthrough
64+
case ev.Key == keyboard.KeyArrowLeft:
65+
fallthrough
66+
case ev.Rune == 'h':
67+
view.selectPrevious()
68+
fmt.Fprintf(wf, "→ %s\n", view.selectedBranchWithColor())
69+
wf.Flush()
70+
71+
// Down
72+
case ev.Key == keyboard.KeyArrowDown:
73+
fallthrough
74+
case ev.Key == keyboard.KeyArrowRight:
75+
fallthrough
76+
case ev.Rune == 'j':
77+
view.selectNext()
78+
fmt.Fprintf(wf, "→ %s\n", view.selectedBranchWithColor())
79+
wf.Flush()
80+
81+
// Enter
82+
case ev.Key == keyboard.KeyEnter:
83+
return true, cmdRun("git", "checkout", extractBranch(view.selectedBranchName()))
84+
85+
default:
86+
return false, nil
87+
}
88+
return false, nil
89+
}
90+
91+
type writerWrapper struct {
92+
io.Writer
93+
}
94+
95+
func (w *writerWrapper) Flush() error {
96+
return nil
97+
}
98+
99+
type writerFlusher interface {
100+
io.Writer
101+
Flush() error
102+
}

0 commit comments

Comments
 (0)