mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-11 16:16:28 -04:00
Merge branch 'master' into f/open-in-terminal
This commit is contained in:
commit
d6228c9908
|
|
@ -14,6 +14,7 @@ import (
|
|||
"github.com/spf13/afero"
|
||||
|
||||
appTypes "github.com/jesseduffield/lazygit/pkg/app/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/direnv"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/jesseduffield/lazygit/pkg/common"
|
||||
|
|
@ -171,6 +172,17 @@ func openRecentRepo(app *App) bool {
|
|||
for _, repoDir := range app.Config.GetAppState().RecentRepos {
|
||||
if isRepo, _ := isDirectoryAGitRepository(repoDir); isRepo {
|
||||
if err := os.Chdir(repoDir); err == nil {
|
||||
// We're still in setup, before the gui exists, so we can't show the approval popup
|
||||
// that DispatchSwitchTo offers for blocked .envrc files; just log and move on.
|
||||
// Also, the logs only go to the debug log, not the Command Log, because that's not
|
||||
// available yet, either.
|
||||
result := direnv.Load(app.OSCommand.Cmd)
|
||||
if result.Message != "" {
|
||||
app.Log.WithField("message", result.Message).Info("direnv")
|
||||
}
|
||||
if result.Err != nil {
|
||||
app.Log.WithError(result.Err).Warn("direnv load failed")
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
@ -239,12 +251,8 @@ func (app *App) setupRepo(
|
|||
}
|
||||
|
||||
// check if we have a recent repo we can open
|
||||
for _, repoDir := range app.Config.GetAppState().RecentRepos {
|
||||
if isRepo, _ := isDirectoryAGitRepository(repoDir); isRepo {
|
||||
if err := os.Chdir(repoDir); err == nil {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
if openRecentRepo(app) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
fmt.Fprintln(os.Stderr, app.Tr.NoRecentRepositories)
|
||||
|
|
@ -262,7 +270,7 @@ func (app *App) setupRepo(
|
|||
os.Exit(0)
|
||||
}
|
||||
|
||||
if didOpenRepo := openRecentRepo(app); didOpenRepo {
|
||||
if openRecentRepo(app) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
130
pkg/commands/direnv/direnv.go
Normal file
130
pkg/commands/direnv/direnv.go
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
package direnv
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
)
|
||||
|
||||
// LoadResult bundles everything callers might want to know about a direnv
|
||||
// invocation. The env-var delta has already been applied to the process by
|
||||
// the time Load returns.
|
||||
type LoadResult struct {
|
||||
// Message is whatever direnv printed to stderr — useful to log
|
||||
// (success: "direnv: loading .envrc"; error: the error text).
|
||||
Message string
|
||||
|
||||
// Err is non-nil when direnv exited non-zero or its stdout could
|
||||
// not be parsed.
|
||||
Err error
|
||||
|
||||
// Blocked is true when the target .envrc exists but hasn't been
|
||||
// approved with `direnv allow` yet. EnvrcPath then holds the path
|
||||
// direnv said was blocked, suitable for passing to Allow.
|
||||
Blocked bool
|
||||
EnvrcPath string
|
||||
}
|
||||
|
||||
// Load runs `direnv export json` for the current working directory and applies
|
||||
// the resulting env-var delta to the current process. If direnv isn't on PATH,
|
||||
// it's a no-op — users who don't use direnv pay nothing, and users who do need
|
||||
// no config to opt in.
|
||||
func Load(cmd oscommands.ICmdObjBuilder) LoadResult {
|
||||
if _, lookupErr := exec.LookPath("direnv"); lookupErr != nil {
|
||||
return LoadResult{}
|
||||
}
|
||||
|
||||
stdout, stderr, runErr := cmd.New([]string{
|
||||
"direnv", "export", "json",
|
||||
}).DontLog().RunWithOutputs()
|
||||
|
||||
result := LoadResult{Message: strings.TrimRight(stderr, "\n")}
|
||||
|
||||
// Apply whatever delta direnv produced even if it exited non-zero.
|
||||
// When the new dir's .envrc is blocked, direnv still emits a valid
|
||||
// JSON delta on stdout that unloads vars from the previous dir;
|
||||
// without applying it the old env would leak into the new repo.
|
||||
delta, parseErr := parseDirenvExport([]byte(stdout))
|
||||
for k, v := range delta {
|
||||
if v == nil {
|
||||
_ = os.Unsetenv(k)
|
||||
} else {
|
||||
_ = os.Setenv(k, *v)
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer the runtime error (whose Error() text is direnv's stderr)
|
||||
// over a parse error, since it's the more actionable signal.
|
||||
if runErr != nil {
|
||||
result.Err = runErr
|
||||
if envrcPath := queryBlockedEnvrc(cmd); envrcPath != "" {
|
||||
result.Blocked = true
|
||||
result.EnvrcPath = envrcPath
|
||||
}
|
||||
} else {
|
||||
result.Err = parseErr
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Allow runs `direnv allow <envrcPath>` to approve a .envrc file so the next
|
||||
// Load can read it.
|
||||
func Allow(cmd oscommands.ICmdObjBuilder, envrcPath string) error {
|
||||
return cmd.New([]string{"direnv", "allow", envrcPath}).DontLog().Run()
|
||||
}
|
||||
|
||||
func parseDirenvExport(stdout []byte) (map[string]*string, error) {
|
||||
trimmed := bytes.TrimSpace(stdout)
|
||||
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
|
||||
return nil, nil
|
||||
}
|
||||
var delta map[string]*string
|
||||
if err := json.Unmarshal(trimmed, &delta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return delta, nil
|
||||
}
|
||||
|
||||
// queryBlockedEnvrc asks direnv (via `status --json`) whether the current
|
||||
// directory has a found-but-not-yet-allowed .envrc, and returns its path
|
||||
// if so. We use direnv's structured output rather than parsing the
|
||||
// human-readable "is blocked" line because the status output is more
|
||||
// stable across versions and locales.
|
||||
func queryBlockedEnvrc(cmd oscommands.ICmdObjBuilder) string {
|
||||
stdout, _, err := cmd.New([]string{
|
||||
"direnv", "status", "--json",
|
||||
}).DontLog().RunWithOutputs()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return parseDirenvStatus([]byte(stdout))
|
||||
}
|
||||
|
||||
func parseDirenvStatus(stdout []byte) string {
|
||||
var status struct {
|
||||
State struct {
|
||||
FoundRC *struct {
|
||||
Allowed int `json:"allowed"`
|
||||
Path string `json:"path"`
|
||||
} `json:"foundRC"`
|
||||
} `json:"state"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout, &status); err != nil {
|
||||
return ""
|
||||
}
|
||||
if status.State.FoundRC == nil {
|
||||
return ""
|
||||
}
|
||||
// direnv's AllowStatus enum (`internal/cmd/rc.go`): 0=Allowed,
|
||||
// 1=NotAllowed, 2=Denied. Only NotAllowed is something the user
|
||||
// can approve; Denied means they already said no.
|
||||
const notAllowed = 1
|
||||
if status.State.FoundRC.Allowed != notAllowed {
|
||||
return ""
|
||||
}
|
||||
return status.State.FoundRC.Path
|
||||
}
|
||||
88
pkg/commands/direnv/direnv_test.go
Normal file
88
pkg/commands/direnv/direnv_test.go
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
package direnv
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestParseDirenvExport(t *testing.T) {
|
||||
hello := "hello"
|
||||
empty := ""
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
input string
|
||||
want map[string]*string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "empty stdout means no .envrc was loaded", input: "", want: nil},
|
||||
{name: "literal null from direnv means no delta", input: "null", want: nil},
|
||||
{name: "empty object means no delta", input: "{}", want: map[string]*string{}},
|
||||
{name: "string value is a set", input: `{"FOO":"hello"}`, want: map[string]*string{"FOO": &hello}},
|
||||
{name: "null value is an unset", input: `{"FOO":null}`, want: map[string]*string{"FOO": nil}},
|
||||
{
|
||||
name: "set and unset can coexist",
|
||||
input: `{"FOO":"hello","BAR":null,"BAZ":""}`,
|
||||
want: map[string]*string{"FOO": &hello, "BAR": nil, "BAZ": &empty},
|
||||
},
|
||||
{name: "malformed JSON is an error", input: `{not json`, wantErr: true},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
got, err := parseDirenvExport([]byte(s.input))
|
||||
if s.wantErr {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, s.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDirenvStatus(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "no .envrc found",
|
||||
input: `{"state":{"foundRC":null}}`,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "found and allowed (0)",
|
||||
input: `{"state":{"foundRC":{"allowed":0,"path":"/repo/.envrc"}}}`,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "found but not allowed (1) — eligible for approval",
|
||||
input: `{"state":{"foundRC":{"allowed":1,"path":"/repo/.envrc"}}}`,
|
||||
want: "/repo/.envrc",
|
||||
},
|
||||
{
|
||||
name: "found but denied (2) — user already said no",
|
||||
input: `{"state":{"foundRC":{"allowed":2,"path":"/repo/.envrc"}}}`,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "malformed JSON",
|
||||
input: `{not json`,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "empty input",
|
||||
input: "",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
assert.Equal(t, s.want, parseDirenvStatus([]byte(s.input)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import (
|
|||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
// .gitmodules looks like this:
|
||||
|
|
@ -86,6 +87,30 @@ func (self *SubmoduleCommands) GetConfigs(parentModule *models.SubmoduleConfig)
|
|||
return configs, nil
|
||||
}
|
||||
|
||||
// AnyHaveStageableChanges reports whether any of the given submodule paths has
|
||||
// a checked-out commit that differs from the one recorded in the
|
||||
// superproject's index, i.e. a change that `git add <path>` would actually
|
||||
// stage. A submodule that only has dirty or untracked content (with no new
|
||||
// commit) can't be staged from the superproject, so it won't be reported here.
|
||||
func (self *SubmoduleCommands) AnyHaveStageableChanges(paths []string) (bool, error) {
|
||||
if len(paths) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
cmdArgs := NewGitCmd("submodule").Arg("status", "--").Arg(paths...).ToArgv()
|
||||
output, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Each line looks like "<prefix><sha> <path> (<describe>)". A '+' prefix
|
||||
// means the checked-out commit differs from the index, i.e. there's a
|
||||
// commit change to stage.
|
||||
return lo.SomeBy(strings.Split(output, "\n"), func(line string) bool {
|
||||
return strings.HasPrefix(line, "+")
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (self *SubmoduleCommands) Stash(submodule *models.SubmoduleConfig) error {
|
||||
// if the path does not exist then it hasn't yet been initialized so we'll swallow the error
|
||||
// because the intention here is to have no dirty worktree state
|
||||
|
|
|
|||
|
|
@ -387,6 +387,9 @@ var unstageStatusMap = map[string]string{
|
|||
"A ": "??",
|
||||
"M ": " M",
|
||||
"D ": " D",
|
||||
// A submodule with both a staged commit and unstageable dirty content; the
|
||||
// staged commit gets unstaged, the dirty content stays.
|
||||
"MM": " M",
|
||||
}
|
||||
|
||||
func (self *FilesController) optimisticStage(file *models.File) bool {
|
||||
|
|
@ -445,13 +448,23 @@ func (self *FilesController) optimisticChange(nodes []*filetree.FileNode, optimi
|
|||
return nil
|
||||
}
|
||||
|
||||
func (self *FilesController) pressWithLock(selectedNodes []*filetree.FileNode) error {
|
||||
// Obtaining this lock because optimistic rendering requires us to mutate
|
||||
// the files in our model.
|
||||
self.c.Mutexes().RefreshingFilesMutex.Lock()
|
||||
defer self.c.Mutexes().RefreshingFilesMutex.Unlock()
|
||||
|
||||
for _, node := range selectedNodes {
|
||||
// toggleStaged decides whether to stage or unstage the given nodes, updates the
|
||||
// model optimistically, and then runs the matching git command via the supplied
|
||||
// callbacks. press() (acting on the selection) and toggleStagedAll() (acting on
|
||||
// the whole tree) share this; they differ only in the git commands they run,
|
||||
// which is why those are passed in.
|
||||
//
|
||||
// If any node has unstaged changes we stage the nodes that have them (staging
|
||||
// already-staged deleted files/folders would fail); otherwise we unstage all
|
||||
// the nodes.
|
||||
func (self *FilesController) toggleStaged(
|
||||
nodes []*filetree.FileNode,
|
||||
stageAction string,
|
||||
unstageAction string,
|
||||
stage func(unstagedNodes []*filetree.FileNode) error,
|
||||
unstage func(nodes []*filetree.FileNode) error,
|
||||
) error {
|
||||
for _, node := range nodes {
|
||||
// if any files within have inline merge conflicts we can't stage or unstage,
|
||||
// or it'll end up with those >>>>>> lines actually staged
|
||||
if node.GetHasInlineMergeConflicts() {
|
||||
|
|
@ -459,6 +472,56 @@ func (self *FilesController) pressWithLock(selectedNodes []*filetree.FileNode) e
|
|||
}
|
||||
}
|
||||
|
||||
nodes = normalisedSelectedNodes(nodes)
|
||||
|
||||
unstagedNodes := filterNodesHaveUnstagedChanges(nodes, self.c.Model().Submodules)
|
||||
|
||||
// Staging a submodule that only has dirty or untracked content (no new
|
||||
// commit) is a no-op: the parent repo can't stage that content. When that's
|
||||
// the only thing that looks stageable, don't stage; fall through to
|
||||
// unstaging instead. That keeps the toggle symmetric (e.g. a fully-staged
|
||||
// tree that also contains a dirty submodule still unstages on the next
|
||||
// press) rather than getting stuck trying to stage the unstageable content.
|
||||
shouldStage := len(unstagedNodes) > 0
|
||||
if shouldStage {
|
||||
noOp, err := self.stagingWouldBeNoOp(unstagedNodes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
shouldStage = !noOp
|
||||
}
|
||||
|
||||
if shouldStage {
|
||||
self.c.LogAction(stageAction)
|
||||
|
||||
if err := self.optimisticChange(unstagedNodes, self.optimisticStage); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return stage(unstagedNodes)
|
||||
}
|
||||
|
||||
// If there's nothing staged to unstage either, then the only thing we acted
|
||||
// on was an unstageable submodule and nothing happened, so say why.
|
||||
if !someNodesHaveStagedChanges(nodes) {
|
||||
return errors.New(self.c.Tr.NothingToStageForSubmodule)
|
||||
}
|
||||
|
||||
self.c.LogAction(unstageAction)
|
||||
|
||||
if err := self.optimisticChange(nodes, self.optimisticUnstage); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return unstage(nodes)
|
||||
}
|
||||
|
||||
func (self *FilesController) pressWithLock(selectedNodes []*filetree.FileNode) error {
|
||||
// Obtaining this lock because optimistic rendering requires us to mutate
|
||||
// the files in our model.
|
||||
self.c.Mutexes().RefreshingFilesMutex.Lock()
|
||||
defer self.c.Mutexes().RefreshingFilesMutex.Unlock()
|
||||
|
||||
// When filtering, expand directory nodes to individual visible file paths
|
||||
// so that only filtered files are staged/unstaged.
|
||||
toPaths := func(nodes []*filetree.FileNode) []string {
|
||||
|
|
@ -477,63 +540,46 @@ func (self *FilesController) pressWithLock(selectedNodes []*filetree.FileNode) e
|
|||
})
|
||||
}
|
||||
|
||||
selectedNodes = normalisedSelectedNodes(selectedNodes)
|
||||
|
||||
// If any node has unstaged changes, we'll stage all the selected unstaged nodes (staging already staged deleted files/folders would fail).
|
||||
// Otherwise, we unstage all the selected nodes.
|
||||
unstagedSelectedNodes := filterNodesHaveUnstagedChanges(selectedNodes)
|
||||
|
||||
if len(unstagedSelectedNodes) > 0 {
|
||||
stage := func(unstagedNodes []*filetree.FileNode) error {
|
||||
var extraArgs []string
|
||||
|
||||
if self.context().GetStatusFilter() == filetree.DisplayTracked {
|
||||
extraArgs = []string{"-u"}
|
||||
}
|
||||
|
||||
self.c.LogAction(self.c.Tr.Actions.StageFile)
|
||||
|
||||
if err := self.optimisticChange(unstagedSelectedNodes, self.optimisticStage); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := self.c.Git().WorkingTree.StageFiles(toPaths(unstagedSelectedNodes), extraArgs); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
self.c.LogAction(self.c.Tr.Actions.UnstageFile)
|
||||
|
||||
if err := self.optimisticChange(selectedNodes, self.optimisticUnstage); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if self.context().IsFiltering() {
|
||||
// When filtering, only unstage visible files
|
||||
if err := self.unstageFilteredFiles(selectedNodes); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// need to partition the paths into tracked and untracked (where we assume directories are tracked). Then we'll run the commands separately.
|
||||
trackedNodes, untrackedNodes := utils.Partition(selectedNodes, func(node *filetree.FileNode) bool {
|
||||
// We treat all directories as tracked. I'm not actually sure why we do this but
|
||||
// it's been the existing behaviour for a while and nobody has complained
|
||||
return !node.IsFile() || node.GetIsTracked()
|
||||
})
|
||||
|
||||
if len(untrackedNodes) > 0 {
|
||||
if err := self.c.Git().WorkingTree.UnstageUntrackedFiles(toPaths(untrackedNodes)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if len(trackedNodes) > 0 {
|
||||
if err := self.c.Git().WorkingTree.UnstageTrackedFiles(toPaths(trackedNodes)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return self.c.Git().WorkingTree.StageFiles(toPaths(unstagedNodes), extraArgs)
|
||||
}
|
||||
|
||||
return nil
|
||||
unstage := func(nodes []*filetree.FileNode) error {
|
||||
if self.context().IsFiltering() {
|
||||
// When filtering, only unstage visible files
|
||||
return self.unstageFilteredFiles(nodes)
|
||||
}
|
||||
|
||||
// need to partition the paths into tracked and untracked (where we assume directories are tracked). Then we'll run the commands separately.
|
||||
trackedNodes, untrackedNodes := utils.Partition(nodes, func(node *filetree.FileNode) bool {
|
||||
// We treat all directories as tracked. I'm not actually sure why we do this but
|
||||
// it's been the existing behaviour for a while and nobody has complained
|
||||
return !node.IsFile() || node.GetIsTracked()
|
||||
})
|
||||
|
||||
if len(untrackedNodes) > 0 {
|
||||
if err := self.c.Git().WorkingTree.UnstageUntrackedFiles(toPaths(untrackedNodes)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if len(trackedNodes) > 0 {
|
||||
if err := self.c.Git().WorkingTree.UnstageTrackedFiles(toPaths(trackedNodes)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return self.toggleStaged(selectedNodes,
|
||||
self.c.Tr.Actions.StageFile, self.c.Tr.Actions.UnstageFile,
|
||||
stage, unstage)
|
||||
}
|
||||
|
||||
func (self *FilesController) press(nodes []*filetree.FileNode) error {
|
||||
|
|
@ -721,19 +767,7 @@ func (self *FilesController) toggleStagedAllWithLock() error {
|
|||
|
||||
root := self.context().FileTreeViewModel.GetRoot()
|
||||
|
||||
// if any files within have inline merge conflicts we can't stage or unstage,
|
||||
// or it'll end up with those >>>>>> lines actually staged
|
||||
if root.GetHasInlineMergeConflicts() {
|
||||
return errors.New(self.c.Tr.ErrStageDirWithInlineMergeConflicts)
|
||||
}
|
||||
|
||||
if root.GetHasUnstagedChanges() {
|
||||
self.c.LogAction(self.c.Tr.Actions.StageAllFiles)
|
||||
|
||||
if err := self.optimisticChange([]*filetree.FileNode{root}, self.optimisticStage); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stage := func(unstagedNodes []*filetree.FileNode) error {
|
||||
if self.context().IsFiltering() {
|
||||
// When filtering, only stage visible files
|
||||
var paths []string
|
||||
|
|
@ -741,35 +775,25 @@ func (self *FilesController) toggleStagedAllWithLock() error {
|
|||
paths = append(paths, file.Path)
|
||||
return nil
|
||||
})
|
||||
if err := self.c.Git().WorkingTree.StageFiles(paths, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
onlyTrackedFiles := self.context().GetStatusFilter() == filetree.DisplayTracked
|
||||
if err := self.c.Git().WorkingTree.StageAll(onlyTrackedFiles); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.c.LogAction(self.c.Tr.Actions.UnstageAllFiles)
|
||||
|
||||
if err := self.optimisticChange([]*filetree.FileNode{root}, self.optimisticUnstage); err != nil {
|
||||
return err
|
||||
return self.c.Git().WorkingTree.StageFiles(paths, nil)
|
||||
}
|
||||
|
||||
if self.context().IsFiltering() {
|
||||
// When filtering, only unstage visible files
|
||||
if err := self.unstageFilteredFiles([]*filetree.FileNode{root}); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := self.c.Git().WorkingTree.UnstageAll(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
onlyTrackedFiles := self.context().GetStatusFilter() == filetree.DisplayTracked
|
||||
return self.c.Git().WorkingTree.StageAll(onlyTrackedFiles)
|
||||
}
|
||||
|
||||
return nil
|
||||
unstage := func(nodes []*filetree.FileNode) error {
|
||||
if self.context().IsFiltering() {
|
||||
// When filtering, only unstage visible files
|
||||
return self.unstageFilteredFiles(nodes)
|
||||
}
|
||||
|
||||
return self.c.Git().WorkingTree.UnstageAll()
|
||||
}
|
||||
|
||||
return self.toggleStaged([]*filetree.FileNode{root},
|
||||
self.c.Tr.Actions.StageAllFiles, self.c.Tr.Actions.UnstageAllFiles,
|
||||
stage, unstage)
|
||||
}
|
||||
|
||||
func (self *FilesController) unstageFiles(node *filetree.FileNode) error {
|
||||
|
|
@ -1421,12 +1445,66 @@ func someNodesHaveStagedChanges(nodes []*filetree.FileNode) bool {
|
|||
return lo.SomeBy(nodes, (*filetree.FileNode).GetHasStagedChanges)
|
||||
}
|
||||
|
||||
func filterNodesHaveUnstagedChanges(nodes []*filetree.FileNode) []*filetree.FileNode {
|
||||
func filterNodesHaveUnstagedChanges(nodes []*filetree.FileNode, submodules []*models.SubmoduleConfig) []*filetree.FileNode {
|
||||
return lo.Filter(nodes, func(node *filetree.FileNode, _ int) bool {
|
||||
return node.GetHasUnstagedChanges()
|
||||
return node.SomeFile(func(file *models.File) bool {
|
||||
return fileHasStageableUnstagedChanges(file, submodules)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// For a submodule, the only thing the parent repo can stage is the
|
||||
// commit-pointer change; dirty or untracked content within the submodule
|
||||
// shows up as an unstaged change but can never be staged from the parent. So
|
||||
// once the submodule's commit is staged (leaving it at e.g. "MM"), we mustn't
|
||||
// treat the leftover unstaged change as stageable, or pressing space would
|
||||
// keep trying to stage it instead of unstaging it.
|
||||
func fileHasStageableUnstagedChanges(file *models.File, submodules []*models.SubmoduleConfig) bool {
|
||||
if !file.HasUnstagedChanges {
|
||||
return false
|
||||
}
|
||||
|
||||
if file.IsSubmodule(submodules) {
|
||||
return !file.HasStagedChanges
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// stagingWouldBeNoOp reports whether staging the given nodes would have no
|
||||
// visible effect, which happens when the only things being staged are
|
||||
// submodules that have dirty or untracked content but no new commit: the
|
||||
// parent repo can't stage that content. If a regular file (or a submodule with
|
||||
// a stageable new commit) is among them, staging does something, so this
|
||||
// returns false.
|
||||
func (self *FilesController) stagingWouldBeNoOp(nodes []*filetree.FileNode) (bool, error) {
|
||||
submodules := self.c.Model().Submodules
|
||||
|
||||
var submodulePaths []string
|
||||
hasOtherStageableChanges := false
|
||||
for _, node := range nodes {
|
||||
_ = node.ForEachFile(func(file *models.File) error {
|
||||
if file.IsSubmodule(submodules) {
|
||||
submodulePaths = append(submodulePaths, file.Path)
|
||||
} else if file.HasUnstagedChanges {
|
||||
hasOtherStageableChanges = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if hasOtherStageableChanges || len(submodulePaths) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
anyStageable, err := self.c.Git().Submodule.AnyHaveStageableChanges(submodulePaths)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return !anyStageable, nil
|
||||
}
|
||||
|
||||
func findSubmoduleNode(nodes []*filetree.FileNode, submodules []*models.SubmoduleConfig) *models.File {
|
||||
for _, node := range nodes {
|
||||
submoduleNode := node.FindFirstFileBy(func(f *models.File) bool {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
|
||||
appTypes "github.com/jesseduffield/lazygit/pkg/app/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/direnv"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/env"
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
|
|
@ -170,13 +171,70 @@ func (self *ReposHelper) DispatchSwitchTo(path string, errMsg string, contextKey
|
|||
return err
|
||||
}
|
||||
|
||||
direnvResult := self.logDirenvResult(direnv.Load(self.c.OS().Cmd))
|
||||
|
||||
if err := self.recordDirectoryHelper.RecordCurrentDirectory(); err != nil {
|
||||
return err
|
||||
self.c.Log.Errorf("error recording current directory: %v", err)
|
||||
}
|
||||
|
||||
self.c.Mutexes().RefreshingFilesMutex.Lock()
|
||||
defer self.c.Mutexes().RefreshingFilesMutex.Unlock()
|
||||
|
||||
return self.onNewRepo(appTypes.StartArgs{}, contextKey)
|
||||
if err := self.onNewRepo(appTypes.StartArgs{}, contextKey); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if direnvResult.Blocked {
|
||||
self.c.OnUIThread(func() error {
|
||||
self.promptDirenvApproval(direnvResult.EnvrcPath)
|
||||
return nil
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
return direnvResult.Err
|
||||
})
|
||||
}
|
||||
|
||||
// logDirenvResult writes whatever direnv emitted to the command log and the
|
||||
// debug log; both happen for every load attempt regardless of outcome.
|
||||
func (self *ReposHelper) logDirenvResult(result direnv.LoadResult) direnv.LoadResult {
|
||||
if result.Message != "" {
|
||||
self.c.LogCommand(result.Message, false)
|
||||
}
|
||||
if result.Err != nil {
|
||||
self.c.Log.WithError(result.Err).Warn("direnv load failed")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// promptDirenvApproval shows the user the contents of an unapproved .envrc
|
||||
// and offers to run `direnv allow` for them. On confirm, we approve the
|
||||
// file and re-run Load so the new env reaches subprocesses; on cancel we
|
||||
// leave the env as-is (the previous repo's vars are already unloaded by
|
||||
// the initial Load call, which is the correct state).
|
||||
func (self *ReposHelper) promptDirenvApproval(envrcPath string) {
|
||||
content, err := os.ReadFile(envrcPath)
|
||||
if err != nil {
|
||||
self.c.Log.WithError(err).Warn("could not read .envrc for approval prompt")
|
||||
return
|
||||
}
|
||||
|
||||
indented := " " + strings.ReplaceAll(strings.TrimRight(string(content), "\n"), "\n", "\n ")
|
||||
prompt := utils.ResolvePlaceholderString(self.c.Tr.DirenvApprovalPrompt, map[string]string{
|
||||
"confirmKey": self.c.UserConfig().Keybinding.Universal.Confirm.String(),
|
||||
"cancelKey": self.c.UserConfig().Keybinding.Universal.Return.String(),
|
||||
"content": indented,
|
||||
})
|
||||
|
||||
self.c.Confirm(types.ConfirmOpts{
|
||||
Title: self.c.Tr.DirenvApprovalTitle,
|
||||
Prompt: prompt,
|
||||
HandleConfirm: func() error {
|
||||
if err := direnv.Allow(self.c.OS().Cmd, envrcPath); err != nil {
|
||||
return err
|
||||
}
|
||||
return self.logDirenvResult(direnv.Load(self.c.OS().Cmd)).Err
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -891,6 +891,8 @@ type TranslationSet struct {
|
|||
CreateWorktreeFromDetached string
|
||||
LcWorktree string
|
||||
ChangingDirectoryTo string
|
||||
DirenvApprovalTitle string
|
||||
DirenvApprovalPrompt string
|
||||
Name string
|
||||
Branch string
|
||||
Path string
|
||||
|
|
@ -915,6 +917,7 @@ type TranslationSet struct {
|
|||
SelectedItemIsNotABranch string
|
||||
SelectedItemDoesNotHaveFiles string
|
||||
MultiSelectNotSupportedForSubmodules string
|
||||
NothingToStageForSubmodule string
|
||||
CommandDoesNotSupportOpeningInEditor string
|
||||
CustomCommands string
|
||||
NoApplicableCommandsInThisContext string
|
||||
|
|
@ -2012,6 +2015,8 @@ func EnglishTranslationSet() *TranslationSet {
|
|||
CreateWorktreeFromDetached: "Create worktree from {{.ref}} (detached)",
|
||||
LcWorktree: "worktree",
|
||||
ChangingDirectoryTo: "Changing directory to {{.path}}",
|
||||
DirenvApprovalTitle: "Approve .envrc?",
|
||||
DirenvApprovalPrompt: "Press {{.confirmKey}} to run 'direnv allow' and load the environment.\nPress {{.cancelKey}} to skip.\n\n{{.content}}",
|
||||
Name: "Name",
|
||||
Branch: "Branch",
|
||||
Path: "Path",
|
||||
|
|
@ -2034,6 +2039,7 @@ func EnglishTranslationSet() *TranslationSet {
|
|||
SelectedItemIsNotABranch: "Selected item is not a branch",
|
||||
SelectedItemDoesNotHaveFiles: "Selected item does not have files to view",
|
||||
MultiSelectNotSupportedForSubmodules: "Multiselection not supported for submodules",
|
||||
NothingToStageForSubmodule: "Nothing to stage: the parent repo can only stage a new submodule commit, not the uncommitted changes inside a submodule. Commit inside the submodule first.",
|
||||
CommandDoesNotSupportOpeningInEditor: "This command doesn't support switching to the editor",
|
||||
CustomCommands: "Custom commands",
|
||||
NoApplicableCommandsInThisContext: "(No applicable commands in this context)",
|
||||
|
|
|
|||
|
|
@ -246,7 +246,11 @@ func getLazygitCommand(
|
|||
cmdObj.AddEnvVars(fmt.Sprintf("GORACE=log_path=%s", raceDetectorLogsPath()))
|
||||
if test.ExtraEnvVars() != nil {
|
||||
for key, value := range test.ExtraEnvVars() {
|
||||
cmdObj.AddEnvVars(fmt.Sprintf("%s=%s", key, value))
|
||||
resolvedValue := utils.ResolvePlaceholderString(value, map[string]string{
|
||||
"actualPath": paths.Actual(),
|
||||
"actualRepoPath": paths.ActualRepo(),
|
||||
})
|
||||
cmdObj.AddEnvVars(fmt.Sprintf("%s=%s", key, resolvedValue))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
90
pkg/integration/tests/misc/direnv_approves_envrc.go
Normal file
90
pkg/integration/tests/misc/direnv_approves_envrc.go
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
package misc
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
// When the new repo's .envrc is blocked, lazygit offers the user a popup to
|
||||
// approve it without leaving the app. Confirming runs `direnv allow` and
|
||||
// re-runs the load so the env reaches subprocesses immediately.
|
||||
var DirenvApprovesEnvrc = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Approving a blocked .envrc from the in-app popup loads its env",
|
||||
ExtraCmdArgs: []string{},
|
||||
ExtraEnvVars: map[string]string{
|
||||
"PATH": "{{actualPath}}/bin:" + os.Getenv("PATH"),
|
||||
},
|
||||
SetupConfig: func(cfg *config.AppConfig) {
|
||||
otherRepo, _ := filepath.Abs("../other")
|
||||
cfg.GetAppState().RecentRepos = []string{otherRepo}
|
||||
cfg.GetUserConfig().CustomCommands = []config.CustomCommand{
|
||||
{
|
||||
Key: config.Keybinding{"X"},
|
||||
Context: "files",
|
||||
Command: `echo "VAR=$LG_DIRENV_TEST" > output.txt`,
|
||||
},
|
||||
}
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.EmptyCommit("initial")
|
||||
shell.CloneNonBare("other")
|
||||
|
||||
shell.CreateFile("../other/.envrc", "export LG_DIRENV_TEST=approved_value\n")
|
||||
|
||||
// Fake direnv that flips behavior once `direnv allow` runs.
|
||||
// Before allow: export errors with the "blocked" signal,
|
||||
// status reports allowed=1 (NotAllowed).
|
||||
// On allow: create a sentinel and exit 0.
|
||||
// After allow: export emits the loaded delta normally.
|
||||
shell.CreateFile("../bin/direnv", `#!/bin/sh
|
||||
SENTINEL="$(dirname "$0")/.approved"
|
||||
case "$1 $2" in
|
||||
"allow "*)
|
||||
touch "$SENTINEL"
|
||||
exit 0
|
||||
;;
|
||||
"export json")
|
||||
if [ -f "$SENTINEL" ]; then
|
||||
echo '{"LG_DIRENV_TEST":"approved_value"}'
|
||||
echo "direnv: loading $PWD/.envrc" >&2
|
||||
else
|
||||
echo '{"LG_DIRENV_TEST":null}'
|
||||
echo "direnv: error $PWD/.envrc is blocked" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
"status --json")
|
||||
if [ -f "$SENTINEL" ]; then
|
||||
printf '{"state":{"foundRC":{"allowed":0,"path":"%s/.envrc"}}}\n' "$PWD"
|
||||
else
|
||||
printf '{"state":{"foundRC":{"allowed":1,"path":"%s/.envrc"}}}\n' "$PWD"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
`)
|
||||
shell.MakeExecutable("../bin/direnv")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.GlobalPress(keys.Universal.OpenRecentRepos)
|
||||
t.ExpectPopup().Menu().Title(Equals("Recent repositories")).
|
||||
Lines(
|
||||
Contains("other").IsSelected(),
|
||||
Contains("Cancel"),
|
||||
).
|
||||
Confirm()
|
||||
|
||||
t.ExpectPopup().Confirmation().
|
||||
Title(Equals("Approve .envrc?")).
|
||||
Content(Contains("export LG_DIRENV_TEST=approved_value")).
|
||||
Confirm()
|
||||
|
||||
t.Views().Files().
|
||||
Focus().
|
||||
Press(config.Keybinding{"X"}).
|
||||
NavigateToLine(Contains("output.txt"))
|
||||
t.Views().Main().Content(Contains("VAR=approved_value"))
|
||||
},
|
||||
})
|
||||
68
pkg/integration/tests/misc/direnv_loaded_on_repo_switch.go
Normal file
68
pkg/integration/tests/misc/direnv_loaded_on_repo_switch.go
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
package misc
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
// Verifies that when the user switches repos from inside lazygit, env vars
|
||||
// that direnv would load for the target repo are applied to subprocesses
|
||||
// (custom commands, git hooks, etc.). The test puts a fake `direnv` binary
|
||||
// on PATH so it works regardless of whether the host has real direnv
|
||||
// installed.
|
||||
var DirenvLoadedOnRepoSwitch = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Switching repos applies direnv-loaded env vars to subprocesses",
|
||||
ExtraCmdArgs: []string{},
|
||||
ExtraEnvVars: map[string]string{
|
||||
// Prepend a dir under the test fixture to PATH so our fake direnv
|
||||
// wins lookup. The placeholder is resolved at run time.
|
||||
"PATH": "{{actualPath}}/bin:" + os.Getenv("PATH"),
|
||||
},
|
||||
SetupConfig: func(cfg *config.AppConfig) {
|
||||
otherRepo, _ := filepath.Abs("../other")
|
||||
cfg.GetAppState().RecentRepos = []string{otherRepo}
|
||||
cfg.GetUserConfig().CustomCommands = []config.CustomCommand{
|
||||
{
|
||||
Key: config.Keybinding{"X"},
|
||||
Context: "files",
|
||||
Command: `echo "VAR=$LG_DIRENV_TEST" > output.txt`,
|
||||
},
|
||||
}
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.EmptyCommit("initial")
|
||||
shell.CloneNonBare("other")
|
||||
|
||||
// Fake direnv: echoes a fixed JSON delta on stdout (set
|
||||
// LG_DIRENV_TEST) and a "loading" line on stderr, exactly as
|
||||
// real direnv would after authorizing an .envrc.
|
||||
shell.CreateFile("../bin/direnv", `#!/bin/sh
|
||||
echo '{"LG_DIRENV_TEST":"from_direnv"}'
|
||||
echo "direnv: loading .envrc" >&2
|
||||
`)
|
||||
shell.MakeExecutable("../bin/direnv")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
// Switch to the "other" repo via the recent-repos menu.
|
||||
t.GlobalPress(keys.Universal.OpenRecentRepos)
|
||||
t.ExpectPopup().Menu().Title(Equals("Recent repositories")).
|
||||
Lines(
|
||||
Contains("other").IsSelected(),
|
||||
Contains("Cancel"),
|
||||
).
|
||||
Confirm()
|
||||
|
||||
// Run the custom command; if direnv loading worked, $LG_DIRENV_TEST
|
||||
// reaches the subprocess and ends up in output.txt.
|
||||
t.Views().Files().
|
||||
Focus().
|
||||
Press(config.Keybinding{"X"}).
|
||||
Lines(
|
||||
Contains("output.txt").IsSelected(),
|
||||
)
|
||||
t.Views().Main().Content(Contains("VAR=from_direnv"))
|
||||
},
|
||||
})
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
package misc
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
// Real direnv exits non-zero when the destination .envrc isn't authorized,
|
||||
// but it still emits a valid JSON delta on stdout that unloads vars from
|
||||
// the previously-active .envrc. We have to apply that delta anyway, or the
|
||||
// previous repo's env leaks into the new one. This test exercises the
|
||||
// "skip approval" branch: the approval popup appears, the user cancels,
|
||||
// and the previous repo's env is still gone.
|
||||
var DirenvUnloadsOnBlockedEnvrc = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Blocked .envrc unloads the previous repo's env even if the user skips approval",
|
||||
ExtraCmdArgs: []string{},
|
||||
ExtraEnvVars: map[string]string{
|
||||
"PATH": "{{actualPath}}/bin:" + os.Getenv("PATH"),
|
||||
// Simulates a var that the previous repo's .envrc would have set.
|
||||
"LG_DIRENV_TEST": "from_previous_repo",
|
||||
},
|
||||
SetupConfig: func(cfg *config.AppConfig) {
|
||||
otherRepo, _ := filepath.Abs("../other")
|
||||
cfg.GetAppState().RecentRepos = []string{otherRepo}
|
||||
cfg.GetUserConfig().CustomCommands = []config.CustomCommand{
|
||||
{
|
||||
Key: config.Keybinding{"X"},
|
||||
Context: "files",
|
||||
Command: `echo "VAR=[$LG_DIRENV_TEST]" > output.txt`,
|
||||
},
|
||||
}
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.EmptyCommit("initial")
|
||||
shell.CloneNonBare("other")
|
||||
|
||||
shell.CreateFile("../other/.envrc", "export LG_DIRENV_TEST=from_envrc\n")
|
||||
|
||||
shell.CreateFile("../bin/direnv", `#!/bin/sh
|
||||
case "$1 $2" in
|
||||
"export json")
|
||||
echo '{"LG_DIRENV_TEST":null}'
|
||||
echo "direnv: error $PWD/.envrc is blocked" >&2
|
||||
exit 1
|
||||
;;
|
||||
"status --json")
|
||||
printf '{"state":{"foundRC":{"allowed":1,"path":"%s/.envrc"}}}\n' "$PWD"
|
||||
;;
|
||||
esac
|
||||
`)
|
||||
shell.MakeExecutable("../bin/direnv")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.GlobalPress(keys.Universal.OpenRecentRepos)
|
||||
t.ExpectPopup().Menu().Title(Equals("Recent repositories")).
|
||||
Lines(
|
||||
Contains("other").IsSelected(),
|
||||
Contains("Cancel"),
|
||||
).
|
||||
Confirm()
|
||||
|
||||
t.ExpectPopup().Confirmation().
|
||||
Title(Equals("Approve .envrc?")).
|
||||
Content(Contains("export LG_DIRENV_TEST=from_envrc")).
|
||||
Cancel()
|
||||
|
||||
t.Views().Files().
|
||||
Focus().
|
||||
Press(config.Keybinding{"X"}).
|
||||
NavigateToLine(Contains("output.txt"))
|
||||
t.Views().Main().Content(Contains("VAR=[]"))
|
||||
},
|
||||
})
|
||||
58
pkg/integration/tests/submodule/stage.go
Normal file
58
pkg/integration/tests/submodule/stage.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
package submodule
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var Stage = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Stage and unstage a submodule that has both a new commit and dirty content. The new commit can be staged, but the dirty content can't, so unstaging must still work; this must hold for both the stage (space) and stage-all (a) keybindings.",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Gui.ShowFileTree = false
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.EmptyCommit("first commit")
|
||||
shell.CloneIntoSubmodule("my_submodule_name", "my_submodule_path")
|
||||
shell.GitAddAll()
|
||||
shell.Commit("add submodule")
|
||||
|
||||
// Give the submodule a new commit, which is a change that the parent
|
||||
// repo can stage, as well as some dirty working-tree content, which
|
||||
// the parent repo can never stage. This is what gets us a "MM" status
|
||||
// once the new commit is staged.
|
||||
shell.RunCommand([]string{"git", "-C", "my_submodule_path", "commit", "--allow-empty", "-m", "submodule commit"})
|
||||
shell.CreateFile("my_submodule_path/dirty_file", "dirty content")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Files().Focus().
|
||||
Lines(
|
||||
Equals(" M my_submodule_path (submodule)").IsSelected(),
|
||||
).
|
||||
// Staging the submodule stages the new commit, but the dirty
|
||||
// content remains unstaged, leaving us at "MM".
|
||||
PressPrimaryAction().
|
||||
Lines(
|
||||
Equals("MM my_submodule_path (submodule)").IsSelected(),
|
||||
).
|
||||
// Pressing again must unstage the submodule, taking us back to
|
||||
// " M" rather than trying (and failing) to stage the dirty content.
|
||||
PressPrimaryAction().
|
||||
Lines(
|
||||
Equals(" M my_submodule_path (submodule)").IsSelected(),
|
||||
).
|
||||
// The same has to hold for the stage-all keybinding, which shares
|
||||
// the same decision logic: it stages the new commit...
|
||||
Press(keys.Files.ToggleStagedAll).
|
||||
Lines(
|
||||
Equals("MM my_submodule_path (submodule)").IsSelected(),
|
||||
).
|
||||
// ...and then unstages it again rather than getting stuck on the
|
||||
// dirty content.
|
||||
Press(keys.Files.ToggleStagedAll).
|
||||
Lines(
|
||||
Equals(" M my_submodule_path (submodule)").IsSelected(),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
package submodule
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var StageAllWithDirtySubmodule = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "A submodule with only dirty content (which can't be staged) must not break the stage-all toggle: pressing it repeatedly should keep toggling the other files between staged and unstaged.",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Gui.ShowFileTree = false
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.EmptyCommit("first commit")
|
||||
shell.CloneIntoSubmodule("my_submodule_name", "my_submodule_path")
|
||||
shell.GitAddAll()
|
||||
shell.Commit("add submodule")
|
||||
|
||||
// A submodule with dirty content but no new commit (can't be staged),
|
||||
// alongside a regular file that can.
|
||||
shell.CreateFile("my_submodule_path/dirty_file", "dirty content")
|
||||
shell.CreateFile("regular_file", "content")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Files().Focus().
|
||||
Lines(
|
||||
Equals(" M my_submodule_path (submodule)"),
|
||||
Equals("?? regular_file"),
|
||||
).
|
||||
// Stage all: the regular file gets staged; the submodule can't be.
|
||||
Press(keys.Files.ToggleStagedAll).
|
||||
Lines(
|
||||
Equals(" M my_submodule_path (submodule)"),
|
||||
Equals("A regular_file"),
|
||||
).
|
||||
// Stage all again: nothing is stageable, but the regular file is
|
||||
// staged, so this unstages it rather than erroring on the submodule.
|
||||
Press(keys.Files.ToggleStagedAll).
|
||||
Lines(
|
||||
Equals(" M my_submodule_path (submodule)"),
|
||||
Equals("?? regular_file"),
|
||||
)
|
||||
},
|
||||
})
|
||||
53
pkg/integration/tests/submodule/stage_dirty_only.go
Normal file
53
pkg/integration/tests/submodule/stage_dirty_only.go
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
package submodule
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var StageDirtyOnly = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Pressing space on a submodule that only has dirty content (no new commit) can't stage anything, so we explain that with an error instead of silently doing nothing.",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Gui.ShowFileTree = false
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.EmptyCommit("first commit")
|
||||
shell.CloneIntoSubmodule("my_submodule_name", "my_submodule_path")
|
||||
shell.GitAddAll()
|
||||
shell.Commit("add submodule")
|
||||
|
||||
// Dirty working-tree content, but no new commit: there's nothing the
|
||||
// parent repo can stage.
|
||||
shell.CreateFile("my_submodule_path/dirty_file", "dirty content")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Files().Focus().
|
||||
Lines(
|
||||
Equals(" M my_submodule_path (submodule)").IsSelected(),
|
||||
).
|
||||
PressPrimaryAction().
|
||||
Tap(func() {
|
||||
t.ExpectPopup().Alert().
|
||||
Title(Equals("Error")).
|
||||
Content(Contains("Nothing to stage")).
|
||||
Confirm()
|
||||
}).
|
||||
// The status is unchanged: nothing got staged.
|
||||
Lines(
|
||||
Equals(" M my_submodule_path (submodule)").IsSelected(),
|
||||
).
|
||||
// Pressing "stage all" must behave the same way.
|
||||
Press(keys.Files.ToggleStagedAll).
|
||||
Tap(func() {
|
||||
t.ExpectPopup().Alert().
|
||||
Title(Equals("Error")).
|
||||
Content(Contains("Nothing to stage")).
|
||||
Confirm()
|
||||
}).
|
||||
Lines(
|
||||
Equals(" M my_submodule_path (submodule)").IsSelected(),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
|
@ -334,6 +334,9 @@ var tests = []*components.IntegrationTest{
|
|||
misc.ConfirmOnQuit,
|
||||
misc.CopyConfirmationMessageToClipboard,
|
||||
misc.CopyToClipboard,
|
||||
misc.DirenvApprovesEnvrc,
|
||||
misc.DirenvLoadedOnRepoSwitch,
|
||||
misc.DirenvUnloadsOnBlockedEnvrc,
|
||||
misc.InitialOpen,
|
||||
misc.RecentReposOnLaunch,
|
||||
patch_building.Apply,
|
||||
|
|
@ -423,6 +426,9 @@ var tests = []*components.IntegrationTest{
|
|||
submodule.RemoveNested,
|
||||
submodule.Reset,
|
||||
submodule.ResetFolder,
|
||||
submodule.Stage,
|
||||
submodule.StageAllWithDirtySubmodule,
|
||||
submodule.StageDirtyOnly,
|
||||
sync.FetchAndAutoForwardBranchesAllBranches,
|
||||
sync.FetchAndAutoForwardBranchesAllBranchesCheckedOutInOtherWorktree,
|
||||
sync.FetchAndAutoForwardBranchesNone,
|
||||
|
|
|
|||
Loading…
Reference in a new issue