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..53b80ae2b25 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,41 @@ 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 { + if err := self.reposHelper.DispatchSwitchToRepo(mainPath, context.WORKTREES_CONTEXT_KEY); err != nil { + return err + } + self.c.ErrorToast(self.c.Tr.WorktreeDeletedSwitchingToMain) + return nil + }) + }) + 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/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/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..955a5ac9d07 --- /dev/null +++ b/pkg/integration/tests/worktree/external_remove_current_worktree.go @@ -0,0 +1,41 @@ +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) + + t.ExpectToast(Contains("Worktree deleted externally")) + + // Lazygit should auto-switch to the main worktree + t.Views().Status(). + Lines( + Contains("repo → mybranch"), + ) + }, +})