mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-11-12 15:53:33 -04:00
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:
@@ -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}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user