diff --git a/pkg/gui/controllers/helpers/diff_helper.go b/pkg/gui/controllers/helpers/diff_helper.go index 1e94c9ac7..d855282ea 100644 --- a/pkg/gui/controllers/helpers/diff_helper.go +++ b/pkg/gui/controllers/helpers/diff_helper.go @@ -1,6 +1,7 @@ package helpers import ( + "errors" "path/filepath" "strings" @@ -11,6 +12,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/gui/modes/diffing" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) @@ -229,27 +231,87 @@ func (self *DiffHelper) OpenDiffToolForRef(selectedRef models.Ref) error { // EditFilesOfDiff opens files of the diff that the current side panel shows in the // editor. They are opened from the worktree of the diff (see otherWorktreeOfDiff). +// Files that don't exist there are left out, and if none of them exist, it returns an +// error (see existingPathsInWorktreeOfDiff). func (self *DiffHelper) EditFilesOfDiff(paths []string) error { - return self.filesHelper.EditFiles(lo.Map(paths, func(path string, _ int) string { - return self.pathInWorktreeOfDiff(path) - })) + existingPaths, err := self.existingPathsInWorktreeOfDiff(paths) + if err != nil { + return err + } + return self.filesHelper.EditFiles(existingPaths) } // EditFileOfDiffAtLine opens a file of the diff shown in the given view in the editor, // at a line of that diff. The line is carried forward to where it is in the file now // (see AdjustLineNumber). Like EditFilesOfDiff, it opens the file from the worktree of -// the diff. +// the diff, and returns an error if the file doesn't exist there. func (self *DiffHelper) EditFileOfDiffAtLine(path string, lineNumber int, viewName string) error { - path = self.pathInWorktreeOfDiff(path) + path, err := self.existingPathInWorktreeOfDiff(path) + if err != nil { + return err + } lineNumber = self.AdjustLineNumber(path, lineNumber, viewName) return self.filesHelper.EditFileAtLine(path, lineNumber) } // OpenFileOfDiff opens a file of the diff that the current side panel shows with the // default application for it. Like EditFilesOfDiff, it opens the file from the -// worktree of the diff. +// worktree of the diff, and returns an error if the file doesn't exist there. func (self *DiffHelper) OpenFileOfDiff(path string) error { - return self.filesHelper.OpenFile(self.pathInWorktreeOfDiff(path)) + path, err := self.existingPathInWorktreeOfDiff(path) + if err != nil { + return err + } + return self.filesHelper.OpenFile(path) +} + +// existingPathsInWorktreeOfDiff returns the absolute paths of those of the given files +// of the diff that exist in the worktree of the diff. A file of a diff may be missing +// there, for example if a later commit deleted it, or if the worktree has a branch +// checked out that doesn't have it. An editor would create such a file as a new, empty +// one, so it is left out. +// +// If none of the files exist, it returns an error. If only some of them are missing, it +// leaves them out silently. The user would see such an error only after returning from +// the editor. +func (self *DiffHelper) existingPathsInWorktreeOfDiff(paths []string) ([]string, error) { + absPaths := make([]string, 0, len(paths)) + for _, path := range paths { + absPath, err := self.AbsolutePathOfFileOfDiff(path) + if err != nil { + return nil, err + } + absPaths = append(absPaths, absPath) + } + + existingPaths := lo.Filter(absPaths, func(path string, _ int) bool { + exists, err := self.c.OS().FileExists(path) + // If we can't tell, the editor reports the problem + return exists || err != nil + }) + if len(existingPaths) > 0 { + return existingPaths, nil + } + + if len(absPaths) == 1 { + return nil, errors.New(utils.ResolvePlaceholderString( + self.c.Tr.FileOfDiffDoesNotExist, map[string]string{"path": absPaths[0]})) + } + worktreePath, ok := self.otherWorktreeOfDiff() + if !ok { + worktreePath = self.c.Git().RepoPaths.WorktreePath() + } + return nil, errors.New(utils.ResolvePlaceholderString( + self.c.Tr.NoFileOfDiffExists, map[string]string{"worktreePath": worktreePath})) +} + +// existingPathInWorktreeOfDiff is existingPathsInWorktreeOfDiff for a single file. +func (self *DiffHelper) existingPathInWorktreeOfDiff(path string) (string, error) { + existingPaths, err := self.existingPathsInWorktreeOfDiff([]string{path}) + if err != nil { + return "", err + } + return existingPaths[0], nil } // AbsolutePathOfFileOfDiff returns the absolute path of a file of the diff that the diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 61f81a1f0..1cb516631 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -422,6 +422,8 @@ type TranslationSet struct { JumpToFileInDiffTooltip string OnlyOneFileInDiff string NoFileInDiffNamed string + FileOfDiffDoesNotExist string + NoFileOfDiffExists string PrevConflict string NextConflict string SelectPrevHunk string @@ -1617,6 +1619,8 @@ func EnglishTranslationSet() *TranslationSet { JumpToFileInDiffTooltip: "Pick one of the files of the diff shown in the main view, and scroll the main view to it. The focus stays in this panel.", OnlyOneFileInDiff: "There is only one file in this diff", NoFileInDiffNamed: "This diff has no file named '{{.path}}'", + FileOfDiffDoesNotExist: "File '{{.path}}' doesn't exist in the working tree", + NoFileOfDiffExists: "None of the selected files exist in the working tree at '{{.worktreePath}}'", PrevConflict: "Previous conflict", NextConflict: "Next conflict", SelectPrevHunk: "Previous hunk", diff --git a/pkg/integration/tests/commit/edit_files_missing_from_working_tree.go b/pkg/integration/tests/commit/edit_files_missing_from_working_tree.go new file mode 100644 index 000000000..2239b2602 --- /dev/null +++ b/pkg/integration/tests/commit/edit_files_missing_from_working_tree.go @@ -0,0 +1,102 @@ +package commit + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var EditFilesMissingFromWorkingTree = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Edit or open files of a commit that a later commit deleted", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + config.GetUserConfig().OS.Edit = "echo {{filename}} > edit-command" + config.GetUserConfig().OS.EditAtLine = "echo {{filename}}:{{line}} > edit-command" + config.GetUserConfig().OS.Open = "echo {{filename}} > open-command" + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\n") + shell.CreateFileAndAdd("file2", "two\n") + shell.CreateFileAndAdd("file3", "three\n") + shell.Commit("add files") + shell.DeleteFileAndAdd("file2") + shell.DeleteFileAndAdd("file3") + shell.Commit("delete files") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Lines( + Contains("delete files").IsSelected(), + Contains("add files"), + ). + NavigateToLine(Contains("add files")). + PressEnter() + + t.Views().CommitFiles(). + IsFocused(). + Lines( + Equals("▼ /").IsSelected(), + Contains("A file1"), + Contains("A file2"), + Contains("A file3"), + ). + NavigateToLine(Contains("file2")). + Press(keys.Universal.Edit). + Tap(func() { + t.ExpectPopup().Alert(). + Title(Equals("Error")). + Content(Contains("/repo/file2' doesn't exist in the working tree")). + Confirm() + }). + Press(keys.Universal.OpenFile). + Tap(func() { + t.ExpectPopup().Alert(). + Title(Equals("Error")). + Content(Contains("/repo/file2' doesn't exist in the working tree")). + Confirm() + }). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + NavigateToLine(Contains("+two")). + Press(keys.Universal.Edit). + Tap(func() { + t.ExpectPopup().Alert(). + Title(Equals("Error")). + Content(Contains("/repo/file2' doesn't exist in the working tree")). + Confirm() + }). + PressEscape() + + t.Views().CommitFiles(). + IsFocused(). + Press(keys.Universal.ToggleRangeSelect). + SelectNextItem(). + SelectedLines( + Contains("A file2"), + Contains("A file3"), + ). + Press(keys.Universal.Edit). + Tap(func() { + t.ExpectPopup().Alert(). + Title(Equals("Error")). + Content(Contains("None of the selected files exist in the working tree at '")). + Confirm() + }). + // If only some of the files are missing, the others are opened without + // complaint + NavigateToLine(Contains("file1")). + SelectedLines( + Contains("A file1"), + Contains("A file2"), + ). + Press(keys.Universal.Edit). + Tap(func() { + t.FileSystem().FileContent("edit-command", + Contains("/repo/file1\n").DoesNotContain("file2")) + }) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 207a2db70..aea6c4f13 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -135,6 +135,7 @@ var tests = []*components.IntegrationTest{ commit.DiscardRenamedFile, commit.DiscardSubmoduleChanges, commit.DoNotShowBranchMarkerForHeadCommit, + commit.EditFilesMissingFromWorkingTree, commit.FailHooksThenCommitNoHooks, commit.FindBaseCommitForFixup, commit.FindBaseCommitForFixupDisregardFixupsForSameBaseCommit,