diff --git a/branches_panel.go b/branches_panel.go index 576bc01ec..75408f266 100644 --- a/branches_panel.go +++ b/branches_panel.go @@ -13,7 +13,8 @@ import ( // "strings" - "github.com/fatih/color" + "fmt" + "github.com/jroimartin/gocui" ) @@ -41,7 +42,11 @@ func getSelectedBranch(v *gocui.View) Branch { } func handleBranchSelect(g *gocui.Gui, v *gocui.View) error { - renderString(g, "options", "space: checkout, s: squash down") + renderString(g, "options", "space: checkout, f: force checkout") + if len(state.Branches) == 0 { + return renderString(g, "main", "No branches for this repo") + } + // may want to standardise how these select methods work lineNumber := getItemPosition(v) branch := state.Branches[lineNumber] diff, _ := getBranchDiff(branch.Name, branch.BaseBranch) @@ -51,33 +56,19 @@ func handleBranchSelect(g *gocui.Gui, v *gocui.View) error { return nil } +// refreshStatus is called at the end of this because that's when we can +// be sure there is a state.Branches array to pick the current branch from func refreshBranches(g *gocui.Gui) error { v, err := g.View("branches") if err != nil { panic(err) } state.Branches = getGitBranches() - yellow := color.New(color.FgYellow) - red := color.New(color.FgRed) - white := color.New(color.FgWhite) - green := color.New(color.FgGreen) - v.Clear() for _, branch := range state.Branches { - if branch.Type == "feature" { - green.Fprintln(v, branch.DisplayString) - continue - } - if branch.Type == "bugfix" { - yellow.Fprintln(v, branch.DisplayString) - continue - } - if branch.Type == "hotfix" { - red.Fprintln(v, branch.DisplayString) - continue - } - white.Fprintln(v, branch.DisplayString) + fmt.Fprintln(v, branch.DisplayString) } resetOrigin(v) + refreshStatus(g) return nil } diff --git a/commit_panel.go b/commit_panel.go index 79a09f17b..b31bcb25a 100644 --- a/commit_panel.go +++ b/commit_panel.go @@ -53,7 +53,6 @@ func closeCommitPrompt(g *gocui.Gui, v *gocui.View) error { // not passing in the view as oldView to switchFocus because we don't want a // reference pointing to a deleted view switchFocus(g, nil, filesView) - devLog("test prompt close") if err := g.DeleteView("commit"); err != nil { return err } diff --git a/commits_panel.go b/commits_panel.go index 2d355a625..f1ca7fc99 100644 --- a/commits_panel.go +++ b/commits_panel.go @@ -7,10 +7,17 @@ package main import ( + "errors" + "github.com/fatih/color" "github.com/jroimartin/gocui" ) +var ( + // ErrNoCommits : When no commits are found for the branch + ErrNoCommits = errors.New("No commits for this branch") +) + func refreshCommits(g *gocui.Gui) error { state.Commits = getCommits() g.Update(func(*gocui.Gui) error { @@ -19,10 +26,17 @@ func refreshCommits(g *gocui.Gui) error { panic(err) } v.Clear() + red := color.New(color.FgRed) yellow := color.New(color.FgYellow) white := color.New(color.FgWhite) + shaColor := white for _, commit := range state.Commits { - yellow.Fprint(v, commit.Sha+" ") + if commit.Pushed { + shaColor = red + } else { + shaColor = yellow + } + shaColor.Fprint(v, commit.Sha+" ") white.Fprintln(v, commit.Name) } return nil @@ -31,9 +45,15 @@ func refreshCommits(g *gocui.Gui) error { } func handleCommitSelect(g *gocui.Gui, v *gocui.View) error { - commit := getSelectedCommit(v) + renderString(g, "options", "s: squash down, r: rename") + commit, err := getSelectedCommit(v) + if err != nil { + if err != ErrNoCommits { + return err + } + return renderString(g, "main", "No commits for this branch") + } commitText := gitShow(commit.Sha) - devLog("commitText:", commitText) return renderString(g, "main", commitText) } @@ -41,7 +61,10 @@ func handleCommitSquashDown(g *gocui.Gui, v *gocui.View) error { if getItemPosition(v) != 0 { return createSimpleConfirmationPanel(g, v, "Error", "Can only squash topmost commit") } - commit := getSelectedCommit(v) + commit, err := getSelectedCommit(v) + if err != nil { + return err + } if output, err := gitSquashPreviousTwoCommits(commit.Name); err != nil { return createSimpleConfirmationPanel(g, v, "Error", output) } @@ -67,13 +90,10 @@ func handleRenameCommit(g *gocui.Gui, v *gocui.View) error { return nil } -func getSelectedCommit(v *gocui.View) Commit { - lineNumber := getItemPosition(v) +func getSelectedCommit(v *gocui.View) (Commit, error) { if len(state.Commits) == 0 { - return Commit{ - Sha: "noCommit", - DisplayString: "none", - } + return Commit{}, ErrNoCommits } - return state.Commits[lineNumber] + lineNumber := getItemPosition(v) + return state.Commits[lineNumber], nil } diff --git a/confirmation_panel.go b/confirmation_panel.go index 08c7e04ab..3db9d4f75 100644 --- a/confirmation_panel.go +++ b/confirmation_panel.go @@ -115,3 +115,8 @@ func createConfirmationPanel(g *gocui.Gui, v *gocui.View, title, prompt string, func createSimpleConfirmationPanel(g *gocui.Gui, v *gocui.View, title, prompt string) error { return createConfirmationPanel(g, v, title, prompt, nil, nil) } + +func createErrorPanel(g *gocui.Gui, message string) error { + v := g.CurrentView() + return createConfirmationPanel(g, v, "Error", message, nil, nil) +} diff --git a/files_panel.go b/files_panel.go index 7e55dfbc3..7d64106e6 100644 --- a/files_panel.go +++ b/files_panel.go @@ -13,12 +13,18 @@ import ( // "strings" + "errors" "strings" "github.com/fatih/color" "github.com/jroimartin/gocui" ) +var ( + // ErrNoFiles : when there are no modified files in the repo + ErrNoFiles = errors.New("No changed files") +) + func stagedFiles(files []GitFile) []GitFile { result := make([]GitFile, 0) for _, file := range files { @@ -30,7 +36,10 @@ func stagedFiles(files []GitFile) []GitFile { } func handleFilePress(g *gocui.Gui, v *gocui.View) error { - file := getSelectedFile(v) + file, err := getSelectedFile(v) + if err != nil { + return err + } if file.HasUnstagedChanges { stageFile(file.Name) @@ -48,24 +57,19 @@ func handleFilePress(g *gocui.Gui, v *gocui.View) error { return nil } -func getSelectedFile(v *gocui.View) GitFile { - lineNumber := getItemPosition(v) +func getSelectedFile(v *gocui.View) (GitFile, error) { if len(state.GitFiles) == 0 { - // find a way to not have to do this - return GitFile{ - Name: "noFile", - DisplayString: "none", - HasStagedChanges: false, - HasUnstagedChanges: false, - Tracked: false, - Deleted: false, - } + return GitFile{}, ErrNoFiles } - return state.GitFiles[lineNumber] + lineNumber := getItemPosition(v) + return state.GitFiles[lineNumber], nil } func handleFileRemove(g *gocui.Gui, v *gocui.View) error { - file := getSelectedFile(v) + file, err := getSelectedFile(v) + if err != nil { + return err + } var deleteVerb string if file.Tracked { deleteVerb = "checkout" @@ -80,30 +84,54 @@ func handleFileRemove(g *gocui.Gui, v *gocui.View) error { }, nil) } +func handleIgnoreFile(g *gocui.Gui, v *gocui.View) error { + file, err := getSelectedFile(v) + if err != nil { + return err + } + if file.Tracked { + return createErrorPanel(g, "Cannot ignore tracked files") + } + gitIgnore(file.Name) + return refreshFiles(g) +} + func handleFileSelect(g *gocui.Gui, v *gocui.View) error { - item := getSelectedFile(v) + baseString := "tab: switch to branches, space: toggle staged, c: commit changes, o: open, s: open in sublime, i: ignore" + item, err := getSelectedFile(v) + if err != nil { + if err != ErrNoFiles { + return err + } + renderString(g, "main", "No changed files") + colorLog(color.FgRed, "error") + return renderString(g, "options", baseString) + } var optionsString string - baseString := "space: toggle staged, c: commit changes, option+o: open" if item.Tracked { - optionsString = baseString + ", option+d: checkout" + optionsString = baseString + ", r: checkout" } else { - optionsString = baseString + ", option+d: delete" + optionsString = baseString + ", r: delete" } renderString(g, "options", optionsString) diff := getDiff(item) return renderString(g, "main", diff) } -func handleFileOpen(g *gocui.Gui, v *gocui.View) error { - file := getSelectedFile(v) - _, err := openFile(file.Name) +func genericFileOpen(g *gocui.Gui, v *gocui.View, open func(string) (string, error)) error { + file, err := getSelectedFile(v) + if err != nil { + return err + } + _, err = open(file.Name) return err } +func handleFileOpen(g *gocui.Gui, v *gocui.View) error { + return genericFileOpen(g, v, openFile) +} func handleSublimeFileOpen(g *gocui.Gui, v *gocui.View) error { - file := getSelectedFile(v) - _, err := sublimeOpenFile(file.Name) - return err + return genericFileOpen(g, v, sublimeOpenFile) } func refreshFiles(g *gocui.Gui) error { @@ -144,10 +172,13 @@ func pullFiles(g *gocui.Gui, v *gocui.View) error { createSimpleConfirmationPanel(g, v, "Error", output) } else { closeConfirmationPrompt(g) + refreshCommits(g) + refreshFiles(g) + refreshStatus(g) + devLog("pulled.") } }() - devLog("pulled.") - return refreshFiles(g) + return nil } func pushFiles(g *gocui.Gui, v *gocui.View) error { @@ -158,8 +189,10 @@ func pushFiles(g *gocui.Gui, v *gocui.View) error { createSimpleConfirmationPanel(g, v, "Error", output) } else { closeConfirmationPrompt(g) + refreshCommits(g) + refreshStatus(g) + devLog("pushed.") } }() - devLog("pushed.") return nil } diff --git a/gitcommands.go b/gitcommands.go index b78da318b..640349569 100644 --- a/gitcommands.go +++ b/gitcommands.go @@ -18,25 +18,27 @@ import ( // GitFile : A staged/unstaged file type GitFile struct { Name string - DisplayString string HasStagedChanges bool HasUnstagedChanges bool Tracked bool Deleted bool + DisplayString string } // Branch : A git branch type Branch struct { Name string - DisplayString string Type string BaseBranch string + DisplayString string + DisplayColor color.Attribute } // Commit : A git commit type Commit struct { Sha string Name string + Pushed bool DisplayString string } @@ -100,43 +102,61 @@ func mergeGitStatusFiles(oldGitFiles, newGitFiles []GitFile) []GitFile { func runDirectCommand(command string) (string, error) { commandLog(command) cmdOut, err := exec.Command("bash", "-c", command).CombinedOutput() - devLog(string(cmdOut)) - devLog(err) return string(cmdOut), err } -func branchNameFromString(branchString string) string { - // because this has the recency at the beginning, - // we need to split and take the second part +func branchStringParts(branchString string) (string, string) { splitBranchName := strings.Split(branchString, "\t") - return splitBranchName[len(splitBranchName)-1] + return splitBranchName[0], splitBranchName[1] +} + +// branchPropertiesFromName : returns branch type, base, and color +func branchPropertiesFromName(name string) (string, string, color.Attribute) { + if strings.Contains(name, "feature/") { + return "feature", "develop", color.FgGreen + } else if strings.Contains(name, "bugfix/") { + return "bugfix", "develop", color.FgYellow + } else if strings.Contains(name, "hotfix/") { + return "hotfix", "master", color.FgRed + } + return "other", name, color.FgWhite +} + +func coloredString(str string, colour color.Attribute) string { + return color.New(colour).SprintFunc()(fmt.Sprint(str)) +} + +func withPadding(str string, padding int) string { + return str + strings.Repeat(" ", padding-len(str)) +} + +func branchFromLine(line string, index int) Branch { + recency, name := branchStringParts(line) + branchType, branchBase, colour := branchPropertiesFromName(name) + if index == 0 { + recency = " *" + } + displayString := withPadding(recency, 4) + coloredString(name, colour) + return Branch{ + Name: name, + Type: branchType, + BaseBranch: branchBase, + DisplayString: displayString, + DisplayColor: colour, + } } func getGitBranches() []Branch { branches := make([]Branch, 0) + // check if there are any branches + branchCheck, _ := runDirectCommand("git branch") + if branchCheck == "" { + return branches + } rawString, _ := runDirectCommand(getBranchesCommand) branchLines := splitLines(rawString) for i, line := range branchLines { - name := branchNameFromString(line) - var branchType string - var baseBranch string - if strings.Contains(line, "feature/") { - branchType = "feature" - baseBranch = "develop" - } else if strings.Contains(line, "bugfix/") { - branchType = "bugfix" - baseBranch = "develop" - } else if strings.Contains(line, "hotfix/") { - branchType = "hotfix" - baseBranch = "master" - } else { - branchType = "other" - baseBranch = name - } - if i == 0 { - line = "* " + line - } - branches = append(branches, Branch{name, line, branchType, baseBranch}) + branches = append(branches, branchFromLine(line, i)) } devLog(branches) return branches @@ -182,7 +202,6 @@ func runCommand(command string) (string, error) { commandLog(command) splitCmd := strings.Split(command, " ") cmdOut, err := exec.Command(splitCmd[0], splitCmd[1:]...).CombinedOutput() - devLog(string(cmdOut[:])) return string(cmdOut), err } @@ -198,30 +217,50 @@ func getBranchDiff(branch string, baseBranch string) (string, error) { return runCommand("git diff --color " + baseBranch + "..." + branch) } +func verifyInGitRepo() { + if output, err := runCommand("git status"); err != nil { + fmt.Println(output) + os.Exit(1) + } +} + func getCommits() []Commit { + pushables := gitCommitsToPush() log := getLog() commits := make([]Commit, 0) // now we can split it up and turn it into commits lines := splitLines(log) for _, line := range lines { splitLine := strings.Split(line, " ") - commits = append(commits, Commit{splitLine[0], strings.Join(splitLine[1:], " "), strings.Join(splitLine, " ")}) + sha := splitLine[0] + pushed := includes(pushables, sha) + commits = append(commits, Commit{ + Sha: sha, + Name: strings.Join(splitLine[1:], " "), + Pushed: pushed, + DisplayString: strings.Join(splitLine, " "), + }) } - devLog(commits) return commits } func getLog() string { result, err := runDirectCommand("git log --oneline") if err != nil { - panic(err) + // assume if there is an error there are no commits yet for this branch + return "" } return result } +func gitIgnore(filename string) { + if _, err := runDirectCommand("echo '" + filename + "' >> .gitignore"); err != nil { + panic(err) + } +} + func gitShow(sha string) string { result, err := runDirectCommand("git show --color " + sha) - // result, err := runDirectCommand("git show --color 10fd353") if err != nil { panic(err) } @@ -296,18 +335,34 @@ func gitRenameCommit(message string) (string, error) { return runDirectCommand("git commit --allow-empty --amend -m \"" + message + "\"") } -func betterHaveWorked(err error) { +func gitUpstreamDifferenceCount() (string, string) { + // TODO: deal with these errors which appear when we haven't yet pushed a feature branch + pushableCount, err := runDirectCommand("git rev-list @{u}..head --count") if err != nil { - panic(err) + return "?", "?" } + pullableCount, err := runDirectCommand("git rev-list head..@{u} --count") + if err != nil { + return "?", "?" + } + return strings.Trim(pullableCount, " \n"), strings.Trim(pushableCount, " \n") } -func gitUpstreamDifferenceCount() (string, string) { - pushableCount, err := runDirectCommand("git rev-list @{u}..head --count") - betterHaveWorked(err) - pullableCount, err := runDirectCommand("git rev-list head..@{u} --count") - betterHaveWorked(err) - return pullableCount, pushableCount +func gitCommitsToPush() []string { + pushables, err := runDirectCommand("git rev-list @{u}..head --abbrev-commit") + if err != nil { + return make([]string, 0) + } + return splitLines(pushables) +} + +func gitCurrentBranchName() string { + branchName, err := runDirectCommand("git rev-parse --abbrev-ref HEAD") + // if there is an error, assume there are no branches yet + if err != nil { + return "" + } + return branchName } const getBranchesCommand = `set -e diff --git a/gui.go b/gui.go index 442433981..d99db6baa 100644 --- a/gui.go +++ b/gui.go @@ -11,6 +11,7 @@ import ( // "io" // "io/ioutil" + "fmt" "log" // "strings" @@ -140,10 +141,13 @@ func keybindings(g *gocui.Gui) error { if err := g.SetKeybinding("files", 's', gocui.ModNone, handleSublimeFileOpen); err != nil { return err } - if err := g.SetKeybinding("files", 'p', gocui.ModNone, pullFiles); err != nil { + if err := g.SetKeybinding("", 'P', gocui.ModNone, pushFiles); err != nil { return err } - if err := g.SetKeybinding("files", 'P', gocui.ModNone, pushFiles); err != nil { + if err := g.SetKeybinding("", 'p', gocui.ModNone, pullFiles); err != nil { + return err + } + if err := g.SetKeybinding("files", 'i', gocui.ModNone, handleIgnoreFile); err != nil { return err } if err := g.SetKeybinding("commit", gocui.KeyEsc, gocui.ModNone, closeCommitPrompt); err != nil { @@ -177,19 +181,39 @@ func handleLogState(g *gocui.Gui, v *gocui.View) error { return nil } +func refreshStatus(g *gocui.Gui) error { + v, err := g.View("status") + if err != nil { + return err + } + up, down := gitUpstreamDifferenceCount() + devLog(up, down) + fmt.Fprint(v, "↑"+up+"↓"+down) + branches := state.Branches + if len(branches) == 0 { + return nil + } + branch := branches[0] + // utilising the fact these all have padding to only grab the name + // from the display string with the existing coloring applied + fmt.Fprint(v, " "+branch.DisplayString[4:]) + return nil +} + func layout(g *gocui.Gui) error { width, height := g.Size() leftSideWidth := width / 3 logsBranchesBoundary := height - 10 filesBranchesBoundary := height - 20 + statusFilesBoundary := 2 - optionsTop := height - 3 + optionsTop := height - 2 // hiding options if there's not enough space if height < 30 { - optionsTop = height + optionsTop = height - 1 } - sideView, err := g.SetView("files", 0, 0, leftSideWidth, filesBranchesBoundary-1) + sideView, err := g.SetView("files", 0, statusFilesBoundary+1, leftSideWidth, filesBranchesBoundary-1) if err != nil { if err != gocui.ErrUnknownView { return err @@ -199,7 +223,14 @@ func layout(g *gocui.Gui) error { refreshFiles(g) } - if v, err := g.SetView("main", leftSideWidth+1, 0, width-1, optionsTop-1); err != nil { + if v, err := g.SetView("status", 0, statusFilesBoundary-2, leftSideWidth, statusFilesBoundary); err != nil { + if err != gocui.ErrUnknownView { + return err + } + v.Title = "Status" + } + + if v, err := g.SetView("main", leftSideWidth+1, 0, width-1, optionsTop); err != nil { if err != gocui.ErrUnknownView { return err } @@ -209,16 +240,6 @@ func layout(g *gocui.Gui) error { handleFileSelect(g, sideView) } - if v, err := g.SetView("commits", 0, logsBranchesBoundary, leftSideWidth, optionsTop-1); err != nil { - if err != gocui.ErrUnknownView { - return err - } - v.Title = "Commits" - - // these are only called once - refreshCommits(g) - } - if v, err := g.SetView("branches", 0, filesBranchesBoundary, leftSideWidth, logsBranchesBoundary-1); err != nil { if err != gocui.ErrUnknownView { return err @@ -230,10 +251,22 @@ func layout(g *gocui.Gui) error { nextView(g, nil) } - if v, err := g.SetView("options", 0, optionsTop, width-1, optionsTop+2); err != nil { + if v, err := g.SetView("commits", 0, logsBranchesBoundary, leftSideWidth, optionsTop); err != nil { if err != gocui.ErrUnknownView { return err } + v.Title = "Commits" + + // these are only called once + refreshCommits(g) + } + + if v, err := g.SetView("options", -1, optionsTop, width, optionsTop+2); err != nil { + if err != gocui.ErrUnknownView { + return err + } + v.BgColor = gocui.ColorBlue + v.Frame = false v.Title = "Options" } diff --git a/main.go b/main.go index 36d35ff68..10272d56b 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,7 @@ package main import "github.com/fatih/color" func main() { + verifyInGitRepo() a, b := gitUpstreamDifferenceCount() colorLog(color.FgRed, a, b) devLog("\n\n\n\n\n\n\n\n\n\n")