Compare commits

...

13 Commits

Author SHA1 Message Date
Jesse Duffield
e092da5f78 pause background threads when running subprocess 2021-04-10 12:16:45 +10:00
Jesse Duffield
e42e7e5cbd fix commit amend 2021-04-10 11:54:38 +10:00
Jesse Duffield
93fac1f312 reduce flicker without worrying about carriage returns 2021-04-09 22:50:55 +10:00
Jesse Duffield
d5504fa5d0 potentially fix credentials issue 2021-04-09 00:39:04 +10:00
Jesse Duffield
273aba38d4 stricter CI 2021-04-09 00:15:48 +10:00
Jesse Duffield
cab0aa462c fix crash at start 2021-04-09 00:10:35 +10:00
Jesse Duffield
b03e2270a0 revert no-flicker due to carriage return weirdness 2021-04-08 23:17:27 +10:00
Jesse Duffield
21049be233 support file tree mode on windows 2021-04-08 21:33:17 +10:00
Jesse Duffield
f89c47b83d add test for building tree 2021-04-08 21:33:17 +10:00
Jesse Duffield
44f1f22068 close commit message panel after returning from subprocess 2021-04-08 20:17:16 +10:00
Jesse Duffield
a229547048 fix CI 2021-04-07 22:59:53 +10:00
Jesse Duffield
4f700c23ba fix crash on first open 2021-04-07 22:59:53 +10:00
Emiliano Ruiz Carletti
b69fc19b35 fix broken link to old AUR package 2021-04-07 08:51:07 +10:00
67 changed files with 2767 additions and 2371 deletions

View File

@@ -114,12 +114,12 @@ scoop install lazygit
### Arch Linux
Packages for Arch Linux are available via AUR (Arch User Repository).
Packages for Arch Linux are available via pacman and AUR (Arch User Repository).
There are two packages. The stable one which is built with the latest release
and the git version which builds from the most recent commit.
- Stable: <https://aur.archlinux.org/packages/lazygit/>
- Stable: `sudo pacman -S lazygit`
- Development: <https://aur.archlinux.org/packages/lazygit-git/>
Instruction of how to install AUR content can be found here:

4
go.mod
View File

@@ -20,7 +20,7 @@ require (
github.com/imdario/mergo v0.3.11
github.com/integrii/flaggy v1.4.0
github.com/jesseduffield/go-git/v5 v5.1.2-0.20201006095850-341962be15a4
github.com/jesseduffield/gocui v0.3.1-0.20210406065942-1b0c68414064
github.com/jesseduffield/gocui v0.3.1-0.20210410011117-a2bb4baca390
github.com/jesseduffield/termbox-go v0.0.0-20200823212418-a2289ed6aafe // indirect
github.com/jesseduffield/yaml v2.1.0+incompatible
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0
@@ -40,7 +40,7 @@ require (
golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0 // indirect
golang.org/x/net v0.0.0-20201002202402-0a1ea396d57c // indirect
golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57 // indirect
golang.org/x/term v0.0.0-20210317153231-de623e64d2a6 // indirect
golang.org/x/term v0.0.0-20210406210042-72f3dc4e9b72 // indirect
golang.org/x/text v0.3.6 // indirect
)

6
go.sum
View File

@@ -106,6 +106,10 @@ github.com/jesseduffield/gocui v0.3.1-0.20210406065811-95ef6e13779b h1:3+4+muhhi
github.com/jesseduffield/gocui v0.3.1-0.20210406065811-95ef6e13779b/go.mod h1:QWq79xplEoyhQO+dgpk3sojjTVRKjQklyTlzm5nC5Kg=
github.com/jesseduffield/gocui v0.3.1-0.20210406065942-1b0c68414064 h1:Oe+QJuUIOd2TU+A3BW5sT1eXqceoBcOOfyoHlGf7F8Y=
github.com/jesseduffield/gocui v0.3.1-0.20210406065942-1b0c68414064/go.mod h1:QWq79xplEoyhQO+dgpk3sojjTVRKjQklyTlzm5nC5Kg=
github.com/jesseduffield/gocui v0.3.1-0.20210409121040-210802112d8a h1:ocrSuZxQIgWWt27b+rjiyIIPz6fzfFeoL5Q4cpa2cAo=
github.com/jesseduffield/gocui v0.3.1-0.20210409121040-210802112d8a/go.mod h1:QWq79xplEoyhQO+dgpk3sojjTVRKjQklyTlzm5nC5Kg=
github.com/jesseduffield/gocui v0.3.1-0.20210410011117-a2bb4baca390 h1:Es72JiUjt01TtvqCugdvOR91baB3DhuWF1DNuxA0frA=
github.com/jesseduffield/gocui v0.3.1-0.20210410011117-a2bb4baca390/go.mod h1:QWq79xplEoyhQO+dgpk3sojjTVRKjQklyTlzm5nC5Kg=
github.com/jesseduffield/termbox-go v0.0.0-20200823212418-a2289ed6aafe h1:qsVhCf2RFyyKIUe/+gJblbCpXMUki9rZrHuEctg6M/E=
github.com/jesseduffield/termbox-go v0.0.0-20200823212418-a2289ed6aafe/go.mod h1:anMibpZtqNxjDbxrcDEAwSdaJ37vyUeM1f/M4uekib4=
github.com/jesseduffield/yaml v2.1.0+incompatible h1:HWQJ1gIv2zHKbDYNp0Jwjlj24K8aqpFHnMCynY1EpmE=
@@ -230,6 +234,8 @@ golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf h1:MZ2shdL+ZM/XzY3ZGOnh4Nlp
golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210317153231-de623e64d2a6 h1:EC6+IGYTjPpRfv9a2b/6Puw0W+hLtAhkV1tPsXhutqs=
golang.org/x/term v0.0.0-20210317153231-de623e64d2a6/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210406210042-72f3dc4e9b72 h1:VqE9gduFZ4dbR7XoL77lHFp0/DyDUBKSXK7CMFkVcV0=
golang.org/x/term v0.0.0-20210406210042-72f3dc4e9b72/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=

View File

@@ -0,0 +1,338 @@
package commands
import (
"os/exec"
"testing"
"github.com/jesseduffield/lazygit/pkg/secureexec"
"github.com/jesseduffield/lazygit/pkg/test"
"github.com/stretchr/testify/assert"
)
// TestGitCommandGetCommitDifferences is a function.
func TestGitCommandGetCommitDifferences(t *testing.T) {
type scenario struct {
testName string
command func(string, ...string) *exec.Cmd
test func(string, string)
}
scenarios := []scenario{
{
"Can't retrieve pushable count",
func(string, ...string) *exec.Cmd {
return secureexec.Command("test")
},
func(pushableCount string, pullableCount string) {
assert.EqualValues(t, "?", pushableCount)
assert.EqualValues(t, "?", pullableCount)
},
},
{
"Can't retrieve pullable count",
func(cmd string, args ...string) *exec.Cmd {
if args[1] == "HEAD..@{u}" {
return secureexec.Command("test")
}
return secureexec.Command("echo")
},
func(pushableCount string, pullableCount string) {
assert.EqualValues(t, "?", pushableCount)
assert.EqualValues(t, "?", pullableCount)
},
},
{
"Retrieve pullable and pushable count",
func(cmd string, args ...string) *exec.Cmd {
if args[1] == "HEAD..@{u}" {
return secureexec.Command("echo", "10")
}
return secureexec.Command("echo", "11")
},
func(pushableCount string, pullableCount string) {
assert.EqualValues(t, "11", pushableCount)
assert.EqualValues(t, "10", pullableCount)
},
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
gitCmd := NewDummyGitCommand()
gitCmd.OSCommand.Command = s.command
s.test(gitCmd.GetCommitDifferences("HEAD", "@{u}"))
})
}
}
// TestGitCommandNewBranch is a function.
func TestGitCommandNewBranch(t *testing.T) {
gitCmd := NewDummyGitCommand()
gitCmd.OSCommand.Command = func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"checkout", "-b", "test", "master"}, args)
return secureexec.Command("echo")
}
assert.NoError(t, gitCmd.NewBranch("test", "master"))
}
// TestGitCommandDeleteBranch is a function.
func TestGitCommandDeleteBranch(t *testing.T) {
type scenario struct {
testName string
branch string
force bool
command func(string, ...string) *exec.Cmd
test func(error)
}
scenarios := []scenario{
{
"Delete a branch",
"test",
false,
func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"branch", "-d", "test"}, args)
return secureexec.Command("echo")
},
func(err error) {
assert.NoError(t, err)
},
},
{
"Force delete a branch",
"test",
true,
func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"branch", "-D", "test"}, args)
return secureexec.Command("echo")
},
func(err error) {
assert.NoError(t, err)
},
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
gitCmd := NewDummyGitCommand()
gitCmd.OSCommand.Command = s.command
s.test(gitCmd.DeleteBranch(s.branch, s.force))
})
}
}
// TestGitCommandMerge is a function.
func TestGitCommandMerge(t *testing.T) {
gitCmd := NewDummyGitCommand()
gitCmd.OSCommand.Command = func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"merge", "--no-edit", "test"}, args)
return secureexec.Command("echo")
}
assert.NoError(t, gitCmd.Merge("test", MergeOpts{}))
}
// TestGitCommandCheckout is a function.
func TestGitCommandCheckout(t *testing.T) {
type scenario struct {
testName string
command func(string, ...string) *exec.Cmd
test func(error)
force bool
}
scenarios := []scenario{
{
"Checkout",
func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"checkout", "test"}, args)
return secureexec.Command("echo")
},
func(err error) {
assert.NoError(t, err)
},
false,
},
{
"Checkout forced",
func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"checkout", "--force", "test"}, args)
return secureexec.Command("echo")
},
func(err error) {
assert.NoError(t, err)
},
true,
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
gitCmd := NewDummyGitCommand()
gitCmd.OSCommand.Command = s.command
s.test(gitCmd.Checkout("test", CheckoutOptions{Force: s.force}))
})
}
}
// TestGitCommandGetBranchGraph is a function.
func TestGitCommandGetBranchGraph(t *testing.T) {
gitCmd := NewDummyGitCommand()
gitCmd.OSCommand.Command = func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"log", "--graph", "--color=always", "--abbrev-commit", "--decorate", "--date=relative", "--pretty=medium", "test", "--"}, args)
return secureexec.Command("echo")
}
_, err := gitCmd.GetBranchGraph("test")
assert.NoError(t, err)
}
func TestGitCommandGetAllBranchGraph(t *testing.T) {
gitCmd := NewDummyGitCommand()
gitCmd.OSCommand.Command = func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"log", "--graph", "--all", "--color=always", "--abbrev-commit", "--decorate", "--date=relative", "--pretty=medium"}, args)
return secureexec.Command("echo")
}
cmdStr := gitCmd.Config.GetUserConfig().Git.AllBranchesLogCmd
_, err := gitCmd.OSCommand.RunCommandWithOutput(cmdStr)
assert.NoError(t, err)
}
// TestGitCommandCurrentBranchName is a function.
func TestGitCommandCurrentBranchName(t *testing.T) {
type scenario struct {
testName string
command func(string, ...string) *exec.Cmd
test func(string, string, error)
}
scenarios := []scenario{
{
"says we are on the master branch if we are",
func(cmd string, args ...string) *exec.Cmd {
assert.Equal(t, "git", cmd)
return secureexec.Command("echo", "master")
},
func(name string, displayname string, err error) {
assert.NoError(t, err)
assert.EqualValues(t, "master", name)
assert.EqualValues(t, "master", displayname)
},
},
{
"falls back to git `git branch --contains` if symbolic-ref fails",
func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
switch args[0] {
case "symbolic-ref":
assert.EqualValues(t, []string{"symbolic-ref", "--short", "HEAD"}, args)
return secureexec.Command("test")
case "branch":
assert.EqualValues(t, []string{"branch", "--contains"}, args)
return secureexec.Command("echo", "* master")
}
return nil
},
func(name string, displayname string, err error) {
assert.NoError(t, err)
assert.EqualValues(t, "master", name)
assert.EqualValues(t, "master", displayname)
},
},
{
"handles a detached head",
func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
switch args[0] {
case "symbolic-ref":
assert.EqualValues(t, []string{"symbolic-ref", "--short", "HEAD"}, args)
return secureexec.Command("test")
case "branch":
assert.EqualValues(t, []string{"branch", "--contains"}, args)
return secureexec.Command("echo", "* (HEAD detached at 123abcd)")
}
return nil
},
func(name string, displayname string, err error) {
assert.NoError(t, err)
assert.EqualValues(t, "123abcd", name)
assert.EqualValues(t, "(HEAD detached at 123abcd)", displayname)
},
},
{
"bubbles up error if there is one",
func(cmd string, args ...string) *exec.Cmd {
assert.Equal(t, "git", cmd)
return secureexec.Command("test")
},
func(name string, displayname string, err error) {
assert.Error(t, err)
assert.EqualValues(t, "", name)
assert.EqualValues(t, "", displayname)
},
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
gitCmd := NewDummyGitCommand()
gitCmd.OSCommand.Command = s.command
s.test(gitCmd.CurrentBranchName())
})
}
}
// TestGitCommandResetHard is a function.
func TestGitCommandResetHard(t *testing.T) {
type scenario struct {
testName string
ref string
command func(string, ...string) *exec.Cmd
test func(error)
}
scenarios := []scenario{
{
"valid case",
"HEAD",
test.CreateMockCommand(t, []*test.CommandSwapper{
{
Expect: `git reset --hard HEAD`,
Replace: "echo",
},
}),
func(err error) {
assert.NoError(t, err)
},
},
}
gitCmd := NewDummyGitCommand()
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
gitCmd.OSCommand.Command = s.command
s.test(gitCmd.ResetHard(s.ref))
})
}
}

View File

