From 6da5a6bbc57a961e24bd71ac7075cba657d92e4b Mon Sep 17 00:00:00 2001 From: Hoss Date: Fri, 13 Mar 2026 17:24:07 +0800 Subject: [PATCH 1/2] Auto-switch to main worktree when linked worktree is deleted externally When a linked worktree's directory is deleted while lazygit is running (e.g. from another terminal), every git command fails and lazygit either panics or shows empty panels. Detect this at the start of Refresh by checking whether the CWD still exists on disk, and if the current worktree is a linked worktree, automatically switch to the main worktree instead of crashing. If the main worktree itself is gone, panic as before since there is nothing to recover to. Handles both Linux (os.Getwd fails) and macOS (os.Getwd succeeds but the path no longer exists in the directory tree) CWD deletion behavior. --- pkg/commands/git_commands/repo_paths.go | 7 ++++ pkg/gui/controllers.go | 1 + pkg/gui/controllers/helpers/refresh_helper.go | 36 +++++++++++++++++ pkg/integration/tests/test_list.go | 1 + .../external_remove_current_worktree.go | 39 +++++++++++++++++++ 5 files changed, 84 insertions(+) create mode 100644 pkg/integration/tests/worktree/external_remove_current_worktree.go diff --git a/pkg/commands/git_commands/repo_paths.go b/pkg/commands/git_commands/repo_paths.go index c64debfc5aa..b7e823baefe 100644 --- a/pkg/commands/git_commands/repo_paths.go +++ b/pkg/commands/git_commands/repo_paths.go @@ -57,6 +57,13 @@ func (self *RepoPaths) IsBareRepo() bool { return self.isBareRepo } +// InLinkedWorktree returns true if the current working directory is a linked +// worktree (as opposed to the main worktree). When true, RepoPath() points to +// the main worktree that can be used as a fallback. +func (self *RepoPaths) InLinkedWorktree() bool { + return self.worktreePath != self.repoPath +} + // Returns the repo paths for a typical repo func MockRepoPaths(currentPath string) *RepoPaths { return &RepoPaths{ diff --git a/pkg/gui/controllers.go b/pkg/gui/controllers.go index 702ed826d11..ced65c9d841 100644 --- a/pkg/gui/controllers.go +++ b/pkg/gui/controllers.go @@ -68,6 +68,7 @@ func (gui *Gui) resetHelpersAndControllers() { mergeConflictsHelper, worktreeHelper, searchHelper, + reposHelper, ) diffHelper := helpers.NewDiffHelper(helperCommon) cherryPickHelper := helpers.NewCherryPickHelper( diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 332b3809125..fadfa0e74ce 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -1,6 +1,7 @@ package helpers import ( + "os" "strings" "sync" "time" @@ -27,6 +28,8 @@ type RefreshHelper struct { mergeConflictsHelper *MergeConflictsHelper worktreeHelper *WorktreeHelper searchHelper *SearchHelper + reposHelper *ReposHelper + switchingToMainOnce sync.Once } func NewRefreshHelper( @@ -38,6 +41,7 @@ func NewRefreshHelper( mergeConflictsHelper *MergeConflictsHelper, worktreeHelper *WorktreeHelper, searchHelper *SearchHelper, + reposHelper *ReposHelper, ) *RefreshHelper { return &RefreshHelper{ c: c, @@ -48,6 +52,7 @@ func NewRefreshHelper( mergeConflictsHelper: mergeConflictsHelper, worktreeHelper: worktreeHelper, searchHelper: searchHelper, + reposHelper: reposHelper, } } @@ -56,6 +61,37 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { panic("RefreshOptions.Then doesn't work with mode ASYNC") } + // If the working directory has been deleted (e.g. a worktree removed + // externally), every git command will fail. Detect this early. + // Two platform-specific behaviors: + // Linux: os.Getwd() fails (returns ENOENT) + // macOS: os.Getwd() succeeds (inode kept alive) but os.Stat(cwd) fails + cwdDeleted := false + if cwd, err := os.Getwd(); err != nil { + cwdDeleted = true + } else if _, statErr := os.Stat(cwd); os.IsNotExist(statErr) { + cwdDeleted = true + } + if cwdDeleted { + repoPaths := self.c.Git().RepoPaths + if repoPaths.InLinkedWorktree() { + self.switchingToMainOnce.Do(func() { + mainPath := repoPaths.RepoPath() + self.c.Log.Warnf("Working directory removed, switching to main worktree at %s", mainPath) + // Restore the CWD first so DispatchSwitchToRepo can + // call os.Getwd() without failing. + if err := os.Chdir(mainPath); err != nil { + panic("fatal: working directory removed and cannot switch to main worktree: " + err.Error()) + } + self.c.OnUIThread(func() error { + return self.reposHelper.DispatchSwitchToRepo(mainPath, context.WORKTREES_CONTEXT_KEY) + }) + }) + return + } + panic("fatal: working directory has been removed") + } + t := time.Now() defer func() { self.c.Log.Infof("Refresh took %s", time.Since(t)) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index c336cce1ffb..1575751cbfd 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -474,6 +474,7 @@ var tests = []*components.IntegrationTest{ worktree.DotfileBareRepo, worktree.DoubleNestedLinkedSubmodule, worktree.ExcludeFileInWorktree, + worktree.ExternalRemoveCurrentWorktree, worktree.FastForwardWorktreeBranch, worktree.FastForwardWorktreeBranchShouldNotPolluteCurrentWorktree, worktree.ForceRemoveWorktree, diff --git a/pkg/integration/tests/worktree/external_remove_current_worktree.go b/pkg/integration/tests/worktree/external_remove_current_worktree.go new file mode 100644 index 00000000000..e3e1e987e13 --- /dev/null +++ b/pkg/integration/tests/worktree/external_remove_current_worktree.go @@ -0,0 +1,39 @@ +package worktree + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var ExternalRemoveCurrentWorktree = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Recover when the current linked worktree is deleted externally", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.NewBranch("mybranch") + shell.CreateFileAndAdd("README.md", "hello world") + shell.Commit("initial commit") + shell.AddWorktree("mybranch", "../linked-worktree", "newbranch") + shell.Chdir("../linked-worktree") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + // Confirm we're in the linked worktree + t.Views().Status(). + Lines( + Contains("repo(linked-worktree) → newbranch"), + ) + + // Simulate external deletion (e.g. another terminal runs rm -rf) + t.Shell().RunShellCommand("rm -rf ../linked-worktree") + + // Trigger a refresh so lazygit detects the deleted CWD + t.GlobalPress(keys.Universal.Refresh) + + // Lazygit should auto-switch to the main worktree + t.Views().Status(). + Lines( + Contains("repo → mybranch"), + ) + }, +}) From 842e86f97866a5c472aca18da00d1101eae203b2 Mon Sep 17 00:00:00 2001 From: Hoss Date: Fri, 13 Mar 2026 23:02:21 +0800 Subject: [PATCH 2/2] Show toast notification when auto-switching from deleted worktree Users had no visual feedback when lazygit auto-switched to the main worktree after the linked worktree was deleted externally. Use ErrorToast (4s display) so the message is readable. --- pkg/gui/controllers/helpers/refresh_helper.go | 6 +++++- pkg/i18n/english.go | 2 ++ .../tests/worktree/external_remove_current_worktree.go | 2 ++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index fadfa0e74ce..53b80ae2b25 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -84,7 +84,11 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { panic("fatal: working directory removed and cannot switch to main worktree: " + err.Error()) } self.c.OnUIThread(func() error { - return self.reposHelper.DispatchSwitchToRepo(mainPath, context.WORKTREES_CONTEXT_KEY) + if err := self.reposHelper.DispatchSwitchToRepo(mainPath, context.WORKTREES_CONTEXT_KEY); err != nil { + return err + } + self.c.ErrorToast(self.c.Tr.WorktreeDeletedSwitchingToMain) + return nil }) }) return diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 4c423c2bcbd..44d4700bae6 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -757,6 +757,7 @@ type TranslationSet struct { ErrStageDirWithInlineMergeConflicts string ErrRepositoryMovedOrDeleted string ErrWorktreeMovedOrRemoved string + WorktreeDeletedSwitchingToMain string CommandLog string ToggleShowCommandLog string FocusCommandLog string @@ -1874,6 +1875,7 @@ func EnglishTranslationSet() *TranslationSet { ErrRepositoryMovedOrDeleted: "Cannot find repo. It might have been moved or deleted ¯\\_(ツ)_/¯", CommandLog: "Command log", ErrWorktreeMovedOrRemoved: "Cannot find worktree. It might have been moved or removed ¯\\_(ツ)_/¯", + WorktreeDeletedSwitchingToMain: "Worktree deleted externally. Switched to main worktree.", ToggleShowCommandLog: "Toggle show/hide command log", FocusCommandLog: "Focus command log", CommandLogHeader: "You can hide/focus this panel by pressing '%s'\n", diff --git a/pkg/integration/tests/worktree/external_remove_current_worktree.go b/pkg/integration/tests/worktree/external_remove_current_worktree.go index e3e1e987e13..955a5ac9d07 100644 --- a/pkg/integration/tests/worktree/external_remove_current_worktree.go +++ b/pkg/integration/tests/worktree/external_remove_current_worktree.go @@ -30,6 +30,8 @@ var ExternalRemoveCurrentWorktree = NewIntegrationTest(NewIntegrationTestArgs{ // Trigger a refresh so lazygit detects the deleted CWD t.GlobalPress(keys.Universal.Refresh) + t.ExpectToast(Contains("Worktree deleted externally")) + // Lazygit should auto-switch to the main worktree t.Views().Status(). Lines(