From 95c6935d1b310883387b7ec74f05013ed30cb668 Mon Sep 17 00:00:00 2001 From: Antti Kupila Date: Wed, 25 Apr 2018 06:30:05 +0200 Subject: [PATCH] Initial implementation --- README.md | 183 ++++++++++++++++++++++++ cmd/gitprompt/gitprompt.go | 41 ++++++ cmd/gitprompt/help.go | 63 ++++++++ formatter.go | 96 +++++++++++++ git.go | 32 +++++ parser.go | 84 +++++++++++ parser_test.go | 267 ++++++++++++++++++++++++++++++++++ printer.go | 284 +++++++++++++++++++++++++++++++++++++ printer_test.go | 272 +++++++++++++++++++++++++++++++++++ 9 files changed, 1322 insertions(+) create mode 100644 README.md create mode 100644 cmd/gitprompt/gitprompt.go create mode 100644 cmd/gitprompt/help.go create mode 100644 formatter.go create mode 100644 git.go create mode 100644 parser.go create mode 100644 parser_test.go create mode 100644 printer.go create mode 100644 printer_test.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..20cf331 --- /dev/null +++ b/README.md @@ -0,0 +1,183 @@ +# gitprompt + +gitprompt is a configurable, fast and zero-dependencies* way of getting the +current git status to be displayed in the `PROMPT`. + +Displays: + +- Current branch / sha1 +- Untracked files +- Modified files +- Staged files +- Commits behind / ahead remote + +When executed, gitprompt gets the git status of the current working directory +then prints it according to the format specified. If the current working +directory is not part of a git repository, gitprompt +exits with code `0` and no output. + +`*` git is required + +## Configuration + +The output is configured with `-format` or the `GITPROMPT_FORMAT` environment +variable. If both are set, the flag takes precedence. + +A very bare-bones format can look like this: + +``` +gitprompt -format="%h >%s ↓%b ↑%a @%c +%m %u" + +# or + +export GITPROMPT_FORMAT="%h >%s ↓%b ↑%a @%c +%m %u" +gitprompt +``` + +Characters that don't have a special meaning are printed as usual _(unicode +characters are fine, go crazy with emojis if that's your thing)_. + +### Data + +Various data from git can be displayed in the output. Data tokens are prefixed +with `%`: + +| token | explanation | +|-------|-----------------------------------| +| `%h` | Current branch or sha1 | +| `%s` | Number of files staged | +| `%b` | Number of commits behind remote | +| `%a` | Number of commits ahead of remote | +| `%c` | Number of conflicts | +| `%m` | Number of files modified | +| `%u` | Number of untracked files | + +Normally `%h` displays the current branch (`master`) but if you're detached +from `HEAD`, it will display the current sha1. Only first 7 characters of the +sha1 are displayed. + +### Colors + +The color can be set with color tokens, prefixed with `#`: + +| token | color | +|-------|-------------------| +| `#k` | Black | +| `#r` | Red | +| `#g` | Green | +| `#y` | Yellow | +| `#b` | Blue | +| `#m` | Magenta | +| `#c` | Cyan | +| `#w` | White | +| `#K` | Highlight Black | +| `#R` | Highlight Red | +| `#G` | Highlight Green | +| `#Y` | Highlight Yellow | +| `#B` | Highlight Blue | +| `#M` | Highlight Magenta | +| `#C` | Highlight Cyan | +| `#W` | Highlight White | + +The color is set until another color overrides it, or a group ends (see below). +If a color was set when gitprompt is done, it will add a color reset escape +code at the end, meaning text after gitprompt won't have the color applied. + +### Text attributes + +The text attributes can be set with attribute tokens, prefixed with `@`: + +| token | attribute | +|-------|-----------------------| +| `@b` | Set bold | +| `@B` | Clear bold | +| `@f` | Set faint/dim color | +| `@F` | Clear faint/dim color | +| `@i` | Set italic | +| `@I` | Clear italic | + +As with colors, if an attribute was set when gitprompt is done, an additional +escape code is automatically added to clear it. + +### Groups + +Groups can be used for adding logic to the format. A group's output is only +printed if at least one item in the group has data. + +| token | action | +|-------|-------------| +| `[` | start group | +| `]` | end group | + +Consider the following: + +``` +%h[ %a][ %m] +``` + +With `head=master, ahead=3, modified=0`, this will print `master 3` since there +were not modified files. Note the space that's included in the group, if +spacing should be added when the group is present, the spacing should be added +to the group itself. + +Colors and attributes are also scoped to a group, meaning they won't leak +outside so there's no need to reset colors: + +``` +[#r behind: %b] - [#g ahead: %a] +``` + +This prints `behind` in red, `-` without any formatting, and `ahead` in green. + +``` +#c%h[ >%s][ ↓%b ↑%a] +``` + +This prints the current branch/sha1 in cyan, then number of staged files (if +not zero), then commits behind and ahead (if both are not zero). This allows +for symmetry, if it's desired to show `master >1 ↓0 ↑2` instead of `master >1 +↑2`. + +### Complete example + +Putting everything together, a complex format may look something like this: + +``` +gitprompt -format="#B(@b#R%h[#y >%s][#m ↓%b ↑%a][#r x%c][#g +%m][#y ϟ%u]#B)" +``` + +- `(` in highlight blue +- Current branch/sha1 in bold red +- If there are staged files, number of staged files in yellow, prefixed with `>` +- If there are commits ahead or behind, show them with arrows in magenta +- If there are conflicts, show them in red, prefixed with `x` +- If files have been modified since the previous commit, show `+3` for 3 modified files +- If files are untracked (added since last commit), show a lightning and the number in yellow +- `)` in highlight blue +- _any text printed after gitprompt will have all formatting cleared_ + +## Download + +### MacOS + +Install [Homebrew] + +`TODO(akupila): brew cask` + +## Configure your shell + +### zsh + +gitprompt needs to execute as a part the `PROMPT`, add this to your `~/.zshrc`: + +``` +PROMPT='$(gitprompt)' +``` + +Now reload the config (`source ~/.zshrc`) and gitprompt should show up. Of +course you're free to add anything else here too, just execute `gitprompt` +where you want the status. + + + +[Homebrew]: https://brew.sh/ diff --git a/cmd/gitprompt/gitprompt.go b/cmd/gitprompt/gitprompt.go new file mode 100644 index 0000000..4c71023 --- /dev/null +++ b/cmd/gitprompt/gitprompt.go @@ -0,0 +1,41 @@ +package main + +import ( + "flag" + "os" + + "github.com/akupila/gitprompt" +) + +const defaultFormat = "#B([@b#R%h][#y ›%s][#m ↓%b][#m ↑%a][#r x%c][#g +%m][#y %u]#B)" + +type formatFlag struct { + set bool + value string +} + +func (f *formatFlag) Set(v string) error { + f.set = true + f.value = v + return nil +} + +func (f *formatFlag) String() string { + if f.set { + return f.value + } + + if envVar := os.Getenv("GITPROMPT_FORMAT"); envVar != "" { + return envVar + } + + return defaultFormat +} + +var format formatFlag + +func main() { + flag.Var(&format, "format", formatHelp()) + flag.Parse() + gitprompt.Exec(format.String()) +} diff --git a/cmd/gitprompt/help.go b/cmd/gitprompt/help.go new file mode 100644 index 0000000..8b6524e --- /dev/null +++ b/cmd/gitprompt/help.go @@ -0,0 +1,63 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/akupila/gitprompt" +) + +var exampleStatus = &gitprompt.GitStatus{ + Branch: "master", + Sha: "0455b83f923a40f0b485665c44aa068bc25029f5", + Untracked: 1, + Modified: 2, + Staged: 3, + Conflicts: 4, + Ahead: 5, + Behind: 6, +} + +var formatHelp = func() string { + return strings.TrimSpace(fmt.Sprintf(` +How to format output + +Default format is: %q +Example result: %s + +Data: + %%h Current branch or SHA1 + %%s Number of files staged + %%b Number of commits behind remote + %%a Number of commits ahead of remote + %%c Number of conflicts + %%m Number of files modified + %%u Number of untracked files + +Colors: + #k Black + #r Red + #g Green + #y Yellow + #b Blue + #m Magenta + #c Cyan + #w White + #K Highlight Black + #R Highlight Red + #G Highlight Green + #Y Highlight Yellow + #B Highlight Blue + #M Highlight Magenta + #C Highlight Cyan + #W Highlight White + +Text attributes: + @b Set bold + @B Clear bold + @f Set faint/dim color + @F Clear faint/dim color + @i Set italic + @I Clear italic +`, defaultFormat, gitprompt.Print(exampleStatus, defaultFormat))) +} diff --git a/formatter.go b/formatter.go new file mode 100644 index 0000000..7b0b787 --- /dev/null +++ b/formatter.go @@ -0,0 +1,96 @@ +package gitprompt + +import ( + "bytes" + "strconv" + "strings" +) + +type formatter struct { + color uint8 + currentColor uint8 + attr uint8 + currentAttr uint8 +} + +func (f *formatter) setColor(c uint8) { + f.color = c +} + +func (f *formatter) clearColor() { + f.color = 0 +} + +func (f *formatter) setAttribute(a uint8) { + f.attr |= (1 << a) +} + +func (f *formatter) clearAttribute(a uint8) { + f.attr &= ^(1 << a) +} + +func (f *formatter) attributeSet(a uint8) bool { + return (f.attr & (1 << a)) != 0 +} + +func (f *formatter) clearAttributes() { + f.attr = 0 +} + +func (f *formatter) printANSI(b *bytes.Buffer) { + if f.color == f.currentColor && f.attr == f.currentAttr { + return + } + b.WriteString("\x1b[") + if f.color == 0 && f.attr == 0 { + // reset all + b.WriteString("0m") + f.currentColor = 0 + f.currentAttr = 0 + return + } + mm := []string{} + aAdded, aRemoved := attrDiff(f.currentAttr, f.attr) + if len(aRemoved) > 0 { + mm = append(mm, "0") + var i uint8 = 1 + for ; i < 8; i++ { + if f.attributeSet(i) { + mm = append(mm, strconv.Itoa(int(i))) + } + } + } else if len(aAdded) > 0 { + for _, a := range aAdded { + mm = append(mm, strconv.Itoa(int(a))) + } + } + if f.color != f.currentColor || len(aRemoved) > 0 { + mm = append(mm, strconv.Itoa(int(f.color))) + } + b.WriteString(strings.Join(mm, ";")) + b.WriteString("m") + f.currentColor = f.color + f.currentAttr = f.attr + +} + +func attrDiff(a, b uint8) ([]uint8, []uint8) { + added := []uint8{} + removed := []uint8{} + var i uint8 + for ; i < 8; i++ { + inA := (a & (1 << i)) != 0 + inB := (b & (1 << i)) != 0 + if inA && inB { + continue + } + if inB { + added = append(added, i) + continue + } + if inA { + removed = append(removed, i) + } + } + return added, removed +} diff --git a/git.go b/git.go new file mode 100644 index 0000000..577ec35 --- /dev/null +++ b/git.go @@ -0,0 +1,32 @@ +package gitprompt + +import ( + "fmt" + "os" +) + +// GitStatus is the parsed status for the current state in git. +type GitStatus struct { + Sha string + Branch string + Untracked int + Modified int + Staged int + Conflicts int + Ahead int + Behind int +} + +// Exec executes gitprompt. It first parses the git status, then outputs the +// data according to the format. +// Exits with a non-zero exit code in case git returned an error. Exits with a +// blank string if the current directory is not part of a git repository. +func Exec(format string) { + s, err := Parse() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + out := Print(s, format) + fmt.Fprintf(os.Stdout, out) +} diff --git a/parser.go b/parser.go new file mode 100644 index 0000000..4561f34 --- /dev/null +++ b/parser.go @@ -0,0 +1,84 @@ +package gitprompt + +import ( + "bufio" + "bytes" + "errors" + "os/exec" + "strconv" + "strings" +) + +// Parse parses the status for the repository from git. Returns nil if the +// current directory is not part of a git repository. +func Parse() (*GitStatus, error) { + status := &GitStatus{} + + stat, err := runGitCommand("git", "status", "--branch", "--porcelain=2") + if err != nil { + if strings.HasPrefix(err.Error(), "fatal: not a git repository") { + return nil, nil + } + return nil, err + } + + lines := strings.Split(stat, "\n") + for _, line := range lines { + switch line[0] { + case '#': + parseHeader(line, status) + case '?': + status.Untracked++ + case 'u': + status.Conflicts++ + case '1': + parts := strings.Split(line, " ") + if parts[1][0] != '.' { + status.Staged++ + } + if parts[1][1] != '.' { + status.Modified++ + } + } + } + + return status, nil +} + +func parseHeader(h string, s *GitStatus) { + if strings.HasPrefix(h, "# branch.oid") { + hash := h[13:] + if hash != "(initial)" { + s.Sha = hash + } + return + } + if strings.HasPrefix(h, "# branch.head") { + branch := h[14:] + if branch != "(detached)" { + s.Branch = branch + } + return + } + if strings.HasPrefix(h, "# branch.ab") { + parts := strings.Split(h, " ") + s.Ahead, _ = strconv.Atoi(strings.TrimPrefix(parts[2], "+")) + s.Behind, _ = strconv.Atoi(strings.TrimPrefix(parts[3], "-")) + return + } +} + +func runGitCommand(cmd string, args ...string) (string, error) { + var stdout bytes.Buffer + var stderr bytes.Buffer + command := exec.Command(cmd, args...) + command.Stdout = bufio.NewWriter(&stdout) + command.Stderr = bufio.NewWriter(&stderr) + if err := command.Run(); err != nil { + if stderr.Len() > 0 { + return "", errors.New(stderr.String()) + } + return "", err + } + return strings.TrimSpace(stdout.String()), nil +} diff --git a/parser_test.go b/parser_test.go new file mode 100644 index 0000000..a55b436 --- /dev/null +++ b/parser_test.go @@ -0,0 +1,267 @@ +package gitprompt + +import ( + "bufio" + "bytes" + "fmt" + "io/ioutil" + "os" + "os/exec" + "path" + "testing" +) + +func TestParseValues(t *testing.T) { + tests := []struct { + name string + setup string + expected *GitStatus + }{ + { + name: "not git repo", + expected: nil, + }, + { + name: "dirty", + setup: ` + git init + touch test + `, + expected: &GitStatus{ + Untracked: 1, + }, + }, + { + name: "staged", + setup: ` + git init + touch test + git add test + `, + expected: &GitStatus{ + Staged: 1, + }, + }, + { + name: "modified", + setup: ` + git init + echo "hello" >> test + git add test + git commit -m 'initial' + echo "world" >> test + `, + expected: &GitStatus{ + Modified: 1, + }, + }, + { + name: "deleted", + setup: ` + git init + echo "hello" >> test + git add test + git commit -m 'initial' + rm test + `, + expected: &GitStatus{ + Modified: 1, + }, + }, + { + name: "conflicts", + setup: ` + git init + git commit --allow-empty -m 'initial' + git checkout -b other + git checkout master + echo foo >> test + git add test + git commit -m 'first' + git checkout other + echo bar >> test + git add test + git commit -m 'first' + git rebase master || true + `, + expected: &GitStatus{ + Conflicts: 1, + }, + }, + { + name: "ahead", + setup: ` + git init + git remote add origin $REMOTE + git commit --allow-empty -m 'first' + git push -u origin HEAD + git commit --allow-empty -m 'second' + `, + expected: &GitStatus{ + Ahead: 1, + }, + }, + { + name: "behind", + setup: ` + git init + git remote add origin $REMOTE + git commit --allow-empty -m 'first' + git commit --allow-empty -m 'second' + git push -u origin HEAD + git reset --hard HEAD^ + `, + expected: &GitStatus{ + Behind: 1, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + dir, cleanupDir := setupTestDir(t) + defer cleanupDir() + + if test.setup != "" { + remote, cleanupRemote := setupRemote(t, dir) + defer cleanupRemote() + commands := "export REMOTE=" + remote + "\n" + test.setup + setupCommands(t, dir, commands) + } + + actual, err := Parse() + if err != nil { + t.Errorf("Received unexpected error: %v", err) + return + } + if test.expected == nil { + if actual != nil { + t.Errorf("Expected nil return, got %v", actual) + } + return + } + assertInt(t, "Untracked", test.expected.Untracked, actual.Untracked) + assertInt(t, "Modified", test.expected.Modified, actual.Modified) + assertInt(t, "Staged", test.expected.Staged, actual.Staged) + assertInt(t, "Conflicts", test.expected.Conflicts, actual.Conflicts) + assertInt(t, "Ahead", test.expected.Ahead, actual.Ahead) + assertInt(t, "Behind", test.expected.Behind, actual.Behind) + }) + } +} + +func TestParseHead(t *testing.T) { + dir, done := setupTestDir(t) + defer done() + + setupCommands(t, dir, ` + git init + `) + s, _ := Parse() + assertString(t, "branch", "master", s.Branch) + + setupCommands(t, dir, ` + git commit --allow-empty -m 'initial' + `) + s, _ = Parse() + assertString(t, "branch", "master", s.Branch) + + setupCommands(t, dir, ` + git checkout -b other + `) + s, _ = Parse() + assertString(t, "branch", "other", s.Branch) + + setupCommands(t, dir, ` + git commit --allow-empty -m 'second' + git checkout head^ + `) + s, _ = Parse() + assertString(t, "branch", "", s.Branch) + if len(s.Sha) != 40 { + t.Errorf("Expected 40 char hash, got %v (%s)", len(s.Sha), s.Sha) + } +} + +func TestExecGitErr(t *testing.T) { + path := os.Getenv("PATH") + os.Setenv("PATH", "") + defer os.Setenv("PATH", path) + + _, err := Parse() + if err == nil { + t.Errorf("Expected error when git not found on $PATH") + } +} + +func setupTestDir(t *testing.T) (string, func()) { + dir, err := ioutil.TempDir("", "gitprompt-test") + if err != nil { + t.Fatalf("Create temp dir: %v", err) + } + + if err := os.Chdir(dir); err != nil { + t.Fatalf("Could not change dir: %v", err) + } + + return dir, func() { + if err = os.RemoveAll(dir); err != nil { + fmt.Fprintf(os.Stderr, "Failed to clean up test dir: %v\n", err) + } + } +} + +func setupRemote(t *testing.T, dir string) (string, func()) { + // Set up remote dir + remote, err := ioutil.TempDir("", "gitprompt-test-remote.git") + if err != nil { + t.Fatalf("create temp dir for remote: %v", err) + } + remoteCmd := exec.Command("git", "init", "--bare") + remoteCmd.Dir = remote + if err = remoteCmd.Run(); err != nil { + t.Fatalf("Set up remote: %v", err) + } + + return remote, func() { + if err = os.RemoveAll(remote); err != nil { + fmt.Fprintf(os.Stderr, "Failed to clean up remote dir: %v\n", err) + } + } +} + +func setupCommands(t *testing.T, dir, commands string) { + commands = "set -eo pipefail\n" + commands + script := path.Join(dir, "setup.sh") + ioutil.WriteFile(script, []byte(commands), 0644) + cmd := exec.Command("bash", "setup.sh") + var stderr bytes.Buffer + var stdout bytes.Buffer + cmd.Stderr = bufio.NewWriter(&stderr) + cmd.Stdout = bufio.NewWriter(&stdout) + cmd.Dir = dir + if err := cmd.Run(); err != nil { + t.Log("STDOUT:", stdout.String()) + t.Log("STDERR:", stderr.String()) + t.Fatalf("Setup command failed: %v", err) + } + if err := os.Remove(script); err != nil { + t.Fatal(err) + } +} + +func assertString(t *testing.T, name, expected, actual string) { + t.Helper() + if expected == actual { + return + } + t.Errorf("%s does not match\n\tExpected: %q\n\tActual: %q", name, expected, actual) +} + +func assertInt(t *testing.T, name string, expected, actual int) { + t.Helper() + if expected == actual { + return + } + t.Errorf("%s does not match\n\tExpected: %v\n\tActual: %v", name, expected, actual) +} diff --git a/printer.go b/printer.go new file mode 100644 index 0000000..982e289 --- /dev/null +++ b/printer.go @@ -0,0 +1,284 @@ +package gitprompt + +import ( + "bufio" + "bytes" + "strconv" + "strings" + "unicode" +) + +const ( + tAttribute rune = '@' + tColor = '#' + tReset = '_' + tData = '%' + tGroupOp = '[' + tGroupCl = ']' + tEsc = '\\' +) + +var attrs = map[rune]uint8{ + 'b': 1, // bold + 'f': 2, // faint + 'i': 3, // italic +} + +var resetAttrs = map[rune]uint8{ + 'B': 1, // bold + 'F': 2, // faint + 'I': 3, // italic +} + +var colors = map[rune]uint8{ + 'k': 30, // black + 'r': 31, // red + 'g': 32, // green + 'y': 33, // yellow + 'b': 34, // blue + 'm': 35, // magenta + 'c': 36, // cyan + 'w': 37, // white + 'K': 90, // highlight black + 'R': 91, // highlight red + 'G': 92, // highlight green + 'Y': 93, // highlight yellow + 'B': 94, // highlight blue + 'M': 95, // highlight magenta + 'C': 96, // highlight cyan + 'W': 97, // highlight white +} + +const ( + head rune = 'h' + untracked = 'u' + modified = 'm' + staged = 's' + conflicts = 'c' + ahead = 'a' + behind = 'b' +) + +type group struct { + buf bytes.Buffer + + parent *group + format formatter + + hasData bool + hasValue bool +} + +// Print prints the status according to the format. +func Print(s *GitStatus, format string) string { + if s == nil { + return "" + } + + in := make(chan rune) + go func() { + r := bufio.NewReader(strings.NewReader(format)) + for { + ch, _, err := r.ReadRune() + if err != nil { + close(in) + return + } + in <- ch + } + }() + + return buildOutput(s, in) +} + +func buildOutput(s *GitStatus, in chan rune) string { + root := &group{} + g := root + + col := false + att := false + dat := false + esc := false + + for ch := range in { + if esc { + esc = false + g.addRune(ch) + continue + } + + if col { + setColor(g, ch) + col = false + continue + } + + if att { + setAttribute(g, ch) + att = false + continue + } + + if dat { + setData(g, s, ch) + dat = false + continue + } + + switch ch { + case tEsc: + esc = true + case tColor: + col = true + case tAttribute: + att = true + case tData: + dat = true + case tGroupOp: + g = &group{ + parent: g, + format: g.format, + } + g.format.clearAttributes() + g.format.clearColor() + case tGroupCl: + if g.writeTo(&g.parent.buf) { + g.parent.format = g.format + g.parent.format.setColor(0) + g.parent.format.clearAttributes() + } + g = g.parent + default: + g.addRune(ch) + } + } + + // trailing characters + if col { + g.addRune(tColor) + } + if att { + g.addRune(tAttribute) + } + if dat { + g.addRune(tData) + } + + g.format.clearColor() + g.format.clearAttributes() + g.format.printANSI(&g.buf) + + return root.buf.String() +} + +func setColor(g *group, ch rune) { + if ch == tReset { + // Reset color code. + g.format.clearColor() + return + } + code, ok := colors[ch] + if ok { + g.format.setColor(code) + return + } + g.addRune(tColor) + g.addRune(ch) +} + +func setAttribute(g *group, ch rune) { + if ch == tReset { + // Reset attribute. + g.format.clearAttributes() + return + } + code, ok := attrs[ch] + if ok { + g.format.setAttribute(code) + return + } + code, ok = resetAttrs[ch] + if ok { + g.format.clearAttribute(code) + return + } + g.addRune(tAttribute) + g.addRune(ch) +} + +func setData(g *group, s *GitStatus, ch rune) { + switch ch { + case head: + g.hasData = true + g.hasValue = true + if s.Branch != "" { + g.addString(s.Branch) + } else { + g.addString(s.Sha[:7]) + } + case modified: + g.addInt(s.Modified) + g.hasData = true + if s.Modified > 0 { + g.hasValue = true + } + case untracked: + g.addInt(s.Untracked) + g.hasData = true + if s.Untracked > 0 { + g.hasValue = true + } + case staged: + g.addInt(s.Staged) + g.hasData = true + if s.Staged > 0 { + g.hasValue = true + } + case conflicts: + g.addInt(s.Conflicts) + g.hasData = true + if s.Conflicts > 0 { + g.hasValue = true + } + case ahead: + g.addInt(s.Ahead) + g.hasData = true + if s.Ahead > 0 { + g.hasValue = true + } + case behind: + g.addInt(s.Behind) + g.hasData = true + if s.Behind > 0 { + g.hasValue = true + } + default: + g.addRune(tData) + g.addRune(ch) + } +} + +func (g *group) writeTo(b *bytes.Buffer) bool { + if g.hasData && !g.hasValue { + return false + } + g.buf.WriteTo(b) + return true +} + +func (g *group) addRune(r rune) { + if !unicode.IsSpace(r) { + g.format.printANSI(&g.buf) + } + g.buf.WriteRune(r) +} + +func (g *group) addString(s string) { + g.format.printANSI(&g.buf) + g.buf.WriteString(s) +} + +func (g *group) addInt(i int) { + g.format.printANSI(&g.buf) + g.buf.WriteString(strconv.Itoa(i)) +} diff --git a/printer_test.go b/printer_test.go new file mode 100644 index 0000000..ef5901a --- /dev/null +++ b/printer_test.go @@ -0,0 +1,272 @@ +package gitprompt + +import ( + "testing" +) + +var all = &GitStatus{ + Branch: "master", + Sha: "0455b83f923a40f0b485665c44aa068bc25029f5", + Untracked: 0, + Modified: 1, + Staged: 2, + Conflicts: 3, + Ahead: 4, + Behind: 5, +} + +func TestPrinterEmpty(t *testing.T) { + actual := Print(nil, "%h") + assertOutput(t, "", actual) +} + +func TestPrinterData(t *testing.T) { + actual := Print(all, "%h %u %m %s %c %a %b") + assertOutput(t, "master 0 1 2 3 4 5", actual) +} + +func TestPrinterUnicode(t *testing.T) { + actual := Print(all, "%h ✋%u ⚡️%m 🚚%s ❗️%c ⬆%a ⬇%b") + assertOutput(t, "master ✋0 ⚡️1 🚚2 ❗️3 ⬆4 ⬇5", actual) +} + +func TestShortSHA(t *testing.T) { + actual := Print(&GitStatus{Sha: "858828b5e153f24644bc867598298b50f8223f9b"}, "%h") + assertOutput(t, "858828b", actual) +} + +func TestPrinterColorAttributes(t *testing.T) { + tests := []struct { + name string + format string + expected string + }{ + { + name: "red", + format: "#r%h", + expected: "\x1b[31mmaster\x1b[0m", + }, + { + name: "bold", + format: "@b%h", + expected: "\x1b[1mmaster\x1b[0m", + }, + { + name: "color & attribute", + format: "#r@bA", + expected: "\x1b[1;31mA\x1b[0m", + }, + { + name: "color & attribute reversed", + format: "@b#rA", + expected: "\x1b[1;31mA\x1b[0m", + }, + { + name: "ignore format until non-whitespace", + format: "A#r#g#b B@i\tC", + expected: "A \x1b[34mB\t\x1b[3mC\x1b[0m", + }, + { + name: "reset color", + format: "#rA#_B", + expected: "\x1b[31mA\x1b[0mB", + }, + { + name: "reset attributes", + format: "@bA@_B", + expected: "\x1b[1mA\x1b[0mB", + }, + { + name: "reset attribute", + format: "#ggreen @b@igreen_bold_italic @Bgreen_italic", + expected: "\x1b[32mgreen \x1b[1;3mgreen_bold_italic \x1b[0;3;32mgreen_italic\x1b[0m", + }, + { + name: "ending with #", + format: "%h#", + expected: "master#", + }, + { + name: "ending with !", + format: "%h!", + expected: "master!", + }, + { + name: "ending with @", + format: "%h@", + expected: "master@", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual := Print(all, test.format) + assertOutput(t, test.expected, actual) + }) + } +} + +func TestPrinterGroups(t *testing.T) { + tests := []struct { + name string + format string + expected string + }{ + { + name: "groups", + format: "<[%h][ B%b A%a][ U%u][ C%c]>", + expected: "", + }, + { + name: "group color", + format: "<[#r%h]-[#g%u]%a[-#b%b]>", + expected: "<\x1b[31mmaster\x1b[0m-4-\x1b[34m5\x1b[0m>", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual := Print(all, test.format) + assertOutput(t, test.expected, actual) + }) + } +} + +func TestPrinterNonMatching(t *testing.T) { + tests := []struct { + name string + format string + expected string + }{ + { + name: "data valid odd", + format: "%%%h", + expected: "%%master", + }, + { + name: "data valid even", + format: "%%%%h", + expected: "%%%%h", + }, + { + name: "data invalid odd", + format: "%%%z", + expected: "%%%z", + }, + { + name: "data invalid even", + format: "%%%%z", + expected: "%%%%z", + }, + { + name: "color valid odd", + format: "###rA", + expected: "##\x1b[31mA\x1b[0m", + }, + { + name: "color valid even", + format: "####rA", + expected: "####rA", + }, + { + name: "color invalid odd", + format: "###zA", + expected: "###zA", + }, + { + name: "color invalid even", + format: "####zA", + expected: "####zA", + }, + { + name: "attribute valid odd", + format: "@@@bA", + expected: "@@\x1b[1mA\x1b[0m", + }, + { + name: "attribute valid even", + format: "@@@@bA", + expected: "@@@@bA", + }, + { + name: "attribute invalid odd", + format: "@@@zA", + expected: "@@@zA", + }, + { + name: "attribute invalid even", + format: "@@@@zA", + expected: "@@@@zA", + }, + { + name: "trailing %", + format: "A%", + expected: "A%", + }, + { + name: "trailing #", + format: "A#", + expected: "A#", + }, + { + name: "trailing @", + format: "A@", + expected: "A@", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual := Print(all, test.format) + assertOutput(t, test.expected, actual) + }) + } +} + +func TestPrinterEscape(t *testing.T) { + tests := []struct { + name string + format string + expected string + }{ + { + name: "data", + format: "A\\%h", + expected: "A%h", + }, + { + name: "color", + format: "A\\#rB", + expected: "A#rB", + }, + { + name: "attribute", + format: "A\\!bB", + expected: "A!bB", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual := Print(all, test.format) + assertOutput(t, test.expected, actual) + }) + } +} + +func assertOutput(t *testing.T, expected, actual string) { + if actual == expected { + return + } + actualEscaped := actual + "\x1b[0m" + t.Errorf(` +Expected: %s + %q +Actual: %s + %q`, + expected, + expected, + actualEscaped, + actual, + ) +}