@@ -19,20 +19,19 @@ func (c *GitCommand) ResetToCommit(sha string, strength string, options oscomman
return c.OSCommand.RunCommandWithOptions(fmt.Sprintf("git reset --%s %s", strength, sha), options)
}
// Commit commits to git
func (c *GitCommand) Commit(message string, flags string) (*exec.Cmd, error) {
func (c *GitCommand) CommitCmdStr(message string, flags string) string {
splitMessage := strings.Split(message, "\n")
lineArgs := ""
for _, line := range splitMessage {
lineArgs += fmt.Sprintf(" -m %s", c.OSCommand.Quote(line))
}
command := fmt.Sprintf("git commit %s%s", flags, lineArgs)
if c.usingGpg() {
return c.OSCommand.ShellCommandFromString(command), nil
flagsStr := ""
if flags != "" {
flagsStr = fmt.Sprintf(" %s", flags)
}
return nil, c.OSCommand.RunCommand(command)
return fmt.Sprintf("git commit%s%s", flagsStr, lineArgs)
}
// Get the subject of the HEAD commit
@@ -50,18 +49,17 @@ func (c *GitCommand) GetCommitMessage(commitSha string) (string, error) {
}
// AmendHead amends HEAD with whatever is staged in your working tree
func (c *GitCommand) AmendHead() (*exec.Cmd, error) {
command := "git commit --amend --no-edit --allow-empty"
if c.usingGpg() {
return c.OSCommand.ShellCommandFromString(command), nil
}
return nil, c.OSCommand.RunCommand(command)
func (c *GitCommand) AmendHead() error {
return c.OSCommand.RunCommand(c.AmendHeadCmdStr())
}
// PrepareCommitAmendSubProcess prepares a subprocess for `git commit --amend --allow-empty`
func (c *GitCommand) PrepareCommitAmendSubProcess() *exec.Cmd {
return c.OSCommand.PrepareSubProcess("git", "commit", "--amend", "--allow-empty")
// PrepareCommitAmendHeadSubProcess prepares a subprocess for `git commit --amend --allow-empty`
func (c *GitCommand) PrepareCommitAmendHeadSubProcess() *exec.Cmd {
return c.OSCommand.ShellCommandFromString(c.AmendHeadCmdStr())
}
func (c *GitCommand) AmendHeadCmdStr() string {
return "git commit --amend --no-edit --allow-empty"
}
func (c *GitCommand) ShowCmdStr(sha string, filterPath string) string {

View File

@@ -0,0 +1,111 @@
package commands
import (
"os/exec"
"testing"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/secureexec"
"github.com/jesseduffield/lazygit/pkg/test"
"github.com/stretchr/testify/assert"
)
// TestGitCommandRenameCommit is a function.
func TestGitCommandRenameCommit(t *testing.T) {
gitCmd := NewDummyGitCommand()
gitCmd.OSCommand.Command = func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"commit", "--allow-empty", "--amend", "--only", "-m", "test"}, args)
return secureexec.Command("echo")
}
assert.NoError(t, gitCmd.RenameCommit("test"))
}
// TestGitCommandResetToCommit is a function.
func TestGitCommandResetToCommit(t *testing.T) {
gitCmd := NewDummyGitCommand()
gitCmd.OSCommand.Command = func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"reset", "--hard", "78976bc"}, args)
return secureexec.Command("echo")
}
assert.NoError(t, gitCmd.ResetToCommit("78976bc", "hard", oscommands.RunCommandOptions{}))
}
// TestGitCommandCommitStr is a function.
func TestGitCommandCommitStr(t *testing.T) {
type scenario struct {
testName string
message string
flags string
expected string
}
scenarios := []scenario{
{
testName: "Commit",
message: "test",
flags: "",
expected: "git commit -m \"test\"",
},
{
testName: "Commit with --no-verify flag",
message: "test",
flags: "--no-verify",
expected: "git commit --no-verify -m \"test\"",
},
{
testName: "Commit with multiline message",
message: "line1\nline2",
flags: "",
expected: "git commit -m \"line1\" -m \"line2\"",
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
gitCmd := NewDummyGitCommand()
cmdStr := gitCmd.CommitCmdStr(s.message, s.flags)
assert.Equal(t, s.expected, cmdStr)
})
}
}
// TestGitCommandCreateFixupCommit is a function.
func TestGitCommandCreateFixupCommit(t *testing.T) {
type scenario struct {
testName string
sha string
command func(string, ...string) *exec.Cmd
test func(error)
}
scenarios := []scenario{
{
"valid case",
"12345",
test.CreateMockCommand(t, []*test.CommandSwapper{
{
Expect: `git commit --fixup=12345`,
Replace: "echo",
},
}),
func(err error) {
assert.NoError(t, err)
},
},
}
gitCmd := NewDummyGitCommand()
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
gitCmd.OSCommand.Command = s.command
s.test(gitCmd.CreateFixupCommit(s.sha))
})
}
}

View File

@@ -46,3 +46,17 @@ func (c *GitCommand) GetConfigValue(key string) string {
output, _ := c.getGitConfigValue(key)
return output
}
// UsingGpg tells us whether the user has gpg enabled so that we can know
// whether we need to run a subprocess to allow them to enter their password
func (c *GitCommand) UsingGpg() bool {
overrideGpg := c.Config.GetUserConfig().Git.OverrideGpg
if overrideGpg {
return false
}
gpgsign := c.GetConfigValue("commit.gpgsign")
value := strings.ToLower(gpgsign)
return value == "true" || value == "1" || value == "yes" || value == "on"
}

View File

@@ -0,0 +1,70 @@
package commands
import (
"testing"
"github.com/stretchr/testify/assert"
)
// TestGitCommandUsingGpg is a function.
func TestGitCommandUsingGpg(t *testing.T) {
type scenario struct {
testName string
getGitConfigValue func(string) (string, error)
test func(bool)
}
scenarios := []scenario{
{
"Option global and local config commit.gpgsign is not set",
func(string) (string, error) { return "", nil },
func(gpgEnabled bool) {
assert.False(t, gpgEnabled)
},
},
{
"Option commit.gpgsign is true",
func(string) (string, error) {
return "True", nil
},
func(gpgEnabled bool) {
assert.True(t, gpgEnabled)
},
},
{
"Option commit.gpgsign is on",
func(string) (string, error) {
return "ON", nil
},
func(gpgEnabled bool) {
assert.True(t, gpgEnabled)
},
},
{
"Option commit.gpgsign is yes",
func(string) (string, error) {
return "YeS", nil
},
func(gpgEnabled bool) {
assert.True(t, gpgEnabled)
},
},
{
"Option commit.gpgsign is 1",
func(string) (string, error) {
return "1", nil
},
func(gpgEnabled bool) {
assert.True(t, gpgEnabled)
},
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
gitCmd := NewDummyGitCommand()
gitCmd.getGitConfigValue = s.getGitConfigValue
s.test(gitCmd.UsingGpg())
})
}
}

View File

@@ -2,16 +2,20 @@ package commands
import (
"fmt"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
"github.com/go-errors/errors"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
"github.com/jesseduffield/lazygit/pkg/secureexec"
"github.com/jesseduffield/lazygit/pkg/test"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/mgutz/str"
"github.com/stretchr/testify/assert"
)
// CatFile obtains the content of a file
@@ -267,10 +271,7 @@ func (c *GitCommand) DiscardOldFileChanges(commits []*models.Commit, commitIndex
}
// amend the commit
cmd, err := c.AmendHead()
if cmd != nil {
return errors.New("received unexpected pointer to cmd")
}
err := c.AmendHead()
if err != nil {
return err
}
@@ -314,9 +315,7 @@ func (c *GitCommand) ResetAndClean() error {
return c.RemoveUntrackedFiles()
}
// EditFile opens a file in a subprocess using whatever editor is available,
// falling back to core.editor, GIT_EDITOR, VISUAL, EDITOR, then vi
func (c *GitCommand) EditFile(filename string) (*exec.Cmd, error) {
func (c *GitCommand) EditFileCmdStr(filename string) (string, error) {
editor := c.GetConfigValue("core.editor")
if editor == "" {
@@ -334,10 +333,386 @@ func (c *GitCommand) EditFile(filename string) (*exec.Cmd, error) {
}
}
if editor == "" {
return nil, errors.New("No editor defined in $GIT_EDITOR, $VISUAL, $EDITOR, or git config")
return "", errors.New("No editor defined in $GIT_EDITOR, $VISUAL, $EDITOR, or git config")
}
splitCmd := str.ToArgv(fmt.Sprintf("%s %s", editor, c.OSCommand.Quote(filename)))
return c.OSCommand.PrepareSubProcess(splitCmd[0], splitCmd[1:]...), nil
return fmt.Sprintf("%s %s", editor, c.OSCommand.Quote(filename)), nil
}
func TestGitCommandApplyPatch(t *testing.T) {
type scenario struct {
testName string
command func(string, ...string) *exec.Cmd
test func(error)
}
scenarios := []scenario{
{
"valid case",
func(cmd string, args ...string) *exec.Cmd {
assert.Equal(t, "git", cmd)
assert.EqualValues(t, []string{"apply", "--cached"}, args[0:2])
filename := args[2]
content, err := ioutil.ReadFile(filename)
assert.NoError(t, err)
assert.Equal(t, "test", string(content))
return secureexec.Command("echo", "done")
},
func(err error) {
assert.NoError(t, err)
},
},
{
"command returns error",
func(cmd string, args ...string) *exec.Cmd {
assert.Equal(t, "git", cmd)
assert.EqualValues(t, []string{"apply", "--cached"}, args[0:2])
filename := args[2]
// TODO: Ideally we want to mock out OSCommand here so that we're not
// double handling testing it's CreateTempFile functionality,
// but it is going to take a bit of work to make a proper mock for it
// so I'm leaving it for another PR
content, err := ioutil.ReadFile(filename)
assert.NoError(t, err)
assert.Equal(t, "test", string(content))
return secureexec.Command("test")
},
func(err error) {
assert.Error(t, err)
},
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
gitCmd := NewDummyGitCommand()
gitCmd.OSCommand.Command = s.command
s.test(gitCmd.ApplyPatch("test", "cached"))
})
}
}
// TestGitCommandDiscardOldFileChanges is a function.
func TestGitCommandDiscardOldFileChanges(t *testing.T) {
type scenario struct {
testName string
getGitConfigValue func(string) (string, error)
commits []*models.Commit
commitIndex int
fileName string
command func(string, ...string) *exec.Cmd
test func(error)
}
scenarios := []scenario{
{
"returns error when index outside of range of commits",
func(string) (string, error) {
return "", nil
},
[]*models.Commit{},
0,
"test999.txt",
nil,
func(err error) {
assert.Error(t, err)
},
},
{
"returns error when using gpg",
func(string) (string, error) {
return "true", nil
},
[]*models.Commit{{Name: "commit", Sha: "123456"}},
0,
"test999.txt",
nil,
func(err error) {
assert.Error(t, err)
},
},
{
"checks out file if it already existed",
func(string) (string, error) {
return "", nil
},
[]*models.Commit{
{Name: "commit", Sha: "123456"},
{Name: "commit2", Sha: "abcdef"},
},
0,
"test999.txt",
test.CreateMockCommand(t, []*test.CommandSwapper{
{
Expect: "git rebase --interactive --autostash --keep-empty abcdef",
Replace: "echo",
},
{
Expect: "git cat-file -e HEAD^:test999.txt",
Replace: "echo",
},
{
Expect: "git checkout HEAD^ test999.txt",
Replace: "echo",
},
{
Expect: "git commit --amend --no-edit --allow-empty",
Replace: "echo",
},
{
Expect: "git rebase --continue",
Replace: "echo",
},
}),
func(err error) {
assert.NoError(t, err)
},
},
// test for when the file was created within the commit requires a refactor to support proper mocks
// currently we'd need to mock out the os.Remove function and that's gonna introduce tech debt
}
gitCmd := NewDummyGitCommand()
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
gitCmd.OSCommand.Command = s.command
gitCmd.getGitConfigValue = s.getGitConfigValue
s.test(gitCmd.DiscardOldFileChanges(s.commits, s.commitIndex, s.fileName))
})
}
}
// TestGitCommandDiscardUnstagedFileChanges is a function.
func TestGitCommandDiscardUnstagedFileChanges(t *testing.T) {
type scenario struct {
testName string
file *models.File
command func(string, ...string) *exec.Cmd
test func(error)
}
scenarios := []scenario{
{
"valid case",
&models.File{Name: "test.txt"},
test.CreateMockCommand(t, []*test.CommandSwapper{
{
Expect: `git checkout -- "test.txt"`,
Replace: "echo",
},
}),
func(err error) {
assert.NoError(t, err)
},
},
}
gitCmd := NewDummyGitCommand()
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
gitCmd.OSCommand.Command = s.command
s.test(gitCmd.DiscardUnstagedFileChanges(s.file))
})
}
}
// TestGitCommandDiscardAnyUnstagedFileChanges is a function.
func TestGitCommandDiscardAnyUnstagedFileChanges(t *testing.T) {
type scenario struct {
testName string
command func(string, ...string) *exec.Cmd
test func(error)
}
scenarios := []scenario{
{
"valid case",
test.CreateMockCommand(t, []*test.CommandSwapper{
{
Expect: `git checkout -- .`,
Replace: "echo",
},
}),
func(err error) {
assert.NoError(t, err)
},
},
}
gitCmd := NewDummyGitCommand()
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
gitCmd.OSCommand.Command = s.command
s.test(gitCmd.DiscardAnyUnstagedFileChanges())
})
}
}
// TestGitCommandRemoveUntrackedFiles is a function.
func TestGitCommandRemoveUntrackedFiles(t *testing.T) {
type scenario struct {
testName string
command func(string, ...string) *exec.Cmd
test func(error)
}
scenarios := []scenario{
{
"valid case",
test.CreateMockCommand(t, []*test.CommandSwapper{
{
Expect: `git clean -fd`,
Replace: "echo",
},
}),
func(err error) {
assert.NoError(t, err)
},
},
}
gitCmd := NewDummyGitCommand()
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
gitCmd.OSCommand.Command = s.command
s.test(gitCmd.RemoveUntrackedFiles())
})
}
}
// TestEditFileCmdStr is a function.
func TestEditFileCmdStr(t *testing.T) {
type scenario struct {
filename string
command func(string, ...string) *exec.Cmd
getenv func(string) string
getGitConfigValue func(string) (string, error)
test func(string, error)
}
scenarios := []scenario{
{
"test",
func(name string, arg ...string) *exec.Cmd {
return secureexec.Command("exit", "1")
},
func(env string) string {
return ""
},
func(cf string) (string, error) {
return "", nil
},
func(cmdStr string, err error) {
assert.EqualError(t, err, "No editor defined in $GIT_EDITOR, $VISUAL, $EDITOR, or git config")
},
},
{
"test",
func(name string, arg ...string) *exec.Cmd {
assert.Equal(t, "which", name)
return secureexec.Command("exit", "1")
},
func(env string) string {
return ""
},
func(cf string) (string, error) {
return "nano", nil
},
func(cmdStr string, err error) {
assert.NoError(t, err)
assert.Equal(t, "nano \"test\"", cmdStr)
},
},
{
"test",
func(name string, arg ...string) *exec.Cmd {
assert.Equal(t, "which", name)
return secureexec.Command("exit", "1")
},
func(env string) string {
if env == "VISUAL" {
return "nano"
}
return ""
},
func(cf string) (string, error) {
return "", nil
},
func(cmdStr string, err error) {
assert.NoError(t, err)
},
},
{
"test",
func(name string, arg ...string) *exec.Cmd {
assert.Equal(t, "which", name)
return secureexec.Command("exit", "1")
},
func(env string) string {
if env == "EDITOR" {
return "emacs"
}
return ""
},
func(cf string) (string, error) {
return "", nil
},
func(cmdStr string, err error) {
assert.NoError(t, err)
assert.Equal(t, "emacs \"test\"", cmdStr)
},
},
{
"test",
func(name string, arg ...string) *exec.Cmd {
assert.Equal(t, "which", name)
return secureexec.Command("echo")
},
func(env string) string {
return ""
},
func(cf string) (string, error) {
return "", nil
},
func(cmdStr string, err error) {
assert.NoError(t, err)
assert.Equal(t, "vi \"test\"", cmdStr)
},
},
{
"file/with space",
func(name string, args ...string) *exec.Cmd {
assert.Equal(t, "which", name)
return secureexec.Command("echo")
},
func(env string) string {
return ""
},
func(cf string) (string, error) {
return "", nil
},
func(cmdStr string, err error) {
assert.NoError(t, err)
assert.Equal(t, "vi \"file/with space\"", cmdStr)
},
},
}
for _, s := range scenarios {
gitCmd := NewDummyGitCommand()
gitCmd.OSCommand.Command = s.command
gitCmd.OSCommand.Getenv = s.getenv
gitCmd.getGitConfigValue = s.getGitConfigValue
s.test(gitCmd.EditFileCmdStr(s.filename))
}
}

