From 1c79c830f800cb5346a5eb2692302c945caacd4c Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 13 Oct 2026 10:07:57 +0200 Subject: [PATCH 1/7] Cleanup: make WorktreeLoader.pathExists return what its name says It returns true if the path is missing. It started out as IsWorktreePathMissing, and was renamed without inverting its result when it moved into the loader in de57cfd6ff. Invert the result instead of renaming it back, so that the call site reads `IsPathMissing: !self.pathExists(path)`. If we can't check the path, it still counts as existing, as it always did. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/commands/git_commands/worktree_loader.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/pkg/commands/git_commands/worktree_loader.go b/pkg/commands/git_commands/worktree_loader.go index ed5dc2f46..4760841c7 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) { From 29e8c79ff59a3cc30e44e9e6397d4f96f9ee2241 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 13 Oct 2026 09:01:40 +0200 Subject: [PATCH 2/7] Extract a helper for trimming the slashes around a worktree path The next commit compares worktree paths with their slashes trimmed. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/commands/git_commands/worktree_loader.go | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/pkg/commands/git_commands/worktree_loader.go b/pkg/commands/git_commands/worktree_loader.go index 4760841c7..418ca5c06 100644 --- a/pkg/commands/git_commands/worktree_loader.go +++ b/pkg/commands/git_commands/worktree_loader.go @@ -249,11 +249,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) @@ -269,11 +266,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) @@ -286,3 +280,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, "/") +} From ebee95b8474ffeb5fe3c1f6e53f5a4e36ddb3737 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 13 Oct 2026 09:03:05 +0200 Subject: [PATCH 3/7] Fix crash when two worktrees have the same path If a linked worktree's directory is deleted and another worktree is moved to the same path, `git worktree list` reports both worktrees with that path. lazygit then crashes at startup with a stack overflow, and the terminal is left in a broken state. To tell the paths in a group apart, getUniqueNamesFromPathsAux keeps looking at one more parent directory, but identical paths never come apart, so it recurses forever. A stack overflow is a fatal error rather than a panic. No deferred function runs, so the terminal is not restored either. Name identical paths like a single path instead of recursing. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/commands/git_commands/worktree_loader.go | 13 ++++++++--- .../git_commands/worktree_loader_test.go | 22 +++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/pkg/commands/git_commands/worktree_loader.go b/pkg/commands/git_commands/worktree_loader.go index 418ca5c06..d9b8c07c4 100644 --- a/pkg/commands/git_commands/worktree_loader.go +++ b/pkg/commands/git_commands/worktree_loader.go @@ -236,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)...) } 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) From 82207df464f81eaa26771f421b27f81b089a1cee Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 13 Oct 2026 09:04:31 +0200 Subject: [PATCH 4/7] Add a test for loading branches when listing them fails Co-Authored-By: Claude Opus 5.5 (1M context) --- .../git_commands/branch_loader_test.go | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/pkg/commands/git_commands/branch_loader_test.go b/pkg/commands/git_commands/branch_loader_test.go index d549f4c39..eee277ca3 100644 --- a/pkg/commands/git_commands/branch_loader_test.go +++ b/pkg/commands/git_commands/branch_loader_test.go @@ -165,6 +165,29 @@ 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, + } + + /* EXPECTED: + _, err := loader.Load(nil, nil, nil, false, nil, nil) + assert.EqualError(t, err, "chdir /path/to/repo: no such file or directory") + ACTUAL: */ + assert.PanicsWithError(t, "chdir /path/to/repo: no such file or directory", func() { _, _ = loader.Load(nil, nil, nil, false, nil, nil) }) + + runner.CheckForMissingCalls() +} + func TestGetBehindBaseBranchValuesForAllBranches_FastPath(t *testing.T) { mainBranchRefs := []string{"refs/heads/master", "refs/remotes/origin/develop"} From 4d288579ee0e433f15dcf637883f5c72363c786e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 13 Oct 2026 09:05:13 +0200 Subject: [PATCH 5/7] Don't panic when listing the branches fails If the repo that lazygit has open is deleted while lazygit is running, the next full refresh panics. The same happens for a linked worktree that is deleted. In both cases `git for-each-ref` fails because its working directory is gone, and BranchLoader panics when that happens. Return the error instead. The branches refresh logs it, like it does with the other errors it gets from loading the branches. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/commands/git_commands/branch_loader.go | 11 +++++++---- pkg/commands/git_commands/branch_loader_test.go | 3 --- 2 files changed, 7 insertions(+), 7 deletions(-) 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 eee277ca3..ad9c0b48f 100644 --- a/pkg/commands/git_commands/branch_loader_test.go +++ b/pkg/commands/git_commands/branch_loader_test.go @@ -179,11 +179,8 @@ func TestLoadWhenListingBranchesFails(t *testing.T) { cmd: gitCommon.cmd, } - /* EXPECTED: _, err := loader.Load(nil, nil, nil, false, nil, nil) assert.EqualError(t, err, "chdir /path/to/repo: no such file or directory") - ACTUAL: */ - assert.PanicsWithError(t, "chdir /path/to/repo: no such file or directory", func() { _, _ = loader.Load(nil, nil, nil, false, nil, nil) }) runner.CheckForMissingCalls() } From 787a309b3ab72a5d6892c95126435c83c7fcbe00 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 13 Oct 2026 09:13:15 +0200 Subject: [PATCH 6/7] Begin blocking input after capturing the refresh's baseline The next commit makes performRefresh return early right after the capture, and it must not leave the input blocked when it does. This doesn't let any keypress slip through before the block begins. Only refreshes on the UI thread block input, and there the capture runs synchronously. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/gui/controllers/helpers/refresh_helper.go | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 605319a8e..1f27221d8 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,18 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr }) { 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{} } From 4b61c4b65010b1edecbd186c11a37eb380b71881 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 13 Oct 2026 09:15:10 +0200 Subject: [PATCH 7/7] 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",