diff --git a/pkg/app/errors.go b/pkg/app/errors.go index ee24cff49..8c6eb4bbf 100644 --- a/pkg/app/errors.go +++ b/pkg/app/errors.go @@ -3,6 +3,8 @@ package app import ( "strings" + "github.com/go-errors/errors" + "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/i18n" "github.com/samber/lo" ) @@ -14,6 +16,11 @@ type errorMapping struct { // knownError takes an error and tells us whether it's an error that we know about where we can print a nicely formatted version of it rather than panicking with a stack trace func knownError(tr *i18n.TranslationSet, err error) (string, bool) { + var quitWithMessage types.ErrQuitWithMessage + if errors.As(err, &quitWithMessage) { + return quitWithMessage.Message, true + } + errorMessage := err.Error() knownErrorMessages := []string{minGitVersionErrorMessage(tr), tr.BareRepoNotSupported} diff --git a/pkg/commands/git_commands/branch_loader.go b/pkg/commands/git_commands/branch_loader.go index 3053cd6fa..6cb61754f 100644 --- a/pkg/commands/git_commands/branch_loader.go +++ b/pkg/commands/git_commands/branch_loader.go @@ -74,7 +74,10 @@ func (self *BranchLoader) Load(reflogCommits []*models.Commit, onWorker func(func() error), renderFunc func(), ) ([]*models.Branch, error) { - branches, tips := self.obtainBranches() + branches, tips, err := self.obtainBranches() + if err != nil { + return nil, err + } switch self.UserConfig().Git.LocalBranchSortOrder { case "date": @@ -326,10 +329,10 @@ func (self *BranchLoader) GetBaseBranch(branch *models.Branch, mainBranches *Mai // Returns the branches, along with the tip of each of them, keyed by full ref // name -func (self *BranchLoader) obtainBranches() ([]*models.Branch, map[string]refTip) { +func (self *BranchLoader) obtainBranches() ([]*models.Branch, map[string]refTip, error) { output, err := self.getRawBranches() if err != nil { - panic(err) + return nil, nil, err } trimmedOutput := strings.TrimSpace(output) @@ -355,7 +358,7 @@ func (self *BranchLoader) obtainBranches() ([]*models.Branch, map[string]refTip) return branch, true }) - return branches, tips + return branches, tips, nil } func (self *BranchLoader) getRawBranches() (string, error) { diff --git a/pkg/commands/git_commands/branch_loader_test.go b/pkg/commands/git_commands/branch_loader_test.go index d549f4c39..ad9c0b48f 100644 --- a/pkg/commands/git_commands/branch_loader_test.go +++ b/pkg/commands/git_commands/branch_loader_test.go @@ -165,6 +165,26 @@ func TestObtainBranch(t *testing.T) { } } +func TestLoadWhenListingBranchesFails(t *testing.T) { + runner := oscommands.NewFakeRunner(t). + ExpectFunc("git for-each-ref", func(cmdObj *oscommands.CmdObj) bool { + return cmdObj.GetCmd().Args[1] == "for-each-ref" + }, "", errors.New("chdir /path/to/repo: no such file or directory")) + + gitCommon := buildGitCommon(commonDeps{runner: runner}) + + loader := &BranchLoader{ + Common: gitCommon.Common, + GitCommon: gitCommon, + cmd: gitCommon.cmd, + } + + _, err := loader.Load(nil, nil, nil, false, nil, nil) + assert.EqualError(t, err, "chdir /path/to/repo: no such file or directory") + + runner.CheckForMissingCalls() +} + func TestGetBehindBaseBranchValuesForAllBranches_FastPath(t *testing.T) { mainBranchRefs := []string{"refs/heads/master", "refs/remotes/origin/develop"} diff --git a/pkg/commands/git_commands/repo_paths.go b/pkg/commands/git_commands/repo_paths.go index 088dd86b6..9850b5a4f 100644 --- a/pkg/commands/git_commands/repo_paths.go +++ b/pkg/commands/git_commands/repo_paths.go @@ -81,6 +81,19 @@ func (self *RepoPaths) GitLocationEnvVars() []string { return self.gitLocationEnvVars } +// Returns the path of the worktree or of the worktree's git dir if it no longer +// exists. This happens if the repo or the worktree is deleted while lazygit is +// running. +func (self *RepoPaths) MissingDir() (string, bool) { + for _, path := range []string{self.worktreePath, self.worktreeGitDirPath} { + if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + return path, true + } + } + + return "", false +} + // Returns the repo paths for a typical repo func MockRepoPaths(currentPath string) *RepoPaths { return &RepoPaths{ diff --git a/pkg/commands/git_commands/repo_paths_test.go b/pkg/commands/git_commands/repo_paths_test.go index c7b7705b4..683c41b81 100644 --- a/pkg/commands/git_commands/repo_paths_test.go +++ b/pkg/commands/git_commands/repo_paths_test.go @@ -2,6 +2,8 @@ package git_commands import ( "fmt" + "os" + "path/filepath" "runtime" "strings" "testing" @@ -314,3 +316,45 @@ func TestGetRepoPaths(t *testing.T) { }) } } + +func TestMissingDir(t *testing.T) { + scenarios := []struct { + name string + deletedDir func(repoPaths *RepoPaths) string + }{ + { + name: "nothing deleted", + deletedDir: nil, + }, + { + name: "worktree deleted", + deletedDir: (*RepoPaths).WorktreePath, + }, + { + name: "git dir deleted", + deletedDir: (*RepoPaths).WorktreeGitDirPath, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + root := t.TempDir() + repoPaths := &RepoPaths{ + worktreePath: filepath.Join(root, "worktree"), + worktreeGitDirPath: filepath.Join(root, "repo", ".git", "worktrees", "worktree"), + } + assert.NoError(t, os.MkdirAll(repoPaths.WorktreePath(), 0o755)) + assert.NoError(t, os.MkdirAll(repoPaths.WorktreeGitDirPath(), 0o755)) + + expectedPath := "" + if s.deletedDir != nil { + expectedPath = s.deletedDir(repoPaths) + assert.NoError(t, os.RemoveAll(expectedPath)) + } + + path, missing := repoPaths.MissingDir() + assert.Equal(t, expectedPath, path) + assert.Equal(t, expectedPath != "", missing) + }) + } +} diff --git a/pkg/commands/git_commands/worktree_loader.go b/pkg/commands/git_commands/worktree_loader.go index ed5dc2f46..d9b8c07c4 100644 --- a/pkg/commands/git_commands/worktree_loader.go +++ b/pkg/commands/git_commands/worktree_loader.go @@ -53,7 +53,7 @@ func (self *WorktreeLoader) GetWorktrees() ([]*models.Worktree, error) { path := strings.SplitN(splitLine, " ", 2)[1] current = &models.Worktree{ - IsPathMissing: self.pathExists(path), + IsPathMissing: !self.pathExists(path), Path: path, // we defer populating GitDir until a loop below so that // we can parallelize the calls to git rev-parse, and @@ -158,12 +158,13 @@ func (self *WorktreeLoader) GetWorktrees() ([]*models.Worktree, error) { func (self *WorktreeLoader) pathExists(path string) bool { if _, err := self.Fs.Stat(path); err != nil { if errors.Is(err, iofs.ErrNotExist) { - return true + return false } + // We can't tell, so we don't show the worktree as missing self.Log.Errorf("failed to check if worktree path `%s` exists\n%v", path, err) - return false + return true } - return false + return true } func (self *WorktreeLoader) rebasedBranch(worktree *models.Worktree) (string, bool) { @@ -235,9 +236,16 @@ func getUniqueNamesFromPathsAux(paths []pathWithIndexT, depth int) []nameWithInd result := []nameWithIndexT{} for _, group := range groups { - if len(group) == 1 { - path := group[0] - result = append(result, nameWithIndexT{index: path.index, name: sliceAtDepth(path.path, depth)}) + // Identical paths can't be told apart at any depth, so they are named + // like a single path. Git reports two worktrees with the same path when + // a worktree directory was deleted and another worktree was moved to + // its place. + if lo.EveryBy(group, func(path pathWithIndexT) bool { + return trimSlashes(path.path) == trimSlashes(group[0].path) + }) { + for _, path := range group { + result = append(result, nameWithIndexT{index: path.index, name: sliceAtDepth(path.path, depth)}) + } } else { result = append(result, getUniqueNamesFromPathsAux(group, depth+1)...) } @@ -248,11 +256,8 @@ func getUniqueNamesFromPathsAux(paths []pathWithIndexT, depth int) []nameWithInd // if the path is /a/b/c/d, and the depth is 0, the value is 'd'. If the depth is 1, the value is 'c', etc func valueAtDepth(path string, depth int) string { - path = strings.TrimPrefix(path, "/") - path = strings.TrimSuffix(path, "/") - // Split the path into segments - segments := strings.Split(path, "/") + segments := strings.Split(trimSlashes(path), "/") // Get the length of segments length := len(segments) @@ -268,11 +273,8 @@ func valueAtDepth(path string, depth int) string { // if the path is /a/b/c/d, and the depth is 0, the value is 'd'. If the depth is 1, the value is 'b/c', etc func sliceAtDepth(path string, depth int) string { - path = strings.TrimPrefix(path, "/") - path = strings.TrimSuffix(path, "/") - // Split the path into segments - segments := strings.Split(path, "/") + segments := strings.Split(trimSlashes(path), "/") // Get the length of segments length := len(segments) @@ -285,3 +287,8 @@ func sliceAtDepth(path string, depth int) string { // Join the segments from the specified depth till end of the path return strings.Join(segments[length-1-depth:], "/") } + +func trimSlashes(path string) string { + path = strings.TrimPrefix(path, "/") + return strings.TrimSuffix(path, "/") +} diff --git a/pkg/commands/git_commands/worktree_loader_test.go b/pkg/commands/git_commands/worktree_loader_test.go index c537c6be4..2f715a7f0 100644 --- a/pkg/commands/git_commands/worktree_loader_test.go +++ b/pkg/commands/git_commands/worktree_loader_test.go @@ -353,6 +353,28 @@ func TestGetUniqueNamesFromPaths(t *testing.T) { "e/c/d", }, }, + { + input: []string{ + "/a/b/c", + "/a/b/c", + }, + expected: []string{ + "c", + "c", + }, + }, + { + input: []string{ + "/a/b/c", + "/a/b/c/", + "/a/d/c", + }, + expected: []string{ + "b/c", + "b/c", + "d/c", + }, + }, } { actual := getUniqueNamesFromPaths(scenario.input) assert.EqualValues(t, scenario.expected, actual) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 605319a8e..826d12400 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -203,17 +203,6 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr panic("a refresh with a Then callback must not set DontBlockRepoSwitch") } - // A RefreshBlockingInput caller wants keyboard input withheld until the - // refreshed state is in place (see IGuiCommon.RefreshBlockingInput). Begin - // the block synchronously here in the calling handler, so that no keypress - // can slip through before it; the finishing step ends it from a callback - // queued behind the refresh's own updates (see waitAndFinalize). Demos - // take the blocking inline path below and need none of this. - blockInputUntilDone := blockInput && !self.c.InDemo() - if blockInputUntilDone { - self.c.GocuiGui().BeginBlockingEvents() - } - // Capture the refresh's baseline once, here at the start: the repo // generation that every scope's bounce is guarded against, and the git // command instance the scopes run their commands through. The two are @@ -236,6 +225,36 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr }) { return } + + // If the repo or worktree was deleted, there's nothing we can show any + // more, and every git command would fail. + if path, missing := env.git.RepoPaths.MissingDir(); missing { + self.c.OnUIThread(func() error { + if self.c.State().GetRepoGeneration() != env.generation { + // We have switched away from the deleted worktree + return nil + } + + return types.ErrQuitWithMessage{ + Message: utils.ResolvePlaceholderString( + self.c.Tr.RepoDirNoLongerExists, map[string]string{"path": path}, + ), + } + }) + return + } + + // A RefreshBlockingInput caller wants keyboard input withheld until the + // refreshed state is in place (see IGuiCommon.RefreshBlockingInput). Begin + // the block synchronously here in the calling handler, so that no keypress + // can slip through before it; the finishing step ends it from a callback + // queued behind the refresh's own updates (see waitAndFinalize). Demos + // take the blocking inline path below and need none of this. + blockInputUntilDone := blockInput && !self.c.InDemo() + if blockInputUntilDone { + self.c.GocuiGui().BeginBlockingEvents() + } + if options.BatchUIUpdates { env.batch = &refreshBounceBatch{} } diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 6f1a04875..803980c50 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -1046,7 +1046,7 @@ func (gui *Gui) RunAndHandleError(startArgs appTypes.StartArgs) error { close(gui.stopChan) - if errors.Is(err, gocui.ErrQuit) { + if errors.Is(err, gocui.ErrQuit) && !errors.As(err, &types.ErrQuitWithMessage{}) { if gui.c.State().GetRetainOriginalDir() { if err := gui.helpers.RecordDirectory.RecordDirectory(gui.InitialDir); err != nil { return err diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 6f77c7856..7665e96e6 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -490,3 +490,17 @@ const ( SCREEN_HALF SCREEN_FULL ) + +// Returning this error from a handler quits lazygit like gocui.ErrQuit does, +// and then prints the message in the terminal. +type ErrQuitWithMessage struct { + Message string +} + +func (e ErrQuitWithMessage) Error() string { + return e.Message +} + +func (e ErrQuitWithMessage) Unwrap() error { + return gocui.ErrQuit +} diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 1cb516631..0fb388216 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -561,6 +561,7 @@ type TranslationSet struct { StashOptions string NotARepository string WorkingDirectoryDoesNotExist string + RepoDirNoLongerExists string ScrollLeft string ScrollRight string DiscardPatch string @@ -1759,6 +1760,7 @@ func EnglishTranslationSet() *TranslationSet { StashOptions: "Stash options", NotARepository: "Error: must be run inside a git repository", WorkingDirectoryDoesNotExist: "Error: the current working directory does not exist", + RepoDirNoLongerExists: "Error: {{.path}} no longer exists", ScrollLeft: "Scroll left", ScrollRight: "Scroll right", DiscardPatch: "Discard patch",