From 4b61c4b65010b1edecbd186c11a37eb380b71881 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 13 Oct 2026 09:15:10 +0200 Subject: [PATCH] Quit with an error message when the repo or worktree is deleted If the repo or linked worktree that lazygit has open is deleted while lazygit is running, every git command fails. lazygit no longer panics since the previous commit, but it shows empty lists, and there's nothing useful left to do in it. Check at the start of every refresh whether the worktree and its git dir still exist. If one of them is gone, quit and print its path in the terminal. The background files refresh does this check too, so lazygit quits within ten seconds even if nobody is using it. To quit from a refresh, return an error that wraps gocui.ErrQuit; this keeps it out of the error popup. Switching to another worktree or to a recent repo would be an alternative, but it would be more surprising than quitting. Also, there isn't always a main worktree to switch to. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/app/errors.go | 7 +++ pkg/commands/git_commands/repo_paths.go | 13 ++++++ pkg/commands/git_commands/repo_paths_test.go | 44 +++++++++++++++++++ pkg/gui/controllers/helpers/refresh_helper.go | 18 ++++++++ pkg/gui/gui.go | 2 +- pkg/gui/types/common.go | 14 ++++++ pkg/i18n/english.go | 2 + 7 files changed, 99 insertions(+), 1 deletion(-) 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/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/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 1f27221d8..826d12400 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -226,6 +226,24 @@ 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 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",