From 0d19776d785a125c2e599c2b36ca7afa1b3ecf19 Mon Sep 17 00:00:00 2001 From: Jesse Duffield Date: Mon, 8 Jan 2024 18:02:55 +1100 Subject: [PATCH] Support staging/unstaging a range of files --- pkg/commands/git_commands/working_tree.go | 27 +++-- pkg/gui/controllers/files_controller.go | 121 +++++++++++++--------- 2 files changed, 83 insertions(+), 65 deletions(-) diff --git a/pkg/commands/git_commands/working_tree.go b/pkg/commands/git_commands/working_tree.go index 9630e2870ee..89b84aea18f 100644 --- a/pkg/commands/git_commands/working_tree.go +++ b/pkg/commands/git_commands/working_tree.go @@ -57,21 +57,20 @@ func (self *WorkingTreeCommands) UnstageAll() error { // UnStageFile unstages a file // we accept an array of filenames for the cases where a file has been renamed i.e. // we accept the current name and the previous name -func (self *WorkingTreeCommands) UnStageFile(fileNames []string, reset bool) error { - for _, name := range fileNames { - var cmdArgs []string - if reset { - cmdArgs = NewGitCmd("reset").Arg("HEAD", "--", name).ToArgv() - } else { - cmdArgs = NewGitCmd("rm").Arg("--cached", "--force", "--", name).ToArgv() - } - - err := self.cmd.New(cmdArgs).Run() - if err != nil { - return err - } +func (self *WorkingTreeCommands) UnStageFile(paths []string, tracked bool) error { + if tracked { + return self.UnstageTrackedFiles(paths) + } else { + return self.UnstageUntrackedFiles(paths) } - return nil +} + +func (self *WorkingTreeCommands) UnstageTrackedFiles(paths []string) error { + return self.cmd.New(NewGitCmd("reset").Arg("HEAD", "--").Arg(paths...).ToArgv()).Run() +} + +func (self *WorkingTreeCommands) UnstageUntrackedFiles(paths []string) error { + return self.cmd.New(NewGitCmd("rm").Arg("--cached", "--force", "--").Arg(paths...).ToArgv()).Run() } func (self *WorkingTreeCommands) BeforeAndAfterFileForRename(file *models.File) (*models.File, *models.File, error) { diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 0db509ca3b0..ea544d9ed89 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -9,6 +9,8 @@ import ( "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/filetree" "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/jesseduffield/lazygit/pkg/utils" + "github.com/samber/lo" ) type FilesController struct { @@ -298,24 +300,28 @@ func (self *FilesController) optimisticUnstage(file *models.File) bool { // the files panel. Then we'll immediately do a proper git status call // so that if the optimistic rendering got something wrong, it's quickly // corrected. -func (self *FilesController) optimisticChange(node *filetree.FileNode, optimisticChangeFn func(*models.File) bool) error { +func (self *FilesController) optimisticChange(nodes []*filetree.FileNode, optimisticChangeFn func(*models.File) bool) error { rerender := false - err := node.ForEachFile(func(f *models.File) error { - // can't act on the file itself: we need to update the original model file - for _, modelFile := range self.c.Model().Files { - if modelFile.Name == f.Name { - if optimisticChangeFn(modelFile) { - rerender = true + + for _, node := range nodes { + err := node.ForEachFile(func(f *models.File) error { + // can't act on the file itself: we need to update the original model file + for _, modelFile := range self.c.Model().Files { + if modelFile.Name == f.Name { + if optimisticChangeFn(modelFile) { + rerender = true + } + break } - break } - } - return nil - }) - if err != nil { - return err + return nil + }) + if err != nil { + return err + } } + if rerender { if err := self.c.PostRefreshUpdate(self.c.Contexts().Files); err != nil { return err @@ -331,56 +337,69 @@ func (self *FilesController) pressWithLock(node *filetree.FileNode) error { self.c.Mutexes().RefreshingFilesMutex.Lock() defer self.c.Mutexes().RefreshingFilesMutex.Unlock() - if node.IsFile() { - file := node.File - - if file.HasUnstagedChanges { - self.c.LogAction(self.c.Tr.Actions.StageFile) - - if err := self.optimisticChange(node, self.optimisticStage); err != nil { - return err - } + if self.context().IsSelectingRange() { + defer self.context().CancelRangeSelect() + } - if err := self.c.Git().WorkingTree.StageFile(file.Name); err != nil { - return self.c.Error(err) - } - } else { - self.c.LogAction(self.c.Tr.Actions.UnstageFile) + startIdx, endIdx := self.context().GetSelectionRange() - if err := self.optimisticChange(node, self.optimisticUnstage); err != nil { - return err - } + nodes := []*filetree.FileNode{} + for i := startIdx; i <= endIdx; i++ { + nodes = append(nodes, self.context().Get(i)) + } - if err := self.c.Git().WorkingTree.UnStageFile(file.Names(), file.Tracked); err != nil { - return self.c.Error(err) - } - } - } else { + 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() { return self.c.ErrorMsg(self.c.Tr.ErrStageDirWithInlineMergeConflicts) } + } - if node.GetHasUnstagedChanges() { - self.c.LogAction(self.c.Tr.Actions.StageFile) + // If any node has unstaged changes, we'll stage all the selected nodes. Otherwise, + // we unstage all the selected nodes. + someNodesHaveUnstagedChanges := lo.SomeBy(nodes, func(node *filetree.FileNode) bool { + return node.GetHasUnstagedChanges() + }) - if err := self.optimisticChange(node, self.optimisticStage); err != nil { - return err - } + toPaths := func(nodes []*filetree.FileNode) []string { + return lo.Map(nodes, func(node *filetree.FileNode, _ int) string { + return node.Path + }) + } - if err := self.c.Git().WorkingTree.StageFile(node.Path); err != nil { - return self.c.Error(err) - } - } else { - self.c.LogAction(self.c.Tr.Actions.UnstageFile) + if someNodesHaveUnstagedChanges { + self.c.LogAction(self.c.Tr.Actions.StageFile) - if err := self.optimisticChange(node, self.optimisticUnstage); err != nil { - return err + if err := self.optimisticChange(nodes, self.optimisticStage); err != nil { + return err + } + + if err := self.c.Git().WorkingTree.StageFiles(toPaths(nodes)); err != nil { + return self.c.Error(err) + } + } else { + self.c.LogAction(self.c.Tr.Actions.UnstageFile) + + if err := self.optimisticChange(nodes, self.optimisticUnstage); err != nil { + return err + } + + // 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 self.c.Error(err) } + } - // pretty sure it doesn't matter that we're always passing true here - if err := self.c.Git().WorkingTree.UnStageFile([]string{node.Path}, true); err != nil { + if len(trackedNodes) > 0 { + if err := self.c.Git().WorkingTree.UnstageTrackedFiles(toPaths(trackedNodes)); err != nil { return self.c.Error(err) } } @@ -491,7 +510,7 @@ func (self *FilesController) toggleStagedAllWithLock() error { if root.GetHasUnstagedChanges() { self.c.LogAction(self.c.Tr.Actions.StageAllFiles) - if err := self.optimisticChange(root, self.optimisticStage); err != nil { + if err := self.optimisticChange([]*filetree.FileNode{root}, self.optimisticStage); err != nil { return err } @@ -501,7 +520,7 @@ func (self *FilesController) toggleStagedAllWithLock() error { } else { self.c.LogAction(self.c.Tr.Actions.UnstageAllFiles) - if err := self.optimisticChange(root, self.optimisticUnstage); err != nil { + if err := self.optimisticChange([]*filetree.FileNode{root}, self.optimisticUnstage); err != nil { return err }