467
pkg/commands/files_test.go Normal file
View File

@@ -0,0 +1,467 @@
package commands
import (
"fmt"
"os/exec"
"runtime"
"testing"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/secureexec"
"github.com/jesseduffield/lazygit/pkg/test"
"github.com/stretchr/testify/assert"
)
// TestGitCommandCatFile tests emitting a file using commands, where commands vary by OS.
func TestGitCommandCatFile(t *testing.T) {
var osCmd string
switch os := runtime.GOOS; os {
case "windows":
osCmd = "type"
default:
osCmd = "cat"
}
gitCmd := NewDummyGitCommand()
gitCmd.OSCommand.Command = func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, osCmd, cmd)
assert.EqualValues(t, []string{"test.txt"}, args)
return secureexec.Command("echo", "-n", "test")
}
o, err := gitCmd.CatFile("test.txt")
assert.NoError(t, err)
assert.Equal(t, "test", o)
}
// TestGitCommandStageFile is a function.
func TestGitCommandStageFile(t *testing.T) {
gitCmd := NewDummyGitCommand()
gitCmd.OSCommand.Command = func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"add", "--", "test.txt"}, args)
return secureexec.Command("echo")
}
assert.NoError(t, gitCmd.StageFile("test.txt"))
}
// TestGitCommandUnstageFile is a function.
func TestGitCommandUnstageFile(t *testing.T) {
type scenario struct {
testName string
command func(string, ...string) *exec.Cmd
test func(error)
reset bool
}
scenarios := []scenario{
{
"Remove an untracked file from staging",
func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"rm", "--cached", "--force", "--", "test.txt"}, args)
return secureexec.Command("echo")
},
func(err error) {
assert.NoError(t, err)
},
false,
},
{
"Remove a tracked file from staging",
func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"reset", "HEAD", "--", "test.txt"}, args)
return secureexec.Command("echo")
},
func(err error) {
assert.NoError(t, err)
},
true,
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
gitCmd := NewDummyGitCommand()
gitCmd.OSCommand.Command = s.command
s.test(gitCmd.UnStageFile([]string{"test.txt"}, s.reset))
})
}
}
// TestGitCommandDiscardAllFileChanges is a function.
// these tests don't cover everything, in part because we already have an integration
// test which does cover everything. I don't want to unnecessarily assert on the 'how'
// when the 'what' is what matters
func TestGitCommandDiscardAllFileChanges(t *testing.T) {
type scenario struct {
testName string
command func() (func(string, ...string) *exec.Cmd, *[][]string)
test func(*[][]string, error)
file *models.File
removeFile func(string) error
}
scenarios := []scenario{
{
"An error occurred when resetting",
func() (func(string, ...string) *exec.Cmd, *[][]string) {
cmdsCalled := [][]string{}
return func(cmd string, args ...string) *exec.Cmd {
cmdsCalled = append(cmdsCalled, args)
return secureexec.Command("test")
}, &cmdsCalled
},
func(cmdsCalled *[][]string, err error) {
assert.Error(t, err)
assert.Len(t, *cmdsCalled, 1)
assert.EqualValues(t, *cmdsCalled, [][]string{
{"reset", "--", "test"},
})
},
&models.File{
Name: "test",
HasStagedChanges: true,
},
func(string) error {
return nil
},
},
{
"An error occurred when removing file",
func() (func(string, ...string) *exec.Cmd, *[][]string) {
cmdsCalled := [][]string{}
return func(cmd string, args ...string) *exec.Cmd {
cmdsCalled = append(cmdsCalled, args)
return secureexec.Command("test")
}, &cmdsCalled
},
func(cmdsCalled *[][]string, err error) {
assert.Error(t, err)
assert.EqualError(t, err, "an error occurred when removing file")
assert.Len(t, *cmdsCalled, 0)
},
&models.File{
Name: "test",
Tracked: false,
Added: true,
},
func(string) error {
return fmt.Errorf("an error occurred when removing file")
},
},
{
"An error occurred with checkout",
func() (func(string, ...string) *exec.Cmd, *[][]string) {
cmdsCalled := [][]string{}
return func(cmd string, args ...string) *exec.Cmd {
cmdsCalled = append(cmdsCalled, args)
return secureexec.Command("test")
}, &cmdsCalled
},
func(cmdsCalled *[][]string, err error) {
assert.Error(t, err)
assert.Len(t, *cmdsCalled, 1)
assert.EqualValues(t, *cmdsCalled, [][]string{
{"checkout", "--", "test"},
})
},
&models.File{
Name: "test",
Tracked: true,
HasStagedChanges: false,
},
func(string) error {
return nil
},
},
{
"Checkout only",
func() (func(string, ...string) *exec.Cmd, *[][]string) {
cmdsCalled := [][]string{}
return func(cmd string, args ...string) *exec.Cmd {
cmdsCalled = append(cmdsCalled, args)
return secureexec.Command("echo")
}, &cmdsCalled
},
func(cmdsCalled *[][]string, err error) {
assert.NoError(t, err)
assert.Len(t, *cmdsCalled, 1)
assert.EqualValues(t, *cmdsCalled, [][]string{
{"checkout", "--", "test"},
})
},
&models.File{
Name: "test",
Tracked: true,
HasStagedChanges: false,
},
func(string) error {
return nil
},
},
{
"Reset and checkout staged changes",
func() (func(string, ...string) *exec.Cmd, *[][]string) {
cmdsCalled := [][]string{}
return func(cmd string, args ...string) *exec.Cmd {
cmdsCalled = append(cmdsCalled, args)
return secureexec.Command("echo")
}, &cmdsCalled
},
func(cmdsCalled *[][]string, err error) {
assert.NoError(t, err)
assert.Len(t, *cmdsCalled, 2)
assert.EqualValues(t, *cmdsCalled, [][]string{
{"reset", "--", "test"},
{"checkout", "--", "test"},
})
},
&models.File{
Name: "test",
Tracked: true,
HasStagedChanges: true,
},
func(string) error {
return nil
},
},
{
"Reset and checkout merge conflicts",
func() (func(string, ...string) *exec.Cmd, *[][]string) {
cmdsCalled := [][]string{}
return func(cmd string, args ...string) *exec.Cmd {
cmdsCalled = append(cmdsCalled, args)
return secureexec.Command("echo")
}, &cmdsCalled
},
func(cmdsCalled *[][]string, err error) {
assert.NoError(t, err)
assert.Len(t, *cmdsCalled, 2)
assert.EqualValues(t, *cmdsCalled, [][]string{
{"reset", "--", "test"},
{"checkout", "--", "test"},
})
},
&models.File{
Name: "test",
Tracked: true,
HasMergeConflicts: true,
},
func(string) error {
return nil
},
},
{
"Reset and remove",
func() (func(string, ...string) *exec.Cmd, *[][]string) {
cmdsCalled := [][]string{}
return func(cmd string, args ...string) *exec.Cmd {
cmdsCalled = append(cmdsCalled, args)
return secureexec.Command("echo")
}, &cmdsCalled
},
func(cmdsCalled *[][]string, err error) {
assert.NoError(t, err)
assert.Len(t, *cmdsCalled, 1)
assert.EqualValues(t, *cmdsCalled, [][]string{
{"reset", "--", "test"},
})
},
&models.File{
Name: "test",
Tracked: false,
Added: true,
HasStagedChanges: true,
},
func(filename string) error {
assert.Equal(t, "test", filename)
return nil
},
},
{
"Remove only",
func() (func(string, ...string) *exec.Cmd, *[][]string) {
cmdsCalled := [][]string{}
return func(cmd string, args ...string) *exec.Cmd {
cmdsCalled = append(cmdsCalled, args)
return secureexec.Command("echo")
}, &cmdsCalled
},
func(cmdsCalled *[][]string, err error) {
assert.NoError(t, err)
assert.Len(t, *cmdsCalled, 0)
},
&models.File{
Name: "test",
Tracked: false,
Added: true,
HasStagedChanges: false,
},
func(filename string) error {
assert.Equal(t, "test", filename)
return nil
},
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
var cmdsCalled *[][]string
gitCmd := NewDummyGitCommand()
gitCmd.OSCommand.Command, cmdsCalled = s.command()
gitCmd.removeFile = s.removeFile
s.test(cmdsCalled, gitCmd.DiscardAllFileChanges(s.file))
})
}
}
// TestGitCommandDiff is a function.
func TestGitCommandDiff(t *testing.T) {
type scenario struct {
testName string
command func(string, ...string) *exec.Cmd
file *models.File
plain bool
cached bool
}
scenarios := []scenario{
{
"Default case",
func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"diff", "--submodule", "--no-ext-diff", "--color=always", "--", "test.txt"}, args)
return secureexec.Command("echo")
},
&models.File{
Name: "test.txt",
HasStagedChanges: false,
Tracked: true,
},
false,
false,
},
{
"cached",
func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"diff", "--submodule", "--no-ext-diff", "--color=always", "--cached", "--", "test.txt"}, args)
return secureexec.Command("echo")
},
&models.File{
Name: "test.txt",
HasStagedChanges: false,
Tracked: true,
},
false,
true,
},
{
"plain",
func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"diff", "--submodule", "--no-ext-diff", "--color=never", "--", "test.txt"}, args)
return secureexec.Command("echo")
},
&models.File{
Name: "test.txt",
HasStagedChanges: false,
Tracked: true,
},
true,
false,
},
{
"File not tracked and file has no staged changes",
func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"diff", "--submodule", "--no-ext-diff", "--color=always", "--no-index", "--", "/dev/null", "test.txt"}, args)
return secureexec.Command("echo")
},
&models.File{
Name: "test.txt",
HasStagedChanges: false,
Tracked: false,
},
false,
false,
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
gitCmd := NewDummyGitCommand()
gitCmd.OSCommand.Command = s.command
gitCmd.WorktreeFileDiff(s.file, s.plain, s.cached)
})
}
}
// TestGitCommandCheckoutFile is a function.
func TestGitCommandCheckoutFile(t *testing.T) {
type scenario struct {
testName string
commitSha string
fileName string
command func(string, ...string) *exec.Cmd
test func(error)
}
scenarios := []scenario{
{
"typical case",
"11af912",
"test999.txt",
test.CreateMockCommand(t, []*test.CommandSwapper{
{
Expect: "git checkout 11af912 test999.txt",
Replace: "echo",
},
}),
func(err error) {
assert.NoError(t, err)
},
},
{
"returns error if there is one",
"11af912",
"test999.txt",
test.CreateMockCommand(t, []*test.CommandSwapper{
{
Expect: "git checkout 11af912 test999.txt",
Replace: "test",
},
}),
func(err error) {
assert.Error(t, err)
},
},
}
gitCmd := NewDummyGitCommand()
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
gitCmd.OSCommand.Command = s.command
s.test(gitCmd.CheckoutFile(s.commitSha, s.fileName))
})
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,122 @@
package commands
import (
"os/exec"
"testing"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/secureexec"
"github.com/stretchr/testify/assert"
)
// TestGitCommandGetStatusFiles is a function.
func TestGitCommandGetStatusFiles(t *testing.T) {
type scenario struct {
testName string
command func(string, ...string) *exec.Cmd
test func([]*models.File)
}
scenarios := []scenario{
{
"No files found",
func(cmd string, args ...string) *exec.Cmd {
return secureexec.Command("echo")
},
func(files []*models.File) {
assert.Len(t, files, 0)
},
},
{
"Several files found",
func(cmd string, args ...string) *exec.Cmd {
return secureexec.Command(
"echo",
"MM file1.txt\nA file3.txt\nAM file2.txt\n?? file4.txt\nUU file5.txt",
)
},
func(files []*models.File) {
assert.Len(t, files, 5)
expected := []*models.File{
{
Name: "file1.txt",
HasStagedChanges: true,
HasUnstagedChanges: true,
Tracked: true,
Added: false,
Deleted: false,
HasMergeConflicts: false,
HasInlineMergeConflicts: false,
DisplayString: "MM file1.txt",
Type: "other",
ShortStatus: "MM",
},
{
Name: "file3.txt",
HasStagedChanges: true,
HasUnstagedChanges: false,
Tracked: false,
Added: true,
Deleted: false,
HasMergeConflicts: false,
HasInlineMergeConflicts: false,
DisplayString: "A file3.txt",
Type: "other",
ShortStatus: "A ",
},
{
Name: "file2.txt",
HasStagedChanges: true,
HasUnstagedChanges: true,
Tracked: false,
Added: true,
Deleted: false,
HasMergeConflicts: false,
HasInlineMergeConflicts: false,
DisplayString: "AM file2.txt",
Type: "other",
ShortStatus: "AM",
},
{
Name: "file4.txt",
HasStagedChanges: false,
HasUnstagedChanges: true,
Tracked: false,
Added: true,
Deleted: false,
HasMergeConflicts: false,
HasInlineMergeConflicts: false,
DisplayString: "?? file4.txt",
Type: "other",
ShortStatus: "??",
},
{
Name: "file5.txt",
HasStagedChanges: false,
HasUnstagedChanges: true,
Tracked: true,
Added: false,
Deleted: false,
HasMergeConflicts: true,
HasInlineMergeConflicts: true,
DisplayString: "UU file5.txt",
Type: "other",
ShortStatus: "UU",
},
}
assert.EqualValues(t, expected, files)
},
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
gitCmd := NewDummyGitCommand()
gitCmd.OSCommand.Command = s.command
s.test(gitCmd.GetStatusFiles(GetStatusFileOptions{}))
})
}
}

