mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-16 02:26:24 -04:00
With the recently added external change detection, it happens more often now that we refresh the commits list because an agent made a commit in the background. In this case, if we keep the selection index the same, it now points at a different commit, making the main view show a different commit too, which is confusing and annoying. To fix this, track the selected commit and range anchor by hash before reloading, then restore those rows if both hashes still exist. This also allows us to get rid of some bespoke code that did this for the specific cases of reverting a commit or cherry-picking commits, because those are now handled by the generic mechanism.
418 lines
13 KiB
Go
418 lines
13 KiB
Go
package helpers
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
|
|
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
|
"github.com/jesseduffield/lazygit/pkg/config"
|
|
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
|
"github.com/jesseduffield/lazygit/pkg/gui/style"
|
|
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
|
"github.com/samber/lo"
|
|
)
|
|
|
|
type WorkingTreeHelper struct {
|
|
c *HelperCommon
|
|
refHelper *RefsHelper
|
|
commitsHelper *CommitsHelper
|
|
gpgHelper *GpgHelper
|
|
mergeAndRebaseHelper *MergeAndRebaseHelper
|
|
}
|
|
|
|
func NewWorkingTreeHelper(
|
|
c *HelperCommon,
|
|
refHelper *RefsHelper,
|
|
commitsHelper *CommitsHelper,
|
|
gpgHelper *GpgHelper,
|
|
mergeAndRebaseHelper *MergeAndRebaseHelper,
|
|
) *WorkingTreeHelper {
|
|
return &WorkingTreeHelper{
|
|
c: c,
|
|
refHelper: refHelper,
|
|
commitsHelper: commitsHelper,
|
|
gpgHelper: gpgHelper,
|
|
mergeAndRebaseHelper: mergeAndRebaseHelper,
|
|
}
|
|
}
|
|
|
|
func (self *WorkingTreeHelper) AnyStagedFiles() bool {
|
|
return AnyStagedFiles(self.c.Model().Files)
|
|
}
|
|
|
|
func AnyStagedFiles(files []*models.File) bool {
|
|
return lo.SomeBy(files, func(f *models.File) bool { return f.HasStagedChanges })
|
|
}
|
|
|
|
func (self *WorkingTreeHelper) AnyStagedFilesExceptSubmodules() bool {
|
|
return AnyStagedFilesExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules)
|
|
}
|
|
|
|
func AnyStagedFilesExceptSubmodules(files []*models.File, submoduleConfigs []*models.SubmoduleConfig) bool {
|
|
return lo.SomeBy(files, func(f *models.File) bool { return f.HasStagedChanges && !f.IsSubmodule(submoduleConfigs) })
|
|
}
|
|
|
|
func (self *WorkingTreeHelper) AnyTrackedFiles() bool {
|
|
return AnyTrackedFiles(self.c.Model().Files)
|
|
}
|
|
|
|
func AnyTrackedFiles(files []*models.File) bool {
|
|
return lo.SomeBy(files, func(f *models.File) bool { return f.Tracked })
|
|
}
|
|
|
|
func (self *WorkingTreeHelper) AnyTrackedFilesExceptSubmodules() bool {
|
|
return AnyTrackedFilesExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules)
|
|
}
|
|
|
|
func AnyTrackedFilesExceptSubmodules(files []*models.File, submoduleConfigs []*models.SubmoduleConfig) bool {
|
|
return lo.SomeBy(files, func(f *models.File) bool { return f.Tracked && !f.IsSubmodule(submoduleConfigs) })
|
|
}
|
|
|
|
func isContainedInPath(candidate string, path string) bool {
|
|
return (
|
|
// If the path is the repo root (appears as "/" in the UI), then all candidates are contained in it
|
|
path == "." ||
|
|
// Exact match; will only be true for files
|
|
candidate == path ||
|
|
// Match for files within a directory. We need to match the trailing slash to avoid
|
|
// matching files with longer names.
|
|
strings.HasPrefix(candidate, path+"/"))
|
|
}
|
|
|
|
func AnyTrackedFilesInPathExceptSubmodules(path string, files []*models.File, submoduleConfigs []*models.SubmoduleConfig) bool {
|
|
return lo.SomeBy(files, func(f *models.File) bool {
|
|
return f.Tracked && isContainedInPath(f.GetPath(), path) && !f.IsSubmodule(submoduleConfigs)
|
|
})
|
|
}
|
|
|
|
func (self *WorkingTreeHelper) IsWorkingTreeDirtyExceptSubmodules() bool {
|
|
return IsWorkingTreeDirtyExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules)
|
|
}
|
|
|
|
func IsWorkingTreeDirtyExceptSubmodules(files []*models.File, submoduleConfigs []*models.SubmoduleConfig) bool {
|
|
return AnyStagedFilesExceptSubmodules(files, submoduleConfigs) || AnyTrackedFilesExceptSubmodules(files, submoduleConfigs)
|
|
}
|
|
|
|
func GetUnstagedFilesExceptSubmodules(files []*models.File, submoduleConfigs []*models.SubmoduleConfig) []string {
|
|
return lo.FilterMap(files, func(f *models.File, _ int) (string, bool) {
|
|
return f.Path, f.HasUnstagedChanges && f.Tracked && !f.IsSubmodule(submoduleConfigs)
|
|
})
|
|
}
|
|
|
|
func (self *WorkingTreeHelper) FileForSubmodule(submodule *models.SubmoduleConfig) *models.File {
|
|
for _, file := range self.c.Model().Files {
|
|
if file.IsSubmodule([]*models.SubmoduleConfig{submodule}) {
|
|
return file
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (self *WorkingTreeHelper) OpenMergeTool() error {
|
|
self.c.LogAction(self.c.Tr.Actions.OpenMergeTool)
|
|
return self.c.RunSubprocessAndRefresh(
|
|
self.c.Git().WorkingTree.OpenMergeToolCmdObj(),
|
|
)
|
|
}
|
|
|
|
func (self *WorkingTreeHelper) HandleCommitPressWithMessage(initialMessage string, forceSkipHooks bool) error {
|
|
return self.WithEnsureCommittableFiles(func() error {
|
|
self.commitsHelper.OpenCommitMessagePanel(
|
|
&OpenCommitMessagePanelOpts{
|
|
CommitIndex: context.NoCommitIndex,
|
|
InitialMessage: initialMessage,
|
|
SummaryTitle: self.c.Tr.CommitSummaryTitle,
|
|
DescriptionTitle: self.c.Tr.CommitDescriptionTitle,
|
|
PreserveMessage: true,
|
|
OnConfirm: func(summary string, description string) error {
|
|
return self.handleCommit(summary, description, forceSkipHooks)
|
|
},
|
|
OnSwitchToEditor: func(filepath string) error {
|
|
return self.switchFromCommitMessagePanelToEditor(filepath, forceSkipHooks)
|
|
},
|
|
ForceSkipHooks: forceSkipHooks,
|
|
SkipHooksPrefix: self.c.UserConfig().Git.SkipHookPrefix,
|
|
},
|
|
)
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (self *WorkingTreeHelper) handleCommit(summary string, description string, forceSkipHooks bool) error {
|
|
cmdObj := self.c.Git().Commit.CommitCmdObj(summary, description, forceSkipHooks)
|
|
self.c.LogAction(self.c.Tr.Actions.Commit)
|
|
return self.gpgHelper.WithGpgHandlingAndSelectHeadCommit(cmdObj, git_commands.CommitGpgSign, self.c.Tr.CommittingStatus,
|
|
func() error {
|
|
self.commitsHelper.ClearPreservedCommitMessage()
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (self *WorkingTreeHelper) switchFromCommitMessagePanelToEditor(filepath string, forceSkipHooks bool) error {
|
|
// We won't be able to tell whether the commit was successful, because
|
|
// RunSubprocessAndRefresh doesn't return the error (it opens an error alert
|
|
// itself and returns nil on error). But even if we could, we wouldn't have
|
|
// access to the last message that the user typed, and it might be very
|
|
// different from what was last in the commit panel. So the best we can do
|
|
// here is to always clear the remembered commit message.
|
|
self.commitsHelper.ClearPreservedCommitMessage()
|
|
|
|
self.c.LogAction(self.c.Tr.Actions.Commit)
|
|
return self.c.RunSubprocessAndRefresh(
|
|
self.c.Git().Commit.CommitInEditorWithMessageFileCmdObj(filepath, forceSkipHooks),
|
|
)
|
|
}
|
|
|
|
// HandleCommitEditorPress - handle when the user wants to commit changes via
|
|
// their editor rather than via the popup panel
|
|
func (self *WorkingTreeHelper) HandleCommitEditorPress() error {
|
|
return self.WithEnsureCommittableFiles(func() error {
|
|
// See reasoning in switchFromCommitMessagePanelToEditor for why it makes sense
|
|
// to clear this message before calling into the editor
|
|
self.commitsHelper.ClearPreservedCommitMessage()
|
|
|
|
self.c.LogAction(self.c.Tr.Actions.Commit)
|
|
return self.c.RunSubprocessAndRefresh(
|
|
self.c.Git().Commit.CommitEditorCmdObj(),
|
|
)
|
|
})
|
|
}
|
|
|
|
func (self *WorkingTreeHelper) HandleWIPCommitPress() error {
|
|
var initialMessage string
|
|
preservedMessage := self.c.Contexts().CommitMessage.GetPreservedMessageAndLogError()
|
|
if preservedMessage == "" {
|
|
// Use the skipHook prefix only if we don't have a preserved message
|
|
initialMessage = self.c.UserConfig().Git.SkipHookPrefix
|
|
}
|
|
return self.HandleCommitPressWithMessage(initialMessage, true)
|
|
}
|
|
|
|
func (self *WorkingTreeHelper) HandleCommitPress() error {
|
|
var initialMessage string
|
|
preservedMessage := self.c.Contexts().CommitMessage.GetPreservedMessageAndLogError()
|
|
if preservedMessage == "" {
|
|
commitPrefixConfigs := self.commitPrefixConfigsForRepo()
|
|
for _, commitPrefixConfig := range commitPrefixConfigs {
|
|
prefixPattern := commitPrefixConfig.Pattern
|
|
if prefixPattern == "" {
|
|
continue
|
|
}
|
|
prefixReplace := commitPrefixConfig.Replace
|
|
branchName := self.refHelper.GetCheckedOutRef().Name
|
|
rgx, err := regexp.Compile(prefixPattern)
|
|
if err != nil {
|
|
return fmt.Errorf("%s: %s", self.c.Tr.CommitPrefixPatternError, err.Error())
|
|
}
|
|
|
|
if rgx.MatchString(branchName) {
|
|
initialMessage = rgx.ReplaceAllString(branchName, prefixReplace)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return self.HandleCommitPressWithMessage(initialMessage, false)
|
|
}
|
|
|
|
func (self *WorkingTreeHelper) WithEnsureCommittableFiles(handler func() error) error {
|
|
if err := self.prepareFilesForCommit(); err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(self.c.Model().Files) == 0 {
|
|
return errors.New(self.c.Tr.NoFilesStagedTitle)
|
|
}
|
|
|
|
if !self.AnyStagedFiles() {
|
|
return self.promptToStageAllAndRetry(handler)
|
|
}
|
|
|
|
return handler()
|
|
}
|
|
|
|
func (self *WorkingTreeHelper) promptToStageAllAndRetry(retry func() error) error {
|
|
self.c.Confirm(types.ConfirmOpts{
|
|
Title: self.c.Tr.NoFilesStagedTitle,
|
|
Prompt: self.c.Tr.NoFilesStagedPrompt,
|
|
HandleConfirm: func() error {
|
|
self.c.LogAction(self.c.Tr.Actions.StageAllFiles)
|
|
if err := self.c.Git().WorkingTree.StageAll(false); err != nil {
|
|
return err
|
|
}
|
|
self.syncRefresh()
|
|
|
|
return retry()
|
|
},
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
// for when you need to refetch files before continuing an action. Runs synchronously.
|
|
func (self *WorkingTreeHelper) syncRefresh() {
|
|
self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.FILES}})
|
|
}
|
|
|
|
func (self *WorkingTreeHelper) prepareFilesForCommit() error {
|
|
noStagedFiles := !self.AnyStagedFiles()
|
|
if noStagedFiles && self.c.UserConfig().Gui.SkipNoStagedFilesWarning {
|
|
self.c.LogAction(self.c.Tr.Actions.StageAllFiles)
|
|
err := self.c.Git().WorkingTree.StageAll(false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
self.syncRefresh()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (self *WorkingTreeHelper) commitPrefixConfigsForRepo() []config.CommitPrefixConfig {
|
|
cfg, ok := self.c.UserConfig().Git.CommitPrefixes[self.c.Git().RepoPaths.RepoName()]
|
|
if ok {
|
|
return append(cfg, self.c.UserConfig().Git.CommitPrefix...)
|
|
}
|
|
|
|
return self.c.UserConfig().Git.CommitPrefix
|
|
}
|
|
|
|
func (self *WorkingTreeHelper) mergeFile(filepath string, strategy string) (string, error) {
|
|
if self.c.Git().Version.IsOlderThan(2, 43, 0) {
|
|
return self.mergeFileWithTempFiles(filepath, strategy)
|
|
}
|
|
|
|
return self.mergeFileWithObjectIDs(filepath, strategy)
|
|
}
|
|
|
|
func (self *WorkingTreeHelper) mergeFileWithTempFiles(filepath string, strategy string) (string, error) {
|
|
showToTempFile := func(stage int, label string) (string, error) {
|
|
output, err := self.c.Git().WorkingTree.ShowFileAtStage(filepath, stage)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
f, err := os.CreateTemp(self.c.GetConfig().GetTempDir(), "mergefile-"+label+"-*")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer f.Close()
|
|
|
|
if _, err := f.Write([]byte(output)); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return f.Name(), nil
|
|
}
|
|
|
|
baseFilepath, err := showToTempFile(1, "base")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer os.Remove(baseFilepath)
|
|
|
|
oursFilepath, err := showToTempFile(2, "ours")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer os.Remove(oursFilepath)
|
|
|
|
theirsFilepath, err := showToTempFile(3, "theirs")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer os.Remove(theirsFilepath)
|
|
|
|
return self.c.Git().WorkingTree.MergeFileForFiles(strategy, oursFilepath, baseFilepath, theirsFilepath)
|
|
}
|
|
|
|
func (self *WorkingTreeHelper) mergeFileWithObjectIDs(filepath, strategy string) (string, error) {
|
|
baseID, err := self.c.Git().WorkingTree.ObjectIDAtStage(filepath, 1)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
oursID, err := self.c.Git().WorkingTree.ObjectIDAtStage(filepath, 2)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
theirsID, err := self.c.Git().WorkingTree.ObjectIDAtStage(filepath, 3)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return self.c.Git().WorkingTree.MergeFileForObjectIDs(strategy, oursID, baseID, theirsID)
|
|
}
|
|
|
|
func (self *WorkingTreeHelper) CreateMergeConflictMenu(selectedFilepaths []string) error {
|
|
onMergeStrategySelected := func(strategy string) error {
|
|
for _, filepath := range selectedFilepaths {
|
|
output, err := self.mergeFile(filepath, strategy)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err = os.WriteFile(filepath, []byte(output), 0o644); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
err := self.c.Git().WorkingTree.StageFiles(selectedFilepaths, nil)
|
|
self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.FILES}})
|
|
return err
|
|
}
|
|
|
|
cmdColor := style.FgBlue
|
|
return self.c.Menu(types.CreateMenuOptions{
|
|
Title: self.c.Tr.MergeConflictOptionsTitle,
|
|
Items: []*types.MenuItem{
|
|
{
|
|
LabelColumns: []string{
|
|
self.c.Tr.UseCurrentChanges,
|
|
cmdColor.Sprint("git merge-file --ours"),
|
|
},
|
|
OnPress: func() error {
|
|
return onMergeStrategySelected("--ours")
|
|
},
|
|
Keys: menuKey('c'),
|
|
},
|
|
{
|
|
LabelColumns: []string{
|
|
self.c.Tr.UseIncomingChanges,
|
|
cmdColor.Sprint("git merge-file --theirs"),
|
|
},
|
|
OnPress: func() error {
|
|
return onMergeStrategySelected("--theirs")
|
|
},
|
|
Keys: menuKey('i'),
|
|
},
|
|
{
|
|
LabelColumns: []string{
|
|
self.c.Tr.UseBothChanges,
|
|
cmdColor.Sprint("git merge-file --union"),
|
|
},
|
|
OnPress: func() error {
|
|
return onMergeStrategySelected("--union")
|
|
},
|
|
Keys: menuKey('b'),
|
|
},
|
|
{
|
|
LabelColumns: []string{
|
|
self.c.Tr.OpenMergeTool,
|
|
cmdColor.Sprint("git mergetool"),
|
|
},
|
|
OnPress: self.OpenMergeTool,
|
|
Keys: menuKey('m'),
|
|
},
|
|
},
|
|
})
|
|
}
|