diff --git a/.gitignore b/.gitignore index 1032e298f..bb150c3dc 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ development.log +commands.log +extra/lgit.rb +notes/go.notes diff --git a/anothertestfile b/anothertestfile new file mode 100644 index 000000000..d6b7cdfc5 --- /dev/null +++ b/anothertestfile @@ -0,0 +1 @@ +test stuff diff --git a/branches_panel.go b/branches_panel.go new file mode 100644 index 000000000..96071efd3 --- /dev/null +++ b/branches_panel.go @@ -0,0 +1,77 @@ +package main + +import ( + "fmt" + + "github.com/jroimartin/gocui" +) + +func handleBranchPress(g *gocui.Gui, v *gocui.View) error { + branch := getSelectedBranch(v) + if output, err := gitCheckout(branch.Name, false); err != nil { + createSimpleConfirmationPanel(g, v, "Error", output) + } + return refreshSidePanels(g, v) +} + +func handleForceCheckout(g *gocui.Gui, v *gocui.View) error { + branch := getSelectedBranch(v) + return createConfirmationPanel(g, v, "Force Checkout Branch", "Are you sure you want force checkout? You will lose all local changes (y/n)", func(g *gocui.Gui, v *gocui.View) error { + if output, err := gitCheckout(branch.Name, true); err != nil { + createSimpleConfirmationPanel(g, v, "Error", output) + } + return refreshSidePanels(g, v) + }, nil) +} + +func handleNewBranch(g *gocui.Gui, v *gocui.View) error { + branch := state.Branches[0] + createPromptPanel(g, v, "New Branch Name (Branch is off of "+branch.Name+")", func(g *gocui.Gui, v *gocui.View) error { + // TODO: make sure the buffer is stripped of whitespace + if output, err := gitNewBranch(v.Buffer()); err != nil { + return createSimpleConfirmationPanel(g, v, "Error", output) + } + refreshSidePanels(g, v) + return handleCommitSelect(g, v) + }) + return nil +} + +func getSelectedBranch(v *gocui.View) Branch { + lineNumber := getItemPosition(v) + return state.Branches[lineNumber] +} + +// may want to standardise how these select methods work +func handleBranchSelect(g *gocui.Gui, v *gocui.View) error { + renderString(g, "options", "space: checkout, f: force checkout") + if len(state.Branches) == 0 { + return renderString(g, "main", "No branches for this repo") + } + go func() { + lineNumber := getItemPosition(v) + branch := state.Branches[lineNumber] + diff, _ := getBranchDiff(branch.Name, branch.BaseBranch) + renderString(g, "main", diff) + }() + 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 { + g.Update(func(g *gocui.Gui) error { + v, err := g.View("branches") + if err != nil { + panic(err) + } + state.Branches = getGitBranches() + v.Clear() + for _, branch := range state.Branches { + fmt.Fprintln(v, branch.DisplayString) + } + resetOrigin(v) + return refreshStatus(g) + }) + return nil +} diff --git a/commit_panel.go b/commit_panel.go new file mode 100644 index 000000000..a3d5d9b68 --- /dev/null +++ b/commit_panel.go @@ -0,0 +1,59 @@ +package main + +import ( + "fmt" + + "github.com/jroimartin/gocui" +) + +func handleCommitPress(g *gocui.Gui, currentView *gocui.View) error { + if len(stagedFiles(state.GitFiles)) == 0 { + return createSimpleConfirmationPanel(g, currentView, "Nothing to Commit", "There are no staged files to commit (esc)") + } + maxX, maxY := g.Size() + if v, err := g.SetView("commit", maxX/2-30, maxY/2-1, maxX/2+30, maxY/2+1); err != nil { + if err != gocui.ErrUnknownView { + return err + } + v.Title = "Commit Message" + v.Editable = true + if _, err := g.SetCurrentView("commit"); err != nil { + return err + } + switchFocus(g, currentView, v) + } + return nil +} + +func handleCommitSubmit(g *gocui.Gui, v *gocui.View) error { + if len(v.BufferLines()) == 0 { + return closeCommitPrompt(g, v) + } + message := fmt.Sprint(v.BufferLines()[0]) + // for whatever reason, a successful commit returns an error, so we're not + // going to check for an error here + if err := gitCommit(message); err != nil { + panic(err) + } + refreshFiles(g) + refreshCommits(g) + return closeCommitPrompt(g, v) +} + +func closeCommitPrompt(g *gocui.Gui, v *gocui.View) error { + filesView, _ := g.View("files") + // 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) + if err := g.DeleteView("commit"); err != nil { + return err + } + if _, err := g.SetCurrentView(state.PreviousView); err != nil { + return err + } + return nil +} + +func handleCommitPromptFocus(g *gocui.Gui, v *gocui.View) error { + return renderString(g, "options", "esc: close, enter: commit") +} diff --git a/commits_panel.go b/commits_panel.go new file mode 100644 index 000000000..1df9e2ad9 --- /dev/null +++ b/commits_panel.go @@ -0,0 +1,127 @@ +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 { + g.Update(func(*gocui.Gui) error { + state.Commits = getCommits() + v, err := g.View("commits") + if err != nil { + 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 { + if commit.Pushed { + shaColor = red + } else { + shaColor = yellow + } + shaColor.Fprint(v, commit.Sha+" ") + white.Fprintln(v, commit.Name) + } + refreshStatus(g) + return nil + }) + return nil +} + +func handleResetToCommit(g *gocui.Gui, commitView *gocui.View) error { + return createConfirmationPanel(g, commitView, "Reset To Commit", "Are you sure you want to reset to this commit? (y/n)", func(g *gocui.Gui, v *gocui.View) error { + commit, err := getSelectedCommit(g) + devLog(commit) + if err != nil { + panic(err) + } + if output, err := gitResetToCommit(commit.Sha); err != nil { + return createSimpleConfirmationPanel(g, commitView, "Error", output) + } + if err := refreshCommits(g); err != nil { + panic(err) + } + if err := refreshFiles(g); err != nil { + panic(err) + } + resetOrigin(commitView) + return handleCommitSelect(g, nil) + }, nil) +} + +func handleCommitSelect(g *gocui.Gui, v *gocui.View) error { + renderString(g, "options", "s: squash down, r: rename, g: reset to this commit") + commit, err := getSelectedCommit(g) + if err != nil { + if err != ErrNoCommits { + return err + } + return renderString(g, "main", "No commits for this branch") + } + commitText := gitShow(commit.Sha) + return renderString(g, "main", commitText) +} + +func handleCommitSquashDown(g *gocui.Gui, v *gocui.View) error { + if getItemPosition(v) != 0 { + return createSimpleConfirmationPanel(g, v, "Error", "Can only squash topmost commit") + } + if len(state.Commits) == 1 { + return createSimpleConfirmationPanel(g, v, "Error", "You have no commits to squash with") + } + commit, err := getSelectedCommit(g) + if err != nil { + return err + } + if output, err := gitSquashPreviousTwoCommits(commit.Name); err != nil { + return createSimpleConfirmationPanel(g, v, "Error", output) + } + if err := refreshCommits(g); err != nil { + panic(err) + } + refreshStatus(g) + return handleCommitSelect(g, v) +} + +func handleRenameCommit(g *gocui.Gui, v *gocui.View) error { + if getItemPosition(v) != 0 { + return createSimpleConfirmationPanel(g, v, "Error", "Can only rename topmost commit") + } + createPromptPanel(g, v, "Rename Commit", func(g *gocui.Gui, v *gocui.View) error { + if output, err := gitRenameCommit(v.Buffer()); err != nil { + return createSimpleConfirmationPanel(g, v, "Error", output) + } + if err := refreshCommits(g); err != nil { + panic(err) + } + return handleCommitSelect(g, v) + }) + return nil +} + +func getSelectedCommit(g *gocui.Gui) (Commit, error) { + v, err := g.View("commits") + if err != nil { + panic(err) + } + if len(state.Commits) == 0 { + return Commit{}, ErrNoCommits + } + lineNumber := getItemPosition(v) + if lineNumber > len(state.Commits)-1 { + colorLog(color.FgRed, "potential error in getSelected Commit (mismatched ui and state)", state.Commits, lineNumber) + return state.Commits[len(state.Commits)-1], nil + } + return state.Commits[lineNumber], nil +} diff --git a/confirmation_panel.go b/confirmation_panel.go new file mode 100644 index 000000000..3db9d4f75 --- /dev/null +++ b/confirmation_panel.go @@ -0,0 +1,122 @@ +// 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. + +package main + +import ( + + // "io" + // "io/ioutil" + + "strings" + // "strings" + + "github.com/jroimartin/gocui" +) + +func wrappedConfirmationFunction(function func(*gocui.Gui, *gocui.View) error) func(*gocui.Gui, *gocui.View) error { + return func(g *gocui.Gui, v *gocui.View) error { + if function != nil { + if err := function(g, v); err != nil { + panic(err) + } + } + return closeConfirmationPrompt(g) + } +} + +func closeConfirmationPrompt(g *gocui.Gui) error { + view, err := g.View("confirmation") + if err != nil { + panic(err) + } + if err := returnFocus(g, view); err != nil { + panic(err) + } + g.DeleteKeybindings("confirmation") + return g.DeleteView("confirmation") +} + +func getMessageHeight(message string, width int) int { + lines := strings.Split(message, "\n") + lineCount := 0 + for _, line := range lines { + lineCount += len(line)/width + 1 + } + return lineCount +} + +func getConfirmationPanelDimensions(g *gocui.Gui, prompt string) (int, int, int, int) { + width, height := g.Size() + panelWidth := 60 + // panelHeight := int(math.Ceil(float64(len(prompt)) / float64(panelWidth))) + panelHeight := getMessageHeight(prompt, panelWidth) + return width/2 - panelWidth/2, + height/2 - panelHeight/2 - panelHeight%2 - 1, + width/2 + panelWidth/2, + height/2 + panelHeight/2 +} + +func createPromptPanel(g *gocui.Gui, v *gocui.View, title string, handleSubmit func(*gocui.Gui, *gocui.View) error) error { + // only need to fit one line + x0, y0, x1, y1 := getConfirmationPanelDimensions(g, "") + if confirmationView, err := g.SetView("confirmation", x0, y0, x1, y1); err != nil { + if err != gocui.ErrUnknownView { + return err + } + confirmationView.Editable = true + g.Cursor = true + confirmationView.Title = title + switchFocus(g, v, confirmationView) + if err := g.SetKeybinding("confirmation", gocui.KeyEnter, gocui.ModNone, wrappedConfirmationFunction(handleSubmit)); err != nil { + return err + } + if err := g.SetKeybinding("confirmation", gocui.KeyEsc, gocui.ModNone, wrappedConfirmationFunction(nil)); err != nil { + return err + } + } + return nil +} + +func createConfirmationPanel(g *gocui.Gui, v *gocui.View, title, prompt string, handleYes, handleNo func(*gocui.Gui, *gocui.View) error) error { + // delete the existing confirmation panel if it exists + if view, _ := g.View("confirmation"); view != nil { + if err := closeConfirmationPrompt(g); err != nil { + panic(err) + } + } + x0, y0, x1, y1 := getConfirmationPanelDimensions(g, prompt) + if confirmationView, err := g.SetView("confirmation", x0, y0, x1, y1); err != nil { + if err != gocui.ErrUnknownView { + return err + } + confirmationView.Title = title + renderString(g, "confirmation", prompt) + switchFocus(g, v, confirmationView) + if err := g.SetKeybinding("confirmation", 'n', gocui.ModNone, wrappedConfirmationFunction(handleNo)); err != nil { + return err + } + if err := g.SetKeybinding("confirmation", gocui.KeyEsc, gocui.ModNone, wrappedConfirmationFunction(handleNo)); err != nil { + return err + } + if err := g.SetKeybinding("confirmation", 'y', gocui.ModNone, wrappedConfirmationFunction(handleYes)); err != nil { + return err + } + if err := g.SetKeybinding("confirmation", gocui.KeyEnter, gocui.ModNone, wrappedConfirmationFunction(handleYes)); err != nil { + return err + } + } + return nil +} + +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 new file mode 100644 index 000000000..40beda2f0 --- /dev/null +++ b/files_panel.go @@ -0,0 +1,193 @@ +package main + +import ( + + // "io" + // "io/ioutil" + + // "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 { + if file.HasStagedChanges { + result = append(result, file) + } + } + return result +} + +func handleFilePress(g *gocui.Gui, v *gocui.View) error { + file, err := getSelectedFile(v) + if err != nil { + return err + } + + if file.HasUnstagedChanges { + stageFile(file.Name) + } else { + unStageFile(file.Name) + } + + 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, error) { + if len(state.GitFiles) == 0 { + return GitFile{}, ErrNoFiles + } + lineNumber := getItemPosition(v) + return state.GitFiles[lineNumber], nil +} + +func handleFileRemove(g *gocui.Gui, v *gocui.View) error { + file, err := getSelectedFile(v) + if err != nil { + return err + } + var deleteVerb string + if file.Tracked { + deleteVerb = "checkout" + } else { + deleteVerb = "delete" + } + return createConfirmationPanel(g, v, strings.Title(deleteVerb)+" file", "Are you sure you want to "+deleteVerb+" "+file.Name+" (you will lose your changes)? (y/n)", func(g *gocui.Gui, v *gocui.View) error { + if err := removeFile(file); err != nil { + panic(err) + } + return refreshFiles(g) + }, 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 { + 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 + if item.Tracked { + optionsString = baseString + ", r: checkout" + } else { + optionsString = baseString + ", r: delete" + } + renderString(g, "options", optionsString) + diff := getDiff(item) + return renderString(g, "main", diff) +} + +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 { + return genericFileOpen(g, v, sublimeOpenFile) +} + +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) + } + } + correctCursor(filesView) + handleFileSelect(g, filesView) + return nil +} + +func pullFiles(g *gocui.Gui, v *gocui.View) error { + devLog("pulling...") + createSimpleConfirmationPanel(g, v, "", "Pulling...") + go func() { + if output, err := gitPull(); err != nil { + createSimpleConfirmationPanel(g, v, "Error", output) + } else { + closeConfirmationPrompt(g) + refreshCommits(g) + refreshFiles(g) + refreshStatus(g) + devLog("pulled.") + } + }() + return nil +} + +func pushFiles(g *gocui.Gui, v *gocui.View) error { + devLog("pushing...") + createSimpleConfirmationPanel(g, v, "", "Pushing...") + go func() { + if output, err := gitPush(); err != nil { + createSimpleConfirmationPanel(g, v, "Error", output) + } else { + closeConfirmationPrompt(g) + refreshCommits(g) + refreshStatus(g) + devLog("pushed.") + } + }() + return nil +} diff --git a/gitcommands.go b/gitcommands.go index 94e71686d..0a7e9c5a5 100644 --- a/gitcommands.go +++ b/gitcommands.go @@ -1,96 +1,317 @@ -// Go has various value types including strings, -// integers, floats, booleans, etc. Here are a few -// basic examples. - package main import ( - "fmt" + // "log" - "os/exec" + "fmt" "os" + "os/exec" "strings" - "regexp" - "runtime" + "time" + + "github.com/fatih/color" ) +// GitFile : A staged/unstaged file +type GitFile struct { + Name string + HasStagedChanges bool + HasUnstagedChanges bool + Tracked bool + Deleted bool + DisplayString string +} + +// Branch : A git branch +type Branch struct { + Name 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 +} + +func devLog(objects ...interface{}) { + localLog(color.FgWhite, "/Users/jesseduffieldduffield/go/src/github.com/jesseduffield/gitgot/development.log", objects...) +} + +func colorLog(colour color.Attribute, objects ...interface{}) { + localLog(colour, "/Users/jesseduffieldduffield/go/src/github.com/jesseduffield/gitgot/development.log", objects...) +} + +func commandLog(objects ...interface{}) { + localLog(color.FgWhite, "/Users/jesseduffieldduffield/go/src/github.com/jesseduffield/gitgot/commands.log", objects...) + // localLog(color.FgWhite, "/Users/jesseduffieldduffield/go/src/github.com/jesseduffield/gitgot/development.log", objects...) +} + +func localLog(colour color.Attribute, path string, objects ...interface{}) { + f, _ := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644) + defer f.Close() + for _, object := range objects { + colorFunction := color.New(colour).SprintFunc() + f.WriteString(colorFunction(fmt.Sprint(object)) + "\n") + } +} + // 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 includesString(list []string, a string) bool { + for _, b := range list { + if b == a { + return true + } + } + return false } -func filesByMatches(statusString string, targets []string) []string { - files := make([]string, 0) - for _, target := range targets { - if strings.Index(statusString, target) == -1 { - continue +// not sure how to genericise this because []interface{} doesn't accept e.g. +// []int arguments +func includesInt(list []int, a int) bool { + for _, b := range list { + if b == a { + return true } - 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...) + } + return false +} +func mergeGitStatusFiles(oldGitFiles, newGitFiles []GitFile) []GitFile { + if len(oldGitFiles) == 0 { + return newGitFiles } - breakHere() + appendedIndexes := make([]int, 0) - // fmt.Println(files) - return files -} - -func breakHere() { - if len(os.Args) > 1 && os.Args[1] == "debug" { - runtime.Breakpoint() + // retain position of files we already could see + result := make([]GitFile, 0) + for _, oldGitFile := range oldGitFiles { + for newIndex, newGitFile := range newGitFiles { + if oldGitFile.Name == newGitFile.Name { + result = append(result, newGitFile) + appendedIndexes = append(appendedIndexes, newIndex) + break + } + } } + + // append any new files to the end + for index, newGitFile := range newGitFiles { + if !includesInt(appendedIndexes, index) { + result = append(result, newGitFile) + } + } + + return result } -func getFilesToStage(statusString string) []string { - targets := []string{"Changes not staged for commit:", "Untracked files:"} - return filesByMatches(statusString, targets) -} +func runDirectCommand(command string) (string, error) { + timeStart := time.Now() -func getFilesToUnstage(statusString string) []string { - targets := []string{"Changes to be committed:"} - return filesByMatches(statusString, targets) -} + commandLog(command) + cmdOut, err := exec.Command("bash", "-c", command).CombinedOutput() + devLog("run direct command time for command: ", command, time.Now().Sub(timeStart)) -func runCommand(cmd string) (string, error) { - splitCmd := strings.Split(cmd, " ") - cmdOut, err := exec.Command(splitCmd[0], splitCmd[1:]...).Output() return string(cmdOut), err } -func getDiff(file string, cached bool) string { - devLog(file) +func branchStringParts(branchString string) (string, string) { + splitBranchName := strings.Split(branchString, "\t") + 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 { + branches = append(branches, branchFromLine(line, i)) + } + return branches +} + +func getGitStatusFiles() []GitFile { + statusOutput, _ := getGitStatus() + statusStrings := splitLines(statusOutput) + 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) (string, error) { + forceArg := "" + if force { + forceArg = "--force " + } + return runCommand("git checkout " + forceArg + branch) +} + +func runCommand(command string) (string, error) { + startTime := time.Now() + commandLog(command) + splitCmd := strings.Split(command, " ") + cmdOut, err := exec.Command(splitCmd[0], splitCmd[1:]...).CombinedOutput() + devLog("run command time: ", time.Now().Sub(startTime)) + return string(cmdOut), err +} + +func openFile(filename string) (string, error) { + return runCommand("open " + filename) +} + +func sublimeOpenFile(filename string) (string, error) { + return runCommand("subl " + filename) +} + +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, " ") + sha := splitLine[0] + pushed := includesString(pushables, sha) + commits = append(commits, Commit{ + Sha: sha, + Name: strings.Join(splitLine[1:], " "), + Pushed: pushed, + DisplayString: strings.Join(splitLine, " "), + }) + } + return commits +} + +func getLog() string { + // currently limiting to 30 for performance reasons + // TODO: add lazyloading when you scroll down + result, err := runDirectCommand("git log --oneline -30") + if err != nil { + // 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) + if err != nil { + panic(err) + } + return result +} + +func getDiff(file GitFile) string { cachedArg := "" - if cached { + if file.HasStagedChanges { cachedArg = "--cached " } - s, err := runCommand("git diff " + cachedArg + file) + deletedArg := "" + if file.Deleted { + deletedArg = "-- " + } + trackedArg := "" + if !file.Tracked { + trackedArg = "--no-index /dev/null " + } + command := "git diff --color " + cachedArg + deletedArg + trackedArg + 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 +321,107 @@ 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") } +func removeFile(file GitFile) error { + // if the file isn't tracked, we assume you want to delete it + if !file.Tracked { + _, err := runCommand("rm -rf ./" + file.Name) + return err + } + // if the file is tracked, we assume you want to just check it out + _, err := runCommand("git checkout " + file.Name) + return err +} +func gitCommit(message string) error { + _, err := runDirectCommand("git commit -m \"" + message + "\"") + return err +} + +func gitPull() (string, error) { + return runDirectCommand("git pull --no-edit") +} + +func gitPush() (string, error) { + return runDirectCommand("git push -u") +} + +func gitSquashPreviousTwoCommits(message string) (string, error) { + return runDirectCommand("git reset --soft head^ && git commit --amend -m \"" + message + "\"") +} + +func gitRenameCommit(message string) (string, error) { + return runDirectCommand("git commit --allow-empty --amend -m \"" + message + "\"") +} + +func gitFetch() (string, error) { + return runDirectCommand("git fetch") +} + +func gitResetToCommit(sha string) (string, error) { + return runDirectCommand("git reset " + sha) +} + +func gitNewBranch(name string) (string, error) { + return runDirectCommand("git checkout -b " + name) +} + +func gitUpstreamDifferenceCount() (string, string) { + pushableCount, err := runDirectCommand("git rev-list @{u}..head --count") + if err != nil { + return "?", "?" + } + pullableCount, err := runDirectCommand("git rev-list head..@{u} --count") + if err != nil { + return "?", "?" + } + return strings.Trim(pushableCount, " \n"), strings.Trim(pullableCount, " \n") +} + +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 +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/ seconds /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..136334656 100644 --- a/gui.go +++ b/gui.go @@ -1,222 +1,260 @@ -// 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. - package main import ( - "fmt" + // "io" // "io/ioutil" + "log" + "time" // "strings" - "os" + "github.com/jroimartin/gocui" - "github.com/fatih/color" ) -type gitFile struct { - Name string - Staged bool +type stateType struct { + GitFiles []GitFile + Branches []Branch + Commits []Commit + PreviousView string } -var gitFiles []gitFile +var state = stateType{ + GitFiles: make([]GitFile, 0), + PreviousView: "files", + Commits: make([]Commit, 0), +} + +var cyclableViews = []string{"files", "branches", "commits"} + +func refreshSidePanels(g *gocui.Gui, v *gocui.View) error { + refreshBranches(g) + refreshFiles(g) + refreshCommits(g) + return nil +} func nextView(g *gocui.Gui, v *gocui.View) error { - if v == nil || v.Name() == "side" { - _, err := g.SetCurrentView("main") - return err - } - _, err := g.SetCurrentView("side") - return err -} - -func cursorDown(g *gocui.Gui, v *gocui.View) error { - if v != nil { - cx, cy := v.Cursor() - 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) -} - -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 - } - } - } - - // refresh main panel's text to match newly selected item - return handleItemSelect(g, v) -} - -func devLog(s string) { - f, _ := os.OpenFile("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) - - if item.Staged { - unStageFile(item.Name) + var focusedViewName string + if v == nil || v.Name() == cyclableViews[len(cyclableViews)-1] { + focusedViewName = cyclableViews[0] } else { - stageFile(item.Name) + for i := range cyclableViews { + if v.Name() == cyclableViews[i] { + focusedViewName = cyclableViews[i+1] + break + } + if i == len(cyclableViews)-1 { + devLog(v.Name() + " is not in the list of views") + return nil + } + } } - - if err := refreshList(v); err != nil { + focusedView, err := g.View(focusedViewName) + if err != nil { + panic(err) return err } + return switchFocus(g, v, focusedView) +} + +func newLineFocused(g *gocui.Gui, v *gocui.View) error { + mainView, _ := g.View("main") + mainView.SetOrigin(0, 0) + + switch v.Name() { + case "files": + return handleFileSelect(g, v) + case "branches": + return handleBranchSelect(g, v) + case "commit": + return handleCommitPromptFocus(g, v) + case "confirmation": + return nil + case "main": + return nil + case "commits": + return handleCommitSelect(g, v) + default: + panic("No view matching newLineFocused switch statement") + } +} + +func scrollUpMain(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 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) - if err := renderString(g, diff); err != nil { - return err - } - - // 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 - // } - // fmt.Fprintln(v, l) - // if _, err := g.SetCurrentView("msg"); err != nil { - // return err - // } - // } - return nil -} - -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 { - return err +func scrollDownMain(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 quit(g *gocui.Gui, v *gocui.View) error { - return gocui.ErrQuit -} - func keybindings(g *gocui.Gui) error { - if err := g.SetKeybinding("side", gocui.KeyCtrlSpace, gocui.ModNone, nextView); err != nil { + if err := g.SetKeybinding("", gocui.KeyTab, gocui.ModNone, nextView); err != nil { return err } - if err := g.SetKeybinding("main", gocui.KeyCtrlSpace, gocui.ModNone, nextView); 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 { + if err := g.SetKeybinding("", 'q', gocui.ModNone, quit); 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 { + if err := g.SetKeybinding("", gocui.KeyArrowDown, gocui.ModNone, cursorDown); err != nil { return err } - if err := g.SetKeybinding("side", gocui.KeySpace, gocui.ModNone, handleItemPress); err != nil { + if err := g.SetKeybinding("", gocui.KeyArrowUp, gocui.ModNone, cursorUp); err != nil { + return err + } + if err := g.SetKeybinding("", gocui.KeyPgup, gocui.ModNone, scrollUpMain); err != nil { + return err + } + if err := g.SetKeybinding("", gocui.KeyPgdn, gocui.ModNone, scrollDownMain); err != nil { + return err + } + if err := g.SetKeybinding("files", 'c', gocui.ModNone, handleCommitPress); err != nil { + return err + } + if err := g.SetKeybinding("files", gocui.KeySpace, gocui.ModNone, handleFilePress); err != nil { + return err + } + if err := g.SetKeybinding("files", 'r', gocui.ModNone, handleFileRemove); err != nil { + return err + } + if err := g.SetKeybinding("files", 'o', gocui.ModNone, handleFileOpen); err != nil { + return err + } + if err := g.SetKeybinding("files", 's', gocui.ModNone, handleSublimeFileOpen); err != nil { + return err + } + if err := g.SetKeybinding("", 'P', gocui.ModNone, pushFiles); err != nil { + return err + } + 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 { + return err + } + if err := g.SetKeybinding("commit", gocui.KeyEnter, gocui.ModNone, handleCommitSubmit); err != nil { + return err + } + if err := g.SetKeybinding("branches", gocui.KeySpace, gocui.ModNone, handleBranchPress); err != nil { + return err + } + if err := g.SetKeybinding("branches", 'F', gocui.ModNone, handleForceCheckout); err != nil { + return err + } + if err := g.SetKeybinding("branches", 'n', gocui.ModNone, handleNewBranch); err != nil { + return err + } + if err := g.SetKeybinding("commits", 's', gocui.ModNone, handleCommitSquashDown); err != nil { + return err + } + if err := g.SetKeybinding("commits", 'r', gocui.ModNone, handleRenameCommit); err != nil { + return err + } + if err := g.SetKeybinding("commits", 'g', gocui.ModNone, handleResetToCommit); err != nil { + return err + } + if err := g.SetKeybinding("", 'S', gocui.ModNone, genericTest); 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) - red := color.New(color.FgRed) - for _, file := range filesToStage { - gitFiles = append(gitFiles, gitFile{file, false}) - red.Fprintln(v, file) - } - green := color.New(color.FgGreen) - for _, file := range filesToUnstage { - gitFiles = append(gitFiles, gitFile{file, true}) - green.Fprintln(v, file) - } - devLog(fmt.Sprint(gitFiles)) +func genericTest(g *gocui.Gui, v *gocui.View) error { + pushFiles(g, v) return nil } func layout(g *gocui.Gui) error { - maxX, maxY := g.Size() - sideView, err := g.SetView("side", -1, -1, 30, maxY) + width, height := g.Size() + leftSideWidth := width / 3 + logsBranchesBoundary := height - 10 + filesBranchesBoundary := height - 20 + statusFilesBoundary := 2 + + optionsTop := height - 2 + // hiding options if there's not enough space + if height < 30 { + optionsTop = height - 1 + } + + sideView, err := g.SetView("files", 0, statusFilesBoundary+1, 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("status", 0, statusFilesBoundary-2, leftSideWidth, statusFilesBoundary); err != nil { if err != gocui.ErrUnknownView { return err } - v.Editable = true - v.Wrap = true - if _, err := g.SetCurrentView("side"); err != nil { + v.Title = "Status" + } + + if v, err := g.SetView("main", leftSideWidth+1, 0, width-1, optionsTop); err != nil { + if err != gocui.ErrUnknownView { return err } - handleItemSelect(g, sideView) + v.Title = "Diff" + v.Wrap = true + switchFocus(g, nil, v) + handleFileSelect(g, sideView) + } + + if v, err := g.SetView("branches", 0, filesBranchesBoundary, leftSideWidth, logsBranchesBoundary-1); err != nil { + if err != gocui.ErrUnknownView { + return err + } + v.Title = "Branches" + + // these are only called once + refreshBranches(g) + nextView(g, 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" } return nil } -func renderString(g *gocui.Gui, s string) error { - g.Update(func(*gocui.Gui) error { - v, err := g.View("main") - if err != nil { - panic(err) - } - v.Clear() - fmt.Fprint(v, s) - v.Wrap = true - return nil - }) - return nil +func fetch(g *gocui.Gui) { + gitFetch() + refreshStatus(g) } func run() { @@ -226,7 +264,12 @@ func run() { } defer g.Close() - g.Cursor = true + // periodically fetching to check for upstream differences + go func() { + for range time.Tick(time.Second * 60) { + fetch(g) + } + }() g.SetManagerFunc(layout) @@ -239,6 +282,10 @@ func run() { } } +func quit(g *gocui.Gui, v *gocui.View) error { + return gocui.ErrQuit +} + // const mcRide = " // `.-::-` // -/o+oossys+:. diff --git a/main.go b/main.go index a83209ef1..66f9686b3 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,15 @@ package main +import ( + "time" +) + +// StartTime : The starting time of the app +var StartTime time.Time + func main() { + devLog("\n\n\n\n\n\n\n\n\n\n") + StartTime = time.Now() + verifyInGitRepo() run() } - - diff --git a/status_panel.go b/status_panel.go new file mode 100644 index 000000000..1a36c486b --- /dev/null +++ b/status_panel.go @@ -0,0 +1,36 @@ +package main + +import ( + "fmt" + "time" + + "github.com/fatih/color" + "github.com/jroimartin/gocui" +) + +func refreshStatus(g *gocui.Gui) error { + v, err := g.View("status") + if err != nil { + panic(err) + } + // for some reason if this isn't wrapped in an update the clear seems to + // be applied after the other things or something like that; the panel's + // contents end up cleared + g.Update(func(*gocui.Gui) error { + v.Clear() + pushables, pullables := gitUpstreamDifferenceCount() + fmt.Fprint(v, "↑"+pushables+"↓"+pullables) + 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:]) + colorLog(color.FgCyan, time.Now().Sub(StartTime)) + return nil + }) + + return nil +} diff --git a/test b/test new file mode 100644 index 000000000..c1a7bd5f1 --- /dev/null +++ b/test @@ -0,0 +1 @@ +blah blah blah diff --git a/testFile.txt b/testFile.txt new file mode 100644 index 000000000..6b9d30ab1 --- /dev/null +++ b/testFile.txt @@ -0,0 +1,4 @@ +test file +hmm +hmm +hmm diff --git a/testFile2.txt b/testFile2.txt new file mode 100644 index 000000000..1cf5b232c --- /dev/null +++ b/testFile2.txt @@ -0,0 +1 @@ +hmm diff --git a/testFile3.txt b/testFile3.txt new file mode 100644 index 000000000..ffb448706 --- /dev/null +++ b/testFile3.txt @@ -0,0 +1,3 @@ +hmm +hmm +hmm diff --git a/testFile4.txt b/testFile4.txt new file mode 100644 index 000000000..ffb448706 --- /dev/null +++ b/testFile4.txt @@ -0,0 +1,3 @@ +hmm +hmm +hmm diff --git a/testfile b/testfile new file mode 100644 index 000000000..e69de29bb diff --git a/view_helpers.go b/view_helpers.go new file mode 100644 index 000000000..4aa3dd5dd --- /dev/null +++ b/view_helpers.go @@ -0,0 +1,118 @@ +package main + +import ( + "fmt" + "strings" + "time" + + "github.com/jroimartin/gocui" +) + +func returnFocus(g *gocui.Gui, v *gocui.View) error { + previousView, err := g.View(state.PreviousView) + if err != nil { + panic(err) + } + return switchFocus(g, v, previousView) +} + +func switchFocus(g *gocui.Gui, oldView, newView *gocui.View) error { + if oldView != nil { + oldView.Highlight = false + devLog("setting previous view to:", oldView.Name()) + state.PreviousView = oldView.Name() + } + newView.Highlight = true + devLog(newView.Name()) + if _, err := g.SetCurrentView(newView.Name()); err != nil { + return err + } + g.Cursor = newView.Editable + return newLineFocused(g, newView) +} + +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 + } + } + + newLineFocused(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 { + if err := v.SetOrigin(ox, oy+1); err != nil { + return err + } + } + } + + newLineFocused(g, v) + return nil +} + +// if the cursor down past the last item, move it up one +func correctCursor(v *gocui.View) error { + cx, cy := v.Cursor() + _, oy := v.Origin() + lineCount := len(v.BufferLines()) - 2 + if cy >= lineCount-oy { + return v.SetCursor(cx, lineCount-oy) + } + return nil +} + +func renderString(g *gocui.Gui, viewName, s string) error { + g.Update(func(*gocui.Gui) error { + timeStart := time.Now() + v, err := g.View(viewName) + if err != nil { + panic(err) + } + v.Clear() + fmt.Fprint(v, s) + v.Wrap = true + devLog("render time: ", time.Now().Sub(timeStart)) + return nil + }) + return nil +} + +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 +}