View File

@@ -0,0 +1,61 @@
package commands
import (
"os/exec"
"testing"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/secureexec"
"github.com/stretchr/testify/assert"
)
// TestGitCommandGetStashEntries is a function.
func TestGitCommandGetStashEntries(t *testing.T) {
type scenario struct {
testName string
command func(string, ...string) *exec.Cmd
test func([]*models.StashEntry)
}
scenarios := []scenario{
{
"No stash entries found",
func(string, ...string) *exec.Cmd {
return secureexec.Command("echo")
},
func(entries []*models.StashEntry) {
assert.Len(t, entries, 0)
},
},
{
"Several stash entries found",
func(string, ...string) *exec.Cmd {
return secureexec.Command("echo", "WIP on add-pkg-commands-test: 55c6af2 increase parallel build\nWIP on master: bb86a3f update github template")
},
func(entries []*models.StashEntry) {
expected := []*models.StashEntry{
{
Index: 0,
Name: "WIP on add-pkg-commands-test: 55c6af2 increase parallel build",
},
{
Index: 1,
Name: "WIP on master: bb86a3f update github template",
},
}
assert.Len(t, entries, 2)
assert.EqualValues(t, expected, entries)
},
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
gitCmd := NewDummyGitCommand()
gitCmd.OSCommand.Command = s.command
s.test(gitCmd.GetStashEntries(""))
})
}
}

View File

@@ -23,7 +23,7 @@ func (c *GitCommand) DeletePatchesFromCommit(commits []*models.Commit, commitInd
}
// time to amend the selected commit
if _, err := c.AmendHead(); err != nil {
if err := c.AmendHead(); err != nil {
return err
}
@@ -51,7 +51,7 @@ func (c *GitCommand) MovePatchToSelectedCommit(commits []*models.Commit, sourceC
}
// amend the destination commit
if _, err := c.AmendHead(); err != nil {
if err := c.AmendHead(); err != nil {
return err
}
@@ -71,7 +71,7 @@ func (c *GitCommand) MovePatchToSelectedCommit(commits []*models.Commit, sourceC
// we can make this GPG thing possible it just means we need to do this in two parts:
// one where we handle the possibility of a credential request, and the other
// where we continue the rebase
if c.usingGpg() {
if c.UsingGpg() {
return errors.New(c.Tr.DisabledForGPG)
}
@@ -103,7 +103,7 @@ func (c *GitCommand) MovePatchToSelectedCommit(commits []*models.Commit, sourceC
}
// amend the source commit
if _, err := c.AmendHead(); err != nil {
if err := c.AmendHead(); err != nil {
return err
}
@@ -122,7 +122,7 @@ func (c *GitCommand) MovePatchToSelectedCommit(commits []*models.Commit, sourceC
}
// amend the destination commit
if _, err := c.AmendHead(); err != nil {
if err := c.AmendHead(); err != nil {
return err
}
@@ -158,7 +158,7 @@ func (c *GitCommand) PullPatchIntoIndex(commits []*models.Commit, commitIdx int,
}
// amend the commit
if _, err := c.AmendHead(); err != nil {
if err := c.AmendHead(); err != nil {
return err
}
@@ -203,7 +203,7 @@ func (c *GitCommand) PullPatchIntoNewCommit(commits []*models.Commit, commitIdx
}
// amend the commit
if _, err := c.AmendHead(); err != nil {
if err := c.AmendHead(); err != nil {
return err
}
@@ -217,7 +217,7 @@ func (c *GitCommand) PullPatchIntoNewCommit(commits []*models.Commit, commitIdx
head_message, _ := c.GetHeadCommitMessage()
new_message := fmt.Sprintf("Split from \"%s\"", head_message)
_, err := c.Commit(new_message, "")
err := c.OSCommand.RunCommand(c.CommitCmdStr(new_message, ""))
if err != nil {
return err
}

View File

@@ -211,7 +211,7 @@ func (c *GitCommand) BeginInteractiveRebaseForCommit(commits []*models.Commit, c
// we can make this GPG thing possible it just means we need to do this in two parts:
// one where we handle the possibility of a credential request, and the other
// where we continue the rebase
if c.usingGpg() {
if c.UsingGpg() {
return errors.New(c.Tr.DisabledForGPG)
}

View File

@@ -0,0 +1,96 @@
package commands
import (
"os/exec"
"regexp"
"testing"
"github.com/jesseduffield/lazygit/pkg/test"
"github.com/stretchr/testify/assert"
)
// TestGitCommandRebaseBranch is a function.
func TestGitCommandRebaseBranch(t *testing.T) {
type scenario struct {
testName string
arg string
command func(string, ...string) *exec.Cmd
test func(error)
}
scenarios := []scenario{
{
"successful rebase",
"master",
test.CreateMockCommand(t, []*test.CommandSwapper{
{
Expect: "git rebase --interactive --autostash --keep-empty master",
Replace: "echo",
},
}),
func(err error) {
assert.NoError(t, err)
},
},
{
"unsuccessful rebase",
"master",
test.CreateMockCommand(t, []*test.CommandSwapper{
{
Expect: "git rebase --interactive --autostash --keep-empty master",
Replace: "test",
},
}),
func(err error) {
assert.Error(t, err)
},
},
}
gitCmd := NewDummyGitCommand()
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
gitCmd.OSCommand.Command = s.command
s.test(gitCmd.RebaseBranch(s.arg))
})
}
}
// TestGitCommandSkipEditorCommand confirms that SkipEditorCommand injects
// environment variables that suppress an interactive editor
func TestGitCommandSkipEditorCommand(t *testing.T) {
cmd := NewDummyGitCommand()
cmd.OSCommand.SetBeforeExecuteCmd(func(cmd *exec.Cmd) {
test.AssertContainsMatch(
t,
cmd.Env,
regexp.MustCompile("^VISUAL="),
"expected VISUAL to be set for a non-interactive external command",
)
test.AssertContainsMatch(
t,
cmd.Env,
regexp.MustCompile("^EDITOR="),
"expected EDITOR to be set for a non-interactive external command",
)
test.AssertContainsMatch(
t,
cmd.Env,
regexp.MustCompile("^GIT_EDITOR="),
"expected GIT_EDITOR to be set for a non-interactive external command",
)
test.AssertContainsMatch(
t,
cmd.Env,
regexp.MustCompile("^LAZYGIT_CLIENT_COMMAND=EXIT_IMMEDIATELY$"),
"expected LAZYGIT_CLIENT_COMMAND to be set for a non-interactive external command",
)
})
_ = cmd.runSkipEditorCommand("true")
}

View File

@@ -0,0 +1,35 @@
package commands
import (
"os/exec"
"testing"
"github.com/jesseduffield/lazygit/pkg/secureexec"
"github.com/stretchr/testify/assert"
)
// TestGitCommandStashDo is a function.
func TestGitCommandStashDo(t *testing.T) {
gitCmd := NewDummyGitCommand()
gitCmd.OSCommand.Command = func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"stash", "drop", "stash@{1}"}, args)
return secureexec.Command("echo")
}
assert.NoError(t, gitCmd.StashDo(1, "drop"))
}
// TestGitCommandStashSave is a function.
func TestGitCommandStashSave(t *testing.T) {
gitCmd := NewDummyGitCommand()
gitCmd.OSCommand.Command = func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"stash", "save", "A stash message"}, args)
return secureexec.Command("echo")
}
assert.NoError(t, gitCmd.StashSave("A stash message"))
}

View File

@@ -2,23 +2,8 @@ package commands
import (
"fmt"
"strings"
)
// usingGpg tells us whether the user has gpg enabled so that we can know
// whether we need to run a subprocess to allow them to enter their password
func (c *GitCommand) usingGpg() bool {
overrideGpg := c.Config.GetUserConfig().Git.OverrideGpg
if overrideGpg {
return false
}
gpgsign := c.GetConfigValue("commit.gpgsign")
value := strings.ToLower(gpgsign)
return value == "true" || value == "1" || value == "yes" || value == "on"
}
// Push pushes to a branch
func (c *GitCommand) Push(branchName string, force bool, upstream string, args string, promptUserForCredential func(string) string) error {
followTagsFlag := "--follow-tags"

98
pkg/commands/sync_test.go Normal file
View File

@@ -0,0 +1,98 @@
package commands
import (
"os/exec"
"testing"
"github.com/jesseduffield/lazygit/pkg/secureexec"
"github.com/stretchr/testify/assert"
)
// TestGitCommandPush is a function.
func TestGitCommandPush(t *testing.T) {
type scenario struct {
testName string
getGitConfigValue func(string) (string, error)
command func(string, ...string) *exec.Cmd
forcePush bool
test func(error)
}
scenarios := []scenario{
{
"Push with force disabled, follow-tags on",
func(string) (string, error) {
return "", nil
},
func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"push", "--follow-tags"}, args)
return secureexec.Command("echo")
},
false,
func(err error) {
assert.NoError(t, err)
},
},
{
"Push with force enabled, follow-tags on",
func(string) (string, error) {
return "", nil
},
func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"push", "--follow-tags", "--force-with-lease"}, args)
return secureexec.Command("echo")
},
true,
func(err error) {
assert.NoError(t, err)
},
},
{
"Push with force disabled, follow-tags off",
func(string) (string, error) {
return "false", nil
},
func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"push"}, args)
return secureexec.Command("echo")
},
false,
func(err error) {
assert.NoError(t, err)
},
},
{
"Push with an error occurring, follow-tags on",
func(string) (string, error) {
return "", nil
},
func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"push", "--follow-tags"}, args)
return secureexec.Command("test")
},
false,
func(err error) {
assert.Error(t, err)
},
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
gitCmd := NewDummyGitCommand()
gitCmd.OSCommand.Command = s.command
gitCmd.getGitConfigValue = s.getGitConfigValue
err := gitCmd.Push("test", s.forcePush, "", "", func(passOrUname string) string {
return "\n"
})
s.test(err)
})
}
}

View File

