Exit gracefully when a repo or worktree gets deleted from under lazygit (#6129)

If the current worktree (or repo) that lazygit has open gets deleted
externally, lazygit used to crash with a panic. Exit gracefully in that
case, showing an error that the directory git deleted.

Also fix a crash for the more exotic case where a linked worktree's
directory is deleted and another worktree is created in the same
location. Users shouldn't do this, but it's still nicer to handle this
more gracefully.

Fixes #5266.
This commit is contained in:
Stefan Haller
2026-10-15 09:06:13 +02:00
committed by GitHub
11 changed files with 182 additions and 31 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}
+7 -4
View File
@@ -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) {
@@ -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"}
+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)
})
}
}
+22 -15
View File
@@ -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, "/")
}
@@ -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)
+30 -11
View File
@@ -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{}
}
+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",