diff --git a/gitcommands.go b/gitcommands.go index 94e71686d..465a61da1 100644 --- a/gitcommands.go +++ b/gitcommands.go @@ -5,92 +5,164 @@ package main import ( - "fmt" + // "log" + "fmt" "os/exec" - "os" "strings" - "regexp" - "runtime" ) +// GitFile : A staged/unstaged file +type GitFile struct { + Name string + DisplayString string + HasStagedChanges bool + HasUnstagedChanges bool + Tracked bool + Deleted bool +} + +// Branch : A git branch +type Branch struct { + Name string + DisplayString string + Type string + BaseBranch string +} + // Map (from https://gobyexample.com/collection-functions) func Map(vs []string, f func(string) string) []string { - vsm := make([]string, len(vs)) - for i, v := range vs { - vsm[i] = f(v) - } - return vsm + vsm := make([]string, len(vs)) + for i, v := range vs { + vsm[i] = f(v) + } + return vsm } -func sanitisedFileString(fileString string) string { - r := regexp.MustCompile("\\s| \\(new commits\\)|.* ") - fileString = r.ReplaceAllString(fileString, "") - return fileString -} - -func filesByMatches(statusString string, targets []string) []string { - files := make([]string, 0) - for _, target := range targets { - if strings.Index(statusString, target) == -1 { - continue - } - r := regexp.MustCompile("(?s)" + target + ".*?\n\n(.*?)\n\n") - // fmt.Println(r) - - matchedFileStrings := strings.Split(r.FindStringSubmatch(statusString)[1], "\n") - // fmt.Println(matchedFileStrings) - - matchedFiles := Map(matchedFileStrings, sanitisedFileString) - // fmt.Println(matchedFiles) - files = append(files, matchedFiles...) - +func mergeGitStatusFiles(oldGitFiles, newGitFiles []GitFile) []GitFile { + if len(oldGitFiles) == 0 { + return newGitFiles } - breakHere() - - // fmt.Println(files) - return files -} - -func breakHere() { - if len(os.Args) > 1 && os.Args[1] == "debug" { - runtime.Breakpoint() + result := make([]GitFile, 0) + for _, oldGitFile := range oldGitFiles { + for _, newGitFile := range newGitFiles { + if oldGitFile.Name == newGitFile.Name { + result = append(result, newGitFile) + break + } + } } + return result } -func getFilesToStage(statusString string) []string { - targets := []string{"Changes not staged for commit:", "Untracked files:"} - return filesByMatches(statusString, targets) +func getGitBranchOutput() (string, error) { + cmdOut, err := exec.Command("bash", "-c", getBranchesCommand).Output() + return string(cmdOut), err } -func getFilesToUnstage(statusString string) []string { - targets := []string{"Changes to be committed:"} - return filesByMatches(statusString, targets) +func branchNameFromString(branchString string) string { + // because this has the recency at the beginning, + // we need to split and take the second part + splitBranchName := strings.Split(branchString, "\t") + return splitBranchName[len(splitBranchName)-1] +} + +func getGitBranches() []Branch { + branches := make([]Branch, 0) + rawString, _ := getGitBranchOutput() + branchLines := splitLines(rawString) + for _, 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 + } + branches = append(branches, Branch{name, line, branchType, baseBranch}) + } + devLog(fmt.Sprint(branches)) + return branches +} + +func getGitStatusFiles() []GitFile { + statusOutput, _ := getGitStatus() + statusStrings := splitLines(statusOutput) + devLog(fmt.Sprint(statusStrings)) + // a file can have both staged and unstaged changes + // I'll probably end up ignoring the unstaged flag for now but might revisit + // tracked, staged, unstaged + + gitFiles := make([]GitFile, 0) + + for _, statusString := range statusStrings { + stagedChange := statusString[0:1] + unstagedChange := statusString[1:2] + filename := statusString[3:] + tracked := statusString[0:2] != "??" + gitFile := GitFile{ + Name: filename, + DisplayString: statusString, + HasStagedChanges: tracked && stagedChange != " ", + HasUnstagedChanges: !tracked || unstagedChange != " ", + Tracked: tracked, + Deleted: unstagedChange == "D" || stagedChange == "D", + } + gitFiles = append(gitFiles, gitFile) + } + return gitFiles +} + +func gitCheckout(branch string, force bool) error { + forceArg := "" + if force { + forceArg = "--force " + } + _, err := runCommand("git checkout " + forceArg + branch) + return err } func runCommand(cmd string) (string, error) { splitCmd := strings.Split(cmd, " ") cmdOut, err := exec.Command(splitCmd[0], splitCmd[1:]...).Output() + devLog(cmd) + devLog(string(cmdOut)) return string(cmdOut), err } -func getDiff(file string, cached bool) string { - devLog(file) +func getBranchDiff(branch string, baseBranch string) (string, error) { + return runCommand("git diff --color " + baseBranch + "..." + branch) +} + +func getDiff(file GitFile) string { cachedArg := "" - if cached { + if file.HasStagedChanges { cachedArg = "--cached " } - s, err := runCommand("git diff " + cachedArg + file) + deletedArg := "" + if file.Deleted || !file.Tracked { + deletedArg = "--no-index /dev/null " + } + command := "git diff --color " + cachedArg + deletedArg + file.Name + s, err := runCommand(command) if err != nil { // for now we assume an error means the file was deleted - return "deleted" + return s } return s } func stageFile(file string) error { - devLog("staging " + file) _, err := runCommand("git add " + file) return err } @@ -100,13 +172,34 @@ func unStageFile(file string) error { return err } -func testGettingFiles() { - - statusString, _ := runCommand("git status") - fmt.Println(getFilesToStage(statusString)) - fmt.Println(getFilesToUnstage(statusString)) - - runCommand("git add hello-world.go") +func getGitStatus() (string, error) { + return runCommand("git status --untracked-files=all --short") } +const getBranchesCommand = `set -e +git reflog -n100 --pretty='%cr|%gs' --grep-reflog='checkout: moving' HEAD | { + seen=":" + git_dir="$(git rev-parse --git-dir)" + while read line; do + date="${line%%|*}" + branch="${line##* }" + if ! [[ $seen == *:"${branch}":* ]]; then + seen="${seen}${branch}:" + if [ -f "${git_dir}/refs/heads/${branch}" ]; then + printf "%s\t%s\n" "$date" "$branch" + fi + fi + done | sed 's/ days /d /g' | sed 's/ weeks /w /g' | sed 's/ hours /h /g' | sed 's/ minutes /m /g' | sed 's/ago//g' | tr -d ' ' +} +` +// func main() { +// getGitStatusFiles() +// } + +// func devLog(s string) { +// f, _ := os.OpenFile("development.log", os.O_APPEND|os.O_WRONLY, 0644) +// defer f.Close() + +// f.WriteString(s + "\n") +// } diff --git a/gui.go b/gui.go index f58d13fdb..63566c737 100644 --- a/gui.go +++ b/gui.go @@ -1,3 +1,5 @@ +// lots of this has been directly ported from one of the example files, will brush up later + // Copyright 2014 The gocui Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. @@ -6,95 +8,213 @@ package main import ( "fmt" + "strings" // "io" // "io/ioutil" "log" // "strings" "os" - "github.com/jroimartin/gocui" + "github.com/fatih/color" + "github.com/jroimartin/gocui" ) -type gitFile struct { - Name string - Staged bool +type stateType struct { + GitFiles []GitFile + Branches []Branch } -var gitFiles []gitFile +var state = stateType{GitFiles: make([]GitFile, 0)} + +var cyclableViews = []string{"files", "branches"} func nextView(g *gocui.Gui, v *gocui.View) error { - if v == nil || v.Name() == "side" { - _, err := g.SetCurrentView("main") + var focusedViewName string + if v == nil || v.Name() == cyclableViews[len(cyclableViews)-1] { + focusedViewName = cyclableViews[0] + } else { + for i := range cyclableViews { + if v.Name() == cyclableViews[i] { + focusedViewName = cyclableViews[i+1] + break + } + if i == len(cyclableViews)-1 { + panic(v.Name() + " is not in the list of views") + } + } + } + focusedView, err := g.View(focusedViewName) + if err != nil { + panic(err) return err } - _, err := g.SetCurrentView("side") + if v != nil { + v.Highlight = false + } + focusedView.Highlight = true + devLog(focusedViewName) + _, err = g.SetCurrentView(focusedViewName) + itemSelected(g, focusedView) + showViewOptions(g, focusedViewName) return err } +func showViewOptions(g *gocui.Gui, viewName string) error { + optionsMap := map[string]string{ + "files": "space: toggle staged, c: commit changes", + "branches": "space: checkout", + } + g.Update(func(*gocui.Gui) error { + v, err := g.View("options") + if err != nil { + panic(err) + } + v.Clear() + fmt.Fprint(v, optionsMap[viewName]) + return nil + }) + return nil +} + +func getItemPosition(v *gocui.View) int { + _, cy := v.Cursor() + _, oy := v.Origin() + return oy + cy +} + +func cursorUp(g *gocui.Gui, v *gocui.View) error { + if v == nil { + return nil + } + + ox, oy := v.Origin() + cx, cy := v.Cursor() + if err := v.SetCursor(cx, cy-1); err != nil && oy > 0 { + if err := v.SetOrigin(ox, oy-1); err != nil { + return err + } + } + + itemSelected(g, v) + return nil +} + +func resetOrigin(v *gocui.View) error { + if err := v.SetCursor(0, 0); err != nil { + return err + } + return v.SetOrigin(0, 0) +} + func cursorDown(g *gocui.Gui, v *gocui.View) error { if v != nil { cx, cy := v.Cursor() + ox, oy := v.Origin() + if cy+oy >= len(v.BufferLines())-2 { + return nil + } if err := v.SetCursor(cx, cy+1); err != nil { - ox, oy := v.Origin() if err := v.SetOrigin(ox, oy+1); err != nil { return err } } } - // refresh main panel's text to match newly selected item - return handleItemSelect(g, v) + itemSelected(g, v) + return nil } -func cursorUp(g *gocui.Gui, v *gocui.View) error { - if v != nil { - ox, oy := v.Origin() - cx, cy := v.Cursor() - if err := v.SetCursor(cx, cy-1); err != nil && oy > 0 { - if err := v.SetOrigin(ox, oy-1); err != nil { - return err - } - } - } +func itemSelected(g *gocui.Gui, v *gocui.View) error { + mainView, _ := g.View("main") + mainView.SetOrigin(0, 0) - // refresh main panel's text to match newly selected item - return handleItemSelect(g, v) + switch v.Name() { + case "files": + return handleFileSelect(g, v) + case "branches": + return handleBranchSelect(g, v) + default: + panic("No view matching itemSelected switch statement") + } +} + +func scrollUp(g *gocui.Gui, v *gocui.View) error { + mainView, _ := g.View("main") + ox, oy := mainView.Origin() + if oy >= 1 { + return mainView.SetOrigin(ox, oy-1) + } + return nil +} + +func scrollDown(g *gocui.Gui, v *gocui.View) error { + mainView, _ := g.View("main") + ox, oy := mainView.Origin() + if oy < len(mainView.BufferLines()) { + return mainView.SetOrigin(ox, oy+1) + } + return nil } func devLog(s string) { - f, _ := os.OpenFile("development.log", os.O_APPEND|os.O_WRONLY, 0644) + f, _ := os.OpenFile("/Users/jesseduffieldduffield/go/src/github.com/jesseduffield/gitgot/development.log", os.O_APPEND|os.O_WRONLY, 0644) defer f.Close() f.WriteString(s + "\n") } -func handleItemPress(g *gocui.Gui, v *gocui.View) error { - item := getItem(v) +func handleBranchPress(g *gocui.Gui, v *gocui.View) error { + branch := getSelectedBranch(v) + if err := gitCheckout(branch.Name, false); err != nil { + return err + } + refreshBranches(v) + refreshFiles(g) + return nil +} - if item.Staged { - unStageFile(item.Name) +func handleFilePress(g *gocui.Gui, v *gocui.View) error { + file := getSelectedFile(v) + + if file.HasUnstagedChanges { + stageFile(file.Name) } else { - stageFile(item.Name) + unStageFile(file.Name) } - if err := refreshList(v); err != nil { + if err := refreshFiles(g); err != nil { + return err + } + if err := handleFileSelect(g, v); err != nil { + return err + } + + return nil +} + +func getSelectedFile(v *gocui.View) GitFile { + lineNumber := getItemPosition(v) + return state.GitFiles[lineNumber] +} + +func getSelectedBranch(v *gocui.View) Branch { + lineNumber := getItemPosition(v) + return state.Branches[lineNumber] +} + +func handleBranchSelect(g *gocui.Gui, v *gocui.View) error { + lineNumber := getItemPosition(v) + branch := state.Branches[lineNumber] + diff, _ := getBranchDiff(branch.Name, branch.BaseBranch) + if err := renderString(g, diff); err != nil { return err } return nil } -func getItem(v *gocui.View) gitFile { - _, lineNumber := v.Cursor() - if lineNumber >= len(gitFiles) { - return gitFiles[len(gitFiles) - 1] - } - return gitFiles[lineNumber] -} - -func handleItemSelect(g *gocui.Gui, v *gocui.View) error { - item := getItem(v) - diff := getDiff(item.Name, item.Staged) - devLog(diff) +func handleFileSelect(g *gocui.Gui, v *gocui.View) error { + item := getSelectedFile(v) + diff := getDiff(item) if err := renderString(g, diff); err != nil { return err } @@ -102,7 +222,7 @@ func handleItemSelect(g *gocui.Gui, v *gocui.View) error { // maxX, maxY := g.Size() // if v, err := g.SetView("msg", maxX/2-30, maxY/2, maxX/2+30, maxY/2+2); err != nil { // if err != gocui.ErrUnknownView { - // return err + // return errkjhgkhj // } // fmt.Fprintln(v, l) // if _, err := g.SetCurrentView("msg"); err != nil { @@ -116,7 +236,7 @@ func delMsg(g *gocui.Gui, v *gocui.View) error { if err := g.DeleteView("msg"); err != nil { return err } - if _, err := g.SetCurrentView("side"); err != nil { + if _, err := g.SetCurrentView("files"); err != nil { return err } return nil @@ -127,79 +247,154 @@ func quit(g *gocui.Gui, v *gocui.View) error { } func keybindings(g *gocui.Gui) error { - if err := g.SetKeybinding("side", gocui.KeyCtrlSpace, gocui.ModNone, nextView); err != nil { + for _, view := range cyclableViews { + if err := g.SetKeybinding(view, gocui.KeyTab, gocui.ModNone, nextView); err != nil { + return err + } + if err := g.SetKeybinding(view, 'q', gocui.ModNone, quit); err != nil { + return err + } + if err := g.SetKeybinding(view, gocui.KeyCtrlC, gocui.ModNone, quit); err != nil { + return err + } + if err := g.SetKeybinding(view, gocui.KeyArrowDown, gocui.ModNone, cursorDown); err != nil { + return err + } + if err := g.SetKeybinding(view, gocui.KeyArrowUp, gocui.ModNone, cursorUp); err != nil { + return err + } + if err := g.SetKeybinding(view, gocui.KeyPgup, gocui.ModNone, scrollUp); err != nil { + return err + } + if err := g.SetKeybinding(view, gocui.KeyPgdn, gocui.ModNone, scrollDown); err != nil { + return err + } + } + if err := g.SetKeybinding("files", gocui.KeySpace, gocui.ModNone, handleFilePress); err != nil { return err } - if err := g.SetKeybinding("main", gocui.KeyCtrlSpace, gocui.ModNone, nextView); err != nil { + if err := g.SetKeybinding("branches", gocui.KeySpace, gocui.ModNone, handleBranchPress); err != nil { return err } - if err := g.SetKeybinding("side", gocui.KeyArrowDown, gocui.ModNone, cursorDown); err != nil { - return err - } - if err := g.SetKeybinding("side", gocui.KeyArrowUp, gocui.ModNone, cursorUp); err != nil { - return err - } - if err := g.SetKeybinding("", gocui.KeyCtrlC, gocui.ModNone, quit); err != nil { - return err - } - if err := g.SetKeybinding("", gocui.KeyEsc, gocui.ModNone, quit); err != nil { - return err - } - if err := g.SetKeybinding("side", gocui.KeySpace, gocui.ModNone, handleItemPress); err != nil { - return err - } - // if err := g.SetKeybinding("msg", gocui.KeySpace, gocui.ModNone, delMsg); err != nil { - // return err - // } return nil } -func refreshList(v *gocui.View) error { - // get files to stage - statusString, _ := runCommand("git status") - filesToStage := getFilesToStage(statusString) - filesToUnstage := getFilesToUnstage(statusString) - // v.Highlight = true - // v.SelBgColor = gocui.ColorWhite - // v.SelFgColor = gocui.ColorBlack - v.Clear() - gitFiles = make([]gitFile, 0) +func splitLines(multilineString string) []string { + if multilineString == "" || multilineString == "\n" { + return make([]string, 0) + } + lines := strings.Split(multilineString, "\n") + if lines[len(lines)-1] == "" { + return lines[:len(lines)-1] + } + return lines +} + +func refreshBranches(v *gocui.View) error { + state.Branches = getGitBranches() + yellow := color.New(color.FgYellow) red := color.New(color.FgRed) - for _, file := range filesToStage { - gitFiles = append(gitFiles, gitFile{file, false}) - red.Fprintln(v, file) - } + white := color.New(color.FgWhite) green := color.New(color.FgGreen) - for _, file := range filesToUnstage { - gitFiles = append(gitFiles, gitFile{file, true}) - green.Fprintln(v, file) + + 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) + } + resetOrigin(v) + return nil +} + +func refreshFiles(g *gocui.Gui) error { + filesView, err := g.View("files") + if err != nil { + return err + } + + // get files to stage + gitFiles := getGitStatusFiles() + state.GitFiles = mergeGitStatusFiles(state.GitFiles, gitFiles) + + filesView.Clear() + red := color.New(color.FgRed) + green := color.New(color.FgGreen) + for _, gitFile := range state.GitFiles { + if !gitFile.Tracked { + red.Fprintln(filesView, gitFile.DisplayString) + continue + } + green.Fprint(filesView, gitFile.DisplayString[0:1]) + red.Fprint(filesView, gitFile.DisplayString[1:3]) + if gitFile.HasUnstagedChanges { + red.Fprintln(filesView, gitFile.Name) + } else { + green.Fprintln(filesView, gitFile.Name) + } } - devLog(fmt.Sprint(gitFiles)) return nil } func layout(g *gocui.Gui) error { maxX, maxY := g.Size() - sideView, err := g.SetView("side", -1, -1, 30, maxY) + leftSideWidth := maxX / 3 + filesBranchesBoundary := maxY - 10 + + optionsTop := maxY - 3 + // hiding options if there's not enough space + if maxY < 30 { + optionsTop = maxY + } + + sideView, err := g.SetView("files", 0, 0, leftSideWidth, filesBranchesBoundary-1) if err != nil { if err != gocui.ErrUnknownView { return err } + sideView.Highlight = true sideView.Title = "Files" - devLog("test") - refreshList(sideView) + refreshFiles(g) } - if v, err := g.SetView("main", 30, -1, maxX, maxY); err != nil { + if v, err := g.SetView("main", leftSideWidth+2, 0, maxX-1, optionsTop-1); err != nil { if err != gocui.ErrUnknownView { return err } - v.Editable = true + v.Title = "Diff" v.Wrap = true - if _, err := g.SetCurrentView("side"); err != nil { + if _, err := g.SetCurrentView("files"); err != nil { return err } - handleItemSelect(g, sideView) + handleFileSelect(g, sideView) + } + + if v, err := g.SetView("branches", 0, filesBranchesBoundary, leftSideWidth, optionsTop-1); err != nil { + if err != gocui.ErrUnknownView { + return err + } + v.Title = "Branches" + + // these are only called once + refreshBranches(v) + nextView(g, nil) + } + + if v, err := g.SetView("options", 0, optionsTop, maxX-1, optionsTop+2); err != nil { + if err != gocui.ErrUnknownView { + return err + } + v.Title = "Options" } return nil @@ -226,7 +421,7 @@ func run() { } defer g.Close() - g.Cursor = true + // g.Cursor = true g.SetManagerFunc(layout) diff --git a/testFile.txt b/testFile.txt new file mode 100644 index 000000000..16b14f5da --- /dev/null +++ b/testFile.txt @@ -0,0 +1 @@ +test file