@@ -1,7 +1,6 @@
package gui
import (
"os/exec"
"strconv"
"strings"
@@ -9,19 +8,6 @@ import (
"github.com/jesseduffield/lazygit/pkg/utils"
)
// runSyncOrAsyncCommand takes the output of a command that may have returned
// either no error, an error, or a subprocess to execute, and if a subprocess
// needs to be run, it runs it
func (gui *Gui) runSyncOrAsyncCommand(sub *exec.Cmd, err error) (bool, error) {
if err != nil {
return false, gui.surfaceError(err)
}
if sub != nil {
return false, gui.runSubprocessWithSuspense(sub)
}
return true, nil
}
func (gui *Gui) handleCommitConfirm() error {
message := gui.trimmedContent(gui.Views.CommitMessage)
if message == "" {
@@ -32,17 +18,12 @@ func (gui *Gui) handleCommitConfirm() error {
if skipHookPrefix != "" && strings.HasPrefix(message, skipHookPrefix) {
flags = "--no-verify"
}
ok, err := gui.runSyncOrAsyncCommand(gui.GitCommand.Commit(message, flags))
if err != nil {
return err
}
if !ok {
return nil
}
gui.clearEditorView(gui.Views.CommitMessage)
_ = gui.returnFromContext()
return gui.refreshSidePanels(refreshOptions{mode: ASYNC})
return gui.withGpgHandling(gui.GitCommand.CommitCmdStr(message, flags), gui.Tr.CommittingStatus, func() error {
_ = gui.returnFromContext()
gui.clearEditorView(gui.Views.CommitMessage)
return nil
})
}
func (gui *Gui) handleCommitClose() error {

View File

@@ -263,7 +263,7 @@ func (gui *Gui) handleRenameCommitEditor() error {
return gui.surfaceError(err)
}
if subProcess != nil {
return gui.runSubprocessWithSuspense(subProcess)
return gui.runSubprocessWithSuspenseAndRefresh(subProcess)
}
return nil

View File

@@ -48,7 +48,7 @@ func (gui *Gui) handleSubmitCredential() error {
return err
}
return gui.refreshSidePanels(refreshOptions{})
return gui.refreshSidePanels(refreshOptions{mode: ASYNC})
}
func (gui *Gui) handleCloseCredentialsView() error {
@@ -78,6 +78,7 @@ func (gui *Gui) handleCredentialsPopup(cmdErr error) {
if strings.Contains(errMessage, "Invalid username, password or passphrase") {
errMessage = gui.Tr.PassUnameWrong
}
_ = gui.returnFromContext()
// we are not logging this error because it may contain a password or a passphrase
_ = gui.createErrorPanel(errMessage)
} else {

View File

@@ -60,7 +60,7 @@ func (gui *Gui) handleCustomCommandKeybinding(customCommand config.CustomCommand
}
if customCommand.Subprocess {
return gui.runSubprocessWithSuspense(gui.OSCommand.PrepareShellSubProcess(cmdStr))
return gui.runSubprocessWithSuspenseAndRefresh(gui.OSCommand.PrepareShellSubProcess(cmdStr))
}
loadingText := customCommand.LoadingText

View File

@@ -448,17 +448,7 @@ func (gui *Gui) handleAmendCommitPress() error {
title: strings.Title(gui.Tr.AmendLastCommit),
prompt: gui.Tr.SureToAmend,
handleConfirm: func() error {
return gui.WithWaitingStatus(gui.Tr.AmendingStatus, func() error {
ok, err := gui.runSyncOrAsyncCommand(gui.GitCommand.AmendHead())
if err != nil {
return err
}
if !ok {
return nil
}
return gui.refreshSidePanels(refreshOptions{mode: ASYNC})
})
return gui.withGpgHandling(gui.GitCommand.AmendHeadCmdStr(), gui.Tr.AmendingStatus, nil)
},
})
}
@@ -474,14 +464,20 @@ func (gui *Gui) handleCommitEditorPress() error {
return gui.promptToStageAllAndRetry(gui.handleCommitEditorPress)
}
return gui.runSubprocessWithSuspense(
return gui.runSubprocessWithSuspenseAndRefresh(
gui.OSCommand.PrepareSubProcess("git", "commit"),
)
}
func (gui *Gui) editFile(filename string) error {
_, err := gui.runSyncOrAsyncCommand(gui.GitCommand.EditFile(filename))
return err
cmdStr, err := gui.GitCommand.EditFileCmdStr(filename)
if err != nil {
return gui.surfaceError(err)
}
return gui.runSubprocessWithSuspenseAndRefresh(
gui.OSCommand.PrepareShellSubProcess(cmdStr),
)
}
func (gui *Gui) handleFileEdit() error {
@@ -729,6 +725,10 @@ func (gui *Gui) pushFiles() error {
// if we have pullables we'll ask if the user wants to force push
currentBranch := gui.currentBranch()
if currentBranch == nil {
// need to wait for branches to refresh
return nil
}
if currentBranch.Pullables == "?" {
// see if we have this branch in our config with an upstream
@@ -804,7 +804,7 @@ func (gui *Gui) handleCustomCommand() error {
return gui.prompt(promptOpts{
title: gui.Tr.CustomCommand,
handleConfirm: func(command string) error {
return gui.runSubprocessWithSuspense(
return gui.runSubprocessWithSuspenseAndRefresh(
gui.OSCommand.PrepareShellSubProcess(command),
)
},

View File

@@ -1,8 +1,6 @@
package filetree
import (
"os"
"path/filepath"
"sort"
"strings"
@@ -14,18 +12,17 @@ func BuildTreeFromFiles(files []*models.File) *FileNode {
var curr *FileNode
for _, file := range files {
split := strings.Split(file.Name, string(os.PathSeparator))
splitPath := split(file.Name)
curr = root
outer:
for i := range split {
for i := range splitPath {
var setFile *models.File
isFile := i == len(split)-1
isFile := i == len(splitPath)-1
if isFile {
setFile = file
}
path := filepath.Join(split[:i+1]...)
path := join(splitPath[:i+1])
for _, existingChild := range curr.Children {
if existingChild.Path == path {
curr = existingChild
@@ -61,17 +58,17 @@ func BuildTreeFromCommitFiles(files []*models.CommitFile) *CommitFileNode {
var curr *CommitFileNode
for _, file := range files {
split := strings.Split(file.Name, string(os.PathSeparator))
splitPath := split(file.Name)
curr = root
outer:
for i := range split {
for i := range splitPath {
var setFile *models.CommitFile
isFile := i == len(split)-1
isFile := i == len(splitPath)-1
if isFile {
setFile = file
}
path := filepath.Join(split[:i+1]...)
path := join(splitPath[:i+1])
for _, existingChild := range curr.Children {
if existingChild.Path == path {
@@ -108,3 +105,11 @@ func BuildFlatTreeFromFiles(files []*models.File) *FileNode {
return &FileNode{Children: sortedFiles}
}
func split(str string) []string {
return strings.Split(str, "/")
}
func join(strs []string) string {
return strings.Join(strs, "/")
}

View File

@@ -0,0 +1,484 @@
package filetree
import (
"testing"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/stretchr/testify/assert"
)
func TestBuildTreeFromFiles(t *testing.T) {
scenarios := []struct {
name string
files []*models.File
expected *FileNode
}{
{
name: "no files",
files: []*models.File{},
expected: &FileNode{
Path: "",
Children: []*FileNode{},
},
},
{
name: "files in same directory",
files: []*models.File{
{
Name: "dir1/a",
},
{
Name: "dir1/b",
},
},
expected: &FileNode{
Path: "",
Children: []*FileNode{
{
Path: "dir1",
Children: []*FileNode{
{
File: &models.File{Name: "dir1/a"},
Path: "dir1/a",
},
{
File: &models.File{Name: "dir1/b"},
Path: "dir1/b",
},
},
},
},
},
},
{
name: "paths that can be compressed",
files: []*models.File{
{
Name: "dir1/a",
},
{
Name: "dir2/b",
},
},
expected: &FileNode{
Path: "",
Children: []*FileNode{
{
File: &models.File{Name: "dir1/a"},
Path: "dir1/a",
CompressionLevel: 1,
},
{
File: &models.File{Name: "dir2/b"},
Path: "dir2/b",
CompressionLevel: 1,
},
},
},
},
{
name: "paths that can be sorted",
files: []*models.File{
{
Name: "b",
},
{
Name: "a",
},
},
expected: &FileNode{
Path: "",
Children: []*FileNode{
{
File: &models.File{Name: "a"},
Path: "a",
},
{
File: &models.File{Name: "b"},
Path: "b",
},
},
},
},
{
name: "paths that can be sorted including a merge conflict file",
files: []*models.File{
{
Name: "b",
},
{
Name: "z",
HasMergeConflicts: true,
},
{
Name: "a",
},
},
expected: &FileNode{
Path: "",
// it is a little strange that we're not bubbling up our merge conflict
// here but we are technically still in in tree mode and that's the rule
Children: []*FileNode{
{
File: &models.File{Name: "a"},
Path: "a",
},
{
File: &models.File{Name: "b"},
Path: "b",
},
{
File: &models.File{Name: "z", HasMergeConflicts: true},
Path: "z",
},
},
},
},
}
for _, s := range scenarios {
s := s
t.Run(s.name, func(t *testing.T) {
result := BuildTreeFromFiles(s.files)
assert.EqualValues(t, s.expected, result)
})
}
}
func TestBuildFlatTreeFromFiles(t *testing.T) {
scenarios := []struct {
name string
files []*models.File
expected *FileNode
}{
{
name: "no files",
files: []*models.File{},
expected: &FileNode{
Path: "",
Children: []*FileNode{},
},
},
{
name: "files in same directory",
files: []*models.File{
{
Name: "dir1/a",
},
{
Name: "dir1/b",
},
},
expected: &FileNode{
Path: "",
Children: []*FileNode{
{
File: &models.File{Name: "dir1/a"},
Path: "dir1/a",
CompressionLevel: 0,
},
{
File: &models.File{Name: "dir1/b"},
Path: "dir1/b",
CompressionLevel: 0,
},
},
},
},
{
name: "paths that can be compressed",
files: []*models.File{
{
Name: "dir1/a",
},
{
Name: "dir2/b",
},
},
expected: &FileNode{
Path: "",
Children: []*FileNode{
{
File: &models.File{Name: "dir1/a"},
Path: "dir1/a",
CompressionLevel: 1,
},
{
File: &models.File{Name: "dir2/b"},
Path: "dir2/b",
CompressionLevel: 1,
},
},
},
},
{
name: "paths that can be sorted",
files: []*models.File{
{
Name: "b",
},
{
Name: "a",
},
},
expected: &FileNode{
Path: "",
Children: []*FileNode{
{
File: &models.File{Name: "a"},
Path: "a",
},
{
File: &models.File{Name: "b"},
Path: "b",
},
},
},
},
{
name: "paths that can be sorted including a merge conflict file",
files: []*models.File{
{
Name: "z",
HasMergeConflicts: true,
},
{
Name: "b",
},
{
Name: "a",
},
},
expected: &FileNode{
Path: "",
Children: []*FileNode{
{
File: &models.File{Name: "z", HasMergeConflicts: true},
Path: "z",
},
{
File: &models.File{Name: "a"},
Path: "a",
},
{
File: &models.File{Name: "b"},
Path: "b",
},
},
},
},
}
for _, s := range scenarios {
s := s
t.Run(s.name, func(t *testing.T) {
result := BuildFlatTreeFromFiles(s.files)
assert.EqualValues(t, s.expected, result)
})
}
}
func TestBuildTreeFromCommitFiles(t *testing.T) {
scenarios := []struct {
name string
files []*models.CommitFile
expected *CommitFileNode
}{
{
name: "no files",
files: []*models.CommitFile{},
expected: &CommitFileNode{
Path: "",
Children: []*CommitFileNode{},
},
},
{
name: "files in same directory",
files: []*models.CommitFile{
{
Name: "dir1/a",
},
{
Name: "dir1/b",
},
},
expected: &CommitFileNode{
Path: "",
Children: []*CommitFileNode{
{
Path: "dir1",
Children: []*CommitFileNode{
{
File: &models.CommitFile{Name: "dir1/a"},
Path: "dir1/a",
},
{
File: &models.CommitFile{Name: "dir1/b"},
Path: "dir1/b",
},
},
},
},
},
},
{
name: "paths that can be compressed",
files: []*models.CommitFile{
{
Name: "dir1/a",
},
{
Name: "dir2/b",
},
},
expected: &CommitFileNode{
Path: "",
Children: []*CommitFileNode{
{
File: &models.CommitFile{Name: "dir1/a"},
Path: "dir1/a",
CompressionLevel: 1,
},
{
File: &models.CommitFile{Name: "dir2/b"},
Path: "dir2/b",
CompressionLevel: 1,
},
},
},
},
{
name: "paths that can be sorted",
files: []*models.CommitFile{
{
Name: "b",
},
{
Name: "a",
},
},
expected: &CommitFileNode{
Path: "",
Children: []*CommitFileNode{
{
File: &models.CommitFile{Name: "a"},
Path: "a",
},
{
File: &models.CommitFile{Name: "b"},
Path: "b",
},
},
},
},
}
for _, s := range scenarios {
s := s
t.Run(s.name, func(t *testing.T) {
result := BuildTreeFromCommitFiles(s.files)
assert.EqualValues(t, s.expected, result)
})
}
}
func TestBuildFlatTreeFromCommitFiles(t *testing.T) {
scenarios := []struct {
name string
files []*models.CommitFile
expected *CommitFileNode
}{
{
name: "no files",
files: []*models.CommitFile{},
expected: &CommitFileNode{
Path: "",
Children: []*CommitFileNode{},
},
},
{
name: "files in same directory",
files: []*models.CommitFile{
{
Name: "dir1/a",
},
{
Name: "dir1/b",
},
},
expected: &CommitFileNode{
Path: "",
Children: []*CommitFileNode{
{
File: &models.CommitFile{Name: "dir1/a"},
Path: "dir1/a",
CompressionLevel: 0,
},
{
File: &models.CommitFile{Name: "dir1/b"},
Path: "dir1/b",
CompressionLevel: 0,
},
},
},
},
{
name: "paths that can be compressed",
files: []*models.CommitFile{
{
Name: "dir1/a",
},
{
Name: "dir2/b",
},
},
expected: &CommitFileNode{
Path: "",
Children: []*CommitFileNode{
{
File: &models.CommitFile{Name: "dir1/a"},
Path: "dir1/a",
CompressionLevel: 1,
},
{
File: &models.CommitFile{Name: "dir2/b"},
Path: "dir2/b",
CompressionLevel: 1,
},
},
},
},
{
name: "paths that can be sorted",
files: []*models.CommitFile{
{
Name: "b",
},
{
Name: "a",
},
},
expected: &CommitFileNode{
Path: "",
Children: []*CommitFileNode{
{
File: &models.CommitFile{Name: "a"},
Path: "a",
},
{
File: &models.CommitFile{Name: "b"},
Path: "b",
},
},
},
},
}
for _, s := range scenarios {
s := s
t.Run(s.name, func(t *testing.T) {
result := BuildFlatTreeFromCommitFiles(s.files)
assert.EqualValues(t, s.expected, result)
})
}
}

View File

@@ -1,17 +1,12 @@
package filetree
import (
"os"
"strings"
)
type CollapsedPaths map[string]bool
func (cp CollapsedPaths) ExpandToPath(path string) {
// need every directory along the way
split := strings.Split(path, string(os.PathSeparator))
for i := range split {
dir := strings.Join(split[0:i+1], string(os.PathSeparator))
splitPath := split(path)
for i := range splitPath {
dir := join(splitPath[0 : i+1])
cp[dir] = false
}
}

View File

@@ -1,10 +1,6 @@
package filetree
import (
"os"
"path/filepath"
"strings"
"github.com/jesseduffield/lazygit/pkg/commands/models"
)
@@ -169,8 +165,8 @@ func (s *CommitFileNode) AnyFile(test func(file *models.CommitFile) bool) bool {
}
func (s *CommitFileNode) NameAtDepth(depth int) string {
splitName := strings.Split(s.Path, string(os.PathSeparator))
name := filepath.Join(splitName[depth:]...)
splitName := split(s.Path)
name := join(splitName[depth:])
return name
}

View File

@@ -2,9 +2,6 @@ package filetree
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/jesseduffield/lazygit/pkg/commands/models"
)
@@ -171,18 +168,18 @@ func (s *FileNode) AnyFile(test func(file *models.File) bool) bool {
}
func (s *FileNode) NameAtDepth(depth int) string {
splitName := strings.Split(s.Path, string(os.PathSeparator))
name := filepath.Join(splitName[depth:]...)
splitName := split(s.Path)
name := join(splitName[depth:])
if s.File != nil && s.File.IsRename() {
splitPrevName := strings.Split(s.File.PreviousName, string(os.PathSeparator))
splitPrevName := split(s.File.PreviousName)
prevName := s.File.PreviousName
// if the file has just been renamed inside the same directory, we can shave off
// the prefix for the previous path too. Otherwise we'll keep it unchanged
sameParentDir := len(splitName) == len(splitPrevName) && filepath.Join(splitName[0:depth]...) == filepath.Join(splitPrevName[0:depth]...)
sameParentDir := len(splitName) == len(splitPrevName) && join(splitName[0:depth]) == join(splitPrevName[0:depth])
if sameParentDir {
prevName = filepath.Join(splitPrevName[depth:]...)
prevName = join(splitPrevName[depth:])
}
return fmt.Sprintf("%s%s%s", prevName, " → ", name)

View File

@@ -31,7 +31,7 @@ func (gui *Gui) gitFlowFinishBranch(gitFlowConfig string, branchName string) err
return gui.createErrorPanel(gui.Tr.NotAGitFlowBranch)
}
return gui.runSubprocessWithSuspense(
return gui.runSubprocessWithSuspenseAndRefresh(
gui.OSCommand.PrepareSubProcess("git", "flow", branchType, "finish", suffix),
)
}
@@ -55,7 +55,7 @@ func (gui *Gui) handleCreateGitFlowMenu() error {
return gui.prompt(promptOpts{
title: title,
handleConfirm: func(name string) error {
return gui.runSubprocessWithSuspense(
return gui.runSubprocessWithSuspenseAndRefresh(
gui.OSCommand.PrepareSubProcess("git", "flow", branchType, "start", name),
)
},

41
pkg/gui/gpg.go Normal file
View File

@@ -0,0 +1,41 @@
package gui
// Currently there is a bug where if we switch to a subprocess from within
// WithWaitingStatus we get stuck there and can't return to lazygit. We could
// fix this bug, or just stop running subprocesses from within there, given that
// we don't need to see a loading status if we're in a subprocess.
func (gui *Gui) withGpgHandling(cmdStr string, waitingStatus string, onSuccess func() error) error {
useSubprocess := gui.GitCommand.UsingGpg()
if useSubprocess {
// Need to remember why we use the shell for the subprocess but not in the other case
// Maybe there's no good reason
success, err := gui.runSubprocessWithSuspense(gui.OSCommand.ShellCommandFromString(cmdStr))
if success && onSuccess != nil {
if err := onSuccess(); err != nil {
return err
}
}
if err := gui.refreshSidePanels(refreshOptions{mode: ASYNC}); err != nil {
return err
}
if err != nil {
return err
}
} else {
return gui.WithWaitingStatus(waitingStatus, func() error {
err := gui.OSCommand.RunCommand(cmdStr)
if err != nil {
return err
} else if onSuccess != nil {
if err := onSuccess(); err != nil {
return err
}
}
return gui.refreshSidePanels(refreshOptions{mode: ASYNC})
})
}
return nil
}

View File

@@ -90,7 +90,7 @@ type Gui struct {
// recent repo with the recent repos popup showing
showRecentRepos bool
Mutexes guiStateMutexes
Mutexes guiMutexes
// findSuggestions will take a string that the user has typed into a prompt
// and return a slice of suggestions which match that string.
@@ -104,6 +104,11 @@ type Gui struct {
ViewsSetup bool
Views Views
// if we've suspended the gui (e.g. because we've switched to a subprocess)
// we typically want to pause some things that are running like background
// file refreshes
PauseBackgroundThreads bool
}
type listPanelState struct {
@@ -288,12 +293,13 @@ type Modes struct {
Diffing Diffing
}
type guiStateMutexes struct {
type guiMutexes struct {
RefreshingFilesMutex sync.Mutex
RefreshingStatusMutex sync.Mutex
FetchMutex sync.Mutex
BranchCommitsMutex sync.Mutex
LineByLinePanelMutex sync.Mutex
SubprocessMutex sync.Mutex
}
type guiState struct {
@@ -476,6 +482,7 @@ func (gui *Gui) Run() error {
if err != nil {
return err
}
gui.g = g // TODO: always use gui.g rather than passing g around everywhere
defer g.Close()
@@ -515,15 +522,6 @@ func (gui *Gui) Run() error {
return err
}
if !gui.Config.GetUserConfig().DisableStartupPopups {
popupTasks := []func(chan struct{}) error{}
storedPopupVersion := gui.Config.GetAppState().StartupPopupVersion
if storedPopupVersion < StartupPopupVersion {
popupTasks = append(popupTasks, gui.showIntroPopupMessage)
}
gui.showInitialPopups(popupTasks)
}
gui.waitForIntro.Add(1)
if gui.Config.GetUserConfig().Git.AutoFetch {
go utils.Safe(gui.startBackgroundFetch)
@@ -577,7 +575,25 @@ func (gui *Gui) RunAndHandleError() error {
})
}
func (gui *Gui) runSubprocessWithSuspense(subprocess *exec.Cmd) error {
// returns whether command exited without error or not
func (gui *Gui) runSubprocessWithSuspenseAndRefresh(subprocess *exec.Cmd) error {
_, err := gui.runSubprocessWithSuspense(subprocess)
if err != nil {
return err
}
if err := gui.refreshSidePanels(refreshOptions{mode: ASYNC}); err != nil {
return err
}
return nil
}
// returns whether command exited without error or not
func (gui *Gui) runSubprocessWithSuspense(subprocess *exec.Cmd) (bool, error) {
gui.Mutexes.SubprocessMutex.Lock()
defer gui.Mutexes.SubprocessMutex.Unlock()
if replaying() {
// we do not yet support running subprocesses within integration tests. So if
// we're replaying an integration test and we're inside this method, something
@@ -586,21 +602,21 @@ func (gui *Gui) runSubprocessWithSuspense(subprocess *exec.Cmd) error {
log.Fatal("opening subprocesses not yet supported in integration tests. Chances are that this test is running too fast and a subprocess is accidentally opened")
}
if err := gocui.Screen.Suspend(); err != nil {
return gui.surfaceError(err)
if err := gui.g.Suspend(); err != nil {
return false, gui.surfaceError(err)
}
gui.PauseBackgroundThreads = true
cmdErr := gui.runSubprocess(subprocess)
if err := gocui.Screen.Resume(); err != nil {
return gui.surfaceError(err)
if err := gui.g.Resume(); err != nil {
return false, err
}
if err := gui.refreshSidePanels(refreshOptions{mode: ASYNC}); err != nil {
return err
}
gui.PauseBackgroundThreads = false
return gui.surfaceError(cmdErr)
return cmdErr == nil, gui.surfaceError(cmdErr)
}
func (gui *Gui) runSubprocess(subprocess *exec.Cmd) error {
@@ -679,6 +695,9 @@ func (gui *Gui) goEvery(interval time.Duration, stop chan struct{}, function fun
for {
select {
case <-ticker.C:
if gui.PauseBackgroundThreads {
continue
}
_ = function()
case <-stop:
return

View File

@@ -1,3 +1,5 @@
// +build !windows
package gui
import (

View File

@@ -24,21 +24,140 @@ func (gui *Gui) informationStr() string {
}
}
func (gui *Gui) createAllViews() error {
viewNameMappings := []struct {
viewPtr **gocui.View
name string
}{
{viewPtr: &gui.Views.Status, name: "status"},
{viewPtr: &gui.Views.Files, name: "files"},
{viewPtr: &gui.Views.Branches, name: "branches"},
{viewPtr: &gui.Views.Commits, name: "commits"},
{viewPtr: &gui.Views.Stash, name: "stash"},
{viewPtr: &gui.Views.CommitFiles, name: "commitFiles"},
{viewPtr: &gui.Views.Main, name: "main"},
{viewPtr: &gui.Views.Secondary, name: "secondary"},
{viewPtr: &gui.Views.Options, name: "options"},
{viewPtr: &gui.Views.AppStatus, name: "appStatus"},
{viewPtr: &gui.Views.Information, name: "information"},
{viewPtr: &gui.Views.Search, name: "search"},
{viewPtr: &gui.Views.SearchPrefix, name: "searchPrefix"},
{viewPtr: &gui.Views.CommitMessage, name: "commitMessage"},
{viewPtr: &gui.Views.Credentials, name: "credentials"},
{viewPtr: &gui.Views.Menu, name: "menu"},
{viewPtr: &gui.Views.Suggestions, name: "suggestions"},
{viewPtr: &gui.Views.Confirmation, name: "confirmation"},
{viewPtr: &gui.Views.Limit, name: "limit"},
}
var err error
for _, mapping := range viewNameMappings {
*mapping.viewPtr, err = gui.prepareView(mapping.name)
if err != nil && err.Error() != UNKNOWN_VIEW_ERROR_MSG {
return err
}
}
gui.Views.Options.Frame = false
gui.Views.Options.FgColor = theme.OptionsColor
gui.Views.SearchPrefix.BgColor = gocui.ColorDefault
gui.Views.SearchPrefix.FgColor = gocui.ColorGreen
gui.Views.SearchPrefix.Frame = false
gui.setViewContent(gui.Views.SearchPrefix, SEARCH_PREFIX)
gui.Views.Stash.Title = gui.Tr.StashTitle
gui.Views.Stash.FgColor = theme.GocuiDefaultTextColor
gui.Views.Stash.ContainsList = true
gui.Views.Commits.Title = gui.Tr.CommitsTitle
gui.Views.Commits.FgColor = theme.GocuiDefaultTextColor
gui.Views.Commits.ContainsList = true
gui.Views.CommitFiles.Title = gui.Tr.CommitFiles
gui.Views.CommitFiles.FgColor = theme.GocuiDefaultTextColor
gui.Views.CommitFiles.ContainsList = true
gui.Views.Branches.Title = gui.Tr.BranchesTitle
gui.Views.Branches.FgColor = theme.GocuiDefaultTextColor
gui.Views.Branches.ContainsList = true
gui.Views.Files.Highlight = true
gui.Views.Files.Title = gui.Tr.FilesTitle
gui.Views.Files.FgColor = theme.GocuiDefaultTextColor
gui.Views.Files.ContainsList = true
gui.Views.Secondary.Title = gui.Tr.DiffTitle
gui.Views.Secondary.Wrap = true
gui.Views.Secondary.FgColor = theme.GocuiDefaultTextColor
gui.Views.Secondary.IgnoreCarriageReturns = true
gui.Views.Main.Title = gui.Tr.DiffTitle
gui.Views.Main.Wrap = true
gui.Views.Main.FgColor = theme.GocuiDefaultTextColor
gui.Views.Main.IgnoreCarriageReturns = true
gui.Views.Limit.Title = gui.Tr.NotEnoughSpace
gui.Views.Limit.Wrap = true
gui.Views.Status.Title = gui.Tr.StatusTitle
gui.Views.Status.FgColor = theme.GocuiDefaultTextColor
gui.Views.Search.BgColor = gocui.ColorDefault
gui.Views.Search.FgColor = gocui.ColorGreen
gui.Views.Search.Frame = false
gui.Views.Search.Editable = true
gui.Views.AppStatus.BgColor = gocui.ColorDefault
gui.Views.AppStatus.FgColor = gocui.ColorCyan
gui.Views.AppStatus.Frame = false
gui.Views.AppStatus.Visible = false
gui.Views.CommitMessage.Visible = false
gui.Views.CommitMessage.Title = gui.Tr.CommitMessage
gui.Views.CommitMessage.FgColor = theme.GocuiDefaultTextColor
gui.Views.CommitMessage.Editable = true
gui.Views.CommitMessage.Editor = gocui.EditorFunc(gui.commitMessageEditor)
gui.Views.Confirmation.Visible = false
gui.Views.Credentials.Visible = false
gui.Views.Credentials.Title = gui.Tr.CredentialsUsername
gui.Views.Credentials.FgColor = theme.GocuiDefaultTextColor
gui.Views.Credentials.Editable = true
gui.Views.Suggestions.Visible = false
gui.Views.Menu.Visible = false
gui.Views.Information.BgColor = gocui.ColorDefault
gui.Views.Information.FgColor = gocui.ColorGreen
gui.Views.Information.Frame = false
if _, err := gui.g.SetCurrentView(gui.defaultSideContext().GetViewName()); err != nil {
return err
}
return nil
}
// layout is called for every screen re-render e.g. when the screen is resized
func (gui *Gui) layout(g *gocui.Gui) error {
if !gui.ViewsSetup {
if err := gui.createAllViews(); err != nil {
return err
}
}
g.Highlight = true
width, height := g.Size()
minimumHeight := 9
minimumWidth := 10
var err error
gui.Views.Limit, err = g.SetView("limit", 0, 0, width-1, height-1, 0)
if err != nil {
if err.Error() != UNKNOWN_VIEW_ERROR_MSG {
return err
}
gui.Views.Limit.Title = gui.Tr.NotEnoughSpace
gui.Views.Limit.Wrap = true
_, err = g.SetView("limit", 0, 0, width-1, height-1, 0)
if err != nil && err.Error() != UNKNOWN_VIEW_ERROR_MSG {
return err
}
gui.Views.Limit.Visible = height < minimumHeight || width < minimumWidth
@@ -98,140 +217,36 @@ func (gui *Gui) layout(g *gocui.Gui) error {
return view, err
}
gui.Views.Main, err = setViewFromDimensions("main", "main", true)
if err != nil {
if err.Error() != UNKNOWN_VIEW_ERROR_MSG {
return err
}
gui.Views.Main.Title = gui.Tr.DiffTitle
gui.Views.Main.Wrap = true
gui.Views.Main.FgColor = theme.GocuiDefaultTextColor
gui.Views.Main.IgnoreCarriageReturns = true
args := []struct {
viewName string
windowName string
frame bool
}{
{viewName: "main", windowName: "main", frame: true},
{viewName: "secondary", windowName: "secondary", frame: true},
{viewName: "status", windowName: "status", frame: true},
{viewName: "files", windowName: "files", frame: true},
{viewName: "branches", windowName: "branches", frame: true},
{viewName: "commitFiles", windowName: gui.State.Contexts.CommitFiles.GetWindowName(), frame: true},
{viewName: "commits", windowName: "commits", frame: true},
{viewName: "stash", windowName: "stash", frame: true},
{viewName: "options", windowName: "options", frame: false},
{viewName: "searchPrefix", windowName: "searchPrefix", frame: false},
{viewName: "search", windowName: "search", frame: false},
{viewName: "appStatus", windowName: "appStatus", frame: false},
{viewName: "information", windowName: "information", frame: false},
}
gui.Views.Secondary, err = setViewFromDimensions("secondary", "secondary", true)
if err != nil {
if err.Error() != UNKNOWN_VIEW_ERROR_MSG {
for _, arg := range args {
_, err = setViewFromDimensions(arg.viewName, arg.windowName, arg.frame)
if err != nil && err.Error() != UNKNOWN_VIEW_ERROR_MSG {
return err
}
gui.Views.Secondary.Title = gui.Tr.DiffTitle
gui.Views.Secondary.Wrap = true
gui.Views.Secondary.FgColor = theme.GocuiDefaultTextColor
gui.Views.Secondary.IgnoreCarriageReturns = true
}
if gui.Views.Status, err = setViewFromDimensions("status", "status", true); err != nil {
if err.Error() != UNKNOWN_VIEW_ERROR_MSG {
return err
}
gui.Views.Status.Title = gui.Tr.StatusTitle
gui.Views.Status.FgColor = theme.GocuiDefaultTextColor
}
gui.Views.Files, err = setViewFromDimensions("files", "files", true)
if err != nil {
if err.Error() != UNKNOWN_VIEW_ERROR_MSG {
return err
}
gui.Views.Files.Highlight = true
gui.Views.Files.Title = gui.Tr.FilesTitle
gui.Views.Files.FgColor = theme.GocuiDefaultTextColor
gui.Views.Files.ContainsList = true
}
gui.Views.Branches, err = setViewFromDimensions("branches", "branches", true)
if err != nil {
if err.Error() != UNKNOWN_VIEW_ERROR_MSG {
return err
}
gui.Views.Branches.Title = gui.Tr.BranchesTitle
gui.Views.Branches.FgColor = theme.GocuiDefaultTextColor
gui.Views.Branches.ContainsList = true
}
gui.Views.CommitFiles, err = setViewFromDimensions("commitFiles", gui.State.Contexts.CommitFiles.GetWindowName(), true)
if err != nil {
if err.Error() != UNKNOWN_VIEW_ERROR_MSG {
return err
}
gui.Views.CommitFiles.Title = gui.Tr.CommitFiles
gui.Views.CommitFiles.FgColor = theme.GocuiDefaultTextColor
gui.Views.CommitFiles.ContainsList = true
}
// if the commit files view is the view to be displayed for its window, we'll display it
gui.Views.CommitFiles.Visible = gui.getViewNameForWindow(gui.State.Contexts.CommitFiles.GetWindowName()) == "commitFiles"
gui.Views.Commits, err = setViewFromDimensions("commits", "commits", true)
if err != nil {
if err.Error() != UNKNOWN_VIEW_ERROR_MSG {
return err
}
gui.Views.Commits.Title = gui.Tr.CommitsTitle
gui.Views.Commits.FgColor = theme.GocuiDefaultTextColor
gui.Views.Commits.ContainsList = true
}
gui.Views.Stash, err = setViewFromDimensions("stash", "stash", true)
if err != nil {
if err.Error() != UNKNOWN_VIEW_ERROR_MSG {
return err
}
gui.Views.Stash.Title = gui.Tr.StashTitle
gui.Views.Stash.FgColor = theme.GocuiDefaultTextColor
gui.Views.Stash.ContainsList = true
}
if gui.Views.Options, err = setViewFromDimensions("options", "options", false); err != nil {
if err.Error() != UNKNOWN_VIEW_ERROR_MSG {
return err
}
gui.Views.Options.Frame = false
gui.Views.Options.FgColor = theme.OptionsColor
}
// this view takes up one character. Its only purpose is to show the slash when searching
if gui.Views.SearchPrefix, err = setViewFromDimensions("searchPrefix", "searchPrefix", false); err != nil {
if err.Error() != UNKNOWN_VIEW_ERROR_MSG {
return err
}
gui.Views.SearchPrefix.BgColor = gocui.ColorDefault
gui.Views.SearchPrefix.FgColor = gocui.ColorGreen
gui.Views.SearchPrefix.Frame = false
gui.setViewContent(gui.Views.SearchPrefix, SEARCH_PREFIX)
}
if gui.Views.Search, err = setViewFromDimensions("search", "search", false); err != nil {
if err.Error() != UNKNOWN_VIEW_ERROR_MSG {
return err
}
gui.Views.Search.BgColor = gocui.ColorDefault
gui.Views.Search.FgColor = gocui.ColorGreen
gui.Views.Search.Frame = false
gui.Views.Search.Editable = true
}
if gui.Views.AppStatus, err = setViewFromDimensions("appStatus", "appStatus", false); err != nil {
if err.Error() != UNKNOWN_VIEW_ERROR_MSG {
return err
}
gui.Views.AppStatus.BgColor = gocui.ColorDefault
gui.Views.AppStatus.FgColor = gocui.ColorCyan
gui.Views.AppStatus.Frame = false
gui.Views.AppStatus.Visible = false
}
gui.Views.Information, err = setViewFromDimensions("information", "information", false)
if err != nil {
if err.Error() != UNKNOWN_VIEW_ERROR_MSG {
return err
}
gui.Views.Information.BgColor = gocui.ColorDefault
gui.Views.Information.FgColor = gocui.ColorGreen
gui.Views.Information.Frame = false
gui.renderString(gui.Views.Information, INFO_SECTION_PADDING+informationStr)
}
if gui.State.OldInformation != informationStr {
gui.setViewContent(gui.Views.Information, informationStr)
gui.State.OldInformation = informationStr
@@ -291,7 +306,7 @@ func (gui *Gui) layout(g *gocui.Gui) error {
return gui.resizeCurrentPopupPanel()
}
func (gui *Gui) setHiddenView(viewName string) (*gocui.View, error) {
func (gui *Gui) prepareView(viewName string) (*gocui.View, error) {
// arbitrarily giving the view enough size so that we don't get an error, but
// it's expected that the view will be given the correct size before being shown
return gui.g.SetView(viewName, 0, 0, 10, 10, 0)
@@ -317,11 +332,6 @@ func (gui *Gui) onInitialViewsCreationForRepo() error {
}
func (gui *Gui) onInitialViewsCreation() error {
// creating some views which are hidden at the start but we need to exist so that we can set an initial ordering
if err := gui.createHiddenViews(); err != nil {
return err
}
// now we order the views (in order of bottom first)
layerOneViews := []*gocui.View{
// first layer. Ordering within this layer does not matter because there are
@@ -340,15 +350,15 @@ func (gui *Gui) onInitialViewsCreation() error {
gui.Views.AppStatus,
gui.Views.Information,
gui.Views.Search,
gui.Views.SearchPrefix,
gui.Views.SearchPrefix, // this view takes up one character. Its only purpose is to show the slash when searching
// popups. Ordering within this layer does not matter because there should
// only be one popup shown at a time
gui.Views.CommitMessage,
gui.Views.Credentials,
gui.Views.Menu,
gui.Views.Suggestions,
gui.Views.Confirmation,
gui.Views.Credentials,
// this guy will cover everything else when it appears
gui.Views.Limit,
@@ -375,6 +385,15 @@ func (gui *Gui) onInitialViewsCreation() error {
return err
}
if !gui.Config.GetUserConfig().DisableStartupPopups {
popupTasks := []func(chan struct{}) error{}
storedPopupVersion := gui.Config.GetAppState().StartupPopupVersion
if storedPopupVersion < StartupPopupVersion {
popupTasks = append(popupTasks, gui.showIntroPopupMessage)
}
gui.showInitialPopups(popupTasks)
}
if gui.showRecentRepos {
if err := gui.handleCreateRecentReposMenu(); err != nil {
return err
@@ -388,58 +407,3 @@ func (gui *Gui) onInitialViewsCreation() error {
return nil
}
func (gui *Gui) createHiddenViews() error {
// doesn't matter where this view starts because it will be hidden
var err error
if gui.Views.CommitMessage, err = gui.setHiddenView("commitMessage"); err != nil {
if err.Error() != UNKNOWN_VIEW_ERROR_MSG {
return err
}
gui.Views.CommitMessage.Visible = false
gui.Views.CommitMessage.Title = gui.Tr.CommitMessage
gui.Views.CommitMessage.FgColor = theme.GocuiDefaultTextColor
gui.Views.CommitMessage.Editable = true
gui.Views.CommitMessage.Editor = gocui.EditorFunc(gui.commitMessageEditor)
}
// doesn't matter where this view starts because it will be hidden
if gui.Views.Credentials, err = gui.setHiddenView("credentials"); err != nil {
if err.Error() != UNKNOWN_VIEW_ERROR_MSG {
return err
}
gui.Views.Credentials.Visible = false
gui.Views.Credentials.Title = gui.Tr.CredentialsUsername
gui.Views.Credentials.FgColor = theme.GocuiDefaultTextColor
gui.Views.Credentials.Editable = true
}
// not worrying about setting attributes because that will be done when the view is actually shown
gui.Views.Confirmation, err = gui.setHiddenView("confirmation")
if err != nil {
if err.Error() != UNKNOWN_VIEW_ERROR_MSG {
return err
}
gui.Views.Confirmation.Visible = false
}
// not worrying about setting attributes because that will be done when the view is actually shown
gui.Views.Suggestions, err = gui.setHiddenView("suggestions")
if err != nil {
if err.Error() != UNKNOWN_VIEW_ERROR_MSG {
return err
}
gui.Views.Suggestions.Visible = false
}
// not worrying about setting attributes because that will be done when the view is actually shown
gui.Views.Menu, err = gui.setHiddenView("menu")
if err != nil {
if err.Error() != UNKNOWN_VIEW_ERROR_MSG {
return err
}
gui.Views.Menu.Visible = false
}
return nil
}

View File

@@ -50,7 +50,7 @@ func (gui *Gui) genericMergeCommand(command string) error {
if status == commands.REBASE_MODE_MERGING && command != "abort" && gui.Config.GetUserConfig().Git.Merging.ManualCommit {
sub := gui.OSCommand.PrepareSubProcess("git", commandType, fmt.Sprintf("--%s", command))
if sub != nil {
return gui.runSubprocessWithSuspense(sub)
return gui.runSubprocessWithSuspenseAndRefresh(sub)
}
return nil
}

View File

@@ -89,7 +89,7 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager {
// overwriting the existing content from the top down. Once we've reached
// the end of the content do display, we call view.FlushStaleCells() to
// clear out the remaining content from the previous render.
view.Rewind()
view.Reset()
},
func() {
gui.g.Update(func(*gocui.Gui) error {

View File

@@ -256,6 +256,7 @@ type TranslationSet struct {
UndoingStatus string
RedoingStatus string
CheckingOutStatus string
CommittingStatus string
CommitFiles string
LcViewCommitFiles string
CommitFilesTitle string
@@ -891,6 +892,7 @@ func englishTranslationSet() TranslationSet {
UndoingStatus: "undoing",
RedoingStatus: "redoing",
CheckingOutStatus: "checking out",
CommittingStatus: "committing",
CommitFiles: "Commit files",
LcViewCommitFiles: "view commit's files",
CommitFilesTitle: "Commit Files",

View File

@@ -71,7 +71,7 @@ func RunTests(
logf("path: %s", testPath)
// three retries at normal speed for the sake of flakey tests
speeds = append(speeds, 1, 0.5, 0.5)
speeds = append(speeds, 1)
for i, speed := range speeds {
logf("%s: attempting test at speed %f\n", test.Name, speed)

View File

@@ -0,0 +1 @@
asd

View File

@@ -0,0 +1 @@
ref: refs/heads/master

View File

@@ -0,0 +1,10 @@
[core]
repositoryformatversion = 0
filemode = true
bare = false
logallrefupdates = true
ignorecase = true
precomposeunicode = true
[user]
email = CI@example.com
name = CI

View File

@@ -0,0 +1 @@
Unnamed repository; edit this file 'description' to name the repository.

Binary file not shown.

View File

@@ -0,0 +1,7 @@
# git ls-files --others --exclude-from=.git/info/exclude
# Lines that start with '#' are comments.
# For a project mostly in C, the following would be a good set of
# exclude patterns (uncomment them if you want to use them):
# *.[oa]
# *~
.DS_Store

View File

@@ -0,0 +1,2 @@
0000000000000000000000000000000000000000 46f86259c48ec60496e43d9c962e32f40e7cdefb CI <CI@example.com> 1617799345 +1000 commit (initial): myfile1
46f86259c48ec60496e43d9c962e32f40e7cdefb e4776798a2a73374b45e6321b60b5578b9fb590c CI <CI@example.com> 1617799348 +1000 commit: asd

View File

@@ -0,0 +1,2 @@
0000000000000000000000000000000000000000 46f86259c48ec60496e43d9c962e32f40e7cdefb CI <CI@example.com> 1617799345 +1000 commit (initial): myfile1
46f86259c48ec60496e43d9c962e32f40e7cdefb e4776798a2a73374b45e6321b60b5578b9fb590c CI <CI@example.com> 1617799348 +1000 commit: asd

View File

@@ -0,0 +1,2 @@
x<01>ÎM
1 @a×=E÷¤iš6 "¸šcô'EÁq†±ÇwŽàöñ-^]æù1¬8ŒMÕ2zˆÁµ¢SHHÑ7Œ\v-T ™5oú¸'Æ •VVòMª0ªÇN ±6íÅäϸ/½Mö|®úÍóúÔS]æuìbñ”ìÑ€Ùë>5ôOnò»™¨q8™

View File

@@ -0,0 +1 @@
e4776798a2a73374b45e6321b60b5578b9fb590c

View File

@@ -0,0 +1 @@
test1

View File

@@ -0,0 +1 @@
test1

View File

@@ -0,0 +1 @@
{"KeyEvents":[{"Timestamp":891,"Mod":0,"Key":27,"Ch":0},{"Timestamp":1344,"Mod":0,"Key":256,"Ch":32},{"Timestamp":1639,"Mod":0,"Key":256,"Ch":99},{"Timestamp":2128,"Mod":0,"Key":256,"Ch":97},{"Timestamp":2176,"Mod":0,"Key":256,"Ch":115},{"Timestamp":2280,"Mod":0,"Key":256,"Ch":100},{"Timestamp":2592,"Mod":0,"Key":13,"Ch":13},{"Timestamp":2960,"Mod":0,"Key":256,"Ch":113}],"ResizeEvents":[{"Timestamp":0,"Width":272,"Height":74}]}

View File

@@ -0,0 +1,14 @@
#!/bin/sh
cd $1
git init
git config user.email "CI@example.com"
git config user.name "CI"
echo test1 > myfile1
git add .
git commit -am "myfile1"
echo test1 > myfile2

View File

@@ -0,0 +1 @@
{ "description": "testing the initial popup appears when first starting lazygit", "speed": 15 }

View File

@@ -13,6 +13,7 @@ import (
"sync"
"time"
"github.com/gdamore/tcell/v2"
"github.com/go-errors/errors"
)
@@ -158,6 +159,10 @@ type Gui struct {
SearchEscapeKey interface{}
NextSearchMatchKey interface{}
PrevSearchMatchKey interface{}
screen tcell.Screen
suspendedMutex sync.Mutex
suspended bool
}
// NewGui returns a new Gui object with a given output mode.
@@ -166,9 +171,9 @@ func NewGui(mode OutputMode, supportOverlaps bool, playMode PlayMode, headless b
var err error
if headless {
err = tcellInitSimulation()
err = g.tcellInitSimulation()
} else {
err = tcellInit()
err = g.tcellInit()
}
if err != nil {
return nil, err
@@ -266,11 +271,14 @@ func (g *Gui) SetView(name string, x0, y0, x1, y1 int, overlaps byte) (*View, er
}
if v, err := g.View(name); err == nil {
if v.x0 != x0 || v.x1 != x1 || v.y0 != y0 || v.y1 != y1 {
v.tainted = true
}
v.x0 = x0
v.y0 = y0
v.x1 = x1
v.y1 = y1
v.tainted = true
return v, nil
}
@@ -997,6 +1005,10 @@ func (g *Gui) drawListFooter(v *View, fgColor, bgColor Attribute) error {
// draw manages the cursor and calls the draw function of a view.
func (g *Gui) draw(v *View) error {
if g.suspended {
return nil
}
if g.Cursor {
if curview := g.currentView; curview != nil {
vMaxX, vMaxY := curview.Size()
@@ -1163,6 +1175,11 @@ func (g *Gui) StartTicking() {
for {
select {
case <-ticker.C:
// I'm okay with having a data race here: there's no harm in letting one of these updates through
if g.suspended {
continue outer
}
for _, view := range g.Views() {
if view.HasLoader {
g.userEvents <- userEvent{func(g *Gui) error { return nil }}
@@ -1287,3 +1304,29 @@ func (g *Gui) replayRecording() {
log.Fatal("gocui should have already exited")
}
func (g *Gui) Suspend() error {
g.suspendedMutex.Lock()
defer g.suspendedMutex.Unlock()
if g.suspended {
return errors.New("Already suspended")
}
g.suspended = true
return g.screen.Suspend()
}
func (g *Gui) Resume() error {
g.suspendedMutex.Lock()
defer g.suspendedMutex.Unlock()
if !g.suspended {
return errors.New("Cannot resume because we are not suspended")
}
g.suspended = false
return g.screen.Resume()
}

View File

@@ -2,19 +2,6 @@ package gocui
import "time"
func (g *Gui) loaderTick() {
go func() {
for range time.Tick(time.Millisecond * 50) {
for _, view := range g.Views() {
if view.HasLoader {
g.userEvents <- userEvent{func(g *Gui) error { return nil }}
break
}
}
}
}()
}
func (v *View) loaderLines() [][]cell {
duplicate := make([][]cell, len(v.lines))
for i := range v.lines {

View File

@@ -21,23 +21,25 @@ type oldStyle struct {
}
// tcellInit initializes tcell screen for use.
func tcellInit() error {
func (g *Gui) tcellInit() error {
if s, e := tcell.NewScreen(); e != nil {
return e
} else if e = s.Init(); e != nil {
return e
} else {
g.screen = s
Screen = s
return nil
}
}
// tcellInitSimulation initializes tcell screen for use.
func tcellInitSimulation() error {
func (g *Gui) tcellInitSimulation() error {
s := tcell.NewSimulationScreen("")
if e := s.Init(); e != nil {
return e
} else {
g.screen = s
Screen = s
return nil
}

View File

@@ -549,8 +549,6 @@ func (v *View) writeRunes(p []rune) {
for _, r := range p {
switch r {
case '\n':
// clear the rest of the line
v.lines[v.wy] = v.lines[v.wy][0:v.wx]
v.wy++
if v.wy >= len(v.lines) {
v.lines = append(v.lines, nil)
@@ -646,6 +644,19 @@ func (v *View) Read(p []byte) (n int, err error) {
return offset, io.EOF
}
// Clear empties the view's internal buffer.
// And resets reading and writing offsets.
func (v *View) Clear() {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.rewind()
v.tainted = true
v.lines = nil
v.viewLines = nil
v.clearRunes()
}
// Rewind sets read and write pos to (0, 0).
func (v *View) Rewind() {
v.writeMutex.Lock()
@@ -654,6 +665,29 @@ func (v *View) Rewind() {
v.rewind()
}
// similar to Rewind but clears lines. Also similar to Clear but doesn't reset
// viewLines
func (v *View) Reset() {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.rewind()
v.lines = nil
}
// This is for when we've done a restart for the sake of avoiding a flicker and
// we've reached the end of the new content to display: we need to clear the remaining
// content from the previous round. We do this by setting v.viewLines to nil so that
// we just render the new content from v.lines directly
func (v *View) FlushStaleCells() {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.rewind()
v.tainted = true
v.viewLines = nil
}
func (v *View) rewind() {
v.ei.reset()
@@ -733,7 +767,7 @@ func (v *View) draw() error {
v.ox = 0
}
if v.tainted {
v.viewLines = nil
lineIdx := 0
lines := v.lines
if v.HasLoader {
lines = v.loaderLines()
@@ -747,7 +781,13 @@ func (v *View) draw() error {
ls := lineWrap(line, wrap)
for j := range ls {
vline := viewLine{linesX: j, linesY: i, line: ls[j]}
v.viewLines = append(v.viewLines, vline)
if lineIdx > len(v.viewLines)-1 {
v.viewLines = append(v.viewLines, vline)
} else {
v.viewLines[lineIdx] = vline
}
lineIdx++
}
}
if !v.HasLoader {
@@ -843,37 +883,6 @@ func (v *View) realPosition(vx, vy int) (x, y int, err error) {
return x, y, nil
}
// Clear empties the view's internal buffer.
// And resets reading and writing offsets.
func (v *View) Clear() {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.rewind()
v.tainted = true
v.lines = nil
v.viewLines = nil
v.clearRunes()
}
// This is for when we've done a rewind for the sake of avoiding a flicker and
// we've reached the end of the new content to display: we need to clear the remaining
// content from the previous round.
func (v *View) FlushStaleCells() {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
// need to wipe the end of the current line and all following lines
if len(v.lines) > 0 && v.wy < len(v.lines) {
// why this needs to be +1 but the 0:v.wx part doesn't, I'm not sure
v.lines = v.lines[0 : v.wy+1]
if len(v.lines[v.wy]) > 0 && v.wx < len(v.lines[v.wy]) {
v.lines[v.wy] = v.lines[v.wy][0:v.wx]
}
}
}
// clearRunes erases all the cells in the view.
func (v *View) clearRunes() {
maxX, maxY := v.Size()

View File

@@ -1,111 +0,0 @@
// Copyright 2019 The Go 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 term
import (
"io"
"syscall"
"golang.org/x/sys/unix"
)
// State contains the state of a terminal.
type state struct {
termios unix.Termios
}
func isTerminal(fd int) bool {
_, err := unix.IoctlGetTermio(fd, unix.TCGETA)
return err == nil
}
func readPassword(fd int) ([]byte, error) {
// see also: http://src.illumos.org/source/xref/illumos-gate/usr/src/lib/libast/common/uwin/getpass.c
val, err := unix.IoctlGetTermios(fd, unix.TCGETS)
if err != nil {
return nil, err
}
oldState := *val
newState := oldState
newState.Lflag &^= syscall.ECHO
newState.Lflag |= syscall.ICANON | syscall.ISIG
newState.Iflag |= syscall.ICRNL
err = unix.IoctlSetTermios(fd, unix.TCSETS, &newState)
if err != nil {
return nil, err
}
defer unix.IoctlSetTermios(fd, unix.TCSETS, &oldState)
var buf [16]byte
var ret []byte
for {
n, err := syscall.Read(fd, buf[:])
if err != nil {
return nil, err
}
if n == 0 {
if len(ret) == 0 {
return nil, io.EOF
}
break
}
if buf[n-1] == '\n' {
n--
}
ret = append(ret, buf[:n]...)
if n < len(buf) {
break
}
}
return ret, nil
}
func makeRaw(fd int) (*State, error) {
// see http://cr.illumos.org/~webrev/andy_js/1060/
termios, err := unix.IoctlGetTermios(fd, unix.TCGETS)
if err != nil {
return nil, err
}
oldState := State{state{termios: *termios}}
termios.Iflag &^= unix.IGNBRK | unix.BRKINT | unix.PARMRK | unix.ISTRIP | unix.INLCR | unix.IGNCR | unix.ICRNL | unix.IXON
termios.Oflag &^= unix.OPOST
termios.Lflag &^= unix.ECHO | unix.ECHONL | unix.ICANON | unix.ISIG | unix.IEXTEN
termios.Cflag &^= unix.CSIZE | unix.PARENB
termios.Cflag |= unix.CS8
termios.Cc[unix.VMIN] = 1
termios.Cc[unix.VTIME] = 0
if err := unix.IoctlSetTermios(fd, unix.TCSETS, termios); err != nil {
return nil, err
}
return &oldState, nil
}
func restore(fd int, oldState *State) error {
return unix.IoctlSetTermios(fd, unix.TCSETS, &oldState.termios)
}
func getState(fd int) (*State, error) {
termios, err := unix.IoctlGetTermios(fd, unix.TCGETS)
if err != nil {
return nil, err
}
return &State{state{termios: *termios}}, nil
}
func getSize(fd int) (width, height int, err error) {
ws, err := unix.IoctlGetWinsize(fd, unix.TIOCGWINSZ)
if err != nil {
return 0, 0, err
}
return int(ws.Col), int(ws.Row), nil
}

View File

@@ -2,8 +2,8 @@
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || zos
// +build aix darwin dragonfly freebsd linux netbsd openbsd zos
//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris || zos
// +build aix darwin dragonfly freebsd linux netbsd openbsd solaris zos
package term

10
vendor/golang.org/x/term/term_unix_solaris.go generated vendored Normal file
View File

@@ -0,0 +1,10 @@
// Copyright 2019 The Go 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 term
import "golang.org/x/sys/unix"
const ioctlReadTermios = unix.TCGETS
const ioctlWriteTermios = unix.TCSETS

4
vendor/modules.txt vendored
View File

@@ -149,7 +149,7 @@ github.com/jesseduffield/go-git/v5/utils/merkletrie/filesystem
github.com/jesseduffield/go-git/v5/utils/merkletrie/index
github.com/jesseduffield/go-git/v5/utils/merkletrie/internal/frame
github.com/jesseduffield/go-git/v5/utils/merkletrie/noder
# github.com/jesseduffield/gocui v0.3.1-0.20210406065942-1b0c68414064
# github.com/jesseduffield/gocui v0.3.1-0.20210410011117-a2bb4baca390
## explicit
github.com/jesseduffield/gocui
# github.com/jesseduffield/termbox-go v0.0.0-20200823212418-a2289ed6aafe
@@ -242,7 +242,7 @@ golang.org/x/sys/internal/unsafeheader
golang.org/x/sys/plan9
golang.org/x/sys/unix
golang.org/x/sys/windows
# golang.org/x/term v0.0.0-20210317153231-de623e64d2a6
# golang.org/x/term v0.0.0-20210406210042-72f3dc4e9b72
## explicit
golang.org/x/term
# golang.org/x/text v0.3.6