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) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller
2026-10-15 08:35:00 +02:00
co-authored by Claude Opus 5.5
parent 787a309b3a
commit 4b61c4b650
7 changed files with 99 additions and 1 deletions
+7
View File
@@ -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}
+13
View File
@@ -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{
@@ -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)
})
}
}
@@ -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
+1 -1
View File
@@ -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
+14
View File
@@ -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
}
+2
View File
@@